63 lines
2.2 KiB
TypeScript
63 lines
2.2 KiB
TypeScript
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import { z } from 'zod';
|
|
import type {
|
|
GatewayPort,
|
|
PaymentRequest,
|
|
RefundRequest,
|
|
} from '../../src/payments/gateway.contract';
|
|
import { commandHash } from '../../src/inventory/inventory.policy';
|
|
const captureSchema = z
|
|
.object({
|
|
eventId: z.uuid(),
|
|
paymentId: z.uuid(),
|
|
orderId: z.uuid(),
|
|
amountMinor: z.string().regex(/^[1-9]\d{0,15}$/),
|
|
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']),
|
|
})
|
|
.strict();
|
|
// Deliberately confined to tests: this signature format is not a real provider protocol.
|
|
export class FakeGateway implements GatewayPort {
|
|
private readonly payments = new Map<
|
|
string,
|
|
{ hash: string; paymentId: string }
|
|
>();
|
|
private readonly refunds = new Map<
|
|
string,
|
|
{ hash: string; refundId: string }
|
|
>();
|
|
constructor(private readonly secret: string) {}
|
|
async createPayment(input: PaymentRequest) {
|
|
const hash = commandHash(input.orderId, input.amountMinor, input.currency);
|
|
const prior = this.payments.get(input.idempotencyKey);
|
|
if (prior && prior.hash !== hash)
|
|
throw new Error('Gateway idempotency conflict');
|
|
const result = prior ?? { hash, paymentId: randomUUID() };
|
|
this.payments.set(input.idempotencyKey, result);
|
|
return { paymentId: result.paymentId };
|
|
}
|
|
sign(raw: Buffer) {
|
|
return createHmac('sha256', this.secret).update(raw).digest('hex');
|
|
}
|
|
verifyCapture(raw: Buffer, signature: string) {
|
|
if (
|
|
raw.length > 32768 ||
|
|
!/^[a-f0-9]{64}$/.test(signature) ||
|
|
!timingSafeEqual(
|
|
Buffer.from(signature, 'hex'),
|
|
Buffer.from(this.sign(raw), 'hex'),
|
|
)
|
|
)
|
|
throw new Error('Invalid gateway signature');
|
|
return captureSchema.parse(JSON.parse(raw.toString('utf8')));
|
|
}
|
|
async requestRefund(input: RefundRequest) {
|
|
const hash = commandHash(input.paymentId, input.amountMinor);
|
|
const prior = this.refunds.get(input.idempotencyKey);
|
|
if (prior && prior.hash !== hash)
|
|
throw new Error('Refund idempotency conflict');
|
|
const result = prior ?? { hash, refundId: randomUUID() };
|
|
this.refunds.set(input.idempotencyKey, result);
|
|
return { refundId: result.refundId, status: 'PENDING' as const };
|
|
}
|
|
}
|