119 lines
5.0 KiB
TypeScript
119 lines
5.0 KiB
TypeScript
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<Request>;
|
|
}
|
|
|
|
/** Executable lifecycle contract shared by built-in and third-party adapters. */
|
|
export async function exerciseGatewayContract(
|
|
adapter: PaymentGateway,
|
|
fixture: GatewayContractFixture,
|
|
): Promise<GatewayContractReport> {
|
|
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<string, unknown>)
|
|
.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 };
|
|
}
|