package finance

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"regexp"
	"strings"
	"testing"
	"time"

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

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

type fakeTx struct {
	store *fakeStore
	tag   pgconn.CommandTag
	err   error
}

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

type fakeRow struct {
	vals []any
	err  error
}

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

type fakeStore struct {
	invoices           map[int64]*Invoice
	payments           map[int64]*Payment
	paymentOwners      map[int64]int64  // paymentID -> linked customer user_id
	paymentReviews     map[int64]string // paymentID -> verdict text stamped by SetPaymentReview
	cashflows          map[int64]*Cashflow
	sos                map[int64]*SOHeader
	soStatusHistory    []db.SalesOrderStatusHistory
	receivs            []ReceivableRow
	soItems            map[int64][]db.ListInvoiceItemsRow
	paymentsFilter     InvoiceFilter
	nextID             int64
	beginCount         int
	lockSOUsedTx       bool
	lockInvoiceUsedTx  bool
	lockPaymentUsedTx  bool
	listCashflowsCount int
	// listCashflowsScope records the outlet scope the service actually handed to
	// the store, so a test can assert the filter ARRIVED rather than merely
	// existing. nil and empty are distinguishable here, which is the point: nil
	// is platform-wide, empty is a restricted scope matching nothing.
	listCashflowsScope []int64
	// listCashflowsScopeSet separates "the service passed nil" from "the service
	// never got as far as calling the store". Without it, a test asserting
	// platform-wide would be unable to tell a correct result from a service that
	// did nothing at all.
	listCashflowsScopeSet bool
	execErr               error // injected raw-SQL failure (sales-order payment sync)
	execCalls             int
	commits               int
	rollbacks             int
	tx                    *fakeTx
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		invoices:       map[int64]*Invoice{},
		payments:       map[int64]*Payment{},
		paymentOwners:  map[int64]int64{},
		paymentReviews: map[int64]string{},
		cashflows:      map[int64]*Cashflow{},
		sos:            map[int64]*SOHeader{},
		soItems:        map[int64][]db.ListInvoiceItemsRow{},
		nextID:         1,
	}
}

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

func (f *fakeStore) CreateInvoice(_ context.Context, _ db.DBTX, p db.CreateInvoiceParams) (db.CreateInvoiceRow, error) {
	inv := invoiceFromCreateParams(p)
	inv.ID = f.nextID
	f.nextID++
	f.invoices[inv.ID] = inv
	return db.CreateInvoiceRow{ID: inv.ID}, nil
}

func (f *fakeStore) UpdateInvoice(_ context.Context, _ db.DBTX, p db.UpdateInvoiceParams) error {
	inv := f.invoices[p.ID]
	inv.Status = p.Status
	inv.PaidAmount = p.PaidAmount
	inv.RemainingAmount = p.RemainingAmount
	inv.Notes = textToStrPtr(p.Notes)
	return nil
}

func (f *fakeStore) InvoiceByID(_ context.Context, id int64) (db.GetInvoiceByIDRow, error) {
	if inv, ok := f.invoices[id]; ok {
		return invoiceRowFrom(inv), nil
	}
	return db.GetInvoiceByIDRow{}, ErrNotFound
}

func (f *fakeStore) LockInvoice(_ context.Context, q db.DBTX, id int64) (db.GetInvoiceByIDForUpdateRow, error) {
	f.lockInvoiceUsedTx = q == f.tx
	row, err := f.InvoiceByID(context.Background(), id)
	return db.GetInvoiceByIDForUpdateRow(row), err
}

func (f *fakeStore) ListInvoices(context.Context, InvoiceFilter) ([]db.ListInvoicesRow, int, error) {
	return nil, 0, nil
}

func (f *fakeStore) LockSO(_ context.Context, q db.DBTX, id int64) (db.GetSOHeaderByIDForUpdateRow, error) {
	f.lockSOUsedTx = q == f.tx
	if s, ok := f.sos[id]; ok {
		return db.GetSOHeaderByIDForUpdateRow{ID: s.ID, SoNumber: s.SoNumber, CustomerID: s.CustomerID, Status: s.Status, TotalAmount: s.TotalAmount}, nil
	}
	return db.GetSOHeaderByIDForUpdateRow{}, ErrNotFound
}

func (f *fakeStore) InvoiceExistsForSO(_ context.Context, q db.DBTX, soID int64) (bool, error) {
	if q != f.tx {
		return false, errors.New("invoice existence read did not use transaction")
	}
	for _, inv := range f.invoices {
		if inv.SalesOrderID == soID {
			return true, nil
		}
	}
	return false, nil
}

func (f *fakeStore) SetSOStatus(_ context.Context, _ db.DBTX, p db.SetSOStatusParams) error {
	if s, ok := f.sos[p.ID]; ok {
		s.Status = p.Status
	}
	return nil
}

func (f *fakeStore) InsertSOStatusHistory(_ context.Context, _ db.DBTX, p db.InsertSOStatusHistoryParams) error {
	f.soStatusHistory = append(f.soStatusHistory, db.SalesOrderStatusHistory{
		ID: int64(len(f.soStatusHistory) + 1), SalesOrderID: p.SalesOrderID,
		FromStatus: p.FromStatus, ToStatus: p.ToStatus, Reason: p.Reason,
		ReferenceType: p.ReferenceType, ReferenceID: p.ReferenceID, ChangedBy: p.ChangedBy,
	})
	return nil
}

func (f *fakeStore) InvoiceCreationStatusHistory(_ context.Context, _ db.DBTX, p db.GetInvoiceCreationStatusHistoryForUpdateParams) (db.SalesOrderStatusHistory, error) {
	for i := len(f.soStatusHistory) - 1; i >= 0; i-- {
		h := f.soStatusHistory[i]
		if h.SalesOrderID == p.SalesOrderID && h.Reason == "invoice_created" && h.ToStatus == "invoiced" &&
			h.ReferenceType.Valid && h.ReferenceType.String == InvoiceModel && h.ReferenceID == p.ReferenceID {
			return h, nil
		}
	}
	return db.SalesOrderStatusHistory{}, ErrInvoiceCreationHistoryRequired
}

