Skip to content

A refunded order silently deletes the settled payment on the target — and its bank entry #13

Description

@Pichinov-Jose

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions