# Backend follow-up issues (filed by Android POS QA)

Three issues found while QA-ing the Android POS MVP that are **not in
this app's scope to fix**. Each is written to be pasted directly into
Linear / GitHub Issues / Jira — title, severity, steps-to-reproduce,
expected behaviour, and at least one suggested fix.

If you'd rather have these as actual GitHub issues, on a machine with
`gh` installed run:

```bash
gh issue create --repo Mohamkh93/DALSEEN-BUSSINESS-OS \
  --title "<title from below>" \
  --label backend,android-pos,qa-finding \
  --body-file - < apps/android-pos/BACKEND_FOLLOWUPS.md
```

(or paste each section individually).

---

## Issue 1 — POS sale pricing drifts when active `pricing_rules` apply

| Field         | Value                                                                          |
|---------------|--------------------------------------------------------------------------------|
| Title         | POS sale total drifts from cashier UI when `pricing_rules` are active          |
| Severity      | **Medium-High** (blocks roll-out to any tenant who uses pricing rules)         |
| Reported in   | Android POS MVP QA — `apps/android-pos/QA_REPORT.md` §6.A (BLOCKER-1)          |
| Owners        | backend (pricing) + frontend (web POS uses the same model)                     |
| Component     | `app/Services/Pricing/PricingResolver` + `PosController::createSale`           |

### Symptom

When a tenant has at least one active row in `pricing_rules`, the
canonical sale total returned by `POST /pos/sales` does **not** match
the price the cashier (or web operator) computed locally from
`product.price * qty * (1 + tax_rate/100)`.

Concrete repro from the demo seed (cashier `cashier@acme.test`):

```
Cart:  1 × COFFE-K1   (catalog price 110, no rule)
       2 × BAKLAVA    (catalog price 78, has rule 01kqx0xsyk1cd6bg1kt3r6gcp8 → -10%)

Cashier-computed total : 305.90  (= (110 + 156) × 1.15)
Backend canonical total: 287.96  (= (110 + 140.40) × 1.15)
Drift                  :  17.94 SAR per cart
```

### Why this is a problem

- The cashier collects the wrong amount of cash from the customer.
- `POST /pos/sales` accepts the over-tendered payment silently
  (see Issue 2), which then skews `expected_cash` on the shift
  reconciliation.
- The receipt shown to the customer reflects the canonical total, but
  the cashier already collected more — there's a paper-trail mismatch.

### Steps to reproduce

```bash
# 1. Run a tenant that has pricing rules (the demo seed does):
php artisan migrate:fresh --seed
php artisan serve --port=8000

# 2. Authenticate as cashier:
curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"company_slug":"acme","email":"cashier@acme.test","password":"password123"}'
# → { token, ... }

# 3. Open a shift and capture a sale that touches a rule-affected SKU.
#    The response's `data.subtotal` will be lower than (catalog_price × qty).
```

### Expected behaviour

The cashier UI should be able to ask the backend "what will this cart
cost?" before tendering, so the on-screen total matches what the
backend will record. Today `POST /catalog/pricing/resolve` only
resolves **one product at a time**, which is too chatty for a tablet
POS.

### Suggested fixes (pick one)

1. **Batch resolver (preferred).** Add `POST /catalog/pricing/resolve-cart`:

   ```http
   POST /api/v1/catalog/pricing/resolve-cart
   Body: { "branch_id": "01...", "tier": "retail",
           "lines": [{ "product_id": "01...", "qty": 2 }] }
   Response: { data: {
     lines: [{ product_id, qty, unit_price, line_total, applied_rule_id }, …],
     subtotal, discount_total, tax_total, total
   }}
   ```

   The web POS + Android POS both call this on cart change (debounced)
   and on Confirm. No client-side guesswork.

