Transaction

NAME

App::Moneymoor::Gateway::Transaction - SQL gateway for transactions, their splits, and transfer pairs.

SYNOPSIS


use App::Moneymoor::Gateway::Transaction;

my $gw = App::Moneymoor::Gateway::Transaction.new(:$db);

# A £42.50 shop, all groceries. Transaction and split land together.
my $t = $gw.create(
    App::Moneymoor::Model::Transaction.new(
        account-id => $visa, date => '2026-03-14',
        payee-id => $tesco, amount => -4250,
    ),
    splits => [
        App::Moneymoor::Model::Split.new(category-id => $groceries, amount => -4250),
    ],
);

# A split shop: the parts must sum to the whole.
my $s = $gw.create($txn, splits => [
    App::Moneymoor::Model::Split.new(category-id => $groceries, amount => -4500),
    App::Moneymoor::Model::Split.new(category-id => $household,  amount => -1500),
]);

# Paying the card: one call, two legs, one SQL transaction.
my ($out, $in) = $gw.create-transfer(
    from-account-id => $current, to-account-id => $visa,
    date => '2026-03-28', amount => 25000,
);
say $in.transfer-peer-id == $out.id;    # True

# Moving money to a tracking account is spending, so it is categorized
# on the on-budget leg:
$gw.create-transfer(
    from-account-id => $current, to-account-id => $isa,
    date => '2026-03-30', amount => 10000,
    splits => [App::Moneymoor::Model::Split.new(
        category-id => $investing, amount => -10000)],
);

$gw.set-cleared($t.id, 'cleared');
$gw.delete($out.id);                    # takes the peer leg with it

DESCRIPTION

This gateway owns three invariants that the budget derivation depends on. All three are enforced before any write, and every multi-row write happens inside one run-txn, so no invariant can be observed half-true.

1. SPLITS SUM TO THE TRANSACTION

A transaction on an on-budget account is either a transfer or fully categorized: at least one split, and the splits' amounts sum exactly to the transaction's amount. A transaction whose splits sum to something else is a hole in the budget — the cash moved but the envelopes did not — so create and update reject it by Failure rather than storing it.

Transactions on tracking accounts must have no splits: they are off budget, and a categorized tracking transaction would move an envelope without moving any cash.

2. TRANSFERS COME IN PAIRS

create-transfer writes both legs and cross-links them in one transaction. $amount is the positive magnitude moving from-account-id → to-account-id; the from leg is stored negative and the to leg positive.

Whether the pair carries splits depends on where the money goes:

  • Both legs on budget (cash ↔ cash, cash ↔ card, card ↔ card): no splits at all. The money has not left the budget, so no envelope may move. (The derivation still moves the payment envelope of a credit leg — see Service::Budget rule 2 — but that is derived, not stored.)

  • One leg on a tracking account: money really is entering or leaving the budget, so the on-budget leg is categorized and :@splits is required, summing to that leg's amount.

  • Both legs tracking: no splits; invisible to the budget.

delete on either leg deletes both. update on either leg keeps the peer's date and amount in step — a transfer whose legs disagree about how much moved is not a transfer.

3. IDS ARE NEVER GUESSED

create returns the stored row read back from the database, so the caller gets the real id, the real defaults and the real timestamp rather than a model it built by hand. :@splits are accepted with no transaction-id: the gateway fills it in once the transaction has an id.

EDITING

update replaces the whole transaction and, when :@splits is passed, its entire split set (delete then insert, inside the same transaction). Passing no :@splits leaves the existing splits alone, which is what a "just fix the memo" edit wants — but if you change the amount without passing splits, the sums no longer agree, so that combination is refused for a categorized transaction.

Moving a transfer leg to a different account is refused: the pair's account types decide whether the pair may be categorized, so a move is a delete plus a create, not an update.

ATTRIBUTES

  • db — required App::Moneymoor::DB.

  • scheme — the App::Moneymoor::Util::Period find-by-period reads its argument against. Defaults to monthly/1, the calendar month. Nothing else in this gateway has an opinion about periods: a transaction is dated, not bucketed, and which bucket its date falls in is the derivation's question.

METHODS

  • create(Model::Transaction:D $txn, :@splits -- Model::Transaction)>

  • create-transfer(Int:D :$from-account-id!, Int:D :$to-account-id!, Str:D :$date!, Int:D :$amount!, Str :$memo, Str :$cleared, :@splits -- Array)> — the two legs, from first.

  • update(Model::Transaction:D $txn, :@splits)

  • delete(Int:D $id) — with its peer leg, if any.

  • set-cleared(Int:D $id, Str:D $state)

  • find-by-id(Int:D $id -- Model::Transaction)>

  • find-all(-- Array)> — ordered (date, id).

  • find-by-account(Int:D $account-id -- Array)>

  • find-by-period(Str:D $period -- Array)> — every transaction dated in [$period, $scheme.next-period($period)). A Failure unless $period is a real period start under scheme: a key that is not one names a window with two possible meanings, and picking either quietly would answer a question nobody asked.

  • find-splits(Int:D $transaction-id -- Array)>

  • find-all-splits(-- Array)> — every split, ordered by (transaction_id, id); this is what Service::Workspace hands to the derivation.

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.