Skip to content

feat(stripe): stamp metadata.accountId on sub Customers + one-time backfill#555

Open
sweetmantech wants to merge 5 commits into
testfrom
feat/stamp-sub-customer-metadata-and-backfill
Open

feat(stripe): stamp metadata.accountId on sub Customers + one-time backfill#555
sweetmantech wants to merge 5 commits into
testfrom
feat/stamp-sub-customer-metadata-and-backfill

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 12, 2026

Summary

PR 2a of 4 in the credits-auto-charge rollout (docs ✅ → api 2a (this PR) → run backfill → open-agents tolerant UI → api 2b auto-charge handler).

Prepares for the auto-charge path by ensuring every Stripe Customer linked to a Recoup account has a discoverable metadata.accountId. The upcoming auto-charge flow (PR 2b) uses stripe.customers.search to find a card on file — which only works for Customers stamped with the metadata.

Zero behavior change to public APIs. The credits flow still always returns Checkout URLs; the subscription flow still produces working Checkout sessions. Internally:

  1. lib/stripe/resolveStripeCustomerForAccount.ts (new) — looks up a Customer via customers.search by metadata['accountId']; if no match, creates one stamped with metadata.accountId. Used by the subscription Checkout flow today and the credits auto-charge flow in PR 2b.
  2. createStripeSession.ts — subscription Checkout now resolves the Customer first and passes customer: to Stripe. New subscribers from this PR onward get findable Customers automatically. (Previously Stripe auto-created a guest-style Customer with no metadata.)
  3. scripts/backfill-subscriber-customer-metadata.ts (new, one-time) — iterates stripe.subscriptions.list and stamps metadata.accountId on each subscription's Customer if missing. Idempotent, supports --dry-run, skips deleted Customers, logs conflicts without overwriting. Pure decision function shouldStampCustomerMetadata is extracted with its own test file.

TDD trail

Test file Tests
lib/stripe/__tests__/resolveStripeCustomerForAccount.test.ts 3
lib/stripe/__tests__/createStripeSession.test.ts (updated) 3
scripts/__tests__/shouldStampCustomerMetadata.test.ts 3

Local: pnpm test → 2763 / 2763 ✅, pnpm lint:check clean, typecheck clean.

Required action before PR 2b can ship safely

Run the backfill against prod Stripe once this deploys:

cd api
STRIPE_SK=sk_live_... npx tsx scripts/backfill-subscriber-customer-metadata.ts --dry-run
# Inspect the output; if it looks right:
STRIPE_SK=sk_live_... npx tsx scripts/backfill-subscriber-customer-metadata.ts

The script logs a stats summary at the end:

{
  "subscriptions": N,
  "missingAccountIdOnSub": N,
  "customersInspected": N,
  "customersStamped": N,
  "customersAlreadyStamped": N,
  "customersWithConflict": N,
  "errors": N
}

A non-zero customersWithConflict is logged per-record and doesn't fail the run — it indicates a Customer whose metadata.accountId already exists but disagrees with the subscription's. Worth manual review if any appear.

Test plan (preview)

  • New subscription Checkout via POST /api/subscriptions/sessions produces a session that passes customer: cus_… to Stripe (verify in Stripe Dashboard → Sessions → that session has a Customer attached, and that the Customer has metadata.accountId set)
  • Credits Checkout via POST /api/credits/sessions still works exactly as before (no behavior change)
  • Existing webhook flow still credits accounts on checkout.session.completed

Follow-ups

  • PR 3 (open-agents): tolerant client that handles both auto-charge and Checkout responses
  • PR 2b (api): the auto-charge handler itself + payment_intent.succeeded webhook
  • Card-on-file review step (UX, between PR 2a and PR 3) — show last4 + brand before charging

🤖 Generated with Claude Code


Summary by cubic

Stamp metadata.accountId on Stripe Customers and reuse the resolved Customer in subscription Checkout. Prevent duplicate Customers by using accountId as the idempotency key; one-time backfill script removed after running in prod; no public API changes.

  • New Features

    • Added resolveStripeCustomerForAccount to find-or-create Customers by metadata.accountId via customers.search.
    • createStripeSession now resolves the Customer and passes customer to Checkout.
  • Bug Fixes

    • Use accountId as the deterministic idempotency key on customers.create to prevent duplicates when the search index lags.

Written for commit 1234882. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Checkout now ensures customers are reliably associated with accounts and reuses existing customer records when possible.
  • Bug Fixes
    • Reduced chances of duplicate customer creation during concurrent checkouts, improving payment reliability and account linkage.

Review Change Stack

backfill script for existing subs

Prepares for credit auto-charge (PR 2b) by ensuring every Stripe
Customer linked to a Recoup account has discoverable
`metadata.accountId`. The upcoming auto-charge flow looks up Customers
via `stripe.customers.search`, which only finds Customers with the
metadata stamped — without this PR, every legacy Pro subscriber's
first top-up would create a duplicate Customer instead of reusing
their subscription Customer.

Three things in this PR (zero behavior change to public APIs):

1. New `lib/stripe/resolveStripeCustomerForAccount.ts` — looks up a
   Customer via `customers.search` by `metadata['accountId']`; if no
   match, creates one stamped with `metadata.accountId`. Pure 2-call
   Stripe interaction, no DB.

2. Subscription Checkout flow (`createStripeSession.ts`) now resolves
   the Customer BEFORE creating the session and passes `customer:` to
   Stripe. New subscribers from this PR onward will have findable
   Customers automatically.

3. One-time backfill script
   `scripts/backfill-subscriber-customer-metadata.ts` — iterates
   `stripe.subscriptions.list` paginated, retrieves each subscription's
   Customer, and stamps `metadata.accountId` if missing. Idempotent
   (skips already-stamped Customers, logs conflicts without
   overwriting). Supports `--dry-run`. Run once after this deploys:

     STRIPE_SK=sk_live_... npx tsx scripts/backfill-subscriber-customer-metadata.ts --dry-run
     STRIPE_SK=sk_live_... npx tsx scripts/backfill-subscriber-customer-metadata.ts

   The decision function `shouldStampCustomerMetadata` is extracted as
   a pure unit with its own test file.

TDD: new tests for resolveStripeCustomerForAccount (3), updated
createStripeSession tests (3), new shouldStampCustomerMetadata tests
(3). Full suite: 471 files / 2763 tests green; lint:check clean;
typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview May 12, 2026 11:11pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Warning

Rate limit exceeded

@sweetmantech has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 37 minutes and 43 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a8b9666b-9ec8-429c-a1a4-919a24737c4c

📥 Commits

Reviewing files that changed from the base of the PR and between 929aa0f and 1234882.

⛔ Files ignored due to path filters (1)
  • lib/stripe/__tests__/resolveStripeCustomerForAccount.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/stripe/resolveStripeCustomerForAccount.ts
📝 Walkthrough

Walkthrough

Adds a helper that resolves (or creates) a Stripe Customer by accountId, and updates checkout session creation to call that helper and pass the resolved customer ID into the Stripe Checkout session parameters.

Changes

Stripe Customer Resolution

Layer / File(s) Summary
Stripe customer resolution helper
lib/stripe/resolveStripeCustomerForAccount.ts
New exported async function that searches Stripe customers by metadata.accountId (limit 1), returns an existing customer ID if found, or creates a new customer with metadata: { accountId } using a deterministic idempotencyKey. Includes module docs about Stripe Customer Search eventual consistency and concurrent-creation races.
Session creation customer binding
lib/stripe/createStripeSession.ts
Imports and calls resolveStripeCustomerForAccount(accountId) inside createStripeSession, assigns the returned customer ID into Stripe.Checkout.SessionCreateParams before creating the Checkout Session. Function signature is unchanged.

Sequence Diagram

