feat: script to check and revalidate firestore <-> stripe sync

This commit is contained in:
Julian Poyourow 2025-12-19 11:27:13 -08:00
parent c7371cd074
commit e3b06527f4
No known key found for this signature in database
GPG Key ID: EA0570ABC73D47D3
9 changed files with 964 additions and 47 deletions

View File

@ -110,7 +110,7 @@ export class StripeFirestore extends StripeFirestoreBase {
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
if (!customer.id) throw new Error('Customer ID must be provided');
return this.fetchAndInsertCustomer(customer.id);
return this.legacyFetchAndInsertCustomer(customer.id);
} else {
throw err;
}
@ -155,7 +155,7 @@ export class StripeFirestore extends StripeFirestoreBase {
await this.insertSubscriptionRecord(subscription);
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
await this.fetchAndInsertCustomer(subscription.customer as string);
await this.legacyFetchAndInsertCustomer(subscription.customer as string);
} else {
throw err;
}
@ -176,7 +176,7 @@ export class StripeFirestore extends StripeFirestoreBase {
await this.insertPaymentMethodRecord(paymentMethod);
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
await this.fetchAndInsertCustomer(paymentMethod.customer as string);
await this.legacyFetchAndInsertCustomer(paymentMethod.customer as string);
return this.insertPaymentMethodRecord(paymentMethod);
} else {
throw err;

View File

@ -3502,7 +3502,7 @@ export class StripeHelper extends StripeHelperBase {
return;
}
return this.stripeFirestore.fetchAndInsertCustomer(customerId);
return this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
}
/**
@ -3525,7 +3525,7 @@ export class StripeHelper extends StripeHelperBase {
CUSTOMER_RESOURCE
);
if (!customer.deleted && !customer.currency) {
await this.stripeFirestore.fetchAndInsertCustomer(customerId);
await this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
const subscription =
await this.stripe.subscriptions.retrieve(subscriptionId);
return subscription;
@ -3566,7 +3566,7 @@ export class StripeHelper extends StripeHelperBase {
);
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
await this.stripeFirestore.fetchAndInsertCustomer(customerId);
await this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
await this.stripeFirestore.fetchAndInsertInvoice(
invoiceId,
event.created

View File

@ -0,0 +1,54 @@
/* 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 { FirestoreStripeSyncChecker } from './check-firestore-stripe-sync/check-firestore-stripe-sync';
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
)
.parse(process.argv);
const { stripeHelper, log } = await setupProcessingTaskObjects(
'check-firestore-stripe-sync'
);
const rateLimit = parseRateLimit(program.rateLimit);
const syncChecker = new FirestoreStripeSyncChecker(
stripeHelper,
rateLimit,
log,
);
await syncChecker.run();
return 0;
}
if (require.main === module) {
let exitStatus = 1;
init()
.then((result) => {
exitStatus = result;
})
.catch((err) => {
console.error(err);
})
.finally(() => {
process.exit(exitStatus);
});
}

View File

@ -0,0 +1,240 @@
/* 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, Firestore } from '@google-cloud/firestore';
import PQueue from 'p-queue';
import { AppConfig, AuthFirestore } from '../../lib/types';
import { ConfigType } from '../../config';
import { StripeHelper } from '../../lib/payments/stripe';
/**
* For RAM-preserving pruposes only
*/
const QUEUE_SIZE_LIMIT = 1000;
/**
* For RAM-preserving pruposes only
*/
const QUEUE_CONCURRENCY_LIMIT = 3;
export class FirestoreStripeSyncChecker {
private config: ConfigType;
private firestore: Firestore;
private stripeQueue: PQueue;
private stripe: Stripe;
private customersCheckedCount = 0;
private subscriptionsCheckedCount = 0;
private outOfSyncCount = 0;
private customersMissingInFirestore = 0;
private subscriptionsMissingInFirestore = 0;
private customersMismatched = 0;
private subscriptionsMismatched = 0;
private customerCollectionDbRef: CollectionReference;
private subscriptionCollection: string;
constructor(
private stripeHelper: StripeHelper,
rateLimit: number,
private log: any,
) {
this.stripe = this.stripeHelper.stripe;
const config = Container.get<ConfigType>(AppConfig);
this.config = config;
const firestore = Container.get<Firestore>(AuthFirestore);
this.firestore = firestore;
this.customerCollectionDbRef = this.firestore.collection(`${this.config.authFirestore.prefix}stripe-customers`);
this.subscriptionCollection = `${this.config.authFirestore.prefix}stripe-subscriptions`;
this.stripeQueue = new PQueue({
intervalCap: rateLimit,
interval: 1000,
});
}
private async enqueueRequest<T>(request: () => Promise<T>): Promise<T> {
return this.stripeQueue.add(request) as Promise<T>;
}
async run(): Promise<void> {
this.log.info('firestore-stripe-sync-check-start');
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.checkCustomerSync(customer);
});
});
await queue.onIdle();
this.log.info('firestore-stripe-sync-check-complete', {
customersCheckedCount: this.customersCheckedCount,
subscriptionsCheckedCount: this.subscriptionsCheckedCount,
outOfSyncCount: this.outOfSyncCount,
customersMissingInFirestore: this.customersMissingInFirestore,
subscriptionsMissingInFirestore: this.subscriptionsMissingInFirestore,
customersMismatched: this.customersMismatched,
subscriptionsMismatched: this.subscriptionsMismatched,
});
}
async checkCustomerSync(stripeCustomer: Stripe.Customer | Stripe.DeletedCustomer): Promise<void> {
try {
if (stripeCustomer.deleted) {
return;
}
this.customersCheckedCount++;
if (!stripeCustomer.metadata.userid) {
throw new Error(`Stripe customer ${stripeCustomer.id} is missing a userid`);
}
const firestoreCustomerDoc = await this.customerCollectionDbRef
.doc(stripeCustomer.metadata.userid)
.get();
if (!firestoreCustomerDoc.exists) {
this.handleOutOfSync(stripeCustomer.id, 'Customer exists in Stripe but not in Firestore', 'customer_missing');
return;
}
const firestoreCustomer = firestoreCustomerDoc.data();
if (!this.isCustomerInSync(firestoreCustomer, stripeCustomer)) {
this.handleOutOfSync(stripeCustomer.id, 'Customer mismatch', 'customer_mismatch');
return;
}
const subscriptions = await this.enqueueRequest(() =>
this.stripe.subscriptions.list({
customer: stripeCustomer.id,
limit: 100,
status: "all",
})
);
for (const stripeSubscription of subscriptions.data) {
await this.checkSubscriptionSync(stripeCustomer.id, stripeCustomer.metadata.userid, stripeSubscription);
}
} catch (e) {
this.log.error('error-checking-customer', {
customerId: stripeCustomer.id,
error: e,
});
}
}
async checkSubscriptionSync(customerId: string, uid: string, stripeSubscription: Stripe.Subscription): Promise<void> {
try {
this.subscriptionsCheckedCount++;
const subscriptionDoc = await this.customerCollectionDbRef
.doc(uid)
.collection(this.subscriptionCollection)
.doc(stripeSubscription.id)
.get();
if (!subscriptionDoc.exists) {
this.handleOutOfSync(customerId, 'Subscription exists in Stripe but not in Firestore', 'subscription_missing', stripeSubscription.id);
return;
}
const firestoreSubscription = subscriptionDoc.data();
if (!this.isSubscriptionInSync(firestoreSubscription, stripeSubscription)) {
this.handleOutOfSync(customerId, 'Subscription data mismatch', 'subscription_mismatch', stripeSubscription.id);
return;
}
} catch (e) {
this.log.error('error-checking-subscription', {
customerId,
subscriptionId: stripeSubscription.id,
error: e,
});
}
}
isCustomerInSync(firestoreCustomer: any, stripeCustomer: Stripe.Customer): boolean {
for (const key of Object.keys(stripeCustomer)) {
if (
stripeCustomer[key] !== null
&& stripeCustomer[key] !== undefined
&& !["string", "number"].includes(typeof stripeCustomer[key])
) continue;
if (firestoreCustomer[key] !== stripeCustomer[key]) {
return false;
}
}
return true;
}
isSubscriptionInSync(firestoreSubscription: any, stripeSubscription: Stripe.Subscription): boolean {
for (const key of Object.keys(stripeSubscription)) {
if (
stripeSubscription[key] !== null
&& stripeSubscription[key] !== undefined
&& !["string", "number"].includes(typeof stripeSubscription[key])
) continue;
if (firestoreSubscription[key] !== stripeSubscription[key]) {
return false;
}
}
return true;
}
handleOutOfSync(customerId: string, reason: string, type: string, subscriptionId: string | null = null): void {
this.outOfSyncCount++;
if (type === 'customer_missing') {
this.customersMissingInFirestore++;
} else if (type === 'customer_mismatch') {
this.customersMismatched++;
} else if (type === 'subscription_missing') {
this.subscriptionsMissingInFirestore++;
} else if (type === 'subscription_mismatch') {
this.subscriptionsMismatched++;
}
this.log.warn('firestore-stripe-out-of-sync', {
customerId,
subscriptionId,
reason,
type,
});
this.triggerResync(customerId);
}
async triggerResync(customerId: string): Promise<void> {
try {
await this.enqueueRequest(() =>
this.stripe.customers.update(customerId, {
metadata: {
forcedResyncAt: Date.now().toString(),
},
})
);
} catch (e) {
this.log.error('failed-to-trigger-resync', {
customerId,
error: e,
});
}
}
}

View File

@ -70,13 +70,13 @@ describe('StripeFirestore', () => {
describe('retrieveAndFetchCustomer', () => {
it('fetches a customer that was already retrieved', async () => {
stripeFirestore.retrieveCustomer = sinon.fake.resolves(customer);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result = await stripeFirestore.retrieveAndFetchCustomer(
customer.id
);
assert.deepEqual(result, customer);
assert.calledOnce(stripeFirestore.retrieveCustomer);
assert.notCalled(stripeFirestore.fetchAndInsertCustomer);
assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer);
});
it('fetches a customer that hasnt been retrieved', async () => {
@ -86,23 +86,23 @@ describe('StripeFirestore', () => {
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
)
);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves(customer);
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves(customer);
const result = await stripeFirestore.retrieveAndFetchCustomer(
customer.id
);
assert.deepEqual(result, customer);
assert.calledOnce(stripeFirestore.retrieveCustomer);
assert.calledOnce(stripeFirestore.fetchAndInsertCustomer);
assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer);
});
it('passes ignoreErrors through to fetchAndInsertCustomer', async () => {
it('passes ignoreErrors through to legacyFetchAndInsertCustomer', async () => {
stripeFirestore.retrieveCustomer = sinon.fake.rejects(
newFirestoreStripeError(
'Not found',
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
)
);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves(customer);
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves(customer);
const result = await stripeFirestore.retrieveAndFetchCustomer(
customer.id,
true
@ -110,7 +110,7 @@ describe('StripeFirestore', () => {
assert.deepEqual(result, customer);
assert.calledOnce(stripeFirestore.retrieveCustomer);
assert.calledOnceWithExactly(
stripeFirestore.fetchAndInsertCustomer,
stripeFirestore.legacyFetchAndInsertCustomer,
customer.id,
true
);
@ -141,13 +141,13 @@ describe('StripeFirestore', () => {
it('fetches a subscription that was already retrieved', async () => {
stripeFirestore.retrieveSubscription = sinon.fake.resolves(subscription);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result = await stripeFirestore.retrieveAndFetchSubscription(
subscription.id
);
assert.deepEqual(result, subscription);
assert.calledOnce(stripeFirestore.retrieveSubscription);
assert.notCalled(stripeFirestore.fetchAndInsertCustomer);
assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer);
});
it('fetches a subscription that hasnt been retrieved', async () => {
@ -160,20 +160,20 @@ describe('StripeFirestore', () => {
stripe.subscriptions = {
retrieve: sinon.fake.resolves(subscription),
};
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result = await stripeFirestore.retrieveAndFetchSubscription(
subscription.id
);
assert.deepEqual(result, subscription);
assert.calledOnce(stripeFirestore.retrieveSubscription);
assert.calledOnce(stripeFirestore.fetchAndInsertCustomer);
assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer);
assert.calledOnceWithExactly(
stripe.subscriptions.retrieve,
subscription.id
);
});
it('passes ignoreErrors through to fetchAndInsertCustomer', async () => {
it('passes ignoreErrors through to legacyFetchAndInsertCustomer', async () => {
stripeFirestore.retrieveSubscription = sinon.fake.rejects(
newFirestoreStripeError(
'Not found',
@ -183,7 +183,7 @@ describe('StripeFirestore', () => {
stripe.subscriptions = {
retrieve: sinon.fake.resolves(subscription),
};
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result = await stripeFirestore.retrieveAndFetchSubscription(
subscription.id,
true
@ -191,7 +191,7 @@ describe('StripeFirestore', () => {
assert.deepEqual(result, subscription);
assert.calledOnce(stripeFirestore.retrieveSubscription);
assert.calledOnceWithExactly(
stripeFirestore.fetchAndInsertCustomer,
stripeFirestore.legacyFetchAndInsertCustomer,
subscription.customer,
true
);
@ -256,7 +256,7 @@ describe('StripeFirestore', () => {
});
});
describe('fetchAndInsertCustomer', () => {
describe('legacyFetchAndInsertCustomer', () => {
let tx;
beforeEach(() => {
@ -293,13 +293,14 @@ describe('StripeFirestore', () => {
.resolves(customer),
};
const result = await stripeFirestore.fetchAndInsertCustomer(customer.id);
const result = await stripeFirestore.legacyFetchAndInsertCustomer(customer.id);
assert.deepEqual(result, customer);
assert.calledTwice(stripe.customers.retrieve);
assert.calledOnceWithExactly(stripe.subscriptions.list, {
customer: customer.id,
status: "all"
status: "all",
limit: 100,
});
assert.callCount(tx.set, 2); // customer + subscription
assert.callCount(tx.get, 2); // customer + subscription
@ -316,7 +317,7 @@ describe('StripeFirestore', () => {
};
try {
await stripeFirestore.fetchAndInsertCustomer(customer.id);
await stripeFirestore.legacyFetchAndInsertCustomer(customer.id);
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.name, FirestoreStripeError.STRIPE_CUSTOMER_DELETED);
@ -330,7 +331,7 @@ describe('StripeFirestore', () => {
.resolves(deletedCustomer),
};
const result = await stripeFirestore.fetchAndInsertCustomer(
const result = await stripeFirestore.legacyFetchAndInsertCustomer(
customer.id,
true
);
@ -348,7 +349,7 @@ describe('StripeFirestore', () => {
.resolves(noMetadataCustomer),
};
const result = await stripeFirestore.fetchAndInsertCustomer(
const result = await stripeFirestore.legacyFetchAndInsertCustomer(
customer.id,
true
);
@ -370,7 +371,7 @@ describe('StripeFirestore', () => {
};
try {
await stripeFirestore.fetchAndInsertCustomer(customer.id);
await stripeFirestore.legacyFetchAndInsertCustomer(customer.id);
assert.fail('should have thrown');
} catch (err) {
assert.equal(
@ -384,13 +385,13 @@ describe('StripeFirestore', () => {
describe('insertCustomerRecordWithBackfill', () => {
it('retrieves a record', async () => {
stripeFirestore.retrieveCustomer = sinon.fake.resolves(customer);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves(customer);
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves(customer);
await stripeFirestore.insertCustomerRecordWithBackfill(
'fxauid',
customer
);
assert.calledOnce(stripeFirestore.retrieveCustomer);
assert.notCalled(stripeFirestore.fetchAndInsertCustomer);
assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer);
});
it('backfills on customer not found', async () => {
@ -400,13 +401,13 @@ describe('StripeFirestore', () => {
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
)
);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
await stripeFirestore.insertCustomerRecordWithBackfill(
'fxauid',
customer
);
assert.calledOnce(stripeFirestore.retrieveCustomer);
assert.calledOnce(stripeFirestore.fetchAndInsertCustomer);
assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer);
});
});
@ -469,13 +470,13 @@ describe('StripeFirestore', () => {
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
)
);
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result = await stripeFirestore.insertSubscriptionRecordWithBackfill(
deepCopy(subscription1)
);
assert.isUndefined(result, {});
assert.calledOnce(stripeFirestore.insertSubscriptionRecord);
assert.calledOnce(stripeFirestore.fetchAndInsertCustomer);
assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer);
});
});
@ -966,14 +967,14 @@ describe('StripeFirestore', () => {
describe('insertPaymentMethodRecordWithBackfill', () => {
it('inserts a record', async () => {
stripeFirestore.insertPaymentMethodRecord = sinon.fake.resolves({});
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
const result =
await stripeFirestore.insertPaymentMethodRecordWithBackfill(
deepCopy(paymentMethod)
);
assert.isUndefined(result, {});
assert.calledOnce(stripeFirestore.insertPaymentMethodRecord);
assert.notCalled(stripeFirestore.fetchAndInsertCustomer);
assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer);
});
it('backfills on customer not found', async () => {
@ -988,12 +989,12 @@ describe('StripeFirestore', () => {
)
);
insertStub.onCall(1).resolves({});
stripeFirestore.fetchAndInsertCustomer = sinon.fake.resolves({});
stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({});
await stripeFirestore.insertPaymentMethodRecordWithBackfill(
deepCopy(paymentMethod)
);
assert.calledTwice(stripeFirestore.insertPaymentMethodRecord);
assert.calledOnce(stripeFirestore.fetchAndInsertCustomer);
assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer);
});
});

View File

@ -4253,14 +4253,14 @@ describe('#integration - StripeHelper', () => {
const customerSecond = deepCopy(customer1);
const expandStub = sandbox.stub(stripeHelper, 'expandResource');
stripeHelper.stripeFirestore = {
fetchAndInsertCustomer: sandbox.stub().resolves({}),
legacyFetchAndInsertCustomer: sandbox.stub().resolves({}),
};
expandStub.onFirstCall().resolves(customer);
expandStub.onSecondCall().resolves(customerSecond);
const result = await stripeHelper.fetchCustomer(existingCustomer.uid);
assert.deepEqual(result, customerSecond);
sinon.assert.calledOnceWithExactly(
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
stripeHelper.stripeFirestore.legacyFetchAndInsertCustomer,
customer.id
);
sinon.assert.calledTwice(expandStub);
@ -7112,7 +7112,8 @@ describe('#integration - StripeHelper', () => {
);
sinon.assert.calledOnceWithExactly(
stripeFirestore.fetchAndInsertCustomer,
event.data.object.customer
event.data.object.customer,
event.created
);
});
@ -7132,7 +7133,8 @@ describe('#integration - StripeHelper', () => {
await stripeHelper.processWebhookEventToFirestore(event);
sinon.assert.calledOnceWithExactly(
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
eventCustomerUpdated.data.object.id
eventCustomerUpdated.data.object.id,
event.created
);
});
}
@ -7168,7 +7170,8 @@ describe('#integration - StripeHelper', () => {
);
sinon.assert.calledOnceWithExactly(
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
event.data.object.customer
event.data.object.customer,
event.created
);
} else {
sinon.assert.calledOnceWithExactly(

View File

@ -0,0 +1,513 @@
/* 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/. */
'use strict';
import sinon from 'sinon';
import { expect } from 'chai';
import Container from 'typedi';
import { ConfigType } from '../../config';
import { AppConfig, AuthFirestore } from '../../lib/types';
import { FirestoreStripeSyncChecker } from '../../scripts/check-firestore-stripe-sync/check-firestore-stripe-sync';
import Stripe from 'stripe';
import { StripeHelper } from '../../lib/payments/stripe';
import customer1 from '../local/payments/fixtures/stripe/customer1.json';
import subscription1 from '../local/payments/fixtures/stripe/subscription1.json';
const mockCustomer = customer1 as unknown as Stripe.Customer;
const mockSubscription = subscription1 as unknown as Stripe.Subscription;
const mockConfig = {
authFirestore: {
prefix: 'mock-fxa-',
},
} as unknown as ConfigType;
describe('FirestoreStripeSyncChecker', () => {
let syncChecker: FirestoreStripeSyncChecker;
let stripeStub: Stripe;
let stripeHelperStub: StripeHelper;
let firestoreStub: any;
let logStub: any;
beforeEach(() => {
firestoreStub = {
collection: sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub(),
}),
}),
};
Container.set(AuthFirestore, firestoreStub);
Container.set(AppConfig, mockConfig);
stripeStub = {
on: sinon.stub(),
customers: {
list: sinon.stub(),
update: sinon.stub(),
},
} as unknown as Stripe;
stripeHelperStub = {
stripe: stripeStub,
} as unknown as StripeHelper;
logStub = {
info: sinon.stub(),
warn: sinon.stub(),
error: sinon.stub(),
};
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
});
afterEach(() => {
Container.reset();
});
describe('run', () => {
let autoPagingEachStub: sinon.SinonStub;
let checkCustomerSyncStub: sinon.SinonStub;
beforeEach(async () => {
autoPagingEachStub = sinon.stub().callsFake(async (callback: any) => {
await callback(mockCustomer);
});
stripeStub.customers.list = sinon.stub().returns({
autoPagingEach: autoPagingEachStub,
}) as any;
checkCustomerSyncStub = sinon.stub().resolves();
syncChecker.checkCustomerSync = checkCustomerSyncStub;
await syncChecker.run();
});
it('calls Stripe customers.list', () => {
sinon.assert.calledWith(stripeStub.customers.list as any, {
limit: 25,
});
});
it('calls autoPagingEach to iterate through all customers', () => {
sinon.assert.calledOnce(autoPagingEachStub);
});
it('checks sync for each customer', () => {
sinon.assert.calledOnce(checkCustomerSyncStub);
sinon.assert.calledWith(checkCustomerSyncStub, mockCustomer);
});
it('logs summary', () => {
sinon.assert.calledWith(logStub.info, 'firestore-stripe-sync-check-complete', sinon.match.object);
});
});
describe('checkCustomerSync', () => {
let checkSubscriptionSyncStub: sinon.SinonStub;
beforeEach(() => {
checkSubscriptionSyncStub = sinon.stub().resolves();
});
describe('customer in sync', () => {
const mockFirestoreCustomer = Object.assign({}, mockCustomer);
beforeEach(async () => {
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: true,
data: sinon.stub().returns(mockFirestoreCustomer),
}),
collection: sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: true,
data: sinon.stub().returns({status: 'active'}),
}),
}),
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
stripeStub.subscriptions = {
list: sinon.stub().resolves({
data: [mockSubscription],
}),
} as any;
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.checkSubscriptionSync = checkSubscriptionSyncStub;
await syncChecker.checkCustomerSync(mockCustomer);
});
it('checks subscription sync', () => {
sinon.assert.calledWith(checkSubscriptionSyncStub, mockCustomer.id, mockCustomer.metadata.userid, mockSubscription);
});
it('does not log out of sync', () => {
sinon.assert.notCalled(logStub.warn);
});
});
describe('customer missing in Firestore', () => {
let handleOutOfSyncStub: sinon.SinonStub;
beforeEach(async () => {
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: false,
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
handleOutOfSyncStub = sinon.stub();
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.handleOutOfSync = handleOutOfSyncStub;
await syncChecker.checkCustomerSync(mockCustomer);
});
it('handles out of sync', () => {
sinon.assert.calledWith(handleOutOfSyncStub, mockCustomer.id, 'Customer exists in Stripe but not in Firestore', 'customer_missing');
});
});
describe('customer metadata mismatch', () => {
let handleOutOfSyncStub: sinon.SinonStub;
const mismatchedFirestoreCustomer = {
email: 'different@example.com',
created: mockCustomer.created,
};
beforeEach(async () => {
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: true,
data: sinon.stub().returns(mismatchedFirestoreCustomer),
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
handleOutOfSyncStub = sinon.stub();
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.handleOutOfSync = handleOutOfSyncStub;
await syncChecker.checkCustomerSync(mockCustomer);
});
it('handles out of sync', () => {
sinon.assert.calledWith(handleOutOfSyncStub, mockCustomer.id, 'Customer mismatch', 'customer_mismatch');
});
});
describe('deleted customer', () => {
beforeEach(async () => {
const deletedCustomer = {
id: mockCustomer.id,
deleted: true,
};
await syncChecker.checkCustomerSync(deletedCustomer as any);
});
it('skips deleted customers', () => {
expect(syncChecker['customersCheckedCount']).eq(0);
});
});
describe('error checking customer', () => {
beforeEach(async () => {
firestoreStub.collection = sinon.stub().returns({
doc: sinon.stub().throws(new Error('Firestore error')),
});
await syncChecker.checkCustomerSync(mockCustomer);
});
it('logs error', () => {
sinon.assert.calledWith(logStub.error, 'error-checking-customer', sinon.match.object);
});
});
});
describe('checkSubscriptionSync', () => {
let handleOutOfSyncStub: sinon.SinonStub;
const mockFirestoreSubscription = Object.assign({}, mockSubscription);
beforeEach(() => {
handleOutOfSyncStub = sinon.stub();
syncChecker.handleOutOfSync = handleOutOfSyncStub;
});
describe('subscription in sync', () => {
beforeEach(async () => {
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
collection: sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: true,
data: sinon.stub().returns(mockFirestoreSubscription),
}),
}),
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
// Recreate syncChecker with new firestore stub
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.handleOutOfSync = handleOutOfSyncStub;
await syncChecker.checkSubscriptionSync(mockCustomer.id, mockCustomer.metadata.userid, mockSubscription);
});
it('does not call handleOutOfSync', () => {
sinon.assert.notCalled(handleOutOfSyncStub);
});
});
describe('subscription missing in Firestore', () => {
beforeEach(async () => {
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
collection: sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: false,
}),
}),
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
// Recreate syncChecker with new firestore stub
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.handleOutOfSync = handleOutOfSyncStub;
await syncChecker.checkSubscriptionSync(mockCustomer.id, mockCustomer.metadata.userid, mockSubscription);
});
it('handles out of sync', () => {
sinon.assert.calledWith(handleOutOfSyncStub, mockCustomer.id, 'Subscription exists in Stripe but not in Firestore', 'subscription_missing', mockSubscription.id);
});
});
describe('subscription data mismatch', () => {
beforeEach(async () => {
const mismatchedSubscription = {
...mockFirestoreSubscription,
status: 'canceled',
};
const collectionStub = sinon.stub().returns({
doc: sinon.stub().returns({
collection: sinon.stub().returns({
doc: sinon.stub().returns({
get: sinon.stub().resolves({
exists: true,
data: sinon.stub().returns(mismatchedSubscription),
}),
}),
}),
}),
});
firestoreStub.collection = collectionStub;
Container.set(AuthFirestore, firestoreStub);
// Recreate syncChecker with new firestore stub
syncChecker = new FirestoreStripeSyncChecker(
stripeHelperStub,
20,
logStub
);
syncChecker.handleOutOfSync = handleOutOfSyncStub;
await syncChecker.checkSubscriptionSync(mockCustomer.id, mockCustomer.metadata.userid, mockSubscription);
});
it('handles out of sync', () => {
sinon.assert.calledWith(handleOutOfSyncStub, mockCustomer.id, 'Subscription data mismatch', 'subscription_mismatch', mockSubscription.id);
});
});
});
describe('isCustomerInSync', () => {
it('returns true when customer data matches', () => {
const firestoreCustomer = Object.assign({}, mockCustomer);
const result = syncChecker.isCustomerInSync(firestoreCustomer, mockCustomer);
expect(result).true;
});
it('returns false when email differs', () => {
const firestoreCustomer = {
email: 'different@example.com',
created: mockCustomer.created,
};
const result = syncChecker.isCustomerInSync(firestoreCustomer, mockCustomer);
expect(result).false;
});
it('returns false when created timestamp differs', () => {
const firestoreCustomer = {
email: mockCustomer.email,
created: 999999,
};
const result = syncChecker.isCustomerInSync(firestoreCustomer, mockCustomer);
expect(result).false;
});
});
describe('isSubscriptionInSync', () => {
it('returns true when subscription data matches', () => {
const firestoreSubscription = Object.assign({}, mockSubscription);
const result = syncChecker.isSubscriptionInSync(firestoreSubscription, mockSubscription);
expect(result).true;
});
it('returns false when status differs', () => {
const firestoreSubscription = {
status: 'canceled',
current_period_end: mockSubscription.current_period_end,
current_period_start: mockSubscription.current_period_start,
};
const result = syncChecker.isSubscriptionInSync(firestoreSubscription, mockSubscription);
expect(result).false;
});
it('returns false when period end differs', () => {
const firestoreSubscription = {
status: mockSubscription.status,
current_period_end: 999999,
current_period_start: mockSubscription.current_period_start,
};
const result = syncChecker.isSubscriptionInSync(firestoreSubscription, mockSubscription);
expect(result).false;
});
});
describe('handleOutOfSync', () => {
let triggerResyncStub: sinon.SinonStub;
beforeEach(() => {
triggerResyncStub = sinon.stub().resolves();
syncChecker.triggerResync = triggerResyncStub;
});
it('increments out of sync counter', () => {
const initialCount = syncChecker['outOfSyncCount'];
syncChecker.handleOutOfSync(mockCustomer.id, 'Test reason', 'customer_missing');
expect(syncChecker['outOfSyncCount']).eq(initialCount + 1);
});
it('increments customer missing counter', () => {
const initialCount = syncChecker['customersMissingInFirestore'];
syncChecker.handleOutOfSync(mockCustomer.id, 'Test reason', 'customer_missing');
expect(syncChecker['customersMissingInFirestore']).eq(initialCount + 1);
});
it('increments subscription missing counter', () => {
const initialCount = syncChecker['subscriptionsMissingInFirestore'];
syncChecker.handleOutOfSync(mockCustomer.id, 'Test reason', 'subscription_missing', mockSubscription.id);
expect(syncChecker['subscriptionsMissingInFirestore']).eq(initialCount + 1);
});
it('logs out-of-sync warning', () => {
syncChecker.handleOutOfSync(mockCustomer.id, 'Test reason', 'customer_missing', mockSubscription.id);
sinon.assert.calledWith(logStub.warn, 'firestore-stripe-out-of-sync', {
customerId: mockCustomer.id,
subscriptionId: mockSubscription.id,
reason: 'Test reason',
type: 'customer_missing',
});
});
it('triggers resync', () => {
syncChecker.handleOutOfSync(mockCustomer.id, 'Test reason', 'customer_missing');
sinon.assert.calledWith(triggerResyncStub, mockCustomer.id);
});
});
describe('triggerResync', () => {
it('updates customer metadata with forcedResyncAt', async () => {
stripeStub.customers.update = sinon.stub().resolves();
await syncChecker.triggerResync(mockCustomer.id);
sinon.assert.calledWith(stripeStub.customers.update as any, mockCustomer.id, sinon.match({
metadata: {
forcedResyncAt: sinon.match.string,
},
}));
});
it('logs error on failure', async () => {
stripeStub.customers.update = sinon.stub().rejects(new Error('Update failed'));
await syncChecker.triggerResync(mockCustomer.id);
sinon.assert.calledWith(logStub.error, 'failed-to-trigger-resync', sinon.match.object);
});
});
});

View File

@ -74,7 +74,7 @@ export class StripeFirestore {
return customer;
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
return this.fetchAndInsertCustomer(customerId, ignoreErrors);
return this.legacyFetchAndInsertCustomer(customerId, ignoreErrors);
}
throw err;
}
@ -96,7 +96,7 @@ export class StripeFirestore {
const subscription = await this.stripe.subscriptions.retrieve(
subscriptionId
);
await this.fetchAndInsertCustomer(
await this.legacyFetchAndInsertCustomer(
subscription.customer as string,
ignoreErrors
);
@ -142,6 +142,111 @@ export class StripeFirestore {
* loads the customers subscriptions into Firestore.
*/
async fetchAndInsertCustomer(
customerId: string,
eventTime: number,
ignoreErrors: boolean = false
) {
const [customer, subscriptions] = await Promise.all([
this.stripe.customers.retrieve(customerId),
this.stripe.subscriptions
.list({
customer: customerId,
status: "all",
limit: 100,
})
.autoPagingToArray({ limit: 10000 }),
]);
if (customer.deleted) {
if (ignoreErrors) {
return customer;
}
throw new FirestoreStripeErrorBuilder(
`Customer ${customerId} was deleted`,
FirestoreStripeError.STRIPE_CUSTOMER_DELETED,
customerId
);
}
const customerUid = customer.metadata.userid;
if (!customerUid) {
if (ignoreErrors) {
return customer;
}
throw new FirestoreStripeErrorBuilder(
`Customer ${customerId} has no uid`,
FirestoreStripeError.STRIPE_CUSTOMER_MISSING_UID,
customerId
);
}
await this.firestore.runTransaction(async (tx) => {
const storedCustomer = await tx.get(
this.customerCollectionDbRef
.doc(customerUid)
)
const subscriptionsToUpdate: Stripe.Subscription[] = [];
for (const subscription of subscriptions) {
const storedSubscription = await tx.get(
this.customerCollectionDbRef.doc(customerUid)
.collection(this.subscriptionCollection)
.doc(subscription.id),
);
const storedSubscriptionEventTime: number | undefined = storedSubscription.data()?.stripeEventCreatedTime;
// stripeEventCreatedTime can be missing since we didn't previously write this value
if (!storedSubscriptionEventTime || storedSubscriptionEventTime < eventTime) {
// If we've already stored a newer record from a more recent Stripe
// webhook event we don't need to do this write.
// In the event of a collision, Firestore transactions are re-run multiple times
// with random gaps. We don't need to re-run this event if we already have processed
// an event newer.
subscriptionsToUpdate.push(subscription);
}
}
const storedCustomerEventTime: number | undefined = storedCustomer.data()?.stripeEventCreatedTime;
// stripeEventCreatedTime can be missing since we didn't previously write this value
if (!storedCustomerEventTime || storedCustomerEventTime < eventTime) {
// If we've already stored a newer record from a more recent Stripe
// webhook event we don't need to do this write.
// In the event of a collision, Firestore transactions are re-run multiple times
// with random gaps. We don't need to re-run this event if we already have processed
// an event newer.
const customerRecord = {
...customer,
stripeEventCreatedTime: eventTime
}
tx.set(this.customerCollectionDbRef.doc(customerUid), customerRecord);
}
// Firestore transactions require writes to occur after all reads.
for (const subscriptionToUpdate of subscriptionsToUpdate) {
const subscriptionRecord = {
...subscriptionToUpdate,
stripeEventCreatedTime: eventTime
}
tx.set(
this.customerCollectionDbRef
.doc(customerUid)
.collection(this.subscriptionCollection)
.doc(subscriptionToUpdate.id),
subscriptionRecord
);
}
});
return customer;
}
/**
* Get a Stripe customer by id, and insert it into Firestore keyed to the fxa uid.
*
* This method is used for populating the customer if missing from Stripe and also
* loads the customers subscriptions into Firestore.
*
* This is kept for compatibility with methods that do not fire directly from a Stripe webhook but still want to populate Firestore
*/
async legacyFetchAndInsertCustomer(
customerId: string,
ignoreErrors: boolean = false
) {
@ -191,9 +296,10 @@ export class StripeFirestore {
this.stripe.subscriptions
.list({
customer: customerId,
status: "all"
status: "all",
limit: 100,
})
.autoPagingToArray({ limit: 100 }),
.autoPagingToArray({ limit: 10000 }),
]);
if (customer.deleted) {
if (ignoreErrors) {

View File

@ -246,7 +246,7 @@ export abstract class StripeHelper {
// If the customer has subscriptions and no currency, we must have a stale
// customer record. Let's update it.
if (customer.subscriptions?.data.length && !customer.currency) {
await this.stripeFirestore.fetchAndInsertCustomer(customer.id);
await this.stripeFirestore.legacyFetchAndInsertCustomer(customer.id);
// Retrieve the customer again.
customer = await this.expandResource<Stripe.Customer>(
stripeCustomerId,