Skip to content

Commit 0d756a0

Browse files
committed
feat(payments): add idempotency keys to payment create
Allow clients to safely retry POST /payments by supplying an idempotencyKey; replaying the same key returns the original payment instead of creating a duplicate (mirrors the existing Transaction idempotency pattern). What changed: - Prisma: add nullable, unique Payment.idempotencyKey + index, migration - CreatePaymentDto: optional idempotencyKey field - PaymentsService.create(): short-circuits to the existing payment when the key was already used, before any wallet/limit checks run; stores the key on new payments - MetricsService: new payment_idempotency_hits_total counter - Fixed pre-existing wiring bugs that left PaymentsModule unable to actually instantiate: PAYMENT_LIMITS_PORT was never bound to a provider in payments.module.ts, payments.service.ts called a non-existent this.limitsService instead of the injected this.paymentLimitsPort, and RequestContextService was provided by payments.module.ts/limits.module.ts but never injected into PaymentsService/LimitsService's constructors. These bugs made src/payments/payments.service.ts and src/limits/limits.service.ts fail to compile/run, which is on the direct path of this feature, so they're fixed here rather than worked around. Narrowed PaymentLimitsPort.checkLimits to Promise<void> (was Promise<void> | void) to match retryWithBackoff's generic constraint. - Updated payments.service.spec.ts / payments-limits.integration.spec.ts / limits.service.spec.ts, which referenced the same undeclared identifiers, to compile and pass - New tests: idempotent replay returns existing payment without a duplicate create, fresh key creates and stores it, no key skips the lookup entirely Closes #477
1 parent 0d72dd3 commit 0d756a0

14 files changed

Lines changed: 169 additions & 7 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- AlterTable
2+
ALTER TABLE "Payment" ADD COLUMN "idempotencyKey" TEXT;
3+
4+
-- CreateIndex
5+
CREATE UNIQUE INDEX "Payment_idempotencyKey_key" ON "Payment"("idempotencyKey");
6+
7+
-- CreateIndex
8+
CREATE INDEX "Payment_idempotencyKey_idx" ON "Payment"("idempotencyKey");

prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ model Payment {
4444
userId Int // Legacy field
4545
user LegacyUser @relation("UserPayments", fields: [userId], references: [id])
4646
47+
/// Client-supplied idempotency key to prevent duplicate submissions
48+
idempotencyKey String? @unique
49+
4750
createdAt DateTime @default(now())
4851
updatedAt DateTime @updatedAt
52+
53+
@@index([idempotencyKey])
4954
}
5055

5156
model UserLimit {

src/limits/limits.service.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
44
import { LimitsService, LimitExceededException } from './limits.service';
55
import { PrismaService } from '../prisma/prisma.service';
66
import { MetricsService } from '../metrics/metrics.service';
7+
import { RequestContextService } from '../common/request-context/request-context.service';
78
import { LimitUpdatedEvent } from './events/limit-updated.event';
89
import { LimitExceededEvent } from './events/limit-exceeded.event';
910

@@ -12,6 +13,7 @@ describe('LimitsService', () => {
1213
let prisma: any;
1314
let eventEmitter: any;
1415
let metrics: any;
16+
let requestContext: any;
1517

1618
const walletId = 'wallet-uuid-1';
1719

@@ -31,15 +33,15 @@ describe('LimitsService', () => {
3133
incrementLimitExceeded: jest.fn(),
3234
incrementLimitChecks: jest.fn(),
3335
};
34-
35-
cacheService = { get: jest.fn(), set: jest.fn(), delete: jest.fn() };
36+
requestContext = { getRequestId: jest.fn().mockReturnValue('req-1') };
3637

3738
const module: TestingModule = await Test.createTestingModule({
3839
providers: [
3940
LimitsService,
4041
{ provide: PrismaService, useValue: prisma },
4142
{ provide: EventEmitter2, useValue: eventEmitter },
4243
{ provide: MetricsService, useValue: metrics },
44+
{ provide: RequestContextService, useValue: requestContext },
4345
],
4446
}).compile();
4547

src/limits/limits.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LimitUpdatedEvent } from './events/limit-updated.event';
1414
import { LimitExceededEvent } from './events/limit-exceeded.event';
1515
import { retryWithBackoff } from '../common/utils/retry';
1616
import { MetricsService } from '../metrics/metrics.service';
17+
import { RequestContextService } from '../common/request-context/request-context.service';
1718

1819
export const LIMIT_ERROR_CODES = {
1920
PER_TX_LIMIT_EXCEEDED: 'LIMIT_PER_TX_EXCEEDED',
@@ -40,6 +41,7 @@ export class LimitsService {
4041
private readonly prisma: PrismaService,
4142
private readonly eventEmitter: EventEmitter2,
4243
private readonly metrics: MetricsService,
44+
private readonly requestContext: RequestContextService,
4345
) {}
4446

4547
async setLimits(walletId: string, daily: number, perTx: number) {

src/metrics/metrics.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import { MetricsController } from './metrics.controller';
3232
help: 'Total number of limit checks performed',
3333
labelNames: ['result'],
3434
}),
35+
makeCounterProvider({
36+
name: 'payment_idempotency_hits_total',
37+
help: 'Total number of payment create requests deduplicated via idempotency key',
38+
}),
3539
],
3640
exports: [MetricsService],
3741
})

