# Billing Lifecycle — BewPro 2.0

> Última actualización: 2026-04-30
> Estado: **PRODUCCIÓN**, validado end-to-end con OG Yastim test

Documentación canónica del ciclo completo de facturación: desde la compra
inicial hasta cobros recurrentes, fallos, suspensión automática del sitio y
reactivación. Cubre arquitectura, archivos clave, eventos, flujos de email y
runbooks operativos.

---

## 1. Arquitectura general

```
┌──────────────┐    Webhook    ┌──────────────┐
│    Stripe    │ ────────────▶ │   bewpro22   │  (la app principal — único receptor de webhooks)
└──────────────┘               └──────┬───────┘
                                       │
                ┌──────────────────────┼─────────────────────┐
                │                      │                     │
                ▼                      ▼                     ▼
          ┌──────────┐         ┌─────────────┐       ┌──────────────┐
          │ Airtable │         │   Email     │       │  Push HTTP   │
          │ Projects │         │ hola/noreply│       │  → tenant    │
          │   +      │         │  + Slack    │       │ /__internal__│
          │   Subs   │         └─────────────┘       │/billing-stat │
          └──────────┘                                └──────────────┘
                                                             │
                                                             ▼
                                                     ┌─────────────────┐
                                                     │  Tenant         │
                                                     │  settings.billing│
                                                     │  middleware 503 │
                                                     └─────────────────┘
```

**Principio**: bewpro22 es la única app que recibe webhooks Stripe. Distribuye
cambios de estado a tenants vía:
1. **Airtable** (source of truth global, también consultado por humanos).
2. **Push HTTP en tiempo real** al tenant (suspension/reactivación instantánea).
3. **Email** al cliente (hola@/noreply@).
4. **Slack** al equipo.

Cron de respaldo: `bewpro:check-billing` corre cada hora en cada tenant y
reconcilia desde Airtable si el push falló.

---

## 2. Archivos clave (codebase compartido)

### Webhook + servicios
- `app/Http/Controllers/StripeWebhookController.php` — eventos Stripe.
- `app/Services/AirtableService.php` — operaciones sobre Airtable.

### Email FROM split
- `config/mail.php` — mailers `hola` y `noreply` con transport `sendmail`.
- `app/Mail/OrderReceivedMail.php`, `SiteProvisionedMail.php`, `PaymentFailedMail.php`,
  `SubscriptionSuspendedMail.php`, `GracePeriodWarningMail.php`, `TrialExpiringMail.php`,
  `ResellFormSubmittedMail.php`, `ProjectProvisionedNotifyResellerMail.php`.
  Cada uno overridea `from()` en su `envelope()`.

### Suspension del sitio
- `app/Http/Controllers/InternalBillingController.php` — endpoint receptor.
- `app/Http/Middleware/InternalSecret.php` — autenticación X-Internal-Secret.
- `app/Http/Middleware/EnsureBillingActive.php` — bloquea frontend si suspended.
- `app/Console/Commands/CheckBillingStatus.php` — fallback hourly.
- `resources/views/errors/billing-suspended.blade.php` — página 503.
- `routes/web.php` — `Route::post('/__internal__/billing-status', ...)`.
- `app/Http/Middleware/VerifyCsrfToken.php` — exclusion `__internal__/*`.

### Comandos artisan
- `bewpro:check-billing` — sync hourly Airtable→settings (tenant).
- `bewpro:check-grace` — daily 09:00 (bewpro22 only) — período de gracia vencido.
- `bewpro:check-renewals` — daily 10:00 (bewpro22 only) — trials por vencer.
- `bewpro:reconcile-stripe` — daily 11:00 (bewpro22 only) — diff Stripe ↔ Airtable.
- `bewpro:backfill-stripe-subscriptions [--dry-run] [--limit=N]` — one-shot backfill.

### Scripts orquestador
- `infrastructure/scripts/orchestrator/process-airtable.sh` — provisión + upsert sub.
- `infrastructure/scripts/provisioner/setup_cd_project4.sh` — inyecta env vars a tenant.

---

## 3. Variables de entorno

