fix: make payment event reduction monotonic
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -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")],
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user