## Block E — Architectural decisions

### D1 — MOQ enforcement location
**Decision:** Enforce at the model layer in `Cart::addItem()` via `ValidationException::withMessages(['quantity' => ...])`.  
**Rationale:** A single enforcement point that no route bypass can circumvent. Controller layer was considered and rejected — it would require duplicating the guard in every cart mutation path.  
**Refinement B:** Error message lives in `lang/en/validation.php` under `cart.moq` so wording changes require no code commits.

### D2 — Razorpay SDK
**Decision:** Official `razorpay/razorpay` package, no wrapper class. Credentials in `config/services.php` under the `razorpay` key.  
**Refinement A:** Do not pin SDK version — `composer require` without a constraint when E4 starts.  
**Three non-negotiable E4 security rules:**
1. Never trust client payment-success claims; always verify HMAC server-side before any state change.
2. Webhook uses a separate `RAZORPAY_WEBHOOK_SECRET`; handler must be idempotent (duplicate events silently no-op).
3. All order state transitions only inside `DB::transaction()` after successful signature verification.

### D3 — Order status for COD
**Decision:** Add `OrderStatus::Confirmed` for COD orders (placed and confirmed immediately). `Pending` stays for Razorpay orders awaiting payment. `payment_method` column added via migration in E3.

### D4 — Cart route auth
**Decision:** All cart and checkout routes behind `['auth', 'approved']` middleware only. No guest cart.  
**Rationale:** Unapproved users cannot see wholesale prices (§6) and therefore must not interact with the cart.

### D5 — Stock decrement
**Decision:** Decrement stock at order-placement inside `DB::transaction()` with an atomic `WHERE stock >= quantity` guard. A 15-minute release job restores stock for stale `Pending` (Razorpay) orders that never reach `Paid`.

### D6 — GST rate storage
**Decision:** No migration needed. `products.gst_rate` already exists as `decimal(5,2) default 18.00` (added in Block D). `TotalsCalculator` reads `product->gst_rate` directly.

### D7 — Shipping cost
**Decision:** Flat rate from `config/shipping.php` via `ShippingCalculator`. No DB table, no per-region logic.  
**Refinement A:** `free_above` defaults to `PHP_INT_MAX` (sentinel — means "never free"), eliminating the null-check branch. One code path always: `subtotal >= freeAbove ? '0.00' : flatRate`.  
**Refinement B:** All money in `TotalsCalculator` uses BCMath at scale=4 internally; outputs half-up rounded to 2dp via `bcadd($value, '0.005', 2)`. Inputs are `decimal:2` strings from Eloquent casts.

### D8 — Double-submit idempotency
**Decision:** Cart is cleared inside the same `DB::transaction()` as order creation. A second form submit reads an empty cart and is redirected before any order is written — no duplicate orders possible.  
**Confirmed E3:** `$cart->items()->delete()` is on line 127 of `CreateOrder.php`, inside the `DB::transaction()` closure that opens at line 50 and closes at line 130.

### D9 — Billing address resolver
**Decision:** `CreateOrder::resolveBillingAddress()` falls back to `resolveShippingAddress()` when no `billing_address` key is present in the checkout payload. Today the fallback always fires. When a future "billing differs" toggle adds `billing_address` to the session payload, no other code changes are required.

### D10 — Order number format
**Decision:** `CP-{Ymd}-{5-char random alphanumeric}`, generated in `Order::booted()` via a `creating` event using `??=` so factories can supply deterministic values. Generated before INSERT — no null-window in the order row.

### D11 — CreateOrder DI
**Decision:** `CreateOrder` receives `TotalsCalculator` and `ShippingCalculator` via constructor injection; bound in `AppServiceProvider` so the container resolves it into `OrderController`. Swappable in unit tests without touching the container.

### D12 — Webhook idempotency storage
**Decision:** Event-id table — `razorpay_webhook_events (id, event_id VARCHAR(50) UNIQUE NOT NULL, created_at)`.  
**Rationale:** Razorpay explicitly does not guarantee event ordering or exactly-once delivery. State-based idempotency ("if order already Paid, skip") handles duplicate same-type events but fails on out-of-order events for the same payment. Event-id deduplication is the primary guard; state-based is the secondary guard. Race condition on concurrent deliveries of the same `event_id` **for non-cancelled orders** is handled by catching `UniqueConstraintViolationException` (returns 200 immediately). Concurrent duplicates for cancelled orders short-circuit at the lock-guarded terminal-state check **before** the INSERT fires — the UCE path is never reached. See D17 for the full ordering rationale.  
**Rejected:** (a) state-based only — insufficient because Razorpay explicitly does not guarantee ordering.

### D13 — Razorpay order creation timing
**Decision:** Razorpay order created at `OrderController::store()` for Razorpay payments — same request as our DB Order creation. `razorpay_order_id` stored on the Order before redirecting to the payment page. The user never sees the payment page without a `razorpay_order_id` set.  
**Atomicity consequence (R2):** The SDK call lives inside `CreateOrder`'s `DB::transaction()` alongside `Order::create()` and the stock decrement. Any SDK failure (timeout, API error, 5xx) rolls back all three atomically — no orphaned Order row, no phantom stock decrement. `OrderController::store()` catches the propagated exception and redirects to the review page with an error flash. Regression guard: `OrderPlacementTest::test_razorpay_api_failure_during_order_placement_rolls_back_cleanly` (T2) — if the SDK call is ever moved outside the transaction, T2's `assertDatabaseCount` and stock assertions fail immediately.

### D14 — Callback vs webhook authority
**Decision:** Both verify independently. Callback does immediate HMAC verification + Pending→Paid transition on success (best UX — order page shows "Paid" immediately after checkout). Webhook is the idempotent catch-all for mobile drop-offs, browser crashes, and network failures. Event-id table (D12) prevents double-processing when both arrive.  
**R3 binding (dual-path invariant):** The Pending→Paid transition must be reachable via two independent code paths (callback and webhook). Side effects at the transition layer must be idempotent — executing them once via callback and once via webhook must produce the same observable outcome as executing them exactly once. This invariant is enforced structurally: the event-id table (D12) ensures only one path completes the INSERT and the state guard on the second path short-circuits before any side effect fires. First consequence: the OrderConfirmed notification (see D20) is dispatched inside the state-transition transaction on both paths; the idempotency guarantee means the customer receives exactly one email regardless of which path fires first.

### D15 — Release job
**Decision:** `ReleaseStaleRazorpayOrders` artisan command, scheduled every minute. Finds `status=pending AND payment_method='razorpay' AND created_at < now() - config('razorpay.payment_window_minutes')`. Processes each order individually in its own `DB::transaction()` — restore stock via `increment` (no WHERE guard needed; adding back not subtracting) then `status = cancelled`. Included in E4, not a separate block.  
**Refinement A (added during E4 implementation):** Each per-order transaction opens with `Order::lockForUpdate()->find($id)` and re-checks `status === Pending` before acting. Reason: between the outer bulk SELECT and the inner transaction, a concurrent webhook can transition the order to Paid — without the lock, both the job and the webhook read Pending, the job cancels an already-paid order and incorrectly restores stock. `lockForUpdate()` serializes the two writers; the job skips any order it finds in a non-Pending state after acquiring the lock.  
**Refinement B — Scheduling guard (R4):** Command registered with `->withoutOverlapping(300)`. The 300-second lock timeout exceeds the schedule interval (every minute), so two concurrent instances cannot run simultaneously even if a previous run stalls. Per-order idempotency from Refinement A still holds independently: if the lock expires and a second instance starts before the first finishes, the `lockForUpdate()` + status re-check inside each per-order transaction prevents double-cancellation for any individual order. R4 binds `->withoutOverlapping(300)` as the schedule-level guard; Refinement A is the per-order guard.