func (f *fakeStore) PaymentsExistForInvoice(_ context.Context, _ db.DBTX, invoiceID int64) (bool, error) {
	for _, payment := range f.payments {
		if payment.PayableType == InvoiceModel && payment.PayableID == invoiceID {
			return true, nil
		}
	}
	return false, nil
}

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

func (f *fakeStore) InvoiceItems(_ context.Context, salesOrderID int64) ([]db.ListInvoiceItemsRow, error) {
	return f.soItems[salesOrderID], nil
}

func (f *fakeStore) ListReceivables(context.Context, ReceivableFilter) ([]db.ListReceivablesRow, int, decimal.Decimal, error) {
	out := make([]db.ListReceivablesRow, 0, len(f.receivs))
	for _, r := range f.receivs {
		out = append(out, receivableRowFrom(r))
	}
	return out, len(out), decimal.NewFromInt(100), nil
}

func (f *fakeStore) CreatePayment(_ context.Context, _ db.DBTX, p db.CreatePaymentParams) (int64, error) {
	payment := paymentFromCreateParams(p)
	payment.ID = f.nextID
	f.nextID++
	f.payments[payment.ID] = payment
	return payment.ID, nil
}

func (f *fakeStore) ListPayments(_ context.Context, f2 InvoiceFilter) ([]db.ListPaymentsRow, int, error) {
	f.paymentsFilter = f2
	return nil, 0, nil
}

func (f *fakeStore) SumCustomerPayments(_ context.Context, _ int64, _, _ string) (db.SumCustomerPaymentsRow, error) {
	return db.SumCustomerPaymentsRow{}, nil
}

func (f *fakeStore) paymentDetailRow(p *Payment) db.GetPaymentByIDForUpdateRow {
	return db.GetPaymentByIDForUpdateRow{
		ID: p.ID, PaymentNumber: p.PaymentNumber, PayableType: p.PayableType, PayableID: p.PayableID,
		PaymentDate: p.PaymentDate, Amount: p.Amount, PaymentMethod: p.PaymentMethod,
		BankName: textOrNilStr(p.BankName), BankAccount: textOrNilStr(p.BankAccount),
		ReferenceNumber: textOrNilStr(p.ReferenceNumber), Notes: textOrNilStr(p.Notes),
		ProofUrl: textOrNilStr(p.ProofURL), CreatedBy: int8OrNil(p.CreatedBy),
	}
}

func (f *fakeStore) GetPaymentByID(_ context.Context, id int64) (db.GetPaymentByIDForUpdateRow, error) {
	p, ok := f.payments[id]
	if !ok {
		return db.GetPaymentByIDForUpdateRow{}, ErrNotFound
	}
	row := f.paymentDetailRow(p)
	if owner, ok := f.paymentOwners[id]; ok {
		row.OwnerUserID = pgtype.Int8{Int64: owner, Valid: true}
	}
	return row, nil
}

func (f *fakeStore) LockPayment(_ context.Context, q db.DBTX, id int64) (db.GetPaymentByIDForUpdateRow, error) {
	f.lockPaymentUsedTx = q == f.tx
	return f.GetPaymentByID(context.Background(), id)
}

func (f *fakeStore) SetPaymentReview(_ context.Context, id int64, verdict string) (int64, error) {
	if _, ok := f.payments[id]; !ok && len(f.payments) > 0 {
		return 0, nil
	}
	// Record what was actually stamped so a test asserts the PERSISTED note,
	// not merely that the service returned something.
	f.paymentReviews[id] = verdict
	return 1, nil
}
func (f *fakeStore) SetPaymentProofURL(_ context.Context, id int64, url string) (int64, error) {
	p, ok := f.payments[id]
	if !ok {
		return 0, nil
	}
	p.ProofURL = &url
	return 1, nil
}

// PaymentProofOwnersByKey mirrors the repository: a payment's stored
// proof_url ("/uploads/<key>" or a bare key) is the source of truth for
// whether an object is a private proof.
func (f *fakeStore) PaymentProofOwnersByKey(_ context.Context, key string) ([]int64, bool, error) {
	var owners []int64
	found := false
	for id, p := range f.payments {
		if p.ProofURL == nil || (*p.ProofURL != key && *p.ProofURL != "/uploads/"+key) {
			continue
		}
		found = true
		if owner, ok := f.paymentOwners[id]; ok {
			owners = append(owners, owner)
		}
	}
	return owners, found, nil
}

func (f *fakeStore) CreateCashflow(_ context.Context, _ db.DBTX, p db.CreateCashflowParams) (int64, error) {
	c := cashflowFromCreateParams(p)
	c.ID = f.nextID
	f.nextID++
	f.cashflows[c.ID] = c
	return c.ID, nil
}

// CashflowByID / LockCashflow apply the SAME outlet scope the SQL does, because
// a fake that ignored storeIDs would make every scoping test pass while the
// query stayed wide open. The semantics are the query's:
//
//	storeIDs == nil  -> platform-wide, matches company-level rows too
//	storeIDs != nil  -> only rows in those stores; a NULL outlet never matches
//
// A row the caller may not see is reported as ErrNotFound, not as a forbidden
// row, so the fake matches the SQL's WHERE clause rather than inventing a
// distinguishable error the real repository cannot produce.
func (f *fakeStore) CashflowByID(_ context.Context, id int64, storeIDs []int64) (db.GetCashflowByIDRow, error) {
	c, ok := f.cashflows[id]
	if !ok || !cashflowVisible(c, storeIDs) {
		return db.GetCashflowByIDRow{}, ErrNotFound
	}
	return cashflowRowFrom(c), nil
}

