# B2B Portal Rebuild — Spec & Implementation Plan

Author: pairing session · 2026-09-25
Target module: `Modules/B2BPortal`
Reference UX: bulkplaintshirt.com (tabular bulk-order matrix)
Reference code (UX only, not stack): `~/Downloads/b2bportal` (Next.js + Express + Prisma)

---

## 1. Goals

1. **Smooth backend product management** — WooCommerce-style Size×Color variation editor with per-combo price / stock / SKU, plus a bulk grid editor to update the whole matrix at once.
2. **Smooth customer ordering** — public storefront styled like bulkplaintshirt.com: a matrix table (colors × sizes, qty input per cell), GSM/category filters, sticky cart, WhatsApp OTP login only at checkout, saved carts + abandoned-cart tracking.
3. **Reliable order sync to UltimatePOS** — every portal order becomes a real UltimatePOS Sell transaction (stock deducted from the configured location, invoice generated, contact linked, ledger entry for on-account).
4. **Fresh schema, no migration debt** — drop existing B2BPortal-only tables (`b2b_carts`, `b2b_cart_items`, `b2b_customers`, `b2b_portal_settings` where obsolete) and rebuild with a cleaner shape. Core UltimatePOS tables untouched.

---

## 2. Decisions locked (from Q&A)

| Area | Decision |
|---|---|
| Architecture | Rebuild inside `Modules/B2BPortal` (Laravel + Blade + Tailwind + Alpine.js) |
| Variation pricing model | Per-combo price/stock/SKU; UI collapses to a single price band when all combos of a product agree |
| Cart price tiers | Apply to **cart total** (qty or amount across all lines), not per-combo |
| Frontend stack | Blade + Tailwind + Alpine.js (lightweight, no Node build step required beyond Tailwind CLI) |
| Access model | Public browsing (prices visible); login required only at checkout |
| Auth | WhatsApp OTP via the existing `Modules/WhatsappNotification` module |
| Payments (v1) | Manual UPI (customer enters UTR) · COD · Credit/On-account (uses UltimatePOS contact credit) |
| Existing schema | Fresh start — drop old B2BPortal tables and rebuild |
| Existing products | Mixed simple + variable; both supported |

---

## 3. Current-state audit (short)

What already exists in `Modules/B2BPortal`:

- **Backend admin** — settings, customers, carts screens (Blade + AdminLTE), install/uninstall, price-group assignment per customer, custom-domain support, token-based storefront URL (`/b2bportal/store/{token}`).
- **API layer** — `/b2bportal/*` REST endpoints for auth (OTP send/verify), products, cart, checkout, orders, invoice download. Uses `B2BToken` and `B2BAuth` middlewares.
- **Data** — `b2b_portal_settings`, `b2b_customers`, `b2b_carts`, `b2b_cart_items`. Products still live in UltimatePOS core (`products`, `product_variations`, `variations`, `variation_location_details`).
- **Product signals on core** — `products.b2b_selling`, `variations.b2b_is_sellable`, `variations.b2b_moq`, `b2b_price_tiers` per variation. These are already the right primitives.

Gaps causing the "not functioning as expected" pain:

1. **No matrix editor in POS admin.** UltimatePOS's native variation UI is one row per combo entered manually — painful when you have 14 colors × 6 sizes = 84 combos.
2. **Storefront `store.blade.php` is not a bulkplaintshirt-style matrix.** Needs a rebuild.
3. **Cart/abandoned-cart wiring is fragile** — separate `b2b_carts` table decoupled from Laravel session and customer identity; sync endpoint exists but drifts.
4. **Order sync path is opaque** — `OrderController@store` exists but its bridge to UltimatePOS's `TransactionUtil` isn't obviously wired (needs verification during implementation).
5. **Pricing precedence unclear** — group price vs price tier vs default sell price; UI doesn't communicate which one applied.

---

## 4. Data model

We keep UltimatePOS's existing variation model (it already supports per-combo price/stock/SKU) and **add three small pieces**:

### 4.1 New/kept tables (all prefixed `b2b_`)

