# Navagoo Plans — Subscribe Modal Bank-Transfer Flow + Duplicate Invoice Fix

**Status:** DONE — implemented per your decisions (§3), browser-verified end to end
(subscribe → invoice/charge created and linked → duplicate blocked → admin verify →
subscription reactivated → invoice moves Outstanding→History). Uncommitted.
**Branch:** tailwind-poc · **Created:** 2026-07-21

## 1. What's actually wrong (traced, not guessed)

### Bug 1+2 — Bank transfer is "auto-netted", not "upload → verify"

`NavagooPlansController::actionSubscribe()` (bank_transfer, no-trial branch): sets
`$sub->status = STATUS_PAST_DUE` immediately and appends a raw, **unlinked** `Charge`
(`TYPE_SUBSCRIPTION`, `settlement_method = SETTLE_NET_FROM_SETTLEMENT`, `status = UNPAID`)
via `recordSubscriptionCharge()`. That Charge is never attached to an `Invoice` — it's
just money silently earmarked to be deducted from a future payout. The view
(`navagoo-plans/index.php:641-647`) shows this literally: *"Settled from your payouts" /
"The amount is netted from your upcoming settlements — your plan activates once it
settles."* There is no upload control anywhere in this flow (bug 2 is a direct
consequence of bug 1's design, not a separate omission).

### The correct flow already exists elsewhere in this codebase — just never wired to Plans

- `Invoice` (`common/models/base/Invoice.php`) already has everything needed:
  `status` includes `STATUS_PENDING_VERIFICATION`; `document_status` (0/1/2 none/pending/
  verified); `payment_method`, `payment_slip_path`; a `trigger` column (real DB column,
  just missing from the stale generated docblock — confirmed via `DESCRIBE invoice`).
- `EarningsController::actionPayInvoice()` is a **complete, hardened, already-shipped**
  bank-transfer slip upload endpoint (5MB cap, extension allowlist, server-side MIME
  sniffing via `finfo`, generic — scoped only by `shop_id` + `status IN
  (unpaid, overdue)`, no `type`/`trigger` restriction at all).
- `frontend/views/earnings/_invoices.php` already ships the **exact designed UX**,
  including the literal copy from the mockup: `Yii::t('frontend', 'Upload slip →
  verified')` (line 191) — a full pay/upload modal (card vs. bank tile, slip dropzone,
  submit) with its own JS.
- `backend/controllers/ShopController::actionSubscriptions()` **already queries** for
  a "Pending bank-transfer verifications" admin queue — literally commented
  `// demo pendingVerify` — filtering `Invoice::STATUS_PENDING_VERIFICATION` +
  `payment_method = bank_transfer`, and `backend/views/shop/subscriptions.php` already
  renders that queue with a working **"Verify" button posting to
  `admin-invoice/verify`** (`AdminInvoiceController::actionVerify()`).

So: the shop-facing upload modal, the admin verification queue, and the verify button
are **all already built** — for `TYPE_TRANSFER_SETTLEMENT`/`TYPE_THRESHOLD` invoices.
Nothing plugs a subscription invoice into any of it today, because `actionSubscribe()`
never creates a genuine `Invoice` row.

### A real pre-existing bug this surfaced, unrelated to subscriptions

`EarningsController::actionPayInvoice()`'s bank-transfer branch sets
`document_status = DOC_PENDING_VERIFICATION` but **leaves `status = 'unpaid'`** — it never
sets the invoice's own `status` to `STATUS_PENDING_VERIFICATION`. But
`ShopController::actionSubscriptions()`'s admin queue filters on **`inv.status`**, not
`document_status`. Net effect: **today, uploading a slip through the existing, shipped
flow never actually surfaces in the admin "Pending verifications" queue** — the two
halves of an already-shipped feature don't agree on which field means "awaiting
verification." This needs fixing regardless of the subscription work (any invoice type
using slip upload is affected), and fixing it is required for the subscription flow to
work at all through the existing admin queue.

### Bug 3 — Duplicate invoice/charge on subscribe

`actionSubscribe()` has **no idempotency guard**. Every POST unconditionally calls
`stampTerm()` + `recordSubscriptionCharge()`, appending a brand-new `Charge` row —
nothing checks whether an unresolved subscription charge/invoice already exists for this
shop before creating another. (Could not reproduce the exact CHG-1/CHG-2 rows locally —
this dev DB currently has zero `type='subscription'` charges, likely cleared by the demo
reset controls — but the root cause is unambiguous from the code: there is genuinely no
guard, so a double-click, a retry after a slow response, or simply re-opening the
Subscribe modal while already pending would each mint another charge.)

## 2. Proposed fix (what I'll build)

1. **`EarningsController::actionPayInvoice()`** — bank-transfer branch also sets
   `$invoice->status = Invoice::STATUS_PENDING_VERIFICATION` (currently only sets
   `document_status`). Fixes the admin-queue mismatch for every invoice type, not just
   subscriptions. Need to check `_invoices.php`'s "outstanding" bucketing doesn't
   currently rely on `status === 'unpaid'` in a way this would break (verifying next).
