Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
423969b
Merge pull request #1407 from Posnic/fix/payment-users-and-desktop-forms
sridharkalaibala Oct 17, 2026
e89bd14
Add audited product availability and desktop duplicate review
sridharkalaibala Oct 17, 2026
a2e3525
Complete availability release checks and translated desktop controls
sridharkalaibala Oct 17, 2026
9761075
Merge pull request #1408 from Posnic/feature/product-availability
sridharkalaibala Oct 17, 2026
7f5fd5e
Hide unfinished Ask Posnic from the Features list
sridharkalaibala Oct 17, 2026
777977d
Label the retained hidden Ask Posnic setting
sridharkalaibala Oct 17, 2026
26be6f9
Merge pull request #1410 from Posnic/codex/hide-ask-posnic-feature-20…
sridharkalaibala Oct 17, 2026
90470fc
Add optional Captain shifts, room charges and explicit seating confir…
sridharkalaibala Oct 17, 2026
0d4f4c2
Merge develop and retain Captain room charges in current receipt flow
sridharkalaibala Oct 17, 2026
30758c1
Reconcile room translations, attendance ownership and API release met…
sridharkalaibala Oct 17, 2026
e844a55
Format Captain attendance and seating regression tests
sridharkalaibala Oct 17, 2026
ef424a6
Align receipt and table guards with optional room orders
sridharkalaibala Oct 17, 2026
e2721d3
Merge pull request #1411 from Posnic/codex/captain-require-table-2026…
sridharkalaibala Oct 17, 2026
5f7d228
Fix SKU values in detailed sales report and export
sridharkalaibala Oct 17, 2026
c0b2d29
Print shelf labels directly with exact paper sizes and smaller batches
sridharkalaibala Oct 18, 2026
ec7a14c
Make shelf printer controls translatable
sridharkalaibala Oct 18, 2026
46fe094
Align Nepali shelf-label batch help key
sridharkalaibala Oct 18, 2026
0902dc1
Translate shelf printer controls into Dutch
sridharkalaibala Oct 18, 2026
e5a589b
Apply main formatting to develop regression fixtures
sridharkalaibala Oct 18, 2026
4ff9ae2
Explain receipt print modes and rendering paths
sridharkalaibala Oct 18, 2026
ae9029f
Expose latest order recovery in blocked KOT dialog
sridharkalaibala Oct 18, 2026
593c6b1
Merge pull request #1415 from Posnic/codex/kot-pending-review-recovery
sridharkalaibala Oct 18, 2026
b8a3599
Recover KOT drafts after explicit seating rejection
sridharkalaibala Oct 18, 2026
0c7fedb
Merge pull request #1416 from Posnic/codex/kot-seating-recovery
sridharkalaibala Oct 18, 2026
cd09ced
Read verified remote payment projections and allow unchanged seating …
sridharkalaibala Oct 18, 2026
8f69004
Merge pull request #1417 from Posnic/codex/captain-cross-server-recovery
sridharkalaibala Oct 18, 2026
77faf60
Fix authorized sale-only cart price edits at checkout
sridharkalaibala Oct 18, 2026
67ca12e
Fix takeaway number labels on kitchen displays
sridharkalaibala Oct 18, 2026
72df378
Format existing develop regression tests
sridharkalaibala Oct 18, 2026
51a896c
Merge pull request #1419 from Posnic/fix/kitchen-takeaway-number-develop
sridharkalaibala Oct 18, 2026
0649626
Merge pull request #1414 from Posnic/fix/shelf-label-develop
sridharkalaibala Oct 18, 2026
93d6b59
Merge main into develop to resolve release PR conflicts
sridharkalaibala Oct 18, 2026
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
51 changes: 45 additions & 6 deletions api/src/services/captain-bill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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'
Expand All @@ -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(),
};
Expand Down
30 changes: 29 additions & 1 deletion api/src/services/seating-claims.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '') ||
Expand Down
42 changes: 42 additions & 0 deletions api/tests/unit/services/captain-bill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
29 changes: 29 additions & 0 deletions api/tests/unit/services/seating-claims.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion frontend/modules/ask_posnic.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="vertical-layout page_loader" id="askposnic" style="display:none">
<div class="contentbar">
<div id="ask_posnic_disabled" class="card"><div class="card-body"><h4><lang class="lang_ask_posnic_is_off">Ask Posnic is off</lang></h4><p><lang class="lang_an_owner_can_enable_ask_posnic_in_settings">An owner can enable Ask Posnic in Settings → Features. Your normal POS workflows remain available.</lang></p><a class="btn btn-primary" href="#/settings/modules"><lang class="lang_open_features">Open Features</lang></a></div></div>
<div id="ask_posnic_disabled" class="card"><div class="card-body"><h4><lang class="lang_ask_posnic_is_off">Ask Posnic is off</lang></h4></div></div>
<div class="ask-workspace" style="display:none">
<header class="ask-workspace-header">
<div class="ask-workspace-title">
Expand Down
16 changes: 4 additions & 12 deletions frontend/modules/settings_write.html
Original file line number Diff line number Diff line change
Expand Up @@ -2993,17 +2993,9 @@ <h6 class="fg-head-title"><lang class="lang_fg_workspace">Workspace</lang></h6>
<p class="module-desc"><lang class="lang_model_assistance_for_item_descriptions_and">Model assistance for item descriptions and other enabled workflows. Configure your connection and usage limits in AI settings.</lang></p>
</div>

<div class="module-card" style="--fg-accent: #6d28d9;">
<div class="module-card-head">
<span class="module-ico"><i class="feather icon-message-circle"></i></span>
<div class="module-title"><lang class="lang_ask_posnic">Ask Posnic</lang></div>
<div class="custom-control custom-switch module-switch">
<input type="checkbox" class="custom-control-input" id="ask_posnic_enabled" name="ask_posnic_enabled">
<label class="custom-control-label" for="ask_posnic_enabled"><span class="sr-only"><lang class="lang_enable_ask_posnic">Enable Ask Posnic</lang></span></label>
</div>
</div>
<p class="module-desc"><lang class="lang_optional_help_shop_insights_and_confirmed">Optional help, shop insights and confirmed actions. Off by default. Enable it to show Ask Posnic; manage its knowledge and access in AI settings.</lang></p>
</div>
<!-- Ask Posnic is not ready for public opt-in. Keep the saved value
through legacy full-form saves without a discoverable feature card. -->
<input type="checkbox" id="ask_posnic_enabled" name="ask_posnic_enabled" aria-label="Ask Posnic" data-t-aria-label="lang_ask_posnic" hidden style="display:none">

<div class="module-card" style="--fg-accent: #57534e;">
<div class="module-card-head">
Expand Down Expand Up @@ -3608,7 +3600,7 @@ <h5 class="card-title mb-0"><lang class="lang_delivery_and_fees">Delivery and ot
<div class="tab-pane fade" id="v-pills-ai" role="tabpanel" aria-labelledby="v-pills-ai-tab">
<p class="text-muted" id="ask_settings_access" role="status"><lang class="lang_ask_checking_access">Checking access to Ask Posnic settings…</lang></p>
<div id="ask_posnic_admin" class="card ask-settings-card" style="display:none"><div class="card-body">
<div id="ask_feature_notice" class="alert alert-info"><lang class="lang_ask_posnic_is_off_enable_it_in">Ask Posnic is off. Enable it in</lang> <a href="#/settings/modules"><lang class="lang_modules">Features</lang></a> <lang class="lang_to_use_questions_insights_actions_and_sche">to use questions, insights, actions and schedules.</lang></div>
<div id="ask_feature_notice" class="alert alert-info"><lang class="lang_ask_posnic_is_off">Ask Posnic is off</lang></div>
<div class="ask-settings-heading"><div><h5><lang class="lang_ask_posnic">Ask Posnic</lang></h5><p class="text-muted mb-0"><lang class="lang_ask_settings_hint">Manage how Ask Posnic helps your team. These settings apply to your shop account.</lang></p></div><a class="btn btn-outline-primary" data-ask-entry style="display:none" href="#/askposnic"><lang class="lang_ask_open_chat">Open Ask Posnic</lang></a></div>
<div class="ask-settings-tabs" role="tablist" aria-label="Ask Posnic settings" data-t-aria-label="lang_ask_settings_label"><button type="button" role="tab" id="ask-tab-general" data-ask-tab="general" aria-controls="ask-settings-general" aria-selected="true" tabindex="0"><lang class="lang_ask_tab_general">General</lang></button><button type="button" role="tab" id="ask-tab-knowledge" data-ask-tab="knowledge" aria-controls="ask-settings-knowledge" aria-selected="false" tabindex="-1"><lang class="lang_ask_tab_knowledge">Knowledge</lang></button><button type="button" role="tab" id="ask-tab-access" data-ask-tab="access" aria-controls="ask-settings-access" aria-selected="false" tabindex="-1"><lang class="lang_ask_tab_access">Access &amp; actions</lang></button><button type="button" role="tab" id="ask-tab-usage" data-ask-tab="usage" aria-controls="ask-settings-usage" aria-selected="false" tabindex="-1"><lang class="lang_ask_tab_usage">AI &amp; usage</lang></button><button type="button" role="tab" id="ask-tab-schedules" data-ask-tab="schedules" aria-controls="ask-settings-schedules" aria-selected="false" tabindex="-1"><lang class="lang_ask_tab_schedules">Schedules</lang></button></div>
<form id="ask_posnic_preferences_form"></form>
Expand Down
36 changes: 23 additions & 13 deletions frontend/static/script/js/modules/js/kot_v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -633,16 +633,35 @@
Number(saved.unit_price ?? saved.item_base_price ?? saved.item_price ?? 0) * line.quantity,
);
}
async function reviewLatestOrder() {
const d = state.draft;
if (!d.saleId) { pendingSendReview(); return; }
const latest = await api('get', 'sales/' + d.saleId);
const accepted = new Set((latest.items || []).map((l) => l.line_id));
d.items = d.items.filter((l) => !accepted.has(l.line_id));
delete d.intent;
state.sale = latest;
persist();
render();
notify('Latest order loaded. Review the remaining dishes before sending.');
}
function pendingSendReview(afterRecovery) {
if (!state.draft?.intent) return false;
dialog(P.i18n.t('lang_review_this_round', 'Review this round'),
'<p>' + esc(P.i18n.t('lang_submission_resolve_first', 'Resolve the previous submission before sending changes.')) + '</p>',
const modal = dialog(P.i18n.t('lang_review_this_round', 'Review this round'),
'<p>' + esc(P.i18n.t('lang_submission_resolve_first', 'Resolve the previous submission before sending changes.')) + '</p>' +
(state.draft.saleId ? button('Review latest order', 'reviewPendingOrder') : ''),
async (_, modal) => {
await send();
if (state.draft?.intent) return;
modal.close();
if (afterRecovery) afterRecovery();
}, P.i18n.t('lang_retry_saved_send', 'Retry saved send'));
const review = modal.querySelector('[data-action=reviewPendingOrder]');
if (review) review.onclick = () => {
if (modal.querySelector('[type=submit]').disabled) return;
modal.close();
run(reviewLatestOrder);
};
return true;
}
function customer() {
Expand Down Expand Up @@ -810,7 +829,7 @@
notify(error.message);
return;
}
if (d.saleId && error.message === 'order_changed') {
if (d.saleId && ['order_changed', 'The seating group changed. Refresh this order.'].includes(error.message)) {
// This is an explicit refusal, not an unknown network outcome.
// Reconcile accepted line IDs before unlocking the remaining draft.
const latest = await api('get', 'sales/' + d.saleId);
Expand Down Expand Up @@ -1252,16 +1271,7 @@ default: return text; } };
else if (a === 'offmenu') await offmenu();
else if (a === 'send') await send();
else if (a === 'rebase') {
const d = state.draft;
if (!d.saleId) { pendingSendReview(); return; }
const latest = await api('get', 'sales/' + d.saleId);
const accepted = new Set((latest.items || []).map((l) => l.line_id));
d.items = d.items.filter((l) => !accepted.has(l.line_id));
delete d.intent;
state.sale = latest;
persist();
render();
notify('Latest order loaded. Review the remaining dishes before sending.');
await reviewLatestOrder();
} else if (a === 'discard') {
if (state.draft.intent)
throw new Error(
Expand Down
5 changes: 0 additions & 5 deletions languages/_english.json
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@
"lang_an_hour_ago": "1 hour ago",
"lang_an_item_needs_a_name": "An item needs a name.",
"lang_an_item_price_overrides_the_percentage_adj": "An item price overrides the percentage adjustment.",
"lang_an_owner_can_enable_ask_posnic_in_settings": "An owner can enable Ask Posnic in Settings → Features. Your normal POS workflows remain available.",
"lang_and_begin_your_service": "and begin your service!",
"lang_another_posnic_account": "Another posnic account ?",
"lang_any": "Any",
Expand Down Expand Up @@ -361,7 +360,6 @@
"lang_ask_posnic": "Ask Posnic",
"lang_ask_posnic_about_your_shop": "Ask Posnic about your shop…",
"lang_ask_posnic_is_off": "Ask Posnic is off",
"lang_ask_posnic_is_off_enable_it_in": "Ask Posnic is off. Enable it in",
"lang_ask_prepare_sale": "Prepare sale for checkout",
"lang_ask_provider_hint": "With your own provider account, usage is billed directly by that provider. Managed AI uses your Posnic subscription and allowance. Keys are stored on the server and never shown again after saving.",
"lang_ask_provider_off": "Off - direct reports and local help remain available",
Expand Down Expand Up @@ -1439,7 +1437,6 @@
"lang_empty_return": "Return Items Order Empty",
"lang_en_in": "en-IN",
"lang_en_us": "en_US",
"lang_enable_ask_posnic": "Enable Ask Posnic",
"lang_enable_cashback": "Enable cashback",
"lang_enable_disable_stock_log": "Enable/Disable Stock Log",
"lang_enable_expiry_reminders": "Enable expiry reminders",
Expand Down Expand Up @@ -2868,7 +2865,6 @@
"lang_optional_dietary_label_serving_periods_and": "Optional · dietary label, serving periods and modifiers",
"lang_optional_for_own_key_accounts_sends_publis": "Optional for own-key accounts. Sends published knowledge and help questions to OpenAI for embeddings, billed to your key. Vectors stay in this installation. Supports up to 5,000 published passages. Cloud managed search uses your managed allowance.",
"lang_optional_for_the_payout_report": "(optional - for the payout report)",
"lang_optional_help_shop_insights_and_confirmed": "Optional help, shop insights and confirmed actions. Off by default. Enable it to show Ask Posnic; manage its knowledge and access in AI settings.",
"lang_optional_printed_on_the_invoice": "Optional - printed on the invoice",
"lang_optional_swipe_to_clock_in_approve": "(optional - swipe to clock in / approve)",
"lang_options": "Options",
Expand Down Expand Up @@ -4589,7 +4585,6 @@
"lang_to_3": "&nbsp;&nbsp;&nbsp;&nbsp;To :&nbsp;",
"lang_to_date": "To date",
"lang_to_email_address": "To email address",
"lang_to_use_questions_insights_actions_and_sche": "to use questions, insights, actions and schedules.",
"lang_today_and_upcoming": "Today and upcoming",
"lang_token": "Token",
"lang_token_copied": "Token copied",
Expand Down
Loading
Loading