Skip to content

FIX never add a payment to an invoice that is already settled - #32

Open
Pichinov-Jose wants to merge 1 commit into
SplashSync:2.0from
Pichinov-Jose:fix/never-double-settle-invoice
Open

Pichinov-Jose wants to merge 1 commit into
SplashSync:2.0from
Pichinov-Jose:fix/never-double-settle-invoice

Conversation

@Pichinov-Jose

Copy link
Copy Markdown

Bug

An invoice can be settled twice by a single sync. setPaymentLineData() pairs source payment lines with local payments by position, taking them from $this->payments — which loadPayments() fills from the payments linked to this invoice. A payment that exists in the database but is linked to no invoice is therefore invisible to the pairing, and its source line has nothing to pair with.

identifyExistingPayment() is the safety net for exactly that, and it gives up in exactly the cases that produce it:

  • getSimilarPayment() bails out on empty($lineData["number"]). Only gateway transactions carry a number. Vouchers, cheques, transfers and cash have none, so no lookup is even attempted.
  • it then requires getPaymentInvoicesTotals() to be non-empty — so a payment carrying no invoice link, the very case it is meant to recover, is rejected.

The line falls through to createPaymentItem(), and the invoice ends up with two payments for one movement. The same happens whenever the announced payment method no longer matches the recorded one, since searchForSimilarPayment() matches on method + number + date + amount as an exact tuple.

Observed in production on one shop: 12 duplicate payments, 1 514.76 € of bank entries with no money behind them, in two episodes — 8 left unlinked by a resync in 2025, and 4 attached to their invoice, which then showed twice its amount as settled.

Note that Dolibarr core offers no protection here either: Paiement::create() computes $remaintopay only to decide whether to close the invoice, and never checks the amount. The interactive form warns (PaymentHigherThanReminderToPay) but it is a confirmation dialog, not a refusal — so nothing stops a programmatic overpayment.

Fix

Rather than enumerate the causes, compare against what the invoice already carries: payments, credit notes and deposits covering the total leave nothing to receive.

This deliberately does not forbid overpaying. A settlement in holiday vouchers or in cash rarely falls on the exact cent — 130.00 € of vouchers against a 126.18 € invoice is a real, legitimate payment, and it is accepted because nothing covers the invoice yet when it arrives. What is refused is the second full settlement of an invoice already closed.

Mirrors the philosophy of #25 and #30: local, recorded money outranks the source's view of it.

🤖 Generated with Claude Code

Source payment lines are paired with local payments by position, so a
payment that is not linked to this invoice is invisible to the pairing.
identifyExistingPayment() is meant to recover those, but it gives up in
exactly the cases that produce them: getSimilarPayment() requires a non
empty "number", which only gateway transactions carry, and the candidate
must already carry invoice totals — so a payment linked to no invoice, the
very case it should catch, is rejected. The line then falls through to
createPaymentItem() and the invoice is settled twice.

Compare against what the invoice already carries instead of enumerating the
causes. Overpaying stays possible — a settlement in vouchers or cash rarely
falls on the exact cent, and nothing covers the invoice when it arrives —
what is refused is the second full settlement of an invoice already closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant