Support ticket comes in: invoice 85427 paid in 3 installments in Fortnox, but only 2 payments show in the platform. The invoice is stuck at "partially paid" when it should be "paid". Classic sync bug, right?
Checking CloudWatch logs reveals the payment sync command ran, processed the invoice, logged "Processing payment" for each Fortnox payment. No errors. Handler completed successfully. But only 1 payment exists in the database.
The Payment Sync Pipeline
Invoice payments flow through a cron-driven pipeline. Every 30 minutes, the FortnoxProcessInvoicePaymentsCommand queries all invoices in booked_in_fortnox or partially_paid status, fetches their payments from Fortnox, and dispatches a message for each.
The handler iterates through each payment number from Fortnox. For each, it calls paymentProcessed() to check if already registered. If not, it creates an InvoicePayment entity and saves it.
The False Match
The bug hides in the backwards-compatibility check. Originally, payments weren't stored with their Fortnox number. The code added a fallback: if amount AND date match an existing payment, assume it's the same one.
This works when payments have different amounts or dates. It breaks catastrophically when multiple payments share both.
Invoice 85427 had two payments on the same day, same amount. Payment #15547 was registered first. When the sync ran later, #15547 matched by fortnox_number — correct. But #15546 matched the existing payment by amount+date — wrong payment, same signature.
The Recovery
Fixing the specific invoice required temporarily breaking the false match. The existing payment's created_at was 2026-02-06. The Fortnox payments both have paymentDate of 2026-02-06. Changing the existing payment's date breaks the amount+date match:
-- Step 1: Break the false match
UPDATE invoice_payment
SET created_at = '2026-02-05'
WHERE id = '8cf58131-fa88-4aa1-a59e-0922fca088a2';
-- Step 2: Set invoice to partially_paid (sync only queries this status)
UPDATE invoice
SET status = 'partially_paid'
WHERE id = 'fec52c61-bac2-431c-8d39-b0cf4a2979a3';
-- Step 3: Run sync
aws lambda invoke --function-name worknode-api-prod-console \
--payload '"app:fortnox:process-invoice-payments"' ...
-- Step 4: Restore original date
UPDATE invoice_payment
SET created_at = '2026-02-06'
WHERE id = '8cf58131-fa88-4aa1-a59e-0922fca088a2';
-- Step 5: Set invoice back to paid
UPDATE invoice SET status = 'paid' WHERE id = '...';After step 3, the database showed 2 payments. #15547 matched by fortnox_number (skipped). #15546 failed both checks — different fortnox_number, different date — and was correctly created.
The Pattern
The backwards-compatibility check assumes payment signatures are unique within an invoice. Amount alone isn't unique (customer could pay same amount twice). Date alone isn't unique (multiple payments same day). Amount AND date together seemed safe — until a customer made two identical partial payments on the same day.
The proper fix is straightforward: if an existing payment has a fortnox_number, only match by that field. The amount+date fallback should only apply to legacy payments without a stored number:
foreach ($invoice->getPayments() as $payment) {
if ($payment->getFortnoxNumber() === $fortnoxPayment->number) {
return true;
}
// Only use amount+date for legacy payments without fortnox_number
if (null === $payment->getFortnoxNumber()) {
if ($equalAmounts && $sameDates) {
return true;
}
}
}This preserves backwards compatibility for old payments while preventing false matches when fortnox_number is available.
The Broader Issue
This bug only surfaces under specific conditions: multiple payments, same invoice, same amount, same date, processed after the first one is already registered. Rare enough to slip through testing, common enough to affect real users.
The logs showed no errors — both payments were "processed" according to the handler. The check passed for both, just for the wrong reasons. Silent failures in idempotency checks are particularly insidious because everything looks correct until you count the records.