Add user credit balance to absorb settlement overpay/round mismatch (prerequisite for #33) #41

Open
opened 2026-06-07 07:18:04 +00:00 by padreug · 0 comments
Owner

What

Libra has no account type for "the user paid more than they currently owe." Today this manifests as:

  • The operator counts the cash, runs /receivables/settle for that amount, and either: (a) the books drift quietly out of sync with reality (overpay just disappears into an unbalanced ledger), or (b) the proposed strict-matching fix in #33 returns 400, leaving the operator holding cash with no clean way to record it.

A per-user Liabilities:Credit:User-{id} account (or equivalent — naming TBD) gives libra a place to record "we owe this user X EUR going forward, against future obligations or refundable."

Why this is a prerequisite for #33

#33's clean fix requires the settlement endpoint to validate that cash matches what's being settled. Without a credit balance, any mismatch on the cash side has no honest accounting home — the only options are reject (400, operator stuck) or silently drift (which is what's happening today and is exactly what #33 was filed about). Credit balance is the third option: settle what you can, absorb the rest as a recorded liability.

Established pattern (this is not novel)

AR/AP netting and customer credit are both textbook accounting patterns with strong precedent:

  • AR/AP netting has a name in every major ERP — "AP/AR offsetting" — and is exposed as a first-class feature in SAP S/4HANA, Oracle, NetSuite. Codified in ASC 210-20 (presentation may net same-counterparty AR and AP when right of set-off exists and intent to settle on a net basis is present — trivially true for libra's collective use case).
  • Customer credit is universally treated as a liability (or contra-asset) account until refunded or applied to future obligations. Named variously "Customer Deposits" (NetSuite/QuickBooks/Xero), "Unearned Revenue" (the Beancount community's recommended pattern), or just "Customer Credit." The principle that "the receiver cannot keep an overpayment, as it is neither revenue nor income" is what makes this not optional — silent drift is not a valid accounting answer.

So Liabilities:Credit:User-{id_short} is a direct application of the Beancount-recommended pattern with libra's existing per-user naming convention. No invention required.

Concrete behaviour the credit account enables

Nancy's #33 scenario, expanded:

Scenario Receivables open Payables open Cash paid Outcome with credit account
Exact net 100 EUR 50 EUR 50 EUR 3-leg settlement zeros both per-user accounts. Credit untouched. (#33's headline case)
Overpay net 100 EUR 50 EUR 70 EUR 3-leg settlement zeros both per-user accounts + 20 EUR moves to Liabilities:Credit:User-X.
Partial subset 100 EUR (rcv-50, rcv-30, rcv-20) 0 50 EUR Operator picks settled_entry_links=[rcv-50]. 2-leg settles that entry exactly. Other two stay open. No credit involved.
Subset + change 100 EUR (rcv-50, rcv-30, rcv-20) 0 90 EUR Operator picks [rcv-50, rcv-30] (sum 80). 2-leg settles those two + 10 EUR moves to credit. rcv-20 stays open.
Future settlement uses credit 0 receivable 0 payable, 40 EUR credit New 30 EUR receivable Operator runs settle with cash = 0, the 30 EUR comes out of credit. (Out of scope for first version; tracked separately.)

Sketch of the data model

  • New account type slot (or reuse existing LIABILITY with a structured name): Liabilities:Credit:User-{id_short} auto-created on first use, like Payable and Receivable.
  • Settlement endpoint adds a credit-overflow leg to the transaction when cash > what's being settled.

Display contract — applies to BOTH libra extension and webapp

This is the load-bearing surface for users, so the credit account is only meaningful if it flows into the displayed per-user balance:

  • GET /libra/api/v1/balance (and /balances/all) returns a per-user net that already accounts for credit:
    • Today: net_fiat[CCY] = sum(Receivable[X]) - sum(Payable[X])
    • After #41: net_fiat[CCY] = sum(Receivable[X]) - sum(Payable[X]) - sum(Credit[X])
    • Same sign convention as today: positive → user owes libra net; negative → libra owes user net (which is what credit, on its own, looks like).
  • Per-account breakdown in the response (the existing accounts: [...] array) gains a credit row when non-zero so the UI can render it as a distinct line item ("you have 40 EUR credit on file").
  • total_expenses_fiat and total_income_fiat are not affected — those track originally-entered lifetime totals per the existing models.py:93 convention. Credit is orthogonal: it's a settlement artefact, not an original entry.

UI surfaces that read this and need to render credit appropriately

  • libra extension UI (LNbits-hosted Vue/Quasar pages: Pending Approvals card, Outstanding Balances card, transactions table, admin dashboard) — already consumes /balance and /balances/all. Showing the credit-inclusive net keeps the existing "what does this user owe (or get back)" semantics correct. Per-account breakdown lets cards optionally surface "(includes 40 EUR credit)" subtext.
  • webapp (aiolabs/webappsrc/modules/expenses/services/ExpensesAPI.ts, TransactionsPage.vue, the Hub balance display) — same endpoint, same net. Webapp may want a separate "Your credit: X EUR" pill or section if the breakdown is in the response; that's an incremental UX task on webapp, not blocking #41.

The minimum bar for #41 to ship is: the displayed net everywhere already accounts for credit, so neither UI ever shows a stale number while credit is on the books. Surfacing credit as a distinct line is incremental polish on top.

Out of scope for this issue (file separately when needed)

  • Drawing from credit during a settlement. "User has 40 EUR credit, owes 30 EUR new receivable, no cash paid → settle from credit." Useful, but not blocking #33. Separate issue.
  • Refunding credit. Operator hands user 40 EUR cash back. Separate flow; could share code with /payables/pay.
  • Credit expiration / accounting policy. When does credit get written off if unclaimed? Policy decision.

Acceptance

  • Liabilities:Credit:User-{id} (final name TBD) gets auto-created on first overflow.
  • POST /receivables/settle with cash > settle_target writes the overflow leg.
  • GET /balance and /balances/all per-user net subtracts credit so the displayed balance is honest (positive → user owes net, negative → libra owes net including any credit).
  • Per-account breakdown surfaces a credit row when non-zero so libra extension UI + webapp can render it as a distinct line item.
  • Tests cover: exact-pay (no credit), overpay (credit appears, balance display reflects it), pure partial via explicit links (no credit).

Sequencing with #33

Best done together as a single PR — #33's fix is what makes the "cash != settle_target" path land somewhere, and the credit account is what makes it land somewhere correct. Test file tests/test_settlement_api.py (task #6 of the test plan) will exercise both together once they ship.

## What Libra has no account type for "the user paid more than they currently owe." Today this manifests as: - The operator counts the cash, runs `/receivables/settle` for that amount, and either: (a) the books drift quietly out of sync with reality (overpay just disappears into an unbalanced ledger), or (b) the proposed strict-matching fix in #33 returns 400, leaving the operator holding cash with no clean way to record it. A per-user `Liabilities:Credit:User-{id}` account (or equivalent — naming TBD) gives libra a place to record "we owe this user X EUR going forward, against future obligations or refundable." ## Why this is a prerequisite for #33 #33's clean fix requires the settlement endpoint to validate that cash matches what's being settled. Without a credit balance, any mismatch on the cash side has no honest accounting home — the only options are reject (400, operator stuck) or silently drift (which is what's happening today and is exactly what #33 was filed about). Credit balance is the third option: settle what you can, absorb the rest as a recorded liability. ## Established pattern (this is not novel) AR/AP netting and customer credit are both textbook accounting patterns with strong precedent: - **AR/AP netting** has a name in every major ERP — "AP/AR offsetting" — and is exposed as a first-class feature in SAP S/4HANA, Oracle, NetSuite. Codified in ASC 210-20 (presentation may net same-counterparty AR and AP when right of set-off exists and intent to settle on a net basis is present — trivially true for libra's collective use case). - **Customer credit** is universally treated as a liability (or contra-asset) account until refunded or applied to future obligations. Named variously "Customer Deposits" (NetSuite/QuickBooks/Xero), "Unearned Revenue" (the Beancount community's recommended pattern), or just "Customer Credit." The principle that **"the receiver cannot keep an overpayment, as it is neither revenue nor income"** is what makes this not optional — silent drift is not a valid accounting answer. So `Liabilities:Credit:User-{id_short}` is a direct application of the Beancount-recommended pattern with libra's existing per-user naming convention. No invention required. ## Concrete behaviour the credit account enables Nancy's #33 scenario, expanded: | Scenario | Receivables open | Payables open | Cash paid | Outcome with credit account | |---|---|---|---|---| | Exact net | 100 EUR | 50 EUR | 50 EUR | 3-leg settlement zeros both per-user accounts. Credit untouched. (#33's headline case) | | Overpay net | 100 EUR | 50 EUR | 70 EUR | 3-leg settlement zeros both per-user accounts + 20 EUR moves to `Liabilities:Credit:User-X`. | | Partial subset | 100 EUR (`rcv-50, rcv-30, rcv-20`) | 0 | 50 EUR | Operator picks `settled_entry_links=[rcv-50]`. 2-leg settles that entry exactly. Other two stay open. No credit involved. | | Subset + change | 100 EUR (`rcv-50, rcv-30, rcv-20`) | 0 | 90 EUR | Operator picks `[rcv-50, rcv-30]` (sum 80). 2-leg settles those two + 10 EUR moves to credit. `rcv-20` stays open. | | Future settlement uses credit | 0 receivable | 0 payable, 40 EUR credit | New 30 EUR receivable | Operator runs settle with cash = 0, the 30 EUR comes out of credit. (Out of scope for first version; tracked separately.) | ## Sketch of the data model - New account type slot (or reuse existing `LIABILITY` with a structured name): `Liabilities:Credit:User-{id_short}` auto-created on first use, like Payable and Receivable. - Settlement endpoint adds a credit-overflow leg to the transaction when cash > what's being settled. ## Display contract — applies to BOTH libra extension and webapp This is the load-bearing surface for users, so the credit account is only meaningful if it flows into the displayed per-user balance: - **`GET /libra/api/v1/balance`** (and `/balances/all`) returns a per-user net that already accounts for credit: - Today: `net_fiat[CCY] = sum(Receivable[X]) - sum(Payable[X])` - After #41: `net_fiat[CCY] = sum(Receivable[X]) - sum(Payable[X]) - sum(Credit[X])` - Same sign convention as today: positive → user owes libra net; negative → libra owes user net (which is what credit, on its own, looks like). - **Per-account breakdown** in the response (the existing `accounts: [...]` array) gains a credit row when non-zero so the UI can render it as a distinct line item ("you have 40 EUR credit on file"). - **`total_expenses_fiat` and `total_income_fiat` are not affected** — those track originally-entered lifetime totals per the existing `models.py:93` convention. Credit is orthogonal: it's a settlement artefact, not an original entry. ### UI surfaces that read this and need to render credit appropriately - **libra extension** UI (LNbits-hosted Vue/Quasar pages: Pending Approvals card, Outstanding Balances card, transactions table, admin dashboard) — already consumes `/balance` and `/balances/all`. Showing the credit-inclusive net keeps the existing "what does this user owe (or get back)" semantics correct. Per-account breakdown lets cards optionally surface "(includes 40 EUR credit)" subtext. - **webapp** (`aiolabs/webapp` — `src/modules/expenses/services/ExpensesAPI.ts`, `TransactionsPage.vue`, the Hub balance display) — same endpoint, same net. Webapp may want a separate "Your credit: X EUR" pill or section if the breakdown is in the response; that's an incremental UX task on webapp, not blocking #41. The minimum bar for #41 to ship is: **the displayed net everywhere already accounts for credit**, so neither UI ever shows a stale number while credit is on the books. Surfacing credit as a distinct line is incremental polish on top. ## Out of scope for this issue (file separately when needed) - **Drawing from credit during a settlement.** "User has 40 EUR credit, owes 30 EUR new receivable, no cash paid → settle from credit." Useful, but not blocking #33. Separate issue. - **Refunding credit.** Operator hands user 40 EUR cash back. Separate flow; could share code with `/payables/pay`. - **Credit expiration / accounting policy.** When does credit get written off if unclaimed? Policy decision. ## Acceptance - [ ] `Liabilities:Credit:User-{id}` (final name TBD) gets auto-created on first overflow. - [ ] `POST /receivables/settle` with cash > settle_target writes the overflow leg. - [ ] `GET /balance` and `/balances/all` per-user net subtracts credit so the displayed balance is honest (positive → user owes net, negative → libra owes net including any credit). - [ ] Per-account breakdown surfaces a credit row when non-zero so libra extension UI + webapp can render it as a distinct line item. - [ ] Tests cover: exact-pay (no credit), overpay (credit appears, balance display reflects it), pure partial via explicit links (no credit). ## Sequencing with #33 Best done together as a single PR — #33's fix is what makes the "cash != settle_target" path land somewhere, and the credit account is what makes it land somewhere correct. Test file `tests/test_settlement_api.py` (task #6 of the test plan) will exercise both together once they ship.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/libra#41
No description provided.