Skip to content

Keep payments the remote source could not have created - #25

Open
Pichinov-Jose wants to merge 3 commits into
SplashSync:2.0from
Pichinov-Jose:fix/keep-locally-entered-payments
Open

Pichinov-Jose wants to merge 3 commits into
SplashSync:2.0from
Pichinov-Jose:fix/keep-locally-entered-payments

Conversation

@Pichinov-Jose

@Pichinov-Jose Pichinov-Jose commented Sep 4, 2026

Copy link
Copy Markdown

The problem

setPaymentLineFields() pairs local payments with remote lines by positionsetPaymentLineData()
starts with array_shift($this->payments) — and then deletes every payment left over:

//====================================================================//
// Delete Remaining Lines
foreach ($this->payments as $paymentData) {
    ...
    $payment->delete($arg1);
}

The only guard is for a payment spread over several invoices. So a payment entered by hand in
Dolibarr, for a movement the source never knew about, is either reused for an unrelated remote line
or deleted outright — and Paiement::delete() takes the llx_bank entry with it.

Nothing in the source ever held that information, so it is simply lost.

This is not theoretical

On a production install, Dolibarr's tamper-evident log (llx_blockedlog, module Archive) records
113 PAYMENT_CUSTOMER_DELETE events for one season, 93 of them performed by the connector's API
user. Cross-referencing each deletion with its creation event shows 11 payments entered by a human
operator that the connector deleted
, totalling 2 259,93 €.

It does not even wait for the operator to finish. On 12 March 2026 they were entering a four-cheque
instalment plan on one invoice, then a three-cheque plan on the next:

16:52:22   operator    creates payment 887   144,50 EUR  CHQ   invoice A
16:53:18   operator    creates payment 888   144,50 EUR  CHQ   invoice A
16:53:52   operator    creates payment 889   145,36 EUR  CHQ   invoice A
16:54:58   operator    creates payment 890   144,50 EUR  CHQ   invoice A
16:55:53   connector   DELETES ALL FOUR, in the same second

16:59:22   operator    creates payment 891   104,21 EUR  CHQ   invoice B
17:00:18   operator    creates payment 892   104,21 EUR  CHQ   invoice B
17:01:02   operator    creates payment 893   104,21 EUR  CHQ   invoice B
17:02:03   connector   DELETES ALL THREE, in the same second

Four of the eleven were French ANCV holiday vouchers — a payment method that exists only offline
and can never reach the source. The log preserves the payload, and the type is explicit:

{"ref":"PAY2511-0777","type_code":"ANCV","payment_part":{"1":{"amount":"393.46", ...}}}

Those were later replaced by card payments carrying no gateway reference, because no card transaction
ever existed. The true payment method, date and bank account were all lost.

The change

Three guards, applied before any pairing happens.

The invoice is left untouched when the local decomposition is richer than the source's. A source
that models one payment per order cannot describe an instalment plan. When the local side holds
several payments that already cover the invoice, and the source declares fewer lines, there is
nothing to write: the local set is a refinement of the very fact being reported. Nothing is paired,
deleted or added. This is the guard that covers the four-cheque plan above — those payments use
CHQ, a method the source expresses perfectly well, so no per-payment test can save them.

Three further kinds of payment can never have come from the source, and are set aside individually:

  • a payment whose method the source does not declare. Cash recorded locally against an order the
    shop believes was paid by card is not another version of the same movement — it is a movement the
    source knows nothing about. This is what protects a settlement still in progress, where the local
    record is the only one that exists.
  • a payment method with no Splash equivalent. PaymentMethods::getSplashCode() returns the raw
    Dolibarr code when it knows no mapping, so an unmapped method is never a key of
    PaymentMethods::KNOWN. Holiday vouchers, and any site-specific method, fall here.
  • a payment whose bank entry is already reconciled with a statement (rappro or num_releve
    set). The accounting is closed; a sync has no business rewriting it.

Each decision is logged as a warning, so the behaviour is visible rather than silent.

Settlement is counted the way the ERP counts it: getSumCreditNotesUsed() and
getSumDepositsUsed() are added to the payments. An invoice closed by payments plus a discount is
settled just as surely as one closed by payments alone, and the first guard must see that.

Scope

The first guard needs at least two local payments, strictly more than the source declares, and a
local total that already settles the invoice. A single local payment, or a source describing as many
lines as the target holds, is unaffected — so an ordinary order syncs exactly as before.

A payment using the method the source declares, not reconciled, on an invoice with a single payment
line stays fully managed by Splash. No configuration is added: the guards only ever protect data the
source could not own, could not express, or never mentioned.

