feat(blueprint): define provider contracts and payment fulfillment policies

This commit is contained in:
mihir 2026-09-11 16:59:55 +05:30
parent 27a1172683
commit dcda859fb8
8 changed files with 402 additions and 0 deletions

View File

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

View File

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

View File

@ -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<TrackingStatus, number> = {
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');
}

View File

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

View File

@ -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();
}

View File

@ -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();
});
});

View File

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

View File

@ -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');
});
});