feat(blueprint): define provider contracts and payment fulfillment policies
This commit is contained in:
parent
27a1172683
commit
dcda859fb8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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' }>;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue