Skip to content

release - #2310

Merged
charlietlamb merged 6 commits into
mainfrom
dev
Jul 20, 2026
Merged

release#2310
charlietlamb merged 6 commits into
mainfrom
dev

Conversation

@charlietlamb

@charlietlamb charlietlamb commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary by cubic

Auto-syncs eligible Stripe subscriptions and schedules when creating a customer, and serializes syncs to prevent duplicates and race conditions. This ensures existing Stripe billing is imported once, preserves license quantities, and avoids duplicating paid defaults.

  • New Features

    • Import Stripe subscriptions and active schedules on customer creation (when stripe_id is provided) via prepareAutoSyncStripeCustomer and autoSyncStripeCustomerWithLock.
    • Serialize customer syncs with a Redis lock (withStripeSyncCustomerLock); also applied to the subscription-created webhook.
    • Build sync from the latest customer state and run only when canAutoSync allows; supports schedule phases and keeps seat/license quantities exact.
    • Skip unknown Stripe prices and keep defaults unchanged for empty Stripe customers.
  • Bug Fixes

    • Avoid attaching paid default products when an active paid subscription already exists.
    • Prevent duplicate imports across retries and concurrent requests.

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

Review in cubic

Greptile Summary

This PR adds automatic Stripe-to-Autumn synchronisation when a customer is created with an existing stripe_id: pending subscriptions and schedules are imported under a Redis spin-lock that also serialises concurrent subscription.created webhook deliveries, preventing double-imports.

  • [Improvements] New syncCreatedCustomerFromStripe step in createCustomerWithDefaults fetches all unlinked Stripe subscriptions/schedules and syncs them before attaching paid defaults, guarded by a per-customer Redis lock shared with the webhook handler.
  • [Bug fixes] autoSyncFromSubscription (webhook path) now re-fetches fresh customer state inside the lock, so a webhook that fires concurrently with a create-sync sees the already-imported subscription and skips it.
  • [Improvements] shouldAttachPaidDefaults is now gated on hasActivePaidSubscription, preventing a duplicate Stripe subscription from being created when the import already brought in an active one.

Confidence Score: 3/5

The customer-creation path now has an unrecoverable sync gap: if the Stripe import throws on first attempt, any subsequent retry returns the existing customer immediately without re-running the sync.

The early-return for existing customers (line 70 of createCustomerWithDefaults) was written before the sync step was introduced. With the new ordering, a transient Stripe error on first creation creates a customer record in the DB and a failed sync; every subsequent retry hits the early-return and silently returns the customer without synced subscriptions. The lock implementation also shares a TTL/deadline value and relies on an unchecked DEL, which could break serialisation under sustained load.

createCustomerWithDefaults.ts and autoSyncStripeCustomer.ts need the most attention — the retry bypass and sequential-failure behaviour interact to permanently strand partially-synced customers.

Important Files Changed

Filename Overview
server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts Added Stripe sync step before paid-defaults attachment; the early-return for existing customers (line 70) means a failed first-attempt sync can never be retried via the create path.
server/src/internal/billing/v2/actions/sync/utils/withStripeSyncCustomerLock.ts New Redis spin-lock for serialising concurrent syncs; lock TTL equals acquisition deadline and clearLock uses unchecked DEL, creating a mutual-exclusion gap if run() exceeds 30 s.
server/src/internal/billing/v2/actions/sync/autoSyncStripeCustomer.ts New orchestrator that fetches pending Stripe subscriptions/schedules and syncs them sequentially; a single syncV2 failure aborts remaining subscriptions.
server/src/internal/billing/v2/actions/sync/setup/prepareAutoSyncStripeCustomer.ts New helper that lists all Stripe subscriptions/schedules for a customer and filters out those already linked to Autumn customer_products; logic looks correct.
server/src/internal/customers/actions/createWithDefaults/syncCreatedCustomerFromStripe.ts New function that triggers the lock-guarded Stripe sync when a stripe_id is supplied on customer creation and re-fetches fresh customer state afterwards; no issues.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant API as customers.create API
    participant CWD as createCustomerWithDefaults
    participant SCFS as syncCreatedCustomerFromStripe
    participant ASWL as autoSyncStripeCustomerWithLock
    participant Lock as withStripeSyncCustomerLock (Redis)
    participant PASC as prepareAutoSyncStripeCustomer
    participant Stripe as Stripe API
    participant DB as Database (CusService)
    participant Webhook as Stripe Webhook Handler

    API->>CWD: create(customerId, stripe_id)
    CWD->>CWD: Phase 1 — create Autumn customer
    CWD->>SCFS: syncCreatedCustomerFromStripe(stripe_id)
    SCFS->>ASWL: autoSyncStripeCustomerWithLock(customerId)
    ASWL->>Lock: acquire lock:stripe-sync:org:env:customerId
    Lock-->>ASWL: acquired
    ASWL->>PASC: prepareAutoSyncStripeCustomer
    PASC->>Stripe: list subscriptions + schedules
    PASC->>DB: CusService.getFull (current state)
    PASC-->>ASWL: syncCandidates (pending only)
    loop each syncCandidate
        ASWL->>DB: syncV2(params)
    end
    ASWL->>Lock: clearLock
    Lock-->>ASWL: released
    SCFS->>DB: CusService.getFull (refreshed)
    SCFS-->>CWD: updated fullCustomer
    CWD->>CWD: skip paid defaults if active subscription exists
    CWD-->>API: FullCustomer
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant API as customers.create API
    participant CWD as createCustomerWithDefaults
    participant SCFS as syncCreatedCustomerFromStripe
    participant ASWL as autoSyncStripeCustomerWithLock
    participant Lock as withStripeSyncCustomerLock (Redis)
    participant PASC as prepareAutoSyncStripeCustomer
    participant Stripe as Stripe API
    participant DB as Database (CusService)
    participant Webhook as Stripe Webhook Handler

    API->>CWD: create(customerId, stripe_id)
    CWD->>CWD: Phase 1 — create Autumn customer
    CWD->>SCFS: syncCreatedCustomerFromStripe(stripe_id)
    SCFS->>ASWL: autoSyncStripeCustomerWithLock(customerId)
    ASWL->>Lock: acquire lock:stripe-sync:org:env:customerId
    Lock-->>ASWL: acquired
    ASWL->>PASC: prepareAutoSyncStripeCustomer
    PASC->>Stripe: list subscriptions + schedules
    PASC->>DB: CusService.getFull (current state)
    PASC-->>ASWL: syncCandidates (pending only)
    loop each syncCandidate
        ASWL->>DB: syncV2(params)
    end
    ASWL->>Lock: clearLock
    Lock-->>ASWL: released
    SCFS->>DB: CusService.getFull (refreshed)
    SCFS-->>CWD: updated fullCustomer
    CWD->>CWD: skip paid defaults if active subscription exists
    CWD-->>API: FullCustomer
Loading

Comments Outside Diff (3)

  1. server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts, line 70 (link)

    P1 Retry path permanently skips Stripe sync after first-attempt failure

    When syncCreatedCustomerFromStripe throws on the first customers.create() call (e.g., a transient Stripe API error or lock timeout), the customer record is already committed to the DB (Phase 1 succeeded). On any subsequent retry, autumnResult.type === "existing" causes an early return here — before the try block that hosts the sync call is ever reached. The caller receives a 200 response with the customer, but the Stripe subscriptions are never imported. There is currently no mechanism inside the create path to re-attempt the sync once the customer record already exists.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts
    Line: 70
    
    Comment:
    **Retry path permanently skips Stripe sync after first-attempt failure**
    
    When `syncCreatedCustomerFromStripe` throws on the first `customers.create()` call (e.g., a transient Stripe API error or lock timeout), the customer record is already committed to the DB (Phase 1 succeeded). On any subsequent retry, `autumnResult.type === "existing"` causes an early return here — before the `try` block that hosts the sync call is ever reached. The caller receives a `200` response with the customer, but the Stripe subscriptions are never imported. There is currently no mechanism inside the create path to re-attempt the sync once the customer record already exists.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. server/src/internal/billing/v2/actions/sync/utils/withStripeSyncCustomerLock.ts, line 242-277 (link)

    P2 Lock TTL equals acquisition deadline; clearLock uses unchecked DEL

    LOCK_TIMEOUT_MS (30 s) is used both as the Redis key TTL and as the maximum acquisition wait. If run() takes longer than 30 s (e.g., many subscriptions + slow Stripe responses), the key expires in Redis, a waiting process acquires a brand-new lock, and when the original holder finishes and reaches clearLockredis.del(lockKey), it deletes the new holder's lock without any ownership check. A third waiter can then acquire the lock while the second holder is still running, breaking the serialisation guarantee and potentially causing concurrent syncs for the same customer. Consider setting the key TTL longer than the expected worst-case run() duration (e.g., 2–3×), keeping the acquisition deadline at 30 s; or, longer term, storing a unique token in the lock value and checking it before deletion.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: server/src/internal/billing/v2/actions/sync/utils/withStripeSyncCustomerLock.ts
    Line: 242-277
    
    Comment:
    **Lock TTL equals acquisition deadline; `clearLock` uses unchecked `DEL`**
    
    `LOCK_TIMEOUT_MS` (30 s) is used both as the Redis key TTL and as the maximum acquisition wait. If `run()` takes longer than 30 s (e.g., many subscriptions + slow Stripe responses), the key expires in Redis, a waiting process acquires a brand-new lock, and when the original holder finishes and reaches `clearLock``redis.del(lockKey)`, it deletes the new holder's lock without any ownership check. A third waiter can then acquire the lock while the second holder is still running, breaking the serialisation guarantee and potentially causing concurrent syncs for the same customer. Consider setting the key TTL longer than the expected worst-case `run()` duration (e.g., 2–3×), keeping the acquisition deadline at 30 s; or, longer term, storing a unique token in the lock value and checking it before deletion.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. server/src/internal/billing/v2/actions/sync/autoSyncStripeCustomer.ts, line 119-128 (link)

    P2 Single subscription failure aborts all remaining syncs in the same pass

    The for loop awaits syncV2 sequentially with no per-iteration error handling. If syncV2 throws for subscription N, the exception escapes the loop, propagates through withStripeSyncCustomerLock, and surfaces as a failure in syncCreatedCustomerFromStripe. Subscriptions N+1, N+2, … are never processed. Because of the early-return issue on retry (line 70 of createCustomerWithDefaults), those subscriptions then stay unsynced indefinitely. Wrapping each iteration in a try/catch to log and continue would make the sync best-effort and resilient to individual failures.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: server/src/internal/billing/v2/actions/sync/autoSyncStripeCustomer.ts
    Line: 119-128
    
    Comment:
    **Single subscription failure aborts all remaining syncs in the same pass**
    
    The `for` loop awaits `syncV2` sequentially with no per-iteration error handling. If `syncV2` throws for subscription _N_, the exception escapes the loop, propagates through `withStripeSyncCustomerLock`, and surfaces as a failure in `syncCreatedCustomerFromStripe`. Subscriptions _N+1_, _N+2_, … are never processed. Because of the early-return issue on retry (line 70 of `createCustomerWithDefaults`), those subscriptions then stay unsynced indefinitely. Wrapping each iteration in a try/catch to log and continue would make the sync best-effort and resilient to individual failures.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts:70
**Retry path permanently skips Stripe sync after first-attempt failure**

When `syncCreatedCustomerFromStripe` throws on the first `customers.create()` call (e.g., a transient Stripe API error or lock timeout), the customer record is already committed to the DB (Phase 1 succeeded). On any subsequent retry, `autumnResult.type === "existing"` causes an early return here — before the `try` block that hosts the sync call is ever reached. The caller receives a `200` response with the customer, but the Stripe subscriptions are never imported. There is currently no mechanism inside the create path to re-attempt the sync once the customer record already exists.

### Issue 2 of 3
server/src/internal/billing/v2/actions/sync/utils/withStripeSyncCustomerLock.ts:242-277
**Lock TTL equals acquisition deadline; `clearLock` uses unchecked `DEL`**

`LOCK_TIMEOUT_MS` (30 s) is used both as the Redis key TTL and as the maximum acquisition wait. If `run()` takes longer than 30 s (e.g., many subscriptions + slow Stripe responses), the key expires in Redis, a waiting process acquires a brand-new lock, and when the original holder finishes and reaches `clearLock``redis.del(lockKey)`, it deletes the new holder's lock without any ownership check. A third waiter can then acquire the lock while the second holder is still running, breaking the serialisation guarantee and potentially causing concurrent syncs for the same customer. Consider setting the key TTL longer than the expected worst-case `run()` duration (e.g., 2–3×), keeping the acquisition deadline at 30 s; or, longer term, storing a unique token in the lock value and checking it before deletion.

### Issue 3 of 3
server/src/internal/billing/v2/actions/sync/autoSyncStripeCustomer.ts:119-128
**Single subscription failure aborts all remaining syncs in the same pass**

The `for` loop awaits `syncV2` sequentially with no per-iteration error handling. If `syncV2` throws for subscription _N_, the exception escapes the loop, propagates through `withStripeSyncCustomerLock`, and surfaces as a failure in `syncCreatedCustomerFromStripe`. Subscriptions _N+1_, _N+2_, … are never processed. Because of the early-return issue on retry (line 70 of `createCustomerWithDefaults`), those subscriptions then stay unsynced indefinitely. Wrapping each iteration in a try/catch to log and continue would make the sync best-effort and resilient to individual failures.

Reviews (1): Last reviewed commit: "Merge pull request #2308 from useautumn/..." | Re-trigger Greptile

charlietlamb and others added 6 commits July 20, 2026 15:19
Import eligible Stripe subscriptions and schedules during initial customer creation, coordinate with subscription-created webhooks, and cover idempotency, schedules, and license quantities.
Keep customer-wide Stripe discovery as an internal sync helper, make lock wrappers explicit, and cover creation boundaries with eight integration scenarios.
…on-creation

fix(customers): sync stripe billing on customer creation
@capy-ai

capy-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@charlietlamb
charlietlamb merged commit b8e5341 into main Jul 20, 2026
20 of 22 checks passed
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