package lhpp

import (
	"fmt"
	"testing"

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

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

// Driver-side row fixtures for foldCandidateRows (see internal/sales and
// internal/distribution tests, which build db.*Row values directly rather than
// standing up a pool). Grouping is by lhpp_groups.id — the persisted sheet — so
// the default fixture puts every row on ONE group, mirroring the old
// one-driver-one-day sheet. Tests that need several sheets override groupID.

const (
	testDriverID = int64(7)
	testDoDate   = "2026-09-20"
	testGroupID  = int64(100)
)

func candidateRow(doID int64) db.ListLHPPCandidateRowsRow {
	return db.ListLHPPCandidateRowsRow{
		DoID:            doID,
		DoNumber:        fmt.Sprintf("DO-%03d", doID),
		DoDate:          testDoDate,
		GroupID:         testGroupID,
		LhppNumber:      "LHPP-202609-000001",
		LhppDate:        testDoDate,
		GroupStatus:     GroupActive,
		DriverID:        testDriverID,
		DriverName:      "Budi Santoso",
		FleetName:       "Truck 01",
		PlateNumber:     "B 1234 ABC",
		CustomerID:      doID * 10,
		CustomerName:    fmt.Sprintf("Toko %03d", doID),
		CustomerAddress: "Jl. Merdeka No. 1",
	}
}

// inGroup moves a row onto a different persisted sheet, i.e. the read-side
// picture after a manual merge/split.
func inGroup(groupID int64, number string) rowOpt {
	return func(r db.ListLHPPCandidateRowsRow) db.ListLHPPCandidateRowsRow {
		r.GroupID = groupID
		r.LhppNumber = number
		return r
	}
}

// onDate moves a row to another delivery date on the same sheet — what a
// multi-day merge looks like to the fold.
func onDate(doDate string) rowOpt {
	return func(r db.ListLHPPCandidateRowsRow) db.ListLHPPCandidateRowsRow {
		r.DoDate = doDate
		return r
	}
}

// rowOpt mutates a candidate row in place and hands it back, so a fixture can
// be composed inline: row(t, 1, withInvoice("cancelled", ...)).
type rowOpt func(db.ListLHPPCandidateRowsRow) db.ListLHPPCandidateRowsRow

// withInvoice attaches an invoice to a row, mirroring the LEFT JOIN in
// ListLHPPCandidateRows. A row with no invoice keeps the zero-value
// InvoiceID/InvoiceStatus, i.e. the NULL-join case.
func withInvoice(status string, total, paid, remaining int64) rowOpt {
	return func(r db.ListLHPPCandidateRowsRow) db.ListLHPPCandidateRowsRow {
		r.InvoiceID = pgtype.Int8{Int64: 900 + r.DoID, Valid: true}
		r.InvoiceNumber = pgtype.Text{String: fmt.Sprintf("INV/%03d", r.DoID), Valid: true}
		r.InvoiceStatus = pgtype.Text{String: status, Valid: true}
		r.TotalAmount = decimal.NewNullDecimal(decimal.NewFromInt(total))
		r.PaidAmount = decimal.NewNullDecimal(decimal.NewFromInt(paid))
		r.RemainingAmount = decimal.NewNullDecimal(decimal.NewFromInt(remaining))
		return r
	}
}

func row(t *testing.T, doID int64, opts ...rowOpt) db.ListLHPPCandidateRowsRow {
	t.Helper()
	r := candidateRow(doID)
	for _, opt := range opts {
		r = opt(r)
	}
	return r
}

// oneGroup folds the rows and asserts exactly one group came back, so a
// broken grouping key fails loudly instead of silently testing nothing.
func oneGroup(t *testing.T, rows ...db.ListLHPPCandidateRowsRow) Group {
	t.Helper()
	groups := foldCandidateRows(rows)
	if len(groups) != 1 {
		t.Fatalf("expected 1 LHPP group, got %d", len(groups))
	}
	g := groups[0]
	if g.DOCount != len(rows) {
		t.Fatalf("expected DOCount %d, got %d", len(rows), g.DOCount)
	}
	return g
}

func assertDecimal(t *testing.T, field string, got, want decimal.Decimal) {
	t.Helper()
	if !got.Equal(want) {
		t.Errorf("%s = %s, want %s", field, got, want)
	}
}

func itemByDO(t *testing.T, g Group, doID int64) Item {
	t.Helper()
	for _, it := range g.Items {
		if it.DOID == doID {
			return it
		}
	}
	t.Fatalf("DO %d not present in group", doID)
	return Item{}
}

// TestLHPPStatus_CancelledInvoiceIsNotLunas is the regression: sales'
// CancelOrderInvoices zeroes an invoice's remaining_amount but leaves
// total_amount, and the driver-side join has no status filter, so a written-off
// debt used to roll up as Tagihan 5,000,000 / Sisa 0 — which satisfied
// "tagihan > 0 && sisa == 0" and stamped a collectable sheet "lunas".
func TestLHPPStatus_CancelledInvoiceIsNotLunas(t *testing.T) {
	g := oneGroup(t, row(t, 1, withInvoice("cancelled", 5_000_000, 0, 0)))

	if g.Status == StatusLunas {
		t.Errorf("status = %q: a cancelled (written-off) invoice must never be reported as fully paid", g.Status)
	}
	if g.Status != StatusDibatalkan {
		t.Errorf("status = %q, want %q", g.Status, StatusDibatalkan)
	}
}

// TestLHPPAmounts_CancelledInvoiceContributesZero pins the totals: the cancelled
// invoice's 5,000,000 must not inflate the group's billable amounts, and its
// own line must read zero on the collector's sheet.
func TestLHPPAmounts_CancelledInvoiceContributesZero(t *testing.T) {
	g := oneGroup(t,
		row(t, 1, withInvoice("cancelled", 5_000_000, 0, 0)),
		row(t, 2, withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
	)

	assertDecimal(t, "TotalTagihan", g.TotalTagihan, decimal.NewFromInt(3_000_000))
	assertDecimal(t, "TotalTerbayar", g.TotalTerbayar, decimal.Zero)
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.NewFromInt(3_000_000))

	cancelled := itemByDO(t, g, 1)
	assertDecimal(t, "cancelled item Tagihan", cancelled.Tagihan, decimal.Zero)
	assertDecimal(t, "cancelled item Terbayar", cancelled.Terbayar, decimal.Zero)
	assertDecimal(t, "cancelled item Sisa", cancelled.Sisa, decimal.Zero)
	// The written-off invoice is still named on the sheet, so the collector can
	// see it was invoiced-and-voided rather than never invoiced at all.
	if cancelled.InvoiceID == nil {
		t.Error("cancelled item lost its invoice_id; the sheet should still name the written-off invoice")
	}
}

// TestLHPPStatus_MixedCancelledAndFullyPaidActiveIsLunas guards the
// roll-up-only-the-active-items rule: a write-off on one DO must not mask two
// genuinely paid ones, nor drag the sheet out of "lunas".
func TestLHPPStatus_MixedCancelledAndFullyPaidActiveIsLunas(t *testing.T) {
	g := oneGroup(t,
		row(t, 1, withInvoice("cancelled", 5_000_000, 0, 0)),
		row(t, 2, withInvoice("paid", 2_000_000, 2_000_000, 0)),
		row(t, 3, withInvoice("paid", 2_000_000, 2_000_000, 0)),
	)

	if g.Status != StatusLunas {
		t.Errorf("status = %q, want %q", g.Status, StatusLunas)
	}
	assertDecimal(t, "TotalTagihan", g.TotalTagihan, decimal.NewFromInt(4_000_000))
	assertDecimal(t, "TotalTerbayar", g.TotalTerbayar, decimal.NewFromInt(4_000_000))
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.Zero)
}

// TestLHPPStatus_NoInvoiceIsBelumDitagih pins that "no invoice" is NOT
// conflated with "cancelled invoice" — a DO the warehouse has not invoiced yet
// is a pending receivable, not a write-off, and must not be labelled
// "dibatalkan".
func TestLHPPStatus_NoInvoiceIsBelumDitagih(t *testing.T) {
	g := oneGroup(t, row(t, 1))

	if g.Status != StatusBelumDitagih {
		t.Errorf("status = %q, want %q", g.Status, StatusBelumDitagih)
	}
	assertDecimal(t, "TotalTagihan", g.TotalTagihan, decimal.Zero)
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.Zero)
	if g.Items[0].InvoiceID != nil {
		t.Error("expected invoice_id to stay nil for an uninvoiced DO")
	}
}

// TestLHPPStatus_CancelledAlongsideUninvoicedIsBelumDitagih: a group holding
// both a write-off and a not-yet-invoiced DO is still "belum_ditagih" — the
// live DO's receivable is outstanding and invoicing is genuinely pending.
func TestLHPPStatus_CancelledAlongsideUninvoicedIsBelumDitagih(t *testing.T) {
	g := oneGroup(t,
		row(t, 1, withInvoice("cancelled", 5_000_000, 0, 0)),
		row(t, 2),
	)

	if g.Status != StatusBelumDitagih {
		t.Errorf("status = %q, want %q", g.Status, StatusBelumDitagih)
	}
}

