package sales

import (
	"context"
	"encoding/json"
	"errors"
	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"io"
	"net/http"
	"sort"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/db"
)

// tag/err let a test drive the raw sqlc writes that go through db.New(q) —
// mirrors the purchasing fake so :execrows row counts are assertable without a
// database. sql records the statement so a test can pin a WHERE predicate the
// fake store never interprets.
type fakeTx struct {
	store     *fakeStore
	tag       pgconn.CommandTag
	err       error
	sql       []string
	commits   int
	rollbacks int
}

func (f *fakeTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
	return &fakeRow{err: pgx.ErrNoRows}
}
func (f *fakeTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
	return nil, nil
}
func (f *fakeTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
	f.sql = append(f.sql, sql)
	return f.tag, f.err
}
func (f *fakeTx) Commit(context.Context) error {
	f.commits++
	return nil
}
func (f *fakeTx) Rollback(context.Context) error {
	f.rollbacks++
	return nil
}

// lastSQL returns the statement of the most recent Exec on this fake tx.
func (f *fakeTx) lastSQL() string {
	if len(f.sql) == 0 {
		return ""
	}
	return f.sql[len(f.sql)-1]
}

type fakeRow struct {
	vals []any
	err  error
}

func (f *fakeRow) Scan(dest ...any) error {
	if f.err != nil {
		return f.err
	}
	for i, d := range dest {
		if p, ok := d.(*any); ok {
			*p = f.vals[i]
		}
	}
	return nil
}

// Stock mirrors the stocks row shape the fake uses for stock assertions.
type Stock struct {
	ID               int64
	ProductID        int64
	WarehouseID      int64
	Quantity         decimal.Decimal
	ReservedQuantity decimal.Decimal
}

type fakeStore struct {
	orders         map[int64]*SalesOrder
	items          map[int64][]*SOItem
	invoices       map[int64]*Invoice
	payments       map[int64]*Payment
	stocks         map[string]*Stock
	customerByUser map[int64]int64
	assignedSales  map[int64]int64 // customerID -> sales user id
	vouchers       map[int64]*fakeVoucher
	voucherErr     error // injected failure for voucher lookups (real error, not ErrNotFound)
	nextID         int64
	// lastPerPage captures the PerPage ListOrders received, so pagination
	// clamp tests can assert what the service passed down (R2-1).
	lastPerPage int
	// returnedByOrder stubs ReturnedQtyByOrder: orderID -> itemID -> qty.
	returnedByOrder map[int64]map[int64]decimal.Decimal

	// snapChecked / snapCheckedQ capture SetInvoiceSnapChecked calls: the
	// invoice ids stamped, and the transaction handle each arrived on.
	snapChecked  []int64
	snapCheckedQ []db.DBTX

	// sweepCandidates is the row set ListSnapSweepCandidates returns; nil means
	// the test is not a sweeper test. See its method.
	sweepCandidates []SnapSweepCandidate

	// txes is every transaction Begin handed out, in order, so a test can
	// assert WHICH transaction committed and which rolled back. The sweeper's
	// retry contract is exactly that claim: a committed probe backs off, a
	// rolled-back one is retried, and only the transaction boundary says which.
	txes []*fakeTx

	// markPaidZeroRows makes MarkInvoicePaid report 0 rows while the invoice is
	// still open — the shape of "a concurrent caller closed this row between the
	// locked read and this write". Without it the fake can only produce that
	// race by pre-marking the invoice paid, which would be caught by the
	// earlier already-settled guard and never reach the backstop under test.
	markPaidZeroRows bool
	IsStoreActiveFn       func(int64) bool
	IsUserInActiveStoreFn func(int64, int64) bool

	// locks records the FOR UPDATE reads in call order ("order" / "invoice"),
	// under mu. The sweeper's anti-deadlock contract is a SEQUENCE, not a set,
	// so a set-shaped assertion could not tell the right order from the wrong
	// one — and the wrong one deadlocks against CancelOrderInvoices in
	// production while passing every other test.
	locks     []string
	lockScope string
	mu        sync.Mutex
}

// noteLock records one locked read.
// noteLock records one locked read under a scope label. A fixture that gets an
// order issued a Snap token performs its own order→invoice locks, so the sweeper
// opens a scope and only its own reads are attributed to it.
func (f *fakeStore) noteLock(scope, kind string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.lockScope != "" {
		f.locks = append(f.locks, f.lockScope+":"+kind)
	}
}

// lockScopeStart opens a lock-attribution scope; lockScopeEnd closes it. Reads
// outside a scope are not attributed, so setup traffic cannot be mistaken for
// the code under test.
func (f *fakeStore) lockScopeStart(scope string) { f.mu.Lock(); f.lockScope = scope; f.mu.Unlock() }
func (f *fakeStore) lockScopeEnd()               { f.mu.Lock(); f.lockScope = ""; f.mu.Unlock() }

// lockSeq returns the locked reads in call order.
func (f *fakeStore) lockSeq() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]string(nil), f.locks...)
}

// fakeVoucher mirrors the sales_vouchers columns the redemption path reads.
type fakeVoucher struct {
	ID        int64
	Number    string
	Amount    decimal.Decimal
	Status    string // available | used | expired
	ExpiredAt pgtype.Date
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		orders:         map[int64]*SalesOrder{},
		items:          map[int64][]*SOItem{},
		invoices:       map[int64]*Invoice{},
		payments:       map[int64]*Payment{},
		stocks:         map[string]*Stock{},
		customerByUser: map[int64]int64{},
		assignedSales:  map[int64]int64{},
		vouchers:       map[int64]*fakeVoucher{},
		nextID:         1,
	}
}

func (f *fakeStore) Begin(context.Context) (Tx, error) {
	tx := &fakeTx{store: f}
	f.txes = append(f.txes, tx)
	return tx, nil
}

func (f *fakeStore) CreateOrder(_ context.Context, _ db.DBTX, p db.CreateSalesOrderParams) (db.CreateSalesOrderRow, error) {
	o := &SalesOrder{
		ID:              f.nextID,
		SoNumber:        p.SoNumber,
		CustomerID:      p.CustomerID,
		WarehouseID:     int8ToPtr(p.WarehouseID),
		SalesID:         int8ToPtr(p.SalesID),
		OrderDate:       pgtzDateToStr(p.OrderDate),
		Status:          p.Status,
		OrderType:       p.OrderType,
		Subtotal:        p.Subtotal,
		TaxAmount:       p.TaxAmount,
		DiscountAmount:  p.DiscountAmount,
		TotalAmount:     p.TotalAmount,
		PaidAmount:      p.PaidAmount,
		RemainingAmount: p.RemainingAmount,
		ShippingCost:    p.ShippingCost,
		ShippingAddress: pgtzTextToStrPtr(p.ShippingAddress),
		Notes:           pgtzTextToStrPtr(p.Notes),
		DeliveryDate:    pgtzDateToStrPtr(p.DeliveryDate),
		CreatedAt:       time.Now(),
		UpdatedAt:       time.Now(),
	}
	f.nextID++
	f.orders[o.ID] = o
	return db.CreateSalesOrderRow{ID: o.ID}, nil
}

func (f *fakeStore) UpdateOrder(_ context.Context, _ db.DBTX, p db.UpdateSalesOrderParams) error {
	o, ok := f.orders[p.ID]
	if !ok {
		return ErrNotFound
	}
	o.SoNumber = p.SoNumber
	o.CustomerID = p.CustomerID
	o.WarehouseID = int8ToPtr(p.WarehouseID)
	o.SalesID = int8ToPtr(p.SalesID)
	o.OrderDate = pgtzDateToStr(p.OrderDate)
	o.Status = p.Status
	o.OrderType = p.OrderType
	o.Subtotal = p.Subtotal
	o.TaxAmount = p.TaxAmount
	o.DiscountAmount = p.DiscountAmount
	o.TotalAmount = p.TotalAmount
	o.PaidAmount = p.PaidAmount
	o.RemainingAmount = p.RemainingAmount
	o.ShippingCost = p.ShippingCost
	o.ShippingAddress = pgtzTextToStrPtr(p.ShippingAddress)
	o.Notes = pgtzTextToStrPtr(p.Notes)
	o.DeliveryDate = pgtzDateToStrPtr(p.DeliveryDate)
	return nil
}

func (f *fakeStore) DeleteOrder(_ context.Context, _ db.DBTX, id int64) error {
	if _, ok := f.orders[id]; !ok {
		return ErrNotFound
	}
	delete(f.orders, id)
	delete(f.items, id)
	return nil
}

func (f *fakeStore) OrderByID(_ context.Context, id int64) (db.GetSalesOrderByIDRow, error) {
	o, ok := f.orders[id]
	if !ok {
		return db.GetSalesOrderByIDRow{}, ErrNotFound
	}
	cp := *o
	return orderToRow(&cp), nil
}

func (f *fakeStore) OrderShiftID(context.Context, db.DBTX, int64) (pgtype.Int8, error) {
	return pgtype.Int8{}, nil
}
func (f *fakeStore) OrderByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.GetSalesOrderByIDForUpdateRow, error) {
	f.noteLock("lock", "order")
	row, err := f.OrderByID(context.Background(), id)
	return db.GetSalesOrderByIDForUpdateRow(row), err
}

func (f *fakeStore) SupervisorsByOrders(context.Context, []int64) (map[int64]OrderSupervisor, error) {
	return map[int64]OrderSupervisor{}, nil
}

func (f *fakeStore) ListOrders(_ context.Context, flt SOFilter) ([]db.ListSalesOrdersRow, int, error) {
	f.lastPerPage = flt.PerPage
	out := []db.ListSalesOrdersRow{}
	for _, o := range f.orders {
		if flt.ExcludeUnapproved && (o.Status == "draft" || o.Status == "pending_approval" || o.Status == "blocked_overdue") {
			continue
		}
		// Mirrors the exclude_pickup SQL clause: the packing list must not
		// show store-pickup orders. Keep in lockstep with ListSalesOrders.
		if flt.ExcludePickup && isPickupOrder(o) {
			continue
		}
		// Mirrors the pickup_only SQL clause: the POS Ambil Toko list must
		// contain ONLY store-pickup orders, filtered before pagination.
		if flt.PickupOnly && !isPickupOrder(o) {
			continue
		}
		if flt.Status != "" {
			match := false
			for _, s := range strings.Split(flt.Status, ",") {
				if o.Status == strings.TrimSpace(s) {
					match = true
					break
				}
			}
			if !match {
				continue
			}
		}
		cp := *o
		r := orderToRow(&cp)
		out = append(out, db.ListSalesOrdersRow{
			ID: r.ID, SoNumber: r.SoNumber, CustomerID: r.CustomerID, WarehouseID: r.WarehouseID,
			SalesID: r.SalesID, StoreID: r.StoreID, OrderDate: r.OrderDate, Status: r.Status,
			OrderType: r.OrderType, SourceType: r.SourceType, Subtotal: r.Subtotal, TaxAmount: r.TaxAmount,
			DiscountAmount: r.DiscountAmount, TotalAmount: r.TotalAmount, PaidAmount: r.PaidAmount,
			RemainingAmount: r.RemainingAmount, ShippingCost: r.ShippingCost, ShippingAddress: r.ShippingAddress,
			Notes: r.Notes, DeliveryDate: r.DeliveryDate, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
			CustomerName: r.CustomerName, SalesName: r.SalesName, WarehouseName: r.WarehouseName,
		})
	}
	if flt.SortBy != "" {
		sort.SliceStable(out, func(i, j int) bool {
			less := false
			switch flt.SortBy {
			case "so_number":
				less = out[i].SoNumber < out[j].SoNumber
			case "customer_name":
				less = out[i].CustomerName.String < out[j].CustomerName.String
			case "sales_name":
				less = out[i].SalesName.String < out[j].SalesName.String
			case "order_date":
				less = out[i].OrderDate < out[j].OrderDate
			case "status":
				less = out[i].Status < out[j].Status
			case "total_amount":
				less = out[i].TotalAmount.LessThan(out[j].TotalAmount)
			default:
				less = out[i].ID < out[j].ID
			}
			if flt.SortDir == "desc" {
				return !less
			}
			return less
		})
	}
	return out, len(out), nil
}

func (f *fakeStore) SalesOrdersSummary(_ context.Context, _ []int64) (SalesOrdersSummary, error) {
	return SalesOrdersSummary{}, nil
}

func (f *fakeStore) MyOrdersSummary(_ context.Context, customerID int64) (db.MyOrdersSummaryRow, error) {
	var row db.MyOrdersSummaryRow
	for _, o := range f.orders {
		if o.CustomerID == customerID && o.OrderType == "ecommerce" {
			switch o.Status {
			case "pending_approval", "draft":
				row.PendingCount++
			case "confirmed", "invoiced":
				if o.PaidAmount.LessThan(o.TotalAmount) {
					row.PendingCount++
				} else {
					row.ProcessingCount++
				}
			case "picking", "paid":
				row.ProcessingCount++
			case "shipped":
				row.ShippingCount++
			case "delivered":
				row.DeliveredCount++
			case "cancelled", "rejected":
				row.CancelledCount++
			}
		}
	}
	return row, nil
}

func (f *fakeStore) CreateItem(_ context.Context, _ db.DBTX, p db.CreateSalesOrderItemParams) (int64, error) {
	it := &SOItem{
		ID:               f.nextID,
		SalesOrderID:     p.SalesOrderID,
		ProductID:        p.ProductID,
		Quantity:         p.Quantity,
		UnitPrice:        p.UnitPrice,
		DiscountPercent:  p.DiscountPercent,
		DiscountAmount:   p.DiscountAmount,
		Subtotal:         p.Subtotal,
		ProductUnitID:    int8ToPtr(p.ProductUnitID),
		UnitID:           int8ToPtr(p.UnitID),
		UnitName:         p.UnitName,
		UnitSymbol:       p.UnitSymbol,
		ConversionFactor: p.ConversionFactor,
		BaseQuantity:     p.BaseQuantity,
		MinPurchase:      p.MinPurchase,
	}
	f.nextID++
	f.items[it.SalesOrderID] = append(f.items[it.SalesOrderID], it)
	return it.ID, nil
}

func (f *fakeStore) ItemsByOrder(_ context.Context, orderID int64) ([]db.ListSalesOrderItemsRow, error) {
	out := []db.ListSalesOrderItemsRow{}
	for _, it := range f.items[orderID] {
		cp := *it
		out = append(out, itemToRow(&cp))
	}
	return out, nil
}

func (f *fakeStore) DeleteItems(_ context.Context, _ db.DBTX, orderID int64) error {
	f.items[orderID] = nil
	return nil
}
func (f *fakeStore) ReturnedQtyByOrder(_ context.Context, orderID int64) (map[int64]decimal.Decimal, error) {
	if f.returnedByOrder != nil {
		if m, ok := f.returnedByOrder[orderID]; ok {
			return m, nil
		}
	}
	return map[int64]decimal.Decimal{}, nil
}

func (f *fakeStore) SetOrderStatus(_ context.Context, _ db.DBTX, id int64, status string) error {
	if o, ok := f.orders[id]; ok {
		o.Status = status
	}
	return nil
}

func (f *fakeStore) UpdateOrderNotes(_ context.Context, _ db.DBTX, id int64, notes pgtype.Text) error {
	o, ok := f.orders[id]
	if !ok {
		return ErrNotFound
	}
	o.Notes = textToStrPtr(notes)
	return nil
}

