Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ before making a package-level licence statement.
[![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest)
[![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests)
[![Coverage](https://img.shields.io/badge/coverage-69%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests)
[![API](https://img.shields.io/badge/REST%20API-897%20endpoints-blue)](docs/API.md)
[![API](https://img.shields.io/badge/REST%20API-900%20endpoints-blue)](docs/API.md)
[![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE)
[![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md)
[![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest)
Expand Down Expand Up @@ -235,7 +235,7 @@ the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE.
| [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules |
| [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues |
| [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite |
| [REST API](docs/API.md) | 897 endpoints, generated from the routes |
| [REST API](docs/API.md) | 900 endpoints, generated from the routes |
| [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked |
| [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built |
| [India GST return gaps](docs/INDIA_GST_RETURNS_GAPS.md) | What the GST reports compute today, measured against GSTR-1, 3B and 9 |
Expand Down
9 changes: 9 additions & 0 deletions api/src/helpers/bill-payload.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const Money = require('../utils/currency');
const { isWalkIn } = require('../utils/walk-in');
const { fieldAvailable } = require('./receipt-design');

Expand Down Expand Up @@ -633,6 +634,14 @@ function buildBillPayload(sale = {}, branch = {}, options = {}) {

subTotal,
taxes: taxRows(sale, branch, allocation),
charges: allocation
? Object.entries(allocation.components)
.filter(([key]) => key.startsWith('charge:'))
.map(([key, minor]) => ({
name: key.slice(7),
amount: Money.fromMinor(minor, Money.policy(branch)),
}))
: (sale.charges || []).map((c) => ({ name: c.name, amount: num(c.amount) })),
discount: num(sale.discount),
roundOff: num(sale.round_off || sale.sales_round_off),
total: num(sale.sales_total != null ? sale.sales_total : sale.total),
Expand Down
2 changes: 2 additions & 0 deletions api/src/models/branch.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ const branchSchema = new Schema(
mobile_pos: { type: Schema.Types.Mixed },
branch_upi_id: { type: String },
branch_upi_name: { type: String },
captain_allow_unassigned: { type: Boolean, default: false },
captain_table_cleaning: { type: Boolean, default: false },
captain_payments: { type: Schema.Types.Mixed },
captain_attendance: { type: Schema.Types.Mixed },
module_mobile_pos_enable: { type: Boolean },
module_billing_outlets_enable: { type: Boolean },
payment_settings: { type: Schema.Types.Mixed },
Expand Down
105 changes: 97 additions & 8 deletions api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -8744,6 +8744,43 @@ class SalesRepository {
};
}

// Preserve idempotent recovery, but never create a new dine-in ticket
// without a destination, including requests from older handsets.
const requestedService = String(order || dine_type || data.fulfilment || 'Dine-in')
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
const requestedTable = kiosk_table_no ?? table;
const hasTable =
['string', 'number'].includes(typeof requestedTable) &&
String(requestedTable).trim() !== '';
if (
staffOrder &&
!['takeaway', 'pickup', 'delivery'].includes(requestedService) &&
!hasTable &&
!venue &&
branchDoc?.captain_allow_unassigned !== true
) {
return {
status: false,
message: 'Choose a table first.',
data: { code: 'TABLE_REQUIRED' },
};
}

if (staffOrder && requestedService === 'room') {
const scoped = await new (require('./settings.repository'))().resolveGroup('channels', {
branchId: branchObjectId,
licenseId: branchDoc.license,
});
if (scoped.status !== true)
return {
status: false,
message: 'Room settings could not be loaded. Try again.',
data: null,
};
venues = partnerVenues.normalizeVenues(scoped.data?.values?.partner_venues);
}
const servicePoint = partnerVenues.resolveServicePoint(
{ table: String(kiosk_table_no || table || '').trim(), venue, unit },
venues
Expand All @@ -8754,6 +8791,22 @@ class SalesRepository {
message: 'Venue pricing is unavailable. Refresh the menu or contact the restaurant.',
data: { state: 'pricing_rule_unavailable' },
};
const staffRoom = staffOrder && requestedService === 'room';
if (
staffRoom &&
(!servicePoint.venue?.staff_orders_enabled ||
!String(unit || '').trim() ||
String(unit).trim().length > 40 ||
Array.from(String(unit)).some((ch) => ch.charCodeAt(0) < 32) ||
servicePoint.label.length > 200)
)
return {
status: false,
message: 'Choose an enabled venue and enter a room number (up to 40 characters).',
data: { code: 'ROOM_REQUIRED' },
};
// Staff room menus use house prices; web venue markup remains a separate policy.
if (staffRoom) servicePoint.venue = { ...servicePoint.venue, price_adjust_percent: 0 };
/* Where the food actually goes, as the customer confirmed it. Null for
the shop's own floor, which needs no address. */
const deliverTo = partnerVenues.confirmDestination(servicePoint, destination || {});
Expand Down Expand Up @@ -8800,7 +8853,7 @@ class SalesRepository {
* string reading in item.repository.js is for SETTINGS values, which are
* a different shape from a different writer.
*/
const runsTableService = branchDoc.table_options === true;
const runsTableService = branchDoc.table_options === true && !staffRoom;
const openTableLimit = Number(branchDoc.table_order_limit ?? 1);
const wantsTable = String(servicePoint.label || kiosk_table_no || table || '').trim();
const seating = require('../services/seating-claims');
Expand Down Expand Up @@ -8973,8 +9026,19 @@ class SalesRepository {
offer on the food, and a shop that ends up paying somebody to collect
an order has been given a rule it did not write. */
const afterCoupon = Math.max(0, round(foodTotal - couponDiscount));
const roomCharge = staffRoom
? await require('../services/room-charge').create(
db,
servicePoint.venue,
data.room_charge_accepted,
Math.max(0, afterCoupon - totalTax),
branchDoc,
data.room_charge_quote
)
: null;
const roomChargeTotal = roomCharge ? roomCharge.amount + roomCharge.tax_amount : 0;
const rounded = require('../services/sale-header').roundSaleTotal(
round(afterCoupon + deliveryFee),
round(afterCoupon + deliveryFee + roomChargeTotal),
branchDoc.roundOff,
monetary.currencyDigits
);
Expand Down Expand Up @@ -9248,8 +9312,9 @@ class SalesRepository {
A hotel room says the hotel and the room; the shop's own table says
the table, exactly as it always did. */
table_number: servicePoint.label || kiosk_table_no || '',
table_id: kiosk_table_id || '',
table_id: staffRoom ? '' : kiosk_table_id || '',
venue: deliverTo,
...(roomCharge ? { charges: [roomCharge] } : {}),
venue_commission: venueCommission,
delivery_fee: deliveryFee,
person_count: person_count || 0,
Expand All @@ -9260,7 +9325,7 @@ class SalesRepository {
round_off: rounded.roundOff,
sales_round_off: rounded.roundOff,
sales_sub_total: subtotal,
tax: totalTax,
tax: totalTax + (roomCharge?.tax_amount || 0),
discount: round(itemDiscountTotal + discountAmt + couponDiscount),
extra_discount: discountAmt,
/*
Expand Down Expand Up @@ -9496,7 +9561,8 @@ class SalesRepository {
items: saleItems,
subtotal,
discount: round(itemDiscountTotal + discountAmt),
tax: totalTax,
tax: totalTax + (roomCharge?.tax_amount || 0),
...(roomCharge ? { charges: [roomCharge] } : {}),
delivery_fee: deliveryFee,
total: finalTotal,
/* Echoed back so the confirmation screen can say "we will bring it
Expand Down Expand Up @@ -10583,6 +10649,11 @@ class SalesRepository {
item_transfer: true,
total_amount: doc.sales_total || doc.total || 0,
subtotal: doc.sales_sub_total || doc.subtotal || 0,
charges: (doc.charges || []).map((c) => ({
name: c.name,
amount: c.amount,
tax_amount: c.tax_amount,
})),
tax: doc.tax || 0,
discount: doc.discount || 0,
pricing_preview: true,
Expand Down Expand Up @@ -10738,13 +10809,21 @@ class SalesRepository {
if (charges !== undefined && preview)
throw new Error('Charges cannot be changed in an item preview.');
const savedCharges = Array.isArray(orderDoc.charges) ? orderDoc.charges : [];
const normalizedCharges =
let normalizedCharges =
charges === undefined
? savedCharges
: [
...savedCharges.filter((c) => c.source === 'outlet'),
...savedCharges.filter(
(c) =>
c.source === 'outlet' ||
(c.source === 'room' &&
Array.isArray(charges) &&
charges.some((v) => v?.source === 'room'))
),
...(await require('../services/sale-charges').normalizeSaleCharges(
Array.isArray(charges) ? charges.filter((c) => c?.source !== 'outlet') : charges,
Array.isArray(charges)
? charges.filter((c) => !['outlet', 'room'].includes(c?.source))
: charges,
savedCharges.filter((c) => c.source !== 'outlet'),
{
branchSettings: shop || {},
Expand Down Expand Up @@ -11319,6 +11398,16 @@ class SalesRepository {
}
if (extraDiscountAmount > itemsSub) extraDiscountAmount = itemsSub;

if (!orderDoc.captain_transfer_allocation)
normalizedCharges = normalizedCharges.map((c) =>
c.source === 'room'
? require('../services/room-charge').recalculate(
c,
status === 'cancelled' ? 0 : Math.max(0, itemsSub - taxTotal - extraDiscountAmount),
shop
)
: c
);
const chargeTotal = normalizedCharges.reduce(
(sum, charge) => sum + Number(charge.amount || 0),
0
Expand Down
2 changes: 2 additions & 0 deletions api/src/repositories/shift.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class ShiftRepository {
* behaviour, e.g. the 409 on double clock-in).
*/
async _autoCloseIfStale(collection, shift, now) {
// Mobile attendance requires an explicit clock-out or manager correction.
if (shift.source === 'captain') return null;
const clockIn = shift.clock_in instanceof Date ? shift.clock_in : new Date(shift.clock_in);
const ageMin = Math.round((now.getTime() - clockIn.getTime()) / 60000);
if (!(ageMin > STALE_SHIFT_AFTER_MINUTES)) return null;
Expand Down
29 changes: 22 additions & 7 deletions api/src/routes/captain-access.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ router.get(
})
);
router.use(protect);
const attendance = require('../services/captain-attendance');
router.get('/attendance', wrap(attendance.current));
router.post('/attendance', limit, wrap(attendance.record));
router.post('/attendance/settings', limit, wrap(attendance.settings));
// Only publish the signed-in branch's addresses. A hint never grants access
// to the peer server; Captain still verifies its session/route proof there.
router.post(
Expand Down Expand Up @@ -351,7 +355,10 @@ router.get(
'/table-settings',
wrap(async (req) => {
const c = await require('../utils/branch-access').context(req);
return { cleaningEnabled: require('../services/table-cleaning').enabled(c.branch) };
return {
tableRequired: c.branch?.captain_allow_unassigned !== true,
cleaningEnabled: require('../services/table-cleaning').enabled(c.branch),
};
})
);
router.post(
Expand All @@ -360,14 +367,22 @@ router.post(
const { allowed, context, fail } = require('../utils/branch-access');
if (!allowed(req.user, 'settings')) fail('Settings permission is required.', 403);
if (typeof req.body?.cleaningEnabled !== 'boolean') fail('Choose a table status.');
if (req.body.tableRequired !== undefined && typeof req.body.tableRequired !== 'boolean')
fail('Choose a table requirement.');
const c = await context(req),
cleaningEnabled = req.body.cleaningEnabled;
await req.db
.collection('branches')
.updateOne(
{ _id: c.branchId, license: c.license },
{ $set: { captain_table_cleaning: cleaningEnabled, updated_date: new Date() } }
);
await req.db.collection('branches').updateOne(
{ _id: c.branchId, license: c.license },
{
$set: {
captain_table_cleaning: cleaningEnabled,
...(req.body.tableRequired === undefined
? {}
: { captain_allow_unassigned: !req.body.tableRequired }),
updated_date: new Date(),
},
}
);
if (!cleaningEnabled)
await req.db.collection('tableorder').updateMany(
{ branch_id: c.branchId, license: c.license, service_state: 'cleaning' },
Expand Down
2 changes: 1 addition & 1 deletion api/src/services/captain-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ async function verifySession(req, user) {
.replace(/^\/api/, '')
.replace(/\/$/, '');
if (
!/^\/(authorizations\/verify-pin|captain\/v1\/(session|connections|logout|profile(?:\/(?:phone|email)\/(?:start|verify))?|password|branch-details|orders\/edit\/preview|bill(?:\/reprint)?|kitchen-ready|kitchen-audio\/(?:start|cancel|voice|status|archive|recordings|playback)|tables(?:\/(?:state|close|guests(?:\/status)?|merge\/prepare|transfer\/(?:preview|status|complete)|move\/(?:prepare|complete|cancel)))?|paper-orders\/(?:options|upload|recognize|reference|photos\/[a-fA-F0-9-]{36})|payment-options|payments\/(table|record|release))|items\/(accessQr|aiAvailability|soldOut|instanceItemInsert|instantItemTax)|sales\/(qrOrder|myDay|guestBills\/(table|latest|print)|requestBillPrint|serveKitchenItems|fireKitchenItems|kitchenDeliveryStatus|handoverStaff|handoverOrder|getTablesWithActiveOrders|getOrderHistory|updateOrder|getFrequentItems|pendingOnlineOrders|getListKot|[a-fA-F0-9]{24}\/(approval|print)|waiterCalls\/[^/]+\/seen|transcribe|voiceIntent))$/.test(
!/^\/(authorizations\/verify-pin|captain\/v1\/(session|connections|logout|attendance(?:\/settings)?|profile(?:\/(?:phone|email)\/(?:start|verify))?|password|branch-details|orders\/edit\/preview|bill(?:\/reprint)?|kitchen-ready|kitchen-audio\/(?:start|cancel|voice|status|archive|recordings|playback)|tables(?:\/(?:state|close|guests(?:\/status)?|merge\/prepare|transfer\/(?:preview|status|complete)|move\/(?:prepare|complete|cancel)))?|paper-orders\/(?:options|upload|recognize|reference|photos\/[a-fA-F0-9-]{36})|payment-options|payments\/(table|record|release))|items\/(accessQr|aiAvailability|soldOut|instanceItemInsert|instantItemTax)|sales\/(qrOrder|myDay|guestBills\/(table|latest|print)|requestBillPrint|serveKitchenItems|fireKitchenItems|kitchenDeliveryStatus|handoverStaff|handoverOrder|getTablesWithActiveOrders|getOrderHistory|updateOrder|getFrequentItems|pendingOnlineOrders|getListKot|[a-fA-F0-9]{24}\/(approval|print)|waiterCalls\/[^/]+\/seen|transcribe|voiceIntent))$/.test(
path
)
)
Expand Down
Loading
Loading