package sales

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/finance"
	"github.com/TaruLab/keluargamart-backend/pkg/seq"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"
)

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrNotFound               = errors.New("not found")
	ErrForbidden              = errors.New("hanya admin/owner yang dapat membatalkan transaksi POS")
	ErrNotPOSOrder            = errors.New("void hanya untuk transaksi POS")
	ErrNotPickupOrder         = errors.New("hanya pesanan ambil di toko atau POS yang dapat dicatat pembayarannya di kasir")
	ErrInvalidStatus          = errors.New("invalid order status")
	ErrItemRequired           = errors.New("items are required")
	ErrCannotModify           = errors.New("order is not draft")
	ErrCannotCancel           = errors.New("order cannot be cancelled")
	ErrPaidCannotCancel       = errors.New("order already has payments — cancel is not allowed; use the return/refund flow")
	ErrCancelled              = errors.New("order is cancelled")
	ErrUnpaidOrderFulfillment = errors.New("pesanan transfer belum lunas; pembayaran harus diselesaikan sebelum pesanan dapat dikemas atau dikirim")
	ErrCustomerRequired       = errors.New("customer is required")
	ErrCustomerCodeTaken      = errors.New("customer code already taken")
	ErrInsufficientStock      = errors.New("stock insufficient")
	ErrCheckoutValidation     = errors.New("invalid checkout")
	// ErrVoucherNotUsable covers unknown, expired and already-used sales
	// vouchers at redemption time; mapped to BUSINESS_RULE_VIOLATION (409).
	ErrVoucherNotUsable = errors.New("voucher is not usable")
	// ErrGatewayDisabled is returned when MIDTRANS_SERVER_KEY is empty;
	// the handler maps it to 503 with a safe message (no secret leak).
	ErrGatewayDisabled      = errors.New("payment gateway is disabled")
	ErrPaymentNotAllowed    = errors.New("metode pembayaran COD atau status pesanan selesai tidak dapat membayar lewat online payment gateway")
	ErrFulfillmentForbidden = errors.New("hanya staf gudang atau superadmin yang berhak memproses pengemasan dan penyelesaian pengiriman pesanan")
)

// isPickupOrder reports whether an e-commerce order is a store pickup
// (Ambil Sendiri di Toko) rather than a courier delivery.
//
// Checkout writes the marker into shipping_address — "[Ambil Sendiri di
// Toko] <outlet> · Pengambil: <name> (<phone>)" — and there is no dedicated
// fulfillment-mode column, so the marker IS the source of truth. This mirrors
// isPickupOrder in apps/b2b/app/utils/paymentMethods.ts: the two must agree,
// or the buyer sees pickup UI for a delivery order (or vice versa).
func isPickupOrder(o *SalesOrder) bool {
	if o == nil || o.ShippingAddress == nil {
		return false
	}
	a := strings.ToLower(*o.ShippingAddress)
	return strings.Contains(a, "ambil sendiri") ||
		strings.Contains(a, "ambil di toko") ||
		strings.Contains(a, "[ambil sendiri")
}

// IsPickupOrder is the exported form, for wiring in cmd/server (auto-DO on
// confirm must skip pickup orders — see onOrderConfirmed).
func IsPickupOrder(o *SalesOrder) bool { return isPickupOrder(o) }

// paymentMethodFromNotes extracts the method checkout recorded as a notes
// marker ("[Metode: cash]"). It is the ONLY place the order carries the chosen
// method — there is no payment_method column on sales_orders — so every surface
// that has to show or act on the method reads it from here.
//
// Returns the lower-cased code (cash, transfer, gateway, qris, …) or "" when no
// marker exists. Cash orders predating the marker still resolve through
// isCashOrder so a legacy row is never reported as "unknown method" while the
// till treats it as cash.
func paymentMethodFromNotes(notes *string) string {
	if notes == nil {
		return ""
	}
	n := *notes
	if i := strings.Index(strings.ToLower(n), "[metode:"); i >= 0 {
		rest := n[i+len("[metode:"):]
		if j := strings.Index(rest, "]"); j > 0 {
			code := strings.ToLower(strings.TrimSpace(rest[:j]))
			code = strings.ReplaceAll(code, " ", "_")
			if code != "" {
				return code
			}
		}
	}
	if isCashOrder(notes) {
		return "cash"
	}
	return ""
}

func isCashOrder(notes *string) bool {
	if notes == nil {
		return false
	}
	n := strings.ToLower(*notes)
	return strings.Contains(n, "[metode: cash]") ||
		strings.Contains(n, "[metode: cod]") ||
		strings.Contains(n, "[metode: tunai]") ||
		strings.Contains(n, "metode: cash") ||
		strings.Contains(n, "metode: cod") ||
		strings.Contains(n, "metode: tunai") ||
		strings.Contains(n, "cod") ||
		strings.Contains(n, "bayar tunai") ||
		strings.Contains(n, "bayar di tempat")
}

// isUpfrontPaymentRequired returns true if the order requires full upfront payment before packing/shipping.
// B2B credit terms (PaymentTerms > 0 or payment_method tempo) allow fulfillment prior to invoice payment.
// Cash on delivery (COD/cash) orders allow fulfillment prior to payment because payment is collected at delivery/handover.
// Regular back-office ERP orders only require upfront payment if explicitly set to transfer/gateway.
func isUpfrontPaymentRequired(o *SalesOrder) bool {
	if o == nil {
		return false
	}
	pm := strings.ToLower(o.PaymentMethod)
	if pm == "cash" || pm == "cod" || pm == "tunai" || isCashOrder(o.Notes) {
		return false
	}
	if o.PaymentTerms > 0 || pm == "tempo" || pm == "credit" {
		return false
	}
	if o.TotalAmount.IsZero() || o.TotalAmount.IsNegative() {
		return false
	}
	if o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) {
		return false
	}
	// Only an order EXPLICITLY marked for bank transfer / gateway needs the
	// money first. "No method recorded" is not evidence of a transfer — it is an
	// order nobody filled the method in for (legacy rows, orders created before
	// the ERP form had the field). Guessing "unpaid transfer" there is exactly
	// what left orders stuck at "Menunggu Pembayaran" with no way forward.
	//
	// This applies to e-commerce too: a B2B checkout always writes its marker,
	// so an unmarked order is a back-office one regardless of order_type.
	n := ""
	if o.Notes != nil {
		n = strings.ToLower(*o.Notes)
	}
	if pm != "transfer" && pm != "gateway" && pm != "qris" && pm != "va" &&
		!strings.Contains(n, "transfer") {
		return false
	}
	return true
}

// Seq allocates atomic counters (pkg/seq) — injected so tests fake it.
type Seq interface {
	Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error)
}

// Service implements sales use cases.
type Service struct {
	store              Store
	seq                Seq
	loc                *time.Location
	posLedger          POSLedger
	returnLedger       ReturnLedger
	cancelLedger       ReturnLedger
	resolvePrices      PriceResolver
	allowNegativeStock bool
	shiftGuard         ShiftGuard
	onConfirm          func(ctx context.Context, orderID int64) error
	onStatusChange     func(ctx context.Context, order *SalesOrder, oldStatus, newStatus string)
	// employeeLookup resolves an auth user to its employees row. sales_id FKs
	// employees(id) (schema), so the creating user's id must never be stored
	// raw: a user without a matching employees row trips 23503 and the order
	// fails with "A referenced record does not exist." Nil = feature off
	// (tests); a lookup miss means NULL sales_id, never another employee.
	employeeLookup EmployeeLookup
	midtrans       MidtransConfig
	httpClient     *http.Client
	// refundDrawer records cash refunds as drawer cash_out entries so the
	// shift's expected-cash stays truthful. Wired late from main (cashier
	// repository); nil = feature off (tests, deployments without a cashier).
	refundDrawer RefundDrawer
	// httpTimeout overrides the per-call Midtrans deadline (see
	// midtransHTTPTimeout). Zero means the default; production never sets it,
	// tests shorten it so a stalled provider is exercised without a 5s wait.
	httpTimeout time.Duration
	// snapSweepToken is the shared secret that authenticates the background
	// sweeper's HTTP route (MIDTRANS_SWEEP_TOKEN). It gates WHO may ask for a
	// pass; it can never say WHICH invoice that pass acts on — the sweeper
	// derives its candidate set from a database predicate and the route takes no
	// identifiers. Empty means the sweeper is not deployed and the route
	// answers 404 (see SweepSnapInvoices in snap_sweep.go).
	//
	// It lives on the Service rather than the Handler so the token is one
	// wiring call next to WithMidtrans, and so the handler that consumes it
	// reads it from the same object that enforces the sweep.
	snapSweepToken string
}

// ShiftGuard serializes drawer mutations with closing the same shift.
type ShiftGuard interface {
	LockOpenShift(context.Context, db.DBTX, int64) error
}

func (s *Service) WithShiftGuard(guard ShiftGuard) *Service {
	s.shiftGuard = guard
	return s
}

func NewService(store Store, seq Seq, loc *time.Location) *Service {
	if loc == nil {
		loc = time.Local
	}
	return &Service{store: store, seq: seq, loc: loc}
}

// MidtransConfig carries the Snap gateway credentials. Empty ServerKey
// disables the gateway (SnapToken returns ErrGatewayDisabled).
// SnapBaseURL is the app.* token endpoint; CoreBaseURL the api.* status
// endpoint — the two host families must not be mixed.
// MidtransConfig carries the Snap gateway credentials + callback URLs.
type MidtransConfig struct {
	ServerKey   string
	SnapBaseURL string
	CoreBaseURL string
	// FinishURL is the storefront page Midtrans redirects back to after the
	// payment completes on the standalone redirect page (see requestSnapToken).
	FinishURL string
	// NotificationURL is where Midtrans POSTs transaction updates. Sent as
	// X-Override-Notification on every Core API charge, so a POS payment
	// settles through the same verified /midtrans/notifications handler that
	// Snap already uses rather than needing a second callback.
	//
	// Left empty the charge still succeeds — the header is optional and the
	// reconciler can still settle from a status lookup — but the till then
	// depends on polling alone, which is both slower and against the provider's
	// own guidance to prefer the notification.
	NotificationURL string
}

// SnapTokenResult is the SnapToken response payload.
type SnapTokenResult struct {
	SnapToken   string `json:"snap_token"`
	RedirectURL string `json:"redirect_url"`
}

// WithMidtrans wires the Snap gateway credentials (main.go, from env).
func (s *Service) WithMidtrans(c MidtransConfig) *Service {
	s.midtrans = c
	return s
}

// WithHTTPClient injects the HTTP client for Snap calls; tests stub the
// transport. Nil (default) uses the bounded snapHTTPClient — never
// http.DefaultClient, which has no timeout at all. An injected client is used
// AS IS and always wins over the default; the call deadline is applied to the
// request context (snapTimeout) rather than by rewriting the client, so an
// injected client is never mutated and a tighter Client.Timeout of its own
// still fires first.
func (s *Service) WithHTTPClient(c *http.Client) *Service {
	s.httpClient = c
	return s
}

// WithHTTPTimeout overrides the per-call Midtrans deadline. Non-positive
// (default) keeps midtransHTTPTimeout. Test seam, like WithHTTPClient.
func (s *Service) WithHTTPTimeout(d time.Duration) *Service {
	s.httpTimeout = d
	return s
}

// WithSnapSweepToken wires MIDTRANS_SWEEP_TOKEN, the shared secret the
// background sweeper's HTTP route is gated on. It is NOT a Midtrans
// credential — the provider never sees it — and it is deliberately separate
// from WithMidtrans: an unset server key disables the gateway (503), an unset
// sweep token means the sweeper is not deployed (404), and those are different
// facts. Wiring one never implies the other.
func (s *Service) WithSnapSweepToken(token string) *Service {
	s.snapSweepToken = token
	return s
}

// midtransHTTPTimeout bounds EVERY Midtrans call. Five seconds is short
// against the 15s provider webhook timeout and against any gateway read
// timeout in front of this API, so the provider's own deadline stays the
// outer bound.
//
// Without it the call inherited http.DefaultClient's zero timeout, i.e. it
// waited forever: a provider that accepts the connection and then stalls held
// the call, its pooled connection, and — on the recovery-poll path — an OPEN
// transaction holding FOR UPDATE on the invoice row. Enough concurrent buyers
// polling a degraded provider pinned a row lock and a connection each until
// the pool was exhausted and the API was down.
const midtransHTTPTimeout = 5 * time.Second

// snapHTTPClient is the shared default for Midtrans calls. One client, so the
// connection pool is shared across calls rather than rebuilt per call;
// http.Client is safe for concurrent use. Its Timeout covers the response BODY
// read too, which the JSON decode performs after Do returns and which a request
// context alone does not bound.
var snapHTTPClient = &http.Client{Timeout: midtransHTTPTimeout}

// snapTimeout is the effective per-call deadline. It is carried on the request
// context, not on the client: a client is shared and may have been injected by
// a caller, so mutating it would race with concurrent requests and would
// silently override a deliberate client choice. Whichever deadline is shortest
// — ours or the client's own — is the one that fires.
func (s *Service) snapTimeout() time.Duration {
	if s.httpTimeout > 0 {
		return s.httpTimeout
	}
	return midtransHTTPTimeout
}

// snapClient is the client for one Midtrans call. Precedence is unchanged:
// an explicitly injected client always wins and is used unmodified; the
// bounded default is only the fallback.
func (s *Service) snapClient() *http.Client {
	if s.httpClient != nil {
		return s.httpClient
	}
	return snapHTTPClient
}

// WithOnConfirm wires a callback invoked when an order is confirmed.
func (s *Service) WithOnConfirm(fn func(ctx context.Context, orderID int64) error) *Service {
	s.onConfirm = fn
	return s
}

// WithOnStatusChange wires a callback invoked when an order status changes.
func (s *Service) WithOnStatusChange(fn func(ctx context.Context, order *SalesOrder, oldStatus, newStatus string)) *Service {
	s.onStatusChange = fn
	return s
}

// WithPricing wires the authoritative price resolver (§11.2). Without it the
// service falls back to the product selling price at conversion factor 1.
func (s *Service) WithPricing(fn PriceResolver) *Service {
	s.resolvePrices = fn
	return s
}

// EmployeeLookup maps an auth user id to its employees row id. Any error
// (including not-found) means "no linked employee".
type EmployeeLookup func(ctx context.Context, userID int64) (int64, error)

// WithEmployeeLookup wires the user->employee resolver (main.go adapts the
// HR repository). Without it, or when the user has no employees row, created
// orders carry NULL sales_id instead of a user id the FK would reject.
func (s *Service) WithEmployeeLookup(fn EmployeeLookup) *Service {
	if fn != nil {
		s.employeeLookup = fn
	}
	return s
}

// WithCancelLedger records the inbound reversal when an unpaid order is cancelled.
func (s *Service) WithCancelLedger(fn ReturnLedger) *Service { s.cancelLedger = fn; return s }

// WithPOSLedger wires the optional stock_out movement recorder for POS lines.
// main.go provides it backed by the inventory movement store.
func (s *Service) WithPOSLedger(fn POSLedger) *Service {
	s.posLedger = fn
	return s
}

// WithReturnLedger wires the optional stock_in movement recorder for returned
// lines (§11.4 restock). main.go provides it backed by the inventory movement
// store.
func (s *Service) WithReturnLedger(fn ReturnLedger) *Service {
	s.returnLedger = fn
	return s
}

// RefundDrawer records cash leaving the drawer for a refund. Implementations
// must tolerate a closed/missing shift (skip, not fail) because a closed
// shift's variance is already stored — see cashier.Repository.RecordCashRefund.
type RefundDrawer interface {
	RecordCashRefund(ctx context.Context, q db.DBTX, shiftID int64, amount decimal.Decimal, notes string) error
}

// WithRefundDrawer wires the cashier drawer seam used to keep cash refunds in
// the shift's expected-cash. Nil (default) skips drawer recording.
func (s *Service) WithRefundDrawer(rd RefundDrawer) *Service {
	s.refundDrawer = rd
	return s
}

// WithAllowNegativeStock toggles the §11.3 guard. Default (false) rejects any
// mutation that would drive stock below zero; true restores the legacy
// permissive behavior for parity testing only.
func (s *Service) WithAllowNegativeStock(allow bool) *Service {
	s.allowNegativeStock = allow
	return s
}

// --- queries ----------------------------------------------------------------

// checkScope enforces the caller's tenant membership on a store-bound row
// (multi-tenant §5.4): restricted callers get ErrNotFound for rows outside
// their stores, never a 403 that would leak existence.
//
// An unbound row (storeID == nil) is an e-commerce order — it belongs to no
// physical store, so it stays visible to every operator (mirrors the
// ListSalesOrders store filter).
func (s *Service) checkScope(ctx context.Context, storeID *int64) error {
	if storeID == nil {
		return nil
	}
	if !auth.StoreScopeFrom(ctx).Allows(storeID) {
		return ErrNotFound
	}
	return nil
}

// normalizeSOSort maps the ERP table's requested column/direction onto
// the values the SQL ORDER BY compares against. Whitelisted strictly against
// allowed sortable fields. An arbitrary string from the query string must never
// survive this function.
func normalizeSOSort(sortBy, sortDir string) (string, string) {
	var by string
	switch strings.ToLower(strings.TrimSpace(sortBy)) {
	case "so_number", "number", "code":
		by = "so_number"
	case "customer_name", "customer":
		by = "customer_name"
	case "sales_name", "sales":
		by = "sales_name"
	case "order_date", "date":
		by = "order_date"
	case "status":
		by = "status"
	case "total_amount", "total", "amount":
		by = "total_amount"
	case "created_at":
		by = "created_at"
	default:
		return "", ""
	}
	switch strings.ToLower(strings.TrimSpace(sortDir)) {
	case "desc":
		return by, "desc"
	default:
		return by, "asc"
	}
}

func (s *Service) List(ctx context.Context, f SOFilter) ([]SalesOrder, int, error) {
	f.SortBy, f.SortDir = normalizeSOSort(f.SortBy, f.SortDir)
	if f.DateFrom != "" {
		if _, err := time.Parse("2006-01-02", f.DateFrom); err != nil {
			return nil, 0, errors.New("date_from must be YYYY-MM-DD")
		}
	}
	if f.DateTo != "" {
		if _, err := time.Parse("2006-01-02", f.DateTo); err != nil {
			return nil, 0, errors.New("date_to must be YYYY-MM-DD")
		}
	}
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	// Customer scoping for a B2B buyer, mirroring MyOrders. Without it a buyer
	// holding sales.sales-orders.view (000069 granted it so "reports render
	// without 403") reads the whole sales desk: verified 202 rows spanning
	// several customer_id values — other customers' names, addresses and order
	// history. Migration 000103 revokes that key from buyer, but the STOREFRONT
	// ADMIN legitimately holds it and genuinely needs the unfiltered list, so
	// no route gate can fix this: it has to be the service deciding.
	//
	// Scoped by ROLE, not by "does the caller look like a customer" guessed
	// from a permission. A role is the one thing the route gate already trusts,
	// and a guess would silently widen whenever a new key appeared. The
	// storefront admin is AppECommerceAdmin with an admin/owner role, so it
	// falls through untouched and keeps the full surface.
	//
	// MyOrders is the mirror image of this branch: same lookup, same
	// not-attached-yet case, and it also pins OrderType. List does NOT pin
	// OrderType: a buyer scoped to their own customer_id still reads only their
	// own orders whichever channel placed them, and forcing ecommerce here would
	// change what an ERP-side caller sees for no authorization reason.
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleBuyer {
		customerID, err := s.store.CustomerByUserID(ctx, nil, u.ID)
		if err != nil {
			if !errors.Is(err, ErrNotFound) {
				return nil, 0, err
			}
			// No customer linked to this account yet: they have no orders, so
			// the honest answer is an empty set, not the unfiltered one.
			return []SalesOrder{}, 0, nil
		}
		f.CustomerID = customerID
	}
	// Tenant isolation: restricted callers only ever see their stores' orders.
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		f.AllowedStoreIDs = sc.IDs()
	}
	// Warehouse role scope: warehouse users only see approved orders
	// (orders not in draft, pending_approval, or blocked_overdue).
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		if f.Status == "draft" || f.Status == "pending_approval" || f.Status == "blocked_overdue" {
			return []SalesOrder{}, 0, nil
		}
		f.ExcludeUnapproved = true
	}
	// payment_status is derived (paid_amount vs total_amount) so it cannot
	// be pushed to SQL. When filtered we must return a total that matches
	// the filtered set, not the unfiltered DB count, otherwise the pager lies.
	if f.PaymentStatus != "" {
		// ponytail: single bulk fetch (cap 100k), paginate after filter; add cursor/DB filter if scale demands
		bulk := f
		bulk.Page = 1
		bulk.PerPage = 100000
		allRows, _, err := s.store.ListOrders(ctx, bulk)
		if err != nil {
			return nil, 0, err
		}
		filtered := make([]SalesOrder, 0)
		for _, r := range allRows {
			so := orderFromListRow(r)
			if so.PaymentStatus == f.PaymentStatus {
				filtered = append(filtered, so)
			}
		}
		total := len(filtered)
		start := (f.Page - 1) * f.PerPage
		if start >= total {
			return []SalesOrder{}, total, nil
		}
		end := start + f.PerPage
		if end > total {
			end = total
		}
		page := filtered[start:end]
		if err := s.attachSupervisors(ctx, page); err != nil {
			return nil, 0, err
		}
		return page, total, nil
	}
	rows, total, err := s.store.ListOrders(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]SalesOrder, 0, len(rows))
	for _, r := range rows {
		out = append(out, orderFromListRow(r))
	}
	if err := s.attachSupervisors(ctx, out); err != nil {
		return nil, 0, err
	}
	return out, total, nil
}

func (s *Service) OrdersSummary(ctx context.Context, allowedStoreIDs []int64) (SalesOrdersSummary, error) {
	return s.store.SalesOrdersSummary(ctx, allowedStoreIDs)
}