### bewpro22 (.env)
Variables que sólo bewpro22 necesita:
```
STRIPE_KEY=...
STRIPE_SECRET=...
STRIPE_WEBHOOK_SECRET=...
BEWPRO_HEALTHCHECK_ENABLED=true   # activa scheduler centralizado
BILLING_PUSH_SECRET=<32 hex>      # shared con tenants
AIRTABLE_TOKEN=...
AIRTABLE_BASE_ID=appRxvpzqCmNsw2JN
AIRTABLE_SUBSCRIPTIONS_TABLE=tblzCgJZCbbt5j13Q
AIRTABLE_SUBSCRIPTIONS_TRACKING_TABLE=tblnpr52JhFBBi2Mg
MAIL_USERNAME_HOLA=hola@bewpro.com
MAIL_PASSWORD_HOLA=...
MAIL_USERNAME_NOREPLY=noreply@bewpro.com
MAIL_PASSWORD_NOREPLY=...
```

### Cada tenant (.env)
Inyectado por `setup_cd_project4.sh` al provisionar:
```
BILLING_PUSH_SECRET=<mismo que bewpro22>
AIRTABLE_TOKEN=...
AIRTABLE_BASE_ID=appRxvpzqCmNsw2JN
AIRTABLE_SUBSCRIPTIONS_TABLE=tblzCgJZCbbt5j13Q
AIRTABLE_SUBSCRIPTIONS_TRACKING_TABLE=tblnpr52JhFBBi2Mg
MAIL_USERNAME_HOLA=hola@bewpro.com
MAIL_PASSWORD_HOLA=...
MAIL_USERNAME_NOREPLY=noreply@bewpro.com
MAIL_PASSWORD_NOREPLY=...
MAIL_HOST=localhost
MAIL_PORT=25
MAIL_FROM_ADDRESS=noreply@bewpro.com
```

### VPS1 (`/root/scripts/.airtable.env`)
Source de verdad para credenciales de scripts orquestador y provisión:
```
AIRTABLE_TOKEN=...
AIRTABLE_BASE_ID=...
AIRTABLE_TABLE_ID=tblzCgJZCbbt5j13Q   # Projects
SMTP_HOST=127.0.0.1
SMTP_PORT=587
SMTP_USER=noreply@bewpro.com
SMTP_PASS=...                          # password cPanel
HOLA_MAIL_USERNAME=hola@bewpro.com
HOLA_MAIL_PASSWORD=...
NOREPLY_MAIL_USERNAME=noreply@bewpro.com
NOREPLY_MAIL_PASSWORD=...
BILLING_PUSH_SECRET=<32 hex>           # se inyecta a tenants nuevos
HOSTINGER_TOKEN=...
SLACK_WEBHOOK_URL=...
```

---

## 4. Webhook events de Stripe

Eventos suscriptos en el endpoint `/stripe/webhook` de bewpro22:

| Evento | Handler | Acciones |
|---|---|---|
| `checkout.session.completed` | `handleCheckoutSessionCompleted` | Crea Project (Required) + Subscription (Pending) en Airtable; email `hola@` "Recibimos tu pedido"; Slack `provisionCreated`. |
| `customer.subscription.created` | `handleCustomerSubscriptionCreated` | Patch Subscription con sub_id, price_id, current_period_end, status, trial_end. Si la sub no existe (race), la crea. |
| `customer.subscription.updated` | `handleCustomerSubscriptionUpdated` | Patch Status, Next_Payment_Date, Auto_Renew (cancel_at_period_end). |
| `invoice.payment_succeeded` | `handleInvoicePaymentSucceeded` | Patch Project Pipeline_Status=Active; Subscription Status=Active, Payment_Status=Paid, Failed_Attempts=0, Suspended_Date=null, Last/Next_Payment_Date. **Push tenant: status=active**. |
| `invoice.payment_failed` | `handleInvoicePaymentFailed` | Failed_Attempts++; Payment_Status=Past Due. Email `noreply@` "Acción requerida". A los 3 fallos: Status=Suspended, Project Pipeline=Paused, email "Sitio suspendido", **push tenant: status=suspended reason=unpaid**. |
| `customer.subscription.deleted` | `handleCustomerSubscriptionDeleted` | Project Pipeline=Archived; Subscription Status=Cancelled. **Push tenant: status=suspended reason=cancelled**. |

Idempotency: cache 24h por invoice_id evita doble procesamiento.

---

## 5. Flujo end-to-end: compra → vida útil → cancelación

