Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Functions: Handle "charge.updated" events to process updated balance transactions from Stripe #1007

Merged
merged 2 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
Loading