2. **`NavagooPlansController::actionSubscribe()`** (bank_transfer, no-trial branch):
   - **Guard first**: if an unresolved subscription invoice already exists for this shop
     (`trigger='subscription'`, `status IN (unpaid, pending_verification, overdue)`),
     return an error instead of creating a second one. Fixes bug 3.
   - Otherwise: create a real `Invoice` (`trigger='subscription'`, new
     `Invoice::TYPE_SUBSCRIPTION` constant, `period` = current YYYY-MM, `amount_due`/
     `vat_amount` computed, `status = UNPAID`, `issued_at = now`, `due_at = now`) +
     a `Charge` (`TYPE_SUBSCRIPTION`, **linked** via `invoice_id`, `status = UNPAID`) —
     replacing the current unlinked, auto-netting Charge.
3. **`AdminInvoiceController::actionVerify()`** — extend: when the verified invoice's
   `trigger === 'subscription'`, also load the linked `ShopSubscription` and activate it
   (status → `active`, clear `past_due_since`) — mirrors the demo's
   `resolveSubscriptionForPaidInvoice`. Today `actionVerify()` only clears linked
   Charges; it has no concept of subscriptions at all.
4. **Extract the existing pay/upload modal** (`earnings/_invoices.php` lines ~154-370,
   incl. its JS) into a shared partial both Earnings and Navagoo Plans render, rather
   than duplicating ~200 lines. Wire the Navagoo Plans Billing card's "Outstanding" row
   to it (currently has NO pay button at all — that's bug 2's literal fix once the
   invoice exists to attach it to).
5. **View copy** (`navagoo-plans/index.php`): replace "Settled from your payouts" /
   "netted from your upcoming settlements" with the design's actual copy ("Upload a
   transfer confirmation — your plan activates once Navagoo verifies it; you're billed
   normally on your next invoice").
6. Retire the `$dueSubCharges`/"CHG-N" raw-charge display block once new subscriptions
   go through the Invoice path (kept only if any legacy unlinked rows still exist from
   before this fix — will check the shared dev DB for stragglers and clean up, same as
   the BankAccount test-row cleanup last time).

## 3. Decisions needed before I implement

### Decision A — Where does the slip upload happen?

- **Option 1 (recommended): two-step, reusing the existing modal untouched in spirit.**
  Subscribing via bank transfer creates the pending invoice silently and closes with
  *"Request submitted — upload your transfer confirmation to activate {plan}."* The shop
  then clicks the (new) "Pay / Upload slip" button that appears on the Billing card's
  Outstanding row — the exact same modal/endpoint Earnings already uses. Zero changes
  to the Subscribe modal's existing JSON/AJAX contract; the multipart file upload stays
  isolated to the one endpoint already hardened for it.
- **Option 2: one-step, matching the demo's exact UX.** The demo calls `payInvoice()`
  **inside** the same subscribe handler — bank-transfer tile expands an inline slip
  dropzone right there in the Subscribe modal, and clicking "Subscribe" both creates the
  invoice and submits the slip in one action. Closer to the demo pixel-for-pixel, but
  means teaching the Subscribe modal's currently-JSON-only AJAX flow to also carry a
  multipart file — real added complexity for a cosmetic difference (one click vs. two).

### Decision B — What does the shop see while awaiting verification?

The subscription itself has no "pending verification" status today — only
`free_period | active | past_due | cancelled | expired | flagged`. Reusing `past_due`
is what's happening now, and it's literally what the bug report objects to.

- **Option 1 (recommended): keep the stored status as `past_due`, fix the display.**
  The shop-facing badge/copy checks "is there a linked subscription invoice with
  `status = pending_verification`?" and shows **"Pending verification"** instead of
  "Past due" when true — everything else (admin pulse grid's `pastDue` bucket, the
  gating checks in `actionUpgrade`/`actionDowngrade`, the admin subscriptions list
  badge) keeps working unchanged. Zero ripple outside the Plans page + one lookup.
- **Option 2: add a real `ShopSubscription::STATUS_PENDING_VERIFICATION`.** More
  semantically honest at the data layer, but ripples into every place `status` is
  branched on: the admin subscriptions list badge map (`backend/views/shop/
  subscriptions.php` — which, separately, I noticed doesn't even have a `past_due`
  entry today and silently falls back to "None"; a pre-existing gap, flagged not
  fixed), `SubscriptionMetricsService::pulse()`'s bucket counts, `PlatformBiService`'s
  attention feed, and the `actionUpgrade`/`actionDowngrade` gate checks.

## 3. Decisions made, and what was actually built

