DEV Community

knot crochet
knot crochet

Posted on Originally published at autonnel.com

I Rebuilt ClickFunnels' One-Click Upsell. The Hard Part Wasn't the UI.

One-click upsell is the page that appears right after someone buys: "add this for $29, no need to re-enter your card." ClickFunnels built a business on it. I rebuilt it, and I want to be precise about where the work actually was, because the visible part is a button and a price.

The button took an afternoon. The four failure orderings behind it took weeks.

Problem one: the two providers have opposite shapes

I support Stripe and PayPal. "Charge the customer again without asking for the card" means two genuinely different things.

Stripe. Vault the payment method during the base checkout, then create a second, independent charge later. The vaulting happens at intent creation:

// Vault the card for later one-click upsell charges: a Customer + setup_future_usage=off_session
// makes the confirmed payment method reusable off-session.
setup_future_usage: customerId ? 'off_session' : undefined,
Enter fullscreen mode Exit fullscreen mode

with one rule that is not negotiable: vaulting is best-effort and must never block the base sale.

} catch {
  customerId = undefined; // vaulting is best-effort; never block the base sale
}
Enter fullscreen mode Exit fullscreen mode

If creating the Customer object fails, the buyer still buys. They just don't get offered upsells. Losing the $79 base order to protect a hypothetical $29 upsell is the wrong trade, and it's an easy one to get backwards when you're writing the upsell feature and the upsell feels like the point.

PayPal. There is no second charge. The order sits approved-but-not-captured, and an upsell is a PATCH that raises the amount, followed by exactly one capture when the buyer leaves the upsell chain:

// PayPal: the order is approved-but-not-captured. PATCH it to add the upsell amount + record the
// line in the snapshot (no charge). When the buyer leaves the upsell chain, capture once - the order
// then materializes with base + all accepted upsells.
Enter fullscreen mode Exit fullscreen mode

So for one provider "accept upsell" means money moved. For the other it means a number in a pending authorization changed and money hasn't moved at all, including for the base order.

I tried to hide this behind one chargeUpsell() port. Don't. The abstraction has to sit further out, at the HTTP boundary, where both paths converge on the same response shape (success, action, order, nextStepUrl). Everything below that is two flows, and pretending otherwise produces a port whose contract is a lie for one of its implementations.

Problem two: idempotency, twice, differently

A buyer double-clicks. A phone loses signal mid-request and the browser retries. This is not an edge case on a page whose entire design encourages one impulsive click.

Stripe gets an idempotency key scoped to the specific upsell step:

idempotencyKey: `${tenantId}:${saleRef}:upsell:${upsellIndex}`
Enter fullscreen mode Exit fullscreen mode

and the local ledger row is written with the same key, with the unique-violation swallowed:

} catch (err) {
  if ((err as { code?: string })?.code !== 'P2002') throw err;
}
Enter fullscreen mode Exit fullscreen mode

PayPal can't use an idempotency key for this, because the operation isn't "create a charge", it's "set the total to a new value". A retried PATCH with the same intent would be indistinguishable from a legitimate second upsell at the same price. So idempotency is a state check against the snapshot:

if (!snapshot.lines.some((l) => l.upsellIndex === upsellIndex)) {
  // append the line, recompute the total, patch the order
}
Enter fullscreen mode Exit fullscreen mode

Same guarantee, completely different mechanism, because the underlying operation is set rather than add.

Problem three: getting it into the merchant's store

This is the part nobody writes about and the part that generates support tickets.

The merchant's actual fulfillment happens in Shopify or WooCommerce. So a sale with three accepted upsells has to arrive there. As what, exactly?

Two legitimate answers, and I made it a setting rather than picking one:

  • merged: base plus all accepted upsells as one external order. One shipment, one packing slip. This is what a single-warehouse merchant wants.
  • split: the base as one order, plus one standalone order per upsell, each tagged back to the parent. This is what a merchant with per-supplier dropshipping needs, because those items ship from different places.

The dispatch is one function, and the interesting lines are the two short-circuits:

export async function executeHandoffForSale(saleRef: string, deps: HandoffForSaleDeps): Promise<void> {
  const mode = await deps.getFulfillmentMode();
  if (mode !== 'split') {
    await deps.runMergedHandoff(saleRef);
    return;
  }
  const ctx = await deps.loadSplitContext(saleRef);
  if (!ctx || ctx.upsellLines.length === 0) {
    await deps.runMergedHandoff(saleRef);
    return;
  }
  await deps.runBaseOnlyHandoff(saleRef);
  for (const u of ctx.upsellLines) {
    try {
      await deps.pushIndependentUpsell({ saleRef, upsellIndex: u.upsellIndex, parentOrderNumber: ctx.orderNumber, line: u.line, customer: ctx.customer });
    } catch (err) {
      logger.error('split upsell push failed', { error: err, saleRef, upsellIndex: u.upsellIndex });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A sale with zero upsells is byte-identical in both modes, so it takes the merged path regardless of the setting. That's not an optimization, it's a correctness statement: the mode should not be able to produce two different results for the same input.

And the loop is deliberately best-effort. One failing sub-order must not prevent the other sub-orders or the main order from existing. A retry job picks up the failure; a thrown exception would strand the whole sale.

Problem four: the one that actually loses money

Here's the ordering that took the longest to see.

In merged mode the push to the merchant's store is held until the buyer finishes the upsell chain, so the order can be assembled complete. Fine. But the buyer isn't obligated to follow that script. They can close the tab, come back from an email an hour later, and accept an upsell after the main order has already been pushed.

Now the upsell cannot be folded into an order that already exists downstream. If you do nothing, the charge succeeds and the item is never fulfilled. The customer paid and gets nothing. That is the worst failure mode in the entire system, and it is completely silent - your payment numbers look great.

The handling is a degrade, not an error:

} else {
  // The main order was already pushed to the backend (handoff not deferred), so this late upsell
  // cannot be folded into it - push it as an independent external order tagged to the parent.
  // Covers merged late-degrade and split late-accept; prevents the upsell from being lost.
  await pushUpsellIndependently(saleRef, intent, line, upsellIndex).catch((err) =>
    logger.error('Independent upsell push failed (cron will retry)', { error: err, saleRef, upsellIndex }));
}
Enter fullscreen mode Exit fullscreen mode

A merchant configured for merged fulfillment gets a second order they didn't ask for, tagged parent:<mainOrderNumber> so it's traceable. That's strictly better than the alternative, and the tag is what makes it defensible when they ask why.

The ordering rule I'd keep

Charge, then persist, then push, and treat everything after the charge as retryable:

const order = await appendLineToOrder(saleRef, line, locals).catch((err) => {
  logger.error('Upsell charged but appending to order failed', { error: err, saleRef, upsellIndex });
  return undefined;
});
Enter fullscreen mode Exit fullscreen mode

The buyer sees a success page even if the internal bookkeeping failed, because from their side the transaction is complete - it is. The log line is written so that the reconciliation job and the human reading it both know exactly which sale is inconsistent.

The general rule: the irreversible step goes last among things that can fail independently, and everything after it must be retryable and must not be able to fail the response. Every bug I hit in this feature was a violation of that sentence in one direction or another.

The UI is a button. Everything worth writing down is in what happens after someone taps it twice on a train.

Top comments (0)