From cadc27ea063821938532eb861f37422702f565a3 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:18:01 -0700 Subject: [PATCH 01/34] AR: red leg, a payment or an application dated after the business day is refused Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/ar_day_test.go | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 core/internal/account/ar_day_test.go diff --git a/core/internal/account/ar_day_test.go b/core/internal/account/ar_day_test.go new file mode 100644 index 000000000..138f4ac07 --- /dev/null +++ b/core/internal/account/ar_day_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +// The business day of the AR core's money acts (G1 walk note N12): money is +// never dated after the branch's business day. + +import ( + "context" + "testing" + "time" + + "github.com/gablelbm/gable/internal/account" +) + +// businessDay is the branch's business day at the service clock. +func (w *arWorld) businessDay() time.Time { + w.t.Helper() + d, err := w.svc.LocalDate(context.Background(), w.branchID, w.svc.Now()) + if err != nil { + w.t.Fatal(err) + } + return d +} + +func TestARRefusesAPaymentReceivedOnADayToCome(t *testing.T) { + w := newARWorld(t) + tomorrow := w.businessDay().AddDate(0, 0, 1) + _, err := w.act("record", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RecordPayment(ctx, account.RecordPaymentIn{CustomerID: w.customerID, BranchID: w.branchID, + Currency: "USD", Method: "CHECK", AmountCents: 5000, ReceivedOn: tomorrow, Actor: "u-test"}) + return fx, e + }) + if err == nil { + t.Fatal("a payment received tomorrow was recorded") + } + if n := w.queryInt(`SELECT COUNT(*) FROM payments WHERE customer_id = $1`, w.customerID); n != 0 { + t.Errorf("%d payments were written by a refused act", n) + } +} + +func TestARRefusesAnApplicationOnADayToCome(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + inv := w.invoice(10000, today.AddDate(0, 0, -5)) + pay := w.payment(10000, today.AddDate(0, 0, -1), "CHECK") + tomorrow := today.AddDate(0, 0, 1) + _, err := w.act("apply", func(ctx context.Context) (*account.Effects, error) { + return w.svc.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: inv.ID, AmountCents: 10000}}, + On: tomorrow, Actor: "u-test"}) + }) + if err == nil { + t.Fatal("an application dated tomorrow was applied") + } + if status, _ := w.invoiceRow(inv.ID); status != "UNPAID" { + t.Errorf("invoice is %s after a refused application", status) + } +} From 81ae3849ce1d491fbfe24a473935c10c45e0ac6c Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:18:01 -0700 Subject: [PATCH 02/34] AR: red leg, a statement dates each row from its document Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../internal/account/ar_statement_day_test.go | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 core/internal/account/ar_statement_day_test.go diff --git a/core/internal/account/ar_statement_day_test.go b/core/internal/account/ar_statement_day_test.go new file mode 100644 index 000000000..98cd049f7 --- /dev/null +++ b/core/internal/account/ar_statement_day_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +// A statement dates each row from its document, not from the moment the +// subledger row was written (G1 walk note N13). + +import ( + "context" + "testing" + + "github.com/gablelbm/gable/internal/account" +) + +func TestARStatementDatesEachRowFromItsDocument(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + invDay, payDay := today.AddDate(0, 0, -40), today.AddDate(0, 0, -10) + inv := w.invoice(12000, invDay) + w.payment(12000, payDay, "CHECK", account.ApplyLine{InvoiceID: inv.ID, AmountCents: 12000}) + ctx := context.Background() + + // A period that ends before the invoice's day leaves it out, though its + // subledger rows were stamped inside the period. + w.exec(`UPDATE customer_transactions SET created_at = $2::date + TIME '12:00' WHERE customer_id = $1`, w.customerID, date(today.AddDate(0, 0, -55))) + st, err := w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -60), To: today.AddDate(0, 0, -50)}) + if err != nil { + t.Fatal(err) + } + for _, c := range st.Currencies { + if len(c.Lines) != 0 || c.OpeningBalanceCents != 0 || c.ClosingBalanceCents != 0 { + t.Errorf("a period before the invoice lists %d rows, opening %d, closing %d", len(c.Lines), c.OpeningBalanceCents, c.ClosingBalanceCents) + } + } + + // A period over the whole history: each row carries its document's day. + st, err = w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -60), To: today}) + if err != nil { + t.Fatal(err) + } + if len(st.Currencies) != 1 || len(st.Currencies[0].Lines) != 2 { + t.Fatalf("statement = %+v, want one currency of two rows", st.Currencies) + } + lines := st.Currencies[0].Lines + if lines[0].Date != date(invDay) || lines[1].Date != date(payDay) { + t.Errorf("row days %s, %s; want the invoice's %s and the payment's %s", lines[0].Date, lines[1].Date, date(invDay), date(payDay)) + } + + // A period that starts between the two: the invoice is the opening balance. + st, err = w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -20), To: today}) + if err != nil { + t.Fatal(err) + } + c := st.Currencies[0] + if c.OpeningBalanceCents != 12000 || len(c.Lines) != 1 || c.ClosingBalanceCents != 0 { + t.Errorf("opening %d, %d rows, closing %d; want 12000, 1, 0", c.OpeningBalanceCents, len(c.Lines), c.ClosingBalanceCents) + } +} From 42d9b1fc7f28906e7f708608abb14470ee012b7c Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:28:30 -0700 Subject: [PATCH 03/34] Seed: red leg, no money after the seed's day and the aging agrees with the documents Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata_test.go | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 84f1f27ed..87c71bf59 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -521,6 +521,84 @@ func TestDemoImport(t *testing.T) { } }) + t.Run("no money is dated after the seed's day and the aging agrees with the documents", func(t *testing.T) { + var today string + if err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date::text`).Scan(&today); err != nil { + t.Fatal(err) + } + var later, laterApps int + if err := db.QueryRow(`SELECT COUNT(*) FROM payments WHERE received_on > $1::date`, today).Scan(&later); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM ar_applications WHERE applied_on > $1::date`, today).Scan(&laterApps); err != nil { + t.Fatal(err) + } + if later != 0 || laterApps != 0 { + t.Errorf("%d payments are received and %d applications applied after the seed's day %s", later, laterApps, today) + } + + // Each customer's aging total as of the seed's day is its open + // invoices less its open credits and unapplied cash, read from the + // documents themselves. + want := map[string]int64{} + rows, err := db.Query(` + SELECT customer_id::text, SUM(cents)::bigint FROM ( + SELECT customer_id, ROUND(amount_open * 100) AS cents FROM invoices WHERE status <> 'VOID' + UNION ALL + SELECT customer_id, ROUND(amount_open * 100) FROM credit_memos WHERE status NOT IN ('DRAFT', 'VOID') + UNION ALL + SELECT customer_id, -ROUND(amount_unapplied * 100) FROM payments WHERE status = 'POSTED' + ) d GROUP BY customer_id`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var id string + var cents int64 + if err := rows.Scan(&id, ¢s); err != nil { + t.Fatal(err) + } + want[id] = cents + } + rows.Close() + var aging struct { + Items []account.AgingItem `json:"items"` + } + mux := accountMux(t, dbURL) + got := map[string]int64{} + cursor := "" + for { + var page struct { + Items []account.AgingItem `json:"items"` + NextCursor *string `json:"next_cursor"` + } + next := "/api/v1/ar/aging?group_by=customer&basis=due_date&as_of=" + today + if cursor != "" { + next += "&cursor=" + url.QueryEscape(cursor) + } + getJSON(t, mux, next, &page) + aging.Items = append(aging.Items, page.Items...) + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor + } + for _, it := range aging.Items { + got[it.CustomerID.String()] += int64(it.TotalCents) + } + for id, w := range want { + if got[id] != w { + t.Errorf("customer %s: the aging totals %d, its documents say %d", id, got[id], w) + } + } + for id, g := range got { + if _, ok := want[id]; !ok && g != 0 { + t.Errorf("customer %s: the aging totals %d, it has no document", id, g) + } + } + }) + t.Run("the tenant's currency is on every imported customer and history document", func(t *testing.T) { keys := jsonlKeys(t, customersData, "account_number") var bad int From ae95aa71e4b33dd194f7ce6295d6037dc1ee9359 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:28:30 -0700 Subject: [PATCH 04/34] Dashboard: red leg, Outstanding AR equals the aging and the documents on the seeded database Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata_test.go | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 87c71bf59..49a3840a0 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/dashboard" "github.com/gablelbm/gable/internal/testutil" ) @@ -599,6 +600,53 @@ func TestDemoImport(t *testing.T) { } }) + t.Run("the dashboard's Outstanding AR equals the aging summary's total", func(t *testing.T) { + ar, err := newReceivables(dbURL) + if err != nil { + t.Fatal(err) + } + defer ar.close() + ctx := context.Background() + var today time.Time + if err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date`).Scan(&today); err != nil { + t.Fatal(err) + } + aging, err := ar.acct.AgingSummary(ctx, account.AgingQuery{AsOf: today, Basis: "due_date"}) + if err != nil { + t.Fatal(err) + } + var total int64 + for _, c := range aging.Totals { + total += int64(c.TotalCents) + } + // The seed's invoices and the aging both read the document day; the + // dashboard must read the same receivable the aging does. + summary, err := dashboard.NewRepository(ar.db).GetDashboardSummary(ctx, nil, nil) + if err != nil { + t.Fatal(err) + } + if summary.OutstandingAR != total { + t.Errorf("Outstanding AR %d, aging summary total %d", summary.OutstandingAR, total) + } + // And both equal the documents: open invoices less open credits and + // unapplied cash. + var docs int64 + if err := db.QueryRow(` + SELECT COALESCE(SUM(cents), 0)::bigint FROM ( + SELECT ROUND(amount_open * 100) AS cents FROM invoices WHERE status <> 'VOID' + UNION ALL + SELECT ROUND(amount_open * 100) FROM credit_memos WHERE status NOT IN ('DRAFT', 'VOID') + UNION ALL + SELECT -ROUND(amount_unapplied * 100) FROM payments WHERE status = 'POSTED' + ) d`).Scan(&docs); err != nil { + t.Fatal(err) + } + if docs <= 0 || summary.OutstandingAR != docs { + t.Errorf("Outstanding AR %d, the documents say %d", summary.OutstandingAR, docs) + } + }) + t.Run("the tenant's currency is on every imported customer and history document", func(t *testing.T) { keys := jsonlKeys(t, customersData, "account_number") var bad int From ffba2608f5c41e0be85faa1da5567b39733c5976 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:40:32 -0700 Subject: [PATCH 05/34] AR: red leg, the aging by customer pages through its cursor Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/aging_page_test.go | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 core/internal/account/aging_page_test.go diff --git a/core/internal/account/aging_page_test.go b/core/internal/account/aging_page_test.go new file mode 100644 index 000000000..e143a0fd7 --- /dev/null +++ b/core/internal/account/aging_page_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gablelbm/gable/internal/account" + "github.com/google/uuid" +) + +// The aging by customer pages through its cursor: a page that is not the last +// answers a cursor the next page reads (the group id of a customer row is +// empty, which a cursor keyset part may not be). +func TestARAgingByCustomerPagesThroughItsCursor(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + // A second customer owing, so a page of one has a next page. + other := uuid.New() + w.exec(`INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'AR Core Second Co', $2, `+arBranch+`)`, + other, "ARC-"+uuid.NewString()[:8]) + w.invoice(10000, today.AddDate(0, 0, -3)) + w.invoiceOn(other, 20000, today.AddDate(0, 0, -3)) + + mux := http.NewServeMux() + account.NewHandler(w.svc).RegisterRoutes(mux, nil, nil) + seen := map[string]bool{} + cursor := "" + for pages := 0; pages < 100; pages++ { + path := "/api/v1/ar/aging?group_by=customer&basis=due_date&limit=1" + if cursor != "" { + path += "&cursor=" + url.QueryEscape(cursor) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body.String()) + } + var page struct { + Items []account.AgingItem `json:"items"` + NextCursor *string `json:"next_cursor"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + for _, it := range page.Items { + seen[it.CustomerID.String()] = true + } + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor + } + if !seen[w.customerID.String()] || !seen[other.String()] { + t.Errorf("the pages hold %d customers, want both of the world's", len(seen)) + } +} From a4976051dbaff68993f41b720e5827209a2251c3 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:40:42 -0700 Subject: [PATCH 06/34] AR core: money is never dated after the branch's business day A payment received, an application, a credit memo application, a write off and both refunds on a day after the business day in the branch's time zone are refused in words. RestampDay lets the base seed put documents' days on its rows for invoices that are part paid or unpaid. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/ar_test.go | 8 ++-- core/internal/account/core.go | 60 ++++++++++++++++++++++++++ core/internal/account/core_payments.go | 18 ++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/core/internal/account/ar_test.go b/core/internal/account/ar_test.go index 0085996ba..1986911cf 100644 --- a/core/internal/account/ar_test.go +++ b/core/internal/account/ar_test.go @@ -836,10 +836,12 @@ func TestARDiscountTakenAndReversed(t *testing.T) { inv := w.invoice(10000, daysFromNow(-1), withDiscount(twoPct, daysFromNow(5))) pay := w.payment(10000, daysFromNow(0), "CHECK") - // Past the due date: refused. + // Past the due date: refused (an invoice whose discount date has gone, + // applied today; money is never dated after the business day). + lateInv := w.invoice(10000, daysFromNow(-3), withDiscount(twoPct, daysFromNow(-1))) _, err := w.act("discount late", func(ctx context.Context) (*account.Effects, error) { - return w.svc.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: inv.ID, AmountCents: 5000, DiscountCents: 100}}, - On: daysFromNow(6), Actor: "u-test"}) + return w.svc.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: lateInv.ID, AmountCents: 5000, DiscountCents: 100}}, + On: daysFromNow(0), Actor: "u-test"}) }) if err == nil || !strings.Contains(err.Error(), "discount") { t.Errorf("late discount = %v, want the discount refusal", err) diff --git a/core/internal/account/core.go b/core/internal/account/core.go index c452b2ec7..06509fdb3 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -67,6 +67,23 @@ func invalidField(field, message string) *httpx.Error { Details: []httpx.FieldError{{Field: field, Message: message}}} } +// refuseDayToCome refuses a money day after the business day of the branch +// the act belongs to, in the branch's time zone (the aging's own rule for today): +// cash is never received, applied or refunded on a day that has not come. +func (s *Service) refuseDayToCome(ctx context.Context, branchID uuid.UUID, on time.Time, field string) error { + if on.IsZero() { + return nil + } + today, err := s.LocalDate(ctx, branchID, s.now()) + if err != nil { + return err + } + if date(on) > date(today) { + return invalidField(field, fmt.Sprintf("%s has not come yet: money is dated no later than today, %s", date(on), date(today))) + } + return nil +} + // ErrNoTransaction is the refusal to run an act with no transaction open. var ErrNoTransaction = gl.ErrNoTransaction @@ -483,6 +500,49 @@ func (s *Service) RestampHistory(ctx context.Context, invoiceID, paymentID uuid. if !settled { return conflict("not_settled", "only a paid invoice and a payment applied to it take a history day") } + return s.restampRows(ctx, invoiceID, paymentID, invoicedAt, paidAt) +} + +// RestampDay is RestampHistory for the base seed, whose invoices are unpaid or +// part paid as often as paid: it puts the documents' days on the rows an +// invoice's posting and, when a payment is named, that payment's act stamped +// with the present. The same writes and the same limits (a transaction, a +// payment day not before the invoice's or after the present, a payment with a +// live application to the invoice), without the settled invoice guard. +// uuid.Nil names no payment. +func (s *Service) RestampDay(ctx context.Context, invoiceID, paymentID uuid.UUID, invoicedAt, paidAt time.Time) error { + if err := s.requireTx(ctx); err != nil { + return err + } + if invoicedAt.IsZero() { + return invalidField("invoiced_at", "a day is given for the invoice") + } + if paymentID == uuid.Nil { + paidAt = invoicedAt + } + if paidAt.Before(invoicedAt) { + return invalidField("paid_at", "a payment is not made before its invoice") + } + if paidAt.After(s.now()) || invoicedAt.After(s.now()) { + return invalidField("paid_at", "a day is not in the future") + } + if paymentID != uuid.Nil { + var applied bool + if err := s.ex(ctx).QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM ar_applications WHERE invoice_id = $1 AND payment_id = $2 AND reversed_at IS NULL)`, + invoiceID, paymentID).Scan(&applied); err != nil { + return fmt.Errorf("the invoice's payment was not read: %w", err) + } + if !applied { + return conflict("not_settled", "only a payment applied to the invoice takes its day") + } + } + return s.restampRows(ctx, invoiceID, paymentID, invoicedAt, paidAt) +} + +// restampRows writes the stamps and the payment's link in the caller's +// transaction. +func (s *Service) restampRows(ctx context.Context, invoiceID, paymentID uuid.UUID, invoicedAt, paidAt time.Time) error { // One statement: each update is a CTE over the rows the acts just wrote, so // a history invoice costs one more round trip, not seven. Each is scoped to // what the payment settled, so the guard above and the writes agree. diff --git a/core/internal/account/core_payments.go b/core/internal/account/core_payments.go index 8a14a6fd5..4937546b5 100644 --- a/core/internal/account/core_payments.go +++ b/core/internal/account/core_payments.go @@ -55,6 +55,9 @@ func (s *Service) RecordPayment(ctx context.Context, in RecordPaymentIn) (uuid.U if in.AmountCents <= 0 { return uuid.Nil, nil, invalidField("amount_cents", "must be greater than zero") } + if err := s.refuseDayToCome(ctx, in.BranchID, in.ReceivedOn, "received_on"); err != nil { + return uuid.Nil, nil, err + } id := in.PaymentID if id == uuid.Nil { id = uuid.New() @@ -171,6 +174,9 @@ func (s *Service) applyLocked(ctx context.Context, fx *Effects, pay *paymentRow, if len(lines) == 0 { return invalidField(prefix, "name at least one invoice") } + if err := s.refuseDayToCome(ctx, pay.BranchID, on, "applied_on"); err != nil { + return err + } // the counter's hold first (C2-5, FROM-PRINCIPAL 60): a payment whose // unapplied cash is a declined void tender's credit applies to no // invoice, whatever invoice the request names; the refusal names the @@ -337,6 +343,9 @@ func (s *Service) ApplyCreditMemo(ctx context.Context, in ApplyCreditMemoIn) (*E if m.Status != "OPEN" && m.Status != "PARTIAL" { return nil, httpx.InvalidStateTransition("only an open credit memo can be applied: it is " + lower(m.Status)) } + if err := s.refuseDayToCome(ctx, m.BranchID, in.On, "applied_on"); err != nil { + return nil, err + } if len(in.Lines) == 0 { return nil, invalidField("applications", "name at least one invoice") } @@ -438,6 +447,9 @@ func (s *Service) WriteOff(ctx context.Context, in WriteOffIn) (*Effects, error) if inv.Status != invUnpaid && inv.Status != invPartial { return nil, httpx.InvalidStateTransition("only an unpaid or partly paid invoice can be written off: it is " + lower(inv.Status)) } + if err := s.refuseDayToCome(ctx, inv.BranchID, in.On, "on"); err != nil { + return nil, err + } // the counter's hold first (C2-5): a held interim invoice refuses before // its amounts are even read if err := s.refuseHeldInvoice(ctx, inv.ID); err != nil { @@ -811,6 +823,9 @@ func (s *Service) RefundPayment(ctx context.Context, in RefundPaymentIn) (uuid.U if pay.Status != "POSTED" { return uuid.Nil, nil, conflict("payment_voided", "the payment is voided: it refunds nothing") } + if err := s.refuseDayToCome(ctx, pay.BranchID, in.On, "refunded_on"); err != nil { + return uuid.Nil, nil, err + } if in.AmountCents > pay.Unapplied { return uuid.Nil, nil, conflict("exceeds_unapplied", fmt.Sprintf("the payment has %d cents unapplied: %d cents were asked", pay.Unapplied, in.AmountCents)) } @@ -865,6 +880,9 @@ func (s *Service) RefundCreditMemo(ctx context.Context, in RefundCreditMemoIn) ( if m.Status != "OPEN" && m.Status != "PARTIAL" { return uuid.Nil, nil, httpx.InvalidStateTransition("only an open credit memo can be refunded: it is " + lower(m.Status)) } + if err := s.refuseDayToCome(ctx, m.BranchID, in.On, "refunded_on"); err != nil { + return uuid.Nil, nil, err + } if in.AmountCents > -m.Open { return uuid.Nil, nil, conflict("exceeds_open_credit", fmt.Sprintf("the credit memo has %d cents of open credit: %d cents were asked", -m.Open, in.AmountCents)) } From 65681fbfaa6da254bf49d495cd904008af16083e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:40:42 -0700 Subject: [PATCH 07/34] AR: the statement dates each row from its document Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/statement.go | 42 ++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/core/internal/account/statement.go b/core/internal/account/statement.go index a009dcd89..6cd74fa40 100644 --- a/core/internal/account/statement.go +++ b/core/internal/account/statement.go @@ -69,21 +69,35 @@ type StatementQuery struct { JobID *uuid.UUID } +// statementRowsSQL reads the subledger with each row's own day: the invoice's +// invoice day (its void's voided day), the credit memo's memo day (its void's +// voided day), an application's applied day (its reversal's reversed day), a +// refund's refunded day; the row's stamp only for a row with no document. The +// opening balance, the period and the order all follow that day. const statementRowsSQL = ` -SELECT t.id, (t.created_at AT TIME ZONE tz.name)::date, t.type, COALESCE(t.description, ''), t.amount, t.balance_after, t.currency, - t.source_kind, t.reference_id, - COALESCE(inv.project_id, cm.project_id, ai.project_id, rcm.project_id, legacy.project_id) AS job -FROM customer_transactions t -CROSS JOIN (SELECT COALESCE((SELECT l.timezone FROM locations l JOIN customers c ON c.primary_branch_id = l.id WHERE c.id = $1), 'UTC') AS name) tz -LEFT JOIN invoices inv ON t.source_kind = 'invoice' AND inv.id = t.reference_id -LEFT JOIN credit_memos cm ON t.source_kind = 'credit_memo' AND cm.id = t.reference_id -LEFT JOIN ar_applications ap ON t.source_kind = 'application' AND ap.id = t.reference_id -LEFT JOIN invoices ai ON ai.id = ap.invoice_id -LEFT JOIN payment_refunds rf ON t.source_kind = 'refund' AND rf.id = t.reference_id -LEFT JOIN credit_memos rcm ON rcm.id = rf.credit_memo_id -LEFT JOIN invoices legacy ON t.source_kind IS NULL AND legacy.id = t.reference_id -WHERE t.customer_id = $1 -ORDER BY t.currency, t.created_at, t.id` +SELECT x.id, x.day, x.type, x.description, x.amount, x.balance_after, x.currency, x.source_kind, x.reference_id, x.job +FROM ( + SELECT t.id, t.type, COALESCE(t.description, '') AS description, t.amount, t.balance_after, t.currency, + t.source_kind, t.reference_id, t.created_at, + COALESCE( + CASE WHEN t.source_kind = 'invoice' THEN CASE WHEN t.type = 'REVERSAL' THEN inv.voided_on ELSE inv.invoice_date END END, + CASE WHEN t.source_kind = 'credit_memo' THEN CASE WHEN t.type = 'REVERSAL' THEN cm.voided_on ELSE cm.memo_date END END, + CASE WHEN t.source_kind = 'application' THEN CASE WHEN t.type = 'REVERSAL' THEN ap.reversed_on ELSE ap.applied_on END END, + CASE WHEN t.source_kind = 'refund' THEN rf.refunded_on END, + (t.created_at AT TIME ZONE tz.name)::date) AS day, + COALESCE(inv.project_id, cm.project_id, ai.project_id, rcm.project_id, legacy.project_id) AS job + FROM customer_transactions t + CROSS JOIN (SELECT COALESCE((SELECT l.timezone FROM locations l JOIN customers c ON c.primary_branch_id = l.id WHERE c.id = $1), 'UTC') AS name) tz + LEFT JOIN invoices inv ON t.source_kind = 'invoice' AND inv.id = t.reference_id + LEFT JOIN credit_memos cm ON t.source_kind = 'credit_memo' AND cm.id = t.reference_id + LEFT JOIN ar_applications ap ON t.source_kind = 'application' AND ap.id = t.reference_id + LEFT JOIN invoices ai ON ai.id = ap.invoice_id + LEFT JOIN payment_refunds rf ON t.source_kind = 'refund' AND rf.id = t.reference_id + LEFT JOIN credit_memos rcm ON rcm.id = rf.credit_memo_id + LEFT JOIN invoices legacy ON t.source_kind IS NULL AND legacy.id = t.reference_id + WHERE t.customer_id = $1 +) x +ORDER BY x.currency, x.day, x.created_at, x.id` // Statement answers the customer's statement: per currency the opening balance // (the rows before From), every row in range (filtered to the job through the From 182f0e2a575bcc7278bd5be04ad0201cda98613b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:40:42 -0700 Subject: [PATCH 08/34] AR: the aging's cursor carries a customer row's empty group id Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/handler.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/internal/account/handler.go b/core/internal/account/handler.go index 8d5cc3e5e..2f50dfdaa 100644 --- a/core/internal/account/handler.go +++ b/core/internal/account/handler.go @@ -286,7 +286,9 @@ func (h *Handler) HandleAging(w http.ResponseWriter, r *http.Request) { next := "" if more { last := items[len(items)-1] - if next, err = httpx.MintCursor(agingCursorScope, last.CustomerName, last.CustomerID.String(), last.GroupID(), last.Currency); err != nil { + // The group id is empty for a customer row; a keyset part never is, so + // the part carries a marker before it. + if next, err = httpx.MintCursor(agingCursorScope, last.CustomerName, last.CustomerID.String(), agingGroupPart+last.GroupID(), last.Currency); err != nil { httpx.WriteError(w, r, err) return } @@ -298,6 +300,9 @@ func (h *Handler) HandleAging(w http.ResponseWriter, r *http.Request) { httpx.WriteList(w, items, next, page.Limit, opts...) } +// agingGroupPart marks the group id in the cursor's keyset. +const agingGroupPart = "g:" + // agingAfter reports whether the item sorts after the cursor's key // (customer name, customer id, group id, currency). func agingAfter(it *AgingItem, key []string) bool { @@ -307,7 +312,7 @@ func agingAfter(it *AgingItem, key []string) bool { if id := it.CustomerID.String(); id != key[1] { return id > key[1] } - if g := it.GroupID(); g != key[2] { + if g := agingGroupPart + it.GroupID(); g != key[2] { return g > key[2] } return it.Currency > key[3] From 739dafa91e0cab6cc399927b8a409e2be723fd55 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:40:42 -0700 Subject: [PATCH 09/34] Seed: no money after the seed's day, and the seed's rows carry their documents' days A check the rule would date later is received on the seed's own day; an invoice dated after that day is left unpaid and never dated past it. The base seed restamps its subledger rows to the documents' days. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/receivables.go | 62 +++++++++++++++++++++++++-- core/internal/app/seed/seed.go | 10 ++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/core/internal/app/seed/receivables.go b/core/internal/app/seed/receivables.go index 151b39b28..cc0d8169e 100644 --- a/core/internal/app/seed/receivables.go +++ b/core/internal/app/seed/receivables.go @@ -5,6 +5,7 @@ package seed import ( "context" + "database/sql" "fmt" "log" "log/slog" @@ -77,8 +78,18 @@ func (r *receivables) post(ctx context.Context, invoices []seedInvoice) int { }}); err != nil { return err } - if in.Paid <= 0 { - return nil + today, err := r.acct.LocalDate(ctx, in.BranchID, r.acct.Now()) + if err != nil { + return err + } + // An invoice dated after the seed's day is left unpaid: no check + // is received before the invoice it pays or after the day the + // seed runs. + if in.Paid <= 0 || dayOf(in.Date) > dayOf(today) { + if !in.Stamp.IsZero() { + return nil + } + return r.stampInvoice(ctx, in, uuid.Nil, time.Time{}) } var currency string if err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT currency FROM invoices WHERE id = $1`, in.ID).Scan(¤cy); err != nil { @@ -95,6 +106,11 @@ func (r *receivables) post(ctx context.Context, invoices []seedInvoice) int { if receivedOn.IsZero() { receivedOn = in.Date.AddDate(0, 0, 7) } + // The seed never dates money after the day it runs: a check the + // rule would date later is received on the seed's own day. + if dayOf(receivedOn) > dayOf(today) { + receivedOn = today + } var orderID *uuid.UUID if in.OrderID != uuid.Nil { orderID = &in.OrderID @@ -107,7 +123,7 @@ func (r *receivables) post(ctx context.Context, invoices []seedInvoice) int { return err } if in.Stamp.IsZero() { - return nil + return r.stampInvoice(ctx, in, payID, receivedOn) } return r.stampDay(ctx, in, payID) }) @@ -161,6 +177,46 @@ func (r *receivables) stampDay(ctx context.Context, in seedInvoice, payID uuid.U return r.acct.RestampHistory(ctx, in.ID, payID, in.Stamp, in.Stamp.Add(time.Second)) } +// dayOf is the calendar day of a moment, the form days compare in. +func dayOf(t time.Time) string { return t.Format("2006-01-02") } + +// seedToday is the seed's own business day: the default branch's calendar day +// now, in its time zone (the day the AR core refuses money past). +func seedToday(db *sql.DB) time.Time { + var d time.Time + err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date`).Scan(&d) + if err != nil { + return time.Now() + } + return d +} + +// stampInvoice puts the documents' own days on the rows the base seed's acts +// wrote with the present: the invoice's rows take the invoice's day, the +// payment's the day it was received, so a seeded ledger reads like a real one. +// The stamps sit at midday and never after the present. +func (r *receivables) stampInvoice(ctx context.Context, in seedInvoice, payID uuid.UUID, receivedOn time.Time) error { + now := r.acct.Now() + at := func(d time.Time) time.Time { + t := time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.UTC) + if t.After(now) { + t = now + } + return t + } + invoicedAt, paidAt := at(in.Date), invoicedAtOr(at(receivedOn), at(in.Date)) + return r.acct.RestampDay(ctx, in.ID, payID, invoicedAt, paidAt) +} + +// invoicedAtOr answers the payment's moment, never before the invoice's. +func invoicedAtOr(paid, invoiced time.Time) time.Time { + if paid.Before(invoiced) { + return invoiced + } + return paid +} + // postMemo posts a draft credit memo the seed inserted (one ADJUST line, no // tax): the entry reverses revenue, the receivable falls. func (r *receivables) postMemo(ctx context.Context, memoID, customerID uuid.UUID, currency string, amountCents int64, on time.Time) error { diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index aa55d8178..a46f40eeb 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -814,6 +814,7 @@ func Run() { if branchID == uuid.Nil { branchID = kelMainID } + seedDay := seedToday(db) for i := 0; i < numOrders; i++ { status := "FULFILLED" r := rand.Float32() @@ -874,6 +875,11 @@ func Run() { paidShare = 0.4 } dueDate := orderDate.AddDate(0, 1, 0) + // the day after the order, never after the day the seed runs + invoiceDay := orderDate.AddDate(0, 0, 1) + if dayOf(invoiceDay) > dayOf(seedDay) { + invoiceDay = seedDay + } taxRate := 0.12 // BC: GST 5% + PST 7% on building materials. subCents := cents(orderTotal) taxCents := cents(float64(subCents) / 100 * taxRate) @@ -884,7 +890,7 @@ func Run() { _, err = db.Exec(`INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_rate, tax_amount, due_date, payment_terms_id, invoice_date, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::date,(SELECT payment_terms_id FROM customers WHERE id = $3),$11::date,$11)`, - invID, orderID, custID, branchID, invStatus, total, subtotal, taxRate, taxAmt, dueDate, orderDate.AddDate(0, 0, 1)) + invID, orderID, custID, branchID, invStatus, total, subtotal, taxRate, taxAmt, dueDate, invoiceDay) if err == nil { invoiceIDs = append(invoiceIDs, invID) // the billed lines: the order's, in the shared line shape @@ -893,7 +899,7 @@ func Run() { SELECT $1, ol.id, ol.product_id, ol.line_type, ol.position, ol.description, ol.sku, ol.quantity, ol.uom, ol.price_uom, ol.uom_qty, ol.price_uom_qty, ol.unit_price, ol.unit_price, ol.price_source, ol.line_total, ol.taxable FROM order_lines ol WHERE ol.order_id = $2 AND ol.line_type = 'PRODUCT'`, invID, orderID) - seeded = append(seeded, seedInvoice{ID: invID, CustomerID: custID, BranchID: branchID, Date: orderDate.AddDate(0, 0, 1), + seeded = append(seeded, seedInvoice{ID: invID, CustomerID: custID, BranchID: branchID, Date: invoiceDay, Subtotal: subCents, Tax: taxCents, Paid: int64(math.Round(float64(subCents+taxCents) * paidShare))}) } } From 51d4dc7be213eb5f45c3107ee0736615b5fd4fa0 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:51:10 -0700 Subject: [PATCH 10/34] Goldens and contract changes: the seed's money carries its documents' days Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../testdata/goldens/clockwindow.json | 26 +- .../testdata/goldens/gl_accounts_entries.json | 616 +++++++++--------- .../testdata/goldens/reporting_reports.json | 6 +- docs/refactor/CONTRACT-CHANGES.md | 1 + 4 files changed, 325 insertions(+), 324 deletions(-) diff --git a/core/internal/characterization/testdata/goldens/clockwindow.json b/core/internal/characterization/testdata/goldens/clockwindow.json index 434984cb1..da546eb91 100644 --- a/core/internal/characterization/testdata/goldens/clockwindow.json +++ b/core/internal/characterization/testdata/goldens/clockwindow.json @@ -148,7 +148,7 @@ "invoice_count": 12, "outstanding_ar": 42140.76, "start_date": "", - "total_collected": 315919.09, + "total_collected": 77355.25, "total_invoiced": 95207.14 } } @@ -164,11 +164,11 @@ "content_type": "application/json", "body": { "active_orders": 21, - "outstanding_ar": 11056347, + "outstanding_ar": 10860244, "outstanding_ar_count": 16, "pending_dispatch": 28, - "today_revenue": 31349485, - "today_revenue_change": 310260.21186021186 + "today_revenue": 203603, + "today_revenue_change": -55.15411690594397 } } }, @@ -188,15 +188,15 @@ }, { "date": "", - "revenue": 20202 + "revenue": 1259336 }, { "date": "", - "revenue": 10101 + "revenue": 454006 }, { "date": "", - "revenue": 31349485 + "revenue": 203603 } ] } @@ -355,14 +355,14 @@ "account_type": "ASSET", "credit": 5000, "currency": "USD", - "debit": 31163382 + "debit": 31359485 }, { "account_code": "1020", "account_id": "", "account_name": "Accounts Receivable", "account_type": "ASSET", - "credit": 31190099, + "credit": 31386202, "currency": "USD", "debit": 40371158 }, @@ -389,9 +389,9 @@ "account_id": "", "account_name": "Customer Deposits", "account_type": "LIABILITY", - "credit": 31156524, + "credit": 31352627, "currency": "USD", - "debit": 31154024 + "debit": 31350127 }, { "account_code": "4010", @@ -468,13 +468,13 @@ "account_code": "1010", "account_id": "", "account_name": "Cash", - "amount": 31158382 + "amount": 31354485 }, { "account_code": "1020", "account_id": "", "account_name": "Accounts Receivable", - "amount": 9181059 + "amount": 8984956 }, { "account_code": "1030", diff --git a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json index bed64b316..c500a3706 100644 --- a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json +++ b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json @@ -721,34 +721,6 @@ "status": 200, "content_type": "application/json", "body": [ - { - "created_at": "", - "entry_date": "", - "entry_number": 41, - "id": "", - "memo": "Payment PAY-000013 applied to IN-000015", - "posted_by": "seed", - "source": "PAYMENT", - "source_ref_id": "", - "status": "POSTED", - "total_credit": 196103, - "total_debit": 196103, - "updated_at": "" - }, - { - "created_at": "", - "entry_date": "", - "entry_number": 40, - "id": "", - "memo": "Payment PAY-000013", - "posted_by": "seed", - "source": "PAYMENT", - "source_ref_id": "", - "status": "POSTED", - "total_credit": 196103, - "total_debit": 196103, - "updated_at": "" - }, { "created_at": "", "entry_date": "", @@ -793,11 +765,11 @@ "created_at": "", "entry_date": "", "entry_number": 144, - "id": "", - "memo": "Vendor payment ", + "id": "", + "memo": "Vendor payment ", "posted_by": "system", "source": "VENDOR_PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 6000, "total_debit": 6000, @@ -807,11 +779,11 @@ "created_at": "", "entry_date": "", "entry_number": 143, - "id": "", - "memo": "Vendor payment ", + "id": "", + "memo": "Vendor payment ", "posted_by": "system", "source": "VENDOR_PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 4000, "total_debit": 4000, @@ -821,11 +793,11 @@ "created_at": "", "entry_date": "", "entry_number": 142, - "id": "", + "id": "", "memo": "Vendor invoice AP-000006", "posted_by": "system", "source": "VENDOR_INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 10000, "total_debit": 10000, @@ -835,11 +807,11 @@ "created_at": "", "entry_date": "", "entry_number": 141, - "id": "", + "id": "", "memo": "Void of payment PAY-000047", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 10000, "total_debit": 10000, @@ -849,11 +821,11 @@ "created_at": "", "entry_date": "", "entry_number": 140, - "id": "", + "id": "", "memo": "Payment PAY-000047", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 10000, "total_debit": 10000, @@ -863,11 +835,11 @@ "created_at": "", "entry_date": "", "entry_number": 139, - "id": "", + "id": "", "memo": "Payment PAY-000046", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2500, "total_debit": 2500, @@ -877,11 +849,11 @@ "created_at": "", "entry_date": "", "entry_number": 138, - "id": "", + "id": "", "memo": "Void of payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 4500, "total_debit": 4500, @@ -891,11 +863,11 @@ "created_at": "", "entry_date": "", "entry_number": 137, - "id": "", + "id": "", "memo": "Reversal of #134: golden void", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1142, "total_debit": 1142, @@ -905,11 +877,11 @@ "created_at": "", "entry_date": "", "entry_number": 136, - "id": "", + "id": "", "memo": "Reversal of #133: golden void", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2000, "total_debit": 2000, @@ -919,11 +891,11 @@ "created_at": "", "entry_date": "", "entry_number": 135, - "id": "", + "id": "", "memo": "Refund of payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 500, "total_debit": 500, @@ -933,11 +905,11 @@ "created_at": "", "entry_date": "", "entry_number": 134, - "id": "", + "id": "", "memo": "Payment PAY-000045 applied to IN-000052", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1142, "total_debit": 1142, @@ -947,11 +919,11 @@ "created_at": "", "entry_date": "", "entry_number": 133, - "id": "", + "id": "", "memo": "Payment PAY-000045 applied to IN-000052", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2000, "total_debit": 2000, @@ -961,11 +933,11 @@ "created_at": "", "entry_date": "", "entry_number": 132, - "id": "", + "id": "", "memo": "Payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 5000, "total_debit": 5000, @@ -975,11 +947,11 @@ "created_at": "", "entry_date": "", "entry_number": 131, - "id": "", + "id": "", "memo": "Reversal of #130: credit memo CM-000003 voided", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 75, "total_debit": 75, @@ -989,11 +961,11 @@ "created_at": "", "entry_date": "", "entry_number": 130, - "id": "", + "id": "", "memo": "Credit memo CM-000003", "posted_by": "system", "source": "CREDIT_MEMO", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 75, "total_debit": 75, @@ -1003,11 +975,11 @@ "created_at": "", "entry_date": "", "entry_number": 129, - "id": "", + "id": "", "memo": "Invoice IN-000052", "posted_by": "system", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 5242, "total_debit": 5242, @@ -1017,7 +989,7 @@ "created_at": "", "entry_date": "", "entry_number": 128, - "id": "", + "id": "", "memo": "golden characterisation entry", "posted_by": "system", "source": "MANUAL", @@ -1028,6 +1000,34 @@ }, { "created_at": "", + "entry_date": "", + "entry_number": 41, + "id": "", + "memo": "Payment PAY-000013 applied to IN-000015", + "posted_by": "seed", + "source": "PAYMENT", + "source_ref_id": "", + "status": "POSTED", + "total_credit": 196103, + "total_debit": 196103, + "updated_at": "" + }, + { + "created_at": "", + "entry_date": "", + "entry_number": 40, + "id": "", + "memo": "Payment PAY-000013", + "posted_by": "seed", + "source": "PAYMENT", + "source_ref_id": "", + "status": "POSTED", + "total_credit": 196103, + "total_debit": 196103, + "updated_at": "" + }, + { + "created_at": "", "entry_date": "", "entry_number": 38, "id": "", @@ -1038,10 +1038,10 @@ "status": "POSTED", "total_credit": 2039305, "total_debit": 2039305, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 36, "id": "", @@ -1052,10 +1052,10 @@ "status": "POSTED", "total_credit": 443905, "total_debit": 443905, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 35, "id": "", @@ -1066,10 +1066,10 @@ "status": "POSTED", "total_credit": 443905, "total_debit": 443905, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 63, "id": "", @@ -1080,10 +1080,10 @@ "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 62, "id": "", @@ -1094,10 +1094,10 @@ "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 39, "id": "", @@ -1108,10 +1108,10 @@ "status": "POSTED", "total_credit": 196103, "total_debit": 196103, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 34, "id": "", @@ -1122,10 +1122,10 @@ "status": "POSTED", "total_credit": 443905, "total_debit": 443905, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 61, "id": "", @@ -1136,10 +1136,10 @@ "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 54, "id": "", @@ -1150,10 +1150,10 @@ "status": "POSTED", "total_credit": 204456, "total_debit": 204456, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 53, "id": "", @@ -1164,10 +1164,10 @@ "status": "POSTED", "total_credit": 204456, "total_debit": 204456, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 127, "id": "", @@ -1178,10 +1178,10 @@ "status": "POSTED", "total_credit": 1881600, "total_debit": 1881600, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 80, "id": "", @@ -1192,10 +1192,10 @@ "status": "POSTED", "total_credit": 906156, "total_debit": 906156, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 79, "id": "", @@ -1206,10 +1206,10 @@ "status": "POSTED", "total_credit": 906156, "total_debit": 906156, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 52, "id": "", @@ -1220,10 +1220,10 @@ "status": "POSTED", "total_credit": 204456, "total_debit": 204456, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 96, "id": "", @@ -1234,10 +1234,10 @@ "status": "POSTED", "total_credit": 476177, "total_debit": 476177, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 95, "id": "", @@ -1248,10 +1248,10 @@ "status": "POSTED", "total_credit": 476177, "total_debit": 476177, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 121, "id": "", @@ -1262,10 +1262,10 @@ "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 120, "id": "", @@ -1276,10 +1276,10 @@ "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 78, "id": "", @@ -1290,10 +1290,10 @@ "status": "POSTED", "total_credit": 906156, "total_debit": 906156, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 47, "id": "", @@ -1304,10 +1304,10 @@ "status": "POSTED", "total_credit": 753725, "total_debit": 753725, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 46, "id": "", @@ -1318,10 +1318,10 @@ "status": "POSTED", "total_credit": 753725, "total_debit": 753725, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 94, "id": "", @@ -1332,10 +1332,10 @@ "status": "POSTED", "total_credit": 476177, "total_debit": 476177, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 57, "id": "", @@ -1346,10 +1346,10 @@ "status": "POSTED", "total_credit": 135367, "total_debit": 135367, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 56, "id": "", @@ -1360,10 +1360,10 @@ "status": "POSTED", "total_credit": 135367, "total_debit": 135367, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 119, "id": "", @@ -1374,10 +1374,10 @@ "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 9, "id": "", @@ -1388,10 +1388,10 @@ "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 8, "id": "", @@ -1402,10 +1402,10 @@ "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 12, "id": "", @@ -1416,10 +1416,10 @@ "status": "POSTED", "total_credit": 317253, "total_debit": 317253, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 11, "id": "", @@ -1430,10 +1430,10 @@ "status": "POSTED", "total_credit": 317253, "total_debit": 317253, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 45, "id": "", @@ -1444,10 +1444,10 @@ "status": "POSTED", "total_credit": 753725, "total_debit": 753725, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 30, "id": "", @@ -1458,10 +1458,10 @@ "status": "POSTED", "total_credit": 131286, "total_debit": 131286, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 29, "id": "", @@ -1472,10 +1472,10 @@ "status": "POSTED", "total_credit": 131286, "total_debit": 131286, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 55, "id": "", @@ -1486,10 +1486,10 @@ "status": "POSTED", "total_credit": 135367, "total_debit": 135367, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 84, "id": "", @@ -1500,10 +1500,10 @@ "status": "POSTED", "total_credit": 879959, "total_debit": 879959, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 7, "id": "", @@ -1514,10 +1514,10 @@ "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 108, "id": "", @@ -1528,10 +1528,10 @@ "status": "POSTED", "total_credit": 114044, "total_debit": 114044, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 107, "id": "", @@ -1542,10 +1542,10 @@ "status": "POSTED", "total_credit": 114044, "total_debit": 114044, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 10, "id": "", @@ -1556,10 +1556,10 @@ "status": "POSTED", "total_credit": 793132, "total_debit": 793132, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 28, "id": "", @@ -1570,10 +1570,10 @@ "status": "POSTED", "total_credit": 131286, "total_debit": 131286, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 76, "id": "", @@ -1584,10 +1584,10 @@ "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 75, "id": "", @@ -1598,10 +1598,10 @@ "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 106, "id": "", @@ -1612,10 +1612,10 @@ "status": "POSTED", "total_credit": 114044, "total_debit": 114044, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 77, "id": "", @@ -1626,10 +1626,10 @@ "status": "POSTED", "total_credit": 471765, "total_debit": 471765, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 105, "id": "", @@ -1640,10 +1640,10 @@ "status": "POSTED", "total_credit": 778130, "total_debit": 778130, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 104, "id": "", @@ -1654,10 +1654,10 @@ "status": "POSTED", "total_credit": 778130, "total_debit": 778130, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 60, "id": "", @@ -1668,10 +1668,10 @@ "status": "POSTED", "total_credit": 968303, "total_debit": 968303, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 59, "id": "", @@ -1682,10 +1682,10 @@ "status": "POSTED", "total_credit": 968303, "total_debit": 968303, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 74, "id": "", @@ -1696,10 +1696,10 @@ "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 6, "id": "", @@ -1710,10 +1710,10 @@ "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 5, "id": "", @@ -1724,10 +1724,10 @@ "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 103, "id": "", @@ -1738,10 +1738,10 @@ "status": "POSTED", "total_credit": 778130, "total_debit": 778130, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 102, "id": "", @@ -1752,10 +1752,10 @@ "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 101, "id": "", @@ -1766,10 +1766,10 @@ "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 58, "id": "", @@ -1780,10 +1780,10 @@ "status": "POSTED", "total_credit": 968303, "total_debit": 968303, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 3, "id": "", @@ -1794,10 +1794,10 @@ "status": "POSTED", "total_credit": 813092, "total_debit": 813092, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 2, "id": "", @@ -1808,10 +1808,10 @@ "status": "POSTED", "total_credit": 813092, "total_debit": 813092, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 51, "id": "", @@ -1822,10 +1822,10 @@ "status": "POSTED", "total_credit": 594508, "total_debit": 594508, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 50, "id": "", @@ -1836,7 +1836,7 @@ "status": "POSTED", "total_credit": 594508, "total_debit": 594508, - "updated_at": "" + "updated_at": "" }, { "created_at": "", @@ -1853,7 +1853,7 @@ "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 37, "id": "", @@ -1864,10 +1864,10 @@ "status": "POSTED", "total_credit": 198455, "total_debit": 198455, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 4, "id": "", @@ -1878,10 +1878,10 @@ "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 100, "id": "", @@ -1892,7 +1892,7 @@ "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, - "updated_at": "" + "updated_at": "" }, { "created_at": "", @@ -1909,7 +1909,7 @@ "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 48, "id": "", @@ -1920,10 +1920,10 @@ "status": "POSTED", "total_credit": 1210905, "total_debit": 1210905, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 1, "id": "", @@ -1934,10 +1934,10 @@ "status": "POSTED", "total_credit": 813092, "total_debit": 813092, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 24, "id": "", @@ -1948,10 +1948,10 @@ "status": "POSTED", "total_credit": 565989, "total_debit": 565989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 23, "id": "", @@ -1962,10 +1962,10 @@ "status": "POSTED", "total_credit": 565989, "total_debit": 565989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 49, "id": "", @@ -1976,10 +1976,10 @@ "status": "POSTED", "total_credit": 594508, "total_debit": 594508, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 115, "id": "", @@ -1990,10 +1990,10 @@ "status": "POSTED", "total_credit": 589412, "total_debit": 589412, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 22, "id": "", @@ -2004,10 +2004,10 @@ "status": "POSTED", "total_credit": 565989, "total_debit": 565989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 72, "id": "", @@ -2018,10 +2018,10 @@ "status": "POSTED", "total_credit": 729297, "total_debit": 729297, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 71, "id": "", @@ -2032,10 +2032,10 @@ "status": "POSTED", "total_credit": 729297, "total_debit": 729297, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 70, "id": "", @@ -2046,10 +2046,10 @@ "status": "POSTED", "total_credit": 729297, "total_debit": 729297, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 90, "id": "", @@ -2060,10 +2060,10 @@ "status": "POSTED", "total_credit": 896506, "total_debit": 896506, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 89, "id": "", @@ -2074,10 +2074,10 @@ "status": "POSTED", "total_credit": 896506, "total_debit": 896506, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 44, "id": "", @@ -2088,10 +2088,10 @@ "status": "POSTED", "total_credit": 610007, "total_debit": 610007, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 43, "id": "", @@ -2102,10 +2102,10 @@ "status": "POSTED", "total_credit": 610007, "total_debit": 610007, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 83, "id": "", @@ -2116,10 +2116,10 @@ "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 82, "id": "", @@ -2130,10 +2130,10 @@ "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 114, "id": "", @@ -2144,10 +2144,10 @@ "status": "POSTED", "total_credit": 312490, "total_debit": 312490, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 113, "id": "", @@ -2158,10 +2158,10 @@ "status": "POSTED", "total_credit": 312490, "total_debit": 312490, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 88, "id": "", @@ -2172,10 +2172,10 @@ "status": "POSTED", "total_credit": 896506, "total_debit": 896506, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 42, "id": "", @@ -2186,10 +2186,10 @@ "status": "POSTED", "total_credit": 610007, "total_debit": 610007, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 118, "id": "", @@ -2200,10 +2200,10 @@ "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 117, "id": "", @@ -2214,10 +2214,10 @@ "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 81, "id": "", @@ -2228,10 +2228,10 @@ "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 112, "id": "", @@ -2242,10 +2242,10 @@ "status": "POSTED", "total_credit": 312490, "total_debit": 312490, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 116, "id": "", @@ -2256,10 +2256,10 @@ "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 66, "id": "", @@ -2270,10 +2270,10 @@ "status": "POSTED", "total_credit": 250918, "total_debit": 250918, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 65, "id": "", @@ -2284,10 +2284,10 @@ "status": "POSTED", "total_credit": 250918, "total_debit": 250918, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 64, "id": "", @@ -2298,10 +2298,10 @@ "status": "POSTED", "total_credit": 627295, "total_debit": 627295, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 87, "id": "", @@ -2312,10 +2312,10 @@ "status": "POSTED", "total_credit": 881989, "total_debit": 881989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 86, "id": "", @@ -2326,10 +2326,10 @@ "status": "POSTED", "total_credit": 881989, "total_debit": 881989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 27, "id": "", @@ -2340,10 +2340,10 @@ "status": "POSTED", "total_credit": 411909, "total_debit": 411909, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 26, "id": "", @@ -2354,10 +2354,10 @@ "status": "POSTED", "total_credit": 411909, "total_debit": 411909, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 15, "id": "", @@ -2368,10 +2368,10 @@ "status": "POSTED", "total_credit": 151538, "total_debit": 151538, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 14, "id": "", @@ -2382,10 +2382,10 @@ "status": "POSTED", "total_credit": 151538, "total_debit": 151538, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 99, "id": "", @@ -2396,10 +2396,10 @@ "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 98, "id": "", @@ -2410,10 +2410,10 @@ "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 85, "id": "", @@ -2424,10 +2424,10 @@ "status": "POSTED", "total_credit": 881989, "total_debit": 881989, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 25, "id": "", @@ -2438,10 +2438,10 @@ "status": "POSTED", "total_credit": 411909, "total_debit": 411909, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 13, "id": "", @@ -2452,10 +2452,10 @@ "status": "POSTED", "total_credit": 378846, "total_debit": 378846, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 111, "id": "", @@ -2466,10 +2466,10 @@ "status": "POSTED", "total_credit": 341856, "total_debit": 341856, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 110, "id": "", @@ -2480,10 +2480,10 @@ "status": "POSTED", "total_credit": 341856, "total_debit": 341856, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 97, "id": "", @@ -2494,10 +2494,10 @@ "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 124, "id": "", @@ -2508,10 +2508,10 @@ "status": "POSTED", "total_credit": 627004, "total_debit": 627004, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 123, "id": "", @@ -2522,10 +2522,10 @@ "status": "POSTED", "total_credit": 627004, "total_debit": 627004, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 109, "id": "", @@ -2536,10 +2536,10 @@ "status": "POSTED", "total_credit": 341856, "total_debit": 341856, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 93, "id": "", @@ -2550,10 +2550,10 @@ "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 92, "id": "", @@ -2564,10 +2564,10 @@ "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 122, "id": "", @@ -2578,10 +2578,10 @@ "status": "POSTED", "total_credit": 627004, "total_debit": 627004, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 91, "id": "", @@ -2592,10 +2592,10 @@ "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 73, "id": "", @@ -2606,10 +2606,10 @@ "status": "POSTED", "total_credit": 671849, "total_debit": 671849, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 69, "id": "", @@ -2620,10 +2620,10 @@ "status": "POSTED", "total_credit": 844652, "total_debit": 844652, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 68, "id": "", @@ -2634,10 +2634,10 @@ "status": "POSTED", "total_credit": 844652, "total_debit": 844652, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 21, "id": "", @@ -2648,10 +2648,10 @@ "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 20, "id": "", @@ -2662,10 +2662,10 @@ "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 33, "id": "", @@ -2676,10 +2676,10 @@ "status": "POSTED", "total_credit": 591608, "total_debit": 591608, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 32, "id": "", @@ -2690,10 +2690,10 @@ "status": "POSTED", "total_credit": 591608, "total_debit": 591608, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 67, "id": "", @@ -2704,10 +2704,10 @@ "status": "POSTED", "total_credit": 844652, "total_debit": 844652, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 18, "id": "", @@ -2718,10 +2718,10 @@ "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 17, "id": "", @@ -2732,10 +2732,10 @@ "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 19, "id": "", @@ -2746,10 +2746,10 @@ "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 31, "id": "", @@ -2760,10 +2760,10 @@ "status": "POSTED", "total_credit": 591608, "total_debit": 591608, - "updated_at": "" + "updated_at": "" }, { - "created_at": "", + "created_at": "", "entry_date": "", "entry_number": 16, "id": "", @@ -2774,7 +2774,7 @@ "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, - "updated_at": "" + "updated_at": "" } ] } diff --git a/core/internal/characterization/testdata/goldens/reporting_reports.json b/core/internal/characterization/testdata/goldens/reporting_reports.json index 4d9ac9c18..661e435b6 100644 --- a/core/internal/characterization/testdata/goldens/reporting_reports.json +++ b/core/internal/characterization/testdata/goldens/reporting_reports.json @@ -11,11 +11,11 @@ "body": { "by_method": { "CASH": 24.35, - "CHECK": 313596.85 + "CHECK": 2138.03 }, "date": "", - "total_collected": 313621.19999999995, - "transaction_count": 46 + "total_collected": 2162.38, + "transaction_count": 8 } } }, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..3f73d489d 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,4 @@ get an entry. | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | +| G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check seven days after its invoice, so an invoice made in the seed's last week had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | From c3e559414cae0dc5ba86ce365ab6d5c394496db3 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:57:05 -0700 Subject: [PATCH 11/34] POS tests: a receipt day in the past stands for any day, money is never dated after today Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pos/tx_test.go | 2 +- core/internal/pos/void_interim_apply_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index c45935380..0b4116071 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -178,7 +178,7 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { for i := 0; i < 3; i++ { r := f.do("POST", "/api/v1/payments", map[string]any{ "customer_id": walkIn, "method": "cash", "amount_cents": 100, - "received_on": "2030-01-01", + "received_on": "2020-01-01", }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) paymentStatuses[i] = r.status } diff --git a/core/internal/pos/void_interim_apply_test.go b/core/internal/pos/void_interim_apply_test.go index 5b9f13803..aeb1f2aec 100644 --- a/core/internal/pos/void_interim_apply_test.go +++ b/core/internal/pos/void_interim_apply_test.go @@ -109,7 +109,7 @@ func TestTheCustomersOwnReceiptCannotApplyToAnInterimVoidInvoice(t *testing.T) { // that invoice: refused, the void must stay completable r := f.do("POST", "/api/v1/payments", map[string]any{ "customer_id": f.customerID, "method": "check", "amount_cents": owed, - "reference": "CHK-72", "received_on": "2030-01-01", + "reference": "CHK-72", "received_on": "2020-01-01", "applications": []map[string]any{{"invoice_id": f.invoiceOf(t, saleID), "amount_cents": owed}}, }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) if r.status != http.StatusConflict { From d0cfed85ca4ce99038652c451627c841609ae7ef Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:58:52 -0700 Subject: [PATCH 12/34] Contract changes: word the seed row without a duration Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 3f73d489d..a76e5909c 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,4 +427,4 @@ get an entry. | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | -| G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check seven days after its invoice, so an invoice made in the seed's last week had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | +| G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | From 94a35e8de65e43cea917628758b50beb0321cac1 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:03:28 -0700 Subject: [PATCH 13/34] AR: red legs, every act that takes a day refuses one that has not come, and the branch's own day rules Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/ar_day_acts_test.go | 219 ++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 core/internal/account/ar_day_acts_test.go diff --git a/core/internal/account/ar_day_acts_test.go b/core/internal/account/ar_day_acts_test.go new file mode 100644 index 000000000..c856012ad --- /dev/null +++ b/core/internal/account/ar_day_acts_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +// Every act that takes a day refuses a day that has not come in the branch's +// time zone, in words that name the field (G1 walk note N12). + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/google/uuid" +) + +// draftMemo inserts a draft credit memo of the world's customer. +func (w *arWorld) draftMemo() uuid.UUID { + memo := uuid.New() + w.exec(`INSERT INTO credit_memos (id, customer_id, branch_id, currency, reason_code, reason, amount, subtotal, tax_amount, + total_amount, status, memo_date) + VALUES ($1, $2, `+arBranch+`, 'USD', 'RETURN', 'returned goods', 0, 0, 0, 0, 'DRAFT', CURRENT_DATE)`, memo, w.customerID) + return memo +} + +func (w *arWorld) postMemo(memo uuid.UUID, cents int64) { + w.t.Helper() + w.mustAct("post the memo", func(ctx context.Context) (*account.Effects, error) { + return w.svc.PostCreditMemo(ctx, account.PostCreditMemoIn{MemoID: memo, CustomerID: w.customerID, Number: "CM-" + memo.String()[:8], + Currency: "USD", MemoDate: daysFromNow(-10), SubtotalCents: -cents, TotalCents: -cents, Actor: "u-test", + Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Debit: cents}}}) + }) +} + +func TestARRefusesADayToComeOnEveryActThatTakesOne(t *testing.T) { + type tc struct { + name string + field string + run func(w *arWorld) error // sets up in the past, then acts on tomorrow + } + cases := []tc{ + {"record a payment", "received_on", func(w *arWorld) error { + _, err := w.act("record", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RecordPayment(ctx, account.RecordPaymentIn{CustomerID: w.customerID, BranchID: w.branchID, + Currency: "USD", Method: "CHECK", AmountCents: 5000, ReceivedOn: w.businessDay().AddDate(0, 0, 1), Actor: "u-test"}) + return fx, e + }) + return err + }}, + {"apply a payment", "applied_on", func(w *arWorld) error { + inv := w.invoice(10000, daysFromNow(-5)) + pay := w.payment(10000, daysFromNow(-1), "CHECK") + _, err := w.act("apply", func(ctx context.Context) (*account.Effects, error) { + return w.svc.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: inv.ID, AmountCents: 10000}}, + On: w.businessDay().AddDate(0, 0, 1), Actor: "u-test"}) + }) + return err + }}, + {"apply deposits", "applied_on", func(w *arWorld) error { + inv := w.invoice(10000, daysFromNow(-5)) + pay := w.payment(10000, daysFromNow(-1), "CHECK") + _, err := w.act("deposits", func(ctx context.Context) (*account.Effects, error) { + return w.svc.ApplyDeposits(ctx, inv.ID, []uuid.UUID{pay}, w.businessDay().AddDate(0, 0, 1), "u-test") + }) + return err + }}, + {"apply a credit memo", "applied_on", func(w *arWorld) error { + inv := w.invoice(10000, daysFromNow(-30)) + memo := w.draftMemo() + w.postMemo(memo, 5000) + _, err := w.act("credit apply", func(ctx context.Context) (*account.Effects, error) { + return w.svc.ApplyCreditMemo(ctx, account.ApplyCreditMemoIn{MemoID: memo, + Lines: []account.ApplyLine{{InvoiceID: inv.ID, AmountCents: 3000}}, On: w.businessDay().AddDate(0, 0, 1), Actor: "u-test"}) + }) + return err + }}, + {"write off", "on", func(w *arWorld) error { + inv := w.invoice(10000, daysFromNow(-30)) + _, err := w.act("write off", func(ctx context.Context) (*account.Effects, error) { + return w.svc.WriteOff(ctx, account.WriteOffIn{InvoiceID: inv.ID, AmountCents: 1000, Reason: "small", Actor: "u-test", + On: w.businessDay().AddDate(0, 0, 1)}) + }) + return err + }}, + {"refund a payment", "refunded_on", func(w *arWorld) error { + pay := w.payment(4000, daysFromNow(-1), "CHECK") + _, err := w.act("refund", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RefundPayment(ctx, account.RefundPaymentIn{PaymentID: pay, AmountCents: 1000, Reason: "back", Actor: "u-test", + On: w.businessDay().AddDate(0, 0, 1)}) + return fx, e + }) + return err + }}, + {"refund a credit memo", "refunded_on", func(w *arWorld) error { + memo := w.draftMemo() + w.postMemo(memo, 5000) + _, err := w.act("refund memo", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RefundCreditMemo(ctx, account.RefundCreditMemoIn{MemoID: memo, AmountCents: 1000, Reason: "back", Actor: "u-test", + On: w.businessDay().AddDate(0, 0, 1)}) + return fx, e + }) + return err + }}, + {"reverse an application", "reversed_on", func(w *arWorld) error { + inv := w.invoice(10000, daysFromNow(-5)) + w.payment(10000, daysFromNow(-1), "CHECK", account.ApplyLine{InvoiceID: inv.ID, AmountCents: 10000}) + app := w.appID(inv.ID, "PAYMENT") + _, err := w.act("reverse", func(ctx context.Context) (*account.Effects, error) { + return w.svc.Reverse(ctx, account.ReverseIn{ApplicationID: app, Reason: "bounced", Actor: "u-test", + On: w.businessDay().AddDate(0, 0, 1)}) + }) + return err + }}, + {"void a payment", "voided_on", func(w *arWorld) error { + pay := w.payment(4000, daysFromNow(-1), "CHECK") + _, err := w.act("void", func(ctx context.Context) (*account.Effects, error) { + return w.svc.VoidPayment(ctx, account.VoidPaymentIn{PaymentID: pay, Reason: "keyed twice", Actor: "u-test", + On: w.businessDay().AddDate(0, 0, 1)}) + }) + return err + }}, + {"post an invoice", "invoice_date", func(w *arWorld) error { + inv := &arInvoice{ID: uuid.New(), Total: 7000, Due: daysFromNow(30)} + w.postInvoiceRow(w.customerID, inv, daysFromNow(-1)) + _, err := w.act("post invoice", func(ctx context.Context) (*account.Effects, error) { + return w.svc.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: inv.ID, CustomerID: w.customerID, + Number: "IN-" + inv.ID.String()[:8], Currency: "USD", TotalCents: inv.Total, On: w.businessDay().AddDate(0, 0, 1), Actor: "u-test", + Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Credit: inv.Total}}}) + }) + return err + }}, + {"void an invoice", "voided_on", func(w *arWorld) error { + inv := w.invoice(7000, daysFromNow(-3)) + _, err := w.act("void invoice", func(ctx context.Context) (*account.Effects, error) { + return w.svc.VoidInvoice(ctx, account.VoidInvoiceIn{InvoiceID: inv.ID, EntryID: entryOf(w, inv.ID), Actor: "u-test", + Reason: "keyed wrong", On: w.businessDay().AddDate(0, 0, 1)}) + }) + return err + }}, + {"post a credit memo", "memo_date", func(w *arWorld) error { + memo := w.draftMemo() + _, err := w.act("post memo", func(ctx context.Context) (*account.Effects, error) { + return w.svc.PostCreditMemo(ctx, account.PostCreditMemoIn{MemoID: memo, CustomerID: w.customerID, Number: "CM-" + memo.String()[:8], + Currency: "USD", MemoDate: w.businessDay().AddDate(0, 0, 1), SubtotalCents: -5000, TotalCents: -5000, Actor: "u-test", + Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Debit: 5000}}}) + }) + return err + }}, + {"void a credit memo", "voided_on", func(w *arWorld) error { + memo := w.draftMemo() + w.postMemo(memo, 5000) + _, err := w.act("void memo", func(ctx context.Context) (*account.Effects, error) { + return w.svc.VoidCreditMemo(ctx, account.VoidCreditMemoIn{MemoID: memo, Actor: "u-test", Reason: "keyed wrong", + On: w.businessDay().AddDate(0, 0, 1)}) + }) + return err + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := newARWorld(t) + err := c.run(w) + var he *httpx.Error + if !errors.As(err, &he) { + t.Fatalf("a day to come was %v, want a 400 naming %s", err, c.field) + } + if he.Status != 400 { + t.Errorf("status %d, want 400", he.Status) + } + if len(he.Details) != 1 || he.Details[0].Field != c.field || !strings.Contains(he.Details[0].Message, "has not come yet") { + t.Errorf("details %+v, want the field %s and words saying the day has not come yet", he.Details, c.field) + } + w.assertInvariants("after the refused act") + }) + } +} + +// A branch whose own day differs from the UTC day: its today is accepted and +// its tomorrow refused, whichever side of UTC the branch sits on. +func TestARBusinessDayIsTheBranchsOwnDay(t *testing.T) { + for _, zone := range []string{"Pacific/Kiritimati", "Pacific/Pago_Pago"} { + t.Run(zone, func(t *testing.T) { + w := newARWorld(t) + branch := uuid.New() + w.exec(`INSERT INTO locations (id, type, code, timezone) VALUES ($1, 'BRANCH', $2, $3)`, branch, "ZN-"+branch.String()[:8], zone) + t.Cleanup(func() { + // the world's own cleanup removes the payment; the branch goes after + w.exec(`UPDATE payments SET branch_id = `+arBranch+` WHERE branch_id = $1`, branch) + w.exec(`DELETE FROM locations WHERE id = $1`, branch) + }) + today, err := w.svc.LocalDate(context.Background(), branch, w.svc.Now()) + if err != nil { + t.Fatal(err) + } + record := func(on string) error { + _, err := w.act("record", func(ctx context.Context) (*account.Effects, error) { + d, _ := w.svc.LocalDate(ctx, branch, w.svc.Now()) + if on == "tomorrow" { + d = d.AddDate(0, 0, 1) + } + _, fx, e := w.svc.RecordPayment(ctx, account.RecordPaymentIn{CustomerID: w.customerID, BranchID: branch, + Currency: "USD", Method: "CHECK", AmountCents: 1000, ReceivedOn: d, Actor: "u-test"}) + return fx, e + }) + return err + } + if err := record("today"); err != nil { + t.Errorf("the branch's own today (%s) was refused: %v", date(today), err) + } + if err := record("tomorrow"); err == nil { + t.Errorf("the branch's tomorrow was accepted") + } + }) + } +} From 073820bc813c5df8e36fd011172d5af160089e22 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:03:28 -0700 Subject: [PATCH 14/34] AR: red leg, the statement lists unapplied cash and states the net balance Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../account/ar_statement_unapplied_test.go | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 core/internal/account/ar_statement_unapplied_test.go diff --git a/core/internal/account/ar_statement_unapplied_test.go b/core/internal/account/ar_statement_unapplied_test.go new file mode 100644 index 000000000..f4d9643a3 --- /dev/null +++ b/core/internal/account/ar_statement_unapplied_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +// The statement lists the payments still unapplied as of the period's end and +// states the net balance, which equals the aging's total on the same day (G1 +// walk note N13, the money read's P2-3). The statement is read through its +// wire form so a missing field fails the leg rather than the build. + +import ( + "context" + "encoding/json" + "testing" + + "github.com/gablelbm/gable/internal/account" +) + +func TestARStatementListsUnappliedCashAndStatesTheNetBalance(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + w.invoice(100000, today.AddDate(0, 0, -20)) + pay := w.payment(50000, today.AddDate(0, 0, -5), "CHECK") // a deposit, nothing applied + var number string + if err := w.db.Pool.QueryRow(context.Background(), `SELECT number FROM payments WHERE id = $1`, pay).Scan(&number); err != nil { + t.Fatal(err) + } + ctx := context.Background() + + st, err := w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -30), To: today}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(st) + var wire struct { + Currencies []map[string]any `json:"currencies"` + } + if err := json.Unmarshal(raw, &wire); err != nil || len(wire.Currencies) != 1 { + t.Fatalf("statement wire form: %v, %d currencies", err, len(wire.Currencies)) + } + c := wire.Currencies[0] + if got := c["closing_balance_cents"]; got != float64(100000) { + t.Errorf("closing balance %v, want 100000 (the subledger has no row for cash on receipt)", got) + } + if got := c["unapplied_cents"]; got != float64(50000) { + t.Errorf("unapplied cash %v, want 50000", got) + } + if got := c["net_balance_cents"]; got != float64(50000) { + t.Errorf("net balance %v, want 50000", got) + } + list, _ := c["unapplied_payments"].([]any) + if len(list) != 1 { + t.Fatalf("%d unapplied payments listed, want 1", len(list)) + } + row, _ := list[0].(map[string]any) + if row["number"] != number || row["received_on"] != date(today.AddDate(0, 0, -5)) || row["unapplied_cents"] != float64(50000) { + t.Errorf("unapplied payment row %v, want number %s, received %s, 50000", row, number, date(today.AddDate(0, 0, -5))) + } + + // The net balance is the aging's total on the same day. + sum, err := w.svc.AgingSummary(ctx, account.AgingQuery{AsOf: today, Basis: "due_date", CustomerID: &w.customerID}) + if err != nil { + t.Fatal(err) + } + if len(sum.Totals) != 1 || int64(sum.Totals[0].TotalCents) != 50000 { + t.Fatalf("aging totals %+v, want one total of 50000", sum.Totals) + } + + // As of a day before the money arrived, nothing is unapplied. + st, err = w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -30), To: today.AddDate(0, 0, -10)}) + if err != nil { + t.Fatal(err) + } + raw, _ = json.Marshal(st) + _ = json.Unmarshal(raw, &wire) + if got := wire.Currencies[0]["unapplied_cents"]; got != float64(0) { + t.Errorf("unapplied cash before it arrived %v, want 0", got) + } +} From d2d75535eed6862328635ba91c11eb0df456837a Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:04:12 -0700 Subject: [PATCH 15/34] AR core: reversals, voids and the document acts refuse a day that has not come Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/core.go | 15 +++++++++++++++ core/internal/account/core_documents.go | 14 ++++++++++++++ core/internal/account/core_payments.go | 6 ++++++ 3 files changed, 35 insertions(+) diff --git a/core/internal/account/core.go b/core/internal/account/core.go index 06509fdb3..590eb20d7 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -84,6 +84,21 @@ func (s *Service) refuseDayToCome(ctx context.Context, branchID uuid.UUID, on ti return nil } +// refuseDayToComeOf is refuseDayToCome for an act that names a document, not +// a branch: the day is the document's own branch's. A document that is not +// there is left to the act's own refusal. +func (s *Service) refuseDayToComeOf(ctx context.Context, table string, id uuid.UUID, on time.Time, field string) error { + var branch uuid.UUID + err := s.ex(ctx).QueryRow(ctx, `SELECT branch_id FROM `+table+` WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("failed to read the document's branch: %w", err) + } + return s.refuseDayToCome(ctx, branch, on, field) +} + // ErrNoTransaction is the refusal to run an act with no transaction open. var ErrNoTransaction = gl.ErrNoTransaction diff --git a/core/internal/account/core_documents.go b/core/internal/account/core_documents.go index 33289c918..2e9335983 100644 --- a/core/internal/account/core_documents.go +++ b/core/internal/account/core_documents.go @@ -33,6 +33,9 @@ func (s *Service) PostInvoice(ctx context.Context, in PostInvoiceIn) (*Effects, if err := s.requireTx(ctx); err != nil { return nil, err } + if err := s.refuseDayToComeOf(ctx, "invoices", in.InvoiceID, in.On, "invoice_date"); err != nil { + return nil, err + } fx := &Effects{} if in.Number == "" || in.Currency == "" { // The counter's account charge (until C2-5) leaves the number and the @@ -92,6 +95,9 @@ func (s *Service) VoidInvoice(ctx context.Context, in VoidInvoiceIn) (*Effects, if inv.Status == invVoid { return nil, httpx.InvalidStateTransition("the invoice is already void") } + if err := s.refuseDayToCome(ctx, inv.BranchID, in.On, "voided_on"); err != nil { + return nil, err + } var live int if err := s.ex(ctx).QueryRow(ctx, `SELECT count(*) FROM ar_applications WHERE invoice_id = $1 AND reversed_at IS NULL`, inv.ID).Scan(&live); err != nil { return nil, fmt.Errorf("failed to read the invoice's applications: %w", err) @@ -157,6 +163,9 @@ func (s *Service) PostCreditMemo(ctx context.Context, in PostCreditMemoIn) (*Eff if m.Status != "DRAFT" { return nil, httpx.InvalidStateTransition("only a draft credit memo can be posted: it is " + lower(m.Status)) } + if err := s.refuseDayToComeOf(ctx, "credit_memos", in.MemoID, in.MemoDate, "memo_date"); err != nil { + return nil, err + } fx := &Effects{} if _, err := s.lockCustomer(ctx, in.CustomerID); err != nil { return nil, err @@ -208,6 +217,11 @@ func (s *Service) VoidCreditMemo(ctx context.Context, in VoidCreditMemoIn) (*Eff if err != nil { return nil, err } + if m.Status != "VOID" { + if err := s.refuseDayToCome(ctx, m.BranchID, in.On, "voided_on"); err != nil { + return nil, err + } + } fx := &Effects{} switch m.Status { case "VOID": diff --git a/core/internal/account/core_payments.go b/core/internal/account/core_payments.go index 4937546b5..62fc9c00a 100644 --- a/core/internal/account/core_payments.go +++ b/core/internal/account/core_payments.go @@ -514,6 +514,9 @@ func (s *Service) Reverse(ctx context.Context, in ReverseIn) (*Effects, error) { if probe.Kind == KindDiscount { return nil, conflict("discount_stands_with_payment", "a discount stands beside the payment that earned it: reverse that payment's application") } + if err := s.refuseDayToComeOf(ctx, "invoices", probe.InvoiceID, in.On, "reversed_on"); err != nil { + return nil, err + } r := &reversal{invoices: map[uuid.UUID]*invoiceRow{}, invoiceAdd: map[uuid.UUID]int64{}} switch { case probe.PaymentID != nil: @@ -717,6 +720,9 @@ func (s *Service) VoidPayment(ctx context.Context, in VoidPaymentIn) (*Effects, if pay.Status != "POSTED" { return nil, httpx.InvalidStateTransition("the payment is already voided") } + if err := s.refuseDayToCome(ctx, pay.BranchID, in.On, "voided_on"); err != nil { + return nil, err + } if pay.Method == "CARD" { return nil, conflict("card_payment", "a card payment is not voided here: refund it through the gateway") } From 39984a472b1faf371c2174368c0818b73d358bdb Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:06:13 -0700 Subject: [PATCH 16/34] Statement page: red leg, unapplied cash and the net balance are shown Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../reports/CustomerStatementPage.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index 4456638ed..c9ed1c844 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts @@ -63,6 +63,9 @@ function statement(customerId: string) { closing_balance_cents: 0, lines: [], open_documents: [], + unapplied_payments: [], + unapplied_cents: 0, + net_balance_cents: 0, }, ], } @@ -198,4 +201,35 @@ describe('customer statement preselect via ?customer=', () => { history.replaceState(null, '', '/') } }) -}) \ No newline at end of file +}) +describe('customer statement unapplied cash', () => { + it('lists the payments still unapplied and states the net balance beside the closing balance', async () => { + const st = statement(CUSTOMER_ID) + st.currencies[0] = { + ...st.currencies[0], + closing_balance_cents: 100000, + unapplied_payments: [{ id: '00000000-0000-4000-8000-0000000000p1', number: 'PAY-000042', received_on: '2031-12-20', unapplied_cents: 50000 }], + unapplied_cents: 50000, + net_balance_cents: 50000, + } + stubRoutes({ + [`/api/v1/customers/${CUSTOMER_ID}`]: customer({ id: CUSTOMER_ID }), + [`/ar/customers/${CUSTOMER_ID}/statement`]: st, + }) + history.replaceState(null, '', `?customer=${CUSTOMER_ID}`) + try { + const el = await mountAsync('gable-customer-statement') + for (let i = 0; i < 4; i++) { + await new Promise((r) => setTimeout(r, 0)) + await el.updateComplete + } + const body = textOf(el) + expect(body).toContain('PAY-000042') + expect(body).toContain('2031-12-20') + expect(body).toContain('$500.00') + expect(body).toMatch(/net balance/i) + } finally { + history.replaceState(null, '', '/') + } + }) +}) From 6d32010f91bd8033658a83daeb28c859f57aa180 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:06:44 -0700 Subject: [PATCH 17/34] AR: the statement lists unapplied cash and states the net balance The subledger writes a row when cash is applied, never on receipt. Each currency now carries the payments still unapplied as of the statement's last day, their sum and the net balance, which equals the aging's total that day. Contract, goldens, the page and the seeded sweep follow. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/accounts.yaml | 19 ++++- core/api/openapi.yaml | 19 ++++- core/internal/account/statement.go | 71 ++++++++++++++++--- core/internal/app/seed/demodata_test.go | 42 +++++++++++ .../testdata/goldens/account.json | 5 +- docs/refactor/CONTRACT-CHANGES.md | 1 + .../pages/reports/CustomerStatementPage.ts | 32 ++++++++- web/apps/desk/src/types/account.ts | 12 ++++ web/packages/api-client/src/schema.d.ts | 21 ++++++ 9 files changed, 210 insertions(+), 12 deletions(-) diff --git a/core/api/fragments/accounts.yaml b/core/api/fragments/accounts.yaml index 0d8ffd482..700e7be04 100644 --- a/core/api/fragments/accounts.yaml +++ b/core/api/fragments/accounts.yaml @@ -421,7 +421,7 @@ components: open_cents: {type: integer, format: int64, description: "Negative for a credit memo."} ArStatementCurrency: type: object - required: [currency, opening_balance_cents, lines, closing_balance_cents, open_documents] + required: [currency, opening_balance_cents, lines, closing_balance_cents, open_documents, unapplied_payments, unapplied_cents, net_balance_cents] properties: currency: {type: string} opening_balance_cents: {type: integer, format: int64} @@ -432,6 +432,23 @@ components: open_documents: type: array items: {$ref: "#/components/schemas/ArOpenDocument"} + unapplied_payments: + type: array + description: Payments received on or before the statement's last day that still hold unapplied cash on it. + items: {$ref: "#/components/schemas/ArUnappliedPayment"} + unapplied_cents: {type: integer, format: int64, description: "The sum of the unapplied payments."} + net_balance_cents: + type: integer + format: int64 + description: The closing balance less unapplied cash; the aging's total for the customer on the statement's last day. + ArUnappliedPayment: + type: object + required: [id, number, received_on, unapplied_cents] + properties: + id: {type: string, format: uuid} + number: {type: string} + received_on: {type: string, format: date} + unapplied_cents: {type: integer, format: int64} ArStatement: type: object required: [customer_id, customer_name, from, to, job_id, currencies] diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..cebced1ee 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -16348,7 +16348,7 @@ components: open_cents: {type: integer, format: int64, description: "Negative for a credit memo."} ArStatementCurrency: type: object - required: [currency, opening_balance_cents, lines, closing_balance_cents, open_documents] + required: [currency, opening_balance_cents, lines, closing_balance_cents, open_documents, unapplied_payments, unapplied_cents, net_balance_cents] properties: currency: {type: string} opening_balance_cents: {type: integer, format: int64} @@ -16359,6 +16359,23 @@ components: open_documents: type: array items: {$ref: "#/components/schemas/ArOpenDocument"} + unapplied_payments: + type: array + description: Payments received on or before the statement's last day that still hold unapplied cash on it. + items: {$ref: "#/components/schemas/ArUnappliedPayment"} + unapplied_cents: {type: integer, format: int64, description: "The sum of the unapplied payments."} + net_balance_cents: + type: integer + format: int64 + description: The closing balance less unapplied cash; the aging's total for the customer on the statement's last day. + ArUnappliedPayment: + type: object + required: [id, number, received_on, unapplied_cents] + properties: + id: {type: string, format: uuid} + number: {type: string} + received_on: {type: string, format: date} + unapplied_cents: {type: integer, format: int64} ArStatement: type: object required: [customer_id, customer_name, from, to, job_id, currencies] diff --git a/core/internal/account/statement.go b/core/internal/account/statement.go index 6cd74fa40..602d20216 100644 --- a/core/internal/account/statement.go +++ b/core/internal/account/statement.go @@ -42,13 +42,28 @@ type OpenDocument struct { OpenCents httpx.Cents `json:"open_cents"` } -// StatementCurrency is the statement of one currency. +// UnappliedPayment is a payment received on or before the statement's last day +// that still holds cash no invoice has used as of that day. +type UnappliedPayment struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + ReceivedOn string `json:"received_on"` + UnappliedCents httpx.Cents `json:"unapplied_cents"` +} + +// StatementCurrency is the statement of one currency. The subledger writes a +// row when cash is applied, never when it is received, so the closing balance +// does not net unapplied cash: the statement lists it and states the net +// balance (closing less unapplied), the figure the aging totals on that day. type StatementCurrency struct { - Currency string `json:"currency"` - OpeningBalanceCents httpx.Cents `json:"opening_balance_cents"` - Lines []StatementLine `json:"lines"` - ClosingBalanceCents httpx.Cents `json:"closing_balance_cents"` - OpenDocuments []OpenDocument `json:"open_documents"` + Currency string `json:"currency"` + OpeningBalanceCents httpx.Cents `json:"opening_balance_cents"` + Lines []StatementLine `json:"lines"` + ClosingBalanceCents httpx.Cents `json:"closing_balance_cents"` + OpenDocuments []OpenDocument `json:"open_documents"` + UnappliedPayments []UnappliedPayment `json:"unapplied_payments"` + UnappliedCents httpx.Cents `json:"unapplied_cents"` + NetBalanceCents httpx.Cents `json:"net_balance_cents"` } // Statement is GET /api/v1/ar/customers/{id}/statement. @@ -134,7 +149,7 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, } sc := byCur[cur] if sc == nil { - sc = &StatementCurrency{Currency: cur, Lines: []StatementLine{}, OpenDocuments: []OpenDocument{}} + sc = &StatementCurrency{Currency: cur, Lines: []StatementLine{}, OpenDocuments: []OpenDocument{}, UnappliedPayments: []UnappliedPayment{}} byCur[cur] = sc order = append(order, cur) } @@ -186,7 +201,7 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, d.TotalCents, d.OpenCents = httpx.Cents(total), httpx.Cents(open) sc := byCur[cur] if sc == nil { - sc = &StatementCurrency{Currency: cur, Lines: []StatementLine{}, OpenDocuments: []OpenDocument{}} + sc = &StatementCurrency{Currency: cur, Lines: []StatementLine{}, OpenDocuments: []OpenDocument{}, UnappliedPayments: []UnappliedPayment{}} byCur[cur] = sc order = append(order, cur) } @@ -196,8 +211,48 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, if err := docs.Err(); err != nil { return nil, err } + pays, err := s.ex(ctx).Query(ctx, ` + SELECT p.id, p.number, p.currency, to_char(p.received_on, 'YYYY-MM-DD'), + ROUND(p.amount * 100)::bigint + - COALESCE((SELECT SUM(ROUND(a.amount * 100)::bigint) FROM ar_applications a + WHERE a.payment_id = p.id AND a.kind = 'PAYMENT' AND a.applied_on <= $2::date AND (a.reversed_on IS NULL OR a.reversed_on > $2::date)), 0) + - COALESCE((SELECT SUM(ROUND(f.amount * 100)::bigint) FROM payment_refunds f WHERE f.payment_id = p.id AND f.refunded_on <= $2::date), 0) + - ROUND(p.migrated_excess * 100)::bigint AS open + FROM payments p + WHERE p.customer_id = $1 AND p.received_on <= $2::date AND (p.voided_on IS NULL OR p.voided_on > $2::date) + AND ($3::uuid IS NULL OR p.project_id = $3) + ORDER BY p.received_on, p.number`, q.CustomerID, date(q.To), q.JobID) + if err != nil { + return nil, fmt.Errorf("failed to read the unapplied cash: %w", err) + } + for pays.Next() { + var u UnappliedPayment + var cur string + var open int64 + if err := pays.Scan(&u.ID, &u.Number, &cur, &u.ReceivedOn, &open); err != nil { + pays.Close() + return nil, fmt.Errorf("failed to scan an unapplied payment: %w", err) + } + if open <= 0 { + continue + } + u.UnappliedCents = httpx.Cents(open) + sc := byCur[cur] + if sc == nil { + sc = &StatementCurrency{Currency: cur, Lines: []StatementLine{}, OpenDocuments: []OpenDocument{}, UnappliedPayments: []UnappliedPayment{}} + byCur[cur] = sc + order = append(order, cur) + } + sc.UnappliedPayments = append(sc.UnappliedPayments, u) + sc.UnappliedCents += u.UnappliedCents + } + pays.Close() + if err := pays.Err(); err != nil { + return nil, err + } sort.Strings(order) for _, c := range order { + byCur[c].NetBalanceCents = byCur[c].ClosingBalanceCents - byCur[c].UnappliedCents out.Currencies = append(out.Currencies, *byCur[c]) } return out, nil diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 49a3840a0..527993652 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -600,6 +600,48 @@ func TestDemoImport(t *testing.T) { } }) + t.Run("every customer's statement net balance equals its aging total", func(t *testing.T) { + var today string + if err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date::text`).Scan(&today); err != nil { + t.Fatal(err) + } + mux := accountMux(t, dbURL) + rows, err := db.Query(`SELECT DISTINCT customer_id::text FROM customer_transactions`) + if err != nil { + t.Fatal(err) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + rows.Close() + if len(ids) == 0 { + t.Fatal("no customer has subledger rows") + } + for _, id := range ids { + var st account.Statement + getJSON(t, mux, fmt.Sprintf("/api/v1/ar/customers/%s/statement?from=2000-01-01&to=%s", id, today), &st) + var net int64 + for _, c := range st.Currencies { + net += int64(c.NetBalanceCents) + } + var ag account.AgingSummary + getJSON(t, mux, fmt.Sprintf("/api/v1/ar/aging/summary?customer_id=%s&as_of=%s", id, today), &ag) + var total int64 + for _, c := range ag.Totals { + total += int64(c.TotalCents) + } + if net != total { + t.Errorf("customer %s: the statement's net balance %d, the aging's total %d", id, net, total) + } + } + }) + t.Run("the dashboard's Outstanding AR equals the aging summary's total", func(t *testing.T) { ar, err := newReceivables(dbURL) if err != nil { diff --git a/core/internal/characterization/testdata/goldens/account.json b/core/internal/characterization/testdata/goldens/account.json index 2e438e18c..30e95a6ff 100644 --- a/core/internal/characterization/testdata/goldens/account.json +++ b/core/internal/characterization/testdata/goldens/account.json @@ -478,6 +478,7 @@ "type": "REVERSAL" } ], + "net_balance_cents": 3142, "open_documents": [ { "date": "", @@ -490,7 +491,9 @@ "total_cents": 3142 } ], - "opening_balance_cents": 0 + "opening_balance_cents": 0, + "unapplied_cents": 0, + "unapplied_payments": [] } ], "customer_id": "", diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index a76e5909c..beabd689b 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -428,3 +428,4 @@ get an entry. | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | | G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | +| G1-FIX (item 130, N12, N13 round 1) | AR core acts `Reverse`, `VoidPayment`, `PostInvoice`, `VoidInvoice`, `PostCreditMemo`, `VoidCreditMemo`; `GET /api/v1/ar/customers/{id}/statement` (new fields `unapplied_payments`, `unapplied_cents`, `net_balance_cents` on each currency, golden `account`) | The core accepted a reversal, a void, an invoice day or a credit memo day after the branch's business day, so the aging counted an application as live while the invoice read unpaid. The subledger writes a row when cash is applied, never on receipt, so a customer with a deposit and an open invoice read the full invoice on the statement and the net on the aging. | Each of those acts is refused with a 400 naming `reversed_on`, `voided_on`, `invoice_date` or `memo_date` when its day has not come. The day is the act's own document's branch's day in that branch's zone; the aging's default and the dashboard use the default branch's zone, so a branch east of the default may hold a day that is today there and a day ahead of the default aging for some hours. The statement lists each currency's payments still unapplied as of its last day (number, received day, amount), their sum, and the net balance (closing balance less unapplied cash), which equals the aging's total on that day. | diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index bedb5e0d1..452438705 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts @@ -336,11 +336,41 @@ export class CustomerStatementPage extends LitElement { ` : nothing} -
+ ${cur.unapplied_payments.length > 0 ? html` +
+

Unapplied cash

+ + + + + + + + + + ${cur.unapplied_payments.map(p => html` + + + + + + `)} + +
PaymentReceivedUnapplied
${p.number}${p.received_on}${formatCents(p.unapplied_cents)}
+
+ ` : nothing} + +

