Dhaka, Bangladesh
Case Studies

WooCommerce Credit Notes That Show What Was Actually Refunded

Cover image for the case study "WooCommerce Credit Notes That Show What Was Actually Refunded"

Quick answer

A WooCommerce refund passing through a case builder that classifies it, builds only the refunded rows, and reconciles the total, producing a credit note that lists just the refunded items.
In WowInvoice, a WooCommerce PDF invoice plugin by WPXPO, credit notes used to print the entire order, not what was refunded. I replaced that with a credit-note case builder that classifies each refund and renders only the refunded lines. The fix shipped as one new PHP class instead of the full database-backed design that had been planned. Within a week it also aggregated every refund on an order into one credit note.

TL;DR

Stat strip: ten refund cases, one new class, 839 lines at its largest, and an 11-scenario QA checklist.
TL;DR summary
ProblemCredit notes reused the invoice path, so they showed the whole order instead of the refund
DecisionA one-class, single-refund first fix instead of a multi-week tables-and-revisions rebuild
HowClassify each refund into one of ten cases, build only refunded rows, reconcile every total
ThenAggregation across all refunds on an order, and merging of same-kind rows
StatusLater removed from the free WordPress.org build of this repository
TeamMe, with guidance from Samin Yaser, who wrote the case definitions and full plan

The problem

Before: a refund of one item out of three produced a credit note listing all three items and the full order total, because credit notes reused the invoice's items and totals functions.
A credit note is supposed to show what the customer got back. WowInvoice rendered credit notes through the same item and totals functions used for invoices. So a refund of one item out of three still printed all three items and the full order total. There was exactly one rendering path, and it didn't know the difference between a refund and an order.

The decision

Two plans side by side: the full plan with two database tables, a revision repository, number allocation and hook orchestration, against a Phase 1 plan with one class and a single-refund scope that fixes the visible defect first.
Samin Yaser had already written the refund case definitions and a full implementation plan. That plan called for two new database tables, a revision repository, atomic number allocation, and idempotent hook orchestration before any template could render correctly. It was the right end state, but it was a multi-week change with no visible output until its third phase. I drew the decision diagram for the cases, then wrote a smaller plan that targeted only the visible defect. My plan added one class and handled a single refund. It explicitly deferred the tables, the revision history, a separate numbering sequence, and aggregation across multiple refunds.

How a refund gets classified

The classification path: no line items routes to an amount-only case, shipping-only and fee-only rows get their own cases, and product rows are split into quantity rows or adjustment rows by comparing the expected proportional credit with the actual credit, before a reconciliation step adds an adjustment row for any leftover difference.
WooCommerce stores refund lines as negative numbers. The builder flips each refund line into a positive customer-facing credit, but only on those specific fields, never with a global absolute value. The hard part is product rows. A refund of 1 of 3 units is a physical quantity, but a partial money refund on the same line is not, and printing "0.1667 units" would be wrong. So the builder compares the expected credit, the original unit price times the refunded quantity, against the actual credit. If they match within one cent it's a quantity row; otherwise it's an adjustment row with no quantity or unit price. Stores that sell by weight or length can override that call through a filter, since the proportional check would misread a genuine fractional refund. Shipping, fee, and amount-only refunds each get their own case. Every case then runs the same reconciliation step. The builder adds up the rows it built, compares them with the refunded amount, and adds an "Order adjustment" row for any difference, so the total always matches the refund.

From one refund to every refund

A timeline from the Phase 1 plan listing multi-refund aggregation as a non-goal, to the first case builder a day later, to cumulative aggregation across refunds five days after the plan, to same-kind row merging four days after that.
The first version shipped a day after the plan. Five days after the plan, I added the thing it had deferred: aggregation across every refund on an order. That mattered for one case in particular. A quantity refund followed later by a separate money adjustment on the same product is only detectable when both refunds are read together. Four days later I added row merging. Two refunds of the same kind against the same order line now collapse into one row. A quantity refund and an adjustment on the same line deliberately stay apart, since merging them would blend a physical quantity with a money amount. Rows that couldn't be traced to an original order line are never treated as the same line. Tax labels come from the refund's own tax lines rather than the live tax settings. That way a later edit under WooCommerce's tax settings can't change an issued credit note. I also wrote an 11-scenario manual QA checklist with a dedicated test order. Its own status line said every case had a code path but no box had yet been ticked in a live store.

Where this code lives now

The repository's free WordPress.org branch after a September commit that removed the credit-note case builder, partial payments, UBL and bulk export, with a dashed box marking that the repository doesn't show where they went.
In mid-September, a commit by another developer removed the credit-note case builder from this repository's line of history. Partial payments, UBL output, and bulk export went out in the same commit. The branch it landed on is the free WordPress.org build, which suggests a free and Pro split. This repository doesn't show where the removed code went, so this case study describes it as it existed here.

FAQ

Four why-questions with compressed answers: a smaller plan first, adjustment rows instead of fractional quantities, reconciliation on every case, and building aggregation five days after deferring it.

Why write a smaller plan instead of building the full design?

The full design was correct but produced nothing visible for weeks, while every credit note was wrong today. One class that fixed the rendering for a single refund could ship immediately without blocking the full design later.

Why print an adjustment row instead of a fractional quantity?

A quantity on a credit note is a claim about physical goods coming back. Printing a fraction for a money-only refund would make the document say something that didn't happen.

Why run reconciliation on every case, not just the amount-only one?

Refunds don't always split cleanly into the cases. Checking the built rows against the refunded amount every time means a refund that doesn't fit any case still produces a total that matches the money.

Why build aggregation five days after listing it as a non-goal?

Deferring it was about shipping the visible fix first, not about it being unnecessary. Once the single-refund version was out, one of the cases couldn't be detected correctly without reading every refund on the order together. Other case studies in this series cover extending a partial-payment layer to four deposit plugins → and linking shared template parts to the live preview's settings →.
Part of the WowInvoice frontend engineering case study series →
On this page
Quick answer
TL;DR
The problem
The decision
How a refund gets classified
From one refund to every refund
Where this code lives now
FAQ
Why write a smaller plan instead of building the full design?
Why print an adjustment row instead of a fractional quantity?
Why run reconciliation on every case, not just the amount-only one?
Why build aggregation five days after listing it as a non-goal?