feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004)

Chiude la decisione "payment provider" aperta in CLAUDE.md.

**Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo.

**Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe.

**Backend** (`apps/api/src/modules/billing/`):
- `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining)
- `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection)
- `POST /me/billing/portal` — Customer Portal Session
- `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed`
- Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking)

**Shared** (`@ketopath/shared/billing/pro-status`):
- `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo)
- `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI
- `computeTrialEndsAt(signupAt, days=30)`
- 11 unit test

**Frontend** (`apps/web/src/app/[locale]/billing/`):
- Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits
- `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro)
- Nav item "Abbonamento" (chapter VI) nel grid asimmetrico
- i18n IT completo (`Billing` namespace)

**Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app.

99/99 test verdi, lint pulito su tutto il monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-07 11:27:06 +02:00
1 parent 41b3646325
commit f455cbe499
28 files changed
+2035 -486

No files matched your search

+13
View File
@@ -31,3 +31,16 @@ BETTER_AUTH_URL=http://localhost:3000
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:hello@ketopath.app
# Stripe — abbonamenti (vedi ADR 0004). Tutti opzionali: senza queste env il
# webhook e gli endpoint /me/billing/* rispondono 503; le route gated (genera
# piano, pesata, fast, export PDF) continuano a funzionare per gli utenti in
# trial o non gated, ma chi è EXPIRED riceve 402 a prescindere.
# Usa le keys "test mode" per sviluppo (sk_test_..., whsec_...).
# I price ID si creano da dashboard Stripe → Products.
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID_MONTHLY=
STRIPE_PRICE_ID_YEARLY=
# URL di base usato nelle redirect post-checkout (success/cancel).
BILLING_RETURN_URL=http://localhost:3000
+1
View File
@@ -24,6 +24,7 @@
"fastify-plugin": "^4.5.1",
"node-cron": "^3.0",
"pdfkit": "^0.15",
"stripe": "^22.1.1",
"web-push": "^3.6",
"zod": "^3.23.8"
},
+4
View File
@@ -7,6 +7,8 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { env } from './config/env.js';
import { Sentry } from './lib/sentry.js';
import { achievementsRoutes } from './modules/achievements/achievements.routes.js';
import { billingRoutes } from './modules/billing/billing.routes.js';
import { stripeWebhookRoutes } from './modules/billing/webhook.routes.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { gdprRoutes } from './modules/me/gdpr.routes.js';
@@ -68,6 +70,8 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(shoppingRoutes);
await app.register(notificationsRoutes);
await app.register(achievementsRoutes);
await app.register(billingRoutes);
await app.register(stripeWebhookRoutes);
if (env.SENTRY_DSN) {
app.setErrorHandler((err, request, reply) => {
@@ -0,0 +1,133 @@
import { deriveProStatus } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { requireAuth } from '../../plugins/auth.js';
import { billingEnv, isBillingConfigured } from './env.js';
import { ensureSubscription, getSubscription, toSnapshot } from './service.js';
import { getStripe } from './stripe-client.js';
const checkoutBodySchema = z.object({
interval: z.enum(['MONTH', 'YEAR']),
});
export const billingRoutes: FastifyPluginAsync = async (fastify) => {
/**
* GET /me/billing/status — stato corrente dell'abbonamento + derived.
* Risponde sempre 200 (anche per utenti senza subscription record): il
* frontend usa lo stesso payload sia per chi non ha mai aperto la pagina
* billing sia per chi è in trial / pro / canceled.
*/
fastify.get('/me/billing/status', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const signupAt = request.user!.createdAt;
// Side effect benefico: chiamare /status la prima volta crea il record
// di trial se non esiste. Idempotente.
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
const snap = toSnapshot(sub);
const derived = deriveProStatus(snap);
return {
subscription: snap
? {
status: snap.status,
trialEndsAt: snap.trialEndsAt.toISOString(),
currentPeriodEnd: snap.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: snap.cancelAtPeriodEnd,
interval: sub.interval,
stripePriceId: sub.stripePriceId,
}
: null,
derived: {
kind: derived.kind,
isPro: derived.isPro,
trialDaysRemaining: derived.trialDaysRemaining,
accessEndsAt: derived.accessEndsAt?.toISOString() ?? null,
},
configured: isBillingConfigured(),
};
});
/**
* POST /me/billing/checkout — crea una Stripe Checkout Session per il
* primo upgrade. Restituisce l'URL hosted Stripe a cui redirigere l'utente.
*/
fastify.post('/me/billing/checkout', { preHandler: requireAuth() }, async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const body = checkoutBodySchema.safeParse(request.body);
if (!body.success) {
return reply.code(400).send({ error: 'invalid_body', issues: body.error.issues });
}
const { interval } = body.data;
const priceId =
interval === 'YEAR' ? billingEnv.STRIPE_PRICE_ID_YEARLY : billingEnv.STRIPE_PRICE_ID_MONTHLY;
if (!priceId) {
return reply.code(503).send({ error: 'price_not_configured' });
}
const userId = request.user!.id;
const userEmail = request.user!.email;
const signupAt = request.user!.createdAt;
const stripe = getStripe();
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
// Riusa il customer Stripe se l'utente ha già pagato in passato; altrimenti
// ne creiamo uno nuovo passando metadata.userId per il webhook.
let customerId = sub.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
});
customerId = customer.id;
await fastify.prisma.subscription.update({
where: { userId },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=canceled`,
// Critico: il webhook risale all'utente da queste metadata.
subscription_data: { metadata: { userId } },
// Stripe Tax: calcolo automatico dell'IVA UE (vedi ADR 0004).
automatic_tax: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
// Permette all'utente di inserire una P.IVA (per chi compra B2B).
tax_id_collection: { enabled: true },
allow_promotion_codes: true,
locale: 'it',
});
return reply.send({ url: session.url });
});
/**
* POST /me/billing/portal — crea una Customer Portal Session per la
* gestione self-service di abbonamento, fatture, metodo di pagamento.
*/
fastify.post('/me/billing/portal', { preHandler: requireAuth() }, async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const userId = request.user!.id;
const sub = await getSubscription(fastify.prisma, userId);
if (!sub?.stripeCustomerId) {
return reply.code(409).send({ error: 'no_stripe_customer' });
}
const stripe = getStripe();
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripeCustomerId,
return_url: `${billingEnv.BILLING_RETURN_URL}/billing`,
locale: 'it',
});
return reply.send({ url: session.url });
});
};
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
/**
* ADR 0004 — env vars Stripe. Tutte opzionali: se mancano, i flussi di
* billing rispondono 503 (come abbiamo fatto per VAPID in ADR 0003).
* Questo permette al PO di sviluppare features non-billing senza dover
* configurare Stripe localmente.
*/
const billingEnvSchema = z.object({
STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_PRICE_ID_MONTHLY: z.string().min(1).optional(),
STRIPE_PRICE_ID_YEARLY: z.string().min(1).optional(),
// URL di base usato per le redirect post-checkout. Deve coincidere col
// dominio del frontend (apps/web).
BILLING_RETURN_URL: z.string().url().optional(),
});
export type BillingEnv = z.infer<typeof billingEnvSchema>;
export function readBillingEnv(source: NodeJS.ProcessEnv = process.env): BillingEnv {
return billingEnvSchema.parse(source);
}
export const billingEnv: BillingEnv = readBillingEnv();
export function isBillingConfigured(env: BillingEnv = billingEnv): boolean {
return Boolean(
env.STRIPE_SECRET_KEY &&
env.STRIPE_WEBHOOK_SECRET &&
env.STRIPE_PRICE_ID_MONTHLY &&
env.BILLING_RETURN_URL,
);
}
+168
View File
@@ -0,0 +1,168 @@
import {
computeTrialEndsAt,
isProActive,
type SubscriptionSnapshot,
type SubscriptionStatus as SharedStatus,
} from '@ketopath/shared';
import type { PrismaClient, Subscription } from '@prisma/client';
// eslint-disable-next-line import/no-named-as-default
import type Stripe from 'stripe';
/**
* ADR 0004 — service di billing. Orchestrazione tra Stripe e DB locale.
* Le route lo invocano per leggere/scrivere lo stato subscription; il
* webhook lo invoca per applicare gli eventi `customer.subscription.*`.
*/
export function toSnapshot(sub: Subscription | null): SubscriptionSnapshot | null {
if (!sub) return null;
return {
status: sub.status as SharedStatus,
trialEndsAt: sub.trialEndsAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
};
}
/**
* Crea il record Subscription per l'utente se non esiste — il trial parte
* dalla data di registrazione. Idempotente: se esiste già, non tocca nulla.
*/
export async function ensureSubscription(
prisma: PrismaClient,
userId: string,
signupAt: Date,
): Promise<Subscription> {
return prisma.subscription.upsert({
where: { userId },
create: {
userId,
status: 'TRIALING',
trialEndsAt: computeTrialEndsAt(signupAt),
},
update: {},
});
}
export async function getSubscription(
prisma: PrismaClient,
userId: string,
): Promise<Subscription | null> {
return prisma.subscription.findUnique({ where: { userId } });
}
export async function isUserPro(prisma: PrismaClient, userId: string): Promise<boolean> {
const sub = await getSubscription(prisma, userId);
return isProActive(toSnapshot(sub));
}
/* ─────────────────────────────────────────────────────────────────────
* Mapping da Stripe.Subscription al nostro stato.
* ────────────────────────────────────────────────────────────────────── */
function mapStatus(
stripeStatus: Stripe.Subscription.Status,
cancelAtPeriodEnd: boolean,
): SharedStatus {
if (cancelAtPeriodEnd && (stripeStatus === 'active' || stripeStatus === 'trialing')) {
return 'CANCEL_AT_PERIOD_END';
}
switch (stripeStatus) {
case 'trialing':
return 'TRIALING';
case 'active':
return 'ACTIVE';
case 'past_due':
return 'PAST_DUE';
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
return 'CANCELED';
case 'incomplete':
case 'paused':
// Stati transitori — manteniamo lo stato corrente in DB. Stripe rinotifica
// appena la situazione si stabilizza. Per sicurezza torniamo PAST_DUE.
return 'PAST_DUE';
}
}
function mapInterval(
interval: Stripe.Price.Recurring.Interval | null | undefined,
): 'MONTH' | 'YEAR' | null {
if (interval === 'month') return 'MONTH';
if (interval === 'year') return 'YEAR';
return null;
}
/**
* Applica un evento `customer.subscription.*` o `checkout.session.completed`
* di Stripe al record locale. Idempotente: lo possiamo invocare con lo stesso
* payload N volte senza side effect.
*/
export async function applyStripeSubscription(
prisma: PrismaClient,
stripeSub: Stripe.Subscription,
): Promise<void> {
const userId = stripeSub.metadata?.userId;
if (!userId) {
throw new Error(`stripe subscription ${stripeSub.id} priva di metadata.userId`);
}
const item = stripeSub.items.data[0];
if (!item) throw new Error(`stripe subscription ${stripeSub.id} senza items`);
const cancelAtPeriodEnd = stripeSub.cancel_at_period_end;
const status = mapStatus(stripeSub.status, cancelAtPeriodEnd);
const currentPeriodEnd = item.current_period_end
? new Date(item.current_period_end * 1000)
: null;
const trialEnd = stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null;
const customerId =
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id;
await prisma.subscription.upsert({
where: { userId },
create: {
userId,
status,
// Se Stripe ci dà un trial_end (caso del trial-with-card), lo prendiamo.
// Altrimenti usiamo il trial nativo a 30gg dalla creazione del record.
trialEndsAt: trialEnd ?? computeTrialEndsAt(new Date()),
stripeCustomerId: customerId,
stripeSubscriptionId: stripeSub.id,
stripePriceId: item.price.id,
currentPeriodEnd,
interval: mapInterval(item.price.recurring?.interval),
cancelAtPeriodEnd,
endedAt: status === 'CANCELED' ? new Date() : null,
},
update: {
status,
stripeCustomerId: customerId,
stripeSubscriptionId: stripeSub.id,
stripePriceId: item.price.id,
currentPeriodEnd,
interval: mapInterval(item.price.recurring?.interval),
cancelAtPeriodEnd,
endedAt: status === 'CANCELED' ? new Date() : null,
},
});
}
/**
* Marca come EXPIRED le subscription TRIALING il cui trial è scaduto.
* Invocata da uno scheduler giornaliero (vedi notifications/scheduler).
* Restituisce il numero di record aggiornati.
*/
export async function expireFinishedTrials(prisma: PrismaClient, now: Date = new Date()) {
const res = await prisma.subscription.updateMany({
where: {
status: 'TRIALING',
trialEndsAt: { lte: now },
},
data: {
status: 'EXPIRED',
endedAt: now,
},
});
return res.count;
}
@@ -0,0 +1,26 @@
// eslint-disable-next-line import/no-named-as-default
import Stripe from 'stripe';
import { billingEnv } from './env.js';
/**
* Singleton Stripe client. Costruito lazy: se `STRIPE_SECRET_KEY` non è
* configurato (es. in CI / sviluppo locale senza account Stripe), `getStripe`
* lancia. Le route che dipendono da Stripe rispondono 503 prima di chiamare.
*/
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (_stripe) return _stripe;
if (!billingEnv.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY non configurata');
}
_stripe = new Stripe(billingEnv.STRIPE_SECRET_KEY, {
// Pin esplicito: niente sorprese quando Stripe rilascia una nuova
// apiVersion. Aggiornare insieme allo `stripe` package.
apiVersion: '2025-09-30.clover',
typescript: true,
appInfo: { name: 'KetoPath', version: '0.0.0' },
});
return _stripe;
}
@@ -0,0 +1,127 @@
import type { PrismaClient } from '@prisma/client';
import type { FastifyPluginAsync } from 'fastify';
// eslint-disable-next-line import/no-named-as-default
import type Stripe from 'stripe';
import { billingEnv, isBillingConfigured } from './env.js';
import { applyStripeSubscription } from './service.js';
import { getStripe } from './stripe-client.js';
/**
* ADR 0004 — webhook Stripe. Receive raw body, verifica firma HMAC,
* idempotenza via tabella `BillingWebhookEvent`, dispatch agli handler
* per i 5 eventi che ci interessano.
*
* Encapsulation: il content-type parser raw è registrato dentro un
* `fastify.register()` scope così non interferisce con le altre rotte
* (che si aspettano JSON parsato).
*/
export const stripeWebhookRoutes: FastifyPluginAsync = async (parent) => {
await parent.register(async (instance) => {
instance.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(_request, body, done) => {
done(null, body);
},
);
instance.post('/webhooks/stripe', async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const signature = request.headers['stripe-signature'];
if (typeof signature !== 'string') {
return reply.code(400).send({ error: 'missing_signature' });
}
const rawBody = request.body as Buffer;
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
billingEnv.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
request.log.warn({ err }, 'webhook signature verification failed');
return reply.code(400).send({ error: 'invalid_signature' });
}
// Idempotenza: se l'evento è già stato visto, rispondiamo 200 senza
// ri-eseguire l'handler. Stripe accetta anche 200 su evento già processato.
const existing = await instance.prisma.billingWebhookEvent.findUnique({
where: { id: event.id },
});
if (existing?.processedAt) {
request.log.debug({ eventId: event.id }, 'webhook already processed, skipping');
return reply.code(200).send({ received: true, duplicate: true });
}
// Registriamo l'evento (anche se l'handler fallirà sotto, così abbiamo
// un audit trail di tutto quello che Stripe ci ha mandato).
await instance.prisma.billingWebhookEvent.upsert({
where: { id: event.id },
create: {
id: event.id,
type: event.type,
payload: event as unknown as Stripe.Event,
},
update: {},
});
try {
await dispatchEvent(instance.prisma, stripe, event);
await instance.prisma.billingWebhookEvent.update({
where: { id: event.id },
data: { processedAt: new Date() },
});
return reply.code(200).send({ received: true });
} catch (err) {
request.log.error({ err, eventId: event.id, type: event.type }, 'webhook handler failed');
// 500 → Stripe ritenta. processedAt resta null, idempotenza ok al
// prossimo retry.
return reply.code(500).send({ error: 'handler_failed' });
}
});
});
};
async function dispatchEvent(
prisma: PrismaClient,
stripe: Stripe,
event: Stripe.Event,
): Promise<void> {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await applyStripeSubscription(prisma, sub);
return;
}
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== 'subscription' || !session.subscription) return;
const subId =
typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
// Recuperiamo la subscription completa (con items expansi) per avere
// tutti i dati che ci servono per applyStripeSubscription.
const fullSub = await stripe.subscriptions.retrieve(subId);
await applyStripeSubscription(prisma, fullSub);
return;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const lineSub = invoice.lines.data.find((line) => line.subscription)?.subscription;
if (!lineSub) return;
const subId = typeof lineSub === 'string' ? lineSub : lineSub.id;
const fullSub = await stripe.subscriptions.retrieve(subId);
await applyStripeSubscription(prisma, fullSub);
return;
}
default:
// Eventi non gestiti — log silenzioso. Stripe ne manda decine, normale.
return;
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import type { FastifyPluginAsync } from 'fastify';
import PDFDocument from 'pdfkit';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
@@ -26,7 +27,7 @@ const MEAL_LABELS: Record<string, string> = {
export const planExportRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get(
'/me/meal-plans/export.pdf',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
+202 -196
View File
@@ -24,6 +24,7 @@ import {
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
@@ -72,220 +73,225 @@ function parseConditions(raw: string | null): string[] {
}
export const planRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
fastify.post(
'/me/meal-plans',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const profile = await fastify.prisma.profile.findUnique({
where: { userId },
include: { user: { include: { preferences: true } } },
});
if (!profile) return reply.code(409).send({ error: 'profile_required' });
const profile = await fastify.prisma.profile.findUnique({
where: { userId },
include: { user: { include: { preferences: true } } },
});
if (!profile) return reply.code(409).send({ error: 'profile_required' });
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
},
},
},
},
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
// che contengono ingredienti non ancora reintrodotti per la settimana
// corrente di fase 2.
const phaseIntForFilter = phaseToInt(profile.currentPhase);
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
const allowedRecipes = recipes.filter((r) =>
isRecipeAllowedForPhaseWeek(
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
phaseIntForFilter,
phase2WeekForFilter,
),
);
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
return {
id: r.id,
name: r.name,
category: r.category,
kcal: r.kcal,
proteinG: r.proteinG,
fatG: r.fatG,
netCarbG: r.netCarbG,
exclusionTags: Array.from(tags),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
// che contengono ingredienti non ancora reintrodotti per la settimana
// corrente di fase 2.
const phaseIntForFilter = phaseToInt(profile.currentPhase);
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
const allowedRecipes = recipes.filter((r) =>
isRecipeAllowedForPhaseWeek(
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
phaseIntForFilter,
phase2WeekForFilter,
),
);
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
}
return {
id: r.id,
name: r.name,
category: r.category,
kcal: r.kcal,
proteinG: r.proteinG,
fatG: r.fatG,
netCarbG: r.netCarbG,
exclusionTags: Array.from(tags),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
const weekStart = startOfWeek(new Date());
const plan = await fastify.prisma.mealPlan.upsert({
where: { userId_weekStart: { userId, weekStart } },
create: { userId, weekStart },
update: { generatedAt: new Date(), status: 'ACTIVE' },
});
// Wipe existing slots before regenerating, in case the plan already existed.
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
for (let day = 0; day < 7; day++) {
const dayPlan = protocolPlanForDay(fastingProtocol, day);
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ??
null;
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
// Ricette scelte negli ultimi 2 giorni sono "recenti".
const recentlyConsumedIds: string[] = plannedSlots
.filter((s) => day - s.day <= 2 && s.recipeId)
.map((s) => s.recipeId!);
const weekStart = startOfWeek(new Date());
for (const meal of MEALS) {
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes,
topN: 5,
});
const selected = top[0];
await fastify.prisma.mealSlot.create({
data: {
planId: plan.id,
dayOfWeek: day,
meal,
recipeId: selected?.id ?? null,
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
},
});
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
}
}
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
const plan = await fastify.prisma.mealPlan.upsert({
where: { userId_weekStart: { userId, weekStart } },
create: { userId, weekStart },
update: { generatedAt: new Date(), status: 'ACTIVE' },
});
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
});
// Wipe existing slots before regenerating, in case the plan already existed.
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
for (let day = 0; day < 7; day++) {
const dayPlan = protocolPlanForDay(fastingProtocol, day);
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
// Ricette scelte negli ultimi 2 giorni sono "recenti".
const recentlyConsumedIds: string[] = plannedSlots
.filter((s) => day - s.day <= 2 && s.recipeId)
.map((s) => s.recipeId!);
for (const meal of MEALS) {
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes,
topN: 5,
});
const selected = top[0];
await fastify.prisma.mealSlot.create({
data: {
planId: plan.id,
dayOfWeek: day,
meal,
recipeId: selected?.id ?? null,
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
},
});
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
}
}
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
},
);
fastify.patch(
'/me/meal-plans/slots/:slotId',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const body = request.body as { recipeId?: unknown };
@@ -322,7 +328,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// automaticamente un nuovo digiuno.
fastify.post(
'/me/meal-plans/slots/:slotId/consumed',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -398,7 +404,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
fastify.post(
'/me/meal-plans/slots/:slotId/free-meal',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -424,7 +430,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// conto dei pasti del giorno già scelti (coerenza dei macros).
fastify.post(
'/me/meal-plans/slots/:slotId/regenerate',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -5,6 +5,7 @@ import { dailyCheckInInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
function todayDateOnly(): Date {
const d = new Date();
@@ -54,36 +55,40 @@ export const checkInRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = dailyCheckInInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
const date = new Date(data.date);
date.setHours(0, 0, 0, 0);
fastify.post(
'/me/check-ins',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = dailyCheckInInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
const date = new Date(data.date);
date.setHours(0, 0, 0, 0);
const item = await fastify.prisma.dailyCheckIn.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
update: {
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
});
const item = await fastify.prisma.dailyCheckIn.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
update: {
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
});
return reply.code(201).send({ item: { id: item.id } });
});
return reply.code(201).send({ item: { id: item.id } });
},
);
};
+149 -134
View File
@@ -6,6 +6,7 @@ import type { FastifyPluginAsync } from 'fastify';
import PDFDocument from 'pdfkit';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
@@ -14,151 +15,165 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
});
export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const userEmail = request.user!.email ?? '—';
fastify.get(
'/me/tracking/export.pdf',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const userEmail = request.user!.email ?? '—';
const [profile, entries, currentPlan] = await Promise.all([
fastify.prisma.profile.findUnique({ where: { userId } }),
fastify.prisma.weightEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 30,
}),
fastify.prisma.mealPlan.findFirst({
where: { userId, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
const [profile, entries, currentPlan] = await Promise.all([
fastify.prisma.profile.findUnique({ where: { userId } }),
fastify.prisma.weightEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 30,
}),
fastify.prisma.mealPlan.findFirst({
where: { userId, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
},
},
},
},
}),
]);
}),
]);
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
// ── Header ─────────────────────────────────────────────────────────
doc
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(22)
.text('KetoPath', { continued: true })
.fillColor('#9a3f29')
.text('.');
doc
.moveDown(0.2)
.fillColor('#3d3530')
.font('Helvetica')
.fontSize(9)
.text(`Sintesi tracking — ${userEmail}`)
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
doc
.moveDown(1)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
// ── Profile summary ────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (profile) {
const weightStart = Number(profile.weightStartKg);
const weightCurrent = Number(profile.weightCurrentKg);
const weightGoal = Number(profile.weightGoalKg);
const lostKg = weightStart - weightCurrent;
// ── Header ─────────────────────────────────────────────────────────
doc
.moveDown(0.3)
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
.text(
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
)
.text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`)
.text(`Fase corrente: ${profile.currentPhase}`);
} else {
doc.text('Profilo non ancora configurato.');
}
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(22)
.text('KetoPath', { continued: true })
.fillColor('#9a3f29')
.text('.');
doc
.moveDown(0.2)
.fillColor('#3d3530')
.font('Helvetica')
.fontSize(9)
.text(`Sintesi tracking — ${userEmail}`)
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
doc
.moveDown(1)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
// ── Weight history ─────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
if (entries.length === 0) {
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
} else {
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false });
const headerY = doc.y - 11;
doc.text('Peso (kg)', colX.weight, headerY);
doc.text('Energia', colX.energy, headerY);
doc.text('Sonno', colX.sleep, headerY);
doc.text('Fame', colX.hunger, headerY);
doc.font('Helvetica').moveDown(0.5);
for (const e of entries) {
const y = doc.y;
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
doc.moveDown(0.4);
// ── Profile summary ────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (profile) {
const weightStart = Number(profile.weightStartKg);
const weightCurrent = Number(profile.weightCurrentKg);
const weightGoal = Number(profile.weightGoalKg);
const lostKg = weightStart - weightCurrent;
doc
.moveDown(0.3)
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
.text(
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
)
.text(
`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`,
)
.text(`Fase corrente: ${profile.currentPhase}`);
} else {
doc.text('Profilo non ancora configurato.');
}
}
// ── Current plan summary ───────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (!currentPlan) {
doc.moveDown(0.3).text('Nessun piano attivo.');
} else {
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
// ── Weight history ─────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
if (entries.length === 0) {
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
} else {
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
doc
.moveDown(0.5)
.font('Helvetica-Bold')
.text('Data', colX.date, doc.y, { continued: false });
const headerY = doc.y - 11;
doc.text('Peso (kg)', colX.weight, headerY);
doc.text('Energia', colX.energy, headerY);
doc.text('Sonno', colX.sleep, headerY);
doc.text('Fame', colX.hunger, headerY);
doc.font('Helvetica').moveDown(0.5);
for (const e of entries) {
const y = doc.y;
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
doc.moveDown(0.4);
}
}
// ── Current plan summary ───────────────────────────────────────────
doc
.moveDown(0.3)
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
.moveDown(1)
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(11)
.text('PIANO CORRENTE');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (!currentPlan) {
doc.moveDown(0.3).text('Nessun piano attivo.');
} else {
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
doc
.moveDown(0.3)
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
.text(
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
);
}
// ── Footer ─────────────────────────────────────────────────────────
doc
.moveDown(2)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
doc
.moveDown(0.5)
.fontSize(8)
.fillColor('#7a7060')
.text(
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
);
}
// ── Footer ─────────────────────────────────────────────────────────
doc
.moveDown(2)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
doc
.moveDown(0.5)
.fontSize(8)
.fillColor('#7a7060')
.text(
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
);
doc.end();
const pdfBuffer = await done;
return reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
.header('Content-Length', String(pdfBuffer.length))
.send(pdfBuffer);
});
doc.end();
const pdfBuffer = await done;
return reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
.header('Content-Length', String(pdfBuffer.length))
.send(pdfBuffer);
},
);
};
+92 -79
View File
@@ -6,6 +6,7 @@ import {
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
@@ -29,91 +30,103 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = fastEventStartSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const event = await fastify.prisma.fastEvent.create({
data: {
userId: request.user!.id,
protocol: data.protocol,
startedAt: data.startedAt ?? new Date(),
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
},
});
return reply.code(201).send({ event: { id: event.id } });
});
fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => {
const id = (request.params as { id: string }).id;
const parsed = fastEventUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
// Garantisce che l'utente possa aggiornare solo i propri eventi.
const owned = await fastify.prisma.fastEvent.findFirst({
where: { id, userId: request.user!.id },
select: { id: true },
});
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
if (data.endedAt) updateData.endedAt = data.endedAt;
if (data.status) updateData.status = data.status;
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
if (data.notes != null) updateData.notes = data.notes;
const event = await fastify.prisma.fastEvent.update({
where: { id },
data: updateData,
});
let newlyUnlocked: string[] = [];
if (event.status === 'COMPLETED') {
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
fastify.post(
'/me/fast-events',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = fastEventStartSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
}
return { event: { id: event.id, status: event.status }, newlyUnlocked };
});
const data = parsed.data;
const event = await fastify.prisma.fastEvent.create({
data: {
userId: request.user!.id,
protocol: data.protocol,
startedAt: data.startedAt ?? new Date(),
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
},
});
return reply.code(201).send({ event: { id: event.id } });
},
);
fastify.patch(
'/me/fast-events/:id',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const id = (request.params as { id: string }).id;
const parsed = fastEventUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
// Garantisce che l'utente possa aggiornare solo i propri eventi.
const owned = await fastify.prisma.fastEvent.findFirst({
where: { id, userId: request.user!.id },
select: { id: true },
});
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
if (data.endedAt) updateData.endedAt = data.endedAt;
if (data.status) updateData.status = data.status;
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
if (data.notes != null) updateData.notes = data.notes;
const event = await fastify.prisma.fastEvent.update({
where: { id },
data: updateData,
});
let newlyUnlocked: string[] = [];
if (event.status === 'COMPLETED') {
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
}
return { event: { id: event.id, status: event.status }, newlyUnlocked };
},
);
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const body = (request.body ?? {}) as { until?: string | null };
let until: Date | null = null;
if (body.until === null) {
until = null;
} else if (typeof body.until === 'string') {
const d = new Date(body.until);
if (Number.isNaN(d.getTime())) {
return reply.code(400).send({ error: 'invalid_until' });
fastify.post(
'/me/fasting/pause',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const body = (request.body ?? {}) as { until?: string | null };
let until: Date | null = null;
if (body.until === null) {
until = null;
} else if (typeof body.until === 'string') {
const d = new Date(body.until);
if (Number.isNaN(d.getTime())) {
return reply.code(400).send({ error: 'invalid_until' });
}
until = d;
} else {
// default: domani alle 06:00 locali (server)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
until = tomorrow;
}
until = d;
} else {
// default: domani alle 06:00 locali (server)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
until = tomorrow;
}
const user = await fastify.prisma.user.update({
where: { id: userId },
data: { fastingPausedUntil: until },
select: { fastingPausedUntil: true },
});
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
});
const user = await fastify.prisma.user.update({
where: { id: userId },
data: { fastingPausedUntil: until },
select: { fastingPausedUntil: true },
});
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
},
);
// PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
+46 -41
View File
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
@@ -26,48 +27,52 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = weightEntryInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
fastify.post(
'/me/weight-entries',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = weightEntryInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
const entry = await fastify.prisma.weightEntry.upsert({
where: { userId_date: { userId, date: data.date } },
create: {
userId,
date: data.date,
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
update: {
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
});
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
const entry = await fastify.prisma.weightEntry.upsert({
where: { userId_date: { userId, date: data.date } },
create: {
userId,
date: data.date,
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
update: {
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
});
}
return reply.code(201).send({
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
newlyUnlocked: ach.newlyUnlocked,
});
});
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
return reply.code(201).send({
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
newlyUnlocked: ach.newlyUnlocked,
});
},
);
};
+29
View File
@@ -0,0 +1,29 @@
import { isProActive } from '@ketopath/shared';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { ensureSubscription, toSnapshot } from '../modules/billing/service.js';
/**
* ADR 0004 — middleware "paywall": le rotte/azioni Pro-only lo applicano
* dopo `requireAuth()`. Risponde 402 (`payment_required`) se l'utente non
* ha accesso Pro (trial scaduto, canceled, expired).
*
* Side effect benefico: `ensureSubscription` crea il record di trial se non
* esiste. Significa che chiamando una rotta gated, un utente nuovo riceve
* automaticamente il trial — non servono webhook on-signup.
*/
export function requirePro() {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (!request.user) {
return reply.code(401).send({ error: 'unauthorized' });
}
const sub = await ensureSubscription(
request.server.prisma,
request.user.id,
request.user.createdAt,
);
if (!isProActive(toSnapshot(sub))) {
return reply.code(402).send({ error: 'payment_required', kind: 'pro_required' });
}
};
}
+53
View File
@@ -15,6 +15,7 @@
"viewShopping": "Lista della spesa",
"viewTracking": "Pesata & misure",
"viewFasting": "Digiuno intermittente",
"viewBilling": "Abbonamento & fatture",
"lettura": "Continua la lettura",
"inizia": "Inizia",
"greeting": "Buongiorno, <name>{name}</name>.",
@@ -710,5 +711,57 @@
"email_required": "Email assente sul tuo account.",
"api_error": "Errore di rete. Riprova fra poco."
}
},
"Billing": {
"title": "Abbonamento",
"subtitle": "Trenta giorni di prova gratuiti, senza carta. Poi un piano semplice — mensile o annuale — che si gestisce in autonomia dal portale Stripe.",
"currentStatus": "Stato attuale",
"actions": "Azioni",
"trialActive": "Sei in prova ({days} giorni rimasti).",
"trialActiveBody": "Hai accesso completo a piani, tracking, digiuno e foto progress fino al {endsAt}. Allo scadere puoi continuare a leggere lo storico — ma per nuove pesate, piani e foto serve l'abbonamento.",
"trialExpired": "La prova è scaduta.",
"trialExpiredBody": "Lo storico resta consultabile. Per ricominciare a generare piani, registrare pesate e completare digiuni, attiva un abbonamento.",
"proActive": "Abbonamento attivo.",
"proActiveBody": "Piano {interval}, prossimo rinnovo il {renewsAt}. Tutto sbloccato.",
"pastDue": "Pagamento in sospeso.",
"pastDueBody": "L'ultimo addebito è fallito. Stripe sta riprovando: aggiorna la carta dal portale per evitare interruzioni.",
"canceling": "Disdetta in corso.",
"cancelingBody": "L'abbonamento resta valido fino al {endsAt}. Puoi riattivarlo in qualsiasi momento dal portale.",
"canceled": "Abbonamento disdetto.",
"canceledBody": "Lo storico resta tuo e consultabile. Quando vuoi rientrare, basta un nuovo upgrade.",
"noSubscription": "Nessun abbonamento attivo.",
"intervalMonth": "mensile",
"intervalYear": "annuale",
"upgradeMonthly": "Abbonati — €9,90 / mese",
"upgradeYearly": "Abbonati — €89 / anno (≈25% di sconto)",
"managePortal": "Gestisci abbonamento",
"opening": "Apertura…",
"checkoutHint": "Verrai reindirizzato al pagamento sicuro su Stripe.",
"notConfigured": "Pagamenti non ancora attivati. La tua prova continua: nessuna interruzione.",
"errorLoading": "Impossibile caricare lo stato dell'abbonamento.",
"whatYouGet": "Cosa è incluso",
"benefit1Title": "Piani settimanali infiniti",
"benefit1Body": "Genera quanti piani vuoi, riadattati alla fase, alle preferenze e alle esclusioni alimentari. Con riadattamento automatico se cambi peso o protocollo.",
"benefit2Title": "Tracking completo",
"benefit2Body": "Pesate quotidiane, check-in soggettivi, foto progress, digiuno con timer e milestone. Tutto cifrato a riposo (art. 9 GDPR).",
"benefit3Title": "Export PDF per il medico",
"benefit3Body": "Un documento sintetico — peso, misure, aderenza — pronto da inviare al nutrizionista o consultare in studio.",
"trialBannerTitle": "Sei in prova",
"trialBannerBody": "{days} giorni rimasti. Tutto sbloccato fino al {endsAt}.",
"trialBannerCta": "Vai all'abbonamento",
"trialEndedBannerTitle": "La prova è scaduta",
"trialEndedBannerBody": "Per generare nuovi piani e registrare pesate serve l'abbonamento.",
"trialEndedBannerCta": "Abbonati",
"paywallTitle": "Funzione Pro",
"paywallBody": "Questa azione richiede un abbonamento attivo.",
"paywallCta": "Vai all'abbonamento",
"navLabel": "Abbonamento",
"error": {
"billing_not_configured": "Il sistema di pagamento non è ancora configurato. Riprova più tardi.",
"no_stripe_customer": "Non hai ancora un abbonamento attivo. Avvia prima il checkout.",
"api_error_400": "Richiesta non valida.",
"api_error_401": "Devi accedere per gestire l'abbonamento.",
"api_error_500": "Errore del server. Riprova fra poco."
}
}
}
@@ -0,0 +1,66 @@
'use server';
import type { ProDerivedKind, SubscriptionStatus } from '@ketopath/shared';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export interface BillingStatus {
subscription: {
status: SubscriptionStatus;
trialEndsAt: string;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
interval: 'MONTH' | 'YEAR' | null;
stripePriceId: string | null;
} | null;
derived: {
kind: ProDerivedKind;
isPro: boolean;
trialDaysRemaining: number | null;
accessEndsAt: string | null;
};
configured: boolean;
}
export async function fetchBillingStatus(): Promise<BillingStatus | null> {
const res = await fetch(`${API_URL}/me/billing/status`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return null;
return (await res.json()) as BillingStatus;
}
export async function startCheckout(
interval: 'MONTH' | 'YEAR',
): Promise<{ url: string } | { error: string }> {
const res = await fetch(`${API_URL}/me/billing/checkout`, {
method: 'POST',
headers: {
cookie: cookieHeader(),
'content-type': 'application/json',
},
body: JSON.stringify({ interval }),
cache: 'no-store',
});
if (res.status === 503) return { error: 'billing_not_configured' };
if (!res.ok) return { error: `api_error_${res.status}` };
return (await res.json()) as { url: string };
}
export async function openCustomerPortal(): Promise<{ url: string } | { error: string }> {
const res = await fetch(`${API_URL}/me/billing/portal`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (res.status === 409) return { error: 'no_stripe_customer' };
if (res.status === 503) return { error: 'billing_not_configured' };
if (!res.ok) return { error: `api_error_${res.status}` };
return (await res.json()) as { url: string };
}
@@ -0,0 +1,59 @@
'use client';
import { useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { openCustomerPortal, startCheckout } from './actions';
interface Props {
hasStripeCustomer: boolean;
isPro: boolean;
}
export function BillingActionsBar({ hasStripeCustomer, isPro }: Props) {
const t = useTranslations('Billing');
const [pending, startTransition] = useTransition();
function handleCheckout(interval: 'MONTH' | 'YEAR') {
startTransition(async () => {
const res = await startCheckout(interval);
if ('url' in res) {
window.location.href = res.url;
} else {
alert(t(`error.${res.error}`));
}
});
}
function handlePortal() {
startTransition(async () => {
const res = await openCustomerPortal();
if ('url' in res) {
window.location.href = res.url;
} else {
alert(t(`error.${res.error}`));
}
});
}
if (isPro && hasStripeCustomer) {
return (
<Button onClick={handlePortal} disabled={pending} size="lg">
{pending ? t('opening') : t('managePortal')}
</Button>
);
}
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<Button onClick={() => handleCheckout('MONTH')} disabled={pending} size="lg">
{pending ? t('opening') : t('upgradeMonthly')}
</Button>
<Button onClick={() => handleCheckout('YEAR')} disabled={pending} size="lg" variant="outline">
{pending ? t('opening') : t('upgradeYearly')}
</Button>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { prisma } from '@ketopath/db';
import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { CursorGlow } from '@/components/cursor-glow';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchBillingStatus, type BillingStatus } from './actions';
import { BillingActionsBar } from './billing-actions-bar';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
export default async function BillingPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { disclaimerAcceptedAt: true },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
const status = await fetchBillingStatus();
return <BillingPageContent status={status} />;
}
function BillingPageContent({ status }: { status: BillingStatus | null }) {
const t = useTranslations('Billing');
if (!status) {
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead issueLabel="N. 08 — Abbonamento" />
<main className="pb-24 pt-12">
<p className="editorial-eyebrow">{t('errorLoading')}</p>
</main>
</div>
</div>
);
}
const { subscription, derived, configured } = status;
const hasStripeCustomer = subscription?.stripePriceId != null;
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead issueLabel="N. 08 — Abbonamento" />
<main className="relative overflow-hidden pb-24 pt-10 sm:pt-12">
<CursorGlow color="hsl(var(--oro))" size={520} />
<div
aria-hidden
className="mesh-blob mesh-blob--oro animate-float-y -right-32 top-12 h-[28rem] w-[28rem] opacity-50"
/>
<div
aria-hidden
className="mesh-blob mesh-blob--pomodoro animate-float-x -left-24 top-72 h-72 w-72 opacity-35"
/>
<span
aria-hidden
className="font-display text-stroke-thin text-chapter pointer-events-none absolute -right-4 -top-12 select-none italic"
>
VIII
</span>
<div
aria-hidden
className="pointer-events-none absolute right-0 top-32 hidden md:block"
style={{ writingMode: 'vertical-rl' }}
>
<p className="text-ink-soft font-mono text-xs uppercase tracking-[0.4em]">
Capitolo VIII — Abbonamento
</p>
</div>
<h1 className="font-display text-mega bleed-left animate-fade-up relative mt-8 font-medium [animation-delay:120ms]">
<span className="text-ink block">
{t('title')}
<span className="text-pomodoro">.</span>
</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up relative mt-10 max-w-2xl text-2xl italic leading-snug [animation-delay:240ms] md:ml-[16.66%]">
{t('subtitle')}
</p>
<div className="rule animate-rule-in my-12 [animation-delay:360ms]" />
<section className="animate-fade-up relative grid gap-12 [animation-delay:420ms] md:grid-cols-12">
<div className="md:col-span-7">
<p className="editorial-eyebrow mb-4">{t('currentStatus')}</p>
<StatusBlock derived={derived} subscription={subscription} />
</div>
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-4">{t('actions')}</p>
{!configured ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('notConfigured')}
</p>
) : (
<div className="space-y-6">
<BillingActionsBar hasStripeCustomer={hasStripeCustomer} isPro={derived.isPro} />
{!derived.isPro || !hasStripeCustomer ? (
<p className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{t('checkoutHint')}
</p>
) : null}
</div>
)}
</aside>
</section>
<div className="rule animate-rule-in my-16 [animation-delay:540ms]" />
<section className="animate-fade-up relative [animation-delay:600ms]">
<p className="editorial-eyebrow mb-4">{t('whatYouGet')}</p>
<div className="grid gap-8 md:grid-cols-3">
<Benefit num="01" titleKey="benefit1Title" bodyKey="benefit1Body" />
<Benefit num="02" titleKey="benefit2Title" bodyKey="benefit2Body" />
<Benefit num="03" titleKey="benefit3Title" bodyKey="benefit3Body" />
</div>
</section>
</main>
</div>
</div>
);
}
function StatusBlock({
derived,
subscription,
}: {
derived: BillingStatus['derived'];
subscription: BillingStatus['subscription'];
}) {
const t = useTranslations('Billing');
switch (derived.kind) {
case 'trial':
return (
<div>
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
{t('trialActive', { days: derived.trialDaysRemaining ?? 0 })}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('trialActiveBody', {
endsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'trial_expired':
return (
<div>
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
{t('trialExpired')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('trialExpiredBody')}
</p>
</div>
);
case 'active':
return (
<div>
<p className="font-display text-oliva text-3xl leading-tight md:text-4xl">
{t('proActive')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('proActiveBody', {
interval:
subscription?.interval === 'YEAR'
? t('intervalYear')
: subscription?.interval === 'MONTH'
? t('intervalMonth')
: '—',
renewsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'past_due':
return (
<div>
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
{t('pastDue')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('pastDueBody')}
</p>
</div>
);
case 'canceling':
return (
<div>
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
{t('canceling')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('cancelingBody', {
endsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'canceled':
return (
<div>
<p className="font-display text-ink-dim text-3xl leading-tight md:text-4xl">
{t('canceled')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('canceledBody')}
</p>
</div>
);
case 'no_subscription':
default:
return (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('noSubscription')}
</p>
);
}
}
function Benefit({ num, titleKey, bodyKey }: { num: string; titleKey: string; bodyKey: string }) {
const t = useTranslations('Billing');
return (
<article>
<p className="font-display text-pomodoro text-2xl italic">{num}</p>
<h3 className="font-display text-ink mt-2 text-xl font-medium leading-tight">
{t(titleKey)}
</h3>
<p className="font-display text-ink-soft mt-3 text-sm italic leading-snug">{t(bodyKey)}</p>
</article>
);
}
+82 -4
View File
@@ -6,21 +6,34 @@ import { CursorGlow } from '@/components/cursor-glow';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchBillingStatus, type BillingStatus } from './billing/actions';
import { SignOutButton } from './sign-out-button';
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
const userName = session?.user?.name ?? session?.user?.email ?? null;
const billing = userName ? await fetchBillingStatus() : null;
return <HomeContent userName={session?.user?.name ?? session?.user?.email ?? null} />;
return <HomeContent userName={userName} billing={billing} />;
}
function HomeContent({ userName }: { userName: string | null }) {
function HomeContent({
userName,
billing,
}: {
userName: string | null;
billing: BillingStatus | null;
}) {
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead />
{userName ? <SignedInDashboard userName={userName} /> : <MarketingLanding />}
{userName ? (
<SignedInDashboard userName={userName} billing={billing} />
) : (
<MarketingLanding />
)}
</div>
</div>
);
@@ -800,7 +813,13 @@ function Disclaimer() {
);
}
function SignedInDashboard({ userName }: { userName: string }) {
function SignedInDashboard({
userName,
billing,
}: {
userName: string;
billing: BillingStatus | null;
}) {
const t = useTranslations('Home');
return (
<main className="relative min-h-[80vh] overflow-hidden pb-24">
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) {
{t('tagline')}
</p>
<TrialBanner billing={billing} />
{/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
<div className="animate-fade-up relative mt-20 grid grid-cols-12 gap-6 [animation-delay:360ms]">
<DashboardHero
@@ -861,6 +882,7 @@ function SignedInDashboard({ userName }: { userName: string }) {
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
<NavItem href="/billing" label={t('viewBilling')} eyebrow="Abbonamento" chapter="VI" />
<li className="pt-6">
<SignOutButton label={t('signOut')} />
</li>
@@ -957,3 +979,59 @@ function NavItem({
</li>
);
}
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
});
function TrialBanner({ billing }: { billing: BillingStatus | null }) {
const t = useTranslations('Billing');
if (!billing) return null;
const { derived } = billing;
if (derived.kind === 'trial' && derived.trialDaysRemaining != null) {
const endsAt = derived.accessEndsAt ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) : '—';
return (
<aside className="border-oro/35 bg-oro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
<div>
<p className="editorial-eyebrow">{t('trialBannerTitle')}</p>
<p className="font-display text-ink mt-2 text-lg leading-snug">
{t('trialBannerBody', { days: derived.trialDaysRemaining, endsAt })}
</p>
</div>
<Link
href="/billing"
className="text-oro hover:text-pomodoro shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
>
{t('trialBannerCta')} →
</Link>
</div>
</aside>
);
}
if (derived.kind === 'trial_expired' || derived.kind === 'past_due') {
return (
<aside className="border-pomodoro/40 bg-pomodoro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
<div>
<p className="editorial-eyebrow">{t('trialEndedBannerTitle')}</p>
<p className="font-display text-ink mt-2 text-lg leading-snug">
{t('trialEndedBannerBody')}
</p>
</div>
<Link
href="/billing"
className="text-pomodoro hover:text-ink shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
>
{t('trialEndedBannerCta')} →
</Link>
</div>
</aside>
);
}
return null;
}