fix: make payment event reduction monotonic
This commit is contained in:
1 parent
98e0813061
commit
fe44bc2091
11 files changed
+458
-16
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { PaymentGateway } from "./types.ts";
|
||||
import type { CreateIntentInput, GatewayIntent, PaymentGateway } from "./types.ts";
|
||||
|
||||
export interface GatewayContractReport {
|
||||
ok: boolean;
|
||||
@@ -36,3 +36,83 @@ export function verifyGatewayContract(adapter: PaymentGateway): GatewayContractR
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user