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

+1 -1
View File
@@ -185,7 +185,7 @@ Quando ti chiedo una nuova funzionalità, segui questo flusso:
Queste decisioni non sono ancora finalizzate. Se le tocchi, aprire un ADR in `docs/decisions/`.
- [ ] Stripe vs Lemon Squeezy per il payment provider
- [x] ~~Stripe vs Lemon Squeezy per il payment provider~~ → Stripe + free 30gg → Pro (vedi `docs/decisions/0004-payment-provider.md`)
- [ ] PostHog self-hosted vs Mixpanel per analytics di prodotto
- [ ] Render vs Fly.io vs AWS ECS per l'hosting backend
- [ ] Strategia di seeding del database ricette (manuale vs scraping autorizzato vs LLM-assisted)
+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;
}
+107
View File
@@ -0,0 +1,107 @@
# ADR 0004 — Payment provider e modello di abbonamento
- **Status**: accepted
- **Date**: 2026-05-07
- **Decision makers**: Luciano (PO), Claude
- **Supersedes**: scelta aperta in `CLAUDE.md` ("Stripe vs Lemon Squeezy")
## Contesto
KetoPath chiude il primo ciclo di feature MVP (onboarding, piani settimanali, tracking, digiuno, lista spesa, achievement). Per portare l'app sul mercato serve un sistema di monetizzazione.
Tre vincoli forti dal contesto del progetto:
1. **Mercato target IT/EU**: tutti gli utenti pagano in EUR e devono ricevere fatture / scontrini compatibili con la normativa fiscale italiana.
2. **GDPR / dati di salute (art. 9)**: il provider può vedere solo email, nome, dati di pagamento. Non deve mai accedere a peso, foto, sintomi.
3. **Operatori limitati**: il PO è un singolo developer, non vuole mettere su una struttura amministrativa per gestire IVA EU OSS, fatturazione elettronica, regolarizzazioni.
## Decisione
### Provider: Stripe (con Stripe Tax + Customer Portal)
- **Stripe** come gateway di pagamento e gestore abbonamenti.
- **Stripe Tax** abilitato per il calcolo automatico di IVA EU (0,5% del fatturato per il servizio di Stripe Tax).
- **Stripe Customer Portal** per la gestione self-service di abbonamento e fatture (l'utente cambia piano, aggiorna carta, cancella, scarica fatture in autonomia).
- **Stripe Checkout** (hosted) per il primo upgrade — niente form di pagamento custom, riduce il perimetro PCI.
### Modello di abbonamento: free 30 giorni → Pro (no carta richiesta)
- **Trial di 30 giorni gratuiti** dalla data di registrazione, **senza carta richiesta**. Allinea il momento del paywall alla fine della fase INTENSIVE (PRD §5.1) — quando l'utente ha già visto i primi risultati, ha accumulato dati storici e ha alta motivazione a continuare.
- **Stato `TRIALING`** nel DB durante i primi 30 giorni; nessuna interazione con Stripe finché l'utente non avvia il checkout.
- **Allo scadere del trial**: l'utente passa a `EXPIRED`. L'app non viene "spenta": modalità sola-lettura (vedi sezione "Gating").
- **Piano Pro**: `€9,90/mese` o `€89/anno` (≈25% sconto pagando 12 mesi). I `priceId` sono in env, non hard-coded.
- **Cancellazione**: gestita interamente via Customer Portal. Lo stato in DB è `CANCEL_AT_PERIOD_END` finché la subscription non scade davvero, poi `CANCELED`.
### Gating
| Area | Trial | Pro | Expired (post-trial / scaduto) |
| --------------------------- | ----- | --- | ------------------------------- |
| Profilo, prefs | ✅ | ✅ | ✅ (lettura + modifica) |
| Storico tracking | ✅ | ✅ | ✅ (lettura) |
| Storico piani | ✅ | ✅ | ✅ (lettura) |
| Lista spesa | ✅ | ✅ | ✅ (lettura, archiviata) |
| **Generazione nuovo piano** | ✅ | ✅ | ❌ (paywall) |
| **Nuova pesata / check-in** | ✅ | ✅ | ❌ (paywall) |
| **Foto progress** | ✅ | ✅ | ❌ (paywall) |
| **Avvio digiuno** | ✅ | ✅ | ❌ (paywall) |
| **Export PDF** | ✅ | ✅ | ❌ (paywall) |
| **Achievements** | ✅ | ✅ | ✅ (lettura, no nuovi sblocchi) |
L'utente Expired non perde dati — può accedere allo storico e riattivare l'abbonamento in qualsiasi momento. Le rotte/azioni gated rispondono `402 payment_required` lato API e mostrano un componente `<Paywall/>` lato web.
## Conseguenze
### Positive
- **IVA UE gestita**: con Stripe Tax l'IVA viene calcolata automaticamente sulla base del paese del cliente; le fatture le emette Stripe (per il Tax-ID) o le emettiamo noi a partire dai dati raccolti — il PO non deve registrarsi al MOSS.
- **Customer Portal pronto**: zero codice per upgrade/downgrade/cancel/aggiorna carta — tutto delegato all'UI Stripe.
- **Fee EU competitivo**: 1,5% + €0,25 per transazione SEPA/EU, contro il ≈5%+€0,50 di Lemon Squeezy. Su €10/mese, Stripe è ≈ €0,40 di fee, LS sarebbe ≈ €1,00.
- **Webhook pattern noto**: pattern molto documentato, libreria `stripe-node` mantenuta e tipata.
- **Trial nativo**: `subscription.trial_end` di Stripe è perfetto per gestire il free 30gg quando l'utente passa al pagato (carta inserita, trial conta come parte della sub).
### Negative
- **PO è "merchant of record"**: a differenza di Lemon Squeezy, Stripe non si interpone — il PO deve avere partita IVA italiana e dichiarare correttamente i ricavi. Sopra €10k/anno serve la registrazione MOSS o Stripe Tax (IVA OSS) — gestibile, ma non zero-effort.
- **Webhook = stato di verità**: tutta la logica di subscription deve passare dal webhook firmato (`stripe-signature` HMAC). Se il webhook fallisce, lo stato in DB diverge.
- **Vendor lock-in moderato**: lo stato `subscriptions` resta in DB nostro, ma `customerId` e `subscriptionId` sono Stripe-specifici. Cambiare provider richiede re-onboarding dei clienti.
## Alternative considerate
| Opzione | Motivo del NO |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Lemon Squeezy | MoR comodo per IVA, ma fee 3-4× superiore. Su volumi MVP la differenza è gestibile, ma scala male appena cresci. Tenuta come backup se Stripe diventasse problematico. |
| Paddle | Anche MoR, simile a LS. Setup più burocratico e UX di checkout meno polished. Niente vantaggio sostanziale rispetto a LS. |
| GoCardless | Solo SEPA, niente carte → friction sul cliente medio italiano che paga con carta. Ottimo per B2B, non per consumer. |
| Mollie | Buon player EU, ma ecosistema npm molto più piccolo e tooling meno maturo (no portal cliente equivalente). Ha senso solo se IVA Stripe diventasse problematica. |
| Crypto-only | Off-topic per il segmento — bassa adozione tra il target keto/wellness IT. |
### Modelli di gating considerati
| Modello | Motivo della scelta |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Free 30gg → Pro** ✅ | Allinea il paywall al punto di massima adesione (fine Phase 1 = utente ha già visto risultati). Lock-in psicologico: 30gg di dati storici creano dipendenza positiva. |
| Freemium con limiti | Più complesso da bilanciare (cosa free, cosa Pro), aggiunge debito di prodotto in ogni feature. Rinviato a v2 se serve acquisition organica. |
| Trial 14gg | Mismatch col dominio — l'adattamento keto dura 2-3 settimane, 14 giorni sono pochi per vedere risultati e converti in modo informato. |
| Pay-from-day-1 | Friction massima all'ingresso, riduce drasticamente l'acquisition. |
## Implementazione
Tracciata in 6 fasi (questo ADR copre la fase 0):
- **Fase 0** (questo ADR + aggiornamento `CLAUDE.md`)
- **Fase 1**: schema Prisma + migration `add_subscriptions`
- **Fase 2**: modulo `@ketopath/api/billing` (env, service, routes checkout/portal/status, webhook firmato)
- **Fase 3**: helper `isProActive(userId)` lato shared + middleware paywall sulle rotte/azioni gated
- **Fase 4**: pagina `/billing` (stato, scadenza, link upgrade/portal), banner trial in dashboard, componente `<Paywall/>`
- **Fase 5**: test unit (`isProActive`, evaluator stato), test integrazione webhook
- **Fase 6**: configurazione live — Stripe account + Stripe Tax + price IDs in env, deploy webhook su URL stabile
## Vincoli operativi da rispettare
- **Webhook = source of truth**: ogni mutazione di stato subscription DEVE passare dal webhook. Niente "fai-da-te" lato frontend dopo il checkout — si redirige a una pagina "in elaborazione" e si attende il webhook.
- **Firma webhook obbligatoria**: il body raw del webhook va verificato con `stripe.webhooks.constructEvent(rawBody, signature, secret)`. Mai accettare un evento senza firma valida.
- **Idempotenza**: il webhook può ricevere lo stesso evento più volte (Stripe ritenta in caso di 5xx). Ogni handler deve essere idempotente — la chiave naturale `event.id` deve essere registrata in DB per scartare i duplicati (tabella `BillingWebhookEvent`).
- **Niente PII oltre il necessario**: a Stripe inviamo solo `email` e `userId` come `metadata`. **Mai** weight, foto, sintomi, achievements.
- **Trial senza carta**: il trial di 30gg è puramente lato DB (status `TRIALING`, `trialEndsAt`). Nessuna interazione con Stripe finché l'utente non clicca "Upgrade" volontariamente.
- **`STRIPE_SECRET_KEY` e `STRIPE_WEBHOOK_SECRET`**: caricati solo lato `apps/api` (mai `apps/web`). Il frontend riceve un `client secret` solo dal backend.
- **PCI scope minimo**: usiamo solo Stripe Checkout hosted — nessun campo carta passa mai per i nostri server.
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCEL_AT_PERIOD_END', 'CANCELED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "BillingInterval" AS ENUM ('MONTH', 'YEAR');
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "SubscriptionStatus" NOT NULL,
"trial_ends_at" TIMESTAMP(3) NOT NULL,
"stripe_customer_id" TEXT,
"stripe_subscription_id" TEXT,
"stripe_price_id" TEXT,
"current_period_end" TIMESTAMP(3),
"interval" "BillingInterval",
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false,
"ended_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "billing_webhook_events" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processed_at" TIMESTAMP(3),
"payload" JSONB NOT NULL,
CONSTRAINT "billing_webhook_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_user_id_key" ON "subscriptions"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_stripe_customer_id_key" ON "subscriptions"("stripe_customer_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_stripe_subscription_id_key" ON "subscriptions"("stripe_subscription_id");
-- CreateIndex
CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status");
-- CreateIndex
CREATE INDEX "billing_webhook_events_type_idx" ON "billing_webhook_events"("type");
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+61
View File
@@ -76,6 +76,23 @@ enum MealPlanStatus {
ARCHIVED
}
// PRD §16.3 / ADR 0004 — stato dell'abbonamento. Mappato 1:1 sullo stato
// Stripe ma mantenuto in DB come source-of-truth applicativo (l'API non
// chiama Stripe a ogni request — usa il valore qui).
enum SubscriptionStatus {
TRIALING // free 30gg post-signup, no carta richiesta
ACTIVE // pagante, in regola
PAST_DUE // pagamento fallito, in retry da Stripe (grace period)
CANCEL_AT_PERIOD_END // canceled ma valido fino a fine periodo corrente
CANCELED // canceled ed expired
EXPIRED // trial scaduto senza upgrade
}
enum BillingInterval {
MONTH
YEAR
}
model User {
id String @id @default(cuid())
email String @unique
@@ -97,6 +114,7 @@ model User {
profile Profile?
preferences Preferences?
subscription Subscription?
sessions Session[]
accounts Account[]
weightEntries WeightEntry[]
@@ -109,6 +127,49 @@ model User {
@@map("users")
}
// ADR 0004 — abbonamento dell'utente. 1:1 con User. Lo stato è la verità
// applicativa: il webhook Stripe lo aggiorna, ma le route che gating-ano
// le feature leggono solo da qui (niente roundtrip Stripe).
model Subscription {
id String @id @default(cuid())
userId String @unique @map("user_id")
status SubscriptionStatus
// Trial nativo (30gg post-signup, no carta). Sempre valorizzato.
trialEndsAt DateTime @map("trial_ends_at")
// Stripe: presente solo dopo il primo upgrade (checkout completato).
stripeCustomerId String? @unique @map("stripe_customer_id")
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripePriceId String? @map("stripe_price_id")
// Periodo corrente (Pro). Riflette `current_period_end` di Stripe.
currentPeriodEnd DateTime? @map("current_period_end")
interval BillingInterval?
// Se `cancelAtPeriodEnd = true`, la sub è ancora ACTIVE ma non si rinnoverà.
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Quando lo status è effettivamente expired/canceled, archiviamo la data.
endedAt DateTime? @map("ended_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([status])
@@map("subscriptions")
}
// ADR 0004 — registro idempotenza dei webhook Stripe. Stripe ritenta gli
// eventi falliti, ricezione multipla è normale: scartiamo i duplicati su
// `event.id` invece di applicare due volte lo stesso aggiornamento.
model BillingWebhookEvent {
id String @id // event.id di Stripe (evt_...)
type String
receivedAt DateTime @default(now()) @map("received_at")
processedAt DateTime? @map("processed_at")
payload Json
@@index([type])
@@map("billing_webhook_events")
}
// PRD §6 — sistema achievement: badge sbloccati man mano che l'utente
// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.).
// `key` identifica univocamente l'achievement (vedi packages/shared).
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import {
computeTrialEndsAt,
deriveProStatus,
isProActive,
TRIAL_DAYS,
type SubscriptionSnapshot,
} from './pro-status.js';
const NOW = new Date('2026-05-07T12:00:00Z');
function snap(partial: Partial<SubscriptionSnapshot>): SubscriptionSnapshot {
return {
status: 'TRIALING',
trialEndsAt: new Date('2026-06-01T00:00:00Z'),
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
...partial,
};
}
describe('isProActive', () => {
it('false se snap è null (nessuna subscription)', () => {
expect(isProActive(null, NOW)).toBe(false);
});
it('TRIALING valido finché trialEndsAt è nel futuro', () => {
expect(isProActive(snap({ status: 'TRIALING' }), NOW)).toBe(true);
});
it('TRIALING con trialEndsAt passato → false (cron in ritardo)', () => {
expect(
isProActive(snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }), NOW),
).toBe(false);
});
it('ACTIVE sempre true', () => {
expect(isProActive(snap({ status: 'ACTIVE' }), NOW)).toBe(true);
});
it('PAST_DUE = true (manteniamo accesso durante dunning Stripe)', () => {
expect(isProActive(snap({ status: 'PAST_DUE' }), NOW)).toBe(true);
});
it('CANCEL_AT_PERIOD_END = true finché currentPeriodEnd è nel futuro', () => {
expect(
isProActive(
snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-06-01') }),
NOW,
),
).toBe(true);
});
it('CANCEL_AT_PERIOD_END con periodo già scaduto → false', () => {
expect(
isProActive(
snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-05-01') }),
NOW,
),
).toBe(false);
});
it('CANCELED, EXPIRED → false', () => {
expect(isProActive(snap({ status: 'CANCELED' }), NOW)).toBe(false);
expect(isProActive(snap({ status: 'EXPIRED' }), NOW)).toBe(false);
});
});
describe('deriveProStatus', () => {
it('no subscription → kind no_subscription, isPro false', () => {
expect(deriveProStatus(null, NOW).kind).toBe('no_subscription');
});
it('trial in corso → trialDaysRemaining = ceil giorni mancanti', () => {
const s = deriveProStatus(snap({ trialEndsAt: new Date('2026-05-10T12:00:00Z') }), NOW);
expect(s.kind).toBe('trial');
expect(s.isPro).toBe(true);
expect(s.trialDaysRemaining).toBe(3);
});
it('trial scaduto ma DB ancora TRIALING → kind trial_expired', () => {
const s = deriveProStatus(
snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }),
NOW,
);
expect(s.kind).toBe('trial_expired');
expect(s.isPro).toBe(false);
expect(s.trialDaysRemaining).toBe(0);
});
it('cancel_at_period_end → kind canceling con accessEndsAt valorizzato', () => {
const end = new Date('2026-06-01');
const s = deriveProStatus(snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: end }), NOW);
expect(s.kind).toBe('canceling');
expect(s.accessEndsAt).toEqual(end);
});
});
describe('computeTrialEndsAt', () => {
it('default 30 giorni dalla signup', () => {
const signup = new Date('2026-05-07T00:00:00Z');
const end = computeTrialEndsAt(signup);
expect(end.toISOString()).toBe('2026-06-06T00:00:00.000Z');
});
it('TRIAL_DAYS = 30', () => {
expect(TRIAL_DAYS).toBe(30);
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* ADR 0004 — calcolo "isProActive" e derivazione dello stato di abbonamento
* a partire dallo snapshot persistito in DB. Funzione pura e testabile.
*
* NB: lo stato in DB viene aggiornato dal webhook Stripe; questa logica è
* usata sia dal backend (paywall middleware) sia dal frontend (banner /
* pulsanti CTA in /billing).
*/
export const TRIAL_DAYS = 30;
export type SubscriptionStatus =
| 'TRIALING'
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCEL_AT_PERIOD_END'
| 'CANCELED'
| 'EXPIRED';
export interface SubscriptionSnapshot {
status: SubscriptionStatus;
trialEndsAt: Date;
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
}
/**
* Vero se l'utente ha diritto alle feature Pro in questo momento.
* Gestita anche la transizione "trial scaduto ma non ancora marcato EXPIRED
* dal cron": in lettura calcoliamo live, così non serve un job che gira
* ogni minuto. Il cron resta utile solo per allineare lo stato persistito.
*/
export function isProActive(snap: SubscriptionSnapshot | null, now: Date = new Date()): boolean {
if (!snap) return false;
const t = now.getTime();
switch (snap.status) {
case 'TRIALING':
return snap.trialEndsAt.getTime() > t;
case 'ACTIVE':
case 'PAST_DUE':
// Durante PAST_DUE Stripe ha la sua dunning sequence (3-7gg).
// Manteniamo l'accesso Pro: l'utente non ha colpa di un retry in corso.
return true;
case 'CANCEL_AT_PERIOD_END':
return snap.currentPeriodEnd != null && snap.currentPeriodEnd.getTime() > t;
case 'CANCELED':
case 'EXPIRED':
return false;
}
}
export type ProDerivedKind =
| 'trial'
| 'trial_expired'
| 'active'
| 'past_due'
| 'canceling'
| 'canceled'
| 'no_subscription';
export interface ProDerivedStatus {
kind: ProDerivedKind;
/** True ↔ isProActive(snap). Comodità per l'UI. */
isPro: boolean;
/** Giorni rimasti del trial (solo se `kind === 'trial'`), arrotondati per eccesso. */
trialDaysRemaining: number | null;
/** Quando finisce l'accesso (trial o abbonamento). Null se canceled/no_sub. */
accessEndsAt: Date | null;
}
const MS_PER_DAY = 1000 * 60 * 60 * 24;
export function deriveProStatus(
snap: SubscriptionSnapshot | null,
now: Date = new Date(),
): ProDerivedStatus {
if (!snap) {
return { kind: 'no_subscription', isPro: false, trialDaysRemaining: null, accessEndsAt: null };
}
const isPro = isProActive(snap, now);
const t = now.getTime();
switch (snap.status) {
case 'TRIALING': {
const remainingMs = snap.trialEndsAt.getTime() - t;
if (remainingMs <= 0) {
// trial scaduto ma DB ancora TRIALING (cron in ritardo): trattiamo come expired
return {
kind: 'trial_expired',
isPro: false,
trialDaysRemaining: 0,
accessEndsAt: snap.trialEndsAt,
};
}
return {
kind: 'trial',
isPro: true,
trialDaysRemaining: Math.max(1, Math.ceil(remainingMs / MS_PER_DAY)),
accessEndsAt: snap.trialEndsAt,
};
}
case 'ACTIVE':
return {
kind: 'active',
isPro,
trialDaysRemaining: null,
accessEndsAt: snap.currentPeriodEnd,
};
case 'PAST_DUE':
return {
kind: 'past_due',
isPro,
trialDaysRemaining: null,
accessEndsAt: snap.currentPeriodEnd,
};
case 'CANCEL_AT_PERIOD_END':
return {
kind: 'canceling',
isPro,
trialDaysRemaining: null,
accessEndsAt: snap.currentPeriodEnd,
};
case 'CANCELED':
case 'EXPIRED':
return { kind: 'canceled', isPro: false, trialDaysRemaining: null, accessEndsAt: null };
}
}
/** Calcola la data di fine trial a partire dalla data di signup. */
export function computeTrialEndsAt(signupAt: Date, days: number = TRIAL_DAYS): Date {
return new Date(signupAt.getTime() + days * MS_PER_DAY);
}
+1
View File
@@ -1,5 +1,6 @@
export * from './achievements/definitions.js';
export * from './achievements/evaluator.js';
export * from './billing/pro-status.js';
export * from './medical/conditions.js';
export * from './planner/adherence.js';
export * from './notifications/schema.js';
BIN
View File
Binary file not shown.