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 cold
    ones need a migration.

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.
  • Adds update-firestore-acacia-records, a one-off script that spots mirror
    subscription and invoice docs still carrying the top-level fields basil
    dropped, and rewrites them through StripeFirestore, behind a --dry-run that
    defaults to true. check-firestore-stripe-sync is left alone — it stays a
    drift reporter that nudges Stripe into re-emitting a webhook.
  • Tallies the run so it reconciles: every customer, subscription and invoice lands
    in exactly one bucket, resync outcomes are counted apart from detections, and
    each term is counted where it happens rather than derived — so a sum that does
    not balance is itself a finding. Every term that is not a clean pass also names
    its record in a log line.

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 update-firestore-acacia-records/.
  • Suggested review order: the stripe.ts block and its spec cases, then the two
    singlePlan() call sites, then the new 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 new 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. Its resync calls leave
    ignoreErrors at the default so StripeFirestore throws: an orphaned invoice
    subtree is reachable by our path traversal but invisible to the customer lookup
    inside fetchAndInsertInvoice, and should land in the failed column rather than
    count as a write that never happened.

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" returns the complete step-4 deletion list;
    update-firestore-acacia-records goes wholesale.
  • 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 the new script's 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. A mirror doc missing
    altogether is drift rather than an outdated shape, so the new script leaves it
    to check-firestore-stripe-sync.

@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
);
@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?

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.

🟡 Changes recommended

Critical migration race conditions and moderate pagination and billing-cadence issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

packages/fxa-auth-server/lib/payments/stripe.ts:2462

  • When the previous price diff omits recurring, these fallbacks copy the new plan's cadence rather than the old plan's cadence. For a monthly-to-annual change, the upgrade email will therefore describe the previous billing cycle as annual. Resolve interval and interval_count from the old price ID (for example via findAbbrevPlanById) instead of falling back to planNew; the new omission test should assert those old-plan values.
        interval: planOldDiff.interval ?? planNew.interval,
        interval_count: planOldDiff.interval_count ?? planNew.interval_count,

packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.ts:250

  • A missing subscription is counted but its identifiers are never logged. This breaks the PR's stated reconciliation contract that every non-clean bucket names its record, and an operator cannot scope a follow-up from the completion tally. Emit a warning here containing customerId, uid, and stripeSubscription.id.
      if (!subscriptionDoc.exists) {
        this.subscriptionsMissingDoc++;
      } else if (isAcaciaShape(subscriptionDoc.data())) {
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Balanced

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 cold docs need a migration of their own.

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.
* Adds update-firestore-acacia-records, a one-off script that rewrites
  mirror docs still carrying the top-level fields basil dropped, behind a
  --dry-run defaulting to true.
* Tallies that run so every record lands in exactly one bucket, with the
  failures named in the log for follow-up.

Addresses: PAY-3900
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