Skip to content

fix(subscriptions): prepare for the Stripe webhook version cutover - #21122

Open
david1alvarez wants to merge 1 commit into
mainfrom
PAY-3900
Open

fix(subscriptions): prepare for the Stripe webhook version cutover#21122
david1alvarez wants to merge 1 commit into
mainfrom
PAY-3900

Conversation

@david1alvarez

@david1alvarez david1alvarez commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Because

  • Our Stripe webhook version is out of sync with the SDK, so inbound event
    payloads don't match the shapes our code reads. We need to flip it and be able
    to roll back.
  • Basil removed Subscription.plan, and three consumers still read it — the
    upgrade/downgrade email off the webhook payload, and /v1/account plus the
    admin-panel subscription list off the Firestore mirror.
  • The mirror is written only from stripe.*.retrieve, so it has been emitting
    basil docs since the SDK pin landed and already holds both shapes. The sync
    script that has to migrate the stragglers couldn't tell them apart.

This pull request

  • Reads the previous price from previous_attributes.plan (acacia) or the
    subscription item's plan/price (basil), treating an item diff as an upgrade
    only when the price id actually moved — basil reports the billing-period bump
    on every renewal, so without that guard every subscriber gets an upgrade notice
    each cycle.
  • Reads the subscription plan through singlePlan() in subscriptionsToResponse
    and the admin-server subscription list, retiring an as any and a @ts-ignore
    over the removed top-level plan.
  • Tags both Stripe webhook receivers with stripe_api_version, covered on each
    side including that it fires only after signature verification.
  • Teaches the Firestore sync script to spot mirror docs still carrying the
    top-level fields basil dropped, across subscriptions and their invoices. It
    previously compared only scalars present on the freshly fetched Stripe object,
    so a stale doc differed from a current one exactly where it wasn't looking and
    every one of them reported "in sync".
  • Resyncs by writing through StripeFirestore rather than poking customer
    metadata to provoke a webhook, behind a --dry-run that defaults to true.

Issue that this pull request solves

Addresses: PAY-3900

Checklist

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).
  • I have manually reviewed all AI generated code.

How to review (Optional)

  • Key files/areas to focus on: the derivation of planOld in
    extractSubscriptionUpdateEventDetailsForEmail, and the shape detection plus
    resync path in check-firestore-stripe-sync/.
  • Suggested review order: the stripe.ts block and its spec cases, then the two
    singlePlan() call sites, then the sync script end to end.
  • Risky or complex parts: a non-null planOld is the only thing that sends an
    upgrade/downgrade email, and the id comparison is what keeps renewals out of
    that path. The sync script is the migration vehicle for production mirror data,
    so its failure modes matter more than its happy path — errors are logged and
    swallowed per record rather than failing the run.

Screenshots (Optional)

Please attach the screenshots of the changes made in case of change in user interface.

Other information (Optional)

  • This readies steps 1 and 3 of the four on PAY-3900. Flipping the endpoint
    version, running the migration, and removing the dual-format code are still
    open — hence Addresses: rather than Closes:. The endpoint version has no
    repo artifact; it's a Stripe Dashboard setting.
  • grep -rn "PAY-3900: remove" returns the complete step-4 deletion list.
  • The ticket's step 1 assumes the webhook format reaches Firestore. It doesn't:
    the mirror's shape follows the SDK pin, so the flip can't pollute it and a
    rollback can't leave mixed-shape docs behind. What it does mean is that the
    mirror is mixed today, and only records untouched since the SDK pin are stale.
  • Known and left: the customer enumeration in run() uses autoPagingEach,
    whose page fetches bypass the script's Stripe rate limiter, so --rate-limit
    isn't a hard ceiling. payment_methods mirror docs get no shape check, since
    basil barely moved that object.

@david1alvarez
david1alvarez marked this pull request as ready for review August 31, 2026 18:54
@david1alvarez
david1alvarez requested a review from a team as a code owner August 31, 2026 18:54
Copilot AI balanced review requested due to automatic review settings August 31, 2026 18:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds backward-compatible Stripe webhook handling during the API-version transition.

Changes:

  • Normalizes previous plan/price webhook shapes.
  • Prevents renewal events from triggering upgrade emails.
  • Adds Stripe API-version telemetry and subscription tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
stripe-webhook.ts Tags legacy webhook telemetry with API version.
stripe.ts Derives previous pricing across Stripe payload shapes.
stripe.spec.ts Tests payload normalization and renewal filtering.
stripe-webhooks.service.ts Tags NestJS webhook telemetry with API version.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +2448 to +2449
interval: planOldDiff.interval ?? planNew.interval,
interval_count: planOldDiff.interval_count ?? planNew.interval_count,
request.payload,
request.headers['stripe-signature']
);
Sentry.setTag('stripe_api_version', event.api_version);
Comment on lines +33 to +36
Sentry.setTag(
'stripe_api_version',
webhookEventResponse.event.api_version
);
Because:

* The Stripe webhook endpoint's own api_version sets inbound payload
  shape, so the version flip has to be revertible without breaking parsing.
* Basil removed Subscription.plan, which several consumers still read
  straight off the Firestore mirror.
* The mirror is written only from SDK reads, so it already holds both
  shapes, and the sync script that has to migrate the rest could not tell
  them apart.

This commit:

* Reads the previous price from previous_attributes.plan, items.data[].plan
  or items.data[].price, and only when the price id moved.
* Tags both Stripe webhook receivers with stripe_api_version.
* Reads the subscription plan through singlePlan() in
  subscriptionsToResponse and the admin-server subscription list.
* Flags mirror docs still carrying the top-level fields basil dropped,
  across subscriptions and their invoices.
* Resyncs through StripeFirestore rather than a customer metadata write,
  behind a --dry-run defaulting to true.

Addresses: PAY-3900
@david1alvarez david1alvarez changed the title fix(subscriptions): read previous price from both webhook API shapes fix(subscriptions): prepare for the Stripe webhook version cutover Sep 1, 2026
@julianpoy

Copy link
Copy Markdown
Member

Is the check firestore stripe sync script the correct home for this migration? I think this deserves a separate script and shouldn't bear the load of this logic.

Comment on lines +2432 to +2436
// Acacia sent the previous price as the top-level `plan`; basil removed
// that field and reports item changes under `items.data[]` instead —
// including the billing-period bump on every renewal, so only a moved
// price id is an actual upgrade.
//

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] Mind removing these past-present looking comments? I think these types of comments should live in tickets rather than in code

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.

Generally agreed, though in this particular case it may be worth keeping despite its verbosity. We're going to be revisiting this to remove the dual-shape-handling code, and having this context at that point may be useful.

Want me to keep it with a // TODO: remove comment alongside code after Firestore-Stripe Sync script run, or to just remove it?

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.

3 participants