WFDASH-WEBAPP/scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts

139 lines
4.7 KiB
TypeScript
Raw Permalink Normal View History

feat(pos): refund a whole sale, put the goods back, print a credit slip A customer who brings goods back needs the sale reversed: the items go back into stock and the money comes back off the till. There was no way to mark a POS sale as refunded, so nothing stopped the same sale being refunded twice — which credits the customer twice and puts the same stock back on the shelf twice. Refunds are WHOLE-SALE only by operator decision; no partial refunds. That is what lets `pos_sales.refunded` be a boolean instead of a per-line refund table. Three columns: `refunded`, `refunded_at`, `refund_amount`, stamped from the sale's post-discount total, which is what the customer actually paid. DOUBLE-REFUND GUARD, twice over, because this is the one failure that costs real money: - `refunded` is read before anything else, and a flagged sale returns 409 with no stock writes at all - the flag is stamped BEFORE any stock write, so a second concurrent tap sees it and bails. Writing stock first and flagging last would let both taps through. A stock write that fails part-way does NOT roll the flag back. The customer has their money either way, so re-running would double-credit them; the failed lines come back in `stockErrors` for a manual count correction, which is strictly better than a second refund. Stock needs no schema: `return` was already in STOCK_REASON_VALUES, just never used. Rows are written with POSITIVE change_qty and reference_type "pos_sale_refund", leaving the original customer_fulfillment rows untouched so the sale's own audit trail still shows what was originally rung up. cashup.html had expected drawer = sum of cash sales, so a cash refund would have shown as a false variance on every cash-up after it. It now subtracts cash refunds, and refunded sales contribute nothing to takings for any tender. The tender is read off the sale, never off the request — an earlier version accepted it in the body, which would have let a caller assert that a cash sale was really an EFT and talk the drawer out of balance. At the till: tap still reprints, hold a sale row for 600ms to refund — moving money sits behind a gesture you cannot do by accident. Refunded sales stay in the list, dimmed and badged, because deleting them would destroy the audit trail. The sheet names the items and says plainly what happens next ("Hand back R10.57 in cash. The drawer count for this day will drop by that amount."). The credit slip reuses the sale renderer and the whole printer path with `refund: true`, so there is no second printer to configure — but note there were THREE render paths to fix (on-screen, Bluetooth text, ePOS XML), and missing the third would have printed "TOTAL" on a credit note. Migration applied to live PB and verified idempotent; field shapes round-tripped on a real sale and restored. 10 new tests, 148 POS tests pass, tsc clean, build passes. sw.js v34 -> v35 (history, cashup, receipt and epos-print are all cache-first in the shell).
2026-10-07 21:41:09 +00:00
/* eslint-disable */
/**
* Migration: add refund fields to `pos_sales`.
*
* ## Why
*
* A customer who brings goods back needs the sale reversed: the items go
* back into stock and the money comes back off the till. There was no way
* to mark a POS sale as refunded, so nothing stopped the same sale being
* refunded twice — which would credit the customer twice and put the
* stock back on the shelf twice.
*
* Refunds are WHOLE-SALE only by operator decision (2026-10-07): no
* partial refunds. That is what makes three plain columns enough — a sale
* is either refunded or it isn't, so a boolean is the complete answer and
* no `pos_refunds` collection is needed. If partial refunds are ever
* wanted this schema has to grow a per-line refund table; the boolean
* here becomes "fully refunded".
*
* The stock side needs no schema at all: `return` is already a member of
* `STOCK_REASON_VALUES`, it just has never been used. The refund writes
* `stock_ledger` rows with `reason: "return"`, positive `change_qty`,
* and `reference_type: "pos_sale_refund"` so the reversal is traceable
* back to the sale without overwriting the original
* `customer_fulfillment` rows. The ledger is the stock-side audit trail;
* these three columns are the money-side one.
*
* ## What it does
*
* Adds to `pos_sales`:
*
* - `refunded` (bool, default false) Has this sale been reversed?
* - `refunded_at` (date, optional) When.
* - `refund_amount` (number, optional) The rand value credited back.
* Stamped from the sale's
* post-discount `total`, which
* is what the customer actually
* paid.
*
* Idempotent — safe to re-run.
*
* Run with:
* npx tsx scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const TAG = "[pos-sales-refund]";
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`${TAG} Authenticated against ${url}`);
const posSales = await pb.collections.getOne("pos_sales");
const existing = new Set(
(posSales.fields as Array<{ name: string }>).map((f) => f.name),
);
// Key shapes copied from the live schema rather than invented — PB rejects
// a field object it doesn't recognise. `id` is generated by PB on write.
// `date` carries min/max "" and `number` min/max null, matching
// customers.dateadded and pos_sales.cash_given respectively.
const additions = [
{
name: "refunded",
type: "bool",
required: false,
presentable: false,
},
{
name: "refunded_at",
type: "date",
required: false,
presentable: false,
min: "",
max: "",
},
{
name: "refund_amount",
type: "number",
required: false,
presentable: false,
min: null,
max: null,
onlyInt: false,
},
].filter((f) => !existing.has(f.name));
if (additions.length === 0) {
console.log(`${TAG} pos_sales refund fields already present — nothing to do.`);
return;
}
await pb.collections.update(posSales.id, {
fields: [...posSales.fields, ...additions],
});
console.log(`${TAG} Added to pos_sales: ${additions.map((f) => f.name).join(", ")}`);
const after = await pb.collections.getOne("pos_sales");
const nowHave = new Set((after.fields as Array<{ name: string }>).map((f) => f.name));
for (const f of additions) {
if (!nowHave.has(f.name)) throw new Error(`${TAG} field ${f.name} missing after write`);
}
console.log(`${TAG} Verified on the live schema.`);
}
main().catch((err) => {
console.error(`${TAG} FAILED:`, err);
process.exit(1);
});