Skip to content

A promotional grant must not block account deletion - #12

Merged
bharathm03 merged 2 commits into
developmentfrom
fix/promotional-grant-blocks-deletion
Aug 26, 2026
Merged

A promotional grant must not block account deletion#12
bharathm03 merged 2 commits into
developmentfrom
fix/promotional-grant-blocks-deletion

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

Every account is currently refused account deletion, with nothing it can do to proceed.

ensureDefaultSubscription hands every new account the pro_yearly plan while checkout is disabled (provider: "promo", promotional: true, status: "active", cancelledAt: null). hasRenewingPaidSubscription then asks only whether the plan slug is free and whether the subscription is pending cancellation — neither of which is true of the grant — so it reports a renewing paid subscription and deleteUserAccount returns blocked_subscription.

The app surfaces that as "Cancel your subscription first" and offers Manage subscription, which opens a pricing page whose cards read "Coming soon". There is no subscription to cancel, so the flow is a dead end. The same function backs the web /account page, so both surfaces are affected.

Fix

Exempt the unpurchased grant, checked before the slug test — the grant rides a paid plan's row, so the slug alone cannot tell it apart from a real purchase. promotional already carries exactly this meaning ("Temporary unpurchased grant… Real purchases must omit this").

Why it went unnoticed

Every existing case in account-delete.test.ts seeds a real purchase (planId: "pro_yearly", customerId: "cust_1"), so nothing exercised the promotional path. The new test goes through provisionProductAccountForUser — the path a real signup takes — and asserts the seeded grant is genuinely promotional and on a paid tier, so it cannot quietly go vacuous if the default plan ever changes.

Testing

bun run typecheck clean.

The test suite has not been run. It needs Postgres, which I had no running instance for. Please run before merging:

bun run --filter antgrid-web test tests/services/account-delete.test.ts

ensureDefaultSubscription hands every new account the pro_yearly plan while checkout is disabled, and hasRenewingPaidSubscription exempted only the free plan — so every account was refused deletion and pointed at a subscription page with nothing to cancel.

The grant rides a paid plan's row, so the slug test cannot tell it apart from a purchase; check promotional first. The existing tests all seeded real purchases, which is why this went unnoticed — the new one goes through provisionProductAccountForUser, the path a real signup takes.
account-page.test.ts pinned the old behaviour as an "accepted limitation during the promo". It was acceptable only while nobody needed to delete: the grant renews nothing, no checkout sold it, and no surface can cancel it, so the limitation was a permanent block on every account rather than a temporary one.

Flipped to assert the control is offered, with the reasoning recorded where the old expectation was.
@bharathm03

Copy link
Copy Markdown
Contributor Author

CI caught a contradiction worth recording: web/tests/routes/account-page.test.ts pinned the old behaviour deliberately, commenting that the promotional grant is "treated like a real paid plan for deletion purposes (accepted limitation during the promo)".

That framing assumed the limitation was temporary. It wasn't: the grant renews nothing, no checkout sold it, GET /account re-provisions it on every visit (so even a lingering free row is upgraded back), and no surface can cancel it. The block was permanent and applied to every account.

Flipped that test to assert the delete control is offered, and recorded the reasoning where the old expectation lived. The run before this change was 618 pass / 1 fail, the single failure being that test; the new deleteUserAccount case passed alongside it, so the fix itself was already proven by CI.

@bharathm03
bharathm03 merged commit b016402 into development Aug 26, 2026
4 checks passed
@bharathm03
bharathm03 deleted the fix/promotional-grant-blocks-deletion branch August 26, 2026 06:00
bharathm03 added a commit that referenced this pull request Sep 1, 2026
…tself

Review of the previous commit. The floor additions had two defects that matter: `gh pr (merge|close)` and `gh (release|repo) delete` were each one regex over two operations, and §5.4 keys an authorization lift on the pattern SOURCE — so "close the stale PRs" granted every merge, and an authorized release delete authorized deleting the repository. Split one operation per pattern.

PR_ANCHOR accepted a bare `#42`, but GitHub numbers issues and pull requests in one series, so "closes #42 once the fix lands" — a backlog line about an issue — granted a session-wide gh pr close. The bare arm is gone; "PR #42" still anchors. The merge alias also matched "fix the merge conflicts on PR #12", which asks for the opposite of a merge.

The git branch flags had no option-token boundary, so `-perf` in a branch name read as a force and flagged the safe `git branch -d`, while the grouped spellings `-fd` and `-Dr` matched nothing. git tag scanned past quotes and separators, so `-m "fix -d flag"` read as a tag deletion. Publish flagged --dry-run and knew only npm, not the pnpm/yarn/bun this repo actually uses.

The capability responder wrote straight to the PTY, the one writer that could land between an injected line and its deferred CR — the interleave the queue exists to prevent. It goes through the queue, which is a pass-through whenever no submit is in flight.

The queue opened a gap before the CR but not after, so the next write joined the CR's read and robbed it of its key event one step later than the bug this fixes. padBareVerb treated an absolute path as a slash verb.

hasTypedContent counted the terminal's own mouse and focus reports as typed content, and typedSessions outlives the frame that set it — so one pointer move made the next bare Enter open a turn no stop hook would close, on the same premise the previous commit acted on for onUserReply.

The judge's onTimeout fired on one of the three legs that spend the budget, staying silent on an exhausted budget and a hung retry — the two that most need naming. The wrap-up push put the expiring undo offer last, behind an unbounded summary, in a string OS surfaces truncate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant