Back to Journal

The payment that matched itself

Listen to this articleAI narration
0:00 / 0:00

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.

Fortnox Payment Sync PipelineCron (30 min)FortnoxProcessInvoicePaymentsCommandFortnox APIGET /invoicepaymentsReturns [15547, 15546]SQS QueueProcessFortnoxInvoicePaymentsHandlerFor each payment:paymentProcessed()?→ skip or createThe paymentProcessed() Checkforeach ($invoice->getPayments() as $payment) {// Match by fortnox_number (correct)if ($payment->getFortnoxNumber() === $fortnoxPayment->number) return true;// Backwards compatibility (problematic)if ($equalAmounts && $sameDates) return true;}

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.

How Identical Payments Cause False MatchesExisting Payment (DB)fortnox_number: 15547amount: 33,437.50 SEKdate: 2026-02-06Fortnox #15547amount: 33,437.50 SEKdate: 2026-02-06Match: fortnox_numberFortnox #15546amount: 33,437.50 SEKdate: 2026-02-06FALSE Match: amount+dateResult#15547: Matched by fortnox_number → correctly skipped#15546: Matched by amount+date against #15547 → incorrectly skippedPayment #15546 never created — silently lost

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.