Back to Journal

Polishing fee-free expense reimbursements

A week after implementing the backend for fee-free expense reimbursements, today was about making sure the frontend tells the right story. Numbers need to show up in the right places, warnings need to fire at the right times, and existing flows can't break.

The Problem Space

The reimbursable expenses feature we shipped last week introduced a split: some expenses go through the normal fee calculation, others bypass it entirely. The backend handled this correctly, but the frontend was treating all expenses the same.

Expense Type SplitRegular ExpensesreimburseSeparately: falseAdded to invoice totalReimbursable ExpensesreimburseSeparately: trueFee-free, direct to freelancerSubject to commission + social feesBypasses all fee calculations

Admin Template: Visual Separation

First fix: the admin invoice view lumped all expenses together. Admins need to see which expenses are reimbursable at a glance - they require receipt verification before approval.

The Twig template needed filtering:

{% set regularExpenses = invoice.expenses|filter(e => not e.reimburseSeparately|default(false)) %} // [!code ++]
{% set reimbursableExpenses = invoice.expenses|filter(e => e.reimburseSeparately|default(false)) %} // [!code ++]

The |default(false) is crucial - existing expenses without the field get treated as regular. Then two separate sections:

<div class="grid grid-cols-2 gap-6">
  <div>
    <h4>Utlägg (Regular)</h4>
    {% for expense in regularExpenses %}
      <!-- expense card -->
    {% endfor %}
  </div>
  <div>
    <h4>Återbetalbara utlägg</h4>
    {% for expense in reimbursableExpenses %}
      <!-- expense card with green accent -->
    {% endfor %}
  </div>
</div>

The Validation Warning

Here's where business logic meets UX. Reimbursable expenses get paid directly to the freelancer, but what if they exceed what's available after the platform's commission?

Expense Validation FlowInvoice: 10,000 SEKCommission: 395 SEKAvailable Balance9,605 SEKReimbursable12,000 SEK (!)Warning Modal"Reimbursable expenses exceedavailable balance"

The provider calculates this on every change:

const reimbursableExpensesExceedBalance = useMemo(() => {
  const commissionAmount = summary?.commissionAmount?.amount 
    ? Number(summary.commissionAmount.amount) 
    : 0; 
  const availableBalance = invoiceTotals.net - commissionAmount; 
  return invoiceTotals.reimbursableExpenses > availableBalance; 
}, [invoiceTotals, summary]);

The modal uses a two-step confirmation - first click shows the warning, second click confirms:

const confirm = async () => {
  if (reimbursableExpensesExceedBalance && !showExpenseWarning) { 
    setShowExpenseWarning(true) 
    return
  } 
  await submit()
  close()
}

This isn't a hard block - sometimes freelancers legitimately need to submit expenses that exceed a single invoice. But they need to acknowledge it.

Salary Page: The Calculation Fix

The salary estimation panel was showing wrong numbers for invoices with reimbursable expenses. The issue: payslips have a different data shape than invoice amounts.

Payslip Data StructureBEFORE (Wrong)expensesAmount: 500 SEKreimbursableExpensesAmount:2,000 SEKTotal shown: 500 SEK (missing!)AFTER (Fixed)expensesAmount: 500 SEKreimbursableExpensesAmount:2,000 SEKTotal shown: 2,500 SEKThe FixCombine both expense types for"to be paid" calculationto_be_paid = netSalary + regularExpenses + reimbursableExpenses

The fix in invoice-helper.jsx:

if (isPayslip) {
  data.net = 0
  data.tax = 0
  data.expenses = data?.expensesAmount?.amount || 0
  const regularExpenses = Number(data?.expensesAmount?.amount || 0) 
  const reimbursableExpenses = Number(data?.reimbursableExpensesAmount?.amount || 0) 
  data.expenses = regularExpenses + reimbursableExpenses 
  // ... rest of payslip handling
}

Invoice List: The "Belopp" Column

The invoice list was adding all expenses to the displayed amount. But reimbursable expenses shouldn't be in the invoice total - they're between the platform and freelancer.

const expensesSum = invoice?.expenses 
  ?.reduce((sum, expense) => sum + Number(expense?.amount?.amount || 0), 0) || 0
 
// Keep total for expenses column display
const expensesSum = invoice?.expenses 
  ?.reduce((sum, expense) => sum + Number(expense?.amount?.amount || 0), 0) || 0
 
// Only regular expenses for "Belopp" column
const regularExpensesSum = invoice?.expenses 
  ?.filter(expense => !expense?.reimburseSeparately) 
  ?.reduce((sum, expense) => sum + Number(expense?.amount?.amount || 0), 0) || 0
 
const totalWithExpenses = baseAmount + regularExpensesSum 

Unpaid Invoice Edge Case

One more bug: unpaid invoices weren't showing expenses in the salary estimation panel. The SentDetails component was pulling from the calculated salary amounts, but those don't exist until the invoice is paid.

The fix: fall back to the invoice's expense data directly:

const invoiceExpenses = invoice?.expenses 
  ?.reduce((sum, expense) => sum + Number(expense?.amount?.amount || 0), 0) || 0; 
const expensesValue = invoiceExpenses || Number(salary.expenses?.value || 0); 

Backwards Compatibility

The key question: do any of these changes break existing invoices without reimbursable expenses?

Filter Safety CheckOld ExpensereimburseSeparately: undefined!undefined = true (regular)New Regular ExpensereimburseSeparately: false!false = true (regular)

All our filters use !expense?.reimburseSeparately. In JavaScript:

  • !undefined = true (treated as regular)
  • !false = true (treated as regular)
  • !true = false (treated as reimbursable)

Old invoices work exactly as before.

The Translation Keys

Both English and Swedish for the warning modal:

// en.json
"expenseWarningModal": {
  "title": "Reimbursable expenses exceed available balance",
  "content": "Your reimbursable expenses are higher than the invoice balance minus the platform's fee. The excess amount will need to be covered from other invoices or your existing balance.",
  "confirm": "Yes, proceed anyway",
  "goBack": "Go back and adjust"
}
// sv.json
"expenseWarningModal": {
  "title": "Återbetalbara utlägg överstiger tillgängligt saldo",
  "content": "Dina återbetalbara utlägg är högre än fakturasaldot minus plattformens avgift. Det överskjutande beloppet behöver täckas från andra fakturor eller ditt befintliga saldo.",
  "confirm": "Ja, fortsätt ändå",
  "goBack": "Gå tillbaka och justera"
}

Takeaways

Frontend work on financial features requires tracing every display path:

  1. Admin views - Need clear visual distinction for approval workflows
  2. User-facing totals - Must match what the business logic actually charges
  3. Estimation panels - Different data shapes for different invoice states
  4. List views - Aggregate numbers need careful filtering
  5. Validation - Catch edge cases before they become support tickets

The reimbursable expenses feature touched invoice creation, list display, detail views, salary estimation, and admin approval. Each had its own assumptions about what "expenses" meant. Unifying that understanding was the real work.