func (f *fakeStore) LockCashflow(_ context.Context, _ db.DBTX, id int64, storeIDs []int64) (db.GetCashflowByIDForUpdateRow, error) {
	row, err := f.CashflowByID(context.Background(), id, storeIDs)
	return db.GetCashflowByIDForUpdateRow(row), err
}

// cashflowVisible mirrors `store_id = ANY(store_ids)` including the NULL
// behaviour: ANY() over a NULL left-hand side is NULL, not true, so a
// company-level row is invisible to a restricted caller by construction.
func cashflowVisible(c *Cashflow, storeIDs []int64) bool {
	if storeIDs == nil {
		return true
	}
	if c.StoreID == nil {
		return false
	}
	for _, id := range storeIDs {
		if id == *c.StoreID {
			return true
		}
	}
	return false
}

func (f *fakeStore) CashflowAlreadyReversed(_ context.Context, _ db.DBTX, id int64) (bool, error) {
	for _, c := range f.cashflows {
		if c.ReversalOf != nil && *c.ReversalOf == id {
			return true, nil
		}
	}
	return false, nil
}

func (f *fakeStore) UpdateCashflow(_ context.Context, _ db.DBTX, p db.UpdateCashflowParams) (db.UpdateCashflowRow, error) {
	c, ok := f.cashflows[p.ID]
	if !ok {
		return db.UpdateCashflowRow{}, ErrNotFound
	}
	if c.ReferenceType != nil || c.ReferenceID != nil {
		return db.UpdateCashflowRow{}, ErrGeneratedCashflowImmutable
	}
	c.Type = p.Type
	c.Category = p.Category
	c.TransactionDate = p.TransactionDate.Time.Format("2006-01-02")
	c.Amount = p.Amount
	c.Description = textToStrPtr(p.Description)
	return db.UpdateCashflowRow(cashflowRowFrom(c)), nil
}

func (f *fakeStore) DeleteCashflow(_ context.Context, _ db.DBTX, id int64) error {
	delete(f.cashflows, id)
	return nil
}

func (f *fakeStore) ListCashflows(_ context.Context, f2 CashflowFilter) ([]db.ListCashflowsRow, int, db.SummarizeCashflowsRow, error) {
	f.listCashflowsCount++
	f.listCashflowsScope = f2.StoreIDs
	f.listCashflowsScopeSet = true
	// Apply the scope for real, with the query's own semantics, so a scoping
	// assertion is about data and not only about the parameter. A fake that
	// ignored the filter would make every scoping test here pass while the SQL
	// stayed wide open — the failure mode this whole lane is guarding against.
	rows := make([]db.ListCashflowsRow, 0, len(f.cashflows))
	for id, c := range f.cashflows {
		if !cashflowVisible(c, f2.StoreIDs) {
			continue
		}
		rows = append(rows, db.ListCashflowsRow{
			ID: id, TransactionNumber: c.TransactionNumber, Type: c.Type, Category: c.Category,
			TransactionDate: c.TransactionDate, Amount: c.Amount,
			Description: textOrNilStr(c.Description), ReferenceType: textOrNilStr(c.ReferenceType),
			ReferenceID: int8OrNil(c.ReferenceID), CreatedBy: int8OrNil(c.CreatedBy), StoreID: int8OrNil(c.StoreID),
		})
	}
	return rows, len(rows), db.SummarizeCashflowsRow{Income: decimal.Zero, Expense: decimal.Zero}, nil
}

func invoiceFromCreateParams(p db.CreateInvoiceParams) *Invoice {
	return &Invoice{
		InvoiceNumber: p.InvoiceNumber, SalesOrderID: p.SalesOrderID.Int64, CustomerID: p.CustomerID,
		InvoiceDate: p.InvoiceDate.Time.Format("2006-01-02"), DueDate: p.DueDate.Time.Format("2006-01-02"),
		Status: p.Status, Subtotal: p.Subtotal, TaxAmount: p.TaxAmount, TotalAmount: p.TotalAmount,
		PaidAmount: p.PaidAmount, RemainingAmount: p.RemainingAmount, Notes: textToStrPtr(p.Notes),
	}
}

func invoiceRowFrom(inv *Invoice) db.GetInvoiceByIDRow {
	return db.GetInvoiceByIDRow{
		ID: inv.ID, InvoiceNumber: inv.InvoiceNumber, SalesOrderID: pgtype.Int8{Int64: inv.SalesOrderID, Valid: true},
		CustomerID: inv.CustomerID, InvoiceDate: inv.InvoiceDate, DueDate: inv.DueDate, Status: inv.Status,
		Subtotal: inv.Subtotal, TaxAmount: inv.TaxAmount, TotalAmount: inv.TotalAmount,
		PaidAmount: inv.PaidAmount, RemainingAmount: inv.RemainingAmount, Notes: textOrNilStr(inv.Notes),
		CreatedAt: pgtype.Timestamptz{Time: inv.CreatedAt, Valid: !inv.CreatedAt.IsZero()},
		UpdatedAt: pgtype.Timestamptz{Time: inv.UpdatedAt, Valid: !inv.UpdatedAt.IsZero()},
	}
}

func paymentFromCreateParams(p db.CreatePaymentParams) *Payment {
	return &Payment{
		PaymentNumber: p.PaymentNumber, PayableType: p.PayableType, PayableID: p.PayableID,
		PaymentDate: p.PaymentDate.Time.Format("2006-01-02"), Amount: p.Amount, PaymentMethod: p.PaymentMethod,
		BankName: textToStrPtr(p.BankName), BankAccount: textToStrPtr(p.BankAccount),
		ReferenceNumber: textToStrPtr(p.ReferenceNumber), Notes: textToStrPtr(p.Notes), CreatedBy: int8ToPtr(p.CreatedBy),
	}
}

