# TechTitans POS — IPTV Credit, Expiry & Payment Parity

**Date:** 2026-11-11 · **Branch:** `techtitans/integration` · Local commits only (no push/deploy). NH POS and the legacy PHP portal untouched (read-only inspection; no PHP executed). No real customer records modified; no live SMS/email or card charges.

Brings the new Laravel/React IPTV account page (`/iptv/:id`) to functional parity with the old TechTitans portal's account screen — Free Credit, Pay Credit, Pay Now/Later, Change Date By, Bring to Current Date, Change Monthly Charge — on the existing POS Order→Invoice→Payment architecture (no second IPTV ledger). Builds on the two-page workflow already delivered ([IPTV-TWO-PAGE-WORKFLOW-REPORT.md](IPTV-TWO-PAGE-WORKFLOW-REPORT.md)).

## 1. Legacy ⇄ new feature comparison

| Old portal control | Confirmed legacy meaning (read-only) | New account page | Status |
|---|---|---|---|
| **Payment Finished Date** | `iptv_details.payment_finished_date` — the service-expiry date; service ends here. | `iptv_subscriptions.expiry_date`, shown as **Service expiry** (summary card + Service & Expiry). | ✅ parity |
| **Free Credit** (0–12) | Advances expiry N months at **$0** (writes a $0 sales row); no money. | **Free Credit 0–12** dropdown in **Credit & Renewal** → `grantFreeExtension` (or combined, below). Advances expiry, no order/payment. Preview of resulting expiry + reason/note. | ✅ parity |
| **Pay Credit** (code 0–24; **brief 0–18**) | Creates a **billable invoice** (`paid=0, due=total`, +13% HST option) **and** advances expiry by the months. Not itself a payment. | **Pay Credit 0–18** dropdown → authorized paid renewal (`apply_charge` + `authorize_unpaid`) that creates a POS invoice (receivable) and grants the months. Charge = monthly × N, authoritative POS tax, optional discount. | ✅ parity (range 0–18 per brief) |
| **Pay Now** (YES/NO) | YES → opens the POS payment screen to settle the just-created invoice. | **Pay now** amount on the credit form (offline cash capture) + **Record payment** on the Payments tab for later settlement (partial allowed). | ✅ parity |
| **Pay Later** (implicit: pay_now=NO) | Invoice stays `due>0`; shows in the red "Credit – N Months". | Leave Pay-now 0; the invoice stays outstanding. Optional **Authorize service before full payment** (`authorize_unpaid`) + **reason** grants service while the invoice is outstanding. | ✅ parity (+ explicit override + reason) |
| **Credit – N Months** | SUM of unpaid `pay_for` months across the connection's `sales_details` (service-months owed, not cash). | Surfaced as the **Connection outstanding** balance (money) + the Renewals/Credits history (months). | ✅ (money-based, see §4) |
| **Change Date By (+/−)** | Manual expiry shift in **days**; no money; audited to `ip_day_update_tbl`; no reason in legacy. | **Change date by ± days OR ± months** (new months option) → `adjustDate(by_days|by_months)`; before→after preview; **required reason**; confirm; audited. No charge. | ✅ parity (+ months, preview, reason) |
| **Bring to Current Date** | Hard-sets expiry to **today**, regardless of future/past (no guard). Audited. | `adjustDate(to_today)` — sets expiry to today (same rule, confirmed below) with a before→after preview + required reason + confirm. | ✅ parity (+ preview/reason/confirm) |
| **Change Monthly Charge** | Sets `iptv_details.monthly_charge` (per-account), audit-stamped. Does not touch the shared plan price or past invoices. | **Change monthly charge** with a **reason** + confirm; records old→new + reason + actor; applies to **future** charges only; idempotent. | ✅ parity (+ reason, confirm, idempotency) |

**Legacy "credit" = service-months owed, NOT a stored cash balance.** Real money always flowed through the shared POS sales tables (the `iptv_payments` table was dead code). The new system reuses POS Orders/Invoices/Payments — **no separate IPTV financial ledger**.

## 2. Exact calculation rules (as implemented)