func (f *fakeStore) SetSalesOrderShift(_ context.Context, _ db.DBTX, _, _ int64) error {
	return nil
}

func (f *fakeStore) LockStockForUpdate(_ context.Context, _ db.DBTX, productID, warehouseID int64) (db.GetSalesStockRowForUpdateRow, error) {
	s, ok := f.stocks[key2(productID, warehouseID)]
	if !ok {
		return db.GetSalesStockRowForUpdateRow{}, ErrNotFound
	}
	return db.GetSalesStockRowForUpdateRow{
		ID:               s.ID,
		ProductID:        s.ProductID,
		WarehouseID:      s.WarehouseID,
		Quantity:         s.Quantity,
		ReservedQuantity: s.ReservedQuantity,
	}, nil
}

func (f *fakeStore) EnsureStockRow(_ context.Context, _ db.DBTX, productID, warehouseID int64) error {
	if _, ok := f.stocks[key2(productID, warehouseID)]; ok {
		return nil
	}
	f.stocks[key2(productID, warehouseID)] = &Stock{ID: f.nextID, ProductID: productID, WarehouseID: warehouseID,
		Quantity: decimal.NewFromInt(1000000)}
	f.nextID++
	return nil
}

func (f *fakeStore) SetStockQuantity(_ context.Context, _ db.DBTX, stockID int64, quantity, reserved decimal.Decimal) error {
	for _, s := range f.stocks {
		if s.ID == stockID {
			s.Quantity = quantity
			s.ReservedQuantity = reserved
			return nil
		}
	}
	return nil
}

func (f *fakeStore) CreateInvoice(_ context.Context, _ db.DBTX, p db.CreateSalesInvoiceParams) (int64, error) {
	inv := &Invoice{
		ID:              f.nextID,
		InvoiceNumber:   p.InvoiceNumber,
		SalesOrderID:    p.SalesOrderID.Int64,
		CustomerID:      p.CustomerID,
		InvoiceDate:     pgtzDateToStr(p.InvoiceDate),
		DueDate:         pgtzDateToStr(p.DueDate),
		Status:          p.Status,
		Subtotal:        p.Subtotal,
		TaxAmount:       p.TaxAmount,
		TotalAmount:     p.TotalAmount,
		PaidAmount:      p.PaidAmount,
		RemainingAmount: p.RemainingAmount,
	}
	f.nextID++
	f.invoices[inv.ID] = inv
	return inv.ID, nil
}

func (f *fakeStore) OpenInvoiceForOrder(_ context.Context, _ db.DBTX, orderID int64) (*Invoice, error) {
	f.noteLock("lock", "invoice-open")
	for _, inv := range f.invoices {
		if inv.SalesOrderID == orderID && (inv.Status == "unpaid" || inv.Status == "partial") {
			c := *inv
			return &c, nil
		}
	}
	return nil, ErrNotFound
}

func (f *fakeStore) InvoiceForOrder(_ context.Context, q db.DBTX, orderID int64) (*Invoice, error) {
	if q != nil {
		f.noteLock("lock", "invoice")
	}
	for _, inv := range f.invoices {
		if inv.SalesOrderID == orderID {
			c := *inv
			return &c, nil
		}
	}
	return nil, ErrNotFound
}

func (f *fakeStore) InvoiceExistsForSO(_ context.Context, _ db.DBTX, soID int64) (bool, error) {
	for _, inv := range f.invoices {
		if inv.SalesOrderID == soID {
			return true, nil
		}
	}
	return false, nil
}

// MarkInvoicePaid mirrors the repository's rows-affected contract: 1 for an
// invoice the fake can settle, 0 for one that is not open, and ErrNotFound for
// a row that does not exist. The count is what the reconciler branches on, so
// the fake must not report 1 for a row the real WHERE clause refuses.
func (f *fakeStore) MarkInvoicePaid(_ context.Context, _ db.DBTX, invoiceID int64, paid decimal.Decimal) (int64, error) {
	inv, ok := f.invoices[invoiceID]
	if !ok {
		return 0, ErrNotFound
	}
	// The concurrent-caller shape: the row is still open as far as the caller's
	// locked read is concerned, but the UPDATE matches nothing.
	if f.markPaidZeroRows {
		return 0, nil
	}
	if inv.Status != "unpaid" && inv.Status != "partial" {
		return 0, nil
	}
	inv.Status, inv.PaidAmount, inv.RemainingAmount = "paid", paid, decimal.Zero
	return 1, nil
}

func (f *fakeStore) SettleOrderPayment(_ context.Context, _ db.DBTX, orderID int64, amount decimal.Decimal) error {
	o, ok := f.orders[orderID]
	if !ok {
		return ErrNotFound
	}
	o.PaidAmount = o.PaidAmount.Add(amount)
	if o.PaidAmount.GreaterThan(o.TotalAmount) {
		o.PaidAmount = o.TotalAmount
	}
	rem := o.RemainingAmount.Sub(amount)
	if rem.IsNegative() {
		rem = decimal.Zero
	}
	o.RemainingAmount = rem
	// Mirrors SettleOrderPayment in repository.go: pending_approval is NOT in
	// the advance list — a settled Midtrans payment records the money but does
	// not accept the order. Keep this in lockstep with the SQL CASE.
	if (o.Status == "draft" || o.Status == "confirmed") && o.RemainingAmount.IsZero() {
		o.Status = "invoiced"
	}
	return nil
}

func (f *fakeStore) SetInvoiceSnapRef(_ context.Context, _ db.DBTX, invoiceID int64, ref string) error {
	inv, ok := f.invoices[invoiceID]
	if !ok {
		return ErrNotFound
	}
	inv.SnapOrderRef = &ref
	return nil
}

// SetInvoiceGatewayRef mirrors the real statement's COALESCE semantics: a nil
// argument leaves the column alone rather than clearing it, which is what makes
// the two-call pattern in ProcessPOS (stamp the ref, charge, stamp the
// transaction id) work.
func (f *fakeStore) SetInvoiceGatewayRef(_ context.Context, _ db.DBTX, invoiceID int64, ref string, transactionID *string, expiresAt *time.Time) error {
	inv, ok := f.invoices[invoiceID]
	if !ok {
		return ErrNotFound
	}
	inv.SnapOrderRef = &ref
	if transactionID != nil {
		inv.GatewayTransactionID = transactionID
	}
	if expiresAt != nil {
		inv.GatewayExpiresAt = *expiresAt
	}
	return nil
}

// snapChecked records the reconciler's probe stamps, and snapCheckedQ the
// transaction handle each one arrived on: the stamp MUST ride the caller's
// transaction so it commits or rolls back with the settle attempt.
func (f *fakeStore) SetInvoiceSnapChecked(_ context.Context, q db.DBTX, invoiceID int64) error {
	inv, ok := f.invoices[invoiceID]
	if !ok {
		return ErrNotFound
	}
	f.snapChecked = append(f.snapChecked, inv.ID)
	f.snapCheckedQ = append(f.snapCheckedQ, q)
	inv.SnapLastCheckedAt = time.Now()
	return nil
}

// sweepCandidates is the set ListSnapSweepCandidates hands back. A nil set
// means "not a sweeper test": ListSnapSweepCandidates then fails loudly, so a
// caller depending on it cannot pass by accident.
//
// The PREDICATE that decides these rows is SQL and is proved against a real
// PostgreSQL in repository_snap_sweep_db_test.go. What the fake is for is the
// part the SQL cannot express: what the service DOES with a selected row, in
// what order, and what it writes. seedSweepCandidates builds the selection
// from the fake's own state, so a test never has to restate a candidate list
// that could drift from the rows it actually seeded.
func (f *fakeStore) ListSnapSweepCandidates(_ context.Context, _ int) ([]SnapSweepCandidate, error) {
	if f.sweepCandidates == nil {
		return nil, errors.New("fakeStore.ListSnapSweepCandidates: no candidates seeded; the candidate predicate is database-proved")
	}
	return f.sweepCandidates, nil
}

// seedSweepCandidates makes the fake select the named invoices, which is the
// shape the SQL predicate returns. Every id must name a real invoice so a typo
// fails loudly instead of quietly selecting nothing; a test that needs a stale
// selection (a row deleted between the select and the pass) writes
// f.sweepCandidates directly.
func (f *fakeStore) seedSweepCandidates(ids ...int64) {
	out := make([]SnapSweepCandidate, 0, len(ids))
	for _, id := range ids {
		inv, ok := f.invoices[id]
		if !ok {
			panic("seedSweepCandidates: no invoice " + itoa2(id))
		}
		out = append(out, SnapSweepCandidate{
			ID:            inv.ID,
			InvoiceNumber: inv.InvoiceNumber,
			SalesOrderID:  inv.SalesOrderID,
		})
	}
	f.sweepCandidates = out
}

func (f *fakeStore) InvoiceBySnapRef(_ context.Context, _ db.DBTX, ref string) (*Invoice, error) {
	for _, inv := range f.invoices {
		if inv.SnapOrderRef != nil && (*inv.SnapOrderRef == ref || strings.HasPrefix(*inv.SnapOrderRef, ref+"-")) {
			c := *inv
			return &c, nil
		}
	}
	return nil, ErrNotFound
}
func (f *fakeStore) CancelOrderInvoices(_ context.Context, _ db.DBTX, orderID int64) error {
	for _, inv := range f.invoices {
		if inv.SalesOrderID == orderID && inv.Status != "paid" {
			inv.Status, inv.RemainingAmount = "cancelled", decimal.Zero
		}
	}
	return nil
}

func (f *fakeStore) CreatePayment(_ context.Context, _ db.DBTX, p db.CreateSalesPaymentParams) (int64, error) {
	pay := &Payment{
		ID:            f.nextID,
		PaymentNumber: p.PaymentNumber,
		PayableType:   p.PayableType,
		PayableID:     p.PayableID,
		PaymentDate:   pgtzDateToStr(p.PaymentDate),
		Amount:        p.Amount,
		PaymentMethod: p.PaymentMethod,
		Notes:         pgtzTextToStrPtr(p.Notes),
		CreatedBy:     int8ToPtr(p.CreatedBy),
	}
	f.nextID++
	f.payments[pay.ID] = pay
	return pay.ID, nil
}

func (f *fakeStore) CreateGatewayPayment(ctx context.Context, q db.DBTX, p db.CreateSalesPaymentParams, gateway, txnID, status string) (int64, error) {
	return f.CreatePayment(ctx, q, p)
}

func (f *fakeStore) CustomerExists(context.Context, db.DBTX, int64) (bool, error) { return true, nil }
func (f *fakeStore) ProductExists(context.Context, db.DBTX, int64) (bool, error)  { return true, nil }
func (f *fakeStore) FirstActiveWarehouse(context.Context, db.DBTX) (int64, error) { return 1, nil }
func (f *fakeStore) WarehouseForStore(_ context.Context, _ db.DBTX, storeID int64) (int64, error) {
	return 100 + storeID, nil
}
func (f *fakeStore) FirstActiveStore(context.Context, db.DBTX) (int64, int64, bool, error) {
	return 0, 0, false, nil
}

var fakePrices = map[int64]string{1: "100", 2: "50"}

func (f *fakeStore) ProductSellingPrice(_ context.Context, _ db.DBTX, id int64) (decimal.Decimal, error) {
	if p, ok := fakePrices[id]; ok {
		return decimal.RequireFromString(p), nil
	}
	return decimal.Zero, ErrNotFound
}

func (f *fakeStore) CheckStockAvailable(_ context.Context, _ db.DBTX, productID, warehouseID int64, qty decimal.Decimal) (bool, error) {
	s, ok := f.stocks[key2(productID, warehouseID)]
	if !ok {
		return false, nil
	}
	return s.Quantity.Sub(s.ReservedQuantity).GreaterThanOrEqual(qty), nil
}

func (f *fakeStore) CustomerByUserID(_ context.Context, _ db.DBTX, userID int64) (int64, error) {
	if id, ok := f.customerByUser[userID]; ok {
		return id, nil
	}
	return 0, ErrNotFound
}

func (f *fakeStore) CustomerAssignedSalesID(_ context.Context, _ db.DBTX, customerID int64) (*int64, error) {
	if id, ok := f.assignedSales[customerID]; ok {
		return &id, nil
	}
	return nil, nil
}

func (f *fakeStore) ValidateCheckoutWarehouse(context.Context, db.DBTX, int64) error { return nil }

func (f *fakeStore) CreateCustomerForUser(_ context.Context, _ db.DBTX, code, name, email string, userID int64) (int64, error) {
	if id, ok := f.customerByUser[userID]; ok {
		return id, nil
	}
	id := f.nextID
	f.nextID++
	f.customerByUser[userID] = id
	return id, nil
}

func (f *fakeStore) WalkInCustomerID(_ context.Context, _ db.DBTX) (int64, error) {
	return 1, nil
}

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

func (f *fakeStore) voucherByNumber(number string) *fakeVoucher {
	for _, v := range f.vouchers {
		if v.Number == number {
			return v
		}
	}
	return nil
}

func (f *fakeStore) CreateSalesVoucher(_ context.Context, _ db.DBTX, p db.CreateSalesVoucherParams) (db.CreateSalesVoucherRow, error) {
	if f.vouchers == nil {
		f.vouchers = make(map[int64]*fakeVoucher)
	}
	id := int64(len(f.vouchers) + 1)
	f.vouchers[id] = &fakeVoucher{
		ID:        id,
		Number:    p.VoucherNumber,
		Amount:    p.Amount,
		Status:    p.Status,
		ExpiredAt: p.ExpiredAt,
	}
	vDateStr := ""
	if p.VoucherDate.Valid {
		vDateStr = p.VoucherDate.Time.Format("2006-01-02")
	}
	return db.CreateSalesVoucherRow{
		ID:            id,
		VoucherNumber: p.VoucherNumber,
		Type:          p.Type,
		Description:   p.Description,
		VoucherDate:   vDateStr,
		Amount:        p.Amount,
		Status:        p.Status,
		CreatedBy:     p.CreatedBy,
		CreatedAt:     tsToPg(time.Now()),
		UpdatedAt:     tsToPg(time.Now()),
	}, nil
}
func (f *fakeStore) GetSalesVoucherByID(_ context.Context, _ db.DBTX, id int64) (db.GetSalesVoucherByIDRow, error) {
	v, ok := f.vouchers[id]
	if !ok {
		return db.GetSalesVoucherByIDRow{}, ErrNotFound
	}
	return db.GetSalesVoucherByIDRow{
		ID: v.ID, VoucherNumber: v.Number, Type: "sales_voucher",
		Amount: v.Amount, Status: v.Status, ExpiredAt: v.ExpiredAt,
		CreatedAt: tsToPg(time.Now()), UpdatedAt: tsToPg(time.Now()),
	}, nil
}
func (f *fakeStore) GetSalesVoucherByNumber(_ context.Context, _ db.DBTX, number string) (db.GetSalesVoucherByNumberRow, error) {
	// Lets a test inject a real failure (not "not found") so the handler's
	// unknown-code branch cannot swallow genuine errors.
	if f.voucherErr != nil {
		return db.GetSalesVoucherByNumberRow{}, f.voucherErr
	}
	v := f.voucherByNumber(number)
	if v == nil {
		return db.GetSalesVoucherByNumberRow{}, ErrNotFound
	}
	row, err := f.GetSalesVoucherByID(context.Background(), nil, v.ID)
	return db.GetSalesVoucherByNumberRow(row), err
}
func (f *fakeStore) GetSalesVoucherByNumberForUpdate(_ context.Context, _ db.DBTX, number string) (db.GetSalesVoucherByNumberForUpdateRow, error) {
	row, err := f.GetSalesVoucherByNumber(context.Background(), nil, number)
	return db.GetSalesVoucherByNumberForUpdateRow(row), err
}
func (f *fakeStore) GetSalesVoucherByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.GetSalesVoucherByIDForUpdateRow, error) {
	row, err := f.GetSalesVoucherByID(context.Background(), nil, id)
	return db.GetSalesVoucherByIDForUpdateRow(row), err
}
func (f *fakeStore) UpdateSalesVoucherStatus(_ context.Context, _ db.DBTX, id int64, status string) error {
	v, ok := f.vouchers[id]
	if !ok {
		return ErrNotFound
	}
	v.Status = status
	return nil
}
func (f *fakeStore) ListSalesVouchers(_ context.Context, _ db.DBTX, _ db.ListSalesVouchersParams) ([]db.ListSalesVouchersRow, error) {
	return nil, nil
}
func (f *fakeStore) CountSalesVouchers(_ context.Context, _ db.DBTX, _ db.CountSalesVouchersParams) (int64, error) {
	return 0, nil
}

// --- sales returns (unused by these tests) ----------------------------------

func (f *fakeStore) CreateSalesReturn(_ context.Context, _ db.DBTX, p db.CreateSalesReturnParams) (db.CreateSalesReturnRow, error) {
	f.nextID++
	return db.CreateSalesReturnRow{
		ID:           f.nextID,
		ReturnNumber: p.ReturnNumber,
		SalesOrderID: p.SalesOrderID,
		CustomerID:   p.CustomerID,
		WarehouseID:  p.WarehouseID,
		RefundAmount: p.RefundAmount,
		RefundMethod: p.RefundMethod,
		Status:       p.Status,
		Reason:       p.Reason,
		CreatedBy:    p.CreatedBy,
		CreatedAt:    pgtype.Timestamptz{Time: time.Now(), Valid: true},
		UpdatedAt:    pgtype.Timestamptz{Time: time.Now(), Valid: true},
	}, nil
}
func (f *fakeStore) GetSalesReturnByID(_ context.Context, _ db.DBTX, _ int64) (db.GetSalesReturnByIDRow, error) {
	return db.GetSalesReturnByIDRow{}, ErrNotFound
}
func (f *fakeStore) ListSalesReturns(_ context.Context, _ db.DBTX, _ db.ListSalesReturnsParams) ([]db.ListSalesReturnsRow, error) {
	return nil, nil
}
func (f *fakeStore) CountSalesReturns(_ context.Context, _ db.DBTX, _ db.CountSalesReturnsParams) (int64, error) {
	return 0, nil
}
func (f *fakeStore) CreateSalesReturnItem(_ context.Context, _ db.DBTX, p db.CreateSalesReturnItemParams) (db.SalesReturnItem, error) {
	f.nextID++
	return db.SalesReturnItem{
		ID:               f.nextID,
		SalesReturnID:    p.SalesReturnID,
		SalesOrderItemID: p.SalesOrderItemID,
		ProductID:        p.ProductID,
		Quantity:         p.Quantity,
		UnitPrice:        p.UnitPrice,
		RefundAmount:     p.RefundAmount,
		Reason:           p.Reason,
	}, nil
}
func (f *fakeStore) SetSalesReturnItemCondition(_ context.Context, _ db.DBTX, p db.SetSalesReturnItemConditionParams) (db.SalesReturnItem, error) {
	return db.SalesReturnItem{ID: p.ID, Condition: p.Condition, Restocked: p.Restocked}, nil
}
func (f *fakeStore) ListSalesReturnItems(_ context.Context, _ db.DBTX, _ int64) ([]db.ListSalesReturnItemsRow, error) {
	return nil, nil
}
func (f *fakeStore) SumReturnedQtyByOrderItem(_ context.Context, _ db.DBTX, _ int64) (decimal.Decimal, error) {
	return decimal.Zero, nil
}
func (f *fakeStore) ListSupervisors(_ context.Context) ([]SupervisorOption, error) {
	return []SupervisorOption{
		{ID: 14, Name: "Eko Wahyudi (SPV)", Role: "spv", RoleLabel: "Supervisor Sales"},
	}, nil
}

// --- fake row <-> view helpers ----------------------------------------------

func orderToRow(o *SalesOrder) db.GetSalesOrderByIDRow {
	return db.GetSalesOrderByIDRow{
		ID:               o.ID,
		SoNumber:         o.SoNumber,
		CustomerID:       o.CustomerID,
		WarehouseID:      int8OrNil(o.WarehouseID),
		SalesID:          int8OrNil(o.SalesID),
		StoreID:          int8OrNil(o.StoreID),
		OrderDate:        o.OrderDate,
		Status:           o.Status,
		OrderType:        o.OrderType,
		PaymentTerms:     o.PaymentTerms,
		Subtotal:         o.Subtotal,
		TaxAmount:        o.TaxAmount,
		DiscountAmount:   o.DiscountAmount,
		TotalAmount:      o.TotalAmount,
		PaidAmount:       o.PaidAmount,
		RemainingAmount:  o.RemainingAmount,
		ShippingCost:     o.ShippingCost,
		ShippingAddress:  textOrNilStr(o.ShippingAddress),
		ReceivedProofUrl: textOrNilStr(o.ReceivedProofURL),
		Notes:            textOrNilStr(o.Notes),
		DeliveryDate:     dateOrNilStr(o.DeliveryDate),
		CreatedAt:        tsToPg(o.CreatedAt),
		UpdatedAt:        tsToPg(o.UpdatedAt),
	}
}

func itemToRow(it *SOItem) db.ListSalesOrderItemsRow {
	return db.ListSalesOrderItemsRow{
		ID:               it.ID,
		SalesOrderID:     it.SalesOrderID,
		ProductID:        it.ProductID,
		Quantity:         it.Quantity,
		UnitPrice:        it.UnitPrice,
		DiscountPercent:  it.DiscountPercent,
		DiscountAmount:   it.DiscountAmount,
		Subtotal:         it.Subtotal,
		ProductUnitID:    int8OrNil(it.ProductUnitID),
		UnitID:           int8OrNil(it.UnitID),
		UnitName:         it.UnitName,
		UnitSymbol:       it.UnitSymbol,
		ConversionFactor: snapshotFactor(it.ConversionFactor),
		BaseQuantity:     it.baseQty(),
		MinPurchase:      snapshotFactor(it.MinPurchase),
		Code:             it.ProductCode,
		Name:             it.ProductName,
	}
}

func tsToPg(t time.Time) pgtype.Timestamptz {
	return pgtype.Timestamptz{Time: t, Valid: true}
}

func pgtzDateToStr(v pgtype.Date) string {
	if !v.Valid {
		return ""
	}
	return v.Time.Format("2006-01-02")
}

func pgtzDateToStrPtr(v pgtype.Date) *string {
	if !v.Valid {
		return nil
	}
	s := v.Time.Format("2006-01-02")
	return &s
}

func pgtzTextToStrPtr(v pgtype.Text) *string {
	if !v.Valid {
		return nil
	}
	s := v.String
	return &s
}

func key2(p, w int64) string { return itoa2(p) + ":" + itoa2(w) }
func itoa2(n int64) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

type fakeSeq struct{ n int64 }

func (f *fakeSeq) Sequence(context.Context, string, time.Time, any) (int64, error) {
	f.n++
	return f.n, nil
}

func svc() *Service { return NewService(newFakeStore(), &fakeSeq{}, time.UTC) }

type failSeq struct{}

func (failSeq) Sequence(context.Context, string, time.Time, any) (int64, error) {
	return 0, errors.New("sequence unavailable")
}

// Create must abort when the SO sequence errors — an empty order number must
// never be stored (Phase 3 gate). Stock must not be reserved either.
func TestCreate_SequenceErrorAborts(t *testing.T) {
	s := NewService(newFakeStore(), failSeq{}, time.UTC)
	_, err := s.Create(context.Background(), SOInput{
		CustomerID: 1,
		OrderDate:  "2026-08-11",
		Items:      []SOItemInput{in2(1, "2", "100")},
	})
	if err == nil {
		t.Fatal("expected sequence error, got nil")
	}
	if !strings.Contains(err.Error(), "SO number sequence") {
		t.Errorf("error = %v, want SO number sequence error", err)
	}
	fs := s.store.(*fakeStore)
	if len(fs.orders) != 0 {
		t.Errorf("order stored despite sequence failure: %d orders", len(fs.orders))
	}
	if st := fs.stocks["1:1"]; st != nil && st.Quantity.String() != "10" {
		t.Errorf("stock changed despite failure: %v", st)
	}
}

func in2(pid int64, qty string, price string) SOItemInput {
	return SOItemInput{ProductID: pid, Quantity: decimal.RequireFromString(qty), UnitPrice: decimal.RequireFromString(price)}
}

func TestCreate_ReservesStockAndComputesTotals(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID:   1,
		OrderDate:    "2026-08-11",
		TaxPercent:   decimal.NewFromInt(10),
		ShippingCost: decimal.NewFromInt(5),
		Items:        []SOItemInput{in2(1, "2", "100"), in2(2, "1", "50")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "draft" {
		t.Errorf("status = %s, want draft", o.Status)
	}
	if o.SoNumber[:2] != "SO" {
		t.Errorf("so_number = %s, want SO prefix", o.SoNumber)
	}
	// subtotal = 2*100 + 1*50 = 250; tax = 25; total = 250+25+5 = 280
	if o.Subtotal.String() != "250" {
		t.Errorf("subtotal = %s, want 250", o.Subtotal)
	}
	if o.TotalAmount.String() != "280" {
		t.Errorf("total = %s, want 280", o.TotalAmount)
	}
	st := s.store.(*fakeStore).stocks["1:1"]
	if st.ReservedQuantity.String() != "2" {
		t.Errorf("reserved p1 = %s, want 2", st.ReservedQuantity)
	}
}

func TestCreate_ResolvesCreatorEmployeeAsSalesID(t *testing.T) {
	s := svc().WithEmployeeLookup(func(_ context.Context, userID int64) (int64, error) {
		if userID == 42 {
			return 7, nil
		}
		return 0, errors.New("no linked employee")
	})
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 42, Name: "Budi"}, 1)
	o, err := s.Create(ctx, SOInput{
		CustomerID: 1,
		OrderDate:  "2026-08-11",
		Items:      []SOItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.SalesID == nil || *o.SalesID != 7 {
		t.Errorf("sales_id = %v, want 7 (linked employees row, not user id 42)", o.SalesID)
	}
}

func TestCreate_NewSalesUserWithoutEmployeeRowSavesWithNullSalesID(t *testing.T) {
	// Regression: sales_id FKs employees(id), so a user without a linked
	// employees row must get NULL — never the raw user id (23503) and never
	// another employee's id. This is the sitiaminah case: brand new sales
	// account, order failed with "A referenced record does not exist."
	s := svc().WithEmployeeLookup(func(_ context.Context, _ int64) (int64, error) {
		return 0, errors.New("no linked employee")
	})
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 99, Name: "Siti"}, 1)
	o, err := s.Create(ctx, SOInput{
		CustomerID: 1,
		OrderDate:  "2026-08-11",
		Items:      []SOItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.SalesID != nil {
		t.Errorf("sales_id = %v, want NULL (no linked employee row)", *o.SalesID)
	}
}

func TestCreate_LineDiscount(t *testing.T) {
	s := svc()
	li := in2(1, "4", "100")
	li.DiscountPercent = decimal.NewFromInt(25) // 25% -> disc 100, sub 300
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{li},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Subtotal.String() != "300" {
		t.Errorf("subtotal = %s, want 300", o.Subtotal)
	}
	if o.TotalAmount.String() != "300" {
		t.Errorf("total = %s, want 300", o.TotalAmount)
	}
	it := s.store.(*fakeStore).items[o.ID][0]
	if it.DiscountAmount.String() != "100" {
		t.Errorf("line discount = %s, want 100", it.DiscountAmount)
	}
}

func TestCreate_Validation(t *testing.T) {
	s := svc()
	// Walk-in (CustomerID 0) resolves to the seeded Pelanggan Umum.
	o, err := s.Create(context.Background(), SOInput{OrderDate: "2026-08-11", Items: []SOItemInput{in2(1, "1", "10")}})
	if err != nil {
		t.Fatal(err)
	}
	if o.CustomerID != 1 {
		t.Errorf("walk-in customer = %d, want 1", o.CustomerID)
	}
	_, err = s.Create(context.Background(), SOInput{CustomerID: 1, OrderDate: "2026-08-11", Items: []SOItemInput{}})
	if !errors.Is(err, ErrItemRequired) {
		t.Errorf("got %v, want ErrItemRequired", err)
	}
	_, err = s.Create(context.Background(), SOInput{CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{{ProductID: 1, Quantity: decimal.Zero, UnitPrice: decimal.NewFromInt(10)}}})
	if err == nil {
		t.Fatal("want error: quantity must be >= 1")
	}
}

func TestCreate_DefaultsOrderDateToToday(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{Items: []SOItemInput{in2(1, "1", "10")}})
	if err != nil {
		t.Fatal(err)
	}
	if want := time.Now().In(time.UTC).Format("2006-01-02"); o.OrderDate != want {
		t.Errorf("order_date = %q, want %q", o.OrderDate, want)
	}
}

func TestUpdate_ReleasesAndRecomputes(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "2", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore).stocks["1:1"]
	if st.ReservedQuantity.String() != "2" {
		t.Fatalf("reserved before update = %s, want 2", st.ReservedQuantity)
	}
	upd, err := s.Update(context.Background(), o.ID, SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "3", "100"), in2(2, "2", "50")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if st.ReservedQuantity.String() != "3" {
		t.Errorf("reserved after update = %s, want 3", st.ReservedQuantity)
	}
	if st2 := s.store.(*fakeStore).stocks["2:1"]; st2.ReservedQuantity.String() != "2" {
		t.Errorf("reserved p2 = %s, want 2", st2.ReservedQuantity)
	}
	if upd.Subtotal.String() != "400" {
		t.Errorf("subtotal = %s, want 400", upd.Subtotal)
	}
}

func TestConfirm_OnlyFromDraft(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "10")},
	})
	if err != nil {
		t.Fatal(err)
	}
	c, err := s.Confirm(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if c.Status != "confirmed" {
		t.Errorf("status = %s, want confirmed", c.Status)
	}
	if _, err := s.Confirm(context.Background(), o.ID); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("second confirm got %v, want ErrInvalidStatus", err)
	}
}

