Skip to content

Commit

Permalink
Functions: Handle "charge.updated" events to process updated balance …
Browse files Browse the repository at this point in the history
…transactions from Stripe (#1007)
  • Loading branch information
mkue authored Jan 12, 2025
1 parent 7fcc09d commit 656d242
Show file tree
Hide file tree
Showing 5 changed files with 38 additions and 16 deletions.
9 changes: 5 additions & 4 deletions functions/src/webhooks/stripe/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions shared/.env.sample
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 9 additions & 4 deletions shared/src/stripe/StripeEventHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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);
});

Expand All @@ -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'),
);
Expand All @@ -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);
});

Expand Down
16 changes: 8 additions & 8 deletions shared/src/stripe/StripeEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -205,8 +205,8 @@ export class StripeEventHandler {
const contributionRef = (
userRef.collection(CONTRIBUTION_FIRESTORE_PATH) as CollectionReference<StripeContribution>
).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;
};
Expand Down
11 changes: 11 additions & 0 deletions shared/tests/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit 656d242

Please sign in to comment.