Skip to content

Cross-budget send: atomic categorized outflow + plain inflow between budgets #165

Description

@lj-n

Problem Statement

I keep two budgets: a personal one, and one shared with my partner. I regularly send money from my personal account to the shared account (household contribution, reimbursements). The Transfer feature (#129) only moves money between accounts of the same budget, so today I have to record a cross-budget movement by hand as two disconnected transactions in two different budgets — duplicated data entry, easy to get amounts or dates out of sync, easy to forget one side entirely.

Solution

From the existing transfer flow, let me pick a counterpart account from another budget I belong to. The app then atomically creates two ordinary, independent transactions:

  • a categorized outflow in the budget the money leaves — real spending against an envelope of that budget, because from that budget's perspective the money is gone;
  • a category-free inflow in the destination budget — ordinary income that lands in that budget's Unassigned, waiting to be assigned like any other income.

This is deliberately not a Transfer in the domain sense: a Transfer is budget-neutral and category-free, which is only coherent when both legs cancel inside one envelope system. A cross-budget send cannot be budget-neutral. After creation the two legs are unlinked: each budget's members edit, delete, and validate their leg like any other transaction. No sync, no cascade — once the money has moved, each budget's bookkeeping is its own business.

User Stories

  1. As a member of two budgets, I want to send money from an account in one budget to an account in another budget in a single form, so that I don't have to record two transactions manually in two places.
  2. As a sender, I want the outflow to require a category from the budget the money leaves, so that the departing money counts as envelope spending and that budget's Remaining and Unassigned stay correct.
  3. As a member of the destination budget, I want the arriving money to appear as ordinary income in Unassigned, so that I can assign it through the normal budgeting workflow.
  4. As a sender, I want both transactions created atomically, so that a failure can never leave one budget updated and the other not.
  5. As a user, I want the counterpart account picker to offer my accounts from other budgets in a clearly separated group (labelled by budget), so that I can tell a same-budget Transfer apart from a cross-budget send before submitting.
  6. As a user, I want the form to visibly change when my chosen counterpart lives in another budget — the required category field appears with a hint that the money leaves this budget as spending — so that it is never ambiguous whether a movement is budget-neutral.
  7. As a user, I want both directions to work through the existing sign semantics of the form, so that I can also record money arriving in the current account from another budget without switching budgets first.
  8. As a user pulling money into the current budget, I want the category picker to show the categories of the budget the money leaves, so that the required category is always meaningful.
  9. As a partner who is a member of only the destination budget, I want the inflow to be a completely ordinary transaction that I can edit, delete, and validate, so that my budget's bookkeeping never depends on a budget I cannot access.
  10. As a sender, I want the notes I type to be copied to both legs at creation, so that both sides carry context without retyping.
  11. As a privacy-conscious user, I want no auto-generated provenance text on the inflow, so that my personal budget's or account's name never leaks into the shared budget unless I write it myself.
  12. As a user, I want each leg's notes to be independently editable after creation, so that either side can adjust its own records.
  13. As a user, I want archived accounts excluded from the counterpart picker and rejected by the server, so that a stale tab cannot write to an inert account.
  14. As a user, I want only budgets where I am OWNER or MEMBER to be offered and accepted, so that an invitation alone grants no write access.
  15. As a sender, I want to enter one date that is applied to both legs (defaulting to today), so that both budgets record the movement on the same day.
  16. As a sender, I want to enter the amount once as a positive magnitude with the direction controlling the signs, so that the legs always mirror each other (negative outflow, positive inflow) at creation.
  17. As a user, I want each leg to be individually validatable, so that each account reconciles against its own statement.
  18. As a user, I want deleting one leg to leave the other untouched, so that correcting one budget's records never silently rewrites another budget.
  19. As a user, I want editing a leg to behave like editing any single transaction (no transfer-pair restrictions), so that the legs are genuinely ordinary transactions.
  20. As a user, I want the transaction lists of both affected budgets to reflect the send immediately after creation, so that I don't see stale registers.
  21. As a user with only one budget, I want the transfer form to look exactly as it does today (no "other budgets" group), so that the feature adds no noise when it doesn't apply.

Implementation Decisions

  • Domain naming. This is a new domain concept, distinct from Transfer (which CONTEXT.md defines as same-budget and budget-neutral). Working name: cross-budget send. Add a glossary entry to CONTEXT.md delineating it from Transfer, and record the decision as an ADR — it resolves the tension ADR-0015 explicitly anticipated ("transfers crossing the budget boundary would need a category").
  • No schema changes. Both legs are ordinary transactions: transferId stays NULL, the ADR-0015 CHECK constraint and composite budget FKs are untouched. Consequences accepted by design: the outflow shows no transfer badge, and neither leg matches the register's TRANSFER filter — the outflow is spending and the inflow is income.
  • New user-context command performing the atomic write. Invariants enforced there per ADR-0001: caller has OWNER/MEMBER access to both budgets; the two budgets are distinct; each account is active (not archived) and belongs to its stated budget; the category is required, belongs to the outflow budget, and is set on the outflow leg only; the inflow leg is category-free; amount is a positive Money magnitude, negated on the outflow leg; one shared date and one shared notes value at creation. Both rows are written in a single database transaction.
  • New user-context query listing the caller's active accounts across all budgets where they are OWNER or MEMBER, grouped/labelled by budget, to feed the counterpart picker.
  • One new adapter (remote function, per ADR-0002): validates input, converts the register-relative sign into send direction, calls the command, refreshes the transaction list caches of both affected budgets. No business rules in the adapter.
  • UI: extend the existing transfer modal, not a separate action. The counterpart picker gains an "other budgets" group. Choosing a foreign counterpart reveals the required category picker, loaded from whichever budget the money leaves (this swaps with direction), plus a hint that the movement leaves that budget as spending. Same-budget selection keeps today's budget-neutral Transfer behaviour unchanged.
  • Legs are independent after creation. The generic edit and delete commands apply to them with no special casing (they carry no transferId, so the transfer-leg guards don't fire). No linkage column, no sync semantics, no cascade — this keeps the access model intact: a member of only the destination budget can never mutate the source budget.
  • Unassigned semantics follow ADR-0007 unchanged: the inflow is income of its Month in the destination budget.
  • i18n: all new form labels, hints, and validation messages go through Paraglide messages.
  • CHANGELOG: entry under Added.

Testing Decisions

  • A good test observes external behaviour through the public user-context surface — commands and queries against a fresh in-memory database seeded via the shared fixtures — never implementation details like generated SQL or row internals.
  • Single seam: the user-context. Two new members on it are tested:
    • The send command: atomic pair creation across two budgets; rejection when the caller lacks access to either budget, when an account is archived or belongs to the wrong budget, when the category is missing or belongs to the wrong budget, when the magnitude is not positive, and when both accounts resolve to the same budget; both directions; date defaulting; notes copied to both legs.
    • Envelope and account effects asserted through existing queries: outflow reduces the chosen category's Remaining and the source account's Balance; inflow raises the destination budget's Unassigned (month-scoped) and the destination account's Balance; neither leg matches the TRANSFER register sentinel; the inflow matches the income (UNASSIGNED) sentinel.
    • Leg independence: generic edit succeeds on a leg; deleting one leg does not touch the other.
    • The cross-budget accounts query: only OWNER/MEMBER budgets, archived accounts excluded, INVITEE budgets excluded.
  • Prior art: the existing transfer and account suites in the user-context tests (in-memory database + fixtures pattern).
  • No component or e2e coverage — consistent with how the Transfer feature itself was pinned; adapters stay untested per ADR-0002.

Out of Scope

  • Any linkage between the two legs — hard (shared id, synced edits, cascading delete) or soft (provenance reference). Rejected in the grilling: sync semantics across budgets would require a member of one budget to mutate another budget, breaking the access model.
  • An optional destination-side category ("assign the arriving money immediately"). Purely additive later if landing in Unassigned proves annoying.
  • Off-budget / tracking accounts (Idea: off-budget (tracking) accounts #151) and revisiting the ADR-0015 CHECK constraint.
  • Auto-generated provenance text on either leg.
  • Transfer badge or TRANSFER filter treatment for cross-budget sends — they are ordinary transactions by design.
  • Scheduled or recurring sends.

Further Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentScoped and unambiguous; an agent can pick this upspecFeature specification from /to-spec

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions