# Module 9 — Payment Types, Order Types & Promo Codes (Commercial Configuration)

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - The `has_additional_fee` surcharge is charged on replacement extra charges (a customised meal's uplift), not only on menu prices.
> - The `has_additional_fee` surcharge is charged on every add-on line on its own price.
> - `enable_delivery_app_price` is resolved per line — add-on lines are charged the add-on item's own `delivery_app_price`, so one order can mix lines priced with and without the delivery-app price.
> - A payment type with `enable_delivery_app_price` must be the order's sole payment method (split payments rejected).
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** TBD (requires As-Built Spec OD register, out of scope this pass)

---

## Module Overview
- **Purpose:** Define tender methods (with surcharges and platform links), service modes (dine-in/pickup/delivery), and promotional discount codes.
- **Business Goal:** Control which payment methods each service mode accepts, price in card/gateway fees, and run named promotions.
- **Main Actors:** Admin/Superadmin (CRUD), Cashier (reads; applies promo codes), Platforms (mapped to payment types).
- **Dependencies:** Orders (pricing engine consumes all three), Platforms.
- **Entry Points:** `/v1/dashboard/payment-types/*`, `/v1/dashboard/order-types/*`, `/v1/dashboard/promo-code/*`; POS: `/v1/pos/payment-types/*`, `/v1/pos/promo-code/view/{name}`.

## Business Workflow
1. Admin creates payment types (name unique; optional settlement `account_no`; optional surcharge: `has_additional_fee` + integer `additional_fee` percent).
2. Admin creates order types (seeded: **Dine-in, Pickup, Delivery**) and assigns which payment types each accepts (`order_type_payment_type`). The POS boot payload delivers order types *with* their allowed payment types, so the cashier can only choose valid combinations client-side.
3. Admin creates promo codes: unique `name` (the code itself), `type` ∈ {percentage, amount}, `amount`.
4. At sale time the cashier looks up a promo code **by name**; if found, its type/amount are applied by the pricing engine (Module 4). Promo data is copied onto the discount row (promo_code_id retained for analytics).

## Business Rules
- Payment type name unique (create and update).
- `additional_fee` is an **integer percentage** (DB column int, default 0); fee applies only when `has_additional_fee` truthy.
- The surcharge of every selected payment type with `has_additional_fee` contributes its `additional_fee` percent to the order's **effective fee rate**, which is then applied **per order line** as `feeRate × basePrice / 100`.
- A line's `basePrice` is the item's price for the selected price mode **plus the sum of the chosen ingredient replacements' `extra_charge`**. The surcharge is therefore charged on a customised meal's uplift as well as on its menu price.
- Each add-on sold with a meal is its own order line and carries the same surcharge on its own price — the surcharge reaches every line of the ticket, not only the meal lines. (Line pricing itself is Module 4.)
- `enable_delivery_app_price` makes the order charge items at their `delivery_app_price`; the choice is resolved **per line** (meal item and each add-on item separately). A line whose item has no delivery-app price falls back to that item's normal price.
- A payment type with `enable_delivery_app_price` can only be used as the order's **sole** payment method: an order whose selected payment types include one with the flag on is rejected if it carries 2 or more payments (validation detail in M04).
- `is_external = 1` marks platform tenders: such orders are hidden from the in-house POS list and shown in the external tab; external payment types are created by seeders, **not** settable via the dashboard API (store/update never touch `is_external`).
- `is_subscription` column exists (default 0) — **no behavior implemented.**
- Deleting a payment type is a hard delete; `order_payments.payment_type_id` FK is `restrictOnDelete` → deleting a used tender fails at DB level (500 to the admin).
- Order types: name required; payment types synced when provided; hard delete with no usage check (orders keep the dangling `order_type` id; resources then render name `null`).
- Promo codes: no expiry dates, no usage limits, no minimum order value, no per-customer limits — **a promo code is valid forever for everyone** until deleted/renamed.
- Promo lookup is by exact name; response includes type/amount so the POS can show the discount before submitting.
- Manual discounts (no promo) are equally allowed by the engine — any cashier with `order.create` can grant an arbitrary discount of any size (no cap, no approval).

## Hidden Rules
- **Order-type IDs are a company standard (confirmed by the business 2026-07-07):** `1 = Dine-in`, `2 = Pickup`, `3 = Delivery` — fixed by convention, never to change; new order types take IDs ≥ 4. The standard is encoded as constants on the `OrderType` model (`OrderType::DINE_IN/PICKUP/DELIVERY`), and the Noon dispatch check uses `OrderType::DELIVERY`. The legacy `OrderTypesEnum` is `@deprecated` (2026-07-07) and survives only for dead legacy code — do not add new usages. Any seeder or manual DB work on `order_types` MUST preserve these IDs.
- POS `payment-types/list` actually returns **order types with nested payment types** (the `payment_type` collection is built then discarded) — the endpoint name is misleading.
- Because the order-type↔payment-type constraint is enforced only client-side, the API will happily accept any payment type for any order type.
- A promo code of type `amount` larger than the order net produces a **negative subtotal** (no floor).
- Every order line records its own `used_delivery_app_price` boolean, so a single order can mix lines that were charged at the delivery-app price with lines that fell back to the normal price (an add-on without a `delivery_app_price` sold under a delivery-app-price tender).
- The surcharge is configured as a tender fee on the menu price, but because it is applied to the line's `basePrice` it also scales with the replacement extra charges the cashier picked.
- `Discount` model has `discount_amount` in fillable but the table column is `value` — leftover; the real fields are `discount_type`, `value` (entered), `calculated_value` (money effect).

## Decision Tree
```
PROMO APPLY (inside order create/update):
IF discount.promo_code_id present → type := promo.type, amount := promo.amount
ELSE → type := discount.discount_type, amount := discount.value
IF type == 'percentage' → value := net × amount/100
ELSE → value := amount  (may exceed net; no clamp)
```

## Database Understanding
- `payment_types` — tender dimension for every money report; also the *channel* marker (`is_external`), the platform link (`platform_payment_type`), and the two pricing switches (`has_additional_fee` + `additional_fee`, `enable_delivery_app_price`) that change what each order line is charged.
- `order_types` — service-mode dimension (dine-in/pickup/delivery) driving DSR sections and the Noon dispatch decision.
- `order_type_payment_type` — the business matrix "which tenders are acceptable for which service mode" (advisory, client-enforced).
- `promo_codes` / `discounts` — a promo is a *template*; a discount is its *application* to one order (with the computed value frozen).

## State Machine
None (no lifecycle on these entities).

## APIs (deltas beyond generic CRUD)
| Endpoint | Notes |
|---|---|
| `POST /dashboard/payment-types/create` | name unique; syncs `order_type_ids` when provided |
| `PUT /dashboard/payment-types/update` | partial update; cannot change `is_external`/`is_subscription` |
| `POST /dashboard/order-types/create|update` | name; `payment_type_ids` sync |
| `GET /pos/promo-code/view/{name}` | 404-style with `success:true` if unknown |
| `GET /pos/payment-types/list` | returns order types with allowed payment types |

## Edge Cases
- Two promo codes differing only by case: MySQL default collation → lookup may match either (case-insensitive uniqueness not guaranteed by validation).
- Deleting an order type that platform mapping/type=3 logic relies on breaks delivery dispatch invisibly.
- An add-on item with no `delivery_app_price` sold under a delivery-app-price tender is charged its normal price while the meal beside it is discounted to the delivery-app price — the ticket mixes both price modes with no warning.
- Turning on both switches on one payment type stacks them: the line is priced at the delivery-app price and then the surcharge percent is added on top of it (plus any replacement extra charges).

## Assumptions
- Fee percentages are whole numbers.
- Promo codes are distributed offline (no customer accounts exist).

## TODOs
- Promo governance: expiry, usage caps, min order, audit of who applied manual discounts.
- ~~Replace `order_type == 3` with a semantic flag on order types~~ — not needed: fixed order-type IDs (1/2/3) are an accepted company standard (2026-07-07). Guard the standard operationally instead (don't reseed `order_types` in production; new order types get IDs ≥ 4).
- Discount cap / negative-total guard.

## Open Questions
- Meaning of `payment_types.account_no` (settlement account? printed nowhere).
- Intended UX for `is_subscription` tenders.
- Is charging the `additional_fee` surcharge on ingredient replacement extra charges intended? The surcharge is configured as a tender fee on the menu price, yet it is applied to the customised line price (menu price + replacement extras).
- Should a payment type with `enable_delivery_app_price` also force the add-on lines to a delivery-app price, i.e. is the fallback to the add-on's normal price the wanted commercial behaviour or a catalog gap to be filled per add-on item?

---

