Zum Inhalt springen
Familienkonto

Domain model

This is the part to understand before changing anything else.

One rule

The unit of account is the expense share: one portion of one payment. Three facts about it are independent:

FactColumnQuestion it answers
who paidexpenses.payer_user_idwho handed over the money
who bears itexpense_shares.bearer_user_idwho carries it in the end
whom it is forexpense_shares.beneficiary_user_idwhich child it belongs to

From those, one rule produces every case:

A share moves money only when the payer and the bearer are different people, and then the bearer owes the payer.

PayerBearerResult
childparentthe parent owes the child
parentchildthe child owes the parent
childchildnothing owed; still the child's own contribution
parentparentnothing owed; the parental contribution the tax office asks about
motherfatherthe father owes the mother - the child is untouched

The last row is why the bearer is a person rather than a side. Separated parents splitting a child's coat settle with each other exactly as a child settles with a parent, and the child's own position does not move because they bear none of it.

A share may not know its bearer yet

bearer_user_id is nullable, and NULL means "a parent, we will work out which". A child recording an expense knows a parent will cover it; which parent is a question for the parents, and making the child answer it recorded a debt against someone who had not agreed to it.

It is a state only a submitted expense may be in. Accepting is exactly the moment a parent takes the cost on, so review fills the bearer in with whoever approved, and validateShares refuses an undecided bearer on anything that skips review. The ledger therefore never sees one: claims are read from accepted expenses only.

Balances

ledger.balanceOf(personId, counterpartyId, claims, credits) nets a position.

  • counterpartyId = 0 nets against everyone else - this is a child's wallet.
  • A specific counterparty gives the position between exactly those two - this is what one parent owes another.
netCents    = owedToPerson - owedByPerson - advances + overpaid
walletCents = -netCents

netCents is the account, positive meaning others owe this person. walletCents is the same figure from their side, which is what a child is shown: positive means they are holding money they can still spend.

The wallet

Money moves before there is anything to settle. Parents transfer a lump sum; the child spends it down. Such a payment has no share to attach to, so its unallocated remainder is a Credit, and the recipient is holding the payer's money.

Whether an expense draws on the float is decided by who bears it, not by who paid:

advance                        +200,00     wallet 200,00
schoolbook 45,00, borne by parent -45,00   wallet 155,00
cinema     15,00, borne by child    0,00   wallet 155,00

ledger.applyCredits(claims, credits) matches payments to the shares they can discharge. A payment from X to Y can only settle a share that X bears and Y paid for - that is the one arrangement where the money already moved the right way. Oldest share, oldest payment first, so the result is reproducible.

This is what makes an expense bought from an advance settled the moment it is accepted, rather than waiting for a reimbursement that already happened.

Dedicated money

A payment may name a purpose: one category, on settlements (purpose_category_id). Its money then clears only shares of expenses in that category, and its remainder waits for one rather than being spent on whatever comes next.

The case it exists for is a rent transfer. 890,00 sent for the rent, treated as an undifferentiated float, is eaten piecemeal by whatever else is open - and the books then say that transfer bought schoolbooks, which is wrong on its face and worse in front of a tax office.

Two rules follow, both in ledger.applyCredits:

  • a credit with a purpose is offered only claims whose categoryId matches (ledger.creditCovers);
  • dedicated credits are offered before general ones. If the general float paid a rent share, the rent money would be left with nothing it is allowed to buy and would never be spent at all.

The match is on the expense's category as it stands now, which is the same category the reports read. Re-categorising an expense therefore moves it into or out of reach of dedicated money, and the callers release and re-apply so the two cannot drift apart.

Approval

A child records what they paid and asks to be reimbursed; an adult accepts or rejects it. Only accepted expenses reach a balance - submitted is still a request, rejected never counts.

An expense that asks nobody else to pay is accepted on the spot, whoever recorded it. A child noting what they bought from their own money is telling the family what they spent, not asking for anything, so it does not sit in the parents' queue. See expenses.asksAnyoneElseToPay.

Locking

Once money has moved for an expense, it is frozen (expenses.isLocked). Otherwise a child could be reimbursed and then edit the entry to say the parents were the payer, leaving a payment on record that matches nothing.

  • The financial fields - amount, split, payer, date - cannot change.
  • The bookkeeping fields - category, tax relevance, description, note - can, through expenses.reclassify. That is the tax adviser's working surface, and it must not require unwinding a payment that has already happened.
  • A child may edit their own entry only while it is still submitted.

An allocation out of a float is not money moved for that expense. The transfer happened before the entry existed and was not about it; the application decided the two belonged together. So such an allocation carries settlement_items.auto_cents, isLocked is settled > auto, and the entry stays fully correctable: settlements.releaseAutomatic gives the money back, the correction is written, and applyAvailableCredit settles whatever it now comes to. Every write path that touches an expense does this - correcting, re-categorising, rejecting, deleting.

Getting this wrong the other way is what the wallet model originally did: an expense bought from an advance locked on acceptance, so a mistyped amount could only be fixed by reversing the whole advance.

Roles and capabilities

Permissions are not a ladder. An adviser needs the Finanzamt reports, which a parent does not get, while being barred from settling, which a parent does. No ordering expresses that, so each capability names the roles that hold it.

childparentmasteradvisoradmin
record own-
record for children-
edit entries-
delete entries--
view all children-
accept / reject--
settle payments--
Finanzamt reports--
manage members---
administer installation----

roles.sortOrder exists only to order a member listing; no permission is derived from it. A test pins down that capabilities are not a ladder, so a future refactor back to ranks fails loudly.

Income, and the two questions it answers

An income row carries two flags, because the FLAG asks two different things about a child's money and the answers differ:

taxableown_income
what it feedsZuverdienstgrenze, § 5 Abs. 1überwiegende Kostentragung, § 2 Abs. 2
wagesyesyes
Lehrlingsentschädigung, Waisenpensionno (§ 5 excludes them)yes
Wohnbeihilfe, Studienbeihilfe the child applied forno (§ 3 EStG: tax-free)yes
Familienbeihilfenono

taxable is asked on the form, defaulted from the kind, because the same word covers both answers: a Studienbeihilfe under the StudFG is tax-free, a private scholarship of the same name may not be.

own_income is derived from the kind and stored, never asked. It is false for exactly one kind, family_allowance, and that is the point of the kind existing: § 2 Abs. 6 reduces the cost of maintenance by what the child receives, and the Familienbeihilfe is granted to the entitled parent (§ 2 FLAG). § 14 direct payment moves the money, not the entitlement. Counting it as the child's own would shrink the very need the parents are trying to show they carried - the family would be arguing against itself out of its own books.

The same fact has a second consequence, in the other direction. A child who pays a bill out of that Beihilfe is spending the parents' money, so ChildReport.allowanceBorneCents moves that much from the child's side to the parents' before coverage.analyze sees it - capped at what arrived in the month, computed per month, and only where the family set the child's Eigenbeitrag to family_allowance, which is the statement that the money reaches her at all. The household view keeps the raw split: she did pay those bills.

incomes.totalFor is what arrived, whosever it is, and feeds the income page. incomes.ownTotalFor is the § 2 Abs. 6 figure and feeds ChildReport. incomes.taxableTotal is the § 5 Abs. 1 figure and feeds limitCheck.

Messages, and mail as a copy

inbox_messages is one row per person per event. Every notice writes one; mail is sent afterwards and only where config.mailConfigured and an address exist. The order is the design: an installation without SMTP could previously say nothing at all, and mail is not a record - delivered or not, filtered or not, read or not, and never knowable. read_at holds the first reading and never moves; the question it answers is "did this reach them", which has one answer per message.

notices.deliver is the single place both channels are written, so an event cannot reach one and miss the other. The per-family switches (notifications) gate both: they were always "what do you want to hear about", and that is now one question rather than two.

Payments, and who says they arrived

settlements.receipt_state is pending, confirmed or missing, answered only by to_user_id - a payer confirming their own transfer is the same signature twice. Three states and not a flag, because "not asked yet" and "the recipient says it never came" are different facts.

The ledger does not wait for it. A payment settles what it settles when it is booked; a dispute is a red row on the parents' overview and reverse is still the deliberate second act that moves money back. Making the books depend on a child's tap would leave a family whose child is on a school trip unable to see where it stands.

Coverage

The Austrian family-allowance rule, in coverage.j:

netNeed  = max(0, expenses - income)
required = min(netNeed, netNeed * threshold / 100 + 1)   # integer division
met      = netNeed > 0 and parentBorne >= required

The denominator is the remaining need, not the gross spend: the child's own income is set against the cost of maintenance before anybody's share is measured. § 2 Abs. 6 FLAG says so outright for tax-free receipts - the cost of maintenance is to be taken reduced by them - and the case law applies the same comparison generally, putting the child's living costs on one side and their own income (taxable and tax-free) together with the claimant's payments on the other. Measuring against the gross spend would count the child's own money as a cost somebody else still has to carry.

The comparison is exact integer arithmetic, never a rounded percentage: a case sitting one cent below half must not pass because it displays as 50,0 %. And on the threshold is a miss - überwiegend is strictly more than half, so required is the first cent past the share and carrying exactly half fails by one cent. The min is there because nothing can exceed the whole: a threshold of 100 % is read as "all of it" rather than as a test no family could pass.

netNeed == 0 is a state of its own, and the interesting one. The child's income reached everything recorded, so nobody can carry the greater part of what is left, however much the parents actually paid: 554,60 of receipts against 3.631,60 of income is a gift and not maintenance. meetsThreshold is false, selfSupporting is true, requiredCents and shortfallCents are 0 - there is no amount that would have fixed it - and the interfaces show a neutral badge with the reason rather than a red one, because the family did nothing wrong.

coveragePermille is the share of the remaining need and therefore routinely exceeds 1000: parents who paid the bills while the child's income stayed in the child's pocket are over 100 %. cappedPermille exists for the bar.

expenses == 0 is the case with no answer at all: nothing recorded proves nothing in either direction, so needApplicable is false and the report says Nichts erfasst rather than drawing a bar.

Three outcomes, and a report has to tell them apart:

needApplicableselfSupportingmeetsThreshold
ordinary periodtruefalsethe comparison above
income covers every expensetruetruefalse
nothing recorded at allfalsefalsefalse

Neither of the last two is a finding against the family - one is an empty period, the other a child who can pay their own way - so both get a neutral badge and a sentence, never the red one.