func TestCancel_ReleasesReservation(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "2", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	c, err := s.Cancel(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if c.Status != "cancelled" {
		t.Errorf("status = %s, want cancelled", c.Status)
	}
	if st := s.store.(*fakeStore).stocks["1:1"]; st.ReservedQuantity.String() != "0" {
		t.Errorf("reserved after cancel = %s, want 0", st.ReservedQuantity)
	}
}

func TestUpdateNotes_ReplacesAndClears(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "10")},
	})
	if err != nil {
		t.Fatal(err)
	}
	upd, err := s.UpdateNotes(context.Background(), o.ID, strPtr("leave at front desk"))
	if err != nil {
		t.Fatal(err)
	}
	if upd.Notes == nil || *upd.Notes != "leave at front desk" {
		t.Errorf("notes = %v, want 'leave at front desk'", upd.Notes)
	}
	// Empty string clears.
	cleared, err := s.UpdateNotes(context.Background(), o.ID, strPtr(""))
	if err != nil {
		t.Fatal(err)
	}
	if cleared.Notes == nil || *cleared.Notes != "" {
		t.Errorf("notes = %v, want empty string", cleared.Notes)
	}
	// Null clears too (nil pointer).
	nulled, err := s.UpdateNotes(context.Background(), o.ID, nil)
	if err != nil {
		t.Fatal(err)
	}
	if nulled.Notes != nil {
		t.Errorf("notes = %v, want nil", nulled.Notes)
	}
}

func TestUpdateNotes_RejectsCancelled(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "10")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Cancel(context.Background(), o.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := s.UpdateNotes(context.Background(), o.ID, strPtr("x")); !errors.Is(err, ErrCancelled) {
		t.Errorf("got %v, want ErrCancelled", err)
	}
}

func TestUpdateNotes_MissingOrder(t *testing.T) {
	s := svc()
	if _, err := s.UpdateNotes(context.Background(), 999, strPtr("x")); !errors.Is(err, ErrNotFound) {
		t.Errorf("got %v, want ErrNotFound", err)
	}
}

func TestDelete_DraftOnly(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "10")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Confirm(context.Background(), o.ID); err != nil {
		t.Fatal(err)
	}
	if err := s.Delete(context.Background(), o.ID); !errors.Is(err, ErrCannotModify) {
		t.Errorf("delete confirmed got %v, want ErrCannotModify", err)
	}
	if err := s.Delete(context.Background(), o.ID+1); !errors.Is(err, ErrNotFound) {
		t.Errorf("delete missing got %v, want ErrNotFound", err)
	}
}

func TestProcessPOS_DeductsAndInvoices(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, inv, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(100)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "invoiced" {
		t.Errorf("status = %s, want invoiced", o.Status)
	}
	if o.OrderType != "pos" {
		t.Errorf("order_type = %s, want pos", o.OrderType)
	}
	if !strings.HasPrefix(o.SoNumber, "TRX-") {
		t.Errorf("so_number = %s, want TRX- prefix (§17: POS orders use TRX-YYYYMM-000001)", o.SoNumber)
	}
	// §16.1 authoritative pricing: client unit_price 100 is ignored, selling
	// price snapshot (fakePrices[1]=100) is used; stock 10 -> 8.
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Errorf("stock qty = %s, want 8", st.Quantity)
	}
	if inv == nil {
		t.Fatal("invoice not created")
	}
	if inv.TotalAmount.String() != "200" {
		t.Errorf("invoice total = %s, want 200", inv.TotalAmount)
	}
}

// Flow-audit item 4 (2 Nov): a POS sale can be handed to a courier. Delivery
// mode must carry the address into shipping_address (so the warehouse DO flow
// and the ERP detail see where to go) while pickup keeps it NULL (the till IS
// the handover).
func TestProcessPOS_DeliveryModeStampsAddress(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:      1,
		WarehouseID:     1,
		PaymentMethod:   "cash",
		FulfillmentMode: "delivery",
		ShippingAddress: strPtr("Jl. Pengantaran No. 7, Medan"),
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(100)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.ShippingAddress == nil || *o.ShippingAddress != "Jl. Pengantaran No. 7, Medan" {
		t.Errorf("shipping_address = %v, want the courier address", o.ShippingAddress)
	}
	if isPickupOrder(o) {
		t.Error("a delivery order must not read as pickup (DO flow would be skipped)")
	}
}

func TestProcessPOS_PickupModeKeepsAddressEmpty(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(100)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.ShippingAddress != nil {
		t.Errorf("pickup shipping_address = %v, want nil (till handover needs no paperwork)", *o.ShippingAddress)
	}
}

func TestProcessPOS_DeliveryRequiresAddress(t *testing.T) {
	s := svc()
	if _, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:      1,
		WarehouseID:     1,
		PaymentMethod:   "cash",
		FulfillmentMode: "delivery",
		// no ShippingAddress
		Items: []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(100)}},
	}); err == nil || !strings.Contains(err.Error(), "alamat pengantaran") {
		t.Fatalf("err = %v, want the address-required validation", err)
	}
}

func TestProcessPOS_UnknownModeRejected(t *testing.T) {
	s := svc()
	if _, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:      1,
		WarehouseID:     1,
		PaymentMethod:   "cash",
		FulfillmentMode: "drone",
		Items:           []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(100)}},
	}); err == nil || !strings.Contains(err.Error(), "pickup or delivery") {
		t.Fatalf("err = %v, want the mode validation", err)
	}
}

// POS QRIS defers payment: unpaid order + unpaid invoice, no payment row,
// stock still deducted (cancel restores), Snap asked QRIS-only with expiry.
func TestProcessPOS_QrisDefersPayment(t *testing.T) {
	s := svc()
	st := snapSvcWithStub(t, s)
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, inv, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "qris",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(100)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "confirmed" {
		t.Errorf("status = %s, want confirmed (unpaid, cancellable)", o.Status)
	}
	if !o.PaidAmount.IsZero() || !o.RemainingAmount.Equal(decimal.NewFromInt(200)) {
		t.Errorf("paid/remaining = %s/%s, want 0/200", o.PaidAmount, o.RemainingAmount)
	}
	if inv == nil || inv.Status != "unpaid" {
		t.Fatalf("invoice = %+v, want unpaid", inv)
	}
	for _, p := range fs.payments {
		if p.PayableID == inv.ID {
			t.Errorf("payment row written for unpaid QRIS order (payable %d)", inv.ID)
		}
	}
	if st.calls != 1 {
		t.Fatalf("gateway calls = %d, want 1", st.calls)
	}
	var raw map[string]json.RawMessage
	if err := json.Unmarshal(st.lastBody, &raw); err != nil {
		t.Fatal(err)
	}

	// POS QRIS charges the Core API, not Snap. This asserts the product split
	// rather than the payload: the till needs a QR to draw, and Snap can only
	// offer a page, which is the whole reason it moved.
	if got := string(raw["payment_type"]); got != `"qris"` {
		t.Errorf("payment_type = %s, want \"qris\"", got)
	}
	if st.lastSnapPath != "" {
		t.Errorf("POS QRIS called the Snap endpoint (%s); it must charge the Core API", st.lastSnapPath)
	}
	if st.lastChargePath != "/v2/charge" {
		t.Errorf("charge path = %q, want /v2/charge", st.lastChargePath)
	}

	// No custom_expiry. The provider default is 15 minutes and the POS guide is
	// explicit that going below it is unreliable because Midtrans' expiry
	// scheduler only reliably expires 15+ minute transactions. Asking for a
	// 15-minute window on a QR the customer is still walking up to is worse
	// than the default.
	if v, present := raw["custom_expiry"]; present {
		t.Errorf("custom_expiry = %s, want absent — the 15-minute provider default is the floor", v)
	}

	// The till gets a QR, not a page. Asserting o.Snap is nil matters as much
	// as asserting o.Qris is populated: a Snap result here would mean the till
	// silently fell back to embedding a page it cannot lay out.
	if o.Snap != nil {
		t.Errorf("snap = %+v, want nil on the POS path", o.Snap)
	}
	if o.Qris == nil {
		t.Fatal("qris = nil, want the QR the till draws")
	}
	if o.Qris.QRURL == "" || o.Qris.TransactionID == "" {
		t.Errorf("qris = %+v, want a transaction id and a QR image URL", o.Qris)
	}
	// The deadline the cashier counts down is the PROVIDER's, not a local 15
	// minutes. The stub returns expiry_time in the real shape — no offset —
	// and this asserts it survived parsing as Asia/Jakarta rather than as UTC,
	// which would show a countdown seven hours long.
	want := time.Date(2026, 10, 1, 1, 30, 0, 0, time.FixedZone("WIB", 7*60*60))
	if !o.Qris.ExpiresAt.Equal(want) {
		t.Errorf("expires_at = %s, want %s (provider time in Asia/Jakarta)", o.Qris.ExpiresAt, want)
	}

	if got := fs.stocks["1:1"].Quantity.String(); got != "8" {
		t.Errorf("stock qty = %s, want 8 (deducted at creation)", got)
	}
}

func TestProcessPOS_QrisWithoutGatewayFailsClosed(t *testing.T) {
	s := svc() // no Midtrans wired
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	_, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID: 1, WarehouseID: 1, PaymentMethod: "qris",
		Items: []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	})
	if !errors.Is(err, ErrGatewayDisabled) {
		t.Fatalf("err = %v, want ErrGatewayDisabled", err)
	}
	if got := fs.stocks["1:1"].Quantity.String(); got != "10" {
		t.Errorf("stock qty = %s, want 10 (nothing moved)", got)
	}
	if len(fs.orders) != 0 {
		t.Errorf("orders = %d, want 0 (failed before any write)", len(fs.orders))
	}
}

func TestSnapPOSStatus_SettlesCapturedQris(t *testing.T) {
	s := svc()
	snapSvcWithStub(t, s)
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID: 1, WarehouseID: 1, PaymentMethod: "qris",
		Items: []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	// Swap the token stub for a settlement status stub.
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", SnapBaseURL: "http://example.test/snap", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: &statusStubRT{status: "settlement"}})
	out, err := s.SnapPOSStatus(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "settled" {
		t.Errorf("payment_status = %q, want settled", out.PaymentStatus)
	}
}

// A bound outlet deducts from ITS OWN warehouse (WarehouseForStore), and a
// request-supplied warehouse_id is ignored so a till cannot touch another
// outlet's stock. fakeStore.WarehouseForStore returns 100+storeID.
func TestProcessPOS_BoundStoreUsesItsOwnWarehouse(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	storeID := int64(7)
	wantWH := int64(107)
	fs.stocks[key2(1, wantWH)] = &Stock{ID: 1, ProductID: 1, WarehouseID: wantWH, Quantity: decimal.NewFromInt(10)}
	// Also seed the request-named warehouse so a regression (using it) would
	// still "succeed" on stock but land in the wrong place — the assertion
	// below is on which row moved.
	fs.stocks[key2(1, 1)] = &Stock{ID: 2, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}

	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		StoreID:       &storeID,
		WarehouseID:   1, // must be ignored for a bound outlet
		PaymentMethod: "cash",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(100)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.WarehouseID == nil || *o.WarehouseID != wantWH {
		t.Fatalf("SO warehouse = %v, want %d", o.WarehouseID, wantWH)
	}
	if got := fs.stocks[key2(1, wantWH)].Quantity.String(); got != "8" {
		t.Errorf("outlet warehouse qty = %s, want 8 (deducted)", got)
	}
	if got := fs.stocks[key2(1, 1)].Quantity.String(); got != "10" {
		t.Errorf("request warehouse qty = %s, want 10 (untouched)", got)
	}
}

func TestProcessPOS_BlocksZeroStock_ByDefault(t *testing.T) {
	s := svc() // allowNegativeStock is false by default
	s.store.(*fakeStore).stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1} // zero stock
	_, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	})
	if !errors.Is(err, ErrInsufficientStock) {
		t.Fatalf("expected ErrInsufficientStock when checking out out-of-stock item, got %v", err)
	}
}

func TestProcessPOS_NegativeStock_WhenEnabled(t *testing.T) {
	s := svc().WithAllowNegativeStock(true)
	s.store.(*fakeStore).stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(3)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "invoiced" {
		t.Errorf("status = %s, want invoiced", o.Status)
	}
	// legacy parity behavior preserved behind the flag
	if st := s.store.(*fakeStore).stocks["1:1"]; st.Quantity.String() != "-3" {
		t.Errorf("stock qty = %s, want -3", st.Quantity)
	}
}

func TestProcessPOS_Validation(t *testing.T) {
	s := svc()
	_, _, err := s.ProcessPOS(context.Background(), POSInput{CustomerID: 1, PaymentMethod: "card",
		Items: []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(10)}}})
	if err == nil {
		t.Fatal("want error: payment_method must be cash or transfer")
	}
	_, _, err = s.ProcessPOS(context.Background(), POSInput{CustomerID: 1, PaymentMethod: "cash", Items: []POSItemInput{}})
	if !errors.Is(err, ErrItemRequired) {
		t.Errorf("got %v, want ErrItemRequired", err)
	}
}

// --- POS order-level discount / voucher redemption ---------------------------

func TestPOSInputUnmarshalDiscountAndVoucherAliases(t *testing.T) {
	var p POSInput
	body := `{"customerId":1,"paymentMethod":"tunai","discountAmount":"25.5","voucherCode":"V-1"}`
	if err := json.Unmarshal([]byte(body), &p); err != nil {
		t.Fatal(err)
	}
	if p.DiscountAmount == nil || p.DiscountAmount.String() != "25.5" {
		t.Errorf("discountAmount alias = %v, want 25.5", p.DiscountAmount)
	}
	if p.VoucherCode == nil || *p.VoucherCode != "V-1" {
		t.Errorf("voucherCode alias = %v, want V-1", p.VoucherCode)
	}
	var q POSInput
	body2 := `{"discount_amount":30,"voucher_code":"V-2"}`
	if err := json.Unmarshal([]byte(body2), &q); err != nil {
		t.Fatal(err)
	}
	if q.DiscountAmount == nil || q.DiscountAmount.String() != "30" {
		t.Errorf("discount_amount = %v, want 30", q.DiscountAmount)
	}
	if q.VoucherCode == nil || *q.VoucherCode != "V-2" {
		t.Errorf("voucher_code = %v, want V-2", q.VoucherCode)
	}
	var n POSInput
	if err := json.Unmarshal([]byte(`{"customerId":1,"notes":"Jangan pakai kresek"}`), &n); err != nil {
		t.Fatal(err)
	}
	if n.Notes == nil || *n.Notes != "Jangan pakai kresek" {
		t.Errorf("notes = %v, want the cashier note", n.Notes)
	}
}

func TestProcessPOS_PersistsOrderNotes(t *testing.T) {
	// P2: the till dropped order notes client-side and the pipeline had no
	// notes field at all. Notes persist verbatim and never affect pricing.
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Notes:         strPtr("Jangan pakai kresek"),
		Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Notes == nil || *o.Notes != "Jangan pakai kresek" {
		t.Errorf("order notes = %v, want the cashier note", o.Notes)
	}
	stored := fs.orders[o.ID]
	if stored == nil || stored.Notes == nil || *stored.Notes != "Jangan pakai kresek" {
		t.Errorf("stored notes = %v, want the cashier note", stored)
	}
}