### D16 — Signature verification layering
**Decision:**
- `RazorpayService::verifyPaymentSignature(orderId, paymentId, sig): bool` — HMAC-SHA256 of `"orderId|paymentId"` with API secret. Pure PHP, no API call.
- `RazorpayService::verifyWebhookSignature(rawBody, sig): bool` — HMAC-SHA256 of raw body with `RAZORPAY_WEBHOOK_SECRET`. Pure PHP, no API call.
- Both methods unit-tested in isolation with known secrets (no mocking).
- `PaymentController` and `WebhookController` call the service and abort immediately on failure.
- Service injected via constructor; swapped in feature tests via `app()->instance()`.

### D17 — Event-id INSERT ordering relative to terminal-state guards
**Decision:** Inside the webhook transaction, the row-level lock and the Cancelled guard fire **before** the `RazorpayWebhookEvent::create()` INSERT; the Paid guard fires **after** the INSERT. The asymmetry is deliberate.

- **Cancelled (guard before INSERT):** A cancelled order is terminal — stock has been restored, resurrection is forbidden. Recording the event_id would mark the event as "processed" in the idempotency table when nothing was actually processed. The table must remain a faithful log of events that reached the processing logic, not a log of events that were received. Future retries of the same event_id go through the guard check again (no UniqueConstraintViolationException shortcut), which is acceptable — the cancelled guard is cheap.

- **Paid (guard after INSERT):** A Paid order reached that state because the *original delivery* of this event was processed (order transitioned to Paid). A retry of the same event_id IS a duplicate of a handled event; recording it in the table and short-circuiting via UniqueConstraintViolationException is correct. If the INSERT were omitted for Paid orders, every retry would re-acquire the lock and re-evaluate state rather than short-circuiting at the DB constraint — more lock contention with no semantic benefit.

**Concurrency note:** Placing the lock before the INSERT (vs. the prior INSERT-first ordering) means two concurrent duplicate deliveries both acquire the lock before one hits the constraint exception. The efficiency difference is immaterial at any realistic webhook volume, and the correct idempotency-table semantics are worth the trade.

**Test:** `WebhookTest::test_webhook_for_cancelled_order_returns_200_and_does_not_resurrect` asserts `assertDatabaseMissing('razorpay_webhook_events', ...)` — this assertion is the live regression guard for this ordering invariant.

### D18 — Order index authorization
**Decision:** `OrderController::index()` uses query-as-authorization: `Order::where('user_id', auth()->id())->latest()->paginate(15)`. No policy needed for scoped collections — the WHERE clause structurally prevents returning another user's orders. `OrderPolicy::view()` (D19) applies only to single-record access on `orders.show`.  
**Rationale:** A policy on a collection would require loading all records and filtering, or a custom before() hook that still delegates to a WHERE clause. The query itself is the authorization; duplicating that logic in a policy adds indirection with no safety gain. Consistent with D4 (route-level auth) and D19 (record-level policy for show).

### D19 — OrderPolicy for orders.show
**Decision:** Introduce `app/Policies/OrderPolicy.php` with `view(User $user, Order $order): bool` returning `$user->id === $order->user_id`. Laravel 11 auto-discovers policies by naming convention (`Order` model → `OrderPolicy`); no `AuthServiceProvider` registration required. `OrderController::show()` calls `$this->authorize('view', $order)` before rendering; non-owners receive a 403.  
**Rationale:** Per-record authorization on `orders.show` requires a policy (query-as-authorization only applies to collections). Naming-convention auto-discovery is the Laravel 11 default — explicit registration would be a redundant and error-prone duplicate. Block F composability is the decisive factor: when `OrderResource` (Filament) adds admin actions in Block F, the same `OrderPolicy` can be extended with `update()` / `delete()` methods without touching controller code.  
**F1 implementation (Block F implication now closed):** `OrderPolicy` extended in F1 alongside D21. `view()` now returns `$user->id === $order->user_id || $user->is_admin` — owner OR admin; the D19 owner-only invariant is preserved for non-admins (F1 Test 5). Two methods added: `viewAny(User $user): bool` returns `$user->is_admin` — consumed by Filament `OrderResource` listing (F2); inert for customers because D18 query-as-authorization on `OrderController::index()` never calls `viewAny`. `update(User $user, Order $order): bool` returns `$user->is_admin` — consumed by F3's Push-to-Shiprocket action; landed now so the policy is complete before F2 builds the Resource.

### D20 — OrderConfirmed notification dispatch mechanism
**Decision:** `OrderConfirmed` notification (implements `ShouldQueue`, mail channel) is dispatched via `->afterCommit()` from inside the state-transition `DB::transaction()` on all three paths: `CreateOrder::execute()` (COD), `PaymentController::callback()` (Razorpay callback), `WebhookController::handle()` (Razorpay webhook). Dispatch line: `$order->user->notify((new OrderConfirmed($order))->afterCommit());`  
**Mechanism:** `->afterCommit()` defers queue dispatch until the surrounding transaction commits. If the transaction rolls back (e.g. stock decrement fails on COD path, or webhook DB write fails), the notification is never enqueued — no phantom confirmation email.  
**R3 consequence (from D14):** Both Razorpay paths dispatch the notification; the D12 event-id idempotency ensures only one path completes the state transition, so the customer receives exactly one email.  
**T3:** Guards R3 once-only on Razorpay paths — asserts `Notification::assertSentTo($user, OrderConfirmed::class)->times(1)` after simulating both callback and webhook arriving for the same order.  
**T4:** Guards rollback semantics on COD path — asserts notification is NOT dispatched when `CreateOrder::execute()` rolls back (e.g. simulated stock decrement failure inside the transaction).  
**Tests 18/19:** Form the `->afterCommit()` mechanism regression guard pair. Test 18 is a property assertion (confirms the modifier is set on the notification object). Test 19 is the behavioral assertion (confirms no dispatch on transaction rollback). Neither T-tag is assigned — T-tags are reserved for tests that anchor a single R-tag invariant; tests 18 and 19 together anchor the mechanism, not an independent invariant.

### D21 — Admin distinction and Filament authorization gate
**Decision:** Add `is_admin BOOLEAN NOT NULL DEFAULT FALSE` to the `users` table (no index — admin count is tiny; `canAccessPanel` checks an already-loaded `User` object in memory, never a `WHERE is_admin` scan). `User` implements `Filament\Models\Contracts\FilamentUser` with:

    public function canAccessPanel(Panel $panel): bool { return $this->is_admin; }

No status check. `is_admin` is the sole gate.

**Promotion:** `php artisan user:make-admin {email}` — finds user by email, sets `is_admin = true` via **direct property assignment** (`$user->is_admin = true; $user->save()`), not `update()` or `fill()`. Those are the array/mass-assignment path, which filters against `$fillable`; `is_admin` is deliberately absent from `$fillable`, so they would silently discard the value, save nothing, exit 0, and promote nobody. In production that means the real admin never gets promoted and the gate locks everyone out. Direct property assignment names one specific attribute in your own code — it bypasses `$fillable` by design. Command is idempotent on re-run.  
The migration does NOT auto-promote anyone; guessing the admin by email would be unsafe across deploys. Promotion is a deliberate operator step.

**Why boolean, not role enum:** Two roles forever — YAGNI. A role enum would require a migration and cast change every time a new internal role appeared. A boolean `is_admin` never changes shape; future distinctions (read-only admin, super-admin) are a new column, not an enum expansion.

**Why `canAccessPanel` ignores status:** Admin-ness and B2B approval are independent axes. The Block A admin row was created via `php artisan filament:user` before the approval workflow existed; it has `status = pending` (the DB default). A status check would lock that row out of the panel it was created to manage. `is_admin = true` is unconditional.

**Why `is_admin` is not in `$fillable`:** That absence is the structural mass-assignment guard. `User::create(['is_admin' => true])` silently ignores the field; `fill(['is_admin' => true])` likewise. The only promotion path is the artisan command. Tested explicitly in F1 Test 6 (via `fill()`, the correct mass-assignment path — factory `create()` uses `forceFill()` internally and bypasses `$fillable`, so it is the wrong vehicle for testing this guard).

**Filament v5 denial behaviour (confirmed from `Authenticate.php` source):**  
`abort_if(! $user->canAccessPanel($panel), 403)` — authenticated non-admins receive HTTP 403, not a redirect to login. The login redirect fires only for unauthenticated users.

**Pre-F1 state (Amendment 3 — honest record):** The pre-F1 suite was 178/178 green despite the panel being wide open because no test ever exercised panel ACCESS. `ApprovalWorkflowTest` uses `Livewire::test(ListUsers::class)`, which instantiates the Livewire component directly and bypasses the HTTP middleware stack entirely — including Filament's `Authenticate` gate. Those tests covered the approval actions (the rooms) but never knocked on the front door. F1 Tests 1 and 2 are the first to do that.

**Deploy sequence (see `DEPLOY.md`):** (1) Run migration, (2) run `user:make-admin <admin@email>`, (3) verify `/admin` reachable as admin and blocked (403) for non-admin — all in one maintenance window. Warning: deploying gate code without step 2 locks everyone out with no in-application recovery.

---

## E4 approved test plan (28 tests)

### Feature — PaymentFlowTest.php (10 tests)
1. test_payment_page_requires_auth_and_approved
2. test_payment_page_returns_200_for_owner_of_pending_razorpay_order
3. test_payment_page_returns_403_for_non_owner
4. test_payment_page_returns_404_for_confirmed_cod_order
5. test_valid_payment_callback_transitions_order_to_paid
6. **test_forged_signature_keeps_order_pending** ← threat model
7. test_missing_payment_id_in_callback_keeps_order_pending
8. test_callback_is_idempotent_when_order_already_paid
9. **test_callback_after_webhook_already_transitioned_to_paid** ← race: webhook wins
10. test_cod_order_placement_redirects_to_order_show (replaces E3 stub)

### Feature — WebhookTest.php (8 tests)
11. test_webhook_payment_captured_transitions_order_to_paid
12. **test_webhook_with_invalid_signature_returns_400_and_no_state_change** ← threat model
13. **test_webhook_with_api_secret_instead_of_webhook_secret_returns_400** ← threat model
14. **test_webhook_replay_returns_200_and_is_a_noop** ← event-id idempotency
15. test_webhook_unknown_event_type_returns_200_and_is_ignored
16. test_webhook_for_unknown_razorpay_order_id_returns_400
17. **test_webhook_for_cancelled_order_returns_200_and_does_not_resurrect** ← logs warning, no state change
18. test_webhook_payment_captured_stores_razorpay_payment_id_on_order

### Feature — ReleaseJobTest.php (3 tests)
19. test_job_cancels_stale_pending_razorpay_orders_outside_window
20. test_job_does_not_cancel_orders_inside_payment_window
21. test_job_restores_stock_when_cancelling_stale_order

### Unit — RazorpayServiceTest.php (7 tests)
22. test_payment_signature_passes_with_correct_api_secret
23. test_payment_signature_fails_with_wrong_api_secret
24. test_payment_signature_fails_when_payment_id_is_tampered
25. test_payment_signature_fails_when_order_id_is_tampered
26. test_webhook_signature_passes_with_correct_webhook_secret
27. test_webhook_signature_fails_with_wrong_webhook_secret
28. **test_webhook_signature_fails_when_api_secret_is_used_instead_of_webhook_secret** ← proves secrets non-interchangeable

---

## Block E — Deferred items

1. **`session('checkout')` payload not re-validated at order placement.** `OrderController::review()` and `::store()` trust whatever is in `session('checkout')` without re-running the checkout form's validation rules. The risk is a stale session from before a deploy that changed the schema (e.g. added a required field). Mitigation for v1: `CheckoutController::store()` validated the structure before writing to session; the user cannot put arbitrary data there. Revisit if the checkout schema changes in a future block — add a lightweight `Validator::make()` call at the top of `store()` and redirect to `checkout.show` on failure.

---

---

## Block F2 — Filament admin Resources

### Reference convention — UserResource as the template
All three F2 Resources follow UserResource's conventions: inline `Section`-grouped form/table structure (within their respective Schema/Table classes), `Filament\Actions\` namespace for all actions (never sub-namespaces), `->badge()->color(fn (Enum $state) => $state->color())->formatStateUsing(...)` for badge columns, `->modalHeading()` / `->modalSubmitActionLabel()` for modal patterns, and `->successNotificationTitle()` for action notifications.

### Product images — Relation Manager (not FileUpload on products column)
Product images are managed via `ImagesRelationManager` bound to `Product::images()` (HasMany on `product_images`). A `FileUpload` on a `products.image` column would be wrong — that column does not exist. The Relation Manager handles `path`, `is_primary`, and `sort_order` with `->visibility('public')` on the upload (default is private).

### gst_rate — Select constrained to legal slabs
`gst_rate` is a `Select` whose options are the five Indian GST slabs: 0, 5, 12, 18, 28. A `->rules(['in:0.00,5.00,12.00,18.00,28.00'])` server-side guard rejects values outside the slabs even if the UI is bypassed. A free decimal `TextInput` would allow an admin to save a non-existent rate that then flows into GST invoices, violating compliance rules (D6).

### OrderResource — read-only, no create, no delete
Orders are immutable historical records. `getPages()` exposes only `index` and `view` — no `create`, no `edit`. `ListOrders::getHeaderActions()` returns an empty array. The table has `ViewAction` only; no `EditAction`, no `DeleteAction`. The snapshot columns (`product_name`, `unit_price`, `hsn_code`, address JSON) exist precisely so history is never mutated. OrderResource performs NO status mutation. Admin-driven status transitions are deferred to F3, where they must honor the side-effect contracts that a generic Filament action bypasses: Pending→Paid is HMAC-verified and dispatches OrderConfirmed via `->afterCommit()` (D14/D16/D20); cancellation restores stock via the guarded release-job path (D5/D15). A bare status flip skips signature verification, skips the confirmation email, and strands the stock decrement. The legitimate admin transitions (shipped/delivered) are downstream of Shiprocket and therefore F3 by definition. No Shiprocket wiring — that is F3.

### No tree-view plugin for CategoryResource
`parent_id` is a plain `Select` that excludes the current record via a `->rules(['not_in:$id'])` guard and a query-filtered `->options()` closure. Nesting is shallow (parent → child, no deeper). A tree-view package would add a dependency for no semantic gain at this depth.

### No spurious ProductPolicy or CategoryPolicy
Filament's default for a model with no policy is: allow any user who passed `canAccessPanel`. Only `OrderPolicy` exists (from D19, because orders had a pre-existing customer ownership policy). Adding `ProductPolicy` or `CategoryPolicy` would be redundant indirection.

---
## Block F3 — Deferred items

1. **CGST/SGST-vs-IGST classification keys off state NAME, not state code.** D23
   compares `config('gst.supplier_state')` against `order->shipping_address['state']`
   case-insensitively. This handles casing ("Delhi"/"delhi") but not spelling or
   abbreviation variance ("Tamil Nadu"/"Tamilnadu"/"TN"). Addresses are stored as
   free-text JSON with no normalization, so a buyer whose typed state differs from the
   supplier-state string by more than case is misclassified — an intra-state buyer
   gets IGST instead of CGST+SGST, a compliance error on a real invoice that affects
   the buyer's Input Tax Credit. `config/gst.php` already carries `state_code`;
   hardening means keying the comparison off a normalized state code rather than the
   display name, which requires capturing the buyer's state as a constrained code
   (dropdown / fixed list) rather than free text. Acceptable for v1 ONLY if address
   capture constrains state to a fixed list; revisit before that assumption breaks.
   Lower-priority companion: `invoice_number` is in `#[Fillable]` — harmless (not a
   privilege field, assigned write-once by InvoiceService at Paid, never via mass
   assignment in practice) but mildly incongruent with how D10/D21 treat other
   immutable identifiers. Left as-is; noted for consistency.


## Block C — Deferred items

1. **verification_notes overwrite. RESOLVED — Block F2.** Each Approve/Reject/Block action now appends a timestamped line (`[YYYY-MM-DD HH:mm] [Admin Name] ACTION: note`) via `UserResource::appendNote()` instead of overwriting. Both note entries survive sequential status changes. Behavioral regression guard: `AdminResourcesTest::test_verification_notes_appends_across_sequential_status_changes`.

2. **Admin vs B2B user separation. RESOLVED — D21 (F1).** `is_admin` boolean column, `FilamentUser::canAccessPanel()` returning `$this->is_admin`, `user:make-admin` artisan command. See D21 for full rationale including why boolean (not role enum), why status is excluded from the gate, and the deploy/lockout sequence.

3. **Self-deletion policy.** Pending/rejected/blocked users can self-delete via /profile DELETE route. 
   Confirm with client; document final decision.

## Block C — Resolved during build

- Filament v3→v5 migrations: Form→Schema, Tables\Actions→Actions
- PHP 8.4 property type strictness on inherited statics

## Block D

- Per spec §2/§6, all price information is hidden from guests. If the 
  client later requests public MRP display, only product-card.blade.php 
  needs editing.

## Block F3 — Decisions

### D22 — Invoice numbering format, reset, gaplessness, and cancellation handling

**Format:** `CP/{FY}/{NNNN}` where `FY` is the Indian financial year (`2026-27`) and `NNNN` is a zero-padded four-digit sequence number. Example: `CP/2026-27/0001`. The four-digit width accommodates up to 9 999 invoices per financial year; if volume ever exceeds this, the format expands naturally (no UNIQUE violation — the column is VARCHAR and the sequence itself is unconstrained).

**Financial year boundary:** India's financial year runs April 1 through March 31. The current FY is computed as:
  `startYear = (now()->month >= 4) ? now()->year : now()->year - 1`
  `FY = "{startYear}-" . substr(startYear + 1, 2)` → `"2026-27"`

**Reset policy:** Per financial year. Sequence resets to 1 on the first invoice of each new FY. A separate row exists in `invoice_sequences` for each FY.

**Gaplessness mechanism — DB-level, two-step:**
  1. `DB::table('invoice_sequences')->insertOrIgnore([financial_year, last_number=0, ...])` — creates the FY row if absent. `insertOrIgnore` is atomic; a concurrent first-invoice race produces exactly one row (the other silently ignores). No UNIQUE violation propagates.
  2. `InvoiceSequence::lockForUpdate()->where('financial_year', $fy)->firstOrFail()` then `->increment('last_number')` — acquires an exclusive row-level lock (InnoDB `SELECT … FOR UPDATE`) before incrementing. Concurrent transactions for the same FY queue behind the lock; each increments in turn. Under SQLite (tests), the database-level write lock provides equivalent serialization.

  This two-step (`insertOrIgnore` then `lockForUpdate` + `increment`) cannot produce duplicates or gaps under any level of concurrent order placement. The UNIQUE constraint on `financial_year` is the belt; the `lockForUpdate` is the suspenders.

**Cancellation and gap avoidance:** Invoices are assigned ONLY when the order reaches `Paid` (Razorpay) or `Confirmed` (COD). Orders that are cancelled, refunded, or otherwise never reach these states are never assigned an invoice number — no gap is created. This is the strictest possible gaplessness guarantee.

**Immutability:** Once an invoice number is assigned to an order, it is never changed. `InvoiceService::generateForOrder()` checks `$order->invoice_number !== null` first; if set, it regenerates the PDF using the existing number without touching the sequence table. An admin "Regenerate invoice" action reuses the same number.

---

### D23 — CGST/SGST vs IGST determination

**Rule:** Compare the supplier's state (from `config('gst.supplier_state')`) against the buyer's state (from `$order->shipping_address['state']`), case-insensitively. If states match, it is an **intra-state** supply → CGST + SGST each at half the GST rate. If states differ, it is an **inter-state** supply → IGST at the full GST rate.

**Implementation:** `InvoiceService::taxSplitFor(OrderItem $item, bool $isIntraState): array` returns `['cgst_rate' => ..., 'sgst_rate' => ..., 'igst_rate' => ..., 'cgst_amount' => ..., 'sgst_amount' => ..., 'igst_amount' => ...]`. All amounts computed with BCMath at scale=4, rounded half-up to scale=2.

**Config:** `config/gst.php` holds `supplier_state` (e.g. `'Maharashtra'`). The state comparison uses `strtolower()` on both sides.

**Compliance note:** This determination is compliance-critical. The wrong split (CGST+SGST vs IGST) causes an incorrect GST return filing for both supplier and buyer. Unit tests pin both branches explicitly.

---

### D24 — Shiprocket service conventions and auth-token R-tag invariant

**Service shape:** `ShiprocketService` follows the `RazorpayService` template: constructor-injected credentials (email + password + api_url from `config('services.shiprocket.*')`), bound as a singleton in `AppServiceProvider`. Feature tests swap the binding via `app()->instance()` — no real HTTP calls in the test suite.

**Auth token R-tag invariant (R5):** The Shiprocket API requires a JWT token obtained by POSTing credentials to `/auth/login`. This token is an ephemeral, in-process-only secret:
  - **Never logged** — not passed to `Log::info()`, `Log::debug()`, or any log call.
  - **Never serialized into any response** — not returned from any controller, not included in Livewire component state, not present in any HTTP response body.
  - **Never stored in the orders table** — `orders.shiprocket_shipment_id` and `orders.awb_code` are the only Shiprocket-derived columns; the token appears in neither.
  - Token is assigned to a `private` local variable inside `getToken()`, consumed by HTTP calls, and discarded at method return. No property stores it.
  - Treat with the same rigor as `RAZORPAY_WEBHOOK_SECRET`.

**Credentials:** `SHIPROCKET_EMAIL` and `SHIPROCKET_PASSWORD` in `.env`; read via `config('services.shiprocket.email')` and `config('services.shiprocket.password')`. The API base URL defaults to `https://apiv2.shiprocket.in/v1/external` and is overridable via `SHIPROCKET_API_URL` for test/staging environments.

---

### D25 — Sanctioned order-status write paths

**Decision:** The `push_to_shiprocket` Filament action on the `ViewOrder` page is the ONLY admin-driven order status write path beyond the HMAC-verified payment transitions. Specifically:

  - **Pending → Paid:** HMAC-verified only. Paths: `PaymentController::callback()` and `WebhookController::handle()`. Never via a Filament action.
  - **Paid/Confirmed → Shipped:** `push_to_shiprocket` action ONLY. It calls `ShiprocketService::createShipment()`, stores `shiprocket_shipment_id` and `awb_code`, and transitions status to `Shipped` — all inside `DB::transaction()`.
  - **Shipped → Delivered:** Not yet built in v1. When added in v1.1, it must follow the same sanctioned-action pattern.
  - **Any → Cancelled/Refunded:** Not admin-driven in v1. Cancellation is automated (release job, D15). Refunds are manual outside the system.

  No `EditAction`, no generic `update_status` action, no bare `$order->update(['status' => ...])` anywhere on the ViewOrder page or OrderResource. The negative test (`test_view_order_header_actions_are_limited_to_sanctioned_set`) is the live regression guard.

---

### D26 — Inventory seam: internal system of record now; ERP integration hook

**Current decision:** Stock is managed entirely within this application. `products.stock` is the authoritative inventory counter. The D5 atomic `WHERE stock >= quantity` decrement (inside `DB::transaction()` in `CreateOrder::execute()`) is the single write path for stock reduction at order time. The D15 release job is the single write path for stock restoration on cancellation.

**ERP integration seam (future):** When a third-party ERP (e.g., Tally, SAP) is integrated, it will need to become the system of record for inventory. The plug-in points are exactly two:
  1. **Stock decrement at order placement** — `CreateOrder.php` lines 65–73 (`Product::where(…)->decrement()`). An ERP integration would either (a) replace this call with an ERP API call that returns the committed stock, or (b) keep the local decrement and enqueue a sync job here.
  2. **Stock restoration on cancellation** — `ReleaseStaleRazorpayOrders.php` inside the per-order transaction. Same two options apply.

  No ERP sync code is built in v1. The two decrement/increment points above are the sole mutation sites. Any future ERP integration changes only these two sites.

  **Tests pinning D5:** `OrderPlacementTest::test_stock_is_decremented_on_order_placement` and `test_stock_guard_rejects_order_when_stock_is_insufficient` both exist and pass as of F3. They are the regression guards for the D5 invariant.

---

---

## Block F4 — Decisions

### D27 — Admin 2FA: package, storage, composition with D21, recovery codes, migration path

**Package:** `pragmarx/google2fa` (core, no Laravel wrapper). Rationale: the wrapper adds a Facade and request helpers we do not need — we build our own middleware and controllers. The core provides everything: `generateSecretKey()`, `verifyKey($secret, $code, $window)`, `getQRCodeUrl($issuer, $holder, $secret)`. Maintained by PragmaRX; PHP 8+ compatible; ~4M monthly downloads on Packagist as of decision date.

**Secret storage:** `totp_secret VARCHAR(255) NULL` added to `users`. Cast as `'encrypted'` in the `User` model — Laravel's `Encrypted` cast applies `APP_KEY`-backed AES-256-CBC before INSERT/after SELECT. The raw DB column is never the plaintext base32 secret; the decrypted value is only available through the model cast. Tested: assert raw `DB::table` value ≠ model value.

**Recovery codes:** Separate `two_factor_recovery_codes` table (`user_id FK, code_hash VARCHAR(255), used_at TIMESTAMP NULL, timestamps`). Ten 10-character random alphanumeric codes generated at enrolment. Plaintext shown to the admin ONCE (flashed to session, rendered by the setup confirm view); then bcrypt-hashed and inserted. Consumption is atomic: `TwoFactorRecoveryCode::lockForUpdate()->where(user_id, userId)->whereNull(used_at)->get()` inside `DB::transaction()`, iterating with `Hash::check()`, marking `used_at = now()` on the matching row. Concurrent recovery attempts for the same code queue behind the InnoDB row lock. Single-use enforced by `used_at` + lock-before-check.

**Composition with D21:** `canAccessPanel()` stays `return $this->is_admin` (unchanged — D21 invariant preserved). A separate `RequireTwoFactor` middleware added to the panel's `->authMiddleware([Authenticate::class, RequireTwoFactor::class])` chain in `AdminPanelProvider`. Running after `Authenticate` guarantees the user is already authenticated and `is_admin` before the 2FA check runs. Combined effect: panel is reachable only when `is_admin` (canAccessPanel + Authenticate) **AND** session has valid 2FA verification (RequireTwoFactor). Reason for middleware rather than modifying canAccessPanel: `canAccessPanel() === false` triggers `abort(403)` — no redirect path; admins without a TOTP secret cannot be sent to the enrolment page via a 403. The middleware layer can redirect.

**Enrolment flow:** On first admin login after deploy (or after `user:make-admin` promotes a new admin), `RequireTwoFactor` finds `totp_secret === null` and redirects to `GET /two-factor/setup`. The setup controller generates a fresh secret (in session only), renders the QR/manual-key page, and accepts a confirmation TOTP code. Only a valid code commits the secret to DB. Cannot be skipped — `RequireTwoFactor` intercepts every panel route until `totp_secret` is set and `session('two_factor_verified')` is truthy.

**Verification flow:** Enrolled admin visits any `/admin/*` URL → `RequireTwoFactor` checks `session('two_factor_verified')` → absent → redirect to `GET /two-factor/challenge` → admin enters 6-digit TOTP or recovery code → on success, `session(['two_factor_verified' => true])` → redirect back to `/admin`.

**Session binding:** `session('two_factor_verified')` only — not a cookie, not a DB flag, not a `remember_me` token. Re-verification required on every new session (browser close, session expiry). No "trust this device."

**Rate limiting:** `RateLimiter::tooManyAttempts('two-factor:' . $userId, 5)` with a 900-second (15-minute) decay window. Applied to both TOTP verification and recovery-code verification routes. The 6th attempt within 15 minutes returns a validation error without touching the TOTP logic. Rate limiter cleared on successful verification.

**2FA routes:** Outside Filament's panel routing (no `/admin` prefix conflict). Under `auth` middleware, no panel middleware:
  - `GET  /two-factor/setup`   → `TwoFactorSetupController@show`
  - `POST /two-factor/setup`   → `TwoFactorSetupController@store`
  - `GET  /two-factor/challenge` → `TwoFactorChallengeController@show`
  - `POST /two-factor/challenge` → `TwoFactorChallengeController@verify`
  - `POST /two-factor/recover`  → `TwoFactorChallengeController@recover`

**Migration path for existing admins post-deploy:** After the migration runs, all admins have `totp_secret = NULL`. The F4 deploy window is: run migration → restart app → every admin sees the setup redirect on next login. See DEPLOY.md §F4 for the bootstrap sequence.

---

### D28 — Filament panel path, robots/sitemap policy, and launch checklist

**Panel path:** Kept at `/admin`. Rationale: the 2FA gate (D27) provides the privileged-access protection that obscurity would otherwise attempt; a non-guessable path would add nothing material once 2FA is required. Decision recorded here to close the audit question. If the client requests a custom path in future, change `->path('admin')` in `AdminPanelProvider` and update `DEPLOY.md`.

**robots.txt policy:** Disallow `/admin`, `/account`, `/cart`, `/checkout`, `/orders`, `/payments`. Allow all public catalog routes (`/`, `/categories`, `/products`). `Sitemap:` directive references `{APP_URL}/sitemap.xml`. The file is served via a named Laravel route (not a static file) so the URL is config-driven and correct across environments.

**Sitemap policy:** Includes home page, all active root categories and their active subcategories, all active non-soft-deleted products. Excludes: inactive products, soft-deleted products, non-public routes (admin, cart, checkout, orders). Updated lazily (generated on-request, not cached) for v1; add Redis caching if sitemap generation becomes slow.

**Launch checklist** (documented here; operator steps in `DEPLOY.md §Launch checklist`):
  1. `APP_DEBUG=false` and `APP_ENV=production` in production `.env`.
  2. `APP_URL` set to `https://crystalparadise.in` (HTTPS).
  3. `QUEUE_CONNECTION=redis` and `CACHE_STORE=redis` in production `.env`.
  4. Queue worker supervised (supervisor or Forge, running `php artisan queue:work`).
  5. Scheduler cron live (`* * * * * cd /path && php artisan schedule:run`).
  6. Backups running (spatie/laravel-backup or manual; retention ≥ 30 days).
  7. SSL certificate active; `URL::forceScheme('https')` enabled in `AppServiceProvider` for production.
  8. All 207+ tests green on the deploy branch.
  9. Every admin user has completed 2FA enrolment (verified in the `users` table: `totp_secret IS NOT NULL`).
  10. Razorpay webhook URL registered in Razorpay dashboard; webhook secret matches `RAZORPAY_WEBHOOK_SECRET`.
  11. Shiprocket credentials verified (`SHIPROCKET_EMAIL`, `SHIPROCKET_PASSWORD`).
  12. GST config filled in (`GST_SUPPLIER_GSTIN`, `GST_SUPPLIER_NAME`, etc.).
  13. Mail config pointing to production SMTP (not `log` driver).

---

## Block F3 — Deferred items (carried in from F2)

1. **OrderResource status transitions are entirely uncovered — add the negative guard when the F3 transition action is built.** F2 shipped (then removed) a view-page status-mutation Action with zero test coverage; the suite was 194 green both with and without it, because no test ever reached the path. That is the same blind spot as the pre-F1 panel hole (D21 "Pre-F1 state"): a green suite certified nothing about an unexercised admin action. F3 introduces the *legitimate* admin status transitions (shipped/delivered, downstream of Shiprocket) with their side-effect contracts — HMAC-verified Pending→Paid dispatching OrderConfirmed via `->afterCommit()` (D14/D16/D20), cancellation restoring stock via the guarded release-job path (D5/D15). When that action lands, write two guards in the same commit: (a) a negative test asserting `OrderResource` exposes NO ad-hoc status-write path outside the sanctioned F3 action — `ViewOrder::getHeaderActions()` contains only the approved transition action, never a bare `update(['status' => ...])`; (b) a positive test that the sanctioned action fires its side effects exactly once. Until F3, OrderResource writes no order state at all (current correct state). The risk this note guards against: a future session re-reads the log, re-adds a convenience status action, and goes green again because nothing tests its absence.

---

### D29 — 2FA setup skip: opt-in at enrolment, sticky once enrolled

**Amends D27** — D27 states "Cannot be skipped — `RequireTwoFactor` intercepts every panel route until `totp_secret` is set and `session('two_factor_verified')` is truthy." D29 introduces a skip mechanism for unenrolled admins only. The enrolled-admin invariant from D27 is unchanged.

**The rule:**
- An admin who has **not** enrolled (`totp_secret IS NULL`) may skip the setup page and reach the panel.
- An admin who **has** enrolled (`totp_secret IS SET`) **must** pass the TOTP/recovery challenge every session — no skip, no bypass, ever.

**Session-scoped, not persisted:** The skip flag (`session('two_factor_setup_skipped')`) lives only in the current PHP session. It is not stored in the database, not in a cookie, not in any `remember_me` token. An admin who skips is offered setup + skip **again** on every new login. No "remember my skip" behavior exists.

**Structural invariant — enrolled-cannot-bypass:** The skip flag is consulted ONLY inside the `totp_secret === null` branch of `RequireTwoFactor::handle()`. An enrolled admin's code path goes directly to the `two_factor_verified` check — the skip flag is never read, not even to be ignored. This means: even if a stale skip flag exists in an enrolled admin's session (e.g. from before they enrolled), it is structurally inaccessible. The separation is in the code branch, not a runtime check. `TwoFactorTest::test_enrolled_admin_with_skip_flag_is_still_redirected_to_challenge` is the live regression guard for this invariant.

**Skip endpoint defensive guard:** `POST /two-factor/skip` (`TwoFactorSetupController::skip`) checks `$user->totp_secret !== null` before writing the flag. An enrolled admin hitting this endpoint is redirected to the challenge and the flag is never written. Belt-and-suspenders: the middleware never reads the flag for enrolled admins, but the endpoint also refuses to set it — two independent guards at read and write. `TwoFactorTest::test_skip_endpoint_refuses_enrolled_admin_and_flag_is_not_set` is the regression guard.

**Enrolment clears stale skip:** `TwoFactorSetupController::store()` calls `session()->forget('two_factor_setup_skipped')` after successful enrolment. Prevents a stale skip flag from lingering in the session after an admin commits to 2FA. Tested in `TwoFactorTest::test_stickiness_skipped_then_enrolled_admin_must_verify_in_new_session`.

**Accepted consequence:** An admin may operate the panel indefinitely with no 2FA by always choosing "Skip for now" at each login. This is the project owner's explicit choice, not an oversight. The alternative — forced enrolment blocking all panel access — was rejected because it could lock out admins who cannot access their authenticator at the moment of first login after a deploy. The sticky-once-enrolled rule is the compensating control: once an admin commits to 2FA, there is no retreat path.

**Route:** `POST /two-factor/skip` → `TwoFactorSetupController::skip`, named `two-factor.skip`. Under the `auth` middleware group alongside the other 2FA routes (D27). CSRF-protected.

---

---

## Block G — Polish, search, notifications (decisions)

### D30 — Single `enquiries` table for both contact and product questions

**Decision:** ONE `enquiries` table with a nullable `product_id` FK, not two tables. General "Contact Us" submissions leave `product_id` null; the product-page "Ask a Question" modal sets `product_id` to the product being asked about. `is_read` is NOT mass-assignable (admin-controlled only, same discipline as `is_admin` per D21). One Filament `EnquiryResource` under the "Settings" nav group serves both — read-mostly, mark read/unread, no edit of inbound content.

**Rationale:** One admin inbox instead of two. Building the table this way during the contact-form task front-loaded the infrastructure so the later Ask-a-Question feature became "a form that writes to the existing table with `product_id` set" — no new migration, no new resource. The nullable FK is the standard model for "an enquiry that may or may not concern a specific product"; `whereNotNull('product_id')` separates product questions from general ones if the UI ever needs that split. Two tables rejected as YAGNI — both enquiry types are name/email/message for v1.

---

### D31 — Admin notification on new B2B registration (`NewRegistrationPending`)

**Decision:** A `ShouldQueue` mail notification sent to all `is_admin` users when a new user registers, dispatched in `RegisteredUserController::store()` immediately after `event(new Registered($user))`, via plain `Notification::send($admins, ...)` (NO `->afterCommit()` — registration is a plain `User::create()` with no wrapping transaction, so `afterCommit` would be inert). Email carries registrant triage details (company, owner, business type, GST if present, contact) and a "Review" link.

**Rationale:** Implements spec §8 step 3, which was **specified but never built** — registration previously fired only `event(new Registered($user))`, so no admin was told a registration happened. On a site where manual approval gates all price visibility and ordering, an unnoticed pending registration is a customer who can't transact. Closes a real operational hole and is the twin of D32 (admins now notified about both registrations and orders).

**Email link limitation (resolved):** `UserResource` has only an index page (`ListUsers`) — no per-user view/edit page (kept lean per Block F2). So the "Review" link cannot target the individual user. Resolved by linking to the users list **pre-filtered to pending** via the `status` `SelectFilter` query string: `route('filament.admin.resources.users.index', ['tableFilters' => ['status' => ['value' => 'pending']]])`. Admin lands on the approval queue, not the unfiltered list. Adding a per-user view page was rejected as scope creep for an email link.

---

### D32 — Admin notification on order confirmation (`NewOrderReceived`)

**Decision:** A `ShouldQueue` mail notification sent to all `is_admin` users when an order is confirmed, dispatched ALONGSIDE the existing customer `OrderConfirmed` (D20) at all three confirmation sites — `CreateOrder::execute()` (COD), `PaymentController::callback()` (Razorpay), `WebhookController::handle()` (webhook) — via `->afterCommit()`, inside the same `DB::transaction()`, past the same Paid-status idempotency guard. Added at three independent sites because there is no shared `transitionToPaid()` method; each controller owns its own transaction and the lock + Paid-status guard inside each is what prevents the second path from reaching the dispatch line.

**Rationale:** Because it sits at the same site as `OrderConfirmed`, past the same structural idempotency guard, it inherits the D12/D14/D20 guarantees for free — admins get exactly ONE email per order regardless of callback/webhook ordering, and no phantom email on transaction rollback. No new idempotency machinery. Separate class from `OrderConfirmed` (different audience, different message: customer "thank you" vs admin "go fulfill"). Recipients are all `is_admin` users via `User::where('is_admin', true)->get()`, mirroring the §8 pattern.

---

### D33 — Per-line-item personalization note (cart → order snapshot)

**Decision:** A free-text `note` (nullable TEXT) on `cart_items`, entered via a textarea at add-to-cart on the product page, passed through the existing `Cart::addItem()` path as an optional parameter (no parallel add method; MOQ enforcement per D1 unchanged). Re-adding the same product OVERWRITES the note (the `updateOrCreate` path on the `unique(cart_id, product_id)` constraint — no merge logic). A matching nullable `note` on `order_items`; `CreateOrder` copies `cart_item.note → order_item.note` in the existing snapshot loop, INSIDE the transaction, BEFORE the cart clear (D8), exactly as `product_name`/`unit_price`/`hsn_code` are snapshotted.

**Rationale:** Per §4.7, an order is an immutable historical record — a note that lived only on `cart_items` would vanish at the cart clear. Snapshotting it into `order_items` makes it survive on the permanent order. The edit to `CreateOrder` is strictly additive — one field copied into the order-items array; stock decrement (D5), cart-clear timing (D8), and Razorpay logic (D13) untouched. Pre-existing D1/D5/D8 tests confirmed green post-change.

---

### D34 — Product search promoted from v1.1 to v1

**Decision:** Functional product search shipped in v1 (was deferred to v1.1 in spec §1/§15). `GET /search?q=` queries active, non-soft-deleted products by `name`/`sku`/`short_description` (case-insensitive `LIKE`), paginated, public, guest-reachable. Header magnifying-glass icon (desktop + mobile menu) opens an Alpine input that submits to the results page. Results render via the §6-safe `catalog.partials.product-card` partial — price gating inherited by construction.

**Rationale:** Search is a baseline storefront expectation; the live reference site has it. `LIKE`-based is correct at this catalog size (~9 products) — Scout / full-text indexing explicitly rejected as over-engineering. The §6 guest-price-leak guard test (guest sees "Login to see price", approved sees ₹) is the critical regression guard, since search is a new product-displaying surface.