One trade-off is explicit: Splash can no longer change an existing payment's method to one it did
not previously declare — correcting a method becomes a local operation. Given that the alternative
silently destroys offline settlements, that seems the right way round, but it is a behaviour change
and worth a maintainer's opinion.

clearPayments() is deliberately untouched, as it is documented as debug-only for PHPUnit.

Verified on the affected install

Replaying the guards against the six invoices from the incident:

Invoice Local payments Outcome
4-cheque plan, 578,86 € 4 × CHQ untouched — richer decomposition
3-cheque plan, 312,63 € 3 × CHQ untouched — richer decomposition
mixed card + 2 cheques, 1 208,48 € CB + 2 × CHQ untouched — richer decomposition
holiday voucher, 393,46 € 1 × ANCV payment kept — unmapped method
holiday voucher, 393,46 € 1 × ANCV payment kept — unmapped method
card + transfer, 419,21 € CB + VIR, plus a 12,21 € credit note untouched — settled, once the credit note is counted
single card payment, 250,00 € 1 × CB still managed by Splash, as intended

The last row is the refunded-order case: a single mapped payment, which this PR deliberately does
not cover. That one belongs to the source declaring it in the first place — see the related issue.

Related

Filed separately as SplashSync/Wordpress#13: a WooCommerce order moving to refunded stops
declaring its payment, which makes this same loop delete a legitimately settled payment. That issue
is what led here, but the loop needs a guard of its own regardless of what any source declares.

setPaymentLineFields() pairs local payments with remote lines by position
(array_shift), then deletes everything left over. A payment entered by hand
in Dolibarr, for a movement the source never knew about, is therefore either
reused for an unrelated remote line or silently destroyed on the next sync,
together with its bank entry.

Two kinds of payment can never have come from the source, and are now set
aside before any pairing happens:

  - a payment method with no Splash equivalent (PaymentMethods::getSplashCode
    returns the raw Dolibarr code when it knows no mapping), such as holiday
    vouchers or any site-specific method;
  - a payment whose bank entry is already reconciled with a statement.

Both are removed from the working list, so they are neither reused nor
deleted, and a warning names the payment that was left alone. Payments using
a mapped method stay fully managed by Splash: existing behaviour is unchanged
for everything the source can actually express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Pichinov-Jose
Pichinov-Jose marked this pull request as ready for review September 5, 2026 00:06
@Pichinov-Jose
Pichinov-Jose marked this pull request as ready for review September 5, 2026 00:06
@Pichinov-Jose
Pichinov-Jose marked this pull request as draft September 5, 2026 00:50
@Pichinov-Jose
Pichinov-Jose marked this pull request as ready for review September 5, 2026 00:51
Pichinov-Jose and others added 2 commits September 5, 2026 02:59
The two previous guards ask "could the source have created this payment?".
That misses the most damaging case, and the one the report opens with: a
four-cheque instalment plan, entered by hand, using a payment method the
source knows perfectly well.

A source that models one payment per order cannot describe a schedule. When
the local side holds several payments that already cover the invoice, pairing
them one-by-one with the source's shorter list keeps the first and deletes the
rest — the schedule is replaced by a single line, for the same total.

There is nothing to write in that case: the local set is a refinement of the
very fact the source is reporting. The invoice is left untouched, neither
paired, deleted, nor added to, and a warning records the decision.

Only triggers with at least two local payments, more than the source declares,
and a local sum that already covers the invoice. A single local payment, or a
source that describes as many lines as the target holds, is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps found while replaying the guards against the affected install.

Credit notes were not counted as settlement. An invoice closed by payments
plus a discount was treated as under-paid, so the richer-decomposition guard
did not apply to it. One real case: two payments entered by hand, 407,00 of
419,21, the remaining 12,21 cancelled by a credit note. Fully settled, and
unprotected. getSumCreditNotesUsed() and getSumDepositsUsed() now count.

A payment whose method the source does not declare is now kept. Cash recorded
locally against an order the shop believes was paid by card is not another
version of the same movement — it is a movement the source knows nothing
about. Pairing them by position rewrites the local one to the source's amount
and method, which is how a partial local settlement gets destroyed: enter ten
euros in cash, let the shop mark the order complete, and the entry comes back
as a card payment for the full total.

This is the case the previous guards missed. The richer-decomposition guard
needs the local set to already cover the invoice, so it cannot help while a
settlement is still incomplete — precisely when the local record is the only
one that exists.

The trade-off is explicit: Splash can no longer change the payment method of
an existing payment to one it did not previously declare. Correcting a method
becomes a local operation. Given that the alternative silently destroys
offline settlements, that seems the right way round.

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