func TestProcessPOS_VoucherAppliesAndConsumes(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	fs.vouchers[7] = &fakeVoucher{ID: 7, Number: "VOUCH-50", Amount: decimal.NewFromInt(50), Status: "available"}

	o, inv, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		VoucherCode:   strPtr("VOUCH-50"),
		Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	// subtotal 200 (2 x fakePrices[1]=100), voucher 50 -> net 150
	if o.Subtotal.String() != "200" || o.DiscountAmount.String() != "50" || o.TotalAmount.String() != "150" {
		t.Errorf("totals = %s/%s/%s, want 200/50/150", o.Subtotal, o.DiscountAmount, o.TotalAmount)
	}
	if o.PaidAmount.String() != "150" || o.RemainingAmount.String() != "0" {
		t.Errorf("paid/remaining = %s/%s, want 150/0", o.PaidAmount, o.RemainingAmount)
	}
	if inv == nil || inv.TotalAmount.String() != "150" || inv.PaidAmount.String() != "150" {
		t.Fatalf("invoice = %+v, want net totals 150", inv)
	}
	for _, p := range fs.payments {
		if p.Amount.String() != "150" {
			t.Errorf("payment amount = %s, want 150", p.Amount)
		}
	}

	// consumed in the same transaction as the order creation
	if fs.vouchers[7].Status != "used" {
		t.Errorf("voucher status = %s, want used", fs.vouchers[7].Status)
	}
}

func TestProcessPOS_VoucherUnknownExpiredOrUsed_Rejected(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	yesterday := pgtype.Date{Time: time.Now().UTC().AddDate(0, 0, -1), Valid: true}
	fs.vouchers[1] = &fakeVoucher{ID: 1, Number: "VOUCH-EXP", Amount: decimal.NewFromInt(50), Status: "available", ExpiredAt: yesterday}
	fs.vouchers[2] = &fakeVoucher{ID: 2, Number: "VOUCH-USED", Amount: decimal.NewFromInt(50), Status: "used"}

	cases := []struct {
		name string
		code string
	}{
		{"unknown code", "VOUCH-MISSING"},
		{"expired", "VOUCH-EXP"},
		{"already used", "VOUCH-USED"},
	}
	for _, tc := range cases {
		_, _, err := s.ProcessPOS(context.Background(), POSInput{
			CustomerID:    1,
			WarehouseID:   1,
			PaymentMethod: "cash",
			VoucherCode:   strPtr(tc.code),
			Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
		})
		if !errors.Is(err, ErrVoucherNotUsable) {
			t.Errorf("%s: got %v, want ErrVoucherNotUsable", tc.name, err)
		}
	}
	// rejected redemptions burn nothing and create nothing
	if fs.vouchers[1].Status != "available" || fs.vouchers[2].Status != "used" {
		t.Errorf("voucher statuses changed on rejection: %s/%s", fs.vouchers[1].Status, fs.vouchers[2].Status)
	}
	if len(fs.orders) != 0 {
		t.Errorf("orders = %d, want 0", len(fs.orders))
	}
}

func TestProcessPOS_RejectsVoucherPlusManualDiscount(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	fs.vouchers[3] = &fakeVoucher{ID: 3, Number: "VOUCH-DUAL", Amount: decimal.NewFromInt(50), Status: "available"}
	disc := decimal.NewFromInt(10)
	_, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:     1,
		WarehouseID:    1,
		PaymentMethod:  "cash",
		VoucherCode:    strPtr("VOUCH-DUAL"),
		DiscountAmount: &disc,
		Items:          []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	})
	if err == nil || !strings.Contains(err.Error(), "not both") {
		t.Errorf("got %v, want mutual-exclusion validation error", err)
	}
	if fs.vouchers[3].Status != "available" {
		t.Errorf("voucher status = %s, want available", fs.vouchers[3].Status)
	}
}

func TestProcessPOS_ManualDiscountCappedAtSubtotal_LikeRegularOrders(t *testing.T) {
	s := svc()
	s.store.(*fakeStore).stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	big := decimal.NewFromInt(500)
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:     1,
		WarehouseID:    1,
		PaymentMethod:  "cash",
		DiscountAmount: &big,
		Items:          []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	// regular-order clamp (§2.1): discount bounded by subtotal+tax (tax=0 here)
	if o.DiscountAmount.String() != "200" || o.TotalAmount.String() != "0" {
		t.Errorf("discount/total = %s/%s, want 200/0 (clamped like regular orders)", o.DiscountAmount, o.TotalAmount)
	}
}

func TestProcessPOS_VoucherOverSubtotalClampedAndConsumed(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	fs.vouchers[9] = &fakeVoucher{ID: 9, Number: "VOUCH-BIG", Amount: decimal.NewFromInt(1000), Status: "available"}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		VoucherCode:   strPtr("VOUCH-BIG"),
		Items:         []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.DiscountAmount.String() != "200" || o.TotalAmount.String() != "0" {
		t.Errorf("discount/total = %s/%s, want 200/0 (clamped like regular orders)", o.DiscountAmount, o.TotalAmount)
	}
	if fs.vouchers[9].Status != "used" {
		t.Errorf("voucher status = %s, want used", fs.vouchers[9].Status)
	}
}

func TestProcessPOS_FailedCheckoutDoesNotBurnVoucher(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1}
	fs.vouchers[5] = &fakeVoucher{ID: 5, Number: "VOUCH-OK", Amount: decimal.NewFromInt(50), Status: "available"}
	_, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		VoucherCode:   strPtr("VOUCH-OK"),
		Items:         []POSItemInput{{ProductID: 99999, Quantity: decimal.NewFromInt(1)}}, // product not found
	})
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("got %v, want ErrNotFound", err)
	}
	if fs.vouchers[5].Status != "available" {
		t.Errorf("voucher status = %s, want available (failed checkout must not burn it)", fs.vouchers[5].Status)
	}
	// NOTE: fs.orders still holds the aborted order because fakeTx.Rollback is
	// a no-op; production rolls the whole tx back so nothing persists.
}

func TestPlaceCheckout_DeductsAndAutoProvisionsCustomer(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2)},
		},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "pending_approval" {
		t.Errorf("status = %s, want pending_approval", o.Status)
	}
	if o.OrderType != "ecommerce" {
		t.Errorf("order_type = %s, want ecommerce", o.OrderType)
	}
	// price snapshot from fakePrices[1] = 100 -> subtotal 200
	if o.Subtotal.String() != "200" {
		t.Errorf("subtotal = %s, want 200", o.Subtotal)
	}
	if o.PaidAmount.String() != "0" {
		t.Errorf("paid = %s, want 0", o.PaidAmount)
	}
	if o.RemainingAmount.String() != "200" {
		t.Errorf("remaining = %s, want 200", o.RemainingAmount)
	}
	st := s.store.(*fakeStore).stocks["1:1"]
	if st.Quantity.String() != "8" {
		t.Errorf("quantity = %s, want 8", st.Quantity)
	}
	if st.ReservedQuantity.String() != "0" {
		t.Errorf("reserved = %s, want 0", st.ReservedQuantity)
	}
	if _, ok := s.store.(*fakeStore).customerByUser[42]; !ok {
		t.Error("customer not auto-provisioned for user 42")
	}
}

func TestPlaceCheckout_PermissiveStock(t *testing.T) {
	s := svc()
	// seed stock with 5 on-hand, 4 reserved -> 1 available
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(5), ReservedQuantity: decimal.NewFromInt(4)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2)},
		},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatalf("checkout should succeed with permissive stock: %v", err)
	}
	if o == nil {
		t.Fatal("expected order to be created")
	}
	// Quantity should be deducted: 5 - 2 = 3
	st := fs.stocks["1:1"]
	if !st.Quantity.Equal(decimal.NewFromInt(3)) {
		t.Fatalf("expected stock 3, got %s", st.Quantity)
	}
}

func TestPayCheckout_SettlesWithoutRededuct(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	// 10 on-hand for p1
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(2)},
		},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	custID := fs.customerByUser[42]
	// checkout deducts physical stock while still unpaid: 10 -> 8.
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Fatalf("stock after checkout = %s, want 8", st.Quantity)
	}
	o2, inv, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"})
	if err != nil {
		t.Fatal(err)
	}
	// A settled gateway payment records the money but is NOT an acceptance:
	// the order must still sit in pending_approval until a human runs Confirm.
	if o2.Status != "pending_approval" {
		t.Errorf("status = %s, want pending_approval (payment must not auto-accept)", o2.Status)
	}
	if !o2.PaidAmount.Equal(o2.TotalAmount) || !o2.RemainingAmount.IsZero() {
		t.Errorf("paid = %s remaining = %s, want fully settled", o2.PaidAmount, o2.RemainingAmount)
	}
	if o2.CustomerID != custID {
		t.Errorf("customer_id = %d, want %d", o2.CustomerID, custID)
	}
	// The human acceptance still works on the paid order and records WHO.
	accepted, err := s.Confirm(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("Confirm on a settled-but-unaccepted order: %v", err)
	}
	if accepted.Status != "confirmed" {
		t.Errorf("status after Confirm = %s, want confirmed", accepted.Status)
	}
	if inv == nil {
		t.Fatal("invoice not created")
	}
	if inv.Status != "paid" {
		t.Errorf("invoice status = %s, want paid", inv.Status)
	}
	// stock: 10 -> 8 at checkout, pay touches nothing.
	st := fs.stocks["1:1"]
	if st.Quantity.String() != "8" {
		t.Errorf("stock qty = %s, want 8", st.Quantity)
	}
	if st.ReservedQuantity.String() != "0" {
		t.Errorf("reserved = %s, want 0", st.ReservedQuantity)
	}
	if len(fs.payments) != 1 {
		t.Error("payment should be created")
	}
}

// Checkout auto-books an unpaid invoice (piutang); paying must settle that
// invoice rather than create a second one, and must not touch stock again.
func TestPayCheckout_SettlesExistingReceivable(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	invID, _ := fs.CreateInvoice(context.Background(), nil, createInvoiceParams(&Invoice{
		InvoiceNumber: "INV-T-1", SalesOrderID: o.ID, CustomerID: o.CustomerID, Status: "unpaid",
		InvoiceDate: "2026-09-20", DueDate: "2026-09-20",
		TotalAmount: o.TotalAmount, RemainingAmount: o.TotalAmount,
	}))
	_ = fs.SetOrderStatus(context.Background(), nil, o.ID, "invoiced")

	o2, inv, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"})
	if err != nil {
		t.Fatal(err)
	}
	if inv.ID != invID || inv.Status != "paid" || !inv.RemainingAmount.IsZero() {
		t.Errorf("invoice = %+v, want id %d paid with 0 remaining", inv, invID)
	}
	if len(fs.invoices) != 1 {
		t.Errorf("invoices = %d, want 1 (no duplicate)", len(fs.invoices))
	}
	if o2.Status != "invoiced" {
		t.Errorf("status = %s, want invoiced", o2.Status)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Errorf("stock qty = %s, want 8 (checkout deducted once, pay touches nothing)", st.Quantity)
	}
	if len(fs.payments) != 1 {
		t.Errorf("payments = %d, want 1", len(fs.payments))
	}
	for _, p := range fs.payments {
		if p.PayableID != invID || !p.Amount.Equal(o.TotalAmount) {
			t.Errorf("payment = %+v, want payable %d amount %s", p, invID, o.TotalAmount)
		}
	}
	// Second pay: nothing left to settle.
	if _, _, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"}); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("second pay err = %v, want ErrInvalidStatus", err)
	}
}

// The payment row and the order advance must carry the SAME figure the
// settlement recorded: what invoicePayableAmount returns, the open balance
// while one remains and the invoice total once none does. PayCheckout settles
// the invoice in full at inv.TotalAmount either way, so all three statements
// have to agree on one number.
//
// The non-positive open balances below are a FIXTURE, not a reachable write
// path: finance.recordInvoicePayment clamps remaining at zero and flips the
// status in the same UPDATE, and both invoice creators (finance.CreateInvoice,
// sales.SnapToken) set remaining = total, so remaining <= 0 on an open invoice
// implies total <= 0 and the two figures coincide. The cases still have to
// hold, because that equivalence is a property of today's writers, not
// something PayCheckout may assume about the row it was handed.
func TestPayCheckout_PaymentFigureIsTheSettledFigure(t *testing.T) {
	cases := []struct {
		name       string
		preInvoice bool
		remaining  decimal.Decimal
		want       string
	}{
		{"open balance still open records the remaining balance", true, decimal.NewFromInt(120), "120"},
		{"zero open balance records the invoice total", true, decimal.Zero, "200"},
		{"negative open balance records the invoice total", true, decimal.NewFromInt(-50), "200"},
		{"fresh invoice records the order total", false, decimal.Zero, "200"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			fs := s.store.(*fakeStore)
			fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
			o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
				Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
			}, 42, "Buyer", "buyer@keluargamart.local")
			if err != nil {
				t.Fatal(err)
			}
			total := o.TotalAmount
			if !total.Equal(decimal.NewFromInt(200)) {
				t.Fatalf("order total = %s, want 200 (the figures below are derived from it)", total)
			}
			if tc.preInvoice {
				if _, err := fs.CreateInvoice(context.Background(), nil, createInvoiceParams(&Invoice{
					InvoiceNumber: "INV-T-PF", SalesOrderID: o.ID, CustomerID: o.CustomerID, Status: "unpaid",
					InvoiceDate: "2026-09-20", DueDate: "2026-09-20",
					TotalAmount: total, RemainingAmount: tc.remaining,
				})); err != nil {
					t.Fatal(err)
				}
				if err := fs.SetOrderStatus(context.Background(), nil, o.ID, "invoiced"); err != nil {
					t.Fatal(err)
				}
			}
			o2, inv, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"})
			if err != nil {
				t.Fatal(err)
			}
			want := decimal.RequireFromString(tc.want)
			// 1. the settlement: paid in full at the invoice total.
			if inv.Status != "paid" || !inv.PaidAmount.Equal(inv.TotalAmount) || !inv.RemainingAmount.IsZero() {
				t.Errorf("invoice = %s paid %s remaining %s, want paid at %s with 0 remaining",
					inv.Status, inv.PaidAmount, inv.RemainingAmount, inv.TotalAmount)
			}
			// 2. the payment row, against that settled invoice.
			if len(fs.payments) != 1 {
				t.Fatalf("payments = %d, want 1", len(fs.payments))
			}
			for _, p := range fs.payments {
				if !p.Amount.Equal(want) {
					t.Errorf("payment amount = %s, want %s", p.Amount, want)
				}
				if p.PayableID != inv.ID {
					t.Errorf("payment payable_id = %d, want the settled invoice %d", p.PayableID, inv.ID)
				}
			}
			// 3. the order advance, by that same figure.
			if !o2.PaidAmount.Equal(want) {
				t.Errorf("order paid_amount = %s, want %s", o2.PaidAmount, want)
			}
			if wantRem := total.Sub(want); !o2.RemainingAmount.Equal(wantRem) {
				t.Errorf("order remaining_amount = %s, want %s", o2.RemainingAmount, wantRem)
			}
		})
	}
}

func TestPayCheckout_ScopingAndStatus(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	// another user cannot pay this order
	fs.customerByUser[7] = 99
	if _, _, err := s.PayCheckout(context.Background(), o.ID, 7, PayCheckoutInput{PaymentMethod: "qris"}); !errors.Is(err, ErrNotFound) {
		t.Errorf("cross-user pay got %v, want ErrNotFound", err)
	}
	// invalid method
	if _, _, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "card"}); err == nil {
		t.Error("want error: invalid payment_method")
	}
	// second pay after invoiced
	if _, _, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"}); err != nil {
		t.Fatal(err)
	}
	if _, _, err := s.PayCheckout(context.Background(), o.ID, 42, PayCheckoutInput{PaymentMethod: "qris"}); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("second pay got %v, want ErrInvalidStatus", err)
	}
}

