diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 1b7ad3b..a2dee38 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -15,6 +15,7 @@ import { AddEmailSubscribers1778800000002 } from './migrations/1778800000002-add import { AddGoals1780531200000 } from './migrations/1780531200000-add_goals'; import { UserRefactoringId1780931163251 } from './migrations/1780931163251-user-refactoring-id'; import { RenameGoalColumns1781161660000 } from './migrations/1781161660-rename-goal-columns'; +import { AddStripeSubscriptionFields1781200000000 } from './migrations/1781200000000-add-stripe-subscription-fields'; dotenv.config(); @@ -35,6 +36,7 @@ const AppDataSource = new DataSource({ AddGoals1780531200000, UserRefactoringId1780931163251, RenameGoalColumns1781161660000, + AddStripeSubscriptionFields1781200000000, ], migrationsRun: true, synchronize: false, diff --git a/apps/backend/src/donations/donation.entity.ts b/apps/backend/src/donations/donation.entity.ts index 3a8c7b9..910143c 100644 --- a/apps/backend/src/donations/donation.entity.ts +++ b/apps/backend/src/donations/donation.entity.ts @@ -69,6 +69,12 @@ export class Donation { @Column({ type: 'int', nullable: true }) feeAmount: number | null; + @Column({ type: 'varchar', nullable: true }) + stripeSubscriptionId: string | null; + + @Column({ type: 'varchar', nullable: true }) + stripeCustomerId: string | null; + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) createdAt: Date; diff --git a/apps/backend/src/donations/donations.repository.spec.ts b/apps/backend/src/donations/donations.repository.spec.ts index fe5f82e..a625587 100644 --- a/apps/backend/src/donations/donations.repository.spec.ts +++ b/apps/backend/src/donations/donations.repository.spec.ts @@ -28,6 +28,8 @@ describe('DonationsRepository', () => { status: DonationStatus.SUCCEEDED, transactionId: 'txn_123456', feeAmount: null, + stripeSubscriptionId: null, + stripeCustomerId: null, createdAt: new Date('2024-01-15T10:00:00Z'), updatedAt: new Date('2024-01-15T10:00:00Z'), }; diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index b0f9871..7bd1854 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -8,6 +8,7 @@ import { CreateDonationRequest } from './mappers'; import { DonationResponseDto } from './dtos/donation-response-dto'; import { DonationsRepository } from './donations.repository'; import { Goal } from './goal.entity'; +import { EmailsService } from '../emails/emails.service'; // mock donations // invalid donation: non positive donation amount @@ -125,6 +126,8 @@ const validDonation1: Donation = { updatedAt: new Date(2026, 7, 1), recurringInterval: null, feeAmount: null, + stripeSubscriptionId: null, + stripeCustomerId: null, }; const validDonation2: Donation = { @@ -155,6 +158,8 @@ const validDonation2: Donation = { dedicationMessage: 'I love fcc!', feeAmount: null, + stripeSubscriptionId: null, + stripeCustomerId: null, }; const validDonation3: Donation = { @@ -185,6 +190,8 @@ const validDonation3: Donation = { dedicationMessage: 'I love fcc!', feeAmount: null, + stripeSubscriptionId: null, + stripeCustomerId: null, }; const allDonations: Donation[] = [ @@ -242,12 +249,17 @@ describe('DonationsService', () => { findLapsedDonors: jest.fn(), }; + const mockEmailsService = { + sendDonationResponseEmail: jest.fn(), + }; + const app = await Test.createTestingModule({ providers: [ DonationsService, { provide: getRepositoryToken(Donation), useValue: repoMock }, { provide: getRepositoryToken(Goal), useValue: {} }, { provide: DonationsRepository, useValue: mockDonationsRepository }, + { provide: EmailsService, useValue: mockEmailsService }, ], }).compile(); @@ -323,6 +335,24 @@ describe('DonationsService', () => { updatedAt: validDonation1.updatedAt, }); }); + + it('persists Stripe subscription and customer ids when provided', async () => { + repo.create.mockReturnValue(validDonation1); + repo.save.mockResolvedValue(validDonation1); + + await service.create({ + ...validCreateDonation1, + stripeSubscriptionId: 'sub_persist', + stripeCustomerId: 'cus_persist', + }); + + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + stripeSubscriptionId: 'sub_persist', + stripeCustomerId: 'cus_persist', + }), + ); + }); }); describe('Find all donations method', () => { @@ -443,6 +473,110 @@ describe('DonationsService', () => { }); }); + describe('recordRenewalCharge', () => { + const template = { + ...validDonation1, + id: 42, + stripeSubscriptionId: 'sub_renew', + stripeCustomerId: 'cus_renew', + }; + + beforeEach(() => { + // shared repo mocks persist across the beforeAll-scoped suite; reset call + // history (not implementations) so per-test assertions are isolated + repo.create.mockClear(); + repo.save.mockClear(); + repo.findOne.mockClear(); + }); + + afterEach(() => { + // restore the shared findOne implementation for other suites + repo.findOne.mockImplementation( + async (options?: FindOneOptions) => { + const where = options?.where as + | FindOptionsWhere + | undefined; + if (!where) return null; + if (where.id !== undefined && where.id !== null) { + return allDonations.find((d) => d.id === where.id) ?? null; + } + if (where.transactionId) { + return ( + allDonations.find( + (d) => d.transactionId === where.transactionId, + ) ?? null + ); + } + return null; + }, + ); + }); + + it('is idempotent: skips when a donation with the transactionId already exists', async () => { + repo.findOne.mockResolvedValueOnce(template); // existing by transactionId + const createSpy = jest.spyOn(repo, 'create'); + + await service.recordRenewalCharge({ + stripeSubscriptionId: 'sub_renew', + transactionId: 'pi_dup', + amount: 1000, + status: DonationStatus.SUCCEEDED, + }); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('warns and does nothing when no template donation exists for the subscription', async () => { + repo.findOne + .mockResolvedValueOnce(null) // no existing by transactionId + .mockResolvedValueOnce(null); // no template by subscription id + const createSpy = jest.spyOn(repo, 'create'); + + await service.recordRenewalCharge({ + stripeSubscriptionId: 'sub_missing', + transactionId: 'pi_new', + amount: 1000, + status: DonationStatus.SUCCEEDED, + }); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('clones the template into a new succeeded row counting toward the goal', async () => { + repo.findOne + .mockResolvedValueOnce(null) // no existing by transactionId + .mockResolvedValueOnce(template); // template by subscription id + const createSpy = jest + .spyOn(repo, 'create') + .mockImplementation((d) => d as Donation); + const saveSpy = jest + .spyOn(repo, 'save') + .mockResolvedValue({ ...template, id: 99 } as Donation); + + await service.recordRenewalCharge({ + stripeSubscriptionId: 'sub_renew', + transactionId: 'pi_renew_new', + amount: 2500, + status: DonationStatus.SUCCEEDED, + feeAmount: 100, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + email: template.email, + amount: 2500, + status: DonationStatus.SUCCEEDED, + transactionId: 'pi_renew_new', + feeAmount: 100, + stripeSubscriptionId: 'sub_renew', + stripeCustomerId: 'cus_renew', + donationType: DonationType.RECURRING, + }), + ); + expect(saveSpy).toHaveBeenCalled(); + }); + }); + describe('exportToCsv', () => { it('should include all donation data in CSV rows', async () => { const stream = await service.exportToCsv(); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 4cefe3d..8e789f0 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -22,6 +22,14 @@ interface PaymentIntentSyncPayload { feeAmount?: number; } +interface RenewalChargePayload { + stripeSubscriptionId: string; + transactionId: string; + amount: number; + status: DonationStatus; + feeAmount?: number; +} + interface DonationStats { total: number; count: number; @@ -93,6 +101,8 @@ export class DonationsService { dedicationMessage: createDonationRequest.dedicationMessage || null, showDedicationPublicly: createDonationRequest.showDedicationPublicly, transactionId: createDonationRequest.paymentIntentId || null, + stripeSubscriptionId: createDonationRequest.stripeSubscriptionId || null, + stripeCustomerId: createDonationRequest.stripeCustomerId || null, }); // Reload from database so any DB-side defaults are reflected @@ -246,10 +256,10 @@ export class DonationsService { ); return { - total: Number(donations?.total ?? 0), + total: Number(donations?.total ?? 0) / 100, count: Number(donations?.count ?? 0), - yearToDate: Number(donations?.yearToDate ?? 0), - monthToDate: Number(donations?.monthToDate ?? 0), + yearToDate: Number(donations?.yearToDate ?? 0) / 100, + monthToDate: Number(donations?.monthToDate ?? 0) / 100, }; } @@ -320,6 +330,88 @@ export class DonationsService { } } + /** + * Records a recurring-subscription renewal charge (month 2+) as its own donation + * row so it counts toward the goal (goal progress = SUM of succeeded rows). + * + * Donor details are cloned from the original ("template") donation created when the + * subscription started, located via {@link stripeSubscriptionId}. Idempotent on + * {@link transactionId} so redelivered Stripe webhooks don't double-count. + */ + async recordRenewalCharge(payload: RenewalChargePayload): Promise { + const { stripeSubscriptionId, transactionId, amount, status, feeAmount } = + payload; + + if (!stripeSubscriptionId || !transactionId) { + this.logger.warn( + 'Unable to record renewal charge without subscription and transaction ids', + ); + return; + } + + // Idempotency: skip if we've already recorded this charge. + const existing = await this.donationRepository.findOne({ + where: { transactionId }, + }); + if (existing) { + this.logger.debug( + `Renewal charge ${transactionId} already recorded (donation ${existing.id}); skipping`, + ); + return; + } + + // Find the original donation for this subscription to clone donor details. + const template = await this.donationRepository.findOne({ + where: { stripeSubscriptionId }, + order: { createdAt: 'ASC' }, + }); + if (!template) { + this.logger.warn( + `No template donation found for subscription ${stripeSubscriptionId}; cannot record renewal ${transactionId}`, + ); + return; + } + + const renewal = this.donationRepository.create({ + firstName: template.firstName, + lastName: template.lastName, + email: template.email, + amount, + isAnonymous: template.isAnonymous, + donationType: DonationType.RECURRING, + recurringInterval: template.recurringInterval, + dedicationMessage: template.dedicationMessage, + showDedicationPublicly: template.showDedicationPublicly, + status, + transactionId, + feeAmount: feeAmount ?? null, + stripeSubscriptionId, + stripeCustomerId: template.stripeCustomerId, + }); + + const saved = await this.donationRepository.save(renewal); + this.logger.log( + `Recorded renewal charge ${transactionId} for subscription ${stripeSubscriptionId} as donation ${saved.id} (${status})`, + ); + + if (status === DonationStatus.SUCCEEDED) { + try { + const donorName = `${saved.firstName} ${saved.lastName}`; + await this.emailsService.sendDonationResponseEmail( + saved.email, + donorName, + saved.amount, + ); + } catch (error) { + this.logger.error( + `Failed to send Donation Response email for renewal donation ${saved.id}`, + error, + ); + // don't let email failure break the renewal sync + } + } + } + async getLapsedDonors(numMonths = 6): Promise<{ emails: string[] }> { if (!Number.isFinite(numMonths) || numMonths <= 0) { throw new BadRequestException('numMonths must be a positive number'); @@ -428,7 +520,7 @@ export class DonationsService { }) .getRawOne<{ amount: string }>(); - const amountRaised = Number(result?.amount ?? 0); + const amountRaised = Number(result?.amount ?? 0) / 100; const progressPercent = goal.targetAmount > 0 diff --git a/apps/backend/src/donations/dtos/create-donation-dto.ts b/apps/backend/src/donations/dtos/create-donation-dto.ts index 302b4aa..888163c 100644 --- a/apps/backend/src/donations/dtos/create-donation-dto.ts +++ b/apps/backend/src/donations/dtos/create-donation-dto.ts @@ -100,4 +100,23 @@ export class CreateDonationDto { @IsString() @IsOptional() paymentIntentId?: string; + + @ApiProperty({ + description: + 'optional Stripe subscription id (set for recurring donations to link renewals)', + example: 'sub_1J2aBcD3eF4GhIjKlmnoPqr', + required: false, + }) + @IsString() + @IsOptional() + stripeSubscriptionId?: string; + + @ApiProperty({ + description: 'optional Stripe customer id (set for recurring donations)', + example: 'cus_ABC123', + required: false, + }) + @IsString() + @IsOptional() + stripeCustomerId?: string; } diff --git a/apps/backend/src/donations/mappers.ts b/apps/backend/src/donations/mappers.ts index 36cfd9e..ab2c991 100644 --- a/apps/backend/src/donations/mappers.ts +++ b/apps/backend/src/donations/mappers.ts @@ -23,6 +23,8 @@ export interface CreateDonationRequest { dedicationMessage?: string; showDedicationPublicly: boolean; paymentIntentId?: string; + stripeSubscriptionId?: string; + stripeCustomerId?: string; } export interface Donation { @@ -56,7 +58,7 @@ export class DonationMappers { firstName: dto.firstName, lastName: dto.lastName, email: dto.email, - amount: dto.amount, + amount: Math.round(dto.amount * 100), isAnonymous: dto.isAnonymous ?? false, donationType: dto.donationType as 'one_time' | 'recurring', recurringInterval: dto.recurringInterval as @@ -69,6 +71,8 @@ export class DonationMappers { dedicationMessage: dto.dedicationMessage, showDedicationPublicly: dto.showDedicationPublicly ?? false, paymentIntentId: dto.paymentIntentId, + stripeSubscriptionId: dto.stripeSubscriptionId, + stripeCustomerId: dto.stripeCustomerId, }; } @@ -78,7 +82,7 @@ export class DonationMappers { firstName: donation.firstName, lastName: donation.lastName, email: donation.email, - amount: donation.amount, + amount: donation.amount / 100, isAnonymous: donation.isAnonymous, donationType: donation.donationType as DonationType, recurringInterval: donation.recurringInterval as RecurringInterval, @@ -95,7 +99,7 @@ export class DonationMappers { static toPublicDonationDto(donation: Donation): PublicDonationDto { const publicDto: PublicDonationDto = { id: donation.id, - amount: donation.amount, + amount: donation.amount / 100, isAnonymous: donation.isAnonymous, donationType: donation.donationType as DonationType, recurringInterval: donation.recurringInterval as RecurringInterval, diff --git a/apps/backend/src/migrations/1781200000000-add-stripe-subscription-fields.ts b/apps/backend/src/migrations/1781200000000-add-stripe-subscription-fields.ts new file mode 100644 index 0000000..59e2111 --- /dev/null +++ b/apps/backend/src/migrations/1781200000000-add-stripe-subscription-fields.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddStripeSubscriptionFields1781200000000 implements MigrationInterface { + name = 'AddStripeSubscriptionFields1781200000000'; + + public async up(queryRunner: QueryRunner): Promise { + // feeAmount was added to the Donation entity in a prior change without a + // migration; add it here (guarded) so the schema matches the entity. + await queryRunner.query( + `ALTER TABLE "donations" ADD COLUMN IF NOT EXISTS "feeAmount" integer`, + ); + await queryRunner.query( + `ALTER TABLE "donations" ADD COLUMN IF NOT EXISTS "stripeSubscriptionId" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "donations" ADD COLUMN IF NOT EXISTS "stripeCustomerId" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "donations" DROP COLUMN IF EXISTS "stripeCustomerId"`, + ); + await queryRunner.query( + `ALTER TABLE "donations" DROP COLUMN IF EXISTS "stripeSubscriptionId"`, + ); + // Note: feeAmount is intentionally not dropped here since it predates this + // migration conceptually; drop manually if a full revert is required. + } +} diff --git a/apps/backend/src/payments/dtos/subscription-response-dto.ts b/apps/backend/src/payments/dtos/subscription-response-dto.ts new file mode 100644 index 0000000..fc11a37 --- /dev/null +++ b/apps/backend/src/payments/dtos/subscription-response-dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * Response for a created donation subscription. + * + * `id` and `clientSecret` intentionally mirror PaymentIntentResponseDto so the + * frontend confirms the first charge with the exact same code as a one-time payment. + */ +export class SubscriptionResponseDto { + @ApiProperty({ + description: + 'The first PaymentIntent id for the subscription (used as the donation transactionId)', + example: 'pi_3ABC123', + }) + id: string; + + @ApiProperty({ + description: 'The client secret used for client-side confirmation', + example: 'pi_3ABC123_secret_XYZ', + }) + clientSecret: string; + + @ApiProperty({ + description: 'The Stripe subscription id', + example: 'sub_1ABC123', + }) + subscriptionId: string; + + @ApiProperty({ + description: 'The Stripe customer id', + example: 'cus_ABC123', + }) + customerId: string; + + @ApiProperty({ + description: 'The Stripe subscription status', + example: 'incomplete', + }) + status: string; + + @ApiProperty({ + description: 'The recurring amount in smallest currency unit (e.g., cents)', + example: 1099, + }) + amount: number; + + @ApiProperty({ + description: 'The three-letter ISO currency code', + example: 'usd', + }) + currency: string; +} diff --git a/apps/backend/src/payments/mappers.ts b/apps/backend/src/payments/mappers.ts index 0778820..6bd2df5 100644 --- a/apps/backend/src/payments/mappers.ts +++ b/apps/backend/src/payments/mappers.ts @@ -1,7 +1,13 @@ import Stripe from 'stripe'; import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; -import { PaymentIntentResponse } from './payments.service'; +import { + CreateSubscriptionParams, + PaymentIntentResponse, + SubscriptionResponse, +} from './payments.service'; import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; +import { CreateSubscriptionDto } from './dtos/create-subscription-dto'; +import { SubscriptionResponseDto } from './dtos/subscription-response-dto'; export interface CreatePaymentIntentRequest { amount: number; @@ -63,4 +69,34 @@ export class PaymentMappers { canceledAt: paymentIntentResponse.canceledAt, }; } + + static toCreateSubscriptionParams( + dto: CreateSubscriptionDto, + ): CreateSubscriptionParams { + return { + email: dto.email, + name: dto.name, + amount: dto.amount, + currency: dto.currency, + interval: dto.interval, + metadata: + dto.metadata == undefined + ? undefined + : PaymentMappers.normalizeMetadata(dto.metadata), + }; + } + + static toSubscriptionResponseDto( + subscriptionResponse: SubscriptionResponse, + ): SubscriptionResponseDto { + return { + id: subscriptionResponse.paymentIntentId, + clientSecret: subscriptionResponse.clientSecret, + subscriptionId: subscriptionResponse.subscriptionId, + customerId: subscriptionResponse.customerId, + status: subscriptionResponse.status, + amount: subscriptionResponse.amount, + currency: subscriptionResponse.currency, + }; + } } diff --git a/apps/backend/src/payments/payments.controller.spec.ts b/apps/backend/src/payments/payments.controller.spec.ts index 681bdaf..1197d2c 100644 --- a/apps/backend/src/payments/payments.controller.spec.ts +++ b/apps/backend/src/payments/payments.controller.spec.ts @@ -63,9 +63,11 @@ describe('PaymentsControler', () => { constructWebhookEvent: jest.fn(), mapPaymentIntentToResponse: jest.fn(), getExactFeeForPaymentIntent: jest.fn(), + getPaymentIntentIdForInvoice: jest.fn(), }; const mockDonationsService = { syncPaymentIntentStatus: jest.fn(), + recordRenewalCharge: jest.fn(), }; const mockConfigService = { @@ -170,6 +172,129 @@ describe('PaymentsControler', () => { }); }); + describe('createSubscription', () => { + it('maps the DTO and returns the subscription response DTO without syncing a donation', async () => { + mockService.createSubscription.mockResolvedValueOnce({ + subscriptionId: 'sub_123', + customerId: 'cus_123', + paymentIntentId: 'pi_sub_123', + clientSecret: 'pi_sub_123_secret_abc', + status: 'incomplete', + amount: 1099, + currency: 'usd', + }); + + const result = await controller.createSubscription({ + amount: 1099, + currency: 'usd', + interval: 'monthly', + email: 'donor@example.com', + name: 'Jane Donor', + }); + + expect(mockService.createSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 1099, + currency: 'usd', + interval: 'monthly', + email: 'donor@example.com', + name: 'Jane Donor', + }), + ); + expect(result).toEqual({ + id: 'pi_sub_123', + clientSecret: 'pi_sub_123_secret_abc', + subscriptionId: 'sub_123', + customerId: 'cus_123', + status: 'incomplete', + amount: 1099, + currency: 'usd', + }); + expect( + mockDonationsService.syncPaymentIntentStatus, + ).not.toHaveBeenCalled(); + }); + }); + + describe('handleWebhook - subscription renewals', () => { + const buildInvoiceEvent = ( + type: string, + billingReason: string, + ): Stripe.Event => + ({ + type, + data: { + object: { + id: 'in_renewal_1', + billing_reason: billingReason, + amount_paid: 2000, + amount_due: 2000, + parent: { + subscription_details: { subscription: 'sub_renew_1' }, + }, + }, + }, + }) as unknown as Stripe.Event; + + beforeEach(() => { + mockConfigService.get.mockReturnValue('whsec_123'); + }); + + const runWebhook = async (event: Stripe.Event) => { + mockService.constructWebhookEvent.mockReturnValue(event); + const req = { + rawBody: Buffer.from('payload'), + } as RawBodyRequest; + return controller.handleWebhook(req, 'sig'); + }; + + it('records a renewal donation on invoice.paid for a subscription_cycle', async () => { + mockService.getPaymentIntentIdForInvoice.mockResolvedValue('pi_renew_1'); + mockService.getExactFeeForPaymentIntent.mockResolvedValue(90); + + await runWebhook(buildInvoiceEvent('invoice.paid', 'subscription_cycle')); + + expect(mockDonationsService.recordRenewalCharge).toHaveBeenCalledWith({ + stripeSubscriptionId: 'sub_renew_1', + transactionId: 'pi_renew_1', + amount: 2000, + status: DonationStatus.SUCCEEDED, + feeAmount: 90, + }); + }); + + it('skips the first invoice (subscription_create)', async () => { + await runWebhook( + buildInvoiceEvent('invoice.paid', 'subscription_create'), + ); + expect(mockDonationsService.recordRenewalCharge).not.toHaveBeenCalled(); + expect(mockService.getPaymentIntentIdForInvoice).not.toHaveBeenCalled(); + }); + + it('records a FAILED renewal row on invoice.payment_failed', async () => { + mockService.getPaymentIntentIdForInvoice.mockResolvedValue('pi_renew_2'); + + await runWebhook( + buildInvoiceEvent('invoice.payment_failed', 'subscription_cycle'), + ); + + expect(mockDonationsService.recordRenewalCharge).toHaveBeenCalledWith({ + stripeSubscriptionId: 'sub_renew_1', + transactionId: 'pi_renew_2', + amount: 2000, + status: DonationStatus.FAILED, + }); + }); + + it('does not record when the payment intent cannot be resolved', async () => { + mockService.getPaymentIntentIdForInvoice.mockResolvedValue(undefined); + + await runWebhook(buildInvoiceEvent('invoice.paid', 'subscription_cycle')); + + expect(mockDonationsService.recordRenewalCharge).not.toHaveBeenCalled(); + }); + }); + describe('handleWebhook', () => { it('should construct event and sync donation for payment intent events', async () => { const paymentIntent = { diff --git a/apps/backend/src/payments/payments.controller.ts b/apps/backend/src/payments/payments.controller.ts index 5722454..fbfe668 100644 --- a/apps/backend/src/payments/payments.controller.ts +++ b/apps/backend/src/payments/payments.controller.ts @@ -5,6 +5,7 @@ import { Headers, HttpCode, HttpStatus, + Logger, Post, Req, Param, @@ -18,6 +19,8 @@ import Stripe from 'stripe'; import { PaymentsService, PaymentIntentResponse } from './payments.service'; import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; +import { CreateSubscriptionDto } from './dtos/create-subscription-dto'; +import { SubscriptionResponseDto } from './dtos/subscription-response-dto'; import { PaymentMappers } from './mappers'; import { DonationsService } from '../donations/donations.service'; import { DonationStatus } from '../donations/donation.entity'; @@ -25,6 +28,8 @@ import { DonationStatus } from '../donations/donation.entity'; @ApiTags('Payments') @Controller('payments') export class PaymentsController { + private readonly logger = new Logger(PaymentsController.name); + constructor( private readonly paymentsService: PaymentsService, private readonly donationsService: DonationsService, @@ -60,6 +65,37 @@ export class PaymentsController { return PaymentMappers.toPaymentIntentResponseDto(paymentIntentResponse); } + @Post('/subscription') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'create a recurring donation subscription in Stripe', + description: + "creates a Stripe Subscription (payment_behavior 'default_incomplete') and returns the first PaymentIntent's id + client secret so the frontend confirms it exactly like a one-time payment", + }) + @ApiResponse({ + status: 201, + description: 'subscription successfully created in Stripe', + type: SubscriptionResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'validation error', + }) + async createSubscription( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + createSubscriptionDto: CreateSubscriptionDto, + ): Promise { + const params = PaymentMappers.toCreateSubscriptionParams( + createSubscriptionDto, + ); + // Note: no donation sync here — the donation row is created by the frontend + // via POST /donations (onBeforePayment), and its status/fee are set by the + // existing payment_intent.succeeded webhook once the first charge confirms. + const subscriptionResponse = + await this.paymentsService.createSubscription(params); + return PaymentMappers.toSubscriptionResponseDto(subscriptionResponse); + } + @Post('/intent/:id/sync') @HttpCode(HttpStatus.OK) @ApiOperation({ @@ -141,11 +177,82 @@ export class PaymentsController { requiresAction: false, }); } + } else if ( + event.type === 'invoice.paid' || + event.type === 'invoice.payment_failed' + ) { + await this.handleSubscriptionRenewalInvoice(event); } return { received: true }; } + /** + * Records subscription renewal charges (month 2+) as their own donation rows so + * they count toward the goal. The first invoice (billing_reason + * 'subscription_create') is skipped — it is already recorded via the frontend + * donation row + the first-charge payment_intent.succeeded event. + */ + private async handleSubscriptionRenewalInvoice( + event: Stripe.Event, + ): Promise { + const invoice = event.data.object as Stripe.Invoice; + + if (invoice.billing_reason !== 'subscription_cycle') { + return; + } + + const subscriptionRef = invoice.parent?.subscription_details?.subscription; + const stripeSubscriptionId = + typeof subscriptionRef === 'string' + ? subscriptionRef + : subscriptionRef?.id; + + if (!stripeSubscriptionId) { + this.logger.warn( + `Renewal invoice ${invoice.id} has no subscription id; skipping`, + ); + return; + } + + if (event.type === 'invoice.payment_failed') { + this.logger.warn( + `Renewal payment failed for subscription ${stripeSubscriptionId} (invoice ${invoice.id})`, + ); + } + + const paymentIntentId = invoice.id + ? await this.paymentsService.getPaymentIntentIdForInvoice(invoice.id) + : undefined; + + if (!paymentIntentId) { + this.logger.warn( + `Could not resolve payment intent for renewal invoice ${invoice.id} ` + + `(subscription ${stripeSubscriptionId}); renewal not recorded`, + ); + return; + } + + if (event.type === 'invoice.paid') { + const feeAmount = + await this.paymentsService.getExactFeeForPaymentIntent(paymentIntentId); + await this.donationsService.recordRenewalCharge({ + stripeSubscriptionId, + transactionId: paymentIntentId, + amount: invoice.amount_paid, + status: DonationStatus.SUCCEEDED, + feeAmount, + }); + } else { + await this.donationsService.recordRenewalCharge({ + stripeSubscriptionId, + transactionId: paymentIntentId, + amount: invoice.amount_due, + status: DonationStatus.FAILED, + }); + } + } + private async syncDonationFromPaymentIntent( paymentIntent: PaymentIntentResponse, feeAmount?: number, diff --git a/apps/backend/src/payments/payments.service.spec.ts b/apps/backend/src/payments/payments.service.spec.ts index 9b33673..e665ed2 100644 --- a/apps/backend/src/payments/payments.service.spec.ts +++ b/apps/backend/src/payments/payments.service.spec.ts @@ -1,4 +1,5 @@ import { PaymentsService } from './payments.service'; +import { ConfigService } from '@nestjs/config'; import { DonationStatus } from '../donations/donation.entity'; import Stripe from 'stripe'; import { CreatePaymentIntentRequest } from './mappers'; @@ -16,6 +17,20 @@ const stripeMock = { update: jest.fn(), cancel: jest.fn(), }, + customers: { + list: jest.fn(), + create: jest.fn(), + }, + products: { + create: jest.fn(), + }, + invoices: { + retrieve: jest.fn(), + }, +}; + +const configServiceMock = { + get: jest.fn(), }; // Mock the Stripe constructor to return our mock @@ -222,7 +237,14 @@ const subscriptionMock1 = { total_count: 1, url: '/v1/subscription_items?subscription=sub_1234567890abcdef', }, - latest_invoice: 'in_1234567890abcdef', + latest_invoice: { + id: 'in_1234567890abcdef', + object: 'invoice', + confirmation_secret: { + client_secret: 'pi_sub1234567890_secret_abcdefghij', + type: 'payment_intent', + }, + }, livemode: false, metadata: { orderId: '123' }, next_pending_invoice_item_invoice: null, @@ -250,8 +272,11 @@ describe('PaymentsService', () => { // Clear all mocks before each test jest.clearAllMocks(); - // Create a new instance with our mock - svc = new PaymentsService(stripeMock as unknown as Stripe); + // Create a new instance with our mocks + svc = new PaymentsService( + stripeMock as unknown as Stripe, + configServiceMock as unknown as ConfigService, + ); // Set up default mock implementations with more realistic Stripe responses stripeMock.paymentIntents.create.mockResolvedValue(paymentIntentMock1); @@ -259,6 +284,14 @@ describe('PaymentsService', () => { stripeMock.paymentIntents.retrieve.mockResolvedValue(paymentIntentMock2); stripeMock.subscriptions.create.mockResolvedValue(subscriptionMock1); + + // Subscription-flow defaults: no existing customer, product created on the fly + configServiceMock.get.mockReturnValue(undefined); + stripeMock.customers.list.mockResolvedValue({ data: [] }); + stripeMock.customers.create.mockResolvedValue({ + id: 'cus_new1234567890', + }); + stripeMock.products.create.mockResolvedValue({ id: 'prod_created123' }); }); describe('createPaymentIntent', () => { @@ -463,28 +496,122 @@ describe('PaymentsService', () => { }); describe('createSubscription', () => { - it('throws for undefined customerId', async () => { + const validParams = { + email: 'donor@example.com', + name: 'Jane Donor', + amount: 1000, + currency: 'usd', + interval: 'monthly', + }; + + it('throws for an amount below the USD minimum', async () => { await expect( - svc.createSubscription(undefined, 'price_1234abcdefgh5678'), - ).rejects.toThrow('Invalid customerId'); + svc.createSubscription({ ...validParams, amount: 10 }), + ).rejects.toThrow( + 'Invalid amount, US currency donations must be at least 50 cents', + ); }); - it('throws for null customerId', async () => { + it('throws for an invalid interval', async () => { await expect( - svc.createSubscription(null, 'price_1234abcdefgh5678'), - ).rejects.toThrow('Invalid customerId'); + svc.createSubscription({ ...validParams, interval: 'fortnightly' }), + ).rejects.toThrow('Invalid interval'); }); - it('throws for undefined priceId', async () => { + it('throws for a missing email', async () => { await expect( - svc.createSubscription('cus_1234abcdefgh5678', undefined), - ).rejects.toThrow('Invalid priceId'); + svc.createSubscription({ + ...validParams, + email: undefined as unknown as string, + }), + ).rejects.toThrow('Invalid email'); }); - it('throws for null priceId', async () => { - await expect( - svc.createSubscription('cus_1234abcdefgh5678', null), - ).rejects.toThrow('Invalid priceId'); + it('reuses an existing Stripe customer when one is found by email', async () => { + stripeMock.customers.list.mockResolvedValue({ + data: [{ id: 'cus_existing123' }], + }); + + const result = await svc.createSubscription(validParams); + + expect(stripeMock.customers.create).not.toHaveBeenCalled(); + expect(result.customerId).toBe('cus_existing123'); + }); + + it('creates a new customer when none exists', async () => { + const result = await svc.createSubscription(validParams); + + expect(stripeMock.customers.create).toHaveBeenCalledWith({ + email: validParams.email, + name: validParams.name, + }); + expect(result.customerId).toBe('cus_new1234567890'); + }); + + it('creates a product when STRIPE_DONATION_PRODUCT_ID is unset', async () => { + await svc.createSubscription(validParams); + expect(stripeMock.products.create).toHaveBeenCalledWith({ + name: 'FCC Donation', + }); + }); + + it('uses the configured product id without creating one', async () => { + configServiceMock.get.mockReturnValue('prod_configured999'); + + await svc.createSubscription(validParams); + + expect(stripeMock.products.create).not.toHaveBeenCalled(); + const createArgs = stripeMock.subscriptions.create.mock.calls[0][0]; + expect(createArgs.items[0].price_data.product).toBe('prod_configured999'); + }); + + it('derives the PaymentIntent id and client secret from confirmation_secret', async () => { + const result = await svc.createSubscription(validParams); + + expect(result.subscriptionId).toBe('sub_1234567890abcdef'); + expect(result.clientSecret).toBe('pi_sub1234567890_secret_abcdefghij'); + expect(result.paymentIntentId).toBe('pi_sub1234567890'); + expect(result.amount).toBe(1000); + expect(result.currency).toBe('usd'); + }); + + it('creates the subscription with default_incomplete and expands confirmation_secret', async () => { + await svc.createSubscription(validParams); + const createArgs = stripeMock.subscriptions.create.mock.calls[0][0]; + expect(createArgs.payment_behavior).toBe('default_incomplete'); + expect(createArgs.expand).toContain('latest_invoice.confirmation_secret'); + expect(createArgs.payment_settings.save_default_payment_method).toBe( + 'on_subscription', + ); + }); + + it.each([ + ['weekly', 'week', 1], + ['monthly', 'month', 1], + ['bimonthly', 'month', 2], + ['quarterly', 'month', 3], + ['annually', 'year', 1], + ])( + 'maps interval %s to { %s, count %d }', + async (interval, expectedInterval, expectedCount) => { + await svc.createSubscription({ ...validParams, interval }); + const createArgs = stripeMock.subscriptions.create.mock.calls[0][0]; + expect(createArgs.items[0].price_data.recurring).toEqual({ + interval: expectedInterval, + interval_count: expectedCount, + }); + }, + ); + + it('throws when the invoice has no confirmation secret', async () => { + stripeMock.subscriptions.create.mockResolvedValueOnce({ + ...subscriptionMock1, + latest_invoice: { id: 'in_x', object: 'invoice' }, + }); + + await expect(svc.createSubscription(validParams)).rejects.toThrow( + 'no confirmation secret', + ); }); it('handles Stripe API errors correctly', async () => { @@ -499,23 +626,9 @@ describe('PaymentsService', () => { paymentMethodNotSupportedError, ); - await expect( - svc.createSubscription( - 'cus_1234abcdefgh5678', - 'price_1234abcdefgh5678', - ), - ).rejects.toMatchObject(paymentMethodNotSupportedError); - }); - - it('creates a mock subscription for valid inputs', async () => { - const sub = await svc.createSubscription( - 'cus_1234abcdefgh5678', - 'price_1234abcdefgh5678', + await expect(svc.createSubscription(validParams)).rejects.toMatchObject( + paymentMethodNotSupportedError, ); - expect(sub).toHaveProperty('id'); - expect(sub.customerId).toBe('cus_1234abcdefgh5678'); - expect(sub.priceId).toBe('price_1234abcdefgh5678'); - expect(sub.status).toBe('active'); }); }); diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts index 6861396..1ec0505 100644 --- a/apps/backend/src/payments/payments.service.ts +++ b/apps/backend/src/payments/payments.service.ts @@ -1,4 +1,5 @@ import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { DonationStatus, @@ -11,6 +12,33 @@ import { CreatePaymentIntentRequest } from './mappers'; */ export type PaymentIntentMetadata = Record; +/** + * Parameters accepted by {@link PaymentsService.createSubscription}. + */ +export type CreateSubscriptionParams = { + email: string; + name: string; + amount: number; // smallest currency unit (e.g. cents) + currency: string; + interval: RecurringInterval | string; + metadata?: Stripe.MetadataParam; +}; + +/** + * Shape returned by {@link PaymentsService.createSubscription}. `paymentIntentId`/ + * `clientSecret` intentionally mirror {@link PaymentIntentResponse} so the frontend + * card-confirmation flow is identical to one-time payments. + */ +export type SubscriptionResponse = { + subscriptionId: string; + customerId: string; + paymentIntentId: string; + clientSecret: string; + status: string; + amount: number; + currency: string; +}; + /** * Interface for object shape returned by service methods that output detailed payment intent info * @@ -55,7 +83,13 @@ export type PaymentIntentResponse = { export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - constructor(@Inject('STRIPE_CLIENT') private stripe: Stripe) {} + /** Cached Stripe Product id for donation subscriptions (see resolveDonationProductId). */ + private cachedDonationProductId?: string; + + constructor( + @Inject('STRIPE_CLIENT') private stripe: Stripe, + private readonly configService: ConfigService, + ) {} /** * Validates the parameters for createPaymentIntent @@ -192,85 +226,168 @@ export class PaymentsService { } /** - * Validates the parameters for a subscription. + * Maps our RecurringInterval enum to Stripe's price `recurring` shape. + * Stripe only supports day/week/month/year, so bimonthly/quarterly are + * expressed via `interval_count`. * - * @param customerId string - ID of the customer in the payment provider - * @param priceId string - ID of the price/product to subscribe the customer to - * @param interval enum RecurringInterval - billing interval - * ( 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'annually') - * @returns string - either an empty string to signify good paramters, or an error message + * @returns the Stripe recurring config, or null if the interval is unknown */ - private validateCreateSubscriptionParams( - customerId: string, - priceId: string, - ): string { - const customerIdPattern = /^cus_[a-zA-Z0-9]{14,}$/; - const priceIdPattern = /^price_[a-zA-Z0-9]{14,}$/; - - if (!customerId || typeof customerId !== 'string') { - this.logger.warn('createSubscription called with invalid customerId'); - return 'Invalid customerId'; + private mapIntervalToStripeRecurring( + interval: RecurringInterval | string, + ): { interval: 'week' | 'month' | 'year'; interval_count: number } | null { + switch (interval) { + case RecurringInterval.WEEKLY: + return { interval: 'week', interval_count: 1 }; + case RecurringInterval.MONTHLY: + return { interval: 'month', interval_count: 1 }; + case RecurringInterval.BIMONTHLY: + return { interval: 'month', interval_count: 2 }; + case RecurringInterval.QUARTERLY: + return { interval: 'month', interval_count: 3 }; + case RecurringInterval.ANNUALLY: + return { interval: 'year', interval_count: 1 }; + default: + return null; } + } - if (!priceId || typeof priceId !== 'string') { - this.logger.warn('createSubscription called with invalid priceId'); - return 'Invalid priceId'; + /** + * Resolves the Stripe Product id used for donation subscriptions. Prefers the + * configured STRIPE_DONATION_PRODUCT_ID; if unset, creates a product once and + * caches it for the lifetime of the process (logging the id so it can be added + * to the environment to avoid creating duplicates on restart). + */ + private async resolveDonationProductId(): Promise { + if (this.cachedDonationProductId) { + return this.cachedDonationProductId; } - if (!customerIdPattern.test(customerId)) { - return 'Invalid customerId format'; + const configured = this.configService.get( + 'STRIPE_DONATION_PRODUCT_ID', + ); + if (configured) { + this.cachedDonationProductId = configured; + return configured; } - if (!priceIdPattern.test(priceId)) { - return 'Invalid priceId format'; - } + const product = await this.stripe.products.create({ name: 'FCC Donation' }); + this.logger.warn( + `STRIPE_DONATION_PRODUCT_ID not configured; created Stripe product ${product.id}. ` + + `Set STRIPE_DONATION_PRODUCT_ID=${product.id} to reuse it and avoid duplicates.`, + ); + this.cachedDonationProductId = product.id; + return product.id; + } - return ''; + /** + * Finds an existing Stripe customer by email, or creates a new one. + */ + private async resolveCustomerId( + email: string, + name: string, + ): Promise { + const existing = await this.stripe.customers.list({ email, limit: 1 }); + if (existing.data.length > 0) { + return existing.data[0].id; + } + const created = await this.stripe.customers.create({ email, name }); + return created.id; } /** - * Creates a subscription for a customer. + * Creates a recurring donation as a Stripe Subscription. * - * @param customerId string - ID of the customer in the payment provider - * @param priceId string - ID of the price/product to subscribe the customer to - * @returns Promise resolving to a Subscription-like object + * Uses `payment_behavior: 'default_incomplete'` so the subscription's first + * invoice yields a PaymentIntent the frontend confirms with the exact same code + * as a one-time payment. The returned `paymentIntentId` matches the id Stripe + * later sends in the `payment_intent.succeeded` webhook, so the existing sync + * path marks the donation succeeded with no changes. + * + * @param params email/name/amount/currency/interval/metadata for the donation + * @returns subscription id, customer id, and the first PaymentIntent id + client secret */ async createSubscription( - customerId: string, - priceId: string, - ): Promise<{ - id: string; - customerId: string; - priceId: string; - interval: RecurringInterval; - status: string; - }> { + params: CreateSubscriptionParams, + ): Promise { + const currency = params.currency + ? params.currency.toLowerCase() + : params.currency; + + const errorMsg = this.validateCreatePaymentIntentParams( + params.amount, + currency, + params.metadata, + ); + if (errorMsg !== '') { + throw new Error(errorMsg); + } + + const recurring = this.mapIntervalToStripeRecurring(params.interval); + if (!recurring) { + this.logger.warn( + `createSubscription called with invalid interval: ${params.interval}`, + ); + throw new Error('Invalid interval'); + } + + if (!params.email || typeof params.email !== 'string') { + throw new Error('Invalid email'); + } + try { - const errorMsg = this.validateCreateSubscriptionParams( - customerId, - priceId, + const customerId = await this.resolveCustomerId( + params.email, + params.name, ); - if (errorMsg !== '') { - throw new Error(errorMsg); - } - const subscription: Stripe.Subscription = - await this.stripe.subscriptions.create({ - customer: customerId, - items: [ - { - price: priceId, + const product = await this.resolveDonationProductId(); + + const subscription = await this.stripe.subscriptions.create({ + customer: customerId, + items: [ + { + price_data: { + currency, + unit_amount: params.amount, + recurring, + product, }, - ], - }); + }, + ], + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret'], + metadata: params.metadata, + }); + + const invoice = subscription.latest_invoice as Stripe.Invoice | null; + const clientSecret = invoice?.confirmation_secret?.client_secret; + if (!clientSecret) { + throw new Error( + 'Subscription created but no confirmation secret was returned', + ); + } + + // confirmation_secret holds a PaymentIntent client secret ("pi_x_secret_y"); + // the PaymentIntent id is the portion before "_secret_". + const paymentIntentId = clientSecret.split('_secret_')[0]; + if (!paymentIntentId.startsWith('pi_')) { + throw new Error( + 'Unexpected client secret format from subscription invoice', + ); + } + + this.logger.debug( + `createSubscription -> ${subscription.id} (pi ${paymentIntentId})`, + ); - this.logger.debug(`createSubscription (stub) -> ${subscription.id}`); return { - id: subscription.id, - customerId: subscription.customer as string, - priceId: subscription.items.data[0].price.id, - interval: subscription.items.data[0].price.recurring - .interval as RecurringInterval, + subscriptionId: subscription.id, + customerId, + paymentIntentId, + clientSecret, status: subscription.status, + amount: params.amount, + currency, }; } catch (error) { this.logger.error(`Error creating subscription: ${error.message}`); @@ -278,6 +395,36 @@ export class PaymentsService { } } + /** + * Resolves the PaymentIntent id that paid a given invoice. On this Stripe API + * version invoices no longer expose `payment_intent` directly, so we read the + * expanded `payments` list. Returns undefined if none is found. + */ + async getPaymentIntentIdForInvoice( + invoiceId: string, + ): Promise { + try { + const invoice = await this.stripe.invoices.retrieve(invoiceId, { + expand: ['payments'], + }); + const payments = invoice.payments?.data ?? []; + for (const invoicePayment of payments) { + const payment = invoicePayment.payment; + if (payment?.type === 'payment_intent' && payment.payment_intent) { + return typeof payment.payment_intent === 'string' + ? payment.payment_intent + : payment.payment_intent.id; + } + } + return undefined; + } catch (err) { + this.logger.error( + `Error resolving payment intent for invoice ${invoiceId}: ${err.message}`, + ); + return undefined; + } + } + /** * Validates the parameters for retrieve payment intent * diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 37d98fd..e045e70 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -13,6 +13,8 @@ export type DonationCreateRequest = { showDedicationPublicly: boolean; recurringInterval?: 'weekly' | 'monthly' | 'annually'; paymentIntentId?: string; + stripeSubscriptionId?: string; + stripeCustomerId?: string; }; export type CreateDonationResponse = { id: string }; @@ -135,6 +137,25 @@ export type PaymentIntentResponse = { status: string; }; +export type CreateSubscriptionRequest = { + amount: number; // in cents + currency: string; + interval: 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'annually'; + email: string; + name: string; + metadata?: Record; +}; + +export type SubscriptionResponse = { + id: string; // first PaymentIntent id (used as the donation transactionId) + clientSecret: string; + subscriptionId: string; + customerId: string; + status: string; + amount: number; + currency: string; +}; + export type SignInRequest = { email: string; password: string }; export type SignUpRequest = { @@ -191,6 +212,29 @@ export class ApiClient { } } + public async createSubscription( + body: CreateSubscriptionRequest, + ): Promise { + try { + const res = await this.axiosInstance.post( + '/api/payments/subscription', + body, + ); + return res.data as SubscriptionResponse; + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + const data = err.response?.data; + const msg = + data?.error ?? + data?.message ?? + err.message ?? + 'Failed to create subscription'; + throw new Error(msg); + } + throw new Error('Failed to create subscription'); + } + } + public async syncPaymentIntent(intentId: string): Promise { try { await this.axiosInstance.post(`/api/payments/intent/${intentId}/sync`); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index f5cfce9..199b1ee 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -110,10 +110,12 @@ const router = createBrowserRouter([ { path: '/donate', element: ( - console.log('Donation successful:', id)} - onError={(err) => console.error('Donation failed:', err)} - /> +
+ console.log('Donation successful:', id)} + onError={(err) => console.error('Donation failed:', err)} + /> +
), }, ]); diff --git a/apps/frontend/src/containers/donations/DonationForm.spec.tsx b/apps/frontend/src/containers/donations/DonationForm.spec.tsx index 18bc459..d8ad625 100644 --- a/apps/frontend/src/containers/donations/DonationForm.spec.tsx +++ b/apps/frontend/src/containers/donations/DonationForm.spec.tsx @@ -8,6 +8,7 @@ import { waitFor, cleanup, } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import apiClient from '../../api/apiClient'; import { DonationForm } from './DonationForm'; import type { CreateDonationResponse } from '../../api/apiClient'; @@ -25,7 +26,11 @@ describe('DonationForm Component', () => { }); it('prevents advancing past Step 1 when amount is missing', async () => { - render(); + render( + + + , + ); fireEvent.click(screen.getByRole('button', { name: /next/i })); @@ -35,7 +40,11 @@ describe('DonationForm Component', () => { }); it('shows payment details on Step 2 after entering amount', async () => { - render(); + render( + + + , + ); fireEvent.change(screen.getByLabelText(/donation amount/i), { target: { value: '45' }, @@ -56,7 +65,11 @@ describe('DonationForm Component', () => { .spyOn(apiClient, 'createDonation') .mockResolvedValueOnce({ id: '123' }); - render(); + render( + + + , + ); fireEvent.change(screen.getByLabelText(/donation amount/i), { target: { value: '50' }, @@ -93,7 +106,11 @@ describe('DonationForm Component', () => { .spyOn(apiClient, 'createDonation') .mockReturnValueOnce(pending as Promise); - render(); + render( + + + , + ); fireEvent.change(screen.getByLabelText(/donation amount/i), { target: { value: '75' }, diff --git a/apps/frontend/src/containers/donations/DonationForm.tsx b/apps/frontend/src/containers/donations/DonationForm.tsx index 42b9cf8..1a5c416 100644 --- a/apps/frontend/src/containers/donations/DonationForm.tsx +++ b/apps/frontend/src/containers/donations/DonationForm.tsx @@ -17,6 +17,7 @@ import { Step2Details } from './steps/Step2Details'; import { Step3Confirm } from './steps/Step3Confirm'; import { Step4Receipt } from './steps/Step4Receipt'; import { StripeProvider } from './StripeProvider'; +import { calculateChargeAmount } from './DonationSummary'; import { Button } from '@components/ui/button'; export const DonationForm: React.FC = ({ @@ -219,14 +220,25 @@ export const DonationForm: React.FC = ({ setCurrentStep(1); }; + const handleCoverFeesChange = (value: boolean) => { + setFormData((prev) => ({ ...prev, coverFees: value })); + }; + const handleBeforePayment = async ( paymentIntentId: string, + subscriptionInfo?: { + stripeSubscriptionId: string; + stripeCustomerId: string; + }, ): Promise => { const payload: CreateDonationRequest = { firstName: formData.firstName.trim(), lastName: formData.lastName.trim(), email: formData.email.trim(), - amount: parseFloat(formData.amount), + amount: calculateChargeAmount( + parseFloat(formData.amount) || 0, + formData.coverFees, + ), isAnonymous: formData.isAnonymous, donationType: formData.donationType, dedicationMessage: formData.dedicationMessage, @@ -235,6 +247,10 @@ export const DonationForm: React.FC = ({ ...(formData.donationType === 'recurring' && { recurringInterval: formData.recurringInterval, }), + ...(subscriptionInfo && { + stripeSubscriptionId: subscriptionInfo.stripeSubscriptionId, + stripeCustomerId: subscriptionInfo.stripeCustomerId, + }), }; const response: CreateDonationResponse = @@ -270,6 +286,7 @@ export const DonationForm: React.FC = ({ errors={errors} isSubmitting={isSubmitting} onChange={handleInputChange} + onCoverFeesChange={handleCoverFeesChange} /> ); diff --git a/apps/frontend/src/containers/donations/DonationSummary.spec.tsx b/apps/frontend/src/containers/donations/DonationSummary.spec.tsx index 254b40f..02209ee 100644 --- a/apps/frontend/src/containers/donations/DonationSummary.spec.tsx +++ b/apps/frontend/src/containers/donations/DonationSummary.spec.tsx @@ -1,97 +1,93 @@ /** @vitest-environment jsdom */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { DonationSummary } from './DonationSummary'; -import { DONATION_FEE_RATE, DONATION_FIXED_FEE } from './DonationSummary'; +import { + DonationSummary, + calculateFeeTotal, + calculateChargeAmount, + DONATION_FEE_RATE, + DONATION_FIXED_FEE, +} from './DonationSummary'; -describe('DonationSummary Component', () => { - beforeEach(() => { - vi.clearAllMocks(); +describe('fee calculations', () => { + it('grosses up so the org nets the base after Stripe takes its cut', () => { + const base = 50; + const charged = base + calculateFeeTotal(base); + const net = + charged - (charged * DONATION_FEE_RATE) / 100 - DONATION_FIXED_FEE; + expect(net).toBeCloseTo(base, 5); }); - afterEach(() => { - cleanup(); + it('supports custom rate/fixed fee', () => { + const base = 40; + const charged = base + calculateFeeTotal(base, 5, 1); + const net = charged - (charged * 5) / 100 - 1; + expect(net).toBeCloseTo(base, 5); }); - // unit tests for fee calculation + it('returns a zero fee for non-positive base amounts', () => { + expect(calculateFeeTotal(0)).toBe(0); + expect(calculateFeeTotal(-5)).toBe(0); + }); - // fee calculation with default values - it('calculates the fee with default values', () => { - const baseAmount = Math.random() * 10; - const feeTotal = ( - (baseAmount * DONATION_FEE_RATE) / 100 + - DONATION_FIXED_FEE - ).toFixed(2); - render(); - expect( - screen.queryByText( - new RegExp( - `Add \\$${feeTotal} to cover transaction fees and tip the fundraising platform to help keep it`, - ), - ), - ).not.toBeNull(); + it('calculateChargeAmount returns the base when coverFees is off', () => { + expect(calculateChargeAmount(50, false)).toBe(50); + expect(calculateChargeAmount(0, true)).toBe(0); + }); + + it('calculateChargeAmount adds the fee (rounded to cents) when coverFees is on', () => { + const expected = Math.round((50 + calculateFeeTotal(50)) * 100) / 100; + const charge = calculateChargeAmount(50, true); + expect(charge).toBe(expected); + expect(charge).toBeGreaterThan(50); + // rounded to whole cents + expect(Math.round(charge * 100)).toBe(charge * 100); + }); +}); + +describe('DonationSummary Component', () => { + afterEach(() => { + cleanup(); }); - // fee calculation with custom values - it('calculates the fee with default values', () => { - const baseAmount = Math.random() * 10; - const feeRate = Math.random() * 10; - const fixedFee = Math.random() * 10; - const feeTotal = ((baseAmount * feeRate) / 100 + fixedFee).toFixed(2); + it('renders the gross-up fee amount in the prompt', () => { + const base = 50; + const feeTotal = calculateFeeTotal(base).toFixed(2); render( , ); expect( screen.queryByText( - new RegExp( - `Add \\$${feeTotal} to cover transaction fees and tip the fundraising platform to help keep it`, - ), + new RegExp(`Add \\$${feeTotal} to cover transaction fees`), ), ).not.toBeNull(); }); - // donation total calculation does not include fee when initially rendered - it('calculates total donation amount without fee when initially rendered', async () => { - const baseAmount = Math.random() * 10; - - // initial rendering does not include fee in total donation calculation - render(); - expect( - screen - .getByTestId('donation-total') - .textContent?.includes(baseAmount.toFixed(2)), - ).toBeTruthy(); - }); - - // donation total calculation includes fee when toggle activated - it('calculates total donation amount with fee when toggle activated', async () => { - const baseAmount = Math.random() * 10; - const feeTotal = - (baseAmount * DONATION_FEE_RATE) / 100 + DONATION_FIXED_FEE; - render(); - - // activate fee toggle - const feeToggle = screen.getAllByTestId('fee-toggle'); - fireEvent.click(feeToggle[0]); - - // donation total calculation should include fee - expect( - screen - .getByTestId('donation-total') - .textContent?.includes((baseAmount + feeTotal).toFixed(2)), - ).toBeTruthy(); + it('is controlled: toggling emits the negated coverFees value', () => { + const onCoverFeesChange = vi.fn(); - fireEvent.click(feeToggle[0]); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId('fee-toggle')); + expect(onCoverFeesChange).toHaveBeenCalledWith(true); - // donation total calculation should not include fee - expect( - screen - .getByTestId('donation-total') - .textContent?.includes(baseAmount.toFixed(2)), - ).toBeTruthy(); + rerender( + , + ); + fireEvent.click(screen.getByTestId('fee-toggle')); + expect(onCoverFeesChange).toHaveBeenCalledWith(false); }); }); diff --git a/apps/frontend/src/containers/donations/DonationSummary.tsx b/apps/frontend/src/containers/donations/DonationSummary.tsx index 7152f96..7cb1c90 100644 --- a/apps/frontend/src/containers/donations/DonationSummary.tsx +++ b/apps/frontend/src/containers/donations/DonationSummary.tsx @@ -1,40 +1,66 @@ -import { useState } from 'react'; - type DonationSummaryProps = { - setCurrentAmount?: React.Dispatch>; baseAmount: number; + coverFees: boolean; + onCoverFeesChange: (value: boolean) => void; feeRate?: number; fixedFee?: number; }; -export const DONATION_FEE_RATE = 2.9; -export const DONATION_FIXED_FEE = 0.3; +export const DONATION_FEE_RATE = 2.9; // percent +export const DONATION_FIXED_FEE = 0.3; // dollars + +/** + * The processing fee a donor covers so the org nets the full base amount. + * Uses the gross-up formula (fee is charged on the total, not just the base): + * charged = (base + fixed) / (1 - rate) -> feeTotal = charged - base + * Returns 0 for non-positive amounts. + */ +export const calculateFeeTotal = ( + baseAmount: number, + feeRate: number = DONATION_FEE_RATE, + fixedFee: number = DONATION_FIXED_FEE, +): number => { + if (baseAmount <= 0) return 0; + const charged = (baseAmount + fixedFee) / (1 - feeRate / 100); + return charged - baseAmount; +}; + +/** + * The amount actually charged: base plus the covered fee (rounded to cents) + * when fee coverage is on, otherwise the base. Single source of truth for the + * donation summary display, the Stripe charge, and the recorded donation. + */ +export const calculateChargeAmount = ( + baseAmount: number, + coverFees: boolean, + feeRate: number = DONATION_FEE_RATE, + fixedFee: number = DONATION_FIXED_FEE, +): number => { + if (!coverFees || baseAmount <= 0) return baseAmount; + const charge = baseAmount + calculateFeeTotal(baseAmount, feeRate, fixedFee); + return Math.round(charge * 100) / 100; +}; export const DonationSummary = ({ - setCurrentAmount, baseAmount, + coverFees, + onCoverFeesChange, feeRate, fixedFee, }: DonationSummaryProps) => { - const [feeApplied, setFeeApplied] = useState(false); - - const rate = feeRate ?? DONATION_FEE_RATE; - const fee = fixedFee ?? DONATION_FIXED_FEE; - const feeTotal = (baseAmount * rate) / 100 + fee; + const feeTotal = calculateFeeTotal(baseAmount, feeRate, fixedFee); const handleToggle = () => { - const next = !feeApplied; - setFeeApplied(next); - setCurrentAmount?.(next ? baseAmount + feeTotal : baseAmount); + onCoverFeesChange(!coverFees); }; const feeText = `Add $${feeTotal.toFixed(2)} to cover transaction fees and help keep it `; - const toggleClass = feeApplied + const toggleClass = coverFees ? 'border-2 border-[#2C8974] bg-[#F0F0F0]' : 'bg-gray-300'; - const circleClass = feeApplied + const circleClass = coverFees ? 'bg-[#2C8974] left-[50%]' : 'bg-white left-[10%]'; diff --git a/apps/frontend/src/containers/donations/steps/Step2Details.tsx b/apps/frontend/src/containers/donations/steps/Step2Details.tsx index 4e2afef..a49152a 100644 --- a/apps/frontend/src/containers/donations/steps/Step2Details.tsx +++ b/apps/frontend/src/containers/donations/steps/Step2Details.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useImperativeHandle, useState } from 'react'; import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; import { Input } from '@components/ui/input'; import { FormField } from './FormField'; -import { DonationSummary } from '../DonationSummary'; +import { DonationSummary, calculateChargeAmount } from '../DonationSummary'; import type { DonationFormData, FormErrors } from '../donation-form.types'; export interface Step2DetailsRef { @@ -18,6 +18,7 @@ type Step2DetailsProps = { HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement >, ) => void; + onCoverFeesChange: (value: boolean) => void; }; const cardElementOptions = { @@ -37,11 +38,14 @@ const cardElementOptions = { }; export const Step2Details = forwardRef( - function Step2Details({ formData, errors, isSubmitting, onChange }, ref) { + function Step2Details( + { formData, errors, isSubmitting, onChange, onCoverFeesChange }, + ref, + ) { const stripe = useStripe(); const elements = useElements(); const baseAmount = parseFloat(formData.amount) || 0; - const [currentAmount, setCurrentAmount] = useState(baseAmount); + const currentAmount = calculateChargeAmount(baseAmount, formData.coverFees); const [cardError, setCardError] = useState(null); useImperativeHandle(ref, () => ({ @@ -168,8 +172,9 @@ export const Step2Details = forwardRef( ); diff --git a/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx b/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx index be8d9b8..62e1bf3 100644 --- a/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx +++ b/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx @@ -2,12 +2,21 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useStripe } from '@stripe/react-stripe-js'; import type { DonationFormData } from '../donation-form.types'; import apiClient from '../../../api/apiClient'; +import { calculateChargeAmount } from '../DonationSummary'; import { Card } from '@components/ui/card'; +export interface SubscriptionInfo { + stripeSubscriptionId: string; + stripeCustomerId: string; +} + interface Step3ConfirmProps { formData: DonationFormData; paymentMethodId: string | null; - onBeforePayment: (paymentIntentId: string) => Promise; + onBeforePayment: ( + paymentIntentId: string, + subscriptionInfo?: SubscriptionInfo, + ) => Promise; onPaymentSuccess: (donationId: string) => void; onPaymentError: (error: string) => void; isSubmitting: boolean; @@ -28,7 +37,10 @@ export const Step3Confirm: React.FC = ({ const stripe = useStripe(); const [error, setError] = useState(null); - const amount = parseFloat(formData.amount) || 0; + const amount = calculateChargeAmount( + parseFloat(formData.amount) || 0, + formData.coverFees, + ); const handleConfirmPayment = useCallback(async () => { if (!stripe) { @@ -47,21 +59,55 @@ export const Step3Confirm: React.FC = ({ setError(null); try { - // Step 1: Create PaymentIntent + // Step 1: Create the payment. Recurring donations create a Stripe + // Subscription whose first invoice yields a PaymentIntent we confirm with + // the exact same code as a one-time payment; one-time donations create a + // plain PaymentIntent. const amountInCents = Math.round(amount * 100); - const paymentIntentResponse = await apiClient.createPaymentIntent({ - amount: amountInCents, - currency: 'usd', - metadata: { + + let clientSecret: string; + let paymentIntentId: string; + let subscriptionInfo: SubscriptionInfo | undefined; + + if (formData.donationType === 'recurring') { + const subscriptionResponse = await apiClient.createSubscription({ + amount: amountInCents, + currency: 'usd', + interval: formData.recurringInterval, email: formData.email, - donationType: formData.donationType, - }, - }); + name: `${formData.firstName} ${formData.lastName}`.trim(), + metadata: { + email: formData.email, + donationType: formData.donationType, + recurringInterval: formData.recurringInterval, + }, + }); + clientSecret = subscriptionResponse.clientSecret; + paymentIntentId = subscriptionResponse.id; + subscriptionInfo = { + stripeSubscriptionId: subscriptionResponse.subscriptionId, + stripeCustomerId: subscriptionResponse.customerId, + }; + } else { + const paymentIntentResponse = await apiClient.createPaymentIntent({ + amount: amountInCents, + currency: 'usd', + metadata: { + email: formData.email, + donationType: formData.donationType, + }, + }); + clientSecret = paymentIntentResponse.clientSecret; + paymentIntentId = paymentIntentResponse.id; + } - const donationId = await onBeforePayment(paymentIntentResponse.id); + const donationId = await onBeforePayment( + paymentIntentId, + subscriptionInfo, + ); const { error: stripeError, paymentIntent } = - await stripe.confirmCardPayment(paymentIntentResponse.clientSecret, { + await stripe.confirmCardPayment(clientSecret, { payment_method: paymentMethodId, }); @@ -74,7 +120,7 @@ export const Step3Confirm: React.FC = ({ if (paymentIntent && paymentIntent.status === 'succeeded') { // Explicitly trigger a backend sync for localhost environments without webhooks listening try { - await apiClient.syncPaymentIntent(paymentIntentResponse.id); + await apiClient.syncPaymentIntent(paymentIntentId); } catch (e) { console.warn('Failed to explicitly sync payment intent', e); } @@ -97,6 +143,10 @@ export const Step3Confirm: React.FC = ({ amount, formData.email, formData.donationType, + formData.recurringInterval, + formData.firstName, + formData.lastName, + onBeforePayment, setIsSubmitting, onPaymentSuccess, onPaymentError, diff --git a/example.env b/example.env index 7ec005b..3f6e54c 100644 --- a/example.env +++ b/example.env @@ -36,6 +36,7 @@ AWS_SES_SECRET_ACCESS_KEY=placeholder STRIPE_SECRET_KEY= STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= +STRIPE_DONATION_PRODUCT_ID= # ------------------------------------------------------------------------------ # 2. UNIQUE PER DEVELOPER (Required for AWS/IAM access) diff --git a/seed-mock.sql b/seed-mock.sql new file mode 100644 index 0000000..169af3a --- /dev/null +++ b/seed-mock.sql @@ -0,0 +1,51 @@ +INSERT INTO donations ("firstName", "lastName", "email", "amount", "isAnonymous", "donationType", "recurringInterval", "status", "createdAt", "updatedAt", "transactionId", "feeAmount") VALUES +('Mallory', 'Black', 'mallory@example.com', 10289, false, 'recurring', 'monthly', 'succeeded', '2024-05-31T01:40:47.207Z', '2024-05-31T01:40:47.207Z', 'txn_mock_uedkr', 328), +('Bob', 'Brown', 'bob@example.com', 46029, false, 'one_time', NULL, 'succeeded', '2026-06-01T10:40:02.013Z', '2026-06-01T10:40:02.013Z', 'txn_mock_ovlwqp', 1364), +('Eve', 'White', 'eve@example.com', 19881, false, 'one_time', NULL, 'succeeded', '2025-10-01T13:54:57.480Z', '2025-10-01T13:54:57.480Z', 'txn_mock_glpt1v', null), +('Bob', 'Brown', 'bob@example.com', 1822, false, 'recurring', 'monthly', 'succeeded', '2026-05-14T23:10:19.724Z', '2026-05-14T23:10:19.724Z', 'txn_mock_z7j9j4', null), +('Eve', 'White', 'eve@example.com', 49304, false, 'one_time', NULL, 'succeeded', '2024-11-03T21:56:28.905Z', '2024-11-03T21:56:28.905Z', 'txn_mock_d1dy4l', null), +('Eve', 'White', 'eve@example.com', 44449, true, 'one_time', NULL, 'succeeded', '2026-01-12T01:40:27.971Z', '2026-01-12T01:40:27.971Z', 'txn_mock_4f4asx', 1319), +('Eve', 'White', 'eve@example.com', 37379, false, 'one_time', NULL, 'succeeded', '2025-03-25T08:24:24.878Z', '2025-03-25T08:24:24.878Z', 'txn_mock_470xmo', 1113), +('Charlie', 'Davis', 'charlie@example.com', 26633, false, 'recurring', 'monthly', 'succeeded', '2024-08-17T09:37:42.411Z', '2024-08-17T09:37:42.411Z', 'txn_mock_8srxr', null), +('Alice', 'Johnson', 'alice@example.com', 46562, false, 'one_time', NULL, 'succeeded', '2025-06-30T14:25:29.231Z', '2025-06-30T14:25:29.231Z', 'txn_mock_943ofm', null), +('Jane', 'Smith', 'jane@example.com', 41791, false, 'one_time', NULL, 'succeeded', '2024-05-23T08:31:20.395Z', '2024-05-23T08:31:20.395Z', 'txn_mock_xe5pqb', null), +('John', 'Doe', 'john@example.com', 40594, true, 'recurring', 'monthly', 'succeeded', '2024-12-16T06:24:11.029Z', '2024-12-16T06:24:11.029Z', 'txn_mock_3i43w', null), +('Mallory', 'Black', 'mallory@example.com', 11415, false, 'one_time', NULL, 'succeeded', '2025-07-31T13:14:21.534Z', '2025-07-31T13:14:21.534Z', 'txn_mock_4bxbz', null), +('John', 'Doe', 'john@example.com', 7684, false, 'one_time', NULL, 'succeeded', '2025-09-12T18:17:01.134Z', '2025-09-12T18:17:01.134Z', 'txn_mock_o0h5b4y', null), +('Mallory', 'Black', 'mallory@example.com', 35669, false, 'one_time', NULL, 'succeeded', '2024-06-10T23:10:24.822Z', '2024-06-10T23:10:24.822Z', 'txn_mock_20tpz9', null), +('Mallory', 'Black', 'mallory@example.com', 17360, false, 'one_time', NULL, 'succeeded', '2024-06-11T14:05:53.597Z', '2024-06-11T14:05:53.597Z', 'txn_mock_sj3dcu', null), +('Eve', 'White', 'eve@example.com', 47057, false, 'one_time', NULL, 'succeeded', '2024-01-06T10:24:37.211Z', '2024-01-06T10:24:37.211Z', 'txn_mock_941c6', 1394), +('John', 'Doe', 'john@example.com', 25333, false, 'one_time', NULL, 'succeeded', '2025-06-05T22:12:48.227Z', '2025-06-05T22:12:48.227Z', 'txn_mock_tlbcb4', null), +('John', 'Doe', 'john@example.com', 42594, false, 'one_time', NULL, 'succeeded', '2024-07-26T15:40:44.894Z', '2024-07-26T15:40:44.894Z', 'txn_mock_f4e45t', null), +('Jane', 'Smith', 'jane@example.com', 17241, false, 'recurring', 'monthly', 'succeeded', '2025-06-26T21:01:37.259Z', '2025-06-26T21:01:37.259Z', 'txn_mock_fpywmwp', null), +('John', 'Doe', 'john@example.com', 24429, false, 'recurring', 'monthly', 'succeeded', '2024-10-01T11:07:24.801Z', '2024-10-01T11:07:24.801Z', 'txn_mock_lwtuwi', 738), +('Charlie', 'Davis', 'charlie@example.com', 38060, false, 'recurring', 'monthly', 'succeeded', '2024-11-01T22:10:29.912Z', '2024-11-01T22:10:29.912Z', 'txn_mock_17tfgk', 1133), +('Bob', 'Brown', 'bob@example.com', 20729, false, 'one_time', NULL, 'succeeded', '2025-10-08T11:55:22.828Z', '2025-10-08T11:55:22.828Z', 'txn_mock_3us1t', 631), +('John', 'Doe', 'john@example.com', 41272, false, 'recurring', 'monthly', 'succeeded', '2025-04-28T04:26:44.216Z', '2025-04-28T04:26:44.216Z', 'txn_mock_qx1ixd', 1226), +('John', 'Doe', 'john@example.com', 39844, false, 'one_time', NULL, 'succeeded', '2025-04-23T16:38:17.801Z', '2025-04-23T16:38:17.801Z', 'txn_mock_gli5al', null), +('Jane', 'Smith', 'jane@example.com', 34082, false, 'recurring', 'monthly', 'succeeded', '2026-05-20T10:38:50.002Z', '2026-05-20T10:38:50.002Z', 'txn_mock_t8cy7l', null), +('Charlie', 'Davis', 'charlie@example.com', 2408, false, 'one_time', NULL, 'succeeded', '2024-03-05T08:37:31.030Z', '2024-03-05T08:37:31.030Z', 'txn_mock_houhwv', null), +('Jane', 'Smith', 'jane@example.com', 33170, false, 'one_time', NULL, 'succeeded', '2024-03-23T04:44:10.238Z', '2024-03-23T04:44:10.238Z', 'txn_mock_4lhzte', null), +('Jane', 'Smith', 'jane@example.com', 35573, false, 'one_time', NULL, 'succeeded', '2024-03-18T12:30:05.407Z', '2024-03-18T12:30:05.407Z', 'txn_mock_r8osdn', null), +('Eve', 'White', 'eve@example.com', 40675, false, 'one_time', NULL, 'succeeded', '2026-06-25T06:06:38.355Z', '2026-06-25T06:06:38.355Z', 'txn_mock_p2lh8n', null), +('Charlie', 'Davis', 'charlie@example.com', 46599, false, 'recurring', 'monthly', 'succeeded', '2024-08-11T18:50:00.502Z', '2024-08-11T18:50:00.502Z', 'txn_mock_s9wlh', null), +('Eve', 'White', 'eve@example.com', 19165, false, 'one_time', NULL, 'succeeded', '2025-09-12T05:56:23.919Z', '2025-09-12T05:56:23.919Z', 'txn_mock_2fom2w', 585), +('Jane', 'Smith', 'jane@example.com', 14777, true, 'recurring', 'monthly', 'succeeded', '2026-04-20T08:37:12.853Z', '2026-04-20T08:37:12.853Z', 'txn_mock_ude9gt', null), +('Bob', 'Brown', 'bob@example.com', 20420, false, 'one_time', NULL, 'succeeded', '2026-04-28T11:40:25.534Z', '2026-04-28T11:40:25.534Z', 'txn_mock_tuc4ie', 622), +('Bob', 'Brown', 'bob@example.com', 2796, false, 'recurring', 'monthly', 'succeeded', '2026-03-20T18:40:50.405Z', '2026-03-20T18:40:50.405Z', 'txn_mock_olnv05', null), +('John', 'Doe', 'john@example.com', 12216, false, 'recurring', 'monthly', 'succeeded', '2025-04-04T23:17:27.753Z', '2025-04-04T23:17:27.753Z', 'txn_mock_0lph2t', null), +('Alice', 'Johnson', 'alice@example.com', 39330, false, 'one_time', NULL, 'succeeded', '2026-05-14T21:28:12.104Z', '2026-05-14T21:28:12.104Z', 'txn_mock_7oniqw', 1170), +('Eve', 'White', 'eve@example.com', 38696, false, 'recurring', 'monthly', 'succeeded', '2025-09-09T14:59:48.117Z', '2025-09-09T14:59:48.117Z', 'txn_mock_yi0yn4', null), +('Mallory', 'Black', 'mallory@example.com', 43645, false, 'recurring', 'monthly', 'succeeded', '2025-01-22T10:43:38.333Z', '2025-01-22T10:43:38.333Z', 'txn_mock_6iv1vq', null), +('Jane', 'Smith', 'jane@example.com', 33986, false, 'one_time', NULL, 'succeeded', '2026-05-29T17:29:56.581Z', '2026-05-29T17:29:56.581Z', 'txn_mock_vu2qd', null), +('Eve', 'White', 'eve@example.com', 19052, false, 'one_time', NULL, 'succeeded', '2024-04-21T19:49:46.045Z', '2024-04-21T19:49:46.045Z', 'txn_mock_vt7uu', 582), +('Jane', 'Smith', 'jane@example.com', 10528, false, 'one_time', NULL, 'succeeded', '2026-02-04T19:32:47.874Z', '2026-02-04T19:32:47.874Z', 'txn_mock_pfd8rk', null), +('Charlie', 'Davis', 'charlie@example.com', 38524, false, 'one_time', NULL, 'succeeded', '2024-10-12T05:49:13.986Z', '2024-10-12T05:49:13.986Z', 'txn_mock_adr8dk', null), +('Charlie', 'Davis', 'charlie@example.com', 26076, false, 'one_time', NULL, 'succeeded', '2024-10-06T03:17:17.791Z', '2024-10-06T03:17:17.791Z', 'txn_mock_1i0pih', null), +('Eve', 'White', 'eve@example.com', 29376, false, 'one_time', NULL, 'succeeded', '2025-05-13T08:40:50.234Z', '2025-05-13T08:40:50.234Z', 'txn_mock_efaqho', null), +('Charlie', 'Davis', 'charlie@example.com', 45900, false, 'one_time', NULL, 'succeeded', '2024-12-26T01:44:34.220Z', '2024-12-26T01:44:34.220Z', 'txn_mock_drhrk', null), +('Alice', 'Johnson', 'alice@example.com', 34254, false, 'recurring', 'monthly', 'succeeded', '2026-05-29T08:44:32.881Z', '2026-05-29T08:44:32.881Z', 'txn_mock_rw5w6q', null), +('Mallory', 'Black', 'mallory@example.com', 5498, true, 'one_time', NULL, 'succeeded', '2024-01-02T04:03:51.917Z', '2024-01-02T04:03:51.917Z', 'txn_mock_8fnz5', 189), +('Eve', 'White', 'eve@example.com', 50110, false, 'recurring', 'monthly', 'succeeded', '2024-02-14T08:32:31.555Z', '2024-02-14T08:32:31.555Z', 'txn_mock_1rtjgrp', null), +('Alice', 'Johnson', 'alice@example.com', 25047, false, 'one_time', NULL, 'succeeded', '2024-08-07T00:09:27.126Z', '2024-08-07T00:09:27.126Z', 'txn_mock_r678j', null), +('Bob', 'Brown', 'bob@example.com', 41571, false, 'recurring', 'monthly', 'succeeded', '2026-03-25T19:21:02.452Z', '2026-03-25T19:21:02.452Z', 'txn_mock_k3d35d', 1235); \ No newline at end of file