Skip to content

Commit a827351

Browse files
committed
feat: script to check and revalidate firestore <-> stripe sync
1 parent c7371cd commit a827351

File tree

9 files changed

+971
-47
lines changed

9 files changed

+971
-47
lines changed

packages/fxa-auth-server/lib/payments/stripe-firestore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export class StripeFirestore extends StripeFirestoreBase {
110110
} catch (err) {
111111
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
112112
if (!customer.id) throw new Error('Customer ID must be provided');
113-
return this.fetchAndInsertCustomer(customer.id);
113+
return this.legacyFetchAndInsertCustomer(customer.id);
114114
} else {
115115
throw err;
116116
}
@@ -155,7 +155,7 @@ export class StripeFirestore extends StripeFirestoreBase {
155155
await this.insertSubscriptionRecord(subscription);
156156
} catch (err) {
157157
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
158-
await this.fetchAndInsertCustomer(subscription.customer as string);
158+
await this.legacyFetchAndInsertCustomer(subscription.customer as string);
159159
} else {
160160
throw err;
161161
}
@@ -176,7 +176,7 @@ export class StripeFirestore extends StripeFirestoreBase {
176176
await this.insertPaymentMethodRecord(paymentMethod);
177177
} catch (err) {
178178
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
179-
await this.fetchAndInsertCustomer(paymentMethod.customer as string);
179+
await this.legacyFetchAndInsertCustomer(paymentMethod.customer as string);
180180
return this.insertPaymentMethodRecord(paymentMethod);
181181
} else {
182182
throw err;

packages/fxa-auth-server/lib/payments/stripe.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3502,7 +3502,7 @@ export class StripeHelper extends StripeHelperBase {
35023502
return;
35033503
}
35043504

3505-
return this.stripeFirestore.fetchAndInsertCustomer(customerId);
3505+
return this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
35063506
}
35073507

35083508
/**
@@ -3525,7 +3525,7 @@ export class StripeHelper extends StripeHelperBase {
35253525
CUSTOMER_RESOURCE
35263526
);
35273527
if (!customer.deleted && !customer.currency) {
3528-
await this.stripeFirestore.fetchAndInsertCustomer(customerId);
3528+
await this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
35293529
const subscription =
35303530
await this.stripe.subscriptions.retrieve(subscriptionId);
35313531
return subscription;
@@ -3566,7 +3566,7 @@ export class StripeHelper extends StripeHelperBase {
35663566
);
35673567
} catch (err) {
35683568
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
3569-
await this.stripeFirestore.fetchAndInsertCustomer(customerId);
3569+
await this.stripeFirestore.fetchAndInsertCustomer(customerId, event.created);
35703570
await this.stripeFirestore.fetchAndInsertInvoice(
35713571
invoiceId,
35723572
event.created
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
import program from 'commander';
5+
import { promisify } from 'util';
6+
import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup';
7+
8+
import { FirestoreStripeSyncChecker } from './check-firestore-stripe-sync/check-firestore-stripe-sync';
9+
10+
const pckg = require('../package.json');
11+
const config = require('../config').default.getProperties();
12+
13+
const parseRateLimit = (rateLimit: string | number) => {
14+
return parseInt(rateLimit.toString(), 10);
15+
};
16+
17+
async function init() {
18+
program
19+
.version(pckg.version)
20+
.option(
21+
'-r, --rate-limit [number]',
22+
'Rate limit for Stripe',
23+
30
24+
)
25+
.parse(process.argv);
26+
27+
const { stripeHelper, log } = await setupProcessingTaskObjects(
28+
'check-firestore-stripe-sync'
29+
);
30+
31+
const rateLimit = parseRateLimit(program.rateLimit);
32+
33+
const syncChecker = new FirestoreStripeSyncChecker(
34+
stripeHelper,
35+
rateLimit,
36+
log,
37+
);
38+
39+
await syncChecker.run();
40+
41+
return 0;
42+
}
43+
44+
if (require.main === module) {
45+
let exitStatus = 1;
46+
init()
47+
.then((result) => {
48+
exitStatus = result;
49+
})
50+
.catch((err) => {
51+
console.error(err);
52+
})
53+
.finally(() => {
54+
process.exit(exitStatus);
55+
});
56+
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
import Stripe from 'stripe';
5+
import Container from 'typedi';
6+
import { CollectionReference, Firestore } from '@google-cloud/firestore';
7+
import PQueue from 'p-queue';
8+
9+
import { AppConfig, AuthFirestore } from '../../lib/types';
10+
import { ConfigType } from '../../config';
11+
import { StripeHelper } from '../../lib/payments/stripe';
12+
13+
/**
14+
* For RAM-preserving pruposes only
15+
*/
16+
const QUEUE_SIZE_LIMIT = 1000;
17+
/**
18+
* For RAM-preserving pruposes only
19+
*/
20+
const QUEUE_CONCURRENCY_LIMIT = 3;
21+
22+
export class FirestoreStripeSyncChecker {
23+
private config: ConfigType;
24+
private firestore: Firestore;
25+
private stripeQueue: PQueue;
26+
private stripe: Stripe;
27+
private customersCheckedCount = 0;
28+
private subscriptionsCheckedCount = 0;
29+
private outOfSyncCount = 0;
30+
private customersMissingInFirestore = 0;
31+
private subscriptionsMissingInFirestore = 0;
32+
private customersMismatched = 0;
33+
private subscriptionsMismatched = 0;
34+
private customerCollectionDbRef: CollectionReference;
35+
private subscriptionCollection: string;
36+
37+
constructor(
38+
private stripeHelper: StripeHelper,
39+
rateLimit: number,
40+
private log: any,
41+
) {
42+
this.stripe = this.stripeHelper.stripe;
43+
44+
const config = Container.get<ConfigType>(AppConfig);
45+
this.config = config;
46+
47+
const firestore = Container.get<Firestore>(AuthFirestore);
48+
this.firestore = firestore;
49+
50+
this.customerCollectionDbRef = this.firestore.collection(`${this.config.authFirestore.prefix}stripe-customers`);
51+
this.subscriptionCollection = `${this.config.authFirestore.prefix}stripe-subscriptions`;
52+
53+
this.stripeQueue = new PQueue({
54+
intervalCap: rateLimit,
55+
interval: 1000,
56+
});
57+
}
58+
59+
private async enqueueRequest<T>(request: () => Promise<T>): Promise<T> {
60+
return this.stripeQueue.add(request) as Promise<T>;
61+
}
62+
63+
async run(): Promise<void> {
64+
this.log.info('firestore-stripe-sync-check-start');
65+
66+
const queue = new PQueue({ concurrency: QUEUE_CONCURRENCY_LIMIT });
67+
68+
await this.stripe.customers.list({
69+
limit: 25,
70+
}).autoPagingEach(async (customer) => {
71+
if (queue.size + queue.pending >= QUEUE_SIZE_LIMIT) {
72+
await queue.onSizeLessThan(QUEUE_SIZE_LIMIT - QUEUE_CONCURRENCY_LIMIT);
73+
}
74+
75+
queue.add(() => {
76+
return this.checkCustomerSync(customer);
77+
});
78+
});
79+
80+
await queue.onIdle();
81+
82+
this.log.info('firestore-stripe-sync-check-complete', {
83+
customersCheckedCount: this.customersCheckedCount,
84+
subscriptionsCheckedCount: this.subscriptionsCheckedCount,
85+
outOfSyncCount: this.outOfSyncCount,
86+
customersMissingInFirestore: this.customersMissingInFirestore,
87+
subscriptionsMissingInFirestore: this.subscriptionsMissingInFirestore,
88+
customersMismatched: this.customersMismatched,
89+
subscriptionsMismatched: this.subscriptionsMismatched,
90+
});
91+
}
92+
93+
async checkCustomerSync(stripeCustomer: Stripe.Customer | Stripe.DeletedCustomer): Promise<void> {
94+
try {
95+
if (stripeCustomer.deleted) {
96+
return;
97+
}
98+
99+
this.customersCheckedCount++;
100+
101+
if (!stripeCustomer.metadata.userid) {
102+
throw new Error(`Stripe customer ${stripeCustomer.id} is missing a userid`);
103+
}
104+
105+
const firestoreCustomerDoc = await this.customerCollectionDbRef
106+
.doc(stripeCustomer.metadata.userid)
107+
.get();
108+
109+
if (!firestoreCustomerDoc.exists) {
110+
this.handleOutOfSync(stripeCustomer.id, 'Customer exists in Stripe but not in Firestore', 'customer_missing');
111+
return;
112+
}
113+
114+
const firestoreCustomer = firestoreCustomerDoc.data();
115+
116+
if (!this.isCustomerInSync(firestoreCustomer, stripeCustomer)) {
117+
this.handleOutOfSync(stripeCustomer.id, 'Customer mismatch', 'customer_mismatch');
118+
return;
119+
}
120+
121+
const subscriptions = await this.enqueueRequest(() =>
122+
this.stripe.subscriptions.list({
123+
customer: stripeCustomer.id,
124+
limit: 100,
125+
status: "all",
126+
})
127+
);
128+
for (const stripeSubscription of subscriptions.data) {
129+
await this.checkSubscriptionSync(stripeCustomer.id, stripeCustomer.metadata.userid, stripeSubscription);
130+
}
131+
} catch (e) {
132+
this.log.error('error-checking-customer', {
133+
customerId: stripeCustomer.id,
134+
error: e,
135+
});
136+
}
137+
}
138+
139+
async checkSubscriptionSync(customerId: string, uid: string, stripeSubscription: Stripe.Subscription): Promise<void> {
140+
try {
141+
this.subscriptionsCheckedCount++;
142+
143+
const subscriptionDoc = await this.customerCollectionDbRef
144+
.doc(uid)
145+
.collection(this.subscriptionCollection)
146+
.doc(stripeSubscription.id)
147+
.get();
148+
149+
if (!subscriptionDoc.exists) {
150+
this.handleOutOfSync(customerId, 'Subscription exists in Stripe but not in Firestore', 'subscription_missing', stripeSubscription.id);
151+
return;
152+
}
153+
154+
const firestoreSubscription = subscriptionDoc.data();
155+
156+
if (!this.isSubscriptionInSync(firestoreSubscription, stripeSubscription)) {
157+
this.handleOutOfSync(customerId, 'Subscription data mismatch', 'subscription_mismatch', stripeSubscription.id);
158+
return;
159+
}
160+
} catch (e) {
161+
this.log.error('error-checking-subscription', {
162+
customerId,
163+
subscriptionId: stripeSubscription.id,
164+
error: e,
165+
});
166+
}
167+
}
168+
169+
isCustomerInSync(firestoreCustomer: any, stripeCustomer: Stripe.Customer): boolean {
170+
for (const key of Object.keys(stripeCustomer)) {
171+
if (
172+
stripeCustomer[key] !== null
173+
&& stripeCustomer[key] !== undefined
174+
&& !["string", "number"].includes(typeof stripeCustomer[key])
175+
) continue;
176+
177+
if (firestoreCustomer[key] !== stripeCustomer[key]) {
178+
return false;
179+
}
180+
}
181+
182+
return true;
183+
}
184+
185+
isSubscriptionInSync(firestoreSubscription: any, stripeSubscription: Stripe.Subscription): boolean {
186+
for (const key of Object.keys(stripeSubscription)) {
187+
if (
188+
stripeSubscription[key] !== null
189+
&& stripeSubscription[key] !== undefined
190+
&& !["string", "number"].includes(typeof stripeSubscription[key])
191+
) continue;
192+
193+
if (firestoreSubscription[key] !== stripeSubscription[key]) {
194+
return false;
195+
}
196+
}
197+
198+
return true;
199+
}
200+
201+
handleOutOfSync(customerId: string, reason: string, type: string, subscriptionId: string | null = null): void {
202+
this.outOfSyncCount++;
203+
204+
if (type === 'customer_missing') {
205+
this.customersMissingInFirestore++;
206+
} else if (type === 'customer_mismatch') {
207+
this.customersMismatched++;
208+
} else if (type === 'subscription_missing') {
209+
this.subscriptionsMissingInFirestore++;
210+
} else if (type === 'subscription_mismatch') {
211+
this.subscriptionsMismatched++;
212+
}
213+
214+
this.log.warn('firestore-stripe-out-of-sync', {
215+
customerId,
216+
subscriptionId,
217+
reason,
218+
type,
219+
});
220+
221+
this.triggerResync(customerId);
222+
}
223+
224+
async triggerResync(customerId: string): Promise<void> {
225+
try {
226+
await this.enqueueRequest(() =>
227+
this.stripe.customers.update(customerId, {
228+
metadata: {
229+
forcedResyncAt: Date.now().toString(),
230+
},
231+
})
232+
);
233+
} catch (e) {
234+
this.log.error('failed-to-trigger-resync', {
235+
customerId,
236+
error: e,
237+
});
238+
}
239+
}
240+
}

0 commit comments

Comments
 (0)