# Subscription / Add-ons / Extensions — Audit Addendum

Continuation of [PLATFORM_COMMERCIAL_ACCOUNTING_STABILIZATION](./PLATFORM_COMMERCIAL_ACCOUNTING_STABILIZATION.md). Read-only audit of the subscription/billing stack before any code lands.

**Branch:** `platform-commercial-accounting-stabilization` (same branch, additional phases X.1–X.6)
**Date:** 2026-05-11

---

## What already exists

| Surface | Where | Status |
|---|---|---|
| Plan model | `SubscriptionPlan` (15 columns inc. `price_monthly`, `price_yearly`, `branches`, `users`, `devices`, `modules[]`, `premium_features[]`, `addons[]` JSON) | ✓ |
| Tenant subscription (type-2 SCD) | `TenantSubscription` (effective_from / effective_to, per-row plan snapshot, status, period, change_reason) | ✓ |
| Plan-change service | `SubscriptionService::assignPlan(companyId, planId, opts)` — closes the open row, inserts a new open row with limits/features/addons frozen. | ✓ |
| Plan-change endpoint | `POST /platform/tenants/{id}/plan` — accepts `plan`, `billing_cycle`, `effective_from`, `change_reason`, `note`. Audited. | ✓ |
| Invoice lifecycle | `BillingService::createInvoice/issue/cancel/refund/recordPayment` — single-line invoices (subtotal + VAT + total). State machine: draft → issued → paid → refunded / cancelled. | ✓ |
| Invoice ↔ subscription link | `tenant_invoices.tenant_subscription_id` + `subscription_snapshot` JSON. | ✓ |
| Platform accounting posting | `PlatformAccountingPoster::postInvoiceIssue / postPayment / postRefund / postWriteOff` — DR AR / CR revenue + VAT etc., idempotent. | ✓ |
| Subscription history endpoint | `GET /platform/tenants/{id}/subscription/history` | ✓ |
| Audit trail | `platform_audit_log` row on every plan change + invoice mutation + payment. | ✓ |

## What is missing (the actual gap)

| Capability | Current state | Gap |
|---|---|---|
| **Branch add-ons as a billing concept** | `subscription_plans.branches` is a flat plan-level limit. `tenant_subscriptions.addons_snapshot` is a JSON blob recorded at signup but never used by `BillingService::createInvoice` (which only reads `price_monthly_at_signup` / `price_yearly_at_signup`). | **NO** mid-cycle branch add-on charges; **NO** per-branch billing line; **NO** effective_from per add-on. |
| **Date-based proration** | None. Invoice subtotal = full plan price for the period regardless of when in the cycle a change happened. | **NO** proration math anywhere in BillingService / SubscriptionService. |
| **Invoice line items** | `tenant_invoices` is flat: subtotal, vat_rate, vat_amount, total. One number per concept. | **NO** way to show "Plan + 2 branch add-ons + promo discount" as separate lines on a single invoice. |
| **Subscription extensions** | Plan changes always cycle the SCD row. There is no concept of "extend the current period without changing the plan". | **NO** extension surface (merchant or platform). |
| **Scheduled plan changes** | `assignPlan(..., effective_from: future)` would close the current row at a future date, breaking the "exactly one open row" invariant during the gap. | **NO** safe scheduled-change path. Today's UI is immediate-only. |
| **Merchant-side billing-mutation requests** | Tenants have NO endpoint to request add-ons or extensions today. Owner-email flow is platform-side only. | **NO** request surface yet. |

## Stop-condition check

| Condition | Triggered? | Why |
|---|---|---|
| Existing CRM model conflicts with proposed lead model | No (resolved in prior phase) | — |
| Platform accounting requires a product decision | **Partially — flagged, not blocking.** | Two open product calls flagged below. |
| Payment/revenue recognition policy is unclear | **Partially — flagged.** | See "Open product decisions". |
| Change risks tenant accounting | No | All changes are additive to platform-side tables only. Tenant Accounting (Retail/Dine/etc.) is untouched. |
| Change weakens PermissionBoundary | No | All new endpoints are platform-staff gated; no new role/permission mutation surface. |
| Destructive migration required | No | Every migration is `CREATE TABLE` or `ADD COLUMN` with defaults. |

## Open product decisions (recorded, not assumed)

