// Command server wires config, DB, Redis, middleware and module routes.
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"mime/multipart"
	"net"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/redis/go-redis/v9"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/account"
	"github.com/TaruLab/keluargamart-backend/internal/accounting"
	"github.com/TaruLab/keluargamart-backend/internal/apidocs"
	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/cart"
	"github.com/TaruLab/keluargamart-backend/internal/cashier"
	"github.com/TaruLab/keluargamart-backend/internal/catalog"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/dbmigrate"
	"github.com/TaruLab/keluargamart-backend/internal/distribution"
	"github.com/TaruLab/keluargamart-backend/internal/finance"
	"github.com/TaruLab/keluargamart-backend/internal/galon"
	"github.com/TaruLab/keluargamart-backend/internal/geo"
	"github.com/TaruLab/keluargamart-backend/internal/health"
	"github.com/TaruLab/keluargamart-backend/internal/hr"
	"github.com/TaruLab/keluargamart-backend/internal/inventory"
	"github.com/TaruLab/keluargamart-backend/internal/lhpp"
	"github.com/TaruLab/keluargamart-backend/internal/mailer"
	"github.com/TaruLab/keluargamart-backend/internal/membership"
	"github.com/TaruLab/keluargamart-backend/internal/partners"
	"github.com/TaruLab/keluargamart-backend/internal/pricing"
	"github.com/TaruLab/keluargamart-backend/internal/purchasing"
	"github.com/TaruLab/keluargamart-backend/internal/requisitions"
	"github.com/TaruLab/keluargamart-backend/internal/push"
	"github.com/TaruLab/keluargamart-backend/internal/reportproxy"
	"github.com/TaruLab/keluargamart-backend/internal/reports"
	"github.com/TaruLab/keluargamart-backend/internal/reports/templates"
	"github.com/TaruLab/keluargamart-backend/internal/sales"
	"github.com/TaruLab/keluargamart-backend/internal/salestargets"
	"github.com/TaruLab/keluargamart-backend/internal/salestracking"
	"github.com/TaruLab/keluargamart-backend/internal/settings"
	"github.com/TaruLab/keluargamart-backend/internal/system"
	"github.com/TaruLab/keluargamart-backend/internal/tickets"
	"github.com/TaruLab/keluargamart-backend/internal/uploads"
	"github.com/TaruLab/keluargamart-backend/internal/warehousedeposit"
	"github.com/TaruLab/keluargamart-backend/internal/warehouses"
	"github.com/TaruLab/keluargamart-backend/pkg/cache"
	"github.com/TaruLab/keluargamart-backend/pkg/config"
	"github.com/TaruLab/keluargamart-backend/pkg/database"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
	"github.com/TaruLab/keluargamart-backend/pkg/logger"
	"github.com/TaruLab/keluargamart-backend/pkg/middleware"
	redislib "github.com/TaruLab/keluargamart-backend/pkg/redis"
	"github.com/TaruLab/keluargamart-backend/pkg/seq"
	"github.com/TaruLab/keluargamart-backend/pkg/storage"
)

func main() {
	cfg := config.Load()
	log := logger.New(cfg.LogLevel)
	slog.SetDefault(log)

	if err := run(cfg, log); err != nil {
		log.Error("server_exit", "err", err)
		os.Exit(1)
	}
}

func run(cfg config.Config, log *slog.Logger) error {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// DB + Redis are optional at boot: the server stays up for /health even if
	// dependencies are down; /ready reports their state.
	var pool *pgxpool.Pool
	if cfg.DatabaseURL != "" {
		var err error
		pool, err = database.New(ctx, cfg.DatabaseURL)
		if err != nil {
			log.Error("database_connect", "err", err)
		}
	} else {
		log.Warn("DATABASE_URL empty; running without DB (health-only)")
	}
	if pool != nil {
		// Apply pending embedded migrations before serving traffic. A failure
		// aborts startup: never serve with a wrong schema. The degraded
		// no-DB boot mode above is untouched.
		if cfg.AutoMigrate {
			if err := dbmigrate.Run(ctx, pool, log); err != nil {
				return err
			}
		} else {
			log.Info("dbmigrate: disabled (AUTO_MIGRATE=false)")
		}
		defer pool.Close()
	}
	rdb := redislib.New(cfg.RedisURL)
	if rdb != nil {
		defer func() { _ = rdb.Close() }()
	}

	// Object store for uploaded files (images), rooted at cfg.UploadDir.
	store, err := storage.NewLocal(cfg.UploadDir)
	if err != nil {
		return fmt.Errorf("init upload store: %w", err)
	}

	// Auto-supervise embedded NextReport engine in local development / standalone mode.
	stopNextReport := reportproxy.EnsureLocalEngine(ctx, log)
	defer stopNextReport()

	srv := &http.Server{
		Addr:              fmt.Sprintf(":%d", cfg.AppPort),
		Handler:           router(cfg, pool, rdb, store),
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      60 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

	go func() {
		log.Info("server_started", "addr", srv.Addr, "env", cfg.AppEnv)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Error("listen", "err", err)
			stop()
		}
	}()

	<-ctx.Done()
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}