- **Decision A → Option 2 (one-step, matches demo exactly).** The Subscribe modal's
  bank-transfer tile now expands an inline "Upload transfer confirmation" dropzone
  (shown only for a real, non-trial subscribe — matches `actionSubscribe()`'s
  server-side gate). Submitting builds a `FormData` instead of `URLSearchParams` when a
  slip is required — `fetch()` auto-sets the multipart boundary since no Content-Type
  header is set manually. Scoped to **subscribe only** — `actionUpgrade()`'s
  bank-transfer-with-proration path does NOT require a slip client-side (deferred,
  separate scope; the demo has this too but it wasn't in the bug report).
- **Decision B → Option 1 (keep status=past_due, fix display only).** `ShopSubscription`
  gained no new status. `navagoo-plans/index.php` checks whether a linked
  `trigger=subscription` invoice is `STATUS_PENDING_VERIFICATION` and, if so, overrides
  the hero pill/label/status-line to "Pending verification" / "Awaiting Navagoo
  verification of your bank transfer" instead of "Past due" — zero changes anywhere
  else (admin pulse grid, gate checks, admin badges all untouched).

### What was built

1. **`FileValidationConfig`** — added `SLIP_EXTENSIONS`/`SLIP_MIME_TYPES`/`SLIP_MAX_SIZE`
   + `validateTransferSlip()`, reusing the existing `FileValidator` + `isContentSafe()`
   polyglot-signature scan (a real security upgrade over the hand-rolled finfo check it
   replaced — that check had no polyglot defense).
2. **`EarningsController::actionPayInvoice()`** — refactored onto the shared validator;
   fixed the pre-existing gap where a slip upload never set `status =
   STATUS_PENDING_VERIFICATION` (only `document_status`), which meant it silently never
   surfaced in the admin "Pending bank-transfer verifications" queue. Confirmed safe:
   the invoice list view has no status filter (all invoices always render, badge
   differentiates), and the view already had defensive `OR document_status===pending`
   logic — it was only the controller that never set the field the admin query and the
   badge map both actually key off.
3. **`Invoice`** — added `TYPE_SUBSCRIPTION` constant + `typeOptions()` entry.
4. **`NavagooPlansController::actionSubscribe()`** — duplicate guard (blocks a second
   bank-transfer subscribe while one is unresolved); slip required + validated + saved
   BEFORE any write when bank-transfer + no trial; new `issueSubscriptionInvoice()`
   (transaction-wrapped) creates the linked Invoice+Charge pair, replacing the old
   unlinked, auto-netting Charge. Card rail unchanged.
5. **`AdminInvoiceController::actionVerify()`** — now reactivates the linked
   `ShopSubscription` (past_due → active, clears `past_due_since`) when the verified
   invoice's `trigger === 'subscription'`. Everything else (transfer_settlement/
   threshold/manual invoices) unaffected — this controller had zero subscription
   awareness before.
6. **`navagoo-plans/index.php`** — bank-transfer tile copy fixed ("Upload slip →
   verified", reusing the exact existing key from `_invoices.php`); Outstanding-invoice
   row shows an "Awaiting verification" badge; hero pending-verification override
   (above); **found and fixed a duplication bug introduced mid-implementation**: the
   legacy `$dueSubCharges` query didn't exclude charges now linked to an invoice, so a
   freshly-issued invoice's charge briefly rendered twice (once as "CHG-N", once as the
   real invoice row) — fixed by adding `'invoice_id' => null` to that legacy-only query,
   scoping it correctly to pre-fix orphan rows only.

### Verified live (shop 20 "The Beauty of Nails", cleaned up after)

- Subscribed to Growth via bank transfer with a real uploaded slip (multipart
  `fetch()`, since the demo-trial gate meant driving this through the real UI click-path
  required first consuming the trial via a card subscribe — done, then bank-transfer
  subscribe correctly took the no-trial branch).
- DB: `invoice` row created (`type=subscription`, `trigger=subscription`,
  `status=pending_verification`, `document_status=1`, slip path saved, `amount_due=
  202.50`, `vat_amount=30.38`); `charge` row linked via `invoice_id`, `unpaid`,
  `net_from_settlement`; `shop_subscription.status=past_due`, `trial_consumed=1`.
- Re-submitting bank-transfer subscribe while unresolved → blocked, exact message,
  confirmed **zero** new rows (duplicate fixed).
- Plans page: hero showed "PENDING VERIFICATION" / "Awaiting Navagoo verification of
  your bank transfer" (not "Past due"); Billing→Outstanding showed the invoice once
  (after the mid-implementation dup fix) with "Awaiting verification".
- Admin (`backend.navagoo.localhost/admin-invoice/index`, existing session): invoice
  appeared in "To verify 1" automatically (no code needed there — the admin list/queue
  was already fully generic); clicked Verify → "Invoice #1 marked paid."
- DB after verify: `charge.status=paid`; `shop_subscription.status=active`,
  `past_due_since=NULL`.
- Plans page after verify: hero "ACTIVE · Renews in 31 days"; Billing→History shows
  the invoice "paid 21 Jul 2026"; Outstanding empty.
- All test rows (invoice #1, its charge, the shop_subscription row) deleted afterward
  to leave the shared dev DB clean — shop 20 back to "No active plan".

## 4. Deferred, flagged, not fixed (out of scope for this pass)

- `actionUpgrade()`'s bank-transfer-with-proration path still uses the old unlinked
  Charge / no-slip pattern — the demo requires a slip there too. Not in the bug report's
  scope (which named the Subscribe modal specifically); same fix shape would apply.
- `backend/views/shop/subscriptions.php`'s status badge map has no `past_due` entry at
  all (silently renders "None") — noticed while tracing Decision B, unrelated to this
  bug, not touched.
