package accounting

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"time"

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

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

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrNotFound             = errors.New("not found")
	ErrValidation           = errors.New("validation error")
	ErrDuplicate            = errors.New("kode sudah digunakan")
	ErrBadReference         = errors.New("referensi tidak ditemukan")
	ErrNotDraft             = errors.New("hanya jurnal draft yang dapat diubah atau dihapus")
	ErrUnbalanced           = errors.New("total debit dan kredit harus seimbang dan bernilai lebih dari 0")
	ErrNotPosted            = errors.New("hanya jurnal posted yang dapat dibatalkan atau dibalikkan")
	ErrExceedsRemaining     = errors.New("jumlah melebihi sisa deposit")
	ErrMissingAccount       = errors.New("akun sistem belum tersedia di COA")
	ErrInvalidDate          = errors.New("tanggal harus menggunakan format YYYY-MM-DD")
	ErrPeriodLocked         = errors.New("accounting period is locked")
	ErrJournalImmutable     = errors.New("posted journal is immutable")
	ErrJournalIntegrity     = errors.New("journal reversal or posting integrity violation")
	ErrJournalVoidForbidden = errors.New("posted journal must be corrected with a linked reversal")
)

// journalPrefixes maps journal_type -> document-number prefix
// (JU/KK/KM/BK/BM/DP/DK-YYYYMM-NNNN).
var journalPrefixes = map[string]string{
	"general":                  "JU",
	"cash_disbursement":        "KK",
	"cash_receipt":             "KM",
	"bank_disbursement":        "BK",
	"bank_receipt":             "BM",
	"customer_deposit_receipt": "DP",
	"customer_deposit_usage":   "DK",
}

var validAccountTypes = map[string]bool{
	"asset": true, "liability": true, "equity": true, "revenue": true, "expense": true,
}

// System accounts used by the advance workflows (migration 000050 seeds).
const (
	acctCashDefault     = "1000.01.01" // Kas Tunai Toko (POS Till)
	acctPettyCashOffice = "1000.01.02" // Kas Kecil Operasional (Office / Back-office)
	acctDepositSales    = "2000.02"    // Uang Muka Penjualan / Deposit Customer
	acctAdvancePurchase = "110501"     // Uang Muka Pembelian
	acctReceivableB2B   = "110302"     // Piutang Dagang B2B
	acctPayableSupplier = "210102"     // Hutang Dagang Supplier
)

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

// Service implements accounting use cases.
type Service struct {
	store Store
	seq   Seq
	loc   *time.Location
}

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

func parseDate(v string) (time.Time, error) {
	return time.Parse("2006-01-02", v)
}

func normalBalanceForType(t string) string {
	if t == "asset" || t == "expense" {
		return "debit"
	}
	return "credit"
}

func validateDateRange(from, to string) error {
	if from != "" {
		if _, err := parseDate(from); err != nil {
			return fmt.Errorf("%w: date_from %s", ErrInvalidDate, from)
		}
	}
	if to != "" {
		if _, err := parseDate(to); err != nil {
			return fmt.Errorf("%w: date_to %s", ErrInvalidDate, to)
		}
	}
	return nil
}

func (s *Service) today() string { return time.Now().In(s.loc).Format("2006-01-02") }

// --- accounts -----------------------------------------------------------------

func (s *Service) ListAccounts(ctx context.Context, active *bool, search string) ([]Account, error) {
	rows, err := s.store.ListAccounts(ctx, active, search)
	if err != nil {
		return nil, err
	}
	out := make([]Account, 0, len(rows))
	for _, a := range rows {
		out = append(out, accountFromRow(a))
	}
	return out, nil
}

func (s *Service) CreateAccount(ctx context.Context, in AccountInput) (*Account, error) {
	if in.Code == "" || in.Name == "" {
		return nil, fmt.Errorf("%w: kode dan nama akun wajib diisi", ErrValidation)
	}
	if !validAccountTypes[in.Type] {
		return nil, fmt.Errorf("%w: tipe akun harus asset|liability|equity|revenue|expense", ErrValidation)
	}
	nb := in.NormalBalance
	if nb == "" {
		nb = normalBalanceForType(in.Type)
	}
	if nb != "debit" && nb != "credit" {
		return nil, fmt.Errorf("%w: normal_balance harus debit atau credit", ErrValidation)
	}
	isPostable, isPlaceholder, isActive := true, false, true
	if in.IsPostable != nil {
		isPostable = *in.IsPostable
	}
	if in.IsPlaceholder != nil {
		isPlaceholder = *in.IsPlaceholder
	}
	if in.IsActive != nil {
		isActive = *in.IsActive
	}
	row, err := s.store.CreateAccount(ctx, db.AcctCreateAccountParams{
		Code: in.Code, Name: in.Name, Type: in.Type, NormalBalance: nb,
		IsPostable: isPostable, IsPlaceholder: isPlaceholder, IsActive: isActive,
		ParentID: int8PtrToPg(in.ParentID),
	})
	if err != nil {
		return nil, err
	}
	a := accountFromRow(row)
	return &a, nil
}

func (s *Service) UpdateAccount(ctx context.Context, id int64, in AccountInput) (*Account, error) {
	if in.Name == "" {
		return nil, fmt.Errorf("%w: nama akun wajib diisi", ErrValidation)
	}
	if !validAccountTypes[in.Type] {
		return nil, fmt.Errorf("%w: tipe akun harus asset|liability|equity|revenue|expense", ErrValidation)
	}
	nb := in.NormalBalance
	if nb == "" {
		nb = normalBalanceForType(in.Type)
	}
	if nb != "debit" && nb != "credit" {
		return nil, fmt.Errorf("%w: normal_balance harus debit atau credit", ErrValidation)
	}
	isPostable, isPlaceholder, isActive := true, false, true
	if in.IsPostable != nil {
		isPostable = *in.IsPostable
	}
	if in.IsPlaceholder != nil {
		isPlaceholder = *in.IsPlaceholder
	}
	if in.IsActive != nil {
		isActive = *in.IsActive
	}
	row, err := s.store.UpdateAccount(ctx, db.AcctUpdateAccountParams{
		ID: id, Name: in.Name, Type: in.Type, NormalBalance: nb,
		IsPostable: isPostable, IsPlaceholder: isPlaceholder, IsActive: isActive,
	})
	if err != nil {
		return nil, err
	}
	a := accountFromRow(row)
	return &a, nil
}

// --- departments ----------------------------------------------------------------

func (s *Service) ListDepartments(ctx context.Context, active *bool, search string) ([]Department, error) {
	rows, err := s.store.ListDepartments(ctx, active, search)
	if err != nil {
		return nil, err
	}
	out := make([]Department, 0, len(rows))
	for _, d := range rows {
		out = append(out, departmentFromRow(d))
	}
	return out, nil
}

func (s *Service) CreateDepartment(ctx context.Context, in DepartmentInput) (*Department, error) {
	if in.Code == "" || in.Name == "" {
		return nil, fmt.Errorf("%w: kode dan nama departemen wajib diisi", ErrValidation)
	}
	isActive := true
	if in.IsActive != nil {
		isActive = *in.IsActive
	}
	row, err := s.store.CreateDepartment(ctx, db.AcctCreateDepartmentParams{
		Code: in.Code, Name: in.Name, IsActive: pgtype.Bool{Bool: isActive, Valid: true},
	})
	if err != nil {
		return nil, err
	}
	d := departmentFromRow(row)
	return &d, nil
}

func (s *Service) UpdateDepartment(ctx context.Context, id int64, in DepartmentInput) (*Department, error) {
	if in.Name == "" {
		return nil, fmt.Errorf("%w: nama departemen wajib diisi", ErrValidation)
	}
	isActive := true
	if in.IsActive != nil {
		isActive = *in.IsActive
	}
	row, err := s.store.UpdateDepartment(ctx, db.AcctUpdateDepartmentParams{ID: id, Name: in.Name, IsActive: isActive})
	if err != nil {
		return nil, err
	}
	d := departmentFromRow(row)
	return &d, nil
}

// --- journals -----------------------------------------------------------------

// resolvedLine is a validated journal line ready to insert.
type resolvedLine struct {
	accountID    int64
	departmentID pgtype.Int8
	debit        decimal.Decimal
	credit       decimal.Decimal
	description  string
}

func (s *Service) resolveLines(ctx context.Context, q db.DBTX, journalType string, in []JournalLineInput) ([]resolvedLine, decimal.Decimal, decimal.Decimal, error) {
	if len(in) < 2 {
		return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: jurnal minimal memiliki 2 baris", ErrValidation)
	}
	lines := make([]resolvedLine, 0, len(in))
	totalDebit, totalCredit := decimal.Zero, decimal.Zero
	deptCache := map[string]pgtype.Int8{}
	for _, l := range in {
		if l.AccountCode == "" {
			return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: account_code wajib diisi pada setiap baris", ErrValidation)
		}
		if journalType == "cash_disbursement" && l.AccountCode == acctCashDefault {
			return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: akun kas laci POS (%s) tidak dapat dipilih pada jurnal pengeluaran kas (BKK); gunakan kas kecil operasional (%s) atau modul arus kas POS", ErrValidation, l.AccountCode, acctPettyCashOffice)
		}
		if !l.Debit.IsPositive() && !l.Credit.IsPositive() {
			return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: setiap baris harus memiliki debit atau kredit bernilai lebih dari 0 (akun %s)", ErrValidation, l.AccountCode)
		}
		if l.Debit.IsPositive() && l.Credit.IsPositive() {
			return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: baris tidak boleh memiliki debit dan kredit sekaligus (akun %s)", ErrValidation, l.AccountCode)
		}
		acc, err := s.store.GetAccountByCode(ctx, q, l.AccountCode)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: akun %s tidak ditemukan", ErrValidation, l.AccountCode)
			}
			return nil, decimal.Zero, decimal.Zero, err
		}
		if !acc.IsPostable {
			return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: akun %s adalah header (tidak dapat diposting)", ErrValidation, l.AccountCode)
		}
		var deptID pgtype.Int8
		if l.DepartmentCode != "" {
			if cached, ok := deptCache[l.DepartmentCode]; ok {
				deptID = cached
			} else {
				dept, err := s.store.GetDepartmentByCode(ctx, q, l.DepartmentCode)
				if err != nil {
					if errors.Is(err, ErrNotFound) {
						return nil, decimal.Zero, decimal.Zero, fmt.Errorf("%w: departemen %s tidak ditemukan", ErrValidation, l.DepartmentCode)
					}
					return nil, decimal.Zero, decimal.Zero, err
				}
				deptID = pgtype.Int8{Int64: dept.ID, Valid: true}
				deptCache[l.DepartmentCode] = deptID
			}
		}
		lines = append(lines, resolvedLine{
			accountID:    acc.ID,
			departmentID: deptID,
			debit:        l.Debit,
			credit:       l.Credit,
			description:  l.Description,
		})
		totalDebit = totalDebit.Add(l.Debit)
		totalCredit = totalCredit.Add(l.Credit)
	}
	return lines, totalDebit, totalCredit, nil
}

func (s *Service) journalNumber(ctx context.Context, tx db.DBTX, journalType, entryDate string) (string, error) {
	prefix := journalPrefixes[journalType]
	t, err := parseDate(entryDate)
	if err != nil {
		return "", fmt.Errorf("%w: entry_date %s", ErrInvalidDate, entryDate)
	}
	n, err := s.seq.Sequence(ctx, prefix, seq.MonthStart(t), tx)
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%s-%s-%04d", prefix, t.Format("20060102"), n), nil
}

func (s *Service) insertJournalTx(ctx context.Context, tx Tx, p db.AcctCreateJournalParams, lines []resolvedLine) (db.JournalEntry, error) {
	postAfterInsert := p.Status == "posted"
	if postAfterInsert {
		p.Status = "draft"
	}
	entry, err := s.store.CreateJournal(ctx, tx, p)
	if err != nil {
		return db.JournalEntry{}, err
	}
	for _, l := range lines {
		if _, err := s.store.CreateJournalLine(ctx, tx, db.AcctCreateJournalLineParams{
			JournalEntryID: entry.ID, AccountID: l.accountID, DepartmentID: l.departmentID,
			Debit: l.debit, Credit: l.credit, Description: pgtype.Text{String: l.description, Valid: l.description != ""},
		}); err != nil {
			return db.JournalEntry{}, err
		}
	}
	if postAfterInsert {
		if err := s.store.SetJournalPosted(ctx, tx, db.AcctSetJournalPostedParams{
			ID: entry.ID, PostedBy: p.PostedBy,
		}); err != nil {
			return db.JournalEntry{}, err
		}
		entry, err = s.store.LockJournal(ctx, tx, entry.ID)
		if err != nil {
			return db.JournalEntry{}, err
		}
	}
	return entry, nil
}

func (s *Service) ListJournals(ctx context.Context, f JournalFilter) ([]Journal, int, error) {
	if err := validateDateRange(f.DateFrom, f.DateTo); err != nil {
		return nil, 0, err
	}
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	// Tenant isolation: restricted callers only see their stores' journals.
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		f.AllowedStoreIDs = sc.IDs()
	}

	// RBAC journal filtering: restrict to caller's permitted journal types.
	if u := auth.UserFrom(ctx); u != nil {
		allowed := allowedJournalTypesForRole(u.Role)
		if allowed != nil {
			if f.JournalType != "" {
				isAllowed := false
				for _, t := range allowed {
					if t == f.JournalType {
						isAllowed = true
						break
					}
				}
				if !isAllowed {
					return []Journal{}, 0, nil
				}
			} else {
				f.AllowedJournalTypes = allowed
			}
		}
	}

	total, err := s.store.CountJournals(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.ListJournals(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	ids := make([]int64, 0, len(rows))
	for _, r := range rows {
		ids = append(ids, r.ID)
	}
	lineRows, err := s.store.ListJournalLinesByEntries(ctx, ids)
	if err != nil {
		return nil, 0, err
	}
	linesByEntry := map[int64][]JournalLine{}
	for _, l := range lineRows {
		linesByEntry[l.JournalEntryID] = append(linesByEntry[l.JournalEntryID], journalLineFromRow(l.ID, l.JournalEntryID, l.AccountCode, l.AccountName, l.DepartmentCode, l.Debit, l.Credit, l.Description))
	}
	out := make([]Journal, 0, len(rows))
	for _, r := range rows {
		out = append(out, journalFromRow(r, linesByEntry[r.ID]))
	}
	return out, total, nil
}

func (s *Service) GetJournal(ctx context.Context, id int64) (*Journal, error) {
	row, err := s.store.GetJournal(ctx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkJournalScope(ctx, row); err != nil {
		return nil, err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, row.JournalType); err != nil {
			return nil, ErrNotFound
		}
	}
	lineRows, err := s.store.ListJournalLinesByEntries(ctx, []int64{id})
	if err != nil {
		return nil, err
	}
	lines := make([]JournalLine, 0, len(lineRows))
	for _, l := range lineRows {
		lines = append(lines, journalLineFromRow(l.ID, l.JournalEntryID, l.AccountCode, l.AccountName, l.DepartmentCode, l.Debit, l.Credit, l.Description))
	}
	j := journalFromRow(row, lines)
	return &j, nil
}

// JournalBySource drills one upstream document to its auto-drafted journal:
// GetGR's response links straight to the accrual entry (reference + lines).
// Tenant scope applies like GetJournal.
func (s *Service) JournalBySource(ctx context.Context, sourceType string, sourceID int64) (*Journal, error) {
	row, err := s.store.JournalBySource(ctx, sourceType, sourceID)
	if err != nil {
		return nil, err
	}
	if err := s.checkJournalScope(ctx, row); err != nil {
		return nil, err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, row.JournalType); err != nil {
			return nil, ErrNotFound
		}
	}
	lineRows, err := s.store.ListJournalLinesByEntries(ctx, []int64{row.ID})
	if err != nil {
		return nil, err
	}
	lines := make([]JournalLine, 0, len(lineRows))
	for _, l := range lineRows {
		lines = append(lines, journalLineFromRow(l.ID, l.JournalEntryID, l.AccountCode, l.AccountName, l.DepartmentCode, l.Debit, l.Credit, l.Description))
	}
	j := journalFromRow(row, lines)
	return &j, nil
}

// checkJournalScope hides journals outside the caller's tenant scope.
func (s *Service) checkJournalScope(ctx context.Context, e db.JournalEntry) error {
	var storeID *int64
	if e.StoreID.Valid {
		id := e.StoreID.Int64
		storeID = &id
	}
	if !auth.StoreScopeFrom(ctx).Allows(storeID) {
		return ErrNotFound
	}
	return nil
}

func (s *Service) resolveStoreBinding(ctx context.Context, in *int64) (*int64, error) {
	resolved, ok := auth.StoreScopeFrom(ctx).ResolveStoreBinding(in)
	if !ok {
		return nil, ErrNotFound
	}
	return resolved, nil
}

func allowedJournalTypesForRole(role auth.Role) []string {
	switch role {
	case auth.RoleCashier, auth.RoleStoreManager:
		return []string{"cash_disbursement", "cash_receipt", "general"}
	case auth.RoleSales, auth.RoleSPV, auth.RoleBM, auth.RoleGM:
		return []string{"customer_deposit_receipt", "customer_deposit_usage", "general"}
	case auth.RoleFinance, auth.RoleFinanceHead, auth.RoleAdmin, auth.RoleOwner, auth.RoleSystem, "":
		return nil // full access across all journal types
	default:
		return []string{"general"}
	}
}

func validateRoleForJournalType(role auth.Role, journalType string) error {
	allowed := allowedJournalTypesForRole(role)
	if allowed == nil {
		return nil
	}
	for _, a := range allowed {
		if a == journalType {
			return nil
		}
	}
	return fmt.Errorf("%w: role %s tidak memiliki akses untuk journal_type %s", ErrValidation, role, journalType)
}

func (s *Service) CreateJournal(ctx context.Context, in JournalInput, userID *int64) (*Journal, error) {
	if _, ok := journalPrefixes[in.JournalType]; !ok {
		return nil, fmt.Errorf("%w: journal_type %s tidak dikenal", ErrValidation, in.JournalType)
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, in.JournalType); err != nil {
			return nil, err
		}
	}
	entryDate := in.EntryDate
	if entryDate == "" {
		entryDate = s.today()
	}
	if _, err := parseDate(entryDate); err != nil {
		return nil, fmt.Errorf("%w: entry_date %s", ErrInvalidDate, entryDate)
	}
	storeID, err := s.resolveStoreBinding(ctx, in.StoreID)
	if err != nil {
		return nil, err
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	lines, totalDebit, totalCredit, err := s.resolveLines(ctx, tx, in.JournalType, in.Lines)
	if err != nil {
		return nil, err
	}
	number, err := s.journalNumber(ctx, tx, in.JournalType, entryDate)
	if err != nil {
		return nil, err
	}
	entry, err := s.insertJournalTx(ctx, tx, db.AcctCreateJournalParams{
		JournalNumber: number,
		JournalType:   in.JournalType,
		EntryDate:     pgtype.Date{Time: mustDate(entryDate), Valid: true},
		Status:        "draft",
		TotalDebit:    totalDebit,
		TotalCredit:   totalCredit,
		Reference:     pgtype.Text{String: in.Reference, Valid: in.Reference != ""},
		Description:   pgtype.Text{String: in.Description, Valid: in.Description != ""},
		StoreID:       int8PtrToPg(storeID),
		CreatedBy:     int8PtrToPg(userID),
		SourceType:    pgtype.Text{String: in.SourceType, Valid: in.SourceType != ""},
		SourceID:      int8PtrToPg(in.SourceID),
	}, lines)
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetJournal(ctx, entry.ID)
}

