DEV Community

knot crochet
knot crochet

Posted on Originally published at autonnel.com

Never Leave a Payment Authorized and Uncaptured

PayPal lets you leave an approved order uncaptured. The buyer has agreed, no money has moved, and you capture it whenever you decide to. My checkout uses that deliberately so a post-purchase upsell can be added to the order before anything is charged.

The consequence is a state I have to defend against: real orders where the customer believes they bought something and I have not taken their money. Nothing in the request path will ever capture those, because the request that would have done it is the one the buyer never made. They closed the tab.

That is not an edge case. Abandoning mid-funnel is the single most common thing a buyer does, and every one of those abandonments is a sale I have already promised to deliver and not yet been paid for.

The safety net

// Safety net for PayPal merged upsells: a buyer who abandons mid-upsell leaves the order APPROVED
// but uncaptured. Capture anything untouched past the cutoff (base + upsells accepted so far) so the
// main sale is never lost. `updatedAt < cutoff` naturally excludes buyers still actively upselling,
// since each accepted upsell patch refreshes the intent.
export async function runDeferredCaptureSweep(deps: {...}) {
  const stale = await deps.intentRepo.findDeferredOlderThan(deps.cutoff, deps.limit);
  let captured = 0, failed = 0;
  for (const intent of stale) {
    try {
      const r = await deps.confirm.captureNow({
        saleRef: intent.saleRef.value,
        idempotencyKey: `safetynet:${intent.id}`,
      });
      if (r.status === 'succeeded') captured++; else failed++;
    } catch (err) {
      failed++;
      logger.error('abandoned deferred capture failed', { error: err, saleRef: intent.saleRef.value });
    }
  }
  return { captured, failed, scanned: stale.length };
}
Enter fullscreen mode Exit fullscreen mode

Called with a 20-minute cutoff. Three details in there are load-bearing.

updatedAt < cutoff is an activity signal, not a timestamp filter. Every accepted upsell patches the intent, which touches updatedAt. So "not updated in 20 minutes" means "this buyer is not currently in the upsell flow", and I get the liveness check for free from a column I was already writing. I don't need a heartbeat or a session-expiry job. If you find yourself designing a "is the user still active" mechanism, check whether your write path already answers it.

The idempotency key is prefixed. safetynet:${intent.id}, distinct from the keys the interactive path uses. If the buyer's own final capture and this sweep race, the provider sees two different keys for the same order and the second one fails cleanly on the provider's own state machine rather than silently succeeding twice. Namespacing keys by who initiated the operation has caught more races for me than trying to make the keys identical.

The loop swallows per-item failures. One order whose card expired between authorization and capture must not stop the other forty-nine.

The part that made this actually run

I wrote that sweep and it worked in tests and did nothing useful in production, because the scheduler underneath it was lying to me about frequency.

On Cloudflare Workers, cron triggers are declared in config and the runtime calls one scheduled handler. My handler runs every sweep on every invocation, and the trigger list is the union of all the jobs' cron expressions. So a job declaring "every 30 minutes" ran every 5 minutes, because some other job's */5 trigger fired the shared handler and the shared handler runs everything.

The declared schedule was decoration. Real frequency needs a gate:

export async function runSweep<T>(name: string, fn: () => Promise<T>): Promise<T | undefined> {
  const ttl = lockTtlByName.get(name);
  const intervalMs = intervalByName.get(name);

  if (intervalMs && (await tooSoon(name, intervalMs))) return undefined;   // cheap pre-gate

  let locked = false;
  if (ttl) {
    locked = await cache.acquireLock(lockKey(name), ttl);
    if (!locked) return undefined;
  }
  try {
    if (intervalMs) {
      if (await tooSoon(name, intervalMs)) return undefined;               // authoritative re-check
      await cache.set(lastRunKey(name), Date.now(), Math.ceil(intervalMs / 1000));
    }
    return await fn();
  } catch (err) {
    log.error('cron sweep failed', { sweep: name, error: err });
    return undefined;
  } finally {
    if (locked) await cache.releaseLock(lockKey(name));
  }
}
Enter fullscreen mode Exit fullscreen mode

Four decisions in there I'd defend:

The pre-gate is outside the lock, and it's racy on purpose. Acquiring the lock is a KV write and releasing it is a KV delete. Doing that before the interval check made every job pay both on every tick, including the overwhelming majority of ticks where the interval check then skipped it immediately. With */5 in the trigger list, a job declaring a 30-minute interval burned roughly 288 writes and 288 deletes a day to do nothing. Across the whole catalog that was the dominant share of this namespace's write volume. The racy pre-gate filters the common case; the authoritative check inside the lock is what's correct.

The re-check inside the lock exists because two ticks can both clear the pre-gate before either takes the lock. Without it the loser runs a second time the moment the winner releases.

The timestamp is stamped before the run, not after. A sweep that is slow or throwing must not re-fire on every subsequent tick and hammer an upstream API. The next attempt comes at the next interval boundary. This trades "a failed run retries promptly" for "a failing run can't turn into a hot loop against someone else's rate limit", and for anything that calls a payment provider that's the right side of the trade.

There's a 10% tolerance on the interval:

// A tick can arrive a few ms before its nominal boundary. Comparing against the exact interval
// would then skip that tick and silently halve the job's real frequency.
const INTERVAL_TOLERANCE = 0.9;
Enter fullscreen mode Exit fullscreen mode

An exact comparison against a clock that isn't exact turns "every 30 minutes" into "every 60 minutes" for the ticks that land a few milliseconds early. Halving your job frequency is the kind of bug that produces a support ticket weeks later and no error anywhere.

What I'd tell someone building the same thing

Two rules, both learned the expensive way.

Any state where the customer thinks they paid and you haven't collected needs a timer that isn't the customer's browser. Not a webhook, not an onbeforeunload, not a session timeout in a store you also lose. A sweep that queries for the state and resolves it.

Check what actually invokes your scheduler before trusting a cron expression. In a shared-handler runtime, per-job cron strings are documentation. The interval gate is the implementation. I had both for weeks and only the gate was doing anything.

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

Using updatedAt as the liveness signal is neat. No heartbeat table needed.

Two edges I'd test around the sweep:

  • The late upsell. The buyer comes back at minute 21 and clicks "add" after the sweep already captured the base. If the upsell handler patches the order without checking state, it either errors in a confusing way or, worse, the UI says "added" and nothing is ever charged for it. The handler needs to see "already captured" and switch to a separate charge or a clear "this offer has closed" message.
  • The failed count. A capture that fails in the sweep is a buyer who thinks they paid and a merchant who wasn't paid, and nobody is looking at the logs at that moment. I'd alert on failed > 0 and keep failed intents in a state the next sweep retries, with the same idempotency key, instead of just logging them once.