### A. Onboarding
```
1. Cliente compra en bewpro.com/products/{slug} (Stripe Checkout)
2. checkout.session.completed → bewpro22:
   - Airtable Projects: nuevo record Pipeline_Status=Required
   - Airtable Subscriptions: nuevo record Status=Pending + Stripe_Subscription_ID
   - Email cliente FROM hola@bewpro.com — "Recibimos tu pedido"
   - Slack → #ventas
3. Cron VPS1 process-airtable.sh (cada minuto):
   - cuenta cPanel + DB + git clone + composer + Laravel + DNS + SSL + cron
   - bewpro:new (12 steps) → password admin random
   - Project: Pipeline=On Development, Server, Cpanel_User, DB, Password
   - Subscription: upsert Status=Active, Provisioned_At, App_URL (no duplica)
4. Email cliente FROM noreply@bewpro.com — "¡Tu sitio … está en camino!"
   con credenciales admin + URL panel
```

### B. Trial period (15 días)
```
- Sitio funcional. Cliente puede customizar contenido/branding.
- Día -7: bewpro:check-renewals envía aviso "trial expira en 7 días" desde noreply@
- Día -3: idem "3 días"
- Día -1: idem "último día"
- Día 0 (trial_end): Stripe automáticamente intenta primer cobro
```

### C. Vida útil — cobros mensuales
```
Día N: Stripe genera invoice + cobra automáticamente con default PM
  ├ Éxito → invoice.payment_succeeded
  │   • Subscription: Status=Active, Failed_Attempts=0, Last/Next_Payment_Date
  │   • Push tenant: active (limpia cualquier suspension previa)
  │   • Slack ✅
  └ Falla → invoice.payment_failed (Stripe smart retry: hasta 4× en 1 semana)
      • Failed_Attempts++, Payment_Status=Past Due
      • Email "Acción requerida" desde noreply@
      • A los 3 fallos:
          - Subscription Status=Suspended, Suspended_Date=hoy
          - Project Pipeline=Paused
          - Email "Sitio suspendido" desde noreply@
          - Push tenant: status=suspended reason=unpaid
          - Sitio devuelve HTTP 503 con vista billing-suspended.blade.php
```

### D. Reactivación
```
Cliente paga via Stripe Customer Portal o admin de su sitio:
  1. invoice.payment_succeeded
  2. Subscription: Status=Active, Failed_Attempts=0, Suspended_Date=null
  3. Project Pipeline=Active
  4. Push tenant: status=active
  5. Sitio HTTP 200
```

### E. Cancelación
```
Cliente cancela en Stripe Portal:
  1. customer.subscription.deleted
  2. Subscription Status=Cancelled
  3. Project Pipeline=Archived
  4. Push tenant: status=suspended reason=cancelled
  5. Sitio devuelve 503 "Suscripción cancelada — contactar BewPro"
```

### F. Suspensión manual (cuando Billing_Model=Manual o auto-suspend falla)

**Cuándo se usa**: clientes legacy con `Billing_Model=Manual` (cobro off-Stripe)
o cuando el auto-flow no funciona (ver post-mortem Gemafi 2026-05-19 abajo).

```bash
# 1. Update Airtable Project: Pipeline_Status=Paused
# 2. Update Airtable Subscription: Status=Suspended
# 3. SSH al tenant:
ssh vps1-claude "su -s /bin/bash <cpanel_user> -c \
  'cd /home/<cpanel_user>/public_html/git-files/<cpanel_user> && \
   php artisan tinker --execute=\"
     \App\Services\SiteConfigService::set(\"billing.status\", \"suspended\");
     \App\Services\SiteConfigService::set(\"billing.suspended_reason\", \"unpaid\");
     \App\Services\SiteConfigService::set(\"billing.suspended_at\", \"YYYY-MM-DD\");
   \"'"
# 4. Verificar: curl -sI https://<domain>/ | head -1  → debe responder 503
```

**Para reactivar (cuando paga)**:
```bash
\App\Services\SiteConfigService::set("billing.status", "active");
\App\Services\SiteConfigService::set("billing.suspended_reason", "");
\App\Services\SiteConfigService::set("billing.suspended_at", "");
```

---

## 5.bis Post-mortem Gemafi (2026-05-19)

**Síntoma**: tenant Gemafi (gemafinversiones.com) con 5+ intentos de cobro
fallidos pero el sitio seguía respondiendo HTTP 200 durante semanas.

**4 causas acumuladas** (cualquiera de ellas por separado ya rompía el flow):

| # | Causa | Detalle | Fix |
|---|---|---|---|
| 1 | **`Billing_Model = Manual`** | Sin sub recurrente Stripe → ningún `invoice.payment_failed` webhook → no escala a Pipeline=Paused. | Documentar política; idealmente migrar clientes legacy a sub recurrente. |
| 2 | **`Provisioned_DB` UNSET en Airtable** | Cron `check-billing` filtra por `{Provisioned_DB}=<dbname>` — sin valor, no encuentra el record. | Backfill: `bewpro:audit-reseller-project` lo setea automáticamente. |
| 3 | **Clone outdated en el tenant** | Comando `bewpro:check-billing` no existía en el código que tenía Gemafi (Feb 2026). | `git pull` en el tenant via `batch-tenant-pull.sh`. |
| 4 | **`AIRTABLE_TOKEN` ausente en `.env`** del tenant | `check-billing` falla con "Airtable not configured". | Backfill: agregar `AIRTABLE_TOKEN/BASE_ID/SUBSCRIPTIONS_TABLE` a `.env` de TODOS los tenants live. |

**Acción tomada**:
1. Linkeé `Stripe_Subscription_ID = sub_1TKnLgGVk9N2Uv66NywQpUda` en Notes del Project (no hay columna dedicada todavía — se sugiere crear).
2. Updateé Airtable: `Pipeline_Status=Paused`, `Status=Suspended`, `Provisioned_DB=gemafi_db`.
3. Git pull en `/home/gemafi/public_html/git-files/gemafi/` (resolviendo conflicto CSS demo con `--ours`).
4. SiteConfigService::set directo en tenant DB con `billing.suspended_at='2026-05-01'` (fecha real del primer fail).
5. Verificado: `curl https://gemafinversiones.com/` → HTTP 503, `/login` → 200.

**Gaps abiertos identificados**:
- Crear columna Airtable `Stripe_Subscription_ID` en tabla Subscriptions tracking.
- Cron `bewpro:check-manual-billing` que verifique `Next_Payment_Date < today - 15d` para `Billing_Model=Manual` → setear Paused automático.
- Backfill `Provisioned_DB` cross-tenant para todos los projects vivos.
- Backfill `AIRTABLE_TOKEN` en `.env` de todos los tenants.

---

## 6. Push HTTP `/__internal__/billing-status`

Endpoint privado en cada tenant para suspension/reactivación instantánea.

### Request
```http
POST /__internal__/billing-status HTTP/1.1
Host: og-yastim.bewpro.com
X-Internal-Secret: <BILLING_PUSH_SECRET>
Content-Type: application/json

{
  "status": "suspended",         // "suspended" | "active"
  "reason": "unpaid",            // "unpaid" | "cancelled" (solo si suspended)
  "suspended_at": "2026-04-30"   // YYYY-MM-DD (solo si suspended)
}
```

### Responses
- `200 {"ok": true, "status": "suspended"}` — escribió en `settings`, cache flushed.
- `403 {"error": "forbidden"}` — secret inválido o ausente.
- `422 {"error": "invalid_status"}` — status no es "active"/"suspended".
- `500` — error interno.

### Comportamiento del tenant
- Endpoint escribe `site.billing.status`, `site.billing.suspended_at`, `site.billing.suspended_reason` en tabla `settings`.
- `Cache::flush()` para que `EnsureBillingActive` lea el valor nuevo en la próxima request.

### Rutas permitidas en tenant suspendido
El middleware `EnsureBillingActive` deja pasar:
- `admin*`, `login`, `logout`, `register`, `password*`
- `metronic/*`, `storage/*`, `cd-project/*` (assets)
- `stripe/*`, `subscriptions/*` (portal de pago)
- `__internal__/*` (push billing-status, debe pasar siempre)
- `unsuspend*`, `health`

---

## 7. Scheduler centralizado vs per-tenant

`app/Console/Kernel.php`:

| Comando | Cadencia | Scope |
|---|---|---|
| `config:clear` | daily 00:00 | per-tenant (cache local) |
| `bewpro:check-billing` | hourly | **per-tenant** (cada uno verifica Airtable, escribe su settings local) |
| `bewpro:onboarding:cleanup-drafts` | daily 03:00 | **per-tenant** (lee tenant DB) |
| `bewpro:check-grace` | daily 09:00 | **bewpro22 only** (`->when(BEWPRO_HEALTHCHECK_ENABLED)`) |
| `bewpro:check-renewals` | daily 10:00 | bewpro22 only |
| `bewpro:reconcile-stripe` | daily 11:00 | bewpro22 only |
| `bewpro:healthcheck` | every 6h | bewpro22 only |

**Setup cron**: cada tenant tiene `* * * * * cd ... && php artisan schedule:run` registrado en su crontab cPanel desde el provisioning.

