- The owner's claimant key is trimmed and lower-cased, the same rule the payout RPC applies, so "Jakob" and "jakob " are one person with one Att göra row and one exact-amount match. - The inbox pages through every registered claim (fetchAllRows) before pairing, so a long backlog can never understate a person's debt. - A foreign receipt's VAT field is locked at 0 and 0 is what is submitted. - Transport failures in the one-click and picker confirms show the destructive toast instead of failing silently. - The open-claims flag is set only after the stale-fetch guard, and a payout match decrements the inbox count like every other row exit. - Test: reset the live-link mock before the bank_line junction case. - Wording: "Återbetalning av utlägg". Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
116 lines
4.3 KiB
TypeScript
116 lines
4.3 KiB
TypeScript
/**
|
|
* Bank-driven repayment of utlägg: who is owed, and which unbooked bank
|
|
* outflow is the transfer that pays them.
|
|
*
|
|
* Pure functions on plain rows so the inbox page (browser), the worklist
|
|
* (server) and the ingest path can share one definition of "this bank line
|
|
* repays that person" without a schema hint column. The match is
|
|
* deliberately strict: an SEK outflow whose absolute amount equals ONE
|
|
* person's outstanding total to the öre. Two people with the same total make
|
|
* the amount ambiguous and produce no suggestion; the user then picks the
|
|
* person in the payout dialog.
|
|
*/
|
|
import { roundOre } from '@/lib/money'
|
|
import type { ExpensePayoutDue } from '@/lib/worklist/types'
|
|
|
|
export interface ExpenseClaimRowForGrouping {
|
|
id: string
|
|
employee_id: string | null
|
|
claimant_name: string
|
|
liability_account: string
|
|
amount_sek: number | string
|
|
expense_date: string
|
|
}
|
|
|
|
/**
|
|
* The person behind a claim: the employee id, or for the owner the trimmed,
|
|
* lower-cased name. Same rule as create_expense_payout_batch's claimant check
|
|
* (lower(btrim(claimant_name))), so "Jakob" and "jakob " are one person here
|
|
* exactly when the RPC would accept their claims in one payout.
|
|
*/
|
|
export function expenseClaimantKey(row: { employee_id: string | null; claimant_name: string }): string {
|
|
return row.employee_id ?? `owner:${row.claimant_name.trim().toLowerCase()}`
|
|
}
|
|
|
|
/**
|
|
* Group registered claims into one item per person, oldest debt first.
|
|
*
|
|
* Claims on 2018 (an enskild firma owner's egen insättning) are skipped: the
|
|
* firm owes its owner nothing, a later withdrawal is eget uttag booked from
|
|
* the bank line like any other, so there is no Betala row and no transfer
|
|
* to pair. Only 2893 (AB owner) and 2820 (employee) are debts.
|
|
*/
|
|
export function groupExpenseClaimsByPerson(rows: ExpenseClaimRowForGrouping[]): ExpensePayoutDue[] {
|
|
const byPerson = new Map<string, ExpensePayoutDue>()
|
|
for (const row of rows) {
|
|
if (row.liability_account === '2018') continue
|
|
const key = expenseClaimantKey(row)
|
|
const amount = Number(row.amount_sek) || 0
|
|
const existing = byPerson.get(key)
|
|
if (existing) {
|
|
existing.claim_count += 1
|
|
existing.claim_ids.push(row.id)
|
|
existing.total_sek = roundOre(existing.total_sek + amount)
|
|
if (row.expense_date < existing.oldest_expense_date) {
|
|
existing.oldest_expense_date = row.expense_date
|
|
}
|
|
} else {
|
|
byPerson.set(key, {
|
|
key,
|
|
employee_id: row.employee_id,
|
|
claimant_name: row.claimant_name,
|
|
liability_account: row.liability_account,
|
|
claim_count: 1,
|
|
claim_ids: [row.id],
|
|
total_sek: roundOre(amount),
|
|
oldest_expense_date: row.expense_date,
|
|
})
|
|
}
|
|
}
|
|
return [...byPerson.values()].sort((a, b) =>
|
|
a.oldest_expense_date < b.oldest_expense_date ? -1 : a.oldest_expense_date > b.oldest_expense_date ? 1 : 0,
|
|
)
|
|
}
|
|
|
|
export interface MatchableOutflow {
|
|
id: string
|
|
amount: number
|
|
currency?: string | null
|
|
is_business?: boolean | null
|
|
journal_entry_id?: string | null
|
|
}
|
|
|
|
/** A bank outflow that repays one person's registered utlägg in full. */
|
|
export interface ExpensePayoutMatch {
|
|
transaction_id: string
|
|
person: ExpensePayoutDue
|
|
}
|
|
|
|
/**
|
|
* Pair unbooked SEK outflows with the person whose outstanding total they
|
|
* equal. Amounts compared in öre. Totals shared by two or more people are
|
|
* skipped (ambiguous), as are rows already booked or flagged is_business.
|
|
*/
|
|
export function matchTransactionsToExpensePayouts(
|
|
transactions: MatchableOutflow[],
|
|
people: ExpensePayoutDue[],
|
|
): Map<string, ExpensePayoutMatch> {
|
|
const out = new Map<string, ExpensePayoutMatch>()
|
|
if (people.length === 0 || transactions.length === 0) return out
|
|
const byOre = new Map<number, ExpensePayoutDue | null>()
|
|
for (const p of people) {
|
|
const ore = Math.round(p.total_sek * 100)
|
|
if (ore <= 0) continue
|
|
// null marks an ambiguous total: two people owed the same amount.
|
|
byOre.set(ore, byOre.has(ore) ? null : p)
|
|
}
|
|
for (const tx of transactions) {
|
|
if (tx.journal_entry_id || tx.is_business !== null && tx.is_business !== undefined) continue
|
|
if ((tx.currency ?? 'SEK').toUpperCase() !== 'SEK') continue
|
|
if (!(tx.amount < 0)) continue
|
|
const person = byOre.get(Math.round(-tx.amount * 100))
|
|
if (person) out.set(tx.id, { transaction_id: tx.id, person })
|
|
}
|
|
return out
|
|
}
|