Back to Journal

Building fee-free expense reimbursements

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:

Client PaymentFortnox(Invoice System)Void Pool(Holding Account)User Pool(Freelancer Wallet)Bank AccountFees(Commission)minus fees

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:

BEFORE (Broken)Void Pool: 10,000 SEKUser Pool: 7,500 SEKReimbursable: 0 SEK (lost!)AFTER (Fixed)Void Pool: 10,000 SEKUser Pool: 7,500 SEK + 500 SEKReimbursable: Transferred!

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:

Invoice Paid: 100,000 SEKSubtract VATNet: 80,000 SEKSubtract Expenses + CommissionBase: 70,000 SEKCalculate Pension + TaxGross Salary: 52,000 SEKSubtract Income TaxNet Salary: 35,000 SEKAdd Reimbursable Expenses (fee-free)To Be Paid: 37,500 SEK

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:

  1. Expense created with reimburseSeparately: true
  2. Invoice total calculated without it
  3. Fortnox invoice sent without it
  4. Payment received into void pool
  5. Calculator runs, tracks reimbursable separately
  6. Transfer actually happens (the bug we caught)
  7. User sees correct payout amount

Each step needed verification. The diagrams helped - not just for documentation, but for finding gaps in the mental model.