From 548bdf5aa6ff671d732172e5dbbc0bad01b7f206 Mon Sep 17 00:00:00 2001 From: cero-fyso Date: Sat, 25 Apr 2026 18:05:47 -0300 Subject: [PATCH 1/2] docs: add Fyso Teams paid entitlement reference (issue #1520) - site/docs/billing/app-entitlements.md: full integrator reference covering schema (migrations 0085-0087), seed, env vars, endpoints, error codes, Paddle webhook routing, state machine D4, cron transitions, and frontend integration (paywall + Paddle.js + polling) - billing/fyso-teams-entitlement.md: canonical source in /billing for internal use - site/docs/changelog.md: v1.43.0 entry for the full 3-wave feature Co-Authored-By: Claude Sonnet 4.6 --- billing/fyso-teams-entitlement.md | 591 ++++++++++++++++++++++++++ site/docs/billing/app-entitlements.md | 419 ++++++++++++++++++ site/docs/changelog.md | 50 +++ 3 files changed, 1060 insertions(+) create mode 100644 billing/fyso-teams-entitlement.md create mode 100644 site/docs/billing/app-entitlements.md diff --git a/billing/fyso-teams-entitlement.md b/billing/fyso-teams-entitlement.md new file mode 100644 index 0000000..26f9737 --- /dev/null +++ b/billing/fyso-teams-entitlement.md @@ -0,0 +1,591 @@ +# Fyso Teams — App entitlements y suscripción Paddle + +Esta página documenta el sistema de entitlements de pago introducido en v1.43.0. +Cubre el modelo de datos, los endpoints de auth, el routing de webhooks Paddle, +la máquina de estados D4 y la integración frontend (paywall + checkout overlay). + +El scope de este documento es **Fyso Teams** (`sourceTenantId = 65422493-73d8-4e74-a879-defa3a9771f1`), +pero el sistema es genérico: cualquier app en `app_catalog` puede adoptar +`required_plan = 'paid'` siguiendo el mismo patrón. + +--- + +## Modelo de datos + +### `app_catalog` — columnas nuevas (migración 0085) + +La tabla existente se extendió con 8 columnas para soportar configuración comercial por app. +Las filas anteriores (`freelancer`, `taller`, `tienda`) no cambian de comportamiento: +tienen `required_plan = 'free'` y `allow_free_instances = true` por defecto. + +| Columna | Tipo | Default | Descripción | +|---------|------|---------|-------------| +| `source_tenant_id` | `UUID UNIQUE` | `NULL` | Identidad canónica del app como tenant publicador. Clave FK en `app_entitlements`. | +| `required_plan` | `TEXT` | `'free'` | Enum: `'paid' \| 'trial' \| 'free' \| 'freemium'` | +| `trial_config` | `JSONB` | `NULL` | Configuración de trial: `{ days, requireCardOnFile }` | +| `provider` | `TEXT` | `NULL` | Proveedor de pagos: `'paddle'` | +| `paddle_price_id` | `TEXT` | `NULL` | ID de precio Paddle (`pri_...`). Requerido si `provider = 'paddle'`. | +| `allow_free_instances` | `BOOLEAN` | `true` | Si `false`, bloquea creación de instancias sin entitlement activo. | +| `max_instances_per_entitlement` | `INTEGER` | `NULL` | Cuota de instancias por entitlement. `NULL` = ilimitado. | +| `display_price` | `JSONB` | `NULL` | Precio local para respuestas rápidas: `{ "amountUsd": 15, "billingInterval": "monthly" }` | + +**Nota sobre `display_price`:** Paddle es la fuente de verdad del cobro real. +`display_price` es solo el dato local que los endpoints `402` devuelven en <50 ms +sin consultar Paddle en cada request. Editarlo manualmente si cambia el precio. + +#### Índice + +```sql +CREATE INDEX idx_app_catalog_source_tenant_id ON app_catalog(source_tenant_id); +``` + +--- + +### `app_entitlements` — tabla nueva (migración 0086) + +Estado de suscripción por par `(org_id, source_tenant_id)`. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | `UUID PK` | `gen_random_uuid()` | +| `org_id` | `UUID NOT NULL` | FK → `organizations(id) ON DELETE CASCADE` | +| `source_tenant_id` | `UUID NOT NULL` | FK → `app_catalog(source_tenant_id)` | +| `status` | `TEXT NOT NULL` | Enum (ver abajo) — default `'active'` | +| `provider` | `TEXT NOT NULL` | Default `'paddle'` | +| `paddle_subscription_id` | `TEXT UNIQUE` | UNIQUE garantiza idempotencia en webhooks | +| `paddle_customer_id` | `TEXT` | — | +| `current_period_end` | `TIMESTAMPTZ` | Vencimiento del período actual | +| `cancel_at_period_end` | `BOOLEAN NOT NULL` | Default `false` | +| `past_due_since` | `TIMESTAMPTZ` | Fecha de entrada a `past_due`; cron usa `past_due_since + 30d` para suspensión | +| `suspended_at` | `TIMESTAMPTZ` | — | +| `created_at` / `updated_at` | `TIMESTAMPTZ NOT NULL` | — | + +**Clave de negocio:** `UNIQUE (org_id, source_tenant_id)` — una org tiene como máximo un entitlement por app. + +#### Status enum + +| Valor | Descripción | +|-------|-------------| +| `active` | Suscripción vigente | +| `active_until_period_end` | Usuario canceló; acceso hasta `current_period_end` | +| `past_due` | Período venció sin renovación; instancias bloqueadas (`is_active = false`) | +| `suspended` | 30 días en `past_due`; datos preservados 90 días | +| `refunded` | Chargeback o refund Paddle; bloqueo inmediato | + +#### Índices + +```sql +CREATE INDEX idx_app_entitlements_status ON app_entitlements(status); + +-- Índice parcial: solo rows en estados activos → eficiente para el cron +CREATE INDEX idx_app_entitlements_period_end + ON app_entitlements(current_period_end) + WHERE status IN ('active', 'active_until_period_end', 'past_due'); +``` + +--- + +### `paddle_webhook_events` — tabla nueva (migración 0087) + +Idempotencia y auditoría de webhooks Paddle. Genérica — no exclusiva de Fyso Teams. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `event_id` | `TEXT PRIMARY KEY` | ID nativo de Paddle; PRIMARY KEY es la garantía de idempotencia | +| `event_type` | `TEXT NOT NULL` | Ej: `subscription.created` | +| `payload` | `JSONB NOT NULL` | Payload Paddle completo | +| `org_id` | `UUID` | Resuelto de `custom_data`; nullable | +| `source_tenant_id` | `UUID` | Resuelto de `custom_data`; nullable | +| `processed_at` | `TIMESTAMPTZ NOT NULL` | Fecha de primera recepción | +| `processing_error` | `TEXT` | No-null si el procesamiento de negocio falló post-recepción | + +**Patrón de idempotencia:** + +```sql +INSERT INTO paddle_webhook_events (event_id, event_type, payload, ...) +VALUES ($1, $2, $3, ...) +ON CONFLICT (event_id) DO NOTHING; + +-- rowCount = 1 → procesar evento +-- rowCount = 0 → ya procesado, responder 200 sin hacer nada +``` + +Paddle reintenta hasta 5 veces. Este patrón garantiza que cada evento se procesa exactamente una vez. + +--- + +### Migraciones y `__extra_migrations` + +Las migraciones 0085, 0086 y 0087 se rastrean via `__extra_migrations` (no en el journal Drizzle). +Razón: son migraciones SQL crudas que extienden tablas ya existentes; el workflow de Drizzle +`migrate()` solo aplica migraciones que no están en su journal. El sistema `__extra_migrations` +de fyso_backend aplica cualquier `.sql` en `/db/migrations/` que aún no haya sido ejecutado, +complementando a Drizzle sin conflicto. + +--- + +## Seed — `seed-fyso-teams-app-catalog.ts` + +El script inserta (o actualiza) la fila de Fyso Teams en `app_catalog` con configuración de pago. +Es idempotente: usa `ON CONFLICT (source_tenant_id) DO UPDATE`, por lo que re-ejecutarlo +actualiza `paddle_price_id` y `display_price` pero nunca duplica filas. + +**Constantes:** + +| Nombre | Valor | +|--------|-------| +| `FYSO_TEAMS_SOURCE_ID` | `65422493-73d8-4e74-a879-defa3a9771f1` | +| `required_plan` | `'paid'` | +| `allow_free_instances` | `false` | +| `max_instances_per_entitlement` | `1` | +| `display_price` | `{ "amountUsd": 15, "billingInterval": "monthly" }` | +| `paddle_price_id` | Valor de `PADDLE_FYSO_TEAMS_PRICE_ID` env var; default `pri_TBD_REPLACE_BEFORE_PROD` | + +**Cómo ejecutar manualmente:** + +```bash +npx tsx packages/db/migrations/seed-fyso-teams-app-catalog.ts +# o via runner: +bun run packages/db/migrations/seed-app-catalog.ts +``` + +El runner `seed-app-catalog.ts` ya incluye este seed; se ejecuta automáticamente en bootstrap. + +--- + +## Variables de entorno + +Nuevas claves en `.env.example` desde Wave 1: + +```dotenv +# ID del precio Paddle para Fyso Teams (Products → Prices en el dashboard). +# Staging y producción usan entornos Paddle separados (Sandbox vs Production) +# pero comparten el mismo source_tenant_id UUID. +# DEBE reemplazarse antes del primer deploy a producción. +PADDLE_FYSO_TEAMS_PRICE_ID=pri_TBD_REPLACE_BEFORE_PROD + +# UUID canónico del source tenant de Fyso Teams en app_catalog. +# Estable entre staging y producción. +FYSO_TEAMS_SOURCE_TENANT_ID=65422493-73d8-4e74-a879-defa3a9771f1 +``` + +Si `PADDLE_FYSO_TEAMS_PRICE_ID` tiene el valor placeholder al arrancar, el proceso +emite una advertencia en consola pero no falla. Los checkouts fallarán en runtime +hasta que se configure el ID real. + +--- + +## Endpoints + +### Auth y resolución de org + +Todos los endpoints de `/api/auth/app-entitlements/*` requieren `Authorization: Bearer `. + +Si el admin pertenece a múltiples orgs, debe especificar la org objetivo: +- Via query param `?orgId=` (GET) +- Via `body.orgId` (POST) +- Si tiene una sola org, se usa automáticamente +- Si tiene varias y no especifica: `400 ORG_REQUIRED` + +--- + +### `GET /api/auth/app-entitlements/:sourceTenantId` + +Consulta el estado del entitlement de la org del admin para una app distribuida. + +**Path param:** `sourceTenantId` — UUID del source tenant + +**Query param opcional:** `orgId` — UUID de la org (requerido si el admin tiene varias) + +#### Respuesta — entitlement activo (`200`) + +```json +{ + "success": true, + "data": { + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "appKey": "fyso_teams", + "active": true, + "canCreateInstance": true, + "provider": "paddle", + "status": "active", + "currentPeriodEnd": "2026-05-25T00:00:00.000Z", + "cancelAtPeriodEnd": false, + "instancesUsed": 0, + "instancesAllowed": 1 + } +} +``` + +#### Respuesta — cancelado pero vigente (`200`) + +```json +{ + "success": true, + "data": { + "status": "active_until_period_end", + "active": true, + "canCreateInstance": true, + "cancelAtPeriodEnd": true, + "currentPeriodEnd": "2026-05-25T00:00:00.000Z" + } +} +``` + +#### Respuesta — past due (`200`) + +```json +{ + "success": true, + "data": { + "status": "past_due", + "active": false, + "canCreateInstance": false, + "pastDueSince": "2026-04-15T00:00:00.000Z" + } +} +``` + +El campo `pastDueSince` está presente cuando `status = 'past_due'`. +Frontend calcula la fecha límite de reactivación como `pastDueSince + 30 días`. + +#### Respuesta — sin entitlement (`200`) + +```json +{ + "success": true, + "data": { + "status": "none", + "active": false, + "canCreateInstance": false, + "currentPeriodEnd": null, + "instancesUsed": 0, + "instancesAllowed": 0 + } +} +``` + +#### Respuesta — app gratuita (`200`) + +Si la app tiene `required_plan = 'free'` o `'freemium'`, el endpoint responde igual pero con: +- `status: "free"` +- `active: true` +- `canCreateInstance: true` +- `provider: null` + +**Regla clave para el integrador:** leer siempre `canCreateInstance`, no `active`. +`canCreateInstance` ya consolida estado + cuota + toda la lógica de acceso. + +--- + +### `POST /api/auth/app-entitlements/:sourceTenantId/checkout` + +Crea una sesión de checkout Paddle para nueva suscripción o reactivación. + +**Body:** + +```json +{ "orgId": "" } +``` + +`orgId` es opcional si el admin tiene una sola org. + +#### Respuesta — checkout creado (`200`) + +```json +{ + "success": true, + "data": { + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "appKey": "fyso_teams", + "provider": "paddle", + "transactionId": "txn_01h...", + "amountUsd": 15, + "billingInterval": "monthly", + "isReactivation": false + } +} +``` + +`isReactivation = true` cuando ya existe entitlement en estado `suspended` o `refunded`. +Úsalo para diferenciar el copy del CTA ("Reactivar Fyso Teams" vs "Activar Fyso Teams"). + +**Metadata Paddle enviada en el checkout:** + +```json +{ + "scope": "app_entitlement", + "admin_id": "...", + "org_id": "...", + "source_tenant_id": "65422493-73d8-4e74-a879-defa3a9771f1", + "app_key": "fyso_teams" +} +``` + +El campo `scope = 'app_entitlement'` es lo que el webhook handler usa para enrutar +el evento al servicio correcto. + +--- + +### `POST /api/auth/tenants` — enforcement (modificado) + +Cuando el body incluye `mode: 'instance'` y un `source_tenant_id` de app paid, +el endpoint valida el entitlement antes de crear la instancia. + +**Body:** + +```json +{ + "mode": "instance", + "source_tenant_id": "65422493-73d8-4e74-a879-defa3a9771f1", + "name": "Mi workspace" +} +``` + +#### `402` — suscripción requerida + +```json +{ + "success": false, + "error": "This app requires an active subscription.", + "code": "APP_SUBSCRIPTION_REQUIRED", + "appKey": "fyso_teams", + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "provider": "paddle", + "amountUsd": 15, + "billingInterval": "monthly", + "checkoutUrl": "/api/auth/app-entitlements/65422493-73d8-4e74-a879-defa3a9771f1/checkout" +} +``` + +#### `409` — cuota agotada + +```json +{ + "success": false, + "error": "Instance quota reached for this app.", + "code": "APP_INSTANCE_QUOTA_REACHED", + "appKey": "fyso_teams", + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "instancesUsed": 1, + "instancesAllowed": 1 +} +``` + +**Regla:** nunca omitir el POST basándose en `canCreateInstance`. Siempre intentar la creación; +si llega `402`, redirigir al checkout. El servidor es la única fuente de verdad y evita +race conditions. + +--- + +## Catálogo de error codes + +| Code | HTTP | Endpoint(s) | Significado | +|------|------|-------------|-------------| +| `UNAUTHORIZED` | 401 | todos | Token faltante o inválido | +| `ORG_REQUIRED` | 400 | GET, POST checkout | Admin con múltiples orgs sin especificar `orgId` | +| `APP_NOT_FOUND` | 404 | GET, POST checkout | `sourceTenantId` no existe en `app_catalog` | +| `APP_NOT_PAID` | 400 | POST checkout | App no requiere pago (no tiene `required_plan = 'paid'`) | +| `APP_NOT_CONFIGURED` | 500 | POST checkout | App existe en catálogo pero le falta `paddle_price_id` — gap operativo, no error del usuario | +| `ENTITLEMENT_ALREADY_ACTIVE` | 409 | POST checkout | La org ya tiene suscripción activa; redirigir a `/apps/fyso-teams` | +| `APP_SUBSCRIPTION_REQUIRED` | 402 | POST tenants | Bloqueo de creación de instancia; redirigir al checkout | +| `APP_INSTANCE_QUOTA_REACHED` | 409 | POST tenants | Tiene entitlement activo pero ya usó las instancias permitidas | + +Frontend debe parsear `code`, no `error`. El texto de `error` puede cambiar; el code es contrato. + +--- + +## Webhook Paddle — routing y eventos + +### Enrutamiento en `routes/billing.ts` + +`POST /api/webhooks/paddle` es el endpoint existente de webhooks Paddle. +Wave 2 lo extendió sin crear un nuevo endpoint: + +1. Verifica firma HMAC-SHA256 (reusa `billingService.constructWebhookEvent`). +2. Parsea el body y lee `data.custom_data.scope`. +3. Si `scope === 'app_entitlement'` → delega a `appEntitlementsService.handlePaddleEvent(rawEvent)`. +4. Cualquier otro evento → handler de billing existente sin cambios. + +Post-verificación HMAC, el endpoint **siempre responde `200 OK`** — nunca 5xx por errores de negocio. +Los errores se loguean; los reintentos de Paddle no solucionan bugs del backend. + +### Eventos manejados + +| Evento Paddle | Transición / acción | +|---------------|---------------------| +| `subscription.created` | Upsert entitlement → `status = 'active'`, setea `current_period_end` | +| `subscription.updated` | Actualiza `current_period_end`, `cancel_at_period_end` si cambió | +| `subscription.canceled` | Si `cancel_at_period_end = true` → `status = 'active_until_period_end'` (el cron T1 lo completará). Si `cancel_at_period_end = false` → `status = 'suspended'` + `setInstancesActive(false)` inmediato | +| `transaction.completed` | Bumpa `current_period_end`; si venía de `past_due` → `status = 'active'` | +| `adjustment.created` | `status = 'refunded'` + `setInstancesActive(false)` inmediato | + +### Idempotencia + +``` +INSERT INTO paddle_webhook_events (...) ON CONFLICT (event_id) DO NOTHING +→ 1 fila insertada: procesar +→ 0 filas insertadas: ya procesado, responder 200 +``` + +--- + +## Máquina de estados D4 (Fase 1) + +``` + none + │ + ▼ subscription.created / transaction.completed + ┌──────┐ + │active│◄────────────────────────────────────────────┐ + └──┬───┘ │ + │ subscription.canceled (cancel_at_period_end=true)│ transaction.completed + ▼ │ (reactivación) + ┌──────────────────────┐ │ + │active_until_period_end│ │ + └──────────┬────────────┘ │ + │ cron T1: current_period_end < NOW() │ + ▼ │ + ┌─────────┐ │ + │past_due │─────────────────────────────────────────┘ + └────┬────┘ + │ cron T2: past_due_since + 30d < NOW() + ▼ + ┌─────────┐ + │suspended│ + └────┬────┘ + │ reactivación (nuevo checkout) + └──────────────────────────────► active + + adjustment.created (refund/chargeback) desde cualquier estado: + ┌─────────┐ + │refunded │ + └─────────┘ +``` + +**Cancelación directa** (`cancel_at_period_end = false`): va directo a `suspended`, omitiendo +el período de gracia `active_until_period_end`. + +**Relación con `tenants.is_active`:** `past_due` y `suspended` ambos setean `tenants.is_active = false` +(mecanismo existente de bloqueo de instancias). La diferencia visible está en el entitlement: +`past_due` permite reactivación con un click (renovar pago vía Paddle); `suspended` requiere +un nuevo checkout. Esta es la Fase 1; la Fase 2 introducirá read-only granular para `past_due`. + +--- + +## Cron — transiciones in-process + +`startEntitlementTransitionsCron()` corre en `packages/api/src/index.ts` con intervalo de **1 hora**. + +**Transición T1 — `active_until_period_end` → `past_due`:** +- Condición: `status = 'active_until_period_end' AND cancel_at_period_end = true AND current_period_end < NOW()` +- Acción: `status = 'past_due'`, `past_due_since = NOW()`, `setInstancesActive(false)` + +**Transición T2 — `past_due` → `suspended`:** +- Condición: `status = 'past_due' AND past_due_since < NOW() - INTERVAL '30 days'` +- Acción: `status = 'suspended'`, `suspended_at = NOW()`, `setInstancesActive(false)` (idempotente) + +El cron es idempotente y tiene aislamiento de errores por fila: una fila que falla no aborta el batch. + +--- + +## Integración frontend (Wave 3) + +### Ruta `/apps/fyso-teams` + +La página tiene 5 estados excluyentes (`PageState`): + +| Estado | Cuándo se muestra | +|--------|------------------| +| `paywall` | `canCreateInstance = false` y sin suscripción activa | +| `polling` | Post-checkout, esperando webhook | +| `create` | `canCreateInstance = true` | +| `quota_reached` | `canCreateInstance = false` con suscripción activa (cuota agotada) | +| `error` | Error al cargar el entitlement | + +### Banners de estado + +| Condición | Banner | +|-----------|--------| +| `status = 'active_until_period_end'` | "Tu suscripción termina el {currentPeriodEnd}" | +| `status = 'past_due'` | "Pago pendiente. Reactivá antes de {pastDueSince + 30d}" | +| `status = 'suspended'` | "Tu suscripción está suspendida" + CTA "Reactivar suscripción" | + +### Paddle.js — checkout overlay + +Paddle.js se inicializa una vez en mount con `Initialize({ token })`. +El `eventCallback` se pasa **por checkout** en `Checkout.open`, no en `Initialize`: + +```ts +import { openPaddleCheckoutWithCallback } from '@/lib/paddle'; + +openPaddleCheckoutWithCallback(transactionId, (event) => { + if (event.name === 'checkout.completed') { + startPolling(); + } +}); +``` + +Este patrón evita re-inicializar Paddle en cada checkout (bug que impedía que +`checkout.completed` disparara). + +### Polling post-checkout + +```ts +const POLLING_INTERVAL_MS = 2_000; // cada 2s +const POLLING_TIMEOUT_MS = 60_000; // máximo 60s + +// Si timeout: mostrar SlowConfirmModal +// "Estamos confirmando tu pago. Recibirás un email cuando tu suscripción esté activa." +``` + +### Redirecciones de `402` / `409` + +`POST /api/auth/tenants` que devuelve `402 APP_SUBSCRIPTION_REQUIRED` hace que la página +recargue el entitlement y vuelva al estado `paywall`. + +`409 APP_INSTANCE_QUOTA_REACHED` pasa el estado a `quota_reached`. + +### i18n + +- Namespace: `fysoteams` en `packages/web/messages/en.json` y `es.json` +- Clave nueva transversal: `common.retry` agregada en ambos archivos + +--- + +## Flujo completo — compra inicial + +``` +1. Admin entra a /apps/fyso-teams + → GET /api/auth/app-entitlements/65422493-... → active=false + → pageState = 'paywall' + +2. Click "Activar Fyso Teams" + → POST /api/auth/app-entitlements/65422493-.../checkout + → recibe transactionId + +3. Paddle.Checkout.open({ transactionId, eventCallback }) + → usuario paga en overlay Paddle + +4. eventCallback({ name: 'checkout.completed' }) + → pageState = 'polling' + → polling cada 2s de GET /api/auth/app-entitlements/65422493-... + +5. Webhook subscription.created llega al backend + → entitlement creado con status='active' + +6. Polling detecta active=true + → pageState = 'create' + +7. Admin crea instancia + → POST /api/auth/tenants { mode: 'instance', source_tenant_id: '65422493-...', name: '...' } + → 201 → redirige a /dashboard +``` + +--- + +## Compatibilidad + +- `POST /api/auth/register` y Google login: sin cambios. +- Apps con `required_plan != 'paid'`: no afectadas por el enforcement. +- Apps gratuitas existentes: ningún cambio en comportamiento. diff --git a/site/docs/billing/app-entitlements.md b/site/docs/billing/app-entitlements.md new file mode 100644 index 0000000..5eaafff --- /dev/null +++ b/site/docs/billing/app-entitlements.md @@ -0,0 +1,419 @@ +--- +title: App Entitlements (Paddle) +sidebar_position: 5 +--- + +# App Entitlements — Fyso Teams + +Fyso supports paid distributed apps via a per-org entitlement system backed by Paddle. +**Fyso Teams** (`sourceTenantId = 65422493-73d8-4e74-a879-defa3a9771f1`) is the first +app to use this system: creating an instance requires an active monthly subscription +(USD 15/mo) purchased through a Paddle overlay before the instance can be created. + +The system is generic — any app in `app_catalog` can adopt `required_plan = 'paid'`. + +--- + +## Quick reference + +| | | +|--|--| +| Source tenant ID | `65422493-73d8-4e74-a879-defa3a9771f1` | +| App key | `fyso_teams` | +| Provider | Paddle | +| Price | USD 15 / month | +| Auth | `Authorization: Bearer ` | +| Base URL | `https://api.fyso.dev` | + +--- + +## Schema + +### `app_catalog` — new columns + +Eight columns added to the existing table (migration 0085). Apps already in the catalog +default to `required_plan = 'free'` with no behavior change. + +| Column | Type | Description | +|--------|------|-------------| +| `source_tenant_id` | `UUID UNIQUE` | Canonical app identity. FK for `app_entitlements`. | +| `required_plan` | `TEXT` | `'paid' \| 'trial' \| 'free' \| 'freemium'` (default `'free'`) | +| `trial_config` | `JSONB` | `{ days, requireCardOnFile }` | +| `provider` | `TEXT` | `'paddle'` or null | +| `paddle_price_id` | `TEXT` | Paddle price ID (`pri_...`). Required when `provider = 'paddle'`. | +| `allow_free_instances` | `BOOLEAN` | If `false`, blocks instance creation without active entitlement. Default `true`. | +| `max_instances_per_entitlement` | `INTEGER` | Instance quota per entitlement. `null` = unlimited. | +| `display_price` | `JSONB` | `{ "amountUsd": 15, "billingInterval": "monthly" }` — local cache for fast `402` responses. | + +`display_price` is **not** the billing source of truth. Paddle is. Edit `display_price` manually when pricing changes. + +### `app_entitlements` — new table + +Subscription state per `(org_id, source_tenant_id)` pair (migration 0086). + +One entitlement per org per app — enforced by `UNIQUE (org_id, source_tenant_id)`. + +#### Status enum + +| Status | Access | Description | +|--------|--------|-------------| +| `active` | Full | Subscription current | +| `active_until_period_end` | Full | User cancelled; access until `current_period_end` | +| `past_due` | Blocked | Period expired without renewal; instances set to `is_active = false` | +| `suspended` | Blocked | 30 days in `past_due`; data preserved 90 days | +| `refunded` | Blocked | Chargeback or Paddle refund; blocked immediately | + +### `paddle_webhook_events` — new table + +Idempotency and audit log for inbound Paddle events (migration 0087). + +Keyed by `event_id` (Paddle's own ID). Pattern: + +```sql +INSERT INTO paddle_webhook_events (...) ON CONFLICT (event_id) DO NOTHING; +-- 1 row inserted → process event +-- 0 rows inserted → already processed, return 200 without reprocessing +``` + +Paddle retries up to 5 times. This table ensures each event is processed exactly once. + +### Why `__extra_migrations`? + +Migrations 0085–0087 are tracked via the `__extra_migrations` system, not the Drizzle journal. +They extend tables already known to Drizzle; `__extra_migrations` applies raw SQL files +that are not in the Drizzle journal, complementing it without conflict. + +--- + +## Seed + +`seed-fyso-teams-app-catalog.ts` inserts (or updates) the Fyso Teams row in `app_catalog`. +Idempotent: uses `ON CONFLICT (source_tenant_id) DO UPDATE`. + +| Constant | Value | +|----------|-------| +| `FYSO_TEAMS_SOURCE_ID` | `65422493-73d8-4e74-a879-defa3a9771f1` | +| `required_plan` | `'paid'` | +| `allow_free_instances` | `false` | +| `max_instances_per_entitlement` | `1` | +| `display_price` | `{ "amountUsd": 15, "billingInterval": "monthly" }` | +| `paddle_price_id` | From `PADDLE_FYSO_TEAMS_PRICE_ID` env var | + +```bash +# Run manually (or via seed-app-catalog.ts runner on bootstrap): +npx tsx packages/db/migrations/seed-fyso-teams-app-catalog.ts +``` + +--- + +## Environment variables + +```dotenv +# Paddle price ID for Fyso Teams (Products → Prices in the Paddle dashboard). +# Staging and production use separate Paddle environments but share the same UUID. +# MUST be set before the first production checkout. +PADDLE_FYSO_TEAMS_PRICE_ID=pri_TBD_REPLACE_BEFORE_PROD + +# Canonical source tenant UUID for Fyso Teams in app_catalog. +FYSO_TEAMS_SOURCE_TENANT_ID=65422493-73d8-4e74-a879-defa3a9771f1 +``` + +If `PADDLE_FYSO_TEAMS_PRICE_ID` holds the placeholder value at startup, the process emits +a warning but does not fail. Checkouts will fail at runtime until the real ID is configured. + +--- + +## Auth and org resolution + +All `/api/auth/app-entitlements/*` endpoints require `Authorization: Bearer `. + +Org resolution for admins with multiple orgs: + +1. Pass `?orgId=` (GET) or `body.orgId` (POST). +2. If the admin has only one org, it is used automatically. +3. Multiple orgs + no `orgId` → `400 ORG_REQUIRED`. + +--- + +## Endpoints + +### `GET /api/auth/app-entitlements/:sourceTenantId` + +Returns the entitlement state for the calling admin's org. + +**Key rule:** always read `canCreateInstance`, not `active`. It consolidates status, +quota, and all access logic. Never branch on `active` alone. + +**200 — active subscription:** + +```json +{ + "success": true, + "data": { + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "appKey": "fyso_teams", + "active": true, + "canCreateInstance": true, + "provider": "paddle", + "status": "active", + "currentPeriodEnd": "2026-05-25T00:00:00.000Z", + "cancelAtPeriodEnd": false, + "instancesUsed": 0, + "instancesAllowed": 1 + } +} +``` + +**200 — cancelled but still valid (`active_until_period_end`):** + +Same shape with `status: "active_until_period_end"`, `cancelAtPeriodEnd: true`, +`active: true`, `canCreateInstance: true`. + +**200 — past due:** + +`status: "past_due"`, `active: false`, `canCreateInstance: false`. +Includes `pastDueSince` field. Frontend deadline = `pastDueSince + 30 days`. + +**200 — no entitlement:** + +`status: "none"`, `active: false`, `canCreateInstance: false`. + +--- + +### `POST /api/auth/app-entitlements/:sourceTenantId/checkout` + +Creates a Paddle checkout session for a new subscription or reactivation. + +**Body:** `{ "orgId": "" }` (optional if single org) + +**200 — checkout created:** + +```json +{ + "success": true, + "data": { + "transactionId": "txn_01h...", + "provider": "paddle", + "amountUsd": 15, + "billingInterval": "monthly", + "isReactivation": false + } +} +``` + +`isReactivation: true` when existing entitlement is in `suspended` or `refunded` state. +Use it to show "Reactivate Fyso Teams" vs "Activate Fyso Teams". + +Pass `transactionId` to `Paddle.Checkout.open` — do not open a new Paddle price session. + +--- + +### `POST /api/auth/tenants` — enforcement + +When `mode = 'instance'` and the source app has `required_plan = 'paid'`, +the backend validates the entitlement before creating the instance. + +**402 — subscription required:** + +```json +{ + "success": false, + "code": "APP_SUBSCRIPTION_REQUIRED", + "appKey": "fyso_teams", + "sourceTenantId": "65422493-73d8-4e74-a879-defa3a9771f1", + "provider": "paddle", + "amountUsd": 15, + "billingInterval": "monthly", + "checkoutUrl": "/api/auth/app-entitlements/65422493-73d8-4e74-a879-defa3a9771f1/checkout" +} +``` + +**409 — quota reached:** + +```json +{ + "success": false, + "code": "APP_INSTANCE_QUOTA_REACHED", + "instancesUsed": 1, + "instancesAllowed": 1 +} +``` + +**Rule:** always attempt `POST /tenants`; never skip it based on a prior `canCreateInstance` check. +The server is the single source of truth and avoids race conditions. + +--- + +## Error codes + +Parse `code`, not `error` (text may change between releases; codes are the contract). + +| Code | HTTP | Endpoint | Meaning | +|------|------|----------|---------| +| `UNAUTHORIZED` | 401 | all | Missing or invalid token | +| `ORG_REQUIRED` | 400 | GET, POST checkout | Admin has multiple orgs but did not specify `orgId` | +| `APP_NOT_FOUND` | 404 | GET, POST checkout | `sourceTenantId` not in `app_catalog` | +| `APP_NOT_PAID` | 400 | POST checkout | App does not require payment | +| `APP_NOT_CONFIGURED` | 500 | POST checkout | App exists in catalog but `paddle_price_id` is missing — ops gap, not a user error. Check `PADDLE_FYSO_TEAMS_PRICE_ID`. | +| `ENTITLEMENT_ALREADY_ACTIVE` | 409 | POST checkout | Org already has an active subscription; redirect to `/apps/fyso-teams` | +| `APP_SUBSCRIPTION_REQUIRED` | 402 | POST tenants | Instance creation blocked; redirect to checkout | +| `APP_INSTANCE_QUOTA_REACHED` | 409 | POST tenants | Has active entitlement but already at instance limit | + +--- + +## Paddle webhook routing + +`POST /api/webhooks/paddle` (the existing endpoint) was extended in Wave 2. +No new endpoint was created. + +After HMAC-SHA256 verification: + +1. Read `data.custom_data.scope` from the raw JSON body. +2. If `scope === 'app_entitlement'` → delegate to `appEntitlementsService.handlePaddleEvent()`. +3. All other events → existing billing handler, unchanged. + +After HMAC validates, the endpoint **always returns `200 OK`** — never 5xx on business errors. +Errors are logged; Paddle retries do not resolve backend bugs. + +### Events handled + +| Paddle event | Transition / action | +|---|---| +| `subscription.created` | Upsert entitlement → `status = 'active'`, set `current_period_end` | +| `subscription.updated` | Update `current_period_end`, `cancel_at_period_end` if changed | +| `subscription.canceled` | `cancel_at_period_end = true` → `status = 'active_until_period_end'`; `cancel_at_period_end = false` → `status = 'suspended'` + block instances immediately | +| `transaction.completed` | Bump `current_period_end`; if from `past_due` → `status = 'active'` | +| `adjustment.created` | `status = 'refunded'` + block instances immediately | + +--- + +## State machine + +``` + ┌────────────────────────────────────────────────┐ + ▼ │ transaction.completed + ┌──────┐ │ (reactivation) + │active│ │ + └──┬───┘ │ + │ subscription.canceled │ + │ (cancel_at_period_end=true) │ + ▼ │ + ┌───────────────────────┐ │ + │ active_until_period_end│ │ + └──────────┬────────────┘ │ + │ cron T1: current_period_end < NOW() │ + ▼ │ + ┌─────────┐ │ + │past_due │─────────────────────────────────────┘ + └────┬────┘ + │ cron T2: past_due_since + 30d < NOW() + ▼ + ┌─────────┐ + │suspended│─────── new checkout ──────► active + └─────────┘ + + adjustment.created (any state) ──────► refunded + subscription.canceled (cancel_at_period_end=false) ──► suspended (direct) +``` + +`past_due` and `suspended` both set `tenants.is_active = false`, using the existing +instance-blocking mechanism. Phase 2 (future) will introduce granular read-only mode for `past_due`. + +--- + +## Cron — in-process state transitions + +`startEntitlementTransitionsCron()` runs in `index.ts` at a **1-hour interval**. + +**T1 — `active_until_period_end` → `past_due`:** +condition: `cancel_at_period_end = true AND current_period_end < NOW()` + +**T2 — `past_due` → `suspended`:** +condition: `past_due_since < NOW() - INTERVAL '30 days'` + +Both transitions are idempotent with per-row error isolation. + +--- + +## Frontend integration + +### Route `/apps/fyso-teams` + +Five exclusive page states: + +| State | When | +|-------|------| +| `paywall` | `canCreateInstance = false`, no active subscription | +| `polling` | Post-checkout, waiting for webhook | +| `create` | `canCreateInstance = true` | +| `quota_reached` | `canCreateInstance = false` with active subscription (quota exhausted) | +| `error` | Failed to load entitlement | + +### Status banners + +| Condition | Banner | +|-----------|--------| +| `status = 'active_until_period_end'` | "Your subscription ends on {currentPeriodEnd}" | +| `status = 'past_due'` | "Payment pending. Reactivate before {pastDueSince + 30d}" | +| `status = 'suspended'` | "Your subscription is suspended" + "Reactivate subscription" CTA | + +### Paddle.js checkout overlay + +Initialize once on mount (no `eventCallback` in `Initialize`). +Pass `eventCallback` per-checkout via `Checkout.open`: + +```ts +Paddle.Checkout.open({ + transactionId: response.data.transactionId, + eventCallback: (event) => { + if (event.name === 'checkout.completed') { + startPolling(); + } + } +}); +``` + +Passing `eventCallback` in `Initialize` instead of `Checkout.open` is a known bug +that prevents `checkout.completed` from firing. + +### Post-checkout polling + +``` +interval: 2 s +timeout: 60 s +on timeout: show SlowConfirmModal + → "We are confirming your payment. You will receive an email when your subscription is active." +``` + +### i18n + +Namespace `fysoteams` in `packages/web/messages/en.json` and `es.json`. +Cross-namespace key `common.retry` added in both files. + +--- + +## Full purchase flow + +``` +1. Admin opens /apps/fyso-teams + GET /api/auth/app-entitlements/:id → active=false → pageState='paywall' + +2. Click "Activate Fyso Teams" + POST /api/auth/app-entitlements/:id/checkout → { transactionId } + +3. Paddle.Checkout.open({ transactionId, eventCallback }) + User completes payment in Paddle overlay + +4. eventCallback({ name: 'checkout.completed' }) fires + → pageState='polling' + → poll GET /api/auth/app-entitlements/:id every 2s + +5. Paddle sends subscription.created webhook + → entitlement created with status='active' + +6. Polling detects active=true → pageState='create' + +7. Admin creates instance + POST /api/auth/tenants { mode:'instance', source_tenant_id:'65422493-...', name:'...' } + → 201 → redirect to /dashboard +``` diff --git a/site/docs/changelog.md b/site/docs/changelog.md index 73aba48..32b82ae 100644 --- a/site/docs/changelog.md +++ b/site/docs/changelog.md @@ -1,3 +1,53 @@ +## v1.43.0 — 2026-04-25 + +### Feature — Fyso Teams paid-only entitlement (Paddle) + +Introduces a subscription-gated app entitlement system. Fyso Teams now requires +an active Paddle monthly subscription (USD 15/mo) before an org can create an instance. +The system is generic: any distributed app can opt into `required_plan = 'paid'`. + +**Schema (migrations 0085–0087):** + +- `app_catalog` extended with 8 new columns: `source_tenant_id`, `required_plan`, `trial_config`, `provider`, `paddle_price_id`, `allow_free_instances`, `max_instances_per_entitlement`, `display_price`. Existing apps default to `required_plan = 'free'` — no behavior change. +- New table `app_entitlements` — subscription state per `(org_id, source_tenant_id)` with status enum `active | active_until_period_end | past_due | suspended | refunded`. +- New table `paddle_webhook_events` — idempotency table keyed by Paddle `event_id` (`ON CONFLICT DO NOTHING`). Generic; reusable by any future Paddle subscription. + +**New endpoints:** + +- `GET /api/auth/app-entitlements/:sourceTenantId` — returns entitlement state including `canCreateInstance` (the consolidated access flag). +- `POST /api/auth/app-entitlements/:sourceTenantId/checkout` — creates a Paddle checkout session; supports new subscription and reactivation. + +**Modified endpoint:** + +- `POST /api/auth/tenants` — now enforces paid entitlement for paid apps before creating an instance. Returns `402 APP_SUBSCRIPTION_REQUIRED` or `409 APP_INSTANCE_QUOTA_REACHED` as applicable. + +**Webhook routing:** + +- `POST /api/webhooks/paddle` extended: `custom_data.scope = 'app_entitlement'` routes to the new entitlement handler. All other events continue to the existing billing handler. +- Handles: `subscription.created`, `subscription.updated`, `subscription.canceled`, `transaction.completed`, `adjustment.created`. + +**State machine cron:** + +- In-process `startEntitlementTransitionsCron()` runs every 1 hour. +- T1: `active_until_period_end` → `past_due` when `current_period_end` passes. +- T2: `past_due` → `suspended` after 30 days. Both transitions set `tenants.is_active = false`. + +**Frontend (Wave 3):** + +- New route `/apps/fyso-teams` with states: `paywall`, `polling`, `create`, `quota_reached`, `error`. +- Status-aware banners for `active_until_period_end`, `past_due`, and `suspended`. +- Paddle.js overlay checkout with `eventCallback` per `Checkout.open` call. +- Post-checkout polling: 2 s interval, 60 s timeout, `SlowConfirmModal` on timeout. +- i18n namespace `fysoteams`; `common.retry` added to `en.json` and `es.json`. + +**New error codes:** `APP_SUBSCRIPTION_REQUIRED` (402), `APP_INSTANCE_QUOTA_REACHED` (409), `APP_NOT_CONFIGURED` (500), `APP_NOT_PAID` (400), `ENTITLEMENT_ALREADY_ACTIVE` (409). + +**New env vars:** `PADDLE_FYSO_TEAMS_PRICE_ID`, `FYSO_TEAMS_SOURCE_TENANT_ID`. + +See [App Entitlements (Paddle)](/docs/billing/app-entitlements) for the full reference. + +--- + ## v1.41.0 — 2026-04-03 ### Features — Admin UX and Scheduling From de43855f6c303a49cb253b43f74b6fe088977e01 Mon Sep 17 00:00:00 2001 From: cero-fyso Date: Sat, 25 Apr 2026 18:09:51 -0300 Subject: [PATCH 2/2] fix: escape MDX curly braces in status banner table Replace {currentPeriodEnd} and {pastDueSince + 30d} with backtick inline code to avoid acorn parse errors in Docusaurus MDX compiler. Co-Authored-By: Claude Sonnet 4.6 --- site/docs/billing/app-entitlements.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/docs/billing/app-entitlements.md b/site/docs/billing/app-entitlements.md index 5eaafff..bc408d2 100644 --- a/site/docs/billing/app-entitlements.md +++ b/site/docs/billing/app-entitlements.md @@ -353,8 +353,8 @@ Five exclusive page states: | Condition | Banner | |-----------|--------| -| `status = 'active_until_period_end'` | "Your subscription ends on {currentPeriodEnd}" | -| `status = 'past_due'` | "Payment pending. Reactivate before {pastDueSince + 30d}" | +| `status = 'active_until_period_end'` | "Your subscription ends on `currentPeriodEnd`" | +| `status = 'past_due'` | "Payment pending. Reactivate before `pastDueSince + 30d`" | | `status = 'suspended'` | "Your subscription is suspended" + "Reactivate subscription" CTA | ### Paddle.js checkout overlay