Skip to content

Add a read-only CreditNote object for WooCommerce refunds - #18

Open
Pichinov-Jose wants to merge 1 commit into
SplashSync:masterfrom
Pichinov-Jose:feat/credit-note-object
Open

Pichinov-Jose wants to merge 1 commit into
SplashSync:masterfrom
Pichinov-Jose:feat/credit-note-object

Conversation

@Pichinov-Jose

Copy link
Copy Markdown

Closes #17.

WooCommerce refunds are invisible to Splash. The connector exposes seven objects and none covers a refund, so a remote server sees an order total change but never learns that a credit note exists, who issued it, why, or which invoice it credits.

This adds a read-only CreditNote object. Unlike Invoice, which is a virtual view of an Order, it is backed by a real WooCommerce entity: the shop_order_refund post type. Its post_parent is the exact counterpart of the fk_facture_source field that the Dolibarr connector's CreditNote carries — see SplashSync/Dolibarr#29, which exposes it on that side. The two ends of the mapping now exist.

Scope

Read-only, like Invoice. wc_create_refund() can trigger a gateway refund, so the write path is a money-moving operation that deserves its own design rather than being slipped into a first pass. create() and delete() refuse explicitly.

Two WooCommerce behaviours worth flagging

Signs. WooCommerce exposes the same money twice: get_total() is negative, get_amount() positive. Totals are built on get_total(), so amounts arrive already negated and no equivalent of the Dolibarr connector's Core\CreditModeTrait is needed here. get_amount() is exposed separately as amount for servers that prefer a positive figure.

Related: Order\TotalsTrait::toTotalPrice() guards its VAT computation with $totalTaxExcl > 0, which never holds on a credit note and would report every one of them as 0% VAT. This PR uses a local toCreditPrice() computing the rate on absolute values, rather than changing the Order path. Happy to fix it there instead if you prefer.

Full versus partial. The _refund_type meta looks like the answer and is not. It is written by WooCommerce Analytics (Admin\API\Reports\Products\DataStore) on the woocommerce_order_(partially|fully)_refunded hooks, so it records the state the order reached when the refund landed — not the refund's own coverage — and it is never revised when a sibling refund is deleted or the order is edited. On the shop this was built against, 165 of 399 refunds carry full while covering only part of their order; one carries full for 125.00 on a 250.00 order with no sibling at all. The object exposes is_order_fully_refunded, recomputed live from the parent, instead.

Line items are the exception. 1 refund in 400 on that shop has any. An amount-only refund is rendered as a single line built from its amount and reason, flagged is_virtual_item so a consumer can tell rebuilt detail from real detail.

Fields

Field Notes
parent_id source invoice — the fk_facture_source counterpart
parent_order_id same id seen as an Order, for servers that sync Orders only
_customer_id read from the parent; a refund has no customer of its own
reference YITH PDF Invoice credit note number when present, else #<order>-R<id>
reason, refunded_by, refunded_payment who, why, and whether the gateway actually moved the money
is_order_fully_refunded, order_total_refunded computed live from the parent
total, total_ht, price_total signed (negative)
amount positive
items[] real lines, or one rebuilt line flagged is_virtual_item

Hooks

woocommerce_refund_created and woocommerce_refund_deleted commit the refund, and re-commit the parent Invoice, whose outstanding amount changed at the same time. Registered from the plugin bootstrap alongside the other objects; the object itself is picked up by the existing src/Objects scan.

Testing

Business logic was exercised against 400 real refunds on a production shop, read-only: every getter used here, no exceptions; the sign convention held on all 400; 400 distinct references, no collisions; no orphan refunds, so the invoice link always resolves; 17 orders carry more than one refund, which is what makes a dedicated object necessary rather than a field on the order.

I could not exercise the framework wiring (FieldFactory, Splash::commit) against a live Splash server, so that part deserves your eye.

Open question

The Dolibarr connector's CreditNote carries protected static bool $disabled = true. It is otherwise complete, down to its own price-inversion trait. Was that a deliberate product decision, or a leftover? The answer probably ought to apply to this object too, and I have left it enabled here pending your view.

Opened as a draft: it is a first pass meant to invite your direction on scope before it is polished.

WooCommerce refunds are invisible to Splash. The connector exposes seven
objects and none of them covers a refund, so a remote server sees an order
total change but never learns that a credit note exists, who issued it, why,
or which invoice it credits.

Unlike Invoice, which is a virtual read-only view of an Order, CreditNote is
backed by a real WooCommerce entity: the shop_order_refund post type. Its
post_parent is the exact counterpart of the fk_facture_source field that the
Dolibarr connector's CreditNote object carries.

Read-only for now, like Invoice: creating a refund can trigger a gateway
refund, so the write path is left out until it is designed explicitly.

Notes on two WooCommerce behaviours this had to accommodate:

- Signs. WooCommerce exposes the same money twice: get_total() is negative,
  get_amount() is positive. Totals are built on get_total() so amounts arrive
  already negated; get_amount() is exposed separately as 'amount'. This is why
  no equivalent of the Dolibarr connector's CreditModeTrait is needed here.
  Order\TotalsTrait::toTotalPrice() guards its VAT computation with
  $totalTaxExcl > 0, which never holds on a credit note and would report every
  one of them as 0% VAT, so the rate is computed on absolute values instead.

- Full versus partial. The _refund_type meta looks like the answer but is not:
  it is written by WooCommerce Analytics on the woocommerce_order_(partially|
  fully)_refunded hooks, so it records the state the ORDER reached when the
  refund landed rather than the refund's own coverage, and it is never revised
  when a sibling refund is deleted or the order is edited. On a shop of 448
  refunds, 165 of 399 carry 'full' while covering part of their order. The
  object recomputes is_order_fully_refunded live from the parent instead.

Line items are the exception rather than the rule — 1 refund in 400 on that
same shop has any — so an amount-only refund is rendered as a single line
built from its amount and reason, flagged is_virtual_item so a consumer can
tell rebuilt detail from real detail.

Hooks commit the refund on woocommerce_refund_created and
woocommerce_refund_deleted, and re-commit the parent Invoice, whose
outstanding amount changed at the same time.
@Pichinov-Jose
Pichinov-Jose marked this pull request as ready for review September 8, 2026 22:26
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.

WooCommerce refunds are never exported — no CreditNote object, no refund hook

1 participant