diff --git a/api/src/services/captain-bill.js b/api/src/services/captain-bill.js index 8ca62d3b..6a15a650 100644 --- a/api/src/services/captain-bill.js +++ b/api/src/services/captain-bill.js @@ -5,6 +5,37 @@ const { snapshotFrom } = require('./guest-bill.service'); const Money = require('../utils/currency'); const { ObjectId } = require('mongodb'); +function confirmedPaidProjection(sale, total, monetary) { + if ( + sale.payment_status !== 'Paid' || + sale.payment_correction_pending || + !Number.isInteger(sale.captain_payment_version) || + sale.captain_payment_version < 1 || + ['balance', 'payment_pending'].some( + (key) => sale[key] == null || !Number.isFinite(Number(sale[key])) || Number(sale[key]) !== 0 + ) || + !Number.isFinite(Number(sale.paid_amount)) || + Money.toMinor(sale.paid_amount, monetary) !== total || + !Array.isArray(sale.captain_payments) || + !sale.captain_payments.length + ) + return false; + const ids = new Set(); + let sum = 0; + for (const payment of sale.captain_payments) { + if ( + !payment.id || + ids.has(payment.id) || + !Number.isFinite(Number(payment.amount)) || + Number(payment.amount) <= 0 + ) + return false; + ids.add(payment.id); + sum += Money.toMinor(payment.amount, monetary); + } + return Number.isSafeInteger(sum) && sum === total; +} + async function read(req) { if (!req.user || !allowed(req.user, 'sales')) fail('Permission is required.', 403); const c = await context(req); @@ -57,21 +88,28 @@ async function read(req) { if (plans.some((plan) => plan.purpose === 'order-restructure')) fail('This order is being updated. Please retry.', 409); let paidMinor = 0; + let projectedReceipt = false; for (const sale of sales) { const total = snapshotFrom([sale], c.branch, table.trim(), { allowZero: true }).totalMinor; let paid; if (sale.captain_payment_plan) { // The journal is authoritative even if a payment's sale projection was interrupted. const plan = plans.find((row) => String(row._id) === String(sale.captain_payment_plan)); - if (!plan) + if (!plan && confirmedPaidProjection(sale, total, monetary)) { + // Journals remain at the payment authority. Synced completed receipts + // are readable, but must never enable collection on another server. + paid = total; + projectedReceipt = true; + } else if (!plan) fail( 'Payment status is not confirmed. Retry this request; do not collect the money again.', 409 ); - paid = (plan.payments || []).reduce( - (sum, payment) => sum + Number(payment.allocations?.[String(sale._id)] || 0), - 0 - ); + if (plan) + paid = (plan.payments || []).reduce( + (sum, payment) => sum + Number(payment.allocations?.[String(sale._id)] || 0), + 0 + ); } else paid = sale.payment_status === 'Paid' @@ -85,7 +123,8 @@ async function read(req) { ...snapshot, paidMinor, dueMinor: snapshot.totalMinor - paidMinor, - collectEnabled: !receipt && require('./captain-payments').settings(c.branch).enabled, + collectEnabled: + !receipt && !projectedReceipt && require('./captain-payments').settings(c.branch).enabled, orderIds: sales.map((sale) => String(sale._id)), serverTime: new Date().toISOString(), }; diff --git a/api/src/services/seating-claims.js b/api/src/services/seating-claims.js index 8e42390d..12580989 100644 --- a/api/src/services/seating-claims.js +++ b/api/src/services/seating-claims.js @@ -1241,7 +1241,35 @@ async function forEdit(db, scope, order, next, { settling = false, cancelling = ); return own || null; } - if (order.seating_request_id && !own) fail('The seating group changed. Refresh this order.', 409); + if (order.seating_request_id && !own) { + // Reservation journals are local to their authority. Editing dishes on a + // synced open sale consumes no seats when the complete seating is unchanged. + const unchanged = + destination === String(order.table_number || '') && + (!next.dine_type || next.dine_type === (order.dine_type || 'Dine-in')) && + (next.guests == null || + next.guests === '' || + Number(next.guests) === Number(order.person_count || 0)); + const knownClaim = claims.some((claim) => claim.order_id === String(order._id)); + const conflictingOperation = claims.some( + (claim) => + !terminal(claim) && + capacityChanging(claim) && + (claim.tables || []).some((table) => + (order.seating_table_ids || []).map(String).includes(String(table)) + ) + ); + if ( + unchanged && + !knownClaim && + !conflictingOperation && + !order.floor_closed_at && + order.payment_status === 'Unpaid' && + order.sale_process === 'KOT' + ) + return null; + fail('The seating group changed. Refresh this order.', 409); + } if ( own && (destination !== String(order.table_number || '') || diff --git a/api/tests/unit/services/captain-bill.test.js b/api/tests/unit/services/captain-bill.test.js index 6c319a6f..793f5a42 100644 --- a/api/tests/unit/services/captain-bill.test.js +++ b/api/tests/unit/services/captain-bill.test.js @@ -216,3 +216,45 @@ test('completed paid receipts target one sale and reprint its payment without mo service.read({ ...r, tenantContext: { branchId: new ObjectId(), licenseId: license } }) ).rejects.toMatchObject({ status: 403 }); }); + +test('remote paid projections allow read-only bills and reject inconsistent amounts', async () => { + const projection = { + dine_type: 'Take away', + floor_lifecycle: true, + payment_status: 'Paid', + captain_payment_plan: 'remote', + captain_payment_version: 1, + paid_amount: 105, + balance: 0, + payment_pending: 0, + captain_payments: [{ id: 'p1', amount: 105 }], + }; + const request = { ...req(), query: { saleId: String(sale._id), receipt: 'true' } }; + await db + .collection('sales') + .updateOne({ _id: sale._id }, { $set: { ...projection, floor_closed_at: new Date() } }); + expect(await service.read(request)).toMatchObject({ + paidMinor: 10500, + dueMinor: 0, + collectEnabled: false, + }); + for (const change of [ + { paid_amount: 100 }, + { balance: 1 }, + { payment_pending: 1 }, + { captain_payment_version: 0 }, + { captain_payments: [{ id: 'p1', amount: 100 }] }, + { + captain_payments: [ + { id: 'same', amount: 50 }, + { id: 'same', amount: 55 }, + ], + }, + ]) { + await db + .collection('sales') + .updateOne({ _id: sale._id }, { $set: { ...projection, ...change } }); + await expect(service.read(request)).rejects.toMatchObject({ status: 409 }); + } + expect(await db.collection('captain_payment_plans').countDocuments()).toBe(0); +}); diff --git a/api/tests/unit/services/seating-claims.test.js b/api/tests/unit/services/seating-claims.test.js index 8fd16a9e..3dde4af1 100644 --- a/api/tests/unit/services/seating-claims.test.js +++ b/api/tests/unit/services/seating-claims.test.js @@ -2983,3 +2983,32 @@ test.each([ seating.forEdit(db, scope, order, { table: 'T1', guests: 2 }, { settling: true }) ).rejects.toMatchObject({ status: 409 }); }); + +test('remote unpaid dish edits preserve seating and cannot reopen closed sales', async () => { + const order = { + _id: new ObjectId(), + seating_request_id: 'remote', + seating_table_ids: [ids[0]], + table_number: 'T1', + person_count: 2, + dine_type: 'Dine-in', + sale_process: 'KOT', + payment_status: 'Unpaid', + }; + const next = { table: 'T1', guests: 2, dine_type: 'Dine-in', sale_process: 'KOT' }; + await expect(seating.forEdit(db, scope, order, next)).resolves.toBeNull(); + await expect(seating.reserveEditCapacity(db, scope, order, next)).resolves.toBeNull(); + for (const change of [{ table: 'T2' }, { guests: 3 }, { guests: 1 }, { dine_type: 'Take away' }]) + await expect(seating.forEdit(db, scope, order, { ...next, ...change })).rejects.toMatchObject({ + status: 409, + }); + for (const change of [ + { payment_status: 'Paid' }, + { floor_closed_at: new Date() }, + { sale_process: 'Hold' }, + ]) + await expect(seating.forEdit(db, scope, { ...order, ...change }, next)).rejects.toMatchObject({ + status: 409, + }); + expect(await db.collection('table_seating').countDocuments()).toBe(0); +}); diff --git a/frontend/modules/ask_posnic.html b/frontend/modules/ask_posnic.html index ae049fea..2af373f1 100644 --- a/frontend/modules/ask_posnic.html +++ b/frontend/modules/ask_posnic.html @@ -1,6 +1,6 @@