-- 商户订单:一次下单一行CREATE TABLE merchant_orders ( id TEXT PRIMARY KEY, -- 商户订单号 customer_id TEXT NOT NULL, -- 商户系统里的用户 product_snapshot JSONB NOT NULL, -- 商品名、单价、数量(下单那一刻的快照) original_amount NUMERIC(18,4) NOT NULL, original_currency TEXT NOT NULL, refundable_paid_clink_order_id TEXT, -- 建立退款基准的成功 Order refundable_paid_amount NUMERIC(18,4), -- 权威的可退款现金金额 refundable_paid_currency TEXT, -- 这笔现金支付/退款使用的币种 clink_session_id TEXT, -- Clink 返回的 sessionId clink_session_status TEXT NOT NULL DEFAULT 'open' CHECK (clink_session_status IN ('open', 'completed', 'expired')), clink_session_last_event_created BIGINT, current_clink_order_id TEXT, -- 当前支付尝试,可能是推导值或 Session 确认值 current_attempt_confirmed BOOLEAN NOT NULL DEFAULT FALSE, -- 只有 Session 查询后才为 true payment_status TEXT NOT NULL, -- 由下面的支付尝试聚合而来 fulfillment_status TEXT NOT NULL, -- 发货状态,和支付状态分开存 refunded_amount NUMERIC(18,4) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT ck_refundable_payment_basis CHECK ( (refundable_paid_clink_order_id IS NULL AND refundable_paid_amount IS NULL AND refundable_paid_currency IS NULL) OR (refundable_paid_clink_order_id IS NOT NULL AND refundable_paid_amount IS NOT NULL AND refundable_paid_currency IS NOT NULL) ), CONSTRAINT ck_clink_session_event_created CHECK ( clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0 ));-- 支付尝试:一个 Clink Order 一行CREATE TABLE payment_attempts ( clink_order_id TEXT PRIMARY KEY, -- obj.orderId merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id), clink_session_id TEXT NOT NULL, status TEXT NOT NULL, -- pending / action_required / succeeded / failed attempt_created_at BIGINT, -- 只来自 order.created 的 event.created last_event_created BIGINT NOT NULL, -- 已处理状态事件的版本,只用于同一 Order 内比较 failure_code TEXT, failure_message TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 本地收到时间,仅审计用 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());-- 排序只认 attempt_created_at;为空的排在后面,代表顺序未知CREATE INDEX idx_attempts_order ON payment_attempts (merchant_order_id, attempt_created_at DESC NULLS LAST);-- 复合归属键:商户订单指针和退款不能引用另一商户订单的 Clink OrderALTER TABLE payment_attempts ADD CONSTRAINT uq_payment_attempt_owner UNIQUE (merchant_order_id, clink_order_id);ALTER TABLE merchant_orders ADD CONSTRAINT fk_current_attempt_owner FOREIGN KEY (id, current_clink_order_id) REFERENCES payment_attempts (merchant_order_id, clink_order_id), ADD CONSTRAINT fk_refundable_attempt_owner FOREIGN KEY (id, refundable_paid_clink_order_id) REFERENCES payment_attempts (merchant_order_id, clink_order_id);-- 成功退款:业务字段不可变,每个 Clink refundId 一行CREATE TABLE refunds ( refund_id TEXT PRIMARY KEY, merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id), clink_order_id TEXT NOT NULL REFERENCES payment_attempts(clink_order_id), amount NUMERIC(18,4) NOT NULL CHECK (amount > 0), currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), status TEXT NOT NULL CHECK (status = 'success'), first_event_id TEXT NOT NULL, -- 仅供审计;精确重放时不改写 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_refund_attempt_owner FOREIGN KEY (merchant_order_id, clink_order_id) REFERENCES payment_attempts (merchant_order_id, clink_order_id));CREATE INDEX idx_refunds_success ON refunds (merchant_order_id, status);-- 自动流程不可重试或覆盖的数据冲突,必须把证据持久化CREATE TABLE reconciliation_cases ( dedupe_key TEXT PRIMARY KEY, -- 稳定业务键,不是随机任务 ID event_id TEXT NOT NULL, merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id), clink_order_id TEXT, refund_id TEXT, reason TEXT NOT NULL, evidence JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
如果已有 merchant_orders 表,应先增加 Session 事件版本列,再部署处理器:
ALTER TABLE merchant_orders ADD COLUMN IF NOT EXISTS clink_session_last_event_created BIGINT;ALTER TABLE merchant_orders DROP CONSTRAINT IF EXISTS ck_clink_session_event_created;ALTER TABLE merchant_orders ADD CONSTRAINT ck_clink_session_event_created CHECK ( clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0 );
只有商户订单、Clink Order、Decimal 金额、规范化币种和状态全部相同,已有 refund_id 才算精确重放。绝不能使用 DO UPDATE:不可变字段不同是人工对账证据,不是覆盖旧行的理由。reconciliation_cases.insertIfAbsent() 同样采用 INSERT ... ON CONFLICT (dedupe_key) DO NOTHING。
CREATE TABLE webhook_events ( id TEXT PRIMARY KEY, -- event.id;约束名为 webhook_events_pkey type TEXT NOT NULL, payload TEXT NOT NULL, -- 精确的原始事件体,重处理时要用 status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'processed', 'failed')), retry_count INT NOT NULL DEFAULT 0, next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
新插入的未排序 Order 会让旧确认指针失效。后到的 order.created 只补该 Attempt 的创建时间,绝不回退已有状态。同一笔 Order 的两个状态事件 event.created 相同时同理,登记 reconcile_attempt,用 GET /order/{orderId} 的 status 收敛。Session 查询持续无结果时保持 pending,并在重试策略耗尽后升级处理。received_at、数据库自增 ID、Webhook 到达顺序、Clink Order ID 字典序都不能用来打破平局——它们和真实创建顺序无关。
事件顺序或字段不足以判定时,以查询结果为准。event.created 只能用来比较同一笔 Order 的事件先后。如果两个事件时间戳相同、或者拿不到可靠的版本字段,就按 obj.orderId 调 GET /order/{orderId} 查一次,用返回的 status 收敛这笔尝试——服务端查询是消除乱序歧义最稳妥的依据,前端事件和推送顺序都不是。这次查询可以放进 Outbox 任务里做,别在 Webhook 请求线程里同步调。
dedupeKey 上建唯一索引,重复登记同一件事时靠数据库挡掉,不会堆出一堆同样的任务。
refund.succeeded 不等于全额退款。 部分退款成功也发这个事件,一律置成 refunded 会让只退了一部分的订单被当成全退。也不要在收到事件的当下立刻回查 Order。 退款服务是先更新退款记录并推事件,之后才异步去更新 Order 状态的。这一刻查 GET /order/{orderId},很可能读到的还是退款前的旧状态。另外,这个事件说的是退款在 Clink 侧处理成功,不代表钱已经回到客户账上。原路退回银行卡通常还要几个工作日,客服话术别写成”已到账”。
import crypto from 'node:crypto';import Decimal from 'decimal.js';import { db, fulfill, applyRefundPolicy, reconciliationDesk, alerting, logger,} from './merchant-adapters.js';const LEASE_MS = 5 * 60 * 1000; // Lease length; leave room for the slowest external callconst HEARTBEAT_MS = Math.floor(LEASE_MS / 3);const MAX_ATTEMPT = 12;const MAX_BACKOFF_MS = 60 * 60 * 1000;function backoff(attempt, { now = Date.now(), random = Math.random } = {}) { const numericAttempt = Number(attempt); const normalizedAttempt = Number.isFinite(numericAttempt) ? Math.max(1, Math.floor(numericAttempt)) : 1; const exponential = Math.min( MAX_BACKOFF_MS, 1000 * (2 ** Math.min(normalizedAttempt - 1, 30)), ); const randomValue = Math.min(1, Math.max(0, Number(random()) || 0)); const jittered = Math.min( MAX_BACKOFF_MS, Math.round(exponential * (0.75 + randomValue * 0.5)), ); return new Date(Number(now) + jittered);}// Step 1: claim exactly one task atomically. A worker never lets a batch wait// behind the first slow task while every lease counts down from the same instant.async function claimTask(workerId) { // A new token on every claim — never reuse the old one const claimToken = crypto.randomUUID(); const { rows } = await db.query( `UPDATE outbox SET status = 'processing', claim_token = $1, worker_id = $2, lease_until = NOW() + ($3 || ' milliseconds')::interval WHERE id IN ( SELECT id FROM outbox WHERE (status = 'pending' AND next_retry_at <= NOW()) OR (status = 'processing' AND lease_until < NOW()) -- expired lease, reclaim ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED ) RETURNING id, task, event_id AS "eventId", order_id AS "orderId", clink_order_id AS "clinkOrderId", payload, dedupe_key AS "dedupeKey", status, attempt, next_retry_at AS "nextRetryAt", claim_token AS "claimToken", worker_id AS "workerId", lease_until AS "leaseUntil", last_error AS "lastError", created_at AS "createdAt", updated_at AS "updatedAt"`, [claimToken, workerId, LEASE_MS] ); return rows[0] ?? null;}function startLeaseHeartbeat(t) { let leaseLost = false; let stopped = false; let renewal = Promise.resolve(true); const controller = new AbortController(); function markLeaseLost(reason) { if (leaseLost) return; leaseLost = true; controller.abort(reason); logger.warn({ taskId: t.id }, 'lease lost, stop claimed task'); } function ensureOwned() { renewal = renewal.then(async () => { if (stopped || leaseLost) return false; try { const owned = await renewLease(t); if (!owned) markLeaseLost(new Error('outbox lease lost')); return owned; } catch (err) { // A renewal error means ownership is unknown. Stop rather than risk a // side effect under an expired token. markLeaseLost(err); return false; } }); return renewal; } const timer = setInterval(() => { void ensureOwned(); }, HEARTBEAT_MS); timer.unref?.(); return { signal: controller.signal, isLost: () => leaseLost, ensureOwned, async stop() { stopped = true; clearInterval(timer); await renewal; }, };}function throwIfAborted(signal) { if (signal?.aborted) throw signal.reason ?? new Error('operation aborted');}async function runOutboxWorker(workerId) { // The claim statement commits here; the row lock is released with it. const t = await claimTask(workerId); if (!t) return; // A task may have waited between claim and execution. Confirm ownership // before starting any API call or external side effect. if (!await renewLease(t)) { logger.warn({ taskId: t.id }, 'lease lost before task start'); return; } const lease = startLeaseHeartbeat(t); try { await executeClaimedTask(t, lease); if (lease.isLost() || !await lease.ensureOwned()) return; await finishTask(t, 'succeeded'); } catch (err) { if (lease.isLost() || !await lease.ensureOwned()) return; await failTask(t, err); } finally { await lease.stop(); }}// Step 2: external calls happen outside row-lock transactions. Every adapter// receives its stable business idempotency key and AbortSignal where supported.async function executeClaimedTask(t, lease, adapters = { fulfill, reconcileRefund, applyRefundPolicy, reprocessWebhook, reconcileAttempt, reconcileSession, reconciliationDesk, alerting, findReconciliationCase: (caseKey) => db.reconciliationCases.findByDedupeKey(caseKey),}) { if (t.task === 'fulfill') { await adapters.fulfill(t.orderId, { eventId: t.eventId, idempotencyKey: t.orderId, signal: lease.signal, }); } else if (t.task === 'refund_reconcile') { await adapters.reconcileRefund(t.orderId, t.clinkOrderId, t.eventId, t.payload, { signal: lease.signal, }); } else if (t.task === 'apply_refund_policy') { // Deduplicate on refundId, and only advance the policy's stored cumulative // amount so an older task cannot roll entitlement backward. await adapters.applyRefundPolicy({ merchantOrderId: t.orderId, refundId: t.payload.refundId, orderStatus: t.payload.orderStatus, refundedAmount: t.payload.refundedAmount, paymentCurrency: t.payload.paymentCurrency, idempotencyKey: t.payload.refundId, signal: lease.signal, }); } else if (t.task === 'manual_reconciliation') { const review = await adapters.findReconciliationCase(t.payload.caseKey); if (!review) throw new Error(`manual-review case ${t.payload.caseKey} missing`); const reviewContext = { caseKey: review.dedupeKey, eventId: review.eventId, merchantOrderId: review.merchantOrderId, clinkOrderId: review.clinkOrderId ?? null, refundId: review.refundId ?? null, reason: review.reason, evidence: review.evidence, }; await adapters.reconciliationDesk.openOrUpdate({ ...reviewContext, externalId: t.payload.caseKey, signal: lease.signal, }); // Desk and paging are separate external steps. Re-check the token so a // lost worker never starts the second step. if (!await lease.ensureOwned()) return; await adapters.alerting.openOrUpdate({ dedupeKey: t.payload.caseKey, title: 'payment data requires manual reconciliation', context: reviewContext, signal: lease.signal, }); } else if (t.task === 'reprocess_webhook') { await adapters.reprocessWebhook(t.eventId, { signal: lease.signal }); } else if (t.task === 'reconcile_attempt') { await adapters.reconcileAttempt(t.clinkOrderId, t.eventId, { signal: lease.signal }); } else if (t.task === 'reconcile_session') { await adapters.reconcileSession(t.orderId, t.eventId, { signal: lease.signal }); } else if (t.task === 'page_outbox_failure') { await adapters.alerting.openOrUpdate({ dedupeKey: t.dedupeKey, title: 'outbox task exhausted', context: { failedTaskId: t.payload.failedTaskId, failedTask: t.payload.failedTask, errorName: t.payload.errorName, errorCode: t.payload.errorCode, }, signal: lease.signal, }); } else { // Never mark an unimplemented task succeeded. throw new Error(`unknown outbox task: ${t.task}`); }}// Raw SQL is snake_case, but claimTask aliases every returned task property to// camelCase. Worker code must use that one shape exclusively.// Step 3: every status update must carry claim_token and status='processing'async function finishTask(t, status) { const { rowCount } = await db.query( `UPDATE outbox SET status = $1, claim_token = NULL, worker_id = NULL, lease_until = NULL WHERE id = $2 AND claim_token = $3 AND status = 'processing'`, [status, t.id, t.claimToken] ); if (rowCount === 0) { // The lease expired and another worker owns this task now. Let go logger.warn({ taskId: t.id }, 'lease lost, result discarded'); } return rowCount;}async function failTask(t, err) { const attempt = t.attempt + 1; // Paging failures retry forever (with a capped delay) and never recursively // create another page_outbox_failure task. const exhausted = t.task !== 'page_outbox_failure' && attempt >= MAX_ATTEMPT; const errorName = typeof err?.name === 'string' ? err.name.slice(0, 100) : 'Error'; const errorCode = ['string', 'number'].includes(typeof err?.code) ? String(err.code).slice(0, 100) : null; const safeLastError = errorCode ? `${errorName}:${errorCode}` : errorName; if (!exhausted) { const { rowCount } = await db.query( `UPDATE outbox SET status = 'pending', claim_token = NULL, worker_id = NULL, lease_until = NULL, attempt = $1, next_retry_at = $2, last_error = $3 WHERE id = $4 AND claim_token = $5 AND status = 'processing'`, [attempt, backoff(attempt), safeLastError, t.id, t.claimToken] ); if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded'); return rowCount; } // Marking the original task failed and creating its paging task is one // transaction. A crash after commit still leaves durable work to claim. const rowCount = await db.transaction(async (tx) => { const result = await tx.query( `UPDATE outbox SET status = 'failed', claim_token = NULL, worker_id = NULL, lease_until = NULL, attempt = $1, next_retry_at = $2, last_error = $3 WHERE id = $4 AND claim_token = $5 AND status = 'processing'`, [attempt, backoff(attempt), safeLastError, t.id, t.claimToken] ); if (result.rowCount === 0) return 0; await tx.outbox.insert({ eventId: t.eventId, task: 'page_outbox_failure', payload: { failedTaskId: t.id, failedTask: t.task, errorName, errorCode, }, dedupeKey: `outbox_failure_alert:${t.id}`, }); return result.rowCount; }); if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded'); return rowCount;}// Renewal carries the token too, or it would extend someone else's leaseasync function renewLease(t) { const { rowCount } = await db.query( `UPDATE outbox SET lease_until = NOW() + ($1 || ' milliseconds')::interval WHERE id = $2 AND claim_token = $3 AND status = 'processing'`, [LEASE_MS, t.id, t.claimToken] ); return rowCount === 1; // false means the lease is gone — stop work immediately}// Retry an event that was deferred until its dependency existedasync function reprocessWebhook(eventId, { signal } = {}) { throwIfAborted(signal); const outcome = await db.transaction(async (tx) => { const row = await tx.webhookEvents.findById(eventId); if (!row || row.status === 'processed') return 'done'; const outcome = await handleEvent(JSON.parse(row.payload), tx); if (outcome === 'done' || outcome === 'manual_review') { await tx.webhookEvents.update(eventId, { status: 'processed' }); } throwIfAborted(signal); // abort rolls the transaction back before commit return outcome; }); // Throw only after the transaction commits. Any reconciliation task queued // by handleEvent stays durable while this task goes through backoff. if (outcome === 'deferred') throw new Error('dependency still missing');}// Two status events on one Order shared a timestamp — converge from the APIasync function reconcileAttempt(clinkOrderId, eventId, { signal } = {}) { throwIfAborted(signal); // Capture the local version before the external query. const snapshot = await db.paymentAttempts.findByClinkOrderId(clinkOrderId); if (!snapshot) throw new Error(`attempt ${clinkOrderId} missing`); // Query first, then open the transaction. External calls never run under a lock. const order = await clinkGet(`/order/${clinkOrderId}`, { signal }); throwIfAborted(signal); const outcome = await db.transaction(async (tx) => { // Same lock, same order as the webhook handler. Re-read the attempt only // after taking this lock, because every local attempt write takes it first. const merchantOrder = await tx.merchantOrders.findByIdForUpdate(snapshot.merchantOrderId); if (!merchantOrder) throw new Error(`merchant order ${snapshot.merchantOrderId} missing`); const attempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId); if ( !attempt || merchantOrder.clinkSessionId !== snapshot.clinkSessionId || attempt.merchantOrderId !== snapshot.merchantOrderId || attempt.clinkSessionId !== snapshot.clinkSessionId || attempt.status !== snapshot.status || attempt.lastEventCreated !== snapshot.lastEventCreated || attempt.attemptCreatedAt !== snapshot.attemptCreatedAt ) { // Local state advanced while Clink was being queried. Roll back and let // the worker retry from a fresh snapshot; never apply this stale result. throw new Error(`attempt ${clinkOrderId} changed during reconciliation`); } if ( order.orderId !== clinkOrderId || order.sessionId !== attempt.clinkSessionId || order.merchantReferenceId !== merchantOrder.id ) { const manualOutcome = await recordManualReview(tx, { dedupeKey: `order_query_ownership_conflict:${merchantOrder.id}:${clinkOrderId}`, eventId, merchantOrderId: merchantOrder.id, clinkOrderId, reason: 'order_query_ownership_conflict', evidence: { requestedOrderId: clinkOrderId, returnedOrder: orderEvidence(order), localAttempt: attemptEvidence(attempt), storedBasis: { clinkOrderId: merchantOrder.refundablePaidClinkOrderId, amount: merchantOrder.refundablePaidAmount, currency: merchantOrder.refundablePaidCurrency, }, }, }); await tx.webhookEvents.update(eventId, { status: 'processed' }); return manualOutcome; } // An already-refunded order takes the refund path, never the // "paid -> queue fulfillment" one let aggregateOutcome = 'done'; if (REFUND_STATUS.has(order.status)) { await tx.paymentAttempts.update(clinkOrderId, { status: 'succeeded', failureCode: null, failureMessage: null, }); } else { const mapped = ORDER_STATUS_TO_ATTEMPT[order.status]; if (!mapped) { // Still pending, or an unknown value — not settled yet throw new Error(`order ${clinkOrderId} not settled yet: ${order.status}`); } await tx.paymentAttempts.update(clinkOrderId, { status: mapped, failureCode: mapped === 'failed' ? order.failureCode : null, failureMessage: mapped === 'failed' ? order.failureMessage : null, }); } if (order.status === 'success' || REFUND_STATUS.has(order.status)) { // GET /order exposes amountTotal and paymentCurrency for Hosted Checkout, // so this path can establish the same basis as order.succeeded. The // attempt update above remains durable if the basis is quarantined. const basisOutcome = await persistRefundablePaymentBasis( tx, merchantOrder, { clinkOrderId, amountTotal: order.amountTotal, paymentCurrency: order.paymentCurrency, }, { eventId }, ); if (basisOutcome === 'manual_review') { await tx.webhookEvents.update(eventId, { status: 'processed' }); return basisOutcome; } } if (REFUND_STATUS.has(order.status)) { await advanceRefundStatus(tx, merchantOrder, order.status); } else { aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId); } // Close the event only after both the attempt and merchant-order aggregate // have converged. A Session tie remains pending. if (aggregateOutcome === 'done' || aggregateOutcome === 'manual_review') { await tx.webhookEvents.update(eventId, { status: 'processed' }); } return aggregateOutcome; }); if (outcome === 'deferred') throw new Error('merchant order still needs Session reconciliation');}const ORDER_STATUS_TO_ATTEMPT = { success: 'succeeded', failed: 'failed', requires_action: 'action_required',};// Refund states are handled separately and never feed the payment aggregationconst REFUND_STATUS = new Set(['partial_refunded', 'refunded']);// Refund state is monotonic: refunded is never overwritten by partial_refundedconst REFUND_RANK = { partial_refunded: 1, refunded: 2 };async function advanceRefundStatus(tx, merchantOrder, orderStatus) { const curRank = REFUND_RANK[merchantOrder.paymentStatus] ?? 0; if (REFUND_RANK[orderStatus] <= curRank) return; // forward only await tx.merchantOrders.updateIfStatus( merchantOrder.id, merchantOrder.paymentStatus, { paymentStatus: orderStatus }, );}async function queueRefundPolicy(tx, merchantOrder, refund) { // Queue once per successful refund, even when the Order remains // partial_refunded. State rank and policy evaluation are separate concerns. await tx.outbox.insert({ eventId: refund.eventId, task: 'apply_refund_policy', orderId: merchantOrder.id, payload: { refundId: refund.refundId, orderStatus: refund.orderStatus, refundedAmount: refund.refundedAmount, paymentCurrency: refund.paymentCurrency, }, dedupeKey: `apply_refund_policy:${refund.refundId}`, });}// Stable comparison only. Lexical Order ID sorting here does not choose the// current attempt; it merely makes identical local snapshots hash the same way.function fingerprintAttempts(attempts) { return attempts .map((x) => JSON.stringify([ x.clinkOrderId, x.merchantOrderId, x.clinkSessionId, x.status, x.attemptCreatedAt ?? null, x.lastEventCreated, ])) .sort() .join('|');}// Two attempts share an attemptCreatedAt — ask the Session which one is currentasync function reconcileSession(merchantOrderId, eventId, { signal } = {}) { throwIfAborted(signal); const local = await db.merchantOrders.findById(merchantOrderId); if (!local) throw new Error(`merchant order ${merchantOrderId} missing`); const localAttempts = await db.paymentAttempts.listByMerchantOrder(merchantOrderId); const snapshot = { clinkSessionId: local.clinkSessionId, currentClinkOrderId: local.currentClinkOrderId, currentAttemptConfirmed: local.currentAttemptConfirmed, attemptsFingerprint: fingerprintAttempts(localAttempts), }; const session = await clinkGet(`/checkout/session/${snapshot.clinkSessionId}`, { signal }); throwIfAborted(signal); await db.transaction(async (tx) => { const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId); if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`); const lockedAttempts = await tx.paymentAttempts.listByMerchantOrder(merchantOrderId); if ( merchantOrder.clinkSessionId !== snapshot.clinkSessionId || merchantOrder.currentClinkOrderId !== snapshot.currentClinkOrderId || merchantOrder.currentAttemptConfirmed !== snapshot.currentAttemptConfirmed || fingerprintAttempts(lockedAttempts) !== snapshot.attemptsFingerprint ) { // Another Order event or reconciliation changed local truth after the // query began. Discard the stale Session answer and retry. throw new Error(`attempt set for ${merchantOrderId} changed during Session reconciliation`); } if ( session.sessionId !== snapshot.clinkSessionId || session.merchantReferenceId !== merchantOrder.id ) { const manualOutcome = await recordManualReview(tx, { dedupeKey: `session_response_conflict:${merchantOrderId}:${snapshot.clinkSessionId}`, eventId, merchantOrderId, clinkOrderId: session.orderId ?? null, reason: 'session_response_ownership_conflict', evidence: { querySnapshot: snapshot, returnedSession: sessionEvidence(session), }, }); await tx.webhookEvents.update(eventId, { status: 'processed' }); return manualOutcome; } if (!session.orderId) { // No Order yet, or the state is unsettled — back off and retry. // Never treat this as success. throw new Error(`session ${snapshot.clinkSessionId} has no current orderId yet`); } // Read by global Order ID inside the lock. Missing is retryable; an Order // already owned by another merchant/session is a durable manual review. const attempt = await tx.paymentAttempts.findByClinkOrderId(session.orderId); if (!attempt) { throw new Error(`session order ${session.orderId} not persisted for ${merchantOrderId}`); } if ( attempt.merchantOrderId !== merchantOrderId || attempt.clinkSessionId !== snapshot.clinkSessionId ) { const manualOutcome = await recordManualReview(tx, { dedupeKey: `session_order_conflict:${merchantOrderId}:${session.orderId}`, eventId, merchantOrderId, clinkOrderId: session.orderId, reason: 'session_order_ownership_conflict', evidence: { querySnapshot: snapshot, returnedSession: sessionEvidence(session), storedAttempt: attemptEvidence(attempt), storedBasis: { clinkOrderId: merchantOrder.refundablePaidClinkOrderId, amount: merchantOrder.refundablePaidAmount, currency: merchantOrder.refundablePaidCurrency, }, }, }); await tx.webhookEvents.update(eventId, { status: 'processed' }); return manualOutcome; } // Persist both the Session answer and its provenance. Only this confirmed // pointer may break an attemptCreatedAt tie. await tx.merchantOrders.update(merchantOrderId, { currentClinkOrderId: session.orderId, currentAttemptConfirmed: true, }); merchantOrder.currentClinkOrderId = session.orderId; merchantOrder.currentAttemptConfirmed = true; const aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId); if (aggregateOutcome === 'deferred') { throw new Error(`session ${snapshot.clinkSessionId} did not resolve the current attempt`); } await tx.webhookEvents.update(eventId, { status: 'processed' }); return aggregateOutcome; });}async function reconcileRefund( merchantOrderId, clinkOrderId, eventId, refund, { signal } = {},) { throwIfAborted(signal); if (!refund?.refundId) { throw new Error('refund reconciliation payload is incomplete'); } // The query runs outside the transaction const order = await clinkGet(`/order/${clinkOrderId}`, { signal }); throwIfAborted(signal); // Only once a result is in hand: open the transaction and take the same // lock, in the same order, as every other path await db.transaction(async (tx) => { const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId); if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`); const reviewContext = { eventId, merchantOrderId, clinkOrderId, refundId: refund.refundId }; const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId); if ( !storedAttempt || storedAttempt.merchantOrderId !== merchantOrder.id || storedAttempt.clinkSessionId !== merchantOrder.clinkSessionId || order.orderId !== clinkOrderId || order.sessionId !== merchantOrder.clinkSessionId || order.merchantReferenceId !== merchantOrder.id ) { const storedRefund = await tx.refunds.findByRefundId(refund.refundId); return recordRefundReview(tx, reviewContext, 'refund_order_query_ownership_conflict', { requestedOrderId: clinkOrderId, returnedOrder: orderEvidence(order), storedAttempt: attemptEvidence(storedAttempt), storedRefund: refundEvidence(storedRefund), storedBasis: { clinkOrderId: merchantOrder.refundablePaidClinkOrderId, amount: merchantOrder.refundablePaidAmount, currency: merchantOrder.refundablePaidCurrency, }, }); } // Ownership is valid but the refund state has not settled yet. Back off // instead of reading a pre-refund status as a completed reconciliation. if (!REFUND_STATUS.has(order.status)) { throw new Error('order status not settled yet'); } const basisOutcome = await persistRefundablePaymentBasis( tx, merchantOrder, { clinkOrderId, amountTotal: order.amountTotal, paymentCurrency: order.paymentCurrency, }, { eventId, refundId: refund.refundId, dedupeKey: `refund_conflict:${refund.refundId}`, }, ); if (basisOutcome === 'manual_review') return basisOutcome; if ( merchantOrder.refundablePaidClinkOrderId == null || merchantOrder.refundablePaidAmount == null || merchantOrder.refundablePaidCurrency == null ) { return recordRefundReview(tx, reviewContext, 'refund_payment_basis_missing', { storedBasis: { clinkOrderId: merchantOrder.refundablePaidClinkOrderId, amount: merchantOrder.refundablePaidAmount, currency: merchantOrder.refundablePaidCurrency, }, }); } if (merchantOrder.refundablePaidClinkOrderId !== clinkOrderId) { return recordRefundReview(tx, reviewContext, 'refund_reconciliation_order_conflict', { storedOrderId: merchantOrder.refundablePaidClinkOrderId, incomingOrderId: clinkOrderId, }); } // Compute the minimum state the Order API must have reached from the // latest locked totals. Decimal avoids rounding a full refund down into a // partial one. A lagging API result must retry; there is no later // order.refunded event that can repair a premature success. const refundedAmount = tryPositiveDecimal(merchantOrder.refundedAmount); const refundablePaidAmount = tryPositiveDecimal(merchantOrder.refundablePaidAmount); const paymentCurrency = tryNormalizeCurrency(merchantOrder.refundablePaidCurrency); if (!refundedAmount || !refundablePaidAmount || !paymentCurrency) { return recordRefundReview(tx, reviewContext, 'invalid_locked_refund_totals', { refundedAmount: merchantOrder.refundedAmount, refundablePaidAmount: merchantOrder.refundablePaidAmount, paymentCurrency: merchantOrder.refundablePaidCurrency, }); } if (refundedAmount.greaterThan(refundablePaidAmount)) { return recordRefundReview(tx, reviewContext, 'refund_total_exceeds_payment_basis', { refundedAmount: refundedAmount.toString(), refundablePaidAmount: refundablePaidAmount.toString(), }); } const expectedStatus = refundedAmount.greaterThanOrEqualTo(refundablePaidAmount) ? 'refunded' : 'partial_refunded'; if (REFUND_RANK[order.status] < REFUND_RANK[expectedStatus]) { throw new Error('order refund status has not caught up yet'); } // Status advances only when its rank increases, but every refundId gets a // policy task so repeated partial refunds are never collapsed together. await advanceRefundStatus(tx, merchantOrder, order.status); await queueRefundPolicy(tx, merchantOrder, { eventId, refundId: refund.refundId, orderStatus: order.status, // Use the latest value read under the lock, never the task's old snapshot. refundedAmount: refundedAmount.toString(), paymentCurrency, }); });}