WFDASH-WEBAPP/scripts/migrations/add-pos-sale-discount-fields.ts
Dave aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00

126 lines
4.3 KiB
TypeScript

/**
* One-shot migration: add discount + gross-totals fields to the
* `pos_sales` collection (idempotent).
*
* New fields:
* - gross_subtotal pre-VAT total before any discount (number)
* - gross_vat VAT portion of the pre-discount total (number)
* - gross_total pre-discount total, VAT-inclusive (number)
* - discount_amount resolved rand value of the discount, capped
* at the gross total (number)
* - discount_kind "amount" / "percent" / "none" (select)
* - discount_input the raw number the operator typed — e.g. 10
* for "10% off" or 50 for "R50 off" (number)
*
* Without these, the POS checkout endpoint will 500 the moment
* the cashier applies a discount. Existing sales (recorded
* before this migration) have all six fields = 0 / "none", which
* the server's checkout math treats as "no discount" — so the
* migration is safe to run on a live database with old sales.
*
* Run with:
* npx tsx scripts/migrations/add-pos-sale-discount-fields.ts
*/
import PocketBase from "pocketbase";
const PB_URL = process.env.PB_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const NEW_FIELDS = [
{
name: "gross_subtotal",
type: "number",
required: false,
presentable: false,
help: "Pre-VAT subtotal before any discount. Kept alongside the post-discount subtotal so the accountant can see what the till would have rung up.",
},
{
name: "gross_vat",
type: "number",
required: false,
presentable: false,
help: "VAT portion of the pre-discount total.",
},
{
name: "gross_total",
type: "number",
required: false,
presentable: false,
help: "Pre-discount total, VAT-inclusive.",
},
{
name: "discount_amount",
type: "number",
required: false,
presentable: false,
help: "Resolved rand value of the discount applied to this sale, capped at the gross total. 0 = no discount.",
},
{
name: "discount_kind",
type: "select",
required: false,
presentable: false,
options: {
maxSelect: 1,
values: ["amount", "percent", "none"],
},
help: "How the operator entered the discount: 'amount' (R off), 'percent' (% off), or 'none' (no discount).",
},
{
name: "discount_input",
type: "number",
required: false,
presentable: false,
help: "The raw number the operator typed. e.g. 10 for '10% off' or 50 for 'R50 off'. The server resolves this to a rand value and stores it in discount_amount.",
},
];
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`[pos-discount] Authenticated against ${PB_URL}`);
const collection = await pb.collections.getOne("pos_sales");
const existingNames = new Set(collection.fields.map((f: { name: string }) => f.name));
const toAdd = NEW_FIELDS.filter((f) => !existingNames.has(f.name));
if (toAdd.length === 0) {
console.log("[pos-discount] All fields already present — nothing to do.");
return;
}
const newFields = [
...collection.fields,
...toAdd.map((f) => ({
...f,
// For the `select` type, PB also needs `system: false`.
// The other props come from NEW_FIELDS.
system: false,
})),
];
console.log(`[pos-discount] Adding ${toAdd.length} field(s):`);
for (const f of toAdd) console.log(` • ${f.name} (${f.type})`);
await pb.collections.update(collection.id, { fields: newFields });
console.log("[pos-discount] Schema updated.");
// Verify by re-reading the collection.
const after = await pb.collections.getOne("pos_sales");
const afterNames = new Set(after.fields.map((f: { name: string }) => f.name));
const missing = NEW_FIELDS.filter((f) => !afterNames.has(f.name)).map((f) => f.name);
if (missing.length > 0) {
throw new Error(`[pos-discount] Verification failed — missing: ${missing.join(", ")}`);
}
console.log("[pos-discount] Verified all fields present. Discount flow is ready.");
}
main().catch((err) => {
console.error("[pos-discount] Migration failed:", err);
process.exit(1);
});