func cashflowFromCreateParams(p db.CreateCashflowParams) *Cashflow {
	return &Cashflow{
		TransactionNumber: p.TransactionNumber, Type: p.Type, Category: p.Category,
		TransactionDate: p.TransactionDate.Time.Format("2006-01-02"), Amount: p.Amount,
		Description: textToStrPtr(p.Description), ReferenceType: textToStrPtr(p.ReferenceType),
		ReferenceID: int8ToPtr(p.ReferenceID), CreatedBy: int8ToPtr(p.CreatedBy),
		// The outlet the SERVICE derived from the caller's context. Round-tripped
		// so a test can read back what CreateCashflow actually persisted, which
		// is the only way to assert the write path rather than the intent.
		StoreID: int8ToPtr(p.StoreID),
	}
}

func cashflowRowFrom(c *Cashflow) db.GetCashflowByIDRow {
	return db.GetCashflowByIDRow{
		ID: c.ID, TransactionNumber: c.TransactionNumber, Type: c.Type, Category: c.Category,
		TransactionDate: c.TransactionDate, Amount: c.Amount, Description: textOrNilStr(c.Description),
		ReferenceType: textOrNilStr(c.ReferenceType), ReferenceID: int8OrNil(c.ReferenceID),
		CreatedBy: int8OrNil(c.CreatedBy), StoreID: int8OrNil(c.StoreID),
	}
}

func receivableRowFrom(r ReceivableRow) db.ListReceivablesRow {
	return db.ListReceivablesRow{
		ID: r.InvoiceID, InvoiceNumber: r.InvoiceNumber, CustomerID: r.CustomerID, Name: r.CustomerName,
		Phone:          textOrNilStr(r.CustomerPhone),
		InvInvoiceDate: r.InvoiceDate, InvDueDate: r.DueDate, Status: r.Status,
		SoNumber: r.SoNumber, PaymentTerms: int4OrNil(r.PaymentTerms), SalesName: textOrNilStr(r.SalesName),
		StoreID: int8OrNil(r.StoreID), StoreName: r.StoreName, OrderType: r.OrderType,
		TotalAmount: r.TotalAmount, PaidAmount: r.PaidAmount, RemainingAmount: r.Remaining,
	}
}

type fakeSeq struct{ n int64 }

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

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

func addSO(s *Service, id int64, status string, total string) {
	s.store.(*fakeStore).sos[id] = &SOHeader{ID: id, SoNumber: "SO-0001", CustomerID: 1, Status: status, TotalAmount: decimal.RequireFromString(total)}
}

func TestCreateInvoice_FromConfirmed(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	if inv.InvoiceNumber[:3] != "INV" {
		t.Errorf("number = %s, want INV prefix", inv.InvoiceNumber)
	}
	if inv.TotalAmount.String() != "250" {
		t.Errorf("total = %s, want 250", inv.TotalAmount)
	}
	// parity §5.1.3: subtotal/tax left 0.00
	if inv.Subtotal.String() != "0" || inv.TaxAmount.String() != "0" {
		t.Errorf("subtotal/tax = %s/%s, want 0/0 (parity)", inv.Subtotal, inv.TaxAmount)
	}
	if inv.Status != "unpaid" || inv.RemainingAmount.String() != "250" {
		t.Errorf("status/remaining = %s/%s, want unpaid/250", inv.Status, inv.RemainingAmount)
	}
	if s.store.(*fakeStore).sos[1].Status != "invoiced" {
		t.Errorf("SO status = %s, want invoiced", s.store.(*fakeStore).sos[1].Status)
	}
	if !s.store.(*fakeStore).lockSOUsedTx {
		t.Error("SO governing invoice creation was not read through the transaction")
	}
}

func TestCreateInvoice_RejectsExistingInvoiceForSO(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	st := s.store.(*fakeStore)
	st.invoices[99] = &Invoice{ID: 99, SalesOrderID: 1}
	if _, err := s.CreateInvoice(context.Background(), 1); !errors.Is(err, ErrCannotInvoice) {
		t.Fatalf("got %v, want ErrCannotInvoice", err)
	}
	if len(st.invoices) != 1 {
		t.Fatalf("invoice count = %d, want 1", len(st.invoices))
	}
}

func TestCreateInvoice_WrongSOStatus(t *testing.T) {
	s := svc()
	addSO(s, 1, "draft", "250")
	if _, err := s.CreateInvoice(context.Background(), 1); !errors.Is(err, ErrCannotInvoice) {
		t.Errorf("got %v, want ErrCannotInvoice", err)
	}
	addSO(s, 2, "invoiced", "250")
	if _, err := s.CreateInvoice(context.Background(), 2); !errors.Is(err, ErrCannotInvoice) {
		t.Errorf("got %v, want ErrCannotInvoice", err)
	}
}

func TestPayInvoice_Full(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	paid, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{
		Amount:        decimal.NewFromInt(250),
		PaymentMethod: "transfer",
	})
	if err != nil {
		t.Fatal(err)
	}
	if paid.Status != "paid" {
		t.Errorf("status = %s, want paid", paid.Status)
	}
	if paid.RemainingAmount.String() != "0" {
		t.Errorf("remaining = %s, want 0", paid.RemainingAmount)
	}
	// payment recorded with morph reference
	found := false
	for _, p := range s.store.(*fakeStore).payments {
		if p.PayableID == inv.ID && p.PayableType == InvoiceModel {
			found = true
			if p.PaymentNumber[:3] != "PAY" {
				t.Errorf("payment number = %s, want PAY prefix", p.PaymentNumber)
			}
		}
	}
	if !found {
		t.Error("payment not recorded for invoice")
	}

	if !s.store.(*fakeStore).lockInvoiceUsedTx {
		t.Error("invoice governing payment was not read through the transaction")
	}
}