func router(cfg config.Config, pool *pgxpool.Pool, rdb *redis.Client, store *storage.Local) http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(middleware.Logging)
	r.Use(middleware.Recovery)
	r.Use(middleware.SecurityHeaders)
	r.Use(middleware.BodyLimit(cfg.BodySizeLimitBytes))
	r.Use(middleware.CORS(cfg.CORSOrigins))
	r.Get("/health", health.Liveness(cfg.AppEnv))
	r.Get("/ready", health.Readiness(health.Dependencies{DB: pool, Redis: rdb}))
	// Object serving for POST /api/v1/uploads. The route itself is registered
	// below, inside the pool block, because payment proofs live in the same key
	// namespace as public product images and reading them is authorization
	// gated (the payments table is the authority).
	uploadsHandler := uploads.NewHandler(store, cfg.UploadDir)

	// API documentation: interactive Scalar reference + raw OpenAPI contract.
	r.Get("/docs", apidocs.ReferenceHandler())
	r.Head("/docs", apidocs.ReferenceHandler())
	r.Get("/docs/*", apidocs.ReferenceHandler())
	r.Head("/docs/*", apidocs.ReferenceHandler())
	r.Get("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))
	r.Head("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))
	r.Options("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))

	// Phase 5+6: modules need DB; when pool is nil only health/ready mount.
	if pool != nil {
		authRepo := auth.NewRepository(pool)
		authSvc := auth.NewService(authRepo, cfg.Timezone(), time.Duration(cfg.TokenTTLHours)*time.Hour)
		// Password reset delivery (client feedback 2026-09-10): real SMTP
		// when configured, otherwise log-only dev fallback — see
		// internal/mailer. Production must set SMTP_HOST or reset links
		// never leave the server logs.
		var mailSvc mailer.Mailer
		if cfg.SMTPHost != "" {
			mailSvc = &mailer.SMTPMailer{Host: cfg.SMTPHost, Port: cfg.SMTPPort, User: cfg.SMTPUser, Pass: cfg.SMTPPass, From: cfg.SMTPFrom}
		} else {
			mailSvc = &mailer.LogMailer{Log: slog.Default()}
		}
		authSvc.WithMailer(mailSvc, cfg.B2BBaseURL)
		// Multi-tenant §5.4: every authenticated request carries the user's
		// tenant membership; store-scoped services enforce it via ctx.
		scopedAuth := auth.Middleware(authSvc, auth.ScopeMiddleware(func(ctx context.Context, userID int64) ([]int64, error) {
			// Platform superusers are never tenant-restricted, even when they
			// hold store_users rows (the seed binds admin to every outlet so
			// the POS demo works). Without this, admin's ERP reads would be
			// scoped to whichever outlet it happens to be linked to.
			if u := auth.UserFrom(ctx); u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner) {
				return nil, nil
			}
			return db.New(pool).StoresForUser(ctx, userID)
		}))
		optAuth := auth.OptionalMiddleware(authSvc)
		authHandler := auth.NewHandler(authSvc).WithStoreResolver(func(ctx context.Context, userID int64) []int64 {
			ids, err := db.New(pool).StoresForUser(ctx, userID)
			if err != nil {
				return nil
			}
			return ids
		}).WithStoreDetailsResolver(func(ctx context.Context, userID int64) []auth.StoreRef {
			rows, err := db.New(pool).StoreDetailsForUser(ctx, userID)
			if err != nil {
				return nil
			}
			refs := make([]auth.StoreRef, 0, len(rows))
			for _, s := range rows {
				refs = append(refs, auth.StoreRef{
					ID:          s.ID,
					Code:        s.Code,
					Name:        s.Name,
					Address:     s.Address.String,
					Phone:       s.Phone.String,
					IsActive:    s.IsActive,
					WarehouseID: s.WarehouseID,
				})
			}
			return refs
		})

		seqAdapter := &kvSeq{pool: pool}
		productSvc := catalog.NewService(catalog.NewRepository(pool), seqAdapter, cfg.Timezone())
		productHandler := catalog.NewHandler(productSvc)
		partnerSvc := partners.NewService(partners.NewRepository(pool), seqAdapter, cfg.Timezone())
		partnerHandler := partners.NewHandler(partnerSvc)
		// Purchase requisitions (000151, flow audit item 8): store head files
		// a request for items running out; admin pembelian converts it to a
		// PO. Request-only records — no stock movement, no money.
		requisitionsSvc := requisitions.NewService(requisitions.NewRepository(pool), cfg.Timezone()).WithPool(pool)
		requisitionsHandler := requisitions.NewHandler(requisitionsSvc)
		partnerRepo := partners.NewRepository(pool)
		// Commerce-identity attributes for GET /auth/me (e-commerce scope).
		authHandler.WithCustomerFlagsResolver(func(ctx context.Context, userID int64) map[string]any {
			admin, _ := partnerRepo.CustomerIsEcommerceAdminByUserID(ctx, userID)
			m := map[string]any{"is_ecommerce_admin": admin}
			var custID int64
			var custName, custPhone, custAddress, custCity string
			err := pool.QueryRow(ctx, "SELECT id, name, COALESCE(phone,''), COALESCE(address,''), COALESCE(city,'') FROM customers WHERE user_id = $1 LIMIT 1", userID).Scan(&custID, &custName, &custPhone, &custAddress, &custCity)
			if err == nil {
				m["id"] = custID
				m["name"] = custName
				m["phone"] = custPhone
				m["address"] = custAddress
				m["city"] = custCity
			}
			return m
		})

		// RBAC audit 2026-08-25 — E-Commerce application admin. The storefront
		// backoffice is gated by the customer-level flag (customers.
		// is_ecommerce_admin) OR an explicit backend permission; a platform
		// role alone never confers it and the flag never confers ERP rights.
		isEcommerceAdmin := func(ctx context.Context, userID int64) bool {
			ok, err := partnerRepo.CustomerIsEcommerceAdminByUserID(ctx, userID)
			return err == nil && ok
		}
		ecomGate := func(perm string) func(http.Handler) http.Handler {
			return func(next http.Handler) http.Handler {
				return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
					u := auth.UserFrom(r.Context())
					if u == nil {
						httpresponse.Error(w, http.StatusUnauthorized, middleware.RequestIDFrom(r.Context()),
							httpresponse.CodeAuthentication, "Unauthenticated", nil)
						return
					}
					// Application boundary (authz audit 2026-08-25): the B2B
					// backoffice is reachable only by the e-commerce admin
					// application scope, never by an ERP/platform role alone.
					if !u.CanAccessApp(auth.AppECommerceAdmin) {
						httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
							httpresponse.CodeForbidden, "Forbidden: application access required", nil)
						return
					}
					if u.Can(perm) || isEcommerceAdmin(r.Context(), u.ID) {
						next.ServeHTTP(w, r)
						return
					}
					httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
						httpresponse.CodeForbidden, "Forbidden", nil)
				})
			}
		}

		// ecomOrPermGate admits a caller holding the ERP permission as before,
		// and additionally the B2B storefront-admin scope (AppECommerceAdmin).
		// The storefront backoffice's order surfaces list and manage sales
		// orders even though the storefront admin holds no ERP sales role.
		// ERP-only identities without the permission AND without the admin
		// scope (e.g. finance/warehouse) stay denied; the plain customer
		// scope (AppECommerce) is never admitted. Product request for the
		// live demo (RBAC follow-up 2026-08-28).
		ecomOrPermGate := func(perm string) func(http.Handler) http.Handler {
			return func(next http.Handler) http.Handler {
				return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
					u := auth.UserFrom(r.Context())
					if u == nil {
						httpresponse.Error(w, http.StatusUnauthorized, middleware.RequestIDFrom(r.Context()),
							httpresponse.CodeAuthentication, "Unauthenticated", nil)
						return
					}
					isBuyer := u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client"
					if u.Can(perm) || (perm == "sales.sales-orders.view" && (u.Can("sales.pos.create") || u.Can("distribution.packing-orders.view"))) || u.CanAccessApp(auth.AppECommerceAdmin) || ((perm == "sales.sales-orders.cancel" || perm == "sales.returns.create") && isBuyer && u.CanAccessApp(auth.AppECommerce)) {
						next.ServeHTTP(w, r)
						return
					}
					httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
						httpresponse.CodeForbidden, "Forbidden", nil)
				})
			}
		}
		warehouseSvc := warehouses.NewService(warehouses.NewRepository(pool), seqAdapter, cfg.Timezone())
		warehouseHandler := warehouses.NewHandler(warehouseSvc)
		inventorySvc := inventory.NewService(inventory.NewRepository(pool), seqAdapter, cfg.Timezone())
		inventorySvc.WithAllowNegativeStock(cfg.AllowNegativeStock)
		inventoryHandler := inventory.NewHandler(inventorySvc)
		salesRepo := sales.NewRepository(pool)
		membershipRepo := membership.NewRepository(pool)
		membershipSvc := membership.NewService(membershipRepo, seqAdapter, cfg.Timezone())
		// Phase 6 item 3: membership benefit resolver. Looks up the active
		// membership on the caller's transaction; returns ok=false when the
		// customer is not an active member, and an error only on real failures
		// or an unknown tier (§13.1). The pricing service computes the discount.
		pricingSvc := pricing.NewService(pricing.NewRepository(pool))
		pricingSvc.WithMembershipLookup(func(ctx context.Context, q db.DBTX, customerID int64) (string, bool, error) {
			row, err := membershipRepo.MembershipByCustomer(ctx, q, customerID)
			if err != nil {
				if errors.Is(err, membership.ErrNotFound) {
					return "", false, nil
				}
				return "", false, err
			}
			if !row.IsActive {
				return "", false, nil
			}
			return row.Tier, true, nil
		})
		// STEP 0: read the tier discount percentage from the database-backed
		// membership_tiers table instead of the in-code fallback map. Fails
		// closed; the tier string is DB-constrained so errors are rare.
		pricingSvc.WithTierBenefitResolver(func(ctx context.Context, q db.DBTX, tier string) (decimal.Decimal, error) {
			row, err := membershipRepo.GetTierByCode(ctx, q, tier)
			if err != nil {
				return decimal.Zero, err
			}
			if !row.IsActive {
				return decimal.Zero, membership.ErrTierInactive
			}
			if row.DiscountType != "percentage" {
				return decimal.Zero, errors.New("unsupported membership discount type")
			}
			return row.DiscountValue, nil
		})
		salesSvc := sales.NewService(salesRepo, seqAdapter, cfg.Timezone())
		salesSvc.WithAllowNegativeStock(cfg.AllowNegativeStock)
		salesSvc.WithMidtrans(sales.MidtransConfig{
			ServerKey:   cfg.MidtransServerKey,
			SnapBaseURL: cfg.MidtransSnapBaseURL,
			CoreBaseURL: cfg.MidtransCoreBaseURL,
			// Snap redirect page bounces the buyer back to the storefront
			// confirmation view (which verifies the real state server-side).
			FinishURL: strings.TrimRight(cfg.B2BBaseURL, "/") + "/checkout/confirmation",
			// POS QRIS charges the Core API instead of Snap, and settles through
			// the same verified notification handler Snap already uses — so the
			// till does not depend on polling alone to find out it was paid.
			NotificationURL: strings.TrimRight(cfg.PublicAPIBaseURL, "/") + "/api/v1/midtrans/notifications",
		}).
			// Background Snap sweeper token. Separate from the Midtrans block above
			// because it is a different secret with a different consequence: unset
			// server key disables the GATEWAY (503, our misconfiguration), unset
			// sweep token means the SWEEPER is not deployed (404, and 404 is
			// deliberate — a 503 would advertise a money-moving endpoint to a
			// scanner).
			WithSnapSweepToken(cfg.MidtransSweepToken)
		salesSvc.WithPricing(func(ctx context.Context, q db.DBTX, reqs []sales.PriceRequest) ([]sales.PricedLine, error) {
			in := make([]pricing.Request, 0, len(reqs))
			for _, r := range reqs {
				in = append(in, pricing.Request{ProductID: r.ProductID, ProductUnitID: r.ProductUnitID, Quantity: r.Quantity, CustomerID: r.CustomerID})
			}
			resolved, err := pricingSvc.ResolveAll(ctx, q, in)
			if err != nil {
				return nil, err
			}
			out := make([]sales.PricedLine, 0, len(resolved))
			for _, r := range resolved {
				out = append(out, sales.PricedLine{
					ProductID:                r.ProductID,
					ProductUnitID:            r.ProductUnitID,
					UnitID:                   r.UnitID,
					UnitName:                 r.UnitName,
					UnitSymbol:               r.UnitSymbol,
					ConversionFactor:         r.ConversionFactor,
					UnitPrice:                r.UnitPrice,
					MinPurchase:              r.MinPurchase,
					Quantity:                 r.Quantity,
					BaseQuantity:             r.BaseQuantity,
					MembershipTier:           r.MembershipTier,
					MembershipDiscountAmount: r.MembershipDiscountAmount,
				})
			}
			return out, nil
		})
		salesSvc.WithPOSLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, orderID int64, before, after, qty decimal.Decimal) error {
			now := time.Now().In(cfg.Timezone())
			n, err := seqAdapter.Sequence(ctx, "sm", now, q)
			if err != nil {
				return err
			}
			m := &inventory.Movement{
				MovementNumber: fmt.Sprintf("SM-%s-%04d", now.Format("20060102"), n),
				ProductID:      productID,
				WarehouseID:    warehouseID,
				MovementType:   inventory.MovementStockOut,
				Quantity:       qty.Round(4),
				QuantityBefore: before.Round(4),
				QuantityAfter:  after.Round(4),
				ReferenceType:  ptrString("sales_order"),
				ReferenceID:    &orderID,
			}
			return inventorySvc.MovementInTx(ctx, q, m)
		})
		// Phase 6 item 7: returns restock accepted goods via a stock_in movement
		// in the same tx (§11.4).
		salesSvc.WithReturnLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, returnID int64, before, after, qty decimal.Decimal) error {
			now := time.Now().In(cfg.Timezone())
			n, err := seqAdapter.Sequence(ctx, "sm", now, q)
			if err != nil {
				return err
			}
			m := &inventory.Movement{
				MovementNumber: fmt.Sprintf("SM-%s-%04d", now.Format("20060102"), n),
				ProductID:      productID,
				WarehouseID:    warehouseID,
				MovementType:   inventory.MovementStockIn,
				Quantity:       qty.Round(4),
				QuantityBefore: before.Round(4),
				QuantityAfter:  after.Round(4),
				ReferenceType:  ptrString("sales_return"),
				ReferenceID:    &returnID,
			}
			return inventorySvc.MovementInTx(ctx, q, m)
		})
		salesSvc.WithCancelLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, orderID int64, before, after, qty decimal.Decimal) error {
			now := time.Now().In(cfg.Timezone())
			n, err := seqAdapter.Sequence(ctx, "sm", now, q)
			if err != nil {
				return err
			}
			m := &inventory.Movement{MovementNumber: fmt.Sprintf("SM-%s-%04d", now.Format("20060102"), n), ProductID: productID, WarehouseID: warehouseID, MovementType: inventory.MovementStockIn, Quantity: qty.Round(4), QuantityBefore: before.Round(4), QuantityAfter: after.Round(4), ReferenceType: ptrString("sales_order_cancellation"), ReferenceID: &orderID}
			if u := auth.UserFrom(ctx); u != nil {
				m.CreatedBy = &u.ID
			}
			return inventorySvc.MovementInTx(ctx, q, m)
		})
		salesHandler := sales.NewHandler(salesSvc)
		purchasingSvc := purchasing.NewService(purchasing.NewRepository(pool), seqAdapter, cfg.Timezone())
		purchasingHandler := purchasing.NewHandler(purchasingSvc)
		financeSvc := finance.NewService(finance.NewRepository(pool), seqAdapter, cfg.Timezone())
		financeHandler := finance.NewHandler(financeSvc)
		lhppSvc := lhpp.NewService(lhpp.NewRepository(pool), financeSvc)
		lhppHandler := lhpp.NewHandler(lhppSvc)
		hardening := finance.NewHardening(pool, cfg.Timezone())
		accountingSvc := accounting.NewService(accounting.NewRepository(pool), seqAdapter, cfg.Timezone())
		accountingHandler := accounting.NewHandler(accountingSvc)
		// Supplier debt payments (purchasing.PayDebt) post a real debit-AP
		// (210102 Hutang Dagang Supplier) / credit-cash journal via the
		// already-instantiated accounting.Service (client feedback: PayDebt
		// previously wrote a cashflow row only, no journal_entries/journal_lines).
		purchasingSvc.WithAccounting(func(ctx context.Context, poNumber, voucherNumber, cashAccountCode string, amount decimal.Decimal, userID *int64) (string, error) {
			desc := fmt.Sprintf("Pembayaran hutang PO %s", poNumber)
			if voucherNumber != "" {
				desc += fmt.Sprintf(" (Voucher %s)", voucherNumber)
			}
			// The caller was already authorised by RequirePermission
			// (purchasing.purchase-orders.pay). The journal is a system-generated
			// side effect of that payment, so post it under the system role:
			// otherwise accounting's per-role journal_type gate rejects roles
			// (e.g. purchasing) that may pay but may not hand-write
			// cash_disbursement journals. created_by/posted_by stay the real user.
			if u := auth.UserFrom(ctx); u != nil {
				sys := *u
				sys.Role = auth.RoleSystem
				ctx = auth.ToContext(ctx, &sys, auth.TokenIDFrom(ctx))
			}
			if cashAccountCode == "" || cashAccountCode == "1000.01.01" {
				cashAccountCode = "1000.01.02"
			}
			jType := "cash_disbursement"
			if strings.HasPrefix(cashAccountCode, "1000.03") || strings.HasPrefix(cashAccountCode, "1100") || strings.HasPrefix(cashAccountCode, "1110") {
				jType = "bank_disbursement"
			}
			j, err := accountingSvc.CreateJournal(ctx, accounting.JournalInput{
				JournalType: jType,
				Description: desc,
				Lines: []accounting.JournalLineInput{
					{AccountCode: "210102", DepartmentCode: "KANTOR PUSAT", Debit: amount, Description: desc},
					{AccountCode: cashAccountCode, DepartmentCode: "KANTOR PUSAT", Credit: amount, Description: desc},
				},
			}, userID)
			if err != nil {
				return "", err
			}
			if _, err := accountingSvc.PostJournal(ctx, j.ID, userID); err != nil {
				return "", err
			}
			return j.JournalNumber, nil
		})
		// GR purchase accrual (keputusan #1-#4): each goods receipt drafts a
		// balanced JU entry — D 500.03 PEMBELIAN (DPP) + D 110701 PPN MASUKAN
		// (tax, when the PO carries one) / K 211.03 HUTANG YG BELUM DITAGIH
		// (DPP+tax, keputusan user #1 "PPN dipisah"). Source linkage:
		// reference = GR number, memo carries the PO number; source_type
		// 'goods_receipt' (+source_id) lets the ledger drill back to the GR.
		// Draft-only: finance posts manually. Failures are swallowed by the
		// caller (GR already committed) — the draft is advisory, the GR is
		// authoritative.
		purchasingSvc.WithAccrual(func(ctx context.Context, in purchasing.GRAccrualInput, userID *int64) (string, error) {
			dept := in.Department
			if dept == "" {
				dept = "KANTOR PUSAT"
			}
			desc := fmt.Sprintf("Penerimaan %s dari %s", in.GRNumber, in.PONumber)
			lines := grAccrualLines(dept, in.GRNumber, in.DPP, in.Tax)
			j, err := accountingSvc.CreateJournal(ctx, accounting.JournalInput{
				JournalType: "general",
				EntryDate:   in.ReceivedDate,
				Reference:   in.GRNumber,
				Description: desc,
				SourceType:  "goods_receipt",
				SourceID:    &in.GRID,
				Lines:       lines,
			}, userID)
			if err != nil {
				return "", err
			}
			return j.JournalNumber, nil
		})
		// Stock opname adjustments: finalize posts an adjustment journal entry
		// balancing inventory against Beban Operasional/Selisih Persediaan (shortage)
		// or Pendapatan Lain-lain (surplus).
		inventorySvc.WithAccounting(func(ctx context.Context, opnameNumber string, warehouseID int64, diffValue decimal.Decimal, userID *int64) (string, error) {
			if diffValue.IsZero() {
				return "", nil
			}
			desc := fmt.Sprintf("Penyesuaian stok opname %s", opnameNumber)
			if u := auth.UserFrom(ctx); u != nil {
				sys := *u
				sys.Role = auth.RoleSystem
				ctx = auth.ToContext(ctx, &sys, auth.TokenIDFrom(ctx))
			}
			var lines []accounting.JournalLineInput
			if diffValue.IsNegative() {
				lines = []accounting.JournalLineInput{
					{AccountCode: "5000.04", DepartmentCode: "KANTOR PUSAT", Debit: diffValue.Abs(), Description: desc},
					{AccountCode: "110401", DepartmentCode: "KANTOR PUSAT", Credit: diffValue.Abs(), Description: desc},
				}
			} else {
				lines = []accounting.JournalLineInput{
					{AccountCode: "110401", DepartmentCode: "KANTOR PUSAT", Debit: diffValue.Abs(), Description: desc},
					{AccountCode: "7100.99", DepartmentCode: "KANTOR PUSAT", Credit: diffValue.Abs(), Description: desc},
				}
			}
			j, err := accountingSvc.CreateJournal(ctx, accounting.JournalInput{
				JournalType: "general",
				Reference:   opnameNumber,
				Description: desc,
				SourceType:  "stock_opname",
				Lines:       lines,
			}, userID)
			if err != nil {
				return "", err
			}
			if _, err := accountingSvc.PostJournal(ctx, j.ID, userID); err != nil {
				return "", err
			}
			return j.JournalNumber, nil
		})
		// Phase 6 item 5: buyer-scoped /me views resolve the caller's linked
		// customer via the sales repository (User -> Customer, §13.1).
		customerByUser := func(ctx context.Context, userID int64) (int64, bool, error) {
			id, err := salesRepo.CustomerByUserID(ctx, nil, userID)
			if errors.Is(err, sales.ErrNotFound) {
				return 0, false, nil
			}
			if err != nil {
				return 0, false, err
			}
			return id, true, nil
		}
		salesHandler.WithCustomerResolver(customerByUser)
		financeHandler.WithCustomerResolver(customerByUser)
		// Payment proofs reuse the uploads storage pipeline (same 5 MB /
		// jpeg-png-webp-gif limits, same opaque keys) but are written under the
		// proof- key namespace so the read path can tell a private financial
		// document from a public product image.
		financeHandler.WithProofSaver(func(ctx context.Context, r *http.Request) (string, error) {
			return uploads.SaveProofUpload(ctx, store, r)
		})
		// Injection point for the read path: internal/uploads cannot import
		// internal/finance (finance imports uploads for the save seam), so the
		// payments-table-backed policy is injected here as a function seam.
		// Without it the uploads handler serves nothing (fail closed), which is
		// why the route is registered only when the pool exists.
		uploadsHandler.WithProofAuthorizer(financeSvc.ProofAccess)
		// Query-token auth for <img> rendering of private proofs (000151):
		// an <img> tag cannot send an Authorization header, so the POD photo
		// in the ERP detail view 404'd ("object not found"). The ERP page now
		// appends ?token=<its own bearer> when rendering; the uploads handler
		// resolves it through the same Authenticate the middleware uses and
		// proof authorization still runs unchanged (fail-closed).
		uploadsHandler.WithAuthenticator(func(ctx context.Context, bearer string) (*auth.User, error) {
			u, _, err := authSvc.Authenticate(ctx, bearer)
			return u, err
		})
		// Phase 4: distribution. Shipment lines record an "sm" movement via the
		// inventory ledger, mirroring the sales POS ledger.
		distributionSvc := distribution.NewService(distribution.NewRepository(pool), seqAdapter, cfg.Timezone())
		// POD photos (000143) ride the same uploads pipeline as payment
		// proofs and buyer received-proofs: 5 MB cap, image types, opaque
		// keys under the proof- namespace (private read path). The header is
		// parsed off the deliver request itself, so the saver stores it
		// directly — no synthetic request re-wrapping.
		distributionSvc.WithPODSaver(func(ctx context.Context, header *multipart.FileHeader) (string, error) {
			return uploads.SaveFileHeader(ctx, store, header, uploads.ProofPrefix)
		})
		distributionSvc.WithAllowNegativeStock(cfg.AllowNegativeStock)
		// Map/tracking audit: Redis-cached latest GPS fixes (PG stays source of truth).
		distributionSvc.WithLocationCache(cache.New(rdb))
		distributionSvc.WithShipmentLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, shipmentID int64, before, after, qty decimal.Decimal) error {
			now := time.Now().In(cfg.Timezone())
			n, err := seqAdapter.Sequence(ctx, "sm", now, q)
			if err != nil {
				return err
			}
			m := &inventory.Movement{
				MovementNumber: fmt.Sprintf("SM-%s-%04d", now.Format("20060102"), n),
				ProductID:      productID,
				WarehouseID:    warehouseID,
				MovementType:   inventory.MovementStockOut,
				Quantity:       qty.Round(4),
				QuantityBefore: before.Round(4),
				QuantityAfter:  after.Round(4),
				ReferenceType:  ptrString("shipment"),
				ReferenceID:    &shipmentID,
			}
			return inventorySvc.MovementInTx(ctx, q, m)
		})
		// Every dispatched DO gets its own single-DO LHPP sheet. Grouping itself
		// is a human decision from here on — the old automatic "one sheet per
		// driver per day" bucketing is gone, so a collector can see unbatched
		// work and a printed sheet no longer changes shape on its own.
		// lhppSvc is built above (line ~425), so it is available here.
		distributionSvc.WithLHPPGrouper(lhppSvc.Grouper)
		distributionHandler := distribution.NewHandler(distributionSvc)
		var routerImpl geo.Router
		switch cfg.RoutingProvider {
		case "osrm":
			if cfg.RoutingBaseURL != "" {
				routerImpl = geo.NewOSRM(cfg.RoutingBaseURL, nil)
			}
		}
		var geocoderImpl geo.Geocoder
		if cfg.GeocoderEnabled && cfg.GeocoderBaseURL != "" {
			geocoderImpl = geo.NewNominatim(cfg.GeocoderBaseURL, "keluargamart-erp", nil)
		}
		geoHandler := geo.NewHandler(routerImpl, geocoderImpl)
		distributionHandler.WithCustomerResolver(customerByUser)
		reportsHandler := reports.NewHandler(reports.NewService(reports.NewRepository(pool), cfg.Timezone()).WithCache(cacheRedis(rdb), 60*time.Second))
		templatesRepo := templates.NewRepository(pool)
		templatesService := templates.NewService(templatesRepo)
		templatesHandler := templates.NewHandler(templatesService)

		onOrderConfirmed := func(ctx context.Context, orderID int64) error {
			// 1. Create Invoice in finance so receivable & invoice exist immediately (§5.1)
			if _, err := financeSvc.CreateInvoice(ctx, orderID); err != nil && !errors.Is(err, finance.ErrCannotInvoice) {
				slog.Error("auto-create invoice on confirm", "order_id", orderID, "err", err)
			}

			// 2. Create Delivery Order in distribution for warehouse fulfillment (§4.1).
			// STORE-PICKUP orders get no DO: their goods change hands at the POS
			// till ("Serahkan Barang"), never through the courier chain — a DO
			// here put pickup orders on the dispatch map as "Sedang Diantar".
			so, err := salesSvc.Get(ctx, orderID)
			if err == nil && so != nil && !sales.IsPickupOrder(so) {
				var items []distribution.DOItemInput
				for _, it := range so.Items {
					items = append(items, distribution.DOItemInput{
						ProductID:         it.ProductID,
						RequestedQuantity: it.Quantity,
					})
				}
				if len(items) > 0 {
					var opID int64
					if u := auth.UserFrom(ctx); u != nil {
						opID = u.ID
					}
					var whID int64
					if so.WarehouseID != nil && *so.WarehouseID > 0 {
						whID = *so.WarehouseID
					} else {
						_ = pool.QueryRow(ctx, `SELECT id FROM warehouses WHERE is_active = true ORDER BY id ASC LIMIT 1`).Scan(&whID)
					}
					_, err := distributionSvc.CreateDeliveryOrder(ctx, distribution.DOInput{
						SalesOrderID: so.ID,
						WarehouseID:  whID,
						Priority:     distribution.PriorityNormal,
						Items:        items,
					}, opID)
					if err != nil && !errors.Is(err, distribution.ErrSOInvalidStatus) && !errors.Is(err, distribution.ErrOpenDOExists) {
						slog.Error("auto-create delivery order on confirm", "order_id", orderID, "err", err)
					}
					// NOTE (sequential DO flow §15): no status/quantity alignment
					// here. The new DO stays `draft`; shipped_quantity only moves
					// through CreateShipment and in_transit only through
					// CreateDispatch — bulk "align with SO status" UPDATEs here
					// fabricated shipped=in_transit DOs with quantities that were
					// never allocated or stocked out, and re-fired after every
					// AdvanceFulfillment step via this onConfirm hook. Packing
					// progress is visible via the DO payload's sales_order_status.
				}
			}

			// 3. Accrue loyalty points for membership customer (§20).
			// Atomically claim point accrual for this order so it is strictly idempotent.
			var (
				custID   *int64
				totalAmt decimal.Decimal
			)
			rowErr := pool.QueryRow(ctx, `
				UPDATE sales_orders
				SET points_accrued = true, updated_at = now()
				WHERE id = $1 AND points_accrued = false AND status IN ('confirmed', 'invoiced', 'picking', 'shipped', 'delivered', 'paid')
				RETURNING customer_id, total_amount
			`, orderID).Scan(&custID, &totalAmt)
			if rowErr == nil && custID != nil && *custID > 0 && totalAmt.GreaterThan(decimal.Zero) {
				if mbr, err := membershipSvc.ByCustomer(ctx, *custID); err == nil && mbr != nil && mbr.IsActive {
					if _, err := membershipSvc.AccruePoints(ctx, mbr.ID, totalAmt); err != nil {
						slog.Error("auto-accrue loyalty points on confirm", "order_id", orderID, "membership_id", mbr.ID, "err", err)
					}
				}
			}
			return nil
		}
		salesSvc.WithOnConfirm(onOrderConfirmed)

		// Reconcile existing pre-fulfilment e-commerce orders without DOs on
		// startup. Only statuses where a draft DO is honest are reconciled —
		// shipped/delivered/completed orders were fulfilled through the counter
		// handover flow; retroactively fabricating their DOs would invent
		// shipment records that never happened.
		go func() {
			time.Sleep(2 * time.Second)
			ctx := context.Background()
			rows, err := pool.Query(ctx, `
				SELECT so.id FROM sales_orders so
				LEFT JOIN delivery_orders d ON d.sales_order_id = so.id
				WHERE d.id IS NULL AND so.order_type = 'ecommerce'
				  AND so.status IN ('confirmed', 'invoiced', 'paid', 'picking')
				ORDER BY so.id ASC
			`)
			if err != nil {
				return
			}
			defer rows.Close()
			var soIDs []int64
			for rows.Next() {
				var id int64
				if err := rows.Scan(&id); err == nil {
					soIDs = append(soIDs, id)
				}
			}
			rows.Close()
			for _, id := range soIDs {
				_ = onOrderConfirmed(ctx, id)
			}

			// Backfill loyalty points for existing confirmed/invoiced/completed orders
			// that were placed before points accrual hook was wired.
			pRows, err := pool.Query(ctx, `
				SELECT id FROM sales_orders
				WHERE points_accrued = false
				  AND status IN ('confirmed', 'invoiced', 'picking', 'shipped', 'delivered', 'paid')
				ORDER BY id ASC
			`)
			if err == nil {
				defer pRows.Close()
				var pIDs []int64
				for pRows.Next() {
					var id int64
					if err := pRows.Scan(&id); err == nil {
						pIDs = append(pIDs, id)
					}
				}
				pRows.Close()
				for _, id := range pIDs {
					_ = onOrderConfirmed(ctx, id)
				}
			}
		}()

		hrRepo := hr.NewRepository(pool)
		hrSvc := hr.NewService(hrRepo, seqAdapter, cfg.Timezone())
		// Order sales_id FKs employees(id), never users(id): resolve the
		// creator to their linked employees row. We resolve in 3 steps:
		// 1. Linked user_id via HR repository
		// 2. Email match from employees table
		// 3. If the user is an active user without an employees row, auto-provision
		//    a dedicated employees row with their own identity so sales_id is preserved.
		salesSvc.WithEmployeeLookup(func(ctx context.Context, userID int64) (int64, error) {
			if e, err := hrRepo.EmployeeByUserID(ctx, userID); err == nil && e.ID != 0 {
				return e.ID, nil
			}
			if pool != nil {
				var empID int64
				err := pool.QueryRow(ctx, `
					SELECT e.id
					  FROM employees e
					  JOIN users u ON LOWER(TRIM(e.email)) = LOWER(TRIM(u.email))
					 WHERE u.id = $1
					   AND e.deleted_at IS NULL
					 ORDER BY e.id
					 LIMIT 1`, userID).Scan(&empID)
				if err == nil && empID != 0 {
					_, _ = pool.Exec(ctx, `UPDATE employees SET user_id = $1 WHERE id = $2 AND user_id IS NULL`, userID, empID)
					return empID, nil
				}

				var uName, uEmail, uRole string
				var uActive bool
				uErr := pool.QueryRow(ctx, `SELECT name, email, role, is_active FROM users WHERE id = $1`, userID).Scan(&uName, &uEmail, &uRole, &uActive)
				if uErr == nil {
					empNum := fmt.Sprintf("EMP-SALES-%04d", userID)
					dept := "sales"
					pos := "Sales Representative"
					if uRole != "sales" {
						pos = uRole
					}
					var newID int64
					insErr := pool.QueryRow(ctx, `
						INSERT INTO employees (
							employee_number, user_id, name, email, department, position,
							employment_type, job_level, basic_salary, is_active, created_at, updated_at
						) VALUES (
							$1, $2, $3, $4, $5, $6, 'permanent', 'staff', 0, $7, NOW(), NOW()
						) RETURNING id`, empNum, userID, uName, uEmail, dept, pos, uActive).Scan(&newID)
					if insErr == nil && newID != 0 {
						return newID, nil
					}
				}
			}
			return 0, hr.ErrNotFound
		})
		hrSvc.WithDisbursement(func(ctx context.Context, p *hr.Payroll, accountCode string, userID *int64) (string, error) {
			desc := fmt.Sprintf("Pembayaran Gaji %s periode %02d/%d", p.EmployeeName, p.PeriodMonth, p.PeriodYear)
			if p.PayrollNumber != "" {
				desc = fmt.Sprintf("Pembayaran Gaji %s (%s)", p.PayrollNumber, p.EmployeeName)
			}
			jType := "bank_disbursement"
			if accountCode == "1000.01.01" || accountCode == "1000.01.02" {
				jType = "cash_disbursement"
			}
			payDate := time.Now().In(cfg.Timezone()).Format("2006-01-02")
			if p.PaymentDate != nil && *p.PaymentDate != "" {
				payDate = *p.PaymentDate
			}
			// The caller was already authorised by RequirePermission
			// (hr.payrolls.pay). The journal is a system-generated side effect
			// of that payment, so post it under the system role: otherwise
			// accounting's per-role journal_type gate rejects roles (e.g. hr_manager,
			// hr_staff) that may pay but may not hand-write bank/cash disbursement journals.
			// created_by/posted_by stay the real user.
			if u := auth.UserFrom(ctx); u != nil {
				sys := *u
				sys.Role = auth.RoleSystem
				ctx = auth.ToContext(ctx, &sys, auth.TokenIDFrom(ctx))
			}
			j, err := accountingSvc.CreateJournal(ctx, accounting.JournalInput{
				JournalType: jType,
				EntryDate:   payDate,
				Reference:   p.PayrollNumber,
				Description: desc,
				SourceType:  "payroll",
				SourceID:    &p.ID,
				Lines: []accounting.JournalLineInput{
					{AccountCode: "5000.02", DepartmentCode: "KANTOR PUSAT", Debit: p.NetSalary, Description: desc},
					{AccountCode: accountCode, DepartmentCode: "KANTOR PUSAT", Credit: p.NetSalary, Description: desc},
				},
			}, userID)
			if err != nil {
				return "", err
			}
			if _, err := accountingSvc.PostJournal(ctx, j.ID, userID); err != nil {
				return "", err
			}
			return j.JournalNumber, nil
		})
		hrHandler := hr.NewHandler(hrSvc)
		stRepo := salestracking.NewRepository(pool)
		// Shared user->employee resolver: salestracking uses it for the
		// jadwal-kunjungan "/me" endpoints, lhpp for GET /lhpp/sales/me (a
		// sales rep's own penagihan-visit worksheet). See employeeLookup.
		employeeLookupFn := employeeLookup(pool, hrRepo)
		stSvc := salestracking.NewService(stRepo, cfg.Timezone()).WithEmployeeLookup(employeeLookupFn)
		stHandler := salestracking.NewHandler(stSvc)
		stargetRepo := salestargets.NewRepository(pool, cfg.Timezone())
		stargetHandler := salestargets.NewHandler(stargetRepo)
		lhppSvc.WithEmployeeLookup(employeeLookupFn)
		sysRepo := system.NewRepository(pool)
		sysSvc := system.NewService(sysRepo, cfg.Timezone())
		sysSvc.WithNotificationProvider(system.NotificationProvider(pool))
		sysHandler := system.NewHandler(sysSvc)
		pushRepo := push.NewRepository(pool)
		pushSvc := push.NewService(pushRepo)
		pushHandler := push.NewHandler(pushSvc)
		salesSvc.WithOnStatusChange(func(ctx context.Context, order *sales.SalesOrder, oldStatus, newStatus string) {
			if order == nil {
				return
			}
			if newStatus == "confirmed" || newStatus == "picking" {
				go pushSvc.SendToRoles(context.Background(), []string{"admin", "owner", "warehouse", "store_manager"}, push.Payload{
					Title: "Proses Pengemasan: " + order.SoNumber,
					Body:  fmt.Sprintf("Pesanan %s siap untuk proses %s.", order.SoNumber, newStatus),
					Icon:  "/icons/icon-192x192.png",
					Badge: "/favicon.svg",
					Sound: "/sounds/notification.mp3",
					URL:   fmt.Sprintf("/distribution/customer-orders/%d", order.ID),
				})
			}
			if newStatus == "shipped" || newStatus == "delivered" {
				var customerUserID *int64
				_ = pool.QueryRow(context.Background(), `SELECT user_id FROM customers WHERE id = $1`, order.CustomerID).Scan(&customerUserID)
				if customerUserID != nil {
					go pushSvc.SendToUser(context.Background(), *customerUserID, push.Payload{
						Title: "Status Pesanan " + order.SoNumber,
						Body:  fmt.Sprintf("Status pesanan Anda kini: %s.", newStatus),
						Icon:  "/icons/icon-192x192.png",
						Badge: "/favicon.svg",
						Sound: "/sounds/notification.mp3",
						URL:   fmt.Sprintf("/orders/%d", order.ID),
					})
				}
			}
		})
		// Session hygiene (R2-6b): an admin-initiated password change ends
		// every session of the target, mirroring self-service behavior.
		sysSvc.WithTokenRevoker(func(ctx context.Context, userID int64) error {
			return authRepo.DeleteTokensByUser(ctx, userID)
		})
		// Login sync (R2-6a): deactivating/deleting a customer also ends the
		// linked login. Per-request auth only checks users.is_active, so
		// without this a rejected buyer keeps browsing on its old token.
		partnerSvc.WithOnCustomerDeactivated(func(ctx context.Context, customerID int64) error {
			var userID *int64
			if err := pool.QueryRow(ctx, `SELECT user_id FROM customers WHERE id = $1`, customerID).Scan(&userID); err != nil {
				return err
			}
			if userID == nil {
				return nil
			}
			if err := sysRepo.SetUserActive(ctx, *userID, false); err != nil {
				return err
			}
			return authRepo.DeleteTokensByUser(ctx, *userID)
		})
		// Password reset login-sync (000151): revoking every bearer token is
		// what makes an admin reset meaningful — a stolen session cannot
		// outlive the credential change. Same adapter pattern as above.
		partnerSvc.WithOnCustomerPasswordReset(func(ctx context.Context, userID int64) error {
			return authRepo.DeleteTokensByUser(ctx, userID)
		})
		// Role-permission matrix (multi-tenant §6): load DB overrides at
		// boot, hot-reload after every ERP Role Management mutation.
		loadMatrix := func() {
			rows, err := sysRepo.RolePermissionMatrix(context.Background(), nil)
			if err != nil {
				slog.Error("role matrix load", "err", err)
				return
			}
			m := map[auth.Role][]string{}
			for _, row := range rows {
				m[auth.Role(row.Role)] = append(m[auth.Role(row.Role)], row.Permission)
			}
			auth.SetRoleMatrix(m)
		}
		loadMatrix()
		sysSvc.SetOnMatrixChange(loadMatrix)
		cartRepo := cart.NewRepository(pool)
		cartSvc := cart.NewService(cartRepo)
		cartSvc.WithOrderPlacer(func(ctx context.Context, q db.DBTX, userID int64, userName, userEmail string, in cart.CheckoutInput, items []cart.PlacedItem) (*cart.PlacedOrder, error) {
			notes := in.Notes
			if in.PaymentMethod != "" {
				prefix := fmt.Sprintf("[Metode: %s]", in.PaymentMethod)
				if notes != nil && *notes != "" {
					merged := fmt.Sprintf("%s %s", prefix, *notes)
					notes = &merged
				} else {
					notes = &prefix
				}
			}
			so, err := salesSvc.PlaceCheckoutInTx(ctx, q, sales.CheckoutInput{
				WarehouseID:     in.WarehouseID,
				PaymentMethod:   in.PaymentMethod,
				ShippingAddress: in.ShippingAddress,
				Notes:           notes,
				VoucherCode:     in.VoucherCode,
				Items:           mapPlacedItems(items),
			}, userID, userName, userEmail)
			if err != nil {
				return nil, err
			}
			if so != nil {
				go pushSvc.SendToRoles(context.Background(), []string{"admin", "owner", "warehouse", "store_manager", "spv", "sales"}, push.Payload{
					Title: "Pesanan Baru Masuk: " + so.SoNumber,
					Body:  fmt.Sprintf("Pesanan baru senilai Rp %s telah dibuat. Silakan periksa dashboard.", so.TotalAmount.StringFixed(0)),
					Icon:  "/icons/icon-192x192.png",
					Badge: "/favicon.svg",
					Sound: "/sounds/notification.mp3",
					URL:   "/distribution/customer-orders",
				})
			}
			return toPlacedOrder(so), nil
		})
		// E-commerce checkout orders remain in 'pending_approval' (Menunggu Konfirmasi)
		// until a human accepts them via salesSvc.Confirm. A settled Midtrans
		// invoice records the money but does not confirm — see
		// SettleOrderPayment. onOrderConfirmed is invoked when the order is
		// explicitly confirmed.
		// GET /cart pricing: resolve each line through the same membership
		// benefit engine checkout uses, so the storefront shows the exact B2B
		// total instead of the raw list price. Read-only (pool connection).
		cartSvc.WithLinePricer(func(ctx context.Context, userID int64, items []cart.PlacedItem) ([]cart.PricedCartLine, error) {
			custID, err := salesRepo.CustomerByUserID(ctx, nil, userID)
			if err != nil {
				if errors.Is(err, sales.ErrNotFound) {
					return nil, nil // no linked customer yet → list price only
				}
				return nil, err
			}
			reqs := make([]pricing.Request, 0, len(items))
			for _, it := range items {
				reqs = append(reqs, pricing.Request{ProductID: it.ProductID, Quantity: it.Quantity, CustomerID: &custID})
			}
			resolved, err := pricingSvc.ResolveAll(ctx, nil, reqs)
			if err != nil {
				return nil, err
			}
			out := make([]cart.PricedCartLine, 0, len(resolved))
			for _, r := range resolved {
				out = append(out, cart.PricedCartLine{
					ProductID:          r.ProductID,
					MembershipDiscount: r.MembershipDiscountAmount,
					Subtotal:           r.FinalSubtotal,
				})
			}
			return out, nil
		})
		cartHandler := cart.NewHandler(cartSvc)

		// Buyer address book (B2B storefront): server-side replacement for the
		// localStorage address book. Same auth posture as /cart.
		accountRepo := account.NewRepository(pool)
		accountSvc := account.NewService(accountRepo)
		accountHandler := account.NewHandler(accountSvc)
		// Audit middleware: records successful mutations per authenticated
		// user (domain-map D11), skipping dashboard + location pings.
		actLog := system.ActivityLogMiddleware(sysRepo, "/dashboard", "/sales-tracking/locations")

		// Idempotency (§19): mandatory for critical mutations, fail-closed when
		// Redis is down so a payment/POS/checkout can never run twice.
		idem := middleware.NewIdempotency(rdb,
			middleware.WithRequired(true),
			middleware.WithTTL(time.Duration(cfg.IdempotencyTTLHours)*time.Hour),
			middleware.WithUserID(func(r *http.Request) int64 {
				if u := auth.UserFrom(r.Context()); u != nil {
					return u.ID
				}
				return 0
			}),
		)

		// Object serving: GET /uploads/{key} stays outside /api/v1 and public
		// for images, but payment proofs live in the same key namespace and are
		// authorized inside the handler against the payments table
		// (financeSvc.ProofAccess, injected above). OptionalMiddleware only puts
		// a caller identity on the context when a token is present, so anonymous
		// callers still get public images while proofs fail closed. Registered
		// here, outside the /api/v1 closure below, to keep the mount path.
		r.Group(func(ur chi.Router) {
			ur.Use(auth.OptionalMiddleware(authSvc))
			ur.Get("/uploads/{key}", uploadsHandler.Serve)
		})

		// Midtrans notification webhook aliases (PUBLIC, signature-verified):
		// Supports dashboard configurations using /midtrans/notifications or
		// /api/midtrans/notifications alongside /api/v1/midtrans/notifications.
		r.Post("/midtrans/notifications", salesHandler.MidtransNotification)
		r.Post("/api/midtrans/notifications", salesHandler.MidtransNotification)

		r.Route("/api/v1", func(api chi.Router) {
			clientIP := newClientIPFunc(cfg.TrustedProxies)
			// Phase 7 §1: generic per-IP API limit across the whole surface.
			genericRL := middleware.NewRateLimiter(rdb, func(r *http.Request) string {
				return "ratelimit:api:" + clientIP(r)
			}, int64(cfg.RateLimitGenericPerMin), time.Minute)
			api.Use(genericRL.Handler)

			api.Get("/docs", apidocs.ReferenceHandler())
			api.Head("/docs", apidocs.ReferenceHandler())
			api.Get("/docs/*", apidocs.ReferenceHandler())
			api.Head("/docs/*", apidocs.ReferenceHandler())
			api.Get("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))
			api.Head("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))
			api.Options("/openapi.yaml", apidocs.OpenAPIFile(cfg.OpenAPIPath))

			loginRL := middleware.NewRateLimiter(rdb, func(r *http.Request) string {
				return "ratelimit:login:" + clientIP(r)
			}, int64(cfg.RateLimitLoginPerMinute), time.Minute)

			api.Route("/auth", func(authR chi.Router) {

				authR.With(loginRL.Handler).Post("/login", authHandler.Login)
				authR.With(loginRL.Handler).Post("/register", authHandler.Register)
				// Email verification (000152): the OTP endpoints sit beside
				// login — same rate limiter, all unauthenticated.
				authR.With(loginRL.Handler).Post("/verify-email", authHandler.VerifyEmail)
				authR.With(loginRL.Handler).Post("/resend-verification", authHandler.ResendVerification)
				// Password reset (client feedback 2026-09-10): reachable now
				// that the link is delivered by email instead of the
				// response body. loginRL matches login/register's stricter
				// per-IP limit — both endpoints are unauthenticated and
				// enumeration/brute-force sensitive.
				authR.With(loginRL.Handler).Post("/forgot-password", authHandler.ForgotPassword)
				authR.With(loginRL.Handler).Post("/reset-password", authHandler.ResetPassword)
				authR.Group(func(pr chi.Router) {
					pr.Use(scopedAuth)
					pr.Use(actLog)
					pr.Post("/logout", authHandler.Logout)
					pr.Get("/me", authHandler.Me)
					pr.Put("/me", authHandler.UpdateMe)
				})
			})

			// Uploads: authenticated upload under the versioned API. Object
			// serving at GET /uploads/{key} is mounted on the root router above
			// (outside /api/v1): it must stay reachable anonymously for public
			// images, while payment proofs in the same key namespace are
			// authorized inside the handler.
			api.With(scopedAuth).Post("/uploads", uploadsHandler.Upload)

			// Categories: Public GETs, Protected Mutations
			api.Route("/categories", func(cr chi.Router) {
				cr.Get("/", productHandler.ListCategories)
				cr.Get("/{id}", productHandler.GetCategory)
				cr.Group(func(prot chi.Router) {
					prot.Use(scopedAuth)
					prot.Use(actLog)
					prot.With(auth.RequirePermission("masterdata.categories.create")).Post("/", productHandler.CreateCategory)
					prot.With(auth.RequirePermission("masterdata.categories.update")).Put("/{id}", productHandler.UpdateCategory)
					prot.With(auth.RequirePermission("masterdata.categories.delete")).Delete("/{id}", productHandler.DeleteCategory)
				})
			})
			api.Route("/units", func(ur chi.Router) {
				ur.Use(scopedAuth)
				ur.Use(actLog)
				ur.Get("/", productHandler.ListUnits)
				ur.With(auth.RequirePermission("masterdata.units.create")).Post("/", productHandler.CreateUnit)
				ur.With(auth.RequirePermission("masterdata.units.update")).Put("/{id}", productHandler.UpdateUnit)
				ur.With(auth.RequirePermission("masterdata.units.delete")).Delete("/{id}", productHandler.DeleteUnit)
			})
			api.Route("/brands", func(br chi.Router) {
				br.Get("/", productHandler.ListBrands)
				br.Get("/{id}", productHandler.GetBrand)
				br.Group(func(prot chi.Router) {
					prot.Use(scopedAuth)
					prot.Use(actLog)
					prot.With(auth.RequirePermission("masterdata.brands.create")).Post("/", productHandler.CreateBrand)
					prot.With(auth.RequirePermission("masterdata.brands.update")).Put("/{id}", productHandler.UpdateBrand)
					prot.With(auth.RequirePermission("masterdata.brands.delete")).Delete("/{id}", productHandler.DeleteBrand)
				})
			})
			// Products: Public GETs, Protected Mutations
			api.Route("/products", func(pr chi.Router) {
				pr.Use(optAuth)
				pr.Get("/", productHandler.ListProducts)
				pr.Get("/{id}", productHandler.GetProduct)
				pr.Get("/{id}/units", productHandler.ListProductUnits)
				pr.Get("/{id}/barcodes", productHandler.ListProductBarcodes)
				pr.Group(func(prot chi.Router) {
					prot.Use(scopedAuth)
					prot.Use(actLog)
					prot.With(auth.RequirePermission("masterdata.products.create")).Post("/", productHandler.CreateProduct)
					prot.With(auth.RequirePermission("masterdata.products.update")).Put("/{id}", productHandler.UpdateProduct)
					prot.With(auth.RequirePermission("masterdata.products.delete")).Delete("/{id}", productHandler.DeleteProduct)
					prot.With(auth.RequirePermission("masterdata.products.units.create")).Post("/{id}/units", productHandler.CreateProductUnit)
					prot.With(auth.RequirePermission("masterdata.products.units.update")).Put("/{id}/units/{unitConfigID}", productHandler.UpdateProductUnit)
					prot.With(auth.RequirePermission("masterdata.products.units.delete")).Delete("/{id}/units/{unitConfigID}", productHandler.DeleteProductUnit)
					prot.With(auth.RequirePermission("masterdata.products.barcodes.create")).Post("/{id}/barcodes", productHandler.CreateProductBarcode)
					prot.With(auth.RequirePermission("masterdata.products.barcodes.delete")).Delete("/{id}/barcodes/{barcodeID}", productHandler.DeleteProductBarcode)
				})
			})
			api.Get("/barcodes/lookup", productHandler.LookupBarcode)
			api.Route("/suppliers", func(sr chi.Router) {
				sr.Use(scopedAuth)
				sr.Use(actLog)
				sr.With(auth.RequirePermission("masterdata.suppliers.view")).Get("/", partnerHandler.ListSuppliers)
				sr.With(auth.RequirePermission("masterdata.suppliers.view")).Get("/{id}", partnerHandler.GetSupplier)
				sr.With(auth.RequirePermission("masterdata.suppliers.create")).Post("/", partnerHandler.CreateSupplier)
				sr.With(auth.RequirePermission("masterdata.suppliers.update")).Put("/{id}", partnerHandler.UpdateSupplier)
				sr.With(auth.RequirePermission("masterdata.suppliers.delete")).Delete("/{id}", partnerHandler.DeleteSupplier)
			})
			api.Route("/customers", func(cr chi.Router) {				cr.Use(scopedAuth)
				cr.Use(actLog)
				// RBAC audit 2026-08-25: the customer directory is internal
				// master data (PII). Storefront admins reach it through the
				// e-commerce admin flag, not the buyer role.
				cr.With(ecomOrPermGate("masterdata.customers.read")).Get("/", partnerHandler.ListCustomers)
				cr.With(ecomOrPermGate("masterdata.customers.read")).Get("/search", partnerHandler.SearchCustomers)
				cr.With(ecomOrPermGate("masterdata.customers.read")).Get("/{id}", partnerHandler.GetCustomer)
				// Own-record routes for the storefront account page (R2-5a).
				// Gate is the e-commerce application scope, NOT
				// masterdata.customers.*: the directory is internal PII master
				// data, and a buyer must be able to read and edit their own
				// record without that grant, which is what would expose everyone
				// else's. The target is derived from the token, so there is no id
				// in the path to tamper with — which is also why "/me" is
				// registered before "/{id}" below.
				cr.With(auth.RequireApp(auth.AppECommerce)).Get("/me", partnerHandler.MyProfile)
				cr.With(auth.RequireApp(auth.AppECommerce)).Put("/me", partnerHandler.UpdateMyProfile)
				cr.With(auth.RequirePermission("masterdata.customers.create")).Post("/", partnerHandler.CreateCustomer)
				cr.With(ecomOrPermGate("masterdata.customers.update")).Put("/{id}", partnerHandler.UpdateCustomer)
				cr.With(auth.RequirePermission("masterdata.customers.delete")).Delete("/{id}", partnerHandler.DeleteCustomer)
				// Password reset for the customer's linked B2B login (000151).
				// The update permission is the right gate: an operator who may
				// edit a customer manages that customer's account access too.
				// Money-adjacent credential change → idempotency key required.
				cr.With(idem.Handler, ecomOrPermGate("masterdata.customers.update")).Post("/{id}/reset-password", partnerHandler.ResetCustomerPassword)
			})
			api.Route("/warehouses", func(wr chi.Router) {
				wr.Use(scopedAuth)
				wr.Use(actLog)
				// Slim options for checkout pickers: bearer-only (any
				// authenticated user), no inventory.warehouses.view required.
				wr.Get("/options", warehouseHandler.Options)
				wr.With(auth.RequirePermission("inventory.warehouses.view")).Get("/", warehouseHandler.List)
				wr.With(auth.RequirePermission("inventory.warehouses.view")).Get("/{id}", warehouseHandler.Get)
				wr.With(auth.RequirePermission("inventory.warehouses.create")).Post("/", warehouseHandler.Create)
				wr.With(auth.RequirePermission("inventory.warehouses.update")).Put("/{id}", warehouseHandler.Update)
				wr.With(auth.RequirePermission("inventory.warehouses.delete")).Delete("/{id}", warehouseHandler.Delete)
			})

			// Phase 7: inventory. Inventory is role:admin on the web routes;
			// permission globs grant warehouse the inventory.* family.
			api.Route("/inventory/stock", func(ir chi.Router) {
				ir.Use(scopedAuth)
				// Stock availability AND cost used to be one payload behind one
				// gate, so a POS operator and a B2B buyer both received
				// purchase_price/hpp/value — the margin. The gate below now
				// admits availability callers only; cost is gated separately by
				// internal/inventory on inventory.cost.view, so this gate answers
				// "may you see stock", not "may you see stock and its price".
				//
				// sales.sales-orders.view was dropped from this list by 000103:
				// it was a third way in to cost data for roles that needed
				// neither stock nor cost. The ERP frontend gates its own stock
				// pages on inventory.stock.view, which is what remains here.
				ir.With(auth.RequireAnyPermission("inventory.stock.view", "sales.pos.create")).Get("/", inventoryHandler.ListStock)
			})
			api.Route("/inventory/movements", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.With(auth.RequirePermission("inventory.movements.view")).Get("/", inventoryHandler.ListMovements)
			})
			api.Route("/inventory/stock-in", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.Use(actLog)
				ir.With(auth.RequirePermission("inventory.stock-in.create"), idem.Handler).Post("/", inventoryHandler.StockIn)
			})
			api.Route("/inventory/stock-out", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.Use(actLog)
				ir.With(auth.RequirePermission("inventory.stock-out.create"), idem.Handler).Post("/", inventoryHandler.StockOut)
			})
			api.Route("/inventory/opnames", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.Use(actLog)
				ir.With(auth.RequirePermission("inventory.opnames.view")).Get("/", inventoryHandler.ListOpnames)
				ir.With(auth.RequirePermission("inventory.opnames.create")).Post("/", inventoryHandler.CreateOpname)
				ir.With(auth.RequirePermission("inventory.opnames.view")).Get("/{id}", inventoryHandler.GetOpname)
				ir.With(auth.RequirePermission("inventory.opnames.update")).Put("/{id}", inventoryHandler.UpdateOpnamePhysical)
				ir.With(auth.RequirePermission("inventory.opnames.finalize"), idem.Handler).Post("/{id}/finalize", inventoryHandler.FinalizeOpname)
			})
			api.Route("/inventory/transfers", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.Use(actLog)
				ir.With(auth.RequirePermission("inventory.transfers.view")).Get("/", inventoryHandler.ListTransfers)
				ir.With(auth.RequirePermission("inventory.transfers.create")).Post("/", inventoryHandler.CreateTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.view")).Get("/{id}", inventoryHandler.GetTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.create")).Post("/{id}/approve", inventoryHandler.ApproveTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.create")).Put("/{id}", inventoryHandler.UpdateTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.create")).Post("/{id}/cancel", inventoryHandler.CancelTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.create")).Post("/{id}/receive", inventoryHandler.ReceiveTransfer)
				ir.With(auth.RequirePermission("inventory.transfers.create"), auth.RequireAdmin()).Post("/{id}/reconcile", inventoryHandler.ReconcileTransfer)
			})

			// Cart: B2B storefront cart for authenticated users (no special permission required).
			api.Route("/cart", func(cr chi.Router) {
				cr.Use(scopedAuth)
				// Application boundary: the B2B storefront cart is an
				// E-Commerce surface; ERP/POS identities may not use it.
				cr.Use(auth.RequireApp(auth.AppECommerce))
				cr.Get("/", cartHandler.GetCart)
				cr.Post("/items", cartHandler.AddItem)
				cr.Patch("/items/{id}", cartHandler.UpdateQuantity)
				cr.Delete("/items/{id}", cartHandler.RemoveItem)
				cr.Delete("/", cartHandler.ClearCart)
				cr.With(idem.Handler).Post("/checkout", cartHandler.Checkout)
			})

			// Account: buyer address book. Storefront-only surface scoped to the
			// authenticated B2B user (no special permission required).
			api.Route("/account/addresses", func(ar chi.Router) {
				ar.Use(scopedAuth)
				ar.Use(auth.RequireApp(auth.AppECommerce))
				ar.Get("/", accountHandler.List)
				ar.With(idem.Handler).Post("/", accountHandler.Create)
				ar.With(idem.Handler).Put("/{id}", accountHandler.Update)
				ar.Delete("/{id}", accountHandler.Delete)
			})

			// Phase 8: sales/POS. role:admin + role:sales (globs sales.*).
			api.Route("/sales-orders", func(sr chi.Router) {
				sr.Use(scopedAuth)
				sr.Use(actLog)
				sr.With(auth.RequireApp(auth.AppECommerce)).Get("/me", salesHandler.MyOrders)
				sr.With(auth.RequireApp(auth.AppECommerce)).Get("/me/summary", salesHandler.MyOrdersSummary)
				sr.With(auth.RequireApp(auth.AppECommerce)).Get("/me/{id}", salesHandler.GetOrder)
				sr.With(ecomOrPermGate("sales.sales-orders.view")).Get("/", salesHandler.ListOrders)
				sr.With(auth.RequirePermission("sales.sales-orders.create")).Get("/supervisors", salesHandler.ListSupervisors)
				sr.With(idem.Handler, auth.RequirePermission("sales.sales-orders.create")).Post("/", salesHandler.CreateOrder)
				// Return lookup for the POS dialog (000103 closed the order-book
				// list to cashiers on purpose): exact SO number, own stores only,
				// gated on the returns key the till already holds. Registered
				// before /{id} so "for-return" never parses as an id.
				sr.With(auth.RequirePermission("sales.returns.view")).Get("/for-return", salesHandler.ForReturnLookup)
				sr.With(auth.RequireAnyPermission("sales.pos.create", "sales.sales-orders.view")).Get("/for-pickup", salesHandler.ForPickupLookup)
				sr.With(ecomOrPermGate("sales.sales-orders.view")).Get("/{id}", salesHandler.GetOrder)
				// Buyer-submitted delivery/pickup proof photo (000142): the URL
				// must have come from POST /uploads (same auth), so the patch
				// only records the reference. Owner or staff; enforced in the
				// service along with terminal-state refusal.
				sr.Patch("/{id}/received-proof", salesHandler.AttachReceivedProof)
				sr.With(auth.RequirePermission("sales.sales-orders.update")).Put("/{id}", salesHandler.UpdateOrder)
				// ERP status transitions: {status: approved|confirmed|cancelled}
				// delegates to Confirm/Cancel; fulfillment (picking|shipped|delivered) allows warehouse operators.
				// Buyer self-confirm (shipped → delivered) rides the same route:
				// the handler enforces order ownership (loadOwnedOrder) and the
				// service restricts buyers to delivered-only, so no permission
				// is needed for the buyer branch — mirroring the cancel carve-out.
				sr.With(func(next http.Handler) http.Handler {
					return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
						u := auth.UserFrom(r.Context())
						isBuyer := u != nil && (u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client")
						if isBuyer && u.CanAccessApp(auth.AppECommerce) {
							next.ServeHTTP(w, r)
							return
						}
						// Till handover of an e-commerce store pickup order (cashier / store manager):
						if u != nil && (u.Role == auth.RoleCashier || u.Role == auth.RoleStoreManager) {
							next.ServeHTTP(w, r)
							return
						}
						auth.RequireAnyPermission("sales.sales-orders.update", "sales.sales-orders.view", "distribution.packing-orders.update", "distribution.packing-orders.view")(next).ServeHTTP(w, r)
					})
				}).Patch("/{id}/status", salesHandler.UpdateSalesOrderStatus)
				sr.With(auth.RequirePermission("sales.sales-orders.delete")).Delete("/{id}", salesHandler.DeleteOrder)
				sr.With(ecomOrPermGate("sales.sales-orders.confirm")).Post("/{id}/confirm", salesHandler.ConfirmOrder)
				// Till cancel of an unpaid QRIS order (deferred gateway): the
				// cashier holds no sales.sales-orders.cancel and gets none —
				// the service itself constrains (own stores, unpaid only with
				// restock, paid/shipped/delivered refused), mirroring the
				// buyer-confirm carve-out. No role widened.
				sr.With(func(next http.Handler) http.Handler {
					return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
						if u := auth.UserFrom(r.Context()); u != nil && u.Role == auth.RoleCashier && u.CanAccessApp(auth.AppPOS) {
							next.ServeHTTP(w, r)
							return
						}
						ecomOrPermGate("sales.sales-orders.cancel")(next).ServeHTTP(w, r)
					})
				}).Post("/{id}/cancel", salesHandler.CancelOrder)
				sr.With(ecomOrPermGate("sales.sales-orders.update")).Put("/{id}/notes", salesHandler.UpdateOrderNotes)
				sr.With(idem.Handler, auth.RequireApp(auth.AppECommerce)).Post("/{id}/pay", salesHandler.PayCheckout)
				// snapGate admits:
				// 1. E-Commerce buyers (auth.AppECommerce) paying for their own order (object-level customer ownership is enforced downstream in the service).
				// 2. ERP/back-office staff holding sales.sales-orders.view, or cashier / POS, or storefront admin (AppECommerceAdmin).
				snapGate := func(next http.Handler) http.Handler {
					return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
						u := auth.UserFrom(r.Context())
						if u == nil {
							httpresponse.Error(w, http.StatusUnauthorized, middleware.RequestIDFrom(r.Context()),
								httpresponse.CodeAuthentication, "Unauthenticated", nil)
							return
						}
						isBuyer := u.Role == auth.RoleBuyer || u.Role == "customer" || u.Role == "b2b_client"
						if (isBuyer && u.CanAccessApp(auth.AppECommerce)) ||
							u.Can("sales.sales-orders.view") ||
							u.Can("distribution.packing-orders.view") ||
							u.Can("sales.pos.create") ||
							u.Role == auth.RoleCashier ||
							u.Role == auth.RoleAdmin ||
							u.Role == auth.RoleOwner ||
							u.CanAccessApp(auth.AppECommerceAdmin) {
							next.ServeHTTP(w, r)
							return
						}
						httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
							httpresponse.CodeForbidden, "Forbidden", nil)
					})
				}
				sr.With(idem.Handler, snapGate).Post("/{id}/snap-token", salesHandler.SnapToken)
				// In-page QRIS for the ERP pay dialog (POS-style Core API
				// charge, no Snap page). Same gate as snap-token; the charge
				// mutates gateway refs, so it sits behind the idempotency
				// handler like every other money-adjacent POST.
				sr.With(idem.Handler, snapGate).Post("/{id}/qris", salesHandler.OrderQRIS)
				// Missed-webhook recovery: buyer's device or ERP polls after Snap
				// closes; the backend verifies against Midtrans server-side.
				sr.With(snapGate).Get("/{id}/payment-status", salesHandler.SnapPaymentStatus)
				// Stranded-payment reconciliation REPORT (read-only): open Snap
				// invoices past Midtrans' webhook retry horizon, carrying both the
				// stored and the canonical order_id so an operator can match them
				// in the Midtrans dashboard. It resolves nothing itself —
				// settlement is the buyer's status poll, the background sweeper,
				// or a human booking POST /invoices/{id}/pay. Gated on the
				// invoice-READING permission, the same key GET /invoices uses,
				// not sales.sales-orders.view: this is finance looking at
				// invoices and a sales reader must not inherit the ledger view.
				// Static segment, so it wins over the /{id} wildcard above.
				sr.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/payment-reconciliation", salesHandler.StrandedInvoiceReport)
			})
			// Midtrans Payment Notification webhook — PUBLIC (no session auth):
			// authenticity is the notification signature checked in the service —
			// SHA-512 of order_id+status_code+gross_amount+server_key, which is the
			// digest Midtrans documents, compared in constant time. Mounted
			// on api directly so no scopedAuth middleware applies. This is the
			// dashboard Payment Notification URL target:
			// https://<api-host>/api/v1/midtrans/notifications
			api.Post("/midtrans/notifications", salesHandler.MidtransNotification)
			// Background Snap sweeper. Mounted on api directly, like the webhook
			// above, for the same reason: its caller is crontab, not a browser.
			// It is a MIDTRANS MACHINE ROUTE, not an ERP money surface, so it is
			// gated on MIDTRANS_SWEEP_TOKEN (checked in the handler, constant
			// time) rather than on scopedAuth + RequirePermission — crontab
			// holds no user session, and the two gates are mutually exclusive.
			// See deploy/MIDTRANS-SWEEPER.md §2 and internal/sales/snap_sweep.go.
			//
			// It takes NO identifiers: no invoice, no order, no ref, and a limit
			// that is only a server-clamped batch size. The candidate set comes
			// from a database predicate, so there is no parameter through which
			// one caller reaches another caller's order. Do not add one.
			// Idempotency applies: this moves money, and a retried POST must
			// not sweep twice. SnapSweepTokenGate is the group-wide gate (404
			// while the token is unset); the handler re-checks it and compares
			// the token in constant time.
			api.Route("/midtrans", func(mr chi.Router) {
				mr.Use(salesHandler.SnapSweepTokenGate)
				mr.With(idem.Handler).Post("/sweep", salesHandler.SweepSnapInvoices)
			})
			api.Route("/pos", func(sr chi.Router) {
				sr.Use(scopedAuth)
				sr.Use(actLog)
				// Shared surface: the store POS frontend and ERP's quick-sale
				// page both POST /pos; B2B identities may not (authz audit).
				sr.Use(auth.RequireAnyApp(auth.AppPOS, auth.AppERP))
				sr.With(idem.Handler, auth.RequirePermission("sales.pos.create")).Post("/", salesHandler.ProcessPOS)
				// Void a completed till sale (000151 flow audit item 7):
				// admin/owner only — it overrides the paid-order cancel guard,
				// refunding money (drawer cash_out / negative payment) and
				// restocking in one transaction. Money-adjacent critical
				// mutation → idempotency key required.
				sr.With(idem.Handler, auth.RequireAdmin()).Post("/{id}/void", salesHandler.VoidPOS)
				// Till-recorded payment for an unpaid pickup order (000151):
				// the store head verifies the transfer/QRIS proof, then settles
				// the order so Serahkan Barang unlocks. Same permission as the
				// till itself; money-adjacent → idempotency required.
				sr.With(idem.Handler, auth.RequirePermission("sales.pos.create")).Post("/{id}/pay", salesHandler.PayPOSPickup)
				// Till-side recovery poll for deferred-gateway (QRIS) orders:
				// the cashier polls while the customer pays on their phone.
				sr.With(auth.RequirePermission("sales.pos.create")).Get("/{id}/payment-status", salesHandler.SnapPOSStatus)
			})

			// Phase 6 item 4: sales/member vouchers (separate aggregate from
			// purchasing vouchers). role:admin + role:sales (globs sales.*).
			api.Route("/sales-vouchers", func(vr chi.Router) {
				vr.Use(scopedAuth)
				vr.Use(actLog)
				vr.With(ecomOrPermGate("sales.sales-vouchers.view")).Get("/", salesHandler.ListSalesVouchers)
				// RBAC audit 2026-08-25: redemption lookups use a dedicated key
				// so customer accounts can validate a code without gaining the
				// company-wide voucher list.
				vr.With(auth.RequirePermission("sales.sales-vouchers.validate")).Post("/validate", salesHandler.ValidateSalesVoucher)
				vr.With(ecomOrPermGate("sales.sales-vouchers.create")).Post("/", salesHandler.CreateSalesVoucher)
				vr.With(auth.RequirePermission("sales.sales-vouchers.validate")).Get("/by-number/{voucher_number}", salesHandler.GetSalesVoucherByNumber)
				vr.With(ecomOrPermGate("sales.sales-vouchers.view")).Get("/{id}", salesHandler.GetSalesVoucherByID)
				vr.With(ecomOrPermGate("sales.sales-vouchers.update")).Put("/{id}/status", salesHandler.UpdateSalesVoucherStatus)
				// POS wiring compatibility shims
				vr.With(ecomOrPermGate("sales.sales-vouchers.update")).Put("/{id}", salesHandler.UpdateSalesVoucher)
				vr.With(ecomOrPermGate("sales.sales-vouchers.delete")).Delete("/{id}", salesHandler.DeleteSalesVoucher)
			})

			// Phase 6 item 7: sales returns (§11.4). Immutable audit events;
			// creation is idempotency-protected (§19). role:admin + role:sales.
			api.Route("/returns", func(rr chi.Router) {
				rr.Use(scopedAuth)
				rr.Use(actLog)
				// Buyer-scoped history (RBAC audit 2026-08-25): buyers hold
				// sales.returns.create but not .view, so the storefront
				// history needs its own ownership-scoped view.
				rr.With(auth.RequireApp(auth.AppECommerce)).Get("/me", salesHandler.MyReturns)
				rr.With(auth.RequirePermission("sales.returns.view")).Get("/", salesHandler.ListReturns)
				rr.With(idem.Handler, ecomOrPermGate("sales.returns.create")).Post("/", salesHandler.CreateReturn)
				rr.With(auth.RequirePermission("sales.returns.view")).Get("/{id}", salesHandler.GetReturn)
			})

			// Phase 9: purchasing. role:admin + role:finance (glob purchasing.*).
			// Purchase requisitions (000151, flow audit item 8): the store
			// head's pengajuan pembelian barang habis. Create is the store
			// head's act (inventory.transfers.create — same role family);
			// review (convert to PO / reject) is admin pembelian's
			// (purchasing.purchase-orders.create). Idempotency on the two
			// mutations.
			api.Route("/purchase-requisitions", func(qr chi.Router) {
				qr.Use(scopedAuth)
				qr.Use(actLog)
				qr.With(auth.RequirePermission("inventory.transfers.create")).Get("/", requisitionsHandler.List)
				qr.With(idem.Handler, auth.RequirePermission("inventory.transfers.create")).Post("/", requisitionsHandler.Create)
				qr.With(auth.RequirePermission("inventory.transfers.create")).Get("/{id}", requisitionsHandler.Get)
				qr.With(idem.Handler, auth.RequirePermission("purchasing.purchase-orders.create")).Post("/{id}/review", requisitionsHandler.Review)
			})

			api.Route("/purchase-orders", func(pr chi.Router) {
				pr.Use(scopedAuth)
				pr.Use(actLog)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.view")).Get("/", purchasingHandler.ListOrders)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.create")).Post("/", purchasingHandler.CreateOrder)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.view")).Get("/{id}", purchasingHandler.GetOrder)
				// Printable document payload (client feedback 2026-08-26 section 1).
				pr.With(auth.RequirePermission("purchasing.purchase-orders.view")).Get("/{id}/print", purchasingHandler.PrintOrder)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.update")).Put("/{id}", purchasingHandler.UpdateOrder)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.delete")).Delete("/{id}", purchasingHandler.DeleteOrder)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.approve")).Post("/{id}/approve", purchasingHandler.ApproveOrder)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.cancel")).Post("/{id}/cancel", purchasingHandler.CancelOrder)
				pr.With(idem.Handler, auth.RequirePermission("purchasing.purchase-orders.pay")).Post("/{id}/pay", purchasingHandler.PayDebt)
			})
			api.Route("/purchase-orders/debts", func(pr chi.Router) {
				pr.Use(scopedAuth)
				pr.With(auth.RequirePermission("purchasing.purchase-orders.view")).Get("/", purchasingHandler.ListDebts)
			})
			api.Route("/goods-receipts", func(gr chi.Router) {
				gr.Use(scopedAuth)
				gr.Use(actLog)
				gr.With(auth.RequirePermission("purchasing.goods-receipts.view")).Get("/", purchasingHandler.ListGRs)
				gr.With(auth.RequirePermission("purchasing.goods-receipts.create"), idem.Handler).Post("/", purchasingHandler.CreateGR)
				gr.With(auth.RequirePermission("purchasing.goods-receipts.view")).Get("/{id}", purchasingHandler.GetGR)
			})
			api.Route("/vouchers", func(vr chi.Router) {
				vr.Use(scopedAuth)
				vr.Use(actLog)
				vr.With(auth.RequirePermission("purchasing.vouchers.view")).Get("/", purchasingHandler.ListVouchers)
				vr.With(auth.RequirePermission("purchasing.vouchers.create")).Post("/", purchasingHandler.CreateVoucher)
				vr.With(auth.RequirePermission("purchasing.vouchers.view")).Get("/{id}", purchasingHandler.GetVoucher)
				vr.With(auth.RequirePermission("purchasing.vouchers.create")).Post("/{id}/print", purchasingHandler.PrintVoucher)
				vr.With(auth.RequirePermission("purchasing.vouchers.update")).Put("/{id}", purchasingHandler.UpdateVoucher)
				vr.With(auth.RequirePermission("purchasing.vouchers.update")).Put("/{id}/status", purchasingHandler.UpdateVoucherStatus)
				vr.With(auth.RequirePermission("purchasing.vouchers.update")).Patch("/{id}/status", purchasingHandler.UpdateVoucherStatus)
			})

			// Phase 4: distribution. role:admin + role:warehouse/sales (globs
			// distribution.*). Shipments and dispatches are idempotency-
			// protected critical mutations (§19).
			api.Route("/delivery-orders", func(dr chi.Router) {
				dr.Use(scopedAuth)
				dr.Use(actLog)
				dr.With(auth.RequirePermission("distribution.delivery-orders.view")).Get("/", distributionHandler.ListDeliveryOrders)
				dr.With(auth.RequirePermission("distribution.delivery-orders.create")).Post("/", distributionHandler.CreateDeliveryOrder)
				// Phase 6 item 5: buyer-scoped DO tracking (B2B surface).
				dr.With(auth.RequireApp(auth.AppECommerce)).Get("/me", distributionHandler.MyDeliveryOrders)
				dr.With(auth.RequirePermission("distribution.delivery-orders.view")).Get("/{id}", distributionHandler.GetDeliveryOrder)
				dr.With(idem.Handler, auth.RequirePermission("distribution.shipments.create")).Post("/{id}/shipments", distributionHandler.CreateShipment)
				dr.With(idem.Handler, auth.RequirePermission("distribution.dispatches.create")).Post("/{id}/dispatch", distributionHandler.CreateDispatch)
				dr.With(idem.Handler, auth.RequirePermission("distribution.shipments.deliver")).Post("/{id}/deliver", distributionHandler.DeliverDeliveryOrder)
			})
			api.Route("/shipments", func(sr chi.Router) {
				sr.Use(scopedAuth)
				sr.Use(actLog)
				sr.With(idem.Handler, auth.RequirePermission("distribution.shipments.deliver")).Post("/{id}/deliver", distributionHandler.DeliverShipment)
				// Phase 5: shipment tracking (§16). Reads are view-level; the
				// event append is idempotency-protected like other critical
				// mutations (§19).
				sr.With(auth.RequirePermission("distribution.shipments.view")).Get("/{id}/tracking", distributionHandler.GetShipmentTracking)
				sr.With(idem.Handler, auth.RequirePermission("distribution.tracking.record")).Post("/{id}/tracking/events", distributionHandler.RecordTrackingEvent)
				sr.With(auth.RequirePermission("distribution.tracking.lookup")).Get("/{id}/tracking/latest", distributionHandler.LatestShipmentLocation)
			})
			// Map/tracking audit 2026-08-25: centralized map workspace feeds.
			api.Route("/distribution/map", func(mr chi.Router) {
				mr.Use(scopedAuth)
				mr.With(auth.RequirePermission("distribution.delivery-orders.view")).Get("/", distributionHandler.MapFeed)
			})
			// Driver picker for the dispatch modal — gated on dispatches.create (not
			// system.users.view) so a warehouse dispatcher can use it without
			// user-admin rights.
			api.Route("/distribution/drivers", func(vr chi.Router) {
				vr.Use(scopedAuth)
				vr.With(auth.RequirePermission("distribution.dispatches.create")).Get("/", distributionHandler.ListDrivers)
				vr.With(idem.Handler, auth.RequirePermission("distribution.dispatches.create")).Post("/", distributionHandler.CreateDriver)
			})

			api.Route("/tracking", func(tr chi.Router) {
				tr.Use(scopedAuth)
				tr.Use(actLog)
				tr.With(auth.RequirePermission("distribution.tracking.lookup")).Get("/lookup", distributionHandler.LookupShipment)
			})

			// Geospatial abstractions (routing/geocoding). Degrade to
			// available:false when no provider is configured.
			api.Route("/geo", func(gr chi.Router) {
				gr.Use(scopedAuth)
				gr.With(auth.RequirePermission("distribution.tracking.lookup")).Get("/route", geoHandler.Route)
				gr.With(auth.RequirePermission("distribution.tracking.lookup")).Get("/geocode", geoHandler.Geocode)
			})
			api.Route("/fleet", func(fr chi.Router) {
				fr.Use(scopedAuth)
				fr.Use(actLog)
				fr.With(auth.RequirePermission("distribution.fleet.view")).Get("/", distributionHandler.ListFleet)
				fr.With(auth.RequirePermission("distribution.fleet.create")).Post("/", distributionHandler.CreateVehicle)
				fr.With(auth.RequirePermission("distribution.fleet.view")).Get("/{id}", distributionHandler.GetVehicle)
			})

			// Phase 10: finance. Fakturisasi routes are role:sales+finance
			// (glob fakturisasi.*); cashflows are role:finance (glob finance.*).
			api.Route("/invoices", func(ir chi.Router) {
				ir.Use(scopedAuth)
				ir.Use(actLog)
				ir.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/", financeHandler.ListInvoices)
				ir.With(auth.RequirePermission("fakturisasi.invoices.create")).Post("/", financeHandler.CreateInvoice)
				ir.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/receivables", financeHandler.ListReceivables)
				// Phase 6 item 5: buyer-scoped invoice/receivable views (B2B surface).
				ir.With(auth.RequireApp(auth.AppECommerce)).Get("/me", financeHandler.MyInvoices)
				ir.With(auth.RequireApp(auth.AppECommerce)).Get("/me/{id}/print", financeHandler.MyPrintInvoice)
				ir.With(auth.RequireApp(auth.AppECommerce)).Get("/receivables/me", financeHandler.MyReceivables)
				ir.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/{id}", financeHandler.GetInvoice)
				ir.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/{id}/print", financeHandler.PrintInvoice)
				ir.With(auth.RequirePermission("fakturisasi.invoices.delete")).Delete("/{id}", financeHandler.DeleteInvoice)
				ir.With(idem.Handler, auth.RequirePermission("fakturisasi.invoices.pay")).Post("/{id}/pay", financeHandler.PayInvoice)
			})
			// LHPP sheets are PERSISTED and maintained by hand: every dispatched DO
			// gets its own single-DO group (the distribution LHPPGrouper seam), and
			// a human merges/splits them from here. Payments still post through
			// financeSvc.PayInvoice (see internal/lhpp), so it stays a thin view
			// over /invoices rather than a parallel ledger — regrouping changes
			// bucket count, never money.
			api.Route("/lhpp", func(lr chi.Router) {
				lr.Use(scopedAuth)
				lr.Use(actLog)
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/", lhppHandler.List)
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/{id}", lhppHandler.Get)
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.pay")).Post("/{id}/payments", lhppHandler.RecordPayment)
				// Sales LHPP: billing visits Finance schedules for a sales rep,
				// grouped by sales+date instead of driver+date (see internal/lhpp
				// buildSalesGroups). "sales" is a static segment so it takes
				// priority over "/{id}" above — same pattern as /invoices/me.
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/sales", lhppHandler.ListSales)
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/sales/me", lhppHandler.MySales)
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/sales/{id}", lhppHandler.GetSales)
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.pay")).Post("/sales/{id}/payments", lhppHandler.RecordSalesPayment)
				// Group maintenance. "groups" is a static segment so it wins over
				// "/{id}" above. Every mutation carries idem.Handler: a merge
				// retried by a double-click must not be applied twice, and the
				// membership invariant is enforced by lhpp_group_items_do_unique.
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.merge")).Post("/groups/merge", lhppHandler.Merge)
				// Sheet handover: piutang gives the sheet to a sales collector
				// (or takes it back with a null assignee). Custody change, not
				// money — but a money-adjacent mutation, so it idempotency-keys
				// like the rest.
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.merge")).Post("/groups/{id}/assign", lhppHandler.Assign)
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.merge")).Post("/groups/{id}/split", lhppHandler.Split)
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.merge")).Post("/groups/{id}/void", lhppHandler.Void)
				lr.With(idem.Handler, auth.RequirePermission("sales.lhpp.merge")).Patch("/groups/{id}", lhppHandler.UpdateGroup)
				lr.With(auth.RequirePermission("sales.lhpp.view")).Get("/groups/{id}/history", lhppHandler.History)
			})
			api.Route("/payments", func(pr chi.Router) {
				pr.Use(scopedAuth)
				pr.Use(actLog)
				// Storefront-admin (AppECommerceAdmin) can list + verify payments from
				// the B2B backoffice even without the ERP fakturisasi role — mirrors the
				// sales-orders widening so the demo /admin/payments surface is usable.
				// Plain ecommerce customers and non-admin ERP identities stay denied.
				pr.With(ecomOrPermGate("fakturisasi.payments.view")).Get("/", financeHandler.ListPayments)
				pr.With(idem.Handler, ecomOrPermGate("fakturisasi.payments.verify")).Post("/{id}/verify", financeHandler.VerifyPayment)
				// Phase 6 item 5: buyer-scoped payment view (B2B surface).
				pr.With(auth.RequireApp(auth.AppECommerce)).Get("/me", financeHandler.MyPayments)
				// Proof upload: the payments table is polymorphic over its
				// payable, so ownership is enforced in-handler — invoice
				// payables resolve to customers.user_id (owning buyer may
				// upload), everything else falls back to the verify permission.
				// Proof uploads are shared by a buyer proving their own invoice
				// payment and ERP staff verifying any payment. Keep POS-only
				// identities outside this ownership/permission handler.
				pr.With(auth.RequireAnyApp(auth.AppECommerce, auth.AppERP)).Post("/{id}/proof", financeHandler.UploadPaymentProof)
				// Partial payments move money exactly like POST /invoices/{id}/pay,
				// so they carry that operation's fakturisasi.invoices.pay
				// permission and idempotency guard.
				pr.With(idem.Handler, auth.RequirePermission("fakturisasi.invoices.pay")).Post("/{id}/partial", financeHandler.RecordPartialPayment)
			})
			api.Route("/cashflows", func(cr chi.Router) {
				cr.Use(scopedAuth)
				cr.Use(actLog)
				cr.With(auth.RequirePermission("finance.cashflows.view")).Get("/", financeHandler.ListCashflows)
				cr.With(auth.RequirePermission("finance.cashflows.view")).Get("/{id}", financeHandler.GetCashflow)
				cr.With(auth.RequirePermission("finance.cashflows.create")).Post("/", financeHandler.CreateCashflow)
				cr.With(auth.RequirePermission("finance.cashflows.update")).Put("/{id}", financeHandler.UpdateCashflow)
				cr.With(auth.RequirePermission("finance.cashflows.update")).Post("/{id}/reverse", financeHandler.ReverseCashflow)
				cr.With(auth.RequirePermission("finance.cashflows.delete")).Delete("/{id}", financeHandler.DeleteCashflow)
			})
			// Accounting hardening (client feedback 2026-08-26 sections 5-10).
			api.Route("/cash-accounts", func(cacr chi.Router) {
				cacr.Use(scopedAuth)
				cacr.Use(actLog)
				cacr.With(auth.RequirePermission("finance.cash-accounts.view")).Get("/", hardening.ListCashAccounts)
				cacr.With(auth.RequirePermission("finance.cash-accounts.create")).Post("/", hardening.CreateCashAccount)
			})
			api.Route("/accounts", func(ar chi.Router) {
				ar.Use(scopedAuth)
				ar.With(auth.RequirePermission("finance.accounts.view")).Get("/", hardening.ListAccounts)
			})
			api.Route("/payment-vouchers", func(pvr chi.Router) {
				pvr.Use(scopedAuth)
				pvr.Use(actLog)
				pvr.With(auth.RequirePermission("finance.payment-vouchers.view")).Get("/", hardening.ListPaymentVouchers)
				pvr.With(auth.RequirePermission("finance.payment-vouchers.view")).Get("/{id}", hardening.GetPaymentVoucher)
				pvr.With(idem.Handler, auth.RequirePermission("finance.payment-vouchers.create")).Post("/", hardening.CreatePaymentVoucher)
				pvr.With(idem.Handler, auth.RequirePermission("finance.payment-vouchers.post")).Post("/{id}/post", hardening.PostPaymentVoucher)
				pvr.With(idem.Handler, auth.RequirePermission("finance.payment-vouchers.void")).Post("/{id}/void", hardening.VoidPaymentVoucher)
			})
			api.Route("/cashflow-balances", func(cbr chi.Router) {
				cbr.Use(scopedAuth)
				cbr.With(auth.RequirePermission("finance.cashflows.view")).Get("/", hardening.Balances)
				cbr.With(auth.RequirePermission("finance.cashflows.view")).Get("/accounts", hardening.AccountBalances)
			})

			// Accounting module (migration 000050): chart of accounts,
			// cost-center departments, double-entry journals, general ledger,
			// and customer deposits / supplier advances. role:finance + gm
			// (glob accounting.*); store managers hold the .view keys.
			// Mutations are idempotency-protected like payment vouchers.
			api.Route("/accounting", func(accr chi.Router) {
				accr.Use(scopedAuth)
				accr.Use(actLog)
				accr.Route("/accounts", func(ar chi.Router) {
					ar.With(auth.RequirePermission("accounting.accounts.view")).Get("/", accountingHandler.ListAccounts)
					ar.With(auth.RequirePermission("accounting.accounts.create")).Post("/", accountingHandler.CreateAccount)
					ar.With(auth.RequirePermission("accounting.accounts.update")).Put("/{id}", accountingHandler.UpdateAccount)
				})
				accr.Route("/departments", func(dr chi.Router) {
					dr.With(auth.RequirePermission("accounting.departments.view")).Get("/", accountingHandler.ListDepartments)
					dr.With(auth.RequirePermission("accounting.departments.create")).Post("/", accountingHandler.CreateDepartment)
					dr.With(auth.RequirePermission("accounting.departments.update")).Put("/{id}", accountingHandler.UpdateDepartment)
				})
				accr.Route("/journals", func(jr chi.Router) {
					jr.With(auth.RequirePermission("accounting.journals.view")).Get("/", accountingHandler.ListJournals)
					jr.With(auth.RequirePermission("accounting.journals.view")).Get("/by-source", accountingHandler.JournalBySource)
					jr.With(idem.Handler, auth.RequirePermission("accounting.journals.create")).Post("/", accountingHandler.CreateJournal)
					jr.With(auth.RequirePermission("accounting.journals.view")).Get("/{id}", accountingHandler.GetJournal)
					jr.With(idem.Handler, auth.RequirePermission("accounting.journals.update")).Put("/{id}", accountingHandler.UpdateJournal)
					jr.With(auth.RequirePermission("accounting.journals.delete")).Delete("/{id}", accountingHandler.DeleteJournal)
					jr.With(idem.Handler, auth.RequirePermission("accounting.journals.post")).Post("/{id}/post", accountingHandler.PostJournal)
					jr.With(idem.Handler, auth.RequirePermission("accounting.journals.void")).Post("/{id}/void", accountingHandler.VoidJournal)
					jr.With(idem.Handler, auth.RequirePermission("accounting.journals.reverse")).Post("/{id}/reverse", accountingHandler.ReverseJournal)
				})
				accr.With(auth.RequirePermission("accounting.ledger.view")).Get("/ledger", accountingHandler.Ledger)
				accr.Route("/advances", func(avr chi.Router) {
					avr.With(auth.RequirePermission("accounting.advances.view")).Get("/", accountingHandler.ListAdvances)
					avr.With(idem.Handler, auth.RequirePermission("accounting.advances.create")).Post("/", accountingHandler.ReceiveAdvance)
					avr.With(auth.RequirePermission("accounting.advances.view")).Get("/{id}", accountingHandler.GetAdvance)
					avr.With(idem.Handler, auth.RequirePermission("accounting.advances.allocate")).Post("/{id}/allocate", accountingHandler.AllocateAdvance)
					avr.With(idem.Handler, auth.RequirePermission("accounting.advances.refund")).Post("/{id}/refund", accountingHandler.RefundAdvance)
				})
				accr.Route("/periods", func(pr chi.Router) {
					pr.With(auth.RequirePermission("accounting.periods.view")).Get("/", financeHandler.ListAccountingPeriods)
					pr.With(auth.RequirePermission("accounting.periods.view")).Get("/{year}/{month}", financeHandler.GetAccountingPeriod)
					pr.With(auth.RequirePermission("accounting.periods.preview")).Get("/{year}/{month}/preview", financeHandler.PreviewAccountingPeriod)
					pr.With(auth.RequirePermission("accounting.periods.validate")).Post("/{year}/{month}/validate", financeHandler.ValidateAccountingPeriod)
					pr.With(idem.Handler, auth.RequirePermission("accounting.periods.close")).Post("/{year}/{month}/close", financeHandler.CloseAccountingPeriod)
					pr.With(idem.Handler, auth.RequirePermission("accounting.periods.reopen")).Post("/{year}/{month}/reopen", financeHandler.ReopenAccountingPeriod)
					pr.With(auth.RequirePermission("accounting.periods.view")).Get("/{year}/{month}/history", financeHandler.AccountingPeriodHistory)
				})
			})
			// Phase 14: HR. Self-service (/me, clock-in/out, leaves) for any
			// authenticated user; management routes carry hr.* permissions.
			api.Route("/employees", func(er chi.Router) {
				er.Use(scopedAuth)
				er.Use(actLog)
				er.With(auth.RequirePermission("hr.employees.view")).Get("/", hrHandler.ListEmployees)
				er.With(auth.RequirePermission("hr.employees.create")).Post("/", hrHandler.CreateEmployee)
				er.With(auth.RequirePermission("hr.employees.view")).Get("/{id}", hrHandler.GetEmployee)
				er.With(auth.RequirePermission("hr.employees.update")).Put("/{id}", hrHandler.UpdateEmployee)
				er.With(auth.RequirePermission("hr.employees.delete")).Delete("/{id}", hrHandler.DeleteEmployee)
			})
			api.Route("/hr/attendance", func(ar chi.Router) {
				ar.Use(scopedAuth)
				ar.Use(actLog)
				ar.Get("/me", hrHandler.MyAttendance)
				ar.With(auth.RequirePermission("hr.clockin")).Post("/clock-in", hrHandler.ClockIn)
				ar.With(auth.RequirePermission("hr.clockout")).Post("/clock-out", hrHandler.ClockOut)
				ar.With(auth.RequirePermission("hr.attendance.view")).Get("/", hrHandler.ListAttendance)
				ar.With(auth.RequirePermission("hr.attendance.view")).Get("/{id}", hrHandler.GetAttendance)
				ar.With(auth.RequirePermission("hr.attendance.create")).Post("/", hrHandler.CreateAttendance)
			})
			api.Route("/hr/leaves", func(lr chi.Router) {
				lr.Use(scopedAuth)
				lr.Use(actLog)
				lr.Get("/me", hrHandler.MyLeaves)
				lr.With(auth.RequirePermission("hr.leaves.store")).Post("/", hrHandler.CreateLeave)
				lr.With(auth.RequirePermission("hr.leaves.view")).Get("/", hrHandler.ListLeaves)
				lr.With(auth.RequirePermission("hr.leaves.approve")).Post("/{id}/approve", hrHandler.ApproveLeave)
				lr.With(auth.RequirePermission("hr.leaves.approve")).Post("/{id}/reject", hrHandler.RejectLeave)
			})
			api.Route("/hr/payrolls", func(pr chi.Router) {
				pr.Use(scopedAuth)
				pr.Use(actLog)
				pr.Get("/me", hrHandler.MyPayrolls)
				pr.With(auth.RequirePermission("hr.payrolls.view")).Get("/", hrHandler.ListPayrolls)
				pr.With(auth.RequirePermission("hr.payrolls.view")).Get("/{id}", hrHandler.GetPayroll)
				pr.With(auth.RequirePermission("hr.payrolls.generate")).Post("/", hrHandler.GeneratePayrolls)
				pr.With(auth.RequirePermission("hr.payrolls.approve")).Post("/{id}/approve", hrHandler.ApprovePayroll)
				pr.With(auth.RequirePermission("hr.payrolls.pay")).Post("/{id}/pay", hrHandler.PayPayroll)
			})
			api.Route("/hr/performances", func(pr chi.Router) {
				pr.Use(scopedAuth)
				pr.Use(actLog)
				pr.With(auth.RequirePermission("hr.performances.view")).Get("/", hrHandler.ListPerformances)
				pr.With(auth.RequirePermission("hr.performances.create")).Post("/", hrHandler.CreatePerformance)
				pr.With(auth.RequirePermission("hr.performances.view")).Get("/{id}", hrHandler.GetPerformance)
				pr.With(auth.RequirePermission("hr.performances.update")).Put("/{id}", hrHandler.UpdatePerformance)
				pr.With(auth.RequirePermission("hr.performances.delete")).Delete("/{id}", hrHandler.DeletePerformance)
			})

			// Phase 14: sales tracking. role:admin + role:sales (globs
			// sales_tracking.*). /me and location pings are self-service.
			// Jadwal kunjungan: /me is the sales rep's own read-only schedule
			// (visits.view_own); creating one is Finance's (billing) or
			// SPV's (routine) call — see visits.create.billing/.routine.
			api.Route("/sales-tracking/visits", func(vr chi.Router) {
				vr.Use(scopedAuth)
				vr.Use(actLog)
				vr.With(auth.RequirePermission("sales_tracking.visits.view_own")).Get("/me", stHandler.MyVisits)
				vr.With(auth.RequirePermission("sales_tracking.visits.view")).Get("/", stHandler.ListVisits)
				vr.With(auth.RequirePermission("sales_tracking.visits.create")).Post("/", stHandler.CreateVisit)
				vr.With(auth.RequirePermission("sales_tracking.visits.create")).Get("/sales-employees", stHandler.ListSalesEmployees)
				vr.With(auth.RequirePermission("sales_tracking.visits.view")).Get("/{id}", stHandler.GetVisit)
				vr.With(auth.RequirePermission("sales_tracking.visits.checkin")).Post("/{id}/checkin", stHandler.VisitCheckin)
				vr.With(auth.RequirePermission("sales_tracking.visits.checkout")).Post("/{id}/checkout", stHandler.VisitCheckout)
			})
			api.Route("/sales-tracking/activities", func(ar chi.Router) {
				ar.Use(scopedAuth)
				ar.Use(actLog)
				ar.With(auth.RequirePermission("sales_tracking.activities.view")).Get("/", stHandler.ListActivities)
				ar.With(auth.RequirePermission("sales_tracking.activities.create")).Post("/", stHandler.CreateActivity)
			})
			api.Route("/sales-tracking/routes", func(rr chi.Router) {
				rr.Use(scopedAuth)
				rr.Use(actLog)
				rr.Get("/me", stHandler.MyRoutes)
				rr.With(auth.RequirePermission("sales_tracking.routes.view")).Get("/", stHandler.ListRoutes)
				rr.With(auth.RequirePermission("sales_tracking.routes.create")).Post("/", stHandler.CreateRoute)
			})
			api.Route("/sales-targets", func(tr chi.Router) {
				tr.Use(scopedAuth)
				tr.Use(actLog)
				tr.With(auth.RequirePermission("sales.targets.view")).Get("/", stargetHandler.List)
				tr.With(auth.RequirePermission("sales.targets.view")).Get("/summary", stargetHandler.Summary)
				tr.With(auth.RequirePermission("sales.targets.manage")).Post("/", stargetHandler.Create)
				tr.With(auth.RequirePermission("sales.targets.manage")).Put("/{id}", stargetHandler.Update)
				tr.With(auth.RequirePermission("sales.targets.manage")).Delete("/{id}", stargetHandler.Delete)
			})
			api.Route("/sales-tracking/locations", func(lr chi.Router) {
				lr.Use(scopedAuth)
				lr.Use(actLog)
				// Phase 7 §1: tighter per-IP limit on high-frequency pings.
				trackingRL := middleware.NewRateLimiter(rdb, func(r *http.Request) string {
					return "ratelimit:tracking:" + clientIP(r)
				}, int64(cfg.RateLimitTrackingPerMin), time.Minute)
				lr.With(trackingRL.Handler, auth.RequirePermission("sales_tracking.locations.ping")).Post("/ping", stHandler.PingLocations)
				lr.With(auth.RequirePermission("sales_tracking.locations.view")).Get("/current", stHandler.CurrentLocations)
				lr.With(auth.RequirePermission("sales_tracking.locations.view")).Get("/", stHandler.ListCustomerLocations)
			})
			api.Route("/sales-tracking/livemap", func(lr chi.Router) {
				lr.Use(scopedAuth)
				lr.With(auth.RequirePermission("sales_tracking.locations.view")).Get("/", stHandler.CurrentLocations)
			})

			// Phase 6 item 3: membership (scaffolding). Customer-facing CRM.
			// role:admin + role:sales (globs masterdata.memberships.*). Benefit
			// formulas and upgrade/downgrade policy are unresolved (business-rules
			// §20) and intentionally not implemented here.
			membershipHandler := membership.NewHandler(membershipSvc)
			// Phase 6 item 6: galon workflow aggregate (§14). Deposit/loan policy
			// is DEFAULT PENDING CONFIRMATION (§20 #3); deposit defaults to 0.
			galonSvc := galon.NewService(galon.NewRepository(pool), seqAdapter, cfg.Timezone())
			galonHandler := galon.NewHandler(galonSvc)
			settingsHandler := settings.NewHandler(pool)
			// Galon money integrity (bug sweep 2026-09): unit prices come from
			// the settings price list, never client input; completing the
			// handover writes the cash payment row in the same tx as the flip.
			galonSvc.WithPrices(func() galon.PriceList {
				g := settingsHandler.GalonSnapshot()
				return galon.PriceList{HargaIsi: g.HargaIsi, HargaGalon: g.HargaGalon, Deposit: g.Deposit, BiayaAntar: g.BiayaAntar}
			})
			galonUserID := func(ctx context.Context) *int64 {
				if u := auth.UserFrom(ctx); u != nil && u.ID != 0 {
					id := u.ID
					return &id
				}
				return nil
			}
			galonSvc = galonSvc.WithJournal(func(ctx context.Context, in galon.GalonJournalInput) (string, error) {
				// One completed galon handover = one posted journal entry:
				//   D Kas Tunai Toko              (cash in the till)
				//   K Pendapatan Depot Galon Air  (the sale)
				//   K Uang Muka / Deposit Customer (liability, deposit only)
				// Deposit gets its own credit line pair-side so a report
				// can state revenue and liability separately — the same
				// split the payments rows already make. Posted under the
				// system role (a cashier completing a handover must not
				// need accounting.journals.create), created_by stays real.
				if u := auth.UserFrom(ctx); u != nil {
					sys := *u
					sys.Role = auth.RoleSystem
					ctx = auth.ToContext(ctx, &sys, auth.TokenIDFrom(ctx))
				}
				desc := "Penjualan depot galon " + in.TransactionNumber
				cash := in.CashAcc
				if cash == "" {
					cash = galon.DefaultGalonCashAccount
				}
				rev := in.RevenueAccount
				if rev == "" {
					rev = galon.DefaultGalonRevenueAccount
				}
				dep := in.DepositAccount
				if dep == "" {
					dep = galon.DefaultGalonDepositAcc
				}
				lines := make([]accounting.JournalLineInput, 0, 4)
				totalCash := in.Revenue + in.DepositAmount
				if totalCash <= 0 {
					return "", nil
				}
				lines = append(lines, accounting.JournalLineInput{
					AccountCode: cash, DepartmentCode: "KANTOR PUSAT", Debit: decimal.NewFromFloat(totalCash),
					Description: desc,
				})
				if in.Revenue > 0 {
					lines = append(lines, accounting.JournalLineInput{
						AccountCode: rev, DepartmentCode: "KANTOR PUSAT", Credit: decimal.NewFromFloat(in.Revenue),
						Description: desc,
					})
				}
				if in.DepositAmount > 0 {
					lines = append(lines, accounting.JournalLineInput{
						AccountCode: dep, DepartmentCode: "KANTOR PUSAT", Credit: decimal.NewFromFloat(in.DepositAmount),
						Description: "Deposit galon " + in.TransactionNumber,
					})
				}
				j, err := accountingSvc.CreateJournal(ctx, accounting.JournalInput{
					JournalType: "cash_receipt",
					Reference:   in.TransactionNumber,
					Description: desc,
					SourceType:  "galon",
					Lines:       lines,
				}, galonUserID(ctx))
				if err != nil {
					return "", err
				}
				if _, err := accountingSvc.PostJournal(ctx, j.ID, galonUserID(ctx)); err != nil {
					return "", err
				}
				return j.JournalNumber, nil
			})
			galonSvc = galonSvc.WithQRIS(&galonQRISAdapter{sales: salesSvc})
			// Phase 6 item 8: cashier shift and drawer operations (§11.5).
			// Register/store binding is DEFAULT PENDING CONFIRMATION (§20 #5).
			cashierRepo := cashier.NewRepository(pool)
			cashierSvc := cashier.NewService(cashierRepo, seqAdapter, cfg.Timezone())
			cashierHandler := cashier.NewHandler(cashierSvc)
			// Cash refunds leave the drawer as cash_out entries (expected-cash
			// §11.5 stays truthful); closed shifts are rejected by the adapter.
			salesSvc.WithRefundDrawer(cashierRepo)
			salesSvc.WithShiftGuard(cashierRepo)
			warehousedepositRepo := warehousedeposit.NewRepository(pool)
			// No cashierSvc dependency: the "Kasir Pusat" who receives a Setoran
			// works in this ERP directly, not the outlet POS app — receiving is a
			// plain custody confirmation, not a POS shift/drawer cash-in.
			warehousedepositSvc := warehousedeposit.NewService(warehousedepositRepo, func(ctx context.Context, prefix string, t time.Time) (int64, error) {
				return seqAdapter.Sequence(ctx, prefix, seq.MonthStart(t), nil)
			}, cfg.Timezone())
			warehousedepositHandler := warehousedeposit.NewHandler(warehousedepositSvc)
			// lhppSvc predates cashierSvc/warehousedepositSvc in this file's
			// init order, so the dependency is wired late via a setter — see
			// lhpp.Service.WithWarehouseDeposits doc comment.
			lhppSvc.WithWarehouseDeposits(warehousedepositSvc)
			api.Route("/memberships", func(mr chi.Router) {
				mr.Use(scopedAuth)
				mr.Use(actLog)
				// Migration 000044 splits this surface into five gates so the
				// storefront, the till, the outlet and head office are not the
				// same identity:
				//   .read          own membership + the tier catalogue. Safe
				//                  for a buyer; a cashier needs the tier names
				//                  and discounts it displays.
				//   .lookup        the member DIRECTORY — list, by-id,
				//                  by-customer, by-number. Internal only.
				//                  ListMemberships/ByCustomer are NOT
				//                  ownership-scoped, so leaving these on .read
				//                  let any buyer enumerate every member's
				//                  identity; that is what this gate closes.
				//   .create        enrol a walk-in at the entry tier (cashier)
				//   .manage        change an existing member's tier / points
				//                  (store manager, SPV, GM/BM)
				//   .tiers.manage  change what a tier is WORTH — company-wide
				//                  discount policy, owner/GM only. Reachable
				//                  only via the masterdata.memberships.* glob.
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/", membershipHandler.ListMemberships)
				mr.With(auth.RequirePermission("masterdata.memberships.read")).Get("/me", membershipHandler.Me)
				mr.With(auth.RequirePermission("masterdata.memberships.read")).Get("/tiers", membershipHandler.ListTiers)
				mr.With(auth.RequirePermission("masterdata.memberships.read")).Get("/tiers/{id}", membershipHandler.GetTier)
				// The tier config audit trail. Read permission rather than
				// lookup, matching GetTier: this is tier configuration, and the
				// per-member tier-history below deliberately uses the weaker
				// permission because it is about one member's own tier.
				mr.With(auth.RequirePermission("masterdata.memberships.read")).Get("/tiers/{id}/config-history", membershipHandler.TierConfigHistory)
				mr.With(auth.RequirePermission("masterdata.memberships.tiers.manage")).Post("/tiers", membershipHandler.CreateTier)
				mr.With(auth.RequirePermission("masterdata.memberships.tiers.manage")).Put("/tiers/{id}", membershipHandler.UpdateTier)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/by-customer/{customerId}", membershipHandler.ByCustomer)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/by-number/{number}", membershipHandler.ByNumber)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/{id}", membershipHandler.Get)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/{id}/tier-history", membershipHandler.TierHistory)
				mr.With(auth.RequirePermission("masterdata.memberships.create")).Post("/", membershipHandler.Create)
				mr.With(auth.RequirePermission("masterdata.memberships.manage")).Put("/{id}", membershipHandler.Update)
				mr.With(idem.Handler, auth.RequirePermission("masterdata.memberships.manage")).Post("/{id}/accrue", membershipHandler.AccruePoints)
			})
			// Alias for legacy /members polls (thin empty compat — mirrors /memberships).
			api.Route("/members", func(mr chi.Router) {
				// ScopeMiddleware parity: every other route resolves the
				// caller's store scope; the alias must not be the one
				// unscoped exception (authz audit 2026-09-09).
				mr.Use(scopedAuth)
				mr.Use(actLog)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/", membershipHandler.ListMemberships)
				mr.With(auth.RequirePermission("masterdata.memberships.lookup")).Get("/{id}", membershipHandler.Get)
			})

			// Settings: store profile & galon pricing (GET any auth; PUT requires system.settings.manage via admin bypass parity)
			api.Route("/settings", func(sr chi.Router) {
				sr.Use(scopedAuth)
				sr.Use(actLog)
				sr.With(auth.RequirePermission("system.settings.view")).Get("/", settingsHandler.Get)
				sr.With(auth.RequirePermission("system.settings.manage")).Put("/", settingsHandler.Update)
			})

			// Phase 6 item 6: galon workflow (§14). role:admin + role:sales
			// (globs galon.*). Mutations are idempotency-protected (§19).
			api.Route("/galon", func(gr chi.Router) {
				gr.Use(scopedAuth)
				gr.Use(actLog)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/services", galonHandler.Services)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/status", galonHandler.Status)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/units", galonHandler.Units)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/by-code/{code}", galonHandler.ByCode)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/by-code/{code}/info", galonHandler.Info)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/", galonHandler.List)
				gr.With(idem.Handler, auth.RequirePermission("galon.transactions.create")).Post("/", galonHandler.Create)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/{id}", galonHandler.Get)
				gr.With(idem.Handler, auth.RequirePermission("galon.transactions.create")).Post("/{id}/qris", galonHandler.QRIS)
				gr.With(auth.RequirePermission("galon.transactions.view")).Get("/{id}/payment-status", galonHandler.PaymentStatus)
				gr.With(idem.Handler, auth.RequirePermission("galon.transactions.complete")).Post("/{id}/complete", galonHandler.Complete)
				gr.With(idem.Handler, auth.RequirePermission("galon.transactions.cancel")).Post("/{id}/cancel", galonHandler.Cancel)
				gr.With(idem.Handler, auth.RequirePermission("galon.transactions.return")).Post("/{id}/return", galonHandler.ReturnGalon)
			})

			// Phase 6 item 8: cashier shift and drawer operations (§11.5).
			// role:admin + role:sales (globs cashier.*). Mutations are
			// idempotency-protected (§19).
			api.Route("/cashier/shifts", func(cr chi.Router) {
				cr.Use(scopedAuth)
				cr.Use(actLog)
				cr.With(auth.RequirePermission("cashier.shifts.view")).Get("/", cashierHandler.List)
				cr.With(auth.RequirePermission("cashier.shifts.view")).Get("/current", cashierHandler.Current)
				cr.With(idem.Handler, auth.RequirePermission("cashier.shifts.open")).Post("/", cashierHandler.Open)
				cr.With(auth.RequirePermission("cashier.shifts.view")).Get("/{id}", cashierHandler.Get)
				cr.With(auth.RequirePermission("cashier.shifts.view")).Get("/{id}/summary", cashierHandler.Summary)
				cr.With(idem.Handler, auth.RequirePermission("cashier.shifts.drawer")).Post("/{id}/cash-in", cashierHandler.CashIn)
				cr.With(idem.Handler, auth.RequirePermission("cashier.shifts.drawer")).Post("/{id}/cash-out", cashierHandler.CashOut)
				cr.With(idem.Handler, auth.RequirePermission("cashier.shifts.close")).Post("/{id}/close", cashierHandler.Close)
			})

			// Warehouse driver deposits: driver (or Sales, for a direct payment)
			// hands cash to warehouse, a checklist of already-recorded cash/giro
			// payments becomes one Setoran with a receipt voucher, then Kasir
			// Pusat (working in this ERP, not the outlet POS app) confirms
			// custody — a plain status flag, no shift/drawer/cashflow involved.
			api.Route("/warehouse-deposits", func(wdr chi.Router) {
				wdr.Use(scopedAuth)
				wdr.Use(actLog)
				wdr.With(auth.RequirePermission("distribution.warehouse.view")).Get("/", warehousedepositHandler.List)
				wdr.With(auth.RequirePermission("distribution.warehouse.create")).Post("/", warehousedepositHandler.Create)
				// "Catat Setoran" checklist source — grant Sales the same
				// distribution.warehouse.create permission (via the Roles admin
				// page) if they should be able to declare direct-collected cash.
				wdr.With(auth.RequirePermission("distribution.warehouse.create")).Get("/undeposited-payments", warehousedepositHandler.UndepositedPayments)
				wdr.With(auth.RequirePermission("distribution.warehouse.view")).Get("/{id}", warehousedepositHandler.Get)
				wdr.With(idem.Handler, auth.RequirePermission("distribution.warehouse.receive")).Post("/{id}/receive", warehousedepositHandler.Receive)
			})

			// Phase 11: reports. RBAC audit 2026-08-25: dashboards and reports
			// previously required only authentication, which leaked company
			// financials to any authenticated principal (including B2B
			// customer accounts). Each surface now carries an explicit
			// permission; exports keep their module-specific gates.
			api.Route("/dashboard", func(dr chi.Router) {
				dr.Use(scopedAuth)
				dr.With(ecomOrPermGate("dashboard.view")).Get("/kpis", reportsHandler.KPIs)
				dr.With(ecomOrPermGate("dashboard.view")).Get("/charts", reportsHandler.Charts)
				dr.With(auth.RequirePermission("dashboard.pos.view")).Get("/pos", reportsHandler.POSDashboard)
				// Kepala toko cockpit — scoped to the caller's own outlet.
				dr.With(auth.RequirePermission("dashboard.pos.view")).Get("/outlet", reportsHandler.OutletDashboard)
				dr.Route("/widgets", func(wr chi.Router) {
					wr.With(auth.RequirePermission("dashboard.view")).Get("/policy", reportsHandler.GetWidgetPolicy)
					wr.With(auth.RequirePermission("system.roles.update")).Put("/policy/{role}", reportsHandler.SetRoleWidgetPolicy)
					wr.With(auth.RequirePermission("dashboard.view")).Get("/preferences", reportsHandler.GetUserWidgetPreferences)
					wr.With(auth.RequirePermission("dashboard.view")).Put("/preferences", reportsHandler.SetUserWidgetPreferences)
					wr.With(auth.RequirePermission("dashboard.view")).Post("/preferences/reset", reportsHandler.ResetUserWidgetPreferences)
				})
			})
			api.Route("/reports", func(rr chi.Router) {
				rr.Use(scopedAuth)
				rr.Use(actLog)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/profit-loss", reportsHandler.ProfitLoss)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/balance-sheet", reportsHandler.BalanceSheet)
				rr.With(auth.RequirePermission("sales.reports.view")).Get("/sales", reportsHandler.SalesReport)
				rr.With(auth.RequirePermission("inventory.stock.view")).Get("/stock", reportsHandler.StockReport)
				rr.With(auth.RequirePermission("fakturisasi.invoices.view")).Get("/fakturisasi", reportsHandler.FakturisasiReport)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/summary", reportsHandler.SummaryReport)
				rr.With(ecomGate("finance.reports.view")).Get("/analytics", reportsHandler.GetAnalytics)
				rr.With(auth.RequirePermission("fakturisasi.receivables.export")).Get("/receivables", reportsHandler.ExportReceivables)
				rr.With(auth.RequirePermission("purchasing.debts.export")).Get("/debts", reportsHandler.ExportDebts)
				rr.With(auth.RequirePermission("finance.cashflows.export")).Get("/cashflow", reportsHandler.ExportCashflow)
				// The journal-derived statement (operating / investing / financing).
				// finance.reports.view rather than cashflows.export: this READS
				// accounting data like the sibling report endpoints, it does not
				// export anything. The /cashflow row export above keeps the
				// stricter export permission because it hands back a file.
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/cashflow-statement", reportsHandler.CashflowStatement)

				// Detailed breakdown, aging, and stock detail reports
				rr.With(auth.RequirePermission("sales.reports.view")).Get("/sales-by-customer", reportsHandler.SalesByCustomer)
				rr.With(auth.RequirePermission("sales.reports.view")).Get("/sales-by-product", reportsHandler.SalesByProduct)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/purchases-by-supplier", reportsHandler.PurchasesBySupplier)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/purchases-by-product", reportsHandler.PurchasesByProduct)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/receivables-aging", reportsHandler.ReceivablesAging)
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/payables-aging", reportsHandler.PayablesAging)
				rr.With(auth.RequirePermission("inventory.stock.view")).Get("/stock-valuation", reportsHandler.StockValuation)
				// Accounting trial balance (migration 000050): per-account
				// debit/credit sums over posted journal lines.
				rr.With(auth.RequirePermission("finance.reports.view")).Get("/trial-balance", accountingHandler.TrialBalance)
				rr.With(auth.RequirePermission("inventory.stock.view")).Get("/stock-movements", reportsHandler.StockMovements)
				rr.With(auth.RequirePermission("inventory.stock.view")).Get("/stock-card", reportsHandler.StockCard)

				// NextReport Engine proxy: PDF and HTML rendering streams through NextReport.
				// RBAC is per-template inside the handler (templatePerms); the
				// route carries scopedAuth only so new templates need no remount.
				reportProxy := reportproxy.NewHandler(cfg.NextReportURL)
				reportProxy.SetTemplateResolver(templatesService)

				reportProxy.SetInvoiceResolver(financeSvc)
				reportProxy.SetPOResolver(purchasingSvc)
				reportProxy.SetDOResolver(distributionSvc)
				reportProxy.SetGRResolver(purchasingSvc)
				// Buyer-invoice ownership: buyer-reachable renders are checked
				// against the caller's linked customer (§13.1).
				reportProxy.SetCustomerResolver(func(ctx context.Context, userID int64) (int64, bool, error) {
					id, err := salesRepo.CustomerByUserID(ctx, nil, userID)
					if errors.Is(err, sales.ErrNotFound) {
						return 0, false, nil
					}
					if err != nil {
						return 0, false, err
					}
					return id, true, nil
				})
				rr.Get("/health", reportProxy.Health)
				rr.HandleFunc("/render", reportProxy.Render)
				rr.HandleFunc("/preview", reportProxy.Preview)

				// NextReport Visual Template Management & Definitions
				rr.Route("/templates", func(tr chi.Router) {
					tr.With(auth.RequirePermission("system.reports.view")).Get("/", templatesHandler.ListTemplates)
					tr.With(auth.RequirePermission("system.reports.manage")).Post("/", templatesHandler.CreateTemplate)
					tr.With(auth.RequirePermission("system.reports.view")).Get("/{id}", templatesHandler.GetTemplate)
					tr.With(auth.RequirePermission("system.reports.manage")).Put("/{id}", templatesHandler.UpdateTemplate)
					tr.With(auth.RequirePermission("system.reports.manage")).Post("/{id}/activate", templatesHandler.ActivateTemplate)
					tr.With(auth.RequirePermission("system.reports.manage")).Post("/{id}/duplicate", templatesHandler.DuplicateTemplate)
				})
				rr.Route("/definitions", func(dr chi.Router) {
					dr.With(auth.RequirePermission("system.reports.view")).Get("/", templatesHandler.ListDefinitions)
					dr.With(auth.RequirePermission("system.reports.view")).Get("/{code}", templatesHandler.GetDefinition)
				})
			})

			// Phase 14: system/audit. role:admin + role:system (globs
			// system.*). The audit middleware above records mutations.
			api.Group(func(g chi.Router) {
				g.Use(scopedAuth)
				g.Use(actLog)
				sysHandler.RegisterRoutes(g)
			})

			// Notifications: Live feed (ERP/POS) + WebPush / Service Worker background push subscriptions
			api.Route("/notifications", func(nr chi.Router) {
				nr.With(scopedAuth, actLog, auth.RequireAnyApp(auth.AppERP, auth.AppPOS)).Get("/", sysHandler.ListNotifications)
				nr.Get("/vapid-key", pushHandler.VapidKey)
				nr.Group(func(ar chi.Router) {
					ar.Use(scopedAuth)
					ar.Post("/push-subscriptions", pushHandler.Subscribe)
					ar.Delete("/push-subscriptions", pushHandler.Unsubscribe)
					ar.Post("/push-test", pushHandler.TestPush)
				})
			})

			// Tickets: Centralized support ticketing system across ERP, POS, and B2B
			ticketSvc := tickets.NewService(tickets.NewRepository(pool)).WithMailer(mailSvc, cfg.SupportEmail)
			ticketHandler := tickets.NewHandler(ticketSvc)
			api.Route("/tickets", func(tr chi.Router) {
				tr.Use(scopedAuth)
				tr.Use(actLog)
				tr.With(auth.RequireAnyPermission("support.tickets.view", "support.tickets.create", "support.tickets.manage")).Get("/", ticketHandler.List)
				tr.With(auth.RequirePermission("support.tickets.create")).Post("/", ticketHandler.Create)
				tr.With(auth.RequireAnyPermission("support.tickets.view", "support.tickets.create", "support.tickets.manage")).Get("/{id}", ticketHandler.Get)
				tr.With(auth.RequirePermission("support.tickets.manage")).Patch("/{id}", ticketHandler.Update)
				tr.With(auth.RequireAnyPermission("support.tickets.create", "support.tickets.manage")).Post("/{id}/comments", ticketHandler.AddComment)
			})
		})
	}
	return r
}

