fix: make payment event reduction monotonic
Quality / quality (ubuntu-latest) (push) Failing after 9m53s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-23 21:34:59 +05:30
parent 98e0813061
commit fe44bc2091
11 changed files with 458 additions and 16 deletions
+6
View File
@@ -131,6 +131,8 @@ export function sandboxGateway(options: SandboxOptions = {}): PaymentGateway {
intentRef: String(payload.intentRef),
type: String(payload.type ?? "payment.updated"),
status: payload.status,
occurredAt: Number(payload.occurredAt ?? Date.now()),
sequence: payload.sequence,
payload,
};
},
@@ -284,6 +286,7 @@ export function stripeGateway(config: StripeConfig): PaymentGateway<StripeConfig
intentRef: object.payment_intent ?? object.id,
type: row.type,
status: statusOf(object.status),
occurredAt: Number(row.created ?? timestamp) * 1000,
payload: row,
};
},
@@ -413,6 +416,8 @@ export function razorpayGateway(config: RazorpayConfig): PaymentGateway<Razorpay
intentRef: payment.order_id ?? payment.id,
type: row.event,
status: statusOf(payment.status),
occurredAt:
Number(payment.created_at ?? row.created_at ?? Math.floor(Date.now() / 1000)) * 1000,
payload: row,
};
},
@@ -540,6 +545,7 @@ export function paypalGateway(config: PayPalConfig): PaymentGateway<PayPalConfig
intentRef: row.resource?.supplementary_data?.related_ids?.order_id ?? row.resource?.id,
type: row.event_type,
status: statusOf(String(row.resource?.status ?? "").toLowerCase()),
occurredAt: Date.parse(row.create_time ?? "") || Date.now(),
payload: row,
};
},
+81 -1
View File
@@ -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 };
}
+31 -1
View File
@@ -39,7 +39,35 @@ const gatewayFault = <T>(reason: string): PaymentResult<T> => ({
reason,
});
export function derivePaymentStatus(events: readonly PaymentEvent[]): PaymentStatus {
return events.at(-1)?.status ?? "created";
const ordered = [...events].sort((left, right) => {
if (left.sequence !== undefined && right.sequence !== undefined) {
const numeric = Number(left.sequence) - Number(right.sequence);
if (Number.isFinite(numeric) && numeric !== 0) return numeric;
const lexical = String(left.sequence).localeCompare(String(right.sequence));
if (lexical) return lexical;
}
return (
(left.occurredAt ?? left.at) - (right.occurredAt ?? right.at) ||
left.id.localeCompare(right.id)
);
});
let status: PaymentStatus = "created";
for (const event of ordered) {
if (status === "refunded") continue;
if (
status === "partially_refunded" &&
!["partially_refunded", "refunded"].includes(event.status)
)
continue;
if (
status === "captured" &&
["created", "pending", "authorized", "failed", "cancelled"].includes(event.status)
)
continue;
if (status === "authorized" && ["created", "pending"].includes(event.status)) continue;
status = event.status;
}
return status;
}
export function defineGateway<T extends PaymentGateway>(gateway: T): T {
if (!gateway.id.trim()) throw new Error("WRN-PAYMENT-GATEWAY: id is required");
@@ -269,6 +297,8 @@ export function definePayment(options: PaymentOptions) {
type: event.type,
status: event.status,
at: Date.now(),
occurredAt: event.occurredAt,
sequence: event.sequence,
payload: event.payload,
});
}
+14 -3
View File
@@ -84,8 +84,17 @@ export function databasePaymentStore(database = "default"): PaymentStore {
async appendEvent(row) {
try {
const result = await db().exec(
"INSERT INTO wrn_payment_event (id,intent_id,type,status,created_at,payload_json) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING",
[row.id, row.intentId, row.type, row.status, row.at, JSON.stringify(row.payload ?? null)],
"INSERT INTO wrn_payment_event (id,intent_id,type,status,created_at,occurred_at,gateway_sequence,payload_json) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING",
[
row.id,
row.intentId,
row.type,
row.status,
row.at,
row.occurredAt ?? row.at,
row.sequence === undefined ? null : String(row.sequence),
JSON.stringify(row.payload ?? null),
],
);
return result.changes > 0;
} catch {
@@ -95,7 +104,7 @@ export function databasePaymentStore(database = "default"): PaymentStore {
async events(id) {
return (
await db().all<any>(
"SELECT * FROM wrn_payment_event WHERE intent_id = ? ORDER BY created_at ASC, id ASC",
"SELECT * FROM wrn_payment_event WHERE intent_id = ? ORDER BY occurred_at ASC, gateway_sequence ASC, id ASC",
[id],
)
).map((row) => ({
@@ -104,6 +113,8 @@ export function databasePaymentStore(database = "default"): PaymentStore {
type: String(row.type),
status: row.status,
at: Number(row.created_at),
occurredAt: Number(row.occurred_at ?? row.created_at),
sequence: row.gateway_sequence == null ? undefined : String(row.gateway_sequence),
payload: parse(row.payload_json, null),
}));
},
+14 -2
View File
@@ -19,12 +19,19 @@ DROP TABLE IF EXISTS wrn_payment_method;
DROP TABLE IF EXISTS wrn_payment_refund;
DROP TABLE IF EXISTS wrn_payment_event;
DROP TABLE IF EXISTS wrn_payment_intent;`;
const orderingMigration = `-- +up
ALTER TABLE wrn_payment_event ADD COLUMN occurred_at BIGINT;
ALTER TABLE wrn_payment_event ADD COLUMN gateway_sequence TEXT;
UPDATE wrn_payment_event SET occurred_at = created_at WHERE occurred_at IS NULL;
CREATE INDEX IF NOT EXISTS wrn_payment_event_gateway_order ON wrn_payment_event(intent_id, occurred_at, gateway_sequence, id);
-- +down
DROP INDEX IF EXISTS wrn_payment_event_gateway_order;`;
export function paymentPlugin() {
const key = "@wrnexus/payment:enabled";
return definePlugin({
name: "@wrnexus/payment",
version: "0.8.0",
version: "0.8.1",
componentDirs: [join(root, "components")],
routeEntries(context) {
return context.metadata.get(key)
@@ -45,7 +52,12 @@ export function paymentPlugin() {
throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production");
},
migrations(context: PluginContext) {
return context.metadata.get(key) ? [{ id: "wrnexus-payment-001", source: migration }] : [];
return context.metadata.get(key)
? [
{ id: "wrnexus-payment-001", source: migration },
{ id: "wrnexus-payment-002-event-order", source: orderingMigration },
]
: [];
},
documentation: [join(root, "README.md")],
});
+6
View File
@@ -73,6 +73,10 @@ export interface WebhookEvent {
intentRef: string;
type: string;
status: PaymentStatus;
/** Gateway-authored event time, never local arrival time. */
occurredAt: number;
/** Gateway-authored ordering token when the provider exposes one. */
sequence?: string | number;
payload: unknown;
}
@@ -111,6 +115,8 @@ export interface PaymentEvent {
type: string;
status: PaymentStatus;
at: number;
occurredAt?: number;
sequence?: string | number;
payload?: unknown;
}
export interface PaymentRefund {