// ForReturnLookup resolves one order by its SO number for the return flow.
// Narrow replacement for the order-book list that 000103 deliberately closed
// to cashiers: the caller holds sales.returns.view (not .sales-orders.view),
// sees only its own stores' rows (List's tenant isolation), and gets 404 for
// anything else — including other stores' numbers. No permission is widened.
func (s *Service) ForReturnLookup(ctx context.Context, number string) (*SalesOrder, error) {
	number = strings.TrimSpace(number)
	if number == "" {
		return nil, ErrNotFound
	}
	rows, _, err := s.List(ctx, SOFilter{Search: number, Page: 1, PerPage: 10})
	if err != nil {
		return nil, err
	}
	for i := range rows {
		if strings.EqualFold(strings.TrimSpace(rows[i].SoNumber), number) {
			u := auth.UserFrom(ctx)
			if u == nil || u.Role != auth.RoleBuyer || u.CanAccessApp(auth.AppECommerceAdmin) {
				if err := s.checkScope(ctx, rows[i].StoreID); err != nil {
					return nil, ErrNotFound
				}
			}
			// Full order (with items) so the return form needs no second
			// call: Get re-checks scope and 404s outside the caller's stores.
			o, err := s.Get(ctx, rows[i].ID)
			if err != nil {
				return nil, err
			}
			// Attach already-returned quantities so the POS dialog clamps
			// each line to its remainder (P2). Advisory only: CreateReturn
			// re-sums server-side and refuses over-returns.
			if returned, rerr := s.store.ReturnedQtyByOrder(ctx, o.ID); rerr == nil {
				for j := range o.Items {
					o.Items[j].ReturnedQty = returned[o.Items[j].ID]
				}
			}
			return o, nil
		}
	}
	return nil, ErrNotFound
}

// PickupFilter configures the pickup lookup for POS cashiers.
type PickupFilter struct {
	Number  string
	Search  string
	Status  string
	Page    int
	PerPage int
}

// ForPickupLookup resolves e-commerce orders destined for store pickup or lookup at the till.
// Cashiers hold sales.pos.create and use this endpoint to view items and hand over orders to customers.
func (s *Service) ForPickupLookup(ctx context.Context, f PickupFilter) ([]SalesOrder, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	searchTerm := strings.TrimSpace(f.Search)
	if f.Number != "" {
		searchTerm = strings.TrimSpace(f.Number)
	}

	soFilter := SOFilter{
		Search:  searchTerm,
		Status:  strings.TrimSpace(f.Status),
		Page:    f.Page,
		PerPage: f.PerPage,
		// The till's Ambil Toko list serves STORE PICKUP only. E-commerce
		// orders where the buyer chose kirim ke alamat are courier work — the
		// warehouse packing list owns those. PickupOnly pushes the same
		// shipping_address marker isPickupOrder uses INTO SQL, before
		// LIMIT/OFFSET: filtering the fetched page in memory hid pickups that
		// sat behind delivery rows and made total disagree with the pager.
		PickupOnly: true,
	}
	if f.Number == "" && f.Search == "" {
		soFilter.OrderType = "ecommerce"
	}
	rows, total, err := s.List(ctx, soFilter)
	if err != nil {
		return nil, 0, err
	}

	if f.Number != "" {
		var exact []SalesOrder
		for _, r := range rows {
			if strings.EqualFold(strings.TrimSpace(r.SoNumber), f.Number) {
				exact = append(exact, r)
			}
		}
		rows = exact
		total = len(exact)
	}

	for i := range rows {
		items, err := s.store.ItemsByOrder(ctx, rows[i].ID)
		if err == nil {
			rows[i].Items = itemsFromRows(items)
		}
	}
	return rows, total, nil
}

// attachSupervisors fills SupervisorID/SupervisorName (approval routing columns
// outside the sqlc row) for the given orders in one query.
func (s *Service) attachSupervisors(ctx context.Context, orders []SalesOrder) error {
	if len(orders) == 0 {
		return nil
	}
	ids := make([]int64, len(orders))
	for i := range orders {
		ids[i] = orders[i].ID
	}
	bySO, err := s.store.SupervisorsByOrders(ctx, ids)
	if err != nil {
		return err
	}
	for i := range orders {
		if sv, ok := bySO[orders[i].ID]; ok {
			id, name := sv.ID, sv.Name
			orders[i].SupervisorID = &id
			orders[i].SupervisorName = &name
		}
	}
	return nil
}

// ListSupervisors retrieves users with supervisor/approver authority.
func (s *Service) ListSupervisors(ctx context.Context) ([]SupervisorOption, error) {
	return s.store.ListSupervisors(ctx)
}

func (s *Service) Get(ctx context.Context, id int64) (*SalesOrder, error) {
	row, err := s.store.OrderByID(ctx, id)
	if err != nil {
		return nil, err
	}
	u := auth.UserFrom(ctx)
	isBuyer := u != nil && (u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client")
	if !isBuyer || u.CanAccessApp(auth.AppECommerceAdmin) {
		if err := s.checkScope(ctx, orderFromRow(row).StoreID); err != nil {
			return nil, err
		}
	}
	o := orderFromRow(row)
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		if o.Status == "draft" || o.Status == "pending_approval" || o.Status == "blocked_overdue" {
			return nil, ErrNotFound
		}
	}
	items, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return nil, err
	}
	o.Items = itemsFromRows(items)
	one := []SalesOrder{*o}
	if err := s.attachSupervisors(ctx, one); err != nil {
		return nil, err
	}
	o.SupervisorID, o.SupervisorName = one[0].SupervisorID, one[0].SupervisorName
	// Attach the order's invoice so the detail payload carries the document its
	// payment_status already summarizes — and so an operator can pay it
	// (POST /invoices/{id}/pay) without a lookup route that does not exist:
	// ListInvoices cannot filter by sales_order_id. Before this, the ERP
	// detail's "Invoice:" line never rendered (order.invoice was always null).
	// Absence is normal (draft orders have none) and stays nil; same read
	// pattern as SnapPOSStatus, whose statement-scoped lock ends at return.
	if inv, err := s.store.InvoiceForOrder(ctx, nil, id); err == nil {
		o.Invoice = inv
	}
	return o, nil
}

// --- locked stock helpers ----------------------------------------------------
//
// Every stock mutation locks the row FOR UPDATE first. Callers must lock lines
// in deterministic (product_id, warehouse_id) order to avoid deadlocks.

// lockStock returns the locked stock row for (product, warehouse), creating a
// zero row on first use.
func (s *Service) lockStock(ctx context.Context, q db.DBTX, productID, warehouseID int64) (db.GetSalesStockRowForUpdateRow, error) {
	row, err := s.store.LockStockForUpdate(ctx, q, productID, warehouseID)
	if errors.Is(err, ErrNotFound) {
		if err := s.store.EnsureStockRow(ctx, q, productID, warehouseID); err != nil {
			return db.GetSalesStockRowForUpdateRow{}, err
		}
		row, err = s.store.LockStockForUpdate(ctx, q, productID, warehouseID)
		if err != nil {
			return db.GetSalesStockRowForUpdateRow{}, err
		}
	} else if err != nil {
		return db.GetSalesStockRowForUpdateRow{}, err
	}
	return row, nil
}

// available is the quantity that can still be sold/reserved for the locked row.
func (s *Service) available(row db.GetSalesStockRowForUpdateRow) decimal.Decimal {
	return row.Quantity.Sub(row.ReservedQuantity)
}

// reserveStock locks the row and adds delta to reserved_quantity, rejecting
// the reservation when it would exceed available (unless negative stock is
// enabled for parity).
func (s *Service) reserveStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) error {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return err
	}
	if !s.allowNegativeStock && s.available(row).LessThan(qty) {
		return ErrInsufficientStock
	}
	return s.store.SetStockQuantity(ctx, q, row.ID, row.Quantity, row.ReservedQuantity.Add(qty))
}

// releaseStock locks the row and reduces reserved_quantity (floor 0).
func (s *Service) releaseStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) error {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return err
	}
	reserved := row.ReservedQuantity.Sub(qty)
	if reserved.IsNegative() {
		reserved = decimal.Zero
	}
	return s.store.SetStockQuantity(ctx, q, row.ID, row.Quantity, reserved)
}

// deductStock locks the row, validates availability and deducts qty from the
// on-hand quantity. It returns the exact before/after quantities from the same
// locked mutation so the movement record is concurrency-exact.
func (s *Service) deductStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) (before, after decimal.Decimal, err error) {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	if !s.allowNegativeStock && s.available(row).LessThan(qty) {
		return decimal.Zero, decimal.Zero, ErrInsufficientStock
	}
	before = row.Quantity
	after = before.Sub(qty)
	if err := s.store.SetStockQuantity(ctx, q, row.ID, after, row.ReservedQuantity); err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	return before, after, nil
}

// deductPermissiveStock deducts stock for B2B e-commerce orders where backorders
// are permitted. Stock is deducted and permitted to go negative.
func (s *Service) deductPermissiveStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) (before, after decimal.Decimal, err error) {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	before = row.Quantity
	after = before.Sub(qty)
	if err := s.store.SetStockQuantity(ctx, q, row.ID, after, row.ReservedQuantity); err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	return before, after, nil
}

// deductPOSStock deducts stock for POS cashier transactions. When negative
// stock is disabled (!s.allowNegativeStock), checkout fails if available stock
// is insufficient (fail-closed guard).
func (s *Service) deductPOSStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) (before, after decimal.Decimal, err error) {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	if !s.allowNegativeStock && s.available(row).LessThan(qty) {
		return decimal.Zero, decimal.Zero, ErrInsufficientStock
	}
	before = row.Quantity
	after = before.Sub(qty)
	if err := s.store.SetStockQuantity(ctx, q, row.ID, after, row.ReservedQuantity); err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	return before, after, nil
}

// Create validates and stores a draft regular order, reserving stock per line
// (§2.1). All writes run inside one tx.
// --- authoritative pricing ---------------------------------------------------

// priceLines resolves every line server-side on the caller's transaction. With
// a resolver wired it honors configured product-unit prices, minimum purchase,
// and conversion factors; without one it falls back to the product selling price
// at factor 1.
func (s *Service) priceLines(ctx context.Context, q db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
	if len(reqs) == 0 {
		return nil, ErrItemRequired
	}
	if s.resolvePrices != nil {
		return s.resolvePrices(ctx, q, reqs)
	}
	out := make([]PricedLine, 0, len(reqs))
	for _, req := range reqs {
		price, err := s.store.ProductSellingPrice(ctx, q, req.ProductID)
		if err != nil {
			return nil, err
		}
		out = append(out, PricedLine{
			ProductID:        req.ProductID,
			ConversionFactor: decimal.NewFromInt(1),
			UnitPrice:        price,
			MinPurchase:      decimal.NewFromInt(1),
			Quantity:         req.Quantity,
			BaseQuantity:     req.Quantity,
		})
	}
	return out, nil
}

// itemFromPricedLine builds the immutable line snapshot. discountPercent is 0
// for POS and checkout; regular orders may discount a line. The membership
// benefit (§13.1) — already computed by the pricing resolver — is folded into
// the final Subtotal while the member portion is recorded separately.
func itemFromPricedLine(pl PricedLine, discountPercent decimal.Decimal) *SOItem {
	gross := pl.UnitPrice.Mul(pl.Quantity)
	lineDisc := gross.Mul(discountPercent.Div(decimal.NewFromInt(100)))
	// The membership benefit applies to the amount left after the line
	// discount; clamping keeps the final subtotal at exactly zero for a
	// fully discounted line instead of letting it go negative.
	memberDisc := pl.MembershipDiscountAmount
	remainder := gross.Sub(lineDisc)
	if memberDisc.GreaterThan(remainder) {
		memberDisc = remainder
	}
	final := gross.Sub(lineDisc).Sub(memberDisc).Round(2)
	return &SOItem{
		ProductID:                pl.ProductID,
		Quantity:                 pl.Quantity,
		UnitPrice:                pl.UnitPrice,
		DiscountPercent:          discountPercent,
		DiscountAmount:           lineDisc,
		Subtotal:                 final,
		ProductUnitID:            pl.ProductUnitID,
		UnitID:                   pl.UnitID,
		UnitName:                 pl.UnitName,
		UnitSymbol:               pl.UnitSymbol,
		ConversionFactor:         pl.ConversionFactor,
		BaseQuantity:             pl.BaseQuantity,
		MinPurchase:              pl.MinPurchase,
		MembershipTier:           pl.MembershipTier,
		MembershipDiscountAmount: memberDisc,
	}
}

// --- commands ----------------------------------------------------------------

// resolveWalkIn maps CustomerID==0 (walk-in / no customer picked) to the
// seeded "Pelanggan Umum" customer so POS checkout never blocks on a pick.
// A non-zero unknown ID still fails validation.
func (s *Service) resolveWalkIn(ctx context.Context, id int64) (int64, error) {
	if id != 0 {
		return id, nil
	}
	wid, err := s.store.WalkInCustomerID(ctx, nil)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return 0, ErrCustomerRequired
		}
		return 0, err
	}
	return wid, nil
}

// orderTotals is the single total-computation path for orders (§2.1): tax is
// taxPercent of subtotal, the order-level discount is bounded by subtotal+tax
// so the total never goes below zero, and shipping is added last. Regular
// create/update and POS checkout all funnel through here so an order discount
// behaves identically everywhere. The returned discount is the EFFECTIVE
// amount after capping — persist that, not the raw request value.
func orderTotals(subtotal, taxPercent, discountAmount, shippingCost decimal.Decimal) (tax, discount, total decimal.Decimal) {
	tax = subtotal.Mul(taxPercent.Div(decimal.NewFromInt(100)))
	// Order discount is bounded by subtotal+tax: a larger value would drive
	// the total below zero, so clamp it and persist the effective amount.
	discount = discountAmount
	if discount.GreaterThan(subtotal.Add(tax)) {
		discount = subtotal.Add(tax)
	}
	total = subtotal.Add(tax).Sub(discount).Add(shippingCost)
	return tax, discount, total
}

func (s *Service) Create(ctx context.Context, in SOInput) (*SalesOrder, error) {
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		return nil, ErrNotFound
	}
	// Tenant binding (§5.4): restricted callers may only create orders
	// inside their own stores; platform-wide callers choose freely.
	bound, ok := auth.StoreScopeFrom(ctx).ResolveStoreBinding(in.StoreID)
	if !ok {
		return nil, ErrNotFound
	}
	in.StoreID = bound
	// A walk-in (CustomerID 0) maps to the "Pelanggan Umum" placeholder for
	// persistence, but a stray membership row on that placeholder must not
	// silently discount every walk-in sale (bug sweep R3-1). Only a customer
	// the caller explicitly picked carries the membership benefit.
	explicitCustomer := in.CustomerID != 0
	custID, err := s.resolveWalkIn(ctx, in.CustomerID)
	if err != nil {
		return nil, err
	}
	in.CustomerID = custID
	var priceCustomer *int64
	if explicitCustomer {
		priceCustomer = &in.CustomerID
	}
	custOK, err := s.store.CustomerExists(ctx, nil, in.CustomerID)
	if err != nil {
		return nil, err
	}
	if !custOK {
		return nil, ErrCustomerRequired
	}
	// POS clients don't send a date; a new sale is always "today" server-side.
	if in.OrderDate == "" {
		in.OrderDate = time.Now().In(s.loc).Format("2006-01-02")
	}
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}
	if in.TaxPercent.IsNegative() || in.TaxPercent.GreaterThan(decimal.NewFromInt(100)) {
		return nil, errors.New("tax_percent must be 0-100")
	}
	srcType, err := normalizeSourceType(in.SourceType)
	if err != nil {
		return nil, err
	}
	in.SourceType = srcType
	if in.DiscountAmount.IsNegative() || in.ShippingCost.IsNegative() {
		return nil, errors.New("discount_amount and shipping_cost must be >= 0")
	}

	warehouseID := in.WarehouseID
	if warehouseID == nil {
		first, err := s.store.FirstActiveWarehouse(ctx, nil)
		if err != nil {
			return nil, err
		}
		warehouseID = &first
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	// Snapshot customer payment_terms inside the tx so the read is consistent.
	// If the caller explicitly provided payment_terms, honour it; otherwise
	// inherit from customer master. 0 = cash / due immediately (§11).
	if in.PaymentTerms == nil {
		var pt int32
		if qerr := tx.QueryRow(ctx, `SELECT COALESCE(payment_terms, 0) FROM customers WHERE id = $1`, in.CustomerID).Scan(&pt); qerr == nil {
			in.PaymentTerms = &pt
		} else {
			zero := int32(0)
			in.PaymentTerms = &zero
		}
	}

	subtotal := decimal.Zero
	reqs := make([]PriceRequest, 0, len(in.Items))
	lines := make([]SOItemInput, 0, len(in.Items))
	for _, li := range in.Items {
		if li.ProductID == 0 {
			continue
		}
		if li.Quantity.LessThan(decimal.NewFromInt(1)) {
			return nil, errors.New("quantity must be >= 1")
		}
		if li.UnitPrice.IsNegative() {
			return nil, errors.New("unit_price must be >= 0")
		}
		if li.DiscountPercent.IsNegative() || li.DiscountPercent.GreaterThan(decimal.NewFromInt(100)) {
			return nil, errors.New("discount_percent must be 0-100")
		}
		exists, err := s.store.ProductExists(ctx, tx, li.ProductID)
		if err != nil {
			return nil, err
		}
		if !exists {
			return nil, ErrNotFound
		}
		reqs = append(reqs, PriceRequest{ProductID: li.ProductID, ProductUnitID: li.ProductUnitID, Quantity: li.Quantity, CustomerID: priceCustomer})
		lines = append(lines, li)
	}
	if len(reqs) == 0 {
		return nil, ErrItemRequired
	}

	// Regular orders keep the negotiated line price (§2.1 Laravel parity) but
	// still snapshot the configured unit so reservations move base quantity.
	priced, err := s.priceLines(ctx, tx, reqs)
	if err != nil {
		return nil, err
	}
	items := make([]*SOItem, 0, len(priced))
	for i, pl := range priced {
		// HARDENING (client feedback 2026-08-26): the authoritative pricing
		// engine is the single source of truth — a client-supplied unit_price
		// can no longer override the resolved price (business-rules §2.2
		// CONFIRMED OVERRIDE). The previous "honored only when supplied"
		// branch let any sales.sales-orders.create holder underprice lines.
		it := itemFromPricedLine(pl, lines[i].DiscountPercent)
		subtotal = subtotal.Add(it.Subtotal)
		items = append(items, it)
	}
	if len(items) == 0 {
		return nil, ErrItemRequired
	}

	tax, discount, total := orderTotals(subtotal, in.TaxPercent, in.DiscountAmount, in.ShippingCost)

	soNumber, err := s.orderCode(ctx, seqKeyOrder, "SO", tx)
	if err != nil {
		return nil, err
	}
	// A field-sales order routed to an atasan starts life in 'pending_approval'
	// (migration 000043); everything else keeps the plain 'draft' entry point.
	initialStatus := "draft"
	if in.SubmitForApproval {
		initialStatus = "pending_approval"
	}
	// The creating user is the order's salesperson ("Sales Pembuat").
	// sales_id FKs employees(id), never users(id): storing the raw auth user
	// id trips 23503 for any user without a matching employees row (a brand
	// new sales account fails with "A referenced record does not exist."
	// while older accounts work by id coincidence). Resolve the linked
	// employee; without one sales_id stays NULL and the order still saves.
	var creatorID *int64
	if u := auth.UserFrom(ctx); u != nil && u.ID != 0 && s.employeeLookup != nil {
		if eid, err := s.employeeLookup(ctx, u.ID); err == nil && eid != 0 {
			id := eid
			creatorID = &id
		}
	}

	// Resolve or normalize payment method
	var pm string
	if in.PaymentMethod != nil && *in.PaymentMethod != "" {
		pm = strings.ToLower(strings.TrimSpace(*in.PaymentMethod))
	} else if in.PaymentTerms != nil && *in.PaymentTerms > 0 {
		pm = "tempo"
	} else {
		pm = "cash"
	}

	notes := in.Notes
	if pm != "" && (notes == nil || !strings.Contains(strings.ToLower(*notes), "[metode:")) {
		prefix := fmt.Sprintf("[Metode: %s]", pm)
		if notes != nil && strings.TrimSpace(*notes) != "" {
			merged := fmt.Sprintf("%s %s", prefix, strings.TrimSpace(*notes))
			notes = &merged
		} else {
			notes = &prefix
		}
	}

	o := &SalesOrder{
		SoNumber:    soNumber,
		CustomerID:  in.CustomerID,
		WarehouseID: warehouseID,
		SalesID:     creatorID,
		OrderDate:   in.OrderDate,
		Status:      initialStatus,
		OrderType: func() string {
			if in.OrderType != nil && *in.OrderType != "" {
				return *in.OrderType
			}
			return "regular"
		}(),
		SourceType:    in.SourceType,
		PaymentMethod: pm,
		PaymentTerms: func() int32 {
			if in.PaymentTerms != nil {
				return *in.PaymentTerms
			}
			return 0
		}(),
		Subtotal:        subtotal,
		TaxAmount:       tax,
		DiscountAmount:  discount,
		TotalAmount:     total,
		PaidAmount:      decimal.Zero,
		RemainingAmount: total,
		ShippingCost:    in.ShippingCost,
		ShippingAddress: in.ShippingAddress,
		Notes:           notes,
		StoreID:         in.StoreID,
	}
	cres, err := s.store.CreateOrder(ctx, tx, createOrderParams(o))
	if err != nil {
		return nil, err
	}
	o.ID = cres.ID
	// Approval routing columns are not part of the sqlc CreateSalesOrder query;
	// stamp them with a follow-up write on the same tx (migration 000043).
	if in.SupervisorID != nil || in.IsUrgent {
		if _, err := tx.Exec(ctx,
			`UPDATE sales_orders SET supervisor_id = $1, is_urgent = $2 WHERE id = $3`,
			in.SupervisorID, in.IsUrgent, o.ID); err != nil {
			return nil, err
		}
	}
	sort.Slice(items, func(i, j int) bool { return items[i].ProductID < items[j].ProductID })
	for _, it := range items {
		it.SalesOrderID = o.ID
		if _, err := s.store.CreateItem(ctx, tx, createItemParams(it)); err != nil {
			return nil, err
		}
		if err := s.reserveStock(ctx, tx, it.ProductID, *warehouseID, it.baseQty()); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, o.ID)
}

// Update rewrites a draft order: release old reservations, replace items,
// recompute totals; remaining = total - paid_amount (§2.3).
func (s *Service) Update(ctx context.Context, id int64, in SOInput) (*SalesOrder, error) {
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		return nil, ErrNotFound
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	existing := orderFromLockedRow(row)
	if err := s.checkScope(ctx, existing.StoreID); err != nil {
		return nil, err
	}
	if existing.Status != "draft" {
		return nil, ErrCannotModify
	}
	if in.CustomerID == 0 {
		return nil, ErrCustomerRequired
	}
	if in.OrderDate == "" {
		return nil, errors.New("order_date is required")
	}
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}
	if in.TaxPercent.IsNegative() || in.TaxPercent.GreaterThan(decimal.NewFromInt(100)) {
		return nil, errors.New("tax_percent must be 0-100")
	}
	srcType, err := normalizeSourceType(in.SourceType)
	if err != nil {
		return nil, err
	}
	in.SourceType = srcType
	if in.DiscountAmount.IsNegative() || in.ShippingCost.IsNegative() {
		return nil, errors.New("discount_amount and shipping_cost must be >= 0")
	}

	warehouseID := existing.WarehouseID
	if warehouseID == nil {
		first, err := s.store.FirstActiveWarehouse(ctx, tx)
		if err != nil {
			return nil, err
		}
		warehouseID = &first
	}

	// release reservations for existing items
	oldItems, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return nil, err
	}
	for _, it := range itemsFromRows(oldItems) {
		if err := s.releaseStock(ctx, tx, it.ProductID, *warehouseID, it.baseQty()); err != nil {
			return nil, err
		}
	}
	if err := s.store.DeleteItems(ctx, tx, id); err != nil {
		return nil, err
	}

	subtotal := decimal.Zero
	reqs := make([]PriceRequest, 0, len(in.Items))
	lines := make([]SOItemInput, 0, len(in.Items))
	for _, li := range in.Items {
		if li.ProductID == 0 {
			continue
		}
		if li.Quantity.LessThan(decimal.NewFromInt(1)) {
			return nil, errors.New("quantity must be >= 1")
		}
		if li.UnitPrice.IsNegative() {
			return nil, errors.New("unit_price must be >= 0")
		}
		if li.DiscountPercent.IsNegative() || li.DiscountPercent.GreaterThan(decimal.NewFromInt(100)) {
			return nil, errors.New("discount_percent must be 0-100")
		}
		exists, err := s.store.ProductExists(ctx, tx, li.ProductID)
		if err != nil {
			return nil, err
		}
		if !exists {
			return nil, ErrNotFound
		}
		reqs = append(reqs, PriceRequest{ProductID: li.ProductID, ProductUnitID: li.ProductUnitID, Quantity: li.Quantity, CustomerID: &in.CustomerID})
		lines = append(lines, li)
	}
	if len(reqs) == 0 {
		return nil, ErrItemRequired
	}
	priced, err := s.priceLines(ctx, tx, reqs)
	if err != nil {
		return nil, err
	}
	newItems := make([]*SOItem, 0, len(priced))
	for i, pl := range priced {
		// Same authoritative-pricing rule as Create (no client override).
		it := itemFromPricedLine(pl, lines[i].DiscountPercent)
		subtotal = subtotal.Add(it.Subtotal)
		newItems = append(newItems, it)
	}
	if len(newItems) == 0 {
		return nil, ErrItemRequired
	}

	tax, discount, total := orderTotals(subtotal, in.TaxPercent, in.DiscountAmount, in.ShippingCost)
	remaining := total.Sub(existing.PaidAmount)
	if remaining.IsNegative() {
		return nil, errors.New("paid_amount exceeds new total")
	}

	existing.CustomerID = in.CustomerID
	existing.WarehouseID = warehouseID
	existing.OrderDate = in.OrderDate
	if in.SourceType != nil {
		existing.SourceType = in.SourceType
	}
	// Preserve snapshotted payment_terms unless the caller explicitly provides a new value.
	if in.PaymentTerms != nil {
		existing.PaymentTerms = *in.PaymentTerms
	}
	existing.Subtotal = subtotal
	existing.TaxAmount = tax
	existing.DiscountAmount = discount
	existing.TotalAmount = total
	existing.RemainingAmount = remaining
	existing.ShippingCost = in.ShippingCost
	existing.ShippingAddress = in.ShippingAddress
	existing.Notes = in.Notes
	if in.PaymentMethod != nil && *in.PaymentMethod != "" {
		pm := strings.ToLower(strings.TrimSpace(*in.PaymentMethod))
		existing.PaymentMethod = pm
		if existing.Notes != nil && !strings.Contains(strings.ToLower(*existing.Notes), "[metode:") {
			marker := fmt.Sprintf("[Metode: %s]", pm)
			if strings.TrimSpace(*existing.Notes) != "" {
				merged := fmt.Sprintf("%s %s", marker, strings.TrimSpace(*existing.Notes))
				existing.Notes = &merged
			} else {
				existing.Notes = &marker
			}
		} else if existing.Notes == nil {
			marker := fmt.Sprintf("[Metode: %s]", pm)
			existing.Notes = &marker
		}
	}
	if err := s.store.UpdateOrder(ctx, tx, updateOrderParams(existing)); err != nil {
		return nil, err
	}
	sort.Slice(newItems, func(i, j int) bool { return newItems[i].ProductID < newItems[j].ProductID })
	for _, it := range newItems {
		it.SalesOrderID = id
		if _, err := s.store.CreateItem(ctx, tx, createItemParams(it)); err != nil {
			return nil, err
		}
		if err := s.reserveStock(ctx, tx, it.ProductID, *warehouseID, it.baseQty()); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// Confirm moves draft->confirmed. No stock deduction (§2.4).
func (s *Service) Confirm(ctx context.Context, id int64) (*SalesOrder, error) {
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		return nil, ErrNotFound
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	// 'draft' is the plain entry point; 'pending_approval' is an order a sales
	// rep submitted for the atasan — confirming it IS the approval (000043).
	if o.Status != "draft" && o.Status != "pending_approval" {
		return nil, ErrInvalidStatus
	}
	if err := s.store.SetOrderStatus(ctx, tx, id, "confirmed"); err != nil {
		return nil, err
	}
	// Record who approved it (skipped for a plain draft confirm with no user).
	if u := auth.UserFrom(ctx); u != nil {
		if _, err := tx.Exec(ctx,
			`UPDATE sales_orders SET approved_by = $1, approved_at = now() WHERE id = $2`,
			u.ID, id); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, id)
	}
	return s.Get(ctx, id)
}

// Cancel marks cancelled. Ecommerce and POS orders deducted physical stock at
// checkout so they restock; regular orders only reserved, so they release.
func (s *Service) Cancel(ctx context.Context, id int64) (*SalesOrder, error) {
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		return nil, ErrNotFound
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	// RBAC audit 2026-08-25: customers may only cancel their own orders
	// regardless of tenant scope.
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleBuyer {
		custID, err := s.store.CustomerByUserID(ctx, tx, u.ID)
		if err != nil {
			return nil, ErrNotFound
		}
		if o.CustomerID != custID {
			return nil, ErrNotFound
		}
	}
	// Shipped goods have physically left the warehouse and delivered orders are
	// complete: both must go through the return/refund flow, not cancel.
	if o.Status == "delivered" || o.Status == "cancelled" || o.Status == "shipped" {
		return nil, ErrCannotCancel
	}
	// Any recorded payment means real money is attached: cancelling would
	// restock the goods while CancelOrderInvoices skips paid invoices and no
	// refund record is ever written — the payment would vanish from the
	// ledger. Money returns only via the return/refund flow.
	if o.PaidAmount.GreaterThan(decimal.Zero) {
		return nil, ErrPaidCannotCancel
	}
	items, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return nil, err
	}
	for _, it := range itemsFromRows(items) {
		if o.WarehouseID == nil {
			return nil, ErrCannotCancel
		}
		if o.OrderType == "ecommerce" || o.OrderType == "pos" {
			// E-commerce and POS orders deducted physical stock at checkout:
			// restock on cancel. Regular orders only reserved, so release.
			before, after, err := s.restockStock(ctx, tx, it.ProductID, *o.WarehouseID, it.baseQty())
			if err != nil {
				return nil, err
			}
			if s.cancelLedger != nil {
				if err := s.cancelLedger(ctx, tx, it.ProductID, *o.WarehouseID, id, before, after, it.baseQty()); err != nil {
					return nil, err
				}
			}
		} else {
			if err := s.releaseStock(ctx, tx, it.ProductID, *o.WarehouseID, it.baseQty()); err != nil {
				return nil, err
			}
		}
	}
	if err := s.store.SetOrderStatus(ctx, tx, id, "cancelled"); err != nil {
		return nil, err
	}
	if err := s.store.CancelOrderInvoices(ctx, tx, id); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// VoidPOS reverses a completed POS sale in one transaction — the recovery
