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::Budgetrule 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
:@splitsis 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ā requiredApp::Moneymoor::DB.schemeā theApp::Moneymoor::Util::Periodfind-by-periodreads its argument against. Defaults tomonthly/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)). AFailureunless$periodis a real period start underscheme: 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 whatService::Workspacehands to the derivation.