Skip to content
R.

Separate payment confirmation from download delivery

Keep confirmed payment, download access, and email notification separate. Follow a stopped email worker through lease expiry without granting access twice.

About 12 min readComments

The payment is confirmed and the download is already available, but the access email has not arrived. Retrying the entire purchase flow would repeat work that succeeded. Calling the order simply fulfilled or failed hides the decision the next worker actually needs to make.

For engineers delivering one-time digital products, this design separates payment evidence from access and notification progress. We will follow one stopped email worker, examine the transaction boundary, and name the crash window that leases cannot close. The examples use a local model; no payment, Drive permission, or email is created by them.

Follow one failure without resetting the whole order

The model begins with a confirmed payment. An access worker claims and finishes its step, leaving access=granted. Email worker A then claims notification and stops before recording a result. At its lease deadline, worker B can reclaim email. Asking for another access claim at that point returns null because access is already complete.

The proposed customer copy below reflects only what this state proves: the download is ready and the notification is still being prepared. In the fixture, worker A cannot finish after replacement; worker B can record accepted. That final state is provider acceptance in the model, not evidence that a real message reached an inbox.

A proposed customer view keeps Open download available while the execution trace moves from email pending to worker email-a, then email-b. Access remains granted throughout.
Local claimStep/finishStep fixture, not a live purchase. At the illustrative 30-second lease boundary only email is reclaimed; completed access is not claimed again. Customer copy is a proposal.

Confirm the payment on the server

A success-page URL is not evidence that an order is paid. The browser may arrive with edited parameters, or may never arrive at all. Stripe recommends webhook-based fulfillment and warns that fulfillment may be invoked more than once, including concurrently, for the same Checkout Session.

Retrieve and validate the relevant session on the server, including its payment state, environment, configured product or price, and order association. A delayed payment method needs its eventual success or failure handled too. The UI should read the resulting order state; it should not grant access because checkout=success appears in a URL.

SourcesStripe: Checkout fulfillment (opens a new tab)

Store the facts a retry must not lose

Payment confirmation answers whether this order is eligible for delivery. Access state answers whether the buyer can obtain the product. Email state answers whether notification work still needs attention. A single status can be useful for display, but it should be derived from these facts rather than replace them.

Give the order a server-derived identity that distinguishes test from live data. Record attempts, timestamps, and safe failure classes for operations. A notification retry should preserve a successful access grant, and a failed email should not turn a confirmed payment back into a payment failure.

State sketch (pseudocode) Illustration only
order key = environment + checkout session ID

payment: pending | confirmed | failed
access:  pending | granted | retryable_failure
email:   pending | accepted | retryable_failure

confirmed payment
  -> claim bounded delivery work
  -> ensure access, record result
  -> request notification, record provider result
  -> expose only the customer-facing status

Payment success is not the end of the workflow
PaymentAccessEmailUseful customer state
pendingpendingpendingWaiting for payment confirmation
confirmedpendingpendingPreparing the download
confirmedgrantedpendingDownload available; email still pending
confirmedgrantedacceptedDownload available; notification accepted by the provider

A transaction coordinates workers, not remote providers

Firestore can rerun a transaction callback after a concurrent edit to a read document. Use that callback only to inspect the current order and claim or finish work. A remote access grant or email call belongs after the claim commits, otherwise callback replay can repeat the external operation.

A claim includes an owner token and expiry. Completion rechecks both against the current record. This prevents a late worker from overwriting its replacement; it does not prevent the late worker's remote request from succeeding. Provider reconciliation remains necessary even when the database claim is correct.

SourcesFirestore: transaction retries (opens a new tab)

Claim one obligation, with one owner and one expiry

This pure model makes the claim decision visible before connecting it to Firestore. access and email have independent states. Access finishes as granted; email finishes as accepted. An email worker cannot start before access is granted. A completed step returns no new claim, so replaying an already completed access job does not recreate that job.

A running claim prevents another worker from claiming until its lease expires. Reclaiming replaces the owner token. Completion checks both the token and the current deadline; an old worker cannot overwrite a newer result just because its remote request eventually succeeded. The clock and token are supplied by the trusted worker, not the buyer or webhook payload.

