Skip to content

feat(billing): disable auto top-up when the default payment method is removed - #3560

Open
baktun14 wants to merge 2 commits into
fix/billing-default-payment-method-syncfrom
feat/billing-auto-disable-reload-on-default-removal
Open

feat(billing): disable auto top-up when the default payment method is removed#3560
baktun14 wants to merge 2 commits into
fix/billing-default-payment-method-syncfrom
feat/billing-auto-disable-reload-on-default-removal

Conversation

@baktun14

@baktun14 baktun14 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 to main after it merges.

What

API

  • StripeController.removePaymentMethod reads whether the card is the local default before detaching, then calls the new WalletSettingService.disableAutoReload(userId) after a default-card detach. The 409 guard (PaymentMethodService.assertRemovable) is removed along with the WalletSettingRepository dependency it pulled into PaymentMethodService; a simple isDefaultPaymentMethod read replaces it.
  • WalletSettingService.disableAutoReload is idempotent: no-ops when the settings row is absent or already disabled, flips autoReloadEnabled to false, cancels the pending WalletBalanceReloadCheck pg-boss job, and logs an AUTO_RELOAD_DISABLED event.
  • No endpoint shape changes (DELETE stays 204), so no OpenAPI/console-api-types regeneration.

deploy-web

  • PaymentMethodsRow always renders the actions menu and the Remove item; isAutoReloadEnabled prop dropped from Row/View.
  • PaymentMethodsContainer confirms every removal via usePopup().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.
  • removePaymentMethod mutation additionally invalidates the wallet-settings query so the Auto Top-Up switch in AccountOverview flips 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-existing user.repository/api-key.repository throttle flakes that fail identically on the base commit. Lint --quiet clean, tsc error count unchanged vs base (40 = 40).
  • apps/deploy-web: full unit suite passes (2989 passed, 5 skipped). Lint --quiet clean, tsc error count unchanged vs base (180 = 180).

@baktun14
baktun14 requested a review from a team as a code owner August 5, 2026 18:33
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 95a41de9-5cde-41cf-a8c7-b8af4f82a3f0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

Comment on lines 178 to 190
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 isDefaultPaymentMethodawait 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:

  1. User has one card, marked default, with auto top-up enabled.
  2. Client calls DELETE /v1/stripe/payment-methods/{id}.
  3. Ownership check passes, isDefaultPaymentMethod returns true.
  4. stripe.detachPaymentMethod succeeds — the card is now gone from Stripe.
  5. disableAutoReload runs: walletSettingRepository.updateById succeeds, flips autoReloadEnabled to false, but then walletReloadJobService.cancelCreatedByUserId throws because of a transient pg-boss/DB hiccup.
  6. The controller's catch block sees an unrecognized error and rethrows it — the client receives a 500 for a removal that actually succeeded.
  7. If the client naively retries the same DELETE, stripe.retrievePaymentMethod on the now-detached method returns an object with customer: null (Stripe doesn't delete the record, just detaches it), so the assert(customerId === currentUser.stripeCustomerId, 403, ...) ownership check fails, surfacing a misleading 403 instead of ever reaching disableAutoReload again.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant