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/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)) + } +} 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") + } + }) + } +} 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) + } +} 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) + } +} 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..31c09c613 --- /dev/null +++ b/core/internal/account/ar_statement_day_test.go @@ -0,0 +1,117 @@ +// 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" + "github.com/gablelbm/gable/internal/gl" + "github.com/google/uuid" +) + +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) + } +} + +// 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') 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) + + 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/ar_statement_unapplied_test.go b/core/internal/account/ar_statement_unapplied_test.go new file mode 100644 index 000000000..8c21c1815 --- /dev/null +++ b/core/internal/account/ar_statement_unapplied_test.go @@ -0,0 +1,143 @@ +// 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" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +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) + } +} + +// 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) + } +} diff --git a/core/internal/account/ar_test.go b/core/internal/account/ar_test.go index 0085996ba..0ea8287e1 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,37 @@ 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, 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 { - return time.Now().UTC().AddDate(0, 0, n).Truncate(24 * time.Hour) + 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 ( + branchDayMu sync.Mutex + branchDayDB *database.DB +) + +// 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) { + branchDayMu.Lock() + branchDayDB = db + branchDayMu.Unlock() } func date(t time.Time) string { return t.Format("2006-01-02") } @@ -836,10 +866,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..bbab1d457 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} } +// 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) 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 { return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, Message: message, @@ -67,6 +72,38 @@ 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 +} + +// 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 @@ -483,6 +520,57 @@ 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 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 { + 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_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 8a14a6fd5..62fc9c00a 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 { @@ -502,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: @@ -705,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") } @@ -811,6 +829,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 +886,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)) } 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] diff --git a/core/internal/account/statement.go b/core/internal/account/statement.go index a009dcd89..9b90fb84a 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. @@ -69,21 +84,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 @@ -120,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) } @@ -172,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) } @@ -182,8 +211,53 @@ 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'), (`+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) + - 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, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + 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 + 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) + } + 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) + } + // 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() + 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/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 d679cbe19..9d19ffa7c 100644 --- a/core/internal/app/seed/demodata.go +++ b/core/internal/app/seed/demodata.go @@ -843,17 +843,15 @@ 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) - // 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 + 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. + seedDay, err := seedToday(db) + if err != nil { + return err } - withToday := now.Sub(todayFrom) >= 10*time.Minute + 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 84f1f27ed..45905e2dd 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -16,10 +16,12 @@ import ( "os" "os/exec" "strings" + "sync" "testing" "time" "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/dashboard" "github.com/gablelbm/gable/internal/testutil" ) @@ -312,6 +314,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 @@ -521,6 +531,173 @@ 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("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 { + 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 @@ -620,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. @@ -628,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 151b39b28..3fd4c0cfc 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" @@ -33,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() } @@ -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, error) { + var d time.Time + 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) + } + return d, nil +} + +// 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.Add(-time.Second)) { + t = now.Add(-time.Second) + } + 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..18c21b570 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -23,9 +23,23 @@ import ( "golang.org/x/crypto/bcrypt" ) +// 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" +} + // 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 @@ -238,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. // @@ -327,6 +344,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 +352,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 +363,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) } @@ -814,6 +832,10 @@ func Run() { if branchID == uuid.Nil { branchID = kelMainID } + seedDay, err := seedToday(db) + if err != nil { + log.Fatalf("Seed: %v", err) + } for i := 0; i < numOrders; i++ { status := "FULFILLED" r := rand.Float32() @@ -874,6 +896,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 +911,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 +920,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))}) } } @@ -1064,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/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) 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/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/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/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/order/fulfil_test.go b/core/internal/order/fulfil_test.go index d1daf8ba7..3f0544fc0 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.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/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 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 b8cff8060..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 }) @@ -193,9 +197,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 { @@ -211,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 }) @@ -225,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/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_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 { 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 { diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..f9e4642b6 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,6 @@ 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). 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. 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. | diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.test.ts index 4456638ed..990dc6b26 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: [] as { id: string; number: string; received_on: string; unapplied_cents: number }[], + unapplied_cents: 0, + net_balance_cents: 0, }, ], } @@ -198,4 +201,97 @@ 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, '', '/') + } + }) +}) + +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('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, + 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') + }) +}) + diff --git a/web/apps/desk/src/pages/reports/CustomerStatementPage.ts b/web/apps/desk/src/pages/reports/CustomerStatementPage.ts index edf2ab973..f56877b28 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!; @@ -336,11 +348,54 @@ 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} + + ${this._scopedToYard() || this._unappliedElsewhere(cur) > 0 ? html` +
+ ${this._unappliedElsewhere(cur) > 0 ? html` +

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

+ ` : nothing} + ${this._scopedToYard() ? html` +

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

+ ` : nothing} +
+ ` : 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 */