redline
← arena

Rename the field

blast radius
~8 minTypeScriptPrismaSystems
PLAT-77THE TICKET

Rename User.plan to User.subscriptionTier

`plan` is ambiguous — we now have billing plans, onboarding plans, and capacity plans, and three of last quarter's incidents traced back to somebody reading the wrong one.

Rename the field on the User model to `subscriptionTier`. Values are unchanged: still `free`, `pro`, `scale`.

THE CHANGE IS CORRECT

The rename is correct. It was done with the refactoring tool, all 41 in-repo call sites moved with it, the type check is clean and the full suite is green. Nothing in this diff is wrong, and you will not find a defect in it.

Your job is the other question: given the rest of the system below, what does this break, what needs a migration alongside it, and what is genuinely untouched? Marking everything as broken is not caution — it is the same as knowing nothing, and it is scored that way.

prisma/schema.prisma
-model User {
- id String @id @default(cuid())
- email String @unique
- plan String @default("free")
- createdAt DateTime @default(now())
-}
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ subscriptionTier String @default("free")
+ createdAt DateTime @default(now())
+}
// 41 call sites updated by the rename tool across 12 files.
// Type check clean. 340 tests green. No behavioural change:
// the stored values ("free" | "pro" | "scale") are untouched.
THE REST OF THE SYSTEM0/8 called
01src/api/billing/upgrade.tsin-repo callerHandles a plan upgrade and writes the new tier.
export async function upgrade(userId: string, tier: Tier) {
const user = await db.user.update({
where: { id: userId },
data: { subscriptionTier: tier },
});
await audit.record("billing.upgraded", { userId, tier });
return user;
}
02src/server/cache/entitlements.tsstored dataCaches a serialized entitlements blob in Redis for 24 hours.
const KEY = (userId: string) => `ent:v1:${userId}`;
export async function getEntitlements(userId: string) {
const cached = await redis.get(KEY(userId));
if (cached) return JSON.parse(cached) as Entitlements;
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
const ent = buildEntitlements(user.subscriptionTier);
await redis.setex(KEY(userId), 86_400, JSON.stringify(ent));
return ent;
}
03prisma/migrations/configurationThe SQL that has to run for the new schema to match the database.
-- No migration file was generated for this change.
--
-- The Prisma model now declares: subscriptionTier String
-- The database column is still: plan text
-- Deploy order in this repo: containers roll first, migrations run after.
04analytics/dashboards/revenue_by_tier.sqlobservabilityThe saved query behind the revenue dashboard and the churn alert.
SELECT
properties ->> 'plan' AS tier,
date_trunc('day', ts) AS day,
count(*) AS events
FROM analytics.events
WHERE name = 'subscription.changed'
GROUP BY 1, 2
ORDER BY 2 DESC;
05src/server/webhooks/emit.tsoutside this repoSends subscription events to customers' own endpoints.
export async function emitSubscriptionChanged(user: User) {
await deliver("subscription.changed", {
user_id: user.id,
email: user.email,
plan: user.subscriptionTier,
changed_at: new Date().toISOString(),
});
}
06src/graphql/schema.graphqloutside this repoThe public API. The iOS app on the App Store queries this.
type User {
id: ID!
email: String!
plan: SubscriptionTier! # resolver: (user) => user.subscriptionTier
createdAt: DateTime!
}
07config/flags.jsonconfigurationFeature flag definitions, read at boot.
{
"plan_v2_pricing": { "enabled": true, "rollout": 1.0 },
"annual_billing": { "enabled": false, "rollout": 0.0 },
"seat_based_trial": { "enabled": true, "rollout": 0.25 }
}
08src/__tests__/billing.test.tstest suiteThe suite that went green on this change.
it("upgrades a free user to pro", async () => {
const user = await factory.user({ subscriptionTier: "free" });
await upgrade(user.id, "pro");
const updated = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(updated.subscriptionTier).toBe("pro");
});