func TestPayInvoice_AllowsOverdueStatus(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "500")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	// Simulate invoice becoming overdue past its due date
	s.store.(*fakeStore).invoices[inv.ID].Status = "overdue"

	paid, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{
		Amount:        decimal.NewFromInt(200),
		PaymentMethod: "cash",
	})
	if err != nil {
		t.Fatalf("expected overdue invoice to be payable, got: %v", err)
	}
	if paid.Status != "partial" {
		t.Errorf("status = %s, want partial", paid.Status)
	}
	if paid.RemainingAmount.String() != "300" {
		t.Errorf("remaining = %s, want 300", paid.RemainingAmount)
	}
}

type failSeq struct{}

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

// PayInvoice must abort when the PAY sequence errors — an empty payment
// number must never be stored (Phase 3 gate).
func TestPayInvoice_SequenceErrorAborts(t *testing.T) {
	s := NewService(newFakeStore(), failSeq{}, time.UTC)
	fs := s.store.(*fakeStore)
	fs.invoices[10] = &Invoice{ID: 10, InvoiceNumber: "INV-1", SalesOrderID: 1, CustomerID: 1, InvoiceDate: "2026-08-11", DueDate: "2026-08-18", Status: "unpaid", TotalAmount: decimal.NewFromInt(250), RemainingAmount: decimal.NewFromInt(250)}
	_, err := s.PayInvoice(context.Background(), 10, PaymentInput{Amount: decimal.NewFromInt(250), PaymentMethod: "transfer"})
	if err == nil {
		t.Fatal("expected sequence error, got nil")
	}
	if !strings.Contains(err.Error(), "PAY number sequence") {
		t.Errorf("error = %v, want PAY number sequence error", err)
	}
	if len(fs.payments) != 0 {
		t.Errorf("payment recorded despite sequence failure: %d payments", len(fs.payments))
	}
}

// CreateInvoice must abort when the INV sequence errors.
func TestCreateInvoice_SequenceErrorAborts(t *testing.T) {
	s := NewService(newFakeStore(), failSeq{}, time.UTC)
	addSO(s, 1, "confirmed", "250")
	if _, err := s.CreateInvoice(context.Background(), 1); err == nil {
		t.Fatal("expected sequence error, got nil")
	}
}

// PAY numbers follow the confirmed monthly format PAY-YYYYMM-000001 (§17).
func TestPayInvoice_MonthlyPaymentNumber(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{Amount: decimal.NewFromInt(250), PaymentMethod: "transfer"}); err != nil {
		t.Fatal(err)
	}
	re := regexp.MustCompile(`^PAY-\d{6}-\d{6}$`)
	found := false
	for _, p := range s.store.(*fakeStore).payments {
		if p.PayableID == inv.ID {
			found = true
			if !re.MatchString(p.PaymentNumber) {
				t.Errorf("payment number = %s, want PAY-YYYYMM-000001", p.PaymentNumber)
			}
		}
	}
	if !found {
		t.Error("payment not recorded")
	}
}

func TestPayInvoice_Partial(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	paid, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{
		Amount:        decimal.NewFromInt(100),
		PaymentMethod: "cash",
	})
	if err != nil {
		t.Fatal(err)
	}
	if paid.Status != "partial" {
		t.Errorf("status = %s, want partial", paid.Status)
	}
	if paid.RemainingAmount.String() != "150" {
		t.Errorf("remaining = %s, want 150", paid.RemainingAmount)
	}
}

func TestPayInvoice_TooLarge(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{
		Amount: decimal.NewFromInt(999), PaymentMethod: "cash",
	}); !errors.Is(err, ErrPaymentTooLarge) {
		t.Errorf("got %v, want ErrPaymentTooLarge", err)
	}
}

func TestCashflowCreate_Validation(t *testing.T) {
	s := svc()
	_, err := s.CreateCashflow(context.Background(), CashflowInput{
		Type: "transfer", Category: "sales", TransactionDate: "2026-08-11", Amount: decimal.NewFromInt(10),
	})
	if err == nil {
		t.Fatal("want error: type must be income or expense")
	}
	_, err = s.CreateCashflow(context.Background(), CashflowInput{
		Type: "income", Category: "bogus", TransactionDate: "2026-08-11", Amount: decimal.NewFromInt(10),
	})
	if err == nil {
		t.Fatal("want error: bad category")
	}
	_, err = s.CreateCashflow(context.Background(), CashflowInput{
		Type: "income", Category: "sales", TransactionDate: "2026-02-30", Amount: decimal.NewFromInt(10),
	})
	if !errors.Is(err, ErrInvalidDate) {
		t.Fatalf("got %v, want ErrInvalidDate", err)
	}
	if begins := s.store.(*fakeStore).beginCount; begins != 0 {
		t.Fatalf("transactions begun = %d, want 0 for invalid inputs", begins)
	}
}

func TestListCashflows_RejectsMalformedDatesBeforeRepository(t *testing.T) {
	tests := []CashflowFilter{
		{DateFrom: "17-08-2026"},
		{DateTo: "2026-13-01"},
	}
	for _, filter := range tests {
		s := svc()
		_, _, _, err := s.ListCashflows(context.Background(), filter)
		if !errors.Is(err, ErrInvalidDate) {
			t.Errorf("filter %+v: got %v, want ErrInvalidDate", filter, err)
		}
		if calls := s.store.(*fakeStore).listCashflowsCount; calls != 0 {
			t.Errorf("filter %+v: repository called %d times", filter, calls)
		}
	}
}

