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
@@ -31,3 +31,16 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:hello@ketopath.app
|
||||
|
||||
# Stripe — abbonamenti (vedi ADR 0004). Tutti opzionali: senza queste env il
|
||||
# webhook e gli endpoint /me/billing/* rispondono 503; le route gated (genera
|
||||
# piano, pesata, fast, export PDF) continuano a funzionare per gli utenti in
|
||||
# trial o non gated, ma chi è EXPIRED riceve 402 a prescindere.
|
||||
# Usa le keys "test mode" per sviluppo (sk_test_..., whsec_...).
|
||||
# I price ID si creano da dashboard Stripe → Products.
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ID_MONTHLY=
|
||||
STRIPE_PRICE_ID_YEARLY=
|
||||
# URL di base usato nelle redirect post-checkout (success/cancel).
|
||||
BILLING_RETURN_URL=http://localhost:3000
|
||||
@@ -24,6 +24,7 @@
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"node-cron": "^3.0",
|
||||
"pdfkit": "^0.15",
|
||||
"stripe": "^22.1.1",
|
||||
"web-push": "^3.6",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { env } from './config/env.js';
|
||||
import { Sentry } from './lib/sentry.js';
|
||||
import { achievementsRoutes } from './modules/achievements/achievements.routes.js';
|
||||
import { billingRoutes } from './modules/billing/billing.routes.js';
|
||||
import { stripeWebhookRoutes } from './modules/billing/webhook.routes.js';
|
||||
import { dbRoutes } from './modules/db/db.routes.js';
|
||||
import { healthRoutes } from './modules/health/health.routes.js';
|
||||
import { gdprRoutes } from './modules/me/gdpr.routes.js';
|
||||
@@ -68,6 +70,8 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(shoppingRoutes);
|
||||
await app.register(notificationsRoutes);
|
||||
await app.register(achievementsRoutes);
|
||||
await app.register(billingRoutes);
|
||||
await app.register(stripeWebhookRoutes);
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
app.setErrorHandler((err, request, reply) => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { deriveProStatus } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
|
||||
import { billingEnv, isBillingConfigured } from './env.js';
|
||||
import { ensureSubscription, getSubscription, toSnapshot } from './service.js';
|
||||
import { getStripe } from './stripe-client.js';
|
||||
|
||||
const checkoutBodySchema = z.object({
|
||||
interval: z.enum(['MONTH', 'YEAR']),
|
||||
});
|
||||
|
||||
export const billingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
/**
|
||||
* GET /me/billing/status — stato corrente dell'abbonamento + derived.
|
||||
* Risponde sempre 200 (anche per utenti senza subscription record): il
|
||||
* frontend usa lo stesso payload sia per chi non ha mai aperto la pagina
|
||||
* billing sia per chi è in trial / pro / canceled.
|
||||
*/
|
||||
fastify.get('/me/billing/status', { preHandler: requireAuth() }, async (request) => {
|
||||
const userId = request.user!.id;
|
||||
const signupAt = request.user!.createdAt;
|
||||
// Side effect benefico: chiamare /status la prima volta crea il record
|
||||
// di trial se non esiste. Idempotente.
|
||||
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
|
||||
const snap = toSnapshot(sub);
|
||||
const derived = deriveProStatus(snap);
|
||||
return {
|
||||
subscription: snap
|
||||
? {
|
||||
status: snap.status,
|
||||
trialEndsAt: snap.trialEndsAt.toISOString(),
|
||||
currentPeriodEnd: snap.currentPeriodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: snap.cancelAtPeriodEnd,
|
||||
interval: sub.interval,
|
||||
stripePriceId: sub.stripePriceId,
|
||||
}
|
||||
: null,
|
||||
derived: {
|
||||
kind: derived.kind,
|
||||
isPro: derived.isPro,
|
||||
trialDaysRemaining: derived.trialDaysRemaining,
|
||||
accessEndsAt: derived.accessEndsAt?.toISOString() ?? null,
|
||||
},
|
||||
configured: isBillingConfigured(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /me/billing/checkout — crea una Stripe Checkout Session per il
|
||||
* primo upgrade. Restituisce l'URL hosted Stripe a cui redirigere l'utente.
|
||||
*/
|
||||
fastify.post('/me/billing/checkout', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
if (!isBillingConfigured()) {
|
||||
return reply.code(503).send({ error: 'billing_not_configured' });
|
||||
}
|
||||
const body = checkoutBodySchema.safeParse(request.body);
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: body.error.issues });
|
||||
}
|
||||
const { interval } = body.data;
|
||||
const priceId =
|
||||
interval === 'YEAR' ? billingEnv.STRIPE_PRICE_ID_YEARLY : billingEnv.STRIPE_PRICE_ID_MONTHLY;
|
||||
if (!priceId) {
|
||||
return reply.code(503).send({ error: 'price_not_configured' });
|
||||
}
|
||||
|
||||
const userId = request.user!.id;
|
||||
const userEmail = request.user!.email;
|
||||
const signupAt = request.user!.createdAt;
|
||||
const stripe = getStripe();
|
||||
|
||||
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
|
||||
|
||||
// Riusa il customer Stripe se l'utente ha già pagato in passato; altrimenti
|
||||
// ne creiamo uno nuovo passando metadata.userId per il webhook.
|
||||
let customerId = sub.stripeCustomerId;
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: userEmail,
|
||||
metadata: { userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
await fastify.prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: { stripeCustomerId: customerId },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=canceled`,
|
||||
// Critico: il webhook risale all'utente da queste metadata.
|
||||
subscription_data: { metadata: { userId } },
|
||||
// Stripe Tax: calcolo automatico dell'IVA UE (vedi ADR 0004).
|
||||
automatic_tax: { enabled: true },
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
// Permette all'utente di inserire una P.IVA (per chi compra B2B).
|
||||
tax_id_collection: { enabled: true },
|
||||
allow_promotion_codes: true,
|
||||
locale: 'it',
|
||||
});
|
||||
|
||||
return reply.send({ url: session.url });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /me/billing/portal — crea una Customer Portal Session per la
|
||||
* gestione self-service di abbonamento, fatture, metodo di pagamento.
|
||||
*/
|
||||
fastify.post('/me/billing/portal', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
if (!isBillingConfigured()) {
|
||||
return reply.code(503).send({ error: 'billing_not_configured' });
|
||||
}
|
||||
const userId = request.user!.id;
|
||||
const sub = await getSubscription(fastify.prisma, userId);
|
||||
if (!sub?.stripeCustomerId) {
|
||||
return reply.code(409).send({ error: 'no_stripe_customer' });
|
||||
}
|
||||
const stripe = getStripe();
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
return_url: `${billingEnv.BILLING_RETURN_URL}/billing`,
|
||||
locale: 'it',
|
||||
});
|
||||
return reply.send({ url: session.url });
|
||||
});
|
||||
};
|
||||
@@ -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 { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
@@ -26,7 +27,7 @@ const MEAL_LABELS: Record<string, string> = {
|
||||
export const planExportRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get(
|
||||
'/me/meal-plans/export.pdf',
|
||||
{ preHandler: requireAuth() },
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
|
||||
@@ -72,220 +73,225 @@ function parseConditions(raw: string | null): string[] {
|
||||
}
|
||||
|
||||
export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
fastify.post(
|
||||
'/me/meal-plans',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const profile = await fastify.prisma.profile.findUnique({
|
||||
where: { userId },
|
||||
include: { user: { include: { preferences: true } } },
|
||||
});
|
||||
if (!profile) return reply.code(409).send({ error: 'profile_required' });
|
||||
const profile = await fastify.prisma.profile.findUnique({
|
||||
where: { userId },
|
||||
include: { user: { include: { preferences: true } } },
|
||||
});
|
||||
if (!profile) return reply.code(409).send({ error: 'profile_required' });
|
||||
|
||||
const recipes = await fastify.prisma.recipe.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
kcal: true,
|
||||
proteinG: true,
|
||||
fatG: true,
|
||||
netCarbG: true,
|
||||
phases: true,
|
||||
prepMinutes: true,
|
||||
ingredients: {
|
||||
select: {
|
||||
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
|
||||
const recipes = await fastify.prisma.recipe.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
kcal: true,
|
||||
proteinG: true,
|
||||
fatG: true,
|
||||
netCarbG: true,
|
||||
phases: true,
|
||||
prepMinutes: true,
|
||||
ingredients: {
|
||||
select: {
|
||||
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (recipes.length === 0) {
|
||||
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
||||
}
|
||||
|
||||
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
|
||||
// che contengono ingredienti non ancora reintrodotti per la settimana
|
||||
// corrente di fase 2.
|
||||
const phaseIntForFilter = phaseToInt(profile.currentPhase);
|
||||
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
|
||||
const allowedRecipes = recipes.filter((r) =>
|
||||
isRecipeAllowedForPhaseWeek(
|
||||
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
|
||||
phaseIntForFilter,
|
||||
phase2WeekForFilter,
|
||||
),
|
||||
);
|
||||
|
||||
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
|
||||
const tags = new Set<string>();
|
||||
const ingredientIds: string[] = [];
|
||||
for (const ri of r.ingredients) {
|
||||
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||
ingredientIds.push(ri.ingredient.id);
|
||||
});
|
||||
if (recipes.length === 0) {
|
||||
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
kcal: r.kcal,
|
||||
proteinG: r.proteinG,
|
||||
fatG: r.fatG,
|
||||
netCarbG: r.netCarbG,
|
||||
exclusionTags: Array.from(tags),
|
||||
ingredientIds,
|
||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
||||
prepMinutes: r.prepMinutes,
|
||||
};
|
||||
});
|
||||
|
||||
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
|
||||
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
|
||||
const conditions = parseConditions(profile.medicalConditions);
|
||||
if (hasExcludingCondition(conditions)) {
|
||||
return reply.code(409).send({ error: 'medical_block', conditions });
|
||||
}
|
||||
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
|
||||
// che contengono ingredienti non ancora reintrodotti per la settimana
|
||||
// corrente di fase 2.
|
||||
const phaseIntForFilter = phaseToInt(profile.currentPhase);
|
||||
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
|
||||
const allowedRecipes = recipes.filter((r) =>
|
||||
isRecipeAllowedForPhaseWeek(
|
||||
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
|
||||
phaseIntForFilter,
|
||||
phase2WeekForFilter,
|
||||
),
|
||||
);
|
||||
|
||||
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
||||
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
|
||||
let bmr = bodyFatPct
|
||||
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
||||
: calculateBmr({
|
||||
weightKg: weightCurrentKg,
|
||||
heightCm: profile.heightCm,
|
||||
ageYears: profile.age,
|
||||
gender: profile.gender,
|
||||
});
|
||||
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
|
||||
bmr *= bmrAdjustmentForConditions(conditions);
|
||||
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
|
||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||
const phaseInt = phaseToInt(profile.currentPhase);
|
||||
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
|
||||
const tags = new Set<string>();
|
||||
const ingredientIds: string[] = [];
|
||||
for (const ri of r.ingredients) {
|
||||
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||
ingredientIds.push(ri.ingredient.id);
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
kcal: r.kcal,
|
||||
proteinG: r.proteinG,
|
||||
fatG: r.fatG,
|
||||
netCarbG: r.netCarbG,
|
||||
exclusionTags: Array.from(tags),
|
||||
ingredientIds,
|
||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
||||
prepMinutes: r.prepMinutes,
|
||||
};
|
||||
});
|
||||
|
||||
// Schedule allenamento: kcal extra sui giorni di allenamento.
|
||||
const trainingDays = profile.user.preferences?.trainingDays ?? [];
|
||||
const trainingType =
|
||||
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
|
||||
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
|
||||
const sessionExtraKcal =
|
||||
trainingType && sessionMinutes
|
||||
? extraKcalForSession({
|
||||
type: trainingType,
|
||||
durationMinutes: sessionMinutes,
|
||||
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
|
||||
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
|
||||
const conditions = parseConditions(profile.medicalConditions);
|
||||
if (hasExcludingCondition(conditions)) {
|
||||
return reply.code(409).send({ error: 'medical_block', conditions });
|
||||
}
|
||||
|
||||
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
||||
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
|
||||
let bmr = bodyFatPct
|
||||
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
||||
: calculateBmr({
|
||||
weightKg: weightCurrentKg,
|
||||
})
|
||||
: 0;
|
||||
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
|
||||
heightCm: profile.heightCm,
|
||||
ageYears: profile.age,
|
||||
gender: profile.gender,
|
||||
});
|
||||
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
|
||||
bmr *= bmrAdjustmentForConditions(conditions);
|
||||
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
|
||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||
const phaseInt = phaseToInt(profile.currentPhase);
|
||||
|
||||
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
|
||||
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
||||
tdee,
|
||||
weightCurrentKg,
|
||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
const baseDailyTarget = macrosForPhase({
|
||||
kcalTarget: baseKcal,
|
||||
weightKg: weightCurrentKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
const exclusions = profile.user.preferences?.exclusions ?? [];
|
||||
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
|
||||
const fastingProtocol =
|
||||
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
|
||||
const cookingTime =
|
||||
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
|
||||
null;
|
||||
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
|
||||
// Schedule allenamento: kcal extra sui giorni di allenamento.
|
||||
const trainingDays = profile.user.preferences?.trainingDays ?? [];
|
||||
const trainingType =
|
||||
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
|
||||
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
|
||||
const sessionExtraKcal =
|
||||
trainingType && sessionMinutes
|
||||
? extraKcalForSession({
|
||||
type: trainingType,
|
||||
durationMinutes: sessionMinutes,
|
||||
weightKg: weightCurrentKg,
|
||||
})
|
||||
: 0;
|
||||
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
|
||||
|
||||
const weekStart = startOfWeek(new Date());
|
||||
|
||||
const plan = await fastify.prisma.mealPlan.upsert({
|
||||
where: { userId_weekStart: { userId, weekStart } },
|
||||
create: { userId, weekStart },
|
||||
update: { generatedAt: new Date(), status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
// Wipe existing slots before regenerating, in case the plan already existed.
|
||||
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
|
||||
|
||||
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const dayPlan = protocolPlanForDay(fastingProtocol, day);
|
||||
// Giorno di digiuno completo (ESE_24): salta tutto.
|
||||
if (dayPlan.kcalMultiplier === 0) continue;
|
||||
|
||||
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
|
||||
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
|
||||
const effectiveShare =
|
||||
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
|
||||
|
||||
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
|
||||
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
|
||||
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
|
||||
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
|
||||
const dailyTarget = macrosForPhase({
|
||||
kcalTarget: dailyKcal,
|
||||
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
|
||||
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
||||
tdee,
|
||||
weightCurrentKg,
|
||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
const baseDailyTarget = macrosForPhase({
|
||||
kcalTarget: baseKcal,
|
||||
weightKg: weightCurrentKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
const exclusions = profile.user.preferences?.exclusions ?? [];
|
||||
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
|
||||
const fastingProtocol =
|
||||
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ??
|
||||
null;
|
||||
const cookingTime =
|
||||
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
|
||||
null;
|
||||
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
|
||||
|
||||
// Ricette scelte negli ultimi 2 giorni sono "recenti".
|
||||
const recentlyConsumedIds: string[] = plannedSlots
|
||||
.filter((s) => day - s.day <= 2 && s.recipeId)
|
||||
.map((s) => s.recipeId!);
|
||||
const weekStart = startOfWeek(new Date());
|
||||
|
||||
for (const meal of MEALS) {
|
||||
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
|
||||
if (effectiveShare[meal] === 0) continue;
|
||||
|
||||
const top = matchMeals({
|
||||
candidates,
|
||||
meal,
|
||||
phase: phaseInt,
|
||||
excludedTags: exclusions,
|
||||
bannedIngredientIds,
|
||||
recentlyConsumedIds,
|
||||
dailyTarget,
|
||||
mealShare: effectiveShare,
|
||||
maxPrepMinutes,
|
||||
topN: 5,
|
||||
});
|
||||
const selected = top[0];
|
||||
await fastify.prisma.mealSlot.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
dayOfWeek: day,
|
||||
meal,
|
||||
recipeId: selected?.id ?? null,
|
||||
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
|
||||
},
|
||||
});
|
||||
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
const plan = await fastify.prisma.mealPlan.upsert({
|
||||
where: { userId_weekStart: { userId, weekStart } },
|
||||
create: { userId, weekStart },
|
||||
update: { generatedAt: new Date(), status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
plan: {
|
||||
id: plan.id,
|
||||
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
||||
dailyTarget: baseDailyTarget,
|
||||
fastingProtocol,
|
||||
},
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
});
|
||||
// Wipe existing slots before regenerating, in case the plan already existed.
|
||||
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
|
||||
|
||||
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const dayPlan = protocolPlanForDay(fastingProtocol, day);
|
||||
// Giorno di digiuno completo (ESE_24): salta tutto.
|
||||
if (dayPlan.kcalMultiplier === 0) continue;
|
||||
|
||||
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
|
||||
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
|
||||
const effectiveShare =
|
||||
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
|
||||
|
||||
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
|
||||
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
|
||||
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
|
||||
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
|
||||
const dailyTarget = macrosForPhase({
|
||||
kcalTarget: dailyKcal,
|
||||
weightKg: weightCurrentKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
|
||||
// Ricette scelte negli ultimi 2 giorni sono "recenti".
|
||||
const recentlyConsumedIds: string[] = plannedSlots
|
||||
.filter((s) => day - s.day <= 2 && s.recipeId)
|
||||
.map((s) => s.recipeId!);
|
||||
|
||||
for (const meal of MEALS) {
|
||||
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
|
||||
if (effectiveShare[meal] === 0) continue;
|
||||
|
||||
const top = matchMeals({
|
||||
candidates,
|
||||
meal,
|
||||
phase: phaseInt,
|
||||
excludedTags: exclusions,
|
||||
bannedIngredientIds,
|
||||
recentlyConsumedIds,
|
||||
dailyTarget,
|
||||
mealShare: effectiveShare,
|
||||
maxPrepMinutes,
|
||||
topN: 5,
|
||||
});
|
||||
const selected = top[0];
|
||||
await fastify.prisma.mealSlot.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
dayOfWeek: day,
|
||||
meal,
|
||||
recipeId: selected?.id ?? null,
|
||||
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
|
||||
},
|
||||
});
|
||||
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
plan: {
|
||||
id: plan.id,
|
||||
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
||||
dailyTarget: baseDailyTarget,
|
||||
fastingProtocol,
|
||||
},
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/me/meal-plans/slots/:slotId',
|
||||
{ preHandler: requireAuth() },
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const slotId = (request.params as { slotId: string }).slotId;
|
||||
const body = request.body as { recipeId?: unknown };
|
||||
@@ -322,7 +328,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// automaticamente un nuovo digiuno.
|
||||
fastify.post(
|
||||
'/me/meal-plans/slots/:slotId/consumed',
|
||||
{ preHandler: requireAuth() },
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const slotId = (request.params as { slotId: string }).slotId;
|
||||
const userId = request.user!.id;
|
||||
@@ -398,7 +404,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
|
||||
fastify.post(
|
||||
'/me/meal-plans/slots/:slotId/free-meal',
|
||||
{ preHandler: requireAuth() },
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const slotId = (request.params as { slotId: string }).slotId;
|
||||
const userId = request.user!.id;
|
||||
@@ -424,7 +430,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// conto dei pasti del giorno già scelti (coerenza dei macros).
|
||||
fastify.post(
|
||||
'/me/meal-plans/slots/:slotId/regenerate',
|
||||
{ preHandler: requireAuth() },
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const slotId = (request.params as { slotId: string }).slotId;
|
||||
const userId = request.user!.id;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dailyCheckInInputSchema } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
|
||||
function todayDateOnly(): Date {
|
||||
const d = new Date();
|
||||
@@ -54,36 +55,40 @@ export const checkInRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const parsed = dailyCheckInInputSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const userId = request.user!.id;
|
||||
const date = new Date(data.date);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
fastify.post(
|
||||
'/me/check-ins',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const parsed = dailyCheckInInputSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const userId = request.user!.id;
|
||||
const date = new Date(data.date);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
const item = await fastify.prisma.dailyCheckIn.upsert({
|
||||
where: { userId_date: { userId, date } },
|
||||
create: {
|
||||
userId,
|
||||
date,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
update: {
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
});
|
||||
const item = await fastify.prisma.dailyCheckIn.upsert({
|
||||
where: { userId_date: { userId, date } },
|
||||
create: {
|
||||
userId,
|
||||
date,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
update: {
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send({ item: { id: item.id } });
|
||||
});
|
||||
return reply.code(201).send({ item: { id: item.id } });
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
@@ -14,151 +15,165 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
});
|
||||
|
||||
export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const userEmail = request.user!.email ?? '—';
|
||||
fastify.get(
|
||||
'/me/tracking/export.pdf',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const userEmail = request.user!.email ?? '—';
|
||||
|
||||
const [profile, entries, currentPlan] = await Promise.all([
|
||||
fastify.prisma.profile.findUnique({ where: { userId } }),
|
||||
fastify.prisma.weightEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 30,
|
||||
}),
|
||||
fastify.prisma.mealPlan.findFirst({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { weekStart: 'desc' },
|
||||
include: {
|
||||
slots: {
|
||||
include: {
|
||||
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
|
||||
const [profile, entries, currentPlan] = await Promise.all([
|
||||
fastify.prisma.profile.findUnique({ where: { userId } }),
|
||||
fastify.prisma.weightEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 30,
|
||||
}),
|
||||
fastify.prisma.mealPlan.findFirst({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { weekStart: 'desc' },
|
||||
include: {
|
||||
slots: {
|
||||
include: {
|
||||
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
||||
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
});
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
||||
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
});
|
||||
|
||||
// ── Header ─────────────────────────────────────────────────────────
|
||||
doc
|
||||
.fillColor('#221d18')
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(22)
|
||||
.text('KetoPath', { continued: true })
|
||||
.fillColor('#9a3f29')
|
||||
.text('.');
|
||||
doc
|
||||
.moveDown(0.2)
|
||||
.fillColor('#3d3530')
|
||||
.font('Helvetica')
|
||||
.fontSize(9)
|
||||
.text(`Sintesi tracking — ${userEmail}`)
|
||||
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
|
||||
doc
|
||||
.moveDown(1)
|
||||
.strokeColor('#bdb6a3')
|
||||
.lineWidth(0.5)
|
||||
.moveTo(50, doc.y)
|
||||
.lineTo(545, doc.y)
|
||||
.stroke();
|
||||
|
||||
// ── Profile summary ────────────────────────────────────────────────
|
||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
||||
if (profile) {
|
||||
const weightStart = Number(profile.weightStartKg);
|
||||
const weightCurrent = Number(profile.weightCurrentKg);
|
||||
const weightGoal = Number(profile.weightGoalKg);
|
||||
const lostKg = weightStart - weightCurrent;
|
||||
// ── Header ─────────────────────────────────────────────────────────
|
||||
doc
|
||||
.moveDown(0.3)
|
||||
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
|
||||
.text(
|
||||
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
|
||||
)
|
||||
.text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`)
|
||||
.text(`Fase corrente: ${profile.currentPhase}`);
|
||||
} else {
|
||||
doc.text('Profilo non ancora configurato.');
|
||||
}
|
||||
.fillColor('#221d18')
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(22)
|
||||
.text('KetoPath', { continued: true })
|
||||
.fillColor('#9a3f29')
|
||||
.text('.');
|
||||
doc
|
||||
.moveDown(0.2)
|
||||
.fillColor('#3d3530')
|
||||
.font('Helvetica')
|
||||
.fontSize(9)
|
||||
.text(`Sintesi tracking — ${userEmail}`)
|
||||
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
|
||||
doc
|
||||
.moveDown(1)
|
||||
.strokeColor('#bdb6a3')
|
||||
.lineWidth(0.5)
|
||||
.moveTo(50, doc.y)
|
||||
.lineTo(545, doc.y)
|
||||
.stroke();
|
||||
|
||||
// ── Weight history ─────────────────────────────────────────────────
|
||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
|
||||
if (entries.length === 0) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
|
||||
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
|
||||
doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false });
|
||||
const headerY = doc.y - 11;
|
||||
doc.text('Peso (kg)', colX.weight, headerY);
|
||||
doc.text('Energia', colX.energy, headerY);
|
||||
doc.text('Sonno', colX.sleep, headerY);
|
||||
doc.text('Fame', colX.hunger, headerY);
|
||||
doc.font('Helvetica').moveDown(0.5);
|
||||
for (const e of entries) {
|
||||
const y = doc.y;
|
||||
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
|
||||
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
|
||||
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
|
||||
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
|
||||
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
|
||||
doc.moveDown(0.4);
|
||||
// ── Profile summary ────────────────────────────────────────────────
|
||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
||||
if (profile) {
|
||||
const weightStart = Number(profile.weightStartKg);
|
||||
const weightCurrent = Number(profile.weightCurrentKg);
|
||||
const weightGoal = Number(profile.weightGoalKg);
|
||||
const lostKg = weightStart - weightCurrent;
|
||||
doc
|
||||
.moveDown(0.3)
|
||||
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
|
||||
.text(
|
||||
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
|
||||
)
|
||||
.text(
|
||||
`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`,
|
||||
)
|
||||
.text(`Fase corrente: ${profile.currentPhase}`);
|
||||
} else {
|
||||
doc.text('Profilo non ancora configurato.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Current plan summary ───────────────────────────────────────────
|
||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE');
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
||||
if (!currentPlan) {
|
||||
doc.moveDown(0.3).text('Nessun piano attivo.');
|
||||
} else {
|
||||
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
|
||||
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
|
||||
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
|
||||
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
|
||||
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
|
||||
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
|
||||
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
|
||||
// ── Weight history ─────────────────────────────────────────────────
|
||||
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
|
||||
if (entries.length === 0) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
|
||||
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
|
||||
doc
|
||||
.moveDown(0.5)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Data', colX.date, doc.y, { continued: false });
|
||||
const headerY = doc.y - 11;
|
||||
doc.text('Peso (kg)', colX.weight, headerY);
|
||||
doc.text('Energia', colX.energy, headerY);
|
||||
doc.text('Sonno', colX.sleep, headerY);
|
||||
doc.text('Fame', colX.hunger, headerY);
|
||||
doc.font('Helvetica').moveDown(0.5);
|
||||
for (const e of entries) {
|
||||
const y = doc.y;
|
||||
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
|
||||
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
|
||||
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
|
||||
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
|
||||
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Current plan summary ───────────────────────────────────────────
|
||||
doc
|
||||
.moveDown(0.3)
|
||||
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
|
||||
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
|
||||
.moveDown(1)
|
||||
.fillColor('#221d18')
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(11)
|
||||
.text('PIANO CORRENTE');
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
|
||||
if (!currentPlan) {
|
||||
doc.moveDown(0.3).text('Nessun piano attivo.');
|
||||
} else {
|
||||
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
|
||||
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
|
||||
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
|
||||
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
|
||||
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
|
||||
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
|
||||
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
|
||||
doc
|
||||
.moveDown(0.3)
|
||||
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
|
||||
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
|
||||
.text(
|
||||
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer ─────────────────────────────────────────────────────────
|
||||
doc
|
||||
.moveDown(2)
|
||||
.strokeColor('#bdb6a3')
|
||||
.lineWidth(0.5)
|
||||
.moveTo(50, doc.y)
|
||||
.lineTo(545, doc.y)
|
||||
.stroke();
|
||||
doc
|
||||
.moveDown(0.5)
|
||||
.fontSize(8)
|
||||
.fillColor('#7a7060')
|
||||
.text(
|
||||
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
|
||||
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer ─────────────────────────────────────────────────────────
|
||||
doc
|
||||
.moveDown(2)
|
||||
.strokeColor('#bdb6a3')
|
||||
.lineWidth(0.5)
|
||||
.moveTo(50, doc.y)
|
||||
.lineTo(545, doc.y)
|
||||
.stroke();
|
||||
doc
|
||||
.moveDown(0.5)
|
||||
.fontSize(8)
|
||||
.fillColor('#7a7060')
|
||||
.text(
|
||||
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
|
||||
);
|
||||
|
||||
doc.end();
|
||||
const pdfBuffer = await done;
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
|
||||
.header('Content-Length', String(pdfBuffer.length))
|
||||
.send(pdfBuffer);
|
||||
});
|
||||
doc.end();
|
||||
const pdfBuffer = await done;
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
|
||||
.header('Content-Length', String(pdfBuffer.length))
|
||||
.send(pdfBuffer);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -29,91 +30,103 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const parsed = fastEventStartSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const event = await fastify.prisma.fastEvent.create({
|
||||
data: {
|
||||
userId: request.user!.id,
|
||||
protocol: data.protocol,
|
||||
startedAt: data.startedAt ?? new Date(),
|
||||
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({ event: { id: event.id } });
|
||||
});
|
||||
|
||||
fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id;
|
||||
const parsed = fastEventUpdateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
// Garantisce che l'utente possa aggiornare solo i propri eventi.
|
||||
const owned = await fastify.prisma.fastEvent.findFirst({
|
||||
where: { id, userId: request.user!.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
|
||||
|
||||
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
|
||||
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
|
||||
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
|
||||
if (data.endedAt) updateData.endedAt = data.endedAt;
|
||||
if (data.status) updateData.status = data.status;
|
||||
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
|
||||
if (data.notes != null) updateData.notes = data.notes;
|
||||
|
||||
const event = await fastify.prisma.fastEvent.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
let newlyUnlocked: string[] = [];
|
||||
if (event.status === 'COMPLETED') {
|
||||
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
|
||||
newlyUnlocked = ach.newlyUnlocked;
|
||||
if (newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
fastify.post(
|
||||
'/me/fast-events',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const parsed = fastEventStartSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
}
|
||||
return { event: { id: event.id, status: event.status }, newlyUnlocked };
|
||||
});
|
||||
const data = parsed.data;
|
||||
const event = await fastify.prisma.fastEvent.create({
|
||||
data: {
|
||||
userId: request.user!.id,
|
||||
protocol: data.protocol,
|
||||
startedAt: data.startedAt ?? new Date(),
|
||||
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({ event: { id: event.id } });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
'/me/fast-events/:id',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id;
|
||||
const parsed = fastEventUpdateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
// Garantisce che l'utente possa aggiornare solo i propri eventi.
|
||||
const owned = await fastify.prisma.fastEvent.findFirst({
|
||||
where: { id, userId: request.user!.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
|
||||
|
||||
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
|
||||
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
|
||||
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
|
||||
if (data.endedAt) updateData.endedAt = data.endedAt;
|
||||
if (data.status) updateData.status = data.status;
|
||||
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
|
||||
if (data.notes != null) updateData.notes = data.notes;
|
||||
|
||||
const event = await fastify.prisma.fastEvent.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
let newlyUnlocked: string[] = [];
|
||||
if (event.status === 'COMPLETED') {
|
||||
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
|
||||
newlyUnlocked = ach.newlyUnlocked;
|
||||
if (newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
return { event: { id: event.id, status: event.status }, newlyUnlocked };
|
||||
},
|
||||
);
|
||||
|
||||
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
|
||||
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
|
||||
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
|
||||
fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const body = (request.body ?? {}) as { until?: string | null };
|
||||
let until: Date | null = null;
|
||||
if (body.until === null) {
|
||||
until = null;
|
||||
} else if (typeof body.until === 'string') {
|
||||
const d = new Date(body.until);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return reply.code(400).send({ error: 'invalid_until' });
|
||||
fastify.post(
|
||||
'/me/fasting/pause',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const body = (request.body ?? {}) as { until?: string | null };
|
||||
let until: Date | null = null;
|
||||
if (body.until === null) {
|
||||
until = null;
|
||||
} else if (typeof body.until === 'string') {
|
||||
const d = new Date(body.until);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return reply.code(400).send({ error: 'invalid_until' });
|
||||
}
|
||||
until = d;
|
||||
} else {
|
||||
// default: domani alle 06:00 locali (server)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(6, 0, 0, 0);
|
||||
until = tomorrow;
|
||||
}
|
||||
until = d;
|
||||
} else {
|
||||
// default: domani alle 06:00 locali (server)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(6, 0, 0, 0);
|
||||
until = tomorrow;
|
||||
}
|
||||
const user = await fastify.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { fastingPausedUntil: until },
|
||||
select: { fastingPausedUntil: true },
|
||||
});
|
||||
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
|
||||
});
|
||||
const user = await fastify.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { fastingPausedUntil: until },
|
||||
select: { fastingPausedUntil: true },
|
||||
});
|
||||
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
|
||||
},
|
||||
);
|
||||
|
||||
// PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3
|
||||
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
|
||||
|
||||
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { requirePro } from '../../plugins/require-pro.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -26,48 +27,52 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const parsed = weightEntryInputSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const userId = request.user!.id;
|
||||
fastify.post(
|
||||
'/me/weight-entries',
|
||||
{ preHandler: [requireAuth(), requirePro()] },
|
||||
async (request, reply) => {
|
||||
const parsed = weightEntryInputSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const userId = request.user!.id;
|
||||
|
||||
const entry = await fastify.prisma.weightEntry.upsert({
|
||||
where: { userId_date: { userId, date: data.date } },
|
||||
create: {
|
||||
userId,
|
||||
date: data.date,
|
||||
weightKg: String(data.weightKg),
|
||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||
notes: data.notes ?? null,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
photos: data.photos ?? [],
|
||||
},
|
||||
update: {
|
||||
weightKg: String(data.weightKg),
|
||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||
notes: data.notes ?? null,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
photos: data.photos ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
const entry = await fastify.prisma.weightEntry.upsert({
|
||||
where: { userId_date: { userId, date: data.date } },
|
||||
create: {
|
||||
userId,
|
||||
date: data.date,
|
||||
weightKg: String(data.weightKg),
|
||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||
notes: data.notes ?? null,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
photos: data.photos ?? [],
|
||||
},
|
||||
update: {
|
||||
weightKg: String(data.weightKg),
|
||||
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||
notes: data.notes ?? null,
|
||||
energy: data.energy ?? null,
|
||||
sleep: data.sleep ?? null,
|
||||
hunger: data.hunger ?? null,
|
||||
photos: data.photos ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
});
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
"viewTracking": "Pesata & misure",
|
||||
"viewFasting": "Digiuno intermittente",
|
||||
"viewBilling": "Abbonamento & fatture",
|
||||
"lettura": "Continua la lettura",
|
||||
"inizia": "Inizia",
|
||||
"greeting": "Buongiorno, <name>{name}</name>.",
|
||||
@@ -710,5 +711,57 @@
|
||||
"email_required": "Email assente sul tuo account.",
|
||||
"api_error": "Errore di rete. Riprova fra poco."
|
||||
}
|
||||
},
|
||||
"Billing": {
|
||||
"title": "Abbonamento",
|
||||
"subtitle": "Trenta giorni di prova gratuiti, senza carta. Poi un piano semplice — mensile o annuale — che si gestisce in autonomia dal portale Stripe.",
|
||||
"currentStatus": "Stato attuale",
|
||||
"actions": "Azioni",
|
||||
"trialActive": "Sei in prova ({days} giorni rimasti).",
|
||||
"trialActiveBody": "Hai accesso completo a piani, tracking, digiuno e foto progress fino al {endsAt}. Allo scadere puoi continuare a leggere lo storico — ma per nuove pesate, piani e foto serve l'abbonamento.",
|
||||
"trialExpired": "La prova è scaduta.",
|
||||
"trialExpiredBody": "Lo storico resta consultabile. Per ricominciare a generare piani, registrare pesate e completare digiuni, attiva un abbonamento.",
|
||||
"proActive": "Abbonamento attivo.",
|
||||
"proActiveBody": "Piano {interval}, prossimo rinnovo il {renewsAt}. Tutto sbloccato.",
|
||||
"pastDue": "Pagamento in sospeso.",
|
||||
"pastDueBody": "L'ultimo addebito è fallito. Stripe sta riprovando: aggiorna la carta dal portale per evitare interruzioni.",
|
||||
"canceling": "Disdetta in corso.",
|
||||
"cancelingBody": "L'abbonamento resta valido fino al {endsAt}. Puoi riattivarlo in qualsiasi momento dal portale.",
|
||||
"canceled": "Abbonamento disdetto.",
|
||||
"canceledBody": "Lo storico resta tuo e consultabile. Quando vuoi rientrare, basta un nuovo upgrade.",
|
||||
"noSubscription": "Nessun abbonamento attivo.",
|
||||
"intervalMonth": "mensile",
|
||||
"intervalYear": "annuale",
|
||||
"upgradeMonthly": "Abbonati — €9,90 / mese",
|
||||
"upgradeYearly": "Abbonati — €89 / anno (≈25% di sconto)",
|
||||
"managePortal": "Gestisci abbonamento",
|
||||
"opening": "Apertura…",
|
||||
"checkoutHint": "Verrai reindirizzato al pagamento sicuro su Stripe.",
|
||||
"notConfigured": "Pagamenti non ancora attivati. La tua prova continua: nessuna interruzione.",
|
||||
"errorLoading": "Impossibile caricare lo stato dell'abbonamento.",
|
||||
"whatYouGet": "Cosa è incluso",
|
||||
"benefit1Title": "Piani settimanali infiniti",
|
||||
"benefit1Body": "Genera quanti piani vuoi, riadattati alla fase, alle preferenze e alle esclusioni alimentari. Con riadattamento automatico se cambi peso o protocollo.",
|
||||
"benefit2Title": "Tracking completo",
|
||||
"benefit2Body": "Pesate quotidiane, check-in soggettivi, foto progress, digiuno con timer e milestone. Tutto cifrato a riposo (art. 9 GDPR).",
|
||||
"benefit3Title": "Export PDF per il medico",
|
||||
"benefit3Body": "Un documento sintetico — peso, misure, aderenza — pronto da inviare al nutrizionista o consultare in studio.",
|
||||
"trialBannerTitle": "Sei in prova",
|
||||
"trialBannerBody": "{days} giorni rimasti. Tutto sbloccato fino al {endsAt}.",
|
||||
"trialBannerCta": "Vai all'abbonamento",
|
||||
"trialEndedBannerTitle": "La prova è scaduta",
|
||||
"trialEndedBannerBody": "Per generare nuovi piani e registrare pesate serve l'abbonamento.",
|
||||
"trialEndedBannerCta": "Abbonati",
|
||||
"paywallTitle": "Funzione Pro",
|
||||
"paywallBody": "Questa azione richiede un abbonamento attivo.",
|
||||
"paywallCta": "Vai all'abbonamento",
|
||||
"navLabel": "Abbonamento",
|
||||
"error": {
|
||||
"billing_not_configured": "Il sistema di pagamento non è ancora configurato. Riprova più tardi.",
|
||||
"no_stripe_customer": "Non hai ancora un abbonamento attivo. Avvia prima il checkout.",
|
||||
"api_error_400": "Richiesta non valida.",
|
||||
"api_error_401": "Devi accedere per gestire l'abbonamento.",
|
||||
"api_error_500": "Errore del server. Riprova fra poco."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use server';
|
||||
|
||||
import type { ProDerivedKind, SubscriptionStatus } from '@ketopath/shared';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export interface BillingStatus {
|
||||
subscription: {
|
||||
status: SubscriptionStatus;
|
||||
trialEndsAt: string;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
interval: 'MONTH' | 'YEAR' | null;
|
||||
stripePriceId: string | null;
|
||||
} | null;
|
||||
derived: {
|
||||
kind: ProDerivedKind;
|
||||
isPro: boolean;
|
||||
trialDaysRemaining: number | null;
|
||||
accessEndsAt: string | null;
|
||||
};
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export async function fetchBillingStatus(): Promise<BillingStatus | null> {
|
||||
const res = await fetch(`${API_URL}/me/billing/status`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as BillingStatus;
|
||||
}
|
||||
|
||||
export async function startCheckout(
|
||||
interval: 'MONTH' | 'YEAR',
|
||||
): Promise<{ url: string } | { error: string }> {
|
||||
const res = await fetch(`${API_URL}/me/billing/checkout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
cookie: cookieHeader(),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ interval }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 503) return { error: 'billing_not_configured' };
|
||||
if (!res.ok) return { error: `api_error_${res.status}` };
|
||||
return (await res.json()) as { url: string };
|
||||
}
|
||||
|
||||
export async function openCustomerPortal(): Promise<{ url: string } | { error: string }> {
|
||||
const res = await fetch(`${API_URL}/me/billing/portal`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 409) return { error: 'no_stripe_customer' };
|
||||
if (res.status === 503) return { error: 'billing_not_configured' };
|
||||
if (!res.ok) return { error: `api_error_${res.status}` };
|
||||
return (await res.json()) as { url: string };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { openCustomerPortal, startCheckout } from './actions';
|
||||
|
||||
interface Props {
|
||||
hasStripeCustomer: boolean;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function BillingActionsBar({ hasStripeCustomer, isPro }: Props) {
|
||||
const t = useTranslations('Billing');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function handleCheckout(interval: 'MONTH' | 'YEAR') {
|
||||
startTransition(async () => {
|
||||
const res = await startCheckout(interval);
|
||||
if ('url' in res) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
alert(t(`error.${res.error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePortal() {
|
||||
startTransition(async () => {
|
||||
const res = await openCustomerPortal();
|
||||
if ('url' in res) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
alert(t(`error.${res.error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isPro && hasStripeCustomer) {
|
||||
return (
|
||||
<Button onClick={handlePortal} disabled={pending} size="lg">
|
||||
{pending ? t('opening') : t('managePortal')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<Button onClick={() => handleCheckout('MONTH')} disabled={pending} size="lg">
|
||||
{pending ? t('opening') : t('upgradeMonthly')}
|
||||
</Button>
|
||||
<Button onClick={() => handleCheckout('YEAR')} disabled={pending} size="lg" variant="outline">
|
||||
{pending ? t('opening') : t('upgradeYearly')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchBillingStatus, type BillingStatus } from './billing/actions';
|
||||
import { SignOutButton } from './sign-out-button';
|
||||
|
||||
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
const userName = session?.user?.name ?? session?.user?.email ?? null;
|
||||
const billing = userName ? await fetchBillingStatus() : null;
|
||||
|
||||
return <HomeContent userName={session?.user?.name ?? session?.user?.email ?? null} />;
|
||||
return <HomeContent userName={userName} billing={billing} />;
|
||||
}
|
||||
|
||||
function HomeContent({ userName }: { userName: string | null }) {
|
||||
function HomeContent({
|
||||
userName,
|
||||
billing,
|
||||
}: {
|
||||
userName: string | null;
|
||||
billing: BillingStatus | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
||||
<Masthead />
|
||||
{userName ? <SignedInDashboard userName={userName} /> : <MarketingLanding />}
|
||||
{userName ? (
|
||||
<SignedInDashboard userName={userName} billing={billing} />
|
||||
) : (
|
||||
<MarketingLanding />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -800,7 +813,13 @@ function Disclaimer() {
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInDashboard({ userName }: { userName: string }) {
|
||||
function SignedInDashboard({
|
||||
userName,
|
||||
billing,
|
||||
}: {
|
||||
userName: string;
|
||||
billing: BillingStatus | null;
|
||||
}) {
|
||||
const t = useTranslations('Home');
|
||||
return (
|
||||
<main className="relative min-h-[80vh] overflow-hidden pb-24">
|
||||
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
||||
{t('tagline')}
|
||||
</p>
|
||||
|
||||
<TrialBanner billing={billing} />
|
||||
|
||||
{/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
|
||||
<div className="animate-fade-up relative mt-20 grid grid-cols-12 gap-6 [animation-delay:360ms]">
|
||||
<DashboardHero
|
||||
@@ -861,6 +882,7 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
||||
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
|
||||
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
|
||||
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
|
||||
<NavItem href="/billing" label={t('viewBilling')} eyebrow="Abbonamento" chapter="VI" />
|
||||
<li className="pt-6">
|
||||
<SignOutButton label={t('signOut')} />
|
||||
</li>
|
||||
@@ -957,3 +979,59 @@ function NavItem({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
function TrialBanner({ billing }: { billing: BillingStatus | null }) {
|
||||
const t = useTranslations('Billing');
|
||||
if (!billing) return null;
|
||||
const { derived } = billing;
|
||||
|
||||
if (derived.kind === 'trial' && derived.trialDaysRemaining != null) {
|
||||
const endsAt = derived.accessEndsAt ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) : '—';
|
||||
return (
|
||||
<aside className="border-oro/35 bg-oro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('trialBannerTitle')}</p>
|
||||
<p className="font-display text-ink mt-2 text-lg leading-snug">
|
||||
{t('trialBannerBody', { days: derived.trialDaysRemaining, endsAt })}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-oro hover:text-pomodoro shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
|
||||
>
|
||||
{t('trialBannerCta')} →
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
if (derived.kind === 'trial_expired' || derived.kind === 'past_due') {
|
||||
return (
|
||||
<aside className="border-pomodoro/40 bg-pomodoro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('trialEndedBannerTitle')}</p>
|
||||
<p className="font-display text-ink mt-2 text-lg leading-snug">
|
||||
{t('trialEndedBannerBody')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-pomodoro hover:text-ink shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
|
||||
>
|
||||
{t('trialEndedBannerCta')} →
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user