// TestLHPPStatus_ActivePartiallyPaidIsSebagian pins the pre-existing
// partial-payment status so the cancellation fix did not disturb it.
func TestLHPPStatus_ActivePartiallyPaidIsSebagian(t *testing.T) {
	g := oneGroup(t, row(t, 1, withInvoice("partial", 5_000_000, 2_000_000, 3_000_000)))

	if g.Status != StatusSebagian {
		t.Errorf("status = %q, want %q", g.Status, StatusSebagian)
	}
	assertDecimal(t, "TotalTagihan", g.TotalTagihan, decimal.NewFromInt(5_000_000))
	assertDecimal(t, "TotalTerbayar", g.TotalTerbayar, decimal.NewFromInt(2_000_000))
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.NewFromInt(3_000_000))
}

// TestLHPPStatus_ActiveFullyPaidIsLunas pins the normal paid sheet.
func TestLHPPStatus_ActiveFullyPaidIsLunas(t *testing.T) {
	g := oneGroup(t, row(t, 1, withInvoice("paid", 5_000_000, 5_000_000, 0)))

	if g.Status != StatusLunas {
		t.Errorf("status = %q, want %q", g.Status, StatusLunas)
	}
	assertDecimal(t, "TotalTagihan", g.TotalTagihan, decimal.NewFromInt(5_000_000))
	assertDecimal(t, "TotalTerbayar", g.TotalTerbayar, decimal.NewFromInt(5_000_000))
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.Zero)
}

// TestLHPPStatus_OverdueUnpaidIsBelumDitagih: a live but wholly unpaid invoice
// keeps the sheet collectable — the "dibatalkan" branch must key off the
// cancellation flag, not off a zero total.
func TestLHPPStatus_OverdueUnpaidIsBelumDitagih(t *testing.T) {
	g := oneGroup(t, row(t, 1, withInvoice("overdue", 5_000_000, 0, 5_000_000)))

	if g.Status != StatusBelumDitagih {
		t.Errorf("status = %q, want %q", g.Status, StatusBelumDitagih)
	}
	assertDecimal(t, "TotalSisa", g.TotalSisa, decimal.NewFromInt(5_000_000))
}

