From 5e9b171460a6c81bc81ae2b3e09f2c8f144c3043 Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:36:40 -0400 Subject: [PATCH 1/9] feat(donations): add Stripe subscription/customer id columns Add nullable stripeSubscriptionId and stripeCustomerId to the Donation entity so recurring donations can be linked to their Stripe subscription (for renewals and future management). Includes a migration that also adds the previously entity-only feeAmount column (guarded with IF NOT EXISTS), and registers the migration in data-source.ts (migrations are statically imported for Webpack bundling). --- apps/backend/src/data-source.ts | 2 ++ apps/backend/src/donations/donation.entity.ts | 6 ++++ ...00000000-add-stripe-subscription-fields.ts | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 apps/backend/src/migrations/1781200000000-add-stripe-subscription-fields.ts 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/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. + } +} From b5bed47896184ff5c5937bf9873aeff8c25b1536 Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:39:43 -0400 Subject: [PATCH 2/9] feat(donations): persist Stripe ids and record subscription renewals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept and persist stripeSubscriptionId/stripeCustomerId on donation creation, and add recordRenewalCharge() which writes each successful (or failed) subscription renewal as its own donation row. Renewals are matched to the original donation via stripeSubscriptionId, are idempotent on the renewal PaymentIntent id, and — being succeeded rows — count toward the goal total automatically. --- .../src/donations/donations.service.ts | 92 +++++++++++++++++++ .../src/donations/dtos/create-donation-dto.ts | 19 ++++ apps/backend/src/donations/mappers.ts | 4 + 3 files changed, 115 insertions(+) diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 4cefe3d..86c5346 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 @@ -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'); 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..672984f 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 { @@ -69,6 +71,8 @@ export class DonationMappers { dedicationMessage: dto.dedicationMessage, showDedicationPublicly: dto.showDedicationPublicly ?? false, paymentIntentId: dto.paymentIntentId, + stripeSubscriptionId: dto.stripeSubscriptionId, + stripeCustomerId: dto.stripeCustomerId, }; } From 80f3d6301b73f7e9a93277532b2140d755babae7 Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:40:11 -0400 Subject: [PATCH 3/9] feat(payments): create Stripe subscriptions and handle renewal invoices Replace the createSubscription stub with a full flow: find-or-create the Stripe customer, resolve/create the donation product, create the subscription with inline price_data and payment_behavior 'default_incomplete', and return the first PaymentIntent's id + client secret (derived from latest_invoice.confirmation_secret) so the frontend confirms it exactly like a one-time payment. Adds POST /payments/subscription and its response DTO/mapper, and webhook handling for invoice.paid / invoice.payment_failed that records subscription-cycle renewals. Co-authored resolution helper getPaymentIntentIdForInvoice reads the expanded invoice.payments list since invoice.payment_intent no longer exists on this Stripe API version. --- .../dtos/subscription-response-dto.ts | 52 ++++ apps/backend/src/payments/mappers.ts | 38 ++- .../src/payments/payments.controller.ts | 107 +++++++ apps/backend/src/payments/payments.service.ts | 265 ++++++++++++++---- 4 files changed, 402 insertions(+), 60 deletions(-) create mode 100644 apps/backend/src/payments/dtos/subscription-response-dto.ts 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.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.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 * From 8692659997f204d12111e85238b06e795d5d3435 Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:40:24 -0400 Subject: [PATCH 4/9] feat(frontend): route recurring donations through Stripe subscriptions Add apiClient.createSubscription and branch the checkout flow: recurring donations call the subscription endpoint, one-time donations keep using the payment intent endpoint. Both return { id, clientSecret }, so the existing confirmCardPayment flow is unchanged. The subscription's customer and subscription ids are threaded into createDonation so they persist on the donation row. --- apps/frontend/src/api/apiClient.ts | 44 ++++++++++++ .../src/containers/donations/DonationForm.tsx | 8 +++ .../donations/steps/Step3Confirm.tsx | 70 +++++++++++++++---- 3 files changed, 110 insertions(+), 12 deletions(-) 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/containers/donations/DonationForm.tsx b/apps/frontend/src/containers/donations/DonationForm.tsx index 42b9cf8..14fa234 100644 --- a/apps/frontend/src/containers/donations/DonationForm.tsx +++ b/apps/frontend/src/containers/donations/DonationForm.tsx @@ -221,6 +221,10 @@ export const DonationForm: React.FC = ({ const handleBeforePayment = async ( paymentIntentId: string, + subscriptionInfo?: { + stripeSubscriptionId: string; + stripeCustomerId: string; + }, ): Promise => { const payload: CreateDonationRequest = { firstName: formData.firstName.trim(), @@ -235,6 +239,10 @@ export const DonationForm: React.FC = ({ ...(formData.donationType === 'recurring' && { recurringInterval: formData.recurringInterval, }), + ...(subscriptionInfo && { + stripeSubscriptionId: subscriptionInfo.stripeSubscriptionId, + stripeCustomerId: subscriptionInfo.stripeCustomerId, + }), }; const response: CreateDonationResponse = diff --git a/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx b/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx index be8d9b8..44e7ec4 100644 --- a/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx +++ b/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx @@ -4,10 +4,18 @@ import type { DonationFormData } from '../donation-form.types'; import apiClient from '../../../api/apiClient'; 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; @@ -47,21 +55,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 +116,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 +139,10 @@ export const Step3Confirm: React.FC = ({ amount, formData.email, formData.donationType, + formData.recurringInterval, + formData.firstName, + formData.lastName, + onBeforePayment, setIsSubmitting, onPaymentSuccess, onPaymentError, From 1185bb27d4ec24444e24a5a84402bdab65c5775f Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:40:48 -0400 Subject: [PATCH 5/9] test: cover subscription flow and fix pre-existing spec breakage Rewrite the createSubscription service tests for the new signature (customer reuse/creation, product resolution, interval mapping, confirmation_secret parsing) and add controller tests for the subscription endpoint and the invoice.paid / invoice.payment_failed renewal branches. Add donations-service tests for Stripe-id persistence and recordRenewalCharge (idempotency, template cloning). Also fix breakage that predated this work: provide EmailsService in the donations-service spec, backfill the new nullable columns in Donation test fixtures, and wrap DonationForm renders in MemoryRouter (the form now uses useSearchParams). --- .../donations/donations.repository.spec.ts | 2 + .../src/donations/donations.service.spec.ts | 134 +++++++++++++ .../src/payments/payments.controller.spec.ts | 125 +++++++++++++ .../src/payments/payments.service.spec.ts | 177 ++++++++++++++---- .../donations/DonationForm.spec.tsx | 25 ++- 5 files changed, 427 insertions(+), 36 deletions(-) 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/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.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/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' }, From bab41ef59d1836536fb99c48e4ed8408ebc6128b Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:29:24 -0400 Subject: [PATCH 6/9] add stripe donation product id to example env --- example.env | 1 + 1 file changed, 1 insertion(+) 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) From 3c8fa26e1d757eee2d4900ba1bfcacee0f16ffb3 Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:25:59 -0400 Subject: [PATCH 7/9] fix(frontend): actually charge donor-covered processing fees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "cover fees" toggle was cosmetic: it updated only Step 2's local state, so the fee was shown but never charged, and formData.coverFees was dead. Make formData the single source of truth — DonationSummary is now controlled (coverFees + onCoverFeesChange), and a shared calculateChargeAmount helper (gross-up: (base + fixed) / (1 - rate), rounded to cents) drives the Step 2 total, the Stripe charge for both one-time and recurring donations, and the recorded donation amount. Rewrite the stale DonationSummary spec to cover the helpers and the controlled toggle. --- .../src/containers/donations/DonationForm.tsx | 11 +- .../donations/DonationSummary.spec.tsx | 142 +++++++++--------- .../containers/donations/DonationSummary.tsx | 58 +++++-- .../donations/steps/Step2Details.tsx | 13 +- .../donations/steps/Step3Confirm.tsx | 6 +- 5 files changed, 135 insertions(+), 95 deletions(-) diff --git a/apps/frontend/src/containers/donations/DonationForm.tsx b/apps/frontend/src/containers/donations/DonationForm.tsx index 14fa234..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,6 +220,10 @@ export const DonationForm: React.FC = ({ setCurrentStep(1); }; + const handleCoverFeesChange = (value: boolean) => { + setFormData((prev) => ({ ...prev, coverFees: value })); + }; + const handleBeforePayment = async ( paymentIntentId: string, subscriptionInfo?: { @@ -230,7 +235,10 @@ export const DonationForm: React.FC = ({ 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, @@ -278,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 44e7ec4..62e1bf3 100644 --- a/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx +++ b/apps/frontend/src/containers/donations/steps/Step3Confirm.tsx @@ -2,6 +2,7 @@ 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 { @@ -36,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) { From 745d19e4384f0fc6b0589455bf180e0c49576b2d Mon Sep 17 00:00:00 2001 From: Thanin Kongkiatsophon <108406347+thaninbew@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:54:59 -0400 Subject: [PATCH 8/9] feat: standardize donation amount handling to subunits and add mock data seed file --- .../src/donations/donations.service.ts | 8 +-- apps/backend/src/donations/mappers.ts | 6 +-- seed-mock.sql | 51 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 seed-mock.sql diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 86c5346..8e789f0 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -256,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, }; } @@ -520,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/mappers.ts b/apps/backend/src/donations/mappers.ts index 672984f..ab2c991 100644 --- a/apps/backend/src/donations/mappers.ts +++ b/apps/backend/src/donations/mappers.ts @@ -58,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 @@ -82,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, @@ -99,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/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 From ba50319bcf8aa18029b9797f4727d95253d53c6f Mon Sep 17 00:00:00 2001 From: Aaron Ashby <101434393+aaronashby@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:55:58 -0400 Subject: [PATCH 9/9] fix(frontend): fix next button not appearing --- apps/frontend/src/app.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index fec0835..73cd89c 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -109,10 +109,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)} + /> +
), }, ]);