func TestCashflowCreate_StoresEntry(t *testing.T) {
	s := svc()
	c, err := s.CreateCashflow(context.Background(), CashflowInput{
		Type: "expense", Category: "operational", TransactionDate: "2026-08-11", Amount: decimal.NewFromInt(50),
		AccountID: &[]int64{1}[0],
	})
	if err != nil {
		t.Fatal(err)
	}
	if c.TransactionNumber[:2] != "CF" {
		t.Errorf("number = %s, want CF prefix", c.TransactionNumber)
	}
	if c.Amount.String() != "50" {
		t.Errorf("amount = %s, want 50", c.Amount)
	}
}

func TestReceivables_OverdueComputed(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.receivs = []ReceivableRow{
		{
			InvoiceID: 1, InvoiceNumber: "INV-1", CustomerName: "A", DueDate: "2020-01-01",
			Remaining: decimal.NewFromInt(10), Status: "unpaid",
			SoNumber: "SO-1", SalesName: ptrString("Siti"), CustomerPhone: ptrString("0812"),
			PaymentTerms: ptrInt32(30),
		},
		{InvoiceID: 2, InvoiceNumber: "INV-2", CustomerName: "B", DueDate: "2099-01-01", Remaining: decimal.NewFromInt(10), Status: "partial"},
	}
	rows, count, total, err := s.ListReceivables(context.Background(), ReceivableFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if !rows[0].Overdue {
		t.Error("row 0 should be overdue")
	}
	if rows[1].Overdue {
		t.Error("row 1 should not be overdue")
	}
	if rows[0].OverdueDays <= 0 {
		t.Errorf("overdue_days = %d, want > 0", rows[0].OverdueDays)
	}
	if count != 2 {
		t.Errorf("count = %d, want 2", count)
	}
	if total.String() != "100" {
		t.Errorf("total = %s, want 100", total)
	}
	if rows[0].SoNumber != "SO-1" || *rows[0].SalesName != "Siti" || *rows[0].CustomerPhone != "0812" || *rows[0].PaymentTerms != 30 {
		t.Errorf("join fields = so_number %q sales_name %v phone %v terms %v, want SO-1/Siti/0812/30",
			rows[0].SoNumber, rows[0].SalesName, rows[0].CustomerPhone, rows[0].PaymentTerms)
	}
	if rows[1].SoNumber != "" || rows[1].SalesName != nil || rows[1].CustomerPhone != nil || rows[1].PaymentTerms != nil {
		t.Errorf("row without SO join should carry empty so_number and null join fields, got %+v", rows[1])
	}
}

func TestDeleteInvoice_RestoresExactCreationStatus(t *testing.T) {
	s := svc()
	addSO(s, 1, "delivered", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	// CreateInvoice on an already-delivered order must NOT stamp 'invoiced'
	// (that dragged delivered orders backwards — reported 1 Nov) and must NOT
	// write a creation history row claiming it did.
	st := s.store.(*fakeStore)
	for _, h := range st.soStatusHistory {
		if h.SalesOrderID == 1 && h.ToStatus == "invoiced" {
			t.Fatalf("creation wrote an 'invoiced' status/history on a delivered order: %+v", h)
		}
	}
	if st.sos[1].Status != "delivered" {
		t.Fatalf("CreateInvoice moved a delivered order to %s", st.sos[1].Status)
	}

	if err := s.DeleteInvoice(context.Background(), inv.ID); err != nil {
		t.Fatal(err)
	}
	if _, ok := st.invoices[inv.ID]; ok {
		t.Fatal("invoice was not deleted")
	}
	if got := st.sos[1].Status; got != "delivered" {
		t.Errorf("SO status = %s, want delivered", got)
	}
	if len(st.soStatusHistory) > 0 {
		last := st.soStatusHistory[len(st.soStatusHistory)-1]
		if last.Reason == "invoice_deleted" {
			t.Errorf("deletion history written for an invoice that never changed the status: %+v", last)
		}
	}
}

func TestDeleteInvoice_RejectsPayment(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore)
	st.payments[99] = &Payment{ID: 99, PayableType: InvoiceModel, PayableID: inv.ID}

	if err := s.DeleteInvoice(context.Background(), inv.ID); !errors.Is(err, ErrInvoiceHasPayment) {
		t.Fatalf("got %v, want ErrInvoiceHasPayment", err)
	}
	if _, ok := st.invoices[inv.ID]; !ok {
		t.Fatal("invoice deleted despite existing payment")
	}
}

// A pre-fulfilment order (confirmed) always writes a creation history row, so
// its deletion restores the prior status. This test manufactures the CORRUPT
// case — a confirmed order whose creation history was lost — and asserts
// deletion still refuses silently: the corrupt row is an operator problem,
// while deleting the invoice without touching the SO is safe either way.
func TestDeleteInvoice_RequiresCreationHistory(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore)
	st.soStatusHistory = nil

	// With the creation row gone, deletion still removes the invoice — but
	// must not guess a status restore. The SO keeps its current status.
	if err := s.DeleteInvoice(context.Background(), inv.ID); err != nil {
		t.Fatalf("deletion without history now succeeds (no status restore): %v", err)
	}
	if _, ok := st.invoices[inv.ID]; ok {
		t.Fatal("invoice was not deleted")
	}
	if got := st.sos[1].Status; got != "invoiced" {
		t.Errorf("SO status = %s, want untouched invoiced", got)
	}
}

func TestUpdateCashflow(t *testing.T) {
	s := svc()
	// platformCtx: the cashflow surface is fail-closed since migration 000104, so
	// a bare context is REFUSED. These tests are about update semantics, so they
	// say what they mean — a caller who may see everything. See platformCtx.
	c, err := s.CreateCashflow(platformCtx(), CashflowInput{
		Type: "expense", Category: "operational", TransactionDate: "2026-08-11", Amount: decimal.NewFromInt(50),
		AccountID: &[]int64{1}[0],
	})
	if err != nil {
		t.Fatal(err)
	}
	description := "updated"
	updated, err := s.UpdateCashflow(platformCtx(), c.ID, CashflowInput{
		Type: "income", Category: "other", TransactionDate: "2026-08-12", Amount: decimal.NewFromInt(75), Description: &description,
	})
	if err != nil {
		t.Fatal(err)
	}
	if updated.TransactionNumber != c.TransactionNumber || updated.Type != "income" || updated.Category != "other" ||
		updated.TransactionDate != "2026-08-12" || !updated.Amount.Equal(decimal.NewFromInt(75)) || updated.Description == nil || *updated.Description != description {
		t.Errorf("updated cashflow = %+v", updated)
	}
}

func TestUpdateCashflow_GeneratedAmountImmutable(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	invoiceID := int64(7)
	st.cashflows[1] = &Cashflow{
		ID: 1, TransactionNumber: "CF-1", Type: "income", Category: "sales", TransactionDate: "2026-08-11",
		Amount: decimal.NewFromInt(250), ReferenceType: ptrString(InvoiceModel), ReferenceID: &invoiceID,
	}

	_, err := s.UpdateCashflow(platformCtx(), 1, CashflowInput{
		Type: "income", Category: "sales", TransactionDate: "2026-08-12", Amount: decimal.NewFromInt(1),
	})
	if !errors.Is(err, ErrGeneratedCashflowImmutable) {
		t.Fatalf("got %v, want ErrGeneratedCashflowImmutable", err)
	}
	if got := st.cashflows[1].Amount; !got.Equal(decimal.NewFromInt(250)) {
		t.Errorf("amount = %s, want 250", got)
	}
}

// firstPaymentFor returns the single payment recorded against an invoice.
func firstPaymentFor(t *testing.T, st *fakeStore, invoiceID int64) *Payment {
	t.Helper()
	for _, p := range st.payments {
		if p.PayableType == InvoiceModel && p.PayableID == invoiceID {
			return p
		}
	}
	t.Fatalf("no payment found for invoice %d", invoiceID)
	return nil
}

// RecordPartialPayment happy path: an ADDITIONAL payment row against the SAME
// payable with a fresh PAY number, and the payable mutated through the same
// recording logic as PayInvoice.
func TestRecordPartialPayment_Happy(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{Amount: decimal.NewFromInt(100), PaymentMethod: "cash"}); err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore)
	first := firstPaymentFor(t, st, inv.ID)

	userID := int64(42)
	p, err := s.RecordPartialPayment(context.Background(), first.ID, &userID, PartialPaymentInput{
		Amount: decimal.NewFromInt(50), PaymentMethod: "transfer",
	})
	if err != nil {
		t.Fatal(err)
	}
	if p.ID == first.ID {
		t.Error("partial payment must be a NEW payment row")
	}
	if p.PayableType != InvoiceModel || p.PayableID != inv.ID {
		t.Errorf("payable = %s/%d, want %s/%d", p.PayableType, p.PayableID, InvoiceModel, inv.ID)
	}
	if p.PaymentNumber[:3] != "PAY" || p.PaymentNumber == first.PaymentNumber {
		t.Errorf("payment number = %s, want a fresh PAY number distinct from %s", p.PaymentNumber, first.PaymentNumber)
	}
	if p.Amount.String() != "50" {
		t.Errorf("amount = %s, want 50", p.Amount)
	}
	if p.CreatedBy == nil || *p.CreatedBy != userID {
		t.Errorf("created_by = %v, want %d", p.CreatedBy, userID)
	}
	if len(st.payments) != 2 {
		t.Errorf("payment count = %d, want 2", len(st.payments))
	}
	// Payable mutated consistently: paid 150 / remaining 100 / partial.
	inv2, err := s.GetInvoice(context.Background(), inv.ID)
	if err != nil {
		t.Fatal(err)
	}
	if inv2.PaidAmount.String() != "150" || inv2.RemainingAmount.String() != "100" || inv2.Status != "partial" {
		t.Errorf("invoice after partial = paid %s remaining %s status %s, want 150/100/partial",
			inv2.PaidAmount, inv2.RemainingAmount, inv2.Status)
	}

	if !st.lockPaymentUsedTx {
		t.Error("payment governing the partial was not read through the transaction")
	}
}

// A partial larger than the payable's remaining balance must be rejected
// before any write happens.
func TestRecordPartialPayment_TooLarge(t *testing.T) {
	s := svc()
	addSO(s, 1, "confirmed", "250")
	inv, err := s.CreateInvoice(context.Background(), 1)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.PayInvoice(context.Background(), inv.ID, PaymentInput{Amount: decimal.NewFromInt(100), PaymentMethod: "cash"}); err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore)
	first := firstPaymentFor(t, st, inv.ID)

	if _, err := s.RecordPartialPayment(context.Background(), first.ID, nil, PartialPaymentInput{
		Amount: decimal.NewFromInt(999), PaymentMethod: "cash",
	}); !errors.Is(err, ErrPaymentTooLarge) {
		t.Fatalf("got %v, want ErrPaymentTooLarge", err)
	}
	if len(st.payments) != 1 {
		t.Errorf("payment count = %d, want 1 (nothing recorded)", len(st.payments))
	}
	got, err := s.GetInvoice(context.Background(), inv.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.PaidAmount.String() != "100" || got.Status != "partial" {
		t.Errorf("invoice mutated on rejection: paid %s status %s", got.PaidAmount, got.Status)
	}
}

// Unknown payment IDs map to ErrNotFound.
func TestRecordPartialPayment_UnknownPayment(t *testing.T) {
	s := svc()
	if _, err := s.RecordPartialPayment(context.Background(), 1234, nil, PartialPaymentInput{
		Amount: decimal.NewFromInt(10), PaymentMethod: "cash",
	}); !errors.Is(err, ErrNotFound) {
		t.Fatalf("got %v, want ErrNotFound", err)
	}
}

