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/dtos/create-payment-intent-dto.ts b/apps/backend/src/payments/dtos/create-payment-intent-dto.ts new file mode 100644 index 0000000..e000877 --- /dev/null +++ b/apps/backend/src/payments/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/dtos/payment-intent-response-dto.ts b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts new file mode 100644 index 0000000..4c24b8e --- /dev/null +++ b/apps/backend/src/payments/dtos/payment-intent-response-dto.ts @@ -0,0 +1,141 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsEnum, + IsOptional, + Min, + IsNotEmpty, + IsArray, + IsBoolean, +} from 'class-validator'; +import { DonationStatus } from '../../donations/donation.entity'; +import Stripe from 'stripe'; + +export class PaymentIntentResponseDto { + @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?: Stripe.PaymentIntent.NextAction; + + @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; +} diff --git a/apps/backend/src/payments/mappers.spec.ts b/apps/backend/src/payments/mappers.spec.ts new file mode 100644 index 0000000..0b39430 --- /dev/null +++ b/apps/backend/src/payments/mappers.spec.ts @@ -0,0 +1,135 @@ +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 mockCreateDtoAllOptionalParams: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, + }; + + const mockCreateDtoNoOptionalParams: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + }; + + const mockPaymentIntentResponseAllOptionalParams: 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, + }; + + 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 with all optional params', () => { + const result = PaymentMappers.toCreatePaymentIntentRequest( + mockCreateDtoAllOptionalParams, + ); + const expected: CreatePaymentIntentRequest = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, + }; + 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 with all optional params', () => { + const result = PaymentMappers.toPaymentIntentResponseDto( + mockPaymentIntentResponseAllOptionalParams, + ); + 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); + }); + }); + + 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 new file mode 100644 index 0000000..0778820 --- /dev/null +++ b/apps/backend/src/payments/mappers.ts @@ -0,0 +1,66 @@ +import Stripe from 'stripe'; +import { PaymentIntentResponseDto } from './dtos/payment-intent-response-dto'; +import { PaymentIntentResponse } from './payments.service'; +import { CreatePaymentIntentDto } from './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: + dto.metadata == undefined + ? undefined + : 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.spec.ts b/apps/backend/src/payments/payments.controller.spec.ts new file mode 100644 index 0000000..b9af9f0 --- /dev/null +++ b/apps/backend/src/payments/payments.controller.spec.ts @@ -0,0 +1,227 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentsController } from './payments.controller'; +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', + clientSecret: 'pi_1234567890abcdef_secret_1234567890abcdef', + amount: 1099, + currency: 'usd', + status: DonationStatus.CANCELLED, + metadata: { orderId: '123', donationId: '42' }, + 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, + metadata: undefined, + paymentMethodTypes: ['card'], + created: 1764789115, + requiresAction: false, +}; + +const createPaymentIntentInput: CreatePaymentIntentDto = { + amount: 1099, + currency: 'usd', + metadata: { orderId: '123' }, +}; +describe('PaymentsControler', () => { + let controller: PaymentsController; + + const mockService = { + 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 () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PaymentsController], + providers: [ + { + provide: PaymentsService, + useValue: mockService, + }, + { + provide: DonationsService, + useValue: mockDonationsService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + controller = module.get(PaymentsController); + }); + + 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); + 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 () => { + 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); + 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 new file mode 100644 index 0000000..1d0cf47 --- /dev/null +++ b/apps/backend/src/payments/payments.controller.ts @@ -0,0 +1,138 @@ +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 { 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, + private readonly donationsService: DonationsService, + private readonly configService: ConfigService, + ) {} + + @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); + 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.spec.ts b/apps/backend/src/payments/payments.service.spec.ts index 7f01b50..9b33673 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: { @@ -121,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', @@ -227,15 +263,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 +323,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 +338,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 +374,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 +453,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); }); }); @@ -500,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 + }); + }); }); }); diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts index 0577f77..f1bf07c 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; @@ -41,14 +42,14 @@ interface PaymentIntentResponse { paymentMethodTypes: string[]; created: number; requiresAction: boolean; - nextAction?: unknown; + nextAction?: Stripe.PaymentIntent.NextAction; lastPaymentError?: { code: string; message: string; 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); @@ -289,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 * @@ -325,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 { diff --git a/apps/frontend/src/components/GrowingGoal/GrowingGoal.tsx b/apps/frontend/src/components/GrowingGoal/GrowingGoal.tsx index b3156d4..d01a263 100644 --- a/apps/frontend/src/components/GrowingGoal/GrowingGoal.tsx +++ b/apps/frontend/src/components/GrowingGoal/GrowingGoal.tsx @@ -143,7 +143,7 @@ export const GrowingGoal = (props: GrowingGoalProps) => {
{props.sampleDonation.name.length > 8 - ? props.sampleDonation.name.slice(0,8) + '...' + ? props.sampleDonation.name.slice(0, 8) + '...' : props.sampleDonation.name} {' donated $'} diff --git a/apps/frontend/src/components/GrowingGoal/Plant.tsx b/apps/frontend/src/components/GrowingGoal/Plant.tsx index 29ee89b..0e96e0e 100644 --- a/apps/frontend/src/components/GrowingGoal/Plant.tsx +++ b/apps/frontend/src/components/GrowingGoal/Plant.tsx @@ -220,4 +220,4 @@ const Sprout: React.FC = () => { ); }; -export default Plant; \ No newline at end of file +export default Plant; diff --git a/apps/frontend/src/containers/root.tsx b/apps/frontend/src/containers/root.tsx index 70845d3..bc7f909 100644 --- a/apps/frontend/src/containers/root.tsx +++ b/apps/frontend/src/containers/root.tsx @@ -21,7 +21,7 @@ const Root: React.FC = () => { message={'Donate to FCC!'} total={3000} goal={10000} - sampleDonation={{ name: 'C4C', amount: 500}} + sampleDonation={{ name: 'C4C', amount: 500 }} />