// Package sales implements sales orders + POS (business-rules §2). Regular SO
// reserves stock (draft -> confirm -> cancel); POS deducts stock in one
// transaction (negative stock allowed) and auto-invoices. Codes:
// SO/POS/CSG-YYYYMMDD-<4> by order_type (§1). All writes inside a use case run
// on the caller's tx; repository reads use the pool.
package sales

import (
	"context"
	"encoding/json"
	"strconv"
	"strings"
	"time"

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

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

// SalesOrder mirrors sales_orders.
type SalesOrder struct {
	ID         int64  `json:"id"`
	SoNumber   string `json:"so_number"`
	CustomerID int64  `json:"customer_id"`
	// Display names resolved by the detail query (LIVE-AUTHZ/Clients 2026-08-26):
	// the ERP detail page previously guessed these fields and rendered blanks.
	CustomerName  *string `json:"customer_name,omitempty"`
	SalesName     *string `json:"sales_name,omitempty"`
	WarehouseName *string `json:"warehouse_name,omitempty"`
	// List view only: cashier who rang the sale (via its shift), plus line and
	// unit counts so the POS transaction list needs no per-row item fetch.
	CashierName *string          `json:"cashier_name,omitempty"`
	LineCount   int              `json:"line_count,omitempty"`
	ItemQty     *decimal.Decimal `json:"item_qty,omitempty"`
	WarehouseID *int64           `json:"warehouse_id"`
	SalesID     *int64           `json:"sales_id"`
	// Approval routing (migration 000043): the atasan the order was routed to,
	// resolved by Service.attachSupervisors (not part of the sqlc row).
	SupervisorID   *int64  `json:"supervisor_id"`
	SupervisorName *string `json:"supervisor_name,omitempty"`
	StoreID        *int64  `json:"store_id"`
	OrderDate      string  `json:"order_date"`
	Status         string  `json:"status"`
	PaymentStatus  string  `json:"payment_status"`
	// PaymentMethod is the code checkout recorded in the notes marker
	// ("[Metode: cash]") — cash / transfer / gateway / qris. The till needs the
	// method itself: an unpaid QRIS order is not a bank transfer, and the
	// pickup screen used to guess from "is it cash?" and label everything else
	// "Menunggu Transfer". Empty when no marker was written (legacy rows).
	PaymentMethod string `json:"payment_method,omitempty"`
	OrderType      string  `json:"order_type"`
	// PaymentTerms is the customer's TOP snapshotted at order creation (days).
	// 0 = cash / due immediately; >0 = credit terms.
	PaymentTerms int32 `json:"payment_terms"`
	// SourceType classifies WHERE the order came from (business rules §sales):
	// NOO = new outlet order, EC = result of a sales visit (same day),
	// AO = placed from the office. NULL for pos/ecommerce channels.
	SourceType      *string         `json:"source_type"`
	Subtotal        decimal.Decimal `json:"subtotal"`
	TaxAmount       decimal.Decimal `json:"tax_amount"`
	DiscountAmount  decimal.Decimal `json:"discount_amount"`
	TotalAmount     decimal.Decimal `json:"total_amount"`
	PaidAmount      decimal.Decimal `json:"paid_amount"`
	RemainingAmount decimal.Decimal `json:"remaining_amount"`
	ShippingCost    decimal.Decimal `json:"shipping_cost"`
	ShippingAddress *string         `json:"shipping_address"`
	Notes           *string         `json:"notes"`
	// ReceivedProofURL is the buyer-submitted photo taken when the goods
	// change hands (delivery handover or store pickup). Uploaded via
	// POST /api/v1/uploads (image types, 5 MB cap) and attached at
	// confirm-receipt time; nil when the buyer confirmed without one.
	ReceivedProofURL *string         `json:"received_proof_url,omitempty"`
	DeliveryDate     *string         `json:"delivery_date"`
	Items           []SOItem        `json:"items,omitempty"`
	Invoice         *Invoice        `json:"invoice,omitempty"`
	// Snap rides only on deferred-gateway (B2B) responses: token + redirect URL
	// the storefront opens for the customer. Nil otherwise.
	Snap *SnapTokenResult `json:"snap,omitempty"`
	// Qris rides only on POS QRIS responses: the QR image URL and the provider
	// deadline, which the till renders itself. Deliberately NOT a page — this is
	// why POS left Snap, whose redirect URL could only be shown inside a frame
	// with a fixed-width provider column.
	Qris      *QRISResult `json:"qris,omitempty"`
	CreatedAt time.Time   `json:"created_at"`
	UpdatedAt time.Time   `json:"updated_at"`
}

// SOItem mirrors sales_order_items (quantity is NUMERIC(12,2)). The unit and
// pricing fields are an immutable snapshot: quantity is expressed in the
// selected unit, BaseQuantity is what inventory moved, and the DB rejects any
// UPDATE that changes them so returns replay the original pricing.
type SOItem struct {
	ID               int64           `json:"id"`
	SalesOrderID     int64           `json:"sales_order_id"`
	ProductID        int64           `json:"product_id"`
	Quantity         decimal.Decimal `json:"quantity"`
	UnitPrice        decimal.Decimal `json:"unit_price"`
	DiscountPercent  decimal.Decimal `json:"discount_percent"`
	DiscountAmount   decimal.Decimal `json:"discount_amount"`
	Subtotal         decimal.Decimal `json:"subtotal"`
	ProductUnitID    *int64          `json:"product_unit_id"`
	UnitID           *int64          `json:"unit_id"`
	UnitName         string          `json:"unit_name"`
	UnitSymbol       string          `json:"unit_symbol"`
	ConversionFactor decimal.Decimal `json:"conversion_factor"`
	BaseQuantity     decimal.Decimal `json:"base_quantity"`
	MinPurchase      decimal.Decimal `json:"min_purchase"`
	// ReturnedQty is attached only by ForReturnLookup: how much of this line
	// was already returned, so the POS dialog clamps to the remainder (P2).
	// Zero elsewhere — never trust it for money, the service re-sums.
	ReturnedQty decimal.Decimal `json:"returned_qty"`
	ProductCode string          `json:"product_code,omitempty"`
	ProductName string          `json:"product_name,omitempty"`
	// Membership benefit snapshot (§13.1): persisted into the immutable line so
	// returns replay the original discount.
	MembershipTier           string          `json:"membership_tier"`
	MembershipDiscountAmount decimal.Decimal `json:"membership_discount_amount"`
}

// baseQty is the quantity to move in stock. Lines written before the pricing
// engine (and test fakes that leave the snapshot empty) fall back to quantity.
func (it *SOItem) baseQty() decimal.Decimal {
	if it.BaseQuantity.GreaterThan(decimal.Zero) {
		return it.BaseQuantity
	}
	return it.Quantity
}

// SOFilter carries pagination/search for List.
// OrderType filters by sales_orders.order_type (regular|pos|consignment);
// accepted query keys are order_type and legacy type alias (?type).
// PaymentStatus is derived (paid/partial/unpaid) and filtered in-memory;
// empty or unknown values are treated as no-filter (permissive, never 400).
// Search accepts ?search and alias ?q.
type SOFilter struct {
	Page          int
	PerPage       int
	Search        string
	Status        string
	PaymentStatus string // derived, permissive; empty/unknown => no filter
	CustomerID    int64
	OrderType     string // regular|pos|consignment — maps to ?order_type (alias ?type)
	// SourceType filter: NOO|EC|AO (order source classification)
	SourceType string
	DateFrom   string
	DateTo     string
	// StoreID is an optional request filter (?store_id=); tenant
	// isolation is enforced on top via AllowedStoreIDs.
	StoreID *int64
	// AllowedStoreIDs is the caller's tenant scope (nil = platform-wide);
	// set by the service from auth.StoreScopeFrom, never from request input.
	AllowedStoreIDs []int64
	// ExcludeUnapproved filters out draft, pending_approval, and blocked_overdue orders (for warehouse callers).
	ExcludeUnapproved bool
	// ExcludePickup filters out store-pickup orders (Ambil Sendiri di Toko).
	// The warehouse packing list sets it: pickup orders are handed over at the
	// POS till and never enter the courier picking/shipping chain, so listing
	// them as "Menunggu Dikemas" offered an action that must not run.
	ExcludePickup bool
	// PickupOnly keeps ONLY store-pickup orders. The POS "Ambil Toko" list
	// sets it so the filter runs in SQL, before LIMIT/OFFSET — filtering the
	// page in memory would hide pickups that sit behind delivery rows and
	// would report a total that disagrees with the pager.
	PickupOnly bool
	// SortBy and SortDir govern order listing sort (so_number, customer_name,
	// sales_name, order_date, status, total_amount, created_at).
	SortBy  string
	SortDir string
}

type SOItemInput struct {
	ProductID       int64           `json:"product_id"`
	ProductUnitID   *int64          `json:"product_unit_id"`
	Quantity        decimal.Decimal `json:"quantity"`
	UnitPrice       decimal.Decimal `json:"unit_price"`
	DiscountPercent decimal.Decimal `json:"discount_percent"`
}

// SOInput mirrors the regular order request body (§2.1).
type SOInput struct {
	CustomerID      int64           `json:"customer_id"`
	WarehouseID     *int64          `json:"warehouse_id"`
	OrderDate       string          `json:"order_date"`
	OrderType       *string         `json:"order_type"`
	TaxPercent      decimal.Decimal `json:"tax_percent"`
	DiscountAmount  decimal.Decimal `json:"discount_amount"`
	ShippingCost    decimal.Decimal `json:"shipping_cost"`
	ShippingAddress *string         `json:"shipping_address"`
	Notes           *string         `json:"notes"`
	Items           []SOItemInput   `json:"items"`
	// StoreID binds the order to a tenant store; scope-checked by the service.
	StoreID *int64 `json:"store_id"`
	// SourceType: NOO | EC | AO (order source classification; validated).
	SourceType *string `json:"source_type"`
	// PaymentMethod optionally sets the order's payment method (cash, transfer, tempo, cod).
	PaymentMethod *string `json:"payment_method"`
	// PaymentTerms is an optional override. When nil, the service will fetch the
	// customer's payment_terms and snapshot that value.
	PaymentTerms *int32 `json:"payment_terms"`
	// Sales-approval routing (migration 000043). When SubmitForApproval is set
	// the order is stored as 'pending_approval' (waiting for the atasan) instead
	// of 'draft'; SupervisorID records which atasan it was routed to and
	// IsUrgent carries the "Urgent Orderan" flag from the create form.
	SubmitForApproval bool   `json:"submit_for_approval"`
	SupervisorID      *int64 `json:"supervisor_id"`
	IsUrgent          bool   `json:"is_urgent"`
}

// SupervisorOption is an active user who has authority to approve sales orders.
type SupervisorOption struct {
	ID        int64  `json:"id"`
	Name      string `json:"name"`
	Role      string `json:"role"`
	RoleLabel string `json:"role_label"`
}

// OrderSupervisor is the atasan an order was routed to for approval.
type OrderSupervisor struct {
	ID   int64
	Name string
}

// POSInput mirrors the POS request body (§2.2).
type POSInput struct {
	CustomerID    int64            `json:"customer_id"`
	WarehouseID   int64            `json:"warehouse_id"`
	PaymentMethod string           `json:"payment_method"`
	CashReceived  *decimal.Decimal `json:"cash_received"`
	Items         []POSItemInput   `json:"items"`
	// ShiftID links the POS order to an open cashier shift (§11.5) so the
	// shift's expected cash includes this sale. Optional.
	ShiftID *int64 `json:"shift_id"`
	// StoreID binds the order to a tenant store (multi-tenant §5.4).
	// Optional for platform-wide callers; restricted callers may only pass
	// a store inside their scope (enforced by the service, not here).
	StoreID *int64 `json:"store_id"`
	// DiscountAmount is an optional order-level discount (same semantics as
	// SOInput.DiscountAmount §2.1). Mutually exclusive with VoucherCode.
	DiscountAmount *decimal.Decimal `json:"discount_amount"`
	// VoucherCode redeems a sales voucher (§13.3); it is consumed in the same
	// transaction as the order. Mutually exclusive with DiscountAmount > 0.
	VoucherCode *string `json:"voucher_code"`
	// Notes is the cashier's free-text order note (P2: previously dropped by
	// the till). Persisted on the order, never priced.
	Notes *string `json:"notes"`
	// FulfillmentMode: "pickup" (ambil di kasir, default) or "delivery"
	// (pengantaran kurir/staf toko). Delivery REQUIRES ShippingAddress and
	// routes the order through the warehouse DO flow instead of till
	// handover (2 Nov flow audit, item 4).
	FulfillmentMode string `json:"fulfillment_mode"`
	// ShippingAddress is the delivery address for fulfillment_mode=delivery.
	ShippingAddress *string `json:"shipping_address"`
}

// UnmarshalJSON tolerates camelCase aliases, string numeric ids ("cust-000",
// "1") and extra fields from ERP/POS frontends. Unknown fields ignored.
// String customerId like "cust-000" resolves to 0 so caller can fallback to CUS-000.
func (p *POSInput) UnmarshalJSON(data []byte) error {
	type raw struct {
		CustomerID     *int64           `json:"customer_id"`
		CustomerIDAlt  *int64           `json:"customerId"`
		WarehouseID    *int64           `json:"warehouse_id"`
		WarehouseAlt   *int64           `json:"warehouseId"`
		PaymentMethod  *string          `json:"payment_method"`
		PaymentAlt     *string          `json:"paymentMethod"`
		CashReceived   *decimal.Decimal `json:"cash_received"`
		CashAlt        *decimal.Decimal `json:"cashReceived"`
		PaidAlt        *decimal.Decimal `json:"paid_amount"`
		PaidAlt2       *decimal.Decimal `json:"amountPaid"`
		Items          []POSItemInput   `json:"items"`
		ShiftID        *int64           `json:"shift_id"`
		ShiftAlt       *int64           `json:"shiftId"`
		StoreID        *int64           `json:"store_id"`
		DiscountAmount *decimal.Decimal `json:"discount_amount"`
		DiscountAlt    *decimal.Decimal `json:"discountAmount"`
		VoucherCode    *string          `json:"voucher_code"`
		VoucherAlt     *string          `json:"voucherCode"`
		Notes          *string          `json:"notes"`
		NotesAlt       *string          `json:"notesAlt"`
		Fulfillment    *string          `json:"fulfillment_mode"`
		FulfillmentAlt *string          `json:"fulfillmentMode"`
		ShippingAddr   *string          `json:"shipping_address"`
		ShippingAlt    *string          `json:"shippingAddress"`
	}
	var r raw
	var m map[string]json.RawMessage
	if err := json.Unmarshal(data, &m); err != nil {
		return err
	}
	filtered, _ := json.Marshal(m)
	if err := json.Unmarshal(filtered, &r); err != nil {
		// If strict int64 decode failed (e.g. "cust-000"), retry with flexible string ids
		// so walk-in still reaches the service fallback instead of 400.
		var flex map[string]any
		if err2 := json.Unmarshal(data, &flex); err2 != nil {
			return err
		}
		if v, ok := flex["customer_id"]; ok {
			if n := parseFlexibleID(v); n != nil {
				r.CustomerID = n
			}
		}
		if r.CustomerID == nil {
			if v, ok := flex["customerId"]; ok {
				if n := parseFlexibleID(v); n != nil {
					r.CustomerIDAlt = n
				}
			}
		}
		// Re-attempt full decode without customer fields to get the rest
		delete(m, "customer_id")
		delete(m, "customerId")
		filtered2, _ := json.Marshal(m)
		_ = json.Unmarshal(filtered2, &r)
	}
	if r.CustomerID != nil {
		p.CustomerID = *r.CustomerID
	} else if r.CustomerIDAlt != nil {
		p.CustomerID = *r.CustomerIDAlt
	}
	if r.WarehouseID != nil {
		p.WarehouseID = *r.WarehouseID
	} else if r.WarehouseAlt != nil {
		p.WarehouseID = *r.WarehouseAlt
	}
	if r.PaymentMethod != nil {
		p.PaymentMethod = *r.PaymentMethod
	} else if r.PaymentAlt != nil {
		p.PaymentMethod = *r.PaymentAlt
	}
	if p.PaymentMethod == "tunai" {
		p.PaymentMethod = "cash"
	}
	if r.CashReceived != nil {
		p.CashReceived = r.CashReceived
	} else if r.CashAlt != nil {
		p.CashReceived = r.CashAlt
	} else if r.PaidAlt != nil {
		p.CashReceived = r.PaidAlt
	} else if r.PaidAlt2 != nil {
		p.CashReceived = r.PaidAlt2
	}
	p.Items = r.Items
	if r.ShiftID != nil {
		p.ShiftID = r.ShiftID
	} else if r.ShiftAlt != nil {
		p.ShiftID = r.ShiftAlt
	}
	if r.StoreID != nil {
		p.StoreID = r.StoreID
	}
	if r.DiscountAmount != nil {
		p.DiscountAmount = r.DiscountAmount
	} else if r.DiscountAlt != nil {
		p.DiscountAmount = r.DiscountAlt
	}
	if r.VoucherCode != nil {
		p.VoucherCode = r.VoucherCode
	} else if r.VoucherAlt != nil {
		p.VoucherCode = r.VoucherAlt
	}
	if r.Notes != nil {
		p.Notes = r.Notes
	} else if r.NotesAlt != nil {
		p.Notes = r.NotesAlt
	}
	if r.Fulfillment != nil {
		p.FulfillmentMode = *r.Fulfillment
	} else if r.FulfillmentAlt != nil {
		p.FulfillmentMode = *r.FulfillmentAlt
	}
	if r.ShippingAddr != nil && strings.TrimSpace(*r.ShippingAddr) != "" {
		p.ShippingAddress = r.ShippingAddr
	} else if r.ShippingAlt != nil && strings.TrimSpace(*r.ShippingAlt) != "" {
		p.ShippingAddress = r.ShippingAlt
	}
	return nil
}

func parseFlexibleID(v any) *int64 {
	switch x := v.(type) {
	case float64:
		n := int64(x)
		return &n
	case string:
		s := strings.TrimSpace(x)
		if s == "" || s == "cust-000" || s == "CUS-000" {
			z := int64(0)
			return &z
		}
		if n, err := strconv.ParseInt(s, 10, 64); err == nil {
			return &n
		}
		return nil
	case int64:
		return &x
	case int:
		n := int64(x)
		return &n
	}
	return nil
}

type POSItemInput struct {
	ProductID     int64           `json:"product_id"`
	ProductUnitID *int64          `json:"product_unit_id"`
	Quantity      decimal.Decimal `json:"quantity"`
	UnitPrice     decimal.Decimal `json:"unit_price"`
}

func (it *POSItemInput) UnmarshalJSON(data []byte) error {
	type raw struct {
		ProductID    *int64           `json:"product_id"`
		ProductAlt   *int64           `json:"productId"`
		ProductAlt2  *int64           `json:"productID"`
		UnitID       *int64           `json:"product_unit_id"`
		UnitAlt      *int64           `json:"productUnitId"`
		Quantity     *decimal.Decimal `json:"quantity"`
		QtyAlt       *decimal.Decimal `json:"qty"`
		UnitPrice    *decimal.Decimal `json:"unit_price"`
		PriceAlt     *decimal.Decimal `json:"price"`
		UnitPriceAlt *decimal.Decimal `json:"unitPrice"`
	}
	var r raw
	var m map[string]json.RawMessage
	if err := json.Unmarshal(data, &m); err != nil {
		return err
	}
	filtered, _ := json.Marshal(m)
	if err := json.Unmarshal(filtered, &r); err != nil {
		return err
	}
	if r.ProductID != nil {
		it.ProductID = *r.ProductID
	} else if r.ProductAlt != nil {
		it.ProductID = *r.ProductAlt
	} else if r.ProductAlt2 != nil {
		it.ProductID = *r.ProductAlt2
	}
	if r.UnitID != nil {
		it.ProductUnitID = r.UnitID
	} else if r.UnitAlt != nil {
		it.ProductUnitID = r.UnitAlt
	}
	if r.Quantity != nil {
		it.Quantity = *r.Quantity
	} else if r.QtyAlt != nil {
		it.Quantity = *r.QtyAlt
	}
	if r.UnitPrice != nil {
		it.UnitPrice = *r.UnitPrice
	} else if r.PriceAlt != nil {
		it.UnitPrice = *r.PriceAlt
	} else if r.UnitPriceAlt != nil {
		it.UnitPrice = *r.UnitPriceAlt
	}
	return nil
}

// CheckoutInput mirrors a B2B cart checkout request. Quantities/prices are
// validated server-side against live stock and product selling prices.
type CheckoutInput struct {
	WarehouseID     int64               `json:"warehouse_id"`
	StoreID         *int64              `json:"store_id"` // outlet that fulfils this online order; unset → first active store
	PaymentMethod   string              `json:"payment_method"`
	ShippingAddress *string             `json:"shipping_address"`
	Notes           *string             `json:"notes"`
	Items           []CheckoutItemInput `json:"items"`
	// VoucherCode optionally redeems a sales voucher (same redemption rules as
	// POS §2.2: locked FOR UPDATE, burned only when the whole tx commits).
	VoucherCode *string `json:"voucher_code"`
}

type CheckoutItemInput struct {
	ProductID     int64           `json:"product_id"`
	ProductUnitID *int64          `json:"product_unit_id"`
	Quantity      decimal.Decimal `json:"quantity"`
}

// PayCheckoutInput mirrors the buyer self-service payment request.
type PayCheckoutInput struct {
	PaymentMethod string `json:"payment_method"`
}

// Invoice mirrors invoices (auto-created for POS and against confirmed/delivered SOs).
type Invoice struct {
	ID              int64           `json:"id"`
	InvoiceNumber   string          `json:"invoice_number"`
	SalesOrderID    int64           `json:"sales_order_id"`
	CustomerID      int64           `json:"customer_id"`
	InvoiceDate     string          `json:"invoice_date"`
	DueDate         string          `json:"due_date"`
	Status          string          `json:"status"`
	Subtotal        decimal.Decimal `json:"subtotal"`
	TaxAmount       decimal.Decimal `json:"tax_amount"`
	TotalAmount     decimal.Decimal `json:"total_amount"`
	PaidAmount      decimal.Decimal `json:"paid_amount"`
	RemainingAmount decimal.Decimal `json:"remaining_amount"`
	// SnapOrderRef is the Midtrans Snap order_id (invoices.snap_order_ref,
	// migration 000095). Set on first snap-token issue, reused on retry.
	SnapOrderRef *string `json:"snap_order_ref,omitempty"`
	// GatewayTransactionID is the Core API transaction_id (migration 000118).
	// Nil for Snap invoices. Not optional metadata: the QR image lives at
	// GET /v2/qris/{transaction_id}/qr-code, so this is the only route back to
	// the QR when a till page is reloaded mid-payment.
	GatewayTransactionID *string `json:"gateway_transaction_id,omitempty"`
	// GatewayExpiresAt is the PROVIDER's deadline, not a local one. The till
	// shows it as a countdown, so a locally computed 15 minutes would drift
	// from what Midtrans actually enforces.
	GatewayExpiresAt time.Time `json:"gateway_expires_at,omitempty"`
	// SnapLastCheckedAt is when the reconciler last PROBED this invoice at
	// Midtrans (invoices.snap_last_checked_at, migration 000102). It is a
	// throttle, never business state: a sweep writes no fact about the
	// invoice, which is why this is a separate column and never updated_at.
	SnapLastCheckedAt time.Time `json:"snap_last_checked_at,omitempty"`
}

// SnapSweepCandidate is one open Snap invoice the reconciler should ask Midtrans
// about. SalesOrderID travels with it because a settle attempt settles the
// order alongside the invoice.
type SnapSweepCandidate struct {
	ID            int64
	InvoiceNumber string
	SalesOrderID  int64
}

// Payment mirrors a payments row created for a paid POS sale.
type Payment struct {
	ID            int64           `json:"id"`
	PaymentNumber string          `json:"payment_number"`
	PayableType   string          `json:"payable_type"`
	PayableID     int64           `json:"payable_id"`
	PaymentDate   string          `json:"payment_date"`
	Amount        decimal.Decimal `json:"amount"`
	PaymentMethod string          `json:"payment_method"`
	Notes         *string         `json:"notes"`
	CreatedBy     *int64          `json:"created_by"`
}

// OrdersSummary aggregates order counts by e-commerce lifecycle phase.
type OrdersSummary struct {
	Pending    int `json:"pending"`
	Processing int `json:"processing"`
	Shipping   int `json:"shipping"`
	Delivered  int `json:"delivered"`
	Cancelled  int `json:"cancelled"`
}

// SalesOrdersSummary aggregates operational figures for the ERP sales order list widgets.
type SalesOrdersSummary struct {
	TodayCount           int             `json:"today_count"`
	TodayRevenue         decimal.Decimal `json:"today_revenue"`
	ProcessingCount      int             `json:"processing_count"`
	PendingApprovalCount int             `json:"pending_approval_count"`
}

// SalesVoucher represents a sales/member voucher or promotion (Phase 6 Item 4).
// Separate from purchasing vouchers — distinct lifecycle, identifiers, accounting
// meaning, and redemption history (§13.3 business-rules.md).
type SalesVoucher struct {
	ID            int64           `json:"id"`
	VoucherNumber string          `json:"voucher_number"`
	Type          string          `json:"type"` // "promotion", "member_discount", "sales_voucher"
	Description   *string         `json:"description"`
	VoucherDate   string          `json:"voucher_date"`
	Amount        decimal.Decimal `json:"amount"`
	Status        string          `json:"status"` // "available", "used", "expired"
	CreatedBy     *int64          `json:"created_by"`
	ExpiredAt     *string         `json:"expired_at"`
	CreatedAt     time.Time       `json:"created_at"`
	UpdatedAt     time.Time       `json:"updated_at"`
}

// SalesVoucherInput mirrors the create request body.
type SalesVoucherInput struct {
	VoucherNumber string          `json:"voucher_number"`
	Type          string          `json:"type"`
	Description   *string         `json:"description"`
	VoucherDate   string          `json:"voucher_date"`
	Amount        decimal.Decimal `json:"amount"`
	Status        string          `json:"status"`
	ExpiredAt     *string         `json:"expired_at"`
}

// SalesVoucherFilter carries pagination and search for List.
type SalesVoucherFilter struct {
	Page          int
	PerPage       int
	Status        string
	Type          string
	VoucherNumber string
}

// VoucherRedemption is the outcome when a sales voucher is applied to an order.
// It carries the discount amount of the adjustment.
type VoucherRedemption struct {
	SalesVoucherID int64
	DiscountAmount decimal.Decimal
}

// Return mirrors sales_returns (§11.4): a separate, immutable audit event
// referencing the original sales order and selected original lines.
type Return struct {
	ID           int64           `json:"id"`
	ReturnNumber string          `json:"return_number"`
	SalesOrderID int64           `json:"sales_order_id"`
	CustomerID   int64           `json:"customer_id"`
	WarehouseID  *int64          `json:"warehouse_id"`
	ReturnDate   string          `json:"return_date"`
	RefundAmount decimal.Decimal `json:"refund_amount"`
	RefundMethod string          `json:"refund_method"`
	Status       string          `json:"status"`
	Reason       *string         `json:"reason"`
	CreatedBy    *int64          `json:"created_by"`
	CreatedAt    time.Time       `json:"created_at"`
	UpdatedAt    time.Time       `json:"updated_at"`
	// Denormalized for list/detail display (joined in the read queries).
	SoNumber     string       `json:"so_number"`
	CustomerName string       `json:"customer_name"`
	Items        []ReturnItem `json:"items,omitempty"`
}

// ReturnItem mirrors sales_return_items.
type ReturnItem struct {
	ID               int64           `json:"id"`
	SalesReturnID    int64           `json:"sales_return_id"`
	SalesOrderItemID int64           `json:"sales_order_item_id"`
	ProductID        int64           `json:"product_id"`
	Quantity         decimal.Decimal `json:"quantity"`
	UnitPrice        decimal.Decimal `json:"unit_price"`
	RefundAmount     decimal.Decimal `json:"refund_amount"`
	Reason           *string         `json:"reason"`
	Condition        string          `json:"condition"`
	Restocked        bool            `json:"restocked"`
	// Joined for display; empty on create responses.
	ProductName string `json:"product_name,omitempty"`
	ProductCode string `json:"product_code,omitempty"`
	UnitSymbol  string `json:"unit_symbol"`
}

// ReturnInput is the create request body.
type ReturnInput struct {
	SalesOrderID int64             `json:"sales_order_id"`
	StoreID      *int64            `json:"store_id,omitempty"`
	RefundMethod string            `json:"refund_method"`
	Reason       *string           `json:"reason"`
	CreatedBy    *int64            `json:"created_by"`
	Items        []ReturnItemInput `json:"items"`
}

// ReturnItemInput is one requested return line.
type ReturnItemInput struct {
	SalesOrderItemID int64           `json:"sales_order_item_id"`
	Quantity         decimal.Decimal `json:"quantity"`
	Reason           *string         `json:"reason"`
	Condition        string          `json:"condition"`
}

// ReturnFilter carries pagination for the return list.
type ReturnFilter struct {
	Page            int
	PerPage         int
	SalesOrderID    int64
	CustomerID      int64
	StoreID         int64
	AllowedStoreIDs []int64
}

// ReturnLedger records a stock_in movement for a returned line inside the
// same tx. before/after come from the same locked stock mutation so the
// movement is exact under concurrency. Wired from main via
// Service.WithReturnLedger.
type ReturnLedger func(ctx context.Context, q db.DBTX, productID, warehouseID, returnID int64, before, after, qty decimal.Decimal) error

// Tx is a physical transaction; pgx.Tx satisfies it. It doubles as the
// db.DBTX handed to tx-scoped store writes.
type Tx interface {
	db.DBTX
	Commit(ctx context.Context) error
	Rollback(ctx context.Context) error
}

// POSLedger records a stock_out movement for a POS line inside the same tx.
// before/after come from the same locked stock mutation so the movement is
// exact under concurrency. Wired from main via Service.WithPOSLedger.
type POSLedger func(ctx context.Context, q db.DBTX, productID, warehouseID, orderID int64, before, after, qty decimal.Decimal) error

// PriceRequest is one line of transaction intent handed to the resolver.
type PriceRequest struct {
	ProductID     int64
	ProductUnitID *int64
	Quantity      decimal.Decimal
	// CustomerID opt-in: non-nil enables the membership benefit stage in the
	// resolver. Nil preserves the existing no-benefit behavior.
	CustomerID *int64
}

// PricedLine is the authoritative outcome for one line: the resolved unit, its
// conversion factor and price, and the base-unit quantity inventory must move.
type PricedLine struct {
	ProductID        int64
	ProductUnitID    *int64
	UnitID           *int64
	UnitName         string
	UnitSymbol       string
	ConversionFactor decimal.Decimal
	UnitPrice        decimal.Decimal
	MinPurchase      decimal.Decimal
	Quantity         decimal.Decimal
	BaseQuantity     decimal.Decimal
	// Membership benefit snapshot (§13.1), copied from the resolver outcome.
	MembershipTier           string
	MembershipDiscountAmount decimal.Decimal
}

// PriceResolver is the authoritative pricing seam (internal/pricing in
// production). Wired via Service.WithPricing; when absent the legacy
// product-selling-price path is used so parity fakes keep working. q is the
// caller's transaction so pricing reads never take a second pool connection.
type PriceResolver func(ctx context.Context, q db.DBTX, reqs []PriceRequest) ([]PricedLine, error)

// Store is the sales persistence seam (sqlc-backed).
type Store interface {
	// Begin starts a physical transaction (fakes: no-op).
	Begin(ctx context.Context) (Tx, error)

	// orders (q is a tx when the write must be atomic with stock ops)
	CreateOrder(ctx context.Context, q db.DBTX, p db.CreateSalesOrderParams) (db.CreateSalesOrderRow, error)
	UpdateOrder(ctx context.Context, q db.DBTX, p db.UpdateSalesOrderParams) error
	DeleteOrder(ctx context.Context, q db.DBTX, id int64) error
	OrderByID(ctx context.Context, id int64) (db.GetSalesOrderByIDRow, error)
	OrderByIDForUpdate(ctx context.Context, q db.DBTX, id int64) (db.GetSalesOrderByIDForUpdateRow, error)
	OrderShiftID(ctx context.Context, q db.DBTX, id int64) (pgtype.Int8, error)
	ListOrders(ctx context.Context, f SOFilter) ([]db.ListSalesOrdersRow, int, error)
	SalesOrdersSummary(ctx context.Context, allowedStoreIDs []int64) (SalesOrdersSummary, error)
	MyOrdersSummary(ctx context.Context, customerID int64) (db.MyOrdersSummaryRow, error)
	// SupervisorsByOrders maps order id -> routed atasan (orders without one are absent).
	SupervisorsByOrders(ctx context.Context, ids []int64) (map[int64]OrderSupervisor, error)

	// items
	CreateItem(ctx context.Context, q db.DBTX, p db.CreateSalesOrderItemParams) (int64, error)
	ItemsByOrder(ctx context.Context, orderID int64) ([]db.ListSalesOrderItemsRow, error)
	// ReturnedQtyByOrder maps sales_order_item_id -> already-returned
	// quantity, so the POS return dialog can clamp to the remaining qty
	// instead of the original line qty (P2).
	ReturnedQtyByOrder(ctx context.Context, orderID int64) (map[int64]decimal.Decimal, error)
	DeleteItems(ctx context.Context, q db.DBTX, orderID int64) error
	SetOrderStatus(ctx context.Context, q db.DBTX, id int64, status string) error
	// SetReceivedProofURL records the buyer's delivery/pickup proof photo URL
	// (migration 000142). Evidence only — never a confirm gate.
	SetReceivedProofURL(ctx context.Context, q db.DBTX, id int64, url string) error
	UpdateOrderNotes(ctx context.Context, q db.DBTX, id int64, notes pgtype.Text) error
	SetSalesOrderShift(ctx context.Context, q db.DBTX, orderID, shiftID int64) error

	// stock within a tx: callers lock in deterministic (product, warehouse)
	// order before mutating, so concurrent requests cannot oversell.
	LockStockForUpdate(ctx context.Context, q db.DBTX, productID, warehouseID int64) (db.GetSalesStockRowForUpdateRow, error)
	EnsureStockRow(ctx context.Context, q db.DBTX, productID, warehouseID int64) error
	SetStockQuantity(ctx context.Context, q db.DBTX, stockID int64, quantity, reserved decimal.Decimal) error

	// invoice
	CreateInvoice(ctx context.Context, q db.DBTX, p db.CreateSalesInvoiceParams) (int64, error)
	CreatePayment(ctx context.Context, q db.DBTX, p db.CreateSalesPaymentParams) (int64, error)
	CreateGatewayPayment(ctx context.Context, q db.DBTX, p db.CreateSalesPaymentParams, gateway, txnID, status string) (int64, error)
	CancelOrderInvoices(ctx context.Context, q db.DBTX, orderID int64) error
	// OpenInvoiceForOrder returns the order's unsettled (unpaid/partial) invoice
	// locked FOR UPDATE, or ErrNotFound when there is none.
	OpenInvoiceForOrder(ctx context.Context, q db.DBTX, orderID int64) (*Invoice, error)
	// InvoiceForOrder returns the order's invoice locked FOR UPDATE whatever its
	// status, or ErrNotFound when there is none. Read-only use: it distinguishes
	// a settled invoice from a written-off one, which OpenInvoiceForOrder
	// collapses into the same ErrNotFound.
	InvoiceForOrder(ctx context.Context, q db.DBTX, orderID int64) (*Invoice, error)
	InvoiceExistsForSO(ctx context.Context, q db.DBTX, soID int64) (bool, error)
	// MarkInvoicePaid settles an open invoice and returns the rows it changed:
	// 1 for a settleable invoice, 0 when it was already paid/cancelled/written
	// off. The count is a real answer the caller may branch on, not an error —
	// ignoring it keeps the old silent-no-op behaviour.
	MarkInvoicePaid(ctx context.Context, q db.DBTX, invoiceID int64, paid decimal.Decimal) (int64, error)
	SettleOrderPayment(ctx context.Context, q db.DBTX, orderID int64, amount decimal.Decimal) error
	// SetInvoiceSnapRef persists the Snap order_id (invoices.snap_order_ref).
	SetInvoiceSnapRef(ctx context.Context, q db.DBTX, invoiceID int64, ref string) error
	// SetInvoiceGatewayRef stamps the Midtrans order_id plus the Core API
	// transaction_id and provider expiry in one write. See the repository method.
	SetInvoiceGatewayRef(ctx context.Context, q db.DBTX, invoiceID int64, ref string, transactionID *string, expiresAt *time.Time) error
	// SetInvoiceSnapChecked stamps the reconciler's last probe
	// (invoices.snap_last_checked_at) inside the caller's transaction, so the
	// stamp commits or rolls back with the settle attempt. It never touches
	// updated_at.
	SetInvoiceSnapChecked(ctx context.Context, q db.DBTX, invoiceID int64) error
	// ListSnapSweepCandidates returns the open Snap invoices worth re-probing
	// against Midtrans, oldest activity first, capped at limit (the service
	// owns the policy clamp; the repository bounds it again).
	ListSnapSweepCandidates(ctx context.Context, limit int) ([]SnapSweepCandidate, error)
	// InvoiceBySnapRef resolves a webhook order_id to its invoice (locked FOR
	// UPDATE), matching exact refs and refs superseded by a SnapToken retry.
	InvoiceBySnapRef(ctx context.Context, q db.DBTX, ref string) (*Invoice, error)

	// lookups. q is the caller's transaction when the read happens inside one,
	// nil to read through the pool. Taking a pool connection while holding a
	// transaction deadlocks once concurrent callers exhaust the pool.
	CustomerExists(ctx context.Context, q db.DBTX, id int64) (bool, error)
	ProductExists(ctx context.Context, q db.DBTX, id int64) (bool, error)
	FirstActiveWarehouse(ctx context.Context, q db.DBTX) (int64, error)
	// WarehouseForStore returns the warehouse an outlet keeps its stock in
	// (every store owns exactly one — migration 000045). Used by the POS
	// pipeline so a sale deducts from the cashier's outlet, not a global pool.
	WarehouseForStore(ctx context.Context, q db.DBTX, storeID int64) (int64, error)
	// FirstActiveStore returns the lowest-id active outlet and its warehouse,
	// or ok=false when no store exists. Used to attribute an online order to a
	// default outlet when the checkout does not name one.
	FirstActiveStore(ctx context.Context, q db.DBTX) (storeID, warehouseID int64, ok bool, err error)
	ValidateCheckoutWarehouse(ctx context.Context, q db.DBTX, warehouseID int64) error
	IsStoreActive(ctx context.Context, q db.DBTX, storeID int64) (bool, error)
	IsUserInActiveStore(ctx context.Context, q db.DBTX, userID, storeID int64) (bool, error)
	ActiveStoresForUser(ctx context.Context, q db.DBTX, userID int64) ([]int64, error)
	ShiftStoreID(ctx context.Context, q db.DBTX, shiftID int64) (*int64, error)
	SetShiftStoreID(ctx context.Context, q db.DBTX, shiftID int64, storeID int64) error

	// e-commerce checkout support
	ProductSellingPrice(ctx context.Context, q db.DBTX, id int64) (decimal.Decimal, error)
	CustomerByUserID(ctx context.Context, q db.DBTX, userID int64) (int64, error)
	// CustomerAssignedSalesID returns the referral sales user linked to a
	// customer (nil = self-registered). Used to attribute checkout orders.
	CustomerAssignedSalesID(ctx context.Context, q db.DBTX, customerID int64) (*int64, error)
	CreateCustomerForUser(ctx context.Context, q db.DBTX, code, name, email string, userID int64) (int64, error)
	WalkInCustomerID(ctx context.Context, q db.DBTX) (int64, error)

	// sales vouchers (§13.3, separate from purchasing vouchers)
	CreateSalesVoucher(ctx context.Context, q db.DBTX, p db.CreateSalesVoucherParams) (db.CreateSalesVoucherRow, error)
	GetSalesVoucherByID(ctx context.Context, q db.DBTX, id int64) (db.GetSalesVoucherByIDRow, error)
	GetSalesVoucherByNumber(ctx context.Context, q db.DBTX, voucherNumber string) (db.GetSalesVoucherByNumberRow, error)
	GetSalesVoucherByNumberForUpdate(ctx context.Context, q db.DBTX, voucherNumber string) (db.GetSalesVoucherByNumberForUpdateRow, error)
	GetSalesVoucherByIDForUpdate(ctx context.Context, q db.DBTX, id int64) (db.GetSalesVoucherByIDForUpdateRow, error)
	UpdateSalesVoucherStatus(ctx context.Context, q db.DBTX, id int64, status string) error
	ListSalesVouchers(ctx context.Context, q db.DBTX, f db.ListSalesVouchersParams) ([]db.ListSalesVouchersRow, error)
	CountSalesVouchers(ctx context.Context, q db.DBTX, f db.CountSalesVouchersParams) (int64, error)

	// sales returns (§11.4, immutable audit events)
	CreateSalesReturn(ctx context.Context, q db.DBTX, p db.CreateSalesReturnParams) (db.CreateSalesReturnRow, error)
	GetSalesReturnByID(ctx context.Context, q db.DBTX, id int64) (db.GetSalesReturnByIDRow, error)
	ListSalesReturns(ctx context.Context, q db.DBTX, f db.ListSalesReturnsParams) ([]db.ListSalesReturnsRow, error)
	CountSalesReturns(ctx context.Context, q db.DBTX, f db.CountSalesReturnsParams) (int64, error)
	CreateSalesReturnItem(ctx context.Context, q db.DBTX, p db.CreateSalesReturnItemParams) (db.SalesReturnItem, error)
	SetSalesReturnItemCondition(ctx context.Context, q db.DBTX, p db.SetSalesReturnItemConditionParams) (db.SalesReturnItem, error)
	ListSalesReturnItems(ctx context.Context, q db.DBTX, salesReturnID int64) ([]db.ListSalesReturnItemsRow, error)
	SumReturnedQtyByOrderItem(ctx context.Context, q db.DBTX, salesOrderItemID int64) (decimal.Decimal, error)

	// supervisors
	ListSupervisors(ctx context.Context) ([]SupervisorOption, error)
}
