diff --git a/.gitignore b/.gitignore
index d267bbffa..9137d97e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,5 +65,6 @@ dev-dist/
.playwright-mcp/*
TODO.md
.claude/settings.json
+.omo/
*.jpeg
missing.txt
diff --git a/POS/components.d.ts b/POS/components.d.ts
index 34185d022..5bae9f497 100644
--- a/POS/components.d.ts
+++ b/POS/components.d.ts
@@ -35,6 +35,7 @@ declare module 'vue' {
NumberField: typeof import('./src/components/settings/NumberField.vue')['default']
OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default']
OfflineInvoicesDialog: typeof import('./src/components/sale/OfflineInvoicesDialog.vue')['default']
+ PackageSelectionDialog: typeof import('./src/components/sale/PackageSelectionDialog.vue')['default']
PartialPayments: typeof import('./src/components/partials/PartialPayments.vue')['default']
PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default']
PhoneInput: typeof import('./src/components/common/PhoneInput.vue')['default']
diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue
index 8e6efd748..ac7b6b460 100644
--- a/POS/src/components/sale/InvoiceCart.vue
+++ b/POS/src/components/sale/InvoiceCart.vue
@@ -940,19 +940,24 @@
{
// Create optimized click handlers for better touch response
const optimizedClickHandlers = new Map();
+function isPackageItem(itemCode) {
+ return packagesStore.isPackageItem(itemCode);
+}
+
function getOptimizedClickHandler(item) {
const key = item.item_code;
if (!optimizedClickHandlers.has(key)) {
@@ -1414,8 +1432,9 @@ function clearLongPress() {
function selectItem(item, autoAdd = false) {
if (!item) return false;
- // Early out-of-stock guard — full qty validation happens in cartStore.addItem()
+ // Packages are non-stock parents; their component stock is checked on add.
if (
+ !isPackageItem(item.item_code) &&
!item.has_variants &&
settingsStore.shouldEnforceStockValidation() &&
shouldValidateItemStock(item)
diff --git a/POS/src/components/sale/PackageSelectionDialog.vue b/POS/src/components/sale/PackageSelectionDialog.vue
new file mode 100644
index 000000000..85fb47a59
--- /dev/null
+++ b/POS/src/components/sale/PackageSelectionDialog.vue
@@ -0,0 +1,371 @@
+
+
+
+
+
diff --git a/POS/src/components/sale/PaymentDialog.vue b/POS/src/components/sale/PaymentDialog.vue
index ee71d01cc..9a199d360 100644
--- a/POS/src/components/sale/PaymentDialog.vue
+++ b/POS/src/components/sale/PaymentDialog.vue
@@ -1347,7 +1347,7 @@
])
}}
-
@@ -1413,6 +1437,35 @@
{{ formatCurrency(amount) }}
+
+
@@ -2898,6 +2951,18 @@ const isLastMethodCash = computed(() => {
});
const { quickAmounts } = useQuickAmounts(remainingAmount, isLastMethodCash);
+// Cash denomination quick buttons (IDR only): fixed banknote face values + "Pas" (exact)
+const CASH_DENOMINATIONS = [2000, 5000, 10000, 20000, 50000, 100000];
+const isIDRCash = computed(() => {
+ return (
+ isCashPaymentMethod(lastSelectedMethod.value) &&
+ (props.currency || "").toUpperCase() === "IDR"
+ );
+});
+function denomLabel(amount) {
+ return amount >= 1000 ? `${amount / 1000}K` : `${amount}`;
+}
+
// Whether a quick amount button should be disabled in exact-amount mode
// Non-cash methods can only pay the exact remaining — no rounding allowed
function isQuickAmountDisabled(amount) {
diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js
index 34b22585c..7c54c8b2e 100644
--- a/POS/src/composables/useInvoice.js
+++ b/POS/src/composables/useInvoice.js
@@ -5,6 +5,7 @@ import { useSerialNumberStore } from "@/stores/serialNumber";
import { CoalescingMutex } from "@/utils/mutex";
import { logger } from "@/utils/logger";
import { roundCurrency } from "@/utils/currency";
+import { PACKAGE_ROLE } from "@/utils/packageQuote";
const log = logger.create("Invoice");
@@ -214,12 +215,32 @@ export function useInvoice() {
);
});
+ /**
+ * Find a cart row that a standalone item may merge into or be edited through.
+ *
+ * Package rows are excluded: each package instance owns its lines, so a loose
+ * item of the same code must never merge into (or be edited via) a package.
+ *
+ * @param {string} itemCode
+ * @param {string|null} uom - Match UOM too when provided
+ * @returns {Object|undefined}
+ */
+ function findStandaloneItem(itemCode, uom = null) {
+ return invoiceItems.value.find(
+ (i) =>
+ i.item_code === itemCode &&
+ !i.package_instance &&
+ (uom === null || i.uom === uom)
+ );
+ }
+
// Actions
function addItem(item, quantity = 1) {
const itemUom = item.uom || item.stock_uom;
- const existingItem = invoiceItems.value.find(
- (i) => i.item_code === item.item_code && i.uom === itemUom
- );
+ // Never merge: each package instance owns its own lines.
+ const existingItem = item.package_instance
+ ? null
+ : findStandaloneItem(item.item_code, itemUom);
if (existingItem) {
// Store old values before update for incremental cache adjustment
@@ -288,6 +309,12 @@ export function useInvoice() {
is_stock_item: item.is_stock_item ?? 1,
is_bundle: item.is_bundle || false,
allow_negative_stock: item.allow_negative_stock || 0,
+ // POS Package linkage — groups a package line with its component rows
+ package_instance: item.package_instance || null,
+ package_name: item.package_name || null,
+ package_role: item.package_role || null,
+ package_label: item.package_label || null,
+ package_snapshot: item.package_snapshot || null,
};
invoiceItems.value.push(newItem);
// Recalculate the newly added item to apply taxes
@@ -312,14 +339,7 @@ export function useInvoice() {
* If null, removes the first item matching item_code.
*/
function removeItem(itemCode, uom = null) {
- let itemToRemove;
- if (uom) {
- itemToRemove = invoiceItems.value.find(
- (i) => i.item_code === itemCode && i.uom === uom
- );
- } else {
- itemToRemove = invoiceItems.value.find((i) => i.item_code === itemCode);
- }
+ const itemToRemove = findStandaloneItem(itemCode, uom);
if (itemToRemove) {
// Update cache incrementally (subtract removed item values)
@@ -338,15 +358,69 @@ export function useInvoice() {
if (itemToRemove.serial_no && itemToRemove.has_serial_no) {
serialStore.returnSerials(itemCode, itemToRemove.serial_no);
}
- }
- if (uom) {
+ // Match the historical contract: without a UOM this drops every
+ // standalone row for the code (paid + its free BOGO row).
invoiceItems.value = invoiceItems.value.filter(
- (i) => !(i.item_code === itemCode && i.uom === uom)
+ (i) =>
+ i.package_instance ||
+ i.item_code !== itemCode ||
+ (uom !== null && i.uom !== uom)
+ );
+ }
+ }
+
+ /**
+ * Add a priced package to the cart as one parent line plus its component lines.
+ *
+ * Every line carries the same `package_instance`, which keeps the group
+ * together in the cart and lets the server re-price it on validate.
+ *
+ * @param {Object} quote - Result from `posPackages.quote()`
+ * @param {Object} pkg - Package definition
+ * @param {Object} context - `{ warehouse }` applied to component rows
+ * @returns {string} The generated package instance id
+ */
+ function addPackage(quote, pkg, { warehouse = null } = {}) {
+ const instance = `pkg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+
+ for (const line of quote.lines) {
+ const isParent = line.role === PACKAGE_ROLE;
+ addItem(
+ {
+ item_code: line.item_code,
+ item_name: line.item_name,
+ rate: line.rate,
+ price_list_rate: line.rate,
+ uom: line.uom,
+ stock_uom: line.uom,
+ warehouse: isParent ? null : warehouse,
+ // The parent is always non-stock; components carry the real
+ // Item flag so non-stock components (e.g. vouchers) are not
+ // stock-validated and do not expect a warehouse.
+ is_stock_item: isParent ? 0 : (line.is_stock_item ?? 1),
+ package_instance: instance,
+ package_name: pkg.name,
+ package_role: line.role,
+ package_label: pkg.package_name,
+ package_snapshot: isParent ? quote.snapshot : null,
+ },
+ line.qty
);
- } else {
- invoiceItems.value = invoiceItems.value.filter((i) => i.item_code !== itemCode);
}
+
+ return instance;
+ }
+
+ /**
+ * Remove a whole package (parent line and every component) from the cart.
+ * @param {string} instance - Package instance id
+ */
+ function removePackage(instance) {
+ if (!instance) return;
+
+ invoiceItems.value = invoiceItems.value.filter((i) => i.package_instance !== instance);
+ rebuildIncrementalCache();
}
/**
@@ -358,12 +432,7 @@ export function useInvoice() {
* If null, updates the first item matching item_code.
*/
function updateItemQuantity(itemCode, quantity, uom = null) {
- let item;
- if (uom) {
- item = invoiceItems.value.find((i) => i.item_code === itemCode && i.uom === uom);
- } else {
- item = invoiceItems.value.find((i) => i.item_code === itemCode);
- }
+ const item = findStandaloneItem(itemCode, uom);
if (item) {
// Store old values before update for incremental cache adjustment
@@ -408,7 +477,7 @@ export function useInvoice() {
}
function updateItemRate(itemCode, rate, isManualEdit = false) {
- const item = invoiceItems.value.find((i) => i.item_code === itemCode);
+ const item = findStandaloneItem(itemCode);
if (item) {
// Store old values before update for incremental cache adjustment
// Use effective rate (manually edited rate or price_list_rate)
@@ -450,7 +519,7 @@ export function useInvoice() {
}
function updateItemDiscount(itemCode, discountPercentage) {
- const item = invoiceItems.value.find((i) => i.item_code === itemCode);
+ const item = findStandaloneItem(itemCode);
if (item) {
// Validate discount percentage (0-100)
let validDiscount = Number.parseFloat(discountPercentage) || 0;
@@ -752,6 +821,14 @@ export function useInvoice() {
is_rate_manually_edited: item.is_rate_manually_edited || 0,
original_rate: item.original_rate || null,
is_free_item: item.is_free_item || 0,
+ // POS Package linkage — the server re-quotes from the snapshot and
+ // overrides these rates, so the payload can never set its own price.
+ pos_package: item.package_name || null,
+ pos_package_instance: item.package_instance || null,
+ pos_package_role: item.package_role || null,
+ pos_package_snapshot: item.package_snapshot
+ ? JSON.stringify(item.package_snapshot)
+ : null,
});
const out = [];
@@ -1281,6 +1358,8 @@ export function useInvoice() {
// Actions
addItem,
+ addPackage,
+ removePackage,
removeItem,
updateItemQuantity,
updateItemRate,
diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue
index 28d292815..a51d2022e 100644
--- a/POS/src/pages/POSSale.vue
+++ b/POS/src/pages/POSSale.vue
@@ -391,6 +391,7 @@
@remove-item="
(itemCode, uom) => cartStore.removeItem(itemCode, uom)
"
+ @remove-package="cartStore.removePackage"
@select-customer="handleCustomerSelected"
@create-customer="handleCreateCustomer"
@edit-customer="handleEditCustomer"
@@ -612,6 +613,15 @@
@batch-serial-selected="handleBatchSerialSelected"
/>
+
+
+
{
? offlineStore.checkOfflineCacheAvailability()
: offlineStore.preloadDataForOffline(shiftStore.currentProfile),
draftsStore.updateDraftsCount(),
+ packagesStore.ensurePackagesFetched(shiftStore.profileName),
]);
// Wait for settings (required for tax rules) + all background ops
@@ -1826,6 +1841,7 @@ async function handleShiftOpened() {
? offlineStore.checkOfflineCacheAvailability()
: offlineStore.preloadDataForOffline(shiftStore.currentProfile),
draftsStore.updateDraftsCount(),
+ packagesStore.ensurePackagesFetched(shiftStore.profileName),
]);
// Wait for settings (required for tax rules) + all background ops
@@ -1870,6 +1886,15 @@ async function handleShiftClosed() {
}
function handleItemSelected(item, autoAdd = false) {
+ // Packages always open their picker — even under auto-add / barcode scan,
+ // since the customer still has to choose the optional items.
+ const pkg = packagesStore.getPackageForItem(item.item_code);
+ if (pkg) {
+ selectedPackage.value = pkg;
+ uiStore.showPackageDialog = true;
+ return;
+ }
+
// Auto-add mode
if (autoAdd) {
try {
@@ -1959,6 +1984,17 @@ function handleItemSelected(item, autoAdd = false) {
}
}
+function handlePackageSelected({ quote, pkg }) {
+ try {
+ cartStore.addPackage(quote, pkg, shiftStore.currentProfile);
+ showSuccess(__("{0} added to cart", [pkg.package_name]));
+ } catch (error) {
+ uiStore.showError(__("Insufficient Stock"), error.message, __("Package: {0}", [pkg.package_name]));
+ } finally {
+ selectedPackage.value = null;
+ }
+}
+
async function handleEditItem(updatedItem) {
await cartStore.updateItemDetails(updatedItem.item_code, updatedItem);
}
diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js
index 8d451c946..46a5261f7 100644
--- a/POS/src/stores/posCart.js
+++ b/POS/src/stores/posCart.js
@@ -1,8 +1,10 @@
import { useInvoice } from "@/composables/useInvoice";
+import { useItemSearchStore } from "@/stores/itemSearch";
import { usePOSOffersStore } from "@/stores/posOffers";
import { usePOSSettingsStore } from "@/stores/posSettings";
import { usePOSShiftStore } from "@/stores/posShift";
import { parseError } from "@/utils/errorHandler";
+import { PACKAGE_ROLE } from "@/utils/packageQuote";
import { shouldValidateItemStock, checkStockAvailability } from "@/utils/stockValidator";
import { offlineState } from "@/utils/offline/offlineState";
import { useToast } from "@/composables/useToast";
@@ -92,6 +94,8 @@ export const usePOSCartStore = defineStore("posCart", () => {
taxInclusive,
isSubmitting,
addItem: addItemToInvoice,
+ addPackage: addPackageToInvoice,
+ removePackage,
removeItem,
updateItemQuantity: baseUpdateItemQuantity,
submitInvoice: baseSubmitInvoice,
@@ -198,6 +202,43 @@ export const usePOSCartStore = defineStore("posCart", () => {
addItemToInvoice(item, qty);
}
+ /**
+ * Add a priced package to the cart, validating stock on its component items.
+ *
+ * @param {Object} quote - Result from `posPackages.quote()`
+ * @param {Object} pkg - Package definition
+ * @param {Object|null} currentProfile - POS Profile, used for stock validation
+ * @returns {string} Package instance id
+ * @throws {Error} When a component item lacks stock
+ */
+ function addPackage(quote, pkg, currentProfile = null) {
+ const warehouse = currentProfile?.warehouse || null;
+
+ if (currentProfile && settingsStore.shouldEnforceStockValidation()) {
+ const itemsStore = useItemSearchStore();
+
+ // Components of the same code can appear twice (an included item and a
+ // chosen option), so validate against the package's combined demand.
+ const demand = new Map();
+ for (const line of quote.lines) {
+ if (line.role === PACKAGE_ROLE) continue;
+ demand.set(line.item_code, (demand.get(line.item_code) || 0) + line.qty);
+ }
+
+ for (const [itemCode, qty] of demand) {
+ const catalogItem = itemsStore.allItems.find((i) => i.item_code === itemCode);
+ if (!catalogItem || !shouldValidateItemStock(catalogItem)) continue;
+
+ const check = checkStockAvailability(catalogItem, qty, warehouse);
+ if (!check.available) {
+ throw new Error(check.error);
+ }
+ }
+ }
+
+ return addPackageToInvoice(quote, pkg, { warehouse });
+ }
+
/**
* Update item quantity with stock validation.
* Wraps useInvoice.updateItemQuantity to enforce stock limits
@@ -1880,6 +1921,8 @@ export const usePOSCartStore = defineStore("posCart", () => {
// Actions
addItem,
+ addPackage,
+ removePackage,
removeItem,
updateItemQuantity,
clearCart,
diff --git a/POS/src/stores/posPackages.js b/POS/src/stores/posPackages.js
new file mode 100644
index 000000000..d92e21968
--- /dev/null
+++ b/POS/src/stores/posPackages.js
@@ -0,0 +1,176 @@
+/**
+ * @fileoverview POS Package ("Paket") store.
+ *
+ * Loads package definitions once per shift, caches them in IndexedDB, and
+ * resolves a tapped catalog item to the package it represents.
+ *
+ * Quoting is server-authoritative when online. Offline it falls back to
+ * `packageQuote.js`, which mirrors the Python implementation; either way the
+ * server re-quotes on Sales Invoice validate.
+ *
+ * @module stores/posPackages
+ */
+
+import { call } from "@/utils/apiWrapper";
+import { logger } from "@/utils/logger";
+import { isOffline } from "@/utils/offline/offlineState";
+import { offlineWorker } from "@/utils/offline/workerClient";
+import { quotePackageLocally, selectionsToChoices } from "@/utils/packageQuote";
+import { defineStore } from "pinia";
+import { computed, ref } from "vue";
+
+const log = logger.create("POSPackages");
+
+export const usePOSPackagesStore = defineStore("posPackages", () => {
+ const packages = ref([]);
+ const fetchedProfile = ref(null);
+ const isLoading = ref(false);
+
+ let fetchPromise = null;
+
+ /** Package definitions keyed by their parent item_code. */
+ const packagesByParentItem = computed(() => {
+ const map = new Map();
+ for (const pkg of packages.value) {
+ map.set(pkg.parent_item, pkg);
+ }
+ return map;
+ });
+
+ /** Item codes that open the package dialog instead of being added directly. */
+ const packageItemCodes = computed(() => new Set(packagesByParentItem.value.keys()));
+
+ function isPackageItem(itemCode) {
+ return packageItemCodes.value.has(itemCode);
+ }
+
+ function getPackageForItem(itemCode) {
+ return packagesByParentItem.value.get(itemCode) || null;
+ }
+
+ function setPackages(list = []) {
+ packages.value = Array.isArray(list) ? list : [];
+ }
+
+ function clearPackages() {
+ packages.value = [];
+ fetchedProfile.value = null;
+ fetchPromise = null;
+ }
+
+ /**
+ * Load packages for a profile, from cache when offline.
+ * Concurrent calls share one in-flight request.
+ *
+ * @param {string} posProfile - POS Profile name
+ * @param {boolean} force - Refetch even if already loaded
+ * @returns {Promise} True when packages are available
+ */
+ async function ensurePackagesFetched(posProfile, force = false) {
+ if (!posProfile) return false;
+
+ // Keyed by profile: a shift switch must not keep showing the previous
+ // outlet's packages, which the server would then reject at checkout.
+ if (fetchedProfile.value === posProfile && !force) return packages.value.length > 0;
+ if (fetchPromise) return fetchPromise;
+
+ isLoading.value = true;
+ fetchPromise = (async () => {
+ try {
+ if (isOffline()) {
+ const cached = await offlineWorker.getCachedPackages(posProfile);
+ setPackages(cached || []);
+ fetchedProfile.value = posProfile;
+ return packages.value.length > 0;
+ }
+
+ const response = await call("pos_next.api.packages.get_packages", {
+ pos_profile: posProfile,
+ });
+ const list = response?.message || response || [];
+ setPackages(list);
+ fetchedProfile.value = posProfile;
+
+ offlineWorker.cachePackages(list, posProfile).catch((error) => {
+ log.warn("Failed to cache packages for offline use", error);
+ });
+
+ return list.length > 0;
+ } catch (error) {
+ log.error("Failed to load packages", error);
+ // Do not cache the failure against this profile: a retry must be
+ // able to load the real list instead of showing an empty catalog.
+ setPackages([]);
+ return false;
+ } finally {
+ isLoading.value = false;
+ fetchPromise = null;
+ }
+ })();
+
+ return fetchPromise;
+ }
+
+ /**
+ * Price a selection. Uses the server when online so the preview matches what
+ * the invoice will charge; falls back to the local mirror when offline.
+ *
+ * @param {Object} pkg - Package definition
+ * @param {Object>} selections - group_key -> option_id -> qty
+ * @param {string} posProfile - POS Profile name
+ * @returns {Promise<{valid: boolean, error: string|null, total: number, lines: Array, snapshot: Object}>}
+ */
+ async function quote(pkg, selections, posProfile) {
+ const local = quotePackageLocally(pkg, selections);
+
+ // Local validation failed — no point asking the server the same question.
+ if (!local.valid || isOffline()) return local;
+
+ try {
+ const response = await call("pos_next.api.packages.quote_package", {
+ package: pkg.name,
+ choices: JSON.stringify(selectionsToChoices(selections)),
+ pos_profile: posProfile,
+ });
+ const result = response?.message || response;
+ if (!result) return local;
+
+ return {
+ valid: true,
+ error: null,
+ total: result.total,
+ lines: result.lines,
+ snapshot: result.snapshot,
+ };
+ } catch (error) {
+ // The server rejected this package (expired, wrong outlet, edited
+ // definition). Falling back to the local price would quote the
+ // customer an amount that the invoice will refuse at checkout.
+ log.error("Server rejected the package quote", error);
+
+ return {
+ valid: false,
+ error:
+ error?.message ||
+ __("This package is no longer available. Please reload the POS."),
+ total: 0,
+ lines: [],
+ snapshot: null,
+ };
+ }
+ }
+
+ return {
+ packages,
+ fetchedProfile,
+ isLoading,
+ packagesByParentItem,
+ packageItemCodes,
+ isPackageItem,
+ getPackageForItem,
+ setPackages,
+ clearPackages,
+ ensurePackagesFetched,
+ quote,
+ };
+});
diff --git a/POS/src/stores/posUI.js b/POS/src/stores/posUI.js
index d64f361d6..144939756 100644
--- a/POS/src/stores/posUI.js
+++ b/POS/src/stores/posUI.js
@@ -26,6 +26,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
const { isOpen: showClearCartDialog } = useDialog("clearCart");
const { isOpen: showLogoutDialog } = useDialog("logout");
const { isOpen: showItemSelectionDialog } = useDialog("itemSelection");
+ const { isOpen: showPackageDialog } = useDialog("package");
const { isOpen: showErrorDialog } = useDialog("invoiceError");
// Global dialog state
@@ -158,6 +159,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
showClearCartDialog.value = false;
showLogoutDialog.value = false;
showItemSelectionDialog.value = false;
+ showPackageDialog.value = false;
showErrorDialog.value = false;
clearError();
lastOfflinePrintDoc.value = null;
@@ -182,6 +184,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
showClearCartDialog,
showLogoutDialog,
showItemSelectionDialog,
+ showPackageDialog,
showErrorDialog,
isAnyDialogOpen,
errorDialogTitle,
diff --git a/POS/src/utils/offline/db.js b/POS/src/utils/offline/db.js
index 8fc6a6712..de7584513 100644
--- a/POS/src/utils/offline/db.js
+++ b/POS/src/utils/offline/db.js
@@ -75,6 +75,10 @@ const CURRENT_SCHEMA = {
// Indexed by name (unique), filterable by pos_profile
offers: "&name, pos_profile, apply_on, valid_upto",
+ // POS Package definitions cache for offline package selection and pricing.
+ // parent_item is indexed so the item grid can resolve a tapped item to its package.
+ packages: "&name, pos_profile, parent_item, valid_upto",
+
// Invoice history cache for offline viewing
// Stores submitted invoices for offline access
invoice_history: "&name, pos_profile, posting_date, customer",
diff --git a/POS/src/utils/offline/workerClient.js b/POS/src/utils/offline/workerClient.js
index 8fa4b3728..434aa7993 100644
--- a/POS/src/utils/offline/workerClient.js
+++ b/POS/src/utils/offline/workerClient.js
@@ -604,6 +604,34 @@ class OfflineWorkerClient {
return this.sendMessage("CLEAR_OFFERS_CACHE", { posProfile });
}
+ /**
+ * Cache POS Package definitions for offline selection and pricing
+ * @param {Array} packages - Packages from pos_next.api.packages.get_packages
+ * @param {string} posProfile - POS Profile name to associate with packages
+ * @returns {Promise<{success: boolean, count: number}>}
+ */
+ async cachePackages(packages, posProfile) {
+ return this.sendMessage("CACHE_PACKAGES", { packages, posProfile });
+ }
+
+ /**
+ * Get cached POS Packages for a profile
+ * @param {string} posProfile - POS Profile name
+ * @returns {Promise} Cached packages (excluding expired)
+ */
+ async getCachedPackages(posProfile) {
+ return this.sendMessage("GET_CACHED_PACKAGES", { posProfile });
+ }
+
+ /**
+ * Clear cached POS Packages
+ * @param {string} posProfile - POS Profile name (optional, clears all if not provided)
+ * @returns {Promise<{success: boolean}>}
+ */
+ async clearPackagesCache(posProfile = null) {
+ return this.sendMessage("CLEAR_PACKAGES_CACHE", { posProfile });
+ }
+
terminate() {
// Stop health check
if (this.healthCheckInterval) {
diff --git a/POS/src/utils/packageQuote.js b/POS/src/utils/packageQuote.js
new file mode 100644
index 000000000..8243bfb8e
--- /dev/null
+++ b/POS/src/utils/packageQuote.js
@@ -0,0 +1,172 @@
+/**
+ * @fileoverview Client-side POS Package quoting.
+ *
+ * Mirrors `pos_next/api/packages.py:quote()` so packages can be selected and
+ * priced while offline. The server re-quotes every package on Sales Invoice
+ * validate, so this result is a preview — never the authority.
+ *
+ * Keep this file and `packages.py` in lockstep: same validation order, same
+ * price formula `base_price + Σ(price_adjustment × qty)`.
+ *
+ * @module packageQuote
+ */
+
+import { roundCurrency } from "@/utils/currency";
+
+export const PACKAGE_ROLE = "Package";
+export const PACKAGE_ITEM_ROLE = "Package Item";
+
+/**
+ * Total units picked in a group.
+ * @param {Object} picks - option_id -> qty
+ * @returns {number}
+ */
+export function pickedQty(picks) {
+ return Object.values(picks || {}).reduce((sum, qty) => sum + (Number(qty) || 0), 0);
+}
+
+/**
+ * Validate a group's picks against its min/max and per-option caps.
+ * @param {Object} group - POS Package Group
+ * @param {Array