1. **Proration day-count convention.** Three valid options: actual-day-count over actual-days-in-period (default), actual-day-count over 30 (banker's), actual-day-count over period-length-in-days regardless of month length. **Decision adopted in this branch**: actual-day-count over `period_end - period_start + 1`. Symmetrically: a mid-cycle removal credits the unused days. Documented + tested.

2. **Merchant-side mutation policy.** The user explicitly said "if not supported, expose as 'request' not direct mutation". **Decision adopted**: merchants get a request-only surface (POST a request that lands in the new `subscription_change_requests` table with `status=pending`). Platform staff approve/reject it. No direct merchant mutation of plan/add-ons/extensions. (Out-of-scope for the schema delta this branch lands — flagged as next-round work; we'll wire only the platform-side mutations and the data model needed to support requests later.)

3. **Add-on price source.** Today there's no platform-managed addon catalogue. **Decision adopted**: branch add-ons are priced per-tenant at add time (operator enters the unit price). The historical `subscription_plans.addons` JSON is sales-side metadata only. A future addon catalogue with platform-managed pricing is a separate proposal — out of scope here.

4. **Scheduled plan change semantics.** Today `effective_from: future` would break the open-row invariant. **Decision adopted**: store `next_plan_*` columns on the current subscription; an artisan command applies them. The endpoint that schedules is new; the existing `POST /tenants/{id}/plan` keeps its immediate-write semantics. Tests only exercise the data persistence + apply command; production scheduling cron is wired but operator-driven for now.

None of these is a hard blocker; all are recorded so a future review can revisit policy without re-reading the code.

## Matrix per surface

| Surface | Backend exists | Frontend exists | Connected | Tests | Gap | Risk |
|---|---|---|---|---|---|---|
| Plan change (immediate) | `SubscriptionService::assignPlan` + `POST /tenants/{id}/plan` | TenantDetailDrawer plan change dialog | ✓ | Strong (`PlatformSubscriptionApiTest`, `TenantSubscriptionTest`) | — | L |
| Plan change (scheduled) | None | None | n/a | None | Net-new endpoint + 4 cols on tenant_subscriptions + artisan applier | M |
| Branch add-ons | None (addons_snapshot is unused metadata) | None | n/a | None | New table + service + 4 endpoints + invoice line-item integration | M |
| Mid-cycle proration | None | None | n/a | None | Pure math service + test matrix | L |
| Invoice line items | Flat invoice today | Flat invoice today | ✓ | Strong on flat | New `tenant_invoice_lines` table OR JSON `line_items` column. **Decision**: JSON column (smaller delta, FE adapter trivial). | L |
| Subscription extension (platform) | None | None | n/a | None | New endpoint + extension table + audit | M |
| Subscription extension (merchant request) | None | None | n/a | None | Out of scope this branch — flagged as next-round work | n/a |

---

## Phase X.1–X.6 scope (each lands as a separate commit)

| Phase | Scope |
|---|---|
| **X.1** BE | Branch add-ons: `subscription_addons` table + `SubscriptionAddonService` with proration math + 4 endpoints (list/create/end/remove). Invoice line-items as JSON column on `tenant_invoices` (`line_items` array). `BillingService::createInvoice` walks active add-ons inside the invoice period and adds lines. 6 endpoints' tests. |
| **X.2** BE | Subscription extension (platform-side): `tenant_subscription_extensions` audit table + `SubscriptionService::extend(subId, days, reason)` + `POST /tenants/{id}/subscription/extend`. Tenant boundary tests. Idempotency tests. |
| **X.3** BE | Scheduled plan change: 4 cols on `tenant_subscriptions` (next_plan_id / next_plan_effective_at / next_plan_billing_cycle / next_plan_reason). `POST /tenants/{id}/plan/schedule`. Artisan `subscriptions:apply-scheduled`. |
| **X.4** FE | TenantDetailDrawer Subscription tab gains: Add-ons section (add / end / list with proration preview), Extend button (with reason modal), Schedule plan change toggle in the existing Plan Change dialog, line items rendered on invoice detail. |
| **X.5** Tests | Full proration matrix, extension idempotency, scheduled-change apply, plan-change history preserved on each branch, invoice line item integrity, security boundary on every new endpoint. |
| **X.6** Doc | Append the subscription/addon/extension chapter to `PLATFORM_COMMERCIAL_ACCOUNTING_STABILIZATION.md`. |

### Out of scope (deliberately)

- Merchant-side request endpoints (deferred to next round — design recorded above).
- Platform-managed addon catalogue with central pricing (deferred — design recorded above).
- Promo-code / discount line items (deferred — invoice line-items column supports them when needed).
- Tax engine changes (KSA VAT stays at 15% from `BillingService::KSA_VAT_RATE`).

### Proration formula (adopted)

For a subscription period `[period_start, period_end]` and an add-on activated on `effective_from`:

```
days_in_period   = period_end - period_start + 1                  (inclusive)
days_active      = period_end - max(period_start, effective_from) + 1
prorated_amount  = round(unit_price * (days_active / days_in_period), 2)
```

Symmetric on removal: `effective_to` shortens `days_active`. Removal mid-cycle credits the unused days as a separate negative line on the next invoice (NOT a refund on a settled invoice — refunds are still operator-driven via `BillingService::refund`).

When `effective_from <= period_start`, prorated_amount == unit_price (full charge). When `effective_from > period_end`, the line isn't generated at all.

---

## Next: code lands phase by phase, each commit independently revertible.
