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.
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.
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.
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.
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 | Access | Useful customer state | |
|---|---|---|---|
| pending | pending | pending | Waiting for payment confirmation |
| confirmed | pending | pending | Preparing the download |
| confirmed | granted | pending | Download available; email still pending |
| confirmed | granted | accepted | Download 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.
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.
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.
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.
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
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');
}); 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.
| Failure point | Persisted evidence | Next action |
|---|---|---|
| Before payment confirmation | No verified paid state | Wait for verified payment evidence |
| After claim, before remote call | Running lease only | Reclaim after expiry; do not guess completion |
| After access succeeds, before saving it | Possibly still running | Reconcile the existing grant before another create |
| After email acceptance, before saving it | Possibly still pending | Use the same idempotency key and payload within the provider window |
| After the idempotency window | Delivery is uncertain | Reconcile or follow a deliberate resend policy |


