Fix the unforced subscription change path - #71
Merged
Conversation
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>
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
force-pushed
the
fix-unforced-subscription-change
branch
from
August 7, 2026 16:13
d5c812d to
a6b7f81
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #70 — review that one first. The base will retarget to
mainautomatically when #70 merges.
The bug
POST /v1/subscriptions?force=falsefailed for every request and answered200while doing it, so terrain'sPOST /terrain/admin/qms/subscriptionssilently created nothing whenever
forcewasn't set. The upgrade-comparisonrule the unforced path exists to implement has therefore never actually run.
Two defects, both on that path:
1. An unbound placeholder.
GetActiveSubscriptionForDatebuiltwith no argument for the second
?, so PostgreSQL rejected the statement withsyntax error at or near ">". The handler wrapped that into a per-itemfailure_reasonand still returned200.The clause is meaningful rather than dead —
effective_end_dateis nullable,and the sibling
GetActiveSubscriptioncovers the same case with aCURRENT_TIMESTAMPliteral — so this bindsdaterather 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
with
activeSubscription == nil, becauseGetActiveSubscriptionForDatereturns
(nil, nil)when there's no match andGetUseronly upserts the userrow 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
TestUnforcedSubscriptionForNewUserpanics atsubscriptions.go:117. Sofixing 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:
new_subscription: true, active plan Pronew_subscription: false, active plan Pronew_subscription: false, active plan Pronew_subscription: true, active plan ProEach asserts the active subscription in the database as well as the response,
since that's what the rest of the DE reads.
go test ./...andgolangci-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
updatesrow recordingADDeven for aSET, and the bulkendpoint storing a different username than every other endpoint. The latter
also implies existing databases hold duplicate
usersrows that needreconciling, which is a data question as much as a code one.
🤖 Generated with Claude Code