mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-28 07:03:55 +00:00
feat: script to check and revalidate firestore <-> stripe sync
This commit is contained in:
parent
c7371cd074
commit
e3b06527f4
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user