fix(auth-server: Surface/guard for failed PayPal credit note refunds#20741
Open
david1alvarez wants to merge 1 commit into
Open
fix(auth-server: Surface/guard for failed PayPal credit note refunds#20741david1alvarez wants to merge 1 commit into
david1alvarez wants to merge 1 commit into
Conversation
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
Contributor
There was a problem hiding this comment.
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
$0refunds 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) { |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Because:
completedwith no refund ID, while their subscription was cancelled and billing agreement removed.handleCreditNoteEventderives the refund amount solely fromcreditNote.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$0PayPalRefundTransactionrather than a real refund, and nothing surfaces — the missing-transaction-id branch only logs.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:
log.errorto a Sentry report plus apaypalRefundReasontag on the invoice, reusing the existingRefusedErrorhandling pattern.out_of_band_amount, instead of firing a bogus$0RefundTransaction.customer_balance_transaction, to prevent double-refunding. Final guard order is missing-txn → balance-credit → no-out-of-band → exceeds-invoice → issue refund.out_of_band_amountofnull/0— and confirms the existing refund/refused paths still pass.Closes #PAY-3762
Checklist
Put an
xin the boxes that applyHow to review (Optional)
handleCreditNoteEventinpackages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts, and the matching cases instripe-webhooks.spec.ts.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
issueRefundwith 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 astripe.invoices.updatewrite, so these escalation paths now make a Stripe write where they previously didn't.