# ECStores — Super-Admin Guide
### For Denis Gallant (Platform Owner)

Last updated: 2026-06-27  
Covers: Phases 0–6 (complete).

---

## Table of Contents

1. [How the Platform Works](#1-how-the-platform-works)
2. [Tech Stack Reference](#2-tech-stack-reference)
3. [Local Development Environment](#3-local-development-environment)
4. [The Two-Database Architecture](#4-the-two-database-architecture)
5. [Subdomain (and Custom Domain) Routing](#5-subdomain-and-custom-domain-routing)
6. [Your Super-Admin Panel](#6-your-super-admin-panel)
7. [Provisioning a New Tenant](#7-provisioning-a-new-tenant)
7b. [Set up a custom domain for a store](#7b-set-up-a-custom-domain-for-a-store-growth--pro)
8. [Suspending and Reactivating Tenants](#8-suspending-and-reactivating-tenants)
9. [Managing Plans](#9-managing-plans)
10. [Routine Maintenance](#10-routine-maintenance)
11. [Key Artisan Commands](#11-key-artisan-commands)
12. [Troubleshooting](#12-troubleshooting)
13. [Production Deployment](#13-production-deployment-placeholder)
14. [Stripe Billing — Cashier (You Billing Merchants)](#14-stripe-billing--cashier)
15. [Stripe Connect (Merchants Billing Their Customers)](#15-stripe-connect)
16. [Branding System](#16-branding-system)

---

## 1. How the Platform Works

ECStores is a multi-tenant SaaS platform. You (Denis) run the platform. Merchants sign up and get their own online store. Each store is completely isolated from every other store.

**The three layers:**

| Layer | Who uses it | URL pattern |
|---|---|---|
| Super-Admin Panel | You (Denis) | `ecstores.ca/super` |
| Tenant Admin Panel | Each store owner | `storename.ecstores.ca/admin` |
| Public Storefront | Each store's customers | `storename.ecstores.ca` |

**The money flow:**
- **EastCoast WebCraft (ECW) is the single biller** for merchant SaaS plan fees — ECW invoices and auto-charges merchants (see Section 14). ECStores does **not** bill plan fees; Laravel Cashier is installed but inert.
- Merchants charge their customers via Stripe Connect on their own **Standard** connected account (onboarded in the ECW client portal)
- Customer payments go directly to the merchant's bank account; your platform takes a 2% Connect application fee on those transactions
- Your SaaS revenue is the monthly plan fees, collected through ECW

---

## 2. Tech Stack Reference

| Component | Technology | Version | Purpose |
|---|---|---|---|
| Framework | Laravel | 13 | Application foundation |
| Multi-tenancy | stancl/tenancy | v3 | Database-per-tenant isolation |
| Admin UI | Filament | v5 | Both admin panels |
| Storefront frontend | Blade + Livewire | v4 | Reactive storefront without JavaScript frameworks |
| SaaS plan billing | EastCoast WebCraft (ECW) | live | Single biller — ECW invoices + auto-charges merchants (Cashier installed but inert) |
| Merchant payments | Stripe Connect (Standard) | live | Merchants accept customer payments; onboarded via the ECW client portal |
| Images | spatie/laravel-medialibrary | installed | Product image uploads |
| Mail | Resend | (Phase 6) | Transactional email |
| Dev tools | Laravel Telescope | installed | Query/mail/job inspector at `/telescope` |
| Language | PHP | 8.3 | |
| Database | MySQL | 8.4 | |
| Web server | Apache (via Laragon) | — | Local dev |

---

## 3. Local Development Environment

**Software stack:** Laragon (bundles Apache, MySQL, PHP, Composer) on Windows 11.

**Project location:** `D:\#WORK\EC_WebCraft\~PRIVATE_HTML\ecstores`

**Apache access path:** `C:\laragon\www\ecstores` (Windows junction — do not delete)

> The project path contains `#` which Apache treats as a comment. The junction at `C:\laragon\www\ecstores` gives Apache a clean path to serve from. Never rename the `#WORK` parent folder.

**Starting the environment:**
1. Launch Laragon
2. Click **Start All** — Apache and MySQL indicators go green
3. Visit `http://ecstores.test` to confirm it's running

**Stopping:** Click **Stop All** in Laragon. Always stop cleanly — don't just close the window.

**PHP and Composer in PATH:**
```
C:\laragon\bin\php\php-8.3.30-Win32-vs16-x64
C:\laragon\bin\composer
```
If you reinstall Laragon and the PHP version changes, update these PATH entries in Windows environment variables.

**Running artisan commands:** Open PowerShell, `cd` to the project directory, then run `php artisan ...`. Laragon must be running (MySQL must be up) for any database commands.

---

## 4. The Two-Database Architecture

This is the most important architectural concept in the platform.

**You have two types of databases:**

### Landlord database: `ecstores_central`

This is YOUR database. It contains:

| Table | What it stores |
|---|---|
| `tenants` | One row per merchant store (id/slug, name, email, suspended_at) |
| `domains` | Subdomain mapping — which subdomain belongs to which tenant |
| `super_admins` | Your login credentials |
| `plans` | Plan definitions (feature set / limits per tier; SaaS billing is handled by ECW) |
| `subscriptions` | Legacy Cashier table — inert/empty in normal operation (ECW is the single biller) |
| `subscription_items` | Legacy Cashier table — inert/empty in normal operation |

**One tenant database per merchant: `tenant_{slug}`**

For example: `tenant_acme`, `tenant_beta`. Each contains:

| Table | What it stores |
|---|---|
| `admins` | The merchant's admin login |
| `users` | That store's customer accounts |
| `products` | That store's product catalogue |
| `orders` | That store's orders |
| `site_settings` | That store's branding, currency, policies |
| `shipping_methods` | That store's shipping rates |
| ... | All other store-specific data |

**Why this matters:**
- A bug can never leak Tenant A's orders to Tenant B — they're in separate databases
- You never need `WHERE tenant_id = ?` clauses scattered everywhere
- Dropping a tenant is as simple as dropping their database

**How the switch happens:**
When a request comes in to `acme.ecstores.ca`, the `InitializeTenancyBySubdomain` middleware reads the subdomain, looks up `acme` in `ecstores_central.domains`, then switches the active database connection to `tenant_acme` for the rest of that request. All Eloquent models automatically use the switched connection.

---

## 5. Subdomain (and Custom Domain) Routing

**How hosts are resolved:**

1. Browser requests `acme.ecstores.test`
2. Hosts file (Windows) maps `acme.ecstores.test` → `127.0.0.1`
3. Apache serves from `C:\laragon\www\ecstores\public` (via the `*.ecstores.test` virtual host wildcard)
4. Laravel's `InitializeTenancyByDomainOrSubdomain` middleware inspects the hostname: a host **under a central domain** (`…ecstores.test` / `…ecstores.ca`) is resolved by its **subdomain** label (`acme`); any **other** host is resolved as a **full custom domain** (see Section 7b).
5. Looks up `SELECT * FROM domains WHERE domain = 'acme'` (or `= 'acmestore.com'` for a custom domain) in `ecstores_central`
6. Finds the `tenant_id`, switches DB to that tenant's database
7. Request continues normally with the tenant's data

> Tenancy is initialized **globally** for every request by `App\Http\Middleware\InitializeTenancyByDomainOrSubdomainIfNotCentral` (appended in `bootstrap/app.php`), which skips central hosts and otherwise identifies by domain-or-subdomain. This is the real routing switch that makes custom domains work — the route-group middleware mirrors it.

**The central domain (`ecstores.test`) is special:**
- It does NOT go through tenant initialization
- It's used for your super-admin panel at `ecstores.test/super`
- Accessing `ecstores.test` directly hits the landlord DB

**Adding a new subdomain locally:**
Every new tenant subdomain needs a hosts file entry. Open `C:\Windows\System32\drivers\etc\hosts` as Administrator and add:
```
127.0.0.1 newstore.ecstores.test
```

**In production:**
A wildcard DNS record (`*.ecstores.ca → your server IP`) handles all tenant subdomains automatically. No individual DNS records needed per tenant.

---

## 6. Your Super-Admin Panel

**URL:** `http://ecstores.test/super` (local) / `https://ecstores.ca/super` (production)

**Login:** Your `super_admins` credentials (separate from any store's admin account).

**What's in the panel:**

### Dashboard
Shows the **Platform Overview widget**: Total Tenants, Active Stores, Suspended count. Updates live based on the database.

### Tenants (Platform group)
- Full list of all provisioned stores
- Columns: Subdomain, Store Name, Admin Email, Domain, Plan, Billing, Status (Active/Suspended), Created date
- **Plan column — NO PLAN badge (data-bug alarm):** if a store shows a red **NO PLAN** badge instead of a plan name, its `plan_id` is NULL (or points at a deleted plan). That store is silently running with ALL features unlocked (the synthetic-Pro fail-open — deliberate, so a paying merchant is never degraded mid-sale), and every page hit writes an ERROR line to `storage/logs/laravel.log`. **Fix it the moment you see it:** row → **Change Plan** → select the store's real plan → Save. Use the **"NO PLAN (data bug)"** table filter (Filter button, tick it, Apply filters) to list all affected stores — an empty filter result is the healthy state.
- Row actions: **Suspend**, **Reactivate**, **Change Plan**, **Sync Stripe Account**, **Resend Welcome Email**, **Admin Panel** (opens tenant's admin in new tab)
- Header action: **Provision New Tenant** (see Section 7)

### Plans (Platform group)
- Subscription plan definitions
- Fields: Name, Price/month, Stripe Price ID (reserved/dormant — Cashier inert; ECW bills SaaS fees), Features (key-value list), Active toggle
- Standard CRUD — create, edit, delete

---

## 7. Provisioning a New Tenant

This is the full workflow for onboarding a new merchant. The provisioning button handles everything automatically.

### Step 1 — Add the subdomain to your hosts file (local dev only)

Open `C:\Windows\System32\drivers\etc\hosts` as Administrator:
```
127.0.0.1 merchantname.ecstores.test
```

In production this step is not needed (wildcard DNS handles it).

### Step 2 — Use the Provision New Tenant form

1. Visit `http://ecstores.test/super/tenants`
2. Click **Provision New Tenant**
3. Fill in the form:
   - **Store Name** — what appears in the store's header (e.g. `Acme Hardware`)
   - **Subdomain** — lowercase letters, numbers, and hyphens only (e.g. `acme`) — this becomes `acme.ecstores.ca`
   - **Owner Name** — the merchant's full name
   - **Owner Email** — the merchant's email (used to log into their admin panel)
   - **Temporary Password** — they'll change this on first login
   - **Plan** — REQUIRED. Pick the store's tier (Starter/Growth/Pro). Provisioning refuses to run without a valid plan — a plan-less tenant would silently get all features via the synthetic-Pro fail-open (see the NO PLAN badge note in Section 6)
4. Click **Provision New Tenant**

### What happens automatically:

| Step | What runs |
|---|---|
| 1 | `Tenant::create(['id' => 'acme', 'name' => '...', 'email' => '...'])` — creates tenant record + auto-creates `tenant_acme` database |
| 2 | Domain record created: `acme` → tenant `acme` |
| 3 | `php artisan tenants:migrate --tenants=acme` — runs all 11 tenant migrations, creating 25 tables |
| 4 | Tenant context initialized, switches to `tenant_acme` DB |
| 5 | `site_settings` row created with company name and `$` currency |
| 6 | Default shipping method created: Standard Shipping at $10.00 |
| 7 | Admin user created with the email and password you entered |
| 8 | Tenant context ended, switches back to landlord DB |

**Expected result:** Success toast notification. Tenant appears in list as Active.

### Step 3 — Send the merchant their login details

Give them:
- Their admin panel URL: `https://merchantname.ecstores.ca/admin`
- Their email and temporary password
- A link to the Tenant Guide

### Step 4 — They should do immediately:

1. Log in and change their password (Profile in the admin top-right)
2. Go to **Site Settings** and fill in their store name, currency, policies
3. Go to **Branding Studio** and set their colours, fonts, and upload a logo
4. Payments: the merchant connects their own Stripe (Standard) account in the **ECW client portal** (Merchant Payments); the store's **Settings → Payments** page then shows read-only status. See Section 15.
5. Add their products
6. Add or adjust their shipping methods

---

## 7b. Set up a custom domain for a store (Growth & Pro)

A Growth/Pro merchant can point their **own** domain (e.g. `acmestore.com`) at their storefront. There is **no automation** — this is a manual, four-part job: the merchant does DNS, you do the cPanel alias + certificate, then you flip it on in the Super-Admin panel. Do the steps **in order** — if you add the domain in the panel before DNS + the certificate are ready, visitors get a certificate warning.

**Before you start**
- Confirm the store is on **Growth or Pro**. The "Add custom domain" action is hidden on Starter (a plan-feature gate). If they're on Starter, they must upgrade first (via the ECW client portal).
- Confirm the merchant actually **owns** the domain (bought from a registrar — GoDaddy, Namecheap, etc.). We never buy it for them.

### Step 1 — Give the merchant their DNS records

The merchant adds these at their registrar's DNS panel. They point the domain at the ECStores server.

1. Find the server's IP: log into **cPanel → sidebar → "Shared IP Address"** (or *Server Information*). This is the address the records point at. (Do **not** guess it — copy the exact value from cPanel.)
2. Tell the merchant to create, at their registrar:
   - an **A record** with host **`@`** (the plain/apex domain) → the server IP
   - an **A record** with host **`www`** → the same server IP
   - (If their registrar can't do an apex A record, they can use an **ALIAS/ANAME** at `@` pointing to `acmestore.com` — registrar-specific.)
3. DNS changes can take anywhere from a few minutes to a few hours to spread. You can check progress with `nslookup acmestore.com` (or an online DNS checker) — proceed once it returns the server IP.

### Step 2 — Attach the domain in cPanel

1. In **cPanel → Domains → "Create A New Domain"** (or *Aliases*, depending on the cPanel version), add `acmestore.com` as an **alias/addon** pointing at the **ECStores document root** (the same `public/` directory the platform is served from — the existing `ecstores.ca` docroot). This makes Apache route the domain to the app.
2. Add `www.acmestore.com` too if cPanel doesn't create it automatically.

> Why this step matters: adding the row in the Super-Admin panel (Step 4) only tells the *application* which tenant a host belongs to. Apache still has to route the domain to the app in the first place — that's this cPanel alias. Without it, the domain either fails to load or lands on a default page.

### Step 3 — Issue the SSL certificate (AutoSSL)

1. In **cPanel → Security → SSL/TLS Status**, find `acmestore.com` and `www.acmestore.com`.
2. Click **"Run AutoSSL"**. Wait until both show a valid certificate (green). This can take a few minutes after DNS has propagated — AutoSSL needs the domain to already resolve to the server.
3. Verify: open `https://acmestore.com` in a browser. You should get the store (or, before Step 4, a "Store not found" page) **with a valid padlock** — no certificate warning. If you see a certificate warning, AutoSSL hasn't finished; wait and re-run.

### Step 4 — Turn it on in the Super-Admin panel

1. Go to `https://ecstores.ca/super/tenants` (locally `http://ecstores.test/super/tenants`).
2. Find the store's row and open the **"Add Custom Domain"** action.
3. Enter the domain exactly (`acmestore.com`, no `https://`, no trailing slash). Click **Add domain**.
   - If it's rejected as invalid, check for a typo or a stray scheme/path. A `*.ecstores.ca` address is rejected on purpose (that's the platform's own).
   - If it says the domain is already in use, another store already has it — check the Domain column on the Tenants list.
4. The row's **Domain** column now shows `acmestore.com`. Visit `https://acmestore.com` — the storefront serves, and its canonical/social URLs use the custom domain. The old `acmestore.ecstores.ca` **still works** and now points search engines at the custom domain.

### Important: admin login stays on the subdomain

Login sessions are tied to the exact host (host-only cookies). **Tell the merchant to keep logging in at `theirstore.ecstores.ca/admin`**, not on the custom domain — the custom domain is the *storefront* face only. (Logging into `/admin` on the custom domain would start a separate session and cause confusing "logged in then bounced" behaviour — the same cookie-scope issue we hit before.)

### Removing a custom domain

1. On the store's row, open **"Remove Custom Domain"** and confirm. The storefront falls back to the `.ecstores.ca` subdomain and canonical URLs revert to it.
2. This does **not** remove the cPanel alias or the merchant's DNS record — clean those up in cPanel (and ask the merchant to remove their DNS records) if the domain is being fully retired.

> **First real-domain drill:** before selling this to a client, run the whole flow once end-to-end with a spare domain you own, so any registrar/cPanel/AutoSSL quirks surface on a throwaway domain — not on a paying merchant's launch.

---

## 8. Suspending and Reactivating Tenants

**When to suspend:** Non-payment, terms of service violation, merchant request.

**To suspend:**
1. Visit `http://ecstores.test/super/tenants`
2. Click **Suspend** on the tenant row
3. Confirm

**What happens:** `suspended_at` timestamp is set on the tenant record. Every request to that tenant's storefront returns a 503 "Store Temporarily Unavailable" page. The merchant's admin panel continues to work normally (so they can still log in and see their data).

**To reactivate:**
1. Click **Reactivate** on the suspended tenant row
2. Confirm

**What happens:** `suspended_at` is cleared. Storefront returns to normal immediately on the next request.

> **Note:** Suspension only affects the public storefront. The admin panel is not blocked. If you need to fully lock out a merchant's admin access, you would need to manually delete or deactivate their admin user in the tenant DB.

---

## 9. Managing Plans

Plans define what subscription tiers you offer merchants (e.g. Starter $29/month, Pro $79/month).

**To create a plan:**
1. Visit `http://ecstores.test/super/plans`
2. Click **New Plan**
3. Fill in name, price, and optional features
4. Leave Stripe Price ID blank — it's reserved/dormant (Cashier is inert; ECW is the single biller, see Section 14)

**Features field:** A key-value list displayed on your pricing page (future). Example:
- `Products: Unlimited`
- `Storage: 5 GB`
- `Support: Email`

**Stripe Price ID:** Optional. Plans no longer drive billing from inside ECStores — **ECW is the single biller** (see Section 14), so this field is not required for normal operation. Leave it set only if you intend to use the dormant Cashier path for a future direct-on-ECStores signup flow.

---

## 10. Routine Maintenance

### When you update the platform code

If a new phase adds tenant migrations (new tables or columns in the tenant DB):

```powershell
php artisan tenants:migrate
```

This runs all pending tenant migrations across every tenant's database simultaneously. Safe to run at any time — it only applies new migrations, never re-runs completed ones.

If a new phase adds landlord migrations (new tables in `ecstores_central`):

```powershell
php artisan migrate
```

### Checking which migrations have run

```powershell
php artisan migrate:status
```

For tenant migrations, run with context:
```php
// In tinker:
tenancy()->initialize(App\Models\Tenant::find('acme'));
Artisan::call('migrate:status');
tenancy()->end();
```

### Resetting a tenant's password via tinker

```powershell
php artisan tinker
```
```php
tenancy()->initialize(App\Models\Tenant::find('acme'));
App\Models\Admin::where('email', 'owner@example.com')->first()->update(['password' => 'newpassword']);
tenancy()->end();
exit
```

### Viewing a tenant's data directly

```php
// In tinker:
tenancy()->initialize(App\Models\Tenant::find('acme'));
App\Models\Order::count();        // how many orders
App\Models\Product::count();      // how many products
App\Models\User::count();         // how many customer accounts
tenancy()->end();
exit
```

### Storage permissions (Windows — run if you see "Access is denied" errors)

```powershell
& icacls "d:\#WORK\EC_WebCraft\~PRIVATE_HTML\ecstores\storage" /grant "Everyone:(OI)(CI)F" /T
php artisan view:clear
```

---

## 11. Key Artisan Commands

| Command | What it does |
|---|---|
| `php artisan migrate` | Run pending landlord (central) migrations |
| `php artisan tenants:migrate` | Run pending migrations on ALL tenant databases |
| `php artisan tenants:migrate --tenants=acme` | Run pending migrations on one specific tenant |
| `php artisan migrate:status` | Show which migrations have run (landlord) |
| `php artisan optimize:clear` | Clear all caches (config, routes, views, compiled) |
| `php artisan view:clear` | Clear compiled Blade view cache |
| `php artisan config:clear` | Clear config cache |
| `php artisan route:list` | List all registered routes |
| `php artisan tinker` | Open the interactive REPL |
| `php artisan storage:link` | Create the public/storage symlink (run once per machine) |
| `php artisan serve` | Start a dev server on port 8000 (alternative to Laragon) |

---

## 12. Troubleshooting

### "Store Temporarily Unavailable" when visiting a storefront

**Cause:** That tenant is suspended.
**Fix:** Go to `ecstores.test/super/tenants` and click **Reactivate**.

### 500 error on a tenant subdomain after provisioning

**Possible causes:**
1. The subdomain isn't in your hosts file — add `127.0.0.1 newstore.ecstores.test`
2. The tenant migrations didn't run — in tinker: `Artisan::call('tenants:migrate', ['--tenants' => ['newstore']])`
3. The tenant database exists but is empty — re-run migrations as above

### "Access is denied" / `rename()` error on any page

**Cause:** Windows file permissions on the storage folder.
**Fix:**
```powershell
& icacls "d:\#WORK\EC_WebCraft\~PRIVATE_HTML\ecstores\storage" /grant "Everyone:(OI)(CI)F" /T
php artisan view:clear
```

### Admin panel login fails (tenant panel)

**Cause:** Wrong credentials, or admin user doesn't exist.
**Fix:** Reset via tinker (see Section 10).

### Provisioning fails with "Field 'id' doesn't have a default value"

**Cause:** `Tenant::getCustomColumns()` was overridden without merging the parent's `['id']`. This is already fixed in the codebase — if it reappears, check that `app/Models/Tenant.php` uses `array_merge(parent::getCustomColumns(), [...])`.

### Product images show "Loading..." in the admin panel

**Cause:** The Filament FileUpload component's `fetchFileInformation` is set incorrectly.
**Fix:** Ensure `getUploadedFileUsing()` in `ProductResource` has `->fetchFileInformation(false)` and uses a relative `/storage/` URL (not an absolute URL with the tenant subdomain).

### Super-admin panel shows 403 when accessed from a tenant subdomain

**Cause:** This is correct behaviour — `EnsureCentralDomain` middleware blocks `acme.ecstores.test/super`. Always access the super-admin from `ecstores.test/super`.

### Git remote points to Laravel framework repo

**Reminder:** The `origin` remote still points to the Laravel framework repository from when the project was created. Before pushing to your own GitHub:
```powershell
git remote set-url origin https://github.com/yourusername/ecstores.git
git push -u origin main
```

---

## 13. Production Deployment

Production is **live** on inMotion **shared** hosting at `ecstores.ca` (since ~2026-06-04). For full deployment and server reference, see **SHARED_HOSTING_DEPLOYMENT.md** (initial/one-time setup) and **DEPLOY.md** (ongoing releases via Git→GitHub→cPanel `.cpanel.yml`, plus cache-clear/troubleshooting reference).

---

## 14. SaaS Plan Billing — ECW is the single biller

> **IMPORTANT:** ECStores does **NOT** bill merchants for their monthly SaaS plan fee. **EastCoast WebCraft (ECW) is the single biller.** This section used to describe billing merchants here via Laravel Cashier — that path has been **deliberately disabled** to prevent double-billing. Cashier remains installed but inert.

### How billing actually works

- A client buys/renews their ECStores plan through **ECW** (`eastcoastwebcraft.ca`): ECW raises the invoice, takes the first payment, saves the card, and the ECW `auto-charge.php` cron charges that card every cycle.
- When ECW provisions a store it sets the tenant `manually_billed = true`. That flag makes the `CheckTenantSubscriptionActive` middleware **stand down** — the store works without a native Cashier subscription.
- The `Tenant` model still has the `Billable` trait and a Stripe customer is created **only** for non-`manually_billed` tenants (none, in current operation). This is reserved for a possible future "sign up directly on ECStores" flow.

### ⚠ Do NOT create a native subscription for a tenant

There is intentionally **no "Activate Subscription" action** in the Tenants list, and the `/api/v1/platform/tenants/{id}/activate-subscription` endpoint was removed. Creating a Cashier subscription on a tenant that ECW already bills would **charge the customer twice**. As a backstop, `Tenant::newSubscription()` throws a `RuntimeException` for `manually_billed` tenants, and `SuperAdminApiController::listTenants` logs a `CRITICAL` if it ever finds a `manually_billed` tenant carrying a live Cashier subscription. To fix any billing issue, work in **ECW**, not here.

### Changing a tenant's plan

1. Tenants list → tenant → **Change Plan** → select the new plan → Save.
2. This updates `plan_id` only — it changes the **feature set / limits** the store gets. It does **not** touch Stripe (no Cashier `swap()`).
3. The **billing rate change is made separately in ECW** (cancel the old ECW subscription, create the new one at the new rate). See ECW SOP ECS-07 / CL-07.

### Non-payment is automatic (dunning)

Suspension/reactivation on non-payment is driven by **ECW's `auto-charge.php`**, not by Cashier:
- 7 days unpaid → ECW calls `POST /api/v1/tenant/{slug}/suspend` (store goes offline, data retained).
- Payment recovers → ECW calls `…/reactivate` (store back online).
- 30 days unpaid → ECW cancels the subscription; the store stays suspended (data preserved).

You only suspend/reactivate manually here for goodwill or overrides (Section 8). Suspension keys off `tenants.suspended_at` and is independent of `manually_billed`.

### Viewing SaaS revenue

SaaS plan revenue lives in **ECW** (invoices, payments, reports — `eastcoastwebcraft.ca/admin/reports.php`), not in the ECStores Stripe dashboard. (The ECStores Stripe dashboard reflects only Stripe **Connect** storefront activity — see Section 15.)

---

## 15. Stripe Connect

This covers **merchants accepting customer payments**. Each merchant has their own Stripe **Standard** connected account. Customer checkout money is charged directly to the merchant's account; your platform collects a 2% application fee automatically.

> **Stage P-2 (2026-07):** account type moved **Express → Standard**, and onboarding moved **out of the ECStores admin into the ECW client portal**. The ECW client record is the source of truth for the merchant's one connected account. ECStores no longer creates Stripe accounts — the tenant "Payments" page is now a **read-only status panel** that links the merchant back to their ECW portal. See _Onboarding & backfill_ below.

### How it works

- At checkout a `PaymentIntent` is created **directly on the merchant's connected account** (a Stripe *direct charge*, via the `stripe_account` request option), with `application_fee_amount` = 2% of the order total
- Because it is a direct charge, the **merchant is the merchant-of-record and pays Stripe's processing fee (~2.9% + 30¢)**, and your 2% application fee is pure platform margin. (The earlier *destination charge* model settled on the platform account, which then paid the Stripe fee itself — netting the platform a loss on every sale — so it was replaced.)
- A store that has **not** completed Stripe onboarding (`charges_enabled = false`) **cannot take payments** — checkout is blocked at the review step rather than pooling the customer's money in the platform account
- Refunds are issued on the merchant's connected account with `refund_application_fee = true`, so the platform's 2% is returned proportionally and a refund never costs the platform. The connected account used for each order is stored on the order (`stripe_connect_account_id`) so refunds route correctly even if the store later re-onboards
- You see your fee income in your Stripe Dashboard → Transactions → **Collected Fees**; the merchant sees the payment on their own Stripe Dashboard (a Standard account is a full Stripe dashboard at dashboard.stripe.com — there are no Express login-links)

### Fee breakdown — who pays what

Two separate fees come off every storefront sale:

| Fee | Rate | Who pays it | Where it lands |
|-----|------|-------------|----------------|
| **Stripe processing fee** | **2.9% + C$0.30** per successful domestic card charge (Stripe's published rate) | The **merchant** (they are the merchant-of-record on a direct charge) | Deducted from the merchant's Stripe balance |
| **ECStores platform fee** | **2%** of the order total (`application_fee_amount`) | The **merchant** (deducted from their proceeds) | Paid to **your** platform Stripe account |

**Worked example — a C$100.00 order:**

| Line | Amount |
|------|--------|
| Order total (customer pays) | C$100.00 |
| − Stripe processing fee (2.9% + $0.30) | − C$3.20 |
| − ECStores platform fee (2%) | − C$2.00 |
| **= Merchant receives** | **C$94.80** |
| **Your platform keeps** | **C$2.00** |

Notes:
- The **2% platform fee is pure margin** for you — because it is a *direct* charge, Stripe's processing fee comes out of the merchant's side, not yours. (Under the old *destination charge* model the platform paid the Stripe fee itself and netted a loss on every sale.)
- Stripe's 2.9% + $0.30 is the **standard Canadian domestic** rate. International cards (+~1%) and currency conversion (+~2%) cost the merchant more — see [stripe.com/pricing](https://stripe.com/pricing) for current rates. The **2% platform fee is unaffected** by card origin.
- On a **refund**, both fees are returned proportionally: the customer gets their money back, Stripe returns its portion, and your 2% is given back via `refund_application_fee` — so a refund is neutral for the platform (the merchant re-absorbs their proceeds and the fixed $0.30 is not returned by Stripe).

### Storefront order-reconciliation webhook (safety net)

Storefront charges are confirmed in the customer's browser. If that browser dies right after
the card is charged but before the order is written, the charge would otherwise be orphaned.
The platform **Connect webhook** `POST /stripe/connect/webhook` catches this: on
`payment_intent.succeeded` it rebuilds the order from the checkout snapshot (idempotently, so
a re-delivered event never double-books), and `account.updated` keeps each store's
"charges enabled" flag current.

- It is signed by a **separate** secret, `STRIPE_CONNECT_WEBHOOK_SECRET` (not the Cashier
  `STRIPE_WEBHOOK_SECRET`). Setup is in `DEPLOY.md` → "storefront Connect webhook".
- Register it as a **Connect** endpoint in the Stripe dashboard (events
  `payment_intent.succeeded` + `account.updated`).
- Healthy behaviour: Stripe's webhook log shows `200` responses. A `500` means a transient
  failure and Stripe will retry (safe — reconciliation is idempotent). A `400` means a
  signature mismatch — check the secret.

### Viewing your platform fee income

Stripe Dashboard → Transactions → **Collected Fees**. Each entry shows the application fee collected from that merchant's payment.

### Checking a merchant's Connect status

1. Log into the tenant's admin panel (or use **Admin Panel** row action in super-admin)
2. Go to **Settings → Payments** (formerly "Stripe Connect")
3. Status shows: Account ID, Charges (Enabled/Pending), Payouts (Enabled/Pending) — this page is **read-only** (the merchant onboards in their ECW portal)

**Charges Enabled** = the merchant can accept customer payments  
**Payouts Enabled** = Stripe can transfer money to their bank account

Charges can be enabled before Payouts. A merchant can take payments as soon as Charges is enabled; Payouts complete once they verify their bank account with Stripe.

### Onboarding & backfill (Stage P-2)

The merchant connects/manages their Stripe account **once in the ECW client portal** (ECW → Merchant Payments), on a Standard account they own. ECW is the source of truth for the `acct_…` id and propagates it to the store two ways:

- **At provisioning** — if the merchant had already connected before the store was created, ECW sends the id in the provision call and it's seeded into the store's Site Settings automatically.
- **Backfill (the common case)** — merchants usually connect *after* the store exists. ECW pushes the id to the already-provisioned store via `POST /api/v1/tenant/{slug}/stripe-account` (HMAC-signed, same channel as suspend/reactivate). Once written, the store's checkout gate opens and Stripe's `account.updated` webhook keeps `charges_enabled` current.

**Manual backfill (super-admin):** if you need to set/sync a store's connected account by hand (e.g. before the ECW auto-push is wired, or to correct a value), use **Super-admin → Tenants → [store] → Sync Stripe Account**:
1. Open the row action; the modal prefills the store's current `acct_…` id (if any).
2. Paste the merchant's connected account id (from their ECW portal / your Stripe Dashboard → Connect → Accounts). It must start with `acct_`.
3. Optionally toggle **Mark charges as enabled now** — leave it OFF unless the account already accepts charges; Stripe's `account.updated` webhook sets this flag on its own. Turning it on only unblocks checkout immediately.
4. Click **Sync**. The id is written into the store's Site Settings.

### Connect account in test mode vs live mode

In test mode (`STRIPE_SECRET=sk_test_...`), Connect payments are test transactions only. When you're ready to go live:
1. Complete Stripe's platform activation (they review your business — can take a few days) and enable **Connect** on the platform account (one-time).
2. Swap `.env` keys to live keys (`sk_live_...`, `pk_live_...`).
3. Merchants connect their Standard account in the ECW portal on live mode. A Standard account the merchant already owns **connects as-is** (no card/data migration); test-mode connections don't carry over to live.

---

## 16. Branding System

Each tenant store can be fully branded — colours, fonts, logo, favicon, and banner image — without touching any code.

### How it works

Every storefront page reads `site_settings` from the tenant's database and outputs CSS custom properties in the `<head>`:

```css
:root {
  --color-primary:   #3B82F6;   /* buttons, links, accents */
  --color-secondary: #1E40AF;
  --color-accent:    #F59E0B;
  --color-bg:        #FFFFFF;
  --color-text:      #111827;
  --font-heading:    'Inter', system-ui;
  --font-body:       'Inter', system-ui;
  --btn-radius:      0.5rem;    /* 9999px for pill, 0px for sharp */
}
```

Selected Google Fonts are loaded via a `<link>` tag. Logo, favicon, and banner are served from `/storage/branding/`.

### The Branding Studio (tenant admin panel)

Merchants access it at **Settings → Branding Studio**. It has:
- **Colours** — five colour pickers (primary, secondary, accent, background, text)
- **Typography** — dropdown to choose heading and body font from a curated Google Fonts list
- **Button Style** — rounded / pill / sharp radio buttons
- **Logo & Images** — file uploads for logo (replaces the store name text in the header), favicon, and homepage banner
- **Custom CSS** — a textarea for advanced overrides; output last so it wins over everything

Changes take effect immediately on the next page load — no cache to clear, no deployment needed.

### Troubleshooting branding

**Colours not changing:** Confirm the merchant clicked **Save Branding** and hard-refreshed (`Ctrl+Shift+R`). Tailwind CDN caches aggressively.

**Font not loading:** The font name must match exactly one of the curated list options. Fonts are loaded from Google Fonts — requires internet access. System fonts (Georgia, system-ui) don't need a Google Fonts load.

**Logo not showing:** Confirm `php artisan storage:link` has been run on the server. The logo is served via `/storage/branding/...`.

---

*This guide is a living document. Update it as new phases are completed.*  
*For questions about the codebase, see SETUP.md for step-by-step environment setup and TEST_PLAN.md for feature verification.*
