From d5bfba2243675bb91d9993671ce3537173de6ad4 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 30 Nov 2025 19:52:12 -0500 Subject: [PATCH 01/13] Initial setup --- .../src/payments/payments.controller.spec.ts | 34 +++++++++++++++++++ .../src/payments/payments.controller.ts | 9 +++++ 2 files changed, 43 insertions(+) create mode 100644 apps/backend/src/payments/payments.controller.spec.ts create mode 100644 apps/backend/src/payments/payments.controller.ts diff --git a/apps/backend/src/payments/payments.controller.spec.ts b/apps/backend/src/payments/payments.controller.spec.ts new file mode 100644 index 0000000..4fd4f56 --- /dev/null +++ b/apps/backend/src/payments/payments.controller.spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { INestApplication, BadRequestException } from '@nestjs/common'; +import request from 'supertest'; +describe('PaymentsControler', () => { + let controller: PaymentsController; + let service: PaymentsService; + + const mockService = { + createPaymentIntent: jest.fn(), + createSubscription: jest.fn(), + retrievePaymentIntent: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PaymentsController], + providers: [ + { + provide: PaymentsService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(PaymentsController); + service = module.get(PaymentsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/apps/backend/src/payments/payments.controller.ts b/apps/backend/src/payments/payments.controller.ts new file mode 100644 index 0000000..ae06bfd --- /dev/null +++ b/apps/backend/src/payments/payments.controller.ts @@ -0,0 +1,9 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PaymentsService } from './payments.service'; + +@ApiTags('Payments') +@Controller('payments') +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} +} From c5fc379fa6163c80ff814e49f2d580e235b6ecbb Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:43:48 -0500 Subject: [PATCH 02/13] Added CreatePaymentIntentDto for payment intents Define CreatePaymentIntentDto with validation and API properties for payment processing. --- .../dtos/create-payment-intent-dto.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/backend/src/donations/dtos/create-payment-intent-dto.ts diff --git a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts b/apps/backend/src/donations/dtos/create-payment-intent-dto.ts new file mode 100644 index 0000000..3e356d5 --- /dev/null +++ b/apps/backend/src/donations/dtos/create-payment-intent-dto.ts @@ -0,0 +1,123 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsEnum, + IsOptional, + Min, + IsNotEmpty, + IsArray, + IsBoolean, +} from 'class-validator'; +import { DonationStatus } from '../donation.entity'; + +export class CreatePaymentIntentDto { + @ApiProperty({ + description: 'The unique identifier for the PaymentIntent, equivalent to what Stripe API returns', + example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + }) + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty({ + description: 'The client secret used for client-side confirmation, equivalent to what Stripe API returns', + example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', + }) + @IsString() + @IsNotEmpty() + clientSecret: string; + + @ApiProperty({ + description: 'The payment amount in smallest currency unit (e.g., cents), equivalent to what Stripe API returns', + example: 1099, + }) + @IsNumber() + @Min(1) + amount: number; + + @ApiProperty({ + description: 'The three-letter ISO currency code, equivalent to what Stripe API returns', + example: 'usd', + }) + @IsString() + @IsNotEmpty() + currency: string; + + @ApiProperty({ + description: 'An enum value from DonationStatus mapped from Stripe\'s status to these four statuses', + example: DonationStatus.PENDING, + }) + @IsEnum(DonationStatus) + status: DonationStatus; + + @ApiProperty({ + description: 'Optional key-value pairs attached to the payment, equivalent to what Stripe API returns', + example: { orderId: '123' }, + }) + @IsOptional() + metadata?: Record; + + @ApiProperty({ + description: 'The ID of the payment method used, mapped from paymentIntent.payment_method cast as a string', + example: 'pm_1F4aBcD3eF4GhIjKlmnoPq', + }) + @IsString() + @IsOptional() + paymentMethodId?: string; + + @ApiProperty({ + description: 'Array of payment method types enabled for this PaymentIntent, equivalent to what Stripe API returns', + example: ['card'], + }) + @IsArray() + paymentMethodTypes: string[]; + + @ApiProperty({ + description: 'Unix timestamp representing when the PaymentIntent was created, equivalent to what Stripe API returns', + example: 1762000000, + }) + @IsNumber() + @Min(0) + created: number; + + @ApiProperty({ + description: "Boolean indicating if the payment requires customer action, determined by checking if paymentIntent.status === 'requires_action'", + example: false, + }) + @IsBoolean() + requiresAction: boolean; + + @ApiProperty({ + description: 'Details about the required next action (if any), equivalent to what Stripe API returns', + example: { + type: 'redirect_to_url', + redirect_to_url: { + url: 'https://hooks.stripe.com/redirect/authenticate/src_1Aa2Bb3Cc4', + return_url: 'https://example.com/checkout/complete', + }, + }, + }) + @IsOptional() + nextAction?: any; + + @ApiProperty({ + description: "Object containing error details if the payment failed. Contains properties 'code', 'message', and 'type' mapped from Stripe's paymentIntent.last_payment_error", + example: { code: 'card_declined', message: 'Your card was declined.', type: 'card_error' }, + }) + @IsOptional() + lastPaymentError?: { + code: string; + message: string; + type: string; + }; + + @ApiProperty({ + description: 'Unix timestamp representing when the PaymentIntent was canceled (if applicable), equivalent to what Stripe API returns', + example: 1762000000, + }) + @IsOptional() + @IsNumber() + @Min(0) + canceledAt?: number; +} From 007fe327fb0eda57a93e4d4e1fa9da336b0d224c Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:52:30 -0500 Subject: [PATCH 03/13] Rename to payment-intent-response --- ...ate-payment-intent-dto.ts => payment-intent-response-dto.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/backend/src/donations/dtos/{create-payment-intent-dto.ts => payment-intent-response-dto.ts} (98%) diff --git a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts b/apps/backend/src/donations/dtos/payment-intent-response-dto.ts similarity index 98% rename from apps/backend/src/donations/dtos/create-payment-intent-dto.ts rename to apps/backend/src/donations/dtos/payment-intent-response-dto.ts index 3e356d5..bffbd94 100644 --- a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts +++ b/apps/backend/src/donations/dtos/payment-intent-response-dto.ts @@ -11,7 +11,7 @@ import { } from 'class-validator'; import { DonationStatus } from '../donation.entity'; -export class CreatePaymentIntentDto { +export class PaymentIntentResponseDto { @ApiProperty({ description: 'The unique identifier for the PaymentIntent, equivalent to what Stripe API returns', example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', From 65718c2590948fc6209b5a2aae638f50ead6030b Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:42:48 -0500 Subject: [PATCH 04/13] Creating Mapper and new types based on Donation Controller --- .../dtos/create-payment-intent-dto.ts | 34 ++++++ apps/backend/src/payments/mappers.spec.ts | 0 apps/backend/src/payments/mappers.ts | 63 +++++++++++ .../src/payments/payments.controller.ts | 42 +++++++- .../src/payments/payments.service.spec.ts | 101 +++++++++++++----- apps/backend/src/payments/payments.service.ts | 60 ++++++++--- 6 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 apps/backend/src/donations/dtos/create-payment-intent-dto.ts create mode 100644 apps/backend/src/payments/mappers.spec.ts create mode 100644 apps/backend/src/payments/mappers.ts diff --git a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts b/apps/backend/src/donations/dtos/create-payment-intent-dto.ts new file mode 100644 index 0000000..e000877 --- /dev/null +++ b/apps/backend/src/donations/dtos/create-payment-intent-dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsOptional, + Min, + IsNotEmpty, +} from 'class-validator'; +import { PaymentIntentMetadata } from '../../payments/payments.service'; + +export class CreatePaymentIntentDto { + @ApiProperty({ + description: 'The payment amount in smallest currency unit (e.g., cents)', + example: 1099, + }) + @IsNumber() + @Min(1) + amount: number; + + @ApiProperty({ + description: 'The three-letter ISO currency code', + example: 'usd', + }) + @IsString() + @IsNotEmpty() + currency: string; + + @ApiProperty({ + description: 'Optional key-value pairs attached to the payment', + example: { orderId: '123' }, + }) + @IsOptional() + metadata?: PaymentIntentMetadata; +} diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/src/payments/mappers.ts b/apps/backend/src/payments/mappers.ts new file mode 100644 index 0000000..c13729e --- /dev/null +++ b/apps/backend/src/payments/mappers.ts @@ -0,0 +1,63 @@ +import Stripe from 'stripe'; +import { PaymentIntentResponseDto } from '../donations/dtos/payment-intent-response-dto'; +import { PaymentIntentResponse } from './payments.service'; +import { CreatePaymentIntentDto } from '../donations/dtos/create-payment-intent-dto'; + +export interface CreatePaymentIntentRequest { + amount: number; + currency: string; + metadata?: Stripe.MetadataParam; +} + +export class PaymentMappers { + static toCreatePaymentIntentRequest( + dto: CreatePaymentIntentDto, + ): CreatePaymentIntentRequest { + return { + amount: dto.amount, + currency: dto.currency, + metadata: PaymentMappers.normalizeMetadata(dto.metadata), + }; + } + + private static normalizeMetadata( + metadata?: Record, + ): Stripe.MetadataParam { + if (!metadata) return {}; + const result: Stripe.MetadataParam = {}; + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined || value === null) continue; + if (typeof value === 'string') result[key] = value; + else if (typeof value === 'number' || typeof value === 'boolean') + result[key] = String(value); + else { + try { + result[key] = JSON.stringify(value); + } catch { + result[key] = String(value as unknown as string); + } + } + } + return result; + } + + static toPaymentIntentResponseDto( + paymentIntentResponse: PaymentIntentResponse, + ): PaymentIntentResponseDto { + return { + id: paymentIntentResponse.id, + clientSecret: paymentIntentResponse.clientSecret, + amount: paymentIntentResponse.amount, + currency: paymentIntentResponse.currency, + status: paymentIntentResponse.status, + metadata: paymentIntentResponse.metadata, + paymentMethodId: paymentIntentResponse.paymentMethodId, + paymentMethodTypes: paymentIntentResponse.paymentMethodTypes, + created: paymentIntentResponse.created, + requiresAction: paymentIntentResponse.requiresAction, + nextAction: paymentIntentResponse.nextAction, + lastPaymentError: paymentIntentResponse.lastPaymentError, + canceledAt: paymentIntentResponse.canceledAt, + }; + } +} diff --git a/apps/backend/src/payments/payments.controller.ts b/apps/backend/src/payments/payments.controller.ts index ae06bfd..096039b 100644 --- a/apps/backend/src/payments/payments.controller.ts +++ b/apps/backend/src/payments/payments.controller.ts @@ -1,9 +1,47 @@ -import { Controller } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; +import { PaymentIntentResponseDto } from '../donations/dtos/payment-intent-response-dto'; +import { CreatePaymentIntentDto } from '../donations/dtos/create-payment-intent-dto'; +import { PaymentMappers } from './mappers'; @ApiTags('Payments') @Controller('payments') export class PaymentsController { constructor(private readonly paymentsService: PaymentsService) {} + + @Post('/intent') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'create a payment intent in Stripe', + description: + 'submit a new payment intent with amount, currency, and optional metadata', + }) + @ApiResponse({ + status: 201, + description: 'payment intent successfully created in Stripe', + type: PaymentIntentResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'validation error', + }) + async createIntent( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + createPaymentIntentDto: CreatePaymentIntentDto, + ): Promise { + const request = PaymentMappers.toCreatePaymentIntentRequest( + createPaymentIntentDto, + ); + const paymentIntentResponse = + await this.paymentsService.createPaymentIntent(request); + return PaymentMappers.toPaymentIntentResponseDto(paymentIntentResponse); + } } diff --git a/apps/backend/src/payments/payments.service.spec.ts b/apps/backend/src/payments/payments.service.spec.ts index 7f01b50..a879a00 100644 --- a/apps/backend/src/payments/payments.service.spec.ts +++ b/apps/backend/src/payments/payments.service.spec.ts @@ -1,6 +1,7 @@ import { PaymentsService } from './payments.service'; import { DonationStatus } from '../donations/donation.entity'; import Stripe from 'stripe'; +import { CreatePaymentIntentRequest } from './mappers'; const stripeMock = { paymentIntents: { @@ -227,15 +228,21 @@ describe('PaymentsService', () => { describe('createPaymentIntent', () => { it('throws for invalid (negative) amount', async () => { - await expect(svc.createPaymentIntent(-1, 'usd')).rejects.toThrow( - 'Invalid amount: must be a number >= 0', - ); + await expect( + svc.createPaymentIntent({ + amount: -1, + currency: 'usd', + } as CreatePaymentIntentRequest), + ).rejects.toThrow('Invalid amount: must be a number >= 0'); }); it('throws for invalid amount (negative value) where currency is not usd', async () => { - await expect(svc.createPaymentIntent(-1, 'eur')).rejects.toThrow( - 'Invalid amount: must be a number >= 0', - ); + await expect( + svc.createPaymentIntent({ + amount: -1, + currency: 'eur', + } as CreatePaymentIntentRequest), + ).rejects.toThrow('Invalid amount: must be a number >= 0'); }); it('returns a well-formed payment intent for valid input (amount=0) where currency is not usd', async () => { @@ -281,7 +288,11 @@ describe('PaymentsService', () => { transfer_group: null, }); - const pi = await svc.createPaymentIntent(0, 'eur', { orderId: '123' }); + const pi = await svc.createPaymentIntent({ + amount: 0, + currency: 'eur', + metadata: { orderId: '123' }, + } as CreatePaymentIntentRequest); expect(pi).toHaveProperty('id'); expect(pi).toHaveProperty('clientSecret'); @@ -292,19 +303,33 @@ describe('PaymentsService', () => { }); it('throws for invalid (currency that has decimals) amount', async () => { - await expect(svc.createPaymentIntent(50.01, 'usd')).rejects.toThrow( + await expect( + svc.createPaymentIntent({ + amount: 50.01, + currency: 'usd', + } as CreatePaymentIntentRequest), + ).rejects.toThrow( 'Invalid amount: amount is already in lowest currency unit, so there should be no decimals', ); }); it('throws for invalid usd of amount < 50 cents', async () => { - await expect(svc.createPaymentIntent(24, 'usd')).rejects.toThrow( + await expect( + svc.createPaymentIntent({ + amount: 24, + currency: 'usd', + } as CreatePaymentIntentRequest), + ).rejects.toThrow( 'Invalid amount, US currency donations must be at least 50 cents', ); }); it('returns a well-formed payment intent for valid input (amount=50) where currency is usd', async () => { - const pi = await svc.createPaymentIntent(50, 'usd', {}); + const pi = await svc.createPaymentIntent({ + amount: 50, + currency: 'usd', + metadata: {}, + } as CreatePaymentIntentRequest); expect(pi).toHaveProperty('id'); expect(pi).toHaveProperty('clientSecret'); expect(pi.amount).toBe(50); @@ -314,38 +339,57 @@ describe('PaymentsService', () => { }); it('throws for null currency', async () => { - await expect(svc.createPaymentIntent(10, null)).rejects.toThrow( - /Invalid currency/i, - ); + await expect( + svc.createPaymentIntent({ + amount: 10, + currency: null, + } as CreatePaymentIntentRequest), + ).rejects.toThrow(/Invalid currency/i); }); it('throws for undefined currency', async () => { - await expect(svc.createPaymentIntent(10, null)).rejects.toThrow( - /Invalid currency/i, - ); + await expect( + svc.createPaymentIntent({ + amount: 10, + currency: null, + } as CreatePaymentIntentRequest), + ).rejects.toThrow(/Invalid currency/i); }); it('throws for non-string currency', async () => { await expect( - svc.createPaymentIntent(10, 0 as unknown as string), + svc.createPaymentIntent({ + amount: 10, + currency: 0 as unknown as string, + } as CreatePaymentIntentRequest), ).rejects.toThrow(/Invalid currency/i); }); it('throws for currency length < 3', async () => { - await expect(svc.createPaymentIntent(10, 'a')).rejects.toThrow( - /Invalid currency/i, - ); + await expect( + svc.createPaymentIntent({ + amount: 10, + currency: 'a', + } as CreatePaymentIntentRequest), + ).rejects.toThrow(/Invalid currency/i); }); it('throws for currency length > 3', async () => { - await expect(svc.createPaymentIntent(10, 'aaaa')).rejects.toThrow( - /Invalid currency/i, - ); + await expect( + svc.createPaymentIntent({ + amount: 10, + currency: 'aaaa', + } as CreatePaymentIntentRequest), + ).rejects.toThrow(/Invalid currency/i); }); it('returns a well-formed payment intent for valid input', async () => { // Update the expected return shape to match the new PaymentIntentResponse format - const pi = await svc.createPaymentIntent(50, 'usd', { orderId: '123' }); + const pi = await svc.createPaymentIntent({ + amount: 50, + currency: 'usd', + metadata: { orderId: '123' }, + } as CreatePaymentIntentRequest); // Check the returned object has all expected properties from PaymentIntentResponse expect(pi).toHaveProperty('id'); @@ -374,9 +418,12 @@ describe('PaymentsService', () => { stripeMock.paymentIntents.create.mockRejectedValueOnce(cardDeclinedError); - await expect(svc.createPaymentIntent(2550, 'usd')).rejects.toMatchObject( - cardDeclinedError, - ); + await expect( + svc.createPaymentIntent({ + amount: 2550, + currency: 'usd', + } as CreatePaymentIntentRequest), + ).rejects.toMatchObject(cardDeclinedError); }); }); diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts index 0577f77..38d3a2d 100644 --- a/apps/backend/src/payments/payments.service.ts +++ b/apps/backend/src/payments/payments.service.ts @@ -4,6 +4,7 @@ import { DonationStatus, RecurringInterval, } from '../donations/donation.entity'; +import { CreatePaymentIntentRequest } from './mappers'; /** * Flexible definition for metadata, may want to change to be stricter later @@ -30,7 +31,7 @@ export type PaymentIntentMetadata = Record; * type - The error type, mapped from paymentIntent.last_payment_error.type * canceledAt - Unix timestamp representing when the PaymentIntent was canceled (if applicable), equivalent to what Stripe API returns */ -interface PaymentIntentResponse { +export type PaymentIntentResponse = { id: string; clientSecret: string; amount: number; @@ -48,7 +49,7 @@ interface PaymentIntentResponse { type: string; }; canceledAt?: number; -} +}; @Injectable() export class PaymentsService { @@ -67,7 +68,7 @@ export class PaymentsService { private validateCreatePaymentIntentParams( amount: number, currency: string, - metadata?: PaymentIntentMetadata, + metadata?: Stripe.MetadataParam, ): string { if (typeof amount === 'undefined') { this.logger.warn( @@ -110,7 +111,10 @@ export class PaymentsService { return 'Invalid currency format; expected 3-letter ISO code like "usd"'; } - if (metadata !== undefined && typeof metadata !== 'object') { + if ( + metadata !== undefined && + !PaymentsService.isValidStripeMetadata(metadata) + ) { this.logger.warn('createPaymentIntent called with invalid metadata'); return 'Invalid metadata'; } @@ -118,6 +122,32 @@ export class PaymentsService { return ''; } + /** + * Runtime check to ensure metadata is a valid Stripe.MetadataParam-like object + * (an object whose keys are strings and whose values are strings). + * Also enforces common Stripe limits: max 50 keys, key length <= 40, value length <= 500. + */ + private static isValidStripeMetadata(metadata?: unknown): boolean { + if (metadata === undefined || metadata === null) return true; + if (typeof metadata !== 'object') return false; + if (Array.isArray(metadata)) return false; + + const obj = metadata as Record; + const keys = Object.keys(obj); + if (keys.length > 50) return false; + + for (const key of keys) { + if (typeof key !== 'string') return false; + if (key.length === 0 || key.length > 40) return false; + const val = obj[key]; + if (val === undefined || val === null) return false; + if (typeof val !== 'string') return false; + if ((val as string).length > 500) return false; + } + + return true; + } + /** * Create a payment intent. * @@ -127,17 +157,15 @@ export class PaymentsService { * @returns Promise resolving to a PaymentIntent-like object */ async createPaymentIntent( - amount: number, - currency: string, - metadata?: PaymentIntentMetadata, + request: CreatePaymentIntentRequest, ): Promise { - if (currency) { - currency = currency.toLowerCase(); + if (request.currency) { + request.currency = request.currency.toLowerCase(); } const errorMsg = this.validateCreatePaymentIntentParams( - amount, - currency, - metadata, + request.amount, + request.currency, + request.metadata, ); if (errorMsg !== '') { throw new Error(errorMsg); @@ -146,14 +174,14 @@ export class PaymentsService { try { const paymentIntent: Stripe.PaymentIntent = await this.stripe.paymentIntents.create({ - amount, - currency, - metadata, + amount: request.amount, + currency: request.currency, + metadata: request.metadata, payment_method_types: ['card', 'us_bank_accounts'], }); this.logger.debug( - `createPaymentIntent (${amount}, ${currency}, ${metadata}) -> ${paymentIntent.id}`, + `createPaymentIntent (${request.amount}, ${request.currency}, ${request.metadata}) -> ${paymentIntent.id}`, ); return this.mapPaymentIntentToResponse(paymentIntent); From e01cbac8b1531ca07b49f72549700bf4b4df0288 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:56:14 -0500 Subject: [PATCH 05/13] Setup for mappers.spec.ts --- .../dtos/payment-intent-response-dto.ts | 45 +++++++++++++------ apps/backend/src/payments/mappers.spec.ts | 22 +++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/donations/dtos/payment-intent-response-dto.ts b/apps/backend/src/donations/dtos/payment-intent-response-dto.ts index bffbd94..e79b59a 100644 --- a/apps/backend/src/donations/dtos/payment-intent-response-dto.ts +++ b/apps/backend/src/donations/dtos/payment-intent-response-dto.ts @@ -13,7 +13,8 @@ import { DonationStatus } from '../donation.entity'; export class PaymentIntentResponseDto { @ApiProperty({ - description: 'The unique identifier for the PaymentIntent, equivalent to what Stripe API returns', + description: + 'The unique identifier for the PaymentIntent, equivalent to what Stripe API returns', example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', }) @IsString() @@ -21,7 +22,8 @@ export class PaymentIntentResponseDto { id: string; @ApiProperty({ - description: 'The client secret used for client-side confirmation, equivalent to what Stripe API returns', + description: + 'The client secret used for client-side confirmation, equivalent to what Stripe API returns', example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', }) @IsString() @@ -29,7 +31,8 @@ export class PaymentIntentResponseDto { clientSecret: string; @ApiProperty({ - description: 'The payment amount in smallest currency unit (e.g., cents), equivalent to what Stripe API returns', + description: + 'The payment amount in smallest currency unit (e.g., cents), equivalent to what Stripe API returns', example: 1099, }) @IsNumber() @@ -37,7 +40,8 @@ export class PaymentIntentResponseDto { amount: number; @ApiProperty({ - description: 'The three-letter ISO currency code, equivalent to what Stripe API returns', + description: + 'The three-letter ISO currency code, equivalent to what Stripe API returns', example: 'usd', }) @IsString() @@ -45,21 +49,24 @@ export class PaymentIntentResponseDto { currency: string; @ApiProperty({ - description: 'An enum value from DonationStatus mapped from Stripe\'s status to these four statuses', + description: + "An enum value from DonationStatus mapped from Stripe's status to these four statuses", example: DonationStatus.PENDING, }) @IsEnum(DonationStatus) status: DonationStatus; @ApiProperty({ - description: 'Optional key-value pairs attached to the payment, equivalent to what Stripe API returns', + description: + 'Optional key-value pairs attached to the payment, equivalent to what Stripe API returns', example: { orderId: '123' }, }) @IsOptional() metadata?: Record; @ApiProperty({ - description: 'The ID of the payment method used, mapped from paymentIntent.payment_method cast as a string', + description: + 'The ID of the payment method used, mapped from paymentIntent.payment_method cast as a string', example: 'pm_1F4aBcD3eF4GhIjKlmnoPq', }) @IsString() @@ -67,14 +74,16 @@ export class PaymentIntentResponseDto { paymentMethodId?: string; @ApiProperty({ - description: 'Array of payment method types enabled for this PaymentIntent, equivalent to what Stripe API returns', + description: + 'Array of payment method types enabled for this PaymentIntent, equivalent to what Stripe API returns', example: ['card'], }) @IsArray() paymentMethodTypes: string[]; @ApiProperty({ - description: 'Unix timestamp representing when the PaymentIntent was created, equivalent to what Stripe API returns', + description: + 'Unix timestamp representing when the PaymentIntent was created, equivalent to what Stripe API returns', example: 1762000000, }) @IsNumber() @@ -82,14 +91,16 @@ export class PaymentIntentResponseDto { created: number; @ApiProperty({ - description: "Boolean indicating if the payment requires customer action, determined by checking if paymentIntent.status === 'requires_action'", + description: + "Boolean indicating if the payment requires customer action, determined by checking if paymentIntent.status === 'requires_action'", example: false, }) @IsBoolean() requiresAction: boolean; @ApiProperty({ - description: 'Details about the required next action (if any), equivalent to what Stripe API returns', + description: + 'Details about the required next action (if any), equivalent to what Stripe API returns', example: { type: 'redirect_to_url', redirect_to_url: { @@ -102,8 +113,13 @@ export class PaymentIntentResponseDto { nextAction?: any; @ApiProperty({ - description: "Object containing error details if the payment failed. Contains properties 'code', 'message', and 'type' mapped from Stripe's paymentIntent.last_payment_error", - example: { code: 'card_declined', message: 'Your card was declined.', type: 'card_error' }, + description: + "Object containing error details if the payment failed. Contains properties 'code', 'message', and 'type' mapped from Stripe's paymentIntent.last_payment_error", + example: { + code: 'card_declined', + message: 'Your card was declined.', + type: 'card_error', + }, }) @IsOptional() lastPaymentError?: { @@ -113,7 +129,8 @@ export class PaymentIntentResponseDto { }; @ApiProperty({ - description: 'Unix timestamp representing when the PaymentIntent was canceled (if applicable), equivalent to what Stripe API returns', + description: + 'Unix timestamp representing when the PaymentIntent was canceled (if applicable), equivalent to what Stripe API returns', example: 1762000000, }) @IsOptional() diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts index e69de29..42ca16e 100644 --- a/apps/backend/src/payments/mappers.spec.ts +++ b/apps/backend/src/payments/mappers.spec.ts @@ -0,0 +1,22 @@ +import { validate } from 'class-validator'; +import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto.ts'; +import { PaymentMappers } from './mappers'; + +describe('PaymentMappers', () => { + const mockCreateDto = null; + const mockPaymentIntentResponse = null; + describe('toCreatePaymentIntentRequest', () => { + it('should map CreatePaymentIntentDto to CreatePaymentIntentRequest correctly', () => { + const result = PaymentMappers.toCreatePaymentIntentRequest(mockCreateDto); + expect(result).toEqual(null); + }); + }); + + describe('toPaymentIntentResponseDto', () => { + it('should map PaymentIntentResponse to PaymentIntentResponseDto correctly', () => { + const result = PaymentMappers.toPaymentIntentResponseDto( + mockPaymentIntentResponse, + ); + }); + }); +}); From b147d7642f94d21bcd3be9003de9c396b08664ee Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:33:41 -0500 Subject: [PATCH 06/13] Moving Payment dtos to the payment/dtos folder --- .../{donations => payments}/dtos/create-payment-intent-dto.ts | 2 +- .../{donations => payments}/dtos/payment-intent-response-dto.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename apps/backend/src/{donations => payments}/dtos/create-payment-intent-dto.ts (90%) rename apps/backend/src/{donations => payments}/dtos/payment-intent-response-dto.ts (98%) diff --git a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts b/apps/backend/src/payments/dtos/create-payment-intent-dto.ts similarity index 90% rename from apps/backend/src/donations/dtos/create-payment-intent-dto.ts rename to apps/backend/src/payments/dtos/create-payment-intent-dto.ts index e000877..56eeda4 100644 --- a/apps/backend/src/donations/dtos/create-payment-intent-dto.ts +++ b/apps/backend/src/payments/dtos/create-payment-intent-dto.ts @@ -6,7 +6,7 @@ import { Min, IsNotEmpty, } from 'class-validator'; -import { PaymentIntentMetadata } from '../../payments/payments.service'; +import { PaymentIntentMetadata } from '../payments.service'; export class CreatePaymentIntentDto { @ApiProperty({ diff --git a/apps/backend/src/donations/dtos/payment-intent-response-dto.ts b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts similarity index 98% rename from apps/backend/src/donations/dtos/payment-intent-response-dto.ts rename to apps/backend/src/payments/dtos/payment-intent-response-dto.ts index e79b59a..4066a2a 100644 --- a/apps/backend/src/donations/dtos/payment-intent-response-dto.ts +++ b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts @@ -9,7 +9,7 @@ import { IsArray, IsBoolean, } from 'class-validator'; -import { DonationStatus } from '../donation.entity'; +import { DonationStatus } from '../../donations/donation.entity'; export class PaymentIntentResponseDto { @ApiProperty({ From 3864e5e518406e8089ebe2255d73c185a8f1dfe0 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:37:58 -0500 Subject: [PATCH 07/13] Changing imports --- apps/backend/src/payments/mappers.ts | 4 ++-- apps/backend/src/payments/payments.controller.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/payments/mappers.ts b/apps/backend/src/payments/mappers.ts index c13729e..13f13d4 100644 --- a/apps/backend/src/payments/mappers.ts +++ b/apps/backend/src/payments/mappers.ts @@ -1,7 +1,7 @@ import Stripe from 'stripe'; -import { PaymentIntentResponseDto } from '../donations/dtos/payment-intent-response-dto'; +import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; import { PaymentIntentResponse } from './payments.service'; -import { CreatePaymentIntentDto } from '../donations/dtos/create-payment-intent-dto'; +import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; export interface CreatePaymentIntentRequest { amount: number; diff --git a/apps/backend/src/payments/payments.controller.ts b/apps/backend/src/payments/payments.controller.ts index 096039b..b2bd437 100644 --- a/apps/backend/src/payments/payments.controller.ts +++ b/apps/backend/src/payments/payments.controller.ts @@ -8,8 +8,8 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; -import { PaymentIntentResponseDto } from '../donations/dtos/payment-intent-response-dto'; -import { CreatePaymentIntentDto } from '../donations/dtos/create-payment-intent-dto'; +import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; +import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; import { PaymentMappers } from './mappers'; @ApiTags('Payments') From 49163f2b47b7e1828eb1d927e8d4874cee1951ee Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:52:26 -0500 Subject: [PATCH 08/13] Completed tests for mappers --- apps/backend/src/payments/mappers.spec.ts | 73 +++++++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts index 42ca16e..593bc00 100644 --- a/apps/backend/src/payments/mappers.spec.ts +++ b/apps/backend/src/payments/mappers.spec.ts @@ -1,14 +1,49 @@ import { validate } from 'class-validator'; -import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto.ts'; -import { PaymentMappers } from './mappers'; - +import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; +import { CreatePaymentIntentRequest, PaymentMappers } from './mappers'; +import { DonationStatus } from '../donations/donation.entity'; +import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; +import { PaymentIntentResponse } from './payments.service'; describe('PaymentMappers', () => { - const mockCreateDto = null; - const mockPaymentIntentResponse = null; + const mockCreateDto: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, + }; + const mockPaymentIntentResponse: PaymentIntentResponse = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', + amount: 1099, + currency: 'usd', + status: DonationStatus.CANCELLED, + metadata: { orderId: '123' }, + paymentMethodId: 'pm_1F4aBcD3eF4GhIjKlmnoPq', + paymentMethodTypes: ['card'], + created: 1762000000, + requiresAction: false, + nextAction: { + type: 'redirect_to_url', + redirect_to_url: { + url: 'https://hooks.stripe.com/redirect/authenticate/src_1Aa2Bb3Cc4', + return_url: 'https://example.com/checkout/complete', + }, + }, + lastPaymentError: { + code: 'card_declined', + message: 'Your card was declined.', + type: 'card_error', + }, + canceledAt: 1762000001, + }; describe('toCreatePaymentIntentRequest', () => { it('should map CreatePaymentIntentDto to CreatePaymentIntentRequest correctly', () => { const result = PaymentMappers.toCreatePaymentIntentRequest(mockCreateDto); - expect(result).toEqual(null); + const expected: CreatePaymentIntentRequest = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, + }; + expect(result).toEqual(expected); }); }); @@ -17,6 +52,32 @@ describe('PaymentMappers', () => { const result = PaymentMappers.toPaymentIntentResponseDto( mockPaymentIntentResponse, ); + const expected: PaymentIntentResponseDto = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', + amount: 1099, + currency: 'usd', + status: DonationStatus.CANCELLED, + metadata: { orderId: '123' }, + paymentMethodId: 'pm_1F4aBcD3eF4GhIjKlmnoPq', + paymentMethodTypes: ['card'], + created: 1762000000, + requiresAction: false, + nextAction: { + type: 'redirect_to_url', + redirect_to_url: { + url: 'https://hooks.stripe.com/redirect/authenticate/src_1Aa2Bb3Cc4', + return_url: 'https://example.com/checkout/complete', + }, + }, + lastPaymentError: { + code: 'card_declined', + message: 'Your card was declined.', + type: 'card_error', + }, + canceledAt: 1762000001, + }; + expect(result).toEqual(expected); }); }); }); From 408f71baca4e965e8399d0bae4b5b20dc04bcd24 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:03:44 -0500 Subject: [PATCH 09/13] Changing to Stripe type, first successful test passes --- .../dtos/payment-intent-response-dto.ts | 3 ++- apps/backend/src/payments/mappers.spec.ts | 1 - apps/backend/src/payments/payments.service.ts | 2 +- apps/frontend/src/containers/root.tsx | 18 ++++++++---------- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/payments/dtos/payment-intent-response-dto.ts b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts index 4066a2a..4c24b8e 100644 --- a/apps/backend/src/payments/dtos/payment-intent-response-dto.ts +++ b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts @@ -10,6 +10,7 @@ import { IsBoolean, } from 'class-validator'; import { DonationStatus } from '../../donations/donation.entity'; +import Stripe from 'stripe'; export class PaymentIntentResponseDto { @ApiProperty({ @@ -110,7 +111,7 @@ export class PaymentIntentResponseDto { }, }) @IsOptional() - nextAction?: any; + nextAction?: Stripe.PaymentIntent.NextAction; @ApiProperty({ description: diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts index 593bc00..ce7008d 100644 --- a/apps/backend/src/payments/mappers.spec.ts +++ b/apps/backend/src/payments/mappers.spec.ts @@ -1,4 +1,3 @@ -import { validate } from 'class-validator'; import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; import { CreatePaymentIntentRequest, PaymentMappers } from './mappers'; import { DonationStatus } from '../donations/donation.entity'; diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts index 38d3a2d..61bbdec 100644 --- a/apps/backend/src/payments/payments.service.ts +++ b/apps/backend/src/payments/payments.service.ts @@ -42,7 +42,7 @@ export type PaymentIntentResponse = { paymentMethodTypes: string[]; created: number; requiresAction: boolean; - nextAction?: unknown; + nextAction?: Stripe.PaymentIntent.NextAction; lastPaymentError?: { code: string; message: string; diff --git a/apps/frontend/src/containers/root.tsx b/apps/frontend/src/containers/root.tsx index 1f62113..7676896 100644 --- a/apps/frontend/src/containers/root.tsx +++ b/apps/frontend/src/containers/root.tsx @@ -3,16 +3,14 @@ import { DonationForm } from './donations/DonationForm'; const Root: React.FC = () => { return ( - <> - - + ); }; From e522dbe030b2985b9a0a62dc8596ad32f35ec1b2 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:30:20 -0500 Subject: [PATCH 10/13] Expanding payment.service.ts to include mapping of stripe statuses to DonationStatus enum --- .../src/payments/payments.service.spec.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/apps/backend/src/payments/payments.service.spec.ts b/apps/backend/src/payments/payments.service.spec.ts index a879a00..9b33673 100644 --- a/apps/backend/src/payments/payments.service.spec.ts +++ b/apps/backend/src/payments/payments.service.spec.ts @@ -122,6 +122,41 @@ const paymentIntentMock2 = { transfer_group: null, }; +const paymentIntentCanceledMock = { + ...paymentIntentMock2, + status: 'canceled', +}; + +const paymentIntentFailedMock = { + ...paymentIntentMock2, + status: 'requires_payment_method', +}; + +const paymentIntentProcessingMock = { + ...paymentIntentMock2, + status: 'processing', +}; + +const paymentIntentRequiresConfirmationMock = { + ...paymentIntentMock2, + status: 'requires_confirmation', +}; + +const paymentIntentRequiresActionMock = { + ...paymentIntentMock2, + status: 'requires_action', +}; + +const paymentIntentRequiresCaptureMock = { + ...paymentIntentMock2, + status: 'requires_capture', +}; + +const paymentIntentOtherStatusMock = { + ...paymentIntentMock2, + status: 'unknown', +}; + const subscriptionMock1 = { id: 'sub_1234567890abcdef', object: 'subscription', @@ -547,5 +582,86 @@ describe('PaymentsService', () => { svc.retrievePaymentIntent(paymentIntentId), ).rejects.toMatchObject(noSuchPaymentIntent); }); + + describe('stripe to DonationStatus values map correctly', () => { + it("maps stripe status 'succeeded' to DonationStatus.SUCCEEDED", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentMock2, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.SUCCEEDED); // Specific check for status mapping + }); + + it("maps stripe status 'canceled' to DonationStatus.CANCELLED", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentCanceledMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.CANCELLED); // Specific check for status mapping + }); + it("maps stripe status 'requires_payment_method' to DonationStatus.FAILED", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentFailedMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.FAILED); // Specific check for status mapping + }); + + it("maps stripe status 'processing' to DonationStatus.PENDING", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentProcessingMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.PENDING); // Specific check for status mapping + }); + + it("maps stripe status 'requires_confirmation' to DonationStatus.PENDING", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentRequiresConfirmationMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.PENDING); // Specific check for status mapping + }); + + it("maps stripe status 'requires_action' to DonationStatus.PENDING", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentRequiresActionMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.PENDING); // Specific check for status mapping + }); + + it("maps stripe status 'requires_capture' to DonationStatus.PENDING", async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentRequiresCaptureMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.PENDING); // Specific check for status mapping + }); + + it('maps any other stripe status to DonationStatus.PENDING', async () => { + stripeMock.paymentIntents.retrieve.mockResolvedValue( + paymentIntentOtherStatusMock, + ); + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.PENDING); // Specific check for status mapping + }); + }); }); }); From 7e517598d4f01f82acc9230fadb33225dc02ec8d Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:02:40 -0500 Subject: [PATCH 11/13] All tests pass --- .../dtos/create-payment-intent-dto.ts | 2 +- apps/backend/src/payments/mappers.spec.ts | 65 +++++++++++++++++-- apps/backend/src/payments/mappers.ts | 5 +- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/payments/dtos/create-payment-intent-dto.ts b/apps/backend/src/payments/dtos/create-payment-intent-dto.ts index 56eeda4..e000877 100644 --- a/apps/backend/src/payments/dtos/create-payment-intent-dto.ts +++ b/apps/backend/src/payments/dtos/create-payment-intent-dto.ts @@ -6,7 +6,7 @@ import { Min, IsNotEmpty, } from 'class-validator'; -import { PaymentIntentMetadata } from '../payments.service'; +import { PaymentIntentMetadata } from '../../payments/payments.service'; export class CreatePaymentIntentDto { @ApiProperty({ diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts index ce7008d..0b39430 100644 --- a/apps/backend/src/payments/mappers.spec.ts +++ b/apps/backend/src/payments/mappers.spec.ts @@ -4,12 +4,18 @@ import { DonationStatus } from '../donations/donation.entity'; import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; import { PaymentIntentResponse } from './payments.service'; describe('PaymentMappers', () => { - const mockCreateDto: CreatePaymentIntentDto = { + const mockCreateDtoAllOptionalParams: CreatePaymentIntentDto = { amount: 1099, currency: 'usd', metadata: { orderId: '123' }, }; - const mockPaymentIntentResponse: PaymentIntentResponse = { + + const mockCreateDtoNoOptionalParams: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + }; + + const mockPaymentIntentResponseAllOptionalParams: PaymentIntentResponse = { id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', clientSecret: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', amount: 1099, @@ -34,9 +40,22 @@ describe('PaymentMappers', () => { }, canceledAt: 1762000001, }; + + const mockPaymentIntentResponseNoOptionalParams: PaymentIntentResponse = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', + amount: 1099, + currency: 'usd', + status: DonationStatus.SUCCEEDED, + paymentMethodTypes: ['card'], + created: 1762000000, + requiresAction: false, + }; describe('toCreatePaymentIntentRequest', () => { - it('should map CreatePaymentIntentDto to CreatePaymentIntentRequest correctly', () => { - const result = PaymentMappers.toCreatePaymentIntentRequest(mockCreateDto); + it('should map CreatePaymentIntentDto to CreatePaymentIntentRequest correctly with all optional params', () => { + const result = PaymentMappers.toCreatePaymentIntentRequest( + mockCreateDtoAllOptionalParams, + ); const expected: CreatePaymentIntentRequest = { amount: 1099, currency: 'usd', @@ -44,12 +63,24 @@ describe('PaymentMappers', () => { }; expect(result).toEqual(expected); }); + + it('should map CreatePaymentIntentDto to CreatePaymentIntentRequest correctly with no optional params', () => { + const result = PaymentMappers.toCreatePaymentIntentRequest( + mockCreateDtoNoOptionalParams, + ); + const expected: CreatePaymentIntentRequest = { + amount: 1099, + currency: 'usd', + metadata: undefined, + }; + expect(result).toEqual(expected); + }); }); describe('toPaymentIntentResponseDto', () => { - it('should map PaymentIntentResponse to PaymentIntentResponseDto correctly', () => { + it('should map PaymentIntentResponse to PaymentIntentResponseDto correctly with all optional params', () => { const result = PaymentMappers.toPaymentIntentResponseDto( - mockPaymentIntentResponse, + mockPaymentIntentResponseAllOptionalParams, ); const expected: PaymentIntentResponseDto = { id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', @@ -79,4 +110,26 @@ describe('PaymentMappers', () => { expect(result).toEqual(expected); }); }); + + it('should map PaymentIntentResponse to PaymentIntentResponseDto correctly with no optional params', () => { + const result = PaymentMappers.toPaymentIntentResponseDto( + mockPaymentIntentResponseNoOptionalParams, + ); + const expected: PaymentIntentResponseDto = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1J2aBcD3eF4GhIjKlmnoPqr_secret_AbCdEfGhIjKlMnOp', + amount: 1099, + currency: 'usd', + status: DonationStatus.SUCCEEDED, + metadata: undefined, + paymentMethodId: undefined, + paymentMethodTypes: ['card'], + created: 1762000000, + requiresAction: false, + nextAction: undefined, + lastPaymentError: undefined, + canceledAt: undefined, + }; + expect(result).toEqual(expected); + }); }); diff --git a/apps/backend/src/payments/mappers.ts b/apps/backend/src/payments/mappers.ts index 13f13d4..0778820 100644 --- a/apps/backend/src/payments/mappers.ts +++ b/apps/backend/src/payments/mappers.ts @@ -16,7 +16,10 @@ export class PaymentMappers { return { amount: dto.amount, currency: dto.currency, - metadata: PaymentMappers.normalizeMetadata(dto.metadata), + metadata: + dto.metadata == undefined + ? undefined + : PaymentMappers.normalizeMetadata(dto.metadata), }; } From f7225588ad817a802c90051e46baf53f051d8c02 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:15:05 -0500 Subject: [PATCH 12/13] Full tests --- .../src/payments/payments.controller.spec.ts | 110 +++++++++++++++++- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/payments/payments.controller.spec.ts b/apps/backend/src/payments/payments.controller.spec.ts index 4fd4f56..59fc75a 100644 --- a/apps/backend/src/payments/payments.controller.spec.ts +++ b/apps/backend/src/payments/payments.controller.spec.ts @@ -1,11 +1,54 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PaymentsController } from './payments.controller'; -import { PaymentsService } from './payments.service'; -import { INestApplication, BadRequestException } from '@nestjs/common'; -import request from 'supertest'; +import { PaymentIntentResponse, PaymentsService } from './payments.service'; +import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; +import { DonationStatus } from '../donations/donation.entity'; +import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; + +const mockWithAllOptionalParameters: PaymentIntentResponse = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1234567890abcdef_secret_1234567890abcdef', + amount: 1099, + currency: 'usd', + status: DonationStatus.CANCELLED, + metadata: { orderId: '123' }, + paymentMethodId: 'pm_1F4aBcD3eF4GhIjKlmnoPq', + paymentMethodTypes: ['card'], + created: 1764789115, + requiresAction: false, + nextAction: { + type: 'redirect_to_url', + redirect_to_url: { + url: 'https://hooks.stripe.com/redirect/authenticate/src_1Aa2Bb3Cc4', + return_url: 'https://example.com/checkout/complete', + }, + }, + lastPaymentError: { + code: 'card_declined', + message: 'Your card was declined.', + type: 'card_error', + }, + canceledAt: 1764789116, +}; + +const mockWithNoOptionalParameters: PaymentIntentResponse = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1234567890abcdef_secret_1234567890abcdef', + amount: 1099, + currency: 'usd', + status: DonationStatus.SUCCEEDED, + paymentMethodTypes: ['card'], + created: 1764789115, + requiresAction: false, +}; + +const createPaymentIntentInput: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, +}; describe('PaymentsControler', () => { let controller: PaymentsController; - let service: PaymentsService; const mockService = { createPaymentIntent: jest.fn(), @@ -25,10 +68,67 @@ describe('PaymentsControler', () => { }).compile(); controller = module.get(PaymentsController); - service = module.get(PaymentsService); }); afterEach(() => { jest.clearAllMocks(); }); + + describe('create intent', () => { + it('should create a payment intent and return a valid payment intent DTO with all optional parameters', async () => { + mockService.createPaymentIntent.mockReturnValueOnce( + mockWithAllOptionalParameters, + ); + const result = await controller.createIntent(createPaymentIntentInput); + const expected: PaymentIntentResponseDto = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1234567890abcdef_secret_1234567890abcdef', + amount: 1099, + currency: 'usd', + status: DonationStatus.CANCELLED, + metadata: { orderId: '123' }, + paymentMethodId: 'pm_1F4aBcD3eF4GhIjKlmnoPq', + paymentMethodTypes: ['card'], + created: 1764789115, + requiresAction: false, + nextAction: { + type: 'redirect_to_url', + redirect_to_url: { + url: 'https://hooks.stripe.com/redirect/authenticate/src_1Aa2Bb3Cc4', + return_url: 'https://example.com/checkout/complete', + }, + }, + lastPaymentError: { + code: 'card_declined', + message: 'Your card was declined.', + type: 'card_error', + }, + canceledAt: 1764789116, + }; + expect(result).toStrictEqual(expected); + }); + + it('it should create a payment intent and return all the fields given with no optional parameters', async () => { + mockService.createPaymentIntent.mockReturnValueOnce( + mockWithNoOptionalParameters, + ); + const result = await controller.createIntent(createPaymentIntentInput); + const expected: PaymentIntentResponseDto = { + id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + clientSecret: 'pi_1234567890abcdef_secret_1234567890abcdef', + amount: 1099, + canceledAt: undefined, + currency: 'usd', + status: DonationStatus.SUCCEEDED, + paymentMethodTypes: ['card'], + created: 1764789115, + requiresAction: false, + lastPaymentError: undefined, + metadata: undefined, + nextAction: undefined, + paymentMethodId: undefined, + }; + expect(result).toStrictEqual(expected); + }); + }); }); From 53bd374219499bca66d7ad6efd3a88977c51197c Mon Sep 17 00:00:00 2001 From: thaninbew Date: Fri, 5 Dec 2025 13:17:53 -0500 Subject: [PATCH 13/13] dono accepts tripe pii, enable push status updates + controller register + expose stripe webhook ep, tests --- .../src/donations/donations.service.spec.ts | 59 ++++++++++-- .../src/donations/donations.service.ts | 50 +++++++++- .../src/donations/dtos/create-donation-dto.ts | 9 ++ apps/backend/src/donations/mappers.ts | 2 + apps/backend/src/main.ts | 4 +- .../src/payments/payments.controller.spec.ts | 95 ++++++++++++++++++- .../src/payments/payments.controller.ts | 95 ++++++++++++++++++- apps/backend/src/payments/payments.module.ts | 5 +- apps/backend/src/payments/payments.service.ts | 14 ++- 9 files changed, 320 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 57f17df..8348fc7 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -248,12 +248,24 @@ describe('DonationsService', () => { repo.findOne.mockImplementation( async (options?: FindOneOptions) => { - const where = options?.where; - if (where && !Array.isArray(where)) { - const id = (where as FindOptionsWhere).id; - if (id !== undefined && id !== null) { - const donation = allDonations.find((d) => d.id === id); - return donation ?? null; + const where = options?.where as FindOptionsWhere | undefined; + if (!where) { + return null; + } + + if (where.id !== undefined && where.id !== null) { + const donation = allDonations.find((d) => d.id === where.id); + if (donation) { + return donation; + } + } + + if (where.transactionId) { + const donation = allDonations.find( + (d) => d.transactionId === where.transactionId, + ); + if (donation) { + return donation; } } @@ -357,4 +369,39 @@ describe('DonationsService', () => { }); }); }); + + describe('syncPaymentIntentStatus', () => { + it('updates donation status when donation id is provided', async () => { + const saveSpy = jest.spyOn(repo, 'save'); + await service.syncPaymentIntentStatus({ + donationId: validDonation1.id, + transactionId: 'pi_sync_123', + status: DonationStatus.SUCCEEDED, + }); + + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: validDonation1.id, + transactionId: 'pi_sync_123', + status: DonationStatus.SUCCEEDED, + }), + ); + }); + + it('falls back to transactionId lookup when donation id missing', async () => { + validDonation2.transactionId = 'pi_existing_456'; + const saveSpy = jest.spyOn(repo, 'save'); + await service.syncPaymentIntentStatus({ + transactionId: 'pi_existing_456', + status: DonationStatus.FAILED, + }); + + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: validDonation2.id, + status: DonationStatus.FAILED, + }), + ); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 46d3860..36e0cbf 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { DonationResponseDto } from './dtos/donation-response-dto'; import { InjectRepository } from '@nestjs/typeorm'; import { @@ -10,8 +10,16 @@ import { import { Repository } from 'typeorm'; import { CreateDonationRequest, Donation as DomainDonation } from './mappers'; +interface PaymentIntentSyncPayload { + donationId?: number; + transactionId?: string; + status: DonationStatus; +} + @Injectable() export class DonationsService { + private readonly logger = new Logger(DonationsService.name); + constructor( @InjectRepository(Donation) private donationRepository: Repository, @@ -65,6 +73,7 @@ export class DonationsService { createDonationRequest.recurringInterval as RecurringInterval | null, dedicationMessage: createDonationRequest.dedicationMessage || null, showDedicationPublicly: createDonationRequest.showDedicationPublicly, + transactionId: createDonationRequest.paymentIntentId || null, }); // Reload from database so any DB-side defaults are reflected @@ -210,4 +219,43 @@ export class DonationsService { return { total, count }; } + + async syncPaymentIntentStatus( + payload: PaymentIntentSyncPayload, + ): Promise { + const { donationId, transactionId, status } = payload; + + if (!donationId && !transactionId) { + this.logger.warn('Unable to sync donation without identifiers'); + return; + } + + let donation: Donation | null = null; + + if (donationId !== undefined) { + donation = await this.donationRepository.findOne({ + where: { id: donationId }, + }); + } + + if (!donation && transactionId) { + donation = await this.donationRepository.findOne({ + where: { transactionId }, + }); + } + + if (!donation) { + this.logger.warn( + `No donation found to sync for payment intent ${transactionId ?? 'unknown'}`, + ); + return; + } + + donation.status = status; + if (transactionId) { + donation.transactionId = transactionId; + } + + await this.donationRepository.save(donation); + } } diff --git a/apps/backend/src/donations/dtos/create-donation-dto.ts b/apps/backend/src/donations/dtos/create-donation-dto.ts index c906699..302b4aa 100644 --- a/apps/backend/src/donations/dtos/create-donation-dto.ts +++ b/apps/backend/src/donations/dtos/create-donation-dto.ts @@ -91,4 +91,13 @@ export class CreateDonationDto { @IsBoolean() @IsOptional() showDedicationPublicly?: boolean = false; + + @ApiProperty({ + description: 'optional Stripe payment intent identifier for this donation', + example: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + required: false, + }) + @IsString() + @IsOptional() + paymentIntentId?: string; } diff --git a/apps/backend/src/donations/mappers.ts b/apps/backend/src/donations/mappers.ts index 7c93b5c..f748ac8 100644 --- a/apps/backend/src/donations/mappers.ts +++ b/apps/backend/src/donations/mappers.ts @@ -22,6 +22,7 @@ export interface CreateDonationRequest { | 'annually'; dedicationMessage?: string; showDedicationPublicly: boolean; + paymentIntentId?: string; } export interface Donation { @@ -66,6 +67,7 @@ export class DonationMappers { | undefined, dedicationMessage: dto.dedicationMessage, showDedicationPublicly: dto.showDedicationPublicly ?? false, + paymentIntentId: dto.paymentIntentId, }; } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index bcd0c9d..236cf6b 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -10,7 +10,9 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + rawBody: true, + }); app.enableCors(); const globalPrefix = 'api'; diff --git a/apps/backend/src/payments/payments.controller.spec.ts b/apps/backend/src/payments/payments.controller.spec.ts index 59fc75a..b9af9f0 100644 --- a/apps/backend/src/payments/payments.controller.spec.ts +++ b/apps/backend/src/payments/payments.controller.spec.ts @@ -4,6 +4,11 @@ import { PaymentIntentResponse, PaymentsService } from './payments.service'; import { CreatePaymentIntentDto } from './dtos/create-payment-intent-dto'; import { DonationStatus } from '../donations/donation.entity'; import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; +import { DonationsService } from '../donations/donations.service'; +import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; +import { RawBodyRequest } from '@nestjs/common'; +import { Request } from 'express'; const mockWithAllOptionalParameters: PaymentIntentResponse = { id: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', @@ -11,7 +16,7 @@ const mockWithAllOptionalParameters: PaymentIntentResponse = { amount: 1099, currency: 'usd', status: DonationStatus.CANCELLED, - metadata: { orderId: '123' }, + metadata: { orderId: '123', donationId: '42' }, paymentMethodId: 'pm_1F4aBcD3eF4GhIjKlmnoPq', paymentMethodTypes: ['card'], created: 1764789115, @@ -37,6 +42,7 @@ const mockWithNoOptionalParameters: PaymentIntentResponse = { amount: 1099, currency: 'usd', status: DonationStatus.SUCCEEDED, + metadata: undefined, paymentMethodTypes: ['card'], created: 1764789115, requiresAction: false, @@ -54,6 +60,15 @@ describe('PaymentsControler', () => { createPaymentIntent: jest.fn(), createSubscription: jest.fn(), retrievePaymentIntent: jest.fn(), + constructWebhookEvent: jest.fn(), + mapPaymentIntentToResponse: jest.fn(), + }; + const mockDonationsService = { + syncPaymentIntentStatus: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), }; beforeEach(async () => { @@ -64,6 +79,14 @@ describe('PaymentsControler', () => { provide: PaymentsService, useValue: mockService, }, + { + provide: DonationsService, + useValue: mockDonationsService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }).compile(); @@ -106,6 +129,13 @@ describe('PaymentsControler', () => { canceledAt: 1764789116, }; expect(result).toStrictEqual(expected); + expect(mockDonationsService.syncPaymentIntentStatus).toHaveBeenCalledWith( + { + donationId: 42, + transactionId: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + status: DonationStatus.CANCELLED, + }, + ); }); it('it should create a payment intent and return all the fields given with no optional parameters', async () => { @@ -129,6 +159,69 @@ describe('PaymentsControler', () => { paymentMethodId: undefined, }; expect(result).toStrictEqual(expected); + expect(mockDonationsService.syncPaymentIntentStatus).toHaveBeenCalledWith( + { + donationId: undefined, + transactionId: 'pi_1J2aBcD3eF4GhIjKlmnoPqr', + status: DonationStatus.SUCCEEDED, + }, + ); + }); + }); + + describe('handleWebhook', () => { + it('should construct event and sync donation for payment intent events', async () => { + const paymentIntent = { + id: 'pi_webhook_123', + object: 'payment_intent', + } as Stripe.PaymentIntent; + mockConfigService.get.mockReturnValue('whsec_123'); + const paymentIntentResponse: PaymentIntentResponse = { + id: 'pi_webhook_123', + clientSecret: 'secret', + amount: 100, + currency: 'usd', + status: DonationStatus.SUCCEEDED, + paymentMethodTypes: ['card'], + created: 0, + requiresAction: false, + metadata: { donationId: '55' }, + }; + mockService.constructWebhookEvent.mockReturnValue({ + type: 'payment_intent.succeeded', + data: { object: paymentIntent }, + } as Stripe.Event); + mockService.mapPaymentIntentToResponse.mockReturnValue( + paymentIntentResponse, + ); + + const req = { + rawBody: Buffer.from('payload'), + } as RawBodyRequest; + const result = await controller.handleWebhook(req, 'sig'); + + expect(result).toEqual({ received: true }); + expect(mockService.constructWebhookEvent).toHaveBeenCalledWith( + req.rawBody, + 'sig', + 'whsec_123', + ); + expect(mockDonationsService.syncPaymentIntentStatus).toHaveBeenCalledWith( + { + donationId: 55, + transactionId: 'pi_webhook_123', + status: DonationStatus.SUCCEEDED, + }, + ); + }); + + it('should throw when stripe signature missing', async () => { + await expect( + controller.handleWebhook( + { rawBody: Buffer.from('payload') } as RawBodyRequest, + undefined, + ), + ).rejects.toThrow('Missing Stripe signature header'); }); }); }); diff --git a/apps/backend/src/payments/payments.controller.ts b/apps/backend/src/payments/payments.controller.ts index b2bd437..1d0cf47 100644 --- a/apps/backend/src/payments/payments.controller.ts +++ b/apps/backend/src/payments/payments.controller.ts @@ -1,21 +1,33 @@ import { + BadRequestException, Body, Controller, + Headers, HttpCode, HttpStatus, Post, + Req, + RawBodyRequest, ValidationPipe, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { PaymentsService } from './payments.service'; +import { Request } from 'express'; +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 { PaymentMappers } from './mappers'; +import { DonationsService } from '../donations/donations.service'; @ApiTags('Payments') @Controller('payments') export class PaymentsController { - constructor(private readonly paymentsService: PaymentsService) {} + constructor( + private readonly paymentsService: PaymentsService, + private readonly donationsService: DonationsService, + private readonly configService: ConfigService, + ) {} @Post('/intent') @HttpCode(HttpStatus.CREATED) @@ -42,6 +54,85 @@ export class PaymentsController { ); const paymentIntentResponse = await this.paymentsService.createPaymentIntent(request); + await this.syncDonationFromPaymentIntent(paymentIntentResponse); return PaymentMappers.toPaymentIntentResponseDto(paymentIntentResponse); } + + @Post('/webhook') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Stripe webhook handler', + description: + 'handles asynchronous Stripe payment intent events to keep donation statuses in sync', + }) + @ApiResponse({ status: 200, description: 'webhook received' }) + async handleWebhook( + @Req() req: RawBodyRequest, + @Headers('stripe-signature') stripeSignature?: string, + ): Promise<{ received: boolean }> { + if (!stripeSignature) { + throw new BadRequestException('Missing Stripe signature header'); + } + + const webhookSecret = this.configService.get( + 'STRIPE_WEBHOOK_SECRET', + ); + if (!webhookSecret) { + throw new BadRequestException('Stripe webhook secret is not configured'); + } + + let event: Stripe.Event; + try { + event = this.paymentsService.constructWebhookEvent( + req.rawBody, + stripeSignature, + webhookSecret, + ); + } catch (error) { + throw new BadRequestException('Invalid Stripe webhook signature'); + } + + if (event.type.startsWith('payment_intent.')) { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + const response = + this.paymentsService.mapPaymentIntentToResponse(paymentIntent); + await this.syncDonationFromPaymentIntent(response); + } + + return { received: true }; + } + + private async syncDonationFromPaymentIntent( + paymentIntent: PaymentIntentResponse, + ): Promise { + await this.donationsService.syncPaymentIntentStatus({ + donationId: this.extractDonationId(paymentIntent.metadata), + transactionId: paymentIntent.id, + status: paymentIntent.status, + }); + } + + private extractDonationId( + metadata?: Record, + ): number | undefined { + if (!metadata) { + return undefined; + } + + const rawValue = (metadata['donationId'] ?? metadata['donation_id']) as + | string + | number + | undefined; + + if (typeof rawValue === 'number') { + return Number.isFinite(rawValue) ? rawValue : undefined; + } + + if (typeof rawValue === 'string') { + const parsed = Number(rawValue); + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; + } } diff --git a/apps/backend/src/payments/payments.module.ts b/apps/backend/src/payments/payments.module.ts index 2149999..d92c75e 100644 --- a/apps/backend/src/payments/payments.module.ts +++ b/apps/backend/src/payments/payments.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { PaymentsService } from './payments.service'; +import { PaymentsController } from './payments.controller'; +import { DonationsModule } from '../donations/donations.module'; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, DonationsModule], + controllers: [PaymentsController], providers: [ { provide: 'STRIPE_CLIENT', diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts index 61bbdec..f1bf07c 100644 --- a/apps/backend/src/payments/payments.service.ts +++ b/apps/backend/src/payments/payments.service.ts @@ -317,6 +317,18 @@ export class PaymentsService { } } + constructWebhookEvent( + payload: Buffer | string, + signature: string, + webhookSecret: string, + ): Stripe.Event { + return this.stripe.webhooks.constructEvent( + payload, + signature, + webhookSecret, + ); + } + /** * Maps a Stripe PaymentIntent status to one of the four DonationStatus enum values * @@ -353,7 +365,7 @@ export class PaymentsService { * @param paymentIntent the payment intent object returned directly by the stripe api * @returns A PaymentIntentResponse object that is closer to data used in backend */ - private mapPaymentIntentToResponse( + public mapPaymentIntentToResponse( paymentIntent: Stripe.PaymentIntent, ): PaymentIntentResponse { return {