func TestCancelEcommerceOrder_RestocksPhysicalStock(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Fatalf("stock after checkout = %s, want 8", st.Quantity)
	}
	// Cancel order
	if _, err := s.Cancel(context.Background(), o.ID); err != nil {
		t.Fatal(err)
	}
	// Stock should be restocked back to 10
	if st := fs.stocks["1:1"]; st.Quantity.String() != "10" {
		t.Errorf("stock after cancel = %s, want 10", st.Quantity)
	}
	// Repeated cancel must fail without double-restocking.
	if _, err := s.Cancel(context.Background(), o.ID); !errors.Is(err, ErrCannotCancel) {
		t.Errorf("second cancel err = %v, want ErrCannotCancel", err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "10" {
		t.Errorf("stock after second cancel = %s, want 10 (no double-restock)", st.Quantity)
	}
}
func TestCancelPOSOrder_RestocksPhysicalStock(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID: 1, WarehouseID: 1, PaymentMethod: "cash",
		Items: []POSItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(100)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Fatalf("stock after POS = %s, want 8", st.Quantity)
	}
	// A POS order is paid at the register: money must come back through the
	// return/refund flow (which restocks via the return ledger), never cancel —
	// cancelling would restock goods while the paid invoice is skipped and no
	// refund record written.
	if _, err := s.Cancel(context.Background(), o.ID); !errors.Is(err, ErrPaidCannotCancel) {
		t.Fatalf("cancel paid POS err = %v, want ErrPaidCannotCancel", err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "8" {
		t.Errorf("stock after rejected cancel = %s, want 8 (no restock)", st.Quantity)
	}

	// An unpaid POS order (payment later voided) still cancels with restock.
	fs.orders[o.ID].PaidAmount = decimal.Zero
	if _, err := s.Cancel(context.Background(), o.ID); err != nil {
		t.Fatalf("cancel unpaid POS: %v", err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "10" {
		t.Errorf("stock after cancel = %s, want 10", st.Quantity)
	}
	if _, err := s.Cancel(context.Background(), o.ID); !errors.Is(err, ErrCannotCancel) {
		t.Errorf("second cancel err = %v, want ErrCannotCancel", err)
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "10" {
		t.Errorf("stock after second cancel = %s, want 10 (no double-restock)", st.Quantity)
	}
}

func TestMyOrders_ScopedToCustomer(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.customerByUser[42] = 5
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	if _, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 42, "Buyer", "buyer@keluargamart.local"); err != nil {
		t.Fatal(err)
	}
	list, total, err := s.MyOrders(context.Background(), 42, SOFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 {
		t.Errorf("total = %d, want 1", total)
	}
	if len(list) == 0 || list[0].OrderType != "ecommerce" {
		t.Errorf("list = %+v, want ecommerce order", list)
	}
	// user without a customer gets an empty list
	empty, etotal, err := s.MyOrders(context.Background(), 99, SOFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if len(empty) != 0 || etotal != 0 {
		t.Errorf("empty = %d/%d, want 0/0", len(empty), etotal)
	}
}

func TestMyOrders_CumulativeWindowSurvivesClamp(t *testing.T) { // R2-1: the storefront merges per-status pages with per_page =
	// pageSize x page. MyOrders must pass buyer windows through up to 500
	// instead of resetting them to 20 the way the generic 100-cap did.
	s := svc()
	fs := s.store.(*fakeStore)
	fs.customerByUser[42] = 5
	for _, pp := range []int{20, 120, 500} {
		if _, _, err := s.MyOrders(context.Background(), 42, SOFilter{Page: 1, PerPage: pp}); err != nil {
			t.Fatal(err)
		}
		if fs.lastPerPage != pp {
			t.Errorf("per_page %d -> ListOrders got %d, want passthrough", pp, fs.lastPerPage)
		}
	}
	if _, _, err := s.MyOrders(context.Background(), 42, SOFilter{Page: 1, PerPage: 600}); err != nil {
		t.Fatal(err)
	}
	if fs.lastPerPage != 20 {
		t.Errorf("per_page 600 -> ListOrders got %d, want reset to 20", fs.lastPerPage)
	}
}

func TestForReturnLookup_ExactNumberOnly(t *testing.T) {
	// Narrow replacement for the order-book list closed to cashiers (000103):
	// exact SO number, own stores, 404 otherwise — no permission widened.
	s := svc()
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	got, err := s.ForReturnLookup(context.Background(), o.SoNumber)
	if err != nil {
		t.Fatalf("lookup: %v", err)
	}
	if got.ID != o.ID {
		t.Errorf("id = %d, want %d", got.ID, o.ID)
	}
	if _, err := s.ForReturnLookup(context.Background(), "  "+strings.ToLower(o.SoNumber)+"  "); err != nil {
		t.Errorf("case-insensitive lookup err = %v, want nil", err)
	}
	if _, err := s.ForReturnLookup(context.Background(), "SO-209901-000000"); !errors.Is(err, ErrNotFound) {
		t.Errorf("unknown number err = %v, want ErrNotFound", err)
	}
	if _, err := s.ForReturnLookup(context.Background(), ""); !errors.Is(err, ErrNotFound) {
		t.Errorf("empty number err = %v, want ErrNotFound", err)
	}
	if _, err := s.ForReturnLookup(context.Background(), o.SoNumber[:5]); !errors.Is(err, ErrNotFound) {
		t.Errorf("prefix err = %v, want ErrNotFound (exact match only)", err)
	}
}

func TestForReturnLookup_AttachesReturnedQty(t *testing.T) {
	// P2: the POS return dialog must clamp each line to its remainder. The
	// lookup attaches per-line already-returned quantities; CreateReturn
	// still re-sums server-side, so this stays advisory.
	s := svc()
	fs := s.store.(*fakeStore)
	o, err := s.Create(context.Background(), SOInput{
		CustomerID: 1, OrderDate: "2026-08-11",
		Items: []SOItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	full, err := s.ForReturnLookup(context.Background(), o.SoNumber)
	if err != nil {
		t.Fatal(err)
	}
	if len(full.Items) != 1 {
		t.Fatalf("items = %d, want 1", len(full.Items))
	}
	lineID := full.Items[0].ID
	fs.returnedByOrder = map[int64]map[int64]decimal.Decimal{
		o.ID: {lineID: decimal.NewFromInt(1)},
	}
	got, err := s.ForReturnLookup(context.Background(), o.SoNumber)
	if err != nil {
		t.Fatal(err)
	}
	if !got.Items[0].ReturnedQty.Equal(decimal.NewFromInt(1)) {
		t.Errorf("returned_qty = %s, want 1", got.Items[0].ReturnedQty)
	}
}

func TestForPickupLookup(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	// Pickup marker in shipping_address — what checkout writes for
	// "Ambil Sendiri di Toko" and what isPickupOrder keys on.
	pickupAddr := "[Ambil Sendiri di Toko] Gudang Pusat Medan · Pengambil: QA (0812)"
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items:           []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
		ShippingAddress: &pickupAddr,
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}

	// Lookup by exact number
	list, total, err := s.ForPickupLookup(context.Background(), PickupFilter{Number: o.SoNumber})
	if err != nil {
		t.Fatalf("pickup lookup err: %v", err)
	}
	if total != 1 || len(list) != 1 {
		t.Fatalf("got %d orders, want 1", len(list))
	}
	if list[0].SoNumber != o.SoNumber {
		t.Errorf("so_number = %s, want %s", list[0].SoNumber, o.SoNumber)
	}
	if len(list[0].Items) != 1 {
		t.Errorf("items count = %d, want 1", len(list[0].Items))
	}

	// Empty query returns list containing the order
	all, totalAll, err := s.ForPickupLookup(context.Background(), PickupFilter{})
	if err != nil {
		t.Fatalf("pickup list err: %v", err)
	}
	if totalAll < 1 || len(all) < 1 {
		t.Fatalf("got %d total, want >= 1", totalAll)
	}
}

// A courier delivery order (kirim ke alamat, no pickup marker) must NOT appear
// on the till's Ambil Toko list — warehouse packing owns those. This was the
// reported bug: the list filtered on order type alone, so every e-commerce
// order showed up regardless of fulfillment mode.
func TestForPickupLookup_ExcludesDeliveryOrders(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	deliveryAddr := "Jl. Ahmad Yani No. 50, Medan, Kota Medan, Sumatera Utara, 20123"
	delivery, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items:           []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
		ShippingAddress: &deliveryAddr,
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}

	all, _, err := s.ForPickupLookup(context.Background(), PickupFilter{})
	if err != nil {
		t.Fatalf("pickup list err: %v", err)
	}
	for _, r := range all {
		if r.ID == delivery.ID {
			t.Errorf("delivery order %s appeared on the Ambil Toko list", r.SoNumber)
		}
	}
	// And an exact-number lookup of the delivery order returns nothing — the
	// till cannot hand over an order that ships by courier.
	list, total, err := s.ForPickupLookup(context.Background(), PickupFilter{Number: delivery.SoNumber})
	if err != nil {
		t.Fatalf("pickup lookup err: %v", err)
	}
	if total != 0 || len(list) != 0 {
		t.Errorf("delivery order found on pickup list: %d results", len(list))
	}
}

// boxResolver stands in for internal/pricing: product 1 sells in a box of 12 at
// 1380 per box with a minimum purchase of 2 boxes.
func boxResolver(reqs []PriceRequest) ([]PricedLine, error) {
	out := make([]PricedLine, 0, len(reqs))
	for _, r := range reqs {
		puID := int64(11)
		unitID := int64(101)
		if r.Quantity.LessThan(decimal.NewFromInt(2)) {
			return nil, errors.New("quantity is below the configured minimum purchase")
		}
		out = append(out, PricedLine{
			ProductID:        r.ProductID,
			ProductUnitID:    &puID,
			UnitID:           &unitID,
			UnitName:         "Box",
			UnitSymbol:       "box",
			ConversionFactor: decimal.NewFromInt(12),
			UnitPrice:        decimal.NewFromInt(1380),
			MinPurchase:      decimal.NewFromInt(2),
			Quantity:         r.Quantity,
			BaseQuantity:     r.Quantity.Mul(decimal.NewFromInt(12)),
		})
	}
	return out, nil
}

func TestProcessPOS_UsesConfiguredUnitPriceAndBaseQuantity(t *testing.T) {
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	boxID := int64(11)

	o, inv, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items: []POSItemInput{
			// client submits a bogus price; the resolver's 1380/box must win
			{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(1)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.TotalAmount.String() != "2760" {
		t.Errorf("total = %s, want 2760 (1380 x 2 boxes)", o.TotalAmount)
	}
	if inv == nil || inv.TotalAmount.String() != "2760" {
		t.Fatalf("invoice total = %v, want 2760", inv)
	}
	// 2 boxes x factor 12 = 24 base units: 100 -> 76
	if st := fs.stocks["1:1"]; st.Quantity.String() != "76" {
		t.Errorf("stock qty = %s, want 76 (24 base units deducted)", st.Quantity)
	}
	items := fs.items[o.ID]
	if len(items) != 1 {
		t.Fatalf("items = %d, want 1", len(items))
	}
	snap := items[0]
	if snap.ProductUnitID == nil || *snap.ProductUnitID != 11 {
		t.Errorf("product_unit_id = %v, want 11", snap.ProductUnitID)
	}
	if snap.ConversionFactor.String() != "12" || snap.BaseQuantity.String() != "24" {
		t.Errorf("factor/base = %s/%s, want 12/24", snap.ConversionFactor, snap.BaseQuantity)
	}
	if snap.UnitName != "Box" || snap.UnitSymbol != "box" {
		t.Errorf("unit = %q/%q, want Box/box", snap.UnitName, snap.UnitSymbol)
	}
	if snap.MinPurchase.String() != "2" {
		t.Errorf("min_purchase = %s, want 2", snap.MinPurchase)
	}
	if snap.UnitPrice.String() != "1380" {
		t.Errorf("unit_price = %s, want 1380 (client price ignored)", snap.UnitPrice)
	}
}

func TestProcessPOS_ResolverRejectionAbortsSale(t *testing.T) {
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	boxID := int64(11)

	_, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    1,
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items:         []POSItemInput{{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(1)}},
	})
	if err == nil {
		t.Fatal("want the minimum-purchase rejection to abort the sale")
	}
	if st := fs.stocks["1:1"]; st.Quantity.String() != "100" {
		t.Errorf("stock qty = %s, want 100 (no partial write)", st.Quantity)
	}
	if len(fs.orders) != 0 {
		t.Errorf("orders = %d, want 0 (transaction rolled back)", len(fs.orders))
	}
}

// Bug sweep R3-1: a stray membership row on the walk-in placeholder must not
// silently discount every walk-in sale. The pricing stage only looks up a
// membership for a customer the cashier actually picked, so the walk-in
// checkout must hand the resolver a nil CustomerID while the persisted order
// still records the placeholder.
func TestProcessPOS_WalkInPricesWithoutMembershipLookup(t *testing.T) {
	var seen []*int64
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		for _, r := range reqs {
			seen = append(seen, r.CustomerID)
		}
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	boxID := int64(11)

	o, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    0, // walk-in
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items:         []POSItemInput{{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(2)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(seen) != 1 || seen[0] != nil {
		t.Fatalf("pricing saw CustomerID %v, want nil for a walk-in sale", seen)
	}
	if o.CustomerID != 1 { // fake walk-in resolves to the "Pelanggan Umum" id
		t.Errorf("order customer = %d, want the walk-in placeholder id 1", o.CustomerID)
	}
}

// The explicit-customer path must keep receiving the picked customer so the
// membership benefit still applies where it belongs.
func TestProcessPOS_ExplicitCustomerKeepsPricingLookup(t *testing.T) {
	var seen []*int64
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		for _, r := range reqs {
			seen = append(seen, r.CustomerID)
		}
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	boxID := int64(11)

	if _, _, err := s.ProcessPOS(context.Background(), POSInput{
		CustomerID:    7, // an explicitly picked customer (fake admits any id)
		WarehouseID:   1,
		PaymentMethod: "cash",
		Items:         []POSItemInput{{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(2)}},
	}); err != nil {
		t.Fatal(err)
	}
	if len(seen) != 1 || seen[0] == nil || *seen[0] != 7 {
		t.Fatalf("pricing saw CustomerID %v, want &7 for an explicit pick", seen)
	}
}

func TestPlaceCheckout_UsesConfiguredUnitAndDeductsBaseQuantity(t *testing.T) {
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.customerByUser[42] = 5
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	boxID := int64(11)

	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(3)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if o.Subtotal.String() != "4140" {
		t.Errorf("subtotal = %s, want 4140 (1380 x 3)", o.Subtotal)
	}
	// 3 boxes x 12 = 36 base units deducted directly, quantity decreases to 64
	if st := fs.stocks["1:1"]; st.Quantity.String() != "64" || st.ReservedQuantity.String() != "0" {
		t.Errorf("stock = %s reserved / %s qty, want 0/64", st.ReservedQuantity, st.Quantity)
	}
	if len(o.Items) != 1 || o.Items[0].BaseQuantity.String() != "36" {
		t.Errorf("response items = %+v, want base_quantity 36", o.Items)
	}
}

func TestCreate_IgnoresClientUnitPrice_AuthoritativePriceWins(t *testing.T) {
	s := svc().WithPricing(func(_ context.Context, _ db.DBTX, reqs []PriceRequest) ([]PricedLine, error) {
		return boxResolver(reqs)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(100)}
	wh := int64(1)
	boxID := int64(11)

	o, err := s.Create(context.Background(), SOInput{
		CustomerID:  1,
		WarehouseID: &wh,
		OrderDate:   "2026-08-17",
		Items: []SOItemInput{
			// Client tries to underprice the box at 1200; resolver says 1380.
			{ProductID: 1, ProductUnitID: &boxID, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(1200)},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	// HARDENING (client feedback 2026-08-26): client-supplied unit_price can
	// no longer override the authoritative engine — subtotal must be
	// 1380 x 2 = 2760, never the attempted 2400.
	if o.Subtotal.String() != "2760" {
		t.Errorf("subtotal = %s, want 2760 (authoritative 1380 x 2)", o.Subtotal)
	}
	snap := fs.items[o.ID][0]
	if !snap.UnitPrice.Equal(decimal.NewFromInt(1380)) {
		t.Errorf("snapshotted unit_price = %s, want 1380", snap.UnitPrice)
	}
	if snap.ConversionFactor.String() != "12" || snap.BaseQuantity.String() != "24" {
		t.Errorf("factor/base = %s/%s, want 12/24", snap.ConversionFactor, snap.BaseQuantity)
	}
	// reservation follows base quantity, not the box count
	if st := fs.stocks["1:1"]; st.ReservedQuantity.String() != "24" {
		t.Errorf("reserved = %s, want 24", st.ReservedQuantity)
	}
}

// Combined scenario: line discount_percent folds first, then the membership
// benefit is clamped to the remainder (§13.1 precedence); rounding stays 2dp.
func TestItemFromPricedLine_CombinedDiscountPrecedence(t *testing.T) {
	pl := PricedLine{
		ProductID:                1,
		UnitPrice:                decimal.NewFromInt(1000),
		Quantity:                 decimal.NewFromInt(3),
		MembershipTier:           "gold",
		MembershipDiscountAmount: decimal.NewFromInt(150), // 5% of 3000
	}
	it := itemFromPricedLine(pl, decimal.NewFromInt(10)) // line disc 10% = 300
	// gross 3000 − 300 line − 150 membership = 2550
	if it.Subtotal.String() != "2550" {
		t.Errorf("subtotal = %s, want 2550", it.Subtotal)
	}
	if it.DiscountAmount.String() != "300" || !it.MembershipDiscountAmount.Equal(decimal.NewFromInt(150)) {
		t.Errorf("discounts = %s/%s, want 300/150", it.DiscountAmount, it.MembershipDiscountAmount)
	}

	// Fully discounted line: membership clamps to the zero remainder.
	pl.MembershipDiscountAmount = decimal.NewFromInt(5000)
	it = itemFromPricedLine(pl, decimal.NewFromInt(100))
	if it.Subtotal.String() != "0" {
		t.Errorf("fully discounted subtotal = %s, want 0", it.Subtotal)
	}
}

func TestCreateReturn_DamagedSkipsRestock(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	wid := int64(1)
	fs.orders[100] = &SalesOrder{
		ID: 100, CustomerID: 1, WarehouseID: &wid, Status: "delivered",
		Subtotal: decimal.NewFromInt(200), TotalAmount: decimal.NewFromInt(200),
	}
	fs.items[100] = []*SOItem{{
		ID: 1, SalesOrderID: 100, ProductID: 1, Quantity: decimal.NewFromInt(2),
		UnitPrice: decimal.NewFromInt(100), Subtotal: decimal.NewFromInt(200),
		BaseQuantity: decimal.NewFromInt(2),
	}}
	fs.stocks[key2(1, wid)] = &Stock{ProductID: 1, WarehouseID: wid, Quantity: decimal.NewFromInt(10)}
	before := fs.stocks[key2(1, wid)].Quantity

	// Damaged: should NOT restock, stock unchanged.
	r, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 100, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 1, Quantity: decimal.NewFromInt(1), Condition: "damaged"}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(r.Items) != 1 {
		t.Fatalf("items = %d, want 1", len(r.Items))
	}
	if r.Items[0].Condition != "damaged" {
		t.Errorf("condition = %q, want damaged", r.Items[0].Condition)
	}
	if r.Items[0].Restocked {
		t.Errorf("damaged return must not restock")
	}
	if got := fs.stocks[key2(1, wid)].Quantity; !got.Equal(before) {
		t.Errorf("damaged return changed stock: %s, want %s", got, before)
	}

	// Good: should restock (Restocked=true, stock increases by base qty 1).
	r2, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 100, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 1, Quantity: decimal.NewFromInt(1), Condition: "good"}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if r2.Items[0].Condition != "good" {
		t.Errorf("condition = %q, want good", r2.Items[0].Condition)
	}
	if !r2.Items[0].Restocked {
		t.Errorf("good return should restock")
	}
	if got := fs.stocks[key2(1, wid)].Quantity; !got.Equal(before.Add(decimal.NewFromInt(1))) {
		t.Errorf("good return stock = %s, want %s", got, before.Add(decimal.NewFromInt(1)))
	}
}

func TestCreateReturn_RequiresDeliveredOrder(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	wid := int64(1)
	fs.orders[101] = &SalesOrder{ID: 101, CustomerID: 1, WarehouseID: &wid, Status: "draft"}
	fs.items[101] = []*SOItem{{ID: 2, SalesOrderID: 101, ProductID: 1, Quantity: decimal.NewFromInt(1), Subtotal: decimal.NewFromInt(100)}}

	if _, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 101, Items: []ReturnItemInput{{SalesOrderItemID: 2, Quantity: decimal.NewFromInt(1)}},
	}); err == nil {
		t.Fatal("expected error for non-delivered order")
	}
}

func TestCreateReturn_AllowsConfirmedPaidPOSOrder(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	wid := int64(1)
	// POS sale: status stays "confirmed" but was paid in full at the register.
	fs.orders[102] = &SalesOrder{
		ID: 102, CustomerID: 1, WarehouseID: &wid, Status: "confirmed",
		TotalAmount: decimal.NewFromInt(200), PaidAmount: decimal.NewFromInt(200),
	}
	fs.items[102] = []*SOItem{{
		ID: 3, SalesOrderID: 102, ProductID: 1, Quantity: decimal.NewFromInt(2),
		UnitPrice: decimal.NewFromInt(100), Subtotal: decimal.NewFromInt(200),
		BaseQuantity: decimal.NewFromInt(2),
	}}
	fs.stocks[key2(1, wid)] = &Stock{ProductID: 1, WarehouseID: wid, Quantity: decimal.NewFromInt(10)}

	if _, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 102, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 3, Quantity: decimal.NewFromInt(1)}},
	}); err != nil {
		t.Fatalf("fully-paid confirmed order should be returnable: %v", err)
	}

	// Unpaid confirmed order stays rejected.
	fs.orders[103] = &SalesOrder{ID: 103, CustomerID: 1, WarehouseID: &wid, Status: "confirmed", TotalAmount: decimal.NewFromInt(200)}
	fs.items[103] = []*SOItem{{ID: 4, SalesOrderID: 103, ProductID: 1, Quantity: decimal.NewFromInt(2), Subtotal: decimal.NewFromInt(200)}}
	if _, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 103, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 4, Quantity: decimal.NewFromInt(1)}},
	}); err == nil {
		t.Fatal("expected error for unpaid confirmed order")
	}
}

