From 656d2423b88282397475d50cf71eed359178d923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=BCndig?= Date: Sun, 12 Jan 2025 12:17:34 +0100 Subject: [PATCH] Functions: Handle "charge.updated" events to process updated balance transactions from Stripe (#1007) --- functions/src/webhooks/stripe/index.ts | 9 +++++---- shared/.env.sample | 5 +++++ shared/src/stripe/StripeEventHandler.test.ts | 13 +++++++++---- shared/src/stripe/StripeEventHandler.ts | 16 ++++++++-------- shared/tests/utils.ts | 11 +++++++++++ 5 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 shared/tests/utils.ts diff --git a/functions/src/webhooks/stripe/index.ts b/functions/src/webhooks/stripe/index.ts index 97efcc295..c48f4629d 100644 --- a/functions/src/webhooks/stripe/index.ts +++ b/functions/src/webhooks/stripe/index.ts @@ -1,7 +1,6 @@ import { DocumentReference, DocumentSnapshot } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; import { onRequest } from 'firebase-functions/v2/https'; -import Stripe from 'stripe'; import { FirestoreAdmin } from '../../../../shared/src/firebase/admin/FirestoreAdmin'; import { SendgridSubscriptionClient } from '../../../../shared/src/sendgrid/SendgridSubscriptionClient'; import { StripeEventHandler } from '../../../../shared/src/stripe/StripeEventHandler'; @@ -43,12 +42,14 @@ export default onRequest(async (request, response) => { try { const sig = request.headers['stripe-signature']!; const event = stripeEventHandler.constructWebhookEvent(request.rawBody, sig, STRIPE_WEBHOOK_SECRET); - const charge = event.data.object as Stripe.Charge; switch (event.type) { case 'charge.succeeded': + // The charge.succeeded events do sometimes not contain the balance_transaction, so we need to listen to charge.updated event + // to get the final balance_transaction and update the contribution document with the final amount. + case 'charge.updated': case 'charge.failed': { - const contributionRef = await stripeEventHandler.handleChargeEvent(charge); - logger.info(`Charge event ${event.type} handled for charge ${charge.id}.`); + const contributionRef = await stripeEventHandler.handleChargeEvent(event.data.object); + logger.info(`Charge event ${event.type} handled for charge ${event.data.object.id}.`); if (contributionRef) { logger.info(`Created contribution ${contributionRef.id}. Adding contributor to newsletter.`); try { diff --git a/shared/.env.sample b/shared/.env.sample index a2d028109..05781c806 100644 --- a/shared/.env.sample +++ b/shared/.env.sample @@ -1,3 +1,8 @@ +# These environment variables are required for runnning the tests against the local firebase environment +GCLOUD_PROJECT="demo-social-income-local" +FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 +FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 + # For the Twilio related tests to work locally, configure the test credentials from Twilio in your .env file TWILIO_SID=ACXXXXXXXXXXXXXXXXXXXX TWILIO_TOKEN=yyyyyyyyyyyyyyyyyyyyy diff --git a/shared/src/stripe/StripeEventHandler.test.ts b/shared/src/stripe/StripeEventHandler.test.ts index 99af6b0e6..0bb6e4364 100644 --- a/shared/src/stripe/StripeEventHandler.test.ts +++ b/shared/src/stripe/StripeEventHandler.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from '@jest/globals'; import { DateTime } from 'luxon'; import Stripe from 'stripe'; +import { clearFirestoreData } from '../../tests/utils'; import { FirestoreAdmin } from '../firebase/admin/FirestoreAdmin'; import { getOrInitializeFirebaseAdmin } from '../firebase/admin/app'; import { toFirebaseAdminTimestamp } from '../firebase/admin/utils'; @@ -14,6 +15,10 @@ describe('stripeWebhook', () => { const firestoreAdmin = new FirestoreAdmin(getOrInitializeFirebaseAdmin({ projectId: projectId })); const stripeWebhook = new StripeEventHandler('DUMMY_KEY', firestoreAdmin); + beforeEach(async () => { + await clearFirestoreData(firestoreAdmin); + }); + // Mock the Stripe API call jest.spyOn(stripeWebhook.stripe.customers, 'retrieve').mockImplementation(async () => { return { @@ -27,7 +32,7 @@ describe('stripeWebhook', () => { expect(initialUser).toBeUndefined(); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); const createdUser = (await stripeWebhook.findFirestoreUser(testCustomer))!.data(); @@ -48,7 +53,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); }); @@ -58,7 +63,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, { campaignId: 'xyz' }); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()?.campaign_path).toEqual( firestoreAdmin.firestore.collection(CAMPAIGN_FIRESTORE_PATH).doc('xyz'), ); @@ -70,7 +75,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); }); diff --git a/shared/src/stripe/StripeEventHandler.ts b/shared/src/stripe/StripeEventHandler.ts index bc6927d09..d1c755d7f 100644 --- a/shared/src/stripe/StripeEventHandler.ts +++ b/shared/src/stripe/StripeEventHandler.ts @@ -33,12 +33,12 @@ export class StripeEventHandler { const checkoutMetadata = await this.getCheckoutMetadata(charge); - // We only store non-successful charges if the user already exists. - // This prevents us from having users in the database that never made a successful contribution. - if ( - fullCharge.status === 'succeeded' || - (await this.findFirestoreUser(await this.retrieveStripeCustomer(fullCharge.customer as string))) - ) { + const firestoreUser = await this.findFirestoreUser( + await this.retrieveStripeCustomer(fullCharge.customer as string), + ); + if (fullCharge.status === 'succeeded' || firestoreUser) { + // We only store non-successful charges if the user already exists. + // This prevents us from having users in the database that never made a successful contribution. return await this.storeCharge(fullCharge, checkoutMetadata); } return null; @@ -205,8 +205,8 @@ export class StripeEventHandler { const contributionRef = ( userRef.collection(CONTRIBUTION_FIRESTORE_PATH) as CollectionReference ).doc(charge.id); - await contributionRef.set(contribution); - console.info(`Ingested ${charge.id} into firestore for user ${userRef.id}`); + await contributionRef.set(contribution, { merge: true }); + console.info(`Updated contribution document: ${contributionRef.path}`); await this.maybeUpdateCampaign(contribution); return contributionRef; }; diff --git a/shared/tests/utils.ts b/shared/tests/utils.ts new file mode 100644 index 000000000..11cb593d3 --- /dev/null +++ b/shared/tests/utils.ts @@ -0,0 +1,11 @@ +import { FirestoreAdmin } from '../src/firebase/admin/FirestoreAdmin'; + +export async function clearFirestoreData(firestoreAdmin: FirestoreAdmin) { + const collections = await firestoreAdmin.firestore.listCollections(); + const deletePromises = collections.map(async (collection) => { + const documents = await collection.listDocuments(); + const deleteDocPromises = documents.map((doc) => firestoreAdmin.firestore.recursiveDelete(doc)); + await Promise.all(deleteDocPromises); + }); + await Promise.all(deletePromises); +}