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
@@ -0,0 +1,53 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCEL_AT_PERIOD_END', 'CANCELED', 'EXPIRED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BillingInterval" AS ENUM ('MONTH', 'YEAR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscriptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"status" "SubscriptionStatus" NOT NULL,
|
||||
"trial_ends_at" TIMESTAMP(3) NOT NULL,
|
||||
"stripe_customer_id" TEXT,
|
||||
"stripe_subscription_id" TEXT,
|
||||
"stripe_price_id" TEXT,
|
||||
"current_period_end" TIMESTAMP(3),
|
||||
"interval" "BillingInterval",
|
||||
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ended_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "billing_webhook_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processed_at" TIMESTAMP(3),
|
||||
"payload" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "billing_webhook_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscriptions_user_id_key" ON "subscriptions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscriptions_stripe_customer_id_key" ON "subscriptions"("stripe_customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscriptions_stripe_subscription_id_key" ON "subscriptions"("stripe_subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "billing_webhook_events_type_idx" ON "billing_webhook_events"("type");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -76,6 +76,23 @@ enum MealPlanStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
// PRD §16.3 / ADR 0004 — stato dell'abbonamento. Mappato 1:1 sullo stato
|
||||
// Stripe ma mantenuto in DB come source-of-truth applicativo (l'API non
|
||||
// chiama Stripe a ogni request — usa il valore qui).
|
||||
enum SubscriptionStatus {
|
||||
TRIALING // free 30gg post-signup, no carta richiesta
|
||||
ACTIVE // pagante, in regola
|
||||
PAST_DUE // pagamento fallito, in retry da Stripe (grace period)
|
||||
CANCEL_AT_PERIOD_END // canceled ma valido fino a fine periodo corrente
|
||||
CANCELED // canceled ed expired
|
||||
EXPIRED // trial scaduto senza upgrade
|
||||
}
|
||||
|
||||
enum BillingInterval {
|
||||
MONTH
|
||||
YEAR
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
@@ -97,6 +114,7 @@ model User {
|
||||
|
||||
profile Profile?
|
||||
preferences Preferences?
|
||||
subscription Subscription?
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
weightEntries WeightEntry[]
|
||||
@@ -109,6 +127,49 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ADR 0004 — abbonamento dell'utente. 1:1 con User. Lo stato è la verità
|
||||
// applicativa: il webhook Stripe lo aggiorna, ma le route che gating-ano
|
||||
// le feature leggono solo da qui (niente roundtrip Stripe).
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
status SubscriptionStatus
|
||||
// Trial nativo (30gg post-signup, no carta). Sempre valorizzato.
|
||||
trialEndsAt DateTime @map("trial_ends_at")
|
||||
// Stripe: presente solo dopo il primo upgrade (checkout completato).
|
||||
stripeCustomerId String? @unique @map("stripe_customer_id")
|
||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||
stripePriceId String? @map("stripe_price_id")
|
||||
// Periodo corrente (Pro). Riflette `current_period_end` di Stripe.
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
interval BillingInterval?
|
||||
// Se `cancelAtPeriodEnd = true`, la sub è ancora ACTIVE ma non si rinnoverà.
|
||||
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
|
||||
// Quando lo status è effettivamente expired/canceled, archiviamo la data.
|
||||
endedAt DateTime? @map("ended_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
// ADR 0004 — registro idempotenza dei webhook Stripe. Stripe ritenta gli
|
||||
// eventi falliti, ricezione multipla è normale: scartiamo i duplicati su
|
||||
// `event.id` invece di applicare due volte lo stesso aggiornamento.
|
||||
model BillingWebhookEvent {
|
||||
id String @id // event.id di Stripe (evt_...)
|
||||
type String
|
||||
receivedAt DateTime @default(now()) @map("received_at")
|
||||
processedAt DateTime? @map("processed_at")
|
||||
payload Json
|
||||
|
||||
@@index([type])
|
||||
@@map("billing_webhook_events")
|
||||
}
|
||||
|
||||
// PRD §6 — sistema achievement: badge sbloccati man mano che l'utente
|
||||
// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.).
|
||||
// `key` identifica univocamente l'achievement (vedi packages/shared).
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeTrialEndsAt,
|
||||
deriveProStatus,
|
||||
isProActive,
|
||||
TRIAL_DAYS,
|
||||
type SubscriptionSnapshot,
|
||||
} from './pro-status.js';
|
||||
|
||||
const NOW = new Date('2026-05-07T12:00:00Z');
|
||||
|
||||
function snap(partial: Partial<SubscriptionSnapshot>): SubscriptionSnapshot {
|
||||
return {
|
||||
status: 'TRIALING',
|
||||
trialEndsAt: new Date('2026-06-01T00:00:00Z'),
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isProActive', () => {
|
||||
it('false se snap è null (nessuna subscription)', () => {
|
||||
expect(isProActive(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('TRIALING valido finché trialEndsAt è nel futuro', () => {
|
||||
expect(isProActive(snap({ status: 'TRIALING' }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('TRIALING con trialEndsAt passato → false (cron in ritardo)', () => {
|
||||
expect(
|
||||
isProActive(snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }), NOW),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ACTIVE sempre true', () => {
|
||||
expect(isProActive(snap({ status: 'ACTIVE' }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('PAST_DUE = true (manteniamo accesso durante dunning Stripe)', () => {
|
||||
expect(isProActive(snap({ status: 'PAST_DUE' }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('CANCEL_AT_PERIOD_END = true finché currentPeriodEnd è nel futuro', () => {
|
||||
expect(
|
||||
isProActive(
|
||||
snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-06-01') }),
|
||||
NOW,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('CANCEL_AT_PERIOD_END con periodo già scaduto → false', () => {
|
||||
expect(
|
||||
isProActive(
|
||||
snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-05-01') }),
|
||||
NOW,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('CANCELED, EXPIRED → false', () => {
|
||||
expect(isProActive(snap({ status: 'CANCELED' }), NOW)).toBe(false);
|
||||
expect(isProActive(snap({ status: 'EXPIRED' }), NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveProStatus', () => {
|
||||
it('no subscription → kind no_subscription, isPro false', () => {
|
||||
expect(deriveProStatus(null, NOW).kind).toBe('no_subscription');
|
||||
});
|
||||
|
||||
it('trial in corso → trialDaysRemaining = ceil giorni mancanti', () => {
|
||||
const s = deriveProStatus(snap({ trialEndsAt: new Date('2026-05-10T12:00:00Z') }), NOW);
|
||||
expect(s.kind).toBe('trial');
|
||||
expect(s.isPro).toBe(true);
|
||||
expect(s.trialDaysRemaining).toBe(3);
|
||||
});
|
||||
|
||||
it('trial scaduto ma DB ancora TRIALING → kind trial_expired', () => {
|
||||
const s = deriveProStatus(
|
||||
snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }),
|
||||
NOW,
|
||||
);
|
||||
expect(s.kind).toBe('trial_expired');
|
||||
expect(s.isPro).toBe(false);
|
||||
expect(s.trialDaysRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it('cancel_at_period_end → kind canceling con accessEndsAt valorizzato', () => {
|
||||
const end = new Date('2026-06-01');
|
||||
const s = deriveProStatus(snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: end }), NOW);
|
||||
expect(s.kind).toBe('canceling');
|
||||
expect(s.accessEndsAt).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTrialEndsAt', () => {
|
||||
it('default 30 giorni dalla signup', () => {
|
||||
const signup = new Date('2026-05-07T00:00:00Z');
|
||||
const end = computeTrialEndsAt(signup);
|
||||
expect(end.toISOString()).toBe('2026-06-06T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('TRIAL_DAYS = 30', () => {
|
||||
expect(TRIAL_DAYS).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* ADR 0004 — calcolo "isProActive" e derivazione dello stato di abbonamento
|
||||
* a partire dallo snapshot persistito in DB. Funzione pura e testabile.
|
||||
*
|
||||
* NB: lo stato in DB viene aggiornato dal webhook Stripe; questa logica è
|
||||
* usata sia dal backend (paywall middleware) sia dal frontend (banner /
|
||||
* pulsanti CTA in /billing).
|
||||
*/
|
||||
|
||||
export const TRIAL_DAYS = 30;
|
||||
|
||||
export type SubscriptionStatus =
|
||||
| 'TRIALING'
|
||||
| 'ACTIVE'
|
||||
| 'PAST_DUE'
|
||||
| 'CANCEL_AT_PERIOD_END'
|
||||
| 'CANCELED'
|
||||
| 'EXPIRED';
|
||||
|
||||
export interface SubscriptionSnapshot {
|
||||
status: SubscriptionStatus;
|
||||
trialEndsAt: Date;
|
||||
currentPeriodEnd: Date | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vero se l'utente ha diritto alle feature Pro in questo momento.
|
||||
* Gestita anche la transizione "trial scaduto ma non ancora marcato EXPIRED
|
||||
* dal cron": in lettura calcoliamo live, così non serve un job che gira
|
||||
* ogni minuto. Il cron resta utile solo per allineare lo stato persistito.
|
||||
*/
|
||||
export function isProActive(snap: SubscriptionSnapshot | null, now: Date = new Date()): boolean {
|
||||
if (!snap) return false;
|
||||
const t = now.getTime();
|
||||
switch (snap.status) {
|
||||
case 'TRIALING':
|
||||
return snap.trialEndsAt.getTime() > t;
|
||||
case 'ACTIVE':
|
||||
case 'PAST_DUE':
|
||||
// Durante PAST_DUE Stripe ha la sua dunning sequence (3-7gg).
|
||||
// Manteniamo l'accesso Pro: l'utente non ha colpa di un retry in corso.
|
||||
return true;
|
||||
case 'CANCEL_AT_PERIOD_END':
|
||||
return snap.currentPeriodEnd != null && snap.currentPeriodEnd.getTime() > t;
|
||||
case 'CANCELED':
|
||||
case 'EXPIRED':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProDerivedKind =
|
||||
| 'trial'
|
||||
| 'trial_expired'
|
||||
| 'active'
|
||||
| 'past_due'
|
||||
| 'canceling'
|
||||
| 'canceled'
|
||||
| 'no_subscription';
|
||||
|
||||
export interface ProDerivedStatus {
|
||||
kind: ProDerivedKind;
|
||||
/** True ↔ isProActive(snap). Comodità per l'UI. */
|
||||
isPro: boolean;
|
||||
/** Giorni rimasti del trial (solo se `kind === 'trial'`), arrotondati per eccesso. */
|
||||
trialDaysRemaining: number | null;
|
||||
/** Quando finisce l'accesso (trial o abbonamento). Null se canceled/no_sub. */
|
||||
accessEndsAt: Date | null;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function deriveProStatus(
|
||||
snap: SubscriptionSnapshot | null,
|
||||
now: Date = new Date(),
|
||||
): ProDerivedStatus {
|
||||
if (!snap) {
|
||||
return { kind: 'no_subscription', isPro: false, trialDaysRemaining: null, accessEndsAt: null };
|
||||
}
|
||||
const isPro = isProActive(snap, now);
|
||||
const t = now.getTime();
|
||||
switch (snap.status) {
|
||||
case 'TRIALING': {
|
||||
const remainingMs = snap.trialEndsAt.getTime() - t;
|
||||
if (remainingMs <= 0) {
|
||||
// trial scaduto ma DB ancora TRIALING (cron in ritardo): trattiamo come expired
|
||||
return {
|
||||
kind: 'trial_expired',
|
||||
isPro: false,
|
||||
trialDaysRemaining: 0,
|
||||
accessEndsAt: snap.trialEndsAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'trial',
|
||||
isPro: true,
|
||||
trialDaysRemaining: Math.max(1, Math.ceil(remainingMs / MS_PER_DAY)),
|
||||
accessEndsAt: snap.trialEndsAt,
|
||||
};
|
||||
}
|
||||
case 'ACTIVE':
|
||||
return {
|
||||
kind: 'active',
|
||||
isPro,
|
||||
trialDaysRemaining: null,
|
||||
accessEndsAt: snap.currentPeriodEnd,
|
||||
};
|
||||
case 'PAST_DUE':
|
||||
return {
|
||||
kind: 'past_due',
|
||||
isPro,
|
||||
trialDaysRemaining: null,
|
||||
accessEndsAt: snap.currentPeriodEnd,
|
||||
};
|
||||
case 'CANCEL_AT_PERIOD_END':
|
||||
return {
|
||||
kind: 'canceling',
|
||||
isPro,
|
||||
trialDaysRemaining: null,
|
||||
accessEndsAt: snap.currentPeriodEnd,
|
||||
};
|
||||
case 'CANCELED':
|
||||
case 'EXPIRED':
|
||||
return { kind: 'canceled', isPro: false, trialDaysRemaining: null, accessEndsAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Calcola la data di fine trial a partire dalla data di signup. */
|
||||
export function computeTrialEndsAt(signupAt: Date, days: number = TRIAL_DAYS): Date {
|
||||
return new Date(signupAt.getTime() + days * MS_PER_DAY);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './achievements/definitions.js';
|
||||
export * from './achievements/evaluator.js';
|
||||
export * from './billing/pro-status.js';
|
||||
export * from './medical/conditions.js';
|
||||
export * from './planner/adherence.js';
|
||||
export * from './notifications/schema.js';
|
||||
|
||||
Reference in new issue
Block a user