src/metrics/metrics.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('MetricsService', () => {
77
let paymentProcessingHistogram: any;
88
let limitExceededCounter: any;
99
let limitChecksCounter: any;
10+
let paymentIdempotencyHitsCounter: any;
1011

1112
beforeEach(() => {
1213
const labeledCounter = { inc: jest.fn() };
@@ -15,13 +16,15 @@ describe('MetricsService', () => {
1516
paymentProcessingHistogram = { observe: jest.fn() };
1617
limitExceededCounter = { labels: jest.fn().mockReturnValue(labeledCounter) };
1718
limitChecksCounter = { labels: jest.fn().mockReturnValue(labeledCounter) };
19+
paymentIdempotencyHitsCounter = { inc: jest.fn() };
1820

1921
service = new MetricsService(
2022
paymentsCreatedCounter,
2123
paymentsFailedCounter,
2224
paymentProcessingHistogram,
2325
limitExceededCounter,
2426
limitChecksCounter,
27+
paymentIdempotencyHitsCounter,
2528
);
2629
});
2730

@@ -61,4 +64,12 @@ describe('MetricsService', () => {
6164
expect(limitChecksCounter.labels).toHaveBeenCalledWith('allowed');
6265
});
6366
});
67+
68+
describe('payment idempotency metrics', () => {
69+
it('should increment payment_idempotency_hits_total counter', () => {
70+
service.incrementPaymentIdempotencyHit();
71+
72+
expect(paymentIdempotencyHitsCounter.inc).toHaveBeenCalled();
73+
});
74+
});
6475
});

src/metrics/metrics.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export class MetricsService {
1515
private readonly limitExceededCounter: Counter,
1616
@InjectMetric('limit_checks_total')
1717
private readonly limitChecksCounter: Counter,
18+
@InjectMetric('payment_idempotency_hits_total')
19+
private readonly paymentIdempotencyHitsCounter: Counter,
1820
) {}
1921

2022
incrementPaymentsCreated(): void {
@@ -36,4 +38,8 @@ export class MetricsService {
3638
incrementLimitChecks(result: 'allowed' | 'denied'): void {
3739
this.limitChecksCounter.labels(result).inc();
3840
}
41+
42+
incrementPaymentIdempotencyHit(): void {
43+
this.paymentIdempotencyHitsCounter.inc();
44+
}
3945
}

src/payments/dto/create-payment.dto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,15 @@ export class CreatePaymentDto {
7272
@IsNotEmpty({ message: 'toId is required' })
7373
@Min(1, { message: 'toId must be greater than 0' })
7474
toId: number;
75+
76+
/** Client-supplied idempotency key. Replaying the same key returns the original payment instead of creating a duplicate. */
77+
@ApiProperty({
78+
example: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
79+
description:
80+
'Optional client-supplied idempotency key. Reusing the same key returns the original payment instead of creating a duplicate.',
81+
required: false,
82+
})
83+
@IsString({ message: 'idempotencyKey must be a string' })
84+
@IsOptional()
85+
idempotencyKey?: string;
7586
}

src/payments/payments-limits.integration.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { PrismaService } from '../prisma/prisma.service';
77
import { PaymentStatus } from './entities/payment.entity';
88
import { WalletStatus } from '../wallets/domain/wallet.model';
99
import { RequestContextService } from '../common/request-context/request-context.service';
10+
import { PAYMENT_LIMITS_PORT } from './ports/payment-limits.port';
11+
import { EventEmitter2 } from '@nestjs/event-emitter';
12+
import { MetricsService } from '../metrics/metrics.service';
1013

1114
describe('Payments and Limits Integration', () => {
1215
let paymentsService: PaymentsService;
@@ -46,6 +49,18 @@ describe('Payments and Limits Integration', () => {
4649
{ provide: PAYMENT_LIMITS_PORT, useExisting: LimitsService },
4750
{ provide: WalletsService, useValue: mockWalletsService },
4851
{ provide: RequestContextService, useValue: mockRequestContext },
52+
{ provide: EventEmitter2, useValue: { emit: jest.fn() } },
53+
{
54+
provide: MetricsService,
55+
useValue: {
56+
incrementPaymentsCreated: jest.fn(),
57+
incrementPaymentsFailed: jest.fn(),
58+
recordPaymentProcessingDuration: jest.fn(),
59+
incrementPaymentIdempotencyHit: jest.fn(),
60+
incrementLimitExceeded: jest.fn(),
61+
incrementLimitChecks: jest.fn(),
62+
},
63+
},
4964
],
5065
}).compile();
5166

src/payments/payments.controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class PaymentsController {
4141

4242
@ApiOperation({
4343
summary: 'Create a new payment',
44-
description: 'Create a new payment between wallets. Requires API key authentication. Rate limited to prevent abuse. Emits payment.created event on success.',
44+
description: 'Create a new payment between wallets. Requires API key authentication. Rate limited to prevent abuse. Emits payment.created event on success. Pass an idempotencyKey to safely retry without creating a duplicate payment — replaying the same key returns the original payment.',
4545
})
4646
@ApiBody({
4747
type: CreatePaymentDto,
@@ -55,6 +55,7 @@ export class PaymentsController {
5555
description: 'Payment for services',
5656
fromId: 1,
5757
toId: 2,
58+
idempotencyKey: 'a1b2c3d4-e5f6-4789-a012-3456789abcde',
5859
},
5960
},
6061
},

0 commit comments

Comments
 (0)