Fresclean POS · Chapter 6 of 8
When a pair comes back
Jovanus Hartono2026
On this page
When a customer brings a pair back unhappy, the POS records a complaint against the treatment, and the pair goes back to the workshop to be cleaned again. The re-clean is a free line on the same order and jumps the queue. Only if that still isn't good enough does the shop refund the treatment, with a voucher on top as a goodwill gesture. I call this the escalation ladder.
It usually starts a day or two after pickup, when a customer gets home and decides the clean wasn't good enough. By then that pair's treatment is already picked up in the POS. That's a A status nothing is meant to move out of, like a finished or cancelled order., and the only way out of it is a refund, so the POS had no way to start up the ladder from there.
Three ways not to do it
The quickest fix would have been to let a picked-up treatment go back to processing. I didn't want that, because it rewrites things that really happened: the pickup, the code check and the payment. As I put it, "This repo's instinct is to append corrections, never rewrite a terminal fact." It would also have needed a second pickup, with no good answer for which code or which payment it belonged to.
The next idea was a separate, free order for the re-clean. That ran straight into the rule that an order is either paid or unpaid. An order has to be paid to be picked up, and paid needs a payment method, but no money changed hands. It also isn't how the counter works. When a customer brings a pair back, what they expect is, as I put it, "same Order, same code, no new receipt."
The laziest idea was to skip complaints entirely and just write a reason on the refund. But how often customers complain is a number I want to know, and you can't count what you don't record.
A free line on the same order
A rework is a new line on the original order. It has a price of zero, it jumps the queue as a priority, and it points back at the complaint it came from. The order is already paid, so a free line needs no payment step at all, and the order's status honestly goes from completed back to processing, then to completed again. The code that moves statuses around didn't change at all.

Zero is a deliberate number here. A price of zero means free on purpose, and a rework is the only line that's meant to have it. A line whose price isn't known yet is blank instead, which is a story for the money chapter. The refund picker leaves rework lines out, since there's nothing to give back. If the ladder ends in a refund, it's the original line that gets refunded, and only an admin can do that.
The complaint that could lie
My first version gave complaints a life of their own. A complaint had a status, open or closed, and a resolution: reworked, refunded or rejected. There was a separate server call to resolve it, a flag for whether a voucher had been promised, and twenty tests.
Reviewing it before merge turned up two holes. Any staff member could mark a complaint's resolution as "refund" without any money being refunded anywhere, so the complaint could claim an escalation that never happened. And adding a rework only checked that the complaint was still open, never the state of the original line, so a free re-clean could be attached to a line that had already been refunded, turning the ladder upside down.
Both holes came from the same mistake. The complaint had become a second status, set by hand, sitting on top of facts that already lived on the order's lines. The rest of the POS works the other way: status is always worked out from the lines, never typed in.
So the complaint became a write-once record with four columns: which line, the reason, who opened it, and when. It never changes after that:
export const complaintsTable = pgTable("complaints", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
order_service_id: integer("order_service_id")
.references(() => ordersServicesTable.id, { onDelete: "cascade" })
.notNull(), // which line
reason: text("reason").notNull(),
opened_by: integer("opened_by").references(() => usersTable.id).notNull(),
created_at: timestamp("created_at").defaultNow().notNull(),
});
// …plus a unique index on order_service_id: one complaint per line, ever
There's no status column to update. Its outcome is read from the lines instead, when the complaint is shown. Stripped down to its labels, that's three lines:
if (refunded) return "Refunded"; // the original line was refunded
if (reworkCount > 0) return "Reworked"; // a rework line points back at it
return "Pending";
That closes the first hole, or as I put it: "there is no resolution field left to lie."
There's no "rejected" outcome either, and I wrote down why: "A Complaint row is an already-accepted grievance; the shop does not record 'we declined'." And a couple of things stay out of the system on purpose, like the voucher and how many days a customer has to come back. Those are still said out loud at the counter.
Same shoe, same tag
Reworks used to get a tag of their own, ending in -RW001. With pairs as Items, that stopped making sense. A rework is the same physical object coming back over the counter, so it now joins the pair it came from, with the same tag and the same description. That pair can't be collected again until the re-clean is ready.
When it is, the customer collects it with the pickup code from their original receipt. No new code and no new receipt.
A photo of the shoe as it came back
The last rule is about photos. A treatment can't start until its pair has a photo, and a returned pair already has photos from its first visit. Those don't count for a rework, because they show the shoes before the first clean. A complaint is about the condition they came back in. So for a rework, only a photo taken after the complaint was opened counts:
export function hasStartPhoto(
photos: ReadonlyArray<{ created_at: Date }>,
reworkOpenedAt: Date | null
): boolean {
return photos.some(
(photo) => reworkOpenedAt === null || photo.created_at > reworkOpenedAt
);
}
For an ordinary treatment, reworkOpenedAt is null, and any photo will do.
That gave the worker's screen a small puzzle. The obvious place for a "photo required" warning is an empty gallery, but a rework's gallery is never empty. So the warning shows whenever the check is blocking, because a worker looking at a full gallery needs to hear why none of those photos count.

Completed, twice
One side effect is easy to miss. An order's "completed" isn't quite final anymore. When a rework is collected, the order's completion date moves to that second pickup. Revenue isn't affected, because it's counted from when the order was paid, which doesn't move.
A rework is also real work that earns nothing. The workers report counts the labour at zero revenue, which is honest. The shop really did spend a worker's time on that pair for free.