From aa385ae16b86081c77b1865b3c5dac80f4de1b6a Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Wed, 1 Jul 2026 16:10:42 -0700 Subject: [PATCH] chore(deps): update Stripe SDK to 22.3.1 (2026-06-24.dahlia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because: * Stripe's "basil" billing redesign — crossed by the acacia -> dahlia API-version bump — reshaped the invoice, subscription, line-item, discount, and payment-intent objects FxA relies on. This commit: * Pins `stripe` to 22.3.1 (from 17.4.0) and pins the apiVersion '2026-06-24.dahlia' via a shared STRIPE_API_VERSION constant. * Migrates every affected call site across the modern libs/payments/* StripeClient stack and the legacy fxa-auth-server StripeHelper: invoice.subscription -> parent.subscription_details.subscription, invoice.payment_intent/charge -> payments[]/confirmation_secret, subscription.current_period_* -> subscription-item level, invoices.retrieveUpcoming -> invoices.createPreview, plus the basil tax/discount/pricing reshape and the PromotionCode.coupon and branded Decimal changes. * Reads webhook-delivered invoices with a transitional dual-read (old + new shapes) to survive the Stripe Dashboard API-version rollout window. * Patches stripe to re-add the top-level `types` field it dropped as an exports-only package, so the SDK resolves under TypeScript. * Updates the hand-written Stripe factories and co-located specs and fixtures to the new basil shapes. Closes: PAY-3759 --- .../stripe-npm-22.3.1-291b1ac8f8.patch | 12 + .../api-server/src/lib/util/mapPriceInfo.ts | 2 +- .../util/transformToWebSubscription.spec.ts | 4 +- .../lib/util/transformToWebSubscription.ts | 10 +- .../cart/src/lib/cart.service.spec.ts | 2 +- .../cart/src/lib/checkout.service.spec.ts | 112 ++++- .../payments/cart/src/lib/checkout.service.ts | 33 +- .../customer/src/lib/invoice.manager.spec.ts | 20 +- .../customer/src/lib/invoice.manager.ts | 16 +- .../src/lib/promotionCode.manager.spec.ts | 38 +- .../customer/src/lib/promotionCode.manager.ts | 4 +- .../src/lib/subscription.manager.spec.ts | 79 ++- .../customer/src/lib/subscription.manager.ts | 17 +- .../util/assertPromotionCodeActive.spec.ts | 9 +- .../src/lib/util/assertPromotionCodeActive.ts | 3 +- .../src/lib/util/determinePriceUnitAmount.ts | 2 +- ...ipeInvoiceToFirstInvoicePreviewDTO.spec.ts | 180 +++---- .../stripeInvoiceToFirstInvoicePreviewDTO.ts | 34 +- .../subscriptionManagement.service.in.spec.ts | 23 +- .../subscriptionManagement.service.spec.ts | 8 +- .../src/lib/subscriptionManagement.service.ts | 11 +- ...ayments-metrics-aggregator.service.spec.ts | 15 +- .../payments-metrics-aggregator.service.ts | 2 +- libs/payments/stripe/src/index.ts | 3 +- .../factories/card-payment-method.factory.ts | 3 + .../stripe/src/lib/factories/card.factory.ts | 1 + .../factories/confirmation-token.factory.ts | 3 + .../lib/factories/customer-session.factory.ts | 1 + .../src/lib/factories/customer.factory.ts | 1 + .../src/lib/factories/discount.factory.ts | 38 +- .../factories/invoice-line-item.factory.ts | 35 +- .../lib/factories/invoice-payment.factory.ts | 30 ++ .../src/lib/factories/invoice.factory.ts | 33 +- .../lib/factories/payment-intent.factory.ts | 4 +- .../lib/factories/payment-method.factory.ts | 3 + .../stripe/src/lib/factories/plan.factory.ts | 6 +- .../stripe/src/lib/factories/price.factory.ts | 8 +- .../lib/factories/promotion-code.factory.ts | 38 +- .../src/lib/factories/setup-intent.factory.ts | 2 +- .../src/lib/factories/subscription.factory.ts | 22 +- .../factories/total-tax-amounts.factory.ts | 19 +- .../lib/factories/upcoming-invoice.factory.ts | 103 +--- .../stripe/src/lib/stripe.client.spec.ts | 99 +++- libs/payments/stripe/src/lib/stripe.client.ts | 43 +- .../stripe/src/lib/stripe.client.types.ts | 197 ++++---- .../stripe/src/lib/stripe.constants.ts | 1 + package.json | 8 +- packages/functional-tests/lib/sub-helpers.ts | 5 +- .../src/subscriptions/stripe.service.ts | 3 +- .../subscriptions.formatters.spec.ts | 13 +- .../subscriptions/subscriptions.formatters.ts | 14 +- .../subscriptions.service.spec.ts | 10 +- .../lib/payments/paypal/helper.spec.ts | 4 +- .../lib/payments/paypal/helper.ts | 6 +- .../lib/payments/paypal/processor.spec.ts | 12 +- .../lib/payments/paypal/processor.ts | 7 +- .../lib/payments/stripe-firestore.spec.ts | 7 +- .../lib/payments/stripe-formatter.spec.ts | 73 +-- .../lib/payments/stripe-formatter.ts | 24 +- .../lib/payments/stripe.spec.ts | 454 +++++++++++------- .../fxa-auth-server/lib/payments/stripe.ts | 268 +++++++---- .../payments/subscription-reminders.spec.ts | 75 ++- .../lib/payments/subscription-reminders.ts | 48 +- .../lib/routes/subscriptions/mozilla.ts | 2 +- .../paypal-notifications.spec.ts | 33 ++ .../subscriptions/paypal-notifications.ts | 17 +- .../routes/subscriptions/stripe-webhook.ts | 6 +- .../subscriptions/stripe-webhooks.spec.ts | 28 ++ .../scripts/audit-orphaned-customers.ts | 3 +- .../cancel-subscriptions-to-plan.spec.ts | 40 +- .../cancel-subscriptions-to-plan.ts | 26 +- .../check-firestore-stripe-sync.spec.ts | 9 +- ...-customers-to-stripe-automatic-tax.spec.ts | 11 +- ...nvert-customers-to-stripe-automatic-tax.ts | 17 +- .../helpers.spec.ts | 17 +- .../helpers.ts | 9 +- .../move-customers-to-new-plan-v2.spec.ts | 30 +- .../move-customers-to-new-plan-v2.ts | 18 +- .../move-customers-to-new-plan.ts | 6 +- .../scripts/paypal-refund-fixer.ts | 14 +- .../scripts/refund-unverified-accounts.ts | 6 +- .../invoice_draft_proration_refund.json | 203 +++----- .../fixtures/stripe/invoice_open.json | 107 ++--- .../invoice_paid_subscription_create.json | 105 ++-- ...ice_paid_subscription_create_discount.json | 168 +++---- .../invoice_paid_subscription_create_tax.json | 123 +++-- ...paid_subscription_create_tax_discount.json | 188 ++++---- .../fixtures/stripe/invoice_preview_tax.json | 97 ++-- .../stripe/invoice_preview_tax_discount.json | 170 +++---- .../fixtures/stripe/subscription1.json | 4 +- .../fixtures/stripe/subscription2.json | 4 +- .../stripe/subscription_cancelled.json | 4 +- .../stripe/subscription_coupon_forever.json | 69 +-- .../stripe/subscription_coupon_once.json | 4 +- .../stripe/subscription_coupon_repeating.json | 69 +-- .../stripe/subscription_multiplan.json | 8 +- .../stripe/subscription_past_due.json | 4 +- .../stripe/subscription_pmi_expanded.json | 10 +- ...tion_pmi_expanded_incomplete_cvc_fail.json | 14 +- .../stripe/subscription_trialing.json | 4 +- .../fxa-shared/payments/stripe-firestore.ts | 44 +- packages/fxa-shared/payments/stripe.ts | 2 +- packages/fxa-shared/subscriptions/stripe.ts | 10 +- packages/fxa-shared/subscriptions/types.ts | 13 +- .../stripe/customer1_with_subscription.json | 6 +- .../stripe/subscription_expanded.json | 360 +++++++------- .../fxa-shared/test/subscriptions/stripe.ts | 14 +- yarn.lock | 32 +- 108 files changed, 2427 insertions(+), 2061 deletions(-) create mode 100644 .yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch create mode 100644 libs/payments/stripe/src/lib/factories/invoice-payment.factory.ts diff --git a/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch b/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch new file mode 100644 index 00000000000..fa8b6844970 --- /dev/null +++ b/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch @@ -0,0 +1,12 @@ +diff --git a/package.json b/package.json +index 090d03f906fc7f3932fce763204c76a269575db0..3fc59877772ce64884e565cac823f7a0c3624d87 100644 +--- a/package.json ++++ b/package.json +@@ -25,6 +25,7 @@ + "node": ">=18" + }, + "main": "cjs/stripe.cjs.node.js", ++ "types": "./esm/stripe.esm.node.d.ts", + "devDependencies": { + "@types/chai": "^4.3.4", + "@types/chai-as-promised": "^7.1.5", diff --git a/libs/payments/api-server/src/lib/util/mapPriceInfo.ts b/libs/payments/api-server/src/lib/util/mapPriceInfo.ts index 66fa4fdfb57..5f1212dd1cb 100644 --- a/libs/payments/api-server/src/lib/util/mapPriceInfo.ts +++ b/libs/payments/api-server/src/lib/util/mapPriceInfo.ts @@ -25,7 +25,7 @@ export const mapPriceInfo = ( amount = unit_amount ?? (unit_amount_decimal != null - ? Math.round(parseFloat(unit_amount_decimal)) + ? Math.round(parseFloat(unit_amount_decimal.toString())) : null); } } diff --git a/libs/payments/api-server/src/lib/util/transformToWebSubscription.spec.ts b/libs/payments/api-server/src/lib/util/transformToWebSubscription.spec.ts index bc1f09b7c4e..fa92adadca7 100644 --- a/libs/payments/api-server/src/lib/util/transformToWebSubscription.spec.ts +++ b/libs/payments/api-server/src/lib/util/transformToWebSubscription.spec.ts @@ -16,14 +16,14 @@ describe('transformToWebSubscription', () => { id: 'sub_w', status: 'active', created: 1, - current_period_start: 2, - current_period_end: 3, cancel_at_period_end: false, ended_at: null, items: { object: 'list', data: [ StripeSubscriptionItemFactory({ + current_period_start: 2, + current_period_end: 3, price: { ...price, product: 'prod_w' }, }), ], diff --git a/libs/payments/api-server/src/lib/util/transformToWebSubscription.ts b/libs/payments/api-server/src/lib/util/transformToWebSubscription.ts index 92c059795d9..84bfc8b39e0 100644 --- a/libs/payments/api-server/src/lib/util/transformToWebSubscription.ts +++ b/libs/payments/api-server/src/lib/util/transformToWebSubscription.ts @@ -2,7 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getPriceFromSubscription } from '@fxa/payments/customer'; +import { + getPriceFromSubscription, + retrieveSubscriptionItem, +} from '@fxa/payments/customer'; import type { StripeSubscription } from '@fxa/payments/stripe'; import type { @@ -21,11 +24,12 @@ export const transformToWebSubscription = ( `Subscription has no product id (subscriptionId=${sub.id})` ); } + const subscriptionItem = retrieveSubscriptionItem(sub); return { _subscription_type: 'web', created: sub.created, - current_period_end: sub.current_period_end, - current_period_start: sub.current_period_start, + current_period_end: subscriptionItem.current_period_end, + current_period_start: subscriptionItem.current_period_start, cancel_at_period_end: sub.cancel_at_period_end, end_at: sub.ended_at ?? null, plan_id: priceId, diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 3e404985b72..95d87609aee 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -4,7 +4,7 @@ import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; -import { Stripe } from 'stripe'; +import Stripe from 'stripe'; import { EligibilityManager, diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index 55e3a380436..f58bd816f74 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -56,10 +56,12 @@ import { SubPlatPaymentMethodType, } from '@fxa/payments/customer'; import { + StripeApiListFactory, StripeClient, StripeConfig, StripeCustomerFactory, StripeInvoiceFactory, + StripeInvoicePaymentFactory, StripePaymentIntentFactory, StripePaymentMethodFactory, StripePriceFactory, @@ -899,7 +901,10 @@ describe('CheckoutService', () => { const mockConfirmationToken = StripeConfirmationTokenFactory(); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); const mockEligibilityResult = SubscriptionEligibilityResultFactory({ @@ -979,7 +984,7 @@ describe('CheckoutService', () => { automatic_tax: { enabled: true, }, - promotion_code: mockPromotionCode.id, + discounts: [{ promotion_code: mockPromotionCode.id }], items: [ { price: mockPrice.id, // TODO: fetch price from cart after FXA-8893 @@ -1022,7 +1027,7 @@ describe('CheckoutService', () => { it('calls calls paymentIntentManager.confirm', async () => { expect(paymentIntentManager.confirm).toHaveBeenCalledWith( - mockInvoice.payment_intent, + mockPaymentIntent.id, { confirmation_token: mockConfirmationToken.id, off_session: false } ); }); @@ -1051,7 +1056,14 @@ describe('CheckoutService', () => { it('handles zero-amount invoices by creating a setup intent', async () => { const freeInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + payments: StripeApiListFactory([ + StripeInvoicePaymentFactory({ + payment: { + type: 'payment_intent', + payment_intent: mockPaymentIntent.id, + }, + }), + ]), amount_due: 0, status: 'paid', }) @@ -1077,6 +1089,26 @@ describe('CheckoutService', () => { ).resolves.not.toThrow(); }); + it('creates the subscription without a discounts key when there is no coupon', async () => { + jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({ + ...mockPrePayStepsResult, + promotionCode: undefined, + }); + + await checkoutService.payWithStripe( + mockCart, + mockConfirmationToken.id, + mockAttributionData, + mockRequestArgs, + mockCart.uid + ); + + expect(subscriptionManager.create).toHaveBeenCalledWith( + expect.not.objectContaining({ discounts: expect.anything() }), + { idempotencyKey: mockCart.id } + ); + }); + describe('upgrade', () => { const mockEligibilityResult = SubscriptionEligibilityUpgradeDowngradeResultFactory({ @@ -1131,7 +1163,7 @@ describe('CheckoutService', () => { describe('calls setup intent manager', () => { const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -1186,7 +1218,7 @@ describe('CheckoutService', () => { ); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -1321,7 +1353,10 @@ describe('CheckoutService', () => { const mockConfirmationToken = StripeConfirmationTokenFactory(); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); const mockEligibilityResult = SubscriptionEligibilityResultFactory({ @@ -1465,7 +1500,14 @@ describe('CheckoutService', () => { ); const mockZeroInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + payments: StripeApiListFactory([ + StripeInvoicePaymentFactory({ + payment: { + type: 'payment_intent', + payment_intent: mockPaymentIntent.id, + }, + }), + ]), amount_due: 0, }) ); @@ -1587,7 +1629,7 @@ describe('CheckoutService', () => { ); const mockTrialInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -1697,7 +1739,10 @@ describe('CheckoutService', () => { ); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); @@ -1821,7 +1866,7 @@ describe('CheckoutService', () => { ); const mockTrialInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -1897,7 +1942,7 @@ describe('CheckoutService', () => { ); const mockTrialInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -1971,7 +2016,10 @@ describe('CheckoutService', () => { ); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); @@ -2072,7 +2120,10 @@ describe('CheckoutService', () => { ); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); @@ -2121,7 +2172,7 @@ describe('CheckoutService', () => { ); const mockTrialInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, }) ); @@ -2195,7 +2246,10 @@ describe('CheckoutService', () => { ); const mockInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: mockPaymentIntent.id, + confirmation_secret: { + type: 'payment_intent', + client_secret: `${mockPaymentIntent.id}_secret_test`, + }, }) ); const mockEligibilityResult = SubscriptionEligibilityResultFactory({ @@ -2436,7 +2490,7 @@ describe('CheckoutService', () => { }, collection_method: 'send_invoice', days_until_due: 1, - promotion_code: mockPromotionCode.id, + discounts: [{ promotion_code: mockPromotionCode.id }], items: [ { price: mockPrice.id, @@ -2463,6 +2517,28 @@ describe('CheckoutService', () => { ); }); + it('creates the subscription without a discounts key when there is no coupon', async () => { + jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue( + PrePayStepsResultFactory({ + ...mockPrePayStepsResult, + promotionCode: undefined, + }) + ); + + await checkoutService.payWithPaypal( + mockCart, + mockAttributionData, + mockRequestArgs, + mockCart.uid, + mockToken + ); + + expect(subscriptionManager.create).toHaveBeenCalledWith( + expect.not.objectContaining({ discounts: expect.anything() }), + { idempotencyKey: mockCart.id } + ); + }); + it('calls asyncLocalStorage.getStore', () => { expect(asyncLocalStorage.getStore).toHaveBeenCalled(); }); @@ -2717,7 +2793,7 @@ describe('CheckoutService', () => { ); const mockTrialInvoice = StripeResponseFactory( StripeInvoiceFactory({ - payment_intent: null, + payments: StripeApiListFactory([]), amount_due: 0, status: 'paid', }) diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index 73d2e25e22b..25d6758abda 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -451,7 +451,9 @@ export class CheckoutService { automatic_tax: { enabled: enableAutomaticTax, }, - promotion_code: promotionCode?.id, + ...(promotionCode + ? { discounts: [{ promotion_code: promotionCode.id }] } + : {}), items: [ { price: price.id, @@ -537,14 +539,23 @@ export class CheckoutService { subscription.latest_invoice ); - if (invoice.payment_intent && invoice.amount_due !== 0) { - intent = await this.paymentIntentManager.confirm( - invoice.payment_intent, - { - confirmation_token: confirmationTokenId, - off_session: false, - } - ); + // For a default_incomplete subscription, the pending PaymentIntent's + // client secret is on the invoice's `confirmation_secret` (populated only + // when expanded — see StripeClient.invoicesRetrieve). Its `client_secret` + // is `{payment_intent_id}_secret_{...}`, so the id is the portion before + // `_secret_`. `invoice.payments` only records *attempted* payments and is + // empty here, so it cannot be used to locate the intent to confirm. + const confirmationSecret = invoice.confirmation_secret; + const paymentIntentId = + confirmationSecret?.type === 'payment_intent' && + confirmationSecret.client_secret + ? confirmationSecret.client_secret.split('_secret_')[0] + : undefined; + if (paymentIntentId && invoice.amount_due !== 0) { + intent = await this.paymentIntentManager.confirm(paymentIntentId, { + confirmation_token: confirmationTokenId, + off_session: false, + }); } else if (subscription.pending_setup_intent) { // Subscription metadata is required by welcome email dispatcher for zero-cost subscriptions await this.setupIntentManager.update( @@ -720,7 +731,9 @@ export class CheckoutService { ...(freeTrial ? { trial_period_days: freeTrial.trialLengthDays } : {}), - promotion_code: promotionCode?.id, + ...(promotionCode + ? { discounts: [{ promotion_code: promotionCode.id }] } + : {}), items: [ { price: price.id, diff --git a/libs/payments/customer/src/lib/invoice.manager.spec.ts b/libs/payments/customer/src/lib/invoice.manager.spec.ts index 1e6a8f86ac1..0e32c657be4 100644 --- a/libs/payments/customer/src/lib/invoice.manager.spec.ts +++ b/libs/payments/customer/src/lib/invoice.manager.spec.ts @@ -12,6 +12,7 @@ import { StripeCouponFactory, StripeCustomerFactory, StripeInvoiceFactory, + StripeInvoiceLineItemTaxFactory, StripePriceFactory, StripePromotionCodeFactory, StripeResponseFactory, @@ -178,7 +179,7 @@ describe('InvoiceManager', () => { const mockInvoicePreview = InvoicePreviewFactory(); jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .spyOn(stripeClient, 'invoicesCreatePreview') .mockResolvedValue(mockUpcomingInvoice); mockedStripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue( @@ -215,7 +216,7 @@ describe('InvoiceManager', () => { StripeResponseFactory(StripeApiListFactory([mockPromotionCode])) ); jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .spyOn(stripeClient, 'invoicesCreatePreview') .mockResolvedValue(mockUpcomingInvoice); mockedStripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue( @@ -244,7 +245,7 @@ describe('InvoiceManager', () => { const mockSubscriptionItem = StripeSubscriptionItemFactory(); jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .spyOn(stripeClient, 'invoicesCreatePreview') .mockResolvedValue(mockUpcomingInvoice); mockedStripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue( @@ -308,7 +309,7 @@ describe('InvoiceManager', () => { const mockPreviewUpcomingInvoice = InvoicePreviewFactory(); const spy = jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .spyOn(stripeClient, 'invoicesCreatePreview') .mockResolvedValue(mockUpcomingInvoice); mockedStripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue( @@ -338,7 +339,7 @@ describe('InvoiceManager', () => { const mockPreviewUpcomingInvoice = InvoicePreviewFactory(); const spy = jest - .spyOn(stripeClient, 'invoicesRetrieveUpcoming') + .spyOn(stripeClient, 'invoicesCreatePreview') .mockResolvedValue(mockUpcomingInvoice); mockedStripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue( @@ -503,7 +504,6 @@ describe('InvoiceManager', () => { currencyCode: mockInvoice.currency, countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, - taxAmountInCents: mockInvoice.tax, }); expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith( mockInvoice.id, @@ -620,7 +620,6 @@ describe('InvoiceManager', () => { currencyCode: mockInvoice.currency, countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, - taxAmountInCents: mockInvoice.tax, }); expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith( mockInvoice.id, @@ -685,7 +684,6 @@ describe('InvoiceManager', () => { currencyCode: mockInvoice.currency, countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, - taxAmountInCents: mockInvoice.tax, }); expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith( mockInvoice.id, @@ -704,7 +702,7 @@ describe('InvoiceManager', () => { expect(stripeClient.invoicesUpdate).toHaveBeenCalledTimes(1); expect(stripeClient.invoicesPay).not.toHaveBeenCalled(); }); - it('successfully processes non-zero invoice with a tax of 0', async () => { + it('successfully processes non-zero invoice with tax', async () => { const mockPaymentAttemptCount = 1; const mockCustomer = StripeResponseFactory( StripeCustomerFactory({ @@ -722,7 +720,7 @@ describe('InvoiceManager', () => { mockPaymentAttemptCount ), }, - tax: 0, + total_taxes: [StripeInvoiceLineItemTaxFactory({ amount: 100 })], customer_shipping: { address: StripeAddressFactory() }, }) ); @@ -757,7 +755,7 @@ describe('InvoiceManager', () => { currencyCode: mockInvoice.currency, countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, - taxAmountInCents: mockInvoice.tax, + taxAmountInCents: 100, }); expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith( mockInvoice.id, diff --git a/libs/payments/customer/src/lib/invoice.manager.ts b/libs/payments/customer/src/lib/invoice.manager.ts index 7fb94a8e494..5dcdf6919ac 100644 --- a/libs/payments/customer/src/lib/invoice.manager.ts +++ b/libs/payments/customer/src/lib/invoice.manager.ts @@ -105,7 +105,7 @@ export class InvoiceManager { } : undefined; - const requestObject: Stripe.InvoiceRetrieveUpcomingParams = { + const requestObject: Stripe.InvoiceCreatePreviewParams = { currency, customer: customer?.id, automatic_tax: { @@ -122,7 +122,7 @@ export class InvoiceManager { }; const upcomingInvoice = - await this.stripeClient.invoicesRetrieveUpcoming(requestObject); + await this.stripeClient.invoicesCreatePreview(requestObject); return stripeInvoiceToInvoicePreviewDTO(upcomingInvoice); } @@ -138,7 +138,7 @@ export class InvoiceManager { fromSubscriptionItem: StripeSubscriptionItem; trialEnd?: number; }): Promise { - const requestObject: Stripe.InvoiceRetrieveUpcomingParams = { + const requestObject: Stripe.InvoiceCreatePreviewParams = { customer: customer.id, subscription_details: { items: [ @@ -155,7 +155,7 @@ export class InvoiceManager { }; const upcomingInvoice = - await this.stripeClient.invoicesRetrieveUpcoming(requestObject); + await this.stripeClient.invoicesCreatePreview(requestObject); return stripeInvoiceToInvoicePreviewDTO(upcomingInvoice); } @@ -193,7 +193,7 @@ export class InvoiceManager { subscription: StripeSubscription; excludeDiscounts?: boolean; }): Promise { - const upcomingInvoice = await this.stripeClient.invoicesRetrieveUpcoming({ + const upcomingInvoice = await this.stripeClient.invoicesCreatePreview({ customer: customer.id, subscription: subscription.id, subscription_details: { @@ -265,6 +265,10 @@ export class InvoiceManager { ); const idempotencyKey = `${invoice.id}-${paymentAttemptCount}`; + const taxAmountInCents = invoice.total_taxes?.length + ? invoice.total_taxes.reduce((total, tax) => total + tax.amount, 0) + : null; + // Charge the customer on PayPal const chargeOptions = { amountInCents: invoice.amount_due, @@ -275,7 +279,7 @@ export class InvoiceManager { countryCode, idempotencyKey, ...(ipaddress && { ipaddress }), - ...(invoice.tax !== null && { taxAmountInCents: invoice.tax }), + ...(taxAmountInCents !== null && { taxAmountInCents }), } satisfies ChargeOptions; let paypalCharge: ChargeResponse; try { diff --git a/libs/payments/customer/src/lib/promotionCode.manager.spec.ts b/libs/payments/customer/src/lib/promotionCode.manager.spec.ts index a7d679a66f6..93eacc47972 100644 --- a/libs/payments/customer/src/lib/promotionCode.manager.spec.ts +++ b/libs/payments/customer/src/lib/promotionCode.manager.spec.ts @@ -5,7 +5,7 @@ import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; import { MockLoggerProvider } from '@fxa/shared/log'; -import { Stripe } from 'stripe'; +import Stripe from 'stripe'; import { ProductManager } from './product.manager'; import { PromotionCodeManager } from './promotionCode.manager'; @@ -179,9 +179,12 @@ describe('PromotionCodeManager', () => { it('resolves correctly when valid', async () => { const mockPrice = StripePriceFactory(); const mockPromotionCode = StripePromotionCodeFactory({ - coupon: StripeCouponFactory({ - currency: mockPrice.currency, - }), + promotion: { + type: 'coupon', + coupon: StripeCouponFactory({ + currency: mockPrice.currency, + }), + }, }); const mockCartCurrency = mockPrice.currency; @@ -245,9 +248,12 @@ describe('PromotionCodeManager', () => { currency: 'cad', }); const mockPromotionCode = StripePromotionCodeFactory({ - coupon: StripeCouponFactory({ - currency: 'cad', - }), + promotion: { + type: 'coupon', + coupon: StripeCouponFactory({ + currency: 'cad', + }), + }, }); const mockCartCurrency = 'CAD'; @@ -273,9 +279,12 @@ describe('PromotionCodeManager', () => { currency: 'cad', }); const mockPromotionCode = StripePromotionCodeFactory({ - coupon: StripeCouponFactory({ - currency: null, - }), + promotion: { + type: 'coupon', + coupon: StripeCouponFactory({ + currency: null, + }), + }, }); const mockCartCurrency = 'CAD'; @@ -301,9 +310,12 @@ describe('PromotionCodeManager', () => { currency: 'cad', }); const mockPromotionCode = StripePromotionCodeFactory({ - coupon: StripeCouponFactory({ - currency: 'cad', - }), + promotion: { + type: 'coupon', + coupon: StripeCouponFactory({ + currency: 'cad', + }), + }, }); const mockCartCurrency = 'usd'; diff --git a/libs/payments/customer/src/lib/promotionCode.manager.ts b/libs/payments/customer/src/lib/promotionCode.manager.ts index bcb26342e47..980d8a96968 100644 --- a/libs/payments/customer/src/lib/promotionCode.manager.ts +++ b/libs/payments/customer/src/lib/promotionCode.manager.ts @@ -60,8 +60,8 @@ export class PromotionCodeManager { // promotion code currency may be null, in which case it is applicable to all currencies if ( - promoCode.coupon.currency && - promoCode.coupon.currency.toLowerCase() !== cartCurrency.toLowerCase() + promoCode.promotion.coupon.currency && + promoCode.promotion.coupon.currency.toLowerCase() !== cartCurrency.toLowerCase() ) throw new CouponErrorInvalidCurrency(); diff --git a/libs/payments/customer/src/lib/subscription.manager.spec.ts b/libs/payments/customer/src/lib/subscription.manager.spec.ts index 898dffda016..f14120d814b 100644 --- a/libs/payments/customer/src/lib/subscription.manager.spec.ts +++ b/libs/payments/customer/src/lib/subscription.manager.spec.ts @@ -594,13 +594,17 @@ describe('SubscriptionManager', () => { }); describe('hasCouponId', () => { - it('returns true if subscription has mentioned coupon id - in discounts', async () => { - const mockCoupon = StripeCouponFactory({ - id: 'coupon_123', - }); + it('returns true if a subscription discount has the mentioned coupon id', async () => { const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory({ - discounts: [mockCoupon.id], + discounts: [ + StripeDiscountFactory({ + source: { + type: 'coupon', + coupon: StripeCouponFactory({ id: 'coupon_123' }), + }, + }), + ], }) ); @@ -610,22 +614,28 @@ describe('SubscriptionManager', () => { const result = await subscriptionManager.hasCouponId( mockSubscription.id, - mockCoupon.id + 'coupon_123' ); expect(result).toBe(true); }); - it('returns true if subscription has mentioned coupon id - in discount', async () => { - const mockCoupon = StripeCouponFactory({ - id: 'coupon_123', - }); + it('returns true if a non-primary subscription discount has the mentioned coupon id', async () => { const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory({ - discount: StripeDiscountFactory({ - coupon: StripeCouponFactory({ - id: 'coupon_123', + discounts: [ + StripeDiscountFactory({ + source: { + type: 'coupon', + coupon: StripeCouponFactory({ id: 'coupon_456' }), + }, }), - }), + StripeDiscountFactory({ + source: { + type: 'coupon', + coupon: StripeCouponFactory({ id: 'coupon_123' }), + }, + }), + ], }) ); @@ -635,43 +645,22 @@ describe('SubscriptionManager', () => { const result = await subscriptionManager.hasCouponId( mockSubscription.id, - mockCoupon.id + 'coupon_123' ); expect(result).toBe(true); }); - it('returns false if subscription does not have mentioned coupon id - in discounts', async () => { - const mockCoupon = StripeCouponFactory({ - id: 'coupon_123', - }); - const mockSubscription = StripeResponseFactory( - StripeSubscriptionFactory({ - discounts: ['coupon_456'], - }) - ); - - jest - .spyOn(subscriptionManager, 'retrieve') - .mockResolvedValue(mockSubscription); - - const result = await subscriptionManager.hasCouponId( - mockSubscription.id, - mockCoupon.id - ); - expect(result).toBe(false); - }); - - it('returns false if subscription does not have mentioned coupon id - in discount', async () => { - const mockCoupon = StripeCouponFactory({ - id: 'coupon_123', - }); + it('returns false if no subscription discount has the mentioned coupon id', async () => { const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory({ - discount: StripeDiscountFactory({ - coupon: StripeCouponFactory({ - id: 'coupon_456', + discounts: [ + StripeDiscountFactory({ + source: { + type: 'coupon', + coupon: StripeCouponFactory({ id: 'coupon_456' }), + }, }), - }), + ], }) ); @@ -681,7 +670,7 @@ describe('SubscriptionManager', () => { const result = await subscriptionManager.hasCouponId( mockSubscription.id, - mockCoupon.id + 'coupon_123' ); expect(result).toBe(false); }); diff --git a/libs/payments/customer/src/lib/subscription.manager.ts b/libs/payments/customer/src/lib/subscription.manager.ts index e23f25f93b6..cd704f9e847 100644 --- a/libs/payments/customer/src/lib/subscription.manager.ts +++ b/libs/payments/customer/src/lib/subscription.manager.ts @@ -190,19 +190,8 @@ export class SubscriptionManager { if (!stripeCouponId) return false; const subscription = await this.retrieve(subscriptionId); - const currentSubscriptionCouponId = subscription.discount?.coupon.id; - if ( - currentSubscriptionCouponId && - currentSubscriptionCouponId === stripeCouponId - ) { - return true; - } - - const discounts = subscription.discounts; - if (discounts.includes(stripeCouponId)) { - return true; - } - - return false; + return subscription.discounts.some( + (discount) => discount.source?.coupon.id === stripeCouponId + ); } } diff --git a/libs/payments/customer/src/lib/util/assertPromotionCodeActive.spec.ts b/libs/payments/customer/src/lib/util/assertPromotionCodeActive.spec.ts index 6c509ecc5ce..cdb96f286e9 100644 --- a/libs/payments/customer/src/lib/util/assertPromotionCodeActive.spec.ts +++ b/libs/payments/customer/src/lib/util/assertPromotionCodeActive.spec.ts @@ -36,9 +36,12 @@ describe('assertPromotionCodeActive', () => { it('throws error if the promotion code coupon is not valid', async () => { const mockPromotionCode = StripePromotionCodeFactory({ - coupon: StripeCouponFactory({ - valid: false, - }), + promotion: { + type: 'coupon', + coupon: StripeCouponFactory({ + valid: false, + }), + }, }); expect(() => assertPromotionCodeActive(mockPromotionCode)).toThrowError( diff --git a/libs/payments/customer/src/lib/util/assertPromotionCodeActive.ts b/libs/payments/customer/src/lib/util/assertPromotionCodeActive.ts index 08a12fb9ad7..3cbf86572db 100644 --- a/libs/payments/customer/src/lib/util/assertPromotionCodeActive.ts +++ b/libs/payments/customer/src/lib/util/assertPromotionCodeActive.ts @@ -18,7 +18,8 @@ export const assertPromotionCodeActive = (code: StripePromotionCode) => { if (code.max_redemptions && code.times_redeemed >= code.max_redemptions) throw new CouponErrorLimitReached(); - if (code.coupon && !code.coupon.valid) throw new CouponErrorInvalidCode(); + if (code.promotion?.coupon && !code.promotion.coupon.valid) + throw new CouponErrorInvalidCode(); if (!code || !code.active) throw new CouponErrorGeneric(); }; diff --git a/libs/payments/customer/src/lib/util/determinePriceUnitAmount.ts b/libs/payments/customer/src/lib/util/determinePriceUnitAmount.ts index 0b05f5bee9e..4d6db7318ee 100644 --- a/libs/payments/customer/src/lib/util/determinePriceUnitAmount.ts +++ b/libs/payments/customer/src/lib/util/determinePriceUnitAmount.ts @@ -17,7 +17,7 @@ export function determinePriceUnitAmount({ return ( unit_amount ?? (unit_amount_decimal != null - ? Math.round(parseFloat(unit_amount_decimal)) + ? Math.round(parseFloat(unit_amount_decimal.toString())) : null) ); } diff --git a/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.spec.ts b/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.spec.ts index 3b23bfe5b48..1a8af22758a 100644 --- a/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.spec.ts +++ b/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.spec.ts @@ -8,24 +8,35 @@ import { StripeCouponFactory, StripeDiscountFactory, StripeInvoiceLineItemFactory, + StripeInvoiceLineItemTaxFactory, StripeResponseFactory, + StripeTaxRateFactory, StripeTotalDiscountAmountsFactory, - StripeTotalTaxAmountsFactory, StripeUpcomingInvoiceFactory, } from '@fxa/payments/stripe'; describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { it('formats invoice', () => { const mockDiscountAmount = StripeTotalDiscountAmountsFactory(); - const mockTaxAmount = StripeTotalTaxAmountsFactory(); + const mockLineItemTaxRate = StripeTaxRateFactory(); + const mockTotalTaxRate = StripeTaxRateFactory(); + const mockLineItemTax = StripeInvoiceLineItemTaxFactory({ + tax_behavior: 'exclusive', + tax_rate_details: { tax_rate: mockLineItemTaxRate }, + }); + const mockTotalTax = StripeInvoiceLineItemTaxFactory({ + amount: 123, + tax_behavior: 'exclusive', + tax_rate_details: { tax_rate: mockTotalTaxRate }, + }); const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ - tax_amounts: [mockTaxAmount], + taxes: [mockLineItemTax], }); const mockUpcomingInvoice = StripeResponseFactory( StripeUpcomingInvoiceFactory({ lines: StripeApiListFactory([mockInvoiceLineItem]), total_discount_amounts: [mockDiscountAmount], - total_tax_amounts: [mockTaxAmount], + total_taxes: [mockTotalTax], }) ); @@ -35,9 +46,9 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { totalAmount: mockUpcomingInvoice.total, taxAmounts: [ { - title: mockTaxAmount.tax_rate.display_name, - inclusive: mockTaxAmount.inclusive, - amount: mockTaxAmount.amount, + title: mockTotalTaxRate.display_name, + inclusive: false, + amount: 123, }, ], discountAmount: mockDiscountAmount.amount, @@ -55,144 +66,101 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { invoiceDate: mockUpcomingInvoice.created, number: null, nextInvoiceDate: mockUpcomingInvoice.lines.data[0].period.end, + promotionName: undefined, subsequentAmount: mockInvoiceLineItem.amount, - subsequentAmountExcludingTax: mockInvoiceLineItem.amount_excluding_tax, + subsequentAmountExcludingTax: mockInvoiceLineItem.amount, subsequentTax: [ { - title: mockTaxAmount.tax_rate.display_name, - inclusive: mockTaxAmount.inclusive, - amount: mockTaxAmount.amount, + title: mockLineItemTaxRate.display_name, + inclusive: false, + amount: mockLineItemTax.amount, }, ], totalExcludingTax: mockUpcomingInvoice.total_excluding_tax, }); }); + it('subtracts inclusive line-item tax from subsequentAmountExcludingTax', () => { + const mockInclusiveTax = StripeInvoiceLineItemTaxFactory({ + tax_behavior: 'inclusive', + amount: 50, + }); + const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ + amount: 1000, + taxes: [mockInclusiveTax], + }); + const mockUpcomingInvoice = StripeResponseFactory( + StripeUpcomingInvoiceFactory({ + lines: StripeApiListFactory([mockInvoiceLineItem]), + total_discount_amounts: [], + total_taxes: [], + }) + ); + + const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); + expect(result.subsequentAmount).toBe(1000); + expect(result.subsequentAmountExcludingTax).toBe(950); + }); + it('formats invoice with discount', () => { const mockDiscountAmount = StripeTotalDiscountAmountsFactory(); - const mockTaxAmount = StripeTotalTaxAmountsFactory(); const mockDiscount = StripeDiscountFactory({ - coupon: StripeCouponFactory({ - name: 'Sonny and Jerry Two-Fur-One Special', - }), - }); - const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ - tax_amounts: [mockTaxAmount], + source: { + type: 'coupon', + coupon: StripeCouponFactory({ + name: 'Sonny and Jerry Two-Fur-One Special', + duration: 'repeating', + }), + }, + end: 1700000000, }); + const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ taxes: [] }); const mockUpcomingInvoice = StripeResponseFactory( StripeUpcomingInvoiceFactory({ - discount: mockDiscount, + discounts: [mockDiscount], lines: StripeApiListFactory([mockInvoiceLineItem]), total_discount_amounts: [mockDiscountAmount], - total_tax_amounts: [mockTaxAmount], + total_taxes: [], }) ); const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); - expect(result).toEqual({ - currency: mockUpcomingInvoice.currency, - totalAmount: mockUpcomingInvoice.total, - taxAmounts: [ - { - title: mockTaxAmount.tax_rate.display_name, - inclusive: mockTaxAmount.inclusive, - amount: mockTaxAmount.amount, - }, - ], - discountAmount: mockDiscountAmount.amount, - subtotal: mockUpcomingInvoice.subtotal, - amountDue: mockUpcomingInvoice.amount_due, - creditApplied: mockUpcomingInvoice.ending_balance - ? mockUpcomingInvoice.starting_balance - - mockUpcomingInvoice.ending_balance - : mockUpcomingInvoice.starting_balance, - remainingAmountTotal: undefined, - startingBalance: mockUpcomingInvoice.starting_balance, - unusedAmountTotal: 0, - discountEnd: null, - discountType: mockDiscount.coupon.duration, - invoiceDate: mockUpcomingInvoice.created, - number: null, - nextInvoiceDate: mockUpcomingInvoice.lines.data[0].period.end, - promotionName: 'Sonny and Jerry Two-Fur-One Special', - subsequentAmount: mockInvoiceLineItem.amount, - subsequentAmountExcludingTax: mockInvoiceLineItem.amount_excluding_tax, - subsequentTax: [ - { - title: mockTaxAmount.tax_rate.display_name, - inclusive: mockTaxAmount.inclusive, - amount: mockTaxAmount.amount, - }, - ], - totalExcludingTax: mockUpcomingInvoice.total_excluding_tax, - }); + expect(result.discountAmount).toBe(mockDiscountAmount.amount); + expect(result.discountEnd).toBe(1700000000); + expect(result.discountType).toBe('repeating'); + expect(result.promotionName).toBe('Sonny and Jerry Two-Fur-One Special'); }); - it('formats invoice with multiple discount and tax amounts', () => { + it('sums multiple discount and tax amounts', () => { const mockDiscountAmount1 = StripeTotalDiscountAmountsFactory(); const mockDiscountAmount2 = StripeTotalDiscountAmountsFactory(); - const mockTaxAmount1 = StripeTotalTaxAmountsFactory(); - const mockTaxAmount2 = StripeTotalTaxAmountsFactory(); - const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ - tax_amounts: [mockTaxAmount1], - }); + const mockInvoiceLineItem = StripeInvoiceLineItemFactory({ taxes: [] }); const mockUpcomingInvoice = StripeResponseFactory( StripeUpcomingInvoiceFactory({ lines: StripeApiListFactory([mockInvoiceLineItem]), total_discount_amounts: [mockDiscountAmount1, mockDiscountAmount2], - total_tax_amounts: [mockTaxAmount1, mockTaxAmount2], + total_taxes: [ + StripeInvoiceLineItemTaxFactory({ amount: 10 }), + StripeInvoiceLineItemTaxFactory({ amount: 20 }), + ], }) ); const result = stripeInvoiceToInvoicePreviewDTO(mockUpcomingInvoice); - expect(result).toEqual({ - currency: mockUpcomingInvoice.currency, - totalAmount: mockUpcomingInvoice.total, - taxAmounts: [ - { - title: mockTaxAmount1.tax_rate.display_name, - inclusive: mockTaxAmount1.inclusive, - amount: mockTaxAmount1.amount, - }, - { - title: mockTaxAmount2.tax_rate.display_name, - inclusive: mockTaxAmount2.inclusive, - amount: mockTaxAmount2.amount, - }, - ], - discountAmount: mockDiscountAmount1.amount + mockDiscountAmount2.amount, - subtotal: mockUpcomingInvoice.subtotal, - amountDue: mockUpcomingInvoice.amount_due, - creditApplied: mockUpcomingInvoice.ending_balance - ? mockUpcomingInvoice.starting_balance - - mockUpcomingInvoice.ending_balance - : mockUpcomingInvoice.starting_balance, - remainingAmountTotal: undefined, - startingBalance: mockUpcomingInvoice.starting_balance, - unusedAmountTotal: 0, - discountEnd: undefined, - discountType: undefined, - invoiceDate: mockUpcomingInvoice.created, - number: null, - nextInvoiceDate: mockUpcomingInvoice.lines.data[0].period.end, - subsequentAmount: mockInvoiceLineItem.amount, - subsequentAmountExcludingTax: mockInvoiceLineItem.amount_excluding_tax, - subsequentTax: [ - { - title: mockTaxAmount1.tax_rate.display_name, - inclusive: mockTaxAmount1.inclusive, - amount: mockTaxAmount1.amount, - }, - ], - totalExcludingTax: mockUpcomingInvoice.total_excluding_tax, - }); + expect(result.discountAmount).toBe( + mockDiscountAmount1.amount + mockDiscountAmount2.amount + ); + expect(result.taxAmounts).toEqual([ + { title: 'VAT', inclusive: false, amount: 10 }, + { title: 'VAT', inclusive: false, amount: 20 }, + ]); }); it('formats invoice with empty discount and tax amounts', () => { const mockUpcomingInvoice = StripeResponseFactory( StripeUpcomingInvoiceFactory({ total_discount_amounts: [], - total_tax_amounts: [], + total_taxes: [], }) ); diff --git a/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.ts b/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.ts index b1c978e23e0..6d4730f6838 100644 --- a/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.ts +++ b/libs/payments/customer/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO.ts @@ -11,10 +11,11 @@ import { InvoicePreview, STRIPE_INVOICE_METADATA } from '../types'; export function stripeInvoiceToInvoicePreviewDTO( invoice: StripeUpcomingInvoice | StripeInvoice ): InvoicePreview { - const taxAmounts = invoice.total_tax_amounts.map((amount) => ({ - title: amount.tax_rate.display_name || '', - inclusive: amount.inclusive, - amount: amount.amount, + const taxAmounts = (invoice.total_taxes ?? []).map((tax) => ({ + title: + tax.tax_rate_details?.tax_rate?.display_name || '', + inclusive: tax.tax_behavior === 'inclusive', + amount: tax.amount, })); const discountAmount = invoice.total_discount_amounts @@ -26,7 +27,7 @@ export function stripeInvoiceToInvoicePreviewDTO( const { remainingAmountTotal, unusedAmountTotal } = invoice.lines.data.reduce( (totals, line) => { - if (line.proration === true) { + if (line.parent?.subscription_item_details?.proration === true) { const amount = line.amount || 0; const description = line.description || ''; @@ -46,15 +47,20 @@ export function stripeInvoiceToInvoicePreviewDTO( ); const notProrated = invoice.lines.data.find( - (line) => line.proration === false + (line) => line.parent?.subscription_item_details?.proration === false ); const subsequentAmount = notProrated?.amount; - const subsequentAmountExcludingTax = - notProrated?.amount_excluding_tax ?? undefined; + const subsequentAmountExcludingTax = notProrated + ? notProrated.amount - + (notProrated.taxes ?? []) + .filter((tax) => tax.tax_behavior === 'inclusive') + .reduce((sum, tax) => sum + tax.amount, 0) + : undefined; - const subsequentTax = notProrated?.tax_amounts.map((tax) => ({ - title: tax.tax_rate.display_name || '', - inclusive: tax.inclusive, + const subsequentTax = notProrated?.taxes?.map((tax) => ({ + title: + tax.tax_rate_details?.tax_rate?.display_name || '', + inclusive: tax.tax_behavior === 'inclusive', amount: tax.amount, })); @@ -64,8 +70,8 @@ export function stripeInvoiceToInvoicePreviewDTO( taxAmounts, discountAmount, subtotal: invoice.subtotal, - discountEnd: invoice.discount?.end, - discountType: invoice.discount?.coupon.duration, + discountEnd: invoice.discounts[0]?.end, + discountType: invoice.discounts[0]?.source?.coupon.duration, number: invoice.number, paypalTransactionId: invoice.metadata?.[STRIPE_INVOICE_METADATA.PaypalTransactionId], @@ -76,7 +82,7 @@ export function stripeInvoiceToInvoicePreviewDTO( creditApplied: invoice.ending_balance ? invoice.starting_balance - invoice.ending_balance : invoice.starting_balance, - promotionName: invoice.discount?.coupon.name, + promotionName: invoice.discounts[0]?.source?.coupon.name, remainingAmountTotal, startingBalance: invoice.starting_balance, totalExcludingTax: invoice.total_excluding_tax, diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts index 524f341207f..bb106873e52 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts @@ -69,6 +69,7 @@ import { StripeResponseFactory, StripeSetupIntentFactory, StripeSubscriptionFactory, + StripeSubscriptionItemFactory, } from '@fxa/payments/stripe'; import { MockStrapiClientConfigProvider, @@ -1394,7 +1395,16 @@ describe('SubscriptionManagementService integration', () => { status: 'active', cancel_at_period_end: false, currency: 'usd', - current_period_end: 1735689600, + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + current_period_end: 1735689600, + }), + ], + has_more: false, + url: '', + }, }) ); const mockPaymentMethodInfo = { @@ -1522,7 +1532,16 @@ describe('SubscriptionManagementService integration', () => { status: 'active', cancel_at_period_end: true, currency: 'usd', - current_period_end: 1735689600, + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + current_period_end: 1735689600, + }), + ], + has_more: false, + url: '', + }, }) ); const mockPaymentMethodInfo = { diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index f2d9b4293a3..cd19ed86322 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -1189,7 +1189,12 @@ describe('SubscriptionManagementService', () => { status: 'past_due', trial_end: trialEnd, trial_start: trialStart, - current_period_start: trialEnd, + items: { + object: 'list', + data: [StripeSubscriptionItemFactory({ current_period_start: trialEnd })], + has_more: false, + url: '', + }, }); const mockTrialContent = TrialSubscriptionContentFactory({ conversionStatus: 'past_due', @@ -1511,7 +1516,6 @@ describe('SubscriptionManagementService', () => { status: 'past_due', trial_end: trialEnd, trial_start: trialStart, - current_period_start: trialEnd, latest_invoice: latestInvoiceId, currency: 'usd', }); diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.ts b/libs/payments/management/src/lib/subscriptionManagement.service.ts index 3bddde6fea4..00000dc5054 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.ts @@ -325,7 +325,7 @@ export class SubscriptionManagementService { const isFailedTrialConversion = sub.status === 'past_due' && sub.trial_end !== null && - sub.current_period_start === sub.trial_end; + item.current_period_start === sub.trial_end; const isTrial = sub.status === 'trialing' || isFailedTrialConversion; if (isTrial) { @@ -560,7 +560,8 @@ export class SubscriptionManagementService { subsequentTax, } = upcomingInvoice; - const nextPromotionName = subscription.discount?.coupon.name ?? null; + const nextPromotionName = + subscription.discounts[0]?.source?.coupon.name ?? null; const totalExclusiveTax = taxAmounts .filter((tax) => !tax.inclusive) @@ -591,7 +592,7 @@ export class SubscriptionManagementService { : totalExclusiveTax ? (totalExcludingTax ?? totalAmount) : totalAmount, - currentPeriodEnd: subscription.current_period_end, + currentPeriodEnd: subscription.items.data[0].current_period_end, currentInvoiceDate, currentInvoiceUrl, nextInvoiceDate, @@ -840,7 +841,7 @@ export class SubscriptionManagementService { cmsPurchase.purchaseDetails.productName; const supportUrl = cmsPurchase.offering.commonContent.supportUrl; const webIcon = cmsPurchase.purchaseDetails.webIcon; - const currentPeriodEnd = subscription.current_period_end; + const currentPeriodEnd = subscription.items.data[0].current_period_end; const upcomingInvoice = await this.invoiceManager.previewUpcomingSubscription({ @@ -962,7 +963,7 @@ export class SubscriptionManagementService { cmsPurchase.purchaseDetails.localizations[0]?.productName || cmsPurchase.purchaseDetails.productName; const webIcon = cmsPurchase.purchaseDetails.webIcon; - const currentPeriodEnd = subscription.current_period_end; + const currentPeriodEnd = subscription.items.data[0].current_period_end; const upcomingInvoice = await this.invoiceManager.previewUpcomingSubscription({ diff --git a/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.spec.ts b/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.spec.ts index 54bebdb8053..0c4a02a839b 100644 --- a/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.spec.ts +++ b/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.spec.ts @@ -41,7 +41,9 @@ import { MockStripeConfigProvider, StripeClient, StripeConfig, + StripeCouponFactory, StripeCustomerFactory, + StripeDiscountFactory, StripePriceFactory, StripeResponseFactory, StripeSubscriptionFactory, @@ -430,11 +432,14 @@ describe('PaymentsMetricsAggregatorService', () => { }); const mockSubscription = StripeResponseFactory( StripeSubscriptionFactory({ - discount: { - coupon: { - id: mockCouponCode, - }, - } as any, + discounts: [ + StripeDiscountFactory({ + source: { + type: 'coupon', + coupon: StripeCouponFactory({ id: mockCouponCode }), + }, + }), + ], items: { object: 'list', data: [mockSubscriptionItem], diff --git a/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.ts b/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.ts index 645ce0d8864..b5f082996bf 100644 --- a/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.ts +++ b/libs/payments/metrics-aggregator/src/lib/payments-metrics-aggregator.service.ts @@ -60,7 +60,7 @@ export class PaymentsMetricsAggregatorService { return { customerId: customer?.id, - couponCode: subscription?.discount?.coupon.id, + couponCode: subscription?.discounts[0]?.source?.coupon.id, currency: customer?.currency || undefined, taxAddress: taxAddress, productId: price?.product, diff --git a/libs/payments/stripe/src/index.ts b/libs/payments/stripe/src/index.ts index f31eed2204d..639f8fefff7 100644 --- a/libs/payments/stripe/src/index.ts +++ b/libs/payments/stripe/src/index.ts @@ -27,6 +27,7 @@ export { StripeCustomerFactory } from './lib/factories/customer.factory'; export { StripeCustomerSessionFactory } from './lib/factories/customer-session.factory'; export { StripeDiscountFactory } from './lib/factories/discount.factory'; export { StripeInvoiceLineItemFactory } from './lib/factories/invoice-line-item.factory'; +export { StripeInvoicePaymentFactory } from './lib/factories/invoice-payment.factory'; export { StripeInvoiceFactory } from './lib/factories/invoice.factory'; export { StripePlanFactory } from './lib/factories/plan.factory'; export { @@ -45,7 +46,7 @@ export { StripePaymentIntentFactory } from './lib/factories/payment-intent.facto export { StripeSetupIntentFactory } from './lib/factories/setup-intent.factory'; export { StripeTaxRateFactory } from './lib/factories/tax-rate.factory'; export { StripeTotalDiscountAmountsFactory } from './lib/factories/total-discount-amounts.factory'; -export { StripeTotalTaxAmountsFactory } from './lib/factories/total-tax-amounts.factory'; +export { StripeInvoiceLineItemTaxFactory } from './lib/factories/total-tax-amounts.factory'; export { StripeUpcomingInvoiceFactory } from './lib/factories/upcoming-invoice.factory'; export { StripeRangeQueryParamFactory } from './lib/factories/utils.factory'; export * from './lib/stripe.client'; diff --git a/libs/payments/stripe/src/lib/factories/card-payment-method.factory.ts b/libs/payments/stripe/src/lib/factories/card-payment-method.factory.ts index 87b3300f9b8..8b525be2993 100644 --- a/libs/payments/stripe/src/lib/factories/card-payment-method.factory.ts +++ b/libs/payments/stripe/src/lib/factories/card-payment-method.factory.ts @@ -22,6 +22,7 @@ export const StripeCardPaymentMethodFactory = ( email: null, name: null, phone: null, + tax_id: null, }, card: { brand: 'visa', @@ -46,6 +47,7 @@ export const StripeCardPaymentMethodFactory = ( available: ['visa'], preferred: null, }, + regulated_status: null, three_d_secure_usage: { supported: true, }, @@ -55,6 +57,7 @@ export const StripeCardPaymentMethodFactory = ( }, created: faker.date.past().getTime() / 1000, customer: null, + customer_account: null, livemode: true, metadata: {}, type: 'card', diff --git a/libs/payments/stripe/src/lib/factories/card.factory.ts b/libs/payments/stripe/src/lib/factories/card.factory.ts index b2bce8c41b9..6aaea47477c 100644 --- a/libs/payments/stripe/src/lib/factories/card.factory.ts +++ b/libs/payments/stripe/src/lib/factories/card.factory.ts @@ -29,6 +29,7 @@ export const StripeCardFactory = ( last4: faker.finance.creditCardNumber().slice(-4), metadata: null, name: faker.person.fullName(), + regulated_status: null, tokenization_method: null, ...override, }); diff --git a/libs/payments/stripe/src/lib/factories/confirmation-token.factory.ts b/libs/payments/stripe/src/lib/factories/confirmation-token.factory.ts index 3ed6dbc002f..d0eb1f0ca37 100644 --- a/libs/payments/stripe/src/lib/factories/confirmation-token.factory.ts +++ b/libs/payments/stripe/src/lib/factories/confirmation-token.factory.ts @@ -18,6 +18,7 @@ export const StripeConfirmationTokenPaymentMethodPreviewCardFactory = ( generated_from: null, last4: faker.string.numeric({ length: 4 }), networks: null, + regulated_status: null, three_d_secure_usage: null, wallet: null, ...override, @@ -31,9 +32,11 @@ export const StripeConfirmationTokenPaymentMethodPreviewFactory = ( email: null, name: null, phone: null, + tax_id: null, }, card: StripeConfirmationTokenPaymentMethodPreviewCardFactory(), customer: null, + customer_account: null, type: 'card', ...override, }); diff --git a/libs/payments/stripe/src/lib/factories/customer-session.factory.ts b/libs/payments/stripe/src/lib/factories/customer-session.factory.ts index fc3b9cf0bb6..09c21794e3d 100644 --- a/libs/payments/stripe/src/lib/factories/customer-session.factory.ts +++ b/libs/payments/stripe/src/lib/factories/customer-session.factory.ts @@ -11,6 +11,7 @@ export const StripeCustomerSessionFactory = ( object: 'customer_session', client_secret: faker.string.alphanumeric(24), customer: faker.string.alphanumeric(24), + customer_account: null, expires_at: faker.number.int(), created: faker.number.int(), livemode: false, diff --git a/libs/payments/stripe/src/lib/factories/customer.factory.ts b/libs/payments/stripe/src/lib/factories/customer.factory.ts index dee21532fca..ca525494921 100644 --- a/libs/payments/stripe/src/lib/factories/customer.factory.ts +++ b/libs/payments/stripe/src/lib/factories/customer.factory.ts @@ -16,6 +16,7 @@ export const StripeCustomerFactory = ( location: null, automatic_tax: 'supported', ip_address: faker.internet.ipv4(), + provider: 'stripe', }, default_source: faker.string.uuid(), description: '', diff --git a/libs/payments/stripe/src/lib/factories/discount.factory.ts b/libs/payments/stripe/src/lib/factories/discount.factory.ts index 1a6689826ed..66dac05d232 100644 --- a/libs/payments/stripe/src/lib/factories/discount.factory.ts +++ b/libs/payments/stripe/src/lib/factories/discount.factory.ts @@ -11,28 +11,32 @@ export const StripeDiscountFactory = ( id: `di_${faker.string.alphanumeric({ length: 14 })}`, object: 'discount', checkout_session: `cs_test_${faker.string.alphanumeric({ length: 60 })}`, - coupon: { - id: 'wsd', - object: 'coupon', - amount_off: null, - created: faker.number.int(), - currency: null, - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: null, - percent_off: faker.number.int({ min: 1, max: 100 }), - redeem_by: null, - times_redeemed: faker.number.int(), - valid: true, - }, customer: `cus_${faker.string.alphanumeric({ length: 14 })}`, + customer_account: null, end: null, invoice: null, invoice_item: null, promotion_code: null, + source: { + type: 'coupon', + coupon: { + id: 'wsd', + object: 'coupon', + amount_off: null, + created: faker.number.int(), + currency: null, + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: null, + percent_off: faker.number.int({ min: 1, max: 100 }), + redeem_by: null, + times_redeemed: faker.number.int(), + valid: true, + }, + }, start: faker.number.int(), subscription: null, subscription_item: null, diff --git a/libs/payments/stripe/src/lib/factories/invoice-line-item.factory.ts b/libs/payments/stripe/src/lib/factories/invoice-line-item.factory.ts index 4ef24d46e9d..c0900f9cb3e 100644 --- a/libs/payments/stripe/src/lib/factories/invoice-line-item.factory.ts +++ b/libs/payments/stripe/src/lib/factories/invoice-line-item.factory.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; +import { Stripe } from 'stripe'; import { StripeInvoiceLineItem } from '../stripe.client.types'; export const StripeInvoiceLineItemFactory = ( @@ -11,7 +12,6 @@ export const StripeInvoiceLineItemFactory = ( id: faker.string.alphanumeric(10), object: 'line_item', amount: faker.number.int({ max: 1000 }), - amount_excluding_tax: faker.number.int({ max: 1000 }), currency: faker.finance.currencyCode().toLowerCase(), description: null, discount_amounts: null, @@ -20,19 +20,36 @@ export const StripeInvoiceLineItemFactory = ( invoice: null, livemode: false, metadata: {}, + parent: { + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: false, + proration_details: { credited_items: null }, + subscription: `sub_${faker.string.alphanumeric({ length: 24 })}`, + subscription_item: `si_${faker.string.alphanumeric({ length: 14 })}`, + }, + type: 'subscription_item_details', + }, period: { end: faker.number.int({ min: 1000000 }), start: faker.number.int({ max: 1000000 }), }, - plan: null, - price: null, - proration: false, - proration_details: null, + pretax_credit_amounts: null, + pricing: { + price_details: { + price: `price_${faker.string.alphanumeric({ length: 24 })}`, + product: `prod_${faker.string.alphanumeric({ length: 24 })}`, + }, + type: 'price_details', + unit_amount_decimal: faker.commerce.price({ + min: 1000, + }) as unknown as Stripe.Decimal, + }, quantity: null, + quantity_decimal: null, subscription: null, - tax_amounts: [], - tax_rates: [], - type: 'subscription', - unit_amount_excluding_tax: null, + subtotal: faker.number.int({ max: 1000 }), + taxes: [], ...override, }); diff --git a/libs/payments/stripe/src/lib/factories/invoice-payment.factory.ts b/libs/payments/stripe/src/lib/factories/invoice-payment.factory.ts new file mode 100644 index 00000000000..0d3c38b429f --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/invoice-payment.factory.ts @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { StripeInvoicePayment } from '../stripe.client.types'; + +export const StripeInvoicePaymentFactory = ( + override?: Partial +): StripeInvoicePayment => ({ + id: `inpay_${faker.string.alphanumeric({ length: 24 })}`, + object: 'invoice_payment', + amount_paid: faker.number.int({ max: 1000 }), + amount_requested: faker.number.int({ max: 1000 }), + created: faker.number.int(), + currency: faker.finance.currencyCode().toLowerCase(), + invoice: `in_${faker.string.alphanumeric({ length: 24 })}`, + is_default: true, + livemode: false, + payment: { + type: 'payment_intent', + payment_intent: `pi_${faker.string.alphanumeric({ length: 24 })}`, + }, + status: 'paid', + status_transitions: { + canceled_at: null, + paid_at: faker.number.int(), + }, + ...override, +}); diff --git a/libs/payments/stripe/src/lib/factories/invoice.factory.ts b/libs/payments/stripe/src/lib/factories/invoice.factory.ts index 8c8eea1eb2f..9e044f53f8c 100644 --- a/libs/payments/stripe/src/lib/factories/invoice.factory.ts +++ b/libs/payments/stripe/src/lib/factories/invoice.factory.ts @@ -15,26 +15,29 @@ export const StripeInvoiceFactory = ( account_name: null, account_tax_ids: null, amount_due: faker.number.int({ min: 0, max: 1000 }), + amount_overpaid: 0, amount_paid: 0, amount_remaining: faker.number.int({ max: 1000 }), amount_shipping: 0, application: null, - application_fee_amount: null, attempt_count: 0, attempted: true, automatic_tax: { + disabled_reason: null, enabled: true, liability: null, + provider: null, status: 'complete', }, automatically_finalizes_at: null, billing_reason: null, - charge: faker.string.alphanumeric(10), collection_method: 'charge_automatically', + confirmation_secret: null, created: faker.number.int(), currency: faker.finance.currencyCode().toLowerCase(), custom_fields: null, customer: `cus_${faker.string.alphanumeric({ length: 14 })}`, + customer_account: null, customer_address: null, customer_email: faker.internet.email(), customer_name: null, @@ -45,7 +48,6 @@ export const StripeInvoiceFactory = ( default_source: faker.string.alphanumeric(10), default_tax_rates: [], description: null, - discount: null, discounts: [], due_date: null, ending_balance: null, @@ -68,19 +70,29 @@ export const StripeInvoiceFactory = ( next_payment_attempt: null, number: null, on_behalf_of: null, - paid: false, - paid_out_of_band: false, - payment_intent: faker.string.alphanumeric(10), + parent: { + quote_details: null, + subscription_details: { + metadata: null, + subscription: `sub_${faker.string.alphanumeric({ length: 24 })}`, + }, + type: 'subscription_details', + }, payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null, }, + payments: { + object: 'list', + data: [], + has_more: false, + url: faker.internet.url(), + }, period_end: faker.number.int({ min: 1000000 }), period_start: faker.number.int({ max: 1000000 }), post_payment_credit_notes_amount: 0, pre_payment_credit_notes_amount: 0, - quote: null, receipt_number: null, rendering: null, shipping_cost: null, @@ -94,17 +106,14 @@ export const StripeInvoiceFactory = ( paid_at: null, voided_at: null, }, - subscription: `sub_${faker.string.alphanumeric({ length: 24 })}`, - subscription_details: null, subtotal: faker.number.int({ max: 1000 }), subtotal_excluding_tax: faker.number.int({ max: 1000 }), - tax: faker.number.int({ max: 1000 }), test_clock: null, total: faker.number.int({ max: 1000 }), total_discount_amounts: null, total_excluding_tax: faker.number.int({ max: 1000 }), - total_tax_amounts: [], - transfer_data: null, + total_pretax_credit_amounts: null, + total_taxes: [], webhooks_delivered_at: null, ...override, }); diff --git a/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts b/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts index ec2a513d657..aaae678ecff 100644 --- a/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts +++ b/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts @@ -29,11 +29,13 @@ export const StripePaymentIntentFactory = ( created: faker.date.past().getTime() / 1000, currency: faker.finance.currencyCode().toLowerCase(), customer: null, + customer_account: null, description: null, - invoice: null, + excluded_payment_method_types: null, last_payment_error: null, latest_charge: null, livemode: true, + managed_payments: null, metadata: {}, next_action: null, on_behalf_of: null, diff --git a/libs/payments/stripe/src/lib/factories/payment-method.factory.ts b/libs/payments/stripe/src/lib/factories/payment-method.factory.ts index 9e6873e4786..338ca96d1d3 100644 --- a/libs/payments/stripe/src/lib/factories/payment-method.factory.ts +++ b/libs/payments/stripe/src/lib/factories/payment-method.factory.ts @@ -22,6 +22,7 @@ export const StripePaymentMethodFactory = ( email: null, name: null, phone: null, + tax_id: null, }, card: { brand: 'visa', @@ -46,6 +47,7 @@ export const StripePaymentMethodFactory = ( available: ['visa'], preferred: null, }, + regulated_status: null, three_d_secure_usage: { supported: true, }, @@ -53,6 +55,7 @@ export const StripePaymentMethodFactory = ( }, created: faker.date.past().getTime() / 1000, customer: null, + customer_account: null, livemode: true, metadata: {}, type: 'card', diff --git a/libs/payments/stripe/src/lib/factories/plan.factory.ts b/libs/payments/stripe/src/lib/factories/plan.factory.ts index e706cb3ec6f..7a7fce2a550 100644 --- a/libs/payments/stripe/src/lib/factories/plan.factory.ts +++ b/libs/payments/stripe/src/lib/factories/plan.factory.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; +import { Stripe } from 'stripe'; import { StripePlan } from '../stripe.client.types'; export const StripePlanFactory = ( @@ -20,9 +21,10 @@ export const StripePlanFactory = ( nickname: null, product: `prod_${faker.string.alphanumeric({ length: 14 })}`, tiers_mode: null, - aggregate_usage: null, amount: faker.number.int({ max: 1000 }), - amount_decimal: faker.commerce.price({ min: 1000 }), + amount_decimal: faker.commerce.price({ + min: 1000, + }) as unknown as Stripe.Decimal, interval: 'month', interval_count: 1, transform_usage: null, diff --git a/libs/payments/stripe/src/lib/factories/price.factory.ts b/libs/payments/stripe/src/lib/factories/price.factory.ts index cf7f3324070..7940c5c3355 100644 --- a/libs/payments/stripe/src/lib/factories/price.factory.ts +++ b/libs/payments/stripe/src/lib/factories/price.factory.ts @@ -12,7 +12,9 @@ export const StripePriceCurrencyOptionFactory = ( custom_unit_amount: null, tax_behavior: 'exclusive', unit_amount: faker.number.int({ max: 1000 }), - unit_amount_decimal: faker.commerce.price({ min: 1000 }), + unit_amount_decimal: faker.commerce.price({ + min: 1000, + }) as unknown as Stripe.Decimal, ...override, }); @@ -25,7 +27,8 @@ export const StripePriceFactory = ( const unit_amount = override?.unit_amount || faker.number.int({ min: 1, max: 1000 }); const unit_amount_decimal = - override?.unit_amount_decimal || faker.commerce.price({ min: 1000 }); + override?.unit_amount_decimal || + (faker.commerce.price({ min: 1000 }) as unknown as Stripe.Decimal); return { id: `price_${faker.string.alphanumeric({ length: 24 })}`, object: 'price', @@ -59,7 +62,6 @@ export const StripePriceFactory = ( export const StripePriceRecurringFactory = ( override?: Partial ): StripePrice['recurring'] => ({ - aggregate_usage: null, meter: null, interval: faker.helpers.arrayElement(['day', 'week', 'month', 'year']), interval_count: 1, diff --git a/libs/payments/stripe/src/lib/factories/promotion-code.factory.ts b/libs/payments/stripe/src/lib/factories/promotion-code.factory.ts index 4d0f4a621bc..86c8094fc91 100644 --- a/libs/payments/stripe/src/lib/factories/promotion-code.factory.ts +++ b/libs/payments/stripe/src/lib/factories/promotion-code.factory.ts @@ -12,29 +12,33 @@ export const StripePromotionCodeFactory = ( object: 'promotion_code', code: faker.string.uuid(), active: true, - coupon: { - id: faker.string.uuid(), - object: 'coupon', - amount_off: null, - created: faker.date.recent().getTime() / 1000, - currency: null, - duration: 'repeating', - duration_in_months: faker.number.int(), - livemode: true, - max_redemptions: null, - metadata: {}, - name: null, - percent_off: faker.number.int({ min: 10, max: 90 }), - redeem_by: null, - times_redeemed: faker.number.int(), - valid: true, - }, created: faker.date.recent().getTime() / 1000, customer: null, + customer_account: null, expires_at: null, livemode: true, max_redemptions: null, metadata: {}, + promotion: { + type: 'coupon', + coupon: { + id: faker.string.uuid(), + object: 'coupon', + amount_off: null, + created: faker.date.recent().getTime() / 1000, + currency: null, + duration: 'repeating', + duration_in_months: faker.number.int(), + livemode: true, + max_redemptions: null, + metadata: {}, + name: null, + percent_off: faker.number.int({ min: 10, max: 90 }), + redeem_by: null, + times_redeemed: faker.number.int(), + valid: true, + }, + }, restrictions: { first_time_transaction: false, minimum_amount: null, diff --git a/libs/payments/stripe/src/lib/factories/setup-intent.factory.ts b/libs/payments/stripe/src/lib/factories/setup-intent.factory.ts index 09edf64065b..3bb360573cf 100644 --- a/libs/payments/stripe/src/lib/factories/setup-intent.factory.ts +++ b/libs/payments/stripe/src/lib/factories/setup-intent.factory.ts @@ -19,6 +19,7 @@ export const StripeSetupIntentFactory = ( created: faker.date.past().getTime() / 1000, customer: null, description: null, + excluded_payment_method_types: null, flow_directions: null, latest_attempt: null, last_setup_error: null, @@ -45,4 +46,3 @@ export const StripeSetupIntentFactory = ( usage: 'off_session', ...override, }); - diff --git a/libs/payments/stripe/src/lib/factories/subscription.factory.ts b/libs/payments/stripe/src/lib/factories/subscription.factory.ts index b73a0f06ebf..1b05dccc081 100644 --- a/libs/payments/stripe/src/lib/factories/subscription.factory.ts +++ b/libs/payments/stripe/src/lib/factories/subscription.factory.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; +import { Stripe } from 'stripe'; import { StripePriceFactory } from './price.factory'; import { StripeSubscription, @@ -17,11 +18,17 @@ export const StripeSubscriptionFactory = ( application: null, application_fee_percent: null, automatic_tax: { + disabled_reason: null, enabled: true, liability: null, }, billing_cycle_anchor: 1, billing_cycle_anchor_config: null, + billing_mode: { + type: 'flexible', + flexible: null, + }, + billing_schedules: [], billing_thresholds: null, cancel_at: null, cancel_at_period_end: false, @@ -30,18 +37,19 @@ export const StripeSubscriptionFactory = ( collection_method: 'charge_automatically', created: faker.number.int(), currency: faker.finance.currencyCode().toLowerCase(), - current_period_end: faker.number.int({ min: 1000000 }), - current_period_start: faker.number.int({ max: 1000000 }), customer: `cus_${faker.string.alphanumeric({ length: 14 })}`, + customer_account: null, days_until_due: null, default_payment_method: faker.string.alphanumeric(10), default_source: faker.string.alphanumeric(10), description: null, - discount: null, discounts: [], ended_at: null, invoice_settings: { account_tax_ids: null, + custom_fields: null, + description: null, + footer: null, issuer: { type: 'self', }, @@ -56,6 +64,7 @@ export const StripeSubscriptionFactory = ( }, latest_invoice: `in_${faker.string.alphanumeric({ length: 24 })}`, livemode: false, + managed_payments: null, metadata: {}, next_pending_invoice_item_invoice: null, on_behalf_of: null, @@ -82,15 +91,18 @@ export const StripeSubscriptionItemFactory = ( object: 'subscription_item', billing_thresholds: null, created: faker.number.int(), + current_period_end: faker.number.int({ min: 1000000 }), + current_period_start: faker.number.int({ max: 1000000 }), discounts: [], metadata: {}, plan: { id: `plan_${faker.string.alphanumeric({ length: 14 })}`, object: 'plan', active: true, - aggregate_usage: null, amount: faker.number.int({ max: 1000 }), - amount_decimal: faker.commerce.price({ min: 1000 }), + amount_decimal: faker.commerce.price({ + min: 1000, + }) as unknown as Stripe.Decimal, billing_scheme: 'per_unit', created: faker.number.int(), currency: faker.finance.currencyCode().toLowerCase(), diff --git a/libs/payments/stripe/src/lib/factories/total-tax-amounts.factory.ts b/libs/payments/stripe/src/lib/factories/total-tax-amounts.factory.ts index ef2da50c685..ec8d1565e60 100644 --- a/libs/payments/stripe/src/lib/factories/total-tax-amounts.factory.ts +++ b/libs/payments/stripe/src/lib/factories/total-tax-amounts.factory.ts @@ -3,16 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; -import { StripeInvoiceLineItemTaxAmount } from '../stripe.client.types'; +import { StripeInvoiceLineItemTax } from '../stripe.client.types'; import { StripeTaxRateFactory } from './tax-rate.factory'; -export const StripeTotalTaxAmountsFactory = ( - override?: Partial -): StripeInvoiceLineItemTaxAmount => ({ +export const StripeInvoiceLineItemTaxFactory = ( + override?: Partial +): StripeInvoiceLineItemTax => ({ amount: faker.number.int(), - inclusive: false, - tax_rate: StripeTaxRateFactory(), - taxability_reason: null, - taxable_amount: null, + tax_behavior: 'exclusive', + tax_rate_details: { + tax_rate: StripeTaxRateFactory(), + }, + taxability_reason: 'standard_rated', + taxable_amount: faker.number.int(), + type: 'tax_rate_details', ...override, }); diff --git a/libs/payments/stripe/src/lib/factories/upcoming-invoice.factory.ts b/libs/payments/stripe/src/lib/factories/upcoming-invoice.factory.ts index 4768675fad9..b7f886bc32f 100644 --- a/libs/payments/stripe/src/lib/factories/upcoming-invoice.factory.ts +++ b/libs/payments/stripe/src/lib/factories/upcoming-invoice.factory.ts @@ -2,108 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { faker } from '@faker-js/faker'; import { StripeUpcomingInvoice } from '../stripe.client.types'; -import { StripeInvoiceLineItemFactory } from './invoice-line-item.factory'; +import { StripeInvoiceFactory } from './invoice.factory'; export const StripeUpcomingInvoiceFactory = ( override?: Partial -): StripeUpcomingInvoice => ({ - object: 'invoice', - account_country: null, - account_name: null, - account_tax_ids: null, - amount_due: faker.number.int({ max: 1000 }), - amount_paid: 0, - amount_remaining: faker.number.int({ max: 1000 }), - amount_shipping: 0, - application: null, - application_fee_amount: null, - attempt_count: 0, - attempted: true, - automatic_tax: { - enabled: true, - liability: null, - status: 'complete', - }, - automatically_finalizes_at: null, - billing_reason: null, - charge: faker.string.alphanumeric(10), - collection_method: 'charge_automatically', - created: faker.number.int(), - currency: faker.finance.currencyCode().toLowerCase(), - custom_fields: null, - customer: `cus_${faker.string.alphanumeric({ length: 14 })}`, - customer_address: null, - customer_email: faker.internet.email(), - customer_name: null, - customer_phone: null, - customer_shipping: null, - customer_tax_exempt: null, - default_payment_method: faker.string.alphanumeric(10), - default_source: faker.string.alphanumeric(10), - default_tax_rates: [], - description: null, - discount: null, - discounts: [], - due_date: null, - ending_balance: null, - effective_at: null, - footer: null, - from_invoice: null, - issuer: { - type: 'self', - }, - last_finalization_error: null, - latest_revision: null, - lines: { - object: 'list', - data: [StripeInvoiceLineItemFactory()], - has_more: false, - url: faker.internet.url(), - }, - livemode: false, - metadata: null, - next_payment_attempt: null, - number: null, - on_behalf_of: null, - paid: false, - paid_out_of_band: false, - payment_intent: faker.string.alphanumeric(10), - payment_settings: { - default_mandate: null, - payment_method_options: null, - payment_method_types: null, - }, - period_end: faker.number.int({ min: 1000000 }), - period_start: faker.number.int({ max: 1000000 }), - post_payment_credit_notes_amount: 0, - pre_payment_credit_notes_amount: 0, - quote: null, - receipt_number: null, - rendering: null, - shipping_cost: null, - shipping_details: null, - starting_balance: faker.number.int({ max: 1000 }), - statement_descriptor: null, - status: null, - status_transitions: { - finalized_at: null, - marked_uncollectible_at: null, - paid_at: null, - voided_at: null, - }, - subscription: `sub_${faker.string.alphanumeric({ length: 24 })}`, - subscription_details: null, - subtotal: faker.number.int({ max: 1000 }), - subtotal_excluding_tax: faker.number.int({ max: 1000 }), - tax: faker.number.int({ max: 1000 }), - test_clock: null, - total: faker.number.int({ max: 1000 }), - total_discount_amounts: null, - total_excluding_tax: faker.number.int({ max: 1000 }), - total_tax_amounts: [], - transfer_data: null, - webhooks_delivered_at: null, - ...override, -}); +): StripeUpcomingInvoice => StripeInvoiceFactory(override); diff --git a/libs/payments/stripe/src/lib/stripe.client.spec.ts b/libs/payments/stripe/src/lib/stripe.client.spec.ts index b7515d45b47..db46cab7bcd 100644 --- a/libs/payments/stripe/src/lib/stripe.client.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.client.spec.ts @@ -34,8 +34,8 @@ const mockStripeCustomersCreate = mockJestFnGenerator(); const mockStripeCustomersUpdate = mockJestFnGenerator(); -const mockStripeRetrieveUpcomingInvoice = - mockJestFnGenerator(); +const mockStripeCreatePreviewInvoice = + mockJestFnGenerator(); const mockStripeInvoicesFinalizeInvoice = mockJestFnGenerator(); const mockStripeInvoicesRetrieve = @@ -69,8 +69,8 @@ const mockStripeSetupIntentsCreate = const mockStripeSetupIntentsRetrieve = mockJestFnGenerator(); -jest.mock('stripe', () => ({ - Stripe: function () { +jest.mock('stripe', () => { + const Stripe = function () { return { on: jest.fn(), customers: { @@ -81,7 +81,7 @@ jest.mock('stripe', () => ({ invoices: { finalizeInvoice: mockStripeInvoicesFinalizeInvoice, retrieve: mockStripeInvoicesRetrieve, - retrieveUpcoming: mockStripeRetrieveUpcomingInvoice, + createPreview: mockStripeCreatePreviewInvoice, }, paymentMethods: { attach: mockStripePaymentMethodsAttach, @@ -110,8 +110,9 @@ jest.mock('stripe', () => ({ retrieve: mockStripeSetupIntentsRetrieve, }, }; - }, -})); + }; + return { __esModule: true, default: Stripe, Stripe }; +}); describe('StripeClient', () => { let stripeClient: StripeClient; @@ -187,6 +188,22 @@ describe('StripeClient', () => { }); expect(result).toEqual(mockSubscriptionList); }); + + it('expands the nested discount coupon', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockSubscriptionList = StripeResponseFactory( + StripeApiListFactory([StripeSubscriptionFactory()]) + ); + + mockStripeSubscriptionsList.mockResolvedValue(mockSubscriptionList); + + await stripeClient.subscriptionsList({ customer: mockCustomer.id }); + + expect(mockStripeSubscriptionsList).toHaveBeenCalledWith({ + customer: mockCustomer.id, + expand: ['data.discounts.source.coupon'], + }); + }); }); describe('subscriptionsListGenerator', () => { @@ -248,6 +265,20 @@ describe('StripeClient', () => { ); expect(result).toEqual(mockResponse); }); + + it('expands the nested discount coupon', async () => { + const mockSubscription = StripeSubscriptionFactory(); + const mockResponse = StripeResponseFactory(mockSubscription); + + mockStripeSubscriptionsRetrieve.mockResolvedValue(mockResponse); + + await stripeClient.subscriptionsRetrieve(mockSubscription.id); + + expect(mockStripeSubscriptionsRetrieve).toHaveBeenCalledWith( + mockSubscription.id, + { expand: ['discounts.source.coupon'] } + ); + }); }); describe('subscriptionsUpdate', () => { @@ -278,17 +309,35 @@ describe('StripeClient', () => { expect(result).toEqual(mockResponse); }); + + it('expands confirmation_secret, discounts, and nested tax rates', async () => { + const mockInvoice = StripeInvoiceFactory(); + const mockResponse = StripeResponseFactory(mockInvoice); + + mockStripeInvoicesRetrieve.mockResolvedValue(mockResponse); + + await stripeClient.invoicesRetrieve(mockInvoice.id); + + expect(mockStripeInvoicesRetrieve).toHaveBeenCalledWith(mockInvoice.id, { + expand: [ + 'confirmation_secret', + 'discounts', + 'lines.data.taxes.tax_rate_details.tax_rate', + 'total_taxes.tax_rate_details.tax_rate', + ], + }); + }); }); - describe('invoicesRetrieveUpcoming', () => { + describe('invoicesCreatePreview', () => { it('calls stripe successfully', async () => { const mockCustomer = StripeCustomerFactory(); const mockInvoice = StripeUpcomingInvoiceFactory(); const mockResponse = StripeResponseFactory(mockInvoice); - mockStripeRetrieveUpcomingInvoice.mockResolvedValue(mockResponse); + mockStripeCreatePreviewInvoice.mockResolvedValue(mockResponse); - const result = await stripeClient.invoicesRetrieveUpcoming({ + const result = await stripeClient.invoicesCreatePreview({ customer: mockCustomer.id, }); @@ -372,6 +421,22 @@ describe('StripeClient', () => { }); expect(result).toEqual(mockResponse); }); + + it('expands the nested promotion coupon', async () => { + const mockPromoCode = StripePromotionCodeFactory(); + const mockResponse = StripeResponseFactory( + StripeApiListFactory([mockPromoCode]) + ); + + mockStripePromotionCodesList.mockResolvedValue(mockResponse); + + await stripeClient.promotionCodesList({ code: mockPromoCode.code }); + + expect(mockStripePromotionCodesList).toHaveBeenCalledWith({ + code: mockPromoCode.code, + expand: ['data.promotion.coupon'], + }); + }); }); describe('promotionCodesRetrieve', () => { @@ -386,6 +451,20 @@ describe('StripeClient', () => { ); expect(result).toEqual(mockResponse); }); + + it('expands the nested promotion coupon', async () => { + const mockPromoCode = StripePromotionCodeFactory(); + const mockResponse = StripeResponseFactory(mockPromoCode); + + mockStripePromotionCodesRetrieve.mockResolvedValue(mockResponse); + + await stripeClient.promotionCodesRetrieve(mockPromoCode.id); + + expect(mockStripePromotionCodesRetrieve).toHaveBeenCalledWith( + mockPromoCode.id, + { expand: ['promotion.coupon'] } + ); + }); }); describe('setupIntentCancel', () => { diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts index 3c31898a01e..f2e4ceb6c0d 100644 --- a/libs/payments/stripe/src/lib/stripe.client.ts +++ b/libs/payments/stripe/src/lib/stripe.client.ts @@ -4,7 +4,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import type { LoggerService } from '@nestjs/common'; -import { Stripe } from 'stripe'; +import Stripe from 'stripe'; import { Cacheable } from '@type-cacheable/core'; import { @@ -25,6 +25,7 @@ import { type StripeSetupIntent, } from './stripe.client.types'; import { StripeConfig } from './stripe.config'; +import { STRIPE_API_VERSION } from './stripe.constants'; import { CaptureTimingWithStatsD, StatsD, @@ -63,12 +64,12 @@ export class StripeClient { // https://github.com/stripe/stripe-node/issues/2207 this.stripeConfig.apiKey || 'api_key_placeholder', { - apiVersion: '2024-11-20.acacia', + apiVersion: STRIPE_API_VERSION, maxNetworkRetries: 3, } ); - this.stripe.on('response', (response) => { + this.stripe.on('response', (response: Stripe.ResponseEvent) => { this.statsd.timing('stripe_request', response.elapsed); // Note that we can't record the method/path as a tag // because ids are in the path which results in too great @@ -151,7 +152,7 @@ export class StripeClient { async subscriptionsList(params?: Stripe.SubscriptionListParams) { const result = await this.stripe.subscriptions.list({ ...params, - expand: undefined, + expand: ['data.discounts.source.coupon'], }); return result as StripeApiList; @@ -211,7 +212,7 @@ export class StripeClient { ) { const result = await this.stripe.subscriptions.retrieve(id, { ...params, - expand: undefined, + expand: ['discounts.source.coupon'], }); return result as StripeResponse; @@ -231,24 +232,28 @@ export class StripeClient { } @CaptureTimingWithStatsD() - async invoicesRetrieve( - id: string, - params?: Stripe.PaymentMethodAttachParams - ) { + async invoicesRetrieve(id: string, params?: Stripe.InvoiceRetrieveParams) { const result = await this.stripe.invoices.retrieve(id, { ...params, - expand: undefined, + expand: [ + 'confirmation_secret', + 'discounts', + 'lines.data.taxes.tax_rate_details.tax_rate', + 'total_taxes.tax_rate_details.tax_rate', + ], }); return result as StripeResponse; } @CaptureTimingWithStatsD() - async invoicesRetrieveUpcoming( - params?: Stripe.InvoiceRetrieveUpcomingParams - ) { - const result = await this.stripe.invoices.retrieveUpcoming({ + async invoicesCreatePreview(params?: Stripe.InvoiceCreatePreviewParams) { + const result = await this.stripe.invoices.createPreview({ ...params, - expand: ['total_tax_amounts.tax_rate'], + expand: [ + 'discounts', + 'lines.data.taxes.tax_rate_details.tax_rate', + 'total_taxes.tax_rate_details.tax_rate', + ], }); return result as StripeResponse; } @@ -395,7 +400,10 @@ export class StripeClient { async promotionCodesList(params: Stripe.PromotionCodeListParams) { const result = await this.stripe.promotionCodes.list({ ...params, - expand: undefined, + // Basil nests the coupon under `promotion.coupon`, which is only returned + // as a full object when expanded. Coupon validity/currency checks rely on + // it (assertPromotionCodeActive, PromotionCodeManager). + expand: ['data.promotion.coupon'], }); return result as StripeResponse>; } @@ -415,7 +423,8 @@ export class StripeClient { ) { const result = await this.stripe.promotionCodes.retrieve(id, { ...params, - expand: undefined, + // See promotionCodesList: basil only returns the nested coupon when expanded. + expand: ['promotion.coupon'], }); return result as StripeResponse; } diff --git a/libs/payments/stripe/src/lib/stripe.client.types.ts b/libs/payments/stripe/src/lib/stripe.client.types.ts index cb1f6236b76..838adb1e1b3 100644 --- a/libs/payments/stripe/src/lib/stripe.client.types.ts +++ b/libs/payments/stripe/src/lib/stripe.client.types.ts @@ -107,7 +107,7 @@ export type StripeDiscount = NegotiateExpanded< DeepOverride< Stripe.Discount, { - coupon: StripeCoupon; + source: DeepOverride; } >, 'customer' | 'promotion_code' @@ -119,73 +119,121 @@ export type StripeDiscount = NegotiateExpanded< export type StripeTaxRate = Stripe.TaxRate; /** - * Stripe.StripeTaxRate with expanded fields removed + * Stripe.InvoiceLineItem.Tax with tax_rate_details.tax_rate expanded. */ -export type StripeInvoiceLineItemTaxAmount = NegotiateExpanded< - never, - DeepOverride< - Stripe.InvoiceLineItem.TaxAmount, - { - tax_rate: StripeTaxRate; - } - >, - never +export type StripeInvoiceLineItemTax = Omit< + Stripe.InvoiceLineItem.Tax, + 'tax_rate_details' +> & { + tax_rate_details: + | (Omit< + NonNullable, + 'tax_rate' + > & { tax_rate: StripeTaxRate }) + | null; +}; + +/** + * Stripe.InvoiceLineItem.Pricing with expanded fields removed + */ +export type StripeInvoiceLineItemPricing = DeepOverride< + Stripe.InvoiceLineItem.Pricing, + { + price_details?: NegotiateExpanded< + never, + Stripe.InvoiceLineItem.Pricing.PriceDetails, + 'price' + >; + } >; /** * Stripe.InvoiceLineItem with expanded fields removed */ -export type StripeInvoiceLineItem = NegotiateExpanded< - never, - DeepOverride< - Stripe.InvoiceLineItem, - { - discount_amounts: Array< - NegotiateExpanded< - never, - Stripe.InvoiceLineItem.DiscountAmount, - 'discount' - > - > | null; - price: StripePrice | null; - tax_amounts: Array; - } +export type StripeInvoiceLineItem = Omit< + NegotiateExpanded< + never, + DeepOverride< + Stripe.InvoiceLineItem, + { + discount_amounts: Array< + NegotiateExpanded< + never, + Stripe.InvoiceLineItem.DiscountAmount, + 'discount' + > + > | null; + pricing: StripeInvoiceLineItemPricing | null; + } + >, + 'discounts' | 'subscription' >, - 'discounts' | 'invoice_item' | 'subscription' | 'subscription_item' ->; + 'taxes' +> & { + taxes: Array | null; +}; /** * Stripe.Event with expanded fields removed */ export type StripeEvent = NegotiateExpanded; +/** + * Stripe.Invoice.Parent with the subscription reference left unexpanded + */ +export type StripeInvoiceParent = DeepOverride< + Stripe.Invoice.Parent, + { + subscription_details: NegotiateExpanded< + never, + Stripe.Invoice.Parent.SubscriptionDetails, + 'subscription' + > | null; + } +>; + +/** + * Stripe.InvoicePayment with the payment references left unexpanded + */ +export type StripeInvoicePayment = DeepOverride< + Stripe.InvoicePayment, + { + payment: NegotiateExpanded< + never, + Stripe.InvoicePayment.Payment, + 'charge' | 'payment_intent' | 'payment_record' + >; + } +>; + /** * Stripe.Invoice with expanded fields removed */ -export type StripeInvoice = NegotiateExpanded< - never, - DeepOverride< - Stripe.Invoice, - { - lines: Stripe.ApiList; - discount: StripeDiscount | null; - total_tax_amounts: StripeInvoiceLineItemTaxAmount[]; - } +export type StripeInvoice = Omit< + NegotiateExpanded< + never, + DeepOverride< + Stripe.Invoice, + { + discounts: Array; + parent: StripeInvoiceParent | null; + payments: Stripe.ApiList; + } + >, + | 'customer' + | 'account_tax_ids' + | 'application' + | 'default_payment_method' + | 'default_source' + | 'latest_revision' + | 'on_behalf_of' + | 'test_clock' >, - | 'charge' - | 'customer' - | 'payment_intent' - | 'subscription' - | 'account_tax_ids' - | 'application' - | 'default_payment_method' - | 'default_source' - | 'discounts' - | 'latest_revision' - | 'on_behalf_of' - | 'quote' - | 'test_clock' ->; + 'lines' | 'total_taxes' +> & { + lines: Stripe.ApiList; + total_taxes: Array | null; +}; /** * Stripe.PaymentIntent with expanded fields removed @@ -195,7 +243,6 @@ export type StripePaymentIntent = NegotiateExpanded< Stripe.PaymentIntent, | 'application' | 'customer' - | 'invoice' | 'latest_charge' | 'on_behalf_of' | 'payment_method' @@ -203,32 +250,10 @@ export type StripePaymentIntent = NegotiateExpanded< >; /** - * Stripe.UpcomingInvoice with expanded fields removed + * The Create Preview Invoice API returns a Stripe.Invoice; preview invoices + * share the same shape as a finalized invoice. */ -export type StripeUpcomingInvoice = NegotiateExpanded< - never, - DeepOverride< - Stripe.UpcomingInvoice, - { - lines: Stripe.ApiList; - discount: StripeDiscount | null; - total_tax_amounts: StripeInvoiceLineItemTaxAmount[]; - } - >, - | 'charge' - | 'customer' - | 'payment_intent' - | 'subscription' - | 'account_tax_ids' - | 'application' - | 'default_payment_method' - | 'default_source' - | 'discounts' - | 'latest_revision' - | 'on_behalf_of' - | 'quote' - | 'test_clock' ->; +export type StripeUpcomingInvoice = StripeInvoice; export type StripeSubscriptionItem = NegotiateExpanded< never, @@ -246,7 +271,10 @@ export type StripeSubscription = NegotiateExpanded< never, DeepOverride< Stripe.Subscription, - { items: StripeApiList } + { + discounts: Array; + items: StripeApiList; + } >, | 'customer' | 'default_payment_method' @@ -254,7 +282,6 @@ export type StripeSubscription = NegotiateExpanded< | 'pending_setup_intent' | 'application' | 'default_source' - | 'discounts' | 'on_behalf_of' | 'schedule' | 'test_clock' @@ -292,7 +319,15 @@ export type StripeProduct = NegotiateExpanded< */ export type StripePromotionCode = NegotiateExpanded< never, - Stripe.PromotionCode, + DeepOverride< + Stripe.PromotionCode, + { + promotion: DeepOverride< + Stripe.PromotionCode.Promotion, + { coupon: StripeCoupon } + >; + } + >, 'customer' >; @@ -316,7 +351,7 @@ export type StripeCreditNote = NegotiateExpanded< lines: Stripe.ApiList; } >, - 'invoice' | 'customer' | 'customer_balance_transaction' | 'refund' + 'invoice' | 'customer' | 'customer_balance_transaction' >; /** diff --git a/libs/payments/stripe/src/lib/stripe.constants.ts b/libs/payments/stripe/src/lib/stripe.constants.ts index 7eb76704b3d..012d2037757 100644 --- a/libs/payments/stripe/src/lib/stripe.constants.ts +++ b/libs/payments/stripe/src/lib/stripe.constants.ts @@ -40,3 +40,4 @@ export const STRIPE_MINIMUM_CHARGE_AMOUNTS = { } as { [key: string]: number }; export const MOZILLA_TAX_ID = 'Tax ID'; +export const STRIPE_API_VERSION = '2026-06-24.dahlia'; diff --git a/package.json b/package.json index a6a2fbc08c2..0374f7534e0 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "semver": "^7.7.1", "set-value": "^4.1.0", "sms-segments-calculator": "^1.2.0", - "stripe": "17.4.0", + "stripe": "22.3.1", "superagent": "^10.2.0", "tslib": "^2.8.1", "twilio": "5.3.5", @@ -312,10 +312,12 @@ "plist": "^3.0.6", "sha.js": "^2.4.12", "underscore": ">=1.13.2", - "intel@npm:^1.2.0": "patch:intel@npm%3A1.2.0#~/.yarn/patches/intel-npm-1.2.0-81acb313a1.patch" + "intel@npm:^1.2.0": "patch:intel@npm%3A1.2.0#~/.yarn/patches/intel-npm-1.2.0-81acb313a1.patch", + "stripe@npm:22.3.1": "patch:stripe@npm%3A22.3.1#~/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch" }, "resolutionComments": { - "multer": "^2.1.1 — remove when @nestjs/platform-express is upgraded to 11.x (ships multer 2.1.1 natively)" + "multer": "^2.1.1 — remove when @nestjs/platform-express is upgraded to 11.x (ships multer 2.1.1 natively)", + "stripe@npm:22.3.1": "patched to re-add the top-level `types` field that stripe 22 dropped (exports-only package), so TypeScript resolves the SDK without a `paths` alias — regenerate the patch on the next stripe upgrade" }, "packageManager": "yarn@4.9.2" } diff --git a/packages/functional-tests/lib/sub-helpers.ts b/packages/functional-tests/lib/sub-helpers.ts index a0290406faf..f392b75971f 100644 --- a/packages/functional-tests/lib/sub-helpers.ts +++ b/packages/functional-tests/lib/sub-helpers.ts @@ -17,7 +17,10 @@ function getStripeClient(): Stripe { ); } return new Stripe(key, { - apiVersion: '2024-11-20.acacia', + // Pinned independently of STRIPE_API_VERSION (@fxa/payments/stripe) so the + // functional-test helpers don't pull in the payments app graph. Keep in sync + // with that constant on a Stripe upgrade. + apiVersion: '2026-06-24.dahlia', }); } diff --git a/packages/fxa-admin-server/src/subscriptions/stripe.service.ts b/packages/fxa-admin-server/src/subscriptions/stripe.service.ts index 560fba57781..b405e14fef1 100644 --- a/packages/fxa-admin-server/src/subscriptions/stripe.service.ts +++ b/packages/fxa-admin-server/src/subscriptions/stripe.service.ts @@ -23,6 +23,7 @@ import Stripe from 'stripe'; import { FirestoreService } from '../backend/firestore.service'; import { AppConfig } from '../config'; import { StripeMapperService } from '@fxa/payments/legacy'; +import { STRIPE_API_VERSION } from '@fxa/payments/stripe'; import { MozLoggerService } from '@fxa/shared/mozlog'; export const StripeFactory: Provider = { @@ -30,7 +31,7 @@ export const StripeFactory: Provider = { useFactory: (configService: ConfigService) => { const stripeConfig = configService.get('subscriptions'); const stripe = new Stripe(stripeConfig.stripeApiKey, { - apiVersion: '2024-11-20.acacia', + apiVersion: STRIPE_API_VERSION, maxNetworkRetries: 3, }); stripe.customers.list({ limit: 1 }).catch((error) => { diff --git a/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.spec.ts b/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.spec.ts index 8ac34280cdb..8f19cd94558 100644 --- a/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.spec.ts +++ b/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.spec.ts @@ -6,6 +6,7 @@ import { PaymentState } from 'fxa-shared/payments/iap/google-play/types'; import { AppStoreFormatter, MozPlaySubscriptionStatusType, + MozStripeSubscriptionDetails, PlayStoreFormatter, StripeFormatter, } from './subscriptions.formatters'; @@ -18,13 +19,19 @@ describe('MozSubscriptionFormatters', () => { { // Note: Stripe has timestamps in seconds not milliseconds. created: created / 1e3, - current_period_end: addDays(created, 30) / 1e3, - current_period_start: addDays(created, 1) / 1e3, cancel_at_period_end: true, ended_at: null, status: 'active', id: '123', - }, + items: { + data: [ + { + current_period_end: addDays(created, 30) / 1e3, + current_period_start: addDays(created, 1) / 1e3, + }, + ], + }, + } as MozStripeSubscriptionDetails, { plan_id: 'plan-123', product_name: 'product 123', diff --git a/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.ts b/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.ts index 184dc976b24..2afdc957a47 100644 --- a/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.ts +++ b/packages/fxa-admin-server/src/subscriptions/subscriptions.formatters.ts @@ -6,7 +6,7 @@ import { AppStoreSubscriptionPurchase } from 'fxa-shared/payments/iap/apple-app- import { PlayStoreSubscriptionPurchase } from 'fxa-shared/payments/iap/google-play/subscription-purchase'; import { PaymentState } from 'fxa-shared/payments/iap/google-play/types'; import { AbbrevPlan } from 'fxa-shared/subscriptions/types'; -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { MozSubscription } from '../rest/model/moz-subscription.model'; /** @@ -23,8 +23,8 @@ export class StripeFormatter { // The stripe API returns timestamps in UTC seconds instead of milliseconds. The conversion // can be done here to normalize the response so that it is consistent with other APIs. created: subscription.created * 1e3, - currentPeriodEnd: subscription.current_period_end * 1e3, - currentPeriodStart: subscription.current_period_start * 1e3, + currentPeriodEnd: subscription.items.data[0].current_period_end * 1e3, + currentPeriodStart: subscription.items.data[0].current_period_start * 1e3, cancelAtPeriodEnd: subscription.cancel_at_period_end, endedAt: !!subscription.ended_at ? subscription.ended_at * 1e3 @@ -41,13 +41,7 @@ export class StripeFormatter { } export type MozStripeSubscriptionDetails = Pick< Stripe.Subscription, - | 'created' - | 'current_period_end' - | 'cancel_at_period_end' - | 'current_period_start' - | 'ended_at' - | 'status' - | 'id' + 'created' | 'cancel_at_period_end' | 'ended_at' | 'status' | 'id' | 'items' >; export type MozPlanDetails = Pick< AbbrevPlan, diff --git a/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts b/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts index b6772c26ea0..d1856b2f5c5 100644 --- a/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts +++ b/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts @@ -173,8 +173,14 @@ describe('Subscription Service', () => { // The stripe API returns timestamps in seconds... converting seconds to millis. created: created / 1e3, cancel_at_period_end: cancelAtPeriodEnd, - current_period_end: currentPeriodEnd / 1e3, - current_period_start: currentPeriodStart / 1e3, + items: { + data: [ + { + current_period_end: currentPeriodEnd / 1e3, + current_period_start: currentPeriodStart / 1e3, + }, + ], + }, ended_at: endedAt, id: subscriptionId, latest_invoice: latestInvoice, diff --git a/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts b/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts index e1d0f774ed9..06f21bb5fd3 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts @@ -975,7 +975,7 @@ describe('PayPalHelper', () => { status: 'open', amount_due: 499, currency: 'eur', - tax: 500, + total_taxes: [{ amount: 500 }], }; const response = await paypalHelper.processInvoice({ @@ -995,7 +995,7 @@ describe('PayPalHelper', () => { paymentAttempts ), ipaddress: '127.0.0.1', - taxAmountInCents: validInvoice.tax, + taxAmountInCents: 500, }); expect(response).toEqual([{ transactionId }, {}]); }); diff --git a/packages/fxa-auth-server/lib/payments/paypal/helper.ts b/packages/fxa-auth-server/lib/payments/paypal/helper.ts index 520c7a400f5..03d95fbfec5 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/helper.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/helper.ts @@ -456,6 +456,10 @@ export class PayPalHelper { } } + const taxAmountInCents = invoice.total_taxes?.length + ? invoice.total_taxes.reduce((sum, tax) => sum + tax.amount, 0) + : null; + const promises: Promise[] = [ this.chargeCustomer({ amountInCents: invoice.amount_due, @@ -466,7 +470,7 @@ export class PayPalHelper { idempotencyKey, ...(countryCode && { countryCode }), ...(ipaddress && { ipaddress }), - ...(invoice.tax && { taxAmountInCents: invoice.tax }), + ...(taxAmountInCents !== null && { taxAmountInCents }), }), ]; if (invoice.status === 'draft') { diff --git a/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts b/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts index fc2beeb161a..bc53adae1fb 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts @@ -117,17 +117,19 @@ describe('PaypalProcessor', () => { describe('cancelInvoiceSubscription', () => { it('marks invoice and cancels subscription', async () => { + const invoice = deepCopy(paidInvoice); + invoice.parent = { + subscription_details: { subscription: { id: 'sub_basil' } }, + }; mockStripeHelper.markUncollectible = jest.fn().mockResolvedValue({}); mockStripeHelper.cancelSubscription = jest.fn().mockResolvedValue({}); - const result = await processor.cancelInvoiceSubscription(paidInvoice); + const result = await processor.cancelInvoiceSubscription(invoice); expect(result).toEqual([{}, {}]); expect(mockStripeHelper.markUncollectible).toHaveBeenCalledTimes(1); - expect(mockStripeHelper.markUncollectible).toHaveBeenCalledWith( - paidInvoice - ); + expect(mockStripeHelper.markUncollectible).toHaveBeenCalledWith(invoice); expect(mockStripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); expect(mockStripeHelper.cancelSubscription).toHaveBeenCalledWith( - paidInvoice.subscription.id + 'sub_basil' ); }); }); diff --git a/packages/fxa-auth-server/lib/payments/paypal/processor.ts b/packages/fxa-auth-server/lib/payments/paypal/processor.ts index 3d6d935f1e1..374395631f1 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/processor.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/processor.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { getMinimumAmount } from 'fxa-shared/subscriptions/stripe'; import { Logger } from 'mozlog'; -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { Container } from 'typedi'; import { PayPalClientError } from '@fxa/payments/paypal'; @@ -94,7 +94,10 @@ export class PaypalProcessor { return Promise.all([ this.stripeHelper.markUncollectible(invoice), this.stripeHelper.cancelSubscription( - (invoice.subscription as Stripe.Subscription).id + ( + invoice.parent?.subscription_details + ?.subscription as Stripe.Subscription + ).id ), ]); } diff --git a/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts b/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts index 47a9ccb4a90..6e268d23ad9 100644 --- a/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts @@ -552,6 +552,9 @@ describe('StripeFirestore', () => { beforeEach(() => { invoice = deepCopy(paidInvoice); + invoice.parent = { + subscription_details: { subscription: invoice.subscription }, + }; }); it('inserts a record', async () => { @@ -616,7 +619,7 @@ describe('StripeFirestore', () => { const mockInvoice = { id: invoiceId, customer: customerId, - subscription: subscriptionId, + parent: { subscription_details: { subscription: subscriptionId } }, }; const eventTime = 123; @@ -726,7 +729,7 @@ describe('StripeFirestore', () => { it('returns invoice as-is when it has no subscription', async () => { const mockInvoiceWithoutSubscription = { ...mockInvoice, - subscription: null, + parent: null, }; stripe.invoices.retrieve.mockResolvedValue( mockInvoiceWithoutSubscription diff --git a/packages/fxa-auth-server/lib/payments/stripe-formatter.spec.ts b/packages/fxa-auth-server/lib/payments/stripe-formatter.spec.ts index 782db80d922..b1b2cce47f0 100644 --- a/packages/fxa-auth-server/lib/payments/stripe-formatter.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe-formatter.spec.ts @@ -35,11 +35,11 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { expect(invoice.line_items).toEqual(expectedLineItems); expect(invoice.total).toBe(previewInvoiceWithTax.total); expect(invoice.subtotal).toBe(previewInvoiceWithTax.subtotal); - expect(invoice.tax?.[0].amount).toBe( - previewInvoiceWithTax.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + previewInvoiceWithTax.total_taxes[0].amount ); - expect(invoice.tax?.[0].display_name).toBe( - previewInvoiceWithTax.total_tax_amounts[0].tax_rate.display_name + expect(invoice.tax[0].display_name).toBe( + previewInvoiceWithTax.total_taxes[0].tax_rate_details.tax_rate.display_name ); expect(invoice.tax?.[0].inclusive).toBe(true); expect(invoice.discount).toBeUndefined(); @@ -52,28 +52,28 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { ]); expect(invoice.total).toBe(previewInvoiceWithDiscountAndTax.total); expect(invoice.subtotal).toBe(previewInvoiceWithDiscountAndTax.subtotal); - expect(invoice.tax?.[0].amount).toBe( - previewInvoiceWithDiscountAndTax.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + previewInvoiceWithDiscountAndTax.total_taxes[0].amount ); - expect(invoice.tax?.[0].display_name).toBe( - previewInvoiceWithDiscountAndTax.total_tax_amounts[0].tax_rate + expect(invoice.tax[0].display_name).toBe( + previewInvoiceWithDiscountAndTax.total_taxes[0].tax_rate_details.tax_rate .display_name ); - expect(invoice.tax?.[0].inclusive).toBe(true); - expect(invoice.discount?.amount).toBe( + expect(invoice.tax[0].inclusive).toBe(true); + expect(invoice.discount.amount).toBe( previewInvoiceWithDiscountAndTax.total_discount_amounts[0].amount ); - expect(invoice.discount?.amount_off).toBe( - previewInvoiceWithDiscountAndTax.discount.coupon.amount_off + expect(invoice.discount.amount_off).toBe( + previewInvoiceWithDiscountAndTax.discounts[0].source.coupon.amount_off ); - expect(invoice.discount?.percent_off).toBe( - previewInvoiceWithDiscountAndTax.discount.coupon.percent_off + expect(invoice.discount.percent_off).toBe( + previewInvoiceWithDiscountAndTax.discounts[0].source.coupon.percent_off ); }); it('formats an invoice where tax display_name is an empty string', () => { const invoicePreview = deepCopy(previewInvoiceWithTax); - invoicePreview.total_tax_amounts[0].tax_rate.display_name = ''; + invoicePreview.total_taxes[0].tax_rate_details.tax_rate.display_name = ''; const invoice = stripeInvoiceToFirstInvoicePreviewDTO([ invoicePreview, @@ -82,8 +82,8 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { expect(invoice.total).toBe(invoicePreview.total); expect(invoice.subtotal).toBe(invoicePreview.subtotal); - expect(invoice.tax?.[0].amount).toBe( - invoicePreview.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + invoicePreview.total_taxes[0].amount ); expect(invoice.tax?.[0].display_name).toBeUndefined(); expect(invoice.tax?.[0].inclusive).toBe(true); @@ -92,7 +92,8 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { it('formats an invoice with a prorated amount', () => { const firstInvoice = deepCopy(previewInvoiceWithTax); const proratedInvoice = deepCopy(previewInvoiceWithTax); - proratedInvoice.lines.data[0].proration = true; + proratedInvoice.lines.data[0].parent.subscription_item_details.proration = + true; const invoice = stripeInvoiceToFirstInvoicePreviewDTO([ firstInvoice, @@ -103,11 +104,11 @@ describe('stripeInvoiceToFirstInvoicePreviewDTO', () => { expect(invoice.line_items).toEqual(expectedLineItems); expect(invoice.total).toBe(previewInvoiceWithTax.total); expect(invoice.subtotal).toBe(previewInvoiceWithTax.subtotal); - expect(invoice.tax?.[0].amount).toBe( - previewInvoiceWithTax.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + previewInvoiceWithTax.total_taxes[0].amount ); - expect(invoice.tax?.[0].display_name).toBe( - previewInvoiceWithTax.total_tax_amounts[0].tax_rate.display_name + expect(invoice.tax[0].display_name).toBe( + previewInvoiceWithTax.total_taxes[0].tax_rate_details.tax_rate.display_name ); expect(invoice.tax?.[0].inclusive).toBe(true); expect(invoice.discount).toBeUndefined(); @@ -126,11 +127,11 @@ describe('stripeInvoiceToLatestInvoiceItemsDTO', () => { expect(invoice.line_items).toEqual(expectedLineItems); expect(invoice.total).toBe(previewInvoiceWithTax.total); expect(invoice.subtotal).toBe(previewInvoiceWithTax.subtotal); - expect(invoice.tax?.[0].amount).toBe( - previewInvoiceWithTax.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + previewInvoiceWithTax.total_taxes[0].amount ); - expect(invoice.tax?.[0].display_name).toBe( - previewInvoiceWithTax.total_tax_amounts[0].tax_rate.display_name + expect(invoice.tax[0].display_name).toBe( + previewInvoiceWithTax.total_taxes[0].tax_rate_details.tax_rate.display_name ); expect(invoice.tax?.[0].inclusive).toBe(true); expect(invoice.discount).toBeUndefined(); @@ -142,22 +143,22 @@ describe('stripeInvoiceToLatestInvoiceItemsDTO', () => { ); expect(invoice.total).toBe(previewInvoiceWithDiscountAndTax.total); expect(invoice.subtotal).toBe(previewInvoiceWithDiscountAndTax.subtotal); - expect(invoice.tax?.[0].amount).toBe( - previewInvoiceWithDiscountAndTax.total_tax_amounts[0].amount + expect(invoice.tax[0].amount).toBe( + previewInvoiceWithDiscountAndTax.total_taxes[0].amount ); - expect(invoice.tax?.[0].display_name).toBe( - previewInvoiceWithDiscountAndTax.total_tax_amounts[0].tax_rate + expect(invoice.tax[0].display_name).toBe( + previewInvoiceWithDiscountAndTax.total_taxes[0].tax_rate_details.tax_rate .display_name ); - expect(invoice.tax?.[0].inclusive).toBe(true); - expect(invoice.discount?.amount).toBe( + expect(invoice.tax[0].inclusive).toBe(true); + expect(invoice.discount.amount).toBe( previewInvoiceWithDiscountAndTax.total_discount_amounts[0].amount ); - expect(invoice.discount?.amount_off).toBe( - previewInvoiceWithDiscountAndTax.discount.coupon.amount_off + expect(invoice.discount.amount_off).toBe( + previewInvoiceWithDiscountAndTax.discounts[0].source.coupon.amount_off ); - expect(invoice.discount?.percent_off).toBe( - previewInvoiceWithDiscountAndTax.discount.coupon.percent_off + expect(invoice.discount.percent_off).toBe( + previewInvoiceWithDiscountAndTax.discounts[0].source.coupon.percent_off ); }); }); diff --git a/packages/fxa-auth-server/lib/payments/stripe-formatter.ts b/packages/fxa-auth-server/lib/payments/stripe-formatter.ts index 89af6434f3a..87ccc85eb2c 100644 --- a/packages/fxa-auth-server/lib/payments/stripe-formatter.ts +++ b/packages/fxa-auth-server/lib/payments/stripe-formatter.ts @@ -20,7 +20,7 @@ export function stripeInvoiceToFirstInvoicePreviewDTO( amount: line.amount, currency: line.currency, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: line.price!.id, + id: line.pricing!.price_details!.price as string, name: line.description || '', period: { end: line.period.end, @@ -30,29 +30,31 @@ export function stripeInvoiceToFirstInvoicePreviewDTO( }; // Add tax if it exists - if (invoice[0].total_tax_amounts.length > 0) { - invoicePreview.tax = invoice[0].total_tax_amounts.map((tax) => ({ + const totalTaxes = invoice[0].total_taxes ?? []; + if (totalTaxes.length > 0) { + invoicePreview.tax = totalTaxes.map((tax) => ({ amount: tax.amount, - inclusive: tax.inclusive, + inclusive: tax.tax_behavior === 'inclusive', display_name: - typeof tax.tax_rate === 'object' - ? tax.tax_rate.display_name || undefined - : undefined, + (tax.tax_rate_details?.tax_rate as unknown as Stripe.TaxRate) + ?.display_name || undefined, })); } // Add discount if it exists - if (invoice[0].discount && invoice[0].total_discount_amounts) { + const discount = invoice[0].discounts?.[0] as Stripe.Discount | undefined; + const coupon = discount?.source.coupon as Stripe.Coupon | undefined; + if (coupon && invoice[0].total_discount_amounts) { invoicePreview.discount = { amount: invoice[0].total_discount_amounts[0].amount, - amount_off: invoice[0].discount.coupon.amount_off, - percent_off: invoice[0].discount.coupon.percent_off, + amount_off: coupon.amount_off, + percent_off: coupon.percent_off, }; } if (invoice[1]) { const proration = invoice[1].lines.data.find( - (lineItem) => lineItem.proration + (lineItem) => lineItem.parent?.subscription_item_details?.proration ); if (proration) { diff --git a/packages/fxa-auth-server/lib/payments/stripe.spec.ts b/packages/fxa-auth-server/lib/payments/stripe.spec.ts index 755e49b3020..a52298bf9fd 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.spec.ts @@ -23,7 +23,7 @@ try { const { asyncIterable } = require('../../test/mocks'); const { AppError: error } = require('@fxa/accounts/errors'); -const stripeError = require('stripe').Stripe.errors; +const stripeError = require('stripe').errors; const uuidv4 = require('uuid').v4; const chance = new Chance(); @@ -164,7 +164,60 @@ const subscriptionCouponForever = require( const subscriptionCouponRepeating = require( `${STRIPE_FIXTURES}/subscription_coupon_repeating.json` ); -const paidInvoice = require(`${STRIPE_FIXTURES}/invoice_paid.json`); +// invoice_paid.json stays acacia-shaped on disk for the webhook dual-read tests; +// augment the in-memory copy with the basil shapes the StripeHelper now reads. +const paidInvoice = (() => { + const invoice = deepCopy(require(`${STRIPE_FIXTURES}/invoice_paid.json`)); + for (const line of invoice.lines.data) { + line.pricing = { + price_details: { + price: line.price?.id ?? line.price, + product: (line.price ?? line.plan)?.product, + }, + type: 'price_details', + }; + line.parent = { + type: 'subscription_item_details', + subscription_item_details: { + subscription: line.subscription, + subscription_item: line.subscription_item, + proration: line.proration ?? false, + proration_details: null, + }, + invoice_item_details: null, + }; + line.taxes = (line.tax_amounts ?? []).map((tax: any) => ({ + amount: tax.amount, + tax_behavior: tax.inclusive ? 'inclusive' : 'exclusive', + tax_rate_details: { tax_rate: tax.tax_rate }, + })); + } + invoice.parent = { + type: 'subscription_details', + subscription_details: { subscription: invoice.subscription }, + }; + invoice.payments = { + object: 'list', + data: [ + { + payment: { + payment_intent: invoice.payment_intent, + charge: invoice.charge, + }, + status: 'paid', + }, + ], + has_more: false, + total_count: 1, + url: '', + }; + invoice.total_taxes = (invoice.total_tax_amounts ?? []).map((tax: any) => ({ + amount: tax.amount, + tax_behavior: tax.inclusive ? 'inclusive' : 'exclusive', + tax_rate_details: { tax_rate: tax.tax_rate }, + })); + return invoice; +})(); const unpaidInvoice = require(`${STRIPE_FIXTURES}/invoice_open.json`); const successfulPaymentIntent = require( `${STRIPE_FIXTURES}/paymentIntent_succeeded.json` @@ -509,9 +562,11 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: jest - .fn() - .mockResolvedValue({ payment_intent: 'pi_mock' }), + retrieve: jest.fn().mockResolvedValue({ + payments: { + data: [{ payment: { payment_intent: 'pi_mock' } }], + }, + }), }, paymentIntents: { retrieve: jest.fn().mockResolvedValue({ payment_method: null }), @@ -536,9 +591,11 @@ describe('StripeHelper', () => { .mockResolvedValue({ payment_method: 'pm_mock' }), }, invoices: { - retrieve: jest - .fn() - .mockResolvedValue({ payment_intent: 'pi_mock' }), + retrieve: jest.fn().mockResolvedValue({ + payments: { + data: [{ payment: { payment_intent: 'pi_mock' } }], + }, + }), }, }; jest @@ -557,9 +614,11 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: jest - .fn() - .mockResolvedValue({ payment_intent: 'pi_mock' }), + retrieve: jest.fn().mockResolvedValue({ + payments: { + data: [{ payment: { payment_intent: 'pi_mock' } }], + }, + }), }, paymentIntents: { retrieve: jest @@ -584,9 +643,11 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: jest - .fn() - .mockResolvedValue({ payment_intent: 'pi_mock' }), + retrieve: jest.fn().mockResolvedValue({ + payments: { + data: [{ payment: { payment_intent: 'pi_mock' } }], + }, + }), }, paymentIntents: { retrieve: jest @@ -616,9 +677,11 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: jest - .fn() - .mockResolvedValue({ payment_intent: 'pi_mock' }), + retrieve: jest.fn().mockResolvedValue({ + payments: { + data: [{ payment: { payment_intent: 'pi_mock' } }], + }, + }), }, paymentIntents: { retrieve: jest @@ -1016,7 +1079,7 @@ describe('StripeHelper', () => { describe('previewInvoiceBySubscriptionId', () => { it('fetches invoice preview', async () => { const stripeStub = jest - .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .spyOn(stripeHelper.stripe.invoices, 'createPreview') .mockResolvedValue(undefined); await stripeHelper.previewInvoiceBySubscriptionId({ subscriptionId: 'sub123', @@ -1024,12 +1087,13 @@ describe('StripeHelper', () => { expect(stripeStub).toHaveBeenCalledTimes(1); expect(stripeStub).toHaveBeenCalledWith({ subscription: 'sub123', + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], }); }); it('fetches invoice preview for cancelled subscription', async () => { const stripeStub = jest - .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .spyOn(stripeHelper.stripe.invoices, 'createPreview') .mockResolvedValue(undefined); await stripeHelper.previewInvoiceBySubscriptionId({ subscriptionId: 'sub123', @@ -1038,7 +1102,8 @@ describe('StripeHelper', () => { expect(stripeStub).toHaveBeenCalledTimes(1); expect(stripeStub).toHaveBeenCalledWith({ subscription: 'sub123', - subscription_cancel_at_period_end: false, + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], + subscription_details: { cancel_at_period_end: false }, }); }); }); @@ -1086,11 +1151,13 @@ describe('StripeHelper', () => { expires_at: null, max_redemptions: null, times_redeemed: 0, - coupon: { - valid: true, - max_redemptions: null, - times_redeemed: 0, - redeem_by: null, + promotion: { + coupon: { + valid: true, + max_redemptions: null, + times_redeemed: 0, + redeem_by: null, + }, }, }; @@ -1118,11 +1185,13 @@ describe('StripeHelper', () => { it('return invalid with maximallyRedeemed for max redeemed coupon', async () => { const promotionCode = { ...promotionCodeTemplate, - coupon: { - ...promotionCodeTemplate.coupon, - valid: false, - max_redemptions: 1, - times_redeemed: 1, + promotion: { + coupon: { + ...promotionCodeTemplate.promotion.coupon, + valid: false, + max_redemptions: 1, + times_redeemed: 1, + }, }, }; const expected = { ...expectedTemplate, maximallyRedeemed: true }; @@ -1136,9 +1205,11 @@ describe('StripeHelper', () => { it('return invalid with expired for expired coupon', async () => { const promotionCode = { ...promotionCodeTemplate, - coupon: { - valid: false, - redeem_by: 1000, + promotion: { + coupon: { + valid: false, + redeem_by: 1000, + }, }, }; const expected = { ...expectedTemplate, expired: true }; @@ -1387,6 +1458,9 @@ describe('StripeHelper', () => { { ...paidInvoice, collection_method: 'charge_automatically', + payments: { + data: [{ payment: { charge: paidInvoice.charge } }], + }, }, ]); expect(stripeHelper.stripe.refunds.create).toHaveBeenCalledTimes(1); @@ -1404,8 +1478,8 @@ describe('StripeHelper', () => { { ...paidInvoice, collection_method: 'charge_automatically', - charge: { - id: paidInvoice.charge, + payments: { + data: [{ payment: { charge: { id: paidInvoice.charge } } }], }, }, ]); @@ -1699,9 +1773,13 @@ describe('StripeHelper', () => { describe('fetchOpenInvoices', () => { it('returns customer paypal agreement id', async () => { const invoice = deepCopy(invoicePaidSubscriptionCreate); - invoice.subscription = { status: 'active' }; + invoice.parent = { + subscription_details: { subscription: { status: 'active' } }, + }; const invoice2 = deepCopy(invoicePaidSubscriptionCreate); - invoice2.subscription = { status: 'cancelled' }; + invoice2.parent = { + subscription_details: { subscription: { status: 'cancelled' } }, + }; async function* genInvoice() { yield invoice; yield invoice2; @@ -1725,7 +1803,10 @@ describe('StripeHelper', () => { collection_method: 'send_invoice', status: 'open', created: 0, - expand: ['data.customer', 'data.subscription'], + expand: [ + 'data.customer', + 'data.parent.subscription_details.subscription', + ], }); }); }); @@ -1843,7 +1924,9 @@ describe('StripeHelper', () => { it('extracts the country if its present', () => { const latest_invoice = { ...subscriptionCreatedInvoice, - payment_intent: { ...closedPaymementIntent }, + payments: { + data: [{ payment: { payment_intent: { ...closedPaymementIntent } } }], + }, }; const subscription = { ...subscription2, latest_invoice }; const result = @@ -1869,7 +1952,9 @@ describe('StripeHelper', () => { const latest_invoice = { ...subscriptionCreatedInvoice, - payment_intent: { latest_charge: null }, + payments: { + data: [{ payment: { payment_intent: { latest_charge: null } } }], + }, }; const subscription = { ...subscription2, latest_invoice }; const result = @@ -2272,13 +2357,19 @@ describe('StripeHelper', () => { it('returns only invoices of active subscriptions', async () => { const expectedString = { id: 'idString', - subscription: 'idSub', + parent: { subscription_details: { subscription: 'idSub' } }, }; jest.spyOn(stripeHelper.stripe.subscriptions, 'list').mockResolvedValue({ data: [{ id: 'idNull' }, { id: 'subIdExpanded' }, { id: 'idSub' }], }); jest.spyOn(stripeHelper.stripe.invoices, 'list').mockResolvedValue({ - data: [{ id: 'idNull', subscription: null }, { ...expectedString }], + data: [ + { + id: 'idNull', + parent: { subscription_details: { subscription: null } }, + }, + { ...expectedString }, + ], }); const result = await stripeHelper.fetchInvoicesForActiveSubscriptions( existingUid, @@ -2515,7 +2606,9 @@ describe('StripeHelper', () => { describe("when Invoice status is 'paid'", () => { describe("Payment Intent Status is 'succeeded'", () => { const invoice = deepCopy(paidInvoice); - invoice.payment_intent = successfulPaymentIntent; + invoice.payments = { + data: [{ payment: { payment_intent: successfulPaymentIntent } }], + }; it('should return true', () => { expect(stripeHelper.paidInvoice(invoice)).toBe(true); }); @@ -2523,7 +2616,9 @@ describe('StripeHelper', () => { describe("Payment Intent Status is NOT 'succeeded'", () => { const invoice = deepCopy(paidInvoice); - invoice.payment_intent = unsuccessfulPaymentIntent; + invoice.payments = { + data: [{ payment: { payment_intent: unsuccessfulPaymentIntent } }], + }; it('should return false', () => { expect(stripeHelper.paidInvoice(invoice)).toBe(false); }); @@ -2533,7 +2628,9 @@ describe('StripeHelper', () => { describe("when Invoice status is NOT 'paid'", () => { describe("Payment Intent Status is 'succeeded'", () => { const invoice = deepCopy(unpaidInvoice); - invoice.payment_intent = successfulPaymentIntent; + invoice.payments = { + data: [{ payment: { payment_intent: successfulPaymentIntent } }], + }; it('should return false', () => { expect(stripeHelper.paidInvoice(invoice)).toBe(false); }); @@ -2541,7 +2638,9 @@ describe('StripeHelper', () => { describe("Payment Intent Status is NOT 'succeeded'", () => { const invoice = deepCopy(unpaidInvoice); - invoice.payment_intent = unsuccessfulPaymentIntent; + invoice.payments = { + data: [{ payment: { payment_intent: unsuccessfulPaymentIntent } }], + }; it('should return false', () => { expect(stripeHelper.paidInvoice(invoice)).toBe(false); }); @@ -2995,7 +3094,9 @@ describe('StripeHelper', () => { describe('invoice is created', () => { it('returns the invoice if marked as paid', async () => { const expected = deepCopy(paidInvoice); - expected.payment_intent = successfulPaymentIntent; + expected.payments = { + data: [{ payment: { payment_intent: successfulPaymentIntent } }], + }; jest .spyOn(stripeHelper.stripe.invoices, 'pay') .mockResolvedValue(expected); @@ -3005,7 +3106,9 @@ describe('StripeHelper', () => { it('throws an error if invoice is not marked as paid', async () => { const expected = deepCopy(paidInvoice); - expected.payment_intent = unsuccessfulPaymentIntent; + expected.payments = { + data: [{ payment: { payment_intent: unsuccessfulPaymentIntent } }], + }; jest .spyOn(stripeHelper.stripe.invoices, 'pay') .mockResolvedValue(expected); @@ -3061,10 +3164,12 @@ describe('StripeHelper', () => { describe('when the payment_intent is loaded', () => { it('returns the payment_intent from the Invoice object', async () => { const invoice = deepCopy(unpaidInvoice); - invoice.payment_intent = unsuccessfulPaymentIntent; + invoice.payments = { + data: [{ payment: { payment_intent: unsuccessfulPaymentIntent } }], + }; const actual = await stripeHelper.fetchPaymentIntentFromInvoice(invoice); - expect(actual).toEqual(invoice.payment_intent); + expect(actual).toEqual(unsuccessfulPaymentIntent); expect( stripeHelper.stripe.paymentIntents.retrieve.mock.calls.length === 0 ).toBe(true); @@ -3074,6 +3179,9 @@ describe('StripeHelper', () => { describe('when the payment_intent is not loaded', () => { it('fetches the payment_intent from Stripe', async () => { const invoice = deepCopy(unpaidInvoice); + invoice.payments = { + data: [{ payment: { payment_intent: unsuccessfulPaymentIntent.id } }], + }; const actual = await stripeHelper.fetchPaymentIntentFromInvoice(invoice); expect(actual).toEqual(unsuccessfulPaymentIntent); @@ -3152,8 +3260,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: subscription1.created, - current_period_end: subscription1.current_period_end, - current_period_start: subscription1.current_period_start, + current_period_end: + subscription1.items.data[0].current_period_end, + current_period_start: + subscription1.items.data[0].current_period_start, cancel_at_period_end: false, end_at: null, plan_id: subscription1.plan.id, @@ -3190,8 +3300,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: pastDueSubscription.created, - current_period_end: pastDueSubscription.current_period_end, - current_period_start: pastDueSubscription.current_period_start, + current_period_end: + pastDueSubscription.items.data[0].current_period_end, + current_period_start: + pastDueSubscription.items.data[0].current_period_start, cancel_at_period_end: false, end_at: null, plan_id: pastDueSubscription.plan.id, @@ -3223,7 +3335,7 @@ describe('StripeHelper', () => { jest .spyOn(stripeHelper, 'expandResource') .mockResolvedValue({ id: productId, name: productName }); - pastDueInvoice.charge = failedChargeCopy; + pastDueInvoice.payments.data[0].payment.charge = failedChargeCopy; pastDueSub.latest_invoice = pastDueInvoice; pastDueSub.plan.product = product1.id; const input = { data: [pastDueSub] }; @@ -3243,7 +3355,7 @@ describe('StripeHelper', () => { jest .spyOn(stripeHelper, 'expandResource') .mockResolvedValue({ id: productId, name: productName }); - pastDueInvoice.charge = 'ch_123'; + pastDueInvoice.payments.data[0].payment.charge = 'ch_123'; pastDueSub.latest_invoice = pastDueInvoice; const input = { data: [pastDueSub] }; const actual = await stripeHelper.subscriptionsToResponse(input); @@ -3272,8 +3384,8 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: sub.created, - current_period_end: sub.current_period_end, - current_period_start: sub.current_period_start, + current_period_end: sub.items.data[0].current_period_end, + current_period_start: sub.items.data[0].current_period_start, cancel_at_period_end: true, end_at: null, plan_id: sub.plan.id, @@ -3315,8 +3427,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: cancelledSubscription.created, - current_period_end: cancelledSubscription.current_period_end, - current_period_start: cancelledSubscription.current_period_start, + current_period_end: + cancelledSubscription.items.data[0].current_period_end, + current_period_start: + cancelledSubscription.items.data[0].current_period_start, cancel_at_period_end: false, end_at: cancelledSubscription.ended_at, plan_id: cancelledSubscription.plan.id, @@ -3429,8 +3543,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: subscriptionCouponOnce.created, - current_period_end: subscriptionCouponOnce.current_period_end, - current_period_start: subscriptionCouponOnce.current_period_start, + current_period_end: + subscriptionCouponOnce.items.data[0].current_period_end, + current_period_start: + subscriptionCouponOnce.items.data[0].current_period_start, cancel_at_period_end: false, end_at: null, plan_id: subscriptionCouponOnce.plan.id, @@ -3467,9 +3583,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: subscriptionCouponForever.created, - current_period_end: subscriptionCouponForever.current_period_end, + current_period_end: + subscriptionCouponForever.items.data[0].current_period_end, current_period_start: - subscriptionCouponForever.current_period_start, + subscriptionCouponForever.items.data[0].current_period_start, cancel_at_period_end: false, end_at: null, plan_id: subscriptionCouponForever.plan.id, @@ -3482,14 +3599,15 @@ describe('StripeHelper', () => { latest_invoice: paidInvoice.number, latest_invoice_items: latestInvoiceItems, promotion_amount_off: - subscriptionCouponForever.discount.coupon.amount_off, + subscriptionCouponForever.discounts[0].source.coupon.amount_off, promotion_code: subscriptionCouponForever.metadata.appliedPromotionCode, promotion_duration: 'forever', promotion_end: null, - promotion_name: subscriptionCouponForever.discount.coupon.name, + promotion_name: + subscriptionCouponForever.discounts[0].source.coupon.name, promotion_percent_off: - subscriptionCouponForever.discount.coupon.percent_off, + subscriptionCouponForever.discounts[0].source.coupon.percent_off, }, ]; const actual = await stripeHelper.subscriptionsToResponse(input); @@ -3509,9 +3627,10 @@ describe('StripeHelper', () => { { _subscription_type: MozillaSubscriptionTypes.WEB, created: subscriptionCouponRepeating.created, - current_period_end: subscriptionCouponRepeating.current_period_end, + current_period_end: + subscriptionCouponRepeating.items.data[0].current_period_end, current_period_start: - subscriptionCouponRepeating.current_period_start, + subscriptionCouponRepeating.items.data[0].current_period_start, cancel_at_period_end: false, end_at: null, plan_id: subscriptionCouponRepeating.plan.id, @@ -3524,14 +3643,15 @@ describe('StripeHelper', () => { latest_invoice: paidInvoice.number, latest_invoice_items: latestInvoiceItems, promotion_amount_off: - subscriptionCouponRepeating.discount.coupon.amount_off, + subscriptionCouponRepeating.discounts[0].source.coupon.amount_off, promotion_code: subscriptionCouponRepeating.metadata.appliedPromotionCode, promotion_duration: 'repeating', - promotion_end: subscriptionCouponRepeating.discount.end, - promotion_name: subscriptionCouponRepeating.discount.coupon.name, + promotion_end: subscriptionCouponRepeating.discounts[0].end, + promotion_name: + subscriptionCouponRepeating.discounts[0].source.coupon.name, promotion_percent_off: - subscriptionCouponRepeating.discount.coupon.percent_off, + subscriptionCouponRepeating.discounts[0].source.coupon.percent_off, }, ]; const actual = await stripeHelper.subscriptionsToResponse(input); @@ -4419,7 +4539,7 @@ describe('StripeHelper', () => { }), {} ); - mockStripe.invoices.retrieveUpcoming = jest + mockStripe.invoices.createPreview = jest .fn() .mockResolvedValue(mockInvoiceUpcoming); stripeHelper.stripe = mockStripe; @@ -4427,76 +4547,28 @@ describe('StripeHelper', () => { describe('extractInvoiceDetailsForEmail', () => { const fixture: any = { ...invoicePaidSubscriptionCreate }; - fixture.lines.data[0] = { - ...fixture.lines.data[0], - plan: { - id: planId, - nickname: planName, - product: productId, - metadata: mockPlan.metadata, - }, - }; - const fixtureDiscount: any = { ...invoicePaidSubscriptionCreateDiscount, }; - fixtureDiscount.lines.data[0] = { - ...fixtureDiscount.lines.data[0], - plan: { - id: planId, - nickname: planName, - product: productId, - metadata: mockPlan.metadata, - }, - }; - const fixtureTaxDiscount: any = { ...invoicePaidSubscriptionCreateTaxDiscount, }; - fixtureTaxDiscount.lines.data[0] = { - ...fixtureTaxDiscount.lines.data[0], - plan: { - id: planId, - nickname: planName, - product: productId, - metadata: mockPlan.metadata, - }, - }; - const fixtureTax: any = { ...invoicePaidSubscriptionCreateTax }; - fixtureTax.lines.data[0] = { - ...fixtureTax.lines.data[0], - plan: { - id: planId, - nickname: planName, - product: productId, - metadata: mockPlan.metadata, - }, - }; const fixtureProrated: any = deepCopy(invoicePaidSubscriptionCreate); fixtureProrated.lines.data.unshift({ ...fixtureProrated.lines.data[0], - type: 'invoiceitem', - proration: true, amount: -100, - plan: { - id: 'mock-prorated-plan-id', - nickname: 'Prorated plan', - product: productId, - metadata: mockPlan.metadata, + parent: { + type: 'invoice_item_details', + invoice_item_details: { proration: true }, + subscription_item_details: null, }, }); const fixtureProrationRefund: any = { ...invoiceDraftProrationRefund }; fixtureProrationRefund.lines.data[1] = { ...fixtureProrationRefund.lines.data[1], - plan: { - id: planId, - nickname: planName, - product: productId, - metadata: mockPlan.metadata, - }, period: { end: 1587767020, start: 1585088620, @@ -4580,6 +4652,12 @@ describe('StripeHelper', () => { }; expandMock.mockResolvedValueOnce(mockCustomer); + expandMock.mockResolvedValueOnce({ + id: planId, + nickname: planName, + product: productId, + metadata: mockPlan.metadata, + }); expandMock.mockResolvedValueOnce(mockCharge); }); @@ -4590,7 +4668,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expected); }); @@ -4602,43 +4680,36 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(true); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expected); }); it('extracts expected details from an expanded invoice', async () => { const expandedFixture = deepCopy(invoicePaidSubscriptionCreate); - expandedFixture.lines.data[0].plan = { - id: planId, - nickname: planName, - metadata: mockPlan.metadata, - product: mockProduct, - }; expandedFixture.customer = mockCustomer; - expandedFixture.charge = mockCharge; + expandedFixture.payments.data[0].payment.charge = mockCharge; const result = await stripeHelper.extractInvoiceDetailsForEmail(expandedFixture); expect( (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expected); }); it('does not throw an exception when details on a payment method are missing', async () => { const noChargeFixture = deepCopy(invoicePaidSubscriptionCreate); - noChargeFixture.lines.data[0].plan = { + noChargeFixture.customer = mockCustomer; + noChargeFixture.payments.data[0].payment.charge = null; + expandMock.mockReset().mockResolvedValue({}); + expandMock.mockResolvedValueOnce(mockCustomer); + expandMock.mockResolvedValueOnce({ id: planId, nickname: planName, metadata: mockPlan.metadata, product: mockProduct, - }; - noChargeFixture.customer = mockCustomer; - noChargeFixture.charge = null; - // Reset the "once" queue from beforeEach and set up: customer, null (no charge), default - expandMock.mockReset().mockResolvedValue({}); - expandMock.mockResolvedValueOnce(mockCustomer); + }); expandMock.mockResolvedValueOnce(null); const result = await stripeHelper.extractInvoiceDetailsForEmail(noChargeFixture); @@ -4646,7 +4717,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual({ ...expected, lastFour: null, @@ -4659,7 +4730,11 @@ describe('StripeHelper', () => { const subscriptionItem = deepCopy(upgradeFixture.lines.data[0]); const subscriptionPeriodEnd = 1593032000; upgradeFixture.lines.data.push(subscriptionItem); - upgradeFixture.lines.data[0].type = 'invoiceitem'; + upgradeFixture.lines.data[0].parent = { + type: 'invoice_item_details', + invoice_item_details: { proration: false }, + subscription_item_details: null, + }; upgradeFixture.lines.data[1].period.end = subscriptionPeriodEnd; const result = await stripeHelper.extractInvoiceDetailsForEmail(upgradeFixture); @@ -4667,7 +4742,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual({ ...expected, nextInvoiceDate: new Date(subscriptionPeriodEnd * 1000), @@ -4681,7 +4756,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expected); }); @@ -4692,7 +4767,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expectedDiscount_foreverCoupon); }); @@ -4711,7 +4786,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual(expectedDiscount100); }); @@ -4736,7 +4811,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual({ ...expected, productMetadata: { @@ -4753,7 +4828,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual({ ...expected, invoiceTaxAmountInCents: 54, @@ -4767,7 +4842,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(3); + expect(expandMock).toHaveBeenCalledTimes(4); expect(result).toEqual({ ...expectedDiscount_foreverCoupon, invoiceTaxAmountInCents: 48, @@ -4782,7 +4857,7 @@ describe('StripeHelper', () => { (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); - expect(expandMock).toHaveBeenCalledTimes(2); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expected, invoiceStatus: 'draft', @@ -4829,7 +4904,7 @@ describe('StripeHelper', () => { expect( (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 ).toBe(true); - expect(expandMock).toHaveBeenCalledTimes(2); + expect(expandMock).toHaveBeenCalledTimes(3); }); it('throws an exception with unexpected data', async () => { @@ -4849,7 +4924,7 @@ describe('StripeHelper', () => { it('throws an exception if invoice line items doesnt have type = "subscription" or "invoiceitem"', async () => { const badFixture = deepCopy(invoicePaidSubscriptionCreate); - badFixture.lines.data[0].type = 'none'; + badFixture.lines.data[0].parent.type = 'none'; try { await stripeHelper.extractInvoiceDetailsForEmail(badFixture); throw new Error('should have thrown'); @@ -4875,12 +4950,16 @@ describe('StripeHelper', () => { it('extracts the correct months and coupon type for a 3 month coupon', async () => { const fixtureDiscount3Month = deepCopy(fixtureDiscount); - fixtureDiscount3Month.discount = { - coupon: { - duration: 'repeating', - duration_in_months: 3, + fixtureDiscount3Month.discounts = [ + { + source: { + coupon: { + duration: 'repeating', + duration_in_months: 3, + }, + }, }, - }; + ]; const actual = await stripeHelper.extractInvoiceDetailsForEmail( fixtureDiscount3Month ); @@ -4890,12 +4969,16 @@ describe('StripeHelper', () => { it('extracts the correct months and coupon type for a one time coupon', async () => { const fixtureDiscountOneTime = deepCopy(fixtureDiscount); - fixtureDiscountOneTime.discount = { - coupon: { - duration: 'once', - duration_in_months: null, + fixtureDiscountOneTime.discounts = [ + { + source: { + coupon: { + duration: 'once', + duration_in_months: null, + }, + }, }, - }; + ]; const actual = await stripeHelper.extractInvoiceDetailsForEmail( fixtureDiscountOneTime ); @@ -4910,9 +4993,11 @@ describe('StripeHelper', () => { ...fixtureDiscountOneTime, discounts: [ { - coupon: { - duration: 'once', - duration_in_months: null, + source: { + coupon: { + duration: 'once', + duration_in_months: null, + }, }, }, ], @@ -5185,11 +5270,11 @@ describe('StripeHelper', () => { } } - it('calls the expected helper method for cancellation, with retrieveUpcoming error', async () => { + it('calls the expected helper method for cancellation, with createPreview error', async () => { const stripeErr: any = new Error('Stripe error'); stripeErr.type = 'StripeInvalidRequestError'; stripeErr.code = 'invoice_upcoming_none'; - mockStripe.invoices.retrieveUpcoming = jest + mockStripe.invoices.createPreview = jest .fn() .mockRejectedValue(stripeErr); const event = deepCopy(eventCustomerSubscriptionUpdated); @@ -5212,10 +5297,10 @@ describe('StripeHelper', () => { ); }); - it('rejects if invoices.retrieveUpcoming errors with unexpected error', async () => { + it('rejects if invoices.createPreview errors with unexpected error', async () => { const stripeErr: any = new Error('Stripe error'); stripeErr.type = 'unexpected'; - mockStripe.invoices.retrieveUpcoming = jest + mockStripe.invoices.createPreview = jest .fn() .mockRejectedValue(stripeErr); const event = deepCopy(eventCustomerSubscriptionUpdated); @@ -5242,10 +5327,10 @@ describe('StripeHelper', () => { const mockInvoiceUpcomingWithData = { ...mockInvoiceUpcoming, lines: { - data: [{ type: 'invoiceitem' }], + data: [{ parent: { type: 'invoice_item_details' } }], }, }; - mockStripe.invoices.retrieveUpcoming = jest + mockStripe.invoices.createPreview = jest .fn() .mockResolvedValue(mockInvoiceUpcomingWithData); const event = deepCopy(eventCustomerSubscriptionUpdated); @@ -5637,8 +5722,13 @@ describe('StripeHelper', () => { expectedBaseUpdateDetails, mockInvoice ); - expect(mockStripe.invoices.retrieveUpcoming.mock.calls).toEqual([ - [{ subscription: event.data.object.id }], + expect(mockStripe.invoices.createPreview.mock.calls).toEqual([ + [ + { + subscription: event.data.object.id, + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], + }, + ], ]); expect(result).toEqual(defaultExpected); }); @@ -5835,6 +5925,8 @@ describe('StripeHelper', () => { describe('extractSubscriptionUpdateCancellationDetailsForEmail', () => { it('extracts expected details for a subscription cancellation', async () => { const event = deepCopy(eventCustomerSubscriptionUpdated); + event.data.object.items.data[0].current_period_end = + event.data.object.current_period_end; const result = await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail( event.data.object, @@ -5857,7 +5949,7 @@ describe('StripeHelper', () => { invoiceTotalInCents: mockInvoice.total, invoiceTotalCurrency: mockInvoice.currency, serviceLastActiveDate: new Date( - subscription.current_period_end * 1000 + subscription.items.data[0].current_period_end * 1000 ), productMetadata: expectedBaseUpdateDetails.productMetadata, showOutstandingBalance: false, @@ -5868,6 +5960,8 @@ describe('StripeHelper', () => { it('extracts expected details for a free trial subscription cancellation with trialEnd', async () => { const event = deepCopy(eventCustomerSubscriptionUpdated); const subscription = event.data.object; + subscription.items.data[0].current_period_end = + subscription.current_period_end; subscription.trial_start = 1582749566; subscription.trial_end = 1585341566; subscription.canceled_at = 1583000000; @@ -5892,7 +5986,7 @@ describe('StripeHelper', () => { invoiceTotalInCents: mockInvoice.total, invoiceTotalCurrency: mockInvoice.currency, serviceLastActiveDate: new Date( - subscription.current_period_end * 1000 + subscription.items.data[0].current_period_end * 1000 ), productMetadata: expectedBaseUpdateDetails.productMetadata, showOutstandingBalance: false, @@ -5908,6 +6002,8 @@ describe('StripeHelper', () => { created: 1666968725952, }; const event = deepCopy(eventCustomerSubscriptionUpdated); + event.data.object.items.data[0].current_period_end = + event.data.object.current_period_end; const result = await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail( event.data.object, @@ -5930,7 +6026,7 @@ describe('StripeHelper', () => { invoiceTotalInCents: mockUpcomingInvoice.total, invoiceTotalCurrency: mockUpcomingInvoice.currency, serviceLastActiveDate: new Date( - subscription.current_period_end * 1000 + subscription.items.data[0].current_period_end * 1000 ), productMetadata: expectedBaseUpdateDetails.productMetadata, showOutstandingBalance: true, diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 69eca01c2ed..fa29fd8b75c 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -53,7 +53,7 @@ import { import { StatsD } from 'hot-shots'; import ioredis from 'ioredis'; import { Logger } from 'mozlog'; -import { Stripe } from 'stripe'; +import Stripe from 'stripe'; import { Container } from 'typedi'; import { ConfigType } from '../../config'; @@ -75,6 +75,7 @@ import { ProductConfigurationManager } from '@fxa/shared/cms'; import { reportSentryError, reportSentryMessage } from '../sentry'; import { StripeMapperService } from '@fxa/payments/legacy'; import { SubPlatPaymentMethodType } from '@fxa/payments/customer'; +import { STRIPE_API_VERSION } from '@fxa/payments/stripe'; // Maintains backwards compatibility. Some type defs hoisted to fxa-shared/payments/stripe export * from 'fxa-shared/payments/stripe'; @@ -82,6 +83,26 @@ export * from 'fxa-shared/payments/stripe'; export const MOZILLA_TAX_ID = 'Tax ID'; export const STRIPE_TAX_RATES_CACHE_KEY = 'listStripeTaxRates'; export const SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY = 'appliedPromotionCode'; + +/** + * Transitional dual-read of an invoice's subscription reference: prefer the + * basil shape (parent.subscription_details.subscription), falling back to the + * legacy top-level `subscription` field for invoices still in the acacia shape + * (events emitted by an acacia-era account, or docs cached in the Firestore + * mirror before the basil cutover). Remove once no acacia-era invoices remain. + */ +export function getInvoiceSubscription( + invoice: Stripe.Invoice +): string | Stripe.Subscription | undefined { + return ( + invoice.parent?.subscription_details?.subscription ?? + ( + invoice as Stripe.Invoice & { + subscription?: string | Stripe.Subscription; + } + ).subscription + ); +} export enum STRIPE_CUSTOMER_METADATA { PAYPAL_AGREEMENT = 'paypalAgreementId', } @@ -198,7 +219,7 @@ export class StripeHelper extends StripeHelperBase { : undefined; this.stripe = new Stripe(config.subscriptions.stripeApiKey, { - apiVersion: '2024-11-20.acacia', + apiVersion: STRIPE_API_VERSION, maxNetworkRetries: 3, }); @@ -368,9 +389,12 @@ export class StripeHelper extends StripeHelperBase { subscriptionId: string; includeCanceled?: boolean; }) { - return this.stripe.invoices.retrieveUpcoming({ + return this.stripe.invoices.createPreview({ subscription: subscriptionId, - ...(includeCanceled && { subscription_cancel_at_period_end: false }), + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], + ...(includeCanceled && { + subscription_details: { cancel_at_period_end: false }, + }), }); } @@ -436,7 +460,7 @@ export class StripeHelper extends StripeHelperBase { priceId: string, promotionCode: Stripe.PromotionCode ) { - const { coupon } = promotionCode; + const coupon = promotionCode.promotion.coupon as Stripe.Coupon; const verifyCoupon = this.checkPromotionAndCouponProperties(coupon); const verifyPromotionCode = this.checkPromotionAndCouponProperties({ @@ -600,7 +624,7 @@ export class StripeHelper extends StripeHelperBase { return false; } const subscription = await this.expandResource( - invoice.subscription, + getInvoiceSubscription(invoice), SUBSCRIPTIONS_RESOURCE ); if (subscription?.collection_method !== 'send_invoice') { @@ -672,10 +696,9 @@ export class StripeHelper extends StripeHelperBase { (invoice) => invoice.collection_method === 'charge_automatically' ); for (const invoice of stripeInvoices) { + const invoiceCharge = invoice.payments?.data[0]?.payment.charge; const chargeId = - typeof invoice.charge === 'string' - ? invoice.charge - : invoice.charge?.id; + typeof invoiceCharge === 'string' ? invoiceCharge : invoiceCharge?.id; if (!chargeId) continue; const charge = await this.stripe.charges.retrieve(chargeId); @@ -952,9 +975,10 @@ export class StripeHelper extends StripeHelperBase { collection_method: 'send_invoice', status: 'open', created, - expand: ['data.customer', 'data.subscription'], + expand: ['data.customer', 'data.parent.subscription_details.subscription'], })) { - const subscription = invoice.subscription as Stripe.Subscription; + const subscription = invoice.parent?.subscription_details + ?.subscription as Stripe.Subscription; if ( subscription && ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status) @@ -1014,15 +1038,18 @@ export class StripeHelper extends StripeHelperBase { ); } else if (typeof subscription.latest_invoice === 'string') { const invoice = await this.stripe.invoices.retrieve( - subscription.latest_invoice + subscription.latest_invoice, + { expand: ['payments'] } ); + const invoicePaymentIntent = invoice.payments?.data[0]?.payment + .payment_intent; if ( - invoice.payment_intent && - typeof invoice.payment_intent === 'string' + invoicePaymentIntent && + typeof invoicePaymentIntent === 'string' ) { const paymentIntent = await this.stripe.paymentIntents.retrieve( - invoice.payment_intent + invoicePaymentIntent ); if (paymentIntent.payment_method) { paymentMethod = await this.getPaymentMethod( @@ -1200,8 +1227,9 @@ export class StripeHelper extends StripeHelperBase { return invoices.data.filter((invoice) => { // The invoice list we fetched did not expand the subscription so these must be strings - if (typeof invoice.subscription !== 'string') return false; - return activeSubscriptionIds.includes(invoice.subscription); + const subscription = invoice.parent?.subscription_details?.subscription; + if (typeof subscription !== 'string') return false; + return activeSubscriptionIds.includes(subscription); }); } @@ -1443,7 +1471,7 @@ export class StripeHelper extends StripeHelperBase { try { invoice = await this.stripe.invoices.pay(invoiceId, { - expand: ['payment_intent'], + expand: ['payments.data.payment.payment_intent'], }); } catch (err) { if (err.code === 'card_declined') { @@ -1462,23 +1490,25 @@ export class StripeHelper extends StripeHelperBase { /** * Verify that the invoice was paid successfully. * - * Note that the invoice *must have the `payment_intent` expanded* + * Note that the invoice *must have the `payments` payment_intent expanded* * or this function will fail. */ paidInvoice(invoice: Stripe.Subscription['latest_invoice']): boolean { + const paymentIntent = + typeof invoice === 'string' + ? undefined + : invoice?.payments?.data[0]?.payment.payment_intent; if ( !invoice || typeof invoice === 'string' || - !invoice.payment_intent || - typeof invoice.payment_intent === 'string' + !paymentIntent || + typeof paymentIntent === 'string' ) { throw error.internalValidationError('paidInvoice', { invoice: invoice, }); } - return ( - invoice.status === 'paid' && invoice.payment_intent.status === 'succeeded' - ); + return invoice.status === 'paid' && paymentIntent.status === 'succeeded'; } /** @@ -1487,7 +1517,8 @@ export class StripeHelper extends StripeHelperBase { async fetchPaymentIntentFromInvoice( invoice: Stripe.Invoice ): Promise { - if (!invoice.payment_intent) { + const paymentIntent = invoice.payments?.data[0]?.payment.payment_intent; + if (!paymentIntent) { // We don't have any code working with draft invoices, so // this should not be hit... yet. PayPal support *will* likely operate // on draft invoices though. @@ -1497,34 +1528,42 @@ export class StripeHelper extends StripeHelperBase { new Error(`Invoice not finalized: ${invoice.id}`) ); } - if (typeof invoice.payment_intent !== 'string') { - return invoice.payment_intent; + if (typeof paymentIntent !== 'string') { + return paymentIntent; } - return this.stripe.paymentIntents.retrieve(invoice.payment_intent); + return this.stripe.paymentIntents.retrieve(paymentIntent); } /** * Extract the source country from a subscription payment details. * - * Requires the `latest_invoice.payment_intent` to be expanded during - * subscription load. + * Requires the `latest_invoice.payments` payment_intent to be expanded + * during subscription load. */ extractSourceCountryFromSubscription( subscription: Stripe.Subscription ): null | string { + const latestInvoice = subscription.latest_invoice; + const paymentIntent = + typeof latestInvoice === 'string' + ? undefined + : latestInvoice?.payments?.data[0]?.payment.payment_intent; // Eliminate all the optional values and ensure they were expanded such // that they're not a string. if ( - !subscription.latest_invoice || - typeof subscription.latest_invoice === 'string' || - !subscription.latest_invoice.payment_intent || - typeof subscription.latest_invoice.payment_intent === 'string' + !latestInvoice || + typeof latestInvoice === 'string' || + !paymentIntent || + typeof paymentIntent === 'string' ) { return null; } - const latestCharge = subscription.latest_invoice.payment_intent - .latest_charge as string | Stripe.Charge | null | undefined; + const latestCharge = paymentIntent.latest_charge as + | string + | Stripe.Charge + | null + | undefined; if (latestCharge && typeof latestCharge !== 'string') { // Get the country from the payment details. // However, historically there were (rare) instances where `charges` was @@ -1702,14 +1741,10 @@ export class StripeHelper extends StripeHelperBase { // to get details of why it failed. The caller should expand the last_invoice // calls by passing ['data.subscriptions.data.latest_invoice'] to `fetchCustomer` // as the `expand` argument or this will not fetch the failure code/message. - if ( - this.checkSubscriptionPastDue(sub) && - latestInvoice && - latestInvoice.charge - ) { - let charge = latestInvoice.charge; - if (typeof latestInvoice.charge === 'string') { - charge = await this.stripe.charges.retrieve(latestInvoice.charge); + let charge = latestInvoice.payments?.data[0]?.payment.charge; + if (this.checkSubscriptionPastDue(sub) && latestInvoice && charge) { + if (typeof charge === 'string') { + charge = await this.stripe.charges.retrieve(charge); } if (typeof charge !== 'string') { @@ -1718,7 +1753,8 @@ export class StripeHelper extends StripeHelperBase { } } - const { discount } = sub; + const discount = sub.discounts?.[0] as Stripe.Discount | undefined; + const coupon = discount?.source.coupon as Stripe.Coupon | undefined; // This type inconsistency runs quite deep, but plan does exist on the subscription here // for all current use-cases. @@ -1738,8 +1774,8 @@ export class StripeHelper extends StripeHelperBase { subs.push({ _subscription_type: MozillaSubscriptionTypes.WEB, created: sub.created, - current_period_end: sub.current_period_end, - current_period_start: sub.current_period_start, + current_period_end: sub.items.data[0].current_period_end, + current_period_start: sub.items.data[0].current_period_start, cancel_at_period_end: sub.cancel_at_period_end, end_at: sub.ended_at, latest_invoice: latestInvoice.number, @@ -1752,13 +1788,13 @@ export class StripeHelper extends StripeHelperBase { subscription_id: sub.id, failure_code, failure_message, - promotion_amount_off: discount?.coupon?.amount_off ?? null, + promotion_amount_off: coupon?.amount_off ?? null, promotion_code: sub.metadata[SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY] ?? null, - promotion_duration: (discount?.coupon?.duration as string) ?? null, + promotion_duration: (coupon?.duration as string) ?? null, promotion_end: discount?.end ?? null, - promotion_name: discount?.coupon?.name ?? null, - promotion_percent_off: discount?.coupon?.percent_off ?? null, + promotion_name: coupon?.name ?? null, + promotion_percent_off: coupon?.percent_off ?? null, }); } return subs; @@ -1807,8 +1843,8 @@ export class StripeHelper extends StripeHelperBase { // plans for this subscription. subs.push({ created: sub.created, - current_period_end: sub.current_period_end, - current_period_start: sub.current_period_start, + current_period_end: sub.items.data[0].current_period_end, + current_period_start: sub.items.data[0].current_period_start, plan_changed, previous_product, product_name, @@ -1835,8 +1871,8 @@ export class StripeHelper extends StripeHelperBase { */ getPriceIdFromInvoice(invoice: Stripe.Invoice) { return invoice.lines.data.find( - (invoiceLine) => invoiceLine.type === 'subscription' - )?.price?.id; + (invoiceLine) => invoiceLine.parent?.type === 'subscription_item_details' + )?.pricing?.price_details?.price; } /** @@ -1861,7 +1897,7 @@ export class StripeHelper extends StripeHelperBase { // Get the new subscription, ignoring any invoiceitem line items // that could contain prorations for old subscriptions const subscriptionLineItem = invoice.lines.data.find( - (line) => line.type === 'subscription' + (line) => line.parent?.type === 'subscription_item_details' ); // In certain instances the invoice won't have a 'subscription' line item. @@ -1869,8 +1905,8 @@ export class StripeHelper extends StripeHelperBase { const invoiceitemLineItem = !subscriptionLineItem ? invoice.lines.data.find( (line) => - line.type === 'invoiceitem' && - !line.proration_details?.credited_items + line.parent?.type === 'invoice_item_details' && + !line.parent.invoice_item_details?.proration_details?.credited_items ) : undefined; @@ -1889,9 +1925,10 @@ export class StripeHelper extends StripeHelperBase { } // Dig up & expand objects in the invoice that usually come as just IDs - const { amount: offeringAmountInCents, plan } = lineItem; - if (!plan) { - // No plan is present if this is not a subscription or proration, which + const offeringAmountInCents = lineItem.amount; + const priceId = lineItem.pricing?.price_details?.price; + if (!priceId) { + // No price is present if this is not a subscription or proration, which // should never happen as we only have subscriptions. throw error.internalValidationError( 'extractInvoiceDetailsForEmail', @@ -1899,31 +1936,33 @@ export class StripeHelper extends StripeHelperBase { new Error(`Unexpected line item: ${invoice.lines.data[0].id}`) ); } + const plan = (await this.expandResource( + priceId, + PLAN_RESOURCE + )) as unknown as Stripe.Plan; const [abbrevProduct, charge] = await Promise.all([ this.expandAbbrevProductForPlan(plan), - this.expandResource(invoice.charge, CHARGES_RESOURCE), + this.expandResource( + invoice.payments?.data[0]?.payment.charge, + CHARGES_RESOURCE + ), ]); - // if the invoice does not have the deprecated discount property but has a discount ID in discounts - // expand the discount let discountType: Stripe.Coupon.Duration | null = null; let discountDuration: number | null = null; - if (invoice.discount) { - discountType = invoice.discount.coupon.duration; - discountDuration = invoice.discount.coupon.duration_in_months; - } - - if ( - invoice.id && - !invoice.discount && - !!invoice.discounts?.length && - invoice.discounts.length === 1 - ) { - const invoiceWithDiscount = await this.getInvoiceWithDiscount(invoice.id); - const discount = invoiceWithDiscount.discounts?.pop() as Stripe.Discount; - discountType = discount.coupon.duration; - discountDuration = discount.coupon.duration_in_months; + if (invoice.id && !!invoice.discounts?.length && invoice.discounts.length === 1) { + // The discount may arrive as a string id, in which case expand it. + let discount = invoice.discounts[0] as string | Stripe.Discount; + if (typeof discount === 'string') { + const invoiceWithDiscount = await this.getInvoiceWithDiscount( + invoice.id + ); + discount = invoiceWithDiscount.discounts?.pop() as Stripe.Discount; + } + const coupon = discount.source.coupon as Stripe.Coupon; + discountType = coupon.duration; + discountDuration = coupon.duration_in_months; } if (!!invoice.discounts?.length && invoice.discounts.length > 1) { @@ -1956,7 +1995,10 @@ export class StripeHelper extends StripeHelperBase { if (invoice.lines.data) { const totals = invoice.lines.data.reduce( (totals, line) => { - if (line.proration === true) { + const proration = + line.parent?.subscription_item_details?.proration || + line.parent?.invoice_item_details?.proration; + if (proration === true) { const amount = line.amount || 0; const description = line.description || ''; @@ -1991,16 +2033,19 @@ export class StripeHelper extends StripeHelperBase { total: invoiceTotalInCents, subtotal: invoiceSubtotalInCents, hosted_invoice_url: invoiceLink, - tax: invoiceTaxAmountInCents, - total_tax_amounts: invoiceTotalTaxAmounts, + total_taxes: invoiceTotalTaxes, status: invoiceStatus, amount_due: invoiceAmountDueInCents, ending_balance: invoiceEndingBalance, starting_balance: invoiceStartingBalance, } = invoice; - const hasExclusiveTax = invoiceTotalTaxAmounts.some( - (tax) => !tax.inclusive + const invoiceTaxAmountInCents = invoiceTotalTaxes?.length + ? invoiceTotalTaxes.reduce((sum, tax) => sum + tax.amount, 0) + : null; + + const hasExclusiveTax = (invoiceTotalTaxes ?? []).some( + (tax) => tax.tax_behavior !== 'inclusive' ); const nextInvoiceDate = lineItem.period.end; @@ -2035,22 +2080,25 @@ export class StripeHelper extends StripeHelperBase { const planSuccessActionButtonURL = successActionButtonURL || ''; const { lastFour, cardType } = this.extractCardDetails({ - charge, + charge: charge ?? null, }); - const paymentIntent = invoice.payment_intent as string; + const paymentIntentRef = invoice.payments?.data[0]?.payment.payment_intent; + const paymentIntent = + typeof paymentIntentRef === 'string' + ? paymentIntentRef + : paymentIntentRef?.id; const payment_provider = await this.getPaymentProvider( customer, paymentIntent ); + const subscription = getInvoiceSubscription(invoice); return { uid, email, subscriptionId: - typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription?.id, + typeof subscription === 'string' ? subscription : subscription?.id, cardType, lastFour, payment_provider, @@ -2163,19 +2211,27 @@ export class StripeHelper extends StripeHelperBase { return { lastFour, cardType }; } - async getSubsequentPrices(invoice: Stripe.Invoice | Stripe.UpcomingInvoice) { + async getSubsequentPrices(invoice: Stripe.Invoice) { const subsequentItem = invoice.lines.data.find( - (line) => line.proration === false + (line) => line.parent?.subscription_item_details?.proration === false ); const subsequentAmount = subsequentItem?.amount; - const subsequentAmountExcludingTax = - subsequentItem?.amount_excluding_tax ?? undefined; - const subsequentTax = subsequentItem?.tax_amounts.map((tax) => ({ - inclusive: tax.inclusive, + const subsequentTax = subsequentItem?.taxes?.map((tax) => ({ + inclusive: tax.tax_behavior === 'inclusive', amount: tax.amount, })); + const inclusiveTax = + subsequentTax && + subsequentTax + .filter((tax) => tax.inclusive) + .reduce((sum, tax) => sum + tax.amount, 0); + const subsequentAmountExcludingTax = + subsequentAmount !== undefined && inclusiveTax !== undefined + ? subsequentAmount - inclusiveTax + : undefined; + const exclusiveTax = subsequentTax && subsequentTax @@ -2289,10 +2345,7 @@ export class StripeHelper extends StripeHelperBase { let invoice = subscription.latest_invoice; if (typeof invoice === 'string') { - // if we have to do a fetch, go ahead and ensure we also get the additional needed resource - invoice = await this.stripe.invoices.retrieve(invoice, { - expand: ['charge'], - }); + invoice = await this.stripe.invoices.retrieve(invoice); } const { @@ -2369,15 +2422,16 @@ export class StripeHelper extends StripeHelperBase { // subscription is updated. Instead there will be pending invoice items // which will be added to next invoice once its generated. // For more info see https://stripe.com/docs/api/subscriptions/update - let upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined; + let upcomingInvoiceWithInvoiceItem: Stripe.Invoice | undefined; try { - const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({ + const upcomingInvoice = await this.stripe.invoices.createPreview({ customer: customer.id, subscription: subscription.id, + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], }); // Only use upcomingInvoice if there are `invoiceitems` upcomingInvoiceWithInvoiceItem = upcomingInvoice?.lines.data.some( - (line) => line.type === 'invoiceitem' + (line) => line.parent?.type === 'invoice_item_details' ) ? upcomingInvoice : undefined; @@ -2479,9 +2533,10 @@ export class StripeHelper extends StripeHelperBase { subscription: Stripe.Subscription, baseDetails: any, invoice: Stripe.Invoice, - upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined + upcomingInvoiceWithInvoiceItem: Stripe.Invoice | undefined ) { - const { current_period_end: serviceLastActiveDate } = subscription; + const { current_period_end: serviceLastActiveDate } = + subscription.items.data[0]; const { uid, @@ -2555,8 +2610,9 @@ export class StripeHelper extends StripeHelperBase { const { lastFour, cardType } = await this.extractCustomerDefaultPaymentDetailsByUid(uid); - const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({ + const upcomingInvoice = await this.stripe.invoices.createPreview({ subscription: subscription.id, + expand: ['discounts', 'lines.data.taxes.tax_rate_details.tax_rate'], }); const { total: invoiceTotalInCents, @@ -2650,7 +2706,7 @@ export class StripeHelper extends StripeHelperBase { subscription: Stripe.Subscription, baseDetails: any, invoice: Stripe.Invoice, - upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined, + upcomingInvoiceWithInvoiceItem: Stripe.Invoice | undefined, planOld: Stripe.Plan ) { const { diff --git a/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts b/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts index 093213e3bc2..d81d6a596a4 100644 --- a/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts +++ b/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts @@ -284,7 +284,7 @@ describe('SubscriptionReminders', () => { expect(reminder.alreadySentEmail).toHaveBeenCalledTimes(1); expect(reminder.alreadySentEmail).toHaveBeenCalledWith( subscription.customer.metadata.userid, - Math.floor(subscription.current_period_start * 1000), + Math.floor(subscription.items.data[0].current_period_start * 1000), { subscriptionId: subscription.id, reminderDays: 7, @@ -319,16 +319,14 @@ describe('SubscriptionReminders', () => { .fn() .mockResolvedValue({ total_excluding_tax: invoicePreview.total_excluding_tax, - tax: invoicePreview.tax, total: invoicePreview.total, currency: invoicePreview.currency, - discount: null, discounts: [], + total_taxes: [], }); mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }); const planConfig = { wibble: 'quux', @@ -388,8 +386,8 @@ describe('SubscriptionReminders', () => { { message: 'Sending a renewal reminder email.', subscriptionId: subscription.id, - currentPeriodStart: subscription.current_period_start, - currentPeriodEnd: subscription.current_period_end, + currentPeriodStart: subscription.items.data[0].current_period_start, + currentPeriodEnd: subscription.items.data[0].current_period_end, currentDateMs: Date.now(), reminderLength: 7, } @@ -408,7 +406,7 @@ describe('SubscriptionReminders', () => { planInterval: 'month', showTax: false, invoiceTotalExcludingTaxInCents: invoicePreview.total_excluding_tax, - invoiceTaxInCents: invoicePreview.tax, + invoiceTaxInCents: 0, invoiceTotalInCents: invoicePreview.total, invoiceTotalCurrency: invoicePreview.currency, productMetadata: formattedSubscription.productMetadata, @@ -586,16 +584,14 @@ describe('SubscriptionReminders', () => { .fn() .mockResolvedValue({ total_excluding_tax: invoicePreview.total_excluding_tax, - tax: invoicePreview.tax, total: invoicePreview.total, currency: invoicePreview.currency, - discount: null, discounts: [], + total_taxes: [], }); mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }); mockLog.info = jest.fn().mockReturnValue({}); mockLog.error = jest.fn().mockReturnValue({}); @@ -660,14 +656,12 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_123' }, - discounts: [], + discounts: [{ id: 'discount_123' }], }; const mockUpcomingInvoice = { total: invoicePreview.total, currency: invoicePreview.currency, - discount: null, discounts: [], }; @@ -733,14 +727,12 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: null, discounts: [{ id: 'discount_123' }], }; const mockUpcomingInvoice = { total: invoicePreview.total, currency: invoicePreview.currency, - discount: null, discounts: [], }; @@ -803,15 +795,13 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_old' }, - discounts: [], + discounts: [{ id: 'discount_old' }], }; const mockUpcomingInvoice = { total: invoicePreview.total, currency: invoicePreview.currency, - discount: { id: 'discount_new' }, - discounts: [], + discounts: [{ id: 'discount_new' }], }; reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); @@ -933,8 +923,7 @@ describe('SubscriptionReminders', () => { }; subscription.latest_invoice = { id: 'in_expanded', - discount: { id: 'discount_456' }, - discounts: [], + discounts: [{ id: 'discount_456' }], }; const account = { @@ -946,7 +935,6 @@ describe('SubscriptionReminders', () => { const mockUpcomingInvoice = { total: invoicePreview.total, currency: invoicePreview.currency, - discount: null, discounts: [], }; @@ -1288,22 +1276,19 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }; const mockUpcomingInvoiceWithTax = { total_excluding_tax: 1000, - tax: 200, total: 1200, currency: 'usd', - discount: null, discounts: [], - total_tax_amounts: [ + total_taxes: [ { amount: 200, - inclusive: false, - tax_rate: { display_name: 'Sales Tax' }, + tax_behavior: 'exclusive', + tax_rate_details: { tax_rate: { display_name: 'Sales Tax' } }, }, ], }; @@ -1368,17 +1353,15 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }; const mockUpcomingInvoiceNoTax = { total_excluding_tax: 1000, - tax: 0, total: 1000, currency: 'usd', - discount: null, discounts: [], + total_taxes: [], }; reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); @@ -1441,16 +1424,13 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }; const mockUpcomingInvoiceNullTax = { total_excluding_tax: 1000, - tax: null, total: 1000, currency: 'usd', - discount: null, discounts: [], }; @@ -1514,19 +1494,20 @@ describe('SubscriptionReminders', () => { const mockInvoice = { id: 'in_test123', - discount: { id: 'discount_ending' }, - discounts: [], + discounts: [{ id: 'discount_ending' }], }; const mockUpcomingInvoiceWithInclusiveTax = { total_excluding_tax: 887, - tax: 113, total: 1000, currency: 'eur', - discount: null, discounts: [], - total_tax_amounts: [ - { amount: 113, inclusive: true, tax_rate: { display_name: 'VAT' } }, + total_taxes: [ + { + amount: 113, + tax_behavior: 'inclusive', + tax_rate_details: { tax_rate: { display_name: 'VAT' } }, + }, ], }; @@ -1583,11 +1564,11 @@ describe('SubscriptionReminders', () => { const mockSubscription = { id: mockSubscriptionId, customer: mockCustomerId, - current_period_start: mockSubCurrentPeriodStart, - current_period_end: mockSubCurrentPeriodEnd, items: { data: [ { + current_period_start: mockSubCurrentPeriodStart, + current_period_end: mockSubCurrentPeriodEnd, price: { recurring: { interval: 'month', diff --git a/packages/fxa-auth-server/lib/payments/subscription-reminders.ts b/packages/fxa-auth-server/lib/payments/subscription-reminders.ts index aa2ae132714..e69e309ded6 100644 --- a/packages/fxa-auth-server/lib/payments/subscription-reminders.ts +++ b/packages/fxa-auth-server/lib/payments/subscription-reminders.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import assert from 'assert'; import { Logger } from 'mozlog'; -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { DateTime, Duration, Interval } from 'luxon'; import { reportSentryError } from '../sentry'; @@ -207,7 +207,7 @@ export class SubscriptionReminders { if ( await this.alreadySentEmail( uid, - Math.floor(subscription.current_period_start * 1000), + Math.floor(subscription.items.data[0].current_period_start * 1000), emailParams, 'subscriptionEndingReminder' ) @@ -219,8 +219,8 @@ export class SubscriptionReminders { this.log.info('sendSubscriptionEndingReminderEmail', { message: 'Sending a stay subscribed reminder email.', subscriptionId: subscription.id, - currentPeriodStart: subscription.current_period_start, - currentPeriodEnd: subscription.current_period_end, + currentPeriodStart: subscription.items.data[0].current_period_start, + currentPeriodEnd: subscription.items.data[0].current_period_end, currentDateMs: Date.now(), }); const { email } = account; @@ -258,7 +258,7 @@ export class SubscriptionReminders { productMetadata: formattedSubscription.productMetadata, planConfig: formattedSubscription.planConfig, serviceLastActiveDate: new Date( - subscription.current_period_end * 1000 + subscription.items.data[0].current_period_end * 1000 ), subscriptionSupportUrl: purchase.offering.commonContent.localizations.at(0)?.supportUrl || @@ -314,7 +314,7 @@ export class SubscriptionReminders { if ( await this.alreadySentEmail( uid, - Math.floor(subscription.current_period_start * 1000), + Math.floor(subscription.items.data[0].current_period_start * 1000), emailParams, 'freeTrialEndingReminder' ) @@ -326,8 +326,8 @@ export class SubscriptionReminders { this.log.info('sendFreeTrialEndingReminderEmail', { message: 'Sending a free trial ending reminder email.', subscriptionId: subscription.id, - currentPeriodStart: subscription.current_period_start, - currentPeriodEnd: subscription.current_period_end, + currentPeriodStart: subscription.items.data[0].current_period_start, + currentPeriodEnd: subscription.items.data[0].current_period_end, currentDateMs: Date.now(), }); const { email } = account; @@ -340,6 +340,10 @@ export class SubscriptionReminders { const invoiceDiscountAmountInCents = ( invoicePreview.total_discount_amounts ?? [] ).reduce((sum, discountAmount) => sum + (discountAmount.amount ?? 0), 0); + const invoiceTaxAmountInCents = (invoicePreview.total_taxes ?? []).reduce( + (sum, tax) => sum + tax.amount, + 0 + ); const cmsPageContent = await this.productConfigurationManager.getPageContentByPriceIds( [formattedSubscription.planId], @@ -363,14 +367,14 @@ export class SubscriptionReminders { productMetadata: formattedSubscription.productMetadata, planConfig: formattedSubscription.planConfig, serviceLastActiveDate: new Date( - subscription.current_period_end * 1000 + subscription.items.data[0].current_period_end * 1000 ), invoiceTotalInCents: invoicePreview.total, invoiceSubtotalInCents: invoicePreview.subtotal, invoiceDiscountAmountInCents, - invoiceTaxAmountInCents: invoicePreview.tax ?? 0, + invoiceTaxAmountInCents, invoiceTotalCurrency: invoicePreview.currency, - showTaxAmount: (invoicePreview.tax ?? 0) > 0, + showTaxAmount: invoiceTaxAmountInCents > 0, showDiscount: invoiceDiscountAmountInCents > 0, subscriptionSupportUrl: purchase.offering.commonContent.localizations.at(0)?.supportUrl || @@ -450,7 +454,7 @@ export class SubscriptionReminders { if ( await this.alreadySentEmail( uid, - Math.floor(subscription.current_period_start * 1000), + Math.floor(subscription.items.data[0].current_period_start * 1000), emailParams, 'subscriptionRenewalReminder' ) @@ -474,16 +478,14 @@ export class SubscriptionReminders { if (typeof latestInvoice === 'string') { latestInvoice = await this.stripeHelper.getInvoice(latestInvoice); } - const currentDiscount = - latestInvoice?.discount || latestInvoice?.discounts?.[0]; + const currentDiscount = latestInvoice?.discounts?.[0]; const currentDiscountId = typeof currentDiscount === 'string' ? currentDiscount : (currentDiscount?.id ?? null); // Check upcoming invoice for upcoming discount - const upcomingDiscount = - invoicePreview.discount || invoicePreview.discounts?.[0]; + const upcomingDiscount = invoicePreview.discounts?.[0]; const upcomingDiscountId = upcomingDiscount ? typeof upcomingDiscount === 'string' ? upcomingDiscount @@ -518,8 +520,8 @@ export class SubscriptionReminders { this.log.info('sendSubscriptionRenewalReminderEmail', { message: 'Sending a renewal reminder email.', subscriptionId: subscription.id, - currentPeriodStart: subscription.current_period_start, - currentPeriodEnd: subscription.current_period_end, + currentPeriodStart: subscription.items.data[0].current_period_start, + currentPeriodEnd: subscription.items.data[0].current_period_end, currentDateMs: Date.now(), reminderLength: effectiveReminderDuration.as('days'), }); @@ -542,11 +544,15 @@ export class SubscriptionReminders { reminderLength: effectiveReminderDuration.as('days'), planInterval, // Using invoice prefix instead of plan to accommodate `yarn write-emails`. - showTax: (invoicePreview.total_tax_amounts ?? []).some( - (tax: { inclusive: boolean }) => !tax.inclusive + showTax: (invoicePreview.total_taxes ?? []).some( + (tax) => tax.tax_behavior !== 'inclusive' ), invoiceTotalExcludingTaxInCents: invoicePreview.total_excluding_tax, - invoiceTaxInCents: invoicePreview.tax, + invoiceTaxInCents: + invoicePreview.total_taxes?.reduce( + (sum, tax) => sum + tax.amount, + 0 + ) ?? null, invoiceTotalInCents: invoicePreview.total, invoiceTotalCurrency: invoicePreview.currency, productMetadata: formattedSubscription.productMetadata, diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts index ec11eeb180e..ed84bb87462 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts @@ -195,7 +195,7 @@ export class MozillaSubscriptionHandler { amount = unit_amount ?? (unit_amount_decimal != null - ? Math.round(parseFloat(unit_amount_decimal)) + ? Math.round(parseFloat(unit_amount_decimal.toString())) : null); } } diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts index 3ff83523abe..a28715486bc 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts @@ -338,6 +338,39 @@ describe('PayPalNotificationHandler', () => { ); expect(stripeHelper.payInvoiceOutOfBand).not.toHaveBeenCalled(); }); + + it('reads the basil subscription shape and refunds a canceled subscription', async () => { + const invoice = { + metadata: { + paypalTransactionId: ipnTransactionId, + }, + parent: { + subscription_details: { + subscription: 'sub_id', + }, + }, + }; + const message = validMessage; + stripeHelper.expandResource = jest + .fn() + .mockResolvedValue({ status: 'canceled' }); + + const result = await handler.handleSuccessfulPayment(invoice, message); + + expect(result).toEqual(refundReturn); + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( + invoice, + validMessage.txn_id, + RefundType.Full + ); + expect(stripeHelper.expandResource).toHaveBeenCalledTimes(1); + expect(stripeHelper.expandResource).toHaveBeenCalledWith( + 'sub_id', + SUBSCRIPTIONS_RESOURCE + ); + expect(stripeHelper.payInvoiceOutOfBand).not.toHaveBeenCalled(); + }); }); describe('handleMerchPayment', () => { diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.ts b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.ts index d1e7eb0e8e1..0d6081dbf99 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.ts @@ -8,7 +8,7 @@ import { PayPalBillingAgreements, updatePayPalBA, } from 'fxa-shared/db/models/auth'; -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { ConfigType } from '../../../config'; import { AppError as error } from '@fxa/accounts/errors'; @@ -17,7 +17,11 @@ import { isIpnMerchPmt, RefundType, } from '@fxa/payments/paypal'; -import { StripeHelper, SUBSCRIPTIONS_RESOURCE } from '../../payments/stripe'; +import { + getInvoiceSubscription, + StripeHelper, + SUBSCRIPTIONS_RESOURCE, +} from '../../payments/stripe'; import { reportSentryError } from '../../sentry'; import { AuthLogger, AuthRequest } from '../../types'; import { PayPalHandler } from './paypal'; @@ -53,12 +57,13 @@ export class PayPalNotificationHandler extends PayPalHandler { }); } - if (invoice.subscription) { + const subscriptionRef = getInvoiceSubscription(invoice); + if (subscriptionRef) { const subscription = - typeof invoice.subscription !== 'string' - ? invoice.subscription + typeof subscriptionRef !== 'string' + ? subscriptionRef : await this.stripeHelper.expandResource( - invoice.subscription, + subscriptionRef, SUBSCRIPTIONS_RESOURCE ); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts index 0d0b6672797..5886a811bff 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts @@ -25,6 +25,7 @@ import { INVOICES_RESOURCE, PAYMENT_METHOD_RESOURCE, STRIPE_OBJECT_TYPE_TO_RESOURCE, + getInvoiceSubscription, StripeHelper, SUBSCRIPTION_UPDATE_TYPES, SUBSCRIPTIONS_RESOURCE, @@ -1073,9 +1074,10 @@ export class StripeWebhookHandler extends StripeHandler { email: account.primaryEmail, }, ]; - const subscription = invoice.subscription + const subscriptionRef = getInvoiceSubscription(invoice); + const subscription = subscriptionRef ? await this.stripeHelper.expandResource( - invoice.subscription, + subscriptionRef, SUBSCRIPTIONS_RESOURCE ) : null; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts index c40d4e0956b..8480b4e665b 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts @@ -15,6 +15,7 @@ const { StripeHelper, SUBSCRIPTION_UPDATE_TYPES, CUSTOMER_RESOURCE, + SUBSCRIPTIONS_RESOURCE, } = require('../../payments/stripe'); const moment = require('moment'); const authDbModule = require('fxa-shared/db/models/auth'); @@ -2651,6 +2652,33 @@ describe('StripeWebhookHandler', () => { ) ); + it('reads the subscription from the basil parent shape', async () => { + const invoice = deepCopy(eventInvoicePaid).data.object; + invoice.billing_reason = 'subscription_create'; + delete invoice.subscription; + invoice.parent = { + subscription_details: { subscription: 'sub_basil' }, + }; + + StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.mockResolvedValue( + { uid: '1234', test: 'fake' } + ); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( + { status: 'active' } + ); + StripeWebhookHandlerInstance.db.account = jest.fn(async () => ({ + emails: 'fakeemails', + locale: 'fakelocale', + verifierSetAt: Date.now(), + })); + + await StripeWebhookHandlerInstance.sendSubscriptionInvoiceEmail(invoice); + + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith('sub_basil', SUBSCRIPTIONS_RESOURCE); + }); + it('does not send the first invoice email for a trialing subscription', async () => { const invoice = eventInvoicePaid.data.object; invoice.billing_reason = 'subscription_create'; diff --git a/packages/fxa-auth-server/scripts/audit-orphaned-customers.ts b/packages/fxa-auth-server/scripts/audit-orphaned-customers.ts index 650ecc90266..42d8fd964f8 100644 --- a/packages/fxa-auth-server/scripts/audit-orphaned-customers.ts +++ b/packages/fxa-auth-server/scripts/audit-orphaned-customers.ts @@ -5,6 +5,7 @@ import program from 'commander'; import Stripe from 'stripe'; import Container from 'typedi'; +import { STRIPE_API_VERSION } from '@fxa/payments/stripe'; import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup'; import { promises as fs } from 'fs'; import { AppConfig, AuthLogger } from '../lib/types'; @@ -143,7 +144,7 @@ async function retrieveStripeCustomers( const config = Container.get(AppConfig); const stripe = new Stripe(config.subscriptions.stripeApiKey, { - apiVersion: '2024-11-20.acacia', + apiVersion: STRIPE_API_VERSION, maxNetworkRetries: 3, }); const baseQuery = `created<=${endDate} AND created>=${startDate}`; diff --git a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts index e757583c6b0..9f426f73530 100644 --- a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts +++ b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts @@ -442,9 +442,9 @@ describe('PlanCanceller', () => { let refundCreateStub: jest.Mock; let refundInvoiceStub: jest.Mock; const mockFullRefundInvoice = { - charge: 'ch_123', + payments: { data: [{ payment: { charge: 'ch_123' } }] }, amount_due: 1000, - paid_out_of_band: false, + collection_method: 'charge_automatically', }; beforeEach(() => { @@ -471,7 +471,7 @@ describe('PlanCanceller', () => { it('creates refund', () => { expect(refundCreateStub).toHaveBeenCalledWith({ - charge: mockFullRefundInvoice.charge, + charge: mockFullRefundInvoice.payments.data[0].payment.charge, }); }); @@ -484,7 +484,7 @@ describe('PlanCanceller', () => { describe('PayPal refund', () => { const mockPaypalInvoice = { ...mockFullRefundInvoice, - paid_out_of_band: true, + collection_method: 'send_invoice', }; beforeEach(async () => { @@ -522,7 +522,7 @@ describe('PlanCanceller', () => { it('throws if invoice has no charge', async () => { invoiceRetrieveStub.mockResolvedValue({ ...mockFullRefundInvoice, - charge: null, + payments: { data: [{ payment: { charge: null } }] }, }); await expect( planCanceller.attemptFullRefund(mockSubscription) @@ -538,14 +538,22 @@ describe('PlanCanceller', () => { const now = Math.floor(Date.now() / 1000); const mockProratedSubscription = { ...mockSubscription, - current_period_start: now - 86400 * 2, - current_period_end: now + 86400 * 28, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + current_period_start: now - 86400 * 2, + current_period_end: now + 86400 * 28, + }, + ], + }, }; const mockProratedInvoice = { - charge: 'ch_123', + payments: { data: [{ payment: { charge: 'ch_123' } }] }, amount_due: 10000, - paid: true, - paid_out_of_band: false, + status: 'paid', + collection_method: 'charge_automatically', created: Math.floor(Date.now() / 1000) - 86400, }; @@ -589,7 +597,7 @@ describe('PlanCanceller', () => { const oneDayMs = 1000 * 60 * 60 * 24; const periodEnd = new Date( - mockProratedSubscription.current_period_end * 1000 + mockProratedSubscription.items.data[0].current_period_end * 1000 ); const nowTime = new Date(); const timeRemainingMs = periodEnd.getTime() - nowTime.getTime(); @@ -598,7 +606,7 @@ describe('PlanCanceller', () => { expect(refundCreateStub).toHaveBeenCalledWith( expect.objectContaining({ - charge: mockProratedInvoice.charge, + charge: mockProratedInvoice.payments.data[0].payment.charge, amount: expectedRefund, }) ); @@ -608,7 +616,7 @@ describe('PlanCanceller', () => { describe('PayPal refund - partial', () => { const mockPaypalInvoice = { ...mockProratedInvoice, - paid_out_of_band: true, + collection_method: 'send_invoice', }; beforeEach(async () => { @@ -621,7 +629,7 @@ describe('PlanCanceller', () => { it('calls PayPal refund with partial amount', () => { const oneDayMs = 1000 * 60 * 60 * 24; const periodEnd = new Date( - mockProratedSubscription.current_period_end * 1000 + mockProratedSubscription.items.data[0].current_period_end * 1000 ); const nowTime = new Date(); const timeRemainingMs = periodEnd.getTime() - nowTime.getTime(); @@ -662,7 +670,7 @@ describe('PlanCanceller', () => { it('throws if invoice is not paid', async () => { invoiceRetrieveStub.mockResolvedValue({ ...mockProratedInvoice, - paid: false, + status: 'open', }); await expect( planCanceller.attemptProratedRefund(mockProratedSubscription as any) @@ -683,7 +691,7 @@ describe('PlanCanceller', () => { it('throws if invoice has no charge for Stripe refund', async () => { invoiceRetrieveStub.mockResolvedValue({ ...mockProratedInvoice, - charge: null, + payments: { data: [{ payment: { charge: null } }] }, }); await expect( planCanceller.attemptProratedRefund(mockProratedSubscription as any) diff --git a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts index ea0b0db27d4..2d6c8125145 100644 --- a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts +++ b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { writeFile } from 'fs/promises'; import PQueue from 'p-queue'; @@ -223,7 +223,7 @@ export class PlanCanceller { this.stripe.invoices.retrieve(latestInvoiceId) ); - if (invoice.paid_out_of_band) { + if (invoice.collection_method === 'send_invoice') { console.log(`Issuing full Paypal refund for ${invoice.id}`); if (this.dryRun) { console.log('(dry run mode, no refund issued)'); @@ -231,10 +231,8 @@ export class PlanCanceller { await this.paypalHelper.refundInvoice(invoice); } } else { - const chargeId = - typeof invoice.charge === 'string' - ? invoice.charge - : invoice.charge?.id; + const charge = invoice.payments?.data[0]?.payment.charge; + const chargeId = typeof charge === 'string' ? charge : charge?.id; if (!chargeId) { throw new Error(`No charge for ${invoice.id}`); } @@ -277,14 +275,18 @@ export class PlanCanceller { this.stripe.invoices.retrieve(latestInvoiceId) ); - if (!invoice.paid) { + if (invoice.status !== 'paid') { throw new Error("Customer is pending renewal right now!"); } const oneDayMs = 1000 * 60 * 60 * 24; - const periodStart = new Date(subscription.current_period_start * 1000); - const periodEnd = new Date(subscription.current_period_end * 1000); + const periodStart = new Date( + subscription.items.data[0].current_period_start * 1000 + ); + const periodEnd = new Date( + subscription.items.data[0].current_period_end * 1000 + ); const now = new Date(); const timeElapsedMs = now.getTime() - periodStart.getTime(); @@ -311,7 +313,7 @@ export class PlanCanceller { throw new Error(`Will not refund ${invoice.id} for ${refundAmount} as it is less than or equal to zero`); } - if (invoice.paid_out_of_band) { + if (invoice.collection_method === 'send_invoice') { const behavior = refundAmount === invoice.amount_due ? { refundType: RefundType.Full } as const : { @@ -326,8 +328,8 @@ export class PlanCanceller { await this.paypalHelper.refundInvoice(invoice, behavior); } } else { - const chargeId = - typeof invoice.charge === 'string' ? invoice.charge : invoice.charge?.id; + const charge = invoice.payments?.data[0]?.payment.charge; + const chargeId = typeof charge === 'string' ? charge : charge?.id; if (!chargeId) { throw new Error(`No charge for ${invoice.id}`); } diff --git a/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts index 13c5035fd2c..3223e189bd9 100644 --- a/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts +++ b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts @@ -474,8 +474,10 @@ describe('FirestoreStripeSyncChecker', () => { it('returns false when status differs', () => { const firestoreSubscription = { status: 'canceled', - current_period_end: mockSubscription.current_period_end, - current_period_start: mockSubscription.current_period_start, + current_period_end: + mockSubscription.items.data[0].current_period_end, + current_period_start: + mockSubscription.items.data[0].current_period_start, }; const result = syncChecker.isSubscriptionInSync( @@ -489,7 +491,8 @@ describe('FirestoreStripeSyncChecker', () => { const firestoreSubscription = { status: mockSubscription.status, current_period_end: 999999, - current_period_start: mockSubscription.current_period_start, + current_period_start: + mockSubscription.items.data[0].current_period_start, }; const result = syncChecker.isSubscriptionInSync( diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts index 3fa897fe55f..554c39c2442 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts @@ -570,12 +570,12 @@ describe('StripeAutomaticTaxConverter', () => { }); describe('fetchInvoicePreview', () => { - let result: Stripe.Response; + let result: Stripe.Response; let stub: jest.Mock; beforeEach(async () => { stub = jest.fn().mockResolvedValue(mockInvoicePreview); - stripeStub.invoices.retrieveUpcoming = stub as any; + stripeStub.invoices.createPreview = stub as any; result = await stripeAutomaticTaxConverter.fetchInvoicePreview( mockSubscription.id @@ -585,7 +585,7 @@ describe('StripeAutomaticTaxConverter', () => { it('calls stripe for the invoice preview', () => { expect(stub).toHaveBeenCalledWith({ subscription: mockSubscription.id, - expand: ['total_tax_amounts.tax_rate'], + expand: ['total_taxes.tax_rate_details.tax_rate'], }); }); @@ -609,6 +609,7 @@ describe('StripeAutomaticTaxConverter', () => { const _mockInvoicePreview = { ...mockInvoicePreview, total_excluding_tax: 10, + total_taxes: [{ amount: 371 }], }; const result = stripeAutomaticTaxConverter.buildReport( @@ -630,14 +631,14 @@ describe('StripeAutomaticTaxConverter', () => { mockPlan.interval_count, mockPlan.interval, (_mockInvoicePreview as any).total_excluding_tax, - (_mockInvoicePreview as any).tax, + 371, mockSpecialTaxAmounts.hst, mockSpecialTaxAmounts.gst, mockSpecialTaxAmounts.pst, mockSpecialTaxAmounts.qst, mockSpecialTaxAmounts.rst, (_mockInvoicePreview as any).total, - mockSubscription.current_period_end, + mockSubscription.items.data[0].current_period_end, `"${mockAccount.locale}"`, ]); }); diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.ts index ee3a0dde27f..82e68ec6381 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { Firestore } from '@google-cloud/firestore'; import Container from 'typedi'; import fs from 'fs'; @@ -302,9 +302,9 @@ export class StripeAutomaticTaxConverter { */ async fetchInvoicePreview(subscriptionId: string) { return this.enqueueRequest(() => - this.stripe.invoices.retrieveUpcoming({ + this.stripe.invoices.createPreview({ subscription: subscriptionId, - expand: ['total_tax_amounts.tax_rate'], + expand: ['total_taxes.tax_rate_details.tax_rate'], }) ); } @@ -320,10 +320,10 @@ export class StripeAutomaticTaxConverter { subscription: Stripe.Subscription, product: Stripe.Product, plan: Stripe.Plan, - invoicePreview: Stripe.UpcomingInvoice + invoicePreview: Stripe.Invoice ) { const { hst, pst, gst, qst, rst } = this.helpers.getSpecialTaxAmounts( - invoicePreview.total_tax_amounts + invoicePreview.total_taxes ?? [] ); // We build a temporary object first for readability & maintainability purposes @@ -339,7 +339,10 @@ export class StripeAutomaticTaxConverter { planIntervalUnit: plan.interval, baseAmount: invoicePreview.total_excluding_tax, - taxAmount: invoicePreview.tax, + taxAmount: (invoicePreview.total_taxes ?? []).reduce( + (sum, tax) => sum + tax.amount, + 0 + ), hst, gst, pst, @@ -347,7 +350,7 @@ export class StripeAutomaticTaxConverter { rst, totalAmount: invoicePreview.total, - nextInvoice: subscription.current_period_end, + nextInvoice: subscription.items.data[0].current_period_end, locale: account.locale, }; diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts index e031e6cfb07..9c6e5f7b788 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts @@ -300,7 +300,7 @@ describe('StripeAutomaticTaxConverterHelpers', () => { it('returns true for yearly when more than 30 days out', () => { const periodEnd = new Date(fakeToday); periodEnd.setUTCDate(periodEnd.getUTCDate() + 31); - yearlySub.current_period_end = periodEnd.getTime() / 1000; + yearlySub.items.data[0].current_period_end = periodEnd.getTime() / 1000; const result = helpers.isWithinNoticePeriod(yearlySub); @@ -310,7 +310,7 @@ describe('StripeAutomaticTaxConverterHelpers', () => { it('returns false for yearly when less than 30 days out', () => { const periodEnd = new Date(fakeToday); periodEnd.setUTCDate(periodEnd.getUTCDate() + 29); - yearlySub.current_period_end = periodEnd.getTime() / 1000; + yearlySub.items.data[0].current_period_end = periodEnd.getTime() / 1000; const result = helpers.isWithinNoticePeriod(yearlySub); @@ -320,7 +320,7 @@ describe('StripeAutomaticTaxConverterHelpers', () => { it('returns true for monthly when more than 14 days out', () => { const periodEnd = new Date(fakeToday); periodEnd.setUTCDate(periodEnd.getUTCDate() + 15); - monthlySub.current_period_end = periodEnd.getTime() / 1000; + monthlySub.items.data[0].current_period_end = periodEnd.getTime() / 1000; const result = helpers.isWithinNoticePeriod(monthlySub); @@ -330,7 +330,7 @@ describe('StripeAutomaticTaxConverterHelpers', () => { it('returns false for monthly when less than 14 days out', () => { const periodEnd = new Date(fakeToday); periodEnd.setUTCDate(periodEnd.getUTCDate() + 13); - monthlySub.current_period_end = periodEnd.getTime() / 1000; + monthlySub.items.data[0].current_period_end = periodEnd.getTime() / 1000; const result = helpers.isWithinNoticePeriod(monthlySub); @@ -342,11 +342,12 @@ describe('StripeAutomaticTaxConverterHelpers', () => { const getMockTaxAmount = (amount: number, display_name: string) => ({ amount, - inclusive: false, - tax_rate: { - display_name, + tax_rate_details: { + tax_rate: { + display_name, + }, }, - }) as Stripe.Invoice.TotalTaxAmount; + }) as unknown as Stripe.Invoice.TotalTax; const mockTaxAmounts = [ getMockTaxAmount(10, 'HST'), diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.ts index 14bd8b3bbe4..3f08c9af1f7 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; /** * Minimum number of days before monthly plan renewal to be upgraded to Stripe automatic tax @@ -157,7 +157,7 @@ export class StripeAutomaticTaxConverterHelpers { } const renewalDate = new Date( - sub.current_period_end * SECONDS_TO_MILLISECONDS + sub.items.data[0].current_period_end * SECONDS_TO_MILLISECONDS ); return noSoonerThan < renewalDate; @@ -169,7 +169,7 @@ export class StripeAutomaticTaxConverterHelpers { * @param taxAmounts A list of tax amounts with tax rates expanded * @returns The amount of the first matching tax amount */ - getSpecialTaxAmounts(taxAmounts: Stripe.Invoice.TotalTaxAmount[]) { + getSpecialTaxAmounts(taxAmounts: Stripe.Invoice.TotalTax[]) { const specialTaxAmounts = { hst: 0, pst: 0, @@ -179,7 +179,8 @@ export class StripeAutomaticTaxConverterHelpers { }; for (const taxAmount of taxAmounts) { - const taxRate = taxAmount.tax_rate as Stripe.TaxRate; + const taxRate = taxAmount.tax_rate_details + ?.tax_rate as unknown as Stripe.TaxRate; const key = taxRate.display_name.toLowerCase(); if (key in specialTaxAmounts) { diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts index db4cb32b9e7..972178ed234 100644 --- a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts @@ -785,18 +785,26 @@ describe('CustomerPlanMover v2', () => { ...mockSubscription, id: 'sub_123', latest_invoice: 'inv_123', - current_period_start: now - 86400 * 25, - current_period_end: now + 86400 * 10, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + current_period_start: now - 86400 * 25, + current_period_end: now + 86400 * 10, + }, + ], + }, } as Stripe.Subscription; const mockPaidInvoice = { ...mockInvoice, id: 'inv_123', - paid: true, + status: 'paid', amount_due: 2000, created: Math.floor(Date.now() / 1000) - 86400 * 5, - charge: 'ch_123', - paid_out_of_band: false, + payments: { data: [{ payment: { charge: 'ch_123' } }] }, + collection_method: 'charge_automatically', } as unknown as Stripe.Invoice; let enqueueRequestStub: jest.Mock; @@ -853,7 +861,7 @@ describe('CustomerPlanMover v2', () => { beforeEach(async () => { const now = new Date().getTime(); const nextBillAt = new Date( - mockSubscriptionWithInvoice.current_period_end * 1000 + mockSubscriptionWithInvoice.items.data[0].current_period_end * 1000 ); const timeUntilBillMs = nextBillAt.getTime() - now; const daysUntilBill = Math.floor( @@ -864,7 +872,7 @@ describe('CustomerPlanMover v2', () => { mockPayPalInvoice = { ...mockPaidInvoice, amount_due: calculatedRefundAmount, - paid_out_of_band: true, + collection_method: 'send_invoice', } as unknown as Stripe.Invoice; enqueueRequestStub.mockResolvedValue(mockPayPalInvoice); @@ -889,7 +897,7 @@ describe('CustomerPlanMover v2', () => { beforeEach(async () => { const now = new Date().getTime(); const nextBillAt = new Date( - mockSubscriptionWithInvoice.current_period_end * 1000 + mockSubscriptionWithInvoice.items.data[0].current_period_end * 1000 ); const timeUntilBillMs = nextBillAt.getTime() - now; const daysUntilBill = Math.floor( @@ -900,7 +908,7 @@ describe('CustomerPlanMover v2', () => { mockPayPalInvoice = { ...mockPaidInvoice, amount_due: calculatedRefundAmount * 2, - paid_out_of_band: true, + collection_method: 'send_invoice', } as unknown as Stripe.Invoice; enqueueRequestStub.mockResolvedValue(mockPayPalInvoice); @@ -969,7 +977,7 @@ describe('CustomerPlanMover v2', () => { it('throws if invoice is not paid', async () => { const unpaidInvoice = { ...mockPaidInvoice, - paid: false, + status: 'open', } as Stripe.Invoice; enqueueRequestStub.mockResolvedValue(unpaidInvoice); @@ -994,7 +1002,7 @@ describe('CustomerPlanMover v2', () => { it('throws if invoice has no charge for Stripe refund', async () => { const invoiceNoCharge = { ...mockPaidInvoice, - charge: null, + payments: { data: [{ payment: { charge: null } }] }, } as unknown as Stripe.Invoice; enqueueRequestStub.mockResolvedValue(invoiceNoCharge); diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.ts index 4230e7b1973..8ab0a0d9831 100644 --- a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.ts +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { writeFile } from 'fs/promises'; import PQueue from 'p-queue'; import { PayPalHelper } from '../../lib/payments/paypal'; @@ -239,17 +239,21 @@ export class CustomerPlanMover { this.stripe.invoices.retrieve(latestInvoiceId) ); - if (!invoice.paid) { + if (invoice.status !== 'paid') { throw new Error("Customer is pending renewal right now!"); } const oneDayMs = 1000 * 60 * 60 * 24; - const lastBilledAt = new Date(subscription.current_period_start * 1000); + const lastBilledAt = new Date( + subscription.items.data[0].current_period_start * 1000 + ); const timeSinceBillMs = new Date().getTime() - lastBilledAt.getTime(); const daysSinceBill = Math.floor(timeSinceBillMs / oneDayMs); - const nextBillAt = new Date(subscription.current_period_end * 1000); + const nextBillAt = new Date( + subscription.items.data[0].current_period_end * 1000 + ); const timeUntilBillMs = nextBillAt.getTime() - new Date().getTime(); const daysUntilBill = Math.floor(timeUntilBillMs / oneDayMs); @@ -275,7 +279,7 @@ export class CustomerPlanMover { throw new Error(`Will not refund ${invoice.id} for ${refundAmount} as it is less than or equal to zero`); } - if (invoice.paid_out_of_band) { + if (invoice.collection_method === 'send_invoice') { const behavior = refundAmount === invoice.amount_due ? { refundType: RefundType.Full } as const : { @@ -290,8 +294,8 @@ export class CustomerPlanMover { await this.paypalHelper.refundInvoice(invoice, behavior); } } else { - const chargeId = - typeof invoice.charge === 'string' ? invoice.charge : invoice.charge?.id; + const charge = invoice.payments?.data[0]?.payment.charge; + const chargeId = typeof charge === 'string' ? charge : charge?.id; if (!chargeId) { throw new Error(`No charge for ${invoice.id}`); } diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.ts index a73757539eb..a662efb1be6 100644 --- a/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.ts +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { Firestore } from '@google-cloud/firestore'; import Container from 'typedi'; import fs from 'fs'; @@ -198,8 +198,8 @@ export class CustomerPlanMover { this.stripe.invoices.retrieve(latestInvoiceId) ); - const chargeId = - typeof invoice.charge === 'string' ? invoice.charge : invoice.charge?.id; + const charge = invoice.payments?.data[0]?.payment.charge; + const chargeId = typeof charge === 'string' ? charge : charge?.id; if (!chargeId) { console.log(`No charge for ${invoice.id}`); return; diff --git a/packages/fxa-auth-server/scripts/paypal-refund-fixer.ts b/packages/fxa-auth-server/scripts/paypal-refund-fixer.ts index fed2a056504..126b7437029 100644 --- a/packages/fxa-auth-server/scripts/paypal-refund-fixer.ts +++ b/packages/fxa-auth-server/scripts/paypal-refund-fixer.ts @@ -4,7 +4,7 @@ import { AuthLogger } from '../lib/types'; import { ACTIVE_SUBSCRIPTION_STATUSES } from 'fxa-shared/subscriptions/stripe'; import { StatsD } from 'hot-shots'; -import stripe from 'stripe'; +import { Stripe } from 'stripe'; import Container from 'typedi'; import { CurrencyHelper } from '../lib/payments/currencies'; @@ -16,18 +16,18 @@ import { configureSentry } from '../lib/sentry'; const config = require('../config').default.getProperties(); class PayPalFixer { - private stripe: stripe; + private stripe: Stripe; constructor( private log: AuthLogger, private stripeHelper: StripeHelper, private paypalHelper: PayPalHelper ) { - this.stripe = (this.stripeHelper as any).stripe as stripe; + this.stripe = (this.stripeHelper as any).stripe as Stripe; } private activeSubscriptions( - subscriptions: stripe.Subscription[] | undefined + subscriptions: Stripe.Subscription[] | undefined ) { if (!subscriptions) { return []; @@ -63,7 +63,7 @@ class PayPalFixer { } } - async zeroAccountBalance(customer: stripe.Customer) { + async zeroAccountBalance(customer: Stripe.Customer) { if (customer.balance >= 0) { return; } @@ -75,7 +75,7 @@ class PayPalFixer { }); } - async issueRefund(invoice: stripe.Invoice) { + async issueRefund(invoice: Stripe.Invoice) { const transactionId = this.stripeHelper.getInvoicePaypalTransactionId(invoice); if (!transactionId) { @@ -127,7 +127,7 @@ class PayPalFixer { } if (subs.length === 1) { const sub = subs[0]; - if (sub.id === invoice.subscription) { + if (sub.id === invoice.parent?.subscription_details?.subscription) { await this.stripeHelper.cancelSubscription(sub.id); } else { this.log.info('Skipping cancellation of unrelated subscription.', {}); diff --git a/packages/fxa-auth-server/scripts/refund-unverified-accounts.ts b/packages/fxa-auth-server/scripts/refund-unverified-accounts.ts index e2ee08c9ff7..7277478b47b 100644 --- a/packages/fxa-auth-server/scripts/refund-unverified-accounts.ts +++ b/packages/fxa-auth-server/scripts/refund-unverified-accounts.ts @@ -5,6 +5,7 @@ import program from 'commander'; import { StripeHelper } from '../lib/payments/stripe'; import Stripe from 'stripe'; import Container from 'typedi'; +import { STRIPE_API_VERSION } from '@fxa/payments/stripe'; import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup'; import { AppConfig } from '../lib/types'; @@ -150,7 +151,7 @@ async function retrieveSubscriptionsByCreatedStripe( ): Promise { const config = Container.get(AppConfig); const stripe = new Stripe(config.subscriptions.stripeApiKey, { - apiVersion: '2024-11-20.acacia', + apiVersion: STRIPE_API_VERSION, maxNetworkRetries: 3, }); const baseQuery = `created<=${endDate} AND created>=${startDate}`; @@ -174,7 +175,8 @@ async function retrieveSubscriptionsByCreatedStripe( : undefined; const invoicePaymentIntentId = typeof sub.latest_invoice !== 'string' - ? ((sub.latest_invoice as Stripe.Invoice)?.payment_intent as string) + ? ((sub.latest_invoice as Stripe.Invoice)?.payments?.data[0]?.payment + .payment_intent as string) : undefined; const output: SubscriptionData = { subscription: sub, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_draft_proration_refund.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_draft_proration_refund.json index 343f8560ba3..6cf1172769b 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_draft_proration_refund.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_draft_proration_refund.json @@ -11,7 +11,6 @@ "attempted": true, "auto_advance": false, "billing_reason": "subscription_create", - "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR", "collection_method": "charge_automatically", "created": 1585088620, "currency": "usd", @@ -28,7 +27,6 @@ "default_source": null, "default_tax_rates": [], "description": null, - "discount": null, "due_date": null, "ending_balance": 0, "footer": null, @@ -54,73 +52,35 @@ "end": 1669314089, "start": 1666635762 }, - "plan": { - "id": "price_1LwVGQBVqmGyQTMavfvTYMbD", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 700, - "amount_decimal": "700", - "billing_scheme": "per_unit", - "created": 1666635642, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - "productOrder": "3" - }, - "nickname": null, - "product": "prod_GvH2k78kKusAlV", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "price_1LwVGQBVqmGyQTMavfvTYMbD", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1666635642, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - "productOrder": "3" - }, - "nickname": null, - "product": "prod_GvH2k78kKusAlV", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "quantity": 1, + "tax_rates": [], + "unit_amount_excluding_tax": "-700", + "pricing": { + "price_details": { + "price": "price_1LwVGQBVqmGyQTMavfvTYMbD", + "product": "prod_GvH2k78kKusAlV" }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 700, + "type": "price_details", "unit_amount_decimal": "700" }, - "proration": true, - "proration_details": { - "credited_items": { - "invoice": "in_1LwVHCBVqmGyQTMaOQWEFgJ9", - "invoice_line_items": ["il_1LwVHCBVqmGyQTMaiyzoPbP4"] - } + "parent": { + "type": "invoice_item_details", + "invoice_item_details": { + "invoice_item": "il_1LwVINBVqmGyQTMak09RiXq3", + "proration": true, + "proration_details": { + "credited_items": { + "invoice": "in_1LwVHCBVqmGyQTMaOQWEFgJ9", + "invoice_line_items": [ + "il_1LwVHCBVqmGyQTMaiyzoPbP4" + ] + } + }, + "subscription": "sub_1LwVHBBVqmGyQTMaC5dRSQ36" + }, + "subscription_item_details": null }, - "quantity": 1, - "subscription": "sub_1LwVHBBVqmGyQTMaC5dRSQ36", - "subscription_item": "si_MfqzhUGwwRvTxw", - "tax_amounts": [], - "tax_rates": [], - "type": "invoiceitem", - "unit_amount_excluding_tax": "-700" + "taxes": [] }, { "id": "il_1LwVINBVqmGyQTMaBfOgfMvs", @@ -139,74 +99,30 @@ "end": 1669314089, "start": 1666635762 }, - "plan": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 1200, - "amount_decimal": "1200", - "billing_scheme": "per_unit", - "created": 1598305143, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - "appStoreProductIds": "skydiving.with.foxkeh", - "productOrder": "5", - "productSet": "foxkeh" - }, - "nickname": null, - "product": "prod_GvH2k78kKusAlV", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 15, - "usage_type": "licensed" - }, - "price": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1598305143, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - "appStoreProductIds": "skydiving.with.foxkeh", - "productOrder": "5", - "productSet": "foxkeh" - }, - "nickname": null, - "product": "prod_GvH2k78kKusAlV", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": 15, - "usage_type": "licensed" + "quantity": 1, + "tax_rates": [], + "unit_amount_excluding_tax": "1200", + "pricing": { + "price_details": { + "price": "plan_GqM9N6qyhvxaVk", + "product": "prod_GvH2k78kKusAlV" }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 1200, + "type": "price_details", "unit_amount_decimal": "1200" }, - "proration": true, - "proration_details": { - "credited_items": null + "parent": { + "type": "invoice_item_details", + "invoice_item_details": { + "invoice_item": "il_1LwVINBVqmGyQTMaBfOgfMvs", + "proration": true, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1LwVHBBVqmGyQTMaC5dRSQ36" + }, + "subscription_item_details": null }, - "quantity": 1, - "subscription": "sub_1LwVHBBVqmGyQTMaC5dRSQ36", - "subscription_item": "si_MfqzhUGwwRvTxw", - "tax_amounts": [], - "tax_rates": [], - "type": "invoiceitem", - "unit_amount_excluding_tax": "1200" + "taxes": [] } ], "has_more": false, @@ -217,8 +133,6 @@ "metadata": {}, "next_payment_attempt": 1585088620, "number": "AAF2CECC-0001", - "paid": false, - "payment_intent": null, "period_end": 1585088620, "period_start": 1585088620, "post_payment_credit_notes_amount": 0, @@ -233,11 +147,30 @@ "paid_at": null, "voided_at": null }, - "subscription": "sub_GyHjvuW3xOeaZS", "subtotal": 500, - "tax": null, "tax_percent": null, "total": 500, - "total_tax_amounts": [], - "webhooks_delivered_at": null + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_GyHjvuW3xOeaZS" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR" + }, + "status": "paid" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "discounts": [], + "total_taxes": [] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_open.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_open.json index ab0d2483d6c..17312559fa7 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_open.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_open.json @@ -11,7 +11,6 @@ "attempted": true, "auto_advance": false, "billing_reason": "subscription_create", - "charge": "ch_1EPZyOJNcmPzuWtRKcc9Fyos", "collection_method": "charge_automatically", "created": 1555354567, "currency": "usd", @@ -28,7 +27,6 @@ "default_source": null, "default_tax_rates": [], "description": null, - "discount": null, "due_date": null, "ending_balance": 0, "footer": null, @@ -41,7 +39,7 @@ "object": "line_item", "amount": 499, "currency": "usd", - "description": "1 × Firefox Private Network (at $4.99 / month)", + "description": "1 \u00d7 Firefox Private Network (at $4.99 / month)", "discountable": true, "livemode": false, "metadata": {}, @@ -49,66 +47,27 @@ "end": 1584220861, "start": 1581715261 }, - "plan": { - "id": "plan_FxFGE9jvhYvCMe", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 499, - "amount_decimal": "499", - "billing_scheme": "per_unit", - "created": 1570549157, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - "upgrades": "plan_FiII5wajzrxfrl" + "quantity": 1, + "tax_rates": [], + "pricing": { + "price_details": { + "price": "plan_FxFGE9jvhYvCMe", + "product": "prod_GvH2k78kKusAlV" }, - "nickname": "Monthly", - "product": "prod_FxFGtsTRZ8NHbi", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" + "type": "price_details", + "unit_amount_decimal": "499" }, - "price": { - "id": "plan_FxFGE9jvhYvCMe", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1570549157, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - "upgrades": "plan_FiII5wajzrxfrl" - }, - "nickname": null, - "product": "prod_GvH2k78kKusAlV", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_GY2uVWIDdywa3l", + "subscription_item": "si_GY2u5UxXBWUS22", + "proration": false, + "proration_details": null }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 499, - "unit_amount_decimal": "499" + "invoice_item_details": null }, - "proration": false, - "quantity": 1, - "subscription": "sub_GY2uVWIDdywa3l", - "subscription_item": "si_GY2u5UxXBWUS22", - "tax_amounts": [], - "tax_rates": [], - "type": "subscription" + "taxes": [] } ], "has_more": false, @@ -119,8 +78,6 @@ "metadata": {}, "next_payment_attempt": null, "number": "3B74E3D0-0001", - "paid": true, - "payment_intent": "pi_1EPZyNJNcmPzuWtR9U3SsJ4w", "period_end": 1555354567, "period_start": 1555354567, "post_payment_credit_notes_amount": 0, @@ -135,11 +92,31 @@ "paid_at": 1555354568, "voided_at": null }, - "subscription": "sub_EtMh0Ty6MEtjdv", "subtotal": 1000, - "tax": null, "tax_percent": null, "total": 1000, - "total_tax_amounts": [], - "webhooks_delivered_at": 1555354569 + "webhooks_delivered_at": 1555354569, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_EtMh0Ty6MEtjdv" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": "pi_1EPZyNJNcmPzuWtR9U3SsJ4w", + "charge": "ch_1EPZyOJNcmPzuWtRKcc9Fyos" + }, + "status": "open" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "discounts": [], + "total_taxes": [] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create.json index e654d2daccc..3977dc08cae 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create.json @@ -11,7 +11,6 @@ "attempted": true, "auto_advance": false, "billing_reason": "subscription_create", - "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR", "collection_method": "charge_automatically", "created": 1585088620, "currency": "usd", @@ -28,7 +27,6 @@ "default_source": null, "default_tax_rates": [], "description": null, - "discount": null, "due_date": null, "ending_balance": 0, "footer": null, @@ -42,7 +40,7 @@ "object": "line_item", "amount": 500, "currency": "usd", - "description": "1 × 123Done Pro (at $5.00 / month)", + "description": "1 \u00d7 123Done Pro (at $5.00 / month)", "discountable": true, "livemode": false, "metadata": {}, @@ -50,62 +48,27 @@ "end": 1587767020, "start": 1585088620 }, - "plan": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 500, - "amount_decimal": "500", - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": {}, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "quantity": 1, + "tax_rates": [], + "pricing": { + "price_details": { + "price": "plan_GqM9N6qyhvxaVk", + "product": "prod_GqM9ToKK62qjkK" }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 500, + "type": "price_details", "unit_amount_decimal": "500" }, - "proration": false, - "quantity": 1, - "subscription": "sub_GyHjvuW3xOeaZS", - "subscription_item": "si_GyHjBYq7x2obek", - "tax_amounts": [], - "tax_rates": [], - "type": "subscription" + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_GyHjvuW3xOeaZS", + "subscription_item": "si_GyHjBYq7x2obek", + "proration": false, + "proration_details": null + }, + "invoice_item_details": null + }, + "taxes": [] } ], "has_more": false, @@ -116,8 +79,6 @@ "metadata": {}, "next_payment_attempt": null, "number": "AAF2CECC-0001", - "paid": true, - "payment_intent": "pi_1GQL9sBVqmGyQTMaSnFPZJlx", "period_end": 1585088620, "period_start": 1585088620, "post_payment_credit_notes_amount": 0, @@ -132,11 +93,31 @@ "paid_at": 1585088621, "voided_at": null }, - "subscription": "sub_GyHjvuW3xOeaZS", "subtotal": 500, - "tax": null, "tax_percent": null, "total": 500, - "total_tax_amounts": [], - "webhooks_delivered_at": null + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_GyHjvuW3xOeaZS" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": "pi_1GQL9sBVqmGyQTMaSnFPZJlx", + "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR" + }, + "status": "paid" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "discounts": [], + "total_taxes": [] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_discount.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_discount.json index 2115a028fd2..80b6f4e1732 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_discount.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_discount.json @@ -16,7 +16,6 @@ "status": null }, "billing_reason": "subscription_create", - "charge": "ch_3KEJVVBVqmGyQTMa1jHz30rE", "collection_method": "charge_automatically", "created": 1585088620, "currency": "usd", @@ -33,36 +32,40 @@ "default_source": null, "default_tax_rates": [], "description": null, - "discount": { - "id": "di_1KEJVVBVqmGyQTMay9usx63n", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "jeVw0xYj", - "object": "coupon", - "amount_off": null, - "created": 1640198196, - "currency": null, - "duration": "forever", - "duration_in_months": null, - "livemode": false, - "max_redemptions": null, - "metadata": {}, - "name": "Percentage small", - "percent_off": 10, - "redeem_by": null, - "times_redeemed": 1, - "valid": true - }, - "customer": "cus_Ku7kUUAPxDG3WU", - "end": null, - "invoice": null, - "invoice_item": null, - "promotion_code": null, - "start": 1585088620, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU" - }, - "discounts": ["di_1KEJVVBVqmGyQTMay9usx63n"], + "discounts": [ + { + "id": "di_1KEJVVBVqmGyQTMay9usx63n", + "object": "discount", + "checkout_session": null, + "customer": "cus_Ku7kUUAPxDG3WU", + "end": null, + "invoice": null, + "invoice_item": null, + "promotion_code": null, + "start": 1585088620, + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", + "source": { + "coupon": { + "id": "jeVw0xYj", + "object": "coupon", + "amount_off": null, + "created": 1640198196, + "currency": null, + "duration": "forever", + "duration_in_months": null, + "livemode": false, + "max_redemptions": null, + "metadata": {}, + "name": "Percentage small", + "percent_off": 10, + "redeem_by": null, + "times_redeemed": 1, + "valid": true + }, + "type": "coupon" + } + } + ], "due_date": null, "ending_balance": 0, "footer": null, @@ -77,7 +80,7 @@ "object": "line_item", "amount": 500, "currency": "usd", - "description": "1 × 123Done Pro (at $5.00 / month)", + "description": "1 \u00d7 123Done Pro (at $5.00 / month)", "discount_amounts": [ { "amount": 50, @@ -92,65 +95,27 @@ "end": 1587767020, "start": 1585088620 }, - "plan": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 500, - "amount_decimal": "500", - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - "promotionCodes": "FRIEND20,ASJQLIUQ,FRIENDS10,FRIENDS1,FRIENDS15,ANOTHER" + "quantity": 1, + "tax_rates": [], + "pricing": { + "price_details": { + "price": "plan_GqM9N6qyhvxaVk", + "product": "prod_GqM9ToKK62qjkK" }, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" + "type": "price_details", + "unit_amount_decimal": "500" }, - "price": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "livemode": false, - "lookup_key": null, - "metadata": { - "promotionCodes": "FRIEND20,ASJQLIUQ,FRIENDS10,FRIENDS1,FRIENDS15,ANOTHER" - }, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", + "subscription_item": "si_Ku7lBbLQOjx5ry", + "proration": false, + "proration_details": null }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 500, - "unit_amount_decimal": "500" + "invoice_item_details": null }, - "proration": false, - "quantity": 1, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", - "subscription_item": "si_Ku7lBbLQOjx5ry", - "tax_amounts": [], - "tax_rates": [], - "type": "subscription" + "taxes": [] } ], "has_more": false, @@ -162,8 +127,6 @@ "next_payment_attempt": null, "number": "3432720C-0001", "on_behalf_of": null, - "paid": true, - "payment_intent": "pi_3KEJVVBVqmGyQTMa1MfskC2I", "payment_settings": { "payment_method_options": null, "payment_method_types": null @@ -183,9 +146,7 @@ "paid_at": 1585088620, "voided_at": null }, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", "subtotal": 500, - "tax": null, "tax_percent": null, "total": 450, "total_discount_amounts": [ @@ -194,7 +155,28 @@ "discount": "di_1KEJVVBVqmGyQTMay9usx63n" } ], - "total_tax_amounts": [], "transfer_data": null, - "webhooks_delivered_at": null + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": "pi_3KEJVVBVqmGyQTMa1MfskC2I", + "charge": "ch_3KEJVVBVqmGyQTMa1jHz30rE" + }, + "status": "paid" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "total_taxes": [] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax.json index 1d40614bfd5..3e8ff54500d 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax.json @@ -11,7 +11,6 @@ "attempted": true, "auto_advance": false, "billing_reason": "subscription_create", - "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR", "collection_method": "charge_automatically", "created": 1585088620, "currency": "usd", @@ -45,7 +44,6 @@ } ], "description": null, - "discount": null, "due_date": null, "ending_balance": 0, "footer": null, @@ -60,7 +58,7 @@ "amount": 500, "amount_excluding_tax": 446, "currency": "usd", - "description": "1 × 123Done Pro (at $5.00 / month)", + "description": "1 \u00d7 123Done Pro (at $5.00 / month)", "discountable": true, "livemode": false, "metadata": {}, @@ -68,69 +66,36 @@ "end": 1587767020, "start": 1585088620 }, - "plan": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 500, - "amount_decimal": "500", - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": {}, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "quantity": 1, + "tax_rates": [], + "unit_amount_excluding_tax": "446", + "pricing": { + "price_details": { + "price": "plan_GqM9N6qyhvxaVk", + "product": "prod_GqM9ToKK62qjkK" }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 500, + "type": "price_details", "unit_amount_decimal": "500" }, - "proration": false, - "quantity": 1, - "subscription": "sub_GyHjvuW3xOeaZS", - "subscription_item": "si_GyHjBYq7x2obek", - "tax_amounts": [ + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_GyHjvuW3xOeaZS", + "subscription_item": "si_GyHjBYq7x2obek", + "proration": false, + "proration_details": null + }, + "invoice_item_details": null + }, + "taxes": [ { "amount": 54, - "inclusive": true, - "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + } } - ], - "tax_rates": [], - "type": "subscription", - "unit_amount_excluding_tax": "446" + ] } ], "has_more": false, @@ -141,8 +106,6 @@ "metadata": {}, "next_payment_attempt": null, "number": "AAF2CECC-0001", - "paid": true, - "payment_intent": "pi_1GQL9sBVqmGyQTMaSnFPZJlx", "period_end": 1585088620, "period_start": 1585088620, "post_payment_credit_notes_amount": 0, @@ -157,19 +120,41 @@ "paid_at": 1585088621, "voided_at": null }, - "subscription": "sub_GyHjvuW3xOeaZS", "subtotal": 500, "subtotal_excluding_tax": 446, - "tax": 54, "tax_percent": null, "total": 500, "total_excluding_tax": 446, - "total_tax_amounts": [ + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_GyHjvuW3xOeaZS" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": "pi_1GQL9sBVqmGyQTMaSnFPZJlx", + "charge": "ch_1GQL9tBVqmGyQTMaNLu55LsR" + }, + "status": "paid" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "discounts": [], + "total_taxes": [ { "amount": 54, - "inclusive": true, - "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + } } - ], - "webhooks_delivered_at": null + ] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax_discount.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax_discount.json index 80c61dfb44e..c81d5c86ca0 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax_discount.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_paid_subscription_create_tax_discount.json @@ -16,7 +16,6 @@ "status": null }, "billing_reason": "subscription_create", - "charge": "ch_3KEJVVBVqmGyQTMa1jHz30rE", "collection_method": "charge_automatically", "created": 1585088620, "currency": "usd", @@ -50,36 +49,40 @@ } ], "description": null, - "discount": { - "id": "di_1KEJVVBVqmGyQTMay9usx63n", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "jeVw0xYj", - "object": "coupon", - "amount_off": null, - "created": 1640198196, - "currency": null, - "duration": "forever", - "duration_in_months": null, - "livemode": false, - "max_redemptions": null, - "metadata": {}, - "name": "Percentage small", - "percent_off": 10, - "redeem_by": null, - "times_redeemed": 1, - "valid": true - }, - "customer": "cus_Ku7kUUAPxDG3WU", - "end": null, - "invoice": null, - "invoice_item": null, - "promotion_code": null, - "start": 1585088620, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU" - }, - "discounts": ["di_1KEJVVBVqmGyQTMay9usx63n"], + "discounts": [ + { + "id": "di_1KEJVVBVqmGyQTMay9usx63n", + "object": "discount", + "checkout_session": null, + "customer": "cus_Ku7kUUAPxDG3WU", + "end": null, + "invoice": null, + "invoice_item": null, + "promotion_code": null, + "start": 1585088620, + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", + "source": { + "coupon": { + "id": "jeVw0xYj", + "object": "coupon", + "amount_off": null, + "created": 1640198196, + "currency": null, + "duration": "forever", + "duration_in_months": null, + "livemode": false, + "max_redemptions": null, + "metadata": {}, + "name": "Percentage small", + "percent_off": 10, + "redeem_by": null, + "times_redeemed": 1, + "valid": true + }, + "type": "coupon" + } + } + ], "due_date": null, "ending_balance": 0, "footer": null, @@ -95,7 +98,7 @@ "amount": 500, "amount_excluding_tax": 452, "currency": "usd", - "description": "1 × 123Done Pro (at $5.00 / month)", + "description": "1 \u00d7 123Done Pro (at $5.00 / month)", "discount_amounts": [ { "amount": 50, @@ -110,72 +113,36 @@ "end": 1587767020, "start": 1585088620 }, - "plan": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 500, - "amount_decimal": "500", - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - "promotionCodes": "FRIEND20,ASJQLIUQ,FRIENDS10,FRIENDS1,FRIENDS15,ANOTHER" + "quantity": 1, + "tax_rates": [], + "unit_amount_excluding_tax": "452", + "pricing": { + "price_details": { + "price": "plan_GqM9N6qyhvxaVk", + "product": "prod_GqM9ToKK62qjkK" }, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" + "type": "price_details", + "unit_amount_decimal": "500" }, - "price": { - "id": "plan_GqM9N6qyhvxaVk", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1583259953, - "currency": "usd", - "livemode": false, - "lookup_key": null, - "metadata": { - "promotionCodes": "FRIEND20,ASJQLIUQ,FRIENDS10,FRIENDS1,FRIENDS15,ANOTHER" + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", + "subscription_item": "si_Ku7lBbLQOjx5ry", + "proration": false, + "proration_details": null }, - "nickname": "123Done Pro Monthly", - "product": "prod_GqM9ToKK62qjkK", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 500, - "unit_amount_decimal": "500" + "invoice_item_details": null }, - "proration": false, - "quantity": 1, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", - "subscription_item": "si_Ku7lBbLQOjx5ry", - "tax_amounts": [ + "taxes": [ { "amount": 48, - "inclusive": true, - "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + } } - ], - "tax_rates": [], - "type": "subscription", - "unit_amount_excluding_tax": "452" + ] } ], "has_more": false, @@ -187,8 +154,6 @@ "next_payment_attempt": null, "number": "3432720C-0001", "on_behalf_of": null, - "paid": true, - "payment_intent": "pi_3KEJVVBVqmGyQTMa1MfskC2I", "payment_settings": { "payment_method_options": null, "payment_method_types": null @@ -208,10 +173,8 @@ "paid_at": 1585088620, "voided_at": null }, - "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU", "subtotal": 500, "subtotal_excluding_tax": 452, - "tax": 48, "tax_percent": null, "total": 450, "total_discount_amounts": [ @@ -221,13 +184,36 @@ } ], "total_excluding_tax": 402, - "total_tax_amounts": [ + "transfer_data": null, + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_1KEJVVBVqmGyQTMakN6U7sPU" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": "pi_3KEJVVBVqmGyQTMa1MfskC2I", + "charge": "ch_3KEJVVBVqmGyQTMa1jHz30rE" + }, + "status": "paid" + } + ], + "has_more": false, + "total_count": 1, + "url": "" + }, + "total_taxes": [ { "amount": 48, - "inclusive": true, - "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": "txr_1LhdJ2BVqmGyQTMaAhC0kQOq" + } } - ], - "transfer_data": null, - "webhooks_delivered_at": null + ] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax.json index 9b5027a4f30..279465d703f 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax.json @@ -14,7 +14,6 @@ "status": null }, "billing_reason": "upcoming", - "charge": null, "collection_method": "charge_automatically", "created": 1638387404, "currency": "usd", @@ -55,7 +54,6 @@ } ], "description": null, - "discount": null, "discounts": [], "due_date": null, "ending_balance": 0, @@ -79,68 +77,37 @@ "end": 1641065804, "start": 1638387404 }, - "plan": { - "id": "price_1JkZUtBVqmGyQTMabzHiPgmR", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2323, - "amount_decimal": "2323", - "billing_scheme": "per_unit", - "created": 1634239307, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": null, - "product": "prod_KPOHRv5MheicuD", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "price_1JkZUtBVqmGyQTMabzHiPgmR", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1634239307, - "currency": "usd", - "livemode": false, - "lookup_key": null, - "metadata": {}, - "nickname": null, - "product": "prod_KPOHRv5MheicuD", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "pricing": { + "price_details": { + "price": "price_1JkZUtBVqmGyQTMabzHiPgmR", + "product": "prod_KPOHRv5MheicuD" }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 2323, + "type": "price_details", "unit_amount_decimal": "2323" }, - "proration": false, "quantity": 1, - "subscription": "sub_1K1ybgBVqmGyQTMapiusG1Xd", - "subscription_item": "si_KhNMIdH4bqqOyQ", - "tax_amounts": [ + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_1K1ybgBVqmGyQTMapiusG1Xd", + "subscription_item": "si_KhNMIdH4bqqOyQ", + "proration": false, + "proration_details": null + }, + "invoice_item_details": null + }, + "taxes": [ { "amount": 371, - "inclusive": true, - "tax_rate": { - "display_name": "GST" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": { + "display_name": "GST" + } } } ], - "tax_rates": [], - "type": "subscription" + "tax_rates": [] } ], "has_more": false, @@ -152,8 +119,12 @@ "next_payment_attempt": 1638391004, "number": "72ABE14-0001", "on_behalf_of": null, - "paid": false, - "payment_intent": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_1K1ybgBVqmGyQTMapiusG1Xd" + } + }, "payment_settings": { "payment_method_options": null, "payment_method_types": null @@ -173,18 +144,18 @@ "paid_at": null, "voided_at": null }, - "subscription": "sub_1K1ybgBVqmGyQTMapiusG1Xd", "subscription_proration_date": 1638387404, "subtotal": 2323, - "tax": 371, "total": 2323, "total_discount_amounts": [], - "total_tax_amounts": [ + "total_taxes": [ { "amount": 371, - "inclusive": true, - "tax_rate": { - "display_name": "GST" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": { + "display_name": "GST" + } } } ], diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax_discount.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax_discount.json index a2ae0b2b32b..3af741d43ff 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax_discount.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/invoice_preview_tax_discount.json @@ -14,7 +14,6 @@ "status": null }, "billing_reason": "upcoming", - "charge": null, "collection_method": "charge_automatically", "created": 1638235373, "currency": "usd", @@ -55,36 +54,40 @@ } ], "description": null, - "discount": { - "id": "di_1K1L3ZBVqmGyQTMaIu772FxN", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "BPGwCW2H", - "object": "coupon", - "amount_off": 499, - "created": 1591322557, - "currency": "usd", - "duration": "once", - "duration_in_months": null, - "livemode": false, - "max_redemptions": 50, - "metadata": {}, - "name": "Discount $4.99 50 times", - "percent_off": null, - "redeem_by": null, - "times_redeemed": 13, - "valid": true - }, - "customer": "cus_KgiUdgpzGqzCea", - "end": null, - "invoice": null, - "invoice_item": null, - "promotion_code": null, - "start": 1638235373, - "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk" - }, - "discounts": ["di_1K1L3ZBVqmGyQTMaIu772FxN"], + "discounts": [ + { + "id": "di_1K1L3ZBVqmGyQTMaIu772FxN", + "object": "discount", + "checkout_session": null, + "customer": "cus_KgiUdgpzGqzCea", + "end": null, + "invoice": null, + "invoice_item": null, + "promotion_code": null, + "start": 1638235373, + "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk", + "source": { + "coupon": { + "id": "BPGwCW2H", + "object": "coupon", + "amount_off": 499, + "created": 1591322557, + "currency": "usd", + "duration": "once", + "duration_in_months": null, + "livemode": false, + "max_redemptions": 50, + "metadata": {}, + "name": "Discount $4.99 50 times", + "percent_off": null, + "redeem_by": null, + "times_redeemed": 13, + "valid": true + }, + "type": "coupon" + } + } + ], "due_date": null, "ending_balance": 0, "footer": null, @@ -97,7 +100,7 @@ "object": "line_item", "amount": 2323, "currency": "usd", - "description": "1 × Robin K Wilson (at $23.23 / month)", + "description": "1 \u00d7 Robin K Wilson (at $23.23 / month)", "discount_amounts": [ { "amount": 499, @@ -112,66 +115,35 @@ "end": 1640827373, "start": 1638235373 }, - "plan": { - "id": "price_1JkZUtBVqmGyQTMabzHiPgmR", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2323, - "amount_decimal": "2323", - "billing_scheme": "per_unit", - "created": 1634239307, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": null, - "product": "prod_KPOHRv5MheicuD", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "price_1JkZUtBVqmGyQTMabzHiPgmR", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1634239307, - "currency": "usd", - "livemode": false, - "lookup_key": null, - "metadata": {}, - "nickname": null, - "product": "prod_KPOHRv5MheicuD", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" + "quantity": 1, + "tax_rates": [], + "pricing": { + "price_details": { + "price": "price_1JkZUtBVqmGyQTMabzHiPgmR", + "product": "prod_KPOHRv5MheicuD" }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 2323, + "type": "price_details", "unit_amount_decimal": "2323" }, - "proration": false, - "quantity": 1, - "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk", - "subscription_item": "si_KgiUz0pUJIOrfh", - "tax_amounts": [ + "parent": { + "type": "subscription_item_details", + "subscription_item_details": { + "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk", + "subscription_item": "si_KgiUz0pUJIOrfh", + "proration": false, + "proration_details": null + }, + "invoice_item_details": null + }, + "taxes": [ { "amount": 291, - "inclusive": true, - "tax_rate": "txr_1JALB2BVqmGyQTMa3ltU6p6y" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": "txr_1JALB2BVqmGyQTMa3ltU6p6y" + } } - ], - "tax_rates": [], - "type": "subscription" + ] } ], "has_more": false, @@ -183,8 +155,6 @@ "next_payment_attempt": 1638238973, "number": "A17F35F-0001", "on_behalf_of": null, - "paid": false, - "payment_intent": null, "payment_settings": { "payment_method_options": null, "payment_method_types": null @@ -204,10 +174,8 @@ "paid_at": null, "voided_at": null }, - "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk", "subscription_proration_date": 1638235373, "subtotal": 2323, - "tax": 291, "total": 1824, "total_discount_amounts": [ { @@ -215,13 +183,23 @@ "discount": "di_1K1L3ZBVqmGyQTMaIu772FxN" } ], - "total_tax_amounts": [ + "transfer_data": null, + "webhooks_delivered_at": null, + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_1K1L3ZBVqmGyQTMascpG5XMk" + } + }, + "total_taxes": [ { "amount": 291, - "inclusive": true, - "tax_rate": "txr_1JALB2BVqmGyQTMa3ltU6p6y" + "tax_behavior": "inclusive", + "tax_rate_details": { + "tax_rate": { + "display_name": "VAT" + } + } } - ], - "transfer_data": null, - "webhooks_delivered_at": null + ] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription1.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription1.json index 37eb230515c..0826c24b6b1 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription1.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription1.json @@ -55,7 +55,9 @@ }, "quantity": 1, "subscription": "sub_GAt1vgMqOSr5hT", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1573695337, + "current_period_end": 1576287337 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription2.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription2.json index 67d8b90758c..783b358a875 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription2.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription2.json @@ -55,7 +55,9 @@ }, "quantity": 1, "subscription": "sub_GAt1vgMqOSr5hT", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1573695337, + "current_period_end": 1576287337 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_cancelled.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_cancelled.json index f81c2b5dc48..cc9996f663b 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_cancelled.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_cancelled.json @@ -55,7 +55,9 @@ }, "quantity": 1, "subscription": "sub_GWSkxxni5LtLCN", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1582646071, + "current_period_end": 1582732471 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_forever.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_forever.json index 1693b68cb67..4cbcd988f2b 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_forever.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_forever.json @@ -17,35 +17,6 @@ "default_payment_method": null, "default_source": null, "default_tax_rates": [], - "discount": { - "id": "di_1KTF4OBVqmGyQTMaysXHqCf5", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "jeKw0Zj", - "object": "coupon", - "amount_off": null, - "created": 1640198196, - "currency": null, - "duration": "forever", - "duration_in_months": null, - "livemode": false, - "max_redemptions": null, - "metadata": {}, - "name": "Percentage small", - "percent_off": 10, - "redeem_by": null, - "times_redeemed": 25, - "valid": true - }, - "customer": "cus_G8ByGeO3M4cBPW", - "end": null, - "invoice": null, - "invoice_item": null, - "promotion_code": "promo_1K9Zm3BVqmGyQTMa48CRi6ge", - "start": 1573695337, - "subscription": "sub_GAt1vgMqOSr5hT" - }, "ended_at": null, "invoice_customer_balance_settings": { "consume_applied_balance_on_void": true @@ -83,7 +54,9 @@ }, "quantity": 1, "subscription": "sub_GAt1vgMqOSr5hT", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1573695337, + "current_period_end": 1576287337 } ], "has_more": false, @@ -127,5 +100,39 @@ "status": "active", "tax_percent": null, "trial_end": null, - "trial_start": null + "trial_start": null, + "discounts": [ + { + "id": "di_1KTF4OBVqmGyQTMaysXHqCf5", + "object": "discount", + "checkout_session": null, + "customer": "cus_G8ByGeO3M4cBPW", + "end": null, + "invoice": null, + "invoice_item": null, + "promotion_code": "promo_1K9Zm3BVqmGyQTMa48CRi6ge", + "start": 1573695337, + "subscription": "sub_GAt1vgMqOSr5hT", + "source": { + "coupon": { + "id": "jeKw0Zj", + "object": "coupon", + "amount_off": null, + "created": 1640198196, + "currency": null, + "duration": "forever", + "duration_in_months": null, + "livemode": false, + "max_redemptions": null, + "metadata": {}, + "name": "Percentage small", + "percent_off": 10, + "redeem_by": null, + "times_redeemed": 25, + "valid": true + }, + "type": "coupon" + } + } + ] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_once.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_once.json index 37eb230515c..0826c24b6b1 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_once.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_once.json @@ -55,7 +55,9 @@ }, "quantity": 1, "subscription": "sub_GAt1vgMqOSr5hT", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1573695337, + "current_period_end": 1576287337 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_repeating.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_repeating.json index 87ca99f9ca2..6d03376f79b 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_repeating.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_coupon_repeating.json @@ -17,35 +17,6 @@ "default_payment_method": null, "default_source": null, "default_tax_rates": [], - "discount": { - "id": "di_1KgxpeBVqmGyQTMaCJfeihs0", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "SaqnwhC7", - "object": "coupon", - "amount_off": 50, - "created": 1641847527, - "currency": "usd", - "duration": "repeating", - "duration_in_months": 3, - "livemode": false, - "max_redemptions": null, - "metadata": {}, - "name": "$0.50 off", - "percent_off": null, - "redeem_by": null, - "times_redeemed": 18, - "valid": true - }, - "customer": "cus_G8ByGeO3M4cBPW", - "end": 1581644137, - "invoice": null, - "invoice_item": null, - "promotion_code": "promo_3FGUk7BVqmGyQTMaC7RjldxJ", - "start": 1573695337, - "subscription": "sub_GAt1vgMqOSr5hT" - }, "ended_at": null, "invoice_customer_balance_settings": { "consume_applied_balance_on_void": true @@ -83,7 +54,9 @@ }, "quantity": 1, "subscription": "sub_GAt1vgMqOSr5hT", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1573695337, + "current_period_end": 1576287337 } ], "has_more": false, @@ -127,5 +100,39 @@ "status": "active", "tax_percent": null, "trial_end": null, - "trial_start": null + "trial_start": null, + "discounts": [ + { + "id": "di_1KgxpeBVqmGyQTMaCJfeihs0", + "object": "discount", + "checkout_session": null, + "customer": "cus_G8ByGeO3M4cBPW", + "end": 1581644137, + "invoice": null, + "invoice_item": null, + "promotion_code": "promo_3FGUk7BVqmGyQTMaC7RjldxJ", + "start": 1573695337, + "subscription": "sub_GAt1vgMqOSr5hT", + "source": { + "coupon": { + "id": "SaqnwhC7", + "object": "coupon", + "amount_off": 50, + "created": 1641847527, + "currency": "usd", + "duration": "repeating", + "duration_in_months": 3, + "livemode": false, + "max_redemptions": null, + "metadata": {}, + "name": "$0.50 off", + "percent_off": null, + "redeem_by": null, + "times_redeemed": 18, + "valid": true + }, + "type": "coupon" + } + } + ] } diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_multiplan.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_multiplan.json index 526858c89c4..ec655940957 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_multiplan.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_multiplan.json @@ -57,7 +57,9 @@ }, "quantity": 1, "subscription": "sub_GgImrfvgTxUibx", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1580940996, + "current_period_end": 1583446596 }, { "id": "si_GgImRsmHJKUnRT", @@ -92,7 +94,9 @@ }, "quantity": 1, "subscription": "sub_GgImrfvgTxUibx", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1580940996, + "current_period_end": 1583446596 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_past_due.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_past_due.json index 9f068260b27..3d0ad39f8f5 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_past_due.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_past_due.json @@ -57,7 +57,9 @@ }, "quantity": 1, "subscription": "sub_FvOedh0SsaUVni", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1580749977, + "current_period_end": 1583255577 } ], "has_more": false, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded.json index 7264adfd655..7b1bfc4ded5 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded.json @@ -76,7 +76,9 @@ }, "quantity": 1, "subscription": "sub_HZRCVOtAhOXGyC", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1593657521, + "current_period_end": 1596335921 } ], "has_more": false, @@ -127,7 +129,7 @@ "object": "line_item", "amount": 499, "currency": "usd", - "description": "1 × Firefox Private Network (at $4.99 / month)", + "description": "1 \u00d7 Firefox Private Network (at $4.99 / month)", "discountable": true, "livemode": false, "metadata": {}, @@ -331,7 +333,9 @@ "request_three_d_secure": "automatic" } }, - "payment_method_types": ["card"], + "payment_method_types": [ + "card" + ], "receipt_email": null, "review": null, "setup_future_usage": "off_session", diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json index 66bcfb23536..de39813a924 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json @@ -85,7 +85,9 @@ }, "quantity": 1, "subscription": "sub_1LLsX2JEcmKzuWtRqw2US2v6", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1643130431, + "current_period_end": 1645808831 } ], "has_more": false, @@ -143,7 +145,7 @@ "object": "line_item", "amount": 999, "currency": "usd", - "description": "1 × Mozilla VPN (at $9.99 / month)", + "description": "1 \u00d7 Mozilla VPN (at $9.99 / month)", "discount_amounts": [], "discountable": true, "discounts": [], @@ -384,7 +386,9 @@ "generated_from": null, "last4": "3808", "networks": { - "available": ["visa"], + "available": [ + "visa" + ], "preferred": null }, "three_d_secure_usage": { @@ -412,7 +416,9 @@ "request_three_d_secure": "automatic" } }, - "payment_method_types": ["card"], + "payment_method_types": [ + "card" + ], "processing": null, "receipt_email": null, "review": null, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_trialing.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_trialing.json index f1cd0209a55..cf237b6c9ae 100644 --- a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_trialing.json +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_trialing.json @@ -57,7 +57,9 @@ }, "quantity": 1, "subscription": "sub_Go8xakzusK28jD", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1582749566, + "current_period_end": 1585341566 } ], "has_more": false, diff --git a/packages/fxa-shared/payments/stripe-firestore.ts b/packages/fxa-shared/payments/stripe-firestore.ts index b04270fcc91..488d2e0479b 100644 --- a/packages/fxa-shared/payments/stripe-firestore.ts +++ b/packages/fxa-shared/payments/stripe-firestore.ts @@ -27,6 +27,19 @@ export class FirestoreStripeErrorBuilder extends Error { } } +/** + * Prepare a Stripe object for Firestore persistence. + * + * Stripe v22 (basil) parses `*_decimal` fields (e.g. `plan.amount_decimal`, + * `price.unit_amount_decimal`) into `DecimalImpl` class instances. Firestore + * rejects values with a custom prototype, so round-trip through JSON to convert + * them back to their string representation — the shape consumers already expect + * — before writing. + */ +function toFirestoreObject(doc: T): T { + return JSON.parse(JSON.stringify(doc)); +} + /** * StripeFirestore manages access to the Stripe customer data stored in Firestore. * @@ -128,7 +141,7 @@ export class StripeFirestore { .doc(uid) .collection(this.subscriptionCollection) .doc(subscription.id), - subscription + toFirestoreObject(subscription) ); return subscription; @@ -216,7 +229,10 @@ export class StripeFirestore { ...customer, stripeEventCreatedTime: eventTime } - tx.set(this.customerCollectionDbRef.doc(customerUid), customerRecord); + tx.set( + this.customerCollectionDbRef.doc(customerUid), + toFirestoreObject(customerRecord) + ); } // Firestore transactions require writes to occur after all reads. @@ -230,7 +246,7 @@ export class StripeFirestore { .doc(customerUid) .collection(this.subscriptionCollection) .doc(subscriptionToUpdate.id), - subscriptionRecord + toFirestoreObject(subscriptionRecord) ); } }); @@ -324,7 +340,10 @@ export class StripeFirestore { ); } - tx.set(this.customerCollectionDbRef.doc(uid), customer); + tx.set( + this.customerCollectionDbRef.doc(uid), + toFirestoreObject(customer) + ); if (subscriptions) { for (const subscription of subscriptions) { tx.set( @@ -332,7 +351,7 @@ export class StripeFirestore { .doc(uid) .collection(this.subscriptionCollection) .doc(subscription.id), - subscription + toFirestoreObject(subscription) ); } } @@ -350,7 +369,7 @@ export class StripeFirestore { uid: string, customer: Partial ) { - return this.customerCollectionDbRef.doc(uid).set(customer); + return this.customerCollectionDbRef.doc(uid).set(toFirestoreObject(customer)); } /** @@ -377,7 +396,8 @@ export class StripeFirestore { ); } - if (typeof invoice.subscription !== 'string') { + const subscription = invoice.parent?.subscription_details?.subscription; + if (typeof subscription !== 'string') { // We can only insert invoices with a subscription for caching, but we // shouldn't throw errors just because we can't cache non-subscription invoices. // TODO: Cache non-subscription invoices. @@ -386,10 +406,10 @@ export class StripeFirestore { return customerSnap.docs[0].ref .collection(this.subscriptionCollection) - .doc(invoice.subscription) + .doc(subscription) .collection(this.invoiceCollection) .doc(invoice.id!) - .set(invoice); + .set(toFirestoreObject(invoice)); } /** @@ -401,13 +421,13 @@ export class StripeFirestore { ignoreErrors: boolean = false, ) { const invoice = await this.stripe.invoices.retrieve(invoiceId); - if (invoice.subscription === null) { + const subscriptionId = invoice.parent?.subscription_details?.subscription; + if (subscriptionId == null) { // We can only insert invoices with a subscription for caching, but we // shouldn't throw errors just because we can't cache non-subscription invoices. // TODO: Cache non-subscription invoices. return invoice; } - const subscriptionId = invoice.subscription; if (typeof subscriptionId !== 'string') { throw new Error("subscriptionId must be of type string"); } @@ -475,7 +495,7 @@ export class StripeFirestore { .doc(subscriptionId) .collection(this.invoiceCollection) .doc(invoiceId), - record + toFirestoreObject(record) ); return invoice; diff --git a/packages/fxa-shared/payments/stripe.ts b/packages/fxa-shared/payments/stripe.ts index adc8fd61198..10e625d9ab8 100644 --- a/packages/fxa-shared/payments/stripe.ts +++ b/packages/fxa-shared/payments/stripe.ts @@ -149,7 +149,7 @@ export abstract class StripeHelper { * Once stripe has been instantiated, this method should be invoked to ensure stripe metrics are being collected. */ protected initStripe() { - this.stripe.on('response', (response) => { + this.stripe.on('response', (response: Stripe.ResponseEvent) => { this.statsd.timing('stripe_request', response.elapsed); // Note that we can't record the method/path as a tag // because ids are in the path which results in too great diff --git a/packages/fxa-shared/subscriptions/stripe.ts b/packages/fxa-shared/subscriptions/stripe.ts index 60f004a5bab..274a19e5b36 100644 --- a/packages/fxa-shared/subscriptions/stripe.ts +++ b/packages/fxa-shared/subscriptions/stripe.ts @@ -1,4 +1,4 @@ -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { Plan, SubscriptionStripeError, @@ -104,8 +104,6 @@ export function filterSubscription( 'cancel_at', 'canceled_at', 'created', - 'current_period_end', - 'current_period_start', 'ended_at', 'id', 'items', @@ -192,11 +190,7 @@ export function filterProduct(item: Stripe.Product) { * @param item */ export function filterInvoice(item: Stripe.Invoice) { - const invoice = pick(item, 'id', 'status', 'object', 'payment_intent'); - if (invoice.payment_intent && typeof invoice.payment_intent !== 'string') { - invoice.payment_intent = filterIntent(invoice.payment_intent) as any; - } - return invoice; + return pick(item, 'id', 'status', 'object'); } /** diff --git a/packages/fxa-shared/subscriptions/types.ts b/packages/fxa-shared/subscriptions/types.ts index 0a18138c54d..9b2e1385fab 100644 --- a/packages/fxa-shared/subscriptions/types.ts +++ b/packages/fxa-shared/subscriptions/types.ts @@ -146,11 +146,12 @@ export const MozillaSubscriptionTypes = { } as const; export type WebSubscription = Pick< Stripe.Subscription, - | 'created' - | 'current_period_end' - | 'current_period_start' - | 'cancel_at_period_end' + 'created' | 'cancel_at_period_end' > & + Pick< + Stripe.SubscriptionItem, + 'current_period_end' | 'current_period_start' + > & Partial> & { _subscription_type: SubscriptionTypes[0]; end_at: Stripe.Subscription['ended_at']; @@ -224,8 +225,8 @@ export class SubscriptionStripeError extends Error { } export type InvoicePreview = [ - invoicePreview: Stripe.UpcomingInvoice, - proratedInvoice?: Stripe.UpcomingInvoice, + invoicePreview: Stripe.Invoice, + proratedInvoice?: Stripe.Invoice, ]; export type SubscriptionChangeEligibility = { diff --git a/packages/fxa-shared/test/fixtures/stripe/customer1_with_subscription.json b/packages/fxa-shared/test/fixtures/stripe/customer1_with_subscription.json index e615a15dbc2..f0de48dd5ab 100644 --- a/packages/fxa-shared/test/fixtures/stripe/customer1_with_subscription.json +++ b/packages/fxa-shared/test/fixtures/stripe/customer1_with_subscription.json @@ -72,8 +72,6 @@ "canceled_at": null, "collection_method": "charge_automatically", "created": 1594153896, - "current_period_end": 1596832296, - "current_period_start": 1594153896, "customer": "cust_new", "days_until_due": null, "default_payment_method": null, @@ -139,7 +137,9 @@ }, "quantity": 1, "subscription": "sub_HbadgOVyxa8z64", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1594153896, + "current_period_end": 1596832296 } ], "has_more": false, diff --git a/packages/fxa-shared/test/fixtures/stripe/subscription_expanded.json b/packages/fxa-shared/test/fixtures/stripe/subscription_expanded.json index bfab602a518..9edf3249b41 100644 --- a/packages/fxa-shared/test/fixtures/stripe/subscription_expanded.json +++ b/packages/fxa-shared/test/fixtures/stripe/subscription_expanded.json @@ -9,8 +9,6 @@ "canceled_at": null, "collection_method": "charge_automatically", "created": 1594080506, - "current_period_end": 1596758906, - "current_period_start": 1594080506, "customer": "cust_new", "days_until_due": null, "default_payment_method": null, @@ -76,7 +74,9 @@ }, "quantity": 1, "subscription": "sub_HbGu2EjvFQpuD2", - "tax_rates": [] + "tax_rates": [], + "current_period_start": 1594080506, + "current_period_end": 1596758906 } ], "has_more": false, @@ -96,7 +96,6 @@ "attempted": true, "auto_advance": false, "billing_reason": "subscription_create", - "charge": "ch_1H24MJBVqmGyQTMaI3hdW8d4", "collection_method": "charge_automatically", "created": 1594080506, "currency": "usd", @@ -200,173 +199,6 @@ "next_payment_attempt": null, "number": "5F033D48-0001", "paid": true, - "payment_intent": { - "id": "pi_1H24MIBVqmGyQTMarpgx9Emm", - "object": "payment_intent", - "amount": 500, - "amount_capturable": 0, - "amount_received": 500, - "application": null, - "application_fee_amount": null, - "canceled_at": null, - "cancellation_reason": null, - "capture_method": "automatic", - "charges": { - "object": "list", - "data": [ - { - "id": "ch_1H24MJBVqmGyQTMaI3hdW8d4", - "object": "charge", - "amount": 500, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_1H24MJBVqmGyQTMamhw5t9r5", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": "12345", - "state": null - }, - "email": null, - "name": "Jane Doe", - "phone": null - }, - "calculated_statement_descriptor": "Stripe", - "captured": true, - "created": 1594080507, - "currency": "usd", - "customer": "cust_new", - "description": "Subscription creation", - "destination": null, - "dispute": null, - "disputed": false, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": "in_1H24MIBVqmGyQTMaaaBNXB0C", - "livemode": false, - "metadata": {}, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 56, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_1H24MIBVqmGyQTMarpgx9Emm", - "payment_method": "card_1H24MHBVqmGyQTMaYjss8H5B", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": "pass", - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 12, - "exp_year": 2021, - "fingerprint": "PXj5xzTtGdBo1fVl", - "funding": "credit", - "installments": null, - "last4": "4242", - "network": "visa", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_1GCAr3BVqmGyQTMa/ch_1H24MJBVqmGyQTMaI3hdW8d4/rcpt_HbGuEn4As8B2hleT37ZV4dVmAH5Mr70", - "refunded": false, - "refunds": { - "object": "list", - "data": [], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_1H24MJBVqmGyQTMaI3hdW8d4/refunds" - }, - "review": null, - "shipping": null, - "source": { - "id": "card_1H24MHBVqmGyQTMaYjss8H5B", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": "12345", - "address_zip_check": "pass", - "brand": "Visa", - "country": "US", - "customer": "cust_new", - "cvc_check": "pass", - "dynamic_last4": null, - "exp_month": 12, - "exp_year": 2021, - "fingerprint": "PXj5xzTtGdBo1fVl", - "funding": "credit", - "last4": "4242", - "metadata": {}, - "name": "Jane Doe", - "tokenization_method": null - }, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/charges?payment_intent=pi_1H24MIBVqmGyQTMarpgx9Emm" - }, - "client_secret": "pi_1H24MIBVqmGyQTMarpgx9Emm_secret_6RBjV10PFqdS8WBlFRkB7hbF9", - "confirmation_method": "automatic", - "created": 1594080506, - "currency": "usd", - "customer": "cust_new", - "description": "Subscription creation", - "invoice": "in_1H24MIBVqmGyQTMaaaBNXB0C", - "last_payment_error": null, - "livemode": false, - "metadata": {}, - "next_action": null, - "on_behalf_of": null, - "payment_method": null, - "payment_method_options": { - "card": { - "installments": null, - "network": null, - "request_three_d_secure": "automatic" - } - }, - "payment_method_types": ["card"], - "receipt_email": null, - "review": null, - "setup_future_usage": "off_session", - "shipping": null, - "source": "card_1H24MHBVqmGyQTMaYjss8H5B", - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - }, "period_end": 1594080506, "period_start": 1594080506, "post_payment_credit_notes_amount": 0, @@ -381,14 +213,196 @@ "paid_at": 1594080506, "voided_at": null }, - "subscription": "sub_HbGu2EjvFQpuD2", "subtotal": 500, "tax": null, "tax_percent": null, "total": 500, "total_tax_amounts": [], "transfer_data": null, - "webhooks_delivered_at": null + "webhooks_delivered_at": null, + "parent": { + "subscription_details": { + "subscription": "sub_HbGu2EjvFQpuD2" + } + }, + "payments": { + "object": "list", + "data": [ + { + "payment": { + "payment_intent": { + "id": "pi_1H24MIBVqmGyQTMarpgx9Emm", + "object": "payment_intent", + "amount": 500, + "amount_capturable": 0, + "amount_received": 500, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_1H24MJBVqmGyQTMaI3hdW8d4", + "object": "charge", + "amount": 500, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_1H24MJBVqmGyQTMamhw5t9r5", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": "12345", + "state": null + }, + "email": null, + "name": "Jane Doe", + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1594080507, + "currency": "usd", + "customer": "cust_new", + "description": "Subscription creation", + "destination": null, + "dispute": null, + "disputed": false, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": "in_1H24MIBVqmGyQTMaaaBNXB0C", + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 56, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_1H24MIBVqmGyQTMarpgx9Emm", + "payment_method": "card_1H24MHBVqmGyQTMaYjss8H5B", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2021, + "fingerprint": "PXj5xzTtGdBo1fVl", + "funding": "credit", + "installments": null, + "last4": "4242", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1GCAr3BVqmGyQTMa/ch_1H24MJBVqmGyQTMaI3hdW8d4/rcpt_HbGuEn4As8B2hleT37ZV4dVmAH5Mr70", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_1H24MJBVqmGyQTMaI3hdW8d4/refunds" + }, + "review": null, + "shipping": null, + "source": { + "id": "card_1H24MHBVqmGyQTMaYjss8H5B", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": "12345", + "address_zip_check": "pass", + "brand": "Visa", + "country": "US", + "customer": "cust_new", + "cvc_check": "pass", + "dynamic_last4": null, + "exp_month": 12, + "exp_year": 2021, + "fingerprint": "PXj5xzTtGdBo1fVl", + "funding": "credit", + "last4": "4242", + "metadata": {}, + "name": "Jane Doe", + "tokenization_method": null + }, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_1H24MIBVqmGyQTMarpgx9Emm" + }, + "client_secret": "pi_1H24MIBVqmGyQTMarpgx9Emm_secret_6RBjV10PFqdS8WBlFRkB7hbF9", + "confirmation_method": "automatic", + "created": 1594080506, + "currency": "usd", + "customer": "cust_new", + "description": "Subscription creation", + "invoice": "in_1H24MIBVqmGyQTMaaaBNXB0C", + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "receipt_email": null, + "review": null, + "setup_future_usage": "off_session", + "shipping": null, + "source": "card_1H24MHBVqmGyQTMaYjss8H5B", + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + } + } + ] + } }, "livemode": false, "metadata": {}, diff --git a/packages/fxa-shared/test/subscriptions/stripe.ts b/packages/fxa-shared/test/subscriptions/stripe.ts index 2cd687d4f1a..a2f880e7b7e 100644 --- a/packages/fxa-shared/test/subscriptions/stripe.ts +++ b/packages/fxa-shared/test/subscriptions/stripe.ts @@ -1,6 +1,6 @@ import 'chai'; import { assert } from 'chai'; -import Stripe from 'stripe'; +import { Stripe } from 'stripe'; import { DeepPartial, @@ -31,8 +31,6 @@ function assertSubscription(item?: DeepPartial) { 'cancel_at', 'canceled_at', 'created', - 'current_period_end', - 'current_period_start', 'ended_at', 'id', 'items', @@ -57,7 +55,7 @@ function assertSource(item?: DeepPartial) { } function assertInvoice(item?: DeepPartial) { - assert.hasAllKeys(item, ['id', 'object', 'payment_intent', 'status']); + assert.hasAllKeys(item, ['id', 'object', 'status']); } function assertIntent( @@ -117,10 +115,6 @@ describe('stripe', () => { assertSubscription(result); assertSubscriptionItem(result.items?.data?.[0]); assertInvoice(result.latest_invoice as any); - assertIntent( - (result.latest_invoice as Stripe.Invoice) - .payment_intent as Stripe.PaymentIntent - ); }); }); @@ -143,14 +137,14 @@ describe('stripe', () => { it('filters recursively', () => { const result = filterInvoice(subscriptionExpanded.latest_invoice); assertInvoice(result); - assertIntent(result.payment_intent as any); }); }); describe('filterIntent', () => { it('filters', () => { const result = filterIntent( - subscriptionExpanded.latest_invoice.payment_intent + subscriptionExpanded.latest_invoice.payments.data[0].payment + .payment_intent ); assertIntent(result as any); }); diff --git a/yarn.lock b/yarn.lock index 7034bd88278..027a1d037d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19901,7 +19901,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:>=8.1.0": +"@types/node@npm:*, @types/node@npm:>=13.7.0": version: 24.0.6 resolution: "@types/node@npm:24.0.6" dependencies: @@ -33033,7 +33033,7 @@ __metadata: sms-segments-calculator: "npm:^1.2.0" storybook: "npm:^8.0.0" storybook-addon-mock: "npm:^5.0.0" - stripe: "npm:17.4.0" + stripe: "npm:22.3.1" stylelint: "npm:^17.13.0" stylelint-config-recommended-scss: "npm:^17.0.1" superagent: "npm:^10.2.0" @@ -51272,13 +51272,27 @@ __metadata: languageName: node linkType: hard -"stripe@npm:17.4.0": - version: 17.4.0 - resolution: "stripe@npm:17.4.0" - dependencies: - "@types/node": "npm:>=8.1.0" - qs: "npm:^6.11.0" - checksum: 10c0/bae24fc8c7b464f26f0b03f278007ef08e7fda31215fd5547521edb18e681615fc20023661f881340b8bcc46b6fdb3fd8e306cb76599fcab49231407932ad990 +"stripe@npm:22.3.1": + version: 22.3.1 + resolution: "stripe@npm:22.3.1" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/0e0e261f6b8f91e9331316e32e7dcc111099530d1c17fcad16ce22eb010a6fe9c0619e8106c3f0be815e1f9321063baaa1127c416bee3a5995d49d48fc8b38d4 + languageName: node + linkType: hard + +"stripe@patch:stripe@npm%3A22.3.1#~/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch": + version: 22.3.1 + resolution: "stripe@patch:stripe@npm%3A22.3.1#~/.yarn/patches/stripe-npm-22.3.1-291b1ac8f8.patch::version=22.3.1&hash=ffed5f" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/f515ac14a6aeb8dfc5bd1cca3945c976807f3cbd2f0c396b05745b5798e44f8e41189f490ae6f250a3062ba9fd956df3e19d0fbab9cd8211fc451bef617834ce languageName: node linkType: hard