A user reported that their dashboard statistics were wrong. They had three unpaid invoices totaling 47,063 SEK — two of them sent in February worth 22,753 and 16,531. The February bar showed zero. The chart only displayed bars for December and January, which happened to be when older invoices had received payments.
The chart wasn't broken. It was answering the wrong question. Instead of "what invoices were sent each month," it was answering "when did payments arrive." Those are fundamentally different views of the data.
How It Worked Before
The InvoiceStatisticsCalculator fetched all InvoicePayment records for the user and grouped them by the payment's createdAt timestamp:
$payments = $this->invoicePaymentRepository->findByUserId($user->getId());
foreach ($payments as $payment) {
$createdAt = $payment->getCreatedAt();
$monthKey = $createdAt->format('Y-m-01');
// aggregate net, gross, salary into $monthKey bucket
}This meant an invoice sent in January but paid in March would appear in March's bar. And an invoice sent in February that hasn't been paid yet? It doesn't have any InvoicePayment records. It simply doesn't exist in the chart.
The ticket was specific: invoices should appear in the month they were sent. Always. Regardless of when — or whether — payment arrives. The only time an invoice should vanish from statistics is when it's credited.
First Fix: Wrong Depth
The initial approach was minimal — keep iterating InvoicePayment records but group by the parent invoice's sentAt instead of the payment's createdAt. Replace $payment->getCreatedAt() with $payment->getInvoice()->getSentAt(), skip credited invoices, done.
This fixed the grouping for paid invoices. A January invoice paid in March would now correctly appear in January. But the user's February invoices — the ones that triggered the ticket — still showed nothing. No payment record means the loop never encounters them.
The fix was correct but incomplete. The real problem wasn't just the grouping key. It was the data source.
Second Fix: Query Invoices Directly
The calculator needed two separate data sources. Invoice amounts should come from the Invoice entity itself, not from payment records. Salary amounts still need payment records because salary calculations (commission, tax, social fees) only exist after payment.
The InvoiceRepository already had findByOwnerAndStatuses() which accepts an array of InvoiceStatus enums. Passing SENT, BOOKED_IN_FORTNOX, PARTIALLY_PAID, and PAID — but not CREDITED — gives exactly the right set. The query already orders by sentAt ASC.
$invoices = $this->invoiceRepository->findByOwnerAndStatuses($user, [
InvoiceStatus::SENT,
InvoiceStatus::BOOKED_IN_FORTNOX,
InvoiceStatus::PARTIALLY_PAID,
InvoiceStatus::PAID,
]);
foreach ($invoices as $invoice) {
$sentAt = $invoice->getSentAt();
if (null === $sentAt) {
continue;
}
$monthKey = $sentAt->format('Y-m-01');
$net = $invoice->getGrandTotalTaxExcluded();
$gross = $invoice->getGrandTotal();
// Currency conversion for non-SEK invoices
if (!$invoice->getCurrency()->equals(SupportedCurrency::SEK())) {
$rate = $invoice->getFortnoxCurrencyRateAsString();
if (null === $rate) {
continue;
}
$net = new Money($net->multiply($rate)->getAmount(), SupportedCurrency::SEK());
$gross = new Money($gross->multiply($rate)->getAmount(), SupportedCurrency::SEK());
}
// Aggregate into monthly buckets and period totals...
}Invoice amounts come from getGrandTotalTaxExcluded() (net, without VAT) and getGrandTotal() (gross, with VAT). These are computed from invoice rows and expenses — the actual invoice value, not what was paid. For non-SEK invoices, the fortnoxCurrencyRate converts to SEK using the same rate Fortnox applied when the invoice was booked.
The salary loop stays payment-based but now groups by the parent invoice's sentAt. Salary figures (commission, social fees, income tax, net payout) are only calculated when a payment is processed, so there's no way to show salary data for unpaid invoices — and that's correct. The salary chart should only reflect actual payouts.
What Changed
One file in the backend: InvoiceStatisticsCalculator.php. The constructor gained an InvoiceRepository dependency — Symfony autowires it automatically. The single foreach loop over payments split into two: one iterating Invoice entities for the invoice chart and period totals, another iterating InvoicePayment records for the salary chart. The early-return on empty payments was removed since the invoice query is independent.
No frontend changes. The API response shape is identical — same MonthlyStatistic objects with month, net, and gross fields. The chart component, the period selector, and the "Totalt betalda fakturor" card all consume the same structure. They don't know or care that the backend now queries different tables.
The Distinction That Matters
The ticket's core insight was deceptively simple: the invoice chart should show invoices, not payments. Those are different entities with different lifecycles. An invoice exists from the moment it's sent. A payment exists from the moment money arrives. Conflating the two made the chart useless for anyone with outstanding invoices — which is most users most of the time.
The salary chart correctly stays payment-based. You can't show someone their salary before it's been calculated. But you can — and should — show them their invoices the moment they're sent.