Treat the thirty-second lease as an example configuration, not an operational recommendation. It needs to cover the expected provider call and result write, with a bounded timeout below the remaining lease. Slow providers may need renewal and a separate work queue. Renewal must itself verify ownership, and the worker must stop committing after losing it.

The model expects a validated server-owned order. It omits refund/revocation transitions, retry budgets, schema migration, and audit fields. Keep those concerns explicit when adopting it. In particular, confirming a payment does not mean that future refund events can be ignored.

delivery-claim.mjs / JavaScript
const finished = { access: 'granted', email: 'accepted' };

export function claimStep(order, step, { token, now, leaseMs = 30_000 }) {
  if (!Object.hasOwn(finished, step)) throw new Error('Unknown delivery step');
  if (!token || !Number.isFinite(now) || !Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('Invalid lease');
  if (order.payment !== 'confirmed') return null;
  if (step === 'email' && order.delivery?.access?.state !== 'granted') return null;
  const current = order.delivery?.[step];
  if (current?.state === finished[step]) return null;
  if (current?.state === 'running' && current.leaseUntil > now) return null;
  return {
    ...order,
    delivery: {
      ...order.delivery,
      [step]: { state: 'running', token, leaseUntil: now + leaseMs },
    },
  };
}

export function finishStep(order, step, { token, now }) {
  if (!Object.hasOwn(finished, step)) throw new Error('Unknown delivery step');
  const current = order.delivery?.[step];
  if (order.payment !== 'confirmed' || current?.state !== 'running' ||
      current.token !== token || !(current.leaseUntil > now)) {
    throw new Error('Delivery claim expired or replaced');
  }
  return {
    ...order,
    delivery: { ...order.delivery, [step]: { state: finished[step] } },
  };
}

Use the database transaction only for the claim and result

The adapter below uses the Firestore transaction shape: read the current order, claim access, and write the delivery state. The external provider is called only after that transaction commits. A second transaction reads again before marking the step granted. Both decisions use the current record; neither relies on a stale object captured before the provider request.

provider.ensureAccess is an application adapter, not a Google Drive or Stripe SDK method. Its contract is to load the trusted product and buyer association for that order, inspect existing access, and create only the missing grant. Do not implement it by blindly adding a new permission every time. The stable order reference is server-derived and distinguishes test from live orders.

The function returns not_claimed for a busy, unpaid, or already finished operation. That is a worker result, not enough information for the customer page. The page should read the authoritative order state. A worker may find that somebody else is currently preparing the download; it should not tell the buyer that payment failed.

deliver-access.mjs / JavaScript
import { claimStep, finishStep } from './delivery-claim.mjs';

export async function deliverAccess(db, orderRef, provider, { token, now = Date.now }) {
  const claimed = await db.runTransaction(async transaction => {
    const snapshot = await transaction.get(orderRef);
    if (!snapshot.exists) throw new Error('Order not found');
    const next = claimStep(snapshot.data(), 'access', { token, now: now() });
    if (!next) return false;
    transaction.update(orderRef, { delivery: next.delivery });
    return true;
  });
  if (!claimed) return { status: 'not_claimed' };

  // The provider adapter reads trusted order data and reconciles an existing grant.
  await provider.ensureAccess(orderRef.id);

  await db.runTransaction(async transaction => {
    const snapshot = await transaction.get(orderRef);
    if (!snapshot.exists) throw new Error('Order not found');
    const next = finishStep(snapshot.data(), 'access', { token, now: now() });
    transaction.update(orderRef, { delivery: next.delivery });
  });
  return { status: 'granted' };
}

Account for the gap between sending and recording

Consider a worker that sends an email successfully and crashes before saving the provider result. The next worker sees email=pending. A lease alone cannot tell whether another message would duplicate a previous one. Use provider idempotency where available and a stable key derived from the same order and operation.

Resend currently retains idempotency keys for 24 hours. That makes the retention window part of the recovery design, not an implementation footnote. A retry outside that window needs reconciliation or a deliberate resend policy; it must not be described as an exactly-once guarantee. Reuse the same operation key and payload for retries within its intended window.

SourcesResend: idempotency keys (opens a new tab)

Test callback replay and replacement workers

The first executable fixture claims access as worker A, refuses B while A is active, then lets B reclaim at expiry. A can no longer finish. B finishes once, and the completed step cannot be claimed again. Additional assertions prevent email from starting before access and prevent unpaid work from being claimed.

The second fixture deliberately runs each transaction callback twice while discarding the first attempt's writes. Its provider asserts that execution is outside the transaction, and the final count is one across an initial delivery and a repeated call. This tests the adapter's placement of side effects. It is not a Firestore emulator contention test or proof of exactly-once remote behavior.

Put the three JavaScript modules and this test in the same directory. The code samples are the actual files exercised by the local Node suite, not an untested transcription. A passing fixture narrows one failure class; it does not remove the crash window discussed above.

delivery-claim.test.mjs
delivery-claim.test.mjs / JavaScript
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { claimStep, finishStep } from './delivery-claim.mjs';
import { deliverAccess } from './deliver-access.mjs';

test('only the current unexpired worker can finish', () => {
  const order = { payment: 'confirmed', delivery: {} };
  const first = claimStep(order, 'access', { token: 'a', now: 0 });
  assert.equal(claimStep(first, 'access', { token: 'b', now: 1 }), null);
  const replacement = claimStep(first, 'access', { token: 'b', now: 30_000 });
  assert.throws(() => finishStep(replacement, 'access', { token: 'a', now: 30_001 }));
  const complete = finishStep(replacement, 'access', { token: 'b', now: 30_001 });
  assert.equal(complete.delivery.access.state, 'granted');
  assert.equal(claimStep(complete, 'access', { token: 'c', now: 30_002 }), null);
  assert.equal(claimStep(order, 'email', { token: 'e', now: 0 }), null);
  assert.equal(claimStep({ ...order, payment: 'pending' }, 'access', { token: 'a', now: 0 }), null);
});

test('replaying a transaction callback does not repeat the provider call', async () => {
  let order = { payment: 'confirmed', delivery: {} };
  let insideTransaction = false;
  let providerCalls = 0;
  const db = { runTransaction: async callback => {
    insideTransaction = true;
    let result; let update;
    // Discard the first callback's writes, as if its commit had conflicted.
    for (let attempt = 0; attempt < 2; attempt++) {
      update = undefined;
      result = await callback({
        get: async () => ({ exists: true, data: () => structuredClone(order) }),
        update: (_ref, value) => { update = value; },
      });
    }
    if (update) order = { ...order, ...update };
    insideTransaction = false;
    return result;
  } };
  const provider = { ensureAccess: async () => {
    assert.equal(insideTransaction, false);
    providerCalls++;
  } };
  const options = { token: 'worker-a', now: () => 1_000 };
  await deliverAccess(db, { id: 'test-order' }, provider, options);
  await deliverAccess(db, { id: 'test-order' }, provider, options);
  assert.equal(providerCalls, 1);
  assert.equal(order.delivery.access.state, 'granted');
});

Run the worker fixtures / Shell
node --test delivery-claim.test.mjs

Choose recovery by the missing evidence

Before the remote call, an expired claim can usually be retried. After a remote call succeeds but before its result is saved, the next worker faces uncertainty: the database may still say running even though the provider performed the action. Reconcile the grant or reuse a supported idempotency key instead of guessing from the local state.

The local tests establish claim ownership and place provider calls outside replayed transaction callbacks. They do not exercise a Firestore emulator under contention, send an email, or make an exactly-once guarantee. Those integration tests belong in the delivery system's own release gate.

A state model is not a scheduler. Add a bounded retry trigger or reconciliation job and alert on old pending work. For a very small system, that might be an explicit repair queue rather than a general workflow engine. The important property is that unfinished obligations can be discovered and retried without repeating completed ones.

  • Do not grant access from client-supplied payment status.
  • Do not turn an email retry into a second access grant.
  • Do not equate provider acceptance with inbox delivery.
  • Do not claim exactly-once delivery across systems without a matching guarantee.
A recovery decision for each failure point
Failure pointPersisted evidenceNext action
Before payment confirmationNo verified paid stateWait for verified payment evidence
After claim, before remote callRunning lease onlyReclaim after expiry; do not guess completion
After access succeeds, before saving itPossibly still runningReconcile the existing grant before another create
After email acceptance, before saving itPossibly still pendingUse the same idempotency key and payload within the provider window
After the idempotency windowDelivery is uncertainReconcile or follow a deliberate resend policy
Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed