feat(billing): disable auto top-up when the default payment method is removed - #3560
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
| const customerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; | ||
| assert(customerId === currentUser.stripeCustomerId, 403, "Payment method does not belong to the user"); | ||
|
|
||
| await this.paymentMethodService.assertRemovable(paymentMethodId, currentUser.id); | ||
| const wasDefault = await this.paymentMethodService.isDefaultPaymentMethod(paymentMethodId, currentUser.id); | ||
|
|
||
| await this.stripe.detachPaymentMethod(paymentMethodId); | ||
|
|
||
| if (wasDefault) { | ||
| await this.walletSettingService.disableAutoReload(currentUser.id); | ||
| } | ||
| } catch (error: unknown) { | ||
| if (this.stripeErrorService.isKnownError(error, "payment")) { | ||
| throw this.stripeErrorService.toAppError(error, "payment"); |
There was a problem hiding this comment.
🟡 After a successful stripe.detachPaymentMethod, removePaymentMethod awaits walletSettingService.disableAutoReload with no try/catch (stripe.controller.ts:183-187); if that call throws on a transient DB/pg-boss error, the endpoint returns a 5xx even though the card was already detached, and the invariant this PR introduces (no default card ⇒ auto top-up off) can be left broken. A retry then hits a confusing 403 because Stripe now returns the payment method with customer=null. Consider wrapping disableAutoReload in a try/catch that logs the failure so a successful detach is never reported as an error.
Extended reasoning...
What happens: In StripeController.removePaymentMethod (stripe.controller.ts:178-190), the flow is: verify ownership → check isDefaultPaymentMethod → await this.stripe.detachPaymentMethod(paymentMethodId) → if it was the default, await this.walletSettingService.disableAutoReload(currentUser.id). disableAutoReload (wallet-settings.service.ts:52-63) has no try/catch around its two side effects: walletSettingRepository.updateById and walletReloadJobService.cancelCreatedByUserId, the latter of which (wallet-reload-job.service.ts:60-62) just awaits jobQueueService.cancelCreatedBy with no error handling of its own.
Why nothing catches it: Back in the controller, the whole block is wrapped in a try/catch, but the catch only special-cases stripeErrorService.isKnownError(error, "payment"). A DB or pg-boss error from disableAutoReload is not a known Stripe payment error, so it falls through to throw error, producing a 5xx to the client.
Concrete walk-through:
- User has one card, marked default, with auto top-up enabled.
- Client calls
DELETE /v1/stripe/payment-methods/{id}. - Ownership check passes,
isDefaultPaymentMethodreturnstrue. stripe.detachPaymentMethodsucceeds — the card is now gone from Stripe.disableAutoReloadruns:walletSettingRepository.updateByIdsucceeds, flipsautoReloadEnabledto false, but thenwalletReloadJobService.cancelCreatedByUserIdthrows because of a transient pg-boss/DB hiccup.- The controller's catch block sees an unrecognized error and rethrows it — the client receives a 500 for a removal that actually succeeded.
- If the client naively retries the same DELETE,
stripe.retrievePaymentMethodon the now-detached method returns an object withcustomer: null(Stripe doesn't delete the record, just detaches it), so theassert(customerId === currentUser.stripeCustomerId, 403, ...)ownership check fails, surfacing a misleading 403 instead of ever reachingdisableAutoReloadagain.
Impact: This is new failure surface introduced by this PR specifically — the previous assertRemovable guard ran before the detach, so there was no post-detach write in that path that could fail this way. That said, the trigger requires a transient infrastructure failure landing in the narrow window between the Stripe detach and the DB/queue writes; the happy path is unaffected. The worst-case downstream state (auto top-up still marked enabled with no default card) is also handled gracefully elsewhere: getDefaultPaymentMethod drops the stale default and the reload-check job simply finds no card to charge and skips, so this doesn't risk an erroneous charge or data loss — just a misleading error response and a confusing 403 on retry.
Suggested fix: Wrap the disableAutoReload call (or its body) in a try/catch that logs via LoggerService on failure rather than letting the exception propagate, so a successful Stripe detach is never reported to the client as a failure. Alternatively, disabling auto-reload before detaching would avoid a post-detach write entirely, though that changes the ordering guarantees the current tests assert on.
Why
With a single default card and auto top-up enabled, the Payment Methods tab is a dead end: the row's actions menu is hidden entirely, so the card can never be removed. #3559 hardened that same rule server-side with a 409 on
DELETE /v1/stripe/payment-methods/{id}.Product decision (Maxime): removal must always be possible. Deleting the default payment method should instead automatically turn off auto top-up, since there is no card left to charge (no auto-promotion of another card).
Stacked on #3559 (
fix/billing-default-payment-method-sync), which owns the guard this PR replaces. Retarget tomainafter it merges.What
API
StripeController.removePaymentMethodreads whether the card is the local default before detaching, then calls the newWalletSettingService.disableAutoReload(userId)after a default-card detach. The 409 guard (PaymentMethodService.assertRemovable) is removed along with theWalletSettingRepositorydependency it pulled intoPaymentMethodService; a simpleisDefaultPaymentMethodread replaces it.WalletSettingService.disableAutoReloadis idempotent: no-ops when the settings row is absent or already disabled, flipsautoReloadEnabledto false, cancels the pendingWalletBalanceReloadCheckpg-boss job, and logs anAUTO_RELOAD_DISABLEDevent.console-api-typesregeneration.deploy-web
PaymentMethodsRowalways renders the actions menu and the Remove item;isAutoReloadEnabledprop dropped from Row/View.PaymentMethodsContainerconfirms every removal viausePopup().confirm. When the target is the default card and auto top-up is on, the copy warns that removing it turns Auto Top-Up off; otherwise a plain confirmation shows.removePaymentMethodmutation additionally invalidates the wallet-settings query so the Auto Top-Up switch inAccountOverviewflips off immediately after the deletion.Verification (stacked PRs against a non-main base skip the validate CI matrix, so ran locally):
apps/api: unit + functional + integration suites pass; the only failures are the pre-existinguser.repository/api-key.repositorythrottle flakes that fail identically on the base commit. Lint--quietclean, tsc error count unchanged vs base (40 = 40).apps/deploy-web: full unit suite passes (2989 passed, 5 skipped). Lint--quietclean, tsc error count unchanged vs base (180 = 180).