// path for a mis-rung cashier transaction (flow audit item 7: "pembatalan
// transaksi" had no UI and no path at all; Cancel refuses anything with a
// recorded payment). Money is never allowed to vanish:
//   - the sale's payment rows stay untouched (financial immutability); the
//     refund is a NEW, NEGATIVE payment row per method, mirroring how the
//     galon deposit refund writes a negative row so ledgers net without
//     deleting history;
//   - cash refunds leave the drawer via RecordCashRefund (cash_out) so the
//     open shift's expected-cash stays truthful;
//   - goods restock through the same restock/ledger path Cancel uses;
//   - the invoice is cancelled and the order lands cancelled.
// Authorization is admin/owner only — a void overrides the paid-order cancel
// guard, so it must not be reachable by cashiers. The shift must still be
// open for a cash refund (the drawer is the shift's); a closed shift refuses
// the void for cash orders — reconciliation first, then void.
func (s *Service) VoidPOS(ctx context.Context, id int64, reason string) (*SalesOrder, error) {	u := auth.UserFrom(ctx)
	if u == nil || (u.Role != auth.RoleAdmin && u.Role != auth.RoleOwner) {
		return nil, ErrForbidden
	}
	if strings.TrimSpace(reason) == "" {
		return nil, errors.New("alasan pembatalan wajib diisi")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	// A void is for COMPLETED till sales: confirmed+paid (cash/transfer) or
	// the QRIS-deferred confirmed+unpaid. Anything already in the warehouse
	// flow (picking/shipped/delivered) must use the return flow; non-POS
	// orders have their own cancel path.
	if o.OrderType != "pos" {
		return nil, ErrNotPOSOrder
	}
	if o.Status != "confirmed" && o.Status != "invoiced" {
		return nil, ErrCannotCancel
	}
	items, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return nil, err
	}
	if o.WarehouseID == nil {
		return nil, ErrCannotCancel
	}

	// Restock first (same ledger path as Cancel): the goods are back on the
	// shelf the moment the void commits.
	for _, it := range itemsFromRows(items) {
		before, after, err := s.restockStock(ctx, tx, it.ProductID, *o.WarehouseID, it.baseQty())
		if err != nil {
			return nil, err
		}
		if s.cancelLedger != nil {
			if err := s.cancelLedger(ctx, tx, it.ProductID, *o.WarehouseID, id, before, after, it.baseQty()); err != nil {
				return nil, err
			}
		}
	}

	// Money back. Refund = new negative payment row per recorded method, so
	// Receivables' ledger nets without touching the original rows. Cash also
	// leaves the drawer while the shift can still be adjusted.
	if o.PaidAmount.GreaterThan(decimal.Zero) {
		method := strings.ToLower(strings.TrimSpace(paymentMethodFromNotes(o.Notes)))
		if method == "" {
			method = "cash"
		}
		refundNumber, err := s.payCode(ctx, tx)
		if err != nil {
			return nil, err
		}
		refund := &Payment{
			PaymentNumber: refundNumber,
			PayableType:   InvoiceModel,
			PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
			Amount:        o.PaidAmount.Neg(),
			PaymentMethod: method,
			CreatedBy:     &u.ID,
			Notes:         ptrString("VOID " + o.SoNumber + ": " + strings.TrimSpace(reason)),
		}
		if o.Invoice != nil && o.Invoice.ID > 0 {
			refund.PayableID = o.Invoice.ID
		} else {
			refund.PayableID = id
		}
		if _, err := s.store.CreatePayment(ctx, tx, createPaymentParams(refund)); err != nil {
			return nil, err
		}
		if method == "cash" && s.refundDrawer != nil {
			shift, err := s.store.OrderShiftID(ctx, tx, id)
			if err != nil {
				return nil, err
			}
			if shift.Valid && shift.Int64 > 0 {
				if err := s.refundDrawer.RecordCashRefund(ctx, tx, shift.Int64, o.PaidAmount, "Void "+o.SoNumber); err != nil {
					return nil, err
				}
			}
		}
	}

	if err := s.store.SetOrderStatus(ctx, tx, id, "cancelled"); err != nil {
		return nil, err
	}
	if err := s.store.CancelOrderInvoices(ctx, tx, id); err != nil {
		return nil, err
	}
	// Zero the money columns so the cancelled order's list rows (Sisa/Lunas)
	// cannot re-enter receivable math — the refund row already carries the
	// money trail.
	if o.PaidAmount.GreaterThan(decimal.Zero) {
		if _, err := tx.Exec(ctx, `UPDATE sales_orders SET paid_amount = 0, remaining_amount = total_amount, updated_at = now() WHERE id = $1`, id); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// PayPOSPickup records payment for an UNPAID POS pickup order whose money
// did not move at the till — a transfer/QRIS pickup the store head settles
// after verifying the proof (flow audit: "ambil toko menunggu pembayaran,
// bayar di mana?"). The till-side blocker chip is intentional: goods must
// not leave before money is recorded; this is the sanctioned way to record
// it. Semantics mirror PayCheckout's settle tail (payment row + invoice
// paid + SettleOrderPayment) but scoped to POS pickup orders, store-bound,
// with the transfer reference REQUIRED so the ledger stays auditable.
// Authorization: the store head / cashier of the owning store
// (sales.pos.create + store scope) — same trust level that runs the till.
func (s *Service) PayPOSPickup(ctx context.Context, id int64, in PayPOSPickupInput) (*SalesOrder, error) {
	u := auth.UserFrom(ctx)
	if u == nil {
		return nil, ErrNotFound
	}
	method := strings.ToLower(strings.TrimSpace(in.PaymentMethod))
	if method != "transfer" && method != "qris" && method != "cash" {
		return nil, errors.New("payment_method must be transfer, qris or cash")
	}
	if method != "cash" && strings.TrimSpace(in.ReferenceNumber) == "" {
		return nil, errors.New("nomor referensi wajib diisi untuk transfer/QRIS")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	// POS orders are the primary case. An e-commerce PICKUP order paid at
	// the till is the second: the buyer chose store pickup ("ambil sendiri di toko"),
	// the payment (cash, bank transfer proof, or counter QRIS) arrives at the
	// till, and the cashier settles the order so Serahkan Barang unlocks.
	isPickupEcommerce := o.OrderType == "ecommerce" && isPickupOrder(o)
	if o.OrderType != "pos" && !isPickupEcommerce {
		return nil, ErrNotPickupOrder
	}
	// Delivery orders are the warehouse's chain — refuse them. Pickup orders
	// (the whole point of this endpoint) pass through.
	if !isPickupOrder(o) && o.OrderType != "pos" {
		return nil, ErrNotPickupOrder
	}
	if o.Status == "cancelled" || o.Status == "delivered" || o.Status == "shipped" {
		return nil, ErrPaymentNotAllowed
	}
	if o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) && !o.TotalAmount.IsZero() {
		return nil, ErrInvalidStatus
	}
	// An ecommerce pickup checkout may have NO invoice yet (total 0.98 = the
	// B2B test order): pay must auto-create the invoice exactly as PayCheckout
	// does, then settle it. POS orders always carry one from the till.
	inv, err := s.store.OpenInvoiceForOrder(ctx, tx, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			invNumber, invErr := s.orderCode(ctx, seqKeyInvoice, "INV", tx)
			if invErr != nil {
				return nil, invErr
			}
			inv = &Invoice{
				InvoiceNumber:   invNumber,
				SalesOrderID:    o.ID,
				CustomerID:      o.CustomerID,
				InvoiceDate:     time.Now().In(s.loc).Format("2006-01-02"),
				DueDate:         time.Now().In(s.loc).Format("2006-01-02"),
				Status:          "unpaid",
				Subtotal:        o.Subtotal,
				TaxAmount:       o.TaxAmount,
				TotalAmount:     o.TotalAmount,
				PaidAmount:      decimal.Zero,
				RemainingAmount: o.TotalAmount,
			}
			inv.ID, err = s.store.CreateInvoice(ctx, tx, createInvoiceParams(inv))
			if err != nil {
				return nil, err
			}
		} else {
			return nil, err
		}
	}
	if !invoiceSettleable(inv) {
		return nil, ErrInvalidStatus
	}
	payAmount := invoicePayableAmount(inv)
	payNumber, err := s.payCode(ctx, tx)
	if err != nil {
		return nil, err
	}
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     inv.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		Amount:        payAmount,
		PaymentMethod: method,
		CreatedBy:     &u.ID,
		Notes:         ptrString("Pickup " + o.SoNumber + ": " + strings.TrimSpace(in.ReferenceNumber)),
	}
	if _, err := s.store.CreatePayment(ctx, tx, createPaymentParams(pay)); err != nil {
		return nil, err
	}
	if _, err := s.store.MarkInvoicePaid(ctx, tx, inv.ID, inv.TotalAmount); err != nil {
		return nil, err
	}
	if err := s.store.SettleOrderPayment(ctx, tx, o.ID, payAmount); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// PayPOSPickupInput carries the till-recorded payment for a pickup order.
type PayPOSPickupInput struct {
	PaymentMethod   string `json:"payment_method"`
	ReferenceNumber string `json:"reference_number"`
}

// fulfillmentFlow is the forward-only path a warehouse operator drives after a
// confirmed order: siapkan (picking) → kirim (shipped) → terkirim (delivered).
var fulfillmentFlow = map[string]string{
	// Paid e-commerce orders rest at confirmed (they wait for human acceptance
	// even when already settled) and enter the flow here once accepted.
	"confirmed": "picking",
	"invoiced":  "picking",
	"paid":      "picking",
	"picking":   "shipped",
	"shipped":   "delivered",
}

// AdvanceFulfillment moves an order exactly one step forward through the
// fulfilment flow. `to` must be the single valid next status for the order's
// current status; any other value returns ErrInvalidStatus.
func (s *Service) AdvanceFulfillment(ctx context.Context, id int64, to string) (*SalesOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	// A buyer may only confirm receipt (shipped → delivered) — picking/shipped
	// stay warehouse-only steps, even though the buyer's own view permission
	// already passes this endpoint's route gate.
	isBuyer := func() bool {
		u := auth.UserFrom(ctx)
		return u != nil && (u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client")
	}()
	if isBuyer && to != "delivered" {
		return nil, ErrNotFound
	}
	u := auth.UserFrom(ctx)
	isCashierHandover := u != nil && (u.Role == auth.RoleCashier || u.Role == auth.RoleStoreManager || u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner) && to == "delivered"
	if u != nil {
		if u.Role != auth.RoleWarehouse && u.Role != auth.RoleAdmin && u.Role != auth.RoleOwner && !isBuyer && !isCashierHandover {
			return nil, ErrFulfillmentForbidden
		}
		if u.Role == auth.RoleWarehouse {
			if o.Status == "draft" || o.Status == "pending_approval" || o.Status == "blocked_overdue" {
				return nil, ErrNotFound
			}
		}
	}
	validNext := fulfillmentFlow[o.Status] == to
	// Counter handover of a STORE-PICKUP order: the cashier hands the goods
	// over directly at the store till, so the order may jump straight to
	// delivered without ever passing through the courier chain (picking →
	// shipped) that exists for delivery orders. Delivery orders (isPickup =
	// false) keep the observable courier chain; for them this branch does not open.
	//
	// Allowed states for pickup handover:
	// 1. Cash order (COD / bayar di kasir): cashier collects cash at the counter,
	//    so pending_approval, confirmed, invoiced, and paid may jump to delivered.
	// 2. Paid online/transfer/gateway order (paid_amount >= total_amount): payment
	//    has already settled online, so pending_approval, confirmed, invoiced,
	//    and paid may jump directly to delivered.
	// 3. Approved/confirmed non-cash orders: allowed from confirmed, invoiced,
	//    paid, or picking — unpaid orders will be rejected by isUpfrontPaymentRequired below.
	if isCashierHandover && isPickupOrder(o) {
		isPaidOrder := !o.TotalAmount.IsZero() && o.PaidAmount.GreaterThanOrEqual(o.TotalAmount)
		if isCashOrder(o.Notes) || isPaidOrder {
			if o.Status == "pending_approval" || o.Status == "confirmed" ||
				o.Status == "invoiced" || o.Status == "paid" || o.Status == "picking" {
				validNext = true
			}
		} else if o.Status == "confirmed" || o.Status == "invoiced" ||
			o.Status == "paid" || o.Status == "picking" {
			validNext = true
		}
	}
	// Operators may fast-forward to delivered (e.g. counter handover after the
	// order was accepted and invoiced); buyers must confirm receipt only from
	// shipped so the fulfillment chain stays observable. WAREHOUSE/ADMIN only:
	// a cashier's handover right is the pickup branch above — a delivery
	// order's courier chain must never be skipped from the till.
	if !isBuyer && u != nil && (u.Role == auth.RoleWarehouse || u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner) &&
		to == "delivered" && (o.Status == "picking" || o.Status == "invoiced" || o.Status == "confirmed" || o.Status == "paid") {
		validNext = true
	}
	if !validNext {
		return nil, ErrInvalidStatus
	}
	if (to == "picking" || to == "shipped" || to == "delivered") && !isBuyer && isUpfrontPaymentRequired(o) {
		return nil, ErrUnpaidOrderFulfillment
	}
	if err := s.store.SetOrderStatus(ctx, tx, id, to); err != nil {
		return nil, err
	}
	// Sequential DO flow (§15): picking/shipped are warehouse-side milestones
	// and must NOT fabricate delivery-order states in bulk SQL. "Sedang
	// Dikemas" is observable through the DO payload's sales_order_status;
	// shipped_quantity only moves through CreateShipment (allocation, surat
	// jalan, stock movement) and in_transit only through CreateDispatch —
	// both of which write their own delivery_status_history rows. Forcing
	// draft→ready / in_transit with shipped=requested here bypassed those
	// transitions and left DOs "Sedang Diantar" with quantities never shipped.
	if to == "delivered" {
		// Serah terima (buyer receipt confirmation / counter handover) may
		// force-close the DOs — but every forced transition gets an audited
		// history row BEFORE the bulk UPDATE overwrites the old status.
		_, _ = tx.Exec(ctx, `
			INSERT INTO delivery_status_history (delivery_order_id, from_status, to_status, reason, reference_type, reference_id, created_at)
			SELECT d.id, d.status, 'completed', 'pesanan diterima (konfirmasi penerima)', 'sales_order', d.sales_order_id, now()
			FROM delivery_orders d
			WHERE d.sales_order_id = $1 AND d.status NOT IN ('completed', 'cancelled')`, id)
		_, _ = tx.Exec(ctx, `
			UPDATE shipments SET status = 'delivered', updated_at = now()
			WHERE delivery_order_id IN (SELECT id FROM delivery_orders WHERE sales_order_id = $1)
			  AND status != 'delivered' AND status != 'cancelled'`, id)
		_, _ = tx.Exec(ctx, `
			UPDATE delivery_orders SET status = 'completed', shipped_quantity = requested_quantity, delivered_quantity = requested_quantity, remaining_quantity = 0, updated_at = now()
			WHERE sales_order_id = $1 AND status != 'completed' AND status != 'cancelled'`, id)
		_, _ = tx.Exec(ctx, `
			UPDATE delivery_order_items SET shipped_quantity = requested_quantity, delivered_quantity = requested_quantity, remaining_quantity = 0
			WHERE delivery_order_id IN (SELECT id FROM delivery_orders WHERE sales_order_id = $1)`, id)

		if isCashOrder(o.Notes) {
			// Cash-on-delivery/pickup: the money changes hands at the counter,
			// so serah terima IS the settlement — same store seam a Midtrans
			// settle uses, keeping the fake store and the SQL in lockstep.
			if err := s.store.SettleOrderPayment(ctx, tx, id, o.TotalAmount); err != nil {
				return nil, err
			}
			// SettleOrderPayment only moves sales_orders. An approved pickup
			// already has its invoice issued, so leaving it alone kept an open
			// receivable for money the cashier had just taken — the customer
			// appeared to owe a bill that was paid at the counter.
			if _, err := tx.Exec(ctx, `
				UPDATE invoices
				SET status = 'paid', paid_amount = total_amount, remaining_amount = 0, updated_at = now()
				WHERE sales_order_id = $1 AND status NOT IN ('paid', 'cancelled')`, id); err != nil {
				return nil, err
			}
		} else if isPickupOrder(o) && !o.TotalAmount.IsZero() && o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) {
			// Online/transfer pickup already paid: ensure any linked invoices are marked paid as well.
			if _, err := tx.Exec(ctx, `
				UPDATE invoices
				SET status = 'paid', paid_amount = total_amount, remaining_amount = 0, updated_at = now()
				WHERE sales_order_id = $1 AND status NOT IN ('paid', 'cancelled')`, id); err != nil {
				return nil, err
			}
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	// onConfirm (auto-invoice + auto-DO in cmd/server) is a CONFIRM hook: it
	// fires only when the order actually reaches 'confirmed'. Fulfillment
	// transitions ride the same function below, and firing the hook here made
	// CreateInvoice — whose SetSOStatus('invoiced') is unconditional — stamp a
	// freshly-delivered pickup order back to 'invoiced' (reported 1 Nov: every
	// till handover ended "invoiced" with a phantom unpaid invoice).
	if to == "confirmed" && s.onConfirm != nil {
		_ = s.onConfirm(ctx, id)
	}
	o2, err := s.Get(ctx, id)
	if err == nil && s.onStatusChange != nil {
		s.onStatusChange(ctx, o2, o.Status, to)
	}
	return o2, err
}

// AddNote appends a note to the order's notes field (B2B POST /sales-orders/{id}/notes).
func (s *Service) AddNote(ctx context.Context, id int64, note string) (*SalesOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	existing := ""
	if o.Notes != nil {
		existing = *o.Notes
	}
	combined := note
	if existing != "" {
		combined = existing + "\n" + note
	}
	o.Notes = &combined
	if err := s.store.UpdateOrder(ctx, tx, updateOrderParams(o)); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// UpdateNotes replaces the order's notes field (PUT /sales-orders/{id}/notes).
// Empty string or null clears it. Cancelled orders are immutable
// (BUSINESS_RULE_VIOLATION); unknown ids are NOT_FOUND.
func (s *Service) UpdateNotes(ctx context.Context, id int64, notes *string) (*SalesOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	if o.Status == "cancelled" {
		return nil, ErrCancelled
	}
	if err := s.store.UpdateOrderNotes(ctx, tx, id, textOrNilStr(notes)); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Get(ctx, id)
}

// Delete removes a draft order (items + order) (§2.6).
func (s *Service) Delete(ctx context.Context, id int64) error {
	if u := auth.UserFrom(ctx); u != nil && u.Role == auth.RoleWarehouse {
		return ErrNotFound
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return err
	}
	o := orderFromLockedRow(row)
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return err
	}
	if o.Status != "draft" {
		return ErrCannotModify
	}
	items, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return err
	}
	for _, it := range itemsFromRows(items) {
		if o.WarehouseID == nil {
			return ErrCannotModify
		}
		if err := s.releaseStock(ctx, tx, it.ProductID, *o.WarehouseID, it.baseQty()); err != nil {
			return err
		}
	}
	if err := s.store.DeleteItems(ctx, tx, id); err != nil {
		return err
	}
	if err := s.store.DeleteOrder(ctx, tx, id); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

// --- POS ---------------------------------------------------------------------

// ProcessPOS runs the full POS pipeline in one transaction (§2.2):
// validate -> confirmed SO -> items (server-authoritative selling price,
// row-locked availability) -> stock_out with exact before/after from the same
// locked mutation -> optional movement ledger -> auto-invoice -> SO
// status=invoiced.
//
// Warehouse resolution (§5.4 + migration 000045): a bound outlet always
// deducts from its own warehouse — a request-supplied warehouse_id is ignored
// for tenant callers so a till cannot move another outlet's stock. Only an
// unbound (platform-wide) caller may name a warehouse; failing that, the first
// active warehouse keeps API parity.
func (s *Service) ProcessPOS(ctx context.Context, in POSInput) (*SalesOrder, *Invoice, error) {
	// In POS, Admin has NO special bypass: all POS transactions MUST belong to an active store
	// and the cashier/user must be linked to that active store in store_users.
	if in.StoreID == nil {
		sc := auth.StoreScopeFrom(ctx)
		if len(sc.IDs()) == 1 {
			id := sc.IDs()[0]
			in.StoreID = &id
		} else if u := auth.UserFrom(ctx); u != nil {
			stores, err := s.store.ActiveStoresForUser(ctx, nil, u.ID)
			if err == nil && len(stores) == 1 {
				in.StoreID = &stores[0]
			}
		}
	}
	if u := auth.UserFrom(ctx); u != nil {
		if in.StoreID == nil {
			return nil, nil, fmt.Errorf("%w: store_id wajib diisi untuk transaksi POS", ErrCheckoutValidation)
		}
		active, err := s.store.IsStoreActive(ctx, nil, *in.StoreID)
		if err != nil || !active {
			return nil, nil, fmt.Errorf("%w: toko sedang nonaktif atau tidak ditemukan", ErrCheckoutValidation)
		}
		inStore, err := s.store.IsUserInActiveStore(ctx, nil, u.ID, *in.StoreID)
		if err != nil || !inStore {
			return nil, nil, fmt.Errorf("%w: user tidak terdaftar pada toko aktif ini", ErrForbidden)
		}
	} else if in.StoreID != nil {
		active, err := s.store.IsStoreActive(ctx, nil, *in.StoreID)
		if err != nil || !active {
			return nil, nil, fmt.Errorf("%w: toko sedang nonaktif atau tidak ditemukan", ErrCheckoutValidation)
		}
	}
	// Walk-in handling mirrors Create: the "Pelanggan Umum" placeholder must
	// price at list (bug sweep R3-1); only an explicitly picked customer gets
	// the membership benefit lookup.
	explicitCustomer := in.CustomerID != 0
	custID, err := s.resolveWalkIn(ctx, in.CustomerID)
	if err != nil {
		return nil, nil, err
	}
	in.CustomerID = custID
	var priceCustomer *int64
	if explicitCustomer {
		priceCustomer = &in.CustomerID
	}
	custOK, err := s.store.CustomerExists(ctx, nil, in.CustomerID)
	if err != nil {
		return nil, nil, err
	}
	if !custOK {
		return nil, nil, ErrCustomerRequired
	}
	if !isValidCheckoutPaymentMethod(in.PaymentMethod) {
		return nil, nil, errors.New("payment_method must be cash, transfer, giro, cheque, qris, kartu or gateway")
	}
	// Deferred gateway payment (POS QRIS): the order + unpaid invoice are
	// created first and Snap is asked afterwards — there is no payment row
	// until the provider confirms. Fail fast when the gateway is unwired so
	// no stock moves for a QR that can never be issued.
	deferredQris := in.PaymentMethod == "qris"
	if deferredQris && s.midtrans.ServerKey == "" {
		return nil, nil, ErrGatewayDisabled
	}
	if len(in.Items) == 0 {
		return nil, nil, ErrItemRequired
	}

	// Fulfillment mode (2 Nov flow audit item 4): "pickup" hands the goods
	// over the counter (the till IS the handover — no delivery paperwork);
	// "delivery" hands them to a courier and must carry an address, which is
	// stamped into shipping_address so the normal [Ambil Sendiri di Toko]
	// pickup detection stays FALSE and the warehouse DO flow picks the order
	// up. Mode and address are validated together here, fail-fast before any
	// stock moves.
	mode := strings.ToLower(strings.TrimSpace(in.FulfillmentMode))
	if mode == "" {
		mode = "pickup"
	}
	if mode != "pickup" && mode != "delivery" {
		return nil, nil, errors.New("fulfillment_mode must be pickup or delivery")
	}
	if mode == "delivery" {
		if in.ShippingAddress == nil || strings.TrimSpace(*in.ShippingAddress) == "" {
			return nil, nil, errors.New("alamat pengantaran wajib diisi untuk mode diantar")
		}
		addr := strings.TrimSpace(*in.ShippingAddress)
		if len(addr) > 500 {
			addr = addr[:500]
		}
		// Keep the customer's own words first, then the machine marker so
		// every downstream parser ([Metode: …] style) stays one grammar.
		in.ShippingAddress = &addr
		in.FulfillmentMode = "delivery"
	} else {
		in.FulfillmentMode = "pickup"
		in.ShippingAddress = nil
	}

	var warehouseID int64
	switch {
	case in.StoreID != nil:
		wid, err := s.store.WarehouseForStore(ctx, nil, *in.StoreID)
		if err != nil {
			return nil, nil, err
		}
		warehouseID = wid
	case in.WarehouseID != 0:
		warehouseID = in.WarehouseID
	default:
		wid, err := s.store.FirstActiveWarehouse(ctx, nil)
		if err != nil {
			return nil, nil, err
		}
		warehouseID = wid
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, nil, err
	}
	defer tx.Rollback(ctx)
	if auth.UserFrom(ctx) != nil && (in.ShiftID == nil || *in.ShiftID <= 0) {
		return nil, nil, fmt.Errorf("%w: an open cashier shift is required", ErrCheckoutValidation)
	}
	if in.ShiftID != nil && s.shiftGuard != nil {
		if err := s.shiftGuard.LockOpenShift(ctx, tx, *in.ShiftID); err != nil {
			return nil, nil, err
		}
		shiftStore, err := s.store.ShiftStoreID(ctx, tx, *in.ShiftID)
		if err != nil {
			return nil, nil, err
		}
		if shiftStore == nil {
			// Auto-bind legacy shift to transaction store if shift has no store assigned yet
			if err := s.store.SetShiftStoreID(ctx, tx, *in.ShiftID, *in.StoreID); err != nil {
				return nil, nil, err
			}
		} else if *shiftStore != *in.StoreID {
			return nil, nil, fmt.Errorf("%w: shift kasir tidak sesuai dengan toko transaksi", ErrCheckoutValidation)
		}
	}

	subtotal := decimal.Zero
	reqs := make([]PriceRequest, 0, len(in.Items))
	for _, li := range in.Items {
		if li.ProductID == 0 {
			continue
		}
		if li.Quantity.LessThan(decimal.NewFromFloat(0.001)) {
			return nil, nil, errors.New("quantity must be >= 0.001")
		}
		exists, err := s.store.ProductExists(ctx, tx, li.ProductID)
		if err != nil {
			return nil, nil, err
		}
		if !exists {
			return nil, nil, ErrNotFound
		}
		reqs = append(reqs, PriceRequest{ProductID: li.ProductID, ProductUnitID: li.ProductUnitID, Quantity: li.Quantity, CustomerID: priceCustomer})
	}
	if len(reqs) == 0 {
		return nil, nil, ErrItemRequired
	}
	// Authoritative pricing (§11.2): the client-supplied unit_price is ignored;
	// the configured unit price and conversion factor are snapshotted here.
	priced, err := s.priceLines(ctx, tx, reqs)
	if err != nil {
		return nil, nil, err
	}
	items := make([]*SOItem, 0, len(priced))
	for _, pl := range priced {
		it := itemFromPricedLine(pl, decimal.Zero)
		subtotal = subtotal.Add(it.Subtotal)
		items = append(items, it)
	}
	if len(items) == 0 {
		return nil, nil, ErrItemRequired
	}
	// Order-level discount (§2.2): either a sales-voucher redemption or a
	// manual discount_amount — never both. The math funnels through the same
	// orderTotals path regular orders use, including the subtotal cap.
	voucherCode := ""
	if in.VoucherCode != nil {
		voucherCode = strings.TrimSpace(*in.VoucherCode)
	}
	if voucherCode != "" && in.DiscountAmount != nil && in.DiscountAmount.GreaterThan(decimal.Zero) {
		return nil, nil, errors.New("use either voucher_code or discount_amount, not both")
	}
	if in.DiscountAmount != nil && in.DiscountAmount.IsNegative() {
		return nil, nil, errors.New("discount_amount must be >= 0")
	}
	var voucherID int64
	discount := decimal.Zero
	if in.DiscountAmount != nil {
		discount = *in.DiscountAmount
	}
	if voucherCode != "" {
		// Lock the voucher row FOR UPDATE inside this tx so two concurrent
		// checkouts cannot redeem the same code; validation mirrors
		// UpdateSalesVoucherStatus/ValidateSalesVoucher rules.
		row, err := s.store.GetSalesVoucherByNumberForUpdate(ctx, tx, voucherCode)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				return nil, nil, fmt.Errorf("%w: %s not found", ErrVoucherNotUsable, voucherCode)
			}
			return nil, nil, err
		}
		if row.Status != "available" {
			return nil, nil, fmt.Errorf("%w: %s is %s", ErrVoucherNotUsable, voucherCode, row.Status)
		}
		if voucherIsExpired(row.ExpiredAt, s.loc) {
			return nil, nil, fmt.Errorf("%w: %s expired", ErrVoucherNotUsable, voucherCode)
		}
		voucherID = row.ID
		discount = row.Amount
	}
	tax, discount, total := orderTotals(subtotal, decimal.Zero, discount, decimal.Zero)

	// L5: a cash tender must cover the full total and never be negative. A short
	// or negative tender would silently create an irrecoverable change shortage
	// and corrupt the linked cashier shift's expected cash (§11.5).
	if in.PaymentMethod == "cash" && in.CashReceived != nil {
		if in.CashReceived.LessThan(decimal.Zero) {
			return nil, nil, errors.New("cash_received must be >= 0")
		}
		if in.CashReceived.LessThan(total) {
			return nil, nil, errors.New("cash_received must be >= total")
		}
	}

	soNumber, err := s.orderCode(ctx, seqKeyOrder, "TRX", tx)
	if err != nil {
		return nil, nil, err
	}
	// QRIS-deferred orders stay open (confirmed, unpaid) until the provider
	// confirms; every other method settles inside this transaction as before.
	paidAmount := total
	remainingAmount := decimal.Zero
	if deferredQris {
		paidAmount = decimal.Zero
		remainingAmount = total
	}
	o := &SalesOrder{
		SoNumber:        soNumber,
		CustomerID:      in.CustomerID,
		WarehouseID:     &warehouseID,
		OrderDate:       time.Now().In(s.loc).Format("2006-01-02"),
		Status:          "confirmed",
		OrderType:       "pos",
		StoreID:         in.StoreID,
		Notes:           in.Notes,
		// Flow-audit bug 2 (4 Nov): every POS sale carried "Kredit 7 Hari"
		// even when fully paid at the till — the detail read "Termin: Kredit
		// 7 Hari" next to Status Bayar "Lunas". A till sale is CASH terms:
		// the only credit terms a POS order can have is when the customer
		// actually walks away before paying, which the till flow does not
		// allow today. 0 renders as "Cash / Tunai" in the ERP detail.
		PaymentTerms:    0,
		Subtotal:        subtotal,
		TaxAmount:       tax,
		DiscountAmount:  discount,
		TotalAmount:     total,
		PaidAmount:      paidAmount,
		RemainingAmount: remainingAmount,
	}
	// Delivery mode stamps the shipping address so the warehouse DO flow
	// (onOrderConfirmed -> CreateDeliveryOrder) picks the order up and the
	// ERP detail shows where the courier must go. Pickup stays NULL — the
	// till IS the handover.
	if in.FulfillmentMode == "delivery" {
		o.ShippingAddress = in.ShippingAddress
	}
	cres, err := s.store.CreateOrder(ctx, tx, createOrderParams(o))
	if err != nil {
		return nil, nil, err
	}
	o.ID = cres.ID
	// Link the order to the cashier shift (§11.5) so expected cash is exact.
	if in.ShiftID != nil && *in.ShiftID > 0 {
		if err := s.store.SetSalesOrderShift(ctx, tx, o.ID, *in.ShiftID); err != nil {
			return nil, nil, err
		}
	}
	sort.Slice(items, func(i, j int) bool { return items[i].ProductID < items[j].ProductID })
	for _, it := range items {
		it.SalesOrderID = o.ID
		if _, err := s.store.CreateItem(ctx, tx, createItemParams(it)); err != nil {
			return nil, nil, err
		}
		before, after, err := s.deductPOSStock(ctx, tx, it.ProductID, warehouseID, it.baseQty())
		if err != nil {
			return nil, nil, err
		}
		if s.posLedger != nil {
			if err := s.posLedger(ctx, tx, it.ProductID, warehouseID, o.ID, before, after, it.baseQty()); err != nil {
				return nil, nil, err
			}
		}
	}
	// auto-invoice: paid in full by the POS payment — except QRIS-deferred,
	// which leaves the invoice open (unpaid) until the provider confirms.
	invNumber, err := s.orderCode(ctx, seqKeyInvoice, "INV", tx)
	if err != nil {
		return nil, nil, err
	}
	invStatus := "paid"
	if deferredQris {
		invStatus = "unpaid"
	}
	today := time.Now().In(s.loc).Format("2006-01-02")
	inv := &Invoice{
		InvoiceNumber:   invNumber,
		SalesOrderID:    o.ID,
		CustomerID:      in.CustomerID,
		InvoiceDate:     today,
		// POS till sales are settled immediately (including QRIS after capture),
		// so their due date is the invoice date, never a credit-term fallback.
		DueDate:         today,
		Status:          invStatus,
		Subtotal:        subtotal,
		TaxAmount:       tax,
		TotalAmount:     total,
		PaidAmount:      paidAmount,
		RemainingAmount: remainingAmount,
	}
	inv.ID, err = s.store.CreateInvoice(ctx, tx, createInvoiceParams(inv))
	if err != nil {
		return nil, nil, err
	}
	if !deferredQris {
		payNumber, err := s.payCode(ctx, tx)
		if err != nil {
			return nil, nil, err
		}
		pay := &Payment{
			PaymentNumber: payNumber,
			PayableType:   InvoiceModel,
			PayableID:     inv.ID,
			PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
			Amount:        total,
			PaymentMethod: in.PaymentMethod,
		}
		pay.ID, err = s.store.CreatePayment(ctx, tx, createPaymentParams(pay))
		if err != nil {
			return nil, nil, err
		}
		o.Invoice = inv
		// Flow-audit item: a fully-paid POS order must NOT read "Ditagih".
		// SetOrderStatus('invoiced') is the generic invoice stamp; when the
		// sale settled at the till (paid_amount == total), the terminal
		// 'invoiced+paid' reads as "Lunas" only if the status carries it —
		// so stamp 'invoiced' but keep payment truth in paid_amount (the
		// ERP derives payment_status=paid from it). The FIX here is the
		// label: ERP SO_STATUS['invoiced'] is shown as "Ditagih" for
		// back-office orders where invoicing is a STEP, while a POS sale is
		// paid AT the till. The lifecycle stays 'invoiced' (finance
		// vocabulary, matching every POS order ever created); what changes
		// is ERP's display, driven by payment_status — see the store fix.
		if err := s.store.SetOrderStatus(ctx, tx, o.ID, "invoiced"); err != nil {
			return nil, nil, err
		}
	} else {
		o.Invoice = inv
	}
	// Burn the redeemed voucher as the final write of the SAME transaction as
	// the order: every fallible step above has already succeeded, and any
	// error from here on rolls the whole tx back — a failed checkout never
	// consumes a voucher. Consumed value is "used" (sales_vouchers status
	// vocabulary: available/used/expired).
	if voucherID != 0 {
		if err := s.store.UpdateSalesVoucherStatus(ctx, tx, voucherID, "used"); err != nil {
			return nil, nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, nil, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, o.ID)
	}
	o2, err := s.Get(ctx, o.ID)
	if err != nil {
		return nil, nil, err
	}
	if !deferredQris {
		return o2, inv, nil
	}
	// QRIS-deferred. The till gets a QR to draw, not a page to open: charge the
	// Core API and hand back the PNG. See requestQrisCharge for why POS is not
	// on Snap while B2B still is.
	ref := canonicalSnapRef(inv.InvoiceNumber, o.ID)
	// The provider ref is written BEFORE the charge, so a charge that succeeds
	// but whose response is lost still has a row to reconcile against. Without
	// it the sweep has nothing to probe and the payment would hang unpaid.
	if err := s.store.SetInvoiceGatewayRef(ctx, nil, inv.ID, ref, nil, nil); err != nil {
		return nil, nil, err
	}
	qris, err := s.requestQrisCharge(ctx, ref, total)
	if err != nil {
		return nil, nil, err
	}
	// transaction_id and the provider expiry are now known, so stamp them on
	// the row the reconciler reads.
	expires := qris.ExpiresAt
	var expiresPtr *time.Time
	if !expires.IsZero() {
		expiresPtr = &expires
	}
	txn := qris.TransactionID
	if err := s.store.SetInvoiceGatewayRef(ctx, nil, inv.ID, ref, &txn, expiresPtr); err != nil {
		return nil, nil, err
	}
	o2.Qris = qris
	return o2, inv, nil
}

// MyOrders lists the current buyer's e-commerce orders, scoped to their
// linked customer (empty list when the user has no customer yet).
func (s *Service) MyOrders(ctx context.Context, userID int64, f SOFilter) ([]SalesOrder, int, error) {
	customerID, err := s.store.CustomerByUserID(ctx, nil, userID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return []SalesOrder{}, 0, nil
		}
		return nil, 0, err
	}
	if f.Page < 1 {
		f.Page = 1
	}
	// Buyer /me window (R2-1): the storefront merges per-status pages with a
	// cumulative per_page = pageSize x page, so the generic 100-cap would
	// reset deep pages to 20 and return wrong rows. This route is scoped to
	// one customer, so a 500 cap is bounded and safe; ListOrders keeps 100.
	if f.PerPage < 1 || f.PerPage > 500 {
		f.PerPage = 20
	}
	f.CustomerID = customerID
	f.OrderType = "ecommerce"
	rows, total, err := s.store.ListOrders(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]SalesOrder, 0, len(rows))
	for _, r := range rows {
		out = append(out, orderFromListRow(r))
	}
	return out, total, nil
}

// MyOrdersSummary returns order counts by lifecycle status for the buyer.
func (s *Service) MyOrdersSummary(ctx context.Context, userID int64) (*OrdersSummary, error) {
	customerID, err := s.store.CustomerByUserID(ctx, nil, userID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return &OrdersSummary{}, nil
		}
		return nil, err
	}
	row, err := s.store.MyOrdersSummary(ctx, customerID)
	if err != nil {
		return nil, err
	}
	return &OrdersSummary{
		Pending:    int(row.PendingCount),
		Processing: int(row.ProcessingCount),
		Shipping:   int(row.ShippingCount),
		Delivered:  int(row.DeliveredCount),
		Cancelled:  int(row.CancelledCount),
	}, nil
}

// --- e-commerce checkout -----------------------------------------------------

// PlaceCheckoutInTx validates a cart-derived order and stores it as a confirmed
// SO that deducts physical stock immediately inside the caller's tx (pending
// payment; cancel restores it). Prices are snapshotted from the live selling
// price; availability is enforced under a row lock. The cart flow passes the
// same tx it uses to clear the cart so order creation, deduction and cart
// cleanup form one boundary.
func (s *Service) PlaceCheckoutInTx(ctx context.Context, q db.DBTX, in CheckoutInput, userID int64, userName, userEmail string) (*SalesOrder, error) {
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}
	if userID <= 0 {
		return nil, ErrCustomerRequired
	}
	if in.PaymentMethod != "" && !isValidCheckoutPaymentMethod(in.PaymentMethod) {
		return nil, fmt.Errorf("%w: unsupported payment method", ErrCheckoutValidation)
	}
	if in.WarehouseID < 0 || (in.ShippingAddress != nil && len(*in.ShippingAddress) > 4000) || (in.Notes != nil && len(*in.Notes) > 4000) {
		return nil, fmt.Errorf("%w: warehouse or address/notes is invalid", ErrCheckoutValidation)
	}

	customerID, err := s.store.CustomerByUserID(ctx, q, userID)
	if err != nil {
		if !errors.Is(err, ErrNotFound) {
			return nil, err
		}
		for attempt := 0; attempt < 50; attempt++ {
			code, cErr := s.custCode(ctx, "CUS", q)
			if cErr != nil {
				err = cErr
				break
			}
			customerID, err = s.store.CreateCustomerForUser(ctx, q, code, userName, userEmail, userID)
			if err == nil || !errors.Is(err, ErrCustomerCodeTaken) {
				break
			}
		}
		if err != nil {
			return nil, err
		}
	}

	// Referral attribution: a referred customer's orders carry their sales
	// user id so sales reports/AO performance pick them up. Self-registered
	// customers (no link) leave SalesID nil and flow to receivables as usual.
	// A database read failure aborts the transaction and must not be ignored.
	salesID, err := s.store.CustomerAssignedSalesID(ctx, q, customerID)
	if err != nil {
		return nil, err
	}

	// Attribute the online order to a fulfilling outlet so its stock ledger and
	// the outlet sales report pick it up. An explicit in.StoreID wins; else the
	// first active store; failing that, the legacy global warehouse pool.
	storeID := in.StoreID
	warehouseID := in.WarehouseID
	if storeID != nil {
		wid, err := s.store.WarehouseForStore(ctx, q, *storeID)
		if err != nil {
			return nil, err
		}
		warehouseID = wid
	} else if warehouseID == 0 {
		sid, wid, ok, err := s.store.FirstActiveStore(ctx, q)
		if err != nil {
			return nil, err
		}
		if ok {
			storeID, warehouseID = &sid, wid
		} else {
			wid, err := s.store.FirstActiveWarehouse(ctx, q)
			if err != nil {
				return nil, err
			}
			warehouseID = wid
		}
	}
	if err := s.store.ValidateCheckoutWarehouse(ctx, q, warehouseID); err != nil {
		return nil, err
	}

	subtotal := decimal.Zero
	reqs := make([]PriceRequest, 0, len(in.Items))
	for _, li := range in.Items {
		if li.ProductID <= 0 || li.Quantity.LessThan(decimal.RequireFromString("0.001")) {
			return nil, fmt.Errorf("%w: product and quantity must be valid", ErrCheckoutValidation)
		}
		reqs = append(reqs, PriceRequest{ProductID: li.ProductID, ProductUnitID: li.ProductUnitID, Quantity: li.Quantity, CustomerID: &customerID})
	}
	if len(reqs) == 0 {
		return nil, ErrItemRequired
	}
	priced, err := s.priceLines(ctx, q, reqs)
	if err != nil {
		return nil, err
	}
	items := make([]*SOItem, 0, len(priced))
	for _, pl := range priced {
		it := itemFromPricedLine(pl, decimal.Zero)
		subtotal = subtotal.Add(it.Subtotal)
		items = append(items, it)
	}
	if len(items) == 0 {
		return nil, ErrItemRequired
	}

	// Optional sales-voucher redemption (§2.2 rules): the voucher row is
	// locked FOR UPDATE inside the caller's tx so two concurrent checkouts
	// cannot redeem the same code; it is burned only as the final write of
	// the SAME tx, after the order + invoice path has fully succeeded.
	voucherCode := ""
	if in.VoucherCode != nil {
		voucherCode = strings.TrimSpace(*in.VoucherCode)
	}
	var voucherID int64
	discount := decimal.Zero
	if voucherCode != "" {
		row, err := s.store.GetSalesVoucherByNumberForUpdate(ctx, q, voucherCode)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				return nil, fmt.Errorf("%w: %s not found", ErrVoucherNotUsable, voucherCode)
			}
			return nil, err
		}
		if row.Status != "available" {
			return nil, fmt.Errorf("%w: %s is %s", ErrVoucherNotUsable, voucherCode, row.Status)
		}
		if voucherIsExpired(row.ExpiredAt, s.loc) {
			return nil, fmt.Errorf("%w: %s expired", ErrVoucherNotUsable, voucherCode)
		}
		voucherID = row.ID
		discount = row.Amount
	}
	tax, discount, total := orderTotals(subtotal, decimal.Zero, discount, decimal.Zero)

	soNumber, err := s.orderCode(ctx, seqKeyOrder, "SO", q)
	if err != nil {
		return nil, err
	}
	o := &SalesOrder{
		SoNumber:    soNumber,
		CustomerID:  customerID,
		WarehouseID: &warehouseID,
		SalesID:     salesID,
		OrderDate:   time.Now().In(s.loc).Format("2006-01-02"),
		// E-commerce checkout enters 'pending_approval' (Menunggu Konfirmasi)
		// and STAYS there until a human accepts it via Confirm — settling the
		// Midtrans invoice records the money but is not an acceptance, so
		// paid_amount rises while the status waits. approved_by then records
		// who accepted. Starting 'confirmed' (or letting payment confirm)
		// skipped the verification step and showed the order as Diproses.
		Status:          "pending_approval",
		OrderType:       "ecommerce",
		Subtotal:        subtotal,
		TaxAmount:       tax,
		DiscountAmount:  discount,
		TotalAmount:     total,
		PaidAmount:      decimal.Zero,
		RemainingAmount: total,
		ShippingAddress: in.ShippingAddress,
		Notes:           in.Notes,
		StoreID:         storeID,
	}
	cres, err := s.store.CreateOrder(ctx, q, createOrderParams(o))
	if err != nil {
		return nil, err
	}
	o.ID = cres.ID
	sort.Slice(items, func(i, j int) bool { return items[i].ProductID < items[j].ProductID })
	for _, it := range items {
		it.SalesOrderID = o.ID
		createdID, err := s.store.CreateItem(ctx, q, createItemParams(it))
		if err != nil {
			return nil, err
		}
		it.ID = createdID
		// In B2B e-commerce checkout, customer transactions must never block on
		// zero or insufficient system stock (permissive stock / backorder).
		// Stock is deducted and permitted to go negative without failing customer checkout.
		before, after, err := s.deductPermissiveStock(ctx, q, it.ProductID, warehouseID, it.baseQty())
		if err != nil {
			return nil, err
		}
		if s.posLedger != nil {
			if err := s.posLedger(ctx, q, it.ProductID, warehouseID, o.ID, before, after, it.baseQty()); err != nil {
				return nil, err
			}
		}
	}

	// Burn the redeemed voucher inside the caller's tx, after order + items +
	// deduction succeeded: any later failure rolls everything back, so a
	// failed checkout never consumes a voucher (mirrors §2.2 POS tail).
	if voucherID != 0 {
		if err := s.store.UpdateSalesVoucherStatus(ctx, q, voucherID, "used"); err != nil {
			return nil, err
		}
	}
	// Build the response in-memory: the caller owns the transaction and has not
	// committed yet, so reading through the pool (s.Get) would see nothing.
	sos := make([]SOItem, 0, len(items))
	for _, it := range items {
		sos = append(sos, *it)
	}
	o.Items = sos
	return o, nil
}

// PlaceCheckout is the standalone (own-transaction) checkout entry point. The
// cart flow uses PlaceCheckoutInTx instead so order creation, stock deduction
// and cart clearing share a single transaction boundary.
func (s *Service) PlaceCheckout(ctx context.Context, in CheckoutInput, userID int64, userName, userEmail string) (*SalesOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	o, err := s.PlaceCheckoutInTx(ctx, tx, in, userID, userName, userEmail)
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	// The tx is committed: re-read through the pool to return the persisted order.
	return s.Get(ctx, o.ID)
}

// PayCheckout settles a buyer's own e-commerce order: stock was already
// deducted at PlaceCheckoutInTx so pay touches only invoice/payment,
// never stock, then auto-invoices fully paid and moves the SO to invoiced
// (§2.2 tail).
func (s *Service) PayCheckout(ctx context.Context, orderID, userID int64, in PayCheckoutInput) (*SalesOrder, *Invoice, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, orderID)
	if err != nil {
		return nil, nil, err
	}
	o := orderFromLockedRow(row)
	if o.OrderType != "ecommerce" {
		return nil, nil, errors.New("order is not an e-commerce order")
	}
	if isCashOrder(o.Notes) {
		return nil, nil, ErrPaymentNotAllowed
	}
	if o.Status == "completed" || o.Status == "delivered" || o.Status == "cancelled" || o.Status == "rejected" {
		return nil, nil, ErrPaymentNotAllowed
	}
	if o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) && !o.TotalAmount.IsZero() {
		return nil, nil, ErrInvalidStatus
	}
	// 'invoiced' = checkout already booked an unpaid invoice (piutang); paying
	// settles that invoice instead of creating a second one. 'pending_approval'
	// is a fresh e-commerce checkout awaiting its confirmation (= this payment).
	if o.Status != "pending_approval" && o.Status != "confirmed" && o.Status != "invoiced" {
		return nil, nil, ErrInvalidStatus
	}
	customerID, err := s.store.CustomerByUserID(ctx, tx, userID)
	if err != nil {
		return nil, nil, ErrNotFound
	}
	if o.CustomerID != customerID {
		return nil, nil, ErrNotFound
	}
	if !isValidCheckoutPaymentMethod(in.PaymentMethod) {
		return nil, nil, errors.New("payment_method must be cash, transfer, giro, cheque, qris, kartu or gateway")
	}

	var inv *Invoice
	payAmount := o.TotalAmount
	if o.Status == "invoiced" {
		// Stock already deducted at checkout; pay must not touch it.
		inv, err = s.store.OpenInvoiceForOrder(ctx, tx, o.ID)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				return nil, nil, ErrInvalidStatus
			}
			return nil, nil, err
		}
		// One figure, one decision: the else-branch below settles this invoice
		// in full at inv.TotalAmount, so the payment row and the order advance
		// must carry that same figure. The raw column disagrees with it whenever
		// the open balance is non-positive.
		payAmount = invoicePayableAmount(inv)
	}
	// Stock was deducted at checkout; settling a confirmed order touches only
	// invoice/payment, never stock.

	payNumber, err := s.payCode(ctx, tx)
	if err != nil {
		return nil, nil, err
	}
	if inv == nil {
		invNumber, err := s.orderCode(ctx, seqKeyInvoice, "INV", tx)
		if err != nil {
			return nil, nil, err
		}
		inv = &Invoice{
			InvoiceNumber:   invNumber,
			SalesOrderID:    o.ID,
			CustomerID:      o.CustomerID,
			InvoiceDate:     time.Now().In(s.loc).Format("2006-01-02"),
			DueDate:         time.Now().In(s.loc).Format("2006-01-02"),
			Status:          "paid",
			Subtotal:        o.Subtotal,
			TaxAmount:       o.TaxAmount,
			TotalAmount:     o.TotalAmount,
			PaidAmount:      o.TotalAmount,
			RemainingAmount: decimal.Zero,
		}
		inv.ID, err = s.store.CreateInvoice(ctx, tx, createInvoiceParams(inv))
		if err != nil {
			return nil, nil, err
		}
	} else {
		if _, err := s.store.MarkInvoicePaid(ctx, tx, inv.ID, inv.TotalAmount); err != nil {
			return nil, nil, err
		}
		inv.Status, inv.PaidAmount, inv.RemainingAmount = "paid", inv.TotalAmount, decimal.Zero
	}
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     inv.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		Amount:        payAmount,
		PaymentMethod: in.PaymentMethod,
	}
	pay.ID, err = s.store.CreatePayment(ctx, tx, createPaymentParams(pay))
	if err != nil {
		return nil, nil, err
	}
	o.Invoice = inv
	if err := s.store.SettleOrderPayment(ctx, tx, o.ID, payAmount); err != nil {
		return nil, nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, nil, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, o.ID)
	}
	o2, err := s.Get(ctx, o.ID)
	if err != nil {
		return nil, nil, err
	}
	return o2, inv, nil
}

// SnapToken issues a Midtrans Snap transaction for the buyer's own
// e-commerce order and returns the token + redirect URL. Ownership mirrors
// PayCheckout (cross-user reads as ErrNotFound); the order must have an
// open (unpaid/partial) invoice or the call fails with ErrInvalidStatus.
// The Snap order_id is deterministic per invoice (KM-<invoice_number>-<orderID>),
// persisted on first issue and reused on retry so webhooks join verbatim.
func (s *Service) SnapToken(ctx context.Context, orderID, userID int64, customFinishURL ...string) (*SnapTokenResult, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, orderID)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	if o.OrderType != "ecommerce" && o.OrderType != "regular" {
		return nil, errors.New("order type does not support online payment")
	}
	u := auth.UserFrom(ctx)
	isStaff := u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner || u.Role == auth.RoleSales || u.Role == auth.RoleFinance || u.Role == auth.RoleFinanceHead || u.Role == auth.RoleGM || u.Role == auth.RoleBM || u.Role == auth.RoleSPV || u.Role == auth.RoleStoreManager || u.Can("sales.sales-orders.view") || u.Can("sales.pos.create") || u.Role == auth.RoleCashier)
	if !isStaff && isCashOrder(o.Notes) {
		return nil, ErrPaymentNotAllowed
	}
	if o.Status == "completed" || o.Status == "delivered" || o.Status == "cancelled" || o.Status == "rejected" {
		return nil, ErrPaymentNotAllowed
	}
	if o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) && !o.TotalAmount.IsZero() {
		return nil, ErrPaymentNotAllowed
	}
	if !isStaff {
		customerID, err := s.store.CustomerByUserID(ctx, tx, userID)
		if err != nil {
			return nil, ErrNotFound
		}
		if o.CustomerID != customerID {
			return nil, ErrNotFound
		}
	}
	inv, err := s.store.OpenInvoiceForOrder(ctx, tx, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			exists, exErr := s.store.InvoiceExistsForSO(ctx, tx, o.ID)
			if exErr != nil {
				return nil, exErr
			}
			if exists || o.Status == "cancelled" || o.Status == "rejected" {
				return nil, fmt.Errorf("order is %s, no open invoice (already paid or settled): %w", o.Status, ErrInvalidStatus)
			}
			invNumber, err := s.orderCode(ctx, seqKeyInvoice, "INV", tx)
			if err != nil {
				return nil, err
			}
			inv = &Invoice{
				InvoiceNumber:   invNumber,
				SalesOrderID:    o.ID,
				CustomerID:      o.CustomerID,
				InvoiceDate:     time.Now().In(s.loc).Format("2006-01-02"),
				DueDate:         time.Now().In(s.loc).Format("2006-01-02"),
				Status:          "unpaid",
				Subtotal:        o.Subtotal,
				TaxAmount:       o.TaxAmount,
				TotalAmount:     o.TotalAmount,
				PaidAmount:      decimal.Zero,
				RemainingAmount: o.TotalAmount,
			}
			inv.ID, err = s.store.CreateInvoice(ctx, tx, createInvoiceParams(inv))
			if err != nil {
				return nil, err
			}
		} else {
			return nil, err
		}
	}
	gross := invoicePayableAmount(inv)
	if !gross.IsPositive() {
		return nil, fmt.Errorf("invoice already fully paid: %w", ErrInvalidStatus)
	}
	ref := ""
	if inv.SnapOrderRef != nil {
		ref = *inv.SnapOrderRef
	}
	canonical := canonicalSnapRef(inv.InvoiceNumber, o.ID)
	if ref == "" {
		ref = canonical
		if err := s.store.SetInvoiceSnapRef(ctx, tx, inv.ID, ref); err != nil {
			return nil, err
		}
	} else {
		// When SnapToken is retried (e.g. user closed the payment modal and clicks pay again),
		// Midtrans rejects duplicate order_id. Append timestamp suffix so Midtrans creates a fresh transaction.
		// The suffix is built ON TOP of canonicalSnapRef so the recovery poll can
		// always re-derive the first-issue ref and still find a payment made on
		// it after this overwrite.
		ref = fmt.Sprintf("%s-%d", canonical, time.Now().Unix())
		if err := s.store.SetInvoiceSnapRef(ctx, tx, inv.ID, ref); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.requestSnapToken(ctx, ref, gross, o.ID, customFinishURL...)
}

// --- gateway amount contract --------------------------------------------------
//
// One definition of "how much is this invoice being charged through Midtrans",
// shared by the send path, the webhook settle, and the missed-webhook recovery
// poll. The three used to disagree, and the disagreement stranded money: Snap
// was told a ROUNDED integer while both settle paths compared that integer
// against the UNROUNDED invoice decimal, so 3221 != 3220.50 and a captured
// payment could never close its invoice.

// invoiceSettleable reports whether a payment may flip this invoice to paid: it
// must still carry an open balance. This is the SAME rule finance.PayInvoice
// enforces at its service level ("invoice is not payable") — a settled invoice
// has nothing left to collect and a cancelled one had its debt written off, so
// neither may accept money. 'paid' is settleable=false too; the settle paths
// give it a dedicated idempotent outcome instead of this guard.
func invoiceSettleable(inv *Invoice) bool {
	return inv.Status == "unpaid" || inv.Status == "partial" || inv.Status == "overdue"
}

// invoicePayableAmount is the ONE invoice figure a settle-in-full pays: the
// open balance while one remains, and the invoice total once nothing remains.
// A non-positive RemainingAmount means the invoice carries no open balance, so
// the full total is the amount due — that fallback is the webhook's original
// behaviour and it is the ONLY definition of it; the recovery poll used to
// have none and rejected what the webhook accepted, and PayCheckout used to
// read the raw column and contradict the settlement it had just recorded.
//
// The rule is gateway-INDEPENDENT. The webhook and the recovery poll also hand
// the result to the provider to charge; PayCheckout has no provider but
// settles that same invoice in full, so it takes the same figure from here. Do
// not re-derive it, and do not add a second helper for the manual path.
func invoicePayableAmount(inv *Invoice) decimal.Decimal {
	if inv.RemainingAmount.IsPositive() {
		return inv.RemainingAmount
	}
	return inv.TotalAmount
}

// snapRoundedGross is the amount Snap is actually TOLD: Midtrans accepts whole
// rupiah only, so a fractional invoice is rounded at send time and the provider
// echoes that integer back on the webhook and on the Core API status call.
// Sending and BOTH reconciliations must go through this one function — a
// send/compare rounding disagreement reintroduces the exact bug above.
func snapRoundedGross(amount decimal.Decimal) decimal.Decimal {
	return amount.Round(0)
}

// snapExpiry builds the Snap `expiry` block (Midtrans wants
// "yyyy-MM-dd HH:mm:ss +0700"). Non-positive minutes = nil (no expiry key,
// the B2B default). POS QRIS passes 15.
func snapExpiry(minutes int, now time.Time) map[string]any {
	if minutes <= 0 {
		return nil
	}
	return map[string]any{
		"start_time": now.Format("2006-01-02 15:04:05 -0700"),
		"unit":       "minute",
		"duration":   minutes,
	}
}

// reconcileSnapGross checks a provider-reported gross amount against what we
// asked the gateway to charge, and returns the invoice figure the payment row
// must record. The comparison is against the ROUNDED gateway amount; the
// returned figure is the UNROUNDED invoice amount.
//
// Do NOT "fix" the ledger to the rounded value: MarkInvoicePaid(...,
// inv.TotalAmount) and the payment row amount record the true invoice figures,
// and a sub-unit gap against the gateway is the unavoidable artifact of a
// provider that only accepts whole rupiah. Only the reconciliation is gateway-
// shaped. A reported amount that differs from the expected one by anything
// else — under- or over-payment, a tampered or mis-categorized total — still
// fails closed with finance.ErrMidtransAmountMismatch.
func reconcileSnapGross(inv *Invoice, source string, reported decimal.Decimal) (decimal.Decimal, error) {
	payable := invoicePayableAmount(inv)
	if !reported.Equal(snapRoundedGross(payable)) {
		return payable, fmt.Errorf("%w: %s %s, invoice %s", finance.ErrMidtransAmountMismatch, source, reported, payable)
	}
	return payable, nil
}

// canonicalSnapRef is the FIRST-issue Snap order_id for an invoice. SnapToken
// persists exactly this on a fresh invoice and derives the retry ref from it,
// so this is the one ref that is always derivable back from the invoice even
// after snap_order_ref has been overwritten by a retry — the webhook resolves
// superseded refs by prefix (InvoiceBySnapRef) and the recovery poll asks
// about this one by name.
//
// Do NOT "recover" the canonical ref by stripping a trailing -<timestamp>
// from the stored ref: invoice numbers carry their own trailing digits, so a
// dash-anchored strip is ambiguous. Re-derive it.
func canonicalSnapRef(invoiceNumber string, salesOrderID int64) string {
	return "KM-" + invoiceNumber + "-" + strconv.FormatInt(salesOrderID, 10)
}

// requestSnapToken POSTs the Snap transaction. Errors carry a safe message;
// the server key travels only in basic-auth and is never logged.
// finishURLForOrder appends the sales order id as ?order= so the checkout
// confirmation page can resolve which order was just paid. Midtrans appends its
// own transaction_id / transaction_status / order_id params to whatever we send,
// so ours only has to lead. Returns "" for an unset base so the caller can omit
// the callbacks block entirely rather than send a relative URL.
func finishURLForOrder(base string, salesOrderID int64) string {
	if base == "" {
		return ""
	}
	sep := "?"
	if strings.Contains(base, "?") {
		sep = "&"
	}
	return base + sep + "order=" + strconv.FormatInt(salesOrderID, 10)
}

func (s *Service) requestSnapToken(ctx context.Context, orderRef string, gross decimal.Decimal, salesOrderID int64, customFinishURL ...string) (*SnapTokenResult, error) {
	return s.requestSnapTokenWithOptions(ctx, orderRef, gross, salesOrderID, SnapRequestOptions{}, customFinishURL...)
}

// SnapRequestOptions tunes a Snap transaction without changing the default
// (B2B) shape: POS QRIS restricts methods and sets a short till-side expiry.
type SnapRequestOptions struct {
	EnabledPayments []string
	ExpiryMinutes   int
}