**Deferred to v1.1:** live typeahead/as-you-type dropdown (the live site does this). Submit-to-results-page is v1; the dropdown would need a Livewire component or JSON endpoint, debounce, keyboard nav, and — critically — §6 *data-layer* gating (a typeahead payload must never carry price for unauthorized users, per §12; this is the most likely place to leak price data because it isn't visually displayed). Sits alongside "admin-editable homepage content" as v1.1 refinement.

---

### D35 — SMTP provider: Gmail SMTP (operator decision)

**Decision:** Gmail SMTP for transactional mail (order + registration notifications), per senior (Ajeet). Credentials supplied by senior, or operator creates own Gmail account and generates an app password if his login is unavailable. Set in `.env` (`MAIL_MAILER=smtp` + Gmail host/port/credentials) — never in code; `.env` is gitignored.

**Caveat for launch (not a demo blocker):** Gmail SMTP has ~500/day send limits and the "from" address is the Gmail account, not `orders@crystalparadise.in`, unless domain sending (SPF/DKIM on the crystalparadise.in domain) is configured later. Fine for demo; revisit before public launch. DEPLOY.md launch checklist already lists "Mail config pointing to production SMTP; send a test email."

**Note:** The notification CODE (customer `OrderConfirmed`, admin `NewOrderReceived`, admin `NewRegistrationPending`) is complete and verified end-to-end via `MAIL_MAILER=log` + a running queue worker — emails render and dispatch correctly. What remains is purely the `.env`/provider wiring.

---

### D36 — Admin product image upload: plugin approach (choice delegated)

**Decision:** Native Filament `FileUpload` is broken on this stack (Livewire 4 + PHP 8.4 + Windows/Herd) — see Operational note O5. Rather than debug core `FileUpload`, the path forward is a Filament image-upload **plugin**, per senior (Ajeet). Plugin choice **delegated to the dev/Claude**. Not yet selected or installed.

**Why this surfaced:** The Filament "Create Product" form has NO image field at all — an admin cannot attach an image to a new product. This was invisible until now because the entire catalog is **seeded** (images placed as files in `public/images/` by the seeder); no product was ever added through the admin with a fresh image. The moment a real admin adds their first product, they hit the wall. For products (unlike hero/affiliations/testimonials, where hardcoding is acceptable), admin image management is routine core work, so it must be solved.

**Plan (next session, research-first):** A CC session whose ONLY output is a comparison — candidate plugins (e.g. Curator, Spatie Media Library plugin, FilePond-field), each one's Filament v5 / Livewire 4 / PHP 8.4 / Windows compatibility, weight (field-replacement vs full media library), and integration with the existing `product_images` table (§4.4) and §6-safe rendering — with a recommendation and **nothing installed**. Operator picks, then a second session installs. The stack-compatibility filter is the deciding factor: a plugin flawless on v3/LW3/Linux may break identically to native `FileUpload` here. **Good fan-out-subagent candidate** (one agent per plugin, synthesize at end) — see O7.

**URL-TextInput workaround considered and rejected:** pasting image URLs into a plain TextInput works on this stack (TextInputs aren't broken), but the dev wants real file upload, so the plugin path was chosen over the workaround.

---

---

## Spec-drift findings (spec ≠ code; PROJECT_SPEC no longer a sole source of truth)

The spec and codebase have diverged in BOTH directions. Recording so future sessions don't trust the spec blindly:

- **§15 lists "Two-factor auth on admin login" as v1.1 ("add before public launch").** It is actually **BUILT** (Block F4 / D27–D29: `pragmarx/google2fa`, TOTP, recovery codes, skip-once-unenrolled). 2FA is done, not pending.
- **§8 step 3 specifies admin notification on registration as if it exists.** It was **never built** until D31 (this session). Now true.
- **§4.4 / Block F2 assume a richer `UserResource`** (the registration-email link wanted a per-user view page); `UserResource` is index-only by design. Worked around in D31.

**Takeaway:** when the spec and code disagree, the code is ground truth. Update the spec OR record the drift here — don't let it sit silently.

---

---

## Operational notes / stack gotchas

Not architecture decisions — hard-won operational lessons for THIS stack (Laravel 11 + Livewire 4 + Filament v5 + PHP 8.4 + Tailwind, on Windows / Laravel Herd). Read before building.

### O1 — Tailwind does NOT auto-recompile on this stack (the dominant failure mode)

Herd/Windows does not watch-and-recompile Tailwind on file change, and `public/build` is gitignored. Consequences, every time:
- After adding or changing ANY Tailwind class, run `npm run build` BEFORE browser-testing.
- ALWAYS hard-refresh (Ctrl+Shift+R) after `npm run build` — the browser caches the old CSS.
- Run `php artisan view:clear` when Blade edits don't appear (Laravel caches compiled Blade separately).
- Prefer CORE Tailwind classes over arbitrary values (`h-[60px]`, `aspect-[21/9]`, `w-[...]`) — arbitrary values are fragile across rebuilds. Hit repeatedly on hero, bestseller cards, testimonials, affiliations.
- Each opacity/variant modifier compiles as its OWN utility: `text-cp-x` being compiled does NOT mean `text-cp-x/20` or `bg-cp-x` or `hover:bg-cp-x` is — first use of any such variant needs a rebuild.
- **Rule of thumb:** when in doubt, just run `npm run build`. It's idempotent and costs seconds; skipping it when needed costs a confusing dead-style debug from the wrong end.

### O2 — Counterexample to O1: moving an already-compiled class does NOT need a rebuild

Moving a class string that's already in the build from a static attribute into an Alpine `:class` binding on the same file does NOT require a rebuild — Tailwind's content scanner is dumb text-matching and finds the literal string either way. The rebuild trap is specifically NEW class strings never previously compiled. (From the thumbnail-gallery task: `border-cp-gold` moved into an Alpine `:class`, no rebuild needed.) Don't over-rebuild reflexively either.

### O3 — Never stage `public/build/`

`public/build` is gitignored — it's a regenerated artifact, not source. CC reports sometimes list `public/build/manifest.json` + `public/build/assets/` in their staging suggestions (seen on the personalization-note and search tasks). Do NOT `git add` it. Always run `git status` between `git add` and `git commit` to confirm `public/build/` did not sneak in. The build runs on the server per DEPLOY.md.

### O4 — `.env` is local-only; mail/queue test settings must be reverted before launch

`.env` is gitignored — local changes never reach git, never affect code/tests/commits. For testing emails locally without a real provider:
- `MAIL_MAILER=log` → renders emails into `storage/logs/laravel.log` instead of sending.
- Queued notifications (`ShouldQueue`) need EITHER `QUEUE_CONNECTION=sync` OR a running `php artisan queue:work` worker — otherwise the email queues and silently never sends (looks broken, isn't). Verified the order + registration emails this way (worker shows `OrderConfirmed`/`NewOrderReceived`/`NewRegistrationPending` → DONE).
- Run `php artisan config:clear` after any `.env` edit.
- These are TEST-ONLY: before a real demo, set `MAIL_MAILER=smtp` (D35) and use `sync` or a supervised worker.
- `Notification::fake()` in tests proves DISPATCH, not DELIVERY/RENDER — it never renders `toMail()`. A broken `route()` in `toMail()` passes all faked tests and only fails at real send time. Always click the actual link in a rendered (log) email before trusting it.

### O5 — Filament `FileUpload` is broken on this stack

`FileUpload` (Livewire 4 + PHP 8.4 + Windows) does not work. Until resolved (D36, plugin approach), use hardcoded `public/images/` files or URL TextInputs — never `FileUpload`. This is why hero, affiliations, bestsellers, and testimonials are hardcoded (admin-editable for those deferred to v1.1) and why the product form had no image field.

### O6 — CC reports describe intent, not ground truth; always `git status`

CC lists what it THINKS it changed. `git status` lists what ACTUALLY changed. They usually match; the time they don't is exactly a missed file (e.g. a referenced Blade component) or an over-included one (`public/build`). Run `git status` before every commit and stage from reality, not from the report's list.

### O7 — Fan-out subagents: research/survey only, never builds

Use Claude Code fan-out subagents for WIDE, INDEPENDENT, READ-HEAVY work — multi-item research, plugin surveys, cross-directory audits, test-gap sweeps — where lanes don't share state and the output is a single synthesized report. Do NOT use them for building/editing code, especially invariant-dense zones (`CreateOrder`, payment transaction, §6 price-gating). The established workflow — one feature, one audit, one diff, one browser-verify, one commit — is what's kept the tree clean; parallel agents writing overlapping changes is the tangle that workflow exists to prevent. Heuristic: **fan out to research, single-thread to build.** (Tomorrow's D36 plugin research is a fit.)

### O8 — Demo credentials are dev-only; clean up pre-launch

- Approved demo buyer: `demo-buyer@crystalparadise.in` / `demo-password-2026` (created by `users:create-demo-buyer`; Rajesh Mehta, Mehta Crystals & Gems — approved wholesaler, sees prices).
- Seeder test users (password `password`): `approved@test.com`, `pending@test.com`, `rejected@test.com`, `blocked@test.com` — one per approval state.
- Admin: promoted via `php artisan user:make-admin <email>` (D21); confirm actual admin email via `User::where('is_admin', true)->pluck('email')`.
- All of these are DEV-ONLY and must be removed or gated (e.g. `--env=local`) and passwords rotated before public launch. Gate/remove `users:create-demo-buyer`.

### O9 — Misc build conventions confirmed this stack

- Flex carousel children need `shrink-0` + a fixed width (otherwise they collapse). Hit on hero/bestsellers/testimonials.
- Filament v5 resources nest in folders (`Pages/`, `Schemas/`, `Tables/`); actions use the `Filament\Actions\` namespace (never sub-namespaces). Admin nav group for site content is "Settings".
- §6 price-visibility spine: reuse `@include('catalog.partials.product-card')` (wraps the product-card Livewire component) for ANY product-displaying surface — never write new price markup. New surfaces get a guest-price-leak test (DomCrawler strips header/footer chrome via `data-testid="site-header"`, asserts the price number absent).

---

---

## Still open / next session

- **SMTP wiring** — Gmail (D35); waiting on Ajeet's creds or own Gmail + app password. Code is done; only `.env` remains.
- **FileUpload plugin** — research-first session (D36); choice delegated; fan-out candidate (O7).
- **Live search typeahead dropdown** — v1.1 (D34).
- **GST CGST/SGST-vs-IGST state-name vs state-code hardening** — F3 deferred #1 (compliance; needs constrained state capture).
- **Invoice PDF download surface** — D22 built numbering; PDF generation deferred.