func (s *Service) UpdateJournal(ctx context.Context, id int64, in JournalInput) (*Journal, error) {
	if _, ok := journalPrefixes[in.JournalType]; !ok {
		return nil, fmt.Errorf("%w: journal_type %s tidak dikenal", ErrValidation, in.JournalType)
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, in.JournalType); err != nil {
			return nil, err
		}
	}
	entryDate := in.EntryDate
	if entryDate == "" {
		entryDate = s.today()
	}
	if _, err := parseDate(entryDate); err != nil {
		return nil, fmt.Errorf("%w: entry_date %s", ErrInvalidDate, entryDate)
	}
	storeID, err := s.resolveStoreBinding(ctx, in.StoreID)
	if err != nil {
		return nil, err
	}

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

	cur, err := s.store.LockJournal(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkJournalScope(ctx, cur); err != nil {
		return nil, err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, cur.JournalType); err != nil {
			return nil, ErrNotFound
		}
	}
	if cur.Status != "draft" {
		return nil, ErrNotDraft
	}
	lines, totalDebit, totalCredit, err := s.resolveLines(ctx, tx, in.JournalType, in.Lines)
	if err != nil {
		return nil, err
	}
	if err := s.store.DeleteJournalLines(ctx, tx, id); err != nil {
		return nil, err
	}
	if err := s.store.UpdateJournal(ctx, tx, db.AcctUpdateJournalParams{
		ID: id, JournalType: in.JournalType,
		EntryDate:   pgtype.Date{Time: mustDate(entryDate), Valid: true},
		TotalDebit:  totalDebit,
		TotalCredit: totalCredit,
		Reference:   pgtype.Text{String: in.Reference, Valid: in.Reference != ""},
		Description: pgtype.Text{String: in.Description, Valid: in.Description != ""},
		StoreID:     int8PtrToPg(storeID),
	}); err != nil {
		return nil, err
	}
	for _, l := range lines {
		if _, err := s.store.CreateJournalLine(ctx, tx, db.AcctCreateJournalLineParams{
			JournalEntryID: id, AccountID: l.accountID, DepartmentID: l.departmentID,
			Debit: l.debit, Credit: l.credit, Description: pgtype.Text{String: l.description, Valid: l.description != ""},
		}); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetJournal(ctx, id)
}

func (s *Service) DeleteJournal(ctx context.Context, id int64) error {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	cur, err := s.store.LockJournal(ctx, tx, id)
	if err != nil {
		return err
	}
	if err := s.checkJournalScope(ctx, cur); err != nil {
		return err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, cur.JournalType); err != nil {
			return ErrNotFound
		}
	}
	if cur.Status != "draft" {
		return ErrNotDraft
	}
	if err := s.store.DeleteJournal(ctx, tx, id); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

