feat(expenses): repay utlägg from the bank line (#2333)

* feat(expenses): repay utlägg from the bank line

The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).

- create_expense_payout_batch(..., p_transaction_id): old signature dropped
  so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
  TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
  outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
  primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ

* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma

- "Matcha mot utlägg" in the inbox row menu: pick the person and the
  receipts a transfer covers when the exact-amount pairing missed it. The
  picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
  foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
  from Att göra, the attention resource, suggestions and the picker; a
  payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
  the dialog says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:16:51 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 238cbe13f9
commit b71f2bf425
26 changed files with 1754 additions and 88 deletions
+32 -2
View File
@@ -2915,6 +2915,8 @@
"invoices_help": "Invoice due dates"
},
"tx_inbox_card": {
"match_expense_btn": "Match to expense claims",
"match_expense_payout_btn": "Book expense reimbursement to {name}",
"skv_counterpart_label": "Possible 1930↔1630 transfer.",
"skv_counterpart_body": "There is a skattekonto event on {date} that matches: post this voucher first, then link the skattekonto row to the same voucher instead of posting it twice.",
"match_invoice_btn": "Match invoice {number}",
@@ -3309,6 +3311,9 @@
"mixed_currency_note": "Documents in different currencies cannot be summed into a single amount. Each document is still booked separately, against its matched bank transaction and the amount the bank actually settled in SEK."
},
"inbox_workspace": {
"payer_help_owner_ef": "Booked as an owner contribution (2018). Nothing to pay out.",
"expense_dialog_help_owner_ef": "Cost and VAT are booked now as an owner contribution on account 2018. The firm owes you nothing: taking money out later is an owner withdrawal.",
"expense_outcome_ef": "No row in To do: an owner contribution is not something to pay out.",
"payer_question": "Who paid?",
"payer_company": "The company",
"payer_owner": "Me, privately",
@@ -3333,7 +3338,7 @@
"expense_vat": "VAT",
"expense_account": "Expense account",
"expense_outcome_att_gora": "Lands in To do: Pay out expenses to {name}.",
"expense_fx_note": "Booked in SEK at the Riksbank rate for the date.",
"expense_fx_note": "Foreign receipt: the VAT is not deducted, the whole amount is booked as cost in SEK at the Riksbank rate for the date.",
"expense_cancel": "Cancel",
"expense_confirm": "Book",
"expense_booked_title": "Expense booked",
@@ -6013,6 +6018,9 @@
"load_failed": "Could not load the brand. Try reloading the page."
},
"transactions": {
"expense_payout_matched_title": "Expense reimbursement booked",
"expense_payout_matched_description": "The transfer to {name} was booked and the claims marked as paid.",
"expense_payout_match_failed_title": "Could not book the reimbursement",
"counterparty_suggestion_gone_title": "That counterparty is no longer available",
"counterparty_suggestion_gone_description": "The suggestion was refreshed. Close the dialog, open it again and pick the counterparty once more.",
"page_title": "Transactions",
@@ -6689,6 +6697,7 @@
"dismiss": "Hide"
},
"dashboard": {
"suggested_kind_expense_payout": "Expense reimbursement",
"band_betala": "Pay",
"row_expense_payout": "Pay out expenses to {name}",
"row_expense_payout_detail_one": "1 receipt · {date}",
@@ -8783,5 +8792,26 @@
"attn_create": "Create suggestions",
"auto_created_title": "{count} suggestions created from the books",
"auto_created_description": "Counterparts your vouchers name that are not in the register. Add them, or hide the ones that do not belong here."
}
},
"tx_expense_payout_match": {
"title": "Book expense reimbursement",
"description": "The transfer is booked against the person's liability account, the claims are marked as paid and the transaction is linked to the voucher.",
"claims_one": "1 receipt · {date}",
"claims_other": "{count} receipts · oldest {date}",
"outcome": "The debt to the person becomes 0 kr and the row leaves To do.",
"cancel": "Cancel",
"confirm": "Book the reimbursement"
},
"tx_expense_claim_picker": {
"title": "Match to expense claims",
"description": "Pick the claims this transfer covers. The sum must equal the amount on the row exactly.",
"person_label": "Person",
"no_claims": "No open expense claims to match.",
"selected_sum": "Selected claims: {amount}",
"transfer_sum": "Transfer: {amount}",
"diff": "Difference: {amount}",
"cancel": "Cancel",
"confirm": "Book the reimbursement",
"failed_title": "Could not book the reimbursement"
}
}
+32 -2
View File
@@ -2915,6 +2915,8 @@
"invoices_help": "Förfallodatum för fakturor"
},
"tx_inbox_card": {
"match_expense_btn": "Matcha mot utlägg",
"match_expense_payout_btn": "Bokför återbetalning av utlägg till {name}",
"skv_counterpart_label": "Möjlig 1930↔1630-överföring.",
"skv_counterpart_body": "Det finns en skattekonto-händelse den {date} som matchar: bokför detta verifikat först, koppla sedan skattekonto-raden mot samma verifikat istället för att bokföra två gånger.",
"match_invoice_btn": "Matcha Faktura {number}",
@@ -3309,6 +3311,9 @@
"mixed_currency_note": "Underlag i olika valutor kan inte summeras till ett belopp. Varje underlag bokförs ändå var för sig, mot sin matchade banktransaktion och det belopp banken faktiskt drog i SEK."
},
"inbox_workspace": {
"payer_help_owner_ef": "Bokförs som egen insättning (2018). Inget att betala ut.",
"expense_dialog_help_owner_ef": "Kostnad och moms bokförs nu som egen insättning på konto 2018. Firman har ingen skuld till dig: tar du ut pengar senare är det ett eget uttag.",
"expense_outcome_ef": "Ingen rad i Att göra: en egen insättning är inget som ska betalas ut.",
"payer_question": "Vem betalade?",
"payer_company": "Företaget",
"payer_owner": "Jag, privat",
@@ -3333,7 +3338,7 @@
"expense_vat": "Moms",
"expense_account": "Kostnadskonto",
"expense_outcome_att_gora": "Hamnar i Att göra: Betala ut utlägg till {name}.",
"expense_fx_note": "Bokförs i SEK med Riksbankens kurs för datumet.",
"expense_fx_note": "Utländskt kvitto: momsen dras inte av, hela beloppet bokförs som kostnad i SEK med Riksbankens kurs för datumet.",
"expense_cancel": "Avbryt",
"expense_confirm": "Bokför",
"expense_booked_title": "Utlägget är bokfört",
@@ -6013,6 +6018,9 @@
"load_failed": "Kunde inte hämta varumärket. Prova att ladda om sidan."
},
"transactions": {
"expense_payout_matched_title": "Återbetalning av utlägg bokförd",
"expense_payout_matched_description": "Överföringen till {name} bokfördes och utläggen markerades som utbetalda.",
"expense_payout_match_failed_title": "Kunde inte bokföra återbetalningen",
"counterparty_suggestion_gone_title": "Motparten är inte längre tillgänglig",
"counterparty_suggestion_gone_description": "Förslaget hann uppdateras. Stäng rutan, öppna den igen och välj motparten på nytt.",
"page_title": "Transaktioner",
@@ -6689,6 +6697,7 @@
"dismiss": "Dölj"
},
"dashboard": {
"suggested_kind_expense_payout": "Återbetalning utlägg",
"band_betala": "Betala",
"row_expense_payout": "Betala ut utlägg till {name}",
"row_expense_payout_detail_one": "1 kvitto · {date}",
@@ -8783,5 +8792,26 @@
"attn_create": "Skapa förslag",
"auto_created_title": "{count} förslag skapade från bokföringen",
"auto_created_description": "Motparter som dina verifikat namnger men som inte finns i registret. Lägg upp dem, eller dölj de som inte hör hemma här."
}
},
"tx_expense_payout_match": {
"title": "Bokför återbetalning av utlägg",
"description": "Överföringen bokförs mot personens skuldkonto, utläggen markeras som utbetalda och transaktionen kopplas till verifikatet.",
"claims_one": "1 kvitto · {date}",
"claims_other": "{count} kvitton · äldsta {date}",
"outcome": "Skulden till personen blir 0 kr och raden försvinner från Att göra.",
"cancel": "Avbryt",
"confirm": "Bokför återbetalningen"
},
"tx_expense_claim_picker": {
"title": "Matcha mot utlägg",
"description": "Välj de utlägg som överföringen täcker. Summan måste stämma exakt med beloppet på raden.",
"person_label": "Person",
"no_claims": "Inga öppna utlägg att matcha.",
"selected_sum": "Valda utlägg: {amount}",
"transfer_sum": "Överföring: {amount}",
"diff": "Skillnad: {amount}",
"cancel": "Avbryt",
"confirm": "Bokför återbetalningen",
"failed_title": "Kunde inte bokföra återbetalningen"
}
}