# Business Rules - ERP, POS, Distribution, B2B, and Galon This document is the authoritative business-rule register for the Go modernization. Every rule is classified by source; an implemented route alone does not prove business parity. ## 0. Source hierarchy and conflict policy Apply rules in this order: 1. **Confirmed Client Rules** in this document. 2. Existing Laravel behavior and the MySQL database in `../ERPGUDANG`. 3. Existing Go behavior. 4. Generic assumptions. When Laravel or Go conflicts with a Confirmed Client Rule, the client rule wins. Rules that are not explicit in the confirmed requirements or discoverable in Laravel/database sources are marked **REQUIRES BUSINESS CONFIRMATION** and must not be implemented as policy. Priority scope: - **P0:** POS, ERP core, inventory, sales, purchasing, finance, cashflow, and owner dashboard. - **P1:** distribution, Delivery Order, partial shipment, fleet, dispatch, shipment tracking, and B2B/e-commerce. - **P2:** mobile applications, additional automation, advanced integrations. Terminology: - **CO means checkout.** It is not a separate document or domain entity. - **KPU is a client reference application only.** No KPU module, API, database, or workflow is in scope unless a later confirmed requirement explicitly adds one. - Employee sales GPS tracking and shipment tracking are separate domains. Sections 1-10 preserve sourced Laravel behavior unless a later **Confirmed Client Rule** overrides it. Sections 11 onward define those confirmed overrides and additions. --- ## 1. Document numbering (model `creating` hooks) | Doc | Pattern | Implementation (Laravel) | Go decision | |---|---|---|---| | Product code | `-YYYYMMDD-<3>` | prefix = first 3 uppercase letters of category name (default `PRD`); seq = count of products in that category **incl. trashed** + 1; uniqueness loop | replicate; atomic seq per category+date | | Customer code | `CUS-<3>` | global count incl. trashed + 1 | replicate; atomic | | Supplier code | `SUP-<3>` | global count incl. trashed + 1 | replicate; atomic | | SO | `SO-YYYYMMDD-<4>` | prefix from order_type: pos→`POS`, consignment→`CSG`, else `SO`; seq = count of today's SO + 1 | replicate; **atomic seq** | | PO | `PO-YYYYMMDD-<4>` | count of today's PO + 1 | replicate; atomic | | GR | `GR-YYYYMMDD-<4>` | count of today's GR + 1 | replicate; atomic | | Invoice | `INV-YYYYMMDD-<4>` | count of today's invoices + 1 | replicate; atomic | | Payment | `PAY-YYYYMMDD-<4>` | count of today's payments + 1 | replicate; atomic | | Cashflow | `CF-YYYYMMDD-<4>` | count of today's cashflows + 1 | replicate; atomic | | StockMovement | `SM-YYYYMMDD-<4>` | count of today's movements + 1 | replicate; atomic | | Opname | `OPN-YYYYMMDD-<4>` | count of today's opnames + 1 | replicate; atomic | | Transfer | `TRF-YYYYMMDD-<4>` | count of today's transfers + 1 | replicate; atomic | | Voucher | `PV-YYYYMMDD-<4>` | global count + 1 | replicate; atomic | | Employee | `EMP-<4>` | global count + 1 | replicate; atomic | > The Laravel `count()+1` is non-atomic (race → duplicate numbers). Go uses per-day DB sequences inside the same transaction. Documented deviation (approved). ## 2. Sales / POS ### 2.1 Regular Sales Order (`SalesController::storeOrder`) 1. Validates: `customer_id` exists, `warehouse_id` nullable, `order_date` required date, `tax_percent` 0–100, `discount_amount` ≥0, `shipping_cost` ≥0, items required min 1; each item: `product_id` exists, `quantity ≥ 1`, `unit_price ≥ 0`, `discount_percent` 0–100. Optional `source_type` ∈ {`NOO`,`EC`,`AO`} (case-insensitive input, canonical uppercase stored; empty → NULL; unknown → 400). - **Order source classification (business rule):** NOO = New Outlet Order (new outlet/store/customer places an order); EC = order placed as the result of a Sales visit (placed same day); AO = order placed from the Office. These are **parallel source labels on the SO, NOT lifecycle stages** — never model NOO→EC→AO as a sequence. EC is a sales-visit classification and has nothing to do with e-commerce. - POS (`order_type=pos`) and e-commerce (`order_type=ecommerce`) orders do not carry `source_type` (NULL) — their channel is already expressed by `order_type`. 2. Warehouse = request value OR first active warehouse (fallback 1). 3. Creates SO: `status=draft`, `order_type=regular`, `subtotal = Σ line totals`, then computes header `tax_amount`, `discount_amount`, `shipping_cost`. 4. Per line: `discount_amount = unit_price × quantity × (discount_percent / 100)`; `line_subtotal = unit_price × quantity − discount_amount` (**not rounded** in PHP; DB stores 2dp). 5. `tax_amount = subtotal × (tax_percent / 100)` (unrounded in PHP). 6. `total_amount = subtotal + tax_amount − discount_amount + shipping_cost`. 7. `paid_amount = 0`; `remaining_amount = total_amount`. 8. For each line: `stocks.reserved_quantity += quantity` (row created via firstOrCreate). 9. ActivityLog recorded. ### 2.2 POS (`SalesController::processPos` / API `store`) 1. Validates: `customer_id` required exists, `warehouse_id` required exists, `payment_method` in `cash,transfer`, items min 1; item: `product_id` exists, `quantity ≥ 0.001`, `unit_price ≥ 0`; `cash_received` nullable ≥0. 2. Warehouse = first active warehouse (fallback 1) — **request `warehouse_id` is ignored** in web flow. 3. `subtotal = Σ (quantity × unit_price)`. 4. Creates SO: `status=confirmed`, `order_type=pos`, `total_amount=subtotal`, `remaining_amount=subtotal`. 5. Per line create SO item. 6. Per line `adjustStock(−quantity, 'stock_out')`. **No availability check → negative stock possible (confirmed in data).** 7. Auto-create Invoice: `invoice_date=today`, `due_date=today+7d`, `status=unpaid`, `total_amount=subtotal`, `remaining=subtotal`. 8. SO `status=invoiced`. 9. Returns JSON with created SO (+ invoice). > POS web flow writes `order_number` in the create array — **column does not exist**; Laravel mass-assignment silently drops it. SO number comes from the boot hook. Go: use `so_number` only. > **CONFIRMED OVERRIDE:** the target POS must reject insufficient stock and must calculate authoritative pricing on the backend. The Laravel behavior that trusts client price and permits negative stock must not be preserved. ### 2.3 SO update (draft only) - Release `reserved_quantity` for existing items; delete items; re-create from payload; re-increment reserved; recompute totals; `remaining = total − paid_amount`. ### 2.4 SO confirm (`confirmOrder`) - Only from `draft`; sets `status=confirmed`. **No stock deduction.** No delivery/pick flow exists in Laravel. ### 2.5 SO cancel (`cancelOrder`) - Cannot cancel if `status ∈ {delivered, cancelled}`. Release `reserved_quantity` for items. `status=cancelled`. ### 2.6 SO delete (`destroyOrder`) - Only when `status=draft`. Deletes items then order. ### 2.7 Receivables (`receivables`) - Invoices with `status ∈ {unpaid, partial, paid}` (overdue computed), filter by customer/status, CSV export with per-invoice remaining + total. ## 3. Inventory ### 3.1 `adjustStock(productId, warehouseId, delta, movementType)` — shared helper 1. `firstOrCreate` stock row `(product, warehouse)`. 2. `quantity_before = current.quantity`; `quantity_after = before + delta`. 3. Update `stocks.quantity = quantity_after`, `last_updated_at = now`. 4. Create `StockMovement`: `movement_number=SM-...`, `quantity = abs(delta)`, before/after, `reference_type/id`, `notes`, `created_by`. - **No `SELECT FOR UPDATE` inside.** (Concurrency fix in Go hardening — approved deviation.) ### 3.2 Manual stock-in (`storeStockIn`) - Validates product, warehouse, quantity ≥1 (StockInRequest: `quantity numeric min 0.001`). - `adjustStock(+qty, 'stock_in')`; default warehouse if not given. ### 3.3 Manual stock-out (`storeStockOut`) - Validates product, quantity ≥1. - Locks stock row (`lockForUpdate`); if `quantity < requested` → error "Stok tidak mencukupi". - `adjustStock(−qty, 'stock_out')`. ### 3.4 Stock list - `SUM(quantity) GROUP BY product_id`, search by product name/code; low stock = active products where any stock `quantity <= products.min_stock` OR product has no stock rows. ### 3.5 Opname - **Create**: `status=in_progress`, opname_date; snapshot every stock row → `StockOpnameItem(system_quantity=stock.quantity)`. - **Finalize**: for each item, `difference = physical − system`; if `difference != 0` → `adjustStock(difference, 'opname')`; set `status=completed`. (Items with physical_qty NULL are skipped.) - `status: draft|in_progress|completed|cancelled`. CSV export. ### 3.6 Transfer - Transfer starts as `draft`; create/edit/cancel never mutate stock. Source authorization is required for draft writes/dispatch; destination authorization for receipt. Missing actor or scope fails closed. - `POST /inventory/transfers/{id}/approve` locks source rows in product order, rechecks unreserved stock, deducts source, records `transfer_out`, and persists per-item transit atomically. Destination is unchanged. Unique `VCH-{transfer_number}` and company/item snapshot are issued once; print/reprint is read-only. - `POST .../{id}/receive` records actual `good`, `damaged`, `missing` with stable `request_key`. Only good increments destination stock with `transfer_in`; damaged is non-operational quarantine on the transfer. Missing remains transit. Partial/damaged/missing requires reason; excess rejected. - States: `draft → in_transit → partial/discrepancy → completed`; draft only may become `cancelled`. Completion requires zero outstanding transit. Dispatch retry returns original voucher; receipt retry requires identical payload/key. - `POST .../{id}/reconcile` requires existing admin/owner authority, destination scope, and reason. Explicit missing quantity becomes audited loss reducing transit, without adding stock. Stock/state/audit writes share one transaction. - Migration 000135 preserves historical completed stock/history. Legacy non-draft version-0 documents cannot be received automatically: operators must reconcile with evidence, never invent transit/history. Recovery restores database and application backup together; never drop live transit/audit. This stock-transfer aggregate is a warehouse-to-warehouse transfer and is not a Delivery Order. It cannot be reused as the customer delivery aggregate without an explicit migration design. ## 4. Purchasing ### 4.1 PO create (`storePO`) 1. Validates: supplier exists, warehouse exists, order_date date, expected_date ≥ order_date, tax_percent 0–100, discount_amount ≥0, items min 1; item: product exists, quantity ≥0.001, unit_price ≥0, discount ≥0. 2. Skips items with empty/null product_id or quantity. 3. Per line: `sub = max(0, (quantity × unit_price) − discount)`. 4. `subtotal = Σ sub`. 5. `tax_amount = round(subtotal × tax_percent / 100)` — **PHP `round()` = round-half-away-to-even? No: PHP `round($x)` default precision 0, mode PHP_ROUND_HALF_UP → integer rounding, half up.** 6. `total_amount = max(0, subtotal + tax_amount − discount_amount)`. 7. `status=draft`, `paid_amount=0`, `remaining_amount=total_amount`. ### 4.2 PO approve / cancel / delete - Approve: `draft→approved` (only draft). Cancel: `status=cancelled` (cannot cancel received/cancelled). Delete: only `draft`. ### 4.3 Goods Receipt (`storeGR`) 1. Validates: `purchase_order_id` exists, `warehouse_id` required exists, `received_date` date, items array. 2. Builds map `product_id → purchase_order_item_id` from PO items. 3. Creates GR header `status=confirmed` (default) + GR items for lines with `quantity_received > 0`, linking `purchase_order_item_id`, capturing `batch_number`, `expired_date`. 4. Per line: ensure and lock the stock row, insert source-linked `stock_in` movement with exact before/after values, update stock balance; propagate every failure to roll back the receipt. 5. PO item `received_quantity += received`. 6. PO status: `received` if `Σ received ≥ Σ ordered`, else `partial`. > Goods receipts lock the PO and stock rows and persist each stock increment with its `stock_in` movement referencing the goods receipt in the same transaction. Requests use idempotency keys; receipts may not exceed the outstanding PO quantity. Missing historical movements are not fabricated. > New manual stock-in/out and goods-receipt HTTP requests persist their authenticated idempotency key, payload fingerprint and response in `stock_operation_receipts` within the stock transaction. Retries serialize on a transaction advisory lock and replay the committed result even if the Redis response cache was interrupted. The ledger and reports classify direction from `quantity_after - quantity_before`; opname quantities are magnitudes, not signed deltas. Movement summary cards use backend full-filter aggregates, independent of page size. ### 4.4 Supplier debt - `supplierDebt`: POs with `status ∈ {received, partial}`, filters by supplier/due_date/search; per-PO remaining; total. - `exportSupplierDebt`: CSV; attempts to read last payment from `Cashflow where reference_type=PurchaseOrder::class and reference_id=po.id` — **never populated** (see payDebt bug) → last payment always empty. ### 4.5 Pay debt (`payDebt`) 1. Validates amount ≥1 numeric. 2. If `amount > remaining` → error "Jumlah pembayaran melebihi sisa hutang". 3. `paid_amount += amount`; `remaining = max(0, remaining − amount)`. 4. If `remaining = 0` → `status=paid`, else `status=partial`. 5. If voucher given: `Voucher.status available → used`. 6. Create Cashflow: `type=expense`, `category='sales'` (sic — not `purchase`), `transaction_date=today`, `amount`, `description` includes PO number and voucher number. **BUG:** `reference_type`/`reference_id` never set; `reference_number` passed but **not a fillable field** → dropped. 7. Voucher `status=used`. ### 4.6 Voucher create (`storeVoucher`) - Fields: description, voucher_date, amount, status default `available`, type default `payment`; auto `PV-YYYYMMDD-`. ## 5. Fakturisasi (Invoicing) ### 5.1 Create invoice (`storeInvoice`) 1. From SO with `status ∈ {confirmed, delivered}`. 2. `invoice_date = today`, `due_date = invoice_date + 7 days`. 3. `total_amount = SO.total_amount`; **`subtotal`/`tax_amount` left 0.00**. 4. `status=unpaid`, `paid=0`, `remaining=total`. 5. SO `status=invoiced`. ### 5.2 Pay invoice (`payInvoice`) 1. Validates amount ≥1 (PaymentRequest). 2. If `amount > remaining` → error. 3. `paid_amount += amount`; `remaining = max(0, remaining − amount)`. 4. Status: `paid` if remaining=0 else `partial`. 5. Create `Payment`: `payable_type=App\Models\Invoice`, `payable_id=invoice.id`, `payment_method` (cash/transfer), bank fields, amount, `PAY-...`. 6. Create `Cashflow`: `type=income`, `category='sales'`, `reference_type=App\Models\Invoice`, `reference_id=invoice.id` (**correct path**), description "Pembayaran Invoice INV-...". 7. If invoice fully paid → `status=paid`. ### 5.3 Invoice print - Blade view; prints invoice + items + totals. **Needs JSON/PDF decision for API.** ## 6. Finance / Cashflow - List filters: `type`, `category`, `date_from`, `date_to`. Summary: `SUM(amount) GROUP BY type` within date filter. - Create manual cashflow: validates type in `income,expense`, category in `sales,purchase,salary,operational,other`, date, amount (CashflowRequest). ## 7. Auth & Authorization ### 7.1 Login (web) - Email exists? "Email tidak terdaftar." Active? "Akun Anda telah dinonaktifkan." `Auth::attempt` else "Password salah." Session regenerate; `last_login_at` updated (≤1×/hr via middleware). ### 7.2 Login (API, `AuthController::apiLogin`) - Validates email/password; returns `{token, user}` on success; 401 otherwise. ### 7.3 Role matrix (from `config/erp.php` + RoleMiddleware + controller constructors) | Role | Allowed modules (web route middleware) | |---|---| | admin | everything (bypasses all role lists) | | sales | master-data (customers,products), sales, fakturisasi, sales-tracking | | finance | master-data, purchasing, fakturisasi, finance | | warehouse | master-data, inventory, purchasing | | hr_manager | hr | | hr_staff | hr.employees, hr.attendance, hr.leaves | | employee | hr.clockin, hr.clockout, hr.leaves.store | | system | system | Permission globs (`role_permissions`): `sales → masterdata.customers.*, masterdata.products.*, sales.*, fakturisasi.*, sales_tracking.*`; `finance → masterdata.*, purchasing.*, fakturisasi.*, finance.*`; `warehouse → masterdata.*, inventory.*, purchasing.*`; `hr_manager → hr.*`; `hr_staff → hr.employees.*, hr.attendance.*, hr.leaves.*`; `employee → hr.clockin, hr.clockout, hr.leaves.store`; `system → system.*`. ### 7.4 Admin bypass `RoleMiddleware` and `User::hasRole()` treat `admin` as always-allowed. **Go must preserve admin bypass.** ### 7.5 API authorization gap `routes/api.php` protects with `auth:sanctum` only — **no role checks on API endpoints.** Go closes this gap by enforcing the same role/permission model on API routes (approved deviation). ## 8. HR ### 8.1 Employee create - Auto `EMP-`; NIK unique; fields: employee_number, user_id, name, nik, department, position, employment_type, join_date, end_date, basic_salary, phone, email, address, city, bank_name, bank_account, bank_account_name, photo, is_active. ### 8.2 Attendance - Clock-in: only if not already clocked in today; sets `clock_in`, attendance_date, GPS, status `present`/`late` (late if after 08:00 + 15 min tolerance). - Clock-out: only if clocked in; sets clock_out, computes `work_hours`; `overtime_hours` if beyond 17:00 (≥8h threshold). - Statuses: `present|late|absent|leave|holiday|wfh`. ### 8.3 Leave - Validate dates (end ≥ start), reason required; `total_days = end − start + 1`; `status=pending`. Approve/reject by admin/hr_manager. `UNKNOWN / REQUIRES CONFIRMATION:` leave balance/entitlement rules — not implemented in Laravel. ### 8.4 Payroll (`storePayroll`) - Hard-coded rates (not from config): - BPJS-TK employee **2.7%** (config says 2.0% — discrepancy; **REQUIRES CONFIRMATION** which is canonical) - BPJS-TK employer 3.7% - BPJS-KES employee 1.0%, employer 4.0% - Overtime pay = overtime hours × 1.5 × (basic / 26 / 8) (working days 26) - `net = basic + allowances + overtime_pay + bonus − deductions − bpjs_tk − bpjs_kes − pph21`. - Components stored on the payroll record; **not recalculated from attendance**. - Status `draft|approved|paid`. Unique per `(employee_id, period_month, period_year)`. ### 8.5 Performance - Monthly scores: kpi, attitude, teamwork, achievement → `total = avg`? (stored); grade A–D. Details `UNKNOWN` granularity of composite formula — derive from PerformanceRequest (score fields all numeric 0–100). ## 9. Sales Tracking - Visit statuses `planned|in_progress|completed|cancelled`; GPS lat/lng on check-in/out; duration minutes = check_out − check_in. - Location ping: bulk insert with recorded_at; high-frequency (60s); **excluded from activity log**. - Route: planned_customers JSON `[{customer_id, order, estimated_time}]`, statuses `planned|in_progress|completed`. - Activity types: `call|meeting|demo|proposal|negotiation|closing|follow_up`. ## 10. Dashboard KPIs - Month sales: `Σ sales_orders.total_amount` where month/year = now, status ≠ cancelled. - Month purchase: same on purchase_orders. - Receivables: `Σ invoices.remaining_amount` where status ∈ {unpaid, partial, overdue}. - Payables: `Σ POs.remaining_amount` where status ∈ {received, partial}. - Low stock count: active products where any stock `quantity <= min_stock` OR no stock rows. - Recent activity (activity_logs latest). - Monthly chart data for sales (last 12 months). ## 11. Confirmed POS rules ### 11.1 Transaction composition - POS is the primary retail transaction client of the shared ERP core. - A transaction supports multiple distinct products and multiple total item quantities. For example, eggs x2, gallon x1, and water x1 are three distinct products and four total quantity. - POS must support product/SKU, barcode, category, brand, configured units, quantity, minimum purchase, server-side pricing, discount, promotion, sales voucher, member, payment, invoice/receipt, return, void/cancel, history, cashier shift, and cash in/out. - POS must not use a separate inventory, payment, invoice, or cashflow database. ### 11.2 Authoritative pricing - The backend is the pricing source of truth for POS and checkout. - The client submits transaction intent: selected product/product-unit, quantity, customer/member, voucher/promotion references, and payment input. - Client-provided `unit_price`, discount, promotion result, line total, and transaction total are never authoritative. - The backend resolves the configured product-unit price, minimum purchase, eligible member/customer price, valid discount/promotion, valid sales voucher, and final total. - The authoritative resolver is `internal/pricing`. Price precedence: the configured `product_units.selling_price` wins; when it is null the product selling price is scaled by the unit conversion factor, so a box of 12 inherits 12x the piece price. Quantity below the configured `min_purchase` rejects the whole transaction with no partial writes. Stock always moves in base units (`quantity x conversion_factor`). - Member/customer special pricing, promotions, and sales vouchers are **not yet** part of the resolver; they arrive with Phase 6 items 3 and 4. - Promotion stacking and precedence are **REQUIRES BUSINESS CONFIRMATION**. ### 11.3 Stock policy - POS must never produce negative stock. - Before mutation, all affected stock rows are locked in deterministic product/warehouse order. - Available quantity is validated for every normalized line. If any item is insufficient, the entire transaction is rejected with a clear business error and no partial writes. - A successful POS transaction atomically creates the sale and items, deducts actual stock, writes accurate movements, creates invoice/payment/cashflow records as applicable, and commits once. - Every movement records the actual quantity before, delta, and quantity after from the same locked mutation. ### 11.4 Void and return - Void/cancel follows explicit status guards and audit requirements; it is not implemented by deleting completed transaction rows. - A return is a separate transaction/event referencing the original transaction and selected original lines. - A return stores returned quantity and reason, creates refund/adjustment records when applicable, creates inventory movement when stock is accepted back, and preserves a complete audit trail. - Refund method, tax/promotion allocation, damaged-return handling, and quarantine-stock policy are **REQUIRES BUSINESS CONFIRMATION**. ### 11.5 Cashier shift - POS supports open shift, opening cash, cash in, cash out, sales, expected cash, actual cash, variance, and close shift. - Whether shifts are bound to one register/store, whether a cashier may change registers, and whether a shift can span stores are **REQUIRES BUSINESS CONFIRMATION**. - Expected drawer cash is persisted opening cash + cash payments on non-cancelled shift orders + drawer `cash_in` − drawer `cash_out`. Non-cash payments never increase drawer cash. Cash refunds are recorded once as `cash_out`; do not subtract returns again. - Cash entries require a positive amount, the authenticated cashier's own open shift, and an allowed store binding; creator identity comes from authentication, never the request. Outlet-restricted callers cannot access unbound shifts. - Summary and detail read the persisted ledger under the shift lock. Closure takes the same row lock as drawer, sale, payment, and refund writers. Closed shifts reject new financial writes; corrections require a separate authorized business operation, not historical mutation. - Normal closure requires physical cash to equal expected cash exactly. A non-zero difference returns a business conflict. Only existing admin/owner authority may explicitly override with a nonblank reason. - Migration `000134` persists closure expected cash, physical cash, difference, authenticated actor, override flag, reason, and timestamp in `cashier_shift_closures`; it does not invent audit history for old closures. ## 12. Product, unit, barcode, and pricing configuration - Products support multiple configured units such as pcs, box, carton, tray, and other master units. - A product-unit configuration may have its own price and minimum purchase. - Unit conversion factors must come from configuration and must never be hard-coded. - Transaction lines snapshot the selected unit, conversion factor, authoritative unit price, and applied adjustments needed for later audit and return calculations. - Product barcode, product code/SKU, and document identifiers are separate identifiers. - Brand and barcode are required catalog capabilities even though Laravel has no corresponding authoritative schema. Implemented (migration `000010`): - `brands` is master data; `products.brand_id` is optional and deleting a brand still referenced by a live product is rejected. - `product_units` holds `conversion_factor` (base units per configured unit, `> 0`), optional `selling_price` (null inherits the product price), `min_purchase` (`> 0`), `is_base`, and `is_active`. Exactly one base unit per product, and the base unit is always factor 1. - `product_barcodes` is globally unique and may bind to one `product_units` row so a box scan and a piece scan resolve to different configured prices; at most one primary barcode per product. - `GET /api/v1/barcodes/lookup` returns the product plus the configured unit, its conversion factor, its price, and its minimum purchase. - Transaction lines snapshot `product_unit_id`, `unit_id`, `unit_name`, `unit_symbol`, `conversion_factor`, `base_quantity`, `min_purchase`, and the authoritative `unit_price` (migration `000011`). A database trigger rejects any UPDATE that changes a snapshot column, so returns and audits always replay the original pricing. ## 13. Membership and B2B/e-commerce ### 13.1 Identity and tiers - Authentication, customer identity, and membership are separate concepts: `User -> Customer -> Membership`. - Confirmed membership tiers are exactly **Bronze**, **Gold**, and **Platinum**. Silver must not be added without new confirmation. - Members may buy market products, eggs, galon products/workflows, and other catalog products; membership is not limited to galon. - Membership supports member login, tier, special price, discount, sales voucher, and benefit. - Tier upgrade, downgrade, and expiry remain **REQUIRES BUSINESS CONFIRMATION**. The membership benefit discount formulas are now **CONFIRMED**: Bronze 2%, Gold 5%, Platinum 8% of the line subtotal (see §13.1.1). ### 13.1.1 Implementation status (2026-08-17) - The identity split, the three tiers, and the append-only tier audit are implemented in `migrations/000012_membership_identity.sql` and `internal/membership`. - The membership benefit is implemented (Phase 6 item 3, 2026-08-17): the pricing engine resolves Bronze 2% / Gold 5% / Platinum 8% of the line subtotal and freezes `membership_tier` + `membership_discount_amount` into the immutable sales-line snapshot (`migrations/000013_membership_benefit.sql`). The benefit applies only to an ACTIVE membership and fails closed on an unknown tier. Tier upgrade, downgrade, and expiry are NOT automatic: every change is admin-initiated and recorded in `membership_tier_history` (actor + reason), and a later tier change does not rewrite the historical sales-line snapshot. Member number uses the established monthly document sequence (`MBR-YYYYMM-NNNNNN`, §17) via `pkg/seq`. - **STEP 0 (2026-08-18): the tier discount percentages are now DATABASE-BACKED** via the `membership_tiers` table (`migrations/000014_membership_tier_config.sql`), seeded Bronze 2% / Gold 5% / Platinum 8% (`percentage` discount type). The pricing engine resolves the percentage from `membership_tiers` at pricing time through a DB resolver; an in-code fallback map (`pricing.tierDiscountPercent`) is retained ONLY for unit tests that wire no resolver. Admin CRUD is exposed through `GET/POST/PUT /api/v1/memberships/tiers` (permission `masterdata.memberships.manage`), and EVERY config change is audited append-only in `membership_tier_config_history` (from/to `discount_type` + `discount_value`, actor, reason). Historical sales-line snapshots (`membership_tier` + `membership_discount_amount`) remain IMMUTABLE — only the source of the percentage changed, never the frozen snapshots. ### 13.2 Checkout - CO means checkout and is the transaction-initiation point, not a separate entity. - Checkout validates product availability, recalculates server-side price, validates discount/promotion/voucher/member benefits, calculates totals, and applies reservation/deduction according to the workflow. - Cart clearing and order creation share a consistent PostgreSQL transaction boundary. A failure to clear the cart must not leave a committed duplicateable order. - Cart mutations and checkout lock the same cart row; order/items, physical-stock deduction, movements, cart clearing, and the durable checkout receipt commit together. B2B never bypasses available-stock validation through the retail negative-stock setting. - `Idempotency-Key` receipts are scoped to the authenticated buyer and exact checkout payload. A timeout retry sends the same key and body without requiring a cart prefetch (the first request may already have cleared it). Changed intent gets a new key; a reused key with changed payload returns 409. Receipts are retained beyond Redis cache TTL to cover commit/cache disconnects; no historical receipts are invented. - Pricing/minimum/product-unit failures return 400, stock/voucher conflicts return 409, authentication/authorization retain 401/403, and infrastructure failures remain safe 5xx. The storefront preserves the cart/form and displays the request reference; it never presents SQL/stack details. Shipping address remains optional free text as supported by this endpoint; no invented address or minimum-order policy is applied. - B2B uses the same ERP inventory, sales, invoices, payments, receivables, and cashflow source of truth. - Hutang/piutang remain ERP/B2B concerns and are not moved into the core POS UI. ### 13.3 Sales voucher separation - Purchasing vouchers are internal purchasing/payment documents. - Sales/member vouchers are customer promotional benefits. - These are separate aggregates with separate identifiers, lifecycle, authorization, accounting meaning, and redemption history. ## 14. Galon domain - Galon is a specialized business workflow, not merely a product category. - Confirmed transaction types are refill, buy galon, buy galon plus contents, and borrow galon. - Confirmed fulfillment modes are pickup and delivery. - Both ordinary customers and members can use galon workflows. - Deposit, loan fee, penalty, due date/expiry, individual-container identity, and per-customer container-balance rules are **REQUIRES BUSINESS CONFIRMATION** and must not be invented. ## 15. Sales Order, Delivery Order, and shipment ### 15.1 Document relationship The target relationship is: `Customer -> Sales Order -> Delivery Order(s) -> Shipment(s) -> Delivery -> Consolidated Invoice` - These documents remain separate aggregates. - A Sales Order may have one or more Delivery Orders. - A Delivery Order is a parent fulfillment document and may have multiple shipment executions. - A shipment is a child execution record and must have an identifier distinct from its parent DO number. ### 15.2 Delivery Order A Delivery Order records at least: - unique DO number; - Sales Order, customer, warehouse, and destination; - line items; - requested, shipped, delivered, and remaining quantities; - priority and delivery status; - shipment history and dispatch/fleet relationships. Delivery status must distinguish draft, approved, partially fulfilled, ready, in transit, delivered, completed, and cancelled where applicable. Transition guards are enforced by the service, not merely represented as database enum values. ### 15.3 Mandatory partial delivery - Partial delivery is required. - Multiple shipments at different times remain children of the same parent DO and retain the same DO number. - Example: requested 770, first shipment 500, remaining 270, second shipment 270. Both shipment records reference the same DO. - DO line and header totals are derived from actual shipment execution records. - A DO is `partially_fulfilled` while delivered/shipped quantity is positive but outstanding quantity remains; it becomes `completed` only when the required total is fulfilled according to the confirmed delivery status policy. - A shipment may not exceed the remaining quantity on any DO line. - Shipment creation, items, stock mutation, inventory movement, aggregate quantities, status history, and dispatch state update commit in one PostgreSQL transaction or all roll back. - Inventory deduction is based on actual shipped quantity, never the entire requested quantity when only a partial shipment occurs. ### 15.4 Dispatch and fleet - Dispatch is an operational workflow, not just a `fleet_id` field. - It records priority, queue/order, fleet assignment, optional driver assignment, status, schedule, dispatcher, and dispatch timestamps. - Operators can choose which DO executes first, assigned fleet, order, and schedule. - Conceptual priorities are `urgent`, `high`, `normal`, and `low` because no legacy priority convention exists. - Fleet capacity enforcement, weight/volume limits, and driver master/employee relationship are **REQUIRES BUSINESS CONFIRMATION**. ### 15.5 Consolidated invoice - Distribution billing uses consolidated invoices; one shipment does not automatically equal one invoice. - Billing timing (per shipment, DO completion, or periodic cycle) and whether one invoice may combine multiple Sales Orders are **REQUIRES BUSINESS CONFIRMATION**. ## 16. Shipment tracking and map data - Shipment tracking is separate from employee sales tracking. - A shipment can be found by DO number, shipment number, barcode, or tracking identifier. - Product barcode, transaction number, SO number, DO number, shipment number, invoice number, payment number, and return number remain distinct identifiers. - Tracking events store timestamp, shipment status, source, and location when supplied. - Map-ready responses provide latest location, location history, event timestamps, and shipment status. - A Location Provider abstraction may accept driver-mobile GPS, third-party tracking, or authorized manual updates. - The system must not claim real-time GPS when no active location source exists. - Proof-of-delivery requirements are **REQUIRES BUSINESS CONFIRMATION**. ## 17. Document numbering Transaction documents use separate monthly, concurrency-safe sequences: - `SO-YYYYMM-000001` - `DO-YYYYMM-000001` - `INV-YYYYMM-000001` - `PAY-YYYYMM-000001` - `RET-YYYYMM-000001` - `SHP-YYYYMM-000001` Rules: - Scope is document type plus calendar month in Asia/Jakarta. - Each document type has an independent counter. - PostgreSQL performs atomic allocation and every document number has a unique constraint. - Month rollover starts a new counter. - Allocation errors abort the surrounding business operation and are never suppressed. - Master codes such as product, customer, supplier, and employee do not automatically adopt monthly document sequencing. - Existing legacy formats in Section 1 describe imported documents and old Laravel behavior; they are superseded for newly generated transaction documents. ## 18. Finance corrections and accounting periods - Accounting periods have `open` or `locked` state. - Cashflow UPDATE/DELETE is rejected when its transaction date belongs to a locked period. - Generated cashflows remain traceable to their source transaction; source references are immutable. - Generated nominal amounts are immutable. Nominal corrections use explicit reversal or adjustment transactions, not direct edits. - Non-nominal corrections allowed in an open period require authorization and before/after audit data. - Invoice deletion is permitted only while the invoice is unpaid and has no payment records. - Invoice deletion locks the invoice and related Sales Order, restores the exact prior SO status from status history, writes audit/history, and commits atomically. ## 19. Idempotency and transactional consistency - `Idempotency-Key` is mandatory for critical creation routes: POS checkout, Sales Order creation, payment, shipment creation, and return creation. - A key is bound to authenticated user and normalized endpoint and includes a request-payload fingerprint. - Reusing a key with a different payload is rejected; a completed matching request replays its stored response; concurrent matching requests execute the business transaction at most once. - Idempotency records have a configured TTL. - If Redis is unavailable, critical idempotent routes fail closed with a service-unavailable response and do not start the business transaction. - Redis stores temporary claim/replay state only. PostgreSQL remains authoritative for sales, stock, shipment, invoice, payment, return, and finance records. - Critical workflows use PostgreSQL transactions and row locks. A transaction is incomplete if a document commits without its required stock/financial records or vice versa. --- ## 20. REQUIRES BUSINESS CONFIRMATION register 1. Leave balance and entitlement rules; not present in Laravel. 2. Payroll BPJS-TK employee rate: Laravel code uses 2.7% while config says 2.0%. 3. Promotion stacking, precedence, and combination with member pricing and sales vouchers. 4. Membership tier upgrade, downgrade, and expiry (benefit formulas Bronze 2% / Gold 5% / Platinum 8% CONFIRMED in §13.1.1). 5. Galon deposit, loan fee, penalty, due date, individual identity, and customer container balance. 6. Return refund method, tax/promotion allocation, damaged-return handling, and quarantine stock. 7. Cashier shift binding to register/store and cashier movement between registers/stores. 8. Consolidated-invoice timing and whether one invoice may combine multiple Sales Orders. 9. Fleet capacity enforcement, weight/volume limits, and driver master relationship. 10. Proof-of-delivery requirements. 11. Leave, payroll, and performance uncertainties retained from the legacy audit where not superseded. 12. Invoice `subtotal`/`tax_amount` legacy zero behavior and persistence of `sales_orders.tax_percent`. The following are explicitly **not unknowns**: CO means checkout; KPU is out of scope; negative stock is rejected; partial delivery is mandatory; multiple shipments share one parent DO; document sequencing is monthly; membership tiers are Bronze, Gold, and Platinum. ### 20.1 Provisional defaults applied in Phase 6 (DEFAULT PENDING CONFIRMATION) Phase 6 items 6–8 were implemented with common/default behavior so the backend is complete and reversible. Each default below is provisional: when the business confirms the real policy, change the value/behavior without changing the aggregate shape where possible. - **#5 Galon deposit/loan policy**: `deposit_amount` defaults to 0 on every galon transaction; the column exists so a confirmed deposit can be applied later. Loan is now tracked: `borrow_galon` sets `loan_amount`, and the new `POST /galon/{id}/return` (`ReturnGalon`) marks the loan returned. Loan fee, penalty, due date/expiry, individual-container identity and per-customer container balance remain NOT implemented. - **#6 Return refund policy**: refund method defaults to `cash` (client may pass `transfer` or `store_credit`). Refund amount is the pro-rata share of the immutable line snapshot (unit price, discounts, membership benefit). Tax/promotion allocation is NOT implemented. Every accepted return with `condition=damaged` is recorded but skips restock; `condition=good` (default) restocks in full. Damaged-return quarantine stock is NOT implemented. - **#7 Cashier shift binding**: shifts accept an optional `register_id` / `store_id` at open time (one open shift per cashier still enforced). Cross-register/store movement awaits confirmation. - **#1 Membership points/tier**: accrual implemented — 1 point per Rp 10,000 spent (`POST /memberships/{id}/accrue`); auto-promotion bronze → gold (≥1000 pts) → platinum (≥5000 pts), each change recorded in `membership_tier_history`. Thresholds live in `tierForPoints` (service) and are tunable. Downgrade for inactivity/expiry remains NOT implemented. - **HR leave balance**: `employees.leave_balance` (default 12 days) and `leave_used` (default 0) are enforced; approving a leave deducts `total_days` from the balance and errors if insufficient. The default entitlement (12) is tunable in migration `000025_employee_leave_balance.sql`. ## 18. Accounting hardening (2026-08-26, client feedback) ### 18.1 Cash/bank accounts & COA counterpart - Every manual cashflow MUST carry `account_id` (cash/bank account) and MAY carry `counterpart_account_id` (chart of accounts). Balances are derived: Opening + In − Out = Current (`GET /cashflow-balances`), never stored client-side. - Flow classification: `flow_type ∈ {operating, investing, financing}`. - Attachment metadata (key/filename/content_type/size/uploaded_by/at) is stored on the cashflow row; binaries live in pkg/storage via POST /uploads. ### 18.2 Payment voucher (expense disbursement) - Numbering: `VPC-YYYYMM-NNNN` via pkg/seq (monthly). Distinct from purchasing `vouchers` (PV-) and `sales_vouchers` discount coupons. - Draft → Post: posting writes ONE expense cashflow per allocation line (account = paid-from, counterpart = line COA, category 'operational', flow_type default operating) in a single transaction; approver recorded. - Void: only from posted; writes opposing cashflows linked by reversal_of; original entries remain (no hard delete). - REQUIRES BUSINESS CONFIRMATION: per-line category mapping beyond 'operational' (e.g. salary lines → 'salary'), and approval workflow thresholds. ### 18.3 SO authoritative pricing (hardening) - Client-supplied unit_price on regular SO create/update is IGNORED; the pricing engine (product unit price → conversion → membership Bronze2/Gold5/ Platinum8 → line discount → voucher/promotion precedence §13) is the sole source of truth. Snapshots remain trigger-immutable. ### 18.4 PO approval audit - purchase_orders gains approved_by/approved_at; Approve records the acting user. Printable document served by GET /purchase-orders/{id}/print. ### 18.5 REQUIRES BUSINESS CONFIRMATION - Purchase Request module (PR → approval → PO): no PR entity exists today. - Send invoice/reminder channel (no mail transport configured). - XLSX export currently produced as Excel-compatible SpreadsheetML from authoritative rows; native OOXML via excelize pending dependency decision. - GET /uploads/{key}: public product images stay anonymous and immutably cacheable, but payment proofs in the same key namespace are authorization gated — owner (payment -> invoice -> customers.user_id) or `fakturisasi.payments.verify` — with `private, no-store`. New proofs carry the `proof-` key marker; legacy unprefixed proof keys are resolved authoritatively from `payments.proof_url` (P1 closed 2026-09-27).