Skip to content

Commit 5646df9

Browse files
committed
fix: harden auto-reload access and amount handling
1 parent 3eb82dc commit 5646df9

14 files changed

Lines changed: 631 additions & 95 deletions

docs/superpowers/specs/2026-07-17-fe-1250-credit-auto-reload-design.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ integration because no auto-reload API exists in the current frontend schema.
2424
- Gate the mount with both `flags.billingControlEnabled` and
2525
`permissions.canManageSubscription`. When the flag is false, no new
2626
component or state is mounted and the existing billing UX is unchanged.
27-
- Freeze the section when the billing status is `paused` or the team plan has
28-
lapsed. Frozen content is dimmed, reports Disabled, and cannot receive input.
27+
- Freeze the section when billing is `paused` or `inactive`, or when the
28+
subscription has `ended`. A canceled subscription that is still active in
29+
its paid-through period remains interactive. Frozen content is dimmed,
30+
reports Disabled, and cannot receive input.
2931
- Keep the prototype's `useAutoReload` composable as the typed frontend wiring
3032
seam. It starts unconfigured and intentionally does not claim persistence.
3133
A later backend adapter can replace its in-memory mutations without changing
@@ -46,7 +48,8 @@ visible states are:
4648
- near limit, with amber percentage text;
4749
- budget exhausted, with red percentage text and a Paused badge;
4850
- configured but Off, with the inner tile dimmed;
49-
- subscription paused or lapsed, with the entire section frozen;
51+
- billing paused or inactive, or subscription ended, with the entire section
52+
frozen; a canceled-but-still-active subscription remains interactive;
5053
- payment at risk, represented only by the existing shared billing banner;
5154
- member access, where the section is absent.
5255

@@ -59,6 +62,26 @@ The section uses the prototype's container-query breakpoint. The integration
5962
container supplies `@container`, preserving the stacked narrow layout and the
6063
two-column wide layout without tying it to the viewport.
6164

65+
## Adversarial hardening
66+
67+
- Eligibility comes from live state rather than click-time snapshots. Capture
68+
the current workspace identity on Setup or Update, then, after the lazy
69+
dialog import resolves and again before save, verify that the workspace is
70+
unchanged, `billing_control_enabled` is still on, the current user can still
71+
manage the subscription, and the lifecycle is still interactive. Abort a
72+
stale lazy open or close without mutation when any check fails.
73+
- Use one strict localized whole-number normalization path for validation and
74+
save. Accept locale digits and canonical locale grouping, but reject signs,
75+
decimals, exponents, non-finite or malformed values. USD inputs represent
76+
whole dollars and are rejected when conversion would overflow safe integer
77+
cents. Use the same rounded credit cost in cents for the dialog's allowed-
78+
reload count and the configured-state calculation.
79+
- Preserve the prototype/source semantics: eligibility remains permission-
80+
based so a Personal Free owner can configure auto-reload when the flag and
81+
permission pass; a positive monthly budget smaller than one reload remains
82+
valid and derives zero full reloads with a Paused state; and a configured Off
83+
state continues to offer Update rather than Setup.
84+
6285
## Backend boundary
6386

6487
Current generated APIs support one-time top-up only. They do not expose

src/locales/en/main.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2934,7 +2934,8 @@
29342934
"title": "Auto-reload credits",
29352935
"unitLabel": "Display unit",
29362936
"update": "Update",
2937-
"usd": "USD"
2937+
"usd": "USD",
2938+
"wholeNumberRequired": "Enter a valid whole number"
29382939
},
29392940
"disabled": "Disabled",
29402941
"edit": "Edit",

src/platform/workspace/components/SubscriptionPanelContentWorkspace.test.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
22
import { render, screen } from '@testing-library/vue'
33
import userEvent from '@testing-library/user-event'
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { computed, ref } from 'vue'
5+
import { computed, nextTick, ref } from 'vue'
66
import { createI18n } from 'vue-i18n'
77

88
import type { SubscriptionInfo } from '@/composables/billing/types'
@@ -53,7 +53,9 @@ const mockIsInPersonalWorkspace = ref(false)
5353
const mockIsWorkspaceSubscribed = ref(true)
5454
const mockCanManageSubscription = ref(true)
5555
const mockBillingControlEnabled = ref(false)
56-
const mockBillingStatus = ref<'paid' | 'paused' | 'payment_failed'>('paid')
56+
const mockBillingStatus = ref<
57+
'paid' | 'paused' | 'payment_failed' | 'inactive'
58+
>('paid')
5759
const mockBillingSubscriptionStatus = ref<'active' | 'canceled' | 'ended'>(
5860
'active'
5961
)
@@ -157,17 +159,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
157159
})
158160
}))
159161

160-
vi.mock('@/platform/workspace/composables/useTeamPlan', () => ({
161-
useTeamPlan: () => ({
162-
hasLapsedTeamPlan: computed(
163-
() =>
164-
!mockIsInPersonalWorkspace.value &&
165-
(mockBillingSubscriptionStatus.value === 'canceled' ||
166-
mockBillingSubscriptionStatus.value === 'ended')
167-
)
168-
})
169-
}))
170-
171162
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
172163
useTeamWorkspaceStore: () => ({
173164
isInPersonalWorkspace: mockIsInPersonalWorkspace,
@@ -349,6 +340,30 @@ describe('SubscriptionPanelContentWorkspace', () => {
349340
)
350341
})
351342

343+
it('removes auto-reload when the billing control kill switch turns off', async () => {
344+
mockBillingControlEnabled.value = true
345+
renderComponent()
346+
347+
expect(screen.getByTestId('auto-reload-section')).toBeInTheDocument()
348+
349+
mockBillingControlEnabled.value = false
350+
await nextTick()
351+
352+
expect(screen.queryByTestId('auto-reload-section')).not.toBeInTheDocument()
353+
})
354+
355+
it('removes auto-reload when subscription management permission is revoked', async () => {
356+
mockBillingControlEnabled.value = true
357+
renderComponent()
358+
359+
expect(screen.getByTestId('auto-reload-section')).toBeInTheDocument()
360+
361+
mockCanManageSubscription.value = false
362+
await nextTick()
363+
364+
expect(screen.queryByTestId('auto-reload-section')).not.toBeInTheDocument()
365+
})
366+
352367
it('keeps auto-reload hidden from members', () => {
353368
mockBillingControlEnabled.value = true
354369
mockCanManageSubscription.value = false
@@ -368,19 +383,39 @@ describe('SubscriptionPanelContentWorkspace', () => {
368383
)
369384
})
370385

371-
it.for(['canceled', 'ended'] as const)(
372-
'freezes auto-reload for a %s team subscription',
373-
(status) => {
374-
mockBillingControlEnabled.value = true
375-
mockBillingSubscriptionStatus.value = status
376-
renderComponent()
386+
it('keeps auto-reload interactive while an active cancellation runs to term', () => {
387+
mockBillingControlEnabled.value = true
388+
mockBillingSubscriptionStatus.value = 'canceled'
389+
mockIsActiveSubscription.value = true
390+
renderComponent()
377391

378-
expect(screen.getByTestId('auto-reload-section')).toHaveAttribute(
379-
'data-frozen',
380-
'true'
381-
)
382-
}
383-
)
392+
expect(screen.getByTestId('auto-reload-section')).toHaveAttribute(
393+
'data-frozen',
394+
'false'
395+
)
396+
})
397+
398+
it('freezes auto-reload after the subscription has ended', () => {
399+
mockBillingControlEnabled.value = true
400+
mockBillingSubscriptionStatus.value = 'ended'
401+
renderComponent()
402+
403+
expect(screen.getByTestId('auto-reload-section')).toHaveAttribute(
404+
'data-frozen',
405+
'true'
406+
)
407+
})
408+
409+
it('freezes auto-reload while billing is inactive', () => {
410+
mockBillingControlEnabled.value = true
411+
mockBillingStatus.value = 'inactive'
412+
renderComponent()
413+
414+
expect(screen.getByTestId('auto-reload-section')).toHaveAttribute(
415+
'data-frozen',
416+
'true'
417+
)
418+
})
384419

385420
it('leaves auto-reload interactive when payment is at risk', () => {
386421
mockBillingControlEnabled.value = true

src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -327,14 +327,13 @@ import DropdownMenu from '@/components/common/DropdownMenu.vue'
327327
import StatusBadge from '@/components/common/StatusBadge.vue'
328328
import Button from '@/components/ui/button/Button.vue'
329329
import { useBillingContext } from '@/composables/billing/useBillingContext'
330-
import { useFeatureFlags } from '@/composables/useFeatureFlags'
331330
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
332331
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
333332
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
334333
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
335334
import AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
335+
import { useAutoReloadAccess } from '@/platform/workspace/composables/useAutoReloadAccess'
336336
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
337-
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
338337
import { useWorkspaceMenuItems } from '@/platform/workspace/composables/useWorkspaceMenuItems'
339338
import { useWorkspacePlanPricing } from '@/platform/workspace/composables/useWorkspacePlanPricing'
340339
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
@@ -353,7 +352,6 @@ const isSettingUp = computed(() => billingOperationStore.isSettingUp)
353352
const {
354353
isActiveSubscription,
355354
isFreeTier: isFreeTierPlan,
356-
billingStatus,
357355
subscription,
358356
isLoading,
359357
error,
@@ -362,14 +360,8 @@ const {
362360
initialize
363361
} = useBillingContext()
364362
365-
const { flags } = useFeatureFlags()
366-
const { hasLapsedTeamPlan } = useTeamPlan()
367-
const showAutoReload = computed(
368-
() => flags.billingControlEnabled && permissions.value.canManageSubscription
369-
)
370-
const autoReloadFrozen = computed(
371-
() => billingStatus.value === 'paused' || hasLapsedTeamPlan.value
372-
)
363+
const { canAccess: showAutoReload, isFrozen: autoReloadFrozen } =
364+
useAutoReloadAccess()
373365
374366
const { showPricingTable } = useSubscriptionDialog()
375367

0 commit comments

Comments
 (0)