```
b2b_portal_settings         # per-business config (kept, extended)
  business_id
  api_token, custom_domain, domain_status
  default_location_id       # which UltimatePOS location fulfils portal orders
  portal_moq                # global fallback MOQ
  cart_total_tiers (json)   # [{min_qty, min_amount, discount_pct or flat_off}, ...]
  payment_methods (json)    # ["upi", "cod", "credit"]
  upi_id, upi_payee_name
  cod_min, cod_max
  storefront_banner_*, storefront_logo_url
  is_active

b2b_customers               # kept, extended
  business_id
  phone (unique per business), name, email
  business_name, gstin
  contact_id                # FK -> contacts.id (UltimatePOS)
  price_group_id            # nullable -> selling_price_groups.id
  is_approved, is_blocked
  addresses (json)          # simple array of shipping addresses

b2b_otps                    # NEW (currently ad-hoc, promote to table)
  business_id, phone, code_hash, expires_at, attempts, created_at

b2b_carts                   # kept, but simpler
  id, business_id, customer_id (nullable for guest), session_id
  updated_at, is_abandoned, reminder_sent_at

b2b_cart_items              # kept
  cart_id, variation_id, quantity

b2b_orders                  # NEW mirror table (light, points to core transactions)
  id, business_id, customer_id
  transaction_id            # FK -> transactions.id (the real UltimatePOS Sell)
  order_number
  payment_method_used       # 'upi' | 'cod' | 'credit'
  upi_utr                   # if manual UPI
  status                    # 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
  cart_snapshot (json)      # for audit/history
  placed_at
```

### 4.2 Product-side reuse (no core schema changes)

- **Variable product = one UltimatePOS product with N variation rows**, each row = one Color+Size combo.
- Variation name convention: `"{Color}-{Size}"` (e.g. `"Black-40"`). The admin matrix editor writes this pattern.
- `variations.default_sell_price` = per-combo B2B price. `variations.sub_sku` = per-combo SKU. `variation_location_details.qty_available` = per-combo stock at the fulfilment location.
- `variations.b2b_is_sellable` gates whether a combo appears in the portal.
- `variations.b2b_moq` = per-combo MOQ; falls back to `products.product_custom_field1` (product MOQ) then `b2b_portal_settings.portal_moq`.
- `b2b_price_tiers` (per-variation) is deprecated for cart-total tiering — cart-total tiers live in `b2b_portal_settings.cart_total_tiers`. Existing per-variation tiers stay in the schema untouched (for future use) but are not read by the new cart engine.

### 4.3 Two new "meta" columns on `products` (small, additive)

```
products.b2b_color_attribute_id     nullable -> variation_templates.id
products.b2b_size_attribute_id      nullable -> variation_templates.id
```

These tell the matrix editor which two attributes to render as rows/columns for a given product. If null, the editor asks the admin to pick on first entry.

---

## 5. Backend admin flow — variation matrix editor

**Where:** New sub-page under UltimatePOS Products screen, or dedicated screen inside `/b2bportal/products/{id}/matrix`.

**Screen layout:**

```
Product: Oversized Drop-shoulder 240gsm            [Save all]  [Bulk fill…]

Color axis: [ Color ▾ ]      Size axis: [ Size ▾ ]

┌────────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│            │  XS    │   S    │   M    │   L    │   XL   │  XXL   │
├────────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ Black      │ [205]  │ [205]  │ [205]  │ [205]  │ [205]  │ [225]  │
│  stock:    │  120   │  240   │  310   │  280   │  190   │   60   │
│  SKU:      │ OS-BK-XS│ …     │ …     │ …     │ …     │ …     │
├────────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ Maroon     │ [255]  │ [255]  │ [255]  │ [255]  │ [255]  │ [275]  │
│ …          │                                                     │
└────────────┴────────┴────────┴────────┴────────┴────────┴────────┘

[+ Add color row]  [+ Add size column]
```