// kvSeq adapts pkg/seq.Next to the module Seq interfaces (prefix, date, tx-any).
// Allocation must run on the supplied transaction querier; it only falls back
// to the pool when no transaction is provided (master codes) — never silently.
type kvSeq struct {
	pool *pgxpool.Pool
}

func (s *kvSeq) Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error) {
	q := db.DBTX(s.pool)
	if t, ok := tx.(db.DBTX); ok && t != nil {
		q = t
	}
	return seq.Next(ctx, q, prefix, date)
}

// cacheRedis returns a *cache.Redis when a client is available, else nil
// (reports falls back to pass-through).
func cacheRedis(rdb *redis.Client) reports.Cache {
	return cache.New(rdb)
}

// newClientIPFunc builds the rate-limit identity resolver (Phase 7 §1).
// X-Forwarded-For is only honored when the direct peer is a configured
// trusted proxy; the client is then the rightmost XFF entry that is NOT
// itself a trusted proxy. Host parsing is IPv6-safe (net.SplitHostPort).
func newClientIPFunc(trusted []string) func(*http.Request) string {
	var nets []*net.IPNet
	ips := map[string]bool{}
	for _, t := range trusted {
		t = strings.TrimSpace(t)
		if t == "" {
			continue
		}
		if strings.Contains(t, "/") {
			if _, n, err := net.ParseCIDR(t); err == nil {
				nets = append(nets, n)
			}
			continue
		}
		ips[t] = true
	}
	isTrusted := func(ip net.IP) bool {
		if ip == nil {
			return false
		}
		if ips[ip.String()] {
			return true
		}
		for _, n := range nets {
			if n.Contains(ip) {
				return true
			}
		}
		return false
	}
	peer := func(r *http.Request) string {
		host, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			return r.RemoteAddr
		}
		return host
	}
	return func(r *http.Request) string {
		p := peer(r)
		if !isTrusted(net.ParseIP(p)) {
			return p
		}
		xff := r.Header.Get("X-Forwarded-For")
		if xff == "" {
			return p
		}
		parts := strings.Split(xff, ",")
		for i := len(parts) - 1; i >= 0; i-- {
			candidate := strings.TrimSpace(parts[i])
			if candidate == "" {
				continue
			}
			if !isTrusted(net.ParseIP(candidate)) {
				return candidate
			}
		}
		return p
	}
}

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

// grAccrualLines builds the balanced purchase-accrual draft for one GR:
// D 500.03 (DPP) + D 110701 (PPN, when taxed) / K 211.03 (DPP+PPN).
// Taxless POs get no PPN line — resolveLines rejects zero-amount rows.
func grAccrualLines(dept, grNumber string, dpp, tax decimal.Decimal) []accounting.JournalLineInput {
	lines := []accounting.JournalLineInput{
		{AccountCode: "500.03", DepartmentCode: dept, Debit: dpp, Description: fmt.Sprintf("PEMBELIAN %s (DPP)", grNumber)},
	}
	if tax.IsPositive() {
		lines = append(lines, accounting.JournalLineInput{
			AccountCode: "110701", DepartmentCode: dept, Debit: tax,
			Description: fmt.Sprintf("PPN MASUKAN %s", grNumber),
		})
	}
	return append(lines, accounting.JournalLineInput{
		AccountCode: "211.03", DepartmentCode: dept, Credit: dpp.Add(tax),
		Description: fmt.Sprintf("TOTAL PEMBELIAN %s", grNumber),
	})
}

func mapPlacedItems(items []cart.PlacedItem) []sales.CheckoutItemInput {
	out := make([]sales.CheckoutItemInput, 0, len(items))
	for _, it := range items {
		out = append(out, sales.CheckoutItemInput{ProductID: it.ProductID, Quantity: it.Quantity})
	}
	return out
}

func toPlacedOrder(so *sales.SalesOrder) *cart.PlacedOrder {
	po := &cart.PlacedOrder{
		ID:              so.ID,
		SoNumber:        so.SoNumber,
		CustomerID:      so.CustomerID,
		Status:          so.Status,
		OrderType:       so.OrderType,
		Subtotal:        so.Subtotal,
		TaxAmount:       so.TaxAmount,
		DiscountAmount:  so.DiscountAmount,
		TotalAmount:     so.TotalAmount,
		PaidAmount:      so.PaidAmount,
		RemainingAmount: so.RemainingAmount,
		ShippingCost:    so.ShippingCost,
		Notes:           so.Notes,
		Items:           make([]cart.PlacedOrderItem, 0, len(so.Items)),
	}
	for _, it := range so.Items {
		po.Items = append(po.Items, cart.PlacedOrderItem{
			ID:                 it.ID,
			ProductID:          it.ProductID,
			Quantity:           it.Quantity,
			UnitPrice:          it.UnitPrice,
			MembershipDiscount: it.MembershipDiscountAmount,
			Subtotal:           it.Subtotal,
		})
	}
	return po
}

type galonQRISAdapter struct {
	sales *sales.Service
}

func (a *galonQRISAdapter) ChargeQRIS(ctx context.Context, orderRef string, gross decimal.Decimal) (*galon.QRISResult, error) {
	res, err := a.sales.RequestQrisCharge(ctx, orderRef, gross)
	if err != nil {
		return nil, err
	}
	return &galon.QRISResult{
		TransactionID: res.TransactionID,
		QRURL:         res.QRURL,
		QRString:      res.QRString,
		ExpiresAt:     res.ExpiresAt,
	}, nil
}

func (a *galonQRISAdapter) CheckQRISStatus(ctx context.Context, orderRef string) (string, string, error) {
	return a.sales.FetchMidtransStatus(ctx, orderRef)
}
