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:
1 parent
41b3646325
commit
f455cbe499
28 files changed
+2035
-486
No files matched your search
@@ -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/`.
|
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
|
- [ ] PostHog self-hosted vs Mixpanel per analytics di prodotto
|
||||||
- [ ] Render vs Fly.io vs AWS ECS per l'hosting backend
|
- [ ] Render vs Fly.io vs AWS ECS per l'hosting backend
|
||||||
- [ ] Strategia di seeding del database ricette (manuale vs scraping autorizzato vs LLM-assisted)
|
- [ ] Strategia di seeding del database ricette (manuale vs scraping autorizzato vs LLM-assisted)
|
||||||
|
|||||||
@@ -31,3 +31,16 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:hello@ketopath.app
|
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
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"fastify-plugin": "^4.5.1",
|
"fastify-plugin": "^4.5.1",
|
||||||
"node-cron": "^3.0",
|
"node-cron": "^3.0",
|
||||||
"pdfkit": "^0.15",
|
"pdfkit": "^0.15",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"web-push": "^3.6",
|
"web-push": "^3.6",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
|||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { Sentry } from './lib/sentry.js';
|
import { Sentry } from './lib/sentry.js';
|
||||||
import { achievementsRoutes } from './modules/achievements/achievements.routes.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 { dbRoutes } from './modules/db/db.routes.js';
|
||||||
import { healthRoutes } from './modules/health/health.routes.js';
|
import { healthRoutes } from './modules/health/health.routes.js';
|
||||||
import { gdprRoutes } from './modules/me/gdpr.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(shoppingRoutes);
|
||||||
await app.register(notificationsRoutes);
|
await app.register(notificationsRoutes);
|
||||||
await app.register(achievementsRoutes);
|
await app.register(achievementsRoutes);
|
||||||
|
await app.register(billingRoutes);
|
||||||
|
await app.register(stripeWebhookRoutes);
|
||||||
|
|
||||||
if (env.SENTRY_DSN) {
|
if (env.SENTRY_DSN) {
|
||||||
app.setErrorHandler((err, request, reply) => {
|
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 });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
|
|
||||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -26,7 +27,7 @@ const MEAL_LABELS: Record<string, string> = {
|
|||||||
export const planExportRoutes: FastifyPluginAsync = async (fastify) => {
|
export const planExportRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/me/meal-plans/export.pdf',
|
'/me/meal-plans/export.pdf',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = request.user!.id;
|
const userId = request.user!.id;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||||
|
|
||||||
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
|
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
|
||||||
@@ -72,220 +73,225 @@ function parseConditions(raw: string | null): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user!.id;
|
'/me/meal-plans',
|
||||||
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user!.id;
|
||||||
|
|
||||||
const profile = await fastify.prisma.profile.findUnique({
|
const profile = await fastify.prisma.profile.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: { user: { include: { preferences: true } } },
|
include: { user: { include: { preferences: true } } },
|
||||||
});
|
});
|
||||||
if (!profile) return reply.code(409).send({ error: 'profile_required' });
|
if (!profile) return reply.code(409).send({ error: 'profile_required' });
|
||||||
|
|
||||||
const recipes = await fastify.prisma.recipe.findMany({
|
const recipes = await fastify.prisma.recipe.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
category: true,
|
category: true,
|
||||||
kcal: true,
|
kcal: true,
|
||||||
proteinG: true,
|
proteinG: true,
|
||||||
fatG: true,
|
fatG: true,
|
||||||
netCarbG: true,
|
netCarbG: true,
|
||||||
phases: true,
|
phases: true,
|
||||||
prepMinutes: true,
|
prepMinutes: true,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
select: {
|
select: {
|
||||||
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
|
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
if (recipes.length === 0) {
|
||||||
if (recipes.length === 0) {
|
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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
|
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
|
||||||
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
|
// che contengono ingredienti non ancora reintrodotti per la settimana
|
||||||
const conditions = parseConditions(profile.medicalConditions);
|
// corrente di fase 2.
|
||||||
if (hasExcludingCondition(conditions)) {
|
const phaseIntForFilter = phaseToInt(profile.currentPhase);
|
||||||
return reply.code(409).send({ error: 'medical_block', conditions });
|
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 candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
|
||||||
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
const tags = new Set<string>();
|
||||||
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
|
const ingredientIds: string[] = [];
|
||||||
let bmr = bodyFatPct
|
for (const ri of r.ingredients) {
|
||||||
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||||
: calculateBmr({
|
ingredientIds.push(ri.ingredient.id);
|
||||||
weightKg: weightCurrentKg,
|
}
|
||||||
heightCm: profile.heightCm,
|
return {
|
||||||
ageYears: profile.age,
|
id: r.id,
|
||||||
gender: profile.gender,
|
name: r.name,
|
||||||
});
|
category: r.category,
|
||||||
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
|
kcal: r.kcal,
|
||||||
bmr *= bmrAdjustmentForConditions(conditions);
|
proteinG: r.proteinG,
|
||||||
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
|
fatG: r.fatG,
|
||||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
netCarbG: r.netCarbG,
|
||||||
const phaseInt = phaseToInt(profile.currentPhase);
|
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.
|
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
|
||||||
const trainingDays = profile.user.preferences?.trainingDays ?? [];
|
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
|
||||||
const trainingType =
|
const conditions = parseConditions(profile.medicalConditions);
|
||||||
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
|
if (hasExcludingCondition(conditions)) {
|
||||||
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
|
return reply.code(409).send({ error: 'medical_block', conditions });
|
||||||
const sessionExtraKcal =
|
}
|
||||||
trainingType && sessionMinutes
|
|
||||||
? extraKcalForSession({
|
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||||
type: trainingType,
|
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
||||||
durationMinutes: sessionMinutes,
|
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
|
||||||
|
let bmr = bodyFatPct
|
||||||
|
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
||||||
|
: calculateBmr({
|
||||||
weightKg: weightCurrentKg,
|
weightKg: weightCurrentKg,
|
||||||
})
|
heightCm: profile.heightCm,
|
||||||
: 0;
|
ageYears: profile.age,
|
||||||
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
|
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).
|
// Schedule allenamento: kcal extra sui giorni di allenamento.
|
||||||
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
const trainingDays = profile.user.preferences?.trainingDays ?? [];
|
||||||
tdee,
|
const trainingType =
|
||||||
weightCurrentKg,
|
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
|
||||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
|
||||||
phase: phaseInt,
|
const sessionExtraKcal =
|
||||||
});
|
trainingType && sessionMinutes
|
||||||
const baseDailyTarget = macrosForPhase({
|
? extraKcalForSession({
|
||||||
kcalTarget: baseKcal,
|
type: trainingType,
|
||||||
weightKg: weightCurrentKg,
|
durationMinutes: sessionMinutes,
|
||||||
phase: phaseInt,
|
weightKg: weightCurrentKg,
|
||||||
});
|
})
|
||||||
const exclusions = profile.user.preferences?.exclusions ?? [];
|
: 0;
|
||||||
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
|
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
|
||||||
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);
|
|
||||||
|
|
||||||
const weekStart = startOfWeek(new Date());
|
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
|
||||||
|
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
||||||
const plan = await fastify.prisma.mealPlan.upsert({
|
tdee,
|
||||||
where: { userId_weekStart: { userId, weekStart } },
|
weightCurrentKg,
|
||||||
create: { userId, weekStart },
|
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||||
update: { generatedAt: new Date(), status: 'ACTIVE' },
|
phase: phaseInt,
|
||||||
});
|
});
|
||||||
|
const baseDailyTarget = macrosForPhase({
|
||||||
// Wipe existing slots before regenerating, in case the plan already existed.
|
kcalTarget: baseKcal,
|
||||||
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,
|
weightKg: weightCurrentKg,
|
||||||
phase: phaseInt,
|
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 weekStart = startOfWeek(new Date());
|
||||||
const recentlyConsumedIds: string[] = plannedSlots
|
|
||||||
.filter((s) => day - s.day <= 2 && s.recipeId)
|
|
||||||
.map((s) => s.recipeId!);
|
|
||||||
|
|
||||||
for (const meal of MEALS) {
|
const plan = await fastify.prisma.mealPlan.upsert({
|
||||||
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
|
where: { userId_weekStart: { userId, weekStart } },
|
||||||
if (effectiveShare[meal] === 0) continue;
|
create: { userId, weekStart },
|
||||||
|
update: { generatedAt: new Date(), status: 'ACTIVE' },
|
||||||
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({
|
// Wipe existing slots before regenerating, in case the plan already existed.
|
||||||
plan: {
|
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
|
||||||
id: plan.id,
|
|
||||||
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
|
||||||
dailyTarget: baseDailyTarget,
|
for (let day = 0; day < 7; day++) {
|
||||||
fastingProtocol,
|
const dayPlan = protocolPlanForDay(fastingProtocol, day);
|
||||||
},
|
// Giorno di digiuno completo (ESE_24): salta tutto.
|
||||||
newlyUnlocked: ach.newlyUnlocked,
|
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(
|
fastify.patch(
|
||||||
'/me/meal-plans/slots/:slotId',
|
'/me/meal-plans/slots/:slotId',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const slotId = (request.params as { slotId: string }).slotId;
|
const slotId = (request.params as { slotId: string }).slotId;
|
||||||
const body = request.body as { recipeId?: unknown };
|
const body = request.body as { recipeId?: unknown };
|
||||||
@@ -322,7 +328,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
// automaticamente un nuovo digiuno.
|
// automaticamente un nuovo digiuno.
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/me/meal-plans/slots/:slotId/consumed',
|
'/me/meal-plans/slots/:slotId/consumed',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const slotId = (request.params as { slotId: string }).slotId;
|
const slotId = (request.params as { slotId: string }).slotId;
|
||||||
const userId = request.user!.id;
|
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.
|
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/me/meal-plans/slots/:slotId/free-meal',
|
'/me/meal-plans/slots/:slotId/free-meal',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const slotId = (request.params as { slotId: string }).slotId;
|
const slotId = (request.params as { slotId: string }).slotId;
|
||||||
const userId = request.user!.id;
|
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).
|
// conto dei pasti del giorno già scelti (coerenza dei macros).
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/me/meal-plans/slots/:slotId/regenerate',
|
'/me/meal-plans/slots/:slotId/regenerate',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const slotId = (request.params as { slotId: string }).slotId;
|
const slotId = (request.params as { slotId: string }).slotId;
|
||||||
const userId = request.user!.id;
|
const userId = request.user!.id;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { dailyCheckInInputSchema } from '@ketopath/shared';
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
|
|
||||||
function todayDateOnly(): Date {
|
function todayDateOnly(): Date {
|
||||||
const d = new 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) => {
|
fastify.post(
|
||||||
const parsed = dailyCheckInInputSchema.safeParse(request.body);
|
'/me/check-ins',
|
||||||
if (!parsed.success) {
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
async (request, reply) => {
|
||||||
}
|
const parsed = dailyCheckInInputSchema.safeParse(request.body);
|
||||||
const data = parsed.data;
|
if (!parsed.success) {
|
||||||
const userId = request.user!.id;
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
const date = new Date(data.date);
|
}
|
||||||
date.setHours(0, 0, 0, 0);
|
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({
|
const item = await fastify.prisma.dailyCheckIn.upsert({
|
||||||
where: { userId_date: { userId, date } },
|
where: { userId_date: { userId, date } },
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
date,
|
date,
|
||||||
energy: data.energy ?? null,
|
energy: data.energy ?? null,
|
||||||
sleep: data.sleep ?? null,
|
sleep: data.sleep ?? null,
|
||||||
hunger: data.hunger ?? null,
|
hunger: data.hunger ?? null,
|
||||||
mood: data.mood ?? null,
|
mood: data.mood ?? null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
energy: data.energy ?? null,
|
energy: data.energy ?? null,
|
||||||
sleep: data.sleep ?? null,
|
sleep: data.sleep ?? null,
|
||||||
hunger: data.hunger ?? null,
|
hunger: data.hunger ?? null,
|
||||||
mood: data.mood ?? null,
|
mood: data.mood ?? null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send({ item: { id: item.id } });
|
return reply.code(201).send({ item: { id: item.id } });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
|
|
||||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -14,151 +15,165 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => {
|
export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => {
|
fastify.get(
|
||||||
const userId = request.user!.id;
|
'/me/tracking/export.pdf',
|
||||||
const userEmail = request.user!.email ?? '—';
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user!.id;
|
||||||
|
const userEmail = request.user!.email ?? '—';
|
||||||
|
|
||||||
const [profile, entries, currentPlan] = await Promise.all([
|
const [profile, entries, currentPlan] = await Promise.all([
|
||||||
fastify.prisma.profile.findUnique({ where: { userId } }),
|
fastify.prisma.profile.findUnique({ where: { userId } }),
|
||||||
fastify.prisma.weightEntry.findMany({
|
fastify.prisma.weightEntry.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
}),
|
}),
|
||||||
fastify.prisma.mealPlan.findFirst({
|
fastify.prisma.mealPlan.findFirst({
|
||||||
where: { userId, status: 'ACTIVE' },
|
where: { userId, status: 'ACTIVE' },
|
||||||
orderBy: { weekStart: 'desc' },
|
orderBy: { weekStart: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
slots: {
|
slots: {
|
||||||
include: {
|
include: {
|
||||||
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
|
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
}),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
||||||
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
|
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
doc.on('data', (c: Buffer) => chunks.push(c));
|
doc.on('data', (c: Buffer) => chunks.push(c));
|
||||||
const done = new Promise<Buffer>((resolve, reject) => {
|
const done = new Promise<Buffer>((resolve, reject) => {
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
doc.on('error', reject);
|
doc.on('error', reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Header ─────────────────────────────────────────────────────────
|
// ── 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;
|
|
||||||
doc
|
doc
|
||||||
.moveDown(0.3)
|
.fillColor('#221d18')
|
||||||
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
|
.font('Helvetica-Bold')
|
||||||
.text(
|
.fontSize(22)
|
||||||
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
|
.text('KetoPath', { continued: true })
|
||||||
)
|
.fillColor('#9a3f29')
|
||||||
.text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`)
|
.text('.');
|
||||||
.text(`Fase corrente: ${profile.currentPhase}`);
|
doc
|
||||||
} else {
|
.moveDown(0.2)
|
||||||
doc.text('Profilo non ancora configurato.');
|
.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 ─────────────────────────────────────────────────
|
// ── Profile summary ────────────────────────────────────────────────
|
||||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
|
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
|
||||||
if (entries.length === 0) {
|
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
||||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
|
if (profile) {
|
||||||
} else {
|
const weightStart = Number(profile.weightStartKg);
|
||||||
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
|
const weightCurrent = Number(profile.weightCurrentKg);
|
||||||
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
|
const weightGoal = Number(profile.weightGoalKg);
|
||||||
doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false });
|
const lostKg = weightStart - weightCurrent;
|
||||||
const headerY = doc.y - 11;
|
doc
|
||||||
doc.text('Peso (kg)', colX.weight, headerY);
|
.moveDown(0.3)
|
||||||
doc.text('Energia', colX.energy, headerY);
|
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
|
||||||
doc.text('Sonno', colX.sleep, headerY);
|
.text(
|
||||||
doc.text('Fame', colX.hunger, headerY);
|
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
|
||||||
doc.font('Helvetica').moveDown(0.5);
|
)
|
||||||
for (const e of entries) {
|
.text(
|
||||||
const y = doc.y;
|
`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`,
|
||||||
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
|
)
|
||||||
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
|
.text(`Fase corrente: ${profile.currentPhase}`);
|
||||||
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
|
} else {
|
||||||
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
|
doc.text('Profilo non ancora configurato.');
|
||||||
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
|
|
||||||
doc.moveDown(0.4);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Current plan summary ───────────────────────────────────────────
|
// ── Weight history ─────────────────────────────────────────────────
|
||||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE');
|
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
|
||||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
if (entries.length === 0) {
|
||||||
if (!currentPlan) {
|
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
|
||||||
doc.moveDown(0.3).text('Nessun piano attivo.');
|
} else {
|
||||||
} else {
|
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
|
||||||
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
|
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
|
||||||
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
|
doc
|
||||||
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
|
.moveDown(0.5)
|
||||||
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
|
.font('Helvetica-Bold')
|
||||||
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
|
.text('Data', colX.date, doc.y, { continued: false });
|
||||||
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
|
const headerY = doc.y - 11;
|
||||||
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
|
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
|
doc
|
||||||
.moveDown(0.3)
|
.moveDown(1)
|
||||||
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
|
.fillColor('#221d18')
|
||||||
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
|
.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(
|
.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.end();
|
||||||
doc
|
const pdfBuffer = await done;
|
||||||
.moveDown(2)
|
return reply
|
||||||
.strokeColor('#bdb6a3')
|
.header('Content-Type', 'application/pdf')
|
||||||
.lineWidth(0.5)
|
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
|
||||||
.moveTo(50, doc.y)
|
.header('Content-Length', String(pdfBuffer.length))
|
||||||
.lineTo(545, doc.y)
|
.send(pdfBuffer);
|
||||||
.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);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||||
|
|
||||||
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
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) => {
|
fastify.post(
|
||||||
const parsed = fastEventStartSchema.safeParse(request.body);
|
'/me/fast-events',
|
||||||
if (!parsed.success) {
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
async (request, reply) => {
|
||||||
}
|
const parsed = fastEventStartSchema.safeParse(request.body);
|
||||||
const data = parsed.data;
|
if (!parsed.success) {
|
||||||
const event = await fastify.prisma.fastEvent.create({
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
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 */
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
const data = parsed.data;
|
||||||
return { event: { id: event.id, status: event.status }, newlyUnlocked };
|
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
|
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
|
||||||
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
|
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
|
||||||
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
|
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
|
||||||
fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user!.id;
|
'/me/fasting/pause',
|
||||||
const body = (request.body ?? {}) as { until?: string | null };
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
let until: Date | null = null;
|
async (request, reply) => {
|
||||||
if (body.until === null) {
|
const userId = request.user!.id;
|
||||||
until = null;
|
const body = (request.body ?? {}) as { until?: string | null };
|
||||||
} else if (typeof body.until === 'string') {
|
let until: Date | null = null;
|
||||||
const d = new Date(body.until);
|
if (body.until === null) {
|
||||||
if (Number.isNaN(d.getTime())) {
|
until = null;
|
||||||
return reply.code(400).send({ error: 'invalid_until' });
|
} 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;
|
const user = await fastify.prisma.user.update({
|
||||||
} else {
|
where: { id: userId },
|
||||||
// default: domani alle 06:00 locali (server)
|
data: { fastingPausedUntil: until },
|
||||||
const tomorrow = new Date();
|
select: { fastingPausedUntil: true },
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
});
|
||||||
tomorrow.setHours(6, 0, 0, 0);
|
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
|
||||||
until = tomorrow;
|
},
|
||||||
}
|
);
|
||||||
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
|
// PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3
|
||||||
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
|
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
import { requirePro } from '../../plugins/require-pro.js';
|
||||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||||
|
|
||||||
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
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) => {
|
fastify.post(
|
||||||
const parsed = weightEntryInputSchema.safeParse(request.body);
|
'/me/weight-entries',
|
||||||
if (!parsed.success) {
|
{ preHandler: [requireAuth(), requirePro()] },
|
||||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
async (request, reply) => {
|
||||||
}
|
const parsed = weightEntryInputSchema.safeParse(request.body);
|
||||||
const data = parsed.data;
|
if (!parsed.success) {
|
||||||
const userId = request.user!.id;
|
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({
|
const entry = await fastify.prisma.weightEntry.upsert({
|
||||||
where: { userId_date: { userId, date: data.date } },
|
where: { userId_date: { userId, date: data.date } },
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
date: data.date,
|
date: data.date,
|
||||||
weightKg: String(data.weightKg),
|
weightKg: String(data.weightKg),
|
||||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
energy: data.energy ?? null,
|
energy: data.energy ?? null,
|
||||||
sleep: data.sleep ?? null,
|
sleep: data.sleep ?? null,
|
||||||
hunger: data.hunger ?? null,
|
hunger: data.hunger ?? null,
|
||||||
photos: data.photos ?? [],
|
photos: data.photos ?? [],
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
weightKg: String(data.weightKg),
|
weightKg: String(data.weightKg),
|
||||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
energy: data.energy ?? null,
|
energy: data.energy ?? null,
|
||||||
sleep: data.sleep ?? null,
|
sleep: data.sleep ?? null,
|
||||||
hunger: data.hunger ?? null,
|
hunger: data.hunger ?? null,
|
||||||
photos: data.photos ?? [],
|
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 */
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(201).send({
|
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||||
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
|
if (ach.newlyUnlocked.length > 0) {
|
||||||
newlyUnlocked: ach.newlyUnlocked,
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"viewShopping": "Lista della spesa",
|
"viewShopping": "Lista della spesa",
|
||||||
"viewTracking": "Pesata & misure",
|
"viewTracking": "Pesata & misure",
|
||||||
"viewFasting": "Digiuno intermittente",
|
"viewFasting": "Digiuno intermittente",
|
||||||
|
"viewBilling": "Abbonamento & fatture",
|
||||||
"lettura": "Continua la lettura",
|
"lettura": "Continua la lettura",
|
||||||
"inizia": "Inizia",
|
"inizia": "Inizia",
|
||||||
"greeting": "Buongiorno, <name>{name}</name>.",
|
"greeting": "Buongiorno, <name>{name}</name>.",
|
||||||
@@ -710,5 +711,57 @@
|
|||||||
"email_required": "Email assente sul tuo account.",
|
"email_required": "Email assente sul tuo account.",
|
||||||
"api_error": "Errore di rete. Riprova fra poco."
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,21 +6,34 @@ import { CursorGlow } from '@/components/cursor-glow';
|
|||||||
import { Masthead } from '@/components/masthead';
|
import { Masthead } from '@/components/masthead';
|
||||||
import { getServerSession } from '@/lib/auth';
|
import { getServerSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
import { fetchBillingStatus, type BillingStatus } from './billing/actions';
|
||||||
import { SignOutButton } from './sign-out-button';
|
import { SignOutButton } from './sign-out-button';
|
||||||
|
|
||||||
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const session = await getServerSession();
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
||||||
<Masthead />
|
<Masthead />
|
||||||
{userName ? <SignedInDashboard userName={userName} /> : <MarketingLanding />}
|
{userName ? (
|
||||||
|
<SignedInDashboard userName={userName} billing={billing} />
|
||||||
|
) : (
|
||||||
|
<MarketingLanding />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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');
|
const t = useTranslations('Home');
|
||||||
return (
|
return (
|
||||||
<main className="relative min-h-[80vh] overflow-hidden pb-24">
|
<main className="relative min-h-[80vh] overflow-hidden pb-24">
|
||||||
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
|||||||
{t('tagline')}
|
{t('tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<TrialBanner billing={billing} />
|
||||||
|
|
||||||
{/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
|
{/* 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]">
|
<div className="animate-fade-up relative mt-20 grid grid-cols-12 gap-6 [animation-delay:360ms]">
|
||||||
<DashboardHero
|
<DashboardHero
|
||||||
@@ -861,6 +882,7 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
|||||||
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
|
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
|
||||||
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
|
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
|
||||||
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
|
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
|
||||||
|
<NavItem href="/billing" label={t('viewBilling')} eyebrow="Abbonamento" chapter="VI" />
|
||||||
<li className="pt-6">
|
<li className="pt-6">
|
||||||
<SignOutButton label={t('signOut')} />
|
<SignOutButton label={t('signOut')} />
|
||||||
</li>
|
</li>
|
||||||
@@ -957,3 +979,59 @@ function NavItem({
|
|||||||
</li>
|
</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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -76,6 +76,23 @@ enum MealPlanStatus {
|
|||||||
ARCHIVED
|
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 {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -97,6 +114,7 @@ model User {
|
|||||||
|
|
||||||
profile Profile?
|
profile Profile?
|
||||||
preferences Preferences?
|
preferences Preferences?
|
||||||
|
subscription Subscription?
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
weightEntries WeightEntry[]
|
weightEntries WeightEntry[]
|
||||||
@@ -109,6 +127,49 @@ model User {
|
|||||||
@@map("users")
|
@@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
|
// PRD §6 — sistema achievement: badge sbloccati man mano che l'utente
|
||||||
// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.).
|
// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.).
|
||||||
// `key` identifica univocamente l'achievement (vedi packages/shared).
|
// `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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,5 +1,6 @@
|
|||||||
export * from './achievements/definitions.js';
|
export * from './achievements/definitions.js';
|
||||||
export * from './achievements/evaluator.js';
|
export * from './achievements/evaluator.js';
|
||||||
|
export * from './billing/pro-status.js';
|
||||||
export * from './medical/conditions.js';
|
export * from './medical/conditions.js';
|
||||||
export * from './planner/adherence.js';
|
export * from './planner/adherence.js';
|
||||||
export * from './notifications/schema.js';
|
export * from './notifications/schema.js';
|
||||||
|
|||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user