- **Expiry advance** (`RenewalCalculator`): `baseline = later(currentExpiry, today)` (active keeps remaining time; lapsed restarts today); months use `addMonthsNoOverflow` (Jan 31 +1mo → Feb 28), days use `addDays`. Calendar-safe, deterministic (clock injected).
- **Pay Credit charge**: `effectiveMonthlyChargeMinor × paidMonths`, minus an authorized discount on the net service amount, plus **authoritative POS tax** (the IPTV service product's tax class — 13% on the dev tenant). The order line is the source of truth; the quote preview resolves the same tax class so preview = invoice.
- **Combined Free + Paid (one transaction)**: the invoice/charge covers **only the paid months**; expiry advances by **paid + free** months; the single renewal row stamps `paid_months` and `free_months`. **Grant policy is atomic:** the whole block (paid + linked free) is granted only when the paid portion is **paid-in-full OR `authorize_unpaid`** — a partial unpaid invoice grants **neither** the paid months nor the free months; expiry is left unchanged and the row records the billing status.
- **Change Date By**: `by_days` = `currentExpiry ± N days`; `by_months` = `currentExpiry ± N months` (no-overflow). Never touches `paid_through_date`; never charges or grants paid service.
- **Bring to Current Date (confirmed rule):** sets `expiry_date` to **exactly today**, regardless of whether current expiry is in the future or past (matches the legacy portal, which overwrites unconditionally). Because this can shorten remaining time, the new UI gates it behind a before→after preview + required reason + ConfirmDialog. It never grants or charges.
- **Change Monthly Charge**: sets the per-account `monthly_charge_minor` only (future charges); the shared plan price and historical invoices are never modified; old→new + reason + actor recorded.
- **Idempotency**: create / renew / activate / free-extension / adjust-date / record-payment / change-charge all run under `RequestIdempotency` (`X-Request-Id`) — a retry records exactly once.

## 3. Changes

### Backend (`backend/`)
- `IptvSubscriptionService::billingOverview()` + `IptvResource::billing` — added **`connection_outstanding_minor`** + `connection_currency` (this connection's orders only), distinct from customer-wide `customer_financials`.
- `doRenew`/`doActivate` + `RenewIptvSubscriptionRequest`/`ActivateIptvSubscriptionRequest` — **`free_months`** (combined free+paid, atomic grant); **`discount_type`/`discount_value`/`discount_reason`** on renew (gated `iptv.discount`), applied via the existing quote/billing path.
- `adjustDate` — new **`by_months`** mode (signed whole months); `by_days`/`to_today` preserved.
- `changeMonthlyCharge` — records a **reason** (old→new + actor) and is **idempotent**.
- No schema change (the renewal columns `paid_months`/`free_months`/`discount_*` already existed).

### Frontend (`frontend/`)
- **Outstanding (Task 9):** the summary card is **Connection outstanding** (`connection_outstanding_minor`); a separate **Customer account balance** line shows the customer-wide total (all currencies), clearly disambiguated — no figures recomputed.
- **Unified Credit & Renewal:** one form — Package, Free Credit 0–12, Pay Credit 0–18, Discount, Pay-now + method + Authorize-before-payment (+ reason), Notes, a quote/tax/total + **total service months (paid+free)** + projected-expiry preview, and Review & Confirm → one atomic call (`renewSubscription` with `free_months`, or `grantFreeExtension` when paid = 0).
- **Expiry:** Days/Months unit on "Change date by" + Bring-to-current, both with preview + required reason + confirm.
- **Change monthly charge:** reason + confirm + "future charges only" note.
- **Notes vs Activity** tabs differentiated (staff notes vs the renewal/credit/date-adjustment timeline).

## 4. Outstanding balance clarification (Task 9)

The reported *"CA$37.80 outstanding vs a CA$16.95 invoice"* is **mathematically and relationally correct** — they are two different totals that were previously conflated under one label:
- **Connection outstanding** = Σ of THIS connection's billed-renewal orders' outstanding (`PaymentLedger`), in the connection's currency — e.g. the single CA$16.95 invoice.
- **Customer account balance** = the customer's total outstanding across **all** their orders (IPTV + repairs + POS sales + other IPTV connections), via `CustomerFinancialService::summary` — the larger CA$37.80.
The old card bound the customer-wide figure. The fix shows **both, separately labelled**; no figure was changed to force a match.

## 5. Quote↔invoice rounding fix

Live testing surfaced a **1-cent** mismatch on *discounted* charges: `IptvPricingService` floored tax with `intdiv` (13% of 1350 → 175) while the authoritative POS order engine rounds half-up (`OrderPricingService::roundDiv` → 176), so paying the quoted total left $0.01 outstanding (PARTIALLY_PAID). Fixed by matching the engine's half-up rounding in the quote (`intdiv($net*$rate + 5000, 10000)`). Whole-cent cases are unchanged; preview total now equals the invoice total.

## 6. Tests

- **Backend** (Xdebug off): `php artisan test tests/Feature/Iptv` → **64 passed (437 assertions)** — incl. `IptvParityGapsTest` (connection vs customer-wide outstanding; combined 3 paid + 1 free paid-in-full = one order 3×monthly+tax, expiry +4mo, row paid_months=3/free_months=1; authorize_unpaid grants + outstanding = paid portion; partial-unpaid NOT granted; combined activation; renew percent/fixed discount + snapshot; discount without `iptv.discount` → 403; `by_months` ±; change-charge reason + idempotency) and the quote-rounding test. Pint clean; PHPStan no new errors.
- **Frontend:** `vitest run src/pages/iptv src/components/ui` → **72 passed**; `tsc -b`, eslint, `npm run build` clean. Covers connection-outstanding card (vs customer balance shown separately), combined free+paid → one `renew` with `free_months`+discount+override-reason, free-only → free-extension, `by_months` adjust, change-charge reason+confirm, Notes vs Activity.

## 7. Browser evidence (live dev, billing enabled) — `docs/techtitans/screenshots/iptv-parity/`
Live E2E **12/12 PASS**, 0 console errors (fresh connection IPTV-2026-0026, Dinesh, Monthly $15):
`01` **Connection outstanding CA$16.95** with a separate **Customer account balance CA$54.75 across all orders** (correctly distinct); `02` **combined** Pay 3 + Free 1 → preview Total months 4 / CA$50.85, **one** invoice INV-2026-0008 CA$50.85 PAID, expiry +4mo, one row "3 paid + 1 free"; `03` 10% discount → net CA$13.50 + tax (quote now matches invoice after the rounding fix); free-only → +1mo, **no** invoice; `04` **pay-later** authorized → granted while CA$16.95 outstanding, then Record payment → $0; partial → not granted, outstanding = remainder; `05` expiry **+2 months**, **−10 days**, **bring-to-today** (each preview + reason-gated); `06` monthly charge $15→$20 with reason + "future charges only"; `07` Notes ≠ Activity; one POST per action (idempotent); `08`/`09` existing-sub + mobile 390 / 150% zoom, 0px overflow.

## 8. Existing-subscription compatibility
Active/expired/suspended/cancelled subscriptions and their existing invoices/payments render in the new sections/tabs unchanged (no activation panel for active rows); the `/iptv/:id` URL is preserved; no account records duplicated; reporting/reminders still gate on `status='active'`.

## 9. Limitations
- **Pay-later override reason** rides in the shared credit `note` (the renew body has no dedicated override-reason field) — recorded + audited, just not a separate column.
- Legacy Pay Credit was 0–24 in code; we use **0–18** per the brief.
- Renewal tax uses the IPTV service product's tax class (13% on the dev tenant); a tenant with no taxable class bills at its default rate until configured.
- No real SMS/email in development (eligibility only).

## 10. Local commits (separate per repo, on `techtitans/integration`, no push)
- **Backend:** `d5427ea` — billing connection-outstanding, free_months+discount on renew/activate, by_months adjust, change-charge reason+idempotency, quote rounding fix, parity tests, this report + screenshots.
- **Frontend:** `ceb15df` — `IptvDetailPage` (outstanding split, unified Credit & Renewal, ±months expiry, charge reason/confirm, Notes vs Activity), `iptv.ts` types, IPTV page tests.