func (s *Service) PostJournal(ctx context.Context, id int64, userID *int64) (*Journal, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	cur, err := s.store.LockJournal(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkJournalScope(ctx, cur); err != nil {
		return nil, err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, cur.JournalType); err != nil {
			return nil, ErrNotFound
		}
	}
	if cur.Status != "draft" {
		return nil, ErrNotDraft
	}
	if !cur.TotalDebit.Equal(cur.TotalCredit) || cur.TotalDebit.IsZero() {
		return nil, ErrUnbalanced
	}
	if err := s.store.SetJournalPosted(ctx, tx, db.AcctSetJournalPostedParams{ID: id, PostedBy: int8PtrToPg(userID)}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetJournal(ctx, id)
}

func (s *Service) VoidJournal(ctx context.Context, id int64) (*Journal, error) {
	return nil, ErrJournalVoidForbidden
}

func (s *Service) ReverseJournal(ctx context.Context, id int64, reason string, userID *int64) (*Journal, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	orig, err := s.store.LockJournal(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkJournalScope(ctx, orig); err != nil {
		return nil, err
	}
	if u := auth.UserFrom(ctx); u != nil {
		if err := validateRoleForJournalType(u.Role, orig.JournalType); err != nil {
			return nil, ErrNotFound
		}
	}
	if orig.Status != "posted" {
		return nil, ErrNotPosted
	}
	if reason == "" {
		reason = "Reversal manual"
	}

	// Mirrored posted entry: debit/credit swapped, both ways linked.
	lineRows, err := s.store.ListJournalLinesByEntries(ctx, []int64{id})
	if err != nil {
		return nil, err
	}
	revLines := make([]resolvedLine, 0, len(lineRows))
	for _, l := range lineRows {
		desc := "Reversal: " + l.Description.String
		if !l.Description.Valid {
			desc = "Reversal: " + orig.Description.String
		}
		revLines = append(revLines, resolvedLine{
			accountID:    l.AccountID,
			departmentID: l.DepartmentID,
			debit:        l.Credit,
			credit:       l.Debit,
			description:  desc,
		})
	}
	if len(revLines) == 0 || !orig.TotalDebit.Equal(orig.TotalCredit) || orig.TotalDebit.IsZero() {
		return nil, ErrUnbalanced
	}
	// The reversal is posted today; the original entry remains in its original
	// period and the mirror belongs to the current period.
	reversalDate := s.today()
	rev, err := s.insertJournalTx(ctx, tx, db.AcctCreateJournalParams{
		JournalNumber: "REV-" + orig.JournalNumber,
		JournalType:   orig.JournalType,
		EntryDate:     pgtype.Date{Time: mustDate(reversalDate), Valid: true},
		Status:        "posted",
		TotalDebit:    orig.TotalCredit,
		TotalCredit:   orig.TotalDebit,
		Reference:     pgtype.Text{String: "REV:" + orig.JournalNumber, Valid: true},
		Description:   pgtype.Text{String: fmt.Sprintf("Pembalik %s: %s", orig.JournalNumber, reason), Valid: true},
		StoreID:       orig.StoreID,
		PostedBy:      int8PtrToPg(userID),
		PostedAt:      pgtype.Timestamptz{Time: time.Now().In(s.loc), Valid: true},
		ReversalOf:    pgtype.Int8{Int64: orig.ID, Valid: true},
	}, revLines)
	if err != nil {
		return nil, err
	}
	if err := s.store.SetJournalReversed(ctx, tx, db.AcctSetJournalReversedParams{
		ID: orig.ID, ReversalEntryID: pgtype.Int8{Int64: rev.ID, Valid: true},
	}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetJournal(ctx, id)
}

// --- general ledger -------------------------------------------------------------

func (s *Service) Ledger(ctx context.Context, f LedgerFilter) (*LedgerReport, error) {
	if err := validateDateRange(f.DateFrom, f.DateTo); err != nil {
		return nil, err
	}
	// Tenant isolation mirrors the journal list.
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		f.AllowedStoreIDs = sc.IDs()
	}
	lineRows, err := s.store.LedgerLines(ctx, f)
	if err != nil {
		return nil, err
	}
	opening := map[string]decimal.Decimal{}
	openingMeta := map[string]db.AcctLedgerOpeningRow{}
	if f.DateFrom != "" {
		openingRows, err := s.store.LedgerOpening(ctx, f)
		if err != nil {
			return nil, err
		}
		for _, o := range openingRows {
			opening[o.AccountCode] = o.OpeningBalance
			openingMeta[o.AccountCode] = o
		}
	}

	report := &LedgerReport{DateFrom: f.DateFrom, DateTo: f.DateTo, Accounts: []LedgerAccount{}}
	accountsMap := map[string]*LedgerAccount{}
	var orderedCodes []string

	totalDebit, totalCredit := decimal.Zero, decimal.Zero
	for _, l := range lineRows {
		cur, exists := accountsMap[l.AccountCode]
		if !exists {
			ob := opening[l.AccountCode]
			cur = &LedgerAccount{
				AccountID:      l.AccountID,
				AccountCode:    l.AccountCode,
				AccountName:    l.AccountName,
				AccountType:    l.AccountType,
				NormalBalance:  l.NormalBalance,
				OpeningBalance: ob,
				EndingBalance:  ob,
				Entries:        []LedgerEntry{},
			}
			accountsMap[l.AccountCode] = cur
			orderedCodes = append(orderedCodes, l.AccountCode)
		}
		debit, credit := l.Debit, l.Credit
		if l.NormalBalance == "debit" {
			cur.EndingBalance = cur.EndingBalance.Add(debit).Sub(credit)
		} else {
			cur.EndingBalance = cur.EndingBalance.Add(credit).Sub(debit)
		}
		desc := l.LineDescription.String
		if !l.LineDescription.Valid {
			desc = l.EntryDescription.String
		}
		cur.Entries = append(cur.Entries, LedgerEntry{
			ID: l.ID, EntryID: l.EntryID, Date: l.EntryDate.Time.Format("2006-01-02"),
			JournalNumber: l.JournalNumber, JournalType: l.JournalType, Status: l.EntryStatus,
			Reference: l.Reference.String, Description: desc,
			Debit: debit, Credit: credit, Balance: cur.EndingBalance,
		})
		totalDebit = totalDebit.Add(debit)
		totalCredit = totalCredit.Add(credit)
	}

	// Include accounts that have an opening balance even if there are no mutations in this period
	for code, o := range openingMeta {
		if _, exists := accountsMap[code]; !exists && !o.OpeningBalance.IsZero() {
			accountsMap[code] = &LedgerAccount{
				AccountID:      o.AccountID,
				AccountCode:    o.AccountCode,
				AccountName:    o.AccountName,
				AccountType:    o.AccountType,
				NormalBalance:  o.NormalBalance,
				OpeningBalance: o.OpeningBalance,
				EndingBalance:  o.OpeningBalance,
				Entries:        []LedgerEntry{},
			}
			orderedCodes = append(orderedCodes, code)
		}
	}

	sort.Strings(orderedCodes)
	for _, code := range orderedCodes {
		report.Accounts = append(report.Accounts, *accountsMap[code])
	}
	report.TotalDebit = totalDebit
	report.TotalCredit = totalCredit
	return report, nil
}

// --- trial balance -------------------------------------------------------------

func (s *Service) TrialBalance(ctx context.Context, dateFrom, dateTo string) (*TrialBalance, error) {
	if err := validateDateRange(dateFrom, dateTo); err != nil {
		return nil, err
	}
	rows, err := s.store.TrialBalanceRows(ctx, dateFrom, dateTo)
	if err != nil {
		return nil, err
	}
	out := &TrialBalance{DateFrom: dateFrom, DateTo: dateTo, Accounts: []TrialBalanceRow{}}
	for _, r := range rows {
		out.Accounts = append(out.Accounts, TrialBalanceRow{
			AccountID: r.ID, AccountCode: r.Code, AccountName: r.Name,
			AccountType: r.Type, NormalBalance: r.NormalBalance,
			Debit: r.TotalDebit, Credit: r.TotalCredit,
		})
		out.TotalDebit = out.TotalDebit.Add(r.TotalDebit)
		out.TotalCredit = out.TotalCredit.Add(r.TotalCredit)
	}
	return out, nil
}

// --- advances -----------------------------------------------------------------

// legacyAdvanceStatus maps the AdvancesPage filter vocabulary to the
// canonical status enum.
var legacyAdvanceStatus = map[string]string{
	"open": "received", "partial": "partially_allocated", "used": "allocated", "refunded": "refunded",
}

func advanceStatus(amount, allocated, refunded decimal.Decimal) string {
	remaining := amount.Sub(allocated).Sub(refunded)
	switch {
	case remaining.IsZero() && refunded.IsPositive():
		return "refunded"
	case remaining.IsZero():
		return "allocated"
	case allocated.IsPositive() || refunded.IsPositive():
		return "partially_allocated"
	default:
		return "received"
	}
}

func (s *Service) ListAdvances(ctx context.Context, f AdvanceFilter) ([]Advance, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	if f.Type != "" && f.Type != "sales" && f.Type != "purchase" {
		return nil, 0, fmt.Errorf("%w: type harus sales atau purchase", ErrValidation)
	}
	if canon, ok := legacyAdvanceStatus[f.Status]; ok {
		f.Status = canon
	}
	total, err := s.store.CountAdvances(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.ListAdvances(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]Advance, 0, len(rows))
	for _, r := range rows {
		out = append(out, advanceFromRow(r.ID, r.AdvanceNumber, r.AdvanceType, r.CustomerID, r.CustomerName,
			r.SupplierID, r.SupplierName, r.Amount, r.AllocatedAmount, r.RefundedAmount, r.Status,
			r.AdvanceDate, r.Notes))
	}
	return out, total, nil
}

func (s *Service) GetAdvance(ctx context.Context, id int64, typ string) (*Advance, error) {
	row, err := s.store.GetAdvance(ctx, id)
	if err != nil {
		return nil, err
	}
	if typ != "" && typ != row.AdvanceType {
		return nil, ErrNotFound
	}
	adv := advanceFromRow(row.ID, row.AdvanceNumber, row.AdvanceType, row.CustomerID, row.CustomerName,
		row.SupplierID, row.SupplierName, row.Amount, row.AllocatedAmount, row.RefundedAmount, row.Status,
		row.AdvanceDate, row.Notes)
	txnRows, err := s.store.ListAdvanceTxns(ctx, id)
	if err != nil {
		return nil, err
	}
	adv.Transactions = make([]AdvanceTxn, 0, len(txnRows))
	for _, t := range txnRows {
		adv.Transactions = append(adv.Transactions, AdvanceTxn{
			ID: t.ID, TxnType: t.TxnType, Amount: t.Amount, Reference: t.Reference.String,
			JournalEntryID: int8PgToPtr(t.JournalEntryID), Notes: t.Notes.String, CreatedAt: t.CreatedAt.Time,
		})
	}
	return &adv, nil
}

// requireAccount resolves a COA account by code inside tx. system=true marks
// accounts the workflows depend on (missing = configuration gap, not user
// input) so the error distinguishes the two.
func (s *Service) requireAccount(ctx context.Context, tx Tx, code string, system bool) (db.Account, error) {
	acc, err := s.store.GetAccountByCode(ctx, tx, code)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			if system {
				return db.Account{}, fmt.Errorf("%w: %s (%s)", ErrMissingAccount, code, "jalankan migrasi 000050")
			}
			return db.Account{}, fmt.Errorf("%w: akun %s tidak ditemukan di COA", ErrValidation, code)
		}
		return db.Account{}, err
	}
	if !acc.IsPostable {
		return db.Account{}, fmt.Errorf("%w: akun %s adalah header (tidak dapat diposting)", ErrValidation, code)
	}
	return acc, nil
}

