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.
126 lines
4.3 KiB
TypeScript
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);
|
|
});
|