// TestLHPPStatus_CancelledInOneGroupDoesNotAffectAnother guards the
// hasCancelled/hasCollectable bookkeeping from leaking across group keys.
func TestLHPPStatus_CancelledInOneGroupDoesNotAffectAnother(t *testing.T) {
	groups := foldCandidateRows([]db.ListLHPPCandidateRowsRow{
		row(t, 1, withInvoice("cancelled", 5_000_000, 0, 0)),
		row(t, 2, withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
		row(t, 9, inGroup(200, "LHPP-202609-000002"), withInvoice("paid", 1_000_000, 1_000_000, 0)),
	})
	if len(groups) != 2 {
		t.Fatalf("expected 2 LHPP groups, got %d", len(groups))
	}

	byID := map[int64]Group{}
	for _, g := range groups {
		byID[g.ID] = g
	}
	if got := byID[testGroupID].Status; got != StatusBelumDitagih {
		t.Errorf("group %d status = %q, want %q", testGroupID, got, StatusBelumDitagih)
	}
	if got := byID[200].Status; got != StatusLunas {
		t.Errorf("group 200 status = %q, want %q", got, StatusLunas)
	}
}

// --- manual grouping ----------------------------------------------------------

// TestGrouping_SameDriverSameDateUnmergedStaysTwoGroups is the headline
// regression: automatic bucketing by (driver, date) is GONE. Two DOs for one
// driver on one day are two sheets until a human merges them, and a collector
// must still be able to see both. Restoring the old key would collapse these
// into one group and the test fails on the count.
func TestGrouping_SameDriverSameDateUnmergedStaysTwoGroups(t *testing.T) {
	groups := foldCandidateRows([]db.ListLHPPCandidateRowsRow{
		row(t, 1, inGroup(100, "LHPP-202609-000001"), withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
		row(t, 2, inGroup(101, "LHPP-202609-000002"), withInvoice("unpaid", 5_000_000, 0, 5_000_000)),
	})
	if len(groups) != 2 {
		t.Fatalf("expected 2 groups for two unmerged DOs of one driver on one date, got %d", len(groups))
	}
	for _, g := range groups {
		if g.DOCount != 1 {
			t.Errorf("group %d DOCount = %d, want 1 (nothing is auto-batched)", g.ID, g.DOCount)
		}
	}
	if groups[0].LHPPNumber == groups[1].LHPPNumber {
		t.Errorf("both groups carry number %q; a stored number must be distinct per sheet", groups[0].LHPPNumber)
	}
}

// TestGrouping_RegroupDoesNotChangeTotals pins that money is independent of
// grouping: splitting one sheet in two, or merging two back into one, moves the
// same per-item figures between buckets without changing a single amount.
func TestGrouping_RegroupDoesNotChangeTotals(t *testing.T) {
	unmerged := []db.ListLHPPCandidateRowsRow{
		row(t, 1, inGroup(100, "LHPP-202609-000001"), withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
		row(t, 2, inGroup(101, "LHPP-202609-000002"), withInvoice("partial", 5_000_000, 1_000_000, 4_000_000)),
	}
	merged := []db.ListLHPPCandidateRowsRow{
		row(t, 1, inGroup(100, "LHPP-202609-000001"), withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
		row(t, 2, inGroup(100, "LHPP-202609-000001"), withInvoice("partial", 5_000_000, 1_000_000, 4_000_000)),
	}

	split := foldCandidateRows(unmerged)
	if len(split) != 2 {
		t.Fatalf("split into 2 groups, got %d", len(split))
	}
	joined := foldCandidateRows(merged)
	if len(joined) != 1 {
		t.Fatalf("merged into 1 group, got %d", len(joined))
	}

	sum := func(groups []Group, f func(Group) decimal.Decimal) decimal.Decimal {
		total := decimal.Zero
		for _, g := range groups {
			total = total.Add(f(g))
		}
		return total
	}
	for _, c := range []struct {
		field string
		get   func(Group) decimal.Decimal
	}{
		{"TotalTagihan", func(g Group) decimal.Decimal { return g.TotalTagihan }},
		{"TotalTerbayar", func(g Group) decimal.Decimal { return g.TotalTerbayar }},
		{"TotalSisa", func(g Group) decimal.Decimal { return g.TotalSisa }},
	} {
		before := sum(split, c.get)
		after := joined[0]
		if !c.get(after).Equal(before) {
			t.Errorf("%s changed under regrouping: split total %s, merged total %s",
				c.field, before, c.get(after))
		}
	}
	assertDecimal(t, "merged TotalTagihan", joined[0].TotalTagihan, decimal.NewFromInt(8_000_000))
	assertDecimal(t, "merged TotalSisa", joined[0].TotalSisa, decimal.NewFromInt(7_000_000))
}

// TestGrouping_MultiDaySheetExposesRangeAndEarliestVehicle pins the read-only
// projection: date_from/date_to widen to the members' span and the vehicle fields
// come from the EARLIEST member, not the last one seen. A multi-day sheet is
// driven by the truck that started the route, so reporting the last member's
// vehicle would silently relabel it.
func TestGrouping_MultiDaySheetExposesRangeAndEarliestVehicle(t *testing.T) {
	late := candidateRow(2)
	late.DoDate = "2026-09-25"
	late.FleetName = "Truck 09"
	late.PlateNumber = "B 9999 XYZ"

	g := oneGroup(t,
		row(t, 1, withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
		withInvoice("unpaid", 5_000_000, 0, 5_000_000)(late),
	)

	if g.DateFrom != "2026-09-20" {
		t.Errorf("DateFrom = %q, want %q", g.DateFrom, "2026-09-20")
	}
	if g.DateTo != "2026-09-25" {
		t.Errorf("DateTo = %q, want %q", g.DateTo, "2026-09-25")
	}
	if g.FleetName != "Truck 01" {
		t.Errorf("FleetName = %q, want the earliest member's Truck 01", g.FleetName)
	}
	if g.PlateNumber != "B 1234 ABC" {
		t.Errorf("PlateNumber = %q, want the earliest member's B 1234 ABC", g.PlateNumber)
	}
	// Same input, reversed order: the fold must not depend on row order.
	reordered := foldCandidateRows([]db.ListLHPPCandidateRowsRow{
		withInvoice("unpaid", 5_000_000, 0, 5_000_000)(late),
		row(t, 1, withInvoice("unpaid", 3_000_000, 0, 3_000_000)),
	})
	if len(reordered) != 1 {
		t.Fatalf("reversed input produced %d groups, want 1", len(reordered))
	}
	if reordered[0].FleetName != "Truck 01" {
		t.Errorf("reversed input FleetName = %q, want the earliest member's Truck 01", reordered[0].FleetName)
	}
	if reordered[0].DateFrom != "2026-09-20" || reordered[0].DateTo != "2026-09-25" {
		t.Errorf("reversed input range = %q..%q, want 2026-09-20..2026-09-25",
			reordered[0].DateFrom, reordered[0].DateTo)
	}
}

// TestGrouping_LHPPNumberIsReadNotRecomputed pins the number-format break: a
// sheet's number is whatever lhpp_groups stored, never derived from the members'
// date and driver. That is what makes a merge non-destructive — the sheet the
// collector is carrying keeps its identity, and so does the payment
// reference_number already stamped on its invoices.
func TestGrouping_LHPPNumberIsReadNotRecomputed(t *testing.T) {
	g := oneGroup(t,
		row(t, 1, inGroup(500, "LHPP-202607-000042"), onDate("2026-07-02")),
		row(t, 2, inGroup(500, "LHPP-202607-000042"), onDate("2026-07-09")),
	)
	if g.LHPPNumber != "LHPP-202607-000042" {
		t.Errorf("LHPPNumber = %q, want the stored LHPP-202607-000042", g.LHPPNumber)
	}
	if g.ID != 500 {
		t.Errorf("ID = %d, want the stored lhpp_groups.id 500 (not a synthetic driver/date key)", g.ID)
	}
	// The old synthetic key would have encoded 20260702*1e9 + 7 here; make sure
	// we did not quietly reintroduce it.
	if g.ID == encodeGroupID(testDriverID, "2026-07-02") {
		t.Errorf("ID = %d looks like the removed synthetic driver+date key", g.ID)
	}
}

// TestGrouping_RowWithNoGroupIsNotSheet: the candidate query inner-joins
// membership, so a group id of 0 must not be folded into a nameless sheet.
func TestGrouping_RowWithNoGroupIsNotSheet(t *testing.T) {
	if groups := foldCandidateRows([]db.ListLHPPCandidateRowsRow{row(t, 1, inGroup(0, ""))}); len(groups) != 0 {
		t.Errorf("expected an ungrouped row to be skipped, got %d group(s)", len(groups))
	}
}

// TestGrouping_GroupStatusIsCarriedThrough pins that the lifecycle state travels
// with the sheet on read, distinct from the billing status.
func TestGrouping_GroupStatusIsCarriedThrough(t *testing.T) {
	rows := foldCandidateRows([]db.ListLHPPCandidateRowsRow{
		withGroupStatus(GroupVoided)(row(t, 1, withInvoice("paid", 1_000_000, 1_000_000, 0))),
	})
	if len(rows) != 1 {
		t.Fatalf("expected 1 group, got %d", len(rows))
	}
	if rows[0].GroupStatus != GroupVoided {
		t.Errorf("GroupStatus = %q, want %q", rows[0].GroupStatus, GroupVoided)
	}
	if rows[0].Status != StatusLunas {
		t.Errorf("billing Status = %q, want %q — the two are orthogonal", rows[0].Status, StatusLunas)
	}
}

func withGroupStatus(status string) rowOpt {
	return func(r db.ListLHPPCandidateRowsRow) db.ListLHPPCandidateRowsRow {
		r.GroupStatus = status
		return r
	}
}

// TestSalesLHPP_IDWithinJavaScriptSafeInteger ensures that synthetic sales group IDs
// do not exceed JavaScript's Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
// Values exceeding 2^53 - 1 are rounded by browser JSON.parse (IEEE 754 float64),
// breaking GET /lhpp/sales/{id} with 404 "LHPP tidak ditemukan".
func TestSalesLHPP_IDWithinJavaScriptSafeInteger(t *testing.T) {
	const maxSafeInteger = int64(9_007_199_254_740_991)

	cases := []struct {
		salesID int64
		date    string
	}{
		{salesID: 1, date: "2026-11-02"},
		{salesID: 3, date: "2026-11-02"},
		{salesID: 999_999, date: "2026-11-02"},
		{salesID: 999_999, date: "2099-12-31"},
	}

	for _, tc := range cases {
		id := encodeGroupID(tc.salesID, tc.date)
		if id > maxSafeInteger {
			t.Errorf("encodeGroupID(%d, %q) = %d, exceeds JS MAX_SAFE_INTEGER %d", tc.salesID, tc.date, id, maxSafeInteger)
		}
		if id <= 0 {
			t.Errorf("encodeGroupID(%d, %q) = %d, expected positive integer", tc.salesID, tc.date, id)
		}
	}
}

func TestFoldSalesCandidateRows_CalculatesTotalTerbayarAndStatus(t *testing.T) {
	rows := []db.ListLHPPSalesCandidateRowsRow{
		{
			VisitID:         1,
			SalesID:         pgtype.Int8{Int64: 5, Valid: true},
			SalesName:       "Sales KH",
			VisitDate:       "2026-11-02",
			Purpose:         "Penagihan piutang",
			CustomerID:      1,
			CustomerName:    "Toko Berkah",
			CustomerAddress: "Jl. Melati No. 5",
			InvoiceID:       pgtype.Int8{Int64: 18, Valid: true},
			InvoiceNumber:   pgtype.Text{String: "INV-202610-000018", Valid: true},
			TotalAmount:     decimal.NewNullDecimal(decimal.NewFromInt(15000)),
			PaidAmount:      decimal.NewNullDecimal(decimal.NewFromInt(15000)),
			RemainingAmount: decimal.NewNullDecimal(decimal.Zero),
			InvoiceStatus:   pgtype.Text{String: "paid", Valid: true},
		},
		{
			VisitID:         2,
			SalesID:         pgtype.Int8{Int64: 5, Valid: true},
			SalesName:       "Sales KH",
			VisitDate:       "2026-11-02",
			Purpose:         "Penagihan piutang",
			CustomerID:      1,
			CustomerName:    "Toko Berkah",
			CustomerAddress: "Jl. Melati No. 5",
			InvoiceID:       pgtype.Int8{Int64: 54, Valid: true},
			InvoiceNumber:   pgtype.Text{String: "INV-202610-000054", Valid: true},
			TotalAmount:     decimal.NewNullDecimal(decimal.NewFromInt(8000)),
			PaidAmount:      decimal.NewNullDecimal(decimal.Zero),
			RemainingAmount: decimal.NewNullDecimal(decimal.NewFromInt(8000)),
			InvoiceStatus:   pgtype.Text{String: "unpaid", Valid: true},
		},
	}

	groups := foldSalesCandidateRows(rows)
	if len(groups) != 1 {
		t.Fatalf("len(groups) = %d, want 1", len(groups))
	}
	g := groups[0]
	if g.LHPPNumber != "LHPP-SLS-20261102-005" {
		t.Errorf("LHPPNumber = %s, want LHPP-SLS-20261102-005", g.LHPPNumber)
	}
	if g.TotalTagihan.String() != "23000" {
		t.Errorf("TotalTagihan = %s, want 23000", g.TotalTagihan)
	}
	if g.TotalTerbayar.String() != "15000" {
		t.Errorf("TotalTerbayar = %s, want 15000", g.TotalTerbayar)
	}
	if g.TotalSisa.String() != "8000" {
		t.Errorf("TotalSisa = %s, want 8000", g.TotalSisa)
	}
	if g.Status != "sebagian" {
		t.Errorf("Status = %s, want sebagian", g.Status)
	}
	if len(g.Items) != 2 {
		t.Fatalf("len(Items) = %d, want 2", len(g.Items))
	}
}