func (s *Service) ReceiveAdvance(ctx context.Context, in AdvanceReceiveInput, userID *int64) (*Advance, error) {
	if in.Type != "sales" && in.Type != "purchase" {
		return nil, fmt.Errorf("%w: type harus sales atau purchase", ErrValidation)
	}
	if !in.Amount.IsPositive() {
		return nil, fmt.Errorf("%w: jumlah harus lebih dari 0", ErrValidation)
	}
	if in.Type == "sales" && (in.CustomerID == nil || *in.CustomerID <= 0) {
		return nil, fmt.Errorf("%w: customer_id wajib diisi untuk deposit customer", ErrValidation)
	}
	if in.Type == "purchase" && (in.SupplierID == nil || *in.SupplierID <= 0) {
		return nil, fmt.Errorf("%w: supplier_id wajib diisi untuk uang muka pembelian", ErrValidation)
	}
	date := in.AdvanceDate
	if date == "" {
		date = s.today()
	}
	if _, err := parseDate(date); err != nil {
		return nil, fmt.Errorf("%w: advance_date %s", ErrInvalidDate, date)
	}
	cashCode := in.CashAccountCode
	if cashCode == "" {
		cashCode = acctCashDefault
	}

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

	cash, err := s.requireAccount(ctx, tx, cashCode, cashCode == acctCashDefault)
	if err != nil {
		return nil, err
	}

	advanceNumber, err := s.journalNumber(ctx, tx, "customer_deposit_receipt", date)
	if err != nil {
		return nil, err
	}

	// Journal: sales Dr cash / Cr deposit liability (DP); purchase Dr advance
	// asset / Cr cash (JU — general; customer_deposit_* is sales-specific).
	var journalType, desc string
	var lines []resolvedLine
	if in.Type == "sales" {
		deposit, err := s.requireAccount(ctx, tx, acctDepositSales, true)
		if err != nil {
			return nil, err
		}
		journalType = "customer_deposit_receipt"
		desc = fmt.Sprintf("Penerimaan deposit customer %s", advanceNumber)
		lines = []resolvedLine{
			{accountID: cash.ID, debit: in.Amount, description: "Penerimaan uang deposit"},
			{accountID: deposit.ID, credit: in.Amount, description: "Uang muka penjualan (deposit customer)"},
		}
	} else {
		advanceAsset, err := s.requireAccount(ctx, tx, acctAdvancePurchase, true)
		if err != nil {
			return nil, err
		}
		journalType = "general"
		desc = fmt.Sprintf("Uang muka pembelian supplier %s", advanceNumber)
		lines = []resolvedLine{
			{accountID: advanceAsset.ID, debit: in.Amount, description: "Uang muka pembelian"},
			{accountID: cash.ID, credit: in.Amount, description: "Pembayaran uang muka"},
		}
	}
	journalNumber, err := s.journalNumber(ctx, tx, journalType, date)
	if err != nil {
		return nil, err
	}
	entry, err := s.insertJournalTx(ctx, tx, db.AcctCreateJournalParams{
		JournalNumber: journalNumber,
		JournalType:   journalType,
		EntryDate:     pgtype.Date{Time: mustDate(date), Valid: true},
		Status:        "posted",
		TotalDebit:    in.Amount,
		TotalCredit:   in.Amount,
		Reference:     pgtype.Text{String: advanceNumber, Valid: true},
		Description:   pgtype.Text{String: desc, Valid: true},
		PostedBy:      int8PtrToPg(userID),
		PostedAt:      pgtype.Timestamptz{Time: time.Now().In(s.loc), Valid: true},
		SourceType:    pgtype.Text{String: "advance", Valid: true},
	}, lines)
	if err != nil {
		return nil, err
	}

	adv, err := s.store.CreateAdvance(ctx, tx, db.AcctCreateAdvanceParams{
		AdvanceNumber:  advanceNumber,
		AdvanceType:    in.Type,
		Amount:         in.Amount,
		Status:         "received",
		AdvanceDate:    pgtype.Date{Time: mustDate(date), Valid: true},
		CustomerID:     int8PtrToPg(in.CustomerID),
		SupplierID:     int8PtrToPg(in.SupplierID),
		Notes:          pgtype.Text{String: in.Notes, Valid: in.Notes != ""},
		JournalEntryID: pgtype.Int8{Int64: entry.ID, Valid: true},
		CreatedBy:      int8PtrToPg(userID),
	})
	if err != nil {
		return nil, err
	}
	if _, err := s.store.CreateAdvanceTxn(ctx, tx, db.AcctCreateAdvanceTxnParams{
		AdvanceID: adv.ID, TxnType: "receipt", Amount: in.Amount,
		Reference:      pgtype.Text{String: journalNumber, Valid: true},
		JournalEntryID: pgtype.Int8{Int64: entry.ID, Valid: true},
		Notes:          pgtype.Text{String: in.Notes, Valid: in.Notes != ""},
		CreatedBy:      int8PtrToPg(userID),
	}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetAdvance(ctx, adv.ID, in.Type)
}

func (s *Service) AllocateAdvance(ctx context.Context, id int64, typ string, in AdvanceAllocateInput, userID *int64) (*Advance, error) {
	if !in.Amount.IsPositive() {
		return nil, fmt.Errorf("%w: jumlah alokasi harus lebih dari 0", ErrValidation)
	}
	return s.mutateAdvance(ctx, id, typ, userID, func(tx Tx, adv db.Advance) (db.AcctCreateJournalParams, []resolvedLine, error) {
		remaining := adv.Amount.Sub(adv.AllocatedAmount).Sub(adv.RefundedAmount)
		if in.Amount.GreaterThan(remaining) {
			return db.AcctCreateJournalParams{}, nil, ErrExceedsRemaining
		}
		deposit, err := s.requireAccount(ctx, tx, acctDepositSales, true)
		if err != nil {
			return db.AcctCreateJournalParams{}, nil, err
		}
		date := s.today()
		var p db.AcctCreateJournalParams
		var lines []resolvedLine
		if adv.AdvanceType == "sales" {
			// Consume the deposit against the B2B receivable.
			ar, err := s.requireAccount(ctx, tx, acctReceivableB2B, true)
			if err != nil {
				return db.AcctCreateJournalParams{}, nil, err
			}
			p = db.AcctCreateJournalParams{
				JournalType: "customer_deposit_usage", Status: "posted",
				Reference:   pgtype.Text{String: adv.AdvanceNumber, Valid: true},
				Description: pgtype.Text{String: fmt.Sprintf("Alokasi deposit %s ke piutang", adv.AdvanceNumber), Valid: true},
			}
			lines = []resolvedLine{
				{accountID: deposit.ID, debit: in.Amount, description: "Pemakaian uang muka penjualan"},
				{accountID: ar.ID, credit: in.Amount, description: "Pelunasan piutang dari deposit"},
			}
		} else {
			// Consume the supplier advance against the payable.
			ap, err := s.requireAccount(ctx, tx, acctPayableSupplier, true)
			if err != nil {
				return db.AcctCreateJournalParams{}, nil, err
			}
			asset, err := s.requireAccount(ctx, tx, acctAdvancePurchase, true)
			if err != nil {
				return db.AcctCreateJournalParams{}, nil, err
			}
			p = db.AcctCreateJournalParams{
				JournalType: "general", Status: "posted",
				Reference:   pgtype.Text{String: adv.AdvanceNumber, Valid: true},
				Description: pgtype.Text{String: fmt.Sprintf("Alokasi uang muka pembelian %s ke hutang", adv.AdvanceNumber), Valid: true},
			}
			lines = []resolvedLine{
				{accountID: ap.ID, debit: in.Amount, description: "Pelunasan hutang dari uang muka"},
				{accountID: asset.ID, credit: in.Amount, description: "Pemakaian uang muka pembelian"},
			}
		}
		p.EntryDate = pgtype.Date{Time: mustDate(date), Valid: true}
		p.TotalDebit = in.Amount
		p.TotalCredit = in.Amount
		p.PostedBy = int8PtrToPg(userID)
		p.PostedAt = pgtype.Timestamptz{Time: time.Now().In(s.loc), Valid: true}
		p.SourceType = pgtype.Text{String: "advance_allocation", Valid: true}
		p.SourceID = pgtype.Int8{Int64: adv.ID, Valid: true}

		number, err := s.journalNumber(ctx, tx, p.JournalType, date)
		if err != nil {
			return db.AcctCreateJournalParams{}, nil, err
		}
		p.JournalNumber = number
		return p, lines, nil
	}, func(tx Tx, adv db.Advance, entryID int64) error {
		allocated := adv.AllocatedAmount.Add(in.Amount)
		status := advanceStatus(adv.Amount, allocated, adv.RefundedAmount)
		if err := s.store.SetAdvanceAmounts(ctx, tx, db.AcctSetAdvanceAmountsParams{
			ID: adv.ID, AllocatedAmount: allocated, RefundedAmount: adv.RefundedAmount, Status: status,
		}); err != nil {
			return err
		}
		ref := adv.AdvanceNumber
		if in.InvoiceID != nil {
			ref = fmt.Sprintf("INV-%d", *in.InvoiceID)
		} else if in.PurchaseOrderID != nil {
			ref = fmt.Sprintf("PO-%d", *in.PurchaseOrderID)
		}
		_, err := s.store.CreateAdvanceTxn(ctx, tx, db.AcctCreateAdvanceTxnParams{
			AdvanceID: adv.ID, TxnType: "allocation", Amount: in.Amount,
			Reference:       pgtype.Text{String: ref, Valid: true},
			InvoiceID:       int8PtrToPg(in.InvoiceID),
			PurchaseOrderID: int8PtrToPg(in.PurchaseOrderID),
			JournalEntryID:  pgtype.Int8{Int64: entryID, Valid: true},
			CreatedBy:       int8PtrToPg(userID),
		})
		return err
	})
}

func (s *Service) RefundAdvance(ctx context.Context, id int64, typ string, in AdvanceRefundInput, userID *int64) (*Advance, error) {
	if !in.Amount.IsPositive() {
		return nil, fmt.Errorf("%w: jumlah refund harus lebih dari 0", ErrValidation)
	}
	cashCode := in.CashAccountCode
	if cashCode == "" {
		cashCode = acctCashDefault
	}
	return s.mutateAdvance(ctx, id, typ, userID, func(tx Tx, adv db.Advance) (db.AcctCreateJournalParams, []resolvedLine, error) {
		remaining := adv.Amount.Sub(adv.AllocatedAmount).Sub(adv.RefundedAmount)
		if in.Amount.GreaterThan(remaining) {
			return db.AcctCreateJournalParams{}, nil, ErrExceedsRemaining
		}
		cash, err := s.requireAccount(ctx, tx, cashCode, cashCode == acctCashDefault)
		if err != nil {
			return db.AcctCreateJournalParams{}, nil, err
		}
		date := s.today()
		var p db.AcctCreateJournalParams
		var lines []resolvedLine
		if adv.AdvanceType == "sales" {
			deposit, err := s.requireAccount(ctx, tx, acctDepositSales, true)
			if err != nil {
				return db.AcctCreateJournalParams{}, nil, err
			}
			p = db.AcctCreateJournalParams{
				JournalType: "customer_deposit_usage", Status: "posted",
				Reference:   pgtype.Text{String: adv.AdvanceNumber, Valid: true},
				Description: pgtype.Text{String: fmt.Sprintf("Refund deposit %s", adv.AdvanceNumber), Valid: true},
			}
			lines = []resolvedLine{
				{accountID: deposit.ID, debit: in.Amount, description: "Pengurangan kewajiban deposit"},
				{accountID: cash.ID, credit: in.Amount, description: "Pengembalian uang tunai ke pelanggan"},
			}
		} else {
			asset, err := s.requireAccount(ctx, tx, acctAdvancePurchase, true)
			if err != nil {
				return db.AcctCreateJournalParams{}, nil, err
			}
			p = db.AcctCreateJournalParams{
				JournalType: "general", Status: "posted",
				Reference:   pgtype.Text{String: adv.AdvanceNumber, Valid: true},
				Description: pgtype.Text{String: fmt.Sprintf("Refund uang muka pembelian %s", adv.AdvanceNumber), Valid: true},
			}
			lines = []resolvedLine{
				{accountID: asset.ID, debit: in.Amount, description: "Pengurangan uang muka pembelian"},
				{accountID: cash.ID, credit: in.Amount, description: "Pengembalian uang ke supplier"},
			}
		}
		p.EntryDate = pgtype.Date{Time: mustDate(date), Valid: true}
		p.TotalDebit = in.Amount
		p.TotalCredit = in.Amount
		p.PostedBy = int8PtrToPg(userID)
		p.PostedAt = pgtype.Timestamptz{Time: time.Now().In(s.loc), Valid: true}
		p.SourceType = pgtype.Text{String: "advance_refund", Valid: true}
		p.SourceID = pgtype.Int8{Int64: adv.ID, Valid: true}

		number, err := s.journalNumber(ctx, tx, p.JournalType, date)
		if err != nil {
			return db.AcctCreateJournalParams{}, nil, err
		}
		p.JournalNumber = number
		return p, lines, nil
	}, func(tx Tx, adv db.Advance, entryID int64) error {
		refunded := adv.RefundedAmount.Add(in.Amount)
		status := advanceStatus(adv.Amount, adv.AllocatedAmount, refunded)
		if err := s.store.SetAdvanceAmounts(ctx, tx, db.AcctSetAdvanceAmountsParams{
			ID: adv.ID, AllocatedAmount: adv.AllocatedAmount, RefundedAmount: refunded, Status: status,
		}); err != nil {
			return err
		}
		_, err := s.store.CreateAdvanceTxn(ctx, tx, db.AcctCreateAdvanceTxnParams{
			AdvanceID: adv.ID, TxnType: "refund", Amount: in.Amount,
			Reference:      pgtype.Text{String: adv.AdvanceNumber, Valid: true},
			JournalEntryID: pgtype.Int8{Int64: entryID, Valid: true},
			CreatedBy:      int8PtrToPg(userID),
		})
		return err
	})
}

// mutateAdvance runs one advance mutation transactionally: lock the advance,
// build + insert the auto-posted journal, then apply the amount/status update
// and the advance_transactions row.
func (s *Service) mutateAdvance(
	ctx context.Context, id int64, typ string, userID *int64,
	buildJournal func(tx Tx, adv db.Advance) (db.AcctCreateJournalParams, []resolvedLine, error),
	apply func(tx Tx, adv db.Advance, entryID int64) error,
) (*Advance, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	adv, err := s.store.LockAdvance(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if typ != "" && typ != adv.AdvanceType {
		return nil, ErrNotFound
	}
	p, lines, err := buildJournal(tx, adv)
	if err != nil {
		return nil, err
	}
	entry, err := s.insertJournalTx(ctx, tx, p, lines)
	if err != nil {
		return nil, err
	}
	if err := apply(tx, adv, entry.ID); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetAdvance(ctx, id, typ)
}

// --- converters -----------------------------------------------------------------

func mustDate(v string) time.Time {
	t, _ := parseDate(v)
	return t
}

func int8PtrToPg(v *int64) pgtype.Int8 {
	if v == nil {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: *v, Valid: true}
}

func int8PgToPtr(v pgtype.Int8) *int64 {
	if !v.Valid {
		return nil
	}
	id := v.Int64
	return &id
}

func accountFromRow(a db.Account) Account {
	return Account{
		ID: a.ID, Code: a.Code, Name: a.Name, Type: a.Type,
		ParentID:      int8PgToPtr(a.ParentID),
		NormalBalance: a.NormalBalance,
		IsPostable:    a.IsPostable, IsPlaceholder: a.IsPlaceholder, IsActive: a.IsActive,
		CreatedAt: a.CreatedAt.Time, UpdatedAt: a.UpdatedAt.Time,
	}
}

func departmentFromRow(d db.AccountingDepartment) Department {
	return Department{
		ID: d.ID, Code: d.Code, Name: d.Name, IsActive: d.IsActive,
		CreatedAt: d.CreatedAt.Time, UpdatedAt: d.UpdatedAt.Time,
	}
}

func journalLineFromRow(id, entryID int64, accountCode, accountName string, departmentCode pgtype.Text, debit, credit decimal.Decimal, description pgtype.Text) JournalLine {
	return JournalLine{
		ID: id, AccountCode: accountCode, AccountName: accountName,
		DepartmentCode: departmentCode.String,
		Debit:          debit, Credit: credit, Description: description.String,
	}
}

func journalFromRow(e db.JournalEntry, lines []JournalLine) Journal {
	var postedAt, reversedAt *time.Time
	if e.PostedAt.Valid {
		t := e.PostedAt.Time
		postedAt = &t
	}
	if e.ReversedAt.Valid {
		t := e.ReversedAt.Time
		reversedAt = &t
	}
	// reversal_entry_id exposes the counterpart of a reversal pair in both
	// directions (original -> mirror, mirror -> original).
	var reversalID *int64
	if e.ReversalEntryID.Valid {
		reversalID = int8PgToPtr(e.ReversalEntryID)
	} else if e.ReversalOf.Valid {
		reversalID = int8PgToPtr(e.ReversalOf)
	}
	return Journal{
		ID: e.ID, JournalNumber: e.JournalNumber, JournalType: e.JournalType,
		EntryDate: e.EntryDate.Time.Format("2006-01-02"),
		Reference: e.Reference.String, Description: e.Description.String,
		Status: e.Status, StoreID: int8PgToPtr(e.StoreID),
		TotalDebit: e.TotalDebit, TotalCredit: e.TotalCredit,
		CreatedAt: e.CreatedAt.Time, PostedAt: postedAt, ReversedAt: reversedAt,
		ReversalEntryID: reversalID,
		SourceType:      e.SourceType.String, SourceID: int8PgToPtr(e.SourceID),
		Lines: lines,
	}
}

func advanceFromRow(id int64, number, typ string, customerID pgtype.Int8, customerName string, supplierID pgtype.Int8, supplierName string, amount, allocated, refunded decimal.Decimal, status string, date pgtype.Date, notes pgtype.Text) Advance {
	remaining := amount.Sub(allocated).Sub(refunded)
	return Advance{
		ID: id, AdvanceNumber: number, AdvanceType: typ,
		CustomerID: int8PgToPtr(customerID), CustomerName: customerName,
		SupplierID: int8PgToPtr(supplierID), SupplierName: supplierName,
		Amount: amount, AllocatedAmount: allocated, RefundedAmount: refunded,
		RemainingAmount: remaining,
		Allocated:       allocated, Refunded: refunded, Remainder: remaining,
		Status:      status,
		Date:        date.Time.Format("2006-01-02"),
		AdvanceDate: date.Time.Format("2006-01-02"),
		Notes:       notes.String,
	}
}
