feat: user menu + profile page + in-app subscription management
All checks were successful
Deploy to Production / deploy (push) Successful in 52s

User-facing identity:
- UserMenu component in dashboard header: avatar (deterministic colour from
  email hash), email + name, current plan badge, dropdown to Profile /
  Billing / Support / Your data / (Admin panel if isAdmin) / Sign out
- /settings/profile: editable display name; email + phone shown read-only
  (changing them requires support ticket — magic-link flow assumed)
- GET + PATCH /v1/account/profile

In-app subscription management (no more Stripe Portal redirect for the
common flows — cancellation, plan switch, invoice viewing all in-app):
- Billing status now combines DB state with a live Stripe lookup of the
  subscription details + last 5 invoices. Single roundtrip.
- POST /v1/billing/cancel       → schedules cancel_at_period_end
- POST /v1/billing/reactivate   → undo scheduled cancel
- POST /v1/billing/change-plan  → prorated swap between any tier+cycle
- /settings/billing rewritten: current plan card with renew/cancel date,
  big cancel button + reactivate flow, plan-switcher grid, invoice list with
  PDF + hosted-invoice links
- Stripe portal still linked at the bottom as the escape hatch for rare
  actions (payment-method update, address change). New-subscription Checkout
  still uses Stripe-hosted Checkout (industry standard for PCI).

Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription
items; updated read paths accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-25 17:46:36 +02:00
parent 1b8f61df5f
commit 1c58977596
6 changed files with 856 additions and 88 deletions

View File

@@ -12,12 +12,57 @@ import {
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
export async function accountRoutes(app: FastifyInstance): Promise<void> {
// ─── Profile: read + update ───────────────────────────────────────────
app.get('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [row] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
phone: users.phone,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, user.userId))
.limit(1);
if (!row) return reply.code(404).send({ error: 'user_not_found' });
return reply.send({ profile: row });
});
app.patch('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Body = z.object({
name: z.string().min(1).max(128).optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
if (!parsed.data.name) return reply.send({ ok: true, changed: false });
await db
.update(users)
.set({ name: parsed.data.name })
.where(eq(users.id, user.userId));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.profile_updated',
resourceType: 'user',
ipAddress: req.ip,
});
return reply.send({ ok: true, changed: true });
});
/**
* GDPR Art. 15 / Swiss DSG Art. 25 — right of access. Returns every record
* we hold that belongs to the calling user. Excludes hashed passwords,

View File

@@ -108,6 +108,9 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
});
// ─── Billing status — drives the /settings/billing UI ────────────────────
// Combines our DB state (plan, suspension) with a live Stripe lookup of the
// subscription + recent invoices, so the page can render cancel buttons +
// invoice links inline without a second round-trip.
app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [org] = await db
@@ -122,13 +125,167 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' });
return reply.send({
const base = {
plan: org.plan,
hasCustomer: Boolean(org.stripeCustomerId),
hasSubscription: Boolean(org.stripeSubscriptionId),
suspended: org.suspended,
suspendedReason: org.suspendedReason,
});
};
if (!stripe || !org.stripeSubscriptionId || !org.stripeCustomerId) {
return reply.send(base);
}
try {
const [sub, invoices] = await Promise.all([
stripe.subscriptions.retrieve(org.stripeSubscriptionId),
stripe.invoices.list({ customer: org.stripeCustomerId, limit: 5 }),
]);
const item = sub.items.data[0];
const price = item?.price;
// Stripe v2024-09 moved period boundaries onto subscription items;
// for our single-item subs they're equivalent to the old sub-level field.
const currentPeriodEnd = item?.current_period_end ?? 0;
return reply.send({
...base,
subscription: {
id: sub.id,
status: sub.status,
currentPeriodEnd,
cancelAtPeriodEnd: sub.cancel_at_period_end,
priceId: price?.id ?? null,
amount: price?.unit_amount ?? null,
currency: price?.currency ?? null,
interval: price?.recurring?.interval ?? null,
},
invoices: invoices.data.map((inv) => ({
id: inv.id,
number: inv.number,
status: inv.status,
amountPaid: inv.amount_paid,
currency: inv.currency,
created: inv.created,
pdfUrl: inv.invoice_pdf,
hostedUrl: inv.hosted_invoice_url,
})),
});
} catch (err) {
app.log.warn({ err }, 'stripe status fetch failed — returning db-only');
return reply.send({ ...base, _stripeError: true });
}
});
// ─── In-app cancellation (no portal redirect) ────────────────────────────
// Schedules cancellation at period end — user keeps paid features until the
// billing date already paid for, and Stripe automatically deletes the sub
// after that. The webhook handler converts that to plan='hobby'.
app.post('/v1/billing/cancel', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const sub = await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const cancelAt = sub.items.data[0]?.current_period_end ?? null;
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.cancel_scheduled',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { cancelAt },
ipAddress: req.ip,
});
return reply.send({ ok: true, cancelAt });
} catch (err) {
app.log.error({ err }, 'cancel failed');
return reply.code(502).send({ error: 'cancel_failed' });
}
});
// ─── Reactivate (undo scheduled cancellation) ────────────────────────────
app.post('/v1/billing/reactivate', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: false,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.reactivated',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'reactivate failed');
return reply.code(502).send({ error: 'reactivate_failed' });
}
});
// ─── In-app plan change (upgrade/downgrade between Pro/Team monthly/yearly)
app.post('/v1/billing/change-plan', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const parsed = TierBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const newPriceId = priceIdForTier(parsed.data.tier as PriceTier);
if (!newPriceId) {
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
}
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId);
const itemId = current.items.data[0]?.id;
if (!itemId) return reply.code(500).send({ error: 'subscription_item_missing' });
await stripe.subscriptions.update(org.stripeSubscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.plan_changed',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { tier: parsed.data.tier },
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'plan change failed');
return reply.code(502).send({ error: 'plan_change_failed' });
}
});
// ─── Webhook ─────────────────────────────────────────────────────────────