**Interactions:**
- Cell click reveals price / stock / SKU / active-toggle in a mini-popover.
- **Bulk fill** dialog: "Set price to X for [all cells | this row | this column | empty cells]".
- Adding a color/size that doesn't exist yet creates the missing `variations` rows on save (with matching `product_variations` group), preserving UltimatePOS invariants.
- Deleting a combo just sets `b2b_is_sellable = 0` (never hard-delete, to protect historical order rows).
- Saves in one atomic transaction; validates each cell (price ≥ 0, stock integer, SKU unique).

**One-time migration for existing "Red-40" style variations:** a `php artisan b2bportal:normalise-variations` command parses variation names, splits into color/size, backfills `products.b2b_color_attribute_id` / `b2b_size_attribute_id`. Reported to the admin for review before applying.

---

## 6. Customer-facing storefront

### 6.1 Screens

| Route | Purpose |
|---|---|
| `GET /b2bportal/store/{token}` (or custom domain root) | **Home**: banner, category rail, tiered pricing summary, matrix products |
| `GET /catalog/{category-slug}` | Filtered category (e.g. `/catalog/hoodies-320gsm`) |
| `GET /product/{slug}` | Single product page (matrix + description + photos) |
| `GET /cart` | Cart summary, tier applied, edit qty inline |
| `GET /checkout` | Address, payment method, place order (triggers OTP login if guest) |
| `GET /orders` | Logged-in buyer's order history + invoice download |

### 6.2 Matrix component (Alpine + Blade)

Component: `resources/views/b2bportal/storefront/components/product-matrix.blade.php`

- Renders one HTML `<table>` per product.
- Rows = colors, columns = sizes (both derived from the product's `b2b_color_attribute_id` / `b2b_size_attribute_id`).
- Each cell = `<input type="number" min="0" x-model.number="qty[variationId]">` plus a small stock/price tooltip.
- Bottom of the product card shows the tier summary derived from `cart_total_tiers`, and — when all cells agree — a single price band like `"XS to XXL · ≥10pcs ₹205 · <10pcs ₹245"`. When cells disagree, per-cell prices appear inline.
- Alpine store `cartStore` holds `{ variationId: qty }`; posts debounced updates to `/b2bportal/api/cart/sync`.

### 6.3 Header & filters

- Sticky top bar: business logo, "Enable Discount (₹X/pc)" toggle if configured, GSM / category tabs, cart badge (item count + total), "Order Now" button.
- GSM/category tabs are derived from UltimatePOS's `categories` table filtered by `category_type='product'`.

### 6.4 Cart persistence

- Guest: cart tied to a signed cookie `b2b_cart_sid` (uuid).
- Logged-in: cart tied to `b2b_customers.id`; on login, merge session cart into customer cart.
- Debounced sync every 800ms writes to `b2b_cart_items`.
- **Abandoned cart** = `updated_at` older than 24h AND has items AND customer identified. A daily job (`b2b:send-abandoned-cart-reminders`) sends a WhatsApp template message via `WhatsappNotification` module and marks `reminder_sent_at`.

### 6.5 Checkout flow

1. **Cart → Checkout button** (guests): opens OTP modal. User enters phone, gets WhatsApp OTP, verifies, optionally provides `name / business_name / gstin` first time. `b2b_customers` row created, mapped to an UltimatePOS `contacts` row (`type='customer'`, tagged `is_b2b_portal=1`).
2. **Address step**: choose from saved addresses (in `b2b_customers.addresses` JSON) or add new.
3. **Payment step**: radio between UPI / COD / Credit. UPI shows QR + UPI ID and a UTR text field; COD shows expected delivery note; Credit shows current available credit from the linked contact (`contacts.credit_limit − outstanding balance`).
4. **Place order** → server-side:
   - Re-quote prices from DB (never trust client).
   - Apply cart-total tier from `b2b_portal_settings.cart_total_tiers`.
   - Create UltimatePOS Sell transaction via `TransactionUtil::createSellTransaction` at `default_location_id`.
   - Decrement stock via `TransactionUtil::decreaseProductStock`.
   - Create `b2b_orders` mirror row referencing `transactions.id`.
   - Fire `OrderPlaced` event (WhatsApp confirmation, admin email).
5. Redirect to `/orders/{id}` success page.

---

## 7. Pricing engine (server-authoritative)

Precedence when quoting a single line:

1. **Selling Price Group** — if the customer is assigned one and the variation has a `variation_group_prices` row for it → use that price.
2. Otherwise **`variations.default_sell_price`** (per-combo B2B price).

Then, on the **cart total**:

3. Apply the first matching row in `cart_total_tiers` (sorted by `min_qty` desc, `min_amount` desc): applies a `discount_pct` or `flat_off_per_pc`.

This precedence is documented in one method: `Modules\B2BPortal\Services\PricingEngine::quoteCart(array $lines, ?B2BCustomer $customer): CartQuote`. All three call sites (product listing, cart page, checkout submit) use the same method to eliminate drift.

---

## 8. WhatsApp OTP

Reuses `Modules/WhatsappNotification`:

- `Modules\B2BPortal\Services\OtpService::send($phone)`:
  - Generates 6-digit code, stores hash in `b2b_otps` with 10-minute TTL.
  - Calls `WhatsappNotificationService::sendTemplate($businessId, $phone, 'b2b_login_otp', ['code' => $code])`.
- Rate limit: max 3 OTP requests per phone per 15 minutes.
- Verify: constant-time hash compare, mark `b2b_otps.attempts++`; ≥5 = lock phone for 30 min.
- Template `b2b_login_otp` needs a one-time admin setup step in Meta WhatsApp Manager — install script will emit instructions.

---

## 9. Order sync back to UltimatePOS

- **Contact mapping**: `b2b_customers.contact_id` links to `contacts` row. Created lazily on first successful OTP verify.
- **Sell transaction**: uses `App\Utils\TransactionUtil` (already in UltimatePOS core). Fields: `type=sell, status=final, payment_status={paid|due|partial}, location_id=default_location_id, contact_id, ...`.
- **Stock**: `decreaseProductStock` at the same location. If any line is out of stock (race), the order is rejected with a 409 and cart flagged so the buyer sees it live.
- **Invoice**: uses UltimatePOS's existing invoice scheme configured on the location. Portal exposes a "Download invoice" button hitting `/b2bportal/orders/{id}/invoice` which streams the standard invoice PDF.
- **Ledger for Credit method**: transaction is created with `payment_status='due'`, no `transaction_payments` row. The customer's contact balance reflects it automatically.

---

## 10. Delivery plan — phases

Each phase is a shippable slice. Estimated size for solo dev with this AI pairing.

### Phase 0 — foundation (½ day)
- Drop obsolete B2BPortal-only tables via a reset migration; rewrite migrations for the new shape in §4.
- Add `products.b2b_color_attribute_id` / `products.b2b_size_attribute_id` (nullable) via a core-safe additive migration.
- Wire Tailwind CLI build for the module (`Modules/B2BPortal/Resources/assets`).
- Skeleton PricingEngine + OtpService classes with unit tests.

### Phase 1 — admin matrix editor (2–3 days)
- Matrix editor screen (Blade + Alpine).
- Bulk-fill dialog.
- Save endpoint that upserts `product_variations` and `variations` transactionally.
- Artisan command `b2bportal:normalise-variations` to migrate existing "Color-Size" name variations.

### Phase 2 — storefront rebuild (3–4 days)
- Home / category / product pages with the matrix component styled like bulkplaintshirt.com.
- Header, GSM tabs, sticky cart drawer.
- Cart sync API + Alpine cartStore.
- Public browsing (no auth) with server-side pricing.

### Phase 3 — checkout + auth (3 days)
- OTP modal + `b2b_otps` table. **Guest checkout blocked** — OTP required.
- Address step, payment method step.
- `b2b_shipping_rules` table + admin CRUD + rule-evaluation service.
- `b2b_location_routing` table + admin CRUD + cart-split logic.
- `PlaceOrder` action wiring `TransactionUtil` (one call per fulfilment location) + `b2b_orders` group + `OrderPlaced` event.
- Success + `/orders` list + invoice download (one PDF per shipment).

### Phase 3.5 — promos (½ day)
- `b2b_promos` table + admin CRUD.
- Storefront header toggle chips wired into PricingEngine.

### Phase 4 — retention + admin ops (1–2 days)
- Abandoned-cart daily job + WhatsApp template.
- Admin views: orders list (already partly exists), reprint invoice, mark shipped/delivered.
- Order status change → WhatsApp buyer notification.

### Phase 5 — polish
- Mobile responsiveness (matrix collapses to color-grouped accordion on <640px).
- SEO for public product pages (og tags, sitemap route).
- Basic analytics events (`cart_updated`, `checkout_started`, `order_placed`).

---

## 11. Resolved decisions (round 2)

1. **Multi-location fulfilment — YES in v1.**
   - `b2b_portal_settings.default_location_id` remains as fallback.
   - New table `b2b_location_routing` (rules ordered by priority): pincode/state/product-category → `location_id`. First match wins; default falls through.
   - Order creation loops routing rules against the cart, splits into per-location sub-orders only if the cart spans multiple locations (each becomes its own `transactions` row referencing the same `b2b_orders` group). Stock deducted per location.
   - Buyer sees one order in `/orders`; admin sees split shipments in POS.

2. **Tax — reuse UltimatePOS config as-is.**
   - Portal reads `variations.default_sell_price`, `products.tax_id`, `products.tax_type` (inclusive/exclusive) exactly like the POS Sell screen.
   - Cart quote returns `subtotal`, `tax_amount`, `total` mirroring what POS would compute. No parallel tax logic in the module.

3. **Shipping — all three types, admin-configurable.**
   - New table `b2b_shipping_rules`:
     ```
     id, business_id, name, type ('flat' | 'per_kg' | 'per_state' | 'per_pincode'),
     state (nullable), pincode_pattern (nullable, supports 'XXXXXX' or '110XXX'),
     flat_amount, per_kg_rate, min_charge, free_above_amount, free_above_kg,
     applies_to_location_id (nullable, else all), priority, is_active
     ```
   - Evaluation order: highest `priority` first; first matching rule wins per shipment.
   - Weight comes from `variations.weight` (already in core) × qty; if null, uses product-level `weight`, else 0.
   - Admin screen: `/b2bportal/shipping-rules` — list, create, edit, priority-drag.

4. **"Enable Discount (₹X/pc)" toggle — buyer-opt-in, admin-scheduled.**
   - New table `b2b_promos`:
     ```
     id, business_id, label ('Enable Discount (₹4/pc)'),
     flat_off_per_pc (or discount_pct),
     min_qty, min_amount,
     starts_at, ends_at, is_active
     ```
   - Storefront queries active-window promos; renders each as a toggle chip in the header. Buyer can enable one at a time; enabled promo id stored in cart session.
   - On quote, applied only if buyer has opted in AND thresholds met. Falls under the same PricingEngine so precedence is: group price → default sell price → cart total tier → opted-in promo (last).
   - Non-opt-in tiers (§7 step 3) still auto-apply.

5. **Guest checkout — NOT allowed.** Every order requires OTP-verified `b2b_customer`.
   - Public storefront still browses without login; the "Proceed to Checkout" button opens the OTP modal for anonymous visitors.
   - Cart merges from session → customer on successful OTP verify (existing plan).

---

## 12. What ships as "done" for v1

- Admin can manage a variable product's Size×Color matrix (create, edit price/stock/SKU per cell, bulk fill, deactivate combos) in under 30 seconds for a typical 14×6 grid.
- Public buyer can visit the portal, browse by GSM/category, enter quantities across the matrix, see live cart totals with tier discount applied, and check out with UPI/COD/Credit after WhatsApp OTP login.
- Every portal order appears in UltimatePOS as a real Sell transaction, with stock deducted and invoice available.
- Abandoned carts trigger a WhatsApp reminder after 24h.
- No changes to core UltimatePOS schema beyond two additive nullable columns.
