feat: user menu + profile page + in-app subscription management
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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:
@@ -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,
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user