func TestCreateReturn_StoreIsolation(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	wid := int64(1)
	store1 := int64(1)
	store2 := int64(2)

	// Order belongs to Store 1
	fs.orders[201] = &SalesOrder{
		ID: 201, CustomerID: 1, WarehouseID: &wid, StoreID: &store1, Status: "delivered",
		TotalAmount: decimal.NewFromInt(100), PaidAmount: decimal.NewFromInt(100),
	}
	fs.items[201] = []*SOItem{{
		ID: 21, SalesOrderID: 201, ProductID: 1, Quantity: decimal.NewFromInt(1),
		UnitPrice: decimal.NewFromInt(100), Subtotal: decimal.NewFromInt(100),
		BaseQuantity: decimal.NewFromInt(1),
	}}
	fs.stocks[key2(1, wid)] = &Stock{ProductID: 1, WarehouseID: wid, Quantity: decimal.NewFromInt(10)}

	// Matching store_id = 1 succeeds
	if _, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 201, StoreID: &store1, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 21, Quantity: decimal.NewFromInt(1)}},
	}); err != nil {
		t.Fatalf("matching store return should succeed: %v", err)
	}

	// Mismatched store_id = 2 is rejected (pos retur independen per toko)
	if _, err := s.CreateReturn(context.Background(), ReturnInput{
		SalesOrderID: 201, StoreID: &store2, RefundMethod: "cash",
		Items: []ReturnItemInput{{SalesOrderItemID: 21, Quantity: decimal.NewFromInt(1)}},
	}); err == nil {
		t.Fatal("expected error for cross-store return attempt")
	} else if !strings.Contains(err.Error(), "toko lain") {
		t.Fatalf("err = %v, want error mentioning 'toko lain'", err)
	}
}

func TestPlaceCheckout_ReferralSalesAttribution(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.customerByUser[42] = 5
	fs.assignedSales[5] = 7
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if o.SalesID == nil || *o.SalesID != 7 {
		t.Errorf("order sales_id = %v, want 7 (customer referral link)", o.SalesID)
	}
}

func TestPlaceCheckout_SelfRegisteredNoSales(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.customerByUser[42] = 5
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if o.SalesID != nil {
		t.Errorf("order sales_id = %v, want nil (self-registered flows to receivables)", *o.SalesID)
	}
}

// --- SnapToken (Midtrans Snap sandbox) --------------------------------------

// snapStubRT stubs the Snap HTTP transport: asserts basic-auth, rejects
// server-key leaks in the body, and serves a canned token response.
type snapStubRT struct {
	t        *testing.T
	key      string
	calls    int
	lastBody []byte
	// Which product each call reached. Snap token creation and the Core API
	// charge share a transport, so the path is what proves the POS flow stopped
	// asking Snap for a page it cannot draw.
	lastSnapPath   string
	lastChargePath string
}

func (s *snapStubRT) RoundTrip(r *http.Request) (*http.Response, error) {
	s.calls++
	if r.Method != http.MethodPost {
		s.t.Errorf("method = %s, want POST", r.Method)
	}
	u, _, ok := r.BasicAuth()
	if !ok || u != s.key {
		s.t.Errorf("basic-auth user = %q, want configured server key", u)
	}
	raw, _ := io.ReadAll(r.Body)
	s.lastBody = raw
	if strings.Contains(string(raw), s.key) {
		s.t.Errorf("request body leaks server key")
	}
	// Path-aware: POS QRIS charges the Core API while B2B still creates Snap
	// tokens, and both go through this one transport.
	//
	// The charge stub answers with expiry_time in the SHAPE the provider really
	// sends — "2006-01-02 15:04:05", no offset — because the first version of
	// this parsed it with an offset-bearing layout, failed silently, and the
	// till got a zero countdown against a payment the provider would keep open
	// for another 15 minutes.
	if strings.HasSuffix(r.URL.Path, "/v2/charge") {
		if got := r.Header.Get("X-Override-Notification"); got == "" {
			s.t.Errorf("X-Override-Notification missing; a POS charge must say where to send settlement")
		}
		s.lastChargePath = r.URL.Path
		return &http.Response{
			StatusCode: http.StatusCreated,
			Body: io.NopCloser(strings.NewReader(`{
				"status_code":"201","status_message":"Qris transaction is created",
				"transaction_id":"txn-abc","order_id":"order-1","gross_amount":"200.00",
				"payment_type":"qris","transaction_status":"pending",
				"qr_string":"0002010102122662","expiry_time":"2026-10-01 01:30:00",
				"actions":[{"name":"generate-qr-code","method":"GET","url":"https://api.example/v2/qris/txn-abc/qr-code"}]
			}`)),
			Header: make(http.Header),
		}, nil
	}
	s.lastSnapPath = r.URL.Path
	return &http.Response{
		StatusCode: http.StatusOK,
		Body:       io.NopCloser(strings.NewReader(`{"token":"tok-123","redirect_url":"https://pay.example/r"}`)),
		Header:     make(http.Header),
	}, nil
}

// snapOrderWithInvoice checks out a 2x100 e-commerce order and books its
// unpaid invoice, the precondition for SnapToken.
func snapOrderWithInvoice(t *testing.T, s *Service) (*SalesOrder, int64) {
	t.Helper()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(2)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	invID, err := fs.CreateInvoice(context.Background(), nil, createInvoiceParams(&Invoice{
		InvoiceNumber: "INV-T-1", SalesOrderID: o.ID, CustomerID: o.CustomerID, Status: "unpaid",
		InvoiceDate: "2026-09-20", DueDate: "2026-09-20",
		TotalAmount: o.TotalAmount, RemainingAmount: o.TotalAmount,
	}))
	if err != nil {
		t.Fatal(err)
	}
	return o, invID
}

func snapSvcWithStub(t *testing.T, s *Service) *snapStubRT {
	t.Helper()
	st := &snapStubRT{t: t, key: "test-server-key"}
	// CoreBaseURL deliberately carries the /v2 suffix, exactly as config derives
	// it. The charge path is joined against a normalised base, so a base without
	// it must produce the same /v2/charge — a test that quietly passed the
	// suffix-less form would miss that regression.
	s.WithMidtrans(MidtransConfig{
		ServerKey:       "test-server-key",
		SnapBaseURL:     "http://example.test/snap",
		CoreBaseURL:     "http://example.test/v2",
		NotificationURL: "https://shop.example/api/v1/midtrans/notifications",
	}).WithHTTPClient(&http.Client{Transport: st})
	return st
}

func TestSnapToken_CrossUserNotFound(t *testing.T) {
	s := svc()
	o, _ := snapOrderWithInvoice(t, s)
	fs := s.store.(*fakeStore)
	fs.customerByUser[7] = 99
	st := snapSvcWithStub(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 7); !errors.Is(err, ErrNotFound) {
		t.Errorf("cross-user snap err = %v, want ErrNotFound", err)
	}
	if st.calls != 0 {
		t.Errorf("gateway calls = %d, want 0 (ownership fails first)", st.calls)
	}
}

func TestSnapToken_PaidOrderInvalidStatus(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	fs := s.store.(*fakeStore)
	if _, err := fs.MarkInvoicePaid(context.Background(), nil, invID, o.TotalAmount); err != nil {
		t.Fatal(err)
	}
	st := snapSvcWithStub(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 42); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("paid-order snap err = %v, want ErrInvalidStatus", err)
	}
	if st.calls != 0 {
		t.Errorf("gateway calls = %d, want 0 (no open invoice)", st.calls)
	}
}

func TestSnapToken_HappyPathPersistsRef(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := snapSvcWithStub(t, s)
	out, err := s.SnapToken(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.SnapToken != "tok-123" || out.RedirectURL != "https://pay.example/r" {
		t.Errorf("result = %+v, want stubbed token+redirect", out)
	}
	if st.calls != 1 {
		t.Fatalf("gateway calls = %d, want 1", st.calls)
	}
	var payload struct {
		TransactionDetails struct {
			OrderID     string `json:"order_id"`
			GrossAmount int64  `json:"gross_amount"`
		} `json:"transaction_details"`
	}
	if err := json.Unmarshal(st.lastBody, &payload); err != nil {
		t.Fatalf("snap request body = %s, want transaction_details JSON: %v", st.lastBody, err)
	}
	wantRef := "KM-INV-T-1-" + itoa2(o.ID)
	if payload.TransactionDetails.OrderID != wantRef {
		t.Errorf("order_id = %s, want %s", payload.TransactionDetails.OrderID, wantRef)
	}
	if want := o.TotalAmount.Round(0).IntPart(); payload.TransactionDetails.GrossAmount != want {
		t.Errorf("gross_amount = %d, want %d (invoice remaining)", payload.TransactionDetails.GrossAmount, want)
	}
	fs := s.store.(*fakeStore)
	got := fs.invoices[invID].SnapOrderRef
	if got == nil || *got != wantRef {
		t.Errorf("persisted snap ref = %v, want %s", got, wantRef)
	}
}

func TestSnapToken_RegularSalesOrderStaffAllowed(t *testing.T) {
	s := svc()
	o, _ := snapOrderWithInvoice(t, s)
	fs := s.store.(*fakeStore)
	o.OrderType = "regular"
	fs.orders[o.ID] = o
	st := snapSvcWithStub(t, s)

	staffUser := &auth.User{
		ID:   999,
		Role: auth.RoleSales,
	}
	ctx := auth.ToContext(context.Background(), staffUser, 1)

	out, err := s.SnapToken(ctx, o.ID, 999)
	if err != nil {
		t.Fatalf("staff snap token for regular SO err = %v, want nil", err)
	}
	if out.SnapToken != "tok-123" {
		t.Errorf("snap token = %s, want tok-123", out.SnapToken)
	}
	if st.calls != 1 {
		t.Fatalf("gateway calls = %d, want 1", st.calls)
	}
}

func TestSnapToken_RetryGeneratesFreshRef(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := snapSvcWithStub(t, s)
	// Initial call
	_, err := s.SnapToken(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	initialRef := "KM-INV-T-1-" + itoa2(o.ID)
	fs := s.store.(*fakeStore)
	if *fs.invoices[invID].SnapOrderRef != initialRef {
		t.Fatalf("initial ref = %s, want %s", *fs.invoices[invID].SnapOrderRef, initialRef)
	}

	// Retry call (e.g. buyer closed the modal and clicks pay again)
	out, err := s.SnapToken(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.SnapToken != "tok-123" {
		t.Errorf("retry result = %+v, want tok-123", out)
	}
	retryRef := *fs.invoices[invID].SnapOrderRef
	if retryRef == initialRef {
		t.Errorf("retry ref should be different from initial ref to avoid Midtrans duplicate order_id, got %s", retryRef)
	}
	if !strings.HasPrefix(retryRef, initialRef+"-") {
		t.Errorf("retry ref = %s, want prefix %s-", retryRef, initialRef)
	}
	if st.calls != 2 {
		t.Errorf("gateway calls = %d, want 2", st.calls)
	}
}

// The ERP packing list ("Menunggu Dikemas" + Mulai) must not offer the courier
// packing chain for a store-pickup order: its goods change hands at the POS
// till and picking/shipping never run. Reported against SO pickup rows that
// showed Menunggu Dikemas with a Mulai action.
func TestListExcludePickup_HidesPickupFromPackingList(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	pickupAddr := "[Ambil Sendiri di Toko] Gudang Pusat Medan · Pengambil: QA (0812)"
	deliveryAddr := "Jl. Ahmad Yani No. 50, Medan, Kota Medan, Sumatera Utara, 20123"

	pickup, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items:           []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
		ShippingAddress: &pickupAddr,
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	delivery, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items:           []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
		ShippingAddress: &deliveryAddr,
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}

	// Packing-list call: exclude_pickup set (ERP sends ?exclude_pickup=1).
	pickupList, _, err := s.List(context.Background(), SOFilter{
		Status: "pending_approval", ExcludePickup: true, PerPage: 50,
	})
	if err != nil {
		t.Fatal(err)
	}
	for _, r := range pickupList {
		if r.ID == pickup.ID {
			t.Errorf("pickup order %s appeared on the packing list with exclude_pickup", r.SoNumber)
		}
	}
	found := false
	for _, r := range pickupList {
		if r.ID == delivery.ID {
			found = true
		}
	}
	if !found {
		t.Errorf("delivery order %s vanished from the packing list — the filter is too broad", delivery.SoNumber)
	}

	// Without the flag both remain visible (sales desk keeps its surface).
	both, _, err := s.List(context.Background(), SOFilter{Status: "pending_approval", PerPage: 50})
	if err != nil {
		t.Fatal(err)
	}
	seenPickup, seenDelivery := false, false
	for _, r := range both {
		if r.ID == pickup.ID {
			seenPickup = true
		}
		if r.ID == delivery.ID {
			seenDelivery = true
		}
	}
	if !seenPickup || !seenDelivery {
		t.Errorf("without exclude_pickup both orders must be listed (pickup=%v delivery=%v)", seenPickup, seenDelivery)
	}
}

// The auto-DO wiring in cmd/server (onOrderConfirmed) must skip store-pickup
// orders: a DO for a pickup put it on the dispatch map as "Sedang Diantar"
// even though the goods change hands at the POS till. This pins the exported
// predicate that wiring gates on, against the exact marker checkout writes.
func TestIsPickupOrder_MatchesCheckoutMarker(t *testing.T) {
	pickup := "[Ambil Sendiri di Toko] Gudang Pusat Medan · Pengambil: amba altukam (083344556677)"
	delivery := "amba altukam (083344556677), Jalan Manyar Raya 438, Kenjeran, Surabaya"

	if !IsPickupOrder(&SalesOrder{ShippingAddress: strPtr(pickup)}) {
		t.Error("pickup marker not detected — auto-DO would create a DO for a pickup again")
	}
	if IsPickupOrder(&SalesOrder{ShippingAddress: strPtr(delivery)}) {
		t.Error("delivery address flagged as pickup — its DO would never be created")
	}
	if IsPickupOrder(&SalesOrder{}) {
		t.Error("nil/empty shipping address flagged as pickup")
	}
	// Case-insensitive: checkout lowercases nothing, buyers type anything.
	if !IsPickupOrder(&SalesOrder{ShippingAddress: strPtr("[AMBIL SENDIRI DI TOKO] Outlet")}) {
		t.Error("uppercase marker not detected")
	}
}

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

func (f *fakeStore) SetReceivedProofURL(_ context.Context, _ db.DBTX, id int64, url string) error {
	o, ok := f.orders[id]
	if !ok {
		return ErrNotFound
	}
	o.ReceivedProofURL = &url
	return nil
}

func (f *fakeStore) IsStoreActive(_ context.Context, _ db.DBTX, storeID int64) (bool, error) {
	if f.IsStoreActiveFn != nil {
		return f.IsStoreActiveFn(storeID), nil
	}
	return true, nil
}

func (f *fakeStore) IsUserInActiveStore(_ context.Context, _ db.DBTX, userID, storeID int64) (bool, error) {
	if f.IsUserInActiveStoreFn != nil {
		return f.IsUserInActiveStoreFn(userID, storeID), nil
	}
	return true, nil
}

func (f *fakeStore) ActiveStoresForUser(context.Context, db.DBTX, int64) ([]int64, error) {
	return []int64{1}, nil
}

func (f *fakeStore) ShiftStoreID(_ context.Context, _ db.DBTX, _ int64) (*int64, error) {
	id := int64(1)
	return &id, nil
}

func (f *fakeStore) SetShiftStoreID(_ context.Context, _ db.DBTX, _ int64, _ int64) error {
	return nil
}

// Delivery/pickup proof (000142): evidence, not a gate. Owner or staff may
// attach while the order is pre-terminal; the URL must point at this
// backend's own uploads store.
func TestAttachReceivedProof(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	addr := "[Ambil Sendiri di Toko] Gudang Pusat Medan · Pengambil: QA (0812)"
	notes := "[Metode: cash]"
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items:           []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
		ShippingAddress: &addr,
		Notes:           &notes,
		PaymentMethod:   "cash",
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}

	ownerCtx := auth.ToContext(context.Background(), &auth.User{ID: 42, Role: auth.RoleBuyer}, 1)
	staffCtx := auth.ToContext(context.Background(), &auth.User{ID: 7, Role: auth.RoleCashier}, 1)
	otherCtx := auth.ToContext(context.Background(), &auth.User{ID: 99, Role: auth.RoleBuyer}, 1)

	// The buyer attaches while the order is pending: allowed.
	up, err := s.AttachReceivedProof(ownerCtx, o.ID, 42, "/uploads/received/proof-1.jpg")
	if err != nil {
		t.Fatalf("owner attach: %v", err)
	}
	if up.ReceivedProofURL == nil || *up.ReceivedProofURL != "/uploads/received/proof-1.jpg" {
		t.Errorf("received_proof_url = %v, want the uploaded path", up.ReceivedProofURL)
	}

	// A non-owner buyer is refused.
	if _, err := s.AttachReceivedProof(otherCtx, o.ID, 99, "/uploads/received/x.jpg"); !errors.Is(err, ErrNotFound) {
		t.Errorf("non-owner err = %v, want ErrNotFound", err)
	}

	// Staff may attach on the buyer's behalf.
	if _, err := s.AttachReceivedProof(staffCtx, o.ID, 7, "/uploads/received/proof-2.jpg"); err != nil {
		t.Errorf("staff attach: %v", err)
	}

	// An external URL is refused — the reference must come from this
	// backend's own upload pipeline.
	if _, err := s.AttachReceivedProof(ownerCtx, o.ID, 42, "https://evil.example/proof.jpg"); err == nil {
		t.Error("external URL accepted")
	}

	// After delivery the order is terminal: no more evidence writes. Path:
	// admin accepts (pending_approval → confirmed), then the buyer confirms
	// receipt (shipped → delivered is the buyer lane; here the pickup cash
	// handover fast-path belongs to the cashier, so drive it as admin instead
	// — both end the order in delivered).
	adminCtx := auth.ToContext(context.Background(), &auth.User{ID: 5, Role: auth.RoleAdmin}, 1)
	if _, err := s.Confirm(adminCtx, o.ID); err != nil {
		t.Fatalf("confirm: %v", err)
	}
	if _, err := s.AdvanceFulfillment(adminCtx, o.ID, "delivered"); err != nil {
		t.Fatalf("advance to delivered: %v", err)
	}
	if _, err := s.AttachReceivedProof(ownerCtx, o.ID, 42, "/uploads/received/late.jpg"); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("terminal-order err = %v, want ErrInvalidStatus", err)
	}
}

func TestService_ListOrders_Sorting(t *testing.T) {
	st := newFakeStore()
	st.orders[1] = &SalesOrder{
		ID:          1,
		SoNumber:    "SO-001",
		OrderDate:   "2026-11-01",
		Status:      "confirmed",
		TotalAmount: decimal.NewFromInt(50000),
	}
	st.orders[2] = &SalesOrder{
		ID:          2,
		SoNumber:    "SO-002",
		OrderDate:   "2026-11-03",
		Status:      "delivered",
		TotalAmount: decimal.NewFromInt(20000),
	}
	st.orders[3] = &SalesOrder{
		ID:          3,
		SoNumber:    "SO-003",
		OrderDate:   "2026-11-02",
		Status:      "picking",
		TotalAmount: decimal.NewFromInt(100000),
	}

	s := NewService(st, &fakeSeq{}, nil)
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 1, Role: auth.RoleAdmin}, 1)

	// 1. Sort by total_amount asc
	res, _, err := s.List(ctx, SOFilter{SortBy: "total_amount", SortDir: "asc"})
	if err != nil {
		t.Fatalf("list err: %v", err)
	}
	if len(res) != 3 {
		t.Fatalf("expected 3 orders, got %d", len(res))
	}
	if res[0].ID != 2 || res[1].ID != 1 || res[2].ID != 3 {
		t.Errorf("expected order [2, 1, 3] by total_amount asc, got [%d, %d, %d]", res[0].ID, res[1].ID, res[2].ID)
	}

	// 2. Sort by total_amount desc
	res, _, err = s.List(ctx, SOFilter{SortBy: "total_amount", SortDir: "desc"})
	if err != nil {
		t.Fatalf("list err: %v", err)
	}
	if res[0].ID != 3 || res[1].ID != 1 || res[2].ID != 2 {
		t.Errorf("expected order [3, 1, 2] by total_amount desc, got [%d, %d, %d]", res[0].ID, res[1].ID, res[2].ID)
	}

	// 3. Sort by order_date asc
	res, _, err = s.List(ctx, SOFilter{SortBy: "order_date", SortDir: "asc"})
	if err != nil {
		t.Fatalf("list err: %v", err)
	}
	if res[0].ID != 1 || res[1].ID != 3 || res[2].ID != 2 {
		t.Errorf("expected order [1, 3, 2] by order_date asc, got [%d, %d, %d]", res[0].ID, res[1].ID, res[2].ID)
	}

	// 4. Sort normalization: unknown column safely falls back
	by, dir := normalizeSOSort("unknown_col", "desc")
	if by != "" || dir != "" {
		t.Errorf("expected empty sort for unknown column, got (%q, %q)", by, dir)
	}
}

type fakeShiftGuard struct{}

func (f *fakeShiftGuard) LockOpenShift(context.Context, db.DBTX, int64) error { return nil }

func TestProcessPOS_RejectsInactiveStore_AndUnassignedAdmin(t *testing.T) {
	s := svc()
	fs := s.store.(*fakeStore)
	fs.stocks["1:101"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 101, Quantity: decimal.NewFromInt(10)}
	shiftID := int64(10)
	s.shiftGuard = &fakeShiftGuard{}

	adminCtx := auth.ToContext(context.Background(), &auth.User{ID: 5, Role: auth.RoleAdmin}, 1)

	// 1. Inactive store rejection
	inactiveStoreID := int64(99)
	fs.IsStoreActiveFn = func(storeID int64) bool {
		return storeID != inactiveStoreID
	}
	_, _, err := s.ProcessPOS(adminCtx, POSInput{
		CustomerID:    1,
		StoreID:       &inactiveStoreID,
		ShiftID:       &shiftID,
		PaymentMethod: "cash",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(1)},
		},
	})
	if err == nil || !strings.Contains(err.Error(), "toko sedang nonaktif") {
		t.Fatalf("expected inactive store error, got %v", err)
	}

	// 2. Unassigned Admin in POS rejection ("admin jangan ada perlakuan khusus di pos")
	activeStoreID := int64(1)
	fs.IsStoreActiveFn = nil
	fs.IsUserInActiveStoreFn = func(userID, storeID int64) bool {
		return false // user not assigned
	}
	_, _, err = s.ProcessPOS(adminCtx, POSInput{
		CustomerID:    1,
		StoreID:       &activeStoreID,
		ShiftID:       &shiftID,
		PaymentMethod: "cash",
		Items: []POSItemInput{
			{ProductID: 1, Quantity: decimal.NewFromInt(1)},
		},
	})
	if !errors.Is(err, ErrForbidden) {
		t.Fatalf("expected ErrForbidden for unassigned admin in POS, got %v", err)
	}
}