sequenceDiagram
  participant createStripeSession
  participant resolveStripeCustomerForAccount
  participant StripeAPI as Stripe API
  createStripeSession->>resolveStripeCustomerForAccount: accountId
  resolveStripeCustomerForAccount->>StripeAPI: search customers by metadata.accountId (limit=1)
  alt Customer found
    StripeAPI-->>resolveStripeCustomerForAccount: existing customerId
  else Customer not found
    resolveStripeCustomerForAccount->>StripeAPI: create customer with metadata.accountId (idempotencyKey)
    StripeAPI-->>resolveStripeCustomerForAccount: new customerId
  end
  resolveStripeCustomerForAccount-->>createStripeSession: customerId
  createStripeSession->>StripeAPI: create checkout session with customerId
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • recoupable/api#493: Introduced createStripeSession; this PR modifies that flow to bind a resolved Stripe customer ID by accountId.

Poem

🎫 A lookup hums, a customer found or born,
IDs stitched to sessions before the morn.
Search, create, idempotent key in hand—
Stripe ties accounts across the land. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed Clean code standards met: file-function names match, functions focused on single responsibility, no duplication, clear documentation, acceptable complexity and nesting, comprehensive test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stamp-sub-customer-metadata-and-backfill

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Confidence score: 3/5

  • There is some merge risk because lib/stripe/resolveStripeCustomerForAccount.ts has a medium-severity issue (6/10, confidence 7/10): interpolating unescaped accountId into a Stripe search query can produce malformed or potentially injectable queries, which could affect customer lookup behavior.
  • The issue in scripts/backfill-subscriber-customer-metadata.ts is lower impact (4/10) and primarily maintainability-focused (file length over the 100-line guideline), so it is unlikely to block functionality by itself.
  • Pay close attention to lib/stripe/resolveStripeCustomerForAccount.ts, scripts/backfill-subscriber-customer-metadata.ts - first for query-safety in Stripe search construction, second for maintainability/style-rule compliance.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/stripe/resolveStripeCustomerForAccount.ts">

<violation number="1" location="lib/stripe/resolveStripeCustomerForAccount.ts:18">
P2: Escape `accountId` before interpolating it into the Stripe search query string to avoid malformed/injectable queries.</violation>
</file>

<file name="scripts/backfill-subscriber-customer-metadata.ts">

<violation number="1" location="scripts/backfill-subscriber-customer-metadata.ts:1">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

New file exceeds the rule-mandated 100-line maximum, violating the maintainability guideline.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client Frontend
    participant Route as API Route
    participant Session as createStripeSession
    participant Resolver as resolveStripeCustomerForAccount
    participant Stripe as Stripe API
    participant DB as Internal Database

    Note over Client,DB: Subscription Checkout Flow (PR 2a)

    Client->>Route: POST /api/subscriptions/sessions
    Route->>Session: createStripeSession(accountId, successUrl)

    Session->>Resolver: resolveStripeCustomerForAccount(accountId)

    Resolver->>Stripe: customers.search(query: metadata['accountId']:'{accountId}', limit: 1)

    alt Customer found via search
        Stripe-->>Resolver: return existing Customer ID
    else No Customer found
        Resolver->>Stripe: customers.create(metadata: { accountId })
        Stripe-->>Resolver: return new Customer ID
    end
    Resolver-->>Session: customer ID (cus_xxx)

    Session->>Stripe: checkout.sessions.create(customer: cus_xxx, line_items, mode: subscription, client_reference_id: accountId)
    Stripe-->>Session: session with URL
    Session-->>Route: checkout session
    Route-->>Client: redirect to Stripe Checkout

    Note over Client,Stripe: Credits Checkout Flow (unchanged)

    Client->>Route: POST /api/credits/sessions
    Route->>Stripe: checkout.sessions.create(line_items, mode: payment)
    Stripe-->>Route: session with URL
    Route-->>Client: redirect to Stripe Checkout

    Note over Resolver,Stripe: Backfill Script (one-time, for legacy Customers)

    participant Script as backfill-subscriber-customer-metadata
    participant Decision as shouldStampCustomerMetadata

    Script->>Stripe: subscriptions.list(limit: 100)
    loop For each subscription
        Stripe-->>Script: subscription with metadata.accountId
        alt Subscription has no accountId
            Script->>Script: skip
        else Subscription has accountId
            Script->>Stripe: customers.retrieve(sub.customer)
            alt Customer is deleted
                Script->>Script: skip
            else Customer exists
                Script->>Decision: shouldStampCustomerMetadata(customer.metadata, accountId)
                alt Decision: skip (already-stamped)
                    Decision-->>Script: skip
                else Decision: skip (conflict)
                    Decision-->>Script: skip + log warning
                else Decision: stamp (missing)
                    opt Not dry-run
                        Script->>Stripe: customers.update(customerId, metadata: { ...existing, accountId })
                    end
                end
            end
        end
    end
    Script->>Script: log stats summary
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

*/
export async function resolveStripeCustomerForAccount(accountId: string): Promise<string> {
const search = await stripeClient.customers.search({
query: `metadata['accountId']:'${accountId}'`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Escape accountId before interpolating it into the Stripe search query string to avoid malformed/injectable queries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/resolveStripeCustomerForAccount.ts, line 18:

<comment>Escape `accountId` before interpolating it into the Stripe search query string to avoid malformed/injectable queries.</comment>

<file context>
@@ -0,0 +1,31 @@
+ */
+export async function resolveStripeCustomerForAccount(accountId: string): Promise<string> {
+  const search = await stripeClient.customers.search({
+    query: `metadata['accountId']:'${accountId}'`,
+    limit: 1,
+  });
</file context>

Comment thread scripts/backfill-subscriber-customer-metadata.ts Outdated
Per review: the prior backfill only stamped Customers attached to a
subscription. Ex-subscribers, trial users, manual/admin creations, and
the one legacy sub with missing metadata.accountId would never be
backfilled — they'd end up with duplicate Customers after PR 2b ships
(their existing un-stamped Customer plus a new precreated one).

Restructured to iterate ALL Stripe Customers with a two-stage
resolution:

1. **Subscription map (authoritative)** — Phase 1 prefetches every
   subscription's `metadata.accountId` and indexes by customer.id.
   Same source as before, just collected up-front.

2. **Email lookup (heuristic)** — Phase 2 iterates `customers.list`
   and falls back to `selectAccountByEmail(customer.email)` for any
   Customer not in the subscription map. Email is trimmed +
   lowercased before lookup since Stripe email casing isn't normalized.

The pure resolver (`resolveAccountIdForCustomer`) is extracted with
its own test file — 5 unit tests cover sub-priority, email fallback,
email normalization, no-email, and no-match cases.

Renamed: `backfill-subscriber-customer-metadata.ts` →
`backfill-customer-metadata.ts` to reflect the broader scope.

New env requirement at runtime: `SUPABASE_URL` and `SUPABASE_KEY`
alongside `STRIPE_SK`, used by the email lookup.

New stats:
  {
    customersScanned, customersAlreadyStamped,
    stampedViaSub, stampedViaEmail,
    conflicts, unresolved, errors
  }

Idempotency preserved: re-runs skip already-stamped Customers
(`customersAlreadyStamped`); conflicts (existing different accountId)
are logged without overwriting.

Tests: 5 new for `resolveAccountIdForCustomer`. Full suite 472 files
/ 2768 tests green; lint clean; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Update — backfill now covers all Customers, not just active subscribers

Pushed 0b1f7dcb in response to feedback that the prior version would leave ex-subscribers, trial users, and the 1 legacy missingAccountIdOnSub Customer with duplicate Customers after PR 2b shipped.

What changed

The script (renamed backfill-subscriber-customer-metadata.tsbackfill-customer-metadata.ts) now iterates all Stripe Customers with two-stage resolution:

  1. Subscription map (authoritative) — Phase 1 prefetches every sub's metadata.accountId, indexed by customer.id. Same source as before, just collected up-front.
  2. Email lookup (heuristic) — Phase 2 iterates customers.list and falls back to selectAccountByEmail(customer.email) for any Customer not in the sub map. Email is trimmed + lowercased before DB lookup since Stripe email casing isn't normalized.

Pure resolver resolveAccountIdForCustomer is extracted with its own test file (5 unit tests).

Runtime env

SUPABASE_URL=... SUPABASE_KEY=... STRIPE_SK=sk_live_... \
  npx tsx scripts/backfill-customer-metadata.ts --dry-run

New stats output

{
  "customersScanned": N,
  "customersAlreadyStamped": N,
  "stampedViaSub": N,           // resolved via subscription metadata
  "stampedViaEmail": N,         // resolved via account_emails lookup
  "conflicts": N,
  "unresolved": N,              // not in sub map, no matching account email
  "errors": N
}

Expected vs prior dry-run

Your prior dry-run showed 12 sub-based stamps + 1 missingAccountIdOnSub. With the new logic, you'll likely see:

  • stampedViaSub: 12 — same as before
  • stampedViaEmail: N — the missing-sub Customer (if their email matches an account) + any non-subscriber Customers in Stripe with matching account emails (ex-subscribers, trial signups, etc.)
  • unresolved: M — truly orphan Customers (no sub link, no matching account email)

Tests

Test file Tests
scripts/__tests__/resolveAccountIdForCustomer.test.ts (new) 5
scripts/__tests__/shouldStampCustomerMetadata.test.ts (existing) 3
lib/stripe/__tests__/resolveStripeCustomerForAccount.test.ts (this PR) 3
lib/stripe/__tests__/createStripeSession.test.ts (this PR) 3

Full suite: 472 / 2768 ✅, lint clean, typecheck clean.

Suggested re-run

SUPABASE_URL=... SUPABASE_KEY=... STRIPE_SK=sk_live_... \
  npx tsx scripts/backfill-customer-metadata.ts --dry-run

Inspect the output, then drop --dry-run for the live run.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/backfill-customer-metadata.ts">

<violation number="1" location="scripts/backfill-customer-metadata.ts:1">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

File exceeds the 100-line maximum and combines multiple responsibilities in one script module.</violation>

<violation number="2" location="scripts/backfill-customer-metadata.ts:55">
P2: `subscriptions.list()` without `status: 'all'` excludes canceled subscriptions, so the authoritative subscription map will miss customers whose subscriptions ended. Add `status: 'all'` to match the documented intent of scanning all subscriptions.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread scripts/backfill-customer-metadata.ts Outdated
Comment thread scripts/backfill-customer-metadata.ts Outdated
The customer-metadata backfill has been run live against production
Stripe (42 customers scanned, 40 stamped, 0 unresolved, 0 errors).
The script and its pure-helper companions have served their purpose
and don't need to live in the repo permanently.

Removed:
  scripts/backfill-customer-metadata.ts
  scripts/resolveAccountIdForCustomer.ts
  scripts/shouldStampCustomerMetadata.ts
  scripts/__tests__/resolveAccountIdForCustomer.test.ts
  scripts/__tests__/shouldStampCustomerMetadata.test.ts

Retained from this PR:
  lib/stripe/resolveStripeCustomerForAccount.ts          (used by sub
                                                          Checkout +
                                                          PR 2b)
  lib/stripe/__tests__/resolveStripeCustomerForAccount.test.ts
  lib/stripe/createStripeSession.ts                      (sub Checkout
                                                          precreates
                                                          Customer)
  lib/stripe/__tests__/createStripeSession.test.ts

Future stamping happens automatically — every new subscription
Checkout calls `resolveStripeCustomerForAccount` which stamps
`metadata.accountId` on the Customer at creation time. The one-time
backfill closed the gap for legacy Customers; no need to keep the
script around.

Tests: 470 files / 2760 ✅ ; lint clean ; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/stripe/resolveStripeCustomerForAccount.ts`:
- Around line 17-20: The Stripe search query is vulnerable to syntax breakage
because accountId is interpolated directly into the query passed to
stripeClient.customers.search; sanitize/validate accountId before building the
query used in resolveStripeCustomerForAccount.ts. Fix by normalizing accountId
(prefer a whitelist: allow only expected chars like [A-Za-z0-9-_] or, if broader
values required, escape special characters) and/or escaping backslashes and
single quotes (replace "\" with "\\\\" and "'" with "\\'") before constructing
the query string for stripeClient.customers.search so queries like `query:
\`metadata['accountId']:'${sanitizedAccountId}'\`` cannot be broken by
malicious/odd input.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 64a37b7c-4c4d-4b30-9a6f-57bde5436abf

📥 Commits

Reviewing files that changed from the base of the PR and between 228e211 and 7969332.

⛔ Files ignored due to path filters (2)
  • lib/stripe/__tests__/createStripeSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/resolveStripeCustomerForAccount.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • lib/stripe/createStripeSession.ts
  • lib/stripe/resolveStripeCustomerForAccount.ts

Comment on lines +17 to +20
const search = await stripeClient.customers.search({
query: `metadata['accountId']:'${accountId}'`,
limit: 1,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Escape or validate accountId to prevent Stripe query injection.

The accountId is directly interpolated into the Stripe search query without escaping. If accountId contains special characters like single quotes ('), colons (:), or backslashes (\), the query will break or behave unexpectedly.

For example, if accountId = "test'foo", the resulting query becomes:

metadata['accountId']:'test'foo'

This breaks Stripe's query syntax and could lead to failed lookups, causing duplicate customer creation even when a customer exists.

🛡️ Proposed fix: sanitize accountId before query construction
 export async function resolveStripeCustomerForAccount(accountId: string): Promise<string> {
+  // Escape single quotes and backslashes for Stripe query syntax
+  const escapedAccountId = accountId.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+  
   const search = await stripeClient.customers.search({
-    query: `metadata['accountId']:'${accountId}'`,
+    query: `metadata['accountId']:'${escapedAccountId}'`,
     limit: 1,
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/stripe/resolveStripeCustomerForAccount.ts` around lines 17 - 20, The
Stripe search query is vulnerable to syntax breakage because accountId is
interpolated directly into the query passed to stripeClient.customers.search;
sanitize/validate accountId before building the query used in
resolveStripeCustomerForAccount.ts. Fix by normalizing accountId (prefer a
whitelist: allow only expected chars like [A-Za-z0-9-_] or, if broader values
required, escape special characters) and/or escaping backslashes and single
quotes (replace "\" with "\\\\" and "'" with "\\'") before constructing the
query string for stripeClient.customers.search so queries like `query:
\`metadata['accountId']:'${sanitizedAccountId}'\`` cannot be broken by
malicious/odd input.

Surfaced during preview testing of PR 2a: two back-to-back sub
Checkout calls for a brand-new accountId created TWO Customers
instead of reusing the first one. The race is exactly the one the
helper's JSDoc warned about — Stripe's customer search index is
eventually consistent (~60s lag), so call #2's search misses the
Customer that call #1 just created.

Fix: pass a deterministic Stripe idempotency key derived from
accountId to `customers.create`. Within Stripe's 24-hour idempotency
window, any two creates with the same key resolve to the SAME
Customer — Stripe dedupes server-side. The search-lag race is now
harmless: even when both calls hit the empty-search branch, the
underlying create call collapses to one Customer.

Behavior change is invisible outside the race scenario. For normal
user behavior (sign up, return days later) the 24-hour window has
long expired and everything works as before.

Tests: existing test now asserts the idempotency key is passed; new
test asserts two back-to-back calls for the same accountId send the
same idempotency key.

Full suite: 470 files / 2761 tests green; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.


const created = await stripeClient.customers.create(
{ metadata: { accountId } },
{ idempotencyKey: `customer-create-account-${accountId}` },
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS - why not simplify to just

Suggested change
{ idempotencyKey: `customer-create-account-${accountId}` },
{ idempotencyKey: accountId },

Per sweetman review on PR #555 (KISS): the prefixed
`customer-create-account-${accountId}` was ceremony — the accountId
itself is already globally unique within our system, and Stripe's
idempotency keys are scoped per-account-per-resource. Dropping the
prefix has identical dedupe semantics with less noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant