From f7c0de54dea62a3df2d247b46c6dd885e8300939 Mon Sep 17 00:00:00 2001 From: mftee Date: Fri, 21 Aug 2026 14:57:15 +0100 Subject: [PATCH] feat(payments): payment domain model, initiation flow, idempotent lifecycle Foundation for the payment track (issue 1 of 7): a Payment entity with a guarded state machine, a provider-agnostic initiation flow, and two-layer idempotency built in from the start. - Payment entity: bookingId/userId refs, amount (minor units), currency, rail (FIAT/STELLAR_CUSTODIAL/STELLAR_EXTERNAL), provider, status, idempotencyKey, metadata, expiresAt TTL. - Guarded state machine (payment-state-machine.ts): the only path that may change Payment#status; illegal transitions throw. - Idempotency: unique (userId, idempotencyKey) index for safe retries, plus a partial unique index on bookingId (non-terminal statuses only) so two different concurrent requests for the same booking can't both create a row. PaymentsService catches the resulting unique-violation and returns the winning row instead of erroring. - Provider-agnostic PaymentRailAdapter interface with a sandbox/placeholder implementation; real Paystack/Stellar adapters land in later issues. - POST /payments/initiate, GET /payments/:id, GET /payments with owner-or- admin RBAC. - Migration creating the payments table, enums, and both unique indexes. - Minimal JWT auth guard + roles guard + RBAC scaffolding, since the backend currently has no auth module for this to build on. - Unit tests covering every legal/illegal state transition and the idempotency-key and booking-id concurrency races. Closes #1570 --- backend/.env.example | 4 + backend/src/.gitkeep | 0 backend/src/app.controller.ts | 12 + backend/src/app.module.ts | 31 ++ backend/src/app.service.ts | 8 + backend/src/auth/auth.module.ts | 21 ++ .../auth/decorators/current-user.decorator.ts | 12 + .../src/auth/decorators/roles.decorator.ts | 5 + backend/src/auth/enums/user-role.enum.ts | 4 + backend/src/auth/guards/jwt-auth.guard.ts | 43 +++ backend/src/auth/guards/roles.guard.ts | 24 ++ .../authenticated-request.interface.ts | 11 + backend/src/database/data-source.ts | 18 ++ .../1755784074000-CreatePaymentsTable.ts | 74 +++++ backend/src/main.ts | 35 +++ .../payments/adapters/sandbox-rail.adapter.ts | 21 ++ .../src/payments/dto/initiate-payment.dto.ts | 47 +++ .../src/payments/dto/payment-response.dto.ts | 40 +++ .../src/payments/entities/payment.entity.ts | 71 +++++ .../src/payments/enums/payment-rail.enum.ts | 5 + .../src/payments/enums/payment-status.enum.ts | 18 ++ .../payment-rail-adapter.interface.ts | 16 + .../payments/payment-state-machine.spec.ts | 54 ++++ backend/src/payments/payment-state-machine.ts | 43 +++ backend/src/payments/payments.controller.ts | 72 +++++ backend/src/payments/payments.module.ts | 14 + backend/src/payments/payments.service.spec.ts | 277 ++++++++++++++++++ backend/src/payments/payments.service.ts | 216 ++++++++++++++ 28 files changed, 1196 insertions(+) delete mode 100644 backend/src/.gitkeep create mode 100644 backend/src/app.controller.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/app.service.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/decorators/current-user.decorator.ts create mode 100644 backend/src/auth/decorators/roles.decorator.ts create mode 100644 backend/src/auth/enums/user-role.enum.ts create mode 100644 backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/auth/guards/roles.guard.ts create mode 100644 backend/src/auth/interfaces/authenticated-request.interface.ts create mode 100644 backend/src/database/data-source.ts create mode 100644 backend/src/database/migrations/1755784074000-CreatePaymentsTable.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/payments/adapters/sandbox-rail.adapter.ts create mode 100644 backend/src/payments/dto/initiate-payment.dto.ts create mode 100644 backend/src/payments/dto/payment-response.dto.ts create mode 100644 backend/src/payments/entities/payment.entity.ts create mode 100644 backend/src/payments/enums/payment-rail.enum.ts create mode 100644 backend/src/payments/enums/payment-status.enum.ts create mode 100644 backend/src/payments/interfaces/payment-rail-adapter.interface.ts create mode 100644 backend/src/payments/payment-state-machine.spec.ts create mode 100644 backend/src/payments/payment-state-machine.ts create mode 100644 backend/src/payments/payments.controller.ts create mode 100644 backend/src/payments/payments.module.ts create mode 100644 backend/src/payments/payments.service.spec.ts create mode 100644 backend/src/payments/payments.service.ts diff --git a/backend/.env.example b/backend/.env.example index a19519e0..8541736a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -82,3 +82,7 @@ STELLAR_HORIZON_URL=https://soroban-testnet.stellar.org # Scheduled Jobs # Minutes a PENDING booking may wait for payment before it is auto-cancelled BOOKING_PAYMENT_TTL_MINUTES=120 + +# Payments module (issue #1570) +# Minutes an INITIATED Payment may sit before it is eligible for expiry sweep +PAYMENT_INITIATED_TTL_MINUTES=30 diff --git a/backend/src/.gitkeep b/backend/src/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 00000000..859dc76c --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('health') + getHealth(): { status: string } { + return this.appService.getHealth(); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 00000000..93c65283 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { PaymentsModule } from './payments/payments.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DATABASE_HOST'), + port: config.get('DATABASE_PORT', 5432), + username: config.get('DATABASE_USERNAME'), + password: config.get('DATABASE_PASSWORD'), + database: config.get('DATABASE_NAME'), + autoLoadEntities: true, + synchronize: false, + }), + }), + AuthModule, + PaymentsModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts new file mode 100644 index 00000000..f3cf9a3d --- /dev/null +++ b/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHealth(): { status: string } { + return { status: 'ok' }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 00000000..e7c0dbac --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Global() +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + }), + }), + ], + providers: [JwtAuthGuard, RolesGuard], + exports: [JwtModule, JwtAuthGuard, RolesGuard], +}) +export class AuthModule {} diff --git a/backend/src/auth/decorators/current-user.decorator.ts b/backend/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 00000000..59b4be9c --- /dev/null +++ b/backend/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { + AuthenticatedRequest, + RequestUser, +} from '../interfaces/authenticated-request.interface'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): RequestUser => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..5f046318 --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../enums/user-role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/auth/enums/user-role.enum.ts b/backend/src/auth/enums/user-role.enum.ts new file mode 100644 index 00000000..9ebd4b18 --- /dev/null +++ b/backend/src/auth/enums/user-role.enum.ts @@ -0,0 +1,4 @@ +export enum UserRole { + USER = 'user', + ADMIN = 'admin', +} diff --git a/backend/src/auth/guards/jwt-auth.guard.ts b/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..3b3d15db --- /dev/null +++ b/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,43 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { + AuthenticatedRequest, + RequestUser, +} from '../interfaces/authenticated-request.interface'; + +interface JwtPayload { + sub: string; + role: RequestUser['role']; +} + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + try { + const payload = await this.jwtService.verifyAsync(token); + request.user = { id: payload.sub, role: payload.role }; + return true; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private extractToken(request: AuthenticatedRequest): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 00000000..6a07e397 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,24 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { AuthenticatedRequest } from '../interfaces/authenticated-request.interface'; +import { UserRole } from '../enums/user-role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + return requiredRoles.includes(request.user?.role); + } +} diff --git a/backend/src/auth/interfaces/authenticated-request.interface.ts b/backend/src/auth/interfaces/authenticated-request.interface.ts new file mode 100644 index 00000000..0d917062 --- /dev/null +++ b/backend/src/auth/interfaces/authenticated-request.interface.ts @@ -0,0 +1,11 @@ +import { Request } from 'express'; +import { UserRole } from '../enums/user-role.enum'; + +export interface RequestUser { + id: string; + role: UserRole; +} + +export interface AuthenticatedRequest extends Request { + user: RequestUser; +} diff --git a/backend/src/database/data-source.ts b/backend/src/database/data-source.ts new file mode 100644 index 00000000..0ef735e9 --- /dev/null +++ b/backend/src/database/data-source.ts @@ -0,0 +1,18 @@ +import 'dotenv/config'; +import { DataSource } from 'typeorm'; + +export const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: process.env.DATABASE_PORT + ? parseInt(process.env.DATABASE_PORT, 10) + : 5432, + username: process.env.DATABASE_USERNAME, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + synchronize: false, +}); + +export default AppDataSource; diff --git a/backend/src/database/migrations/1755784074000-CreatePaymentsTable.ts b/backend/src/database/migrations/1755784074000-CreatePaymentsTable.ts new file mode 100644 index 00000000..fea7adaa --- /dev/null +++ b/backend/src/database/migrations/1755784074000-CreatePaymentsTable.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePaymentsTable1755784074000 implements MigrationInterface { + name = 'CreatePaymentsTable1755784074000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "payments_rail_enum" AS ENUM ( + 'FIAT', 'STELLAR_CUSTODIAL', 'STELLAR_EXTERNAL' + ) + `); + + await queryRunner.query(` + CREATE TYPE "payments_status_enum" AS ENUM ( + 'INITIATED', 'AWAITING_CONFIRMATION', 'CONFIRMED', + 'FAILED', 'EXPIRED', 'REFUNDED', 'PARTIALLY_REFUNDED' + ) + `); + + await queryRunner.query(` + CREATE TABLE "payments" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "booking_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "amount" bigint NOT NULL, + "currency" varchar(3) NOT NULL, + "rail" "payments_rail_enum" NOT NULL, + "provider" varchar, + "provider_reference" varchar, + "status" "payments_status_enum" NOT NULL DEFAULT 'INITIATED', + "idempotency_key" varchar NOT NULL, + "metadata" jsonb, + "expires_at" timestamptz, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_payments" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE INDEX "idx_payments_booking_id" ON "payments" ("booking_id") + `); + + await queryRunner.query(` + CREATE INDEX "idx_payments_user_id" ON "payments" ("user_id") + `); + + // Idempotency: a retried request (same user, same key) must resolve to + // the same row. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payments_user_id_idempotency_key" + ON "payments" ("user_id", "idempotency_key") + `); + + // Concurrency safety: at most one non-terminal Payment per booking, + // independent of idempotency key, so two different initiate requests + // racing for the same booking can't both create a row. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payments_booking_id_non_terminal" + ON "payments" ("booking_id") + WHERE "status" IN ('INITIATED', 'AWAITING_CONFIRMATION') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "uq_payments_booking_id_non_terminal"`); + await queryRunner.query(`DROP INDEX "uq_payments_user_id_idempotency_key"`); + await queryRunner.query(`DROP INDEX "idx_payments_user_id"`); + await queryRunner.query(`DROP INDEX "idx_payments_booking_id"`); + await queryRunner.query(`DROP TABLE "payments"`); + await queryRunner.query(`DROP TYPE "payments_status_enum"`); + await queryRunner.query(`DROP TYPE "payments_rail_enum"`); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 00000000..c5dd5d04 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,35 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + const config = new DocumentBuilder() + .setTitle('ManageHub API') + .setDescription( + 'ManageHub backend API.\n\n' + + '## Payment state machine\n' + + 'INITIATED -> AWAITING_CONFIRMATION -> CONFIRMED | FAILED | EXPIRED\n' + + 'CONFIRMED -> REFUNDED | PARTIALLY_REFUNDED\n' + + 'All transitions are enforced by a single guarded service method.', + ) + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.PORT ?? 6000; + await app.listen(port); +} +bootstrap(); diff --git a/backend/src/payments/adapters/sandbox-rail.adapter.ts b/backend/src/payments/adapters/sandbox-rail.adapter.ts new file mode 100644 index 00000000..cf7bd434 --- /dev/null +++ b/backend/src/payments/adapters/sandbox-rail.adapter.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { Payment } from '../entities/payment.entity'; +import { + PaymentInitiationResult, + PaymentRailAdapter, +} from '../interfaces/payment-rail-adapter.interface'; + +/** + * Placeholder adapter used until the real Paystack / Stellar adapters land + * (issues 2-7 of the payment track). Never wired to a live provider. + */ +@Injectable() +export class SandboxRailAdapter implements PaymentRailAdapter { + async initiate(payment: Payment): Promise { + return { + providerReference: `sandbox_${randomUUID()}`, + metadata: { sandbox: true, bookingId: payment.bookingId }, + }; + } +} diff --git a/backend/src/payments/dto/initiate-payment.dto.ts b/backend/src/payments/dto/initiate-payment.dto.ts new file mode 100644 index 00000000..7d6f15ca --- /dev/null +++ b/backend/src/payments/dto/initiate-payment.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEnum, + IsInt, + IsObject, + IsOptional, + IsPositive, + IsString, + IsUUID, + Length, +} from 'class-validator'; +import { PaymentRail } from '../enums/payment-rail.enum'; + +export class InitiatePaymentDto { + @ApiProperty({ description: 'Booking (or order) this payment is for' }) + @IsUUID() + bookingId: string; + + @ApiProperty({ + description: 'Amount in minor units (e.g. cents)', + example: 5000, + }) + @IsInt() + @IsPositive() + amount: number; + + @ApiProperty({ description: 'ISO 4217 currency code', example: 'USD' }) + @IsString() + @Length(3, 3) + currency: string; + + @ApiProperty({ enum: PaymentRail }) + @IsEnum(PaymentRail) + rail: PaymentRail; + + @ApiPropertyOptional({ + description: 'Payment provider identifier, e.g. paystack', + }) + @IsOptional() + @IsString() + provider?: string; + + @ApiPropertyOptional({ type: 'object' }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/backend/src/payments/dto/payment-response.dto.ts b/backend/src/payments/dto/payment-response.dto.ts new file mode 100644 index 00000000..9920bb94 --- /dev/null +++ b/backend/src/payments/dto/payment-response.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaymentRail } from '../enums/payment-rail.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { Payment } from '../entities/payment.entity'; + +export class PaymentResponseDto { + @ApiProperty() id: string; + @ApiProperty() bookingId: string; + @ApiProperty() userId: string; + @ApiProperty() amount: number; + @ApiProperty() currency: string; + @ApiProperty({ enum: PaymentRail }) rail: PaymentRail; + @ApiProperty({ + enum: PaymentStatus, + description: 'See the state machine in the API description', + }) + status: PaymentStatus; + @ApiProperty({ nullable: true }) provider: string | null; + @ApiProperty({ nullable: true }) providerReference: string | null; + @ApiProperty({ nullable: true }) expiresAt: Date | null; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; + + static fromEntity(payment: Payment): PaymentResponseDto { + const dto = new PaymentResponseDto(); + dto.id = payment.id; + dto.bookingId = payment.bookingId; + dto.userId = payment.userId; + dto.amount = payment.amount; + dto.currency = payment.currency; + dto.rail = payment.rail; + dto.status = payment.status; + dto.provider = payment.provider; + dto.providerReference = payment.providerReference; + dto.expiresAt = payment.expiresAt; + dto.createdAt = payment.createdAt; + dto.updatedAt = payment.updatedAt; + return dto; + } +} diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts new file mode 100644 index 00000000..3fd7d355 --- /dev/null +++ b/backend/src/payments/entities/payment.entity.ts @@ -0,0 +1,71 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { PaymentRail } from '../enums/payment-rail.enum'; +import { PaymentStatus } from '../enums/payment-status.enum'; + +@Entity('payments') +@Index(['userId', 'idempotencyKey'], { unique: true }) +@Index('uq_payments_booking_id_non_terminal', ['bookingId'], { + unique: true, + where: `"status" IN ('INITIATED', 'AWAITING_CONFIRMATION')`, +}) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid', name: 'booking_id' }) + bookingId: string; + + @Index() + @Column({ type: 'uuid', name: 'user_id' }) + userId: string; + + /** Minor units (e.g. cents / stroops) — never a float. */ + @Column({ + type: 'bigint', + transformer: { to: (v: number) => v, from: (v: string) => parseInt(v, 10) }, + }) + amount: number; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + @Column({ type: 'enum', enum: PaymentRail }) + rail: PaymentRail; + + @Column({ type: 'varchar', nullable: true }) + provider: string | null; + + @Column({ type: 'varchar', name: 'provider_reference', nullable: true }) + providerReference: string | null; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.INITIATED, + }) + status: PaymentStatus; + + @Column({ type: 'varchar', name: 'idempotency_key' }) + idempotencyKey: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + /** TTL for INITIATED payments; a later reconciliation job sweeps on this. */ + @Column({ type: 'timestamptz', name: 'expires_at', nullable: true }) + expiresAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/payments/enums/payment-rail.enum.ts b/backend/src/payments/enums/payment-rail.enum.ts new file mode 100644 index 00000000..64c02f9d --- /dev/null +++ b/backend/src/payments/enums/payment-rail.enum.ts @@ -0,0 +1,5 @@ +export enum PaymentRail { + FIAT = 'FIAT', + STELLAR_CUSTODIAL = 'STELLAR_CUSTODIAL', + STELLAR_EXTERNAL = 'STELLAR_EXTERNAL', +} diff --git a/backend/src/payments/enums/payment-status.enum.ts b/backend/src/payments/enums/payment-status.enum.ts new file mode 100644 index 00000000..a4294425 --- /dev/null +++ b/backend/src/payments/enums/payment-status.enum.ts @@ -0,0 +1,18 @@ +export enum PaymentStatus { + INITIATED = 'INITIATED', + AWAITING_CONFIRMATION = 'AWAITING_CONFIRMATION', + CONFIRMED = 'CONFIRMED', + FAILED = 'FAILED', + EXPIRED = 'EXPIRED', + REFUNDED = 'REFUNDED', + PARTIALLY_REFUNDED = 'PARTIALLY_REFUNDED', +} + +/** + * Statuses that still permit a competing initiate() for the same booking. + * Used by the partial unique index and by the proactive conflict check. + */ +export const NON_TERMINAL_PAYMENT_STATUSES = [ + PaymentStatus.INITIATED, + PaymentStatus.AWAITING_CONFIRMATION, +]; diff --git a/backend/src/payments/interfaces/payment-rail-adapter.interface.ts b/backend/src/payments/interfaces/payment-rail-adapter.interface.ts new file mode 100644 index 00000000..ac67cd94 --- /dev/null +++ b/backend/src/payments/interfaces/payment-rail-adapter.interface.ts @@ -0,0 +1,16 @@ +import { Payment } from '../entities/payment.entity'; + +export interface PaymentInitiationResult { + providerReference: string; + /** Opaque data the caller can hand back to the client (e.g. a checkout URL). */ + metadata?: Record; +} + +/** + * Provider-agnostic boundary between the Payment domain and a concrete rail + * (Paystack, Stellar custodial, Stellar external, ...). Later issues in the + * payment track implement real adapters; this issue only needs the shape. + */ +export interface PaymentRailAdapter { + initiate(payment: Payment): Promise; +} diff --git a/backend/src/payments/payment-state-machine.spec.ts b/backend/src/payments/payment-state-machine.spec.ts new file mode 100644 index 00000000..9393fac6 --- /dev/null +++ b/backend/src/payments/payment-state-machine.spec.ts @@ -0,0 +1,54 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { assertValidTransition, canTransition } from './payment-state-machine'; +import { PaymentStatus } from './enums/payment-status.enum'; + +describe('payment state machine', () => { + const ALL_STATUSES = Object.values(PaymentStatus); + + const VALID_TRANSITIONS: [PaymentStatus, PaymentStatus][] = [ + [PaymentStatus.INITIATED, PaymentStatus.AWAITING_CONFIRMATION], + [PaymentStatus.INITIATED, PaymentStatus.FAILED], + [PaymentStatus.INITIATED, PaymentStatus.EXPIRED], + [PaymentStatus.AWAITING_CONFIRMATION, PaymentStatus.CONFIRMED], + [PaymentStatus.AWAITING_CONFIRMATION, PaymentStatus.FAILED], + [PaymentStatus.AWAITING_CONFIRMATION, PaymentStatus.EXPIRED], + [PaymentStatus.CONFIRMED, PaymentStatus.REFUNDED], + [PaymentStatus.CONFIRMED, PaymentStatus.PARTIALLY_REFUNDED], + ]; + + it.each(VALID_TRANSITIONS)('allows %s -> %s', (from, to) => { + expect(canTransition(from, to)).toBe(true); + expect(() => assertValidTransition(from, to)).not.toThrow(); + }); + + const validSet = new Set( + VALID_TRANSITIONS.map(([from, to]) => `${from}->${to}`), + ); + + const illegalTransitions = ALL_STATUSES.flatMap((from) => + ALL_STATUSES.filter((to) => !validSet.has(`${from}->${to}`)).map( + (to): [PaymentStatus, PaymentStatus] => [from, to], + ), + ); + + it.each(illegalTransitions)('rejects %s -> %s', (from, to) => { + expect(canTransition(from, to)).toBe(false); + expect(() => assertValidTransition(from, to)).toThrow( + UnprocessableEntityException, + ); + }); + + it('has no outgoing transitions from any terminal status', () => { + const terminal = [ + PaymentStatus.FAILED, + PaymentStatus.EXPIRED, + PaymentStatus.REFUNDED, + PaymentStatus.PARTIALLY_REFUNDED, + ]; + for (const status of terminal) { + for (const to of ALL_STATUSES) { + expect(canTransition(status, to)).toBe(false); + } + } + }); +}); diff --git a/backend/src/payments/payment-state-machine.ts b/backend/src/payments/payment-state-machine.ts new file mode 100644 index 00000000..1351be30 --- /dev/null +++ b/backend/src/payments/payment-state-machine.ts @@ -0,0 +1,43 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { PaymentStatus } from './enums/payment-status.enum'; + +/** + * The single source of truth for legal Payment status transitions. + * No code outside PaymentsService.transitionStatus() may assign + * Payment#status directly (enforced in review, see issue #1570). + */ +const ALLOWED_TRANSITIONS: Record = { + [PaymentStatus.INITIATED]: [ + PaymentStatus.AWAITING_CONFIRMATION, + PaymentStatus.FAILED, + PaymentStatus.EXPIRED, + ], + [PaymentStatus.AWAITING_CONFIRMATION]: [ + PaymentStatus.CONFIRMED, + PaymentStatus.FAILED, + PaymentStatus.EXPIRED, + ], + [PaymentStatus.CONFIRMED]: [ + PaymentStatus.REFUNDED, + PaymentStatus.PARTIALLY_REFUNDED, + ], + [PaymentStatus.FAILED]: [], + [PaymentStatus.EXPIRED]: [], + [PaymentStatus.REFUNDED]: [], + [PaymentStatus.PARTIALLY_REFUNDED]: [], +}; + +export function canTransition(from: PaymentStatus, to: PaymentStatus): boolean { + return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false; +} + +export function assertValidTransition( + from: PaymentStatus, + to: PaymentStatus, +): void { + if (!canTransition(from, to)) { + throw new UnprocessableEntityException( + `Illegal payment status transition: ${from} -> ${to}`, + ); + } +} diff --git a/backend/src/payments/payments.controller.ts b/backend/src/payments/payments.controller.ts new file mode 100644 index 00000000..073775ef --- /dev/null +++ b/backend/src/payments/payments.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/interfaces/authenticated-request.interface'; +import { PaymentsService } from './payments.service'; +import { InitiatePaymentDto } from './dto/initiate-payment.dto'; +import { PaymentResponseDto } from './dto/payment-response.dto'; + +@ApiTags('payments') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('payments') +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post('initiate') + @ApiOperation({ + summary: 'Initiate a payment (INITIATED -> AWAITING_CONFIRMATION)', + description: + 'Idempotent via the required Idempotency-Key header: replaying the ' + + 'same key returns the original Payment instead of creating a duplicate.', + }) + @ApiResponse({ status: 201, type: PaymentResponseDto }) + async initiate( + @CurrentUser() currentUser: RequestUser, + @Headers('idempotency-key') idempotencyKey: string, + @Body() dto: InitiatePaymentDto, + ): Promise { + const payment = await this.paymentsService.initiate( + currentUser.id, + idempotencyKey, + dto, + ); + return PaymentResponseDto.fromEntity(payment); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a payment by id (owner or admin only)' }) + @ApiResponse({ status: 200, type: PaymentResponseDto }) + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() currentUser: RequestUser, + ): Promise { + const payment = await this.paymentsService.findOne(id, currentUser); + return PaymentResponseDto.fromEntity(payment); + } + + @Get() + @ApiOperation({ summary: 'List payments (own for users, all for admins)' }) + @ApiResponse({ status: 200, type: [PaymentResponseDto] }) + async findAll( + @CurrentUser() currentUser: RequestUser, + ): Promise { + const payments = await this.paymentsService.findAll(currentUser); + return payments.map((payment) => PaymentResponseDto.fromEntity(payment)); + } +} diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts new file mode 100644 index 00000000..41966a72 --- /dev/null +++ b/backend/src/payments/payments.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Payment } from './entities/payment.entity'; +import { PaymentsService } from './payments.service'; +import { PaymentsController } from './payments.controller'; +import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; + +@Module({ + imports: [TypeOrmModule.forFeature([Payment])], + controllers: [PaymentsController], + providers: [PaymentsService, SandboxRailAdapter], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts new file mode 100644 index 00000000..cd7824a2 --- /dev/null +++ b/backend/src/payments/payments.service.spec.ts @@ -0,0 +1,277 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { PaymentsService } from './payments.service'; +import { Payment } from './entities/payment.entity'; +import { PaymentRail } from './enums/payment-rail.enum'; +import { PaymentStatus } from './enums/payment-status.enum'; +import { InitiatePaymentDto } from './dto/initiate-payment.dto'; +import { UserRole } from '../auth/enums/user-role.enum'; + +type MockRepository = { + findOne: jest.Mock; + find: jest.Mock; + create: jest.Mock; + save: jest.Mock; +}; + +function makeRepository(): MockRepository { + return { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn((data) => ({ ...data })), + save: jest.fn(async (entity) => ({ + id: entity.id ?? 'generated-id', + ...entity, + })), + }; +} + +function makeDto( + overrides: Partial = {}, +): InitiatePaymentDto { + return { + bookingId: 'booking-1', + amount: 5000, + currency: 'usd', + rail: PaymentRail.FIAT, + ...overrides, + }; +} + +function uniqueViolation(constraint: string) { + return Object.assign( + new Error('duplicate key value violates unique constraint'), + { + code: '23505', + constraint, + }, + ); +} + +describe('PaymentsService', () => { + let repository: MockRepository; + let railAdapter: { initiate: jest.Mock }; + let config: { get: jest.Mock }; + let service: PaymentsService; + + beforeEach(() => { + repository = makeRepository(); + railAdapter = { + initiate: jest.fn().mockResolvedValue({ providerReference: 'ref-1' }), + }; + config = { get: jest.fn().mockReturnValue(30) }; + service = new PaymentsService( + repository as any, + railAdapter as any, + config as any, + ); + }); + + describe('initiate', () => { + it('rejects when the Idempotency-Key header is missing', async () => { + await expect(service.initiate('user-1', '', makeDto())).rejects.toThrow( + BadRequestException, + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('creates a new INITIATED payment then progresses it to AWAITING_CONFIRMATION', async () => { + repository.findOne.mockResolvedValueOnce(null); // no existing idempotency-key row + repository.findOne.mockResolvedValueOnce(null); // booking is free + + const result = await service.initiate('user-1', 'key-1', makeDto()); + + expect(result.status).toBe(PaymentStatus.AWAITING_CONFIRMATION); + expect(result.providerReference).toBe('ref-1'); + expect(railAdapter.initiate).toHaveBeenCalledTimes(1); + expect(repository.save).toHaveBeenCalledTimes(2); + }); + + it('replays the same Idempotency-Key and returns the original payment without creating a duplicate', async () => { + const existing = { + id: 'p-1', + userId: 'user-1', + bookingId: 'booking-1', + amount: 5000, + currency: 'USD', + rail: PaymentRail.FIAT, + status: PaymentStatus.AWAITING_CONFIRMATION, + } as unknown as Payment; + repository.findOne.mockResolvedValueOnce(existing); + + const result = await service.initiate('user-1', 'key-1', makeDto()); + + expect(result).toBe(existing); + expect(repository.save).not.toHaveBeenCalled(); + expect(railAdapter.initiate).not.toHaveBeenCalled(); + }); + + it('rejects when the same Idempotency-Key is replayed with a different payload', async () => { + const existing = { + id: 'p-1', + userId: 'user-1', + bookingId: 'booking-1', + amount: 9999, + currency: 'USD', + rail: PaymentRail.FIAT, + status: PaymentStatus.INITIATED, + } as unknown as Payment; + repository.findOne.mockResolvedValueOnce(existing); + + await expect( + service.initiate('user-1', 'key-1', makeDto()), + ).rejects.toThrow(ConflictException); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('rejects initiation when the booking already has a non-terminal payment', async () => { + repository.findOne.mockResolvedValueOnce(null); + repository.findOne.mockResolvedValueOnce({ + status: PaymentStatus.AWAITING_CONFIRMATION, + } as unknown as Payment); + + await expect( + service.initiate('user-1', 'key-1', makeDto()), + ).rejects.toThrow(ConflictException); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('rejects initiation when the booking already has a confirmed payment', async () => { + repository.findOne.mockResolvedValueOnce(null); + repository.findOne.mockResolvedValueOnce({ + status: PaymentStatus.CONFIRMED, + } as unknown as Payment); + + await expect( + service.initiate('user-1', 'key-1', makeDto()), + ).rejects.toThrow(ConflictException); + }); + + it('resolves an Idempotency-Key race by returning the row the concurrent request created', async () => { + const winner = { + id: 'p-winner', + userId: 'user-1', + bookingId: 'booking-1', + amount: 5000, + currency: 'USD', + rail: PaymentRail.FIAT, + status: PaymentStatus.INITIATED, + } as unknown as Payment; + + repository.findOne + .mockResolvedValueOnce(null) // pre-check: no row yet + .mockResolvedValueOnce(null) // booking free at pre-check time + .mockResolvedValueOnce(winner); // recovery lookup after losing the insert race + + repository.save.mockRejectedValueOnce( + uniqueViolation('uq_payments_user_id_idempotency_key'), + ); + + const result = await service.initiate('user-1', 'key-1', makeDto()); + + expect(result).toBe(winner); + expect(repository.save).toHaveBeenCalledTimes(1); + expect(railAdapter.initiate).not.toHaveBeenCalled(); + }); + + it('rejects a booking-level race with a conflict instead of creating a second row', async () => { + repository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + repository.save.mockRejectedValueOnce( + uniqueViolation('uq_payments_booking_id_non_terminal'), + ); + + await expect( + service.initiate('user-1', 'key-1', makeDto()), + ).rejects.toThrow(ConflictException); + expect(repository.save).toHaveBeenCalledTimes(1); + }); + + it('rethrows unrelated database errors', async () => { + repository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + repository.save.mockRejectedValueOnce(new Error('connection lost')); + + await expect( + service.initiate('user-1', 'key-1', makeDto()), + ).rejects.toThrow('connection lost'); + }); + }); + + describe('transitionStatus', () => { + it('allows a legal transition', () => { + const payment = { status: PaymentStatus.INITIATED } as unknown as Payment; + service.transitionStatus(payment, PaymentStatus.FAILED); + expect(payment.status).toBe(PaymentStatus.FAILED); + }); + + it('throws on an illegal transition and leaves status untouched', () => { + const payment = { status: PaymentStatus.INITIATED } as unknown as Payment; + expect(() => + service.transitionStatus(payment, PaymentStatus.CONFIRMED), + ).toThrow(); + expect(payment.status).toBe(PaymentStatus.INITIATED); + }); + }); + + describe('findOne', () => { + it('throws NotFoundException when the payment does not exist', async () => { + repository.findOne.mockResolvedValueOnce(null); + await expect( + service.findOne('missing', { id: 'user-1', role: UserRole.USER }), + ).rejects.toThrow(NotFoundException); + }); + + it('allows the owner to view their own payment', async () => { + repository.findOne.mockResolvedValueOnce({ + id: 'p-1', + userId: 'user-1', + } as unknown as Payment); + await expect( + service.findOne('p-1', { id: 'user-1', role: UserRole.USER }), + ).resolves.toMatchObject({ id: 'p-1' }); + }); + + it('allows an admin to view any payment', async () => { + repository.findOne.mockResolvedValueOnce({ + id: 'p-1', + userId: 'someone-else', + } as unknown as Payment); + await expect( + service.findOne('p-1', { id: 'admin-1', role: UserRole.ADMIN }), + ).resolves.toMatchObject({ id: 'p-1' }); + }); + + it('forbids a non-owner, non-admin from viewing the payment', async () => { + repository.findOne.mockResolvedValueOnce({ + id: 'p-1', + userId: 'someone-else', + } as unknown as Payment); + await expect( + service.findOne('p-1', { id: 'user-1', role: UserRole.USER }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('findAll', () => { + it('scopes the query to the current user for non-admins', async () => { + repository.find.mockResolvedValueOnce([]); + await service.findAll({ id: 'user-1', role: UserRole.USER }); + expect(repository.find).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + }); + + it('returns every payment for admins', async () => { + repository.find.mockResolvedValueOnce([]); + await service.findAll({ id: 'admin-1', role: UserRole.ADMIN }); + expect(repository.find).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts new file mode 100644 index 00000000..6734741b --- /dev/null +++ b/backend/src/payments/payments.service.ts @@ -0,0 +1,216 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { RequestUser } from '../auth/interfaces/authenticated-request.interface'; +import { UserRole } from '../auth/enums/user-role.enum'; +import { Payment } from './entities/payment.entity'; +import { InitiatePaymentDto } from './dto/initiate-payment.dto'; +import { + NON_TERMINAL_PAYMENT_STATUSES, + PaymentStatus, +} from './enums/payment-status.enum'; +import { assertValidTransition } from './payment-state-machine'; +import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; + +const USER_IDEMPOTENCY_KEY_CONSTRAINT = 'uq_payments_user_id_idempotency_key'; +const BOOKING_NON_TERMINAL_CONSTRAINT = 'uq_payments_booking_id_non_terminal'; +const POSTGRES_UNIQUE_VIOLATION = '23505'; + +const BLOCKING_STATUSES_FOR_NEW_PAYMENT = [ + ...NON_TERMINAL_PAYMENT_STATUSES, + PaymentStatus.CONFIRMED, +]; + +@Injectable() +export class PaymentsService { + constructor( + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + private readonly railAdapter: SandboxRailAdapter, + private readonly config: ConfigService, + ) {} + + async initiate( + userId: string, + idempotencyKey: string, + dto: InitiatePaymentDto, + ): Promise { + if (!idempotencyKey) { + throw new BadRequestException('Idempotency-Key header is required'); + } + + const existing = await this.findByIdempotencyKey(userId, idempotencyKey); + if (existing) { + return this.assertSamePayload(existing, dto); + } + + await this.assertBookingAvailable(dto.bookingId); + + const payment = this.buildInitiatedPayment(userId, idempotencyKey, dto); + + try { + const saved = await this.paymentRepository.save(payment); + return await this.progressToAwaitingConfirmation(saved); + } catch (error) { + return this.handleInsertConflict(error, userId, idempotencyKey); + } + } + + async findOne(id: string, currentUser: RequestUser): Promise { + const payment = await this.paymentRepository.findOne({ where: { id } }); + if (!payment) { + throw new NotFoundException('Payment not found'); + } + this.assertCanView(payment, currentUser); + return payment; + } + + async findAll(currentUser: RequestUser): Promise { + if (currentUser.role === UserRole.ADMIN) { + return this.paymentRepository.find(); + } + return this.paymentRepository.find({ where: { userId: currentUser.id } }); + } + + /** + * The only sanctioned way to change Payment#status. Every write path in + * this service must route through here so illegal transitions throw + * instead of silently corrupting state. + */ + transitionStatus(payment: Payment, next: PaymentStatus): Payment { + assertValidTransition(payment.status, next); + payment.status = next; + return payment; + } + + private async findByIdempotencyKey( + userId: string, + idempotencyKey: string, + ): Promise { + return this.paymentRepository.findOne({ + where: { userId, idempotencyKey }, + }); + } + + private assertSamePayload( + existing: Payment, + dto: InitiatePaymentDto, + ): Payment { + const samePayload = + existing.bookingId === dto.bookingId && + existing.amount === dto.amount && + existing.currency === dto.currency.toUpperCase() && + existing.rail === dto.rail; + + if (!samePayload) { + throw new ConflictException( + 'Idempotency-Key was already used with a different payload', + ); + } + return existing; + } + + private async assertBookingAvailable(bookingId: string): Promise { + const blocking = await this.paymentRepository.findOne({ + where: { + bookingId, + status: In(BLOCKING_STATUSES_FOR_NEW_PAYMENT), + }, + }); + if (blocking) { + throw new ConflictException( + blocking.status === PaymentStatus.CONFIRMED + ? 'This booking already has a confirmed payment' + : 'This booking already has a payment in progress', + ); + } + } + + private buildInitiatedPayment( + userId: string, + idempotencyKey: string, + dto: InitiatePaymentDto, + ): Payment { + const ttlMinutes = this.config.get( + 'PAYMENT_INITIATED_TTL_MINUTES', + 30, + ); + return this.paymentRepository.create({ + bookingId: dto.bookingId, + userId, + amount: dto.amount, + currency: dto.currency.toUpperCase(), + rail: dto.rail, + provider: dto.provider ?? null, + status: PaymentStatus.INITIATED, + idempotencyKey, + metadata: dto.metadata ?? null, + expiresAt: new Date(Date.now() + ttlMinutes * 60_000), + }); + } + + private async progressToAwaitingConfirmation( + payment: Payment, + ): Promise { + const result = await this.railAdapter.initiate(payment); + payment.providerReference = result.providerReference; + this.transitionStatus(payment, PaymentStatus.AWAITING_CONFIRMATION); + return this.paymentRepository.save(payment); + } + + /** + * Two initiate() calls can race past the pre-checks above and both reach + * the insert. Only one wins; the DB unique constraints are the actual + * source of truth. The loser recovers here instead of erroring. + */ + private async handleInsertConflict( + error: unknown, + userId: string, + idempotencyKey: string, + ): Promise { + if (!this.isUniqueViolation(error)) { + throw error; + } + + if (this.violatedConstraint(error) === USER_IDEMPOTENCY_KEY_CONSTRAINT) { + const winner = await this.findByIdempotencyKey(userId, idempotencyKey); + if (winner) { + return winner; + } + } + + if (this.violatedConstraint(error) === BOOKING_NON_TERMINAL_CONSTRAINT) { + throw new ConflictException( + 'This booking already has a payment in progress', + ); + } + + throw error; + } + + private isUniqueViolation(error: unknown): boolean { + const code = (error as any)?.code ?? (error as any)?.driverError?.code; + return code === POSTGRES_UNIQUE_VIOLATION; + } + + private violatedConstraint(error: unknown): string | undefined { + return ( + (error as any)?.constraint ?? (error as any)?.driverError?.constraint + ); + } + + private assertCanView(payment: Payment, currentUser: RequestUser): void { + const isOwner = payment.userId === currentUser.id; + const isAdmin = currentUser.role === UserRole.ADMIN; + if (!isOwner && !isAdmin) { + throw new ForbiddenException('You may not view this payment'); + } + } +}