An invoice shows status paid. The customer paid it. Fortnox confirms it. The internal money pool has the funds allocated — commission deducted, social fees moved, income tax reserved. But the freelancer's bank account is empty. No payslip was ever created.
Checking the invoice_payment table: zero rows. Checking with a different invoice that does have an InvoicePayment record: still no payslip. Two different failure modes, same outcome — the worker never got paid.
The Pipeline
Invoice payments flow through a multi-stage async pipeline. A cron job polls Fortnox for new payments, a handler creates local records and sends funds to the internal pool, another handler updates the invoice status, and an event subscriber creates the payslip that triggers the actual bank payout.
Scenario A: Paid Without Payments
The first invoice had status paid but zero InvoicePayment records. The UpdateInvoicePaymentStatusHandler is the culprit — it checks Fortnox directly and transitions based solely on the finalPayDate field:
if (null !== $fortnoxInvoice->finalPayDate) {
$this->invoiceStateMachine->apply($invoice, 'pay');
$this->finalizeProSettlement($invoice);
$this->invoiceRepo->flush();
return;
}No check for existing InvoicePayment records. Fortnox says paid, the invoice transitions. The CompletedPay event fires. The payslip subscriber runs — but $invoice->getPayments() returns an empty collection:
if ([] === $paymentsToAdd) {
$this->logger->warning('[PAYSLIP] No payments to add to payslip for invoice {invoiceId}, skipping payslip creation.');
return;
}Meanwhile, the cron that actually creates InvoicePayment records only queries invoices in booked_in_fortnox or partially_paid. This invoice is already paid, so it's never picked up again. Dead end.
Scenario B: Payments Without Status
The second invoice still showed booked_in_fortnox despite having an InvoicePayment with sent_to_pinebucket_at set. The money had been moved to the user's internal pool — commission deducted, taxes reserved — but the invoice status never transitioned.
The Pinebucket transaction log confirmed it. Two transactions completed on the same day: void-to-user pool (gross amount), then user-to-fees pools (commission, social fees, income tax). The net amount was sitting in the user's spendable balance. But no to_external transfer — because that requires a payslip, which requires a status transition, which never happened.
2026-02-16 10:56:43 20,400 SEK void money_pool completed
2026-02-16 10:56:53 -806 SEK commission completed
2026-02-16 10:56:53 -4,685 SEK social_fees completed
2026-02-16 10:56:53 -4,473 SEK income_tax completed
--- no to_external transfer ---
The SendPaymentToPinebucketHandler dispatches UpdateInvoicePaymentStatus with DispatchAfterCurrentBusStamp. If that dispatch failed, or the subsequent handler errored when checking Fortnox, or the SQS message was lost — the invoice stays stuck. The money is allocated internally but never reaches the freelancer's bank account.
The Fix
Scenario B is straightforward. The force-pay console command re-dispatches UpdateInvoicePaymentStatus, which checks Fortnox, sees finalPayDate is set, transitions to paid, fires CompletedPay, and the subscriber finds the existing InvoicePayment and creates the payslip:
php bin/console app:invoice:force-pay <invoice-id>Scenario A requires two steps. The invoice is already paid but has no payment records. Reverting the status to booked_in_fortnox lets the backfill command pick it up, create the InvoicePayment records from Fortnox data, and then force-pay completes the cycle.
For cases where the invoice did transition correctly but the payslip subscriber failed independently, we added a new command that re-dispatches the CompletedPay event:
#[AsCommand(
name: 'app:invoice:link-payslip',
description: 'Re-dispatch CompletedPay to link existing invoice payments to a payslip.',
)]
class LinkInvoicePayslipCommand extends Command
{
public function __construct(private readonly MessageBusInterface $messengerBusEvent)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$invoiceId = Uuid::fromString((string) $input->getArgument('invoiceId'));
$this->messengerBusEvent->dispatch(new CompletedPay($invoiceId));
$io->success(sprintf('CompletedPay dispatched for %s', $invoiceId));
return Command::SUCCESS;
}
}The event is routed to [sync, workqueue] — sync executes immediately in the same process, so the payslip is created on the spot.
Finding Other Affected Invoices
Two queries surface both failure modes:
-- Scenario A: paid but no InvoicePayment records at all
SELECT i.id, i.fortnox_id, i.owner_id
FROM invoice i
LEFT JOIN invoice_payment ip ON ip.invoice_id = i.id
WHERE i.status = 'paid'
GROUP BY i.id HAVING COUNT(ip.id) = 0;
-- Scenario B: InvoicePayment exists but no payslip linked
SELECT ip.id, ip.invoice_id, ip.sent_to_pinebucket_at
FROM invoice_payment ip
WHERE ip.payslip_id IS NULL
AND ip.sent_to_pinebucket_at IS NOT NULL;The Deeper Problem
Both scenarios stem from the same architectural gap: the pipeline assumes each stage will succeed if the previous one did. There's no reconciliation step that checks end-to-end consistency. A payment can exist in Fortnox, be allocated in the internal pool, but never reach the freelancer — and nothing flags it.
The UpdateInvoicePaymentStatusHandler trusting Fortnox's finalPayDate without checking for local payment records is the immediate cause. But the broader issue is that three independently dispatched async messages — create payment, update status, create payslip — have no transactional guarantee across the full chain. If any link breaks, the downstream steps never fire and nothing retries the whole sequence.
CloudWatch retention was set to 3 days, which meant the original failure logs were already gone by the time we investigated. The state_log table — which persists every invoice status transition with timestamps and context — was the only way to reconstruct what happened. That table and the Pinebucket transaction history became the forensic trail.