@paykernel/reconciliation
v0.1.1
Published
Portable reconciliation primitives for @paykernel/core: safe provider lookup, machine-readable drift, decision-only policy, store-backed scheduling, batch reconcile (no queue required).
Maintainers
Readme
@paykernel/reconciliation
Portable reconciliation primitives for @paykernel/core: safe ordered provider lookup, machine-readable drift, decision-only policy helpers, store-backed durable scheduling (no mandatory queue), and bounded-concurrency batch reconcile.
Portable. No Node-only imports. No Redis/queue product required. Runtime: Bun / Node ≥ 18 / Deno / Workers (Web APIs). Depends only on
@paykernel/core.
Install
bun add @paykernel/reconciliation
# peer / workspace: @paykernel/coreQuickstart
1. Safe check of an indeterminate payment
Inject a ProviderLookupPort (and optionally a durable or test store for scheduling).
import {
createPaymentReconciler,
decideReconciliationPolicy,
type ProviderLookupPort,
type ReconciliationTarget,
} from "@paykernel/reconciliation";
declare const lookup: ProviderLookupPort;
const reconciler = createPaymentReconciler({ lookup });
const target: ReconciliationTarget = {
gateway: "stripe",
gatewayPaymentId: "pi_123",
expected: { status: "pending" },
};
const result = await reconciler.reconcile(target);
const decision = decideReconciliationPolicy(result, target);
// Apply local updates in YOUR app — this package never mutates payments.
if (decision.action === "update_local_to_paid" && decision.safe) {
// await orderService.markPaid(decision.provider);
}
// NEVER create a replacement charge while original is indeterminate.
// Multi-match is never silent pick-first (outcome: ambiguous_match).2. Durable schedule (store-backed, no queue)
import {
createPaymentReconciler,
createReconciliationScheduler,
decideReconciliationPolicy,
type ProviderLookupPort,
type ReconciliationStore,
type ReconciliationTarget,
} from "@paykernel/reconciliation";
declare const store: ReconciliationStore; // testkit memory in tests; adapter in production
declare const lookup: ProviderLookupPort;
declare function loadTarget(job: { record: { subjectId: string } }): Promise<ReconciliationTarget>;
const reconciler = createPaymentReconciler({ lookup });
const scheduler = createReconciliationScheduler({ store, maxAttempts: 8 });
await scheduler.schedule({
target: {
gateway: "stripe",
gatewayPaymentId: "pi_123",
expected: { status: "pending" },
},
runAt: new Date().toISOString(),
reason: "indeterminate_create",
});
// Production poll loop (the only one): processDue claims immediately before
// each handler and auto-renews on leaseMs/3.
await scheduler.processDue({
limit: 10,
handler: async (job) => {
const target = await loadTarget(job); // store row is subjectId + reason, not a full target
const result = await reconciler.reconcile(target);
const decision = decideReconciliationPolicy(result, target);
// Never complete on raw result.outcome === "consistent" — pending/processing
// still settling maps to retry_later, not recovery-complete.
if (decision.action === "mark_consistent" && decision.safe) {
return { disposition: "complete" };
}
if (
(decision.action === "update_local_to_paid" ||
decision.action === "update_local_to_failed") &&
decision.safe
) {
// apply the safe local paid/failed update in YOUR app first, then complete:
return { disposition: "complete" };
}
if (decision.action === "retry_later") {
return { disposition: "retry_later", error: new Error("retry_later") };
}
if (decision.action === "do_not_create_replacement") {
// Never createPayment for the same intent; reschedule lookup if needed.
return { disposition: "retry", error: new Error(decision.reason) };
}
if (
decision.action === "manual_review" ||
decision.action === "apply_drift_review"
) {
return { disposition: "manual_review", note: decision.action };
}
return { disposition: "retry" };
},
});Production worker: processDue is the only production poll loop. claimDue is discovery / test inspection: it claims sequentially but still returns N live leases. Do not claimDue({ limit: N }) then serial-work that array — later default-30s leases expire while the first handler runs, and a peer can steal them (lease_lost after a successful lookup). Use processDue, which claims immediately before each handler and auto-renews on leaseMs/3.
Inject any ReconciliationStore (testkit createMemoryReconciliationStore in tests; postgres/redis/sqlite/turso/d1/do adapters in production). Durable adapters must pass runReconciliationStoreConformanceSuite from @paykernel/testkit.
(@paykernel/reconciliation does not depend on testkit — import memory stores only from test code.)
Dual memory-store honesty: this package keeps a non-exported in-package memory store for domain unit tests. Testkit ships a separate createMemoryReconciliationStore. Both are test-only / NON-PRODUCTION and can drift vs durable SQL fencing; production apps inject @paykernel/store-* adapters that pass runReconciliationStoreConformanceSuite.
3. Batch with concurrency limit
for await (const { index, target, result } of reconciler.reconcileMany(targets, {
concurrency: 5,
})) {
// Completion order; use index/target to correlate (RECON-1).
// Persist or alert in application code — package does not auto-mutate.
void index;
void target;
void result;
}Design rules (authoritative)
- Decision-only policy — helpers return enums; never auto-mutate local payments.
- No replacement charges while original is indeterminate or matches are ambiguous.
- Never invent failure from timeouts / unavailable provider responses.
- Multi-match →
ambiguous_match— never silent pick-first. - Atomic claim only via
store.claim— no get-then-set races in the scheduler. - No secrets in errors, logs, or stored
lastError(usesanitizeReconciliationError). - No mandatory queue —
ReconciliationStoreis the scheduling abstraction.
Lookup order
When keys and methods are available:
- provider payment ID (
findByPaymentId) - idempotency key (
findByIdempotencyKey) - merchant local reference (
findByLocalReference) - provider request ID (
findByProviderRequestId)
Unsupported methods are skipped (capability-aware). Missing all methods for available keys → manual_review_required.
Documentation
| Doc | Contents |
| --- | -------- |
| docs/overview.md | Purpose, package boundary, mental model |
| docs/reconciliation.md | Target, snapshots, results, compare, policy |
| docs/safe-lookup.md | Ordered lookup, ProviderLookupPort, multi-match |
| docs/scheduling.md | Scheduler, backoff/jitter, manual review, no queue |
| docs/batch.md | reconcileMany, concurrency, app persist/alert |
| docs/crash-boundaries.md | Schedule / claim / lookup / complete under crash |
| testkit store-contracts | Lease-aware store semantics + conformance |
| adapter selection | Choosing a durable store adapter |
Package boundary
- Depends only on
@paykernel/core(core). - Does not import testkit, webhooks, adapters, Redis, or DB drivers.
- Dual-owns
ReconciliationStorestructurally compatible with Phase 9 testkit. - Core must never depend on this package.
License
MIT
