8 min read·Run it here

Design a Stripe Customer Book

Attach a card and give the customer a default without stomping the one they picked, find the right customer when the same email exists three times, and merge the duplicates without charging the same card twice.

Pattern
Read the default, mutate, re-read · page the list before you pick
Time
O(cards) per card op, O(customers) per lookup
Space
O(page) + O(fingerprints)

This is the layer between your app and Stripe, and every bug in it is a billing bug. A customer who ends up with no default payment method does not get an error. They get an invoice that fails next month. A duplicate customer does not get an error either. Somebody gets charged on the account with none of their history on it.

The drill injects a fake Stripe client, so nothing makes a real call. The shapes are the real ones, and the shapes are most of the problem.

The five API facts that carry the whole drill

Say before you type: "Lists are newest first, created is in seconds, and a deleted customer comes back as an object, not an exception."

  1. Lists are newest first. data[0] is the most recent, and the oldest row is on the last page.
  2. The cursor is starting_after, set to the id of the LAST element of data. Loop while has_more. limit caps at 100.
  3. created is UNIX seconds. Two objects made in the same second tie, and a tie needs a deterministic second key or your script gives different answers on different runs.
  4. A deleted customer is returned, not thrown. customers.retrieve on one hands back { id, object: "customer", deleted: true }. Code that waits for an exception merges into a tombstone.
  5. customers.list({ email }) is a case-sensitive exact filter. It cannot answer "who has this email".

Phase 1: the default is a pointer, and detach does not clear it

Two fields look like the default. Only one is.

private defaultOf(customer: any): string | null {
  const settings = customer && customer.invoice_settings;
  const id = settings ? settings.default_payment_method : null;
  return typeof id === "string" && id.length > 0 ? id : null;
}

customer.default_source is the legacy Card and Source field. A customer can have a default_source set and a null default_payment_method, which means they have no default for invoices. Read the wrong one and the card they just added never becomes the default, and nothing anywhere says so.

Adding a card is attach, then set the default only if there is not one already:

await this.client.paymentMethods.attach(paymentMethodId, { customer: customerId });
const customer = await this.client.customers.retrieve(customerId);
if (this.defaultOf(customer)) return { paymentMethodId, becameDefault: false };
await this.setDefault(customerId, paymentMethodId);

Say the reason out loud: the default is a choice the customer made, so a new card does not get to overwrite it.

Removing is where the money is. Detaching a payment method does not clear invoice_settings.default_payment_method. Stripe leaves the pointer where it was, nothing errors, and the customer now points at a card that no longer exists.

const before = await this.client.customers.retrieve(customerId);
const standing = this.defaultOf(before);
const wasDefault = standing === paymentMethodId;

await this.client.paymentMethods.detach(paymentMethodId);
if (!wasDefault) return { removed: paymentMethodId, wasDefault: false, defaultPaymentMethod: standing };

const remaining = await this.listAllCards(customerId);
const promoted = remaining.length > 0 ? remaining[0].id : null;
await this.setDefault(customerId, promoted);

Three details in that block are each worth a sentence:

  • List after the detach. List before it and the card you just removed is still in the array, so you promote the card you removed and change nothing.
  • Set the default to null when nothing remains. if (promoted) around that line is the bug: the customer keeps pointing at a detached card forever. Clearing it on purpose is the honest state.
  • Do not filter by type. A customer can pay invoices from a bank account. type: "card" hides it and you report no default on a customer who has one.

And the seconds fact bites here. Two cards attached in the same second tie on created, so if you sort yourself, a >= comparison silently prefers the later element and a > prefers the earlier one. Trust the list order instead: Stripe already sorted it newest first.

Phase 2: Stripe does not enforce unique emails

This is the fact people do not believe until they see it. Nothing stops your account from holding four customers with the email ada@example.com, and customers.list({ email }) is case-sensitive, so it will not even find the ones stored as Ada@Example.com.

So page everything and compare normalized, on both sides:

const wanted = String(email).trim().toLowerCase();
const matches = all.filter((row) => typeof row.email === "string" && this.normalize(row.email) === wanted);

