feat(billing): disclose that changing plans on a cancelled subscription resumes it - #14222
Conversation
…led subscriptions Relaxing the backend guard on /billing/subscribe to allow plan changes while a subscription is cancelled means picking a new plan would otherwise silently clear the pending cancellation and resume auto-renewal with no warning. This adds a disclosure banner to the plan-transition preview screen so the user sees they're reactivating, sees the exact charge, and (above their current monthly price) has to explicitly acknowledge the charge via a checkbox before confirming. - SubscriptionTransitionPreviewWorkspace.vue: banner + variant-specific title/ body/button copy for upgrade/downgrade/duration_change, driven by the current subscription's cancelled state; checkbox-gated confirm above the current plan's monthly price. - workspaceApi.ts: adds confirm_reactivation to the subscribe request/options so the backend can require explicit confirmation. - useSubscriptionCheckout.ts: derives confirmReactivation from subscription.isCancelled and forwards it on both the personal and team subscribe paths. - main.json: new subscription.preview.reactivation.* copy. - Tests: new SubscriptionTransitionPreviewWorkspaceReactivation.test.ts for the banner; targeted updates to useSubscriptionCheckout.test.ts and workspaceApi.test.ts to cover the new flag reaching the request body.
…cription state confirmReactivation was set to `subscription.value?.isCancelled`, which is true in exactly the circumstances the server requires the flag — so the server-side consent check could never fail and the audit trail asserted consent that was never actually collected. SubscriptionTransitionPreviewWorkspace now computes consent from its own state (banner rendered + confirm clicked, checkbox ticked above the charge threshold) and emits it with `confirm`. useSubscriptionCheckout forwards that value into `subscribe()` instead of re-deriving it. Added a regression test that fails against the old wiring: a cancelled subscription with no disclosure confirmation must not send confirm_reactivation: true.
🎨 Storybook: ✅ Built — View Storybook🎭 Playwright: ✅ 1753 passed, 0 failed · 3 flaky📊 Browser Reports
📦 Bundle: 8.15 MB gzip 🔴 +3.7 kBDetailsSummary
Category Glance App Entry Points — 3.66 kB (baseline 3.63 kB) • 🔴 +32 BMain entry bundles and manifests
Status: 1 added / 1 removed Graph Workspace — 1.28 MB (baseline 1.28 MB) • 🔴 +88 BGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed / 1 unchanged Views & Navigation — 112 kB (baseline 112 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces
Status: 12 added / 12 removed / 4 unchanged Panels & Settings — 551 kB (baseline 551 kB) • 🔴 +32 BConfiguration panels, inspectors, and settings screens
Status: 11 added / 11 removed / 15 unchanged User & Accounts — 29.1 kB (baseline 29.1 kB) • ⚪ 0 BAuthentication, profile, and account management bundles
Status: 7 added / 7 removed / 3 unchanged Editors & Dialogs — 121 kB (baseline 121 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors
Status: 5 added / 5 removed / 1 unchanged UI Components — 64.8 kB (baseline 64.8 kB) • ⚪ 0 BReusable component library chunks
Status: 6 added / 6 removed / 8 unchanged Data & Services — 3.42 MB (baseline 3.42 MB) • 🔴 +306 BStores, services, APIs, and repositories
Status: 14 added / 14 removed / 3 unchanged Utilities & Hooks — 371 kB (baseline 369 kB) • 🔴 +2.31 kBHelpers, composables, and utility bundles
Status: 18 added / 18 removed / 17 unchanged Vendor & Third-Party — 15.7 MB (baseline 15.7 MB) • ⚪ 0 BExternal libraries and shared vendor chunks Status: 16 unchanged Other — 12.5 MB (baseline 12.5 MB) • 🔴 +13.1 kBBundles that do not match a named category
Status: 79 added / 78 removed / 199 unchanged ⚡ Performance Report
Show regressions
All metrics
Historical variance (last 15 runs)
Trend (last 15 commits on main)
Raw data{
"timestamp": "2026-07-29T04:13:27.794Z",
"gitSha": "df909b27149a6c6fa75ca525fd3f3a565856f1b1",
"branch": "wei/be-4782-reactivation-disclosure",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2126.67799999997,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.016,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 588.635,
"heapDeltaBytes": 3832424,
"heapUsedBytes": 71896500,
"domNodes": 18,
"jsHeapTotalBytes": 20574208,
"scriptDurationMs": 18.918,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-idle",
"durationMs": 2018.1239999999434,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.908,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 446.426,
"heapDeltaBytes": 3486588,
"heapUsedBytes": 71349912,
"domNodes": 16,
"jsHeapTotalBytes": 20312064,
"scriptDurationMs": 16.194,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1870.6809999999905,
"styleRecalcs": 75,
"styleRecalcDurationMs": 35.097,
"layouts": 12,
"layoutDurationMs": 3.245,
"taskDurationMs": 867.1610000000001,
"heapDeltaBytes": -16794556,
"heapUsedBytes": 50930844,
"domNodes": -274,
"jsHeapTotalBytes": 22011904,
"scriptDurationMs": 124.611,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1823.240999999939,
"styleRecalcs": 75,
"styleRecalcDurationMs": 40.732,
"layouts": 12,
"layoutDurationMs": 3.699,
"taskDurationMs": 827.3050000000001,
"heapDeltaBytes": -1359032,
"heapUsedBytes": 66530000,
"domNodes": 57,
"jsHeapTotalBytes": 21360640,
"scriptDurationMs": 124.341,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1721.4720000000625,
"styleRecalcs": 31,
"styleRecalcDurationMs": 16.156000000000002,
"layouts": 6,
"layoutDurationMs": 0.502,
"taskDurationMs": 355.58299999999997,
"heapDeltaBytes": 7080900,
"heapUsedBytes": 74873224,
"domNodes": 78,
"jsHeapTotalBytes": 20574208,
"scriptDurationMs": 18.229,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1739.121999999952,
"styleRecalcs": 32,
"styleRecalcDurationMs": 16.244,
"layouts": 6,
"layoutDurationMs": 0.513,
"taskDurationMs": 357.20399999999995,
"heapDeltaBytes": 7186304,
"heapUsedBytes": 75217296,
"domNodes": 77,
"jsHeapTotalBytes": 20574208,
"scriptDurationMs": 17.929,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "dom-widget-clipping",
"durationMs": 589.2410000000154,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.209999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 365.815,
"heapDeltaBytes": -12362404,
"heapUsedBytes": 55553184,
"domNodes": 18,
"jsHeapTotalBytes": 21884928,
"scriptDurationMs": 58.788999999999994,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "dom-widget-clipping",
"durationMs": 562.5240000000531,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.734,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 367.004,
"heapDeltaBytes": -12098952,
"heapUsedBytes": 55846108,
"domNodes": 18,
"jsHeapTotalBytes": 21360640,
"scriptDurationMs": 57.352,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-idle",
"durationMs": 2061.0529999999017,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.116000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 665.9170000000001,
"heapDeltaBytes": 6868904,
"heapUsedBytes": 65572448,
"domNodes": -274,
"jsHeapTotalBytes": 4517888,
"scriptDurationMs": 108.177,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-idle",
"durationMs": 2022.7919999999813,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.546,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 658.336,
"heapDeltaBytes": 6003316,
"heapUsedBytes": 64733844,
"domNodes": -278,
"jsHeapTotalBytes": 4517888,
"scriptDurationMs": 103.53999999999999,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-pan",
"durationMs": 2152.421000000004,
"styleRecalcs": 68,
"styleRecalcDurationMs": 12.658,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1260.565,
"heapDeltaBytes": 4642300,
"heapUsedBytes": 64667944,
"domNodes": -278,
"jsHeapTotalBytes": 4460544,
"scriptDurationMs": 427.962,
"eventListeners": -142,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2223.9900000000716,
"styleRecalcs": 69,
"styleRecalcDurationMs": 14.904,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1339.0249999999999,
"heapDeltaBytes": 5404564,
"heapUsedBytes": 65208720,
"domNodes": -276,
"jsHeapTotalBytes": 4460544,
"scriptDurationMs": 461.094,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3198.682000000076,
"styleRecalcs": 66,
"styleRecalcDurationMs": 14.478000000000002,
"layouts": 60,
"layoutDurationMs": 6.942,
"taskDurationMs": 1475.6609999999998,
"heapDeltaBytes": -1148100,
"heapUsedBytes": 59994168,
"domNodes": -279,
"jsHeapTotalBytes": 7401472,
"scriptDurationMs": 522.2509999999999,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3179.8290000000407,
"styleRecalcs": 66,
"styleRecalcDurationMs": 15.075999999999999,
"layouts": 60,
"layoutDurationMs": 7.067,
"taskDurationMs": 1501.309,
"heapDeltaBytes": -3605092,
"heapUsedBytes": 57955768,
"domNodes": -281,
"jsHeapTotalBytes": 7401472,
"scriptDurationMs": 519.856,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2041.5249999999787,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.758,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 649.372,
"heapDeltaBytes": 7461628,
"heapUsedBytes": 67738468,
"domNodes": -277,
"jsHeapTotalBytes": 4517888,
"scriptDurationMs": 102.348,
"eventListeners": -144,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2054.322999999954,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.922999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 670.5429999999999,
"heapDeltaBytes": 7193108,
"heapUsedBytes": 67577824,
"domNodes": -276,
"jsHeapTotalBytes": 4780032,
"scriptDurationMs": 108.69500000000001,
"eventListeners": -142,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 600.3069999999298,
"styleRecalcs": 47,
"styleRecalcDurationMs": 9.604999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 388.73400000000004,
"heapDeltaBytes": -11777656,
"heapUsedBytes": 56205836,
"domNodes": 20,
"jsHeapTotalBytes": 22147072,
"scriptDurationMs": 115.426,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 578.8149999999632,
"styleRecalcs": 48,
"styleRecalcDurationMs": 11.607999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 389.496,
"heapDeltaBytes": -11937132,
"heapUsedBytes": 56162052,
"domNodes": 22,
"jsHeapTotalBytes": 22147072,
"scriptDurationMs": 116.022,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "subgraph-idle",
"durationMs": 1993.4379999999692,
"styleRecalcs": 11,
"styleRecalcDurationMs": 9.032,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 437.602,
"heapDeltaBytes": 3462752,
"heapUsedBytes": 71486216,
"domNodes": 22,
"jsHeapTotalBytes": 21098496,
"scriptDurationMs": 12.473999999999998,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "subgraph-idle",
"durationMs": 1993.4919999999465,
"styleRecalcs": 10,
"styleRecalcDurationMs": 8.977,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 440.546,
"heapDeltaBytes": 3411852,
"heapUsedBytes": 71195596,
"domNodes": 20,
"jsHeapTotalBytes": 21360640,
"scriptDurationMs": 13.699000000000002,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1718.8809999998966,
"styleRecalcs": 76,
"styleRecalcDurationMs": 35.686,
"layouts": 16,
"layoutDurationMs": 4.308999999999999,
"taskDurationMs": 785.44,
"heapDeltaBytes": -19070024,
"heapUsedBytes": 49026984,
"domNodes": -274,
"jsHeapTotalBytes": 20701184,
"scriptDurationMs": 89.473,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1715.1270000000522,
"styleRecalcs": 78,
"styleRecalcDurationMs": 35.74,
"layouts": 16,
"layoutDurationMs": 3.6430000000000002,
"taskDurationMs": 779.0030000000002,
"heapDeltaBytes": -20686828,
"heapUsedBytes": 47374904,
"domNodes": -271,
"jsHeapTotalBytes": 20963328,
"scriptDurationMs": 88.369,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-transition-enter",
"durationMs": 1400.9060000000773,
"styleRecalcs": 19,
"styleRecalcDurationMs": 30.938000000000006,
"layouts": 15,
"layoutDurationMs": 12.850999999999997,
"taskDurationMs": 977.611,
"heapDeltaBytes": 31354760,
"heapUsedBytes": 97503676,
"domNodes": 13673,
"jsHeapTotalBytes": 14680064,
"scriptDurationMs": 35.84899999999999,
"eventListeners": 2371,
"totalBlockingTimeMs": 135,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "viewport-pan-sweep",
"durationMs": 8218.391999999994,
"styleRecalcs": 249,
"styleRecalcDurationMs": 34.291999999999994,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4323.392,
"heapDeltaBytes": 9659992,
"heapUsedBytes": 69418680,
"domNodes": -274,
"jsHeapTotalBytes": 6295552,
"scriptDurationMs": 1351.1029999999998,
"eventListeners": -128,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8254.14699999999,
"styleRecalcs": 249,
"styleRecalcDurationMs": 34.533,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4373.611,
"heapDeltaBytes": 1789336,
"heapUsedBytes": 60747728,
"domNodes": -277,
"jsHeapTotalBytes": 5566464,
"scriptDurationMs": 1350.093,
"eventListeners": -128,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 16255.204000000049,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 16234.943000000001,
"heapDeltaBytes": -42778656,
"heapUsedBytes": 165792572,
"domNodes": -8312,
"jsHeapTotalBytes": -16977920,
"scriptDurationMs": 531.005,
"eventListeners": -16387,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666763,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-idle",
"durationMs": 16595.123000000058,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 16557.524,
"heapDeltaBytes": -42028540,
"heapUsedBytes": 166934252,
"domNodes": -8312,
"jsHeapTotalBytes": -13570048,
"scriptDurationMs": 544.038,
"eventListeners": -16387,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.219999999999953,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 20034.28500000007,
"styleRecalcs": 134,
"styleRecalcDurationMs": 14.565999999999967,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 20009.963,
"heapDeltaBytes": -60401204,
"heapUsedBytes": 166463224,
"domNodes": -8312,
"jsHeapTotalBytes": -13570048,
"scriptDurationMs": 889.438,
"eventListeners": -16381,
"totalBlockingTimeMs": 114,
"frameDurationMs": 17.776666666666763,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 19908.199999999964,
"styleRecalcs": 137,
"styleRecalcDurationMs": 14.259000000000022,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 19880.882,
"heapDeltaBytes": -42204616,
"heapUsedBytes": 166319496,
"domNodes": -8312,
"jsHeapTotalBytes": -14618624,
"scriptDurationMs": 819.7869999999999,
"eventListeners": -16387,
"totalBlockingTimeMs": 52,
"frameDurationMs": 18.886666666666617,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 101.13799999999173,
"styleRecalcs": 7,
"styleRecalcDurationMs": 12.169,
"layouts": 2,
"layoutDurationMs": 0.406,
"taskDurationMs": 71.975,
"heapDeltaBytes": 3027480,
"heapUsedBytes": 70132656,
"domNodes": 110,
"jsHeapTotalBytes": 3145728,
"scriptDurationMs": 7.566000000000002,
"eventListeners": 33,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "workflow-execution",
"durationMs": 480.38500000006934,
"styleRecalcs": 13,
"styleRecalcDurationMs": 22.37,
"layouts": 3,
"layoutDurationMs": 0.6629999999999999,
"taskDurationMs": 117.10300000000001,
"heapDeltaBytes": -16130076,
"heapUsedBytes": 51112772,
"domNodes": 119,
"jsHeapTotalBytes": 7729152,
"scriptDurationMs": 9.901,
"eventListeners": 65,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
}
]
} |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe subscription transition and downgrade flows now display reactivation disclosures, collect required consent, prevent unconfirmed cancelled-subscription changes, and forward confirmation through checkout to ChangesSubscription reactivation confirmation
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant SubscriptionTransitionPreviewWorkspace
participant dialogService
participant useDowngradeToPersonal
participant useSubscriptionCheckout
participant workspaceApi
SubscriptionTransitionPreviewWorkspace->>useSubscriptionCheckout: emit confirmReactivation
dialogService->>useDowngradeToPersonal: previewDowngrade
useDowngradeToPersonal->>dialogService: return charge and confirmation requirement
dialogService->>useDowngradeToPersonal: downgradeToPersonal(confirmReactivation)
useSubscriptionCheckout->>workspaceApi: subscribe(confirmReactivation)
workspaceApi->>workspaceApi: POST /api/billing/subscribe with confirm_reactivation
Possibly related PRs
Suggested labels: Suggested reviewers: Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 inconclusive)
✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue`:
- Around line 316-322: Update currentMonthlyPriceCents to reuse the existing
currentIsYearly computed value when deciding whether to divide plan.price_cents
by 12, removing the duplicate plan.duration === 'ANNUAL' check while preserving
the existing monthly-price calculation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: f43bcf9d-0b63-46a0-bcd5-5620b19ca398
📒 Files selected for processing (8)
src/locales/en/main.jsonsrc/platform/workspace/api/workspaceApi.test.tssrc/platform/workspace/api/workspaceApi.tssrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.tssrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vuesrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspaceReactivation.test.tssrc/platform/workspace/composables/useSubscriptionCheckout.test.tssrc/platform/workspace/composables/useSubscriptionCheckout.ts
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## main #14222 +/- ##
==========================================
- Coverage 79.29% 78.90% -0.39%
==========================================
Files 1751 1755 +4
Lines 99965 101420 +1455
Branches 32413 33569 +1156
==========================================
+ Hits 79271 80030 +759
- Misses 20298 20960 +662
- Partials 396 430 +34
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 39 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @wei-hai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 4 |
| 🟡 Medium | 2 |
| 🟢 Low | 4 |
Panel: 8/8 reviewers contributed findings.
The comment named an internal tracker issue, which does not belong in a public repo. The guard it describes is unchanged.
…ade/checkout paths Critical: downgradeToPersonal() removed team members before subscribing, and that subscribe never sent confirm_reactivation. For a cancelled team subscription, this meant members were deleted and the plan change was then rejected by the API. downgradeToPersonal() now takes a confirmReactivation flag, checks whether the transition requires it before touching membership, and throws (removing nobody) if consent wasn't given. showDowngradeToPersonalDialog now previews up front and shows a real disclosure (with the exact charge) for either member removal or reactivation, so the flag comes from actual user consent, not from subscription state. Also fixes 9 review findings on the reactivation disclosure banner and checkout paths: - Show the exact charge in cents instead of rounding to whole dollars. - Give annual->monthly duration changes their own copy instead of reusing the annual-only "full year billed" text. - Block (instead of silently sending confirmReactivation:false) on checkout paths that have no banner to collect consent. - Disable confirm until billing context has loaded, so a fast preview can't race a not-yet-loaded cancellation state. - Reset a ticked consent checkbox when the charge amount changes. - Use seat_summary.total_cost_cents (whole-subscription) instead of the per-seat price_cents for the charge threshold. - Never show the checkbox on the downgrade variant, whose copy already says no charge applies today. - Fall back to one billing period after activation, not the activation date itself, when period_end is missing. - Hide the banner when required data (endDate, current_plan) is missing instead of rendering broken copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All 10 review findings addressed. None were rejected — every one reproduced. The critical one first. The remove-then-subscribe ordering is genuinely forced, confirmed two ways: the composable's own docstring already flags the non-atomic orchestration as known temporary behaviour pending a backend endpoint, and the personal plan carries To supply a real (non-derived) consent value, the dialog now previews up front and always shows a confirmation when either members would be removed or a reactivation needs disclosing, instead of skipping straight to a doomed request. Its existing type-to-confirm gate is the source of the flag. The other three Highs: the acknowledged amount no longer rounds to whole dollars ( Mediums: confirm is disabled until billing state has loaded, closing the race where a preview resolving first let someone confirm with no disclosure; and consent now resets when the charge changes, so a box ticked for a small amount can't carry onto a larger one. Lows: threshold now uses the whole-subscription total rather than a per-seat price, the checkbox is excluded from the downgrade variant whose copy promises no charge, the renewal date no longer falls back to the activation date, and Each fix is mutation-verified — the pre-fix file restored, tests confirmed failing, then restored. I re-ran the critical one independently: neutering the consent guard fails exactly the one test asserting no member is removed, and nothing else. Also stripped two pre-existing internal tracker IDs from doc comments on functions being edited, since this repo is public. Known and unchanged: if the subscribe fails for an unrelated reason after members are removed, they stay removed. That is pre-existing — the codebase already has a dedicated error string for it — and belongs with the atomic-endpoint work, not here. |
Nothing outside the composable catches it by type — the caller avoids the case up front and the generic handler surfaces its message — so exporting it only widened the module's surface.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue (1)
453-468: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
confirmCtaisn't gated byisReactivating, unlike every other reactivation-derived computed.
reactivationVariantonly requiresisCancelled+transition_type— it doesn't requiresubscription.endDateorpreviewData.current_planthe wayisReactivatingdoes.exceedsMonthlyThresholdandconfirmReactivationboth correctly checkisReactivating.valuefirst, butconfirmCtabranches straight onreactivationVariant.value.Concretely: if the subscription is cancelled but
endDatehasn't loaded yet (orcurrent_planis briefly missing), the banner is correctly hidden (isReactivatingfalse) andconfirmReactivationcorrectly emitsfalse— but the button still renders "Confirm & reactivate — {amount} today" (or "Confirm & reactivate" for downgrade), falsely implying a reactivation charge that was never disclosed and won't actually be confirmed. Neither of the "missing reactivation data" tests catch this since they only assert the banner/checkbox are absent, not the button label.🐛 Proposed fix
const confirmCta = computed(() => { + if (!isReactivating.value) { + return isImmediate.value + ? t('subscription.preview.confirmUpgradeCta') + : t('subscription.preview.confirmChange') + } if (reactivationVariant.value === 'downgrade') { return t('subscription.preview.reactivation.confirmButton') } - if ( - reactivationVariant.value === 'upgrade' || - reactivationVariant.value === 'duration_change' - ) { - return t('subscription.preview.reactivation.confirmButtonWithCharge', { - amount: chargeDisplay.value - }) - } - return isImmediate.value - ? t('subscription.preview.confirmUpgradeCta') - : t('subscription.preview.confirmChange') + return t('subscription.preview.reactivation.confirmButtonWithCharge', { + amount: chargeDisplay.value + }) })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue` around lines 453 - 468, Gate the reactivation-specific branches in confirmCta by isReactivating.value before evaluating reactivationVariant.value. When reactivation data is incomplete and isReactivating is false, fall through to the existing non-reactivation CTA logic; preserve the current downgrade and upgrade/duration-change labels only for active reactivation flows.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.test.ts`:
- Around line 104-112: Update the test named “shows the removal-only body when
reactivation is not required” to mount the component with requiresRemoval set to
true and requiresReactivation set to false. Keep the existing assertion for the
immediate member-removal text, ensuring the test exercises the intended
removal-only branch rather than the default fallback.
In `@src/platform/workspace/composables/useDowngradeToPersonal.ts`:
- Around line 82-89: Update requiresReactivationConfirmation and its
downgradeToPersonal call-site logic to fail closed when subscription.value is
unavailable: require reactivation confirmation when the subscription is missing
or cancelled, unless preview.transition_type is 'new_subscription'. Remove the
nullish fallback that treats an unset subscription as active, while preserving
the existing behavior for available subscription data.
In `@src/services/dialogService.ts`:
- Around line 675-696: Prevent the downgrade flow from billing an amount
different from the one confirmed in showDowngradeToPersonalDialog: update the
interaction between previewDowngrade and downgradeToPersonal so the fetched
preview is reused, or ensure downgradeToPersonal validates its fresh preview
cost against the confirmed chargeCents and rejects or re-prompts on mismatch.
Keep the confirmation amount and actual billing amount consistent across the
onConfirm path.
---
Outside diff comments:
In
`@src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue`:
- Around line 453-468: Gate the reactivation-specific branches in confirmCta by
isReactivating.value before evaluating reactivationVariant.value. When
reactivation data is incomplete and isReactivating is false, fall through to the
existing non-reactivation CTA logic; preserve the current downgrade and
upgrade/duration-change labels only for active reactivation flows.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 678689cd-3e2f-4436-9645-438a186d82a9
📒 Files selected for processing (13)
src/locales/en/main.jsonsrc/platform/telemetry/types.tssrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.tssrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vuesrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspaceReactivation.test.tssrc/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.test.tssrc/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vuesrc/platform/workspace/composables/useDowngradeToPersonal.test.tssrc/platform/workspace/composables/useDowngradeToPersonal.tssrc/platform/workspace/composables/useSubscriptionCheckout.test.tssrc/platform/workspace/composables/useSubscriptionCheckout.tssrc/services/dialogService.downgrade.test.tssrc/services/dialogService.ts
The downgrade dialog previewed once to decide what to show, then downgradeToPersonal previewed again before billing, with nothing checking the two agreed. Consent to a specific figure could silently apply to a different one. The charge is computed server-side inside subscribe, so passing the first preview forward would not pin the amount — it would only stop the code re-checking. Instead the confirmed figure is passed down and compared against the fresh preview, refusing before any member is removed. Also fail closed when billing context has not loaded: unknown subscription state now requires confirmation rather than reading as 'not cancelled', matching the preview UI's existing gating.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/platform/workspace/composables/useDowngradeToPersonal.ts`:
- Around line 92-99: The requiresReactivationConfirmation function must use an
authoritative reactivation-required field from PreviewSubscribeResponse instead
of isInitialized and cached subscription.value status. Add that field to the
preview contract, then return false only for new_subscription or an explicitly
false authoritative value; treat an unavailable value as requiring confirmation
to fail closed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7b0afc37-68f8-4345-b9d4-98981d0fd31e
📒 Files selected for processing (7)
src/locales/en/main.jsonsrc/platform/telemetry/types.tssrc/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.test.tssrc/platform/workspace/composables/useDowngradeToPersonal.test.tssrc/platform/workspace/composables/useDowngradeToPersonal.tssrc/services/dialogService.downgrade.test.tssrc/services/dialogService.ts
…ransitionPreviewWorkspace Six stories covering the reactivation banner: an ordinary (non-cancelled) upgrade to prove the banner doesn't leak into normal flows, an immediate upgrade with an exact-cents charge, a scheduled downgrade with $0 due today, both cadence-change directions (monthly<->annual), and a charge above the current plan's monthly total to render the consent checkbox with the confirm button disabled until ticked. Billing context is stubbed per story via the existing Storybook setBillingContextMock helper, since the component reads subscription/isInitialized from useBillingContext rather than props.
…tokens The reactivation-disclosure banner used text-text-primary/text-text-secondary and border-border-default, a minority token family in this codebase. The nearest sibling (BillingStatusBanner.vue in the same settings dialogs directory) instead pairs text-base-foreground/text-muted-foreground with border-interface-stroke — align the banner's title, body, checkbox label, and checkbox border to match, so both banners a user can see in the same flow render the same greys. The warning-accent classes (border/bg/text on warning-background) are unchanged; only the neutral tokens moved.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.stories.ts`:
- Around line 67-79: Update cancelledSubscription to accept a duration
parameter, use that duration for SubscriptionInfo.duration, and derive planSlug
from the tier and supplied duration. Update each reactivation story, especially
ReactivatingAnnualToMonthly, to pass the duration matching its current_plan so
the mocked subscription contract remains consistent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: df947b2d-16dc-4b10-948e-9b35ffe3d169
📒 Files selected for processing (2)
src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.stories.tssrc/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue
Two review-thread fixes on the reactivation-disclosure flow: - useDowngradeToPersonal: requiresReactivationConfirmation read the billing context's cached subscription snapshot, which can predate a cancellation that happened after the last load. Refresh via fetchStatus() right before evaluating the guard, both when deciding what to show (previewDowngrade) and again right before member removal (downgradeToPersonal), so a cancellation that occurred after the initial load isn't missed. - SubscriptionTransitionPreviewWorkspace.stories.ts: cancelledSubscription() always hardcoded MONTHLY, so the ReactivatingAnnualToMonthly story's billing-context mock disagreed with its ANNUAL current_plan. The helper now takes a duration parameter and derives planSlug from it. Verified with mutation testing: reverting the fetchStatus() calls makes the two new regression tests fail as expected; restoring the fix makes them pass again.
dante01yoon
left a comment
There was a problem hiding this comment.
Two blocking consent-path issues remain on the current head. Both can make the confirmation UI disagree with what is actually disclosed or charged.
dante01yoon
left a comment
There was a problem hiding this comment.
Re-reviewed the latest regression fixes. The five previously blocking scenarios are now covered:
- amount drift updates the displayed preview and allows a newly confirmed retry;
- downgrade amount/status drift clears the typed acknowledgement;
- Team status refresh is inside loading/error/telemetry handling;
- Team → personal uses subscription-status readiness rather than aggregate billing initialization;
- unusable status-drift previews return the user to pricing instead of creating another blocked retry.
Focused validation passed locally: 107 checkout, downgrade, dialog, and reactivation component tests. I found no remaining blocking regression in the current head.
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |






Tells a user that changing plans while their subscription is cancelled will resume it.
Why
A subscription can be cancelled but still active — it runs to the end of the period already paid for. Backend work landing alongside this lets those users change plans, and doing so clears the pending cancellation: the subscription stops ending and starts renewing again.
That is a bigger change than "swap my plan", triggered by what looks like an ordinary plan click. Without a disclosure, someone who deliberately cancelled ends up subscribed indefinitely and never told.
What changes
When the current subscription is cancelled, the transition preview gains a banner above the plan comparison and the primary button becomes "Confirm & reactivate". The copy differs per case because the consequence does:
Because the downgrade case has no money attached, the copy leads with reactivates, not with a number.
Above the current plan's monthly price the amount renders larger and higher-contrast, and the confirm button is disabled behind an explicit "I understand I'll be charged $X today" checkbox. Standard proration styling renders a $6 upgrade and a $336 annual charge identically, and only one of those deserves a second look.
The load-bearing part
confirm_reactivationis a server-checked flag — the API refuses the change without it, and records it for audit.An earlier commit on this branch derived it from
subscription.isCancelled, which made it true in exactly the circumstances the server demanded it. The check could therefore never fail, and the audit record asserted consent for requests where no disclosure was ever shown. Consent is now computed by the component that actually rendered the banner and emitted onconfirm; the composable only forwards it. That distinction is the entire point of the field, so it has a regression test that fails against the old wiring.Scope notes
PricingTableWorkspace.vueis untouched deliberately — a different-tier click while cancelled already routes through this preview, and the "Resubscribe to X" button hits a different endpoint whose label is already explicit.SubscriptionTransitionPreviewWorkspace.test.tsis untouched: itsvue-i18nmodule mock is incompatible with<i18n-t>, so the new tests live in a sibling file.Verification
pnpm typecheckclean,pnpm lint0 errors,pnpm knipclean,pnpm formatno diff.Consent is asserted on the request payload, not on component state — a test that only checked the checkbox toggles a local ref would pass with the bug fully intact.
Six Storybook states cover the banner (baseline, upgrade, downgrade, monthly→annual, annual→monthly, above-threshold); rendered screenshots are in a comment below.
Found while reviewing, not fixed here
The credit line reads "Credit from current monthly plan" on an annual→monthly switch, where the current plan is annual.
creditFromPlanLabelreturns the monthly string for any cadence change and never reads the current subscription's duration. This is onmaintoday, is not touched by this branch, and affects every cadence change rather than just the reactivation path — so it wants its own PR rather than widening this one. It is visible in the annual→monthly screenshot below.