import type { CreateIntentInput, GatewayIntent, PaymentGateway } from "./types.ts"; export interface GatewayContractReport { ok: boolean; issues: string[]; } /** Structural half of the shared adapter suite, usable by third-party adapters in CI. */ export function verifyGatewayContract(adapter: PaymentGateway): GatewayContractReport { const issues: string[] = []; const capability = (name: string, declared: boolean, present: boolean) => { if (declared !== present) issues.push( `${adapter.id}: capability '${name}' is ${declared ? "declared but not implemented" : "implemented but not declared"}`, ); }; capability( "authorizeThenCapture", adapter.capabilities.authorizeThenCapture, typeof adapter.capture === "function", ); capability( "storedMethods", adapter.capabilities.storedMethods, typeof adapter.listMethods === "function" && typeof adapter.attachMethod === "function" && typeof adapter.detachMethod === "function", ); capability( "customerVault", adapter.capabilities.customerVault, typeof adapter.createCustomer === "function", ); if (!adapter.capabilities.hostedFields && !adapter.capabilities.hostedCheckout) issues.push(`${adapter.id}: a hosted payment surface is required`); if (!adapter.capabilities.webhookSignature) issues.push(`${adapter.id}: signed webhooks are required`); return { ok: issues.length === 0, issues }; } export interface GatewayContractFixture { input: CreateIntentInput; webhook(intent: GatewayIntent): Request | Promise; } /** Executable lifecycle contract shared by built-in and third-party adapters. */ export async function exerciseGatewayContract( adapter: PaymentGateway, fixture: GatewayContractFixture, ): Promise { const report = verifyGatewayContract(adapter); const issues = [...report.issues]; const check = (condition: unknown, message: string) => { if (!condition) issues.push(`${adapter.id}: ${message}`); }; try { const intent = await adapter.createIntent(fixture.input); check(Boolean(intent.ref), "createIntent returned no reference"); check(intent.amount === fixture.input.amount, "createIntent changed the amount"); check(intent.currency === fixture.input.currency, "createIntent changed the currency"); const duplicate = await adapter.createIntent(fixture.input); check(duplicate.ref === intent.ref, "createIntent did not honor its idempotency key"); const fetched = await adapter.fetchIntent(intent.ref); check(fetched.ref === intent.ref, "fetchIntent returned a different reference"); const client = adapter.clientConfig(intent); const configuredSecrets = Object.entries((adapter.config ?? {}) as Record) .filter(([key, value]) => /secret|password/i.test(key) && typeof value === "string") .map(([, value]) => value); check( !configuredSecrets.some((secret) => Object.values(client).includes(secret as string)), "clientConfig exposed a configured secret", ); if (adapter.capabilities.authorizeThenCapture) { const captured = await adapter.capture!(intent.ref); check(Boolean(captured.ref), "capture returned no reference"); } const cancellable = await adapter.createIntent({ ...fixture.input, id: `${fixture.input.id}-cancel`, idempotencyKey: `${fixture.input.idempotencyKey}:cancel`, }); const cancelled = await adapter.cancelIntent(cancellable.ref, "contract"); check(cancelled.status === "cancelled", "cancelIntent did not return cancelled state"); const refund = await adapter.refund({ intentRef: intent.ref, amount: Math.max(1, Math.floor(intent.amount / 2)), currency: intent.currency, reason: "contract", idempotencyKey: `${fixture.input.idempotencyKey}:refund`, }); check(Boolean(refund.ref), "refund returned no reference"); if (adapter.capabilities.customerVault) { const customer = await adapter.createCustomer!({ subject: fixture.input.subject }); check(Boolean(customer), "createCustomer returned no reference"); } if (adapter.capabilities.storedMethods) { const customer = "contract-customer"; const method = await adapter.attachMethod!(customer, "contract-token-4242"); check(Boolean(method.ref), "attachMethod returned no reference"); check( Array.isArray(await adapter.listMethods!(customer)), "listMethods did not return an array", ); await adapter.detachMethod!(method.ref); } const event = await adapter.verifyWebhook( await fixture.webhook(intent), "contract-webhook-secret", ); check(Boolean(event.id), "verified webhook has no gateway event id"); check(Boolean(event.intentRef), "verified webhook has no intent reference"); check(Number.isFinite(event.occurredAt), "verified webhook has no gateway occurrence time"); } catch (error) { issues.push( `${adapter.id}: lifecycle threw ${error instanceof Error ? error.message : String(error)}`, ); } return { ok: issues.length === 0, issues }; }