Today was all about following money through a system. The freelancer invoicing platform we've been working on needed a new feature: expenses that get reimbursed without fees.
The problem sounds simple. A freelancer pays for something on behalf of their client - a train ticket, software license, whatever. They want that money back at cost, not reduced by social fees and commission. The solution turned out to touch almost every layer of the stack.
The Architecture
Here's how money flows through the system when an invoice gets paid:
The tricky part: reimbursable expenses need to bypass the fee calculation entirely. They go straight from void pool to user pool at face value.
The Database Changes
Two migrations. One adds a toggle to expenses:
ALTER TABLE expense ADD reimburse_separately BOOLEAN DEFAULT false NOT NULL;The other tracks the reimbursable amount on each payment:
ALTER TABLE invoice_payment ADD reimbursable_expenses_amount INT DEFAULT 0 NOT NULL;We store amounts in minor units (cents/ore), so an INT works fine. The DEFAULT 0 ensures existing records don't break.
Where It Got Interesting
The invoice entity needed changes to split expense totals. The original getTotalExpenses() summed everything - now it filters out reimbursables:
public function getTotalExpenses(): Money
{
$amount = new Money(0, $this->currency);
foreach ($this->expenses as $expense) {
$amount = $amount->add($expense->getAmount());
if (!$expense->isReimburseSeparately()) {
$amount = $amount->add($expense->getAmount());
}
}
return $amount;
}And a new method to get just the reimbursable expenses:
public function getTotalReimbursableExpenses(): Money
{
$amount = new Money(0, $this->currency);
foreach ($this->expenses as $expense) {
if ($expense->isReimburseSeparately()) {
$amount = $amount->add($expense->getAmount());
}
}
return $amount;
} Not elegant, but explicit. You can see exactly what each method counts.
The Bug We Almost Shipped
Here's the part that would have been embarrassing. We had the calculations working, the UI showing the right numbers, but the money wouldn't have actually moved.
The PinebucketInvoicePaymentProcessor handles transfers between pools. Our initial implementation calculated reimbursable amounts but never transferred them:
The fix was straightforward once we found it:
// Reimbursable expenses are transferred fee-free directly to user's pool
if ($payment->getReimbursableExpenses()->isPositive()) {
$voidPoolTransactionRequest->addTransfer(
$voidMoneyPool,
$usersMoneyPool,
(int) $payment->getReimbursableExpenses()->getAmount(),
sprintf(
'payment_label.reimbursable_expenses:%s',
json_encode(['invoice_number' => $invoice->getFortnoxId()])
),
$invoiceId,
);
}This goes in PinebucketInvoicePaymentProcessor.php right after the main salary transfer. The key insight: the void pool holds the full payment amount, and we need explicit transfers for every destination.
Revenue Reports
Another subtle issue. The revenue report query was summing all expenses. Reimbursable expenses aren't revenue - they're pass-through:
LEFT JOIN expense e ON i.id = e.invoice_id
LEFT JOIN expense e ON i.id = e.invoice_id AND e.reimburse_separately = false Admin Approval
Reimbursable expenses require receipt verification. We added a check in the invoice workflow:
// In GetReadyForFortnoxWhenCompletedAccept.php
if ($invoice->hasReimbursableExpenses()) {
return true; // Requires admin approval
}Simple but important. Someone needs to verify the receipts match the amounts before fee-free money moves.
The Calculator
The InvoiceAmountCalculator is where salary calculations happen. The flow looks like this:
The reimbursable amount gets added at the very end, after all fee calculations. It's tracked separately in InvoiceAmounts:
public function __construct(
// ... existing params
public readonly Money $reimbursableExpensesAmount,
) {}Frontend
The React side was mostly plumbing. The expense form got a checkbox:
<Checkbox
name="reimburseSeparately"
label={t('form.label.invoice.expense.reimburseSeparately')}
/>And the invoice totals calculation splits regular from reimbursable:
const invoiceTotals = useMemo(() => {
const expenses = allDocuments
.filter((item) => !item.reimburseSeparately)
.reduce((acc, item) => acc + item.amount, 0) * 100;
const reimbursableExpenses = allDocuments
.filter((item) => item.reimburseSeparately)
.reduce((acc, item) => acc + item.amount, 0) * 100;
return {
// ... other totals
expenses,
reimbursableExpenses,
gross: gross + expenses, // Note: reimbursable NOT in invoice total
};
}, [rows, allDocuments]);The key detail: gross only includes regular expenses. The invoice sent to the client shouldn't include reimbursable amounts - those are between the platform and the freelancer.
Translations
Swedish translations for both frontend and backend:
// Frontend (sv.json)
"reimbursableExpenses": "Aterbetalbara utlagg"<!-- Backend (messages+intl-icu.sv.xlf) -->
<trans-unit id="rE1mburS" resname="salary.reimbursable_expenses">
<source>salary.reimbursable_expenses</source>
<target>Aterbetalbara utlagg</target>
</trans-unit>Takeaways
Money systems are unforgiving. The logic can be "correct" in isolation but wrong in the actual flow. Every path needs tracing:
- Expense created with
reimburseSeparately: true - Invoice total calculated without it
- Fortnox invoice sent without it
- Payment received into void pool
- Calculator runs, tracks reimbursable separately
- Transfer actually happens (the bug we caught)
- User sees correct payout amount
Each step needed verification. The diagrams helped - not just for documentation, but for finding gaps in the mental model.