Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions apps/backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,24 @@ describe('DonationsService', () => {

repo.findOne.mockImplementation(
async (options?: FindOneOptions<Donation>) => {
const where = options?.where;
if (where && !Array.isArray(where)) {
const id = (where as FindOptionsWhere<Donation>).id;
if (id !== undefined && id !== null) {
const donation = allDonations.find((d) => d.id === id);
return donation ?? null;
const where = options?.where as FindOptionsWhere<Donation> | 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;
}
}

Expand Down Expand Up @@ -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,
}),
);
});
});
});
50 changes: 49 additions & 1 deletion apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Donation>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -210,4 +219,43 @@ export class DonationsService {

return { total, count };
}

async syncPaymentIntentStatus(
payload: PaymentIntentSyncPayload,
): Promise<void> {
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);
}
}
9 changes: 9 additions & 0 deletions apps/backend/src/donations/dtos/create-donation-dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions apps/backend/src/donations/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface CreateDonationRequest {
| 'annually';
dedicationMessage?: string;
showDedicationPublicly: boolean;
paymentIntentId?: string;
}

export interface Donation {
Expand Down Expand Up @@ -66,6 +67,7 @@ export class DonationMappers {
| undefined,
dedicationMessage: dto.dedicationMessage,
showDedicationPublicly: dto.showDedicationPublicly ?? false,
paymentIntentId: dto.paymentIntentId,
};
}

Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
34 changes: 34 additions & 0 deletions apps/backend/src/payments/dtos/create-payment-intent-dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
141 changes: 141 additions & 0 deletions apps/backend/src/payments/dtos/payment-intent-response-dto.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

@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;
}
Loading