Repository: SplashSync/Wordpress
Plugin version: 2.1.0 (code identical at current master HEAD)
WooCommerce: 11.0.1 · Target node: Dolibarr module 2.23.3
Severity: data loss — destroys the record of money actually received
Summary
When a WooCommerce order moves to refunded, the connector stops declaring its payment. The target
reads that as "this invoice has no payments any more" and deletes the payment it holds, together with
the bank entry attached to it.
The customer really did pay. The gateway really did capture the money. After the sync, the target has
no record that it ever happened — and no record of the refund either, since refunds are not exported
at all. Both cash movements vanish.
The condition
src/Objects/Order/PaymentsTrait.php, in getPaymentsFields():
//====================================================================//
// Verify if Order Was Paid
if ($this->object->get_date_paid() && ("refunded" != $this->object->get_status())) {
$data = $this->getPaymentData($fieldId);
self::lists()->insert($this->out, "payments", $fieldName, 0, $data);
}
get_date_paid() is still set — the order was paid, and WooCommerce keeps that timestamp after a
refund. The second test is what empties the list.
What the target does with an empty list
This is list-based reconciliation, so an empty list means "delete everything". On
SplashSync/Dolibarr, src/Objects/Invoice/PaymentsTrait.php:
//====================================================================//
// Delete Remaining Lines
foreach ($this->payments as $paymentData) {
$payment = $this->newPayment();
$payment->fetch($paymentData->id);
//====================================================================//
// Check If Payment impact another Bill
$billArray = $payment->getBillsArray();
if (is_array($billArray) && (count($billArray) > 1)) {
continue;
}
$arg1 = (Local::dolVersionCmp("20.0.0") > 0) ? $user : 0;
//====================================================================//
// Try to delete Payment Line
$payment->delete($arg1);
}
The only guard is for a payment spread over several invoices. A single-invoice payment — the normal
case — is deleted unconditionally, and Dolibarr's Paiement::delete() removes the linked
llx_bank entry with it.
Measured on a production install
One season, 404 course orders:
|
|
| orders whose Stripe charge is correctly recorded on the target |
289 |
| orders with no payment at all on the target |
17 |
of those 17, orders that are refunded in WooCommerce |
17 / 17 |
| real Stripe charges behind them |
4 535,00 € |
Every one of the 17 carries a genuine ch_… charge id and a _date_paid. None is a payment that was
never made.
The deletions are visible in the primary keys:
llx_paiement : 1 164 rows, max rowid 1 277 -> 113 missing ids
llx_bank : 1 255 rows, max rowid 1 368 -> 113 missing ids
The same count on both sides — each deleted payment took its bank entry with it. An
auto-increment sequence does not leave holes for records that were never inserted.
And the timing places the deletion at the sync, within seconds of the refund:
| Invoice |
WooCommerce set to refunded |
Dolibarr invoice tms |
Delay |
| FA2510-0335 |
2025-11-04 10:12:00 |
2025-11-04 10:12:03 |
3 s |
| FA2510-0347 |
2025-11-28 16:44:51 |
2025-11-28 16:44:54 |
3 s |
| FA2511-0425 |
2025-11-05 11:19:18 |
2025-11-05 11:19:21 |
3 s |
The deletion is not limited to refunds — it also destroys payments entered by hand
The refund case above is how we found this, but the target-side loop is unconditional, so it reaches
much further. Anything the source does not declare is removed, including payments a human entered
directly on the target for movements WooCommerce never knew about — cheques, bank transfers, holiday
vouchers.
Dolibarr's tamper-evident log (llx_blockedlog, module Archive) records every payment creation and
deletion with the acting user, and cannot be rewritten. On this install it holds 1 239
PAYMENT_CUSTOMER_CREATE and 113 PAYMENT_CUSTOMER_DELETE events for the season.
Who performed the 113 deletions:
| Acting user |
Deletions |
Amount |
Splashsync (the connector's API user) |
93 |
13 107,43 € |
| an operator, cleaning up after it |
18 |
7 058,08 € |
| other |
2 |
756,02 € |
Cross-referencing each deletion with the matching creation event shows that 11 payments entered by
a human operator were deleted by the connector, totalling 2 259,93 €.
It does not wait for the operator to finish
On 12 March 2026 the operator was entering a four-cheque instalment plan on one invoice, then a
three-cheque plan on the next. The log, to the second:
16:52:22 operator creates payment 887 144,50 EUR CHQ invoice FA2510-0879
16:53:18 operator creates payment 888 144,50 EUR CHQ invoice FA2510-0879
16:53:52 operator creates payment 889 145,36 EUR CHQ invoice FA2510-0879
16:54:58 operator creates payment 890 144,50 EUR CHQ invoice FA2510-0879
16:55:53 Splashsync DELETES ALL FOUR, in the same second
16:59:22 operator creates payment 891 104,21 EUR CHQ invoice FA2511-0880
17:00:18 operator creates payment 892 104,21 EUR CHQ invoice FA2511-0880
17:01:02 operator creates payment 893 104,21 EUR CHQ invoice FA2511-0880
17:02:03 Splashsync DELETES ALL THREE, in the same second
Fifty-five seconds after the last entry the first time, sixty-one the second. The next sync touching
that order rebuilt the payment list from the source's view and wiped everything else — a partially
entered instalment plan included.
The record is replaced by a fabricated one
Four of the eleven were holiday-voucher payments (French ANCV vouchers, a payment method that
exists only offline and can never reach WooCommerce). The log preserves the payload, and the type is
explicit:
{"ref":"PAY2511-0777","date":1763204400,"type_code":"ANCV",
"payment_part":{"1":{"amount":"393.46","invoice":{"ref":"FA2511-0819", ... }}}}
The full sequence on two invoices, both of which the source believed were paid by card:
|
FA2511-0819 |
FA2511-0818 |
| operator records the real voucher payment |
2026-02-10 17:25 — 393,46 € ANCV |
2026-02-10 17:15 — 393,46 € ANCV |
| connector deletes it |
2026-02-13 10:24 |
2026-03-16 10:46 |
| connector later creates a card payment for the full amount |
2026-03-19 |
2026-07-17 |
The substituted payments carry no ext_payment_id — no gateway reference of any kind — because no
card transaction ever existed. Across the install, 13 orders whose declared payment method is a
holiday voucher now hold a gateway payment with no reference, for 3 938,19 €. The true payment
method, the true date and the true bank account were all lost, and replaced with values that look
plausible and are wrong.
Practical consequence
The operator used the target for six weeks — 41 payment entries in February 2026, 59 in March — and
stopped on 17 March 2026. We cannot prove causation from the log alone, but the same log shows them
deleting 18 connector-created payments during those six weeks while the connector deleted 11 of
theirs: a tug-of-war neither side could win. Nothing was entered by hand after that date.
This half belongs to SplashSync/Dolibarr, not to this repository: the destructive loop is
src/Objects/Invoice/PaymentsTrait.php, quoted above. It is filed here because the refund condition
in this connector is what makes the loop fire on orders that were legitimately paid — but the
unconditional delete would benefit from its own guard regardless of what the source declares.
Why this is worse than it looks
It is retroactive. The target does not merely stop showing the invoice as paid — it erases the
evidence that it ever was. There is no audit trail on the target; only the gaps in the id sequence
betray what happened.
It breaks bank reconciliation permanently. With the receipt gone and the refund never exported,
the gateway account on the target has neither leg of the transaction. On this install none of the
1 074 entries on the Stripe bank account is reconciled, and cannot be: the counterparts no longer
exist.
It compounds with the status mapping. OrderStatusManager and InvoiceStatusManager both map
refunded alongside cancelled and trash to CANCELED, so the invoice is also marked abandoned.
The target ends up stating that a fully paid, fully refunded order was cancelled and never paid —
three facts, all false.
Suggested fix
Keep declaring the payment. A refund does not undo the payment; it is a second, opposite
movement. Dropping the "refunded" != get_status() test would leave the receipt intact and let the
refund be represented as what it is.
-if ($this->object->get_date_paid() && ("refunded" != $this->object->get_status())) {
+if ($this->object->get_date_paid()) {
That alone stops the data loss. Representing the refund itself needs the CreditNote object filed
separately.
Meanwhile, a filter is enough to work around it locally. OrderStatusManager exposes
splash_prepend_order_statuses, and encode() returns the first Splash status matching the
WooCommerce one — so moving "refunded" from CANCELED to DELIVERED in a small mu-plugin both
fixes the status and makes the condition above true again. Useful, but it should not be the answer
for every installation.
Related
The target side needs a guard of its own, and the evidence above raises the bar for it. Refusing to
delete a payment that is reconciled with a bank statement is the minimum. Better: never delete a
payment the source could not have created in the first place — one whose payment method does not
exist in the source, or that carries no external reference tying it to a source transaction. At the
very least, a removed settled payment should be logged rather than vanish silently.
Filed separately: WooCommerce never exports refunds (no CreditNote object, no refund hook), and
Dolibarr's CreditNote cannot be attached to its source invoice (fk_facture_source not exposed).
The three together are what make a refund unrepresentable end to end.
Repository: SplashSync/Wordpress
Plugin version: 2.1.0 (code identical at current
masterHEAD)WooCommerce: 11.0.1 · Target node: Dolibarr module 2.23.3
Severity: data loss — destroys the record of money actually received
Summary
When a WooCommerce order moves to
refunded, the connector stops declaring its payment. The targetreads that as "this invoice has no payments any more" and deletes the payment it holds, together with
the bank entry attached to it.
The customer really did pay. The gateway really did capture the money. After the sync, the target has
no record that it ever happened — and no record of the refund either, since refunds are not exported
at all. Both cash movements vanish.
The condition
src/Objects/Order/PaymentsTrait.php, ingetPaymentsFields():get_date_paid()is still set — the order was paid, and WooCommerce keeps that timestamp after arefund. The second test is what empties the list.
What the target does with an empty list
This is list-based reconciliation, so an empty list means "delete everything". On
SplashSync/Dolibarr,src/Objects/Invoice/PaymentsTrait.php:The only guard is for a payment spread over several invoices. A single-invoice payment — the normal
case — is deleted unconditionally, and Dolibarr's
Paiement::delete()removes the linkedllx_bankentry with it.Measured on a production install
One season, 404 course orders:
refundedin WooCommerceEvery one of the 17 carries a genuine
ch_…charge id and a_date_paid. None is a payment that wasnever made.
The deletions are visible in the primary keys:
The same count on both sides — each deleted payment took its bank entry with it. An
auto-increment sequence does not leave holes for records that were never inserted.
And the timing places the deletion at the sync, within seconds of the refund:
refundedtmsThe deletion is not limited to refunds — it also destroys payments entered by hand
The refund case above is how we found this, but the target-side loop is unconditional, so it reaches
much further. Anything the source does not declare is removed, including payments a human entered
directly on the target for movements WooCommerce never knew about — cheques, bank transfers, holiday
vouchers.
Dolibarr's tamper-evident log (
llx_blockedlog, module Archive) records every payment creation anddeletion with the acting user, and cannot be rewritten. On this install it holds 1 239
PAYMENT_CUSTOMER_CREATEand 113PAYMENT_CUSTOMER_DELETEevents for the season.Who performed the 113 deletions:
Splashsync(the connector's API user)Cross-referencing each deletion with the matching creation event shows that 11 payments entered by
a human operator were deleted by the connector, totalling 2 259,93 €.
It does not wait for the operator to finish
On 12 March 2026 the operator was entering a four-cheque instalment plan on one invoice, then a
three-cheque plan on the next. The log, to the second:
Fifty-five seconds after the last entry the first time, sixty-one the second. The next sync touching
that order rebuilt the payment list from the source's view and wiped everything else — a partially
entered instalment plan included.
The record is replaced by a fabricated one
Four of the eleven were holiday-voucher payments (French ANCV vouchers, a payment method that
exists only offline and can never reach WooCommerce). The log preserves the payload, and the type is
explicit:
{"ref":"PAY2511-0777","date":1763204400,"type_code":"ANCV", "payment_part":{"1":{"amount":"393.46","invoice":{"ref":"FA2511-0819", ... }}}}The full sequence on two invoices, both of which the source believed were paid by card:
FA2511-0819FA2511-0818ANCVANCVThe substituted payments carry no
ext_payment_id— no gateway reference of any kind — because nocard transaction ever existed. Across the install, 13 orders whose declared payment method is a
holiday voucher now hold a gateway payment with no reference, for 3 938,19 €. The true payment
method, the true date and the true bank account were all lost, and replaced with values that look
plausible and are wrong.
Practical consequence
The operator used the target for six weeks — 41 payment entries in February 2026, 59 in March — and
stopped on 17 March 2026. We cannot prove causation from the log alone, but the same log shows them
deleting 18 connector-created payments during those six weeks while the connector deleted 11 of
theirs: a tug-of-war neither side could win. Nothing was entered by hand after that date.
This half belongs to
SplashSync/Dolibarr, not to this repository: the destructive loop issrc/Objects/Invoice/PaymentsTrait.php, quoted above. It is filed here because the refund conditionin this connector is what makes the loop fire on orders that were legitimately paid — but the
unconditional delete would benefit from its own guard regardless of what the source declares.
Why this is worse than it looks
It is retroactive. The target does not merely stop showing the invoice as paid — it erases the
evidence that it ever was. There is no audit trail on the target; only the gaps in the id sequence
betray what happened.
It breaks bank reconciliation permanently. With the receipt gone and the refund never exported,
the gateway account on the target has neither leg of the transaction. On this install none of the
1 074 entries on the Stripe bank account is reconciled, and cannot be: the counterparts no longer
exist.
It compounds with the status mapping.
OrderStatusManagerandInvoiceStatusManagerboth maprefundedalongsidecancelledandtrashtoCANCELED, so the invoice is also marked abandoned.The target ends up stating that a fully paid, fully refunded order was cancelled and never paid —
three facts, all false.
Suggested fix
Keep declaring the payment. A refund does not undo the payment; it is a second, opposite
movement. Dropping the
"refunded" != get_status()test would leave the receipt intact and let therefund be represented as what it is.
That alone stops the data loss. Representing the refund itself needs the
CreditNoteobject filedseparately.
Meanwhile, a filter is enough to work around it locally.
OrderStatusManagerexposessplash_prepend_order_statuses, andencode()returns the first Splash status matching theWooCommerce one — so moving
"refunded"fromCANCELEDtoDELIVEREDin a small mu-plugin bothfixes the status and makes the condition above true again. Useful, but it should not be the answer
for every installation.
Related
The target side needs a guard of its own, and the evidence above raises the bar for it. Refusing to
delete a payment that is reconciled with a bank statement is the minimum. Better: never delete a
payment the source could not have created in the first place — one whose payment method does not
exist in the source, or that carries no external reference tying it to a source transaction. At the
very least, a removed settled payment should be logged rather than vanish silently.
Filed separately: WooCommerce never exports refunds (no
CreditNoteobject, no refund hook), andDolibarr's
CreditNotecannot be attached to its source invoice (fk_facture_sourcenot exposed).The three together are what make a refund unrepresentable end to end.