Skip to content

fix(auth-server: Surface/guard for failed PayPal credit note refunds#20741

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

fix(auth-server: Surface/guard for failed PayPal credit note refunds#20741
david1alvarez wants to merge 1 commit into
mainfrom
PAY-3762

Conversation

@david1alvarez

Copy link
Copy Markdown
Contributor

Because:

  • A PayPal customer was refunded by support via a Stripe credit note but never received a payout — the PayPal transaction stayed completed with no refund ID, while their subscription was cancelled and billing agreement removed.
  • Root cause: handleCreditNoteEvent derives the refund amount solely from creditNote.out_of_band_amount. When a credit note has no out-of-band amount (e.g. the credit was applied to the customer's Stripe balance instead of disbursed), the handler falls through to a $0 PayPal RefundTransaction rather than a real refund, and nothing surfaces — the missing-transaction-id branch only logs.
  • The handler also never inspects customer_balance_transaction, so a credit note carrying both a balance credit and an out-of-band amount would refund the customer twice (PayPal payout + Stripe credit).

This commit:

  • Escalates the silent-failure paths (missing PayPal transaction id, and credit note exceeding the invoice amount) from a bare log.error to a Sentry report plus a paypalRefundReason tag on the invoice, reusing the existing RefusedError handling pattern.
  • Adds a guard that withholds the PayPal refund when the credit note has no out_of_band_amount, instead of firing a bogus $0 RefundTransaction.
  • Adds a guard that withholds the PayPal refund when the credit note applied a customer_balance_transaction, to prevent double-refunding. Final guard order is missing-txn → balance-credit → no-out-of-band → exceeds-invoice → issue refund.
  • Adds unit coverage for each escalation path — including the both-fields-present double-refund case and out_of_band_amount of null/0 — and confirms the existing refund/refused paths still pass.

Closes #PAY-3762

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: handleCreditNoteEvent in packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts, and the matching cases in stripe-webhooks.spec.ts.
  • Risky or complex parts: the two new guards are the only behavioral changes — everything else is reporting. The balance-credit guard is the one that can withhold a refund that previously would have been issued.

Other information (Optional)

Most of this is logging/reporting — Guard A (missing txn id) and the exceeds-invoice branch already refused to refund, so those only gain Sentry + the invoice tag. The two genuinely functional changes are the no-out-of-band guard (previously called issueRefund with a null amount → AMT 0.00; now escalates instead) and the balance-credit guard (previously unguarded; now suppresses the PayPal refund to avoid a double payout). The invoice tag itself is a stripe.invoices.update write, so these escalation paths now make a Stripe write where they previously didn't.

Because:

* A PayPal customer was refunded by support via a Stripe credit note but never received a payout — the PayPal transaction stayed `completed` with no refund ID, while their subscription was cancelled and billing agreement removed.
* Root cause: `handleCreditNoteEvent` derives the refund amount solely from `creditNote.out_of_band_amount`. When a credit note has no out-of-band amount (e.g. the credit was applied to the customer's Stripe balance instead of disbursed), the handler falls through to a `$0` PayPal `RefundTransaction` rather than a real refund, and nothing surfaces — the missing-transaction-id branch only logs.
* The handler also never inspects `customer_balance_transaction`, so a credit note carrying both a balance credit and an out-of-band amount would refund the customer twice (PayPal payout + Stripe credit).

This commit:

* Escalates the silent-failure paths (missing PayPal transaction id, and credit note exceeding the invoice amount) from a bare `log.error` to a Sentry report plus a `paypalRefundReason` tag on the invoice, reusing the existing `RefusedError` handling pattern.
* Adds a guard that withholds the PayPal refund when the credit note has no `out_of_band_amount`, instead of firing a bogus `$0` `RefundTransaction`.
* Adds a guard that withholds the PayPal refund when the credit note applied a `customer_balance_transaction`, to prevent double-refunding. Final guard order is missing-txn → balance-credit → no-out-of-band → exceeds-invoice → issue refund.
* Adds unit coverage for each escalation path — including the both-fields-present double-refund case and `out_of_band_amount` of `null`/`0` — and confirms the existing refund/refused paths still pass.

Closes #PAY-3762
@david1alvarez david1alvarez requested a review from a team as a code owner June 12, 2026 20:45
Copilot AI review requested due to automatic review settings June 12, 2026 20:45

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

This PR hardens handleCreditNoteEvent (Stripe credit_note.created webhook handling for PayPal-funded subscriptions) to prevent incorrect/duplicate PayPal refunds and to surface previously-silent failure modes via Sentry plus an invoice metadata tag.

Changes:

  • Add escalation + invoice-tagging when a PayPal refund cannot/should not be auto-issued (missing PayPal txn id, customer balance credit applied, no out-of-band amount, credit note exceeds invoice amount).
  • Add guards to withhold PayPal refunds in scenarios that would otherwise create bogus $0 refunds or double-refunds.
  • Add/extend unit tests covering the new guard/escalation paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts Adds new guard/early-return paths, invoice tagging, and Sentry reporting in handleCreditNoteEvent.
packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts Adds unit coverage for the new guard/escalation behaviors in handleCreditNoteEvent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

// No out-of-band amount means there's nothing to disburse via PayPal.
if (!creditNote.out_of_band_amount && invoice.amount_due > 0) {
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.

2 participants