Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cadc27e
AR: red leg, a payment or an application dated after the business day…
futurebuildai Oct 14, 2026
81ae384
AR: red leg, a statement dates each row from its document
futurebuildai Oct 14, 2026
42d9b1f
Seed: red leg, no money after the seed's day and the aging agrees wit…
futurebuildai Oct 14, 2026
ae95aa7
Dashboard: red leg, Outstanding AR equals the aging and the documents…
futurebuildai Oct 14, 2026
ffba260
AR: red leg, the aging by customer pages through its cursor
futurebuildai Oct 14, 2026
a497605
AR core: money is never dated after the branch's business day
futurebuildai Oct 14, 2026
65681fb
AR: the statement dates each row from its document
futurebuildai Oct 14, 2026
182f0e2
AR: the aging's cursor carries a customer row's empty group id
futurebuildai Oct 14, 2026
739dafa
Seed: no money after the seed's day, and the seed's rows carry their …
futurebuildai Oct 14, 2026
51d4dc7
Goldens and contract changes: the seed's money carries its documents'…
futurebuildai Oct 14, 2026
c3e5594
POS tests: a receipt day in the past stands for any day, money is nev…
futurebuildai Oct 14, 2026
d0cfed8
Contract changes: word the seed row without a duration
futurebuildai Oct 14, 2026
94a35e8
AR: red legs, every act that takes a day refuses one that has not com…
futurebuildai Oct 14, 2026
073820b
AR: red leg, the statement lists unapplied cash and states the net ba…
futurebuildai Oct 14, 2026
d2d7553
AR core: reversals, voids and the document acts refuse a day that has…
futurebuildai Oct 14, 2026
39984a4
Statement page: red leg, unapplied cash and the net balance are shown
futurebuildai Oct 14, 2026
6d32010
AR: the statement lists unapplied cash and states the net balance
futurebuildai Oct 14, 2026
2e5ee95
Order tests: the account service reads the same clock as the order se…
futurebuildai Oct 14, 2026
5e33f0b
AR: red leg, a payment stamped the invoice's day follows its invoice,…
futurebuildai Oct 14, 2026
87951a5
AR: a payment follows its invoice on the same day, the seed fails whe…
futurebuildai Oct 14, 2026
5752776
Statement page test: type the unapplied payments of the fixture
futurebuildai Oct 14, 2026
579b465
AR: red leg, a pinned caller lists only its yard's unapplied payments…
futurebuildai Oct 14, 2026
91d9809
Statement page: red leg, the page says its listed documents follow th…
futurebuildai Oct 14, 2026
9a4afd1
AR: the unapplied payments list follows the caller's reach, the balan…
futurebuildai Oct 14, 2026
0a337a0
Contract changes: say the legacy counter return credit is the one exc…
futurebuildai Oct 14, 2026
8e19cbf
Statement page: red legs, the scope is said whenever a yard is chosen…
futurebuildai Oct 15, 2026
3121dcc
Statement page: the scope is said whenever a yard is chosen, and unap…
futurebuildai Oct 15, 2026
f63b349
AR tests: today is the default branch's day, so the legs hold in the …
futurebuildai Oct 15, 2026
cc102d6
Seed: the history's today is the seed's business day
futurebuildai Oct 15, 2026
8a496ce
Characterization: the seed's branches take the zone the server reads
futurebuildai Oct 15, 2026
e1ca818
Order and payment tests: a deposit is received on the branch's own day
futurebuildai Oct 15, 2026
51f01f0
Merge origin/refactor/g1-dashboard-day into refactor/g1-ar-agree
futurebuildai Oct 15, 2026
3599d6a
Tests: the legs that date money use the branch's day, read from the d…
futurebuildai Oct 15, 2026
1d5741e
Statement page: red leg, the other yards row shows for a caller scope…
futurebuildai Oct 15, 2026
6c3c6a6
Seed: a clock the seed reads for its business day and stamps, validat…
futurebuildai Oct 15, 2026
bb86d7a
Merge origin/refactor/v1 into refactor/g1-ar-agree
futurebuildai Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion core/api/fragments/accounts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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]
Expand Down
19 changes: 18 additions & 1 deletion core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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]
Expand Down
62 changes: 62 additions & 0 deletions core/internal/account/aging_page_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
219 changes: 219 additions & 0 deletions core/internal/account/ar_day_acts_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading
Loading