From dcda859fb8452aca74bcddf2eba72a95e7fc7a61 Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 16:59:55 +0530 Subject: [PATCH] feat(blueprint): define provider contracts and payment fulfillment policies --- src/common/errors/error-catalog.ts | 2 + src/common/errors/operations-errors.ts | 62 ++++++++++++++++ src/fulfillment/fulfillment.policy.ts | 63 ++++++++++++++++ src/payments/gateway.contract.ts | 26 +++++++ src/payments/payment.policy.ts | 44 ++++++++++++ test/fulfillment-blueprint.spec.ts | 44 ++++++++++++ test/helpers/fake-gateway.ts | 62 ++++++++++++++++ test/payment-blueprint.spec.ts | 99 ++++++++++++++++++++++++++ 8 files changed, 402 insertions(+) create mode 100644 src/common/errors/operations-errors.ts create mode 100644 src/fulfillment/fulfillment.policy.ts create mode 100644 src/payments/gateway.contract.ts create mode 100644 src/payments/payment.policy.ts create mode 100644 test/fulfillment-blueprint.spec.ts create mode 100644 test/helpers/fake-gateway.ts create mode 100644 test/payment-blueprint.spec.ts diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index 8bcdbcd..25360b4 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -1,9 +1,11 @@ import { CHECKOUT_ERRORS } from './checkout-errors'; +import { OPERATIONS_ERRORS } from './operations-errors'; import { PLATFORM_ERRORS } from './platform-errors'; import { COMMERCE_ERRORS } from './commerce-errors'; export const ERRORS = { ...PLATFORM_ERRORS, ...COMMERCE_ERRORS, ...CHECKOUT_ERRORS, + ...OPERATIONS_ERRORS, } as const; export type ErrorCode = keyof typeof ERRORS; diff --git a/src/common/errors/operations-errors.ts b/src/common/errors/operations-errors.ts new file mode 100644 index 0000000..9b4dbd3 --- /dev/null +++ b/src/common/errors/operations-errors.ts @@ -0,0 +1,62 @@ +export const OPERATIONS_ERRORS = { + PAYMENT_REFERENCE_MISMATCH: [ + 409, + 'Payment references do not match the order', + 'Payment reference verification failed', + ], + PAYMENT_CURRENCY_MISMATCH: [ + 409, + 'Payment currency does not match', + 'Captured payment currency mismatch', + ], + PAYMENT_AMOUNT_MISMATCH: [ + 409, + 'Payment amount does not match', + 'Captured payment amount mismatch', + ], + REFUND_AMOUNT_INVALID: [ + 409, + 'Refund exceeds the available captured amount', + 'Refund amount or aggregate bound rejected', + ], + SHIPMENT_PAYMENT_REQUIRED: [ + 409, + 'Payment is required before shipping', + 'Unpaid shipment attempt rejected', + ], + SHIPMENT_QUANTITY_INVALID: [ + 409, + 'Shipment quantity exceeds remaining items', + 'Shipment quantity bound rejected', + ], + RETURN_QUANTITY_INVALID: [ + 409, + 'Return quantity exceeds eligible delivered items', + 'Return quantity bound rejected', + ], + RETURN_WINDOW_CLOSED: [ + 409, + 'Return request is outside the configured window', + 'Return timing rule rejected request', + ], + PRICING_POLICY_NOT_FOUND: [ + 404, + 'Pricing policy not found', + 'Scoped pricing policy lookup failed', + ], + EVENT_NOT_FOUND: [ + 404, + 'Commerce event not found', + 'Scoped commerce event lookup failed', + ], + EVENT_NOT_RETRYABLE: [ + 409, + 'This event cannot be retried', + 'Delivered or actively leased event retry rejected', + ], + DELIVERY_UNAVAILABLE: [ + 503, + 'Notification delivery is not configured', + 'No commerce notification adapter configured', + ], +} as const; diff --git a/src/fulfillment/fulfillment.policy.ts b/src/fulfillment/fulfillment.policy.ts new file mode 100644 index 0000000..fafba1c --- /dev/null +++ b/src/fulfillment/fulfillment.policy.ts @@ -0,0 +1,63 @@ +import { AppError } from '../common/errors/app-error'; + +export function assertShipment( + paid: boolean, + ordered: number, + shipped: number, + requested: number, +) { + if (!paid) throw new AppError('SHIPMENT_PAYMENT_REQUIRED'); + if ( + ![ordered, shipped, requested].every(Number.isSafeInteger) || + ordered < 1 || + shipped < 0 || + requested < 1 || + shipped + requested > ordered + ) + throw new AppError('SHIPMENT_QUANTITY_INVALID'); +} +export type TrackingStatus = 'PLANNED' | 'SHIPPED' | 'DELIVERED'; +const rank: Record = { + PLANNED: 0, + SHIPPED: 1, + DELIVERED: 2, +}; +export function advanceTracking( + current: TrackingStatus, + incoming: TrackingStatus, +): TrackingStatus { + return rank[incoming] > rank[current] ? incoming : current; +} +export function assertReturn(input: { + delivered: number; + returned: number; + pending: number; + requested: number; + deliveredAt: Date; + now: Date; + windowDays: number; +}) { + const quantities = [ + input.delivered, + input.returned, + input.pending, + input.requested, + ]; + if ( + !quantities.every(Number.isSafeInteger) || + quantities.some((value) => value < 0) || + input.requested < 1 || + input.returned + input.pending + input.requested > input.delivered + ) + throw new AppError('RETURN_QUANTITY_INVALID'); + if ( + !Number.isSafeInteger(input.windowDays) || + input.windowDays < 0 || + !Number.isFinite(input.deliveredAt.getTime()) || + !Number.isFinite(input.now.getTime()) || + input.now < input.deliveredAt || + input.now.getTime() - input.deliveredAt.getTime() > + input.windowDays * 86400000 + ) + throw new AppError('RETURN_WINDOW_CLOSED'); +} diff --git a/src/payments/gateway.contract.ts b/src/payments/gateway.contract.ts new file mode 100644 index 0000000..7c9f9ac --- /dev/null +++ b/src/payments/gateway.contract.ts @@ -0,0 +1,26 @@ +// Provider-neutral contract only. No production implementation is registered. +export interface PaymentRequest { + orderId: string; + amountMinor: string; + currency: string; + idempotencyKey: string; +} +export interface CapturedPayment { + eventId: string; + paymentId: string; + orderId: string; + amountMinor: string; + currency: string; +} +export interface RefundRequest { + paymentId: string; + amountMinor: string; + idempotencyKey: string; +} +export interface GatewayPort { + createPayment(input: PaymentRequest): Promise<{ paymentId: string }>; + verifyCapture(rawBody: Buffer, signature: string): CapturedPayment; + requestRefund( + input: RefundRequest, + ): Promise<{ refundId: string; status: 'PENDING' }>; +} diff --git a/src/payments/payment.policy.ts b/src/payments/payment.policy.ts new file mode 100644 index 0000000..4d83111 --- /dev/null +++ b/src/payments/payment.policy.ts @@ -0,0 +1,44 @@ +import { AppError } from '../common/errors/app-error'; +import type { CapturedPayment } from './gateway.contract'; +export interface ExpectedPayment { + orderId: string; + paymentId: string; + amountMinor: string; + currency: string; + expiresAt: Date; + cancelled: boolean; + alreadyCaptured: boolean; +} +export function captureDecision( + expected: ExpectedPayment, + event: CapturedPayment, + now: Date, +) { + if ( + event.orderId !== expected.orderId || + event.paymentId !== expected.paymentId + ) + throw new AppError('PAYMENT_REFERENCE_MISMATCH'); + if (event.currency !== expected.currency) + throw new AppError('PAYMENT_CURRENCY_MISMATCH'); + if (event.amountMinor !== expected.amountMinor) + throw new AppError('PAYMENT_AMOUNT_MISMATCH'); + if (expected.alreadyCaptured) return 'DUPLICATE' as const; + if (expected.cancelled || expected.expiresAt <= now) + return 'REVIEW_AND_REFUND' as const; + return 'COMMIT_RESERVED_STOCK' as const; +} +export function refundAmount( + captured: bigint, + completed: bigint, + pending: bigint, + requested: bigint, +) { + if ( + [captured, completed, pending].some((value) => value < 0n) || + requested <= 0n || + completed + pending + requested > captured + ) + throw new AppError('REFUND_AMOUNT_INVALID'); + return requested.toString(); +} diff --git a/test/fulfillment-blueprint.spec.ts b/test/fulfillment-blueprint.spec.ts new file mode 100644 index 0000000..550d635 --- /dev/null +++ b/test/fulfillment-blueprint.spec.ts @@ -0,0 +1,44 @@ +import { + assertShipment, + assertReturn, + advanceTracking, +} from '../src/fulfillment/fulfillment.policy'; +describe('shipping and return blueprint policies', () => { + it('requires payment and bounds partial shipments', () => { + expect(() => assertShipment(true, 5, 2, 3)).not.toThrow(); + expect(() => assertShipment(false, 5, 0, 1)).toThrow(); + for (const quantity of [0, -1, 0.5, 4]) + expect(() => assertShipment(true, 5, 2, quantity)).toThrow(); + expect(() => assertShipment(true, 0, 0, 1)).toThrow(); + expect(() => assertShipment(true, 5, -1, 1)).toThrow(); + }); + it('does not regress tracking on duplicated or out-of-order events', () => { + expect(advanceTracking('PLANNED', 'SHIPPED')).toBe('SHIPPED'); + expect(advanceTracking('SHIPPED', 'DELIVERED')).toBe('DELIVERED'); + expect(advanceTracking('DELIVERED', 'SHIPPED')).toBe('DELIVERED'); + expect(advanceTracking('DELIVERED', 'DELIVERED')).toBe('DELIVERED'); + }); + it('counts existing and pending returns and enforces the configured window', () => { + const input = { + delivered: 5, + returned: 1, + pending: 1, + requested: 3, + deliveredAt: new Date(0), + now: new Date(86400000), + windowDays: 1, + }; + expect(() => assertReturn(input)).not.toThrow(); + for (const change of [ + { requested: 4 }, + { requested: 0 }, + { requested: 0.5 }, + { delivered: -1 }, + { now: new Date(86400001) }, + { now: new Date(-1) }, + { windowDays: -1 }, + { deliveredAt: new Date('invalid') }, + ]) + expect(() => assertReturn({ ...input, ...change })).toThrow(); + }); +}); diff --git a/test/helpers/fake-gateway.ts b/test/helpers/fake-gateway.ts new file mode 100644 index 0000000..51d7caf --- /dev/null +++ b/test/helpers/fake-gateway.ts @@ -0,0 +1,62 @@ +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 }; + } +} diff --git a/test/payment-blueprint.spec.ts b/test/payment-blueprint.spec.ts new file mode 100644 index 0000000..19c6e92 --- /dev/null +++ b/test/payment-blueprint.spec.ts @@ -0,0 +1,99 @@ +import { randomUUID } from 'node:crypto'; +import { FakeGateway } from './helpers/fake-gateway'; +import { captureDecision, refundAmount } from '../src/payments/payment.policy'; + +describe('provider-neutral payment blueprint', () => { + const gateway = new FakeGateway('test-only-secret'); + it('replays payment creation and rejects changed retry payloads', async () => { + const input = { + orderId: randomUUID(), + amountMinor: '12500', + currency: 'INR', + idempotencyKey: randomUUID(), + }; + const first = await gateway.createPayment(input); + expect(await gateway.createPayment(input)).toEqual(first); + await expect( + gateway.createPayment({ ...input, amountMinor: '1' }), + ).rejects.toThrow('idempotency'); + }); + it('verifies exact raw bytes and rejects malformed, oversized and forged callbacks', () => { + const event = { + eventId: randomUUID(), + paymentId: randomUUID(), + orderId: randomUUID(), + amountMinor: '12500', + currency: 'INR', + }; + const raw = Buffer.from(JSON.stringify(event)); + expect(gateway.verifyCapture(raw, gateway.sign(raw))).toEqual(event); + for (const signature of ['', 'x'.repeat(64), '0'.repeat(64)]) + expect(() => gateway.verifyCapture(raw, signature)).toThrow('signature'); + expect(() => + gateway.verifyCapture( + Buffer.concat([raw, Buffer.from(' ')]), + gateway.sign(raw), + ), + ).toThrow('signature'); + const oversized = Buffer.alloc(32769); + expect(() => + gateway.verifyCapture(oversized, gateway.sign(oversized)), + ).toThrow('signature'); + const malformed = Buffer.from('{}'); + expect(() => + gateway.verifyCapture(malformed, gateway.sign(malformed)), + ).toThrow(); + }); + it('requires matching capture references, amount and currency before fulfillment', () => { + const now = new Date(); + const event = { + eventId: randomUUID(), + paymentId: randomUUID(), + orderId: randomUUID(), + amountMinor: '12500', + currency: 'INR', + }; + const expected = { + ...event, + expiresAt: new Date(now.getTime() + 1000), + cancelled: false, + alreadyCaptured: false, + }; + expect(captureDecision(expected, event, now)).toBe('COMMIT_RESERVED_STOCK'); + expect( + captureDecision({ ...expected, alreadyCaptured: true }, event, now), + ).toBe('DUPLICATE'); + expect(captureDecision({ ...expected, cancelled: true }, event, now)).toBe( + 'REVIEW_AND_REFUND', + ); + expect(captureDecision({ ...expected, expiresAt: now }, event, now)).toBe( + 'REVIEW_AND_REFUND', + ); + for (const change of [ + { paymentId: randomUUID() }, + { orderId: randomUUID() }, + { amountMinor: '1' }, + { currency: 'USD' }, + ]) + expect(() => + captureDecision(expected, { ...event, ...change }, now), + ).toThrow(); + }); + it('counts pending refunds against captured money and makes refund retries idempotent', async () => { + expect(refundAmount(1000n, 200n, 300n, 500n)).toBe('500'); + for (const request of [0n, -1n, 501n]) + expect(() => refundAmount(1000n, 200n, 300n, request)).toThrow(); + expect(() => refundAmount(1000n, -1n, 0n, 1n)).toThrow(); + const request = { + paymentId: randomUUID(), + amountMinor: '500', + idempotencyKey: randomUUID(), + }; + expect(await gateway.requestRefund(request)).toEqual( + await gateway.requestRefund(request), + ); + await expect( + gateway.requestRefund({ ...request, amountMinor: '501' }), + ).rejects.toThrow('idempotency'); + }); +});