2. **Dry-run on `/pos/sales`.** Add a `?dry_run=1` query flag (or a
   `dry_run: true` body field). Backend computes the same response
   shape as a normal sale but does not persist or commit any rows.
   Slightly less elegant (POST that doesn't write feels weird) but
   reuses 100% of the existing pricing pipeline.

3. **Document the per-product resolver and parallelise.** Lowest-effort
   path: keep `POST /catalog/pricing/resolve` per-product and have the
   client fan out via `Promise.all` per cart line. Acceptable for small
   carts; bad for 50-line restaurant orders.

### Acceptance criteria

- [ ] One canonical pricing endpoint that the Android POS, web POS,
      and any future SoftPOS surface all use.
- [ ] Same endpoint covers tenants with **and** without rules (returns
      the catalog price unchanged when no rule matches).
- [ ] Locale + branch-aware: respects `X-Branch-Id` and any branch-
      scoped rule conditions.
- [ ] Idempotent and cheap (cart resolution is on the cashier hot path
      — should be sub-100 ms server-side).

---

## Issue 2 — `POST /pos/sales` silently absorbs cash overpayment

| Field         | Value                                                                          |
|---------------|--------------------------------------------------------------------------------|
| Title         | POS sale silently accepts cash overpayment; `change_due` returns 0             |
| Severity      | **Medium** (silently corrupts shift reconciliation; not a money loss but bad audit trail) |
| Reported in   | Android POS MVP QA — `apps/android-pos/QA_REPORT.md` §6.C (BLOCKER-3)          |
| Owners        | backend (POS service)                                                          |
| Component     | `app/Services/Pos/PosService::createSale` + `PosController`                    |

### Symptom

`POST /pos/sales` returns 201 Created when `sum(payments[].amount)`
exceeds the canonical sale total, **without** computing `change_due`.
The over-tendered amount is recorded in `pos_payments.amount` and
flows into `pos_shifts.expected_cash`, so the shift's variance
calculation is wrong by the overpayment amount.

### Steps to reproduce

```bash
# Sale of 1 line with canonical total 126.50, but cashier sent 130.00:
curl -sS -X POST http://127.0.0.1:8000/api/v1/pos/sales \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"shift_id":"…","lines":[{"product_id":"…","qty":1}],
       "payments":[{"tender":"cash","amount":130}]}'

# Response: 201 with
#   total: 126.50, change_due: 0, payments: [{tender:"cash", amount:130}]
# Expected: change_due: 3.50, OR a 422 PAYMENT_OVERFLOW.
```

(This was reproduced cleanly in the QA pass — see the transcript in
`QA_REPORT.md` §5.)

### Expected behaviour

For **cash** tenders specifically, the backend should compute
`change_due = sum(cash_payments.amount) - sale_total` whenever that's
positive. For card / wallet / gift_card / store_credit, overpayment
shouldn't be possible at all (the PSP capture is exact); these tenders
should 422 with a clear error code if the client tries.

### Suggested fixes

Inside `PosService::createSale` at the post-validate step:

```php
$cashTendered    = collect($data['payments'])->where('tender', 'cash')->sum('amount');
$nonCashTendered = collect($data['payments'])->where('tender', '!=', 'cash')->sum('amount');
$total           = $sale->total;

// Cash overpayment → record change due.
if ($cashTendered > 0 && ($cashTendered + $nonCashTendered) > $total) {
    $sale->change_due = ($cashTendered + $nonCashTendered) - $total;
}

// Non-cash overpayment is not legal — PSP would never settle that way.
if ($nonCashTendered > $total) {
    throw new ApiException(
        code: 'PAYMENT_OVERFLOW',
        message_en: 'Non-cash payments exceed sale total.',
        message_ar: 'الدفعات غير النقدية تتجاوز الإجمالي.',
        http_status: 422,
        fields: ['payments' => ['Non-cash sum exceeds total.']],
    );
}
```

Plus: clamp `expected_cash` math on `pos_shifts` to use
`min(cash_received, total)` so an overpaid sale doesn't inflate the
expected drawer.

### Acceptance criteria

- [ ] Cash overpayment → `change_due` populated; `expected_cash`
      reflects only the canonical total.
- [ ] Non-cash overpayment → 422 with `PAYMENT_OVERFLOW`.
- [ ] Existing balanced sales keep working unchanged (idempotency
      replay still returns the original sale).
- [ ] Backend test added (`tests/Feature/Pos/PosOverpaymentTest.php`).

---

## Issue 3 — Production deployment serves Railway edge 404

| Field         | Value                                                                          |
|---------------|--------------------------------------------------------------------------------|
| Title         | `dalseen-bussiness-os-production.up.railway.app` returns 404 for every route   |
| Severity      | **High** (blocks production rollout for both web and Android POS)              |
| Reported in   | Android POS MVP QA — `apps/android-pos/QA_REPORT.md` §6.B (BLOCKER-PROD-DEPLOY)|
| Owners        | DevOps / backend                                                               |
| Component     | Railway service `dalseen-bussiness-os-production`                              |

### Symptom

Every request to the documented production URL returns the Railway
edge's "no app routed" 404 — not a Laravel 404. The `request_id`
header confirms it's Railway's edge proxy responding, not the
application.

```bash
$ curl -sS -i https://dalseen-bussiness-os-production.up.railway.app/api/v1/openapi.json | head -10
HTTP/2 404
…
{ "status":"error", "code":404,
  "message":"Application not found",
  "request_id":"V5mH_HtITbuZCxuO0-QtfA" }

$ curl -sS https://dalseen-bussiness-os-production.up.railway.app/
{ "status":"error", "code":404, "message":"Application not found", … }
```

By contrast, a local Laravel `php artisan serve` returns the correct
Laravel 404 envelope:

```json
{ "error": { "code": "UNAUTHENTICATED", "message_en": "...",
             "message_ar": "...", "fields": {}, "correlation_id": "..." } }
```

### Likely causes

1. Railway service is paused or sleeping.
2. Build/deploy failed on the latest deploy and there is no healthy
   container behind the edge.
3. Service was renamed/moved and the URL the team is sharing is stale.
4. `Procfile` / `railway.json` start command misconfigured (the repo
   ships both — worth re-checking).

### Steps to investigate

1. `railway status` (from a logged-in CLI in the project directory).
2. Check the Railway dashboard's deploy log for the `dalseen-bussiness-os-production`
   service — is the latest deploy `Crashed` or `Failed`?
3. If the URL changed, distribute the new one to the integration
   teams (web frontend + Android POS need updated `EXPO_PUBLIC_API_BASE_URL`).
4. If the service is intentionally retired, document that and point
   the apps at the replacement.

### Acceptance criteria

- [ ] Production URL responds with a Laravel-shape envelope (success or
      authenticated error), not the Railway edge 404.
- [ ] `GET /api/v1/openapi.json` returns the OpenAPI spec.
- [ ] `POST /api/v1/auth/login` accepts the seeded production
      credentials (or whatever the prod owner shares).
- [ ] Web frontend's `app/.env.example` and Android POS's
      `apps/android-pos/.env.example` point at the canonical URL.

---

## Filing checklist (for whoever picks these up)

- [ ] File Issue 1 — Pricing rules / cart resolver (label: `backend`, `pricing`, `android-pos`)
- [ ] File Issue 2 — Overpayment absorption (label: `backend`, `pos`, `android-pos`)
- [ ] File Issue 3 — Railway 404 (label: `devops`, `production`, `blocker`)
- [ ] Link each issue back to `apps/android-pos/QA_REPORT.md` and this
      file so the discovery context is preserved.
- [ ] Once Issue 3 is resolved, kick the Android team to retest the
      `EXPO_PUBLIC_API_BASE_URL=…/api/v1` production path end-to-end.
