Skip to content

Fix the unforced subscription change path - #71

Merged
johnworth merged 3 commits into
mainfrom
fix-unforced-subscription-change
Aug 7, 2026
Merged

Fix the unforced subscription change path#71
johnworth merged 3 commits into
mainfrom
fix-unforced-subscription-change

Conversation

@johnworth

Copy link
Copy Markdown
Collaborator

Stacked on #70 — review that one first. The base will retarget to main
automatically when #70 merges.

The bug

POST /v1/subscriptions?force=false failed for every request and answered
200 while doing it, so terrain's POST /terrain/admin/qms/subscriptions
silently created nothing whenever force wasn't set. The upgrade-comparison
rule the unforced path exists to implement has therefore never actually run.

Two defects, both on that path:

1. An unbound placeholder. GetActiveSubscriptionForDate built

db.Where("? BETWEEN subscriptions.effective_start_date AND subscriptions.effective_end_date", date).
    Or("? > subscriptions.effective_start_date AND subscriptions.effective_end_date IS NULL")

with no argument for the second ?, so PostgreSQL rejected the statement with
syntax error at or near ">". The handler wrapped that into a per-item
failure_reason and still returned 200.

The clause is meaningful rather than dead — effective_end_date is nullable,
and the sibling GetActiveSubscription covers the same case with a
CURRENT_TIMESTAMP literal — so this binds date rather than dropping it.
The two functions were clearly the same query with the literal swapped for a
placeholder, and one swap lost its argument.

2. A nil dereference the first fix exposes. With the query working, a user
who has no subscription as of the start date reaches

activeCPUAllocation := activeSubscription.Plan.GetDefaultQuotaValue(...)

with activeSubscription == nil, because GetActiveSubscriptionForDate
returns (nil, nil) when there's no match and GetUser only upserts the user
row without subscribing them. That panics; echo's recover middleware turns it
into a 500.

Verified rather than assumed — reverting just the guard and re-running
TestUnforcedSubscriptionForNewUser panics at subscriptions.go:117. So
fixing the query alone would have traded a silent no-op for a failure on every
new user, which is arguably worse than the original bug.

A missing subscription can't be a downgrade, so the request now proceeds to
create one.

Tests

The test that pinned the broken behavior is replaced by the matrix the rule
actually describes:

starting requested outcome
Basic Pro upgraded — new_subscription: true, active plan Pro
Pro Basic refused — new_subscription: false, active plan Pro
Pro Pro refused — new_subscription: false, active plan Pro
(none) Pro subscribed — new_subscription: true, active plan Pro

Each asserts the active subscription in the database as well as the response,
since that's what the rest of the DE reads.

go test ./... and golangci-lint run ./... (v2.12.2, the CI pin) are clean.

Not fixed here

The other two bugs #70 records are left alone so each fix stays reviewable on
its own: every updates row recording ADD even for a SET, and the bulk
endpoint storing a different username than every other endpoint. The latter
also implies existing databases hold duplicate users rows that need
reconciling, which is a data question as much as a code one.

🤖 Generated with Claude Code

POST /v1/subscriptions?force=false failed for every request and reported
success while doing it, so terrain's POST /terrain/admin/qms/subscriptions
silently created nothing whenever force wasn't set. Two defects, both on that
path:

GetActiveSubscriptionForDate supplied no argument for the placeholder in its
Or() clause, so PostgreSQL rejected the statement with `syntax error at or
near ">"`. The clause is meaningful — effective_end_date is nullable, and the
sibling GetActiveSubscription covers the same case with a CURRENT_TIMESTAMP
literal — so the date is bound rather than the clause removed.

Binding it then exposed a nil dereference: a user with no subscription as of
the start date has nothing to compare against, and AddSubscription read
activeSubscription.Plan unconditionally. Reaching that line panicked, which
echo's recover middleware turned into a 500, so fixing the query alone would
have traded a silent no-op for a failure on every new user. A missing
subscription can't be a downgrade, so the request now proceeds.

With both fixed, the upgrade rule the path exists to implement runs for the
first time. The test that pinned the broken behavior is replaced by the
matrix it describes: a larger plan upgrades, a smaller or equal one is
refused, and a user with no prior subscription is subscribed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from add-characterization-tests to main August 6, 2026 23:19
Repairing the unforced path made code reachable that had never run in
production, and two of the paths it reaches disagree about what "active"
means. The predicate was hand-copied into four queries while
DeactivateSubscriptions implemented a fifth, so an open-ended
subscription counted as active for the lookup but could not be closed by
the deactivation, and a subscription scheduled to start later was
invisible to the lookup but was still destroyed by the deactivation.

Collapse the copies into activeNow and treat a null effective end date
consistently: in effect indefinitely, and closed when a new subscription
supersedes it. Weigh an unforced request against every subscription
overlapping its period rather than only the one in effect on its start
date, so it can no longer cancel a better subscription scheduled later
without reporting a downgrade.

Also report a failed commit instead of discarding it; the unforced path
now writes, so a transaction that fails to commit would otherwise be
reported to terrain as a subscription that exists.

Every golden is unchanged, so the responses terrain parses are
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@johnworth
johnworth force-pushed the fix-unforced-subscription-change branch from d5c812d to a6b7f81 Compare August 7, 2026 16:13
Nothing distinguished the current-plan lookups filtering by the active
predicate from them simply taking the most recently starting row: every
other test gives a user only subscriptions that have already started, so
the two behaviors agree. Give the user a subscription scheduled to start
later, which sorts first by effective start date, and they diverge.

Covers GetActiveSubscription through GET /v1/users/{username}/plan and
ListSubscriptions through GET /v1/subscriptions. Removing the scope from
either one fails this test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@johnworth
johnworth merged commit 37642a6 into main Aug 7, 2026
2 checks passed
@johnworth
johnworth deleted the fix-unforced-subscription-change branch August 7, 2026 18:30
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