Files
pad/internal/models
xarmian 249e44d18e feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295) (#1657)
* feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295)

PLAN-3291 DR-6. users.plan_source ('manual' | 'stripe') records who set a
user's plan. The rule lives in the store write, not in callers: a write that
lowers the plan to free applies only when its source is the current
plan_source, the plan is already free or blank, or the write is forced.
Every other write applies and takes the source over. It is one conditional
UPDATE, so it is decided atomically against the row as it stands, and a
refused write changes nothing, plan_expires_at included.

- store.SetUserPlan(userID, PlanWrite{Plan, ExpiresAt, Source, Force})
  returns {Applied, Plan, Source}. Source is required and validated, so
  every caller names one; the compiler found all of them.
- POST /api/v1/admin/plan takes an optional `source` (omitted = manual,
  anything but manual/stripe = 400). A refused lowering is a 200 with
  `applied: false` and the plan the user keeps: pad-cloud treats any
  other status as a failure and the Stripe webhook would retry forever.
  Applied writes now also answer `applied: true` and `plan_source`
  (additive). The source is in the audit metadata.
- The admin user update writes manual with force: an operator's choice
  always applies, lowering included.
- plan_source is on models.User (json:"-") and in the four admin user
  responses that carry plan.
- Migrations 101 (SQLite) / 075 (Postgres): column default 'manual', plus a
  backfill to 'stripe' for pro users with a Stripe customer. Without it, an
  already-paying user's Stripe cancellation would be a refused lowering and
  they would keep pro after they stopped paying. Billing is not switched
  on yet, so it should match no rows in prod; it is there so the migration
  is safe either way.
- nulcolumns: users.plan_source recorded as an excluded server enum.

Writers of users.plan, from grep (SET plan / INSERT INTO users /
SetUserPlan): the two SetUserPlan callers above; BackfillUserPlans, which
fills blank rows only and is not a source write, so the source is left
alone; and the two user INSERTs, which write no plan and take the column
defaults.

Skew: a pad without this ignores `source` (json.Unmarshal) and applies the
write, which is today's behaviour, so pad deploys first, then the sidecar.

Tests (SQLite here; Postgres under make test-pg): a 9-case rule table
reading the row back through GetUser; a new user is free/manual; invalid
source and missing user refuse without writing; the migration's own UPDATE
run against a fixture per dialect; and router-level /admin/plan and admin
PATCH/GET tests. Mutants, each BUILD OK and killed by named tests: rule
removed, source not written, force ignored, same-source clause dropped,
admin update unforced, refused write answering 409, omitted source
defaulting to stripe.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(billing): the startup plan backfill takes plan_source over (TASK-3295)

Codex round 1 on 28c036de (P2): BackfillUserPlans("self-hosted") raises
free and blank plans to self-hosted without touching plan_source, which
bypasses the rule that a non-lowering write takes the source over. A free
user carrying a stripe source would become self-hosted still labelled
stripe. Both backfill branches now write plan_source = manual on every row
they change, and rows they skip keep their source (pinned by
TestBackfillUserPlans_TakesSourceOver).

The same round's P1 (pad-cloud does not send `source`) is the sidecar half
of this unit, which lands in pad-cloud. It also corrects the deploy order
stated in 28c036de: pad-cloud must deploy FIRST. A new pad with an old
sidecar receives a Stripe cancellation as `manual`, and for a user the
migration backfilled to `stripe` that is a refused lowering: they would
keep pro after they stopped paying. A new sidecar with an old pad is
harmless, because the old pad ignores `source`.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 10:14:38 -04:00
..
2026-03-26 01:52:36 +00:00