---

## 8. Runbooks

### Reset de suscripción suspendida (cliente pagó offline / soporte)
```bash
ssh vps1-claude
# 1. Cancelar todas las invoices pendientes en Stripe Dashboard, o:
#    POST /v1/invoices/{id}/void
# 2. Reactivar via API:
SECRET=$(grep '^BILLING_PUSH_SECRET=' /home/bewpro22/public_html/bewpro/.env | cut -d= -f2-)
curl -X POST https://{tenant}.bewpro.com/__internal__/billing-status \
  -H "X-Internal-Secret: $SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"status":"active"}'
# 3. Patch Airtable Project Pipeline_Status=Active, Subscription Status=Active
```

### Backfill Stripe IDs en histórico
```bash
ssh vps1-claude
su - bewpro22 -c 'cd public_html/bewpro && php artisan bewpro:backfill-stripe-subscriptions --dry-run'
# revisar output, después:
su - bewpro22 -c 'cd public_html/bewpro && php artisan bewpro:backfill-stripe-subscriptions'
```

### Detectar drift Stripe ↔ Airtable
```bash
ssh vps1-claude
su - bewpro22 -c 'cd public_html/bewpro && php artisan bewpro:reconcile-stripe --dry-run'
# revisar drift reportado, después correr sin --dry-run para corregir
```

### Forzar sync billing en tenant específico (sin esperar el cron horario)
```bash
ssh vps1-claude
su - {tenant} -c 'cd public_html/git-files/{tenant} && php artisan bewpro:check-billing'
```

### Borrar tenant + cancelar Stripe + cleanup Airtable
```bash
ssh vps1-claude
/root/scripts/delete-project.sh {cpanel_user} --skip-backup --yes
# Cancelar sub Stripe via API:
STRIPE_SECRET=$(grep '^STRIPE_SECRET=' /home/bewpro22/public_html/bewpro/.env | cut -d= -f2-)
curl -X DELETE https://api.stripe.com/v1/subscriptions/{sub_id} \
  -u $STRIPE_SECRET:
```

---

## 9. Pendientes / mejoras opcionales

| Severidad | Issue | Solución sugerida |
|---|---|---|
| baja | Tenants legacy sin AIRTABLE_TOKEN/BILLING_PUSH_SECRET en .env | Crear `migrate-tenant-billing-config.sh` análogo a `migrate-tenant-mail-config.sh`, idempotente, lee de `/root/scripts/.airtable.env`. |
| baja | Idempotency cache 24h muy largo bloquea retries reales de Stripe (smart retries hasta 4× en 7 días) | Bajar TTL a 1h o usar `(invoice_id, attempt_count)` como key. |
| baja | Validación post-provision (`/12]`) no verifica frontend renderizado | Agregar check de markup HTML mínimo (existe `<title>`, etc), pero mantener soft-fail. |
| baja | No hay metric / dashboard de salud del flujo billing | Sumar métricas Sentry o Slack daily summary del estado de subs. |

---

## 10. Comandos de troubleshooting

```bash
# Estado de un tenant
ssh vps1-claude "su - {tenant} -c 'cd public_html/git-files/{tenant} && \
  echo \"=== settings.billing ===\" && \
  mysql {db} -e \"SELECT \\\`key\\\`, value FROM settings WHERE \\\`key\\\` LIKE \\\"site.billing.%\\\"\" && \
  echo \"=== logs Laravel ===\" && \
  tail -50 storage/logs/laravel.log | grep -i billing'"

# Webhook Stripe — últimos eventos procesados
ssh vps1-claude "tail -100 /home/bewpro22/public_html/bewpro/storage/logs/laravel.log | grep 'Stripe Webhook'"

# Subscription en Airtable
ssh vps1-claude "set -a; source /root/scripts/.airtable.env; set +a; python3 -c '
import os, json, urllib.request
token=os.environ[\"AIRTABLE_TOKEN\"]; base=os.environ[\"AIRTABLE_BASE_ID\"]
url=f\"https://api.airtable.com/v0/{base}/tblnpr52JhFBBi2Mg?pageSize=10&filterByFormula=\"+__import__(\"urllib.parse\").parse.quote(\"{Stripe_Customer_ID}=\\\"cus_...\\\"\")
print(json.dumps(json.loads(urllib.request.urlopen(urllib.request.Request(url, headers={\"Authorization\":f\"Bearer {token}\"})).read()), indent=2))'"
```