Closing Balance

${formatCents(cur.closing_balance_cents)}

+ ${cur.unapplied_cents > 0 ? html` +
+

Net balance (after unapplied cash)

+

${formatCents(cur.net_balance_cents)}

+
+ ` : nothing}
diff --git a/web/apps/desk/src/types/account.ts b/web/apps/desk/src/types/account.ts index 347564d0e..ac0534c07 100644 --- a/web/apps/desk/src/types/account.ts +++ b/web/apps/desk/src/types/account.ts @@ -108,6 +108,18 @@ export interface ArStatementCurrency { lines: ArStatementLine[]; closing_balance_cents: number; open_documents: ArOpenDocument[]; + /** Payments received by the statement's last day that still hold unapplied cash on it. */ + unapplied_payments: ArUnappliedPayment[]; + unapplied_cents: number; + /** The closing balance less unapplied cash: the aging's total for the customer on that day. */ + net_balance_cents: number; +} + +export interface ArUnappliedPayment { + id: string; + number: string; + received_on: string; + unapplied_cents: number; } export interface ArStatement { diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 84c3cf3fc..04325df09 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -7325,6 +7325,27 @@ export interface components { /** Format: int64 */ closing_balance_cents: number; open_documents: components["schemas"]["ArOpenDocument"][]; + /** @description Payments received on or before the statement's last day that still hold unapplied cash on it. */ + unapplied_payments: components["schemas"]["ArUnappliedPayment"][]; + /** + * Format: int64 + * @description The sum of the unapplied payments. + */ + unapplied_cents: number; + /** + * Format: int64 + * @description The closing balance less unapplied cash; the aging's total for the customer on the statement's last day. + */ + net_balance_cents: number; + }; + ArUnappliedPayment: { + /** Format: uuid */ + id: string; + number: string; + /** Format: date */ + received_on: string; + /** Format: int64 */ + unapplied_cents: number; }; ArStatement: { /** Format: uuid */ From 2e5ee95232a57796ceaf41353779a800259400da Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:23:53 -0700 Subject: [PATCH 18/34] Order tests: the account service reads the same clock as the order service The invoice day is now refused when it has not come, so a test that moves the order service's clock moves the account service's too. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/core.go | 5 +++++ core/internal/order/fulfil_test.go | 3 +++ core/internal/order/invoice_date_test.go | 1 + core/internal/order/wire_test.go | 3 +++ 4 files changed, 12 insertions(+) diff --git a/core/internal/account/core.go b/core/internal/account/core.go index 590eb20d7..47ec91c23 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -56,6 +56,11 @@ func NewService(db *database.DB, glSvc *gl.Service, logger *slog.Logger) *Servic return &Service{db: db, gl: glSvc, logger: logger, now: time.Now} } +// WithClock sets the clock the business day of the acts is read on (the +// service reads time.Now otherwise); a test that moves the time of a whole +// flow sets the same clock on every service of the flow. +func (s *Service) WithClock(now func() time.Time) *Service { s.now = now; return s } + // conflict is a 409 with a blocker, the shape the refusals of section 9 take. func conflict(code, message string) *httpx.Error { return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, Message: message, diff --git a/core/internal/order/fulfil_test.go b/core/internal/order/fulfil_test.go index d1daf8ba7..73b743c72 100644 --- a/core/internal/order/fulfil_test.go +++ b/core/internal/order/fulfil_test.go @@ -33,6 +33,9 @@ func (f *fixture) withMoney() func(*order.Service) *order.Service { return func(s *order.Service) *order.Service { glSvc := gl.NewService(gl.NewRepository(f.db), nil, slog.Default()) acct := account.NewService(f.db, glSvc, slog.Default()) + if f.now != nil { + acct.WithClock(f.now) + } inv := invoice.NewService(invoice.NewRepository(f.db), glSvc, acct, f.db).WithAuditLog(audit.NewLogger(f.db)) return s.WithInvoices(inv).WithDeposits(acct) } diff --git a/core/internal/order/invoice_date_test.go b/core/internal/order/invoice_date_test.go index 4d1bc73fb..ba3bf3831 100644 --- a/core/internal/order/invoice_date_test.go +++ b/core/internal/order/invoice_date_test.go @@ -48,6 +48,7 @@ func TestFulfilmentStampsTheBranchLocalInvoiceDate(t *testing.T) { f.productID, yard); err != nil { t.Fatalf("seed inventory: %v", err) } + f.now = func() time.Time { return instant } f.serveWith(f.withStock(), f.withMoney(), func(s *order.Service) *order.Service { return s.WithClockForTest(func() time.Time { return instant }) }) diff --git a/core/internal/order/wire_test.go b/core/internal/order/wire_test.go index fdd3bb542..5cc34bf3c 100644 --- a/core/internal/order/wire_test.go +++ b/core/internal/order/wire_test.go @@ -19,6 +19,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gablelbm/gable/internal/order" "github.com/gablelbm/gable/internal/platform/httpx" @@ -31,6 +32,8 @@ import ( ) type fixture struct { + // now, when set, is the clock the fixture's account service reads. + now func() time.Time t *testing.T db *database.DB srv *httptest.Server From 5e33f0bf04b3f68ead6729b408f96ebaea4bf4f3 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:24:20 -0700 Subject: [PATCH 19/34] AR: red leg, a payment stamped the invoice's day follows its invoice, and a future invoice day names invoiced_at Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/ar_restamp_day_test.go | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 core/internal/account/ar_restamp_day_test.go diff --git a/core/internal/account/ar_restamp_day_test.go b/core/internal/account/ar_restamp_day_test.go new file mode 100644 index 000000000..e623dddd7 --- /dev/null +++ b/core/internal/account/ar_restamp_day_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package account_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/google/uuid" +) + +// RestampDay puts a payment received on its invoice's own day after the +// invoice, so a statement orders the application behind its invoice, and +// names the invoice's day in its refusal of a day to come. +func TestARRestampDayOrdersAPaymentAfterItsInvoiceOnTheSameDay(t *testing.T) { + w := newARWorld(t) + day := time.Now().UTC().AddDate(0, 0, -3) + noon := time.Date(day.Year(), day.Month(), day.Day(), 12, 0, 0, 0, time.UTC) + inv := w.invoice(8000, day) + w.payment(8000, day, "CHECK", account.ApplyLine{InvoiceID: inv.ID, AmountCents: 8000}) + var pay uuid.UUID + if err := w.db.Pool.QueryRow(context.Background(), `SELECT payment_id FROM ar_applications WHERE invoice_id = $1`, inv.ID).Scan(&pay); err != nil { + t.Fatal(err) + } + // the seed hands the same moment for both documents of one day + w.mustAct("restamp", func(ctx context.Context) (*account.Effects, error) { + return &account.Effects{}, w.svc.RestampDay(ctx, inv.ID, pay, noon, noon) + }) + var invAt, payAt time.Time + if err := w.db.Pool.QueryRow(context.Background(), ` + SELECT (SELECT created_at FROM customer_transactions WHERE source_kind = 'invoice' AND reference_id = $1), + (SELECT t.created_at FROM customer_transactions t JOIN ar_applications a ON a.id = t.reference_id + WHERE t.source_kind = 'application' AND a.invoice_id = $1)`, inv.ID).Scan(&invAt, &payAt); err != nil { + t.Fatal(err) + } + if !payAt.After(invAt) { + t.Errorf("the payment's row is stamped %s, the invoice's %s: the payment must follow its invoice", payAt, invAt) + } + + // a day to come is named under the invoice's day, not the payment's + err := w.db.RunInTx(context.Background(), func(ctx context.Context) error { + return w.svc.RestampDay(ctx, inv.ID, uuid.Nil, time.Now().UTC().AddDate(0, 0, 400), time.Time{}) + }) + var he *httpx.Error + if !errors.As(err, &he) || len(he.Details) != 1 || he.Details[0].Field != "invoiced_at" { + t.Errorf("a future invoice day was refused as %v, want the field invoiced_at", err) + } +} From 87951a502524d489fdb485770f1d0f6dc52250ec Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:24:53 -0700 Subject: [PATCH 20/34] AR: a payment follows its invoice on the same day, the seed fails when its day is unread, the contract row names every moved value Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../internal/account/ar_statement_day_test.go | 57 +++++++++++++++++++ core/internal/account/core.go | 10 +++- core/internal/app/seed/receivables.go | 10 ++-- core/internal/app/seed/seed.go | 5 +- docs/refactor/CONTRACT-CHANGES.md | 2 +- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/core/internal/account/ar_statement_day_test.go b/core/internal/account/ar_statement_day_test.go index 98cd049f7..060306136 100644 --- a/core/internal/account/ar_statement_day_test.go +++ b/core/internal/account/ar_statement_day_test.go @@ -11,6 +11,8 @@ import ( "testing" "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/google/uuid" ) func TestARStatementDatesEachRowFromItsDocument(t *testing.T) { @@ -57,3 +59,58 @@ func TestARStatementDatesEachRowFromItsDocument(t *testing.T) { t.Errorf("opening %d, %d rows, closing %d; want 12000, 1, 0", c.OpeningBalanceCents, len(c.Lines), c.ClosingBalanceCents) } } + +// The other kinds of row take their document's day too: a credit memo, its +// refund, a reversed application, and a row with no document at all keeps its +// stamp. +func TestARStatementDatesMemoRefundReversalAndLegacyRows(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + ctx := context.Background() + inv := w.invoice(10000, today.AddDate(0, 0, -40)) + memo := w.draftMemo() + w.mustAct("post the memo", func(ctx context.Context) (*account.Effects, error) { + return w.svc.PostCreditMemo(ctx, account.PostCreditMemoIn{MemoID: memo, CustomerID: w.customerID, Number: "CM-" + memo.String()[:8], + Currency: "USD", MemoDate: today.AddDate(0, 0, -30), SubtotalCents: -3000, TotalCents: -3000, Actor: "u-test", + Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Debit: 3000}}}) + }) + w.mustAct("refund the memo", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RefundCreditMemo(ctx, account.RefundCreditMemoIn{MemoID: memo, AmountCents: 1000, Reason: "back", Actor: "u-test", + On: today.AddDate(0, 0, -20)}) + return fx, e + }) + w.payment(10000, today.AddDate(0, 0, -15), "CHECK", account.ApplyLine{InvoiceID: inv.ID, AmountCents: 10000}) + app := w.appID(inv.ID, "PAYMENT") + w.mustAct("reverse", func(ctx context.Context) (*account.Effects, error) { + return w.svc.Reverse(ctx, account.ReverseIn{ApplicationID: app, Reason: "bounced", Actor: "u-test", On: today.AddDate(0, 0, -10)}) + }) + // a legacy row: no source kind, no document, its stamp is its day + w.exec(`INSERT INTO customer_transactions (id, customer_id, type, amount, balance_after, description, currency, created_at) + VALUES ($1, $2, 'ADJUSTMENT', 0, 0, 'legacy', 'USD', $3::date + TIME '12:00')`, uuid.New(), w.customerID, date(today.AddDate(0, 0, -5))) + // every stamp is the present, as the acts write them + w.exec(`UPDATE customer_transactions SET created_at = clock_timestamp() WHERE customer_id = $1 AND description <> 'legacy'`, w.customerID) + + st, err := w.svc.Statement(ctx, account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -60), To: today}) + if err != nil { + t.Fatal(err) + } + days := map[string]string{} + for _, l := range st.Currencies[0].Lines { + days[string(l.Type)] = l.Date + } + want := map[string]string{ + "INVOICE": date(today.AddDate(0, 0, -40)), "CREDIT_MEMO": date(today.AddDate(0, 0, -30)), + "REFUND": date(today.AddDate(0, 0, -20)), "PAYMENT": date(today.AddDate(0, 0, -15)), + "REVERSAL": date(today.AddDate(0, 0, -10)), "ADJUSTMENT": date(today.AddDate(0, 0, -5)), + } + for k, v := range want { + if days[k] != v { + t.Errorf("%s row is dated %q, want %s", k, days[k], v) + } + } + for i := 1; i < len(st.Currencies[0].Lines); i++ { + if st.Currencies[0].Lines[i].Date < st.Currencies[0].Lines[i-1].Date { + t.Errorf("rows out of day order at %d", i) + } + } +} diff --git a/core/internal/account/core.go b/core/internal/account/core.go index 47ec91c23..af2c65a6d 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -543,7 +543,15 @@ func (s *Service) RestampDay(ctx context.Context, invoiceID, paymentID uuid.UUID if paidAt.Before(invoicedAt) { return invalidField("paid_at", "a payment is not made before its invoice") } - if paidAt.After(s.now()) || invoicedAt.After(s.now()) { + if invoicedAt.After(s.now()) { + return invalidField("invoiced_at", "a day is not in the future") + } + if paymentID != uuid.Nil && !paidAt.After(invoicedAt) { + // a payment received the invoice's own day follows it by a second, so + // a statement orders the application behind its invoice + paidAt = invoicedAt.Add(time.Second) + } + if paidAt.After(s.now()) { return invalidField("paid_at", "a day is not in the future") } if paymentID != uuid.Nil { diff --git a/core/internal/app/seed/receivables.go b/core/internal/app/seed/receivables.go index cc0d8169e..90242425f 100644 --- a/core/internal/app/seed/receivables.go +++ b/core/internal/app/seed/receivables.go @@ -182,14 +182,14 @@ func dayOf(t time.Time) string { return t.Format("2006-01-02") } // seedToday is the seed's own business day: the default branch's calendar day // now, in its time zone (the day the AR core refuses money past). -func seedToday(db *sql.DB) time.Time { +func seedToday(db *sql.DB) (time.Time, error) { var d time.Time err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date`).Scan(&d) if err != nil { - return time.Now() + return time.Time{}, fmt.Errorf("the seed's business day was not read: %w", err) } - return d + return d, nil } // stampInvoice puts the documents' own days on the rows the base seed's acts @@ -200,8 +200,8 @@ func (r *receivables) stampInvoice(ctx context.Context, in seedInvoice, payID uu now := r.acct.Now() at := func(d time.Time) time.Time { t := time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.UTC) - if t.After(now) { - t = now + if t.After(now.Add(-time.Second)) { + t = now.Add(-time.Second) } return t } diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index a46f40eeb..4d95af5ed 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -814,7 +814,10 @@ func Run() { if branchID == uuid.Nil { branchID = kelMainID } - seedDay := seedToday(db) + seedDay, err := seedToday(db) + if err != nil { + log.Fatalf("Seed: %v", err) + } for i := 0; i < numOrders; i++ { status := "FULFILLED" r := rand.Float32() diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index beabd689b..482bea509 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,5 +427,5 @@ get an entry. | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | -| G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | +| G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). The same two causes (a check dated ahead now lands on the seed's day, so 196103 cents move from receivables to cash as of today; the restamp spreads each row's stamp from the seed's moment over its document's day) also move, in `clockwindow`: the thirty day `total_collected` 315919.09 to 77355.25, `today_revenue_change`, the revenue trend's day before yesterday 20202 to 1259336 and yesterday 10101 to 454006, and the trial balance (1010 Cash debit 31163382 to 31359485, 1020 receivables credit 31190099 to 31386202, 2200 Customer Deposits both sides, the balance sheet's Cash 31158382 to 31354485 and receivables 9181059 to 8984956); and in `gl_accounts_entries`: the two PAY-000013 payment entries leave the ahead dated position and about 125 entries' `created_at` and `updated_at` move to their documents' days (no amount, number or account moved). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | | G1-FIX (item 130, N12, N13 round 1) | AR core acts `Reverse`, `VoidPayment`, `PostInvoice`, `VoidInvoice`, `PostCreditMemo`, `VoidCreditMemo`; `GET /api/v1/ar/customers/{id}/statement` (new fields `unapplied_payments`, `unapplied_cents`, `net_balance_cents` on each currency, golden `account`) | The core accepted a reversal, a void, an invoice day or a credit memo day after the branch's business day, so the aging counted an application as live while the invoice read unpaid. The subledger writes a row when cash is applied, never on receipt, so a customer with a deposit and an open invoice read the full invoice on the statement and the net on the aging. | Each of those acts is refused with a 400 naming `reversed_on`, `voided_on`, `invoice_date` or `memo_date` when its day has not come. The day is the act's own document's branch's day in that branch's zone; the aging's default and the dashboard use the default branch's zone, so a branch east of the default may hold a day that is today there and a day ahead of the default aging for some hours. The statement lists each currency's payments still unapplied as of its last day (number, received day, amount), their sum, and the net balance (closing balance less unapplied cash), which equals the aging's total on that day. | From 575277651898cec51562a0bbb30a144456b5f234 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:33:56 -0700 Subject: [PATCH 21/34] Statement page test: type the unapplied payments of the fixture Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index c9ed1c844..c9d2ff642 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts @@ -63,7 +63,7 @@ function statement(customerId: string) { closing_balance_cents: 0, lines: [], open_documents: [], - unapplied_payments: [], + unapplied_payments: [] as { id: string; number: string; received_on: string; unapplied_cents: number }[], unapplied_cents: 0, net_balance_cents: 0, }, From 579b465fda58dee8defceafe2251aed1c3191405 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:19:54 -0700 Subject: [PATCH 22/34] AR: red leg, a pinned caller lists only its yard's unapplied payments and the balance stays whole Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../account/ar_statement_unapplied_test.go | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/core/internal/account/ar_statement_unapplied_test.go b/core/internal/account/ar_statement_unapplied_test.go index f4d9643a3..8c21c1815 100644 --- a/core/internal/account/ar_statement_unapplied_test.go +++ b/core/internal/account/ar_statement_unapplied_test.go @@ -14,6 +14,8 @@ import ( "testing" "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" ) func TestARStatementListsUnappliedCashAndStatesTheNetBalance(t *testing.T) { @@ -77,3 +79,65 @@ func TestARStatementListsUnappliedCashAndStatesTheNetBalance(t *testing.T) { t.Errorf("unapplied cash before it arrived %v, want 0", got) } } + +// The documents a statement lists follow the caller's reach; the balances +// cover the whole account (FROM-PRINCIPAL 117): a caller pinned to one yard +// sees only that yard's unapplied payments in the list, and the net balance +// of the whole account, which is the unwalled aging's total. +func TestARStatementUnappliedListFollowsTheWallAndTheBalanceDoesNot(t *testing.T) { + w := newARWorld(t) + today := w.businessDay() + w.invoice(100000, today.AddDate(0, 0, -20)) + own := w.payment(50000, today.AddDate(0, 0, -5), "CHECK") + + other := uuid.New() + w.exec(`INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, other, "UW-"+other.String()[:8]) + t.Cleanup(func() { + w.exec(`UPDATE payments SET branch_id = `+arBranch+` WHERE branch_id = $1`, other) + w.exec(`DELETE FROM locations WHERE id = $1`, other) + }) + otherPay := uuid.New() + w.mustAct("a check at another yard", func(ctx context.Context) (*account.Effects, error) { + _, fx, e := w.svc.RecordPayment(ctx, account.RecordPaymentIn{PaymentID: otherPay, CustomerID: w.customerID, BranchID: other, + Currency: "USD", Method: "CHECK", AmountCents: 7700, ReceivedOn: today.AddDate(0, 0, -4), Actor: "u-test"}) + return fx, e + }) + var ownNumber, otherNumber string + for id, dst := range map[uuid.UUID]*string{own: &ownNumber, otherPay: &otherNumber} { + if err := w.db.Pool.QueryRow(context.Background(), `SELECT number FROM payments WHERE id = $1`, id).Scan(dst); err != nil { + t.Fatal(err) + } + } + q := account.StatementQuery{CustomerID: w.customerID, From: today.AddDate(0, 0, -30), To: today} + + // pinned to the world's yard + pinned := middleware.WithBranchContext(context.Background(), &middleware.BranchContext{BranchID: &w.branchID, IsAdmin: true}) + st, err := w.svc.Statement(pinned, q) + if err != nil { + t.Fatal(err) + } + c := st.Currencies[0] + if len(c.UnappliedPayments) != 1 || c.UnappliedPayments[0].Number != ownNumber { + t.Errorf("a pinned caller lists %+v, want only its own yard's %s", c.UnappliedPayments, ownNumber) + } + if int64(c.UnappliedCents) != 57700 || int64(c.NetBalanceCents) != 100000-57700 { + t.Errorf("unapplied %d, net %d: the balances cover the whole account (57700, 42300)", c.UnappliedCents, c.NetBalanceCents) + } + + // an unwalled caller lists both and agrees with the unwalled aging + st, err = w.svc.Statement(context.Background(), q) + if err != nil { + t.Fatal(err) + } + c = st.Currencies[0] + if len(c.UnappliedPayments) != 2 { + t.Errorf("an unwalled caller lists %d payments, want 2", len(c.UnappliedPayments)) + } + sum, err := w.svc.AgingSummary(context.Background(), account.AgingQuery{AsOf: today, Basis: "due_date", CustomerID: &w.customerID}) + if err != nil { + t.Fatal(err) + } + if int64(sum.Totals[0].TotalCents) != int64(c.NetBalanceCents) { + t.Errorf("aging total %d, net balance %d", sum.Totals[0].TotalCents, c.NetBalanceCents) + } +} From 91d9809d3c1528d86381e7d9d240ae40ebb73cc8 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:20:32 -0700 Subject: [PATCH 23/34] Statement page: red leg, the page says its listed documents follow the yard and the balance covers the account Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index c9d2ff642..205b02281 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts @@ -228,6 +228,7 @@ describe('customer statement unapplied cash', () => { expect(body).toContain('2031-12-20') expect(body).toContain('$500.00') expect(body).toMatch(/net balance/i) + expect(body).toContain('Documents listed are those this yard can see; the balance covers the whole account') } finally { history.replaceState(null, '', '/') } From 9a4afd141d76ce0b4f07b5e7dabb1b2a38171283 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:20:42 -0700 Subject: [PATCH 24/34] AR: the unapplied payments list follows the caller's reach, the balances stay whole; WithClockForTest Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/core.go | 4 ++-- core/internal/account/statement.go | 13 +++++++++---- core/internal/order/fulfil_test.go | 2 +- docs/refactor/CONTRACT-CHANGES.md | 1 + .../desk/src/pages/reports/CustomerStatementPage.ts | 4 ++++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/core/internal/account/core.go b/core/internal/account/core.go index af2c65a6d..bbab1d457 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -56,10 +56,10 @@ func NewService(db *database.DB, glSvc *gl.Service, logger *slog.Logger) *Servic return &Service{db: db, gl: glSvc, logger: logger, now: time.Now} } -// WithClock sets the clock the business day of the acts is read on (the +// WithClockForTest sets the clock the business day of the acts is read on (the // service reads time.Now otherwise); a test that moves the time of a whole // flow sets the same clock on every service of the flow. -func (s *Service) WithClock(now func() time.Time) *Service { s.now = now; return s } +func (s *Service) WithClockForTest(now func() time.Time) *Service { s.now = now; return s } // conflict is a 409 with a blocker, the shape the refusals of section 9 take. func conflict(code, message string) *httpx.Error { diff --git a/core/internal/account/statement.go b/core/internal/account/statement.go index 602d20216..9b90fb84a 100644 --- a/core/internal/account/statement.go +++ b/core/internal/account/statement.go @@ -212,7 +212,7 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, return nil, err } pays, err := s.ex(ctx).Query(ctx, ` - SELECT p.id, p.number, p.currency, to_char(p.received_on, 'YYYY-MM-DD'), + SELECT p.id, p.number, p.currency, to_char(p.received_on, 'YYYY-MM-DD'), (`+wall("p", 4, 5)+`) AS visible, ROUND(p.amount * 100)::bigint - COALESCE((SELECT SUM(ROUND(a.amount * 100)::bigint) FROM ar_applications a WHERE a.payment_id = p.id AND a.kind = 'PAYMENT' AND a.applied_on <= $2::date AND (a.reversed_on IS NULL OR a.reversed_on > $2::date)), 0) @@ -221,7 +221,7 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, FROM payments p WHERE p.customer_id = $1 AND p.received_on <= $2::date AND (p.voided_on IS NULL OR p.voided_on > $2::date) AND ($3::uuid IS NULL OR p.project_id = $3) - ORDER BY p.received_on, p.number`, q.CustomerID, date(q.To), q.JobID) + ORDER BY p.received_on, p.number`, q.CustomerID, date(q.To), q.JobID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) if err != nil { return nil, fmt.Errorf("failed to read the unapplied cash: %w", err) } @@ -229,7 +229,8 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, var u UnappliedPayment var cur string var open int64 - if err := pays.Scan(&u.ID, &u.Number, &cur, &u.ReceivedOn, &open); err != nil { + var visible bool + if err := pays.Scan(&u.ID, &u.Number, &cur, &u.ReceivedOn, &visible, &open); err != nil { pays.Close() return nil, fmt.Errorf("failed to scan an unapplied payment: %w", err) } @@ -243,7 +244,11 @@ func (s *Service) Statement(ctx context.Context, q StatementQuery) (*Statement, byCur[cur] = sc order = append(order, cur) } - sc.UnappliedPayments = append(sc.UnappliedPayments, u) + // the documents listed follow the caller's reach; the balances cover + // the whole account behind the customer wall (FROM-PRINCIPAL 117) + if visible { + sc.UnappliedPayments = append(sc.UnappliedPayments, u) + } sc.UnappliedCents += u.UnappliedCents } pays.Close() diff --git a/core/internal/order/fulfil_test.go b/core/internal/order/fulfil_test.go index 73b743c72..3f0544fc0 100644 --- a/core/internal/order/fulfil_test.go +++ b/core/internal/order/fulfil_test.go @@ -34,7 +34,7 @@ func (f *fixture) withMoney() func(*order.Service) *order.Service { glSvc := gl.NewService(gl.NewRepository(f.db), nil, slog.Default()) acct := account.NewService(f.db, glSvc, slog.Default()) if f.now != nil { - acct.WithClock(f.now) + acct.WithClockForTest(f.now) } inv := invoice.NewService(invoice.NewRepository(f.db), glSvc, acct, f.db).WithAuditLog(audit.NewLogger(f.db)) return s.WithInvoices(inv).WithDeposits(acct) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 482bea509..c00cd0e94 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -429,3 +429,4 @@ get an entry. | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | | G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). The same two causes (a check dated ahead now lands on the seed's day, so 196103 cents move from receivables to cash as of today; the restamp spreads each row's stamp from the seed's moment over its document's day) also move, in `clockwindow`: the thirty day `total_collected` 315919.09 to 77355.25, `today_revenue_change`, the revenue trend's day before yesterday 20202 to 1259336 and yesterday 10101 to 454006, and the trial balance (1010 Cash debit 31163382 to 31359485, 1020 receivables credit 31190099 to 31386202, 2200 Customer Deposits both sides, the balance sheet's Cash 31158382 to 31354485 and receivables 9181059 to 8984956); and in `gl_accounts_entries`: the two PAY-000013 payment entries leave the ahead dated position and about 125 entries' `created_at` and `updated_at` move to their documents' days (no amount, number or account moved). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | | G1-FIX (item 130, N12, N13 round 1) | AR core acts `Reverse`, `VoidPayment`, `PostInvoice`, `VoidInvoice`, `PostCreditMemo`, `VoidCreditMemo`; `GET /api/v1/ar/customers/{id}/statement` (new fields `unapplied_payments`, `unapplied_cents`, `net_balance_cents` on each currency, golden `account`) | The core accepted a reversal, a void, an invoice day or a credit memo day after the branch's business day, so the aging counted an application as live while the invoice read unpaid. The subledger writes a row when cash is applied, never on receipt, so a customer with a deposit and an open invoice read the full invoice on the statement and the net on the aging. | Each of those acts is refused with a 400 naming `reversed_on`, `voided_on`, `invoice_date` or `memo_date` when its day has not come. The day is the act's own document's branch's day in that branch's zone; the aging's default and the dashboard use the default branch's zone, so a branch east of the default may hold a day that is today there and a day ahead of the default aging for some hours. The statement lists each currency's payments still unapplied as of its last day (number, received day, amount), their sum, and the net balance (closing balance less unapplied cash), which equals the aging's total on that day. | +| G1-FIX (item 130, N13 round 2) | `GET /api/v1/ar/customers/{id}/statement`, `unapplied_payments`, `unapplied_cents`, `net_balance_cents` (no shape change) | The unapplied payments list read through no branch wall, so a caller pinned to one yard saw another yard's payment number, day and amount. | The list follows the caller's reach, as the open documents do. `unapplied_cents` and `net_balance_cents` stay whole account behind the customer wall, as the closing balance does (the balance shows at every yard, the documents behind it follow grants), so a pinned caller's listed rows may sum to less than `unapplied_cents`; the page says so in one line. | diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index 452438705..38fcbb27b 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts @@ -360,6 +360,10 @@ export class CustomerStatementPage extends LitElement { ` : nothing} + ${cur.unapplied_payments.length > 0 || cur.open_documents.length > 0 ? html` +

Documents listed are those this yard can see; the balance covers the whole account.

+ ` : nothing} +

Closing Balance

From 0a337a0bfe3a43e2c22410eff16655d9950a1c1c Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:28:55 -0700 Subject: [PATCH 25/34] Contract changes: say the legacy counter return credit is the one exception to net equals aging Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index c00cd0e94..f9e4642b6 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -429,4 +429,4 @@ get an entry. | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | | G1-FIX (item 130, N12 and N13) | the base seed's receivables, seen by the `clockwindow`, `gl_accounts_entries` and `reporting_reports` goldens; AR core payment, apply, credit memo apply, write off and refund acts; `GET /api/v1/ar/customers/{id}/statement`; `GET /api/v1/ar/aging` cursor (no route or shape changes) | The base seed dated a paying check a fixed step after its invoice, so an invoice made late in the seed's span had a check from the future and the aging and the dashboard's Outstanding AR counted it open while the invoice read PAID. Every seeded subledger row and payment carried the moment the seed ran. The statement dated each row from that stamp. The core accepted any money day. The aging by customer answered 500 on a page that was not the last. | A check the rule would date later is received on the seed's own day, an invoice is never dated after it, and the seed restamps its rows to the documents' days (the moved goldens: the till's `total_collected` 313621.19 to 2162.38 and `transaction_count` 46 to 8, the dashboard's `outstanding_ar` 11056347 to 10860244, `today_revenue` 31349485 to 203603 and the ledger entries' payment dates). The same two causes (a check dated ahead now lands on the seed's day, so 196103 cents move from receivables to cash as of today; the restamp spreads each row's stamp from the seed's moment over its document's day) also move, in `clockwindow`: the thirty day `total_collected` 315919.09 to 77355.25, `today_revenue_change`, the revenue trend's day before yesterday 20202 to 1259336 and yesterday 10101 to 454006, and the trial balance (1010 Cash debit 31163382 to 31359485, 1020 receivables credit 31190099 to 31386202, 2200 Customer Deposits both sides, the balance sheet's Cash 31158382 to 31354485 and receivables 9181059 to 8984956); and in `gl_accounts_entries`: the two PAY-000013 payment entries leave the ahead dated position and about 125 entries' `created_at` and `updated_at` move to their documents' days (no amount, number or account moved). A payment received, an application, a credit memo application, a write off and a refund dated after the branch's business day are refused with a 400 naming the field. The statement dates an invoice by its invoice day, a credit memo by its memo day, a payment by its received day, an application by its applied day and a refund by its refunded day, and splits the opening balance, the period and the order on that day. The aging cursor of a customer grouping reads on the next page. | | G1-FIX (item 130, N12, N13 round 1) | AR core acts `Reverse`, `VoidPayment`, `PostInvoice`, `VoidInvoice`, `PostCreditMemo`, `VoidCreditMemo`; `GET /api/v1/ar/customers/{id}/statement` (new fields `unapplied_payments`, `unapplied_cents`, `net_balance_cents` on each currency, golden `account`) | The core accepted a reversal, a void, an invoice day or a credit memo day after the branch's business day, so the aging counted an application as live while the invoice read unpaid. The subledger writes a row when cash is applied, never on receipt, so a customer with a deposit and an open invoice read the full invoice on the statement and the net on the aging. | Each of those acts is refused with a 400 naming `reversed_on`, `voided_on`, `invoice_date` or `memo_date` when its day has not come. The day is the act's own document's branch's day in that branch's zone; the aging's default and the dashboard use the default branch's zone, so a branch east of the default may hold a day that is today there and a day ahead of the default aging for some hours. The statement lists each currency's payments still unapplied as of its last day (number, received day, amount), their sum, and the net balance (closing balance less unapplied cash), which equals the aging's total on that day. | -| G1-FIX (item 130, N13 round 2) | `GET /api/v1/ar/customers/{id}/statement`, `unapplied_payments`, `unapplied_cents`, `net_balance_cents` (no shape change) | The unapplied payments list read through no branch wall, so a caller pinned to one yard saw another yard's payment number, day and amount. | The list follows the caller's reach, as the open documents do. `unapplied_cents` and `net_balance_cents` stay whole account behind the customer wall, as the closing balance does (the balance shows at every yard, the documents behind it follow grants), so a pinned caller's listed rows may sum to less than `unapplied_cents`; the page says so in one line. | +| G1-FIX (item 130, N13 round 2) | `GET /api/v1/ar/customers/{id}/statement`, `unapplied_payments`, `unapplied_cents`, `net_balance_cents` (no shape change) | The unapplied payments list read through no branch wall, so a caller pinned to one yard saw another yard's payment number, day and amount. | The list follows the caller's reach, as the open documents do. `unapplied_cents` and `net_balance_cents` stay whole account behind the customer wall, as the closing balance does (the balance shows at every yard, the documents behind it follow grants), so a pinned caller's listed rows may sum to less than `unapplied_cents`; the page says so in one line. One exception stays: a legacy counter return credit (`source_kind` `pos_return`, written by code with no caller left) is a subledger row with no document, so the statement shows it and the aging does not; it touches rows already in a database only, and the net balance equals the aging for every row the code writes today. | From 8e19cbffd4f58e63dd557fff2f6b3e6be497819e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 17:03:03 -0700 Subject: [PATCH 26/34] Statement page: red legs, the scope is said whenever a yard is chosen and the unapplied cash at other yards is named Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../reports/CustomerStatementPage.test.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index 205b02281..19a337fa1 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts @@ -228,9 +228,60 @@ describe('customer statement unapplied cash', () => { expect(body).toContain('2031-12-20') expect(body).toContain('$500.00') expect(body).toMatch(/net balance/i) - expect(body).toContain('Documents listed are those this yard can see; the balance covers the whole account') } finally { history.replaceState(null, '', '/') } }) }) + +describe('customer statement scope at a yard', () => { + const BRANCH_KEY = 'gable_current_branch_id' + afterEach(() => localStorage.removeItem(BRANCH_KEY)) + + async function shown(cur: Record) { + const st = statement(CUSTOMER_ID) + st.currencies[0] = { ...st.currencies[0], ...cur } as never + stubRoutes({ + [`/api/v1/customers/${CUSTOMER_ID}`]: customer({ id: CUSTOMER_ID }), + [`/ar/customers/${CUSTOMER_ID}/statement`]: st, + }) + history.replaceState(null, '', `?customer=${CUSTOMER_ID}`) + const el = await mountAsync('gable-customer-statement') + for (let i = 0; i < 4; i++) { + await new Promise((r) => setTimeout(r, 0)) + await el.updateComplete + } + history.replaceState(null, '', '/') + return textOf(el) + } + + it('says the scope and names the unapplied cash at other yards when this yard sees none of it', async () => { + localStorage.setItem(BRANCH_KEY, 'b-yard-a') + const body = await shown({ closing_balance_cents: 100000, unapplied_payments: [], unapplied_cents: 7700, net_balance_cents: 92300, open_documents: [] }) + expect(body).toContain("Open documents and unapplied payments are this yard's; the activity and the balances cover the whole account") + expect(body).toMatch(/Unapplied cash at other yards:?\s*\$77\.00/) + expect(body).toContain('$923.00') + }) + + it('names only the part of the unapplied cash this yard cannot list', async () => { + localStorage.setItem(BRANCH_KEY, 'b-yard-a') + const body = await shown({ + closing_balance_cents: 100000, + unapplied_payments: [{ id: '00000000-0000-4000-8000-0000000000p1', number: 'PAY-000042', received_on: '2031-12-20', unapplied_cents: 50000 }], + unapplied_cents: 57700, net_balance_cents: 42300, + }) + expect(body).toContain('PAY-000042') + expect(body).toMatch(/Unapplied cash at other yards:?\s*\$77\.00/) + }) + + it('has no other yards row when everything is listed, and no scope line without a yard', async () => { + const body = await shown({ + closing_balance_cents: 100000, + unapplied_payments: [{ id: '00000000-0000-4000-8000-0000000000p1', number: 'PAY-000042', received_on: '2031-12-20', unapplied_cents: 50000 }], + unapplied_cents: 50000, net_balance_cents: 50000, + }) + expect(body).not.toMatch(/other yards/i) + expect(body).not.toContain('are this yard') + }) +}) + From 3121dcc2c86f9b3662fdeac19a9b352a2660c577 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 17:03:16 -0700 Subject: [PATCH 27/34] Statement page: the scope is said whenever a yard is chosen, and unapplied cash at other yards is named Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../pages/reports/CustomerStatementPage.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index 38fcbb27b..5ddc976ce 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts @@ -12,6 +12,7 @@ import { onBranchChanged } from '../../lib/branch-listener.ts'; import { LedgerOutsideYardError, ledgerYardNote } from '../../lib/ledgerYards.ts'; import type { ArStatement, ArStatementCurrency } from '../../types/account.ts'; import type { Customer } from '../../types/customer.ts'; +import { BRANCH_STORAGE_KEY } from '@gable/auth'; import { FileText, Search } from 'lucide'; @customElement('gable-customer-statement') @@ -247,6 +248,17 @@ export class CustomerStatementPage extends LitElement { `; } + /** A yard is chosen: the open documents and unapplied payments follow it, the balances do not. */ + private _scopedToYard(): boolean { + return !!localStorage.getItem(BRANCH_STORAGE_KEY); + } + + /** Unapplied cash the listed payments do not cover: it sits at yards this one cannot see. */ + private _unappliedElsewhere(cur: ArStatementCurrency): number { + const listed = cur.unapplied_payments.reduce((sum, p) => sum + p.unapplied_cents, 0); + return Math.max(0, cur.unapplied_cents - listed); + } + /** One currency's statement: never mixed with another's amounts. */ private _renderCurrency(cur: ArStatementCurrency) { const st = this.statement!; @@ -360,8 +372,15 @@ export class CustomerStatementPage extends LitElement {
` : nothing} - ${cur.unapplied_payments.length > 0 || cur.open_documents.length > 0 ? html` -

Documents listed are those this yard can see; the balance covers the whole account.

+ ${this._scopedToYard() ? html` +
+ ${this._unappliedElsewhere(cur) > 0 ? html` +

+ Unapplied cash at other yards: ${formatCents(this._unappliedElsewhere(cur))} +

+ ` : nothing} +

Open documents and unapplied payments are this yard's; the activity and the balances cover the whole account.

+
` : nothing}
From f63b34989d9e7458899d4ef8f98743bc7f54c6c9 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 17:21:52 -0700 Subject: [PATCH 28/34] AR tests: today is the default branch's day, so the legs hold in the hours when UTC's day is ahead The core refuses money dated after the branch's own day; a leg that dated today by the UTC clock was refused between UTC's midnight and the branch's. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/account/ar_test.go | 34 +++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/core/internal/account/ar_test.go b/core/internal/account/ar_test.go index 1986911cf..98d6c1a05 100644 --- a/core/internal/account/ar_test.go +++ b/core/internal/account/ar_test.go @@ -48,6 +48,7 @@ func newARWorld(t *testing.T) *arWorld { func newARWorldOn(t *testing.T, db *database.DB) *arWorld { t.Helper() + loadBranchZone(db) w := &arWorld{t: t, db: db, customerID: uuid.New()} w.svc = account.NewService(db, gl.NewService(gl.NewRepository(db), nil, slog.Default()), slog.Default()) ctx := context.Background() @@ -245,8 +246,39 @@ func (w *arWorld) netOf(code string, debitMinusCredit bool, upTo *time.Time) int // The world's documents. // --------------------------------------------------------------------------- +// daysFromNow is the business day n days from today in the default branch's +// zone, as a UTC midnight date. The AR core refuses money dated after the +// branch's own day, so a test that dated "today" by the UTC clock would be +// refused in the hours between UTC's midnight and the branch's. func daysFromNow(n int) time.Time { - return time.Now().UTC().AddDate(0, 0, n).Truncate(24 * time.Hour) + now := time.Now().In(testBranchZone()) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, n) +} + +var ( + branchZoneOnce sync.Once + branchZone = time.UTC +) + +// testBranchZone is the default branch's time zone, read once from the test +// database by the first world. +func testBranchZone() *time.Location { + return branchZone +} + +func loadBranchZone(db *database.DB) { + branchZoneOnce.Do(func() { + var name string + err := db.Pool.QueryRow(context.Background(), ` + SELECT COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC')`).Scan(&name) + if err != nil { + return + } + if loc, err := time.LoadLocation(name); err == nil { + branchZone = loc + } + }) } func date(t time.Time) string { return t.Format("2006-01-02") } From cc102d6056754dd7d5f713f3f01cfcc0230bc7c0 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 17:49:30 -0700 Subject: [PATCH 29/34] Seed: the history's today is the seed's business day Between UTC's midnight and the branch's, the UTC date is a day the AR core refuses, so the history's documents dated by it were not posted. The history now trades the default branch's calendar day, and its today check skips when the server's UTC day is ahead of it. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata.go | 14 +++++++++++++- core/internal/app/seed/demodata_test.go | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/core/internal/app/seed/demodata.go b/core/internal/app/seed/demodata.go index d679cbe19..5dbedc165 100644 --- a/core/internal/app/seed/demodata.go +++ b/core/internal/app/seed/demodata.go @@ -844,7 +844,14 @@ func seedHistory(ctx context.Context, db *sql.DB, ar *receivables, rng := rand.New(rand.NewSource(int64(hp.Seed))) now := time.Now().UTC() - anchor := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC) + // The history's today is the seed's business day, the default branch's + // calendar day: the AR core refuses a document dated past it, so between + // UTC's midnight and the branch's the UTC date is not today. + seedDay, err := seedToday(db) + if err != nil { + return err + } + anchor := time.Date(seedDay.Year(), seedDay.Month(), seedDay.Day(), 12, 0, 0, 0, time.UTC) // Today trades too, so the dashboard's day is a real one: its documents are // stamped from two hours before the run, never before the day began (the // server's UTC day), and are left out while the day is too young to hold @@ -854,6 +861,11 @@ func seedHistory(ctx context.Context, db *sql.DB, ar *receivables, todayFrom = dayStart } withToday := now.Sub(todayFrom) >= 10*time.Minute + if dayOf(now) != dayOf(seedDay) { + // the server's UTC day is ahead of the branch's: the branch's today + // trades at its midday, which has passed + todayFrom, withToday = anchor, true + } // The yards walk in code order (a map's order is random, and the history // must be the same for the same day), each with its weekday mean from the diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 527993652..f9a6c54d4 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -313,6 +313,14 @@ func TestDemoImport(t *testing.T) { if now.Weekday() == time.Sunday || now.Sub(dayStart) < 30*time.Minute { t.Skip("the yards are closed today, or the day is too young for the history to hold it") } + var branchDay string + if err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date::text`).Scan(&branchDay); err != nil { + t.Fatal(err) + } + if branchDay != now.Format("2006-01-02") { + t.Skip("the server's UTC day is ahead of the branch's: the history trades the branch's day, which this check does not read") + } keys := jsonlKeys(t, customersData, "account_number") var today int64 var future int From 8a496ce3fd211192848525ce7ba2308c65843835 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 18:13:47 -0700 Subject: [PATCH 30/34] Characterization: the seed's branches take the zone the server reads The seed's business day is its branches' calendar day. The harness seeded in the branches' own zone and pinned them to UTC afterwards, so between UTC's midnight and that zone's the seeded days and the server's day cut differed and clockwindow and gl_accounts_entries drifted. The seed now takes its branch zone from SEED_BRANCH_TIMEZONE (the production seed leaves it unset and keeps its zone) and the harness sets it to the server's zone. The goldens pass unchanged at an hour when UTC's date is ahead of the branch's. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/seed.go | 23 +++++++++++++++---- .../internal/characterization/harness_test.go | 4 ++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 4d95af5ed..0364a012d 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -24,6 +24,20 @@ import ( ) // recentDate returns a timestamp drawn from the last `daysBack` days. +// seedBranchZoneEnv names the time zone the seed gives the branches it writes. +// The production seed leaves it unset and keeps its own zone; the +// characterization run sets it to the zone its server reads, so the seed's +// business day (the branch's calendar day) and the server's day cut are the +// same day at every hour, not only outside the hours when UTC's date is ahead. +const seedBranchZoneEnv = "SEED_BRANCH_TIMEZONE" + +func seedBranchZone() string { + if z := os.Getenv(seedBranchZoneEnv); z != "" { + return z + } + return "America/Vancouver" +} + func recentDate(daysBack int) time.Time { return time.Now().AddDate(0, 0, -rand.Intn(daysBack)) } @@ -327,6 +341,7 @@ func Run() { // because parent_id is NULL for branches and NULL != NULL in unique indexes. // Pre-select instead: if a BRANCH with this code exists, UPDATE it; else INSERT. branchIDs := make(map[string]uuid.UUID) + branchZone := seedBranchZone() for _, b := range branches { var id string err := db.QueryRow(`SELECT id FROM locations WHERE type='BRANCH' AND code=$1 LIMIT 1`, b.Code).Scan(&id) @@ -334,9 +349,9 @@ func Run() { // Not found → insert. err = db.QueryRow(`INSERT INTO locations (id, code, type, description, path, name, address, city, state, zip, phone, tax_jurisdiction_code, default_tax_rate, timezone, active) - VALUES (gen_random_uuid(), $1, 'BRANCH', $2, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'America/Vancouver', TRUE) + VALUES (gen_random_uuid(), $1, 'BRANCH', $2, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE) RETURNING id`, - b.Code, b.Name, b.Addr, b.City, b.State, b.Zip, b.Phone, b.TaxJur, b.TaxRate).Scan(&id) + b.Code, b.Name, b.Addr, b.City, b.State, b.Zip, b.Phone, b.TaxJur, b.TaxRate, branchZone).Scan(&id) if err != nil { log.Printf("Branch %s insert: %v", b.Code, err) continue @@ -345,10 +360,10 @@ func Run() { // Found → update in place. _, err = db.Exec(`UPDATE locations SET description=$2, name=$2, address=$3, city=$4, state=$5, zip=$6, phone=$7, - tax_jurisdiction_code=$8, default_tax_rate=$9, timezone='America/Vancouver', + tax_jurisdiction_code=$8, default_tax_rate=$9, timezone=$10, active=TRUE, updated_at=NOW() WHERE id=$1`, - id, b.Name, b.Addr, b.City, b.State, b.Zip, b.Phone, b.TaxJur, b.TaxRate) + id, b.Name, b.Addr, b.City, b.State, b.Zip, b.Phone, b.TaxJur, b.TaxRate, branchZone) if err != nil { log.Printf("Branch %s update: %v", b.Code, err) } diff --git a/core/internal/characterization/harness_test.go b/core/internal/characterization/harness_test.go index 5e8199c07..74eec8129 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -259,6 +259,10 @@ func newHarness(t *testing.T, freshURL string) *harness { "DEMO_SEED": "1", "GODEBUG": "randautoseed=0", "TZ": "Etc/UTC", + // The seed's business day is its branches' calendar day: the branches + // take the zone the server reads, so the seeded days and the server's + // day cut agree at every hour of the UTC day. + "SEED_BRANCH_TIMEZONE": "Etc/UTC", }), buildDir+"/core", "", "seed") pinBranchZonesToUTC(t, freshURL) From e1ca81830ae02b18875401845a7242d8d9f3cd3f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 18:31:15 -0700 Subject: [PATCH 31/34] Order and payment tests: a deposit is received on the branch's own day Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/order/fulfil_deposit_test.go | 8 ++++++-- core/internal/payment/tx_test.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/core/internal/order/fulfil_deposit_test.go b/core/internal/order/fulfil_deposit_test.go index 5e3384f52..208895f25 100644 --- a/core/internal/order/fulfil_deposit_test.go +++ b/core/internal/order/fulfil_deposit_test.go @@ -37,9 +37,13 @@ func (f *fixture) deposit(orderID string, cents int64) uuid.UUID { acct := account.NewService(f.db, gl.NewService(gl.NewRepository(f.db), nil, slog.Default()), slog.Default()) var id uuid.UUID err := f.db.RunInTx(ctx, func(ctx context.Context) error { - var e error + // the branch's own day: the core refuses a day that has not come there + day, e := acct.LocalDate(ctx, branch, time.Now()) + if e != nil { + return e + } id, _, e = acct.RecordPayment(ctx, account.RecordPaymentIn{CustomerID: f.customerID, BranchID: branch, Currency: "USD", - Method: "CASH", AmountCents: cents, ReceivedOn: time.Now(), OrderID: &oid, Actor: "u-test"}) + Method: "CASH", AmountCents: cents, ReceivedOn: day, OrderID: &oid, Actor: "u-test"}) return e }) if err != nil { diff --git a/core/internal/payment/tx_test.go b/core/internal/payment/tx_test.go index b8cff8060..abfc4de21 100644 --- a/core/internal/payment/tx_test.go +++ b/core/internal/payment/tx_test.go @@ -193,9 +193,13 @@ func (w *txWorld) payment(amount int64, apps ...account.ApplyLine) uuid.UUID { w.t.Helper() var id uuid.UUID err := w.db.RunInTx(context.Background(), func(ctx context.Context) error { - var e error + // the branch's own day: the core refuses a day that has not come there + day, e := w.core.LocalDate(ctx, w.branch, time.Now()) + if e != nil { + return e + } id, _, e = w.core.RecordPayment(ctx, account.RecordPaymentIn{CustomerID: w.customer, BranchID: w.branch, Currency: "USD", - Method: "CASH", AmountCents: amount, ReceivedOn: time.Now(), Actor: "u-test", Applications: apps}) + Method: "CASH", AmountCents: amount, ReceivedOn: day, Actor: "u-test", Applications: apps}) return e }) if err != nil { From 3599d6a06a2451a5abbd05b245086925402c63d5 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 19:02:32 -0700 Subject: [PATCH 32/34] Tests: the legs that date money use the branch's day, read from the database payment, pos and migration legs date by the branch's day; the account legs read it from the database, so a zone Go cannot load (a day behind UTC at every hour) is read as the core reads it; the legacy row's stamp is noon in the branch's zone; the dashboard leg's yards sit in the default branch's zone. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../internal/account/ar_statement_day_test.go | 3 +- core/internal/account/ar_test.go | 48 +++++++++---------- .../dashboard/outstanding_ar_pg_test.go | 6 ++- core/internal/payment/migration101_test.go | 6 ++- core/internal/payment/tx_test.go | 22 +++++++-- core/internal/pos/void_credit_hold_test.go | 9 +++- 6 files changed, 59 insertions(+), 35 deletions(-) diff --git a/core/internal/account/ar_statement_day_test.go b/core/internal/account/ar_statement_day_test.go index 060306136..31c09c613 100644 --- a/core/internal/account/ar_statement_day_test.go +++ b/core/internal/account/ar_statement_day_test.go @@ -86,7 +86,8 @@ func TestARStatementDatesMemoRefundReversalAndLegacyRows(t *testing.T) { }) // a legacy row: no source kind, no document, its stamp is its day w.exec(`INSERT INTO customer_transactions (id, customer_id, type, amount, balance_after, description, currency, created_at) - VALUES ($1, $2, 'ADJUSTMENT', 0, 0, 'legacy', 'USD', $3::date + TIME '12:00')`, uuid.New(), w.customerID, date(today.AddDate(0, 0, -5))) + VALUES ($1, $2, 'ADJUSTMENT', 0, 0, 'legacy', 'USD', ($3::date + TIME '12:00') AT TIME ZONE + COALESCE((SELECT l.timezone FROM locations l WHERE l.id = `+arBranch+`), 'UTC'))`, uuid.New(), w.customerID, date(today.AddDate(0, 0, -5))) // every stamp is the present, as the acts write them w.exec(`UPDATE customer_transactions SET created_at = clock_timestamp() WHERE customer_id = $1 AND description <> 'legacy'`, w.customerID) diff --git a/core/internal/account/ar_test.go b/core/internal/account/ar_test.go index 98d6c1a05..0ea8287e1 100644 --- a/core/internal/account/ar_test.go +++ b/core/internal/account/ar_test.go @@ -247,38 +247,36 @@ func (w *arWorld) netOf(code string, debitMinusCredit bool, upTo *time.Time) int // --------------------------------------------------------------------------- // daysFromNow is the business day n days from today in the default branch's -// zone, as a UTC midnight date. The AR core refuses money dated after the -// branch's own day, so a test that dated "today" by the UTC clock would be -// refused in the hours between UTC's midnight and the branch's. +// zone, as a UTC midnight date, read from the database as the production code +// reads it (a POSIX zone name Go cannot load is read the same). The AR core +// refuses money dated after the branch's own day, so a test that dated "today" +// by the process clock would be refused in the hours between UTC's midnight +// and the branch's. func daysFromNow(n int) time.Time { - now := time.Now().In(testBranchZone()) - return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, n) + branchDayMu.Lock() + db := branchDayDB + branchDayMu.Unlock() + if db == nil { + panic("daysFromNow before a world was made") + } + var day time.Time + if err := db.Pool.QueryRow(context.Background(), `SELECT `+testutil.BranchDaySQL).Scan(&day); err != nil { + panic(fmt.Sprintf("the branch's day: %v", err)) + } + return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, n) } var ( - branchZoneOnce sync.Once - branchZone = time.UTC + branchDayMu sync.Mutex + branchDayDB *database.DB ) -// testBranchZone is the default branch's time zone, read once from the test -// database by the first world. -func testBranchZone() *time.Location { - return branchZone -} - +// loadBranchZone remembers the test database of the world being made: the +// pool of an earlier world is closed with its test. func loadBranchZone(db *database.DB) { - branchZoneOnce.Do(func() { - var name string - err := db.Pool.QueryRow(context.Background(), ` - SELECT COALESCE((SELECT l.timezone FROM locations l - WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC')`).Scan(&name) - if err != nil { - return - } - if loc, err := time.LoadLocation(name); err == nil { - branchZone = loc - } - }) + branchDayMu.Lock() + branchDayDB = db + branchDayMu.Unlock() } func date(t time.Time) string { return t.Format("2006-01-02") } diff --git a/core/internal/dashboard/outstanding_ar_pg_test.go b/core/internal/dashboard/outstanding_ar_pg_test.go index 60eb63b91..ca6787952 100644 --- a/core/internal/dashboard/outstanding_ar_pg_test.go +++ b/core/internal/dashboard/outstanding_ar_pg_test.go @@ -58,7 +58,11 @@ func outstandingARLeg(t *testing.T) { yardA, yardB, customer := uuid.New(), uuid.New(), uuid.New() for _, b := range []uuid.UUID{yardA, yardB} { if _, err := db.Pool.Exec(ctx, - `INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, b, "OAR-"+b.String()[:8]); err != nil { + // the yards sit in the default branch's zone: the AR core dates a + // payment by the payment's own branch's day + `INSERT INTO locations (id, type, code, timezone) VALUES ($1, 'BRANCH', $2, + COALESCE((SELECT l.timezone FROM locations l WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))`, + b, "OAR-"+b.String()[:8]); err != nil { t.Fatalf("seed yard: %v", err) } } diff --git a/core/internal/payment/migration101_test.go b/core/internal/payment/migration101_test.go index 7a7fc277f..7ad15c56f 100644 --- a/core/internal/payment/migration101_test.go +++ b/core/internal/payment/migration101_test.go @@ -1001,7 +1001,11 @@ func TestMigration101_MigratedApplicationsReverse(t *testing.T) { if err != nil { return err } - _, err = svc.Reverse(ctx, account.ReverseIn{ApplicationID: id, Reason: "migrated, undone", Actor: "u-finance", On: time.Now()}) + var day time.Time + if err := db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+testutil.BranchDaySQL).Scan(&day); err != nil { + return err + } + _, err = svc.Reverse(ctx, account.ReverseIn{ApplicationID: id, Reason: "migrated, undone", Actor: "u-finance", On: day}) return err }) if err != nil { diff --git a/core/internal/payment/tx_test.go b/core/internal/payment/tx_test.go index abfc4de21..303b99361 100644 --- a/core/internal/payment/tx_test.go +++ b/core/internal/payment/tx_test.go @@ -178,8 +178,12 @@ func (w *txWorld) invoice(total int64) uuid.UUID { VALUES ($1, $2, $3, 'UNPAID', 'POS', $4, 'USD', $5::bigint::numeric / 100, $5::bigint::numeric / 100, 0, 0, $5::bigint::numeric / 100, CURRENT_DATE + 30, CURRENT_DATE - 5)`, id, w.customer, w.branch, "IN-"+id.String()[:8], total) err := w.db.RunInTx(context.Background(), func(ctx context.Context) error { - _, e := w.core.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: id, CustomerID: w.customer, Number: "IN-" + id.String()[:8], - Currency: "USD", TotalCents: total, On: time.Now(), Actor: "u-test", + day, e := w.core.LocalDate(ctx, w.branch, time.Now()) + if e != nil { + return e + } + _, e = w.core.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: id, CustomerID: w.customer, Number: "IN-" + id.String()[:8], + Currency: "USD", TotalCents: total, On: day, Actor: "u-test", Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Credit: total}}}) return e }) @@ -215,8 +219,12 @@ func (w *txWorld) memo(credit int64) uuid.UUID { total_amount, status, memo_date) VALUES ($1, $2, $3, 'USD', 'RETURN', 'returned goods', 0, 0, 0, 0, 'DRAFT', CURRENT_DATE)`, id, w.customer, w.branch) err := w.db.RunInTx(context.Background(), func(ctx context.Context) error { - _, e := w.core.PostCreditMemo(ctx, account.PostCreditMemoIn{MemoID: id, CustomerID: w.customer, Number: "CM-" + id.String()[:8], - Currency: "USD", MemoDate: time.Now(), SubtotalCents: -credit, TotalCents: -credit, Actor: "u-test", + day, e := w.core.LocalDate(ctx, w.branch, time.Now()) + if e != nil { + return e + } + _, e = w.core.PostCreditMemo(ctx, account.PostCreditMemoIn{MemoID: id, CustomerID: w.customer, Number: "CM-" + id.String()[:8], + Currency: "USD", MemoDate: day, SubtotalCents: -credit, TotalCents: -credit, Actor: "u-test", Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Debit: credit}}}) return e }) @@ -229,7 +237,11 @@ func (w *txWorld) memo(credit int64) uuid.UUID { func (w *txWorld) application(pay, inv uuid.UUID, cents int64) uuid.UUID { w.t.Helper() err := w.db.RunInTx(context.Background(), func(ctx context.Context) error { - _, e := w.core.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: inv, AmountCents: cents}}, On: time.Now(), Actor: "u-test"}) + day, e := w.core.LocalDate(ctx, w.branch, time.Now()) + if e != nil { + return e + } + _, e = w.core.Apply(ctx, account.ApplyIn{PaymentID: pay, Lines: []account.ApplyLine{{InvoiceID: inv, AmountCents: cents}}, On: day, Actor: "u-test"}) return e }) if err != nil { diff --git a/core/internal/pos/void_credit_hold_test.go b/core/internal/pos/void_credit_hold_test.go index 71967362b..b1a20913a 100644 --- a/core/internal/pos/void_credit_hold_test.go +++ b/core/internal/pos/void_credit_hold_test.go @@ -40,8 +40,13 @@ func (f *fixture) openWalkInInvoice(t *testing.T, total int64) string { 0, 0, $5::bigint::numeric / 100, CURRENT_DATE + 30, CURRENT_DATE - 5)`, id, walkIn, f.branchID, number, total) acct := account.NewService(f.db, gl.NewService(gl.NewRepository(f.db), nil, slog.Default()), slog.Default()) if err := f.db.RunInTx(context.Background(), func(ctx context.Context) error { - _, err := acct.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: id, CustomerID: uuid.MustParse(walkIn), - Number: number, Currency: "USD", TotalCents: total, On: time.Now(), Actor: "u-test", + // the branch's own day: the core refuses a day that has not come there + day, err := acct.LocalDate(ctx, f.branchID, time.Now()) + if err != nil { + return err + } + _, err = acct.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: id, CustomerID: uuid.MustParse(walkIn), + Number: number, Currency: "USD", TotalCents: total, On: day, Actor: "u-test", Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Credit: total}}}) return err }); err != nil { From 1d5741e07905013aad55aba4e94cdd626e92799b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 19:03:25 -0700 Subject: [PATCH 33/34] Statement page: red leg, the other yards row shows for a caller scoped by grants with no yard chosen Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../src/pages/reports/CustomerStatementPage.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index 19a337fa1..990dc6b26 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts @@ -274,6 +274,16 @@ describe('customer statement scope at a yard', () => { expect(body).toMatch(/Unapplied cash at other yards:?\s*\$77\.00/) }) + it('names the unapplied cash at other yards for a caller scoped by grants with no yard chosen', async () => { + const body = await shown({ + closing_balance_cents: 100000, + unapplied_payments: [{ id: '00000000-0000-4000-8000-0000000000p1', number: 'PAY-000042', received_on: '2031-12-20', unapplied_cents: 50000 }], + unapplied_cents: 57700, net_balance_cents: 42300, + }) + expect(body).toMatch(/Unapplied cash at other yards:?\s*\$77\.00/) + expect(body).not.toContain('are this yard') + }) + it('has no other yards row when everything is listed, and no scope line without a yard', async () => { const body = await shown({ closing_balance_cents: 100000, From 6c3c6a6c4a45ee09884ad42ebf2bdc0211650c7e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 19:03:41 -0700 Subject: [PATCH 34/34] Seed: a clock the seed reads for its business day and stamps, validated settings; the page names unapplied cash at other yards for any scoped caller SEED_NOW is set by tests and unset in production. Both seed runs of TestDemoImport read one instant. SEED_BRANCH_TIMEZONE and SEED_NOW are checked at start and refused in words. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/clock.go | 64 ++++++++++++++++ core/internal/app/seed/clock_test.go | 73 +++++++++++++++++++ core/internal/app/seed/demodata.go | 18 +---- core/internal/app/seed/demodata_test.go | 19 +++++ core/internal/app/seed/multi_yard.go | 3 +- core/internal/app/seed/receivables.go | 6 +- core/internal/app/seed/seed.go | 9 ++- .../pages/reports/CustomerStatementPage.ts | 6 +- 8 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 core/internal/app/seed/clock.go create mode 100644 core/internal/app/seed/clock_test.go diff --git a/core/internal/app/seed/clock.go b/core/internal/app/seed/clock.go new file mode 100644 index 000000000..d86cd6e1d --- /dev/null +++ b/core/internal/app/seed/clock.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package seed + +import ( + "fmt" + "os" + "time" +) + +// seedNowEnv names the instant a test gives the seed to read as "now" for its +// business day, its history window and every stamp it writes (RFC 3339). The +// production seed leaves it unset and reads the real clock. A test that seeds +// twice sets it once, so the two runs share one instant and a boundary between +// them (the branch's midnight or UTC's) cannot move the second history. +const seedNowEnv = "SEED_NOW" + +// seedNow is the seed's clock: the instant SEED_NOW names, else the real time. +func seedNow() time.Time { + if raw := os.Getenv(seedNowEnv); raw != "" { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t.UTC() + } + } + return time.Now().UTC() +} + +// checkSeedEnv refuses, in words and before anything is written, a seed +// setting that is not valid: a clock that is not RFC 3339 or a branch zone +// that is not a zone name the database and Go both know. +func checkSeedEnv() error { + if raw := os.Getenv(seedNowEnv); raw != "" { + if _, err := time.Parse(time.RFC3339, raw); err != nil { + return fmt.Errorf("%s is %q: it must be an instant in RFC 3339, for example 2031-01-15T03:30:00Z", seedNowEnv, raw) + } + } + if z := os.Getenv(seedBranchZoneEnv); z != "" { + if _, err := time.LoadLocation(z); err != nil { + return fmt.Errorf("%s is %q: it must be a time zone name such as America/Vancouver or Etc/UTC", seedBranchZoneEnv, z) + } + } + return nil +} + +// historyFrame is the history's anchor (noon of the seed's business day), the +// moment today's documents are stamped from, and whether today trades at all. +// seedDay is the branch's calendar day at now. Today's documents are stamped +// from two hours before the run, never before the UTC day began, and are left +// out while the day is too young to hold them. When the UTC date is ahead of +// the branch's, the branch's today trades from its midday, which has passed. +func historyFrame(now, seedDay time.Time) (anchor, todayFrom time.Time, withToday bool) { + now = now.UTC() + anchor = time.Date(seedDay.Year(), seedDay.Month(), seedDay.Day(), 12, 0, 0, 0, time.UTC) + todayFrom = now.Add(-2 * time.Hour) + if dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC); todayFrom.Before(dayStart) { + todayFrom = dayStart + } + withToday = now.Sub(todayFrom) >= 10*time.Minute + if dayOf(now) != dayOf(seedDay) { + todayFrom, withToday = anchor, true + } + return anchor, todayFrom, withToday +} diff --git a/core/internal/app/seed/clock_test.go b/core/internal/app/seed/clock_test.go new file mode 100644 index 000000000..c780c0033 --- /dev/null +++ b/core/internal/app/seed/clock_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package seed + +import ( + "testing" + "time" +) + +func TestSeedNowReadsItsInstantAndKeepsTheRealClockWhenUnset(t *testing.T) { + t.Setenv(seedNowEnv, "2031-01-15T03:30:00Z") + if got := seedNow(); !got.Equal(time.Date(2031, 1, 15, 3, 30, 0, 0, time.UTC)) { + t.Errorf("seedNow = %s, want the instant the environment names", got) + } + t.Setenv(seedNowEnv, "") + before := time.Now().UTC() + if got := seedNow(); got.Before(before.Add(-time.Second)) || got.After(time.Now().UTC().Add(time.Second)) { + t.Errorf("seedNow = %s with no instant set, want the real clock", got) + } +} + +func TestCheckSeedEnvRefusesABadInstantAndABadZoneInWords(t *testing.T) { + t.Setenv(seedNowEnv, "tomorrow") + if err := checkSeedEnv(); err == nil { + t.Error("an instant that is not RFC 3339 was accepted") + } + t.Setenv(seedNowEnv, "") + t.Setenv(seedBranchZoneEnv, "Mars/Olympus") + if err := checkSeedEnv(); err == nil { + t.Error("a zone name nobody knows was accepted") + } + t.Setenv(seedBranchZoneEnv, "Etc/UTC") + if err := checkSeedEnv(); err != nil { + t.Errorf("a zone name Go knows was refused: %v", err) + } +} + +// The history's frame is a function of the instant and the seed's day alone: +// two seed runs at one instant read the same frame, and the instants either +// side of the branch's midnight and of UTC's give the same frame at each, so a +// boundary between two runs of one test cannot move the second. +func TestHistoryFrameIsOneFunctionOfTheInstant(t *testing.T) { + day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + cases := []struct { + name string + now time.Time + seedDay time.Time + }{ + {"just before UTC's midnight", time.Date(2031, 1, 14, 23, 59, 0, 0, time.UTC), day(2031, 1, 14)}, + {"just after UTC's midnight, the branch a day behind", time.Date(2031, 1, 15, 0, 1, 0, 0, time.UTC), day(2031, 1, 14)}, + {"just before the branch's midnight", time.Date(2031, 1, 15, 4, 59, 0, 0, time.UTC), day(2031, 1, 14)}, + {"just after the branch's midnight", time.Date(2031, 1, 15, 5, 1, 0, 0, time.UTC), day(2031, 1, 15)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a1, f1, w1 := historyFrame(c.now, c.seedDay) + a2, f2, w2 := historyFrame(c.now, c.seedDay) + if !a1.Equal(a2) || !f1.Equal(f2) || w1 != w2 { + t.Fatalf("one instant gave two frames") + } + if !w1 { + t.Errorf("today does not trade") + } + if dayOf(a1) != dayOf(c.seedDay) { + t.Errorf("anchor %s is not the seed's day %s", a1, dayOf(c.seedDay)) + } + if f1.After(c.now) { + t.Errorf("today's documents start at %s, after the instant %s", f1, c.now) + } + }) + } +} diff --git a/core/internal/app/seed/demodata.go b/core/internal/app/seed/demodata.go index 5dbedc165..9d19ffa7c 100644 --- a/core/internal/app/seed/demodata.go +++ b/core/internal/app/seed/demodata.go @@ -843,7 +843,7 @@ func seedHistory(ctx context.Context, db *sql.DB, ar *receivables, } rng := rand.New(rand.NewSource(int64(hp.Seed))) - now := time.Now().UTC() + now := seedNow() // The history's today is the seed's business day, the default branch's // calendar day: the AR core refuses a document dated past it, so between // UTC's midnight and the branch's the UTC date is not today. @@ -851,21 +851,7 @@ func seedHistory(ctx context.Context, db *sql.DB, ar *receivables, if err != nil { return err } - anchor := time.Date(seedDay.Year(), seedDay.Month(), seedDay.Day(), 12, 0, 0, 0, time.UTC) - // Today trades too, so the dashboard's day is a real one: its documents are - // stamped from two hours before the run, never before the day began (the - // server's UTC day), and are left out while the day is too young to hold - // them before the run. - todayFrom := now.Add(-2 * time.Hour) - if dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC); todayFrom.Before(dayStart) { - todayFrom = dayStart - } - withToday := now.Sub(todayFrom) >= 10*time.Minute - if dayOf(now) != dayOf(seedDay) { - // the server's UTC day is ahead of the branch's: the branch's today - // trades at its midday, which has passed - todayFrom, withToday = anchor, true - } + anchor, todayFrom, withToday := historyFrame(now, seedDay) // The yards walk in code order (a map's order is random, and the history // must be the same for the same day), each with its weekday mean from the diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index f9a6c54d4..45905e2dd 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -16,6 +16,7 @@ import ( "os" "os/exec" "strings" + "sync" "testing" "time" @@ -796,6 +797,21 @@ func demoImportDB(t *testing.T) (*sql.DB, string) { return db, scratch.String() } +const demoSeedInstantEnv = "SEED_NOW" + +var demoInstant struct { + sync.Once + at string +} + +// demoSeedInstant is the one instant the package's seed runs read: the first +// time a test asks. +func demoSeedInstant(t *testing.T) string { + t.Helper() + demoInstant.Do(func() { demoInstant.at = time.Now().UTC().Format(time.RFC3339) }) + return demoInstant.at +} + // runCommand runs one of the repository's commands (by its package path // under core/, where the commands run so migrate finds migrations/) on the // scratch database with the demo seed and its import asked for. @@ -804,6 +820,9 @@ func runCommand(t *testing.T, dbURL, pkg string) { cmd := exec.Command("go", "run", pkg) cmd.Dir = "../../.." cmd.Env = append(os.Environ(), + // every seed run of one test reads the same instant, so a boundary + // between two runs cannot move the second history + demoSeedInstantEnv+"="+demoSeedInstant(t), "DATABASE_URL="+dbURL, demoSeedEnv+"=1", demoImportEnv+"=1", diff --git a/core/internal/app/seed/multi_yard.go b/core/internal/app/seed/multi_yard.go index 7dd545f66..1808f3b10 100644 --- a/core/internal/app/seed/multi_yard.go +++ b/core/internal/app/seed/multi_yard.go @@ -18,7 +18,6 @@ import ( "fmt" "log" "os" - "time" "github.com/google/uuid" ) @@ -173,7 +172,7 @@ func seedMultiYard(ctx context.Context, db *sql.DB, ar *receivables, yards map[s db.QueryRow(`SELECT COUNT(*) FROM invoices WHERE customer_id = $1`, cid).Scan(&invCount) if invCount == 0 { orderID, invID := uuid.New(), uuid.New() - on := time.Now().AddDate(0, 0, -12) + on := seedNow().AddDate(0, 0, -12) seedExec(db, "Ridgeline order", `INSERT INTO orders (id, customer_id, branch_id, total_amount, subtotal, status, created_at, delivery_type, currency, tax_source) VALUES ($1, $2, $3, 18816.00, 16800.00, 'FULFILLED', $4, 'PICKUP', COALESCE((SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD'), 'LEGACY')`, orderID, cid, branch, on) diff --git a/core/internal/app/seed/receivables.go b/core/internal/app/seed/receivables.go index 90242425f..3fd4c0cfc 100644 --- a/core/internal/app/seed/receivables.go +++ b/core/internal/app/seed/receivables.go @@ -34,7 +34,7 @@ func newReceivables(dbURL string) (*receivables, error) { return nil, fmt.Errorf("the seed's AR core could not connect: %w", err) } logger := slog.Default() - return &receivables{db: pdb, acct: account.NewService(pdb, gl.NewService(gl.NewRepository(pdb), nil, logger), logger)}, nil + return &receivables{db: pdb, acct: account.NewService(pdb, gl.NewService(gl.NewRepository(pdb), nil, logger), logger).WithClockForTest(seedNow)}, nil } func (r *receivables) close() { r.db.Close() } @@ -184,8 +184,8 @@ func dayOf(t time.Time) string { return t.Format("2006-01-02") } // now, in its time zone (the day the AR core refuses money past). func seedToday(db *sql.DB) (time.Time, error) { var d time.Time - err := db.QueryRow(`SELECT (NOW() AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l - WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date`).Scan(&d) + err := db.QueryRow(`SELECT ($1::timestamptz AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l + WHERE l.id::text = (SELECT value FROM system_settings WHERE key = 'default_branch_id')), 'UTC'))::date`, seedNow()).Scan(&d) if err != nil { return time.Time{}, fmt.Errorf("the seed's business day was not read: %w", err) } diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 0364a012d..18c21b570 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -23,7 +23,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -// recentDate returns a timestamp drawn from the last `daysBack` days. // seedBranchZoneEnv names the time zone the seed gives the branches it writes. // The production seed leaves it unset and keeps its own zone; the // characterization run sets it to the zone its server reads, so the seed's @@ -38,8 +37,9 @@ func seedBranchZone() string { return "America/Vancouver" } +// recentDate returns a timestamp drawn from the last `daysBack` days. func recentDate(daysBack int) time.Time { - return time.Now().AddDate(0, 0, -rand.Intn(daysBack)) + return seedNow().AddDate(0, 0, -rand.Intn(daysBack)) } // dedupeBranches resets the locations hierarchy when prior seed runs left @@ -252,6 +252,9 @@ func demoHistoryRequested() bool { // entry point: without DEMO_SEED set it logs and returns without touching // any database. func Run() { + if err := checkSeedEnv(); err != nil { + log.Fatalf("Seed: %v", err) + } // Checked before anything else, so an install that did not ask for demo // data needs no database and no credentials. // @@ -1088,7 +1091,7 @@ func Run() { if len(vehicleIDs) > 0 && len(driverIDs) > 0 && len(orderIDs) > 0 { for i := 0; i < 15; i++ { rStatus := routeStatuses[rand.Intn(len(routeStatuses))] - sDate := time.Now().AddDate(0, 0, -(1 + rand.Intn(60))) + sDate := seedNow().AddDate(0, 0, -(1 + rand.Intn(60))) vid := vehicleIDs[rand.Intn(len(vehicleIDs))] did := driverIDs[rand.Intn(len(driverIDs))] var rid string diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index 5ddc976ce..9a76de8b9 100644 --- a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts +++ b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts @@ -372,14 +372,16 @@ export class CustomerStatementPage extends LitElement {
` : nothing} - ${this._scopedToYard() ? html` + ${this._scopedToYard() || this._unappliedElsewhere(cur) > 0 ? html`
${this._unappliedElsewhere(cur) > 0 ? html`

Unapplied cash at other yards: ${formatCents(this._unappliedElsewhere(cur))}

` : nothing} -

Open documents and unapplied payments are this yard's; the activity and the balances cover the whole account.

+ ${this._scopedToYard() ? html` +

Open documents and unapplied payments are this yard's; the activity and the balances cover the whole account.

+ ` : nothing}
` : nothing}