Normalize for the comparison only. What you store is the caller's casing, trimmed. Lowercasing what you save is a small lie that shows up on their receipt.

The cursor loop is the same three lines every time, so have them memorized:

const page = await this.client.customers.list({ limit: 100, ...(after ? { starting_after: after } : {}) });
if (!page.has_more || page.data.length === 0) break;
after = page.data[page.data.length - 1].id;

Because the list is newest first, reading one page gets you the newest match. That is the worst possible answer: the newest duplicate is the one with no subscriptions, no invoices, and no history. Return the oldest by created, tie broken on id ascending, and put the rest in duplicates. Reporting them is the deliverable. Silently picking one is the bug the phase is named for.

The idempotency key is load-bearing

await this.client.customers.create(
  { ...params, email: String(email).trim() },
  { idempotencyKey: "customer:" + wanted }
);

Name the failure it prevents: you read from a replica that has not caught up, the list comes back empty, you create. Your request times out, you retry, the list is still stale, and now there are two. A key derived from the normalized email means the second create returns the first customer instead of making a new one. That is the difference between "I know what idempotency keys are" and "I know when I need one".

Phase 3: merge without double-charging

Refuse first, and refuse before you touch a card. A refusal that has already detached something is not a refusal.

if (primaryId === duplicateId) throw new Error("cannot merge a customer into itself: " + primaryId);
const primary = await this.client.customers.retrieve(primaryId);
if (primary && primary.deleted === true) throw new Error("cannot merge a deleted customer: " + primaryId);

Then the fingerprint rule. pm.card.fingerprint is stable for the same physical card, so a card the primary already holds must be skipped, not moved. Move it and the customer sees the same card twice on their billing page and someone eventually charges both.

const held = new Set<string>();
for (const pm of await this.listAllCards(primaryId)) {
  const fingerprint = this.fingerprintOf(pm);
  if (fingerprint) held.add(fingerprint);
}

The set has to grow as you move. This is the one to say out loud, because the snapshot version looks finished and is not: if the duplicate holds two copies of the same card, the first moves, and then the second is compared against a set that never learned about the first, so it moves too. One held.add(fingerprint) after each successful move is the entire fix.

Two more edges in the same loop:

  • A skipped card is left alone. Detach is irreversible in real Stripe, so detaching a card you were never going to move destroys it for nothing.
  • A payment method with no card has no fingerprint. A bank account is never a fingerprint duplicate, and pm.card.fingerprint throws on it.

Moving is detach then attach, one card at a time, because Stripe refuses to attach a PaymentMethod that still belongs to someone else. Finish one card before you start the next: detaching the whole batch first leaves every one of them orphaned if the process dies halfway. Be honest about what the mock softens: real detach is one way, so a production merge re-collects the card or clones it through Connect. The bookkeeping is what you are rehearsing.

A move that throws is one conflict, not a dead merge:

catch (error) {
  conflicts.push({ id: pm.id, reason: error instanceof Error ? error.message : String(error) });
  continue;
}

And say the consequence, because the interviewer is waiting for it: that card is now detached and attached to nobody. It succeeded at leaving and failed at arriving. The report exists so a human can go fix exactly that.

Finally, close the loop back to Phase 1. If anything moved and the primary had no default, give it one, by the same rule: the newest card it now holds. A merged customer with no default is Phase 1's bug with more steps. If nothing moved, leave the primary exactly as you found it.

What the interviewer is listening for

They are not checking whether you can call attach. They are checking three things:

  1. Do you know that the default is a pointer that can dangle? One sentence: detach does not clear it, so I either promote or null it.
  2. Do you know the list is newest first? Everything about "the oldest duplicate" falls out of that, and so does the one-page bug.
  3. Can you name a failure the idempotency key prevents? A stale read plus a retry. If you can say that in one breath, you have already answered the follow-up.

The fourth thing, unasked: every refusal in merge happens before any mutation. Interviewers who have run a data migration notice that immediately.