func (s *Service) requestSnapTokenWithOptions(ctx context.Context, orderRef string, gross decimal.Decimal, salesOrderID int64, opts SnapRequestOptions, customFinishURL ...string) (*SnapTokenResult, error) {
	base := s.midtrans.SnapBaseURL
	if base == "" {
		base = "https://app.sandbox.midtrans.com/snap/v1/transactions"
	}
	// callbacks: without finish_url the standalone redirect page dead-ends on
	// "You can now close this page" (no auto-return). finish carries the
	// outcome; error/unfinish land on the same page, which reads the status
	// from the server (never from the callback params) and offers Pay Now.
	//
	// The finish URL must carry ?order=<sales order id>: the confirmation page
	// resolves the order from that query param and renders "order not found"
	// without it. Midtrans appends its own transaction params to whatever we
	// send, so ours only has to lead. snap_order_ref stays the authoritative
	// webhook join and is a different identifier from this one.
	finishBase := s.midtrans.FinishURL
	if len(customFinishURL) > 0 && strings.TrimSpace(customFinishURL[0]) != "" {
		finishBase = strings.TrimSpace(customFinishURL[0])
	}
	finish := finishURLForOrder(finishBase, salesOrderID)
	callbacks := map[string]any{}
	if finish != "" {
		callbacks["finish"] = finish
		callbacks["error"] = finish
		callbacks["unfinish"] = finish
	}
	body := map[string]any{
		"transaction_details": map[string]any{
			"order_id": orderRef,
			// snapRoundedGross is the single rounding contract: the provider
			// echoes this integer back, and both settle paths reconcile against
			// it (see reconcileSnapGross).
			"gross_amount": snapRoundedGross(gross).IntPart(),
		},
		"callbacks": callbacks,
	}
	// enabled_payments is a RESTRICTION, and restricting Snap to a single
	// channel makes it render "No payment channels available" — verified against
	// this account; see the POS call site. The field is still honoured when a
	// caller deliberately passes two or more, so the option is not removed, only
	// the POS stopped using it for a one-element list.
	if len(opts.EnabledPayments) > 1 {
		body["enabled_payments"] = opts.EnabledPayments
	}
	// POS QRIS bounds the till-side wait (abandoned QR holds deducted stock
	// until the cashier cancels, which restocks). Absent opts = B2B default.
	if exp := snapExpiry(opts.ExpiryMinutes, time.Now().In(s.loc)); exp != nil {
		body["expiry"] = exp
	}
	payload, err := json.Marshal(body)
	if err != nil {
		return nil, errors.New("payment gateway request failed")
	}
	// Bounded in time: this is a buyer-facing call, and an unbounded one
	// blocked the request plus a pooled connection for as long as the provider
	// stalled. snapClient() keeps an injected client; snapTimeout() bounds the
	// call whichever client is in play.
	callCtx, cancel := context.WithTimeout(ctx, s.snapTimeout())
	defer cancel()
	req, err := http.NewRequestWithContext(callCtx, http.MethodPost, base, bytes.NewReader(payload))
	if err != nil {
		return nil, errors.New("payment gateway request failed")
	}
	req.SetBasicAuth(s.midtrans.ServerKey, "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")
	resp, err := s.snapClient().Do(req)
	if err != nil {
		if isTimeout(err) {
			return nil, errGatewayTimeout
		}
		return nil, errors.New("payment gateway request failed")
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, errors.New("payment gateway request failed")
	}
	var out struct {
		Token       string `json:"token"`
		RedirectURL string `json:"redirect_url"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, errors.New("payment gateway returned an invalid response")
	}
	if out.Token == "" || out.RedirectURL == "" {
		return nil, errors.New("payment gateway returned an incomplete response")
	}
	return &SnapTokenResult{SnapToken: out.Token, RedirectURL: out.RedirectURL}, nil
}

// --- Midtrans notification + status recovery ---------------------------------

// HandleMidtransNotification verifies a Midtrans webhook, resolves the
// snap_order_ref back to the invoice, and settles it exactly once inside one
// transaction. Signature/amount/order errors return sentinel errors mapped to
// non-2xx by the handler so Midtrans retries; provider "ignored" outcomes
// (pending, unknown statuses, fraud-challenge capture) return 200 no-ops —
// they are not failures and must not trigger redelivery.
//
// Monotonicity: a settled invoice stays settled — late deny/cancel/expire
// notifications and duplicate settlement webhooks never un-pay it.
func (s *Service) HandleMidtransNotification(ctx context.Context, n finance.MidtransNotification) (string, error) {
	if s.midtrans.ServerKey == "" {
		return "", ErrGatewayDisabled
	}
	if !finance.VerifyMidtransSignature(n.OrderID, n.StatusCode, n.GrossAmount, s.midtrans.ServerKey, n.SignatureKey) {
		return "", finance.ErrMidtransBadSignature
	}
	action := finance.MidtransAction(n.TransactionStatus, n.FraudStatus)
	if action == finance.MidtransActionIgnored {
		return finance.MidtransOutcomeIgnored, nil
	}
	tpx, err := s.store.Begin(ctx)
	if err != nil {
		return "", err
	}
	defer tpx.Rollback(ctx)
	inv, err := s.store.InvoiceBySnapRef(ctx, tpx, n.OrderID)
	if err != nil {
		return "", fmt.Errorf("%w: %s", finance.ErrMidtransUnknownOrder, n.OrderID)
	}
	// Ledger integrity, before any money moves: only an OPEN invoice may be
	// settled. CancelOrderInvoices writes a written-off (cancelled) invoice
	// that keeps its total_amount AND its snap_order_ref, and InvoiceBySnapRef
	// resolves that ref regardless of status by design, so a capture that
	// settles after the order was cancelled lands here. finance.PayInvoice
	// refuses the very same invoice ("invoice is not payable"); without the
	// equivalent guard this path flipped cancelled -> paid, set paid_amount and
	// wrote a payments row, making a deliberately forgiven debt
	// indistinguishable from a collected one in the ledger.
	//
	// 'paid' is excluded on purpose: it has its own already-settled
	// short-circuit further down, and turning a duplicate notification into an
	// error would make Midtrans redeliver it forever.
	//
	// Checked BEFORE the amount reconciliation for the same reason
	// PayInvoice checks status before size: a written-off capture is not
	// settleable at any amount, so it must not be answered with the 409 that an
	// amount mismatch produces — that conflict can never resolve and the
	// provider would retry indefinitely.
	if inv.Status != "paid" && !invoiceSettleable(inv) {
		// money captured, debt written off: a 2xx so the provider stops
		// delivering, plus a warning so an operator can find and reconcile the
		// capture (refund at Midtrans, or re-book the invoice by hand).
		slog.Default().Warn("midtrans capture for a written-off invoice: captured money needs manual reconciliation",
			"invoice_id", inv.ID, "invoice_number", inv.InvoiceNumber, "invoice_status", inv.Status,
			"sales_order_id", inv.SalesOrderID, "snap_order_ref", n.OrderID, "transaction_id", n.TransactionID,
			"gross_amount", n.GrossAmount)
		return finance.MidtransOutcomeWrittenOff, nil
	}
	// Amount reconciliation on the RAW notification string (never reformatted):
	// it is parsed as-is, never echoed back through a different formatting.
	// reconcileSnapGross owns the expected value — the ROUNDED amount Snap was
	// told — so a fractional invoice settles instead of failing a 3221 vs
	// 3220.50 comparison.
	gross, aErr := decimal.NewFromString(strings.TrimSpace(n.GrossAmount))
	if aErr != nil {
		// Unparsable gross_amount: fail closed rather than settle on a guess.
		return "", fmt.Errorf("%w: notification %s is not a number", finance.ErrMidtransAmountMismatch, n.GrossAmount)
	}
	payable, aErr := reconcileSnapGross(inv, "notification", gross)
	if aErr != nil {
		return "", aErr
	}
	if action != finance.MidtransActionSettled {
		// deny/cancel/expire/failure: acknowledge without state change. A
		// cancelled/late failure notification must never un-pay a settled
		// invoice, and the invoice stays open so the buyer can retry.
		return finance.MidtransOutcomeFailedRecorded, nil
	}
	if inv.Status == "paid" {
		return finance.MidtransOutcomeAlreadySettled, nil
	}
	// LOCK ORDER: sales_orders FIRST, then invoices — identical to Cancel, Sweep, and POS.
	if inv.SalesOrderID != 0 {
		if _, err := s.store.OrderByIDForUpdate(ctx, tpx, inv.SalesOrderID); err != nil {
			return "", err
		}
	}
	marked, err := s.store.MarkInvoicePaid(ctx, tpx, inv.ID, inv.TotalAmount)
	if err != nil {
		return "", err
	}
	if marked == 0 {
		return finance.MidtransOutcomeAlreadySettled, nil
	}
	payNumber, err := s.payCode(ctx, tpx)
	if err != nil {
		return "", err
	}
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     inv.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		// The TRUE invoice figure, not the rounded gateway amount — see
		// reconcileSnapGross. Do not "align" this with the gateway.
		Amount:        payable,
		PaymentMethod: finance.MidtransMethod(n.PaymentType),
		Notes:         ptrString("Midtrans " + n.TransactionID),
	}
	if _, err := s.store.CreateGatewayPayment(ctx, tpx, createPaymentParams(pay), "midtrans", n.TransactionID, n.TransactionStatus); err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return finance.MidtransOutcomeAlreadySettled, nil
		}
		return "", err
	}
	if inv.SalesOrderID != 0 {
		if err := s.store.SettleOrderPayment(ctx, tpx, inv.SalesOrderID, payable); err != nil {
			return "", err
		}
	}
	if err := tpx.Commit(ctx); err != nil {
		return "", err
	}
	if s.onConfirm != nil && inv.SalesOrderID != 0 {
		_ = s.onConfirm(ctx, inv.SalesOrderID)
	}
	return finance.MidtransOutcomeSettled, nil
}

// SnapPaymentStatus is the missed-webhook recovery path: the buyer's device
// polls it after Snap closes. It performs the SAME verified settle as the
// webhook (amount reconciliation, idempotency, monotonicity), driven by the
// canonical Snap status endpoint — the browser result is never trusted.
// Token-not-found before method selection is a normal "not attempted yet".
//
// It asks about the stored snap_order_ref AND the canonical first-issue ref,
// because a SnapToken retry overwrites the stored ref with a fresh one and the
// buyer may have paid on the attempt that ref replaced.
//
// A written-off (cancelled) invoice is reported as finance.MidtransOutcomeWrittenOff,
// never as "settled", and is never settled here — see the no-open-invoice
// branch below.
func (s *Service) SnapPaymentStatus(ctx context.Context, orderID, userID int64) (*SnapStatusResult, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	row, err := s.store.OrderByID(ctx, orderID)
	if err != nil {
		return nil, err
	}
	o := orderFromRow(row)
	if o.OrderType != "ecommerce" && o.OrderType != "regular" {
		return nil, errors.New("order type does not support online payment")
	}
	u := auth.UserFrom(ctx)
	isStaff := u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner || u.Role == auth.RoleSales || u.Role == auth.RoleFinance || u.Role == auth.RoleFinanceHead || u.Role == auth.RoleGM || u.Role == auth.RoleBM || u.Role == auth.RoleSPV || u.Role == auth.RoleStoreManager || u.Can("sales.sales-orders.view") || u.Can("sales.pos.create") || u.Role == auth.RoleCashier)
	if !isStaff {
		customerID, err := s.store.CustomerByUserID(ctx, nil, userID)
		if err != nil {
			return nil, ErrNotFound
		}
		if o.CustomerID != customerID {
			return nil, ErrNotFound
		}
	}
	any, err := s.store.InvoiceForOrder(ctx, nil, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "not_attempted"}, nil
		}
		return nil, err
	}
	if any.Status == "paid" {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "settled"}, nil
	}
	if !invoiceSettleable(any) {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: finance.MidtransOutcomeWrittenOff}, nil
	}
	ref := ""
	if any.SnapOrderRef != nil {
		ref = *any.SnapOrderRef
	}
	if ref == "" {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "not_attempted"}, nil
	}
	status, gross, reported, refErr := s.snapSettleCandidate(ctx, ref, canonicalSnapRef(any.InvoiceNumber, o.ID))
	if refErr != nil {
		return nil, refErr
	}
	if status == nil {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: tillPaymentStatus(reported)}, nil
	}
	payable, err := reconcileSnapGross(any, "provider", gross)
	if err != nil {
		return nil, err
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	lockedRow, err := s.store.OrderByIDForUpdate(ctx, tx, orderID)
	if err != nil {
		return nil, err
	}
	lockedOrder := orderFromLockedRow(lockedRow)

	marked, err := s.store.MarkInvoicePaid(ctx, tx, any.ID, any.TotalAmount)
	if err != nil {
		return nil, err
	}
	if marked == 0 {
		return &SnapStatusResult{OrderStatus: lockedOrder.Status, PaymentStatus: "settled"}, nil
	}

	payNumber, err := s.payCode(ctx, tx)
	if err != nil {
		return nil, err
	}
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     any.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		Amount:        payable,
		PaymentMethod: finance.MidtransMethod(status.PaymentType),
		Notes:         ptrString("Midtrans " + status.TransactionID),
	}
	if _, err := s.store.CreateGatewayPayment(ctx, tx, createPaymentParams(pay), "midtrans", status.TransactionID, status.TransactionStatus); err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return &SnapStatusResult{OrderStatus: "invoiced", PaymentStatus: "settled"}, nil
		}
		return nil, err
	}
	if err := s.store.SettleOrderPayment(ctx, tx, o.ID, payable); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, o.ID)
	}
	return &SnapStatusResult{OrderStatus: "invoiced", PaymentStatus: "settled"}, nil
}

// SnapPOSStatus is the till-side recovery poll for deferred-gateway (POS
// QRIS) orders: same settle-on-captured semantics as SnapPaymentStatus, but
// scoped to the caller's stores instead of a linked customer. The cashier
// polls this while the customer pays on their own phone; the webhook settles
// independently, and both are idempotent.
func (s *Service) SnapPOSStatus(ctx context.Context, orderID int64) (*SnapStatusResult, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	row, err := s.store.OrderByID(ctx, orderID)
	if err != nil {
		return nil, err
	}
	o := orderFromRow(row)
	if o.OrderType != "pos" {
		return nil, errors.New("order is not a POS order")
	}
	if err := s.checkScope(ctx, o.StoreID); err != nil {
		return nil, err
	}
	any, err := s.store.InvoiceForOrder(ctx, nil, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "not_attempted"}, nil
		}
		return nil, err
	}
	if any.Status == "paid" {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "settled"}, nil
	}
	if !invoiceSettleable(any) {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: finance.MidtransOutcomeWrittenOff}, nil
	}
	ref := ""
	if any.SnapOrderRef != nil {
		ref = *any.SnapOrderRef
	}
	if ref == "" {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: "not_attempted"}, nil
	}
	status, gross, reported, refErr := s.snapSettleCandidate(ctx, ref, canonicalSnapRef(any.InvoiceNumber, o.ID))
	if refErr != nil {
		return nil, refErr
	}
	if status == nil {
		return &SnapStatusResult{OrderStatus: o.Status, PaymentStatus: tillPaymentStatus(reported)}, nil
	}
	payable, err := reconcileSnapGross(any, "provider", gross)
	if err != nil {
		return nil, err
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	lockedRow, err := s.store.OrderByIDForUpdate(ctx, tx, orderID)
	if err != nil {
		return nil, err
	}
	lockedOrder := orderFromLockedRow(lockedRow)

	marked, err := s.store.MarkInvoicePaid(ctx, tx, any.ID, any.TotalAmount)
	if err != nil {
		return nil, err
	}
	if marked == 0 {
		return &SnapStatusResult{OrderStatus: lockedOrder.Status, PaymentStatus: "settled"}, nil
	}

	payNumber, err := s.payCode(ctx, tx)
	if err != nil {
		return nil, err
	}
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     any.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		Amount:        payable,
		PaymentMethod: finance.MidtransMethod(status.PaymentType),
		Notes:         ptrString("Midtrans " + status.TransactionID),
	}
	if _, err := s.store.CreateGatewayPayment(ctx, tx, createPaymentParams(pay), "midtrans", status.TransactionID, status.TransactionStatus); err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return &SnapStatusResult{OrderStatus: "invoiced", PaymentStatus: "settled"}, nil
		}
		return nil, err
	}
	if err := s.store.SettleOrderPayment(ctx, tx, o.ID, payable); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, o.ID)
	}
	return &SnapStatusResult{OrderStatus: "invoiced", PaymentStatus: "settled"}, nil
}

// SnapStatusResult is the recovery-poll response payload.
type SnapStatusResult struct {
	OrderStatus   string `json:"order_status"`
	PaymentStatus string `json:"payment_status"`
}

// snapStatusResponse is the Core API /v2/{order_id}/status payload subset.
type snapStatusResponse struct {
	StatusCode        string `json:"status_code"`
	TransactionStatus string `json:"transaction_status"`
	FraudStatus       string `json:"fraud_status"`
	PaymentType       string `json:"payment_type"`
	TransactionID     string `json:"transaction_id"`
	GrossAmount       string `json:"gross_amount"`
	// Actions and ExpiryTime exist so OrderQRIS can RE-SERVE a still-pending
	// QRIS from a status probe: the generate-qr-code action is the same URL
	// the charge response carries, and re-serving it means reopening the pay
	// dialog does not strand the QR the operator already showed the payer.
	QRString   string `json:"qr_string"`
	ExpiryTime string `json:"expiry_time"`
	Actions    []struct {
		Name   string `json:"name"`
		Method string `json:"method"`
		URL    string `json:"url"`
	} `json:"actions"`
}

// statusActionURL returns the named action's URL from a status payload, ""
// when the provider omitted it.
func statusActionURL(n *snapStatusResponse, name string) string {
	for _, a := range n.Actions {
		if a.Name == name && a.URL != "" {
			return a.URL
		}
	}
	return ""
}

// parseMidtransExpiry parses the provider's expiry_time exactly as the charge
// path does: Asia/Jakarta is explicit rather than the process timezone, a
// parse failure leaves the zero time, and the caller decides what an unknown
// deadline means. Both the charge response and the status probe share this.
func parseMidtransExpiry(raw string) time.Time {
	jakarta := time.FixedZone("WIB", 7*60*60)
	for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
		if t, perr := time.ParseInLocation(layout, raw, jakarta); perr == nil {
			return t
		}
	}
	return time.Time{}
}

// errSnapRefUnknown marks a status call for a ref the gateway has never seen
// (HTTP 404, or a not-found-shaped body). It is INTERNAL: the recovery poll
// consumes it as "nothing to settle on this ref" and must never surface it —
// a ref Midtrans does not know is not a gateway failure, it is the normal
// answer for a token that was never created (or has since expired out of
// Midtrans' 24h status window). Transport failures and malformed responses
// stay hard errors.
var errSnapRefUnknown = errors.New("snap order reference unknown to the gateway")

// errGatewayTimeout is returned when a Midtrans call hits OUR deadline
// (midtransHTTPTimeout) instead of getting an answer. It is deliberately NOT
// errSnapRefUnknown: a timeout says nothing about whether the buyer paid, so it
// is a HARD, transient failure — the invoice is retried on a later pass rather
// than reported as "ref unknown, nothing to settle", which would silently mark
// a genuinely-paid invoice as un-settleable. Message is safe: the handler
// surfaces it verbatim and no provider detail is included.
var errGatewayTimeout = errors.New("payment gateway request timed out")

// isTimeout reports whether err is a missed deadline rather than a gateway
// answer. Both spellings matter: our per-call context deadline surfaces as
// context.DeadlineExceeded, while a client/dial timeout arrives as a net.Error
// with Timeout() true (context.DeadlineExceeded satisfies that test too).
func isTimeout(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return true
	}
	var ne net.Error
	return errors.As(err, &ne) && ne.Timeout()
}

// snapSettleCandidate asks Midtrans about the candidate refs for one invoice,
// newest first, and returns the first response that reports a settled
// transaction — with the gross amount that came with THAT ref, so the caller
// reconciles the figure the settling transaction actually reported.
//
// This is snapProbeRefs projected onto the recovery poll's contract — see
// snapProbeRefs for the loop, the hard-error rule and the
// gross-from-the-settling-ref rule, all of which live there so the sweeper
// cannot grow a second copy of them. It returns:
//
//   - status:    the settling response, or nil when no ref settled;
//   - gross:     the gross reported alongside that settling response;
//   - reported:  the payment_status to report when nothing settled — the action
//     of the first ref the gateway knew about, else MidtransActionIgnored
//     ("nothing to report, no transaction on any ref"). This preserves the
//     pre-existing single-ref contract: the stored ref's answer still wins
//     when it settles on its own.
//   - err:       a real gateway failure from the FIRST ref that failed. A ref
//     the gateway does not know is skipped; a hard error is remembered and the
//     remaining refs are still asked, because a transport failure on the
//     newest transaction must not hide a capture on an older one. If nothing
//     settles, that error is returned exactly as a single-ref poll returned it.
func (s *Service) snapSettleCandidate(ctx context.Context, refs ...string) (*snapStatusResponse, decimal.Decimal, string, error) {
	status, gross, _, reported, err := s.snapProbeRefs(ctx, refs...)
	if status != nil {
		return status, gross, reported, nil
	}
	return nil, decimal.Zero, reported, err
}

// snapProbeRefs is the one loop that asks Midtrans about an invoice's candidate
// refs, and snapSettleCandidate is its projection for the recovery poll. The
// sweeper needs the extra `known` count — it distinguishes a provider that holds
// a transaction that has not settled from one that has never heard of the ref,
// and those are different facts with different remedies — so the loop is here
// rather than duplicated, and the hard-error and gross-from-the-settling-ref
// rules are stated exactly once.
//
// known counts the refs the gateway positively answered about. A ref it does not
// know is SOFT and skipped, never fatal: the next ref may still hold the money.
func (s *Service) snapProbeRefs(ctx context.Context, refs ...string) (*snapStatusResponse, decimal.Decimal, int, string, error) {
	reported := ""
	known := 0
	var hardErr error
	for _, ref := range refs {
		if ref == "" {
			continue
		}
		status, gross, err := s.fetchSnapStatus(ctx, ref)
		if errors.Is(err, errSnapRefUnknown) {
			// SOFT: the gateway positively answered "I have never seen this
			// ref". Skip it and keep asking the other refs.
			continue
		}
		if err != nil {
			// HARD, timeouts (errGatewayTimeout) included: a missed deadline
			// is transient, so the invoice must be retried on a later pass.
			// Only errSnapRefUnknown above may be swallowed.
			if hardErr == nil {
				hardErr = err
			}
			continue
		}
		known++
		action := finance.MidtransAction(status.TransactionStatus, status.FraudStatus)
		if action == finance.MidtransActionSettled {
			return status, gross, known, action, nil
		}
		// Not settled on this ref. Keep the first real answer for the response
		// but keep looking: the money may be captured on a superseded ref.
		if reported == "" {
			reported = action
		}
	}
	if hardErr != nil {
		return nil, decimal.Zero, known, "", hardErr
	}
	if reported == "" {
		reported = finance.MidtransActionIgnored
	}
	return nil, decimal.Zero, known, reported, nil
}

// fetchSnapStatus GETs the Core API status endpoint (api.* host family — NOT
// the app.* Snap host). Basic auth with the server key, same credential.
// A ref Midtrans does not know returns errSnapRefUnknown rather than a
// hard failure — see that var.
func (s *Service) fetchSnapStatus(ctx context.Context, orderRef string) (*snapStatusResponse, decimal.Decimal, error) {
	base := s.midtrans.CoreBaseURL
	if base == "" {
		base = "https://api.sandbox.midtrans.com/v2"
	}
	// Bounded in time, and deliberately so: the caller reaches here with an
	// OPEN transaction that holds FOR UPDATE on the invoice row, so an
	// unbounded provider call would hold that row lock and a pooled connection
	// until the client gave up — which, on http.DefaultClient, was never.
	// Every ref probed gets its own fresh deadline.
	callCtx, cancel := context.WithTimeout(ctx, s.snapTimeout())
	defer cancel()
	req, err := http.NewRequestWithContext(callCtx, http.MethodGet, base+"/"+orderRef+"/status", nil)
	if err != nil {
		return nil, decimal.Zero, errors.New("payment gateway request failed")
	}
	req.SetBasicAuth(s.midtrans.ServerKey, "")
	req.Header.Set("Accept", "application/json")
	resp, err := s.snapClient().Do(req)
	if err != nil {
		if isTimeout(err) {
			return nil, decimal.Zero, errGatewayTimeout
		}
		return nil, decimal.Zero, errors.New("payment gateway request failed")
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return nil, decimal.Zero, errSnapRefUnknown
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, decimal.Zero, errors.New("payment gateway request failed")
	}
	var out snapStatusResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, decimal.Zero, errors.New("payment gateway returned an invalid response")
	}
	// Not-found can also arrive 200-shaped (status_code 404 in the body).
	// Same meaning, same handling: the gateway does not know this ref.
	if strings.TrimSpace(out.StatusCode) == "404" {
		return nil, decimal.Zero, errSnapRefUnknown
	}
	gross, gErr := decimal.NewFromString(strings.TrimSpace(out.GrossAmount))
	if gErr != nil {
		return nil, decimal.Zero, errors.New("payment gateway returned an invalid response")
	}
	return &out, gross, nil
}

const (
	seqKeyOrder   = "so"
	seqKeyInvoice = "inv"
	seqKeyPay     = "pay"
	seqKeyReturn  = "rtn"
	seqKeyVoucher = "vch"
)

// InvoiceModel is the morph reference used by payments (Laravel FQCN, §5.2).
const InvoiceModel = "App\\Models\\Invoice"

func isValidPaymentMethod(m string) bool {
	switch m {
	case "cash", "transfer", "giro", "cheque", "qris", "kartu":
		return true
	}
	return false
}

func isValidCheckoutPaymentMethod(m string) bool {
	switch m {
	case "cash", "transfer", "giro", "cheque", "qris", "kartu", "gateway":
		return true
	}
	return false
}

// orderCode allocates a monthly transaction number (business-rules.md §17):
// SO-YYYYMM-000001 / INV-YYYYMM-000001. The counter is scoped to the document
// type + calendar month in Asia/Jakarta; sequence errors abort the operation
// instead of producing an empty number.
func (s *Service) orderCode(ctx context.Context, seqKey, display string, tx any) (string, error) {
	loc := s.loc
	if loc == nil {
		loc = time.Local
	}
	if s.seq == nil {
		return fmt.Sprintf("%s-%s-%06d", display, time.Now().In(loc).Format("200601"), time.Now().UnixNano()%1000000), nil
	}
	n, err := s.seq.Sequence(ctx, seqKey, seq.MonthStart(time.Now().In(loc)), tx)
	if err != nil {
		return "", fmt.Errorf("%s number sequence: %w", display, err)
	}
	return fmt.Sprintf("%s-%s-%06d", display, time.Now().In(loc).Format("200601"), n), nil
}

var globalAnchorDate = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

func (s *Service) custCode(ctx context.Context, prefix string, tx any) (string, error) {
	n, err := s.seq.Sequence(ctx, prefix, globalAnchorDate, tx)
	if err != nil {
		return "", fmt.Errorf("%s code sequence: %w", prefix, err)
	}
	return fmt.Sprintf("%s-%03d", prefix, n), nil
}

func (s *Service) payCode(ctx context.Context, tx any) (string, error) {
	return s.prefixedCode(ctx, seqKeyPay, "PAY", tx)
}

// prefixedCode formats PAY numbers (monthly, §17). CF numbering lives in
// finance.cfCode for manual Arus Kas entries.
func (s *Service) prefixedCode(ctx context.Context, seqKey, display string, tx any) (string, error) {
	date := time.Now().In(s.loc)
	scope := date
	format := "20060102"
	width := 4
	if seqKey == seqKeyPay {
		scope = seq.MonthStart(date)
		format = "200601"
		width = 6
	}
	n, err := s.seq.Sequence(ctx, seqKey, scope, tx)
	if err != nil {
		return "", fmt.Errorf("%s number sequence: %w", display, err)
	}
	return fmt.Sprintf("%s-%s-%0*d", display, date.Format(format), width, n), nil
}

func ptrString(s string) *string { return &s }

func itemsFromRows(rows []db.ListSalesOrderItemsRow) []SOItem {
	out := make([]SOItem, 0, len(rows))
	for _, r := range rows {
		out = append(out, itemFromRow(r))
	}
	return out
}

// --- sales vouchers ------------------------------------------------------------

func isValidVoucherType(t string) bool {
	switch t {
	case "promotion", "member_discount", "sales_voucher":
		return true
	}
	return false
}

func isValidVoucherStatus(st string) bool {
	switch st {
	case "available", "used", "expired":
		return true
	}
	return false
}

// voucherIsExpired mirrors the redemption rule shared with
// UpdateSalesVoucherStatus: ExpiredAt is a DATE at midnight, past-due only
// when strictly before today in the service location, so a voucher expiring
// today is still usable.
func voucherIsExpired(expiredAt pgtype.Date, loc *time.Location) bool {
	if !expiredAt.Valid {
		return false
	}
	today := time.Now().In(loc).Format("2006-01-02")
	return expiredAt.Time.Format("2006-01-02") < today
}

func voucherFromCreateRow(r db.CreateSalesVoucherRow) *SalesVoucher {
	return &SalesVoucher{
		ID:            r.ID,
		VoucherNumber: r.VoucherNumber,
		Type:          r.Type,
		Description:   textToStrPtr(r.Description),
		VoucherDate:   r.VoucherDate,
		Amount:        r.Amount,
		Status:        r.Status,
		CreatedBy:     int8ToPtr(r.CreatedBy),
		ExpiredAt:     dateToStrPtr(r.ExpiredAt),
		CreatedAt:     r.CreatedAt.Time,
		UpdatedAt:     r.UpdatedAt.Time,
	}
}

func voucherFromRow(r db.GetSalesVoucherByIDRow) *SalesVoucher {
	return &SalesVoucher{
		ID:            r.ID,
		VoucherNumber: r.VoucherNumber,
		Type:          r.Type,
		Description:   textToStrPtr(r.Description),
		VoucherDate:   r.VoucherDate,
		Amount:        r.Amount,
		Status:        r.Status,
		CreatedBy:     int8ToPtr(r.CreatedBy),
		ExpiredAt:     dateToStrPtr(r.ExpiredAt),
		CreatedAt:     r.CreatedAt.Time,
		UpdatedAt:     r.UpdatedAt.Time,
	}
}

// CreateSalesVoucher creates a sales/member/promotion voucher (§13.3: separate
// aggregate from purchasing vouchers).
func (s *Service) CreateSalesVoucher(ctx context.Context, in SalesVoucherInput) (*SalesVoucher, error) {
	vNumber := strings.TrimSpace(in.VoucherNumber)
	if !isValidVoucherType(in.Type) {
		return nil, errors.New("type must be promotion, member_discount or sales_voucher")
	}
	if in.Amount.LessThanOrEqual(decimal.Zero) {
		return nil, errors.New("amount must be > 0")
	}
	status := in.Status
	if status == "" {
		status = "available"
	}
	if !isValidVoucherStatus(status) {
		return nil, errors.New("status must be available, used or expired")
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	if vNumber == "" {
		code, err := s.orderCode(ctx, seqKeyVoucher, "VCH", tx)
		if err != nil {
			return nil, err
		}
		vNumber = code
	} else {
		if _, err := s.store.GetSalesVoucherByNumber(ctx, tx, vNumber); err == nil {
			return nil, errors.New("voucher_number already exists")
		} else if !errors.Is(err, ErrNotFound) {
			return nil, err
		}
	}

	vDate := strings.TrimSpace(in.VoucherDate)
	if vDate == "" {
		vDate = time.Now().In(s.loc).Format("2006-01-02")
	}

	var createdByID pgtype.Int8
	if u := auth.UserFrom(ctx); u != nil && u.ID > 0 {
		createdByID = pgtype.Int8{Int64: u.ID, Valid: true}
	}

	row, err := s.store.CreateSalesVoucher(ctx, tx, db.CreateSalesVoucherParams{
		VoucherNumber: vNumber,
		Type:          in.Type,
		Description:   textOrNilStr(in.Description),
		VoucherDate:   dateOrNil(vDate),
		Amount:        in.Amount,
		Status:        status,
		CreatedBy:     createdByID,
		ExpiredAt:     dateOrNilStr(in.ExpiredAt),
	})
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return voucherFromCreateRow(row), nil
}

// GetSalesVoucherByID retrieves a sales voucher by ID.
func (s *Service) GetSalesVoucherByID(ctx context.Context, id int64) (*SalesVoucher, error) {
	row, err := s.store.GetSalesVoucherByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	return voucherFromRow(row), nil
}

// GetSalesVoucherByNumber retrieves a sales voucher by its number.
func (s *Service) GetSalesVoucherByNumber(ctx context.Context, voucherNumber string) (*SalesVoucher, error) {
	row, err := s.store.GetSalesVoucherByNumber(ctx, nil, voucherNumber)
	if err != nil {
		return nil, err
	}
	return voucherFromRow(db.GetSalesVoucherByIDRow(row)), nil
}

// UpdateSalesVoucherStatus transitions a voucher's status with proper locking and state validation.
func (s *Service) UpdateSalesVoucherStatus(ctx context.Context, id int64, status string) (*SalesVoucher, error) {
	if !isValidVoucherStatus(status) {
		return nil, errors.New("status must be available, used or expired")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	// Lock the voucher row to prevent concurrent transitions
	voucher, err := s.store.GetSalesVoucherByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}

	// Validate state transition
	if !isValidVoucherTransition(voucher.Status, status) {
		return nil, fmt.Errorf("invalid status transition: %s -> %s", voucher.Status, status)
	}

	// If transitioning to "used", ensure not expired (same rule as POS
	// redemption — see voucherIsExpired).
	if status == "used" && voucherIsExpired(voucher.ExpiredAt, s.loc) {
		return nil, errors.New("cannot use expired voucher")
	}

	if err := s.store.UpdateSalesVoucherStatus(ctx, tx, id, status); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetSalesVoucherByID(ctx, id)
}

// isValidVoucherTransition returns true if the status transition is allowed.
func isValidVoucherTransition(from, to string) bool {
	// allowed transitions
	switch from {
	case "available":
		return to == "used" || to == "expired"
	case "used":
		return to == "expired" // used -> expired only (not back to available)
	case "expired":
		return false // terminal state
	default:
		return false
	}
}

// ListSalesVouchers returns a paginated list of sales vouchers.
func (s *Service) ListSalesVouchers(ctx context.Context, f SalesVoucherFilter) ([]SalesVoucher, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	filter := db.CountSalesVouchersParams{
		Status:        textOrNil(f.Status),
		Type:          textOrNil(f.Type),
		VoucherNumber: textOrNil(f.VoucherNumber),
	}
	total, err := s.store.CountSalesVouchers(ctx, nil, filter)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.ListSalesVouchers(ctx, nil, db.ListSalesVouchersParams{
		Limit:         int32(f.PerPage),
		Offset:        int32((f.Page - 1) * f.PerPage),
		Status:        filter.Status,
		Type:          filter.Type,
		VoucherNumber: filter.VoucherNumber,
	})
	if err != nil {
		return nil, 0, err
	}
	out := make([]SalesVoucher, 0, len(rows))
	for _, r := range rows {
		out = append(out, *voucherFromRow(db.GetSalesVoucherByIDRow(r)))
	}
	return out, int(total), nil
}

// --- sales returns (§11.4) ---------------------------------------------------

// CreateReturn records a return against an original sales order in one
// transaction: lock the order, validate each requested line against the
// already-returned quantity, compute the refund pro-rata from the immutable
// line snapshot (unit price, discounts, membership benefit), restock accepted
// goods via the return ledger, and persist the immutable return event.
//
// DEFAULT PENDING CONFIRMATION (§20 #4): refund method defaults to cash;
// tax/promotion allocation, damaged-return handling and quarantine stock are
// not implemented — every accepted return restocks in full and refunds the
// pro-rata snapshot amount until the business confirms otherwise.
func (s *Service) CreateReturn(ctx context.Context, in ReturnInput) (*Return, error) {
	if in.SalesOrderID == 0 {
		return nil, errors.New("sales_order_id is required")
	}
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}
	method := in.RefundMethod
	if method == "" {
		method = "cash"
	}
	if !isValidRefundMethod(method) {
		return nil, errors.New("refund_method must be cash, transfer or store_credit")
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	order, err := s.store.OrderByIDForUpdate(ctx, tx, in.SalesOrderID)
	if err != nil {
		return nil, err
	}
	// RBAC audit 2026-08-25: a customer (buyer) may only return THEIR OWN
	// order. Internal operators pass unrestricted (store scope still applies
	// via checkScope below where relevant).
	u := auth.UserFrom(ctx)
	isBuyerCaller := u != nil && (u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client")
	if isBuyerCaller && !u.CanAccessApp(auth.AppECommerceAdmin) {
		custID, err := s.store.CustomerByUserID(ctx, tx, u.ID)
		if err != nil {
			return nil, ErrNotFound
		}
		if order.CustomerID != custID {
			return nil, ErrNotFound
		}
	} else {
		// Tenant scope: internal callers may only return against orders in their own outlet
		if err := s.checkScope(ctx, int8ToPtr(order.StoreID)); err != nil {
			return nil, err
		}
	}
	// POS return independen per toko: if caller specifies a store_id, it must match the order's store_id
	if in.StoreID != nil && *in.StoreID > 0 {
		orderStoreID := int8ToPtr(order.StoreID)
		if orderStoreID == nil || *orderStoreID != *in.StoreID {
			return nil, errors.New("transaksi ini tercatat di toko lain, retur hanya dapat diproses pada toko tempat transaksi dilakukan")
		}
	}
	if order.Status == "cancelled" {
		return nil, ErrCannotCancel
	}
	// Only allow returns on orders that have been fulfilled (delivered/completed/invoiced/paid).
	// POS sales stay "confirmed" but are paid in full at the register, so a
	// fully-paid confirmed order is returnable too.
	fullyPaid := order.TotalAmount.GreaterThan(decimal.Zero) && order.PaidAmount.GreaterThanOrEqual(order.TotalAmount)
	returnable := order.Status == "delivered" || order.Status == "completed" || order.Status == "invoiced" || order.Status == "paid" ||
		(order.Status == "confirmed" && fullyPaid)
	if !returnable {
		return nil, errors.New("returns only allowed on delivered, invoiced, or paid orders")
	}
	items, err := s.store.ItemsByOrder(ctx, order.ID)
	if err != nil {
		return nil, err
	}
	byID := make(map[int64]db.ListSalesOrderItemsRow, len(items))
	for _, it := range items {
		byID[it.ID] = it
	}

	warehouseID := int8ToPtr(order.WarehouseID)

	returnNumber, err := s.orderCode(ctx, seqKeyReturn, "RTN", tx)
	if err != nil {
		return nil, err
	}

	totalRefund := decimal.Zero
	type pendingLine struct {
		in      ReturnItemInput
		orig    db.ListSalesOrderItemsRow
		refund  decimal.Decimal
		baseQty decimal.Decimal
	}
	pending := make([]pendingLine, 0, len(in.Items))
	for _, li := range in.Items {
		orig, ok := byID[li.SalesOrderItemID]
		if !ok {
			return nil, fmt.Errorf("line %d is not part of order %d", li.SalesOrderItemID, order.ID)
		}
		if li.Quantity.LessThanOrEqual(decimal.Zero) {
			return nil, errors.New("return quantity must be > 0")
		}
		already, err := s.store.SumReturnedQtyByOrderItem(ctx, tx, orig.ID)
		if err != nil {
			return nil, err
		}
		if already.Add(li.Quantity).GreaterThan(orig.Quantity) {
			return nil, fmt.Errorf("return exceeds remaining quantity on line %d", orig.ID)
		}
		// Pro-rata refund from the immutable snapshot: the line's final
		// subtotal (after discounts and membership benefit) scaled by the
		// returned share.
		refund := orig.Subtotal.Mul(li.Quantity).Div(orig.Quantity).Round(2)
		// Restock in base units using the snapshotted conversion factor.
		baseQty := orig.BaseQuantity.Mul(li.Quantity).Div(orig.Quantity).Round(4)
		totalRefund = totalRefund.Add(refund)
		pending = append(pending, pendingLine{in: li, orig: orig, refund: refund, baseQty: baseQty})
	}

	row, err := s.store.CreateSalesReturn(ctx, tx, db.CreateSalesReturnParams{
		ReturnNumber: returnNumber,
		SalesOrderID: order.ID,
		CustomerID:   order.CustomerID,
		WarehouseID:  order.WarehouseID,
		ReturnDate:   pgtype.Date{Time: time.Now().In(s.loc), Valid: true},
		RefundAmount: totalRefund,
		RefundMethod: method,
		Status:       "completed",
		Reason:       textOrNilStr(in.Reason),
		CreatedBy:    int8OrNil(in.CreatedBy),
	})
	if err != nil {
		return nil, err
	}

	out := &Return{
		ID:           row.ID,
		ReturnNumber: row.ReturnNumber,
		SalesOrderID: row.SalesOrderID,
		CustomerID:   row.CustomerID,
		WarehouseID:  warehouseID,
		ReturnDate:   row.ReturnDate,
		RefundAmount: row.RefundAmount,
		RefundMethod: row.RefundMethod,
		Status:       row.Status,
		Reason:       textToStrPtr(row.Reason),
		CreatedBy:    int8ToPtr(row.CreatedBy),
		CreatedAt:    row.CreatedAt.Time,
		UpdatedAt:    row.UpdatedAt.Time,
		Items:        make([]ReturnItem, 0, len(pending)),
	}
	for _, pl := range pending {
		ri, err := s.store.CreateSalesReturnItem(ctx, tx, db.CreateSalesReturnItemParams{
			SalesReturnID:    row.ID,
			SalesOrderItemID: pl.orig.ID,
			ProductID:        pl.orig.ProductID,
			Quantity:         pl.in.Quantity,
			UnitPrice:        pl.orig.UnitPrice,
			RefundAmount:     pl.refund,
			Reason:           textOrNilStr(pl.in.Reason),
		})
		if err != nil {
			return nil, err
		}
		condition := pl.in.Condition
		if condition != "damaged" {
			condition = "good"
		}
		restocked := false
		// Restock accepted goods in the same tx (§11.4). Damaged returns are NOT
		// restocked (quarantine/discard) — default pending confirmation (§20 #6).
		if condition != "damaged" && warehouseID != nil && !pl.baseQty.IsZero() {
			before, after, err := s.restockStock(ctx, tx, pl.orig.ProductID, *warehouseID, pl.baseQty)
			if err != nil {
				return nil, err
			}
			if s.returnLedger != nil {
				if err := s.returnLedger(ctx, tx, pl.orig.ProductID, *warehouseID, row.ID, before, after, pl.baseQty); err != nil {
					return nil, err
				}
			}
			restocked = true
		}
		if _, err := s.store.SetSalesReturnItemCondition(ctx, tx, db.SetSalesReturnItemConditionParams{
			ID:        ri.ID,
			Condition: condition,
			Restocked: restocked,
		}); err != nil {
			return nil, err
		}
		out.Items = append(out.Items, ReturnItem{
			ID:               ri.ID,
			SalesReturnID:    ri.SalesReturnID,
			SalesOrderItemID: ri.SalesOrderItemID,
			ProductID:        ri.ProductID,
			Quantity:         ri.Quantity,
			UnitPrice:        ri.UnitPrice,
			RefundAmount:     ri.RefundAmount,
			Reason:           textToStrPtr(ri.Reason),
			Condition:        condition,
			Restocked:        restocked,
		})
	}

	// A cash refund must leave the drawer: expected-cash is
	// opening + cash_in − cash_out + cash sales, so without a cash_out entry
	// every cash return makes the counted drawer look short and the variance
	// blames the cashier. Only a still-open shift can be adjusted (the
	// implementation skips closed shifts); the entry joins this transaction.
	if s.refundDrawer != nil && method == "cash" && totalRefund.GreaterThan(decimal.Zero) {
		shift, err := s.store.OrderShiftID(ctx, tx, order.ID)
		if err != nil {
			return nil, err
		}
		if shift.Valid && shift.Int64 > 0 {
			if err := s.refundDrawer.RecordCashRefund(ctx, tx, shift.Int64, totalRefund, "Retur "+returnNumber); err != nil {
				return nil, err
			}
		}
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return out, nil
}

// restockStock locks the stock row and adds qty back, returning exact
// before/after for the movement ledger.
func (s *Service) restockStock(ctx context.Context, q db.DBTX, productID, warehouseID int64, qty decimal.Decimal) (before, after decimal.Decimal, err error) {
	row, err := s.lockStock(ctx, q, productID, warehouseID)
	if err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	before = row.Quantity
	after = before.Add(qty)
	if err := s.store.SetStockQuantity(ctx, q, row.ID, after, row.ReservedQuantity); err != nil {
		return decimal.Zero, decimal.Zero, err
	}
	return before, after, nil
}

// GetReturn returns a return with its items.
func (s *Service) GetReturn(ctx context.Context, id int64) (*Return, error) {
	row, err := s.store.GetSalesReturnByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	// Tenant scope via the parent order (sales_returns carries no store_id).
	order, err := s.store.OrderByID(ctx, row.SalesOrderID)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, orderFromRow(order).StoreID); err != nil {
		return nil, err
	}
	items, err := s.store.ListSalesReturnItems(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	out := &Return{
		ID:           row.ID,
		ReturnNumber: row.ReturnNumber,
		SalesOrderID: row.SalesOrderID,
		CustomerID:   row.CustomerID,
		WarehouseID:  int8ToPtr(row.WarehouseID),
		ReturnDate:   row.ReturnDate,
		RefundAmount: row.RefundAmount,
		RefundMethod: row.RefundMethod,
		Status:       row.Status,
		Reason:       textToStrPtr(row.Reason),
		CreatedBy:    int8ToPtr(row.CreatedBy),
		CreatedAt:    row.CreatedAt.Time,
		UpdatedAt:    row.UpdatedAt.Time,
		SoNumber:     row.SoNumber,
		CustomerName: row.CustomerName,
		Items:        make([]ReturnItem, 0, len(items)),
	}
	for _, it := range items {
		out.Items = append(out.Items, ReturnItem{
			ID:               it.ID,
			SalesReturnID:    it.SalesReturnID,
			SalesOrderItemID: it.SalesOrderItemID,
			ProductID:        it.ProductID,
			Quantity:         it.Quantity,
			UnitPrice:        it.UnitPrice,
			RefundAmount:     it.RefundAmount,
			Reason:           textToStrPtr(it.Reason),
			ProductName:      it.ProductName,
			ProductCode:      it.ProductCode,
			UnitSymbol:       it.UnitSymbol,
		})
	}
	return out, nil
}

// ListReturns returns a paginated list of returns.
func (s *Service) ListReturns(ctx context.Context, f ReturnFilter) ([]Return, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	// Tenant isolation mirrors List: restricted callers only ever see their
	// stores' returns; unbound (e-commerce) orders stay visible to all.
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		if f.StoreID > 0 {
			if !sc.AllowsID(f.StoreID) {
				return []Return{}, 0, nil
			}
			f.AllowedStoreIDs = []int64{f.StoreID}
		} else {
			f.AllowedStoreIDs = sc.IDs()
		}
	}
	filter := db.CountSalesReturnsParams{
		SalesOrderID:    int8OrZero(f.SalesOrderID),
		CustomerID:      int8OrZero(f.CustomerID),
		StoreID:         int8OrZero(f.StoreID),
		AllowedStoreIds: f.AllowedStoreIDs,
	}
	total, err := s.store.CountSalesReturns(ctx, nil, filter)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.ListSalesReturns(ctx, nil, db.ListSalesReturnsParams{
		Limit:           int32(f.PerPage),
		Offset:          int32((f.Page - 1) * f.PerPage),
		SalesOrderID:    filter.SalesOrderID,
		CustomerID:      filter.CustomerID,
		StoreID:         filter.StoreID,
		AllowedStoreIds: filter.AllowedStoreIds,
	})
	if err != nil {
		return nil, 0, err
	}
	out := make([]Return, 0, len(rows))
	for _, r := range rows {
		out = append(out, Return{
			ID:           r.ID,
			ReturnNumber: r.ReturnNumber,
			SalesOrderID: r.SalesOrderID,
			CustomerID:   r.CustomerID,
			WarehouseID:  int8ToPtr(r.WarehouseID),
			ReturnDate:   r.ReturnDate,
			RefundAmount: r.RefundAmount,
			RefundMethod: r.RefundMethod,
			Status:       r.Status,
			Reason:       textToStrPtr(r.Reason),
			CreatedBy:    int8ToPtr(r.CreatedBy),
			CreatedAt:    r.CreatedAt.Time,
			UpdatedAt:    r.UpdatedAt.Time,
			SoNumber:     r.SoNumber,
			CustomerName: r.CustomerName,
		})
	}
	return out, int(total), nil
}

func isValidRefundMethod(m string) bool {
	switch m {
	case "cash", "transfer", "store_credit":
		return true
	}
	return false
}

// ---------------------------------------------------------------------------
// POS QRIS — Core API charge
//
// Snap is wrong for a till. The cashier's own screen had to host a Midtrans
// page in an iframe just to show a QR, so the provider's fixed ~360px column sat
// inside our dialog with dead space either side and the QR sized by Midtrans
// rather than by us. The Core API hands back the QR as a PNG, which we render at
// whatever size the tablet needs, wrapped in our own amount / order / countdown /
// cancel UI.
//
// Per docs.midtrans.com/docs/gopay-qris-pos-integration. Authenticated by the
// same server key as Snap: no access token, no signing key. Signature
// verification is byte-identical and the notification body decodes into the same
// struct Snap already uses, so the settlement path is unchanged.

// QRISResult is what the till needs to draw the payment. Nothing here is a
// page: QRURL is an <img> source and QRString is there only as a fallback for
// rendering the code locally, per the POS guide.
type QRISResult struct {
	TransactionID string `json:"transaction_id"`
	QRURL         string `json:"qr_url"`
	QRString      string `json:"qr_string,omitempty"`
	// ExpiresAt is the PROVIDER's deadline, taken from the charge response.
	// The till shows this as a countdown, so it must be the real one rather
	// than a locally computed 15 minutes that would drift from what Midtrans
	// actually enforces.
	ExpiresAt time.Time `json:"expires_at"`
}

// chargeQrisResponse is the /v2/charge success payload. Only the fields we act
// on are decoded; Midtrans sends more and the shape is stable.
type chargeQrisResponse struct {
	StatusCode        string `json:"status_code"`
	StatusMessage     string `json:"status_message"`
	TransactionID     string `json:"transaction_id"`
	OrderID           string `json:"order_id"`
	TransactionStatus string `json:"transaction_status"`
	GrossAmount       string `json:"gross_amount"`
	PaymentType       string `json:"payment_type"`
	QRString          string `json:"qr_string"`
	ExpiryTime        string `json:"expiry_time"`
	Actions           []struct {
		Name   string `json:"name"`
		Method string `json:"method"`
		URL    string `json:"url"`
	} `json:"actions"`
	ErrorMessages []string `json:"error_messages"`
}

// RequestQrisCharge exports requestQrisCharge for cross-package adapters (such as galon QRIS).
func (s *Service) RequestQrisCharge(ctx context.Context, orderRef string, gross decimal.Decimal) (*QRISResult, error) {
	return s.requestQrisCharge(ctx, orderRef, gross)
}

// FetchMidtransStatus queries Midtrans /v2/{orderRef}/status and returns settled/failed/pending and transaction ID.
func (s *Service) FetchMidtransStatus(ctx context.Context, orderRef string) (string, string, error) {
	if s.midtrans.ServerKey == "" {
		return "", "", ErrGatewayDisabled
	}
	res, _, err := s.fetchSnapStatus(ctx, orderRef)
	if err != nil {
		return "", "", err
	}
	return finance.MidtransAction(res.TransactionStatus, res.FraudStatus), res.TransactionID, nil
}

// requestQrisCharge creates a QRIS charge and returns the pieces the till
// renders.
//
// gross is passed through snapRoundedGross for the same reason Snap is: the
// provider accepts whole rupiah only and echoes that integer back, so the amount
// we SEND has to be the amount settlement is reconciled against.
func (s *Service) requestQrisCharge(ctx context.Context, orderRef string, gross decimal.Decimal) (*QRISResult, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	// CoreBaseURL already carries the /v2 suffix — config derives it as
	// https://api.sandbox.midtrans.com/v2 and the status/cancel calls append a
	// path to it directly. Appending "/v2/charge" to that produced
	// /v2/v2/charge, a 404 the caller saw only as "payment gateway request
	// failed". Strip any trailing /v2 so the join below is correct whichever
	// form the config carries.
	base := strings.TrimRight(s.midtrans.CoreBaseURL, "/")
	base = strings.TrimSuffix(base, "/v2")
	if base == "" {
		base = "https://api.sandbox.midtrans.com"
	}
	body := map[string]any{
		"payment_type": "qris",
		"transaction_details": map[string]any{
			"order_id":     orderRef,
			"gross_amount": snapRoundedGross(gross).IntPart(),
		},
	}
	payload, err := json.Marshal(body)
	if err != nil {
		return nil, errors.New("payment gateway request failed")
	}

	// Bounded exactly as the Snap call is: this is a caller-facing request and
	// an unbounded one blocks the request plus a pooled connection for as long
	// as the provider stalls.
	callCtx, cancel := context.WithTimeout(ctx, s.snapTimeout())
	defer cancel()

	req, err := http.NewRequestWithContext(callCtx, http.MethodPost, strings.TrimRight(base, "/")+"/v2/charge", bytes.NewReader(payload))
	if err != nil {
		return nil, errors.New("payment gateway request failed")
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")
	// Basic auth with the server key and a BLANK password. The same shape Snap
	// uses; the provider rejects a non-empty password.
	req.SetBasicAuth(s.midtrans.ServerKey, "")
	if s.midtrans.NotificationURL != "" {
		req.Header.Set("X-Override-Notification", s.midtrans.NotificationURL)
	}

	client := s.snapClient()
	resp, err := client.Do(req)
	if err != nil {
		// Same mapping as the Snap call: a blown deadline is transient and the
		// caller retries, anything else is a generic failure. errGatewayTimeout
		// is distinct because the reconciler treats it differently from a
		// permanent error.
		if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
			return nil, errGatewayTimeout
		}
		return nil, errors.New("payment gateway request failed")
	}
	defer func() {
		_, _ = io.Copy(io.Discard, resp.Body)
		_ = resp.Body.Close()
	}()

	var out chargeQrisResponse
	decErr := json.NewDecoder(resp.Body).Decode(&out)

	if decErr != nil || resp.StatusCode < 200 || resp.StatusCode > 299 || out.StatusCode != "201" {
		// The provider explains itself in status_message / error_messages and
		// those are not secrets, so they reach the operator. Anything else is
		// replaced with a generic error rather than leaking a body.
		if len(out.ErrorMessages) > 0 {
			return nil, errors.New(strings.Join(out.ErrorMessages, "; "))
		}
		if out.StatusMessage != "" {
			return nil, errors.New(out.StatusMessage)
		}
		return nil, errors.New("payment gateway request failed")
	}

	// Prefer the v2 action; fall back to whatever action exists, and finally
	// to the documented path shape so a provider-side rename degrades to a
	// working QR rather than a broken one.
	qrURL := ""
	for _, a := range out.Actions {
		if a.URL != "" {
			qrURL = a.URL
			if a.Name == "generate-qr-code" {
				break
			}
		}
	}
	if qrURL == "" && out.TransactionID != "" {
		qrURL = base + "/v2/qris/" + out.TransactionID + "/qr-code"
	}
	if out.TransactionID == "" || qrURL == "" {
		return nil, errors.New("payment gateway response was incomplete")
	}

	expires := time.Time{}
	if out.ExpiryTime != "" {
		// The charge response sends expiry_time with NO offset — "2006-01-02
		// 15:04:05", in merchant-local time. Parsing it with an offset-bearing
		// layout silently fails and left the till with a zero deadline, i.e. a
		// countdown of "0:00" against a payment the provider would happily keep
		// open for 15 more minutes.
		//
		// Asia/Jakarta is used explicitly rather than the process timezone: the
		// host is not guaranteed to be set to it, and guessing would put the
		// countdown off by the container's offset. A parse failure still leaves
		// the zero time rather than inventing a deadline — the provider's own
		// expiry applies regardless, and the caller decides what an unknown one
		// means.
		jakarta := time.FixedZone("WIB", 7*60*60)
		for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
			if t, perr := time.ParseInLocation(layout, out.ExpiryTime, jakarta); perr == nil {
				expires = t
				break
			}
		}
	}

	return &QRISResult{
		TransactionID: out.TransactionID,
		QRURL:         qrURL,
		QRString:      out.QRString,
		ExpiresAt:     expires,
	}, nil
}

// OrderQRISResult is what the ERP pay dialog needs to draw the QRIS IN PAGE —
// the back-office twin of the till's deferred-QRIS: no Snap page, no tab, the
// operator's own screen carries the QR exactly as a cashier's till does.
// PaymentStatus follows the till's poll vocabulary (pending/settled/failed) so
// the dialog treats this and the recovery poll identically.
type OrderQRISResult struct {
	OrderStatus   string `json:"order_status"`
	PaymentStatus string `json:"payment_status"`
	// Amount is the rounded gross the QR actually charges — the dialog shows
	// it so the payer's app amount reconciles with the page (§7-D).
	Amount        decimal.Decimal `json:"amount,omitempty"`
	TransactionID string          `json:"transaction_id,omitempty"`
	QRURL         string          `json:"qr_url,omitempty"`
	QRString      string          `json:"qr_string,omitempty"`
	ExpiresAt     time.Time       `json:"expires_at,omitempty"`
}

// OrderQRIS charges a sales order through the Core API and returns the QR for
// the ERP pay dialog to draw itself — the ProcessPOS deferred-QRIS flow for a
// back-office operator. NOT Snap: the B2B buyer needs the hosted page (many
// methods, card + 3DS containment); an operator standing in for a payer needs
// one QR on screen, which is what the till flow already is.
//
// Behaviour on reopen mirrors the till's reality: a still-pending charge is
// re-served from the provider's status probe (same QR, no second charge), a
// settled one answers settled, and a failed/expired one re-charges on a fresh
// suffixed ref — the same suffix shape SnapToken's retry uses so the recovery
// poll can always re-derive the first-issue ref. Settling is NOT done here:
// the dialog's recovery poll (SnapPaymentStatus) books the payment seconds
// later, and the webhook remains authoritative out of band — the same
// division ProcessPOS keeps.
func (s *Service) OrderQRIS(ctx context.Context, orderID int64) (*OrderQRISResult, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, orderID)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	// The same order types and the same settled/cancelled guards SnapToken's
	// staff path enforces — this endpoint is its Core-API sibling.
	if o.OrderType != "ecommerce" && o.OrderType != "regular" {
		return nil, errors.New("order type does not support online payment")
	}
	u := auth.UserFrom(ctx)
	isStaff := u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner || u.Role == auth.RoleSales || u.Role == auth.RoleFinance || u.Role == auth.RoleFinanceHead || u.Role == auth.RoleGM || u.Role == auth.RoleBM || u.Role == auth.RoleSPV || u.Role == auth.RoleStoreManager || u.Can("sales.sales-orders.view") || u.Can("sales.pos.create") || u.Role == auth.RoleCashier)
	if !isStaff && isCashOrder(o.Notes) {
		return nil, ErrPaymentNotAllowed
	}
	if o.Status == "completed" || o.Status == "delivered" || o.Status == "cancelled" || o.Status == "rejected" {
		return nil, ErrPaymentNotAllowed
	}
	if o.PaidAmount.GreaterThanOrEqual(o.TotalAmount) && !o.TotalAmount.IsZero() {
		return nil, ErrPaymentNotAllowed
	}
	if !isStaff && u != nil {
		customerID, err := s.store.CustomerByUserID(ctx, tx, u.ID)
		if err != nil {
			return nil, ErrNotFound
		}
		if o.CustomerID != customerID {
			return nil, ErrNotFound
		}
	}
	inv, err := s.store.OpenInvoiceForOrder(ctx, tx, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			exists, exErr := s.store.InvoiceExistsForSO(ctx, tx, o.ID)
			if exErr != nil {
				return nil, exErr
			}
			if exists || o.Status == "cancelled" || o.Status == "rejected" {
				return nil, fmt.Errorf("order is %s, no open invoice (already paid or settled): %w", o.Status, ErrInvalidStatus)
			}
			invNumber, err := s.orderCode(ctx, seqKeyInvoice, "INV", tx)
			if err != nil {
				return nil, err
			}
			inv = &Invoice{
				InvoiceNumber:   invNumber,
				SalesOrderID:    o.ID,
				CustomerID:      o.CustomerID,
				InvoiceDate:     time.Now().In(s.loc).Format("2006-01-02"),
				DueDate:         time.Now().In(s.loc).Format("2006-01-02"),
				Status:          "unpaid",
				Subtotal:        o.Subtotal,
				TaxAmount:       o.TaxAmount,
				TotalAmount:     o.TotalAmount,
				PaidAmount:      decimal.Zero,
				RemainingAmount: o.TotalAmount,
			}
			inv.ID, err = s.store.CreateInvoice(ctx, tx, createInvoiceParams(inv))
			if err != nil {
				return nil, err
			}
		} else {
			return nil, err
		}
	}
	gross := invoicePayableAmount(inv)
	if !gross.IsPositive() {
		return nil, fmt.Errorf("invoice already fully paid: %w", ErrInvalidStatus)
	}
	canonical := canonicalSnapRef(inv.InvoiceNumber, o.ID)
	ref := ""
	if inv.SnapOrderRef != nil {
		ref = *inv.SnapOrderRef
	}
	if ref != "" {
		// An earlier charge exists for this invoice: ask the provider what
		// became of it before stacking another one. A single direct probe, not
		// snapSettleCandidate — that helper folds every non-settled answer
		// into a reported action and hides the payload OrderQRIS needs to
		// RE-SERVE a live QR.
		status, _, perr := s.fetchSnapStatus(ctx, ref)
		switch {
		case perr != nil && !errors.Is(perr, errSnapRefUnknown):
			// Transport-level failure: the provider's answer about EXISTING
			// money is unknown, so charging again could double-charge. Surface
			// the error; the dialog retries, the sweep still runs.
			return nil, perr
		case perr != nil:
			// The gateway never heard of the ref — the original charge request
			// never landed. Retrying the SAME ref is the recovery: Midtrans
			// only rejects duplicate order_id once a transaction exists.
		case finance.MidtransAction(status.TransactionStatus, status.FraudStatus) == finance.MidtransActionSettled:
			return &OrderQRISResult{OrderStatus: o.Status, PaymentStatus: "settled"}, nil
		case finance.MidtransAction(status.TransactionStatus, status.FraudStatus) == finance.MidtransActionFailed:
			// Expired/denied QRIS is FINAL at the provider: re-charging the
			// same order_id would be rejected, so mint a suffixed ref exactly
			// as SnapToken's retry does.
			ref = fmt.Sprintf("%s-%d", canonical, time.Now().Unix())
		default:
			// Pending: re-serve the live QR. A reopen must not strand the code
			// the operator already showed the payer.
			qr := statusActionURL(status, "generate-qr-code")
			if qr == "" && status.TransactionID != "" {
				base := strings.TrimSuffix(strings.TrimRight(s.midtrans.CoreBaseURL, "/"), "/v2")
				if base == "" {
					base = "https://api.sandbox.midtrans.com"
				}
				qr = base + "/v2/qris/" + status.TransactionID + "/qr-code"
			}
			if qr == "" {
				return nil, errors.New("payment gateway response was incomplete")
			}
			return &OrderQRISResult{
				OrderStatus:   o.Status,
				PaymentStatus: "pending",
				Amount:        snapRoundedGross(gross),
				TransactionID: status.TransactionID,
				QRURL:         qr,
				QRString:      status.QRString,
				ExpiresAt:     parseMidtransExpiry(status.ExpiryTime),
			}, nil
		}
	} else {
		ref = canonical
	}
	// The provider ref is written BEFORE the charge, so a charge that succeeds
	// but whose response is lost still has a row to reconcile against — the
	// same ordering the till flow enforces.
	if err := s.store.SetInvoiceGatewayRef(ctx, tx, inv.ID, ref, nil, nil); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	qris, err := s.requestQrisCharge(ctx, ref, gross)
	if err != nil {
		return nil, err
	}
	// transaction_id and the provider expiry are now known, so stamp them on
	// the row the reconciler reads (same two-step stamp as ProcessPOS).
	expires := qris.ExpiresAt
	var expiresPtr *time.Time
	if !expires.IsZero() {
		expiresPtr = &expires
	}
	txn := qris.TransactionID
	if err := s.store.SetInvoiceGatewayRef(ctx, nil, inv.ID, ref, &txn, expiresPtr); err != nil {
		return nil, err
	}
	return &OrderQRISResult{
		OrderStatus:   o.Status,
		PaymentStatus: "pending",
		Amount:        snapRoundedGross(gross),
		TransactionID: txn,
		QRURL:         qris.QRURL,
		QRString:      qris.QRString,
		ExpiresAt:     expires,
	}, nil
}

// tillPaymentStatus maps the reconciler's action vocabulary onto what a cashier
// needs to hear.
//
// finance.MidtransAction is deliberately reconciler-shaped: every non-terminal
// provider state collapses to "ignored" because there is nothing to settle. That
// is the right answer for a background sweep and the wrong one for a till
// polling every five seconds, where the only question is "keep waiting or give
// up". So:
//   - ignored / pending -> pending, keep waiting
//   - settled          -> settled, the POS already handles it before this runs
//   - failed family    -> failed, offer retry
//   - anything unknown -> pending rather than failed, because guessing "failed"
//     would cancel a payment that may still arrive, and the cancel path
//     restocks.
func tillPaymentStatus(action string) string {
	switch strings.ToLower(strings.TrimSpace(action)) {
	case finance.MidtransActionFailed:
		return "failed"
	case finance.MidtransActionSettled:
		return "settled"
	default:
		return "pending"
	}
}