// Only invoice payables flow through the finance recording logic.
func TestRecordPartialPayment_RejectsNonInvoicePayable(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.payments[5] = &Payment{ID: 5, PaymentNumber: "PAY-1", PayableType: "App\\Models\\SalesOrder", PayableID: 9}
	if _, err := s.RecordPartialPayment(context.Background(), 5, nil, PartialPaymentInput{
		Amount: decimal.NewFromInt(10), PaymentMethod: "cash",
	}); !errors.Is(err, ErrUnknownPayable) {
		t.Fatalf("got %v, want ErrUnknownPayable", err)
	}
}

// SetPaymentProof persists the URL on the payment row; unknown ids are 404s.
func TestSetPaymentProof_StoresURL(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.payments[7] = &Payment{ID: 7, PaymentNumber: "PAY-1", PayableType: InvoiceModel, PayableID: 3}

	url := "/uploads/9f2c1a.jpg"
	if err := s.SetPaymentProof(context.Background(), 7, url); err != nil {
		t.Fatal(err)
	}
	row, err := s.store.(*fakeStore).GetPaymentByID(context.Background(), 7)
	if err != nil {
		t.Fatal(err)
	}
	if !row.ProofUrl.Valid || row.ProofUrl.String != url {
		t.Errorf("proof_url = %+v, want %s", row.ProofUrl, url)
	}
	if err := s.SetPaymentProof(context.Background(), 99, url); !errors.Is(err, ErrNotFound) {
		t.Fatalf("got %v, want ErrNotFound", err)
	}
}

// The ownership linkage resolves only for invoice payables with a linked user.
func TestPaymentOwnerUser_Linkage(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.payments[1] = &Payment{ID: 1, PaymentNumber: "PAY-1", PayableType: InvoiceModel, PayableID: 11}
	st.paymentOwners[1] = 77
	st.payments[2] = &Payment{ID: 2, PaymentNumber: "PAY-2", PayableType: "App\\Models\\SalesOrder", PayableID: 12}

	owner, ok, err := s.PaymentOwnerUser(context.Background(), 1)
	if err != nil || !ok || owner != 77 {
		t.Errorf("owner/ok = %d/%v (err %v), want 77/true", owner, ok, err)
	}
	if _, ok, err := s.PaymentOwnerUser(context.Background(), 2); err != nil || ok {
		t.Errorf("non-invoice payable: ok = %v (err %v), want false", ok, err)
	}
	if _, _, err := s.PaymentOwnerUser(context.Background(), 404); !errors.Is(err, ErrNotFound) {
		t.Fatalf("got %v, want ErrNotFound", err)
	}
}

func ptrInt32(v int32) *int32 { return &v }

// Invoice detail lines are the source sales order's items (there is no
// invoice_items table), resolved through invoices.sales_order_id.
func TestGetInvoice_IncludesSOItems(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.invoices[7] = &Invoice{ID: 7, SalesOrderID: 3, Status: "unpaid"}
	st.soItems[3] = []db.ListInvoiceItemsRow{
		{ID: 1, SalesOrderID: 3, ProductID: 11, ProductCode: "P-11", ProductName: "Beras 5kg",
			Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(65000),
			DiscountPercent: decimal.NewFromInt(5), DiscountAmount: decimal.NewFromInt(6500),
			Subtotal: decimal.NewFromInt(123500)},
	}
	inv, err := s.GetInvoice(context.Background(), 7)
	if err != nil {
		t.Fatal(err)
	}
	if len(inv.Items) != 1 {
		t.Fatalf("items = %d, want 1", len(inv.Items))
	}
	it := inv.Items[0]
	if it.ProductCode != "P-11" || it.ProductName != "Beras 5kg" || it.Quantity.String() != "2" ||
		it.UnitPrice.String() != "65000" || it.DiscountAmount.String() != "6500" || it.Subtotal.String() != "123500" {
		t.Errorf("item = %+v", it)
	}
}

// An invoice without a sales order reference has no item source: the detail
// response carries no items array rather than a fabricated empty list.
func TestGetInvoice_NoSOHasNoItems(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.invoices[8] = &Invoice{ID: 8, Status: "unpaid"}
	inv, err := s.GetInvoice(context.Background(), 8)
	if err != nil {
		t.Fatal(err)
	}
	if inv.Items != nil {
		t.Errorf("items = %+v, want nil", inv.Items)
	}
}

// The payments list accepts optional payable_id / payable_type morph filters
// (GET /payments?payable_id=..&payable_type=..); absent params are no-ops.
func TestListPayments_HandlerParsesPayableFilter(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	h := NewHandler(s)

	res := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/payments?payable_id=11&payable_type=App%5CModels%5CInvoice", nil)
	h.ListPayments(res, req)
	if res.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", res.Code)
	}
	if st.paymentsFilter.PayableID != 11 || st.paymentsFilter.PayableType != InvoiceModel {
		t.Errorf("filter = payable_id %d payable_type %q, want 11 / %s", st.paymentsFilter.PayableID, st.paymentsFilter.PayableType, InvoiceModel)
	}

	// Absent params must reach the store zeroed: unchanged behavior.
	res = httptest.NewRecorder()
	h.ListPayments(res, httptest.NewRequest(http.MethodGet, "/payments?page=1&per_page=20", nil))
	if res.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", res.Code)
	}
	if st.paymentsFilter.PayableID != 0 || st.paymentsFilter.PayableType != "" {
		t.Errorf("filter without params = payable_id %d payable_type %q, want 0 / empty", st.paymentsFilter.PayableID, st.paymentsFilter.PayableType)
	}
}
