diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts index daf4768fdf6..f39d8bb9083 100644 --- a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts +++ b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts @@ -61,6 +61,7 @@ jest.mock('@sentry/node', () => { return { ...actual, captureException: jest.fn(), + setTag: jest.fn(), }; }); @@ -193,6 +194,34 @@ describe('StripeWebhookService', () => { ); }); + it('should tag the event api version in Sentry', async () => { + const mockResponse = CustomerSubscriptionUpdatedResponseFactory(); + jest + .spyOn(stripeEventManager, 'constructWebhookEventResponse') + .mockReturnValue(mockResponse); + + await stripeWebhookService.handleWebhookEvent({}, 'signature'); + + expect(Sentry.setTag).toHaveBeenCalledWith( + 'stripe_api_version', + mockResponse.event.api_version + ); + }); + + it('should not tag the api version when the signature is rejected', async () => { + jest + .spyOn(stripeEventManager, 'constructWebhookEventResponse') + .mockImplementation(() => { + throw new Error('Webhook signature verification failed'); + }); + + await expect( + stripeWebhookService.handleWebhookEvent({}, 'signature') + ).rejects.toThrow('Webhook signature verification failed'); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + it('should report exception to Sentry and throw on exception', async () => { jest .spyOn(stripeWebhookService as any, 'dispatchEventToHandler') diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.service.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.service.ts index dd6ca399df4..73f13da8ccb 100644 --- a/libs/payments/webhooks/src/lib/stripe-webhooks.service.ts +++ b/libs/payments/webhooks/src/lib/stripe-webhooks.service.ts @@ -30,6 +30,10 @@ export class StripeWebhookService { payload, signature ); + Sentry.setTag( + 'stripe_api_version', + webhookEventResponse.event.api_version + ); const eventAlreadyProcessed = await this.stripeEventManager.isProcessed( webhookEventResponse.event.id 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 dc3f80e76b6..bc0a01b7423 100644 --- a/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts +++ b/packages/fxa-admin-server/src/subscriptions/subscriptions.service.spec.ts @@ -178,17 +178,17 @@ describe('Subscription Service', () => { { current_period_end: currentPeriodEnd / 1e3, current_period_start: currentPeriodStart / 1e3, + plan: { + id: planId, + product: productId, + product_name: productName, + }, }, ], }, ended_at: endedAt, id: subscriptionId, latest_invoice: latestInvoice, - plan: { - id: planId, - product: productId, - product_name: productName, - }, status, }, ], diff --git a/packages/fxa-admin-server/src/subscriptions/subscriptions.service.ts b/packages/fxa-admin-server/src/subscriptions/subscriptions.service.ts index 3cb0fe46992..a04f6f1d8ee 100644 --- a/packages/fxa-admin-server/src/subscriptions/subscriptions.service.ts +++ b/packages/fxa-admin-server/src/subscriptions/subscriptions.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { singlePlan } from 'fxa-shared/subscriptions/stripe'; import { AbbrevPlan } from 'fxa-shared/subscriptions/types'; import Stripe from 'stripe'; import { MozSubscription } from '../rest/model/moz-subscription.model'; @@ -112,10 +113,8 @@ export class SubscriptionsService { for (const subscription of customer?.subscriptions?.data || []) { // Inspired by code in auth-server payments ;] - const plan = plans.find( - // @ts-ignore - (p) => p.plan_id === subscription.plan.id - ); + const subscriptionPlan = singlePlan(subscription); + const plan = plans.find((p) => p.plan_id === subscriptionPlan?.id); let invoice = subscription.latest_invoice; if (typeof invoice === 'string') { diff --git a/packages/fxa-auth-server/lib/payments/stripe.spec.ts b/packages/fxa-auth-server/lib/payments/stripe.spec.ts index f0ab54d87bf..79bd52ae030 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.spec.ts @@ -3466,7 +3466,7 @@ describe('StripeHelper', () => { .mockResolvedValue({ id: productId, name: productName }); pastDueInvoice.payments.data[0].payment.charge = failedChargeCopy; pastDueSub.latest_invoice = pastDueInvoice; - pastDueSub.plan.product = product1.id; + pastDueSub.items.data[0].plan.product = product1.id; const input = { data: [pastDueSub] }; const actual = await stripeHelper.subscriptionsToResponse(input); expect(actual).toEqual(expectedPastDue); @@ -3544,7 +3544,7 @@ describe('StripeHelper', () => { const latestInvoiceItemsEnded = stripeInvoiceToLatestInvoiceItemsDTO(paidInvoice); const sub = deepCopy(cancelledSubscription); - sub.plan.product = product1.id; + sub.items.data[0].plan.product = product1.id; const input = { data: [sub] }; jest .spyOn(stripeHelper.stripe.invoices, 'retrieve') @@ -5644,6 +5644,122 @@ describe('StripeHelper', () => { ); }); + it('reconstructs the old plan from a basil items.data[].price diff', async () => { + const event = deepCopy(eventCustomerSubscriptionUpdated); + const oldPrice = { + id: 'OLD_00000000000000', + recurring: { interval: 'year', interval_count: 1 }, + unit_amount: 1000000, + }; + event.data.object.cancel_at_period_end = false; + delete event.data.previous_attributes.plan; + event.data.previous_attributes.cancel_at_period_end = false; + event.data.previous_attributes.latest_invoice = + 'mock_latest_invoice_id'; + event.data.previous_attributes.items = { data: [{ price: oldPrice }] }; + const result = + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( + event + ); + expect(result).toBe(mockUpgradeDowngradeDetails); + assertOnlyExpectedHelperCalledWith( + 'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail', + event.data.object, + expectedBaseUpdateDetails, + mockInvoice, + undefined, + { + ...event.data.object.items.data[0].plan, + id: oldPrice.id, + interval: 'year', + interval_count: 1, + } + ); + }); + + it('reconstructs the old plan from a basil items.data[].plan diff', async () => { + const event = deepCopy(eventCustomerSubscriptionUpdated); + const oldItemPlan = { + id: 'OLD_00000000000000', + interval: 'year', + interval_count: 1, + amount: 1000000, + }; + event.data.object.cancel_at_period_end = false; + delete event.data.previous_attributes.plan; + event.data.previous_attributes.cancel_at_period_end = false; + event.data.previous_attributes.latest_invoice = + 'mock_latest_invoice_id'; + event.data.previous_attributes.items = { + data: [{ plan: oldItemPlan }], + }; + const result = + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( + event + ); + expect(result).toBe(mockUpgradeDowngradeDetails); + assertOnlyExpectedHelperCalledWith( + 'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail', + event.data.object, + expectedBaseUpdateDetails, + mockInvoice, + undefined, + { ...event.data.object.items.data[0].plan, ...oldItemPlan } + ); + }); + + it('keeps the old cadence when a basil price diff omits recurring', async () => { + const event = deepCopy(eventCustomerSubscriptionUpdated); + event.data.object.cancel_at_period_end = false; + delete event.data.previous_attributes.plan; + event.data.previous_attributes.cancel_at_period_end = false; + event.data.previous_attributes.latest_invoice = + 'mock_latest_invoice_id'; + event.data.previous_attributes.items = { + data: [{ price: { id: 'OLD_00000000000000', unit_amount: 1000000 } }], + }; + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(event); + const [, , , , planOld] = ( + stripeHelper as any + ).extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail.mock.calls[0]; + expect(planOld.interval).toBe( + event.data.object.items.data[0].plan.interval + ); + expect(planOld.interval_count).toBe( + event.data.object.items.data[0].plan.interval_count + ); + }); + + // Stripe's basil changelog says previous_attributes now reports the + // subscription item's billing-period change, so this arrives on every + // renewal. The full prior item is asserted here rather than a partial + // diff, because the real payload shape is not verified. + it('does not send an upgrade when a renewal item diff repeats the current price', async () => { + const event = deepCopy(eventCustomerSubscriptionUpdated); + const currentPlan = event.data.object.items.data[0].plan; + event.data.object.cancel_at_period_end = false; + delete event.data.previous_attributes.plan; + event.data.previous_attributes.latest_invoice = + 'mock_latest_invoice_id'; + event.data.previous_attributes.items = { + data: [ + { + current_period_end: 1326853478, + plan: currentPlan, + price: { id: currentPlan.id, unit_amount: currentPlan.amount }, + }, + ], + }; + const result = + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( + event + ); + expect(result).toEqual(expectedBaseUpdateDetails); + expect( + stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail + ).not.toHaveBeenCalled(); + }); + it('calls the expected helper method for upgrade or downgrade if previously cancelled', async () => { const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = false; diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 6855475dc66..64d7fc4b34f 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -90,7 +90,10 @@ export const SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY = 'appliedPromotionCode'; * 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. + * mirror before the basil cutover). + * + * PAY-3900: remove with the rest of the dual-format support once the Firestore + * migration has run. */ export function getInvoiceSubscription( invoice: Stripe.Invoice @@ -1802,9 +1805,16 @@ export class StripeHelper extends StripeHelperBase { const rawCoupon = discount?.source?.coupon; const coupon = typeof rawCoupon === 'object' ? rawCoupon : undefined; - // This type inconsistency runs quite deep, but plan does exist on the subscription here - // for all current use-cases. - const plan = (sub as any).plan as Stripe.Plan; + const plan = singlePlan(sub); + if (!plan) { + throw error.internalValidationError( + 'subscriptionsToResponse', + sub, + new Error( + `Multiple items for a subscription not supported: ${sub.id}` + ) + ); + } const product = products.find((p) => p.product_id === plan.product); if (!product) @@ -2416,15 +2426,42 @@ export class StripeHelper extends StripeHelperBase { // Stripe only sends fields that have changed in their previous_attributes field // Additionally, previous_attributes is a generic field that has no proper typings // and is used in a flexible manner. - const previousAttributes = eventData.previous_attributes as any; - const planOldDiff = (previousAttributes as any) - .plan as Partial | null; - const planOld: Stripe.Plan | null = planOldDiff - ? { - ...planNew, - ...planOldDiff, - } - : null; + const previousAttributes: Partial & { + plan?: Partial; + } = eventData.previous_attributes ?? {}; + // Acacia sent the previous price as the top-level `plan`; basil removed + // that field and reports item changes under `items.data[]` instead — + // including the billing-period bump on every renewal, so only a moved + // price id is an actual upgrade. + // + // PAY-3900: remove with the rest of the dual-format support once the + // Firestore migration has run. + const previousItem: + | { plan?: Partial; price?: Partial } + | undefined = previousAttributes.items?.data?.[0]; + const previousItemPrice = previousItem?.price; + const itemDiff: Partial | undefined = + previousItem?.plan ?? + (previousItemPrice + ? { + id: previousItemPrice.id, + interval: previousItemPrice.recurring?.interval, + interval_count: previousItemPrice.recurring?.interval_count, + } + : undefined); + const planOldDiff: Partial | undefined = + previousAttributes.plan ?? + (itemDiff && itemDiff.id !== planNew.id ? itemDiff : undefined); + + let planOld: Stripe.Plan | null = null; + if (planOldDiff) { + planOld = { + ...planNew, + ...planOldDiff, + interval: planOldDiff.interval ?? planNew.interval, + interval_count: planOldDiff.interval_count ?? planNew.interval_count, + }; + } let invoiceOldCurrency: string | undefined; let invoiceTotalOldInCents: number | undefined; 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 5886a811bff..059eea1efd4 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts @@ -236,6 +236,7 @@ export class StripeWebhookHandler extends StripeHandler { request.payload, request.headers['stripe-signature'] ); + Sentry.setTag('stripe_api_version', event.api_version); const firestoreHandled = await this.processEventToFirestore(event); await this.dispatchEventToHandler(request, event, firestoreHandled); } catch (error) { 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 7df6d1401e8..f6a12bd409a 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 @@ -242,6 +242,42 @@ describe('StripeWebhookHandler', () => { .mockImplementation((fn: any) => fn(scopeSpy)); }); + describe('stripe_api_version tag', () => { + it('tags the version off the verified event', async () => { + const setTagSpy = jest + .spyOn(Sentry, 'setTag') + .mockImplementation(() => {}); + const createdEvent = deepCopy(eventCustomerUpdated); + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( + createdEvent + ); + + await StripeWebhookHandlerInstance.handleWebhookEvent(request); + + expect(setTagSpy).toHaveBeenCalledWith( + 'stripe_api_version', + createdEvent.api_version + ); + }); + + it('does not tag when the signature is rejected', async () => { + const setTagSpy = jest + .spyOn(Sentry, 'setTag') + .mockImplementation(() => {}); + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockImplementation( + () => { + throw new Error('Webhook signature verification failed'); + } + ); + + await expect( + StripeWebhookHandlerInstance.handleWebhookEvent(request) + ).rejects.toThrow('Webhook signature verification failed'); + + expect(setTagSpy).not.toHaveBeenCalled(); + }); + }); + const assertNamedHandlerCalled = ( expectedHandlerName: string | null = null ) => { diff --git a/packages/fxa-auth-server/scripts/update-firestore-acacia-records.ts b/packages/fxa-auth-server/scripts/update-firestore-acacia-records.ts new file mode 100644 index 00000000000..009742a2634 --- /dev/null +++ b/packages/fxa-auth-server/scripts/update-firestore-acacia-records.ts @@ -0,0 +1,58 @@ +/* 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 program from 'commander'; +import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup'; +import { parseBooleanArg } from './lib/args'; + +import { FirestoreAcaciaUpdater } from './update-firestore-acacia-records/update-firestore-acacia-records'; + +const pckg = require('../package.json'); + +const parseRateLimit = (rateLimit: string | number) => { + return parseInt(rateLimit.toString(), 10); +}; + +async function init() { + program + .version(pckg.version) + .option('-r, --rate-limit [number]', 'Rate limit for Stripe', 30) + .option( + '--dry-run [true|false]', + 'Report outdated records instead of resyncing them. Defaults to true.', + true + ) + .parse(process.argv); + + const { stripeHelper, log } = await setupProcessingTaskObjects( + 'update-firestore-acacia-records' + ); + + const rateLimit = parseRateLimit(program.rateLimit); + const isDryRun = parseBooleanArg(program.dryRun); + + const acaciaUpdater = new FirestoreAcaciaUpdater( + stripeHelper, + rateLimit, + log, + isDryRun + ); + + await acaciaUpdater.run(); + + return 0; +} + +if (require.main === module) { + let exitStatus = 1; + init() + .then((result) => { + exitStatus = result; + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + process.exit(exitStatus); + }); +} diff --git a/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.spec.ts b/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.spec.ts new file mode 100644 index 00000000000..5992c09ae4b --- /dev/null +++ b/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.spec.ts @@ -0,0 +1,847 @@ +/* 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 Container from 'typedi'; +import { StatsD } from 'hot-shots'; + +import { ConfigType } from '../../config'; +import { AppConfig, AuthFirestore } from '../../lib/types'; + +import { + FirestoreAcaciaUpdater, + isAcaciaShape, +} from './update-firestore-acacia-records'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; +import { StripeFirestore } from '../../lib/payments/stripe-firestore'; +import { + StripeInvoiceFactory, + StripeSubscriptionFactory, +} from '@fxa/payments/stripe'; + +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; +import invoicePaid from '../../test/local/payments/fixtures/stripe/invoice_paid.json'; + +jest.mock('../../lib/payments/stripe-firestore'); + +const mockCustomer = customer1 as unknown as Stripe.Customer; + +// The Stripe API answers in the current (basil) shape, while these fixtures +// predate the cutover and stand in for the records the migration has to find. +const mockSubscription = StripeSubscriptionFactory(); +const mockAcaciaSubscription = subscription1; +const mockAcaciaInvoice = invoicePaid; + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, +} as unknown as ConfigType; + +/** + * Mirrors the customers -> subscriptions -> invoices document hierarchy the + * updater walks. + */ +const buildFirestoreStub = ({ + subscriptionSnapshot, + invoiceDocs = [], + invoiceReadError, +}: { + subscriptionSnapshot?: any; + invoiceDocs?: { id: string; data: () => any }[]; + invoiceReadError?: Error; +} = {}) => { + const invoiceCollectionRef = { + get: invoiceReadError + ? jest.fn().mockRejectedValue(invoiceReadError) + : jest.fn().mockResolvedValue({ docs: invoiceDocs }), + }; + const subscriptionDocRef = { + get: jest.fn().mockResolvedValue(subscriptionSnapshot ?? { exists: false }), + collection: jest.fn().mockReturnValue(invoiceCollectionRef), + }; + const customerDocRef = { + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue(subscriptionDocRef), + }), + }; + + return { + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue(customerDocRef), + }), + }; +}; + +const snapshotOf = (data: any) => ({ + exists: true, + data: jest.fn().mockReturnValue(data), +}); + +const invoiceDocOf = (id: string, data: any) => ({ + id, + data: jest.fn().mockReturnValue(data), +}); + +const subscriptionListStub = (subscriptions: any[]) => + jest.fn().mockReturnValue({ + autoPagingToArray: jest.fn().mockResolvedValue(subscriptions), + }); + +const lastStripeFirestore = () => + jest + .mocked(StripeFirestore) + .mock.instances.at(-1) as jest.Mocked; + +describe('FirestoreAcaciaUpdater', () => { + let acaciaUpdater: FirestoreAcaciaUpdater; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let firestoreStub: any; + let logStub: any; + + const buildUpdater = (dryRun = false) => { + Container.set(AuthFirestore, firestoreStub); + return new FirestoreAcaciaUpdater(stripeHelperStub, 20, logStub, dryRun); + }; + + beforeEach(() => { + firestoreStub = buildFirestoreStub(); + + Container.set(AuthFirestore, firestoreStub); + Container.set(AppConfig, mockConfig); + Container.set(StatsD, { increment: jest.fn(), timing: jest.fn() }); + + stripeStub = { + on: jest.fn(), + customers: { + list: jest.fn(), + }, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + } as unknown as StripeHelper; + + logStub = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + acaciaUpdater = buildUpdater(); + }); + + afterEach(() => { + Container.reset(); + }); + + describe('isAcaciaShape', () => { + it('spots a subscription still carrying the dropped top-level fields', () => { + expect(isAcaciaShape(mockAcaciaSubscription)).toBe(true); + }); + + it('spots an invoice still carrying the dropped top-level fields', () => { + expect(isAcaciaShape(mockAcaciaInvoice)).toBe(true); + }); + + it('passes over records already in the current shape', () => { + expect(isAcaciaShape(StripeSubscriptionFactory())).toBe(false); + expect(isAcaciaShape(StripeInvoiceFactory())).toBe(false); + }); + + it('passes over a missing record', () => { + expect(isAcaciaShape(undefined)).toBe(false); + }); + }); + + describe('run', () => { + let autoPagingEachStub: jest.Mock; + let processCustomerStub: jest.Mock; + + beforeEach(async () => { + autoPagingEachStub = jest + .fn() + .mockImplementation(async (callback: any) => { + await callback(mockCustomer); + }); + + stripeStub.customers.list = jest.fn().mockReturnValue({ + autoPagingEach: autoPagingEachStub, + }) as any; + + processCustomerStub = jest.fn().mockResolvedValue(undefined); + acaciaUpdater.processCustomer = processCustomerStub; + + await acaciaUpdater.run(); + }); + + it('calls Stripe customers.list', () => { + expect(stripeStub.customers.list as any).toHaveBeenCalledWith({ + limit: 25, + }); + }); + + it('calls autoPagingEach to iterate through all customers', () => { + expect(autoPagingEachStub).toHaveBeenCalledTimes(1); + }); + + it('processes each customer', () => { + expect(processCustomerStub).toHaveBeenCalledTimes(1); + expect(processCustomerStub).toHaveBeenCalledWith(mockCustomer); + }); + + it('logs summary', () => { + expect(logStub.info).toHaveBeenCalledWith( + 'firestore-acacia-update-complete', + expect.objectContaining({ + dryRun: false, + customersChecked: 0, + subscriptionsOutdatedShape: 0, + invoicesOutdatedShape: 0, + }) + ); + }); + }); + + describe('completion tally', () => { + const deletedCustomer = { id: 'cus_deleted', deleted: true }; + + beforeEach(async () => { + firestoreStub = buildFirestoreStub({ + subscriptionSnapshot: snapshotOf(mockAcaciaSubscription), + invoiceDocs: [ + invoiceDocOf('in_acacia', mockAcaciaInvoice), + invoiceDocOf('in_dahlia', StripeInvoiceFactory()), + ], + }); + + stripeStub.customers.list = jest.fn().mockReturnValue({ + autoPagingEach: jest.fn().mockImplementation(async (callback: any) => { + await callback(deletedCustomer); + await callback(mockCustomer); + }), + }) as any; + + stripeStub.subscriptions = { + list: subscriptionListStub([mockSubscription]), + } as any; + + acaciaUpdater = buildUpdater(); + + await acaciaUpdater.run(); + }); + + it('reports every record in exactly one bucket', () => { + expect(logStub.info).toHaveBeenCalledWith( + 'firestore-acacia-update-complete', + { + dryRun: false, + customersChecked: 2, + customersSkippedDeleted: 1, + customersProcessed: 1, + customersFailed: 0, + subscriptionsChecked: 1, + subscriptionsCurrentShape: 0, + subscriptionsMissingDoc: 0, + subscriptionsOutdatedShape: 1, + subscriptionsFailed: 0, + subscriptionsInvoiceWalkFailed: 0, + subscriptionsResynced: 1, + subscriptionsResyncFailed: 0, + invoicesChecked: 2, + invoicesCurrentShape: 1, + invoicesOutdatedShape: 1, + invoicesResynced: 1, + invoicesResyncFailed: 0, + } + ); + }); + + it('balances customers seen against how they were disposed of', () => { + expect(acaciaUpdater['customersChecked']).toBe( + acaciaUpdater['customersSkippedDeleted'] + + acaciaUpdater['customersProcessed'] + + acaciaUpdater['customersFailed'] + ); + }); + + it('balances subscriptions checked against how they were classified', () => { + expect(acaciaUpdater['subscriptionsChecked']).toBe( + acaciaUpdater['subscriptionsCurrentShape'] + + acaciaUpdater['subscriptionsMissingDoc'] + + acaciaUpdater['subscriptionsOutdatedShape'] + + acaciaUpdater['subscriptionsFailed'] + ); + }); + + it('balances invoices checked against how they were classified', () => { + expect(acaciaUpdater['invoicesChecked']).toBe( + acaciaUpdater['invoicesCurrentShape'] + + acaciaUpdater['invoicesOutdatedShape'] + ); + }); + + it('names every outdated record it found', () => { + expect(logStub.warn).toHaveBeenCalledWith( + 'firestore-acacia-record-outdated', + { + type: 'subscription', + customerId: mockCustomer.id, + uid: mockCustomer.metadata.userid, + subscriptionId: mockSubscription.id, + invoiceId: null, + } + ); + expect(logStub.warn).toHaveBeenCalledWith( + 'firestore-acacia-record-outdated', + { + type: 'invoice', + customerId: mockCustomer.id, + uid: mockCustomer.metadata.userid, + subscriptionId: mockSubscription.id, + invoiceId: 'in_acacia', + } + ); + }); + }); + + describe('processCustomer', () => { + const buildUpdaterFor = (subscriptionCount: number) => { + acaciaUpdater = buildUpdater(); + acaciaUpdater.processSubscription = jest + .fn() + .mockResolvedValue(undefined); + + stripeStub.subscriptions = { + list: subscriptionListStub( + new Array(subscriptionCount) + .fill(null) + .map(() => StripeSubscriptionFactory()) + ), + } as any; + + return acaciaUpdater; + }; + + beforeEach(() => { + stripeStub.subscriptions = { + list: subscriptionListStub([mockSubscription]), + } as any; + }); + + describe('a customer holding one subscription', () => { + beforeEach(async () => { + await buildUpdaterFor(1).processCustomer(mockCustomer); + }); + + it('checks the subscription', () => { + expect(acaciaUpdater.processSubscription).toHaveBeenCalledWith( + mockCustomer.id, + mockCustomer.metadata.userid, + expect.any(Object) + ); + }); + }); + + describe('a customer holding more subscriptions than one Stripe page', () => { + const pageSize = 100; + const subscriptionCount = pageSize + 20; + + beforeEach(async () => { + await buildUpdaterFor(subscriptionCount).processCustomer(mockCustomer); + }); + + it('checks every subscription past the first page', () => { + expect(acaciaUpdater.processSubscription).toHaveBeenCalledTimes( + subscriptionCount + ); + }); + + it('walks the pages rather than reading only the first', () => { + const listResult = (stripeStub.subscriptions.list as jest.Mock).mock + .results[0].value; + expect(listResult.autoPagingToArray).toHaveBeenCalledWith({ + limit: 10000, + }); + }); + }); + + describe('deleted customer', () => { + beforeEach(async () => { + const deletedCustomer = { + id: mockCustomer.id, + deleted: true, + }; + + await acaciaUpdater.processCustomer(deletedCustomer as any); + }); + + it('counts the customer as skipped rather than processed', () => { + expect(acaciaUpdater['customersSkippedDeleted']).toBe(1); + expect(acaciaUpdater['customersProcessed']).toBe(0); + }); + + it('does not go looking for subscriptions', () => { + expect(stripeStub.subscriptions.list).not.toHaveBeenCalled(); + }); + }); + + describe('customer missing a userid', () => { + beforeEach(async () => { + await acaciaUpdater.processCustomer({ + ...mockCustomer, + metadata: {}, + }); + }); + + it('logs error', () => { + expect(logStub.error).toHaveBeenCalledWith( + 'error-processing-customer', + expect.any(Object) + ); + }); + }); + + describe('error processing customer', () => { + beforeEach(async () => { + stripeStub.subscriptions = { + list: jest.fn().mockReturnValue({ + autoPagingToArray: jest + .fn() + .mockRejectedValue(new Error('Stripe error')), + }), + } as any; + + await acaciaUpdater.processCustomer(mockCustomer); + }); + + it('logs error', () => { + expect(logStub.error).toHaveBeenCalledWith( + 'error-processing-customer', + expect.any(Object) + ); + }); + }); + }); + + describe('processSubscription', () => { + let recordOutdatedStub: jest.Mock; + let processInvoicesStub: jest.Mock; + let resyncSubscriptionStub: jest.Mock; + + const buildUpdaterFor = (subscriptionData?: any) => { + firestoreStub = buildFirestoreStub({ + subscriptionSnapshot: subscriptionData + ? snapshotOf(subscriptionData) + : { exists: false, data: jest.fn() }, + }); + + acaciaUpdater = buildUpdater(); + acaciaUpdater.recordOutdated = recordOutdatedStub; + acaciaUpdater.processInvoices = processInvoicesStub; + acaciaUpdater.resyncSubscription = resyncSubscriptionStub; + return acaciaUpdater; + }; + + beforeEach(() => { + recordOutdatedStub = jest.fn(); + processInvoicesStub = jest.fn().mockResolvedValue(undefined); + resyncSubscriptionStub = jest.fn().mockResolvedValue(undefined); + }); + + describe('subscription already in the current shape', () => { + beforeEach(async () => { + await buildUpdaterFor({ + ...mockSubscription, + }).processSubscription( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('does not record the subscription as outdated', () => { + expect(recordOutdatedStub).not.toHaveBeenCalled(); + }); + + it('does not resync the subscription', () => { + expect(resyncSubscriptionStub).not.toHaveBeenCalled(); + }); + + it('walks the subscription invoices', () => { + expect(processInvoicesStub).toHaveBeenCalledWith( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + }); + }); + + describe('subscription still in the pre-cutover shape', () => { + beforeEach(async () => { + await buildUpdaterFor(mockAcaciaSubscription).processSubscription( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('records the subscription as outdated', () => { + expect(recordOutdatedStub).toHaveBeenCalledWith( + 'subscription', + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + }); + + it('resyncs that subscription rather than the whole customer', () => { + expect(resyncSubscriptionStub).toHaveBeenCalledWith( + mockSubscription.id, + mockCustomer.id, + mockCustomer.metadata.userid + ); + }); + + it('still walks the subscription invoices', () => { + expect(processInvoicesStub).toHaveBeenCalledWith( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + }); + }); + + describe('subscription missing in Firestore', () => { + beforeEach(async () => { + await buildUpdaterFor().processSubscription( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('leaves the missing doc to the sync checker', () => { + expect(recordOutdatedStub).not.toHaveBeenCalled(); + expect(resyncSubscriptionStub).not.toHaveBeenCalled(); + }); + + it('counts it as a missing doc rather than a current shape', () => { + expect(acaciaUpdater['subscriptionsMissingDoc']).toBe(1); + expect(acaciaUpdater['subscriptionsCurrentShape']).toBe(0); + }); + }); + + describe('invoice walk fails', () => { + beforeEach(async () => { + firestoreStub = buildFirestoreStub({ + subscriptionSnapshot: snapshotOf(mockAcaciaSubscription), + invoiceReadError: new Error('Firestore error'), + }); + + acaciaUpdater = buildUpdater(); + acaciaUpdater.recordOutdated = recordOutdatedStub; + acaciaUpdater.resyncSubscription = resyncSubscriptionStub; + + await acaciaUpdater.processSubscription( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('still resyncs the subscription', () => { + expect(resyncSubscriptionStub).toHaveBeenCalledWith( + mockSubscription.id, + mockCustomer.id, + mockCustomer.metadata.userid + ); + }); + + it('counts the subscription as having an unexamined invoice walk', () => { + expect(acaciaUpdater['subscriptionsInvoiceWalkFailed']).toBe(1); + expect(acaciaUpdater['invoicesChecked']).toBe(0); + }); + + it('logs the invoice error rather than the subscription error', () => { + expect(logStub.error).toHaveBeenCalledWith( + 'error-processing-invoices', + { + customerId: mockCustomer.id, + uid: mockCustomer.metadata.userid, + subscriptionId: mockSubscription.id, + error: expect.any(Error), + } + ); + expect(logStub.error).not.toHaveBeenCalledWith( + 'error-processing-subscription', + expect.anything() + ); + }); + }); + + describe('error processing subscription', () => { + beforeEach(async () => { + firestoreStub = buildFirestoreStub(); + firestoreStub.collection = jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation(() => { + throw new Error('Firestore error'); + }), + }); + + acaciaUpdater = buildUpdater(); + acaciaUpdater.resyncSubscription = resyncSubscriptionStub; + + await acaciaUpdater.processSubscription( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('logs error', () => { + expect(logStub.error).toHaveBeenCalledWith( + 'error-processing-subscription', + expect.any(Object) + ); + }); + + it('does not resync the subscription', () => { + expect(resyncSubscriptionStub).not.toHaveBeenCalled(); + }); + }); + }); + + describe('processInvoices', () => { + let recordOutdatedStub: jest.Mock; + let resyncInvoiceStub: jest.Mock; + + const buildUpdaterFor = ( + invoiceDocs: { id: string; data: () => any }[] + ) => { + firestoreStub = buildFirestoreStub({ invoiceDocs }); + + acaciaUpdater = buildUpdater(); + acaciaUpdater.recordOutdated = recordOutdatedStub; + acaciaUpdater.resyncInvoice = resyncInvoiceStub; + return acaciaUpdater; + }; + + beforeEach(() => { + recordOutdatedStub = jest.fn(); + resyncInvoiceStub = jest.fn().mockResolvedValue(undefined); + }); + + describe('invoice still in the pre-cutover shape', () => { + beforeEach(async () => { + await buildUpdaterFor([ + invoiceDocOf('in_acacia', mockAcaciaInvoice), + ]).processInvoices( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + }); + + it('records the invoice as outdated', () => { + expect(recordOutdatedStub).toHaveBeenCalledWith( + 'invoice', + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id, + 'in_acacia' + ); + }); + + it('resyncs the invoice', () => { + expect(resyncInvoiceStub).toHaveBeenCalledWith( + 'in_acacia', + mockCustomer.id, + mockSubscription.id + ); + }); + }); + + describe('invoice already in the current shape', () => { + beforeEach(async () => { + await buildUpdaterFor([ + invoiceDocOf('in_dahlia', StripeInvoiceFactory()), + ]).processInvoices( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + }); + + it('does not record the invoice as outdated', () => { + expect(recordOutdatedStub).not.toHaveBeenCalled(); + }); + + it('does not resync the invoice', () => { + expect(resyncInvoiceStub).not.toHaveBeenCalled(); + }); + }); + + it('counts every invoice it walks', async () => { + const updater = buildUpdaterFor([ + invoiceDocOf('in_acacia', mockAcaciaInvoice), + invoiceDocOf('in_dahlia', StripeInvoiceFactory()), + ]); + + await updater.processInvoices( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + + expect(updater['invoicesChecked']).toBe(2); + }); + }); + + describe('recordOutdated', () => { + it('increments the outdated subscription counter', () => { + acaciaUpdater.recordOutdated( + 'subscription', + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id + ); + + expect(acaciaUpdater['subscriptionsOutdatedShape']).toBe(1); + expect(acaciaUpdater['invoicesOutdatedShape']).toBe(0); + }); + + it('increments the outdated invoice counter', () => { + acaciaUpdater.recordOutdated( + 'invoice', + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id, + 'in_acacia' + ); + + expect(acaciaUpdater['invoicesOutdatedShape']).toBe(1); + expect(acaciaUpdater['subscriptionsOutdatedShape']).toBe(0); + }); + + it('logs the outdated record', () => { + acaciaUpdater.recordOutdated( + 'invoice', + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription.id, + 'in_acacia' + ); + + expect(logStub.warn).toHaveBeenCalledWith( + 'firestore-acacia-record-outdated', + { + type: 'invoice', + customerId: mockCustomer.id, + uid: mockCustomer.metadata.userid, + subscriptionId: mockSubscription.id, + invoiceId: 'in_acacia', + } + ); + }); + }); + + describe('resyncSubscription', () => { + const resync = (updater: FirestoreAcaciaUpdater) => + updater.resyncSubscription( + mockSubscription.id, + mockCustomer.id, + mockCustomer.metadata.userid + ); + + it('rewrites the subscription through the locking Firestore path', async () => { + await resync(acaciaUpdater); + + expect( + lastStripeFirestore().fetchAndInsertSubscription + ).toHaveBeenCalledWith(mockSubscription.id, mockCustomer.metadata.userid); + expect( + lastStripeFirestore().fetchAndInsertCustomer + ).not.toHaveBeenCalled(); + }); + + it('writes nothing on a dry run', async () => { + const dryRunUpdater = buildUpdater(true); + + await resync(dryRunUpdater); + + expect( + lastStripeFirestore().fetchAndInsertSubscription + ).not.toHaveBeenCalled(); + }); + + it('logs error on failure', async () => { + lastStripeFirestore().fetchAndInsertSubscription.mockRejectedValue( + new Error('Firestore error') + ); + + await resync(acaciaUpdater); + + expect(logStub.error).toHaveBeenCalledWith( + 'failed-to-resync-subscription', + { + subscriptionId: mockSubscription.id, + customerId: mockCustomer.id, + uid: mockCustomer.metadata.userid, + error: expect.any(Error), + } + ); + }); + }); + + describe('resyncInvoice', () => { + it('rewrites the invoice from Stripe', async () => { + await acaciaUpdater.resyncInvoice( + 'in_acacia', + mockCustomer.id, + mockSubscription.id + ); + + expect(lastStripeFirestore().fetchAndInsertInvoice).toHaveBeenCalledWith( + 'in_acacia', + expect.any(Number) + ); + }); + + it('writes nothing on a dry run', async () => { + const dryRunUpdater = buildUpdater(true); + + await dryRunUpdater.resyncInvoice( + 'in_acacia', + mockCustomer.id, + mockSubscription.id + ); + + expect( + lastStripeFirestore().fetchAndInsertInvoice + ).not.toHaveBeenCalled(); + }); + + it('logs error on failure', async () => { + lastStripeFirestore().fetchAndInsertInvoice.mockRejectedValue( + new Error('Firestore error') + ); + + await acaciaUpdater.resyncInvoice( + 'in_acacia', + mockCustomer.id, + mockSubscription.id + ); + + expect(logStub.error).toHaveBeenCalledWith('failed-to-resync-invoice', { + invoiceId: 'in_acacia', + customerId: mockCustomer.id, + subscriptionId: mockSubscription.id, + error: expect.any(Error), + }); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.ts b/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.ts new file mode 100644 index 00000000000..36307fe1ec9 --- /dev/null +++ b/packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.ts @@ -0,0 +1,389 @@ +/* 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 Stripe from 'stripe'; +import Container from 'typedi'; +import { + CollectionReference, + DocumentData, + Firestore, +} from '@google-cloud/firestore'; +import PQueue from 'p-queue'; +import { StatsD } from 'hot-shots'; + +import { AppConfig, AuthFirestore } from '../../lib/types'; +import { ConfigType } from '../../config'; +import { StripeHelper } from '../../lib/payments/stripe'; +import { StripeFirestore } from '../../lib/payments/stripe-firestore'; + +/** + * For RAM-preserving purposes only + */ +const QUEUE_SIZE_LIMIT = 1000; +/** + * For RAM-preserving purposes only + */ +const QUEUE_CONCURRENCY_LIMIT = 3; + +/** + * Top-level fields the acacia API shape carried and the basil shape dropped. A + * mirror doc holding any of them predates the webhook version cutover and has + * to be re-fetched, whatever its values say. + */ +const ACACIA_SUBSCRIPTION_KEYS = [ + 'current_period_end', + 'current_period_start', + 'discount', + 'plan', +]; +const ACACIA_INVOICE_KEYS = [ + 'charge', + 'discount', + 'payment_intent', + 'subscription', + 'total_tax_amounts', +]; + +export const isAcaciaShape = (record: DocumentData | undefined) => + !!record && + [...ACACIA_SUBSCRIPTION_KEYS, ...ACACIA_INVOICE_KEYS].some( + (key) => key in record + ); + +/** + * The completion tally is meant to reconcile, so a follow-up run can be scoped + * to whatever this one could not finish: + * + * customersChecked = customersSkippedDeleted + customersProcessed + * + customersFailed + * subscriptionsChecked = subscriptionsCurrentShape + subscriptionsMissingDoc + * + subscriptionsOutdatedShape + subscriptionsFailed + * invoicesChecked = invoicesCurrentShape + invoicesOutdatedShape + */ +export class FirestoreAcaciaUpdater { + private config: ConfigType; + private firestore: Firestore; + private stripeQueue: PQueue; + private stripe: Stripe; + private stripeFirestore: StripeFirestore; + private customersChecked = 0; + private customersSkippedDeleted = 0; + private customersProcessed = 0; + private customersFailed = 0; + private subscriptionsChecked = 0; + private subscriptionsCurrentShape = 0; + private subscriptionsMissingDoc = 0; + private subscriptionsOutdatedShape = 0; + private subscriptionsFailed = 0; + private subscriptionsInvoiceWalkFailed = 0; + private subscriptionsResynced = 0; + private subscriptionsResyncFailed = 0; + private invoicesChecked = 0; + private invoicesCurrentShape = 0; + private invoicesOutdatedShape = 0; + private invoicesResynced = 0; + private invoicesResyncFailed = 0; + private customerCollectionDbRef: CollectionReference; + private subscriptionCollection: string; + private invoiceCollection: string; + + constructor( + private stripeHelper: StripeHelper, + rateLimit: number, + private log: any, + private dryRun: boolean + ) { + this.stripe = this.stripeHelper.stripe; + + const config = Container.get(AppConfig); + this.config = config; + + const firestore = Container.get(AuthFirestore); + this.firestore = firestore; + + const prefix = `${this.config.authFirestore.prefix}stripe-`; + this.customerCollectionDbRef = this.firestore.collection( + `${prefix}customers` + ); + this.subscriptionCollection = `${prefix}subscriptions`; + this.invoiceCollection = `${prefix}invoices`; + + this.stripeFirestore = new StripeFirestore( + this.firestore, + this.customerCollectionDbRef, + this.stripe, + prefix, + Container.get(StatsD), + this.log + ); + + this.stripeQueue = new PQueue({ + intervalCap: rateLimit, + interval: 1000, + }); + } + + private async enqueueRequest(request: () => Promise): Promise { + return this.stripeQueue.add(request) as Promise; + } + + async run(): Promise { + this.log.info('firestore-acacia-update-start', { + dryRun: this.dryRun, + }); + + const queue = new PQueue({ concurrency: QUEUE_CONCURRENCY_LIMIT }); + + await this.stripe.customers + .list({ + limit: 25, + }) + .autoPagingEach(async (customer) => { + if (queue.size + queue.pending >= QUEUE_SIZE_LIMIT) { + await queue.onSizeLessThan( + QUEUE_SIZE_LIMIT - QUEUE_CONCURRENCY_LIMIT + ); + } + + queue.add(() => { + return this.processCustomer(customer); + }); + }); + + await queue.onIdle(); + + this.log.info('firestore-acacia-update-complete', { + dryRun: this.dryRun, + customersChecked: this.customersChecked, + customersSkippedDeleted: this.customersSkippedDeleted, + customersProcessed: this.customersProcessed, + customersFailed: this.customersFailed, + subscriptionsChecked: this.subscriptionsChecked, + subscriptionsCurrentShape: this.subscriptionsCurrentShape, + subscriptionsMissingDoc: this.subscriptionsMissingDoc, + subscriptionsOutdatedShape: this.subscriptionsOutdatedShape, + subscriptionsFailed: this.subscriptionsFailed, + subscriptionsInvoiceWalkFailed: this.subscriptionsInvoiceWalkFailed, + subscriptionsResynced: this.subscriptionsResynced, + subscriptionsResyncFailed: this.subscriptionsResyncFailed, + invoicesChecked: this.invoicesChecked, + invoicesCurrentShape: this.invoicesCurrentShape, + invoicesOutdatedShape: this.invoicesOutdatedShape, + invoicesResynced: this.invoicesResynced, + invoicesResyncFailed: this.invoicesResyncFailed, + }); + } + + async processCustomer( + stripeCustomer: Stripe.Customer | Stripe.DeletedCustomer + ): Promise { + try { + this.customersChecked++; + + if (stripeCustomer.deleted) { + this.customersSkippedDeleted++; + return; + } + + if (!stripeCustomer.metadata.userid) { + throw new Error( + `Stripe customer ${stripeCustomer.id} is missing a userid` + ); + } + + const subscriptions = await this.enqueueRequest(() => + this.stripe.subscriptions + .list({ + customer: stripeCustomer.id, + limit: 100, + status: 'all', + }) + .autoPagingToArray({ limit: 10000 }) + ); + + for (const stripeSubscription of subscriptions) { + await this.processSubscription( + stripeCustomer.id, + stripeCustomer.metadata.userid, + stripeSubscription + ); + } + + this.customersProcessed++; + } catch (e) { + this.customersFailed++; + this.log.error('error-processing-customer', { + customerId: stripeCustomer.id, + error: e, + }); + } + } + + /** + * A mirror doc missing altogether is drift rather than an outdated shape, so + * it is left to the sync checker. + */ + async processSubscription( + customerId: string, + uid: string, + stripeSubscription: Stripe.Subscription + ): Promise { + try { + this.subscriptionsChecked++; + + const subscriptionDoc = await this.customerCollectionDbRef + .doc(uid) + .collection(this.subscriptionCollection) + .doc(stripeSubscription.id) + .get(); + + if (!subscriptionDoc.exists) { + this.subscriptionsMissingDoc++; + } else if (isAcaciaShape(subscriptionDoc.data())) { + this.recordOutdated( + 'subscription', + customerId, + uid, + stripeSubscription.id + ); + await this.resyncSubscription(stripeSubscription.id, customerId, uid); + } else { + this.subscriptionsCurrentShape++; + } + + await this.processInvoices(customerId, uid, stripeSubscription.id); + } catch (e) { + this.subscriptionsFailed++; + this.log.error('error-processing-subscription', { + customerId, + uid, + subscriptionId: stripeSubscription.id, + error: e, + }); + } + } + + /** + * Checks every invoice for a subscription, marks outdated ones, and resyncs them. + */ + async processInvoices( + customerId: string, + uid: string, + subscriptionId: string + ): Promise { + try { + const invoiceDocs = await this.customerCollectionDbRef + .doc(uid) + .collection(this.subscriptionCollection) + .doc(subscriptionId) + .collection(this.invoiceCollection) + .get(); + + for (const invoiceDoc of invoiceDocs.docs) { + this.invoicesChecked++; + + if (isAcaciaShape(invoiceDoc.data())) { + this.recordOutdated( + 'invoice', + customerId, + uid, + subscriptionId, + invoiceDoc.id + ); + await this.resyncInvoice(invoiceDoc.id, customerId, subscriptionId); + } else { + this.invoicesCurrentShape++; + } + } + } catch (e) { + this.subscriptionsInvoiceWalkFailed++; + this.log.error('error-processing-invoices', { + customerId, + uid, + subscriptionId, + error: e, + }); + } + } + + recordOutdated( + type: 'subscription' | 'invoice', + customerId: string, + uid: string, + subscriptionId: string, + invoiceId: string | null = null + ): void { + if (type === 'subscription') { + this.subscriptionsOutdatedShape++; + } else { + this.invoicesOutdatedShape++; + } + + this.log.warn('firestore-acacia-record-outdated', { + type, + customerId, + uid, + subscriptionId, + invoiceId, + }); + } + + /** + * Rewrites the subscription doc from the Stripe API, which the SDK pins to the + * current version. `fetchAndInsertSubscription` reads Stripe inside the + * transaction that locks the doc, so a webhook writing concurrently cannot + * lose its update to a snapshot taken before that transaction opened. + */ + async resyncSubscription( + subscriptionId: string, + customerId: string, + uid: string + ): Promise { + if (this.dryRun) { + return; + } + + try { + await this.enqueueRequest(() => + this.stripeFirestore.fetchAndInsertSubscription(subscriptionId, uid) + ); + this.subscriptionsResynced++; + } catch (e) { + this.subscriptionsResyncFailed++; + this.log.error('failed-to-resync-subscription', { + subscriptionId, + customerId, + uid, + error: e, + }); + } + } + + async resyncInvoice( + invoiceId: string, + customerId: string, + subscriptionId: string + ): Promise { + if (this.dryRun) { + return; + } + + try { + await this.enqueueRequest(() => + this.stripeFirestore.fetchAndInsertInvoice( + invoiceId, + Math.floor(Date.now() / 1000) + ) + ); + this.invoicesResynced++; + } catch (e) { + this.invoicesResyncFailed++; + this.log.error('failed-to-resync-invoice', { + invoiceId, + customerId, + subscriptionId, + error: e, + }); + } + } +}