From 6a4c6b2a469c2df7c2ffc69adc9f2df6cab03892 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:16:46 -0700 Subject: [PATCH 01/10] test(counter): no tender until the cashier chooses one (red) Five legs that fail on today's counter, which starts every sale on cash, puts cash back after a sale and after an account tender, and lets Pay complete the sale in cash for the whole amount. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/pos/POSTerminal.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 143b9d12d..2da4c08a0 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -1766,3 +1766,77 @@ describe('closing the till', () => { el.remove(); }); }); + +describe('no tender until the cashier chooses one', () => { + beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); + + const pressed = (el: Element) => ['cash', 'check', 'card', 'account'] + .filter((m) => q(el, `tender-${m}`)?.getAttribute('aria-pressed') === 'true'); + const settle = () => new Promise((r) => setTimeout(r, 0)); + + it('starts a sale with a line and the till open with no tender pressed and Pay off', async () => { + const el = await mount(sale()); + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + expect(q(el, 'pos-pay').textContent).toContain('Choose how the customer pays'); + expect((el.querySelector('#pos-tender-amount') as HTMLInputElement).disabled).toBe(true); + expect((q(el, 'pos-add-tender') as HTMLButtonElement).disabled).toBe(true); + expect(q(el, 'panel-summary-body').textContent).toContain('None yet'); + el.remove(); + }); + + it('does not complete the sale when Pay is pressed with no tender chosen', async () => { + const el = await mount(sale()); + q(el, 'pos-pay').click(); + await settle(); + expect(svc.completeSale).not.toHaveBeenCalled(); + el.remove(); + }); + + it('sends one account tender for a named account customer who chose Charge to account', async () => { + svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); + const el = await mount(sale({ customer_id: 'cu1' })); + (el as unknown as { _customer: CounterCustomer })._customer = account; + await el.updateComplete; + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + q(el, 'tender-account').click(); + await el.updateComplete; + expect(pressed(el)).toEqual(['account']); + q(el, 'pos-pay').click(); + await settle(); + expect(svc.completeSale).toHaveBeenCalledTimes(1); + expect(svc.completeSale).toHaveBeenCalledWith('s1', 3, [{ method: 'account', amount_cents: 8650 }]); + el.remove(); + }); + + it('asks for a tender again after a completed sale and New Sale', async () => { + svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); + const el = await mount(sale()); + q(el, 'tender-card').click(); + await el.updateComplete; + q(el, 'pos-pay').click(); + await settle(); + await el.updateComplete; + svc.startSale.mockResolvedValue(sale({ id: 's2', lines: [line('c', 5000)], total_cents: 5000 })); + const newSale = [...q(el, 'pos-header').querySelectorAll('button')].find((x) => x.textContent?.trim() === 'New Sale') as HTMLElement; + newSale.click(); + await settle(); + await el.updateComplete; + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + el.remove(); + }); + + it('asks for the next tender again after Add as split tender', async () => { + const el = await mount(sale()); + q(el, 'tender-cash').click(); + await el.updateComplete; + await typeInto(el, '50.00'); + q(el, 'pos-add-tender').click(); + await el.updateComplete; + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + el.remove(); + }); +}); From 5e02176a7496b510705cbf1771fed3286a3d8f65 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:17:45 -0700 Subject: [PATCH 02/10] fix(counter): no tender until the cashier chooses one The tender starts empty, is empty again after a completed sale, a new sale, Clear sale, Clear tenders and Add as split tender. With none chosen, Pay is off and says "Choose how the customer pays", the amount field and Add as split tender are off, and the summary reads None yet. Older legs that assumed cash was preselected now press cash first. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/pos/POSTerminal.test.ts | 13 ++++++++- web/apps/desk/src/pages/pos/POSTerminal.ts | 27 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 2da4c08a0..648f1f03a 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -78,7 +78,15 @@ async function mount(s: Sale): Promise { return el; } const q = (el: Element, id: string) => el.querySelector(`[data-testid="${id}"]`) as HTMLElement; +// A tender is never assumed; where a leg is about the amount, not the choice, +// the cashier presses cash first. +const chooseCash = async (el: POSTerminal) => { + q(el, 'tender-cash').click(); + await el.updateComplete; +}; const typeInto = async (el: POSTerminal, v: string) => { + const anyPressed = el.querySelector('[data-testid^="tender-"][aria-pressed="true"]'); + if (!anyPressed) await chooseCash(el); const input = el.querySelector('#pos-tender-amount') as HTMLInputElement; input.value = v; input.dispatchEvent(new Event('input')); @@ -104,6 +112,7 @@ describe('the counter as the Panels layout', () => { expect(q(el, 'panel-payment')).toBeTruthy(); expect(q(el, 'panel-summary')).toBeTruthy(); for (const n of ['customer', 'payment', 'summary']) expect(q(el, `progress-${n}`)).toBeTruthy(); + await chooseCash(el); const pay = q(el, 'pos-pay'); expect(el.querySelectorAll('[data-testid="pos-pay"]').length).toBe(1); expect(q(el, 'summary-total').textContent?.trim()).toBe('$86.50'); @@ -210,7 +219,7 @@ describe('the counter as the Panels layout', () => { expect((q(el, 'tender-account') as HTMLButtonElement).disabled).toBe(true); inner(el)._chooseMethod('account'); await el.updateComplete; - expect(inner(el)._tenderMethod).toBe('cash'); + expect(inner(el)._tenderMethod).toBeNull(); expect(el.querySelector('[role="alert"]')).toBeTruthy(); expect(svc.completeSale).not.toHaveBeenCalled(); }); @@ -363,6 +372,7 @@ describe('the counter as the Panels layout', () => { it('with a till open and a payable sale, Pay is the one primary', async () => { const el = await mount(sale()); + await chooseCash(el); const primaries = filled(el); expect(primaries.length).toBe(1); expect(primaries[0]).toBe(q(el, 'pos-pay')); @@ -641,6 +651,7 @@ describe('the Panels board', () => { it('changes no money when a panel opens or closes', async () => { const el = await mount(sale()); + await chooseCash(el); for (const key of ['payment', 'summary', 'customer', 'customer', 'payment']) { toggle(el, key).click(); await el.updateComplete; diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 23a7b6c49..22db183fc 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -32,7 +32,9 @@ export class POSTerminal extends LitElement { @state() private _sale: Sale | null = null; @state() private _pendingTenders: TenderIn[] = []; - @state() private _tenderMethod: TenderMethod = 'cash'; + // Null until the cashier presses a tender: nothing is assumed, so Pay cannot + // complete a sale on a method nobody chose. + @state() private _tenderMethod: TenderMethod | null = null; @state() private _tenderAmount = ''; @state() private _loading = false; @state() private _error: string | null = null; @@ -529,7 +531,7 @@ export class POSTerminal extends LitElement { if (reg !== this._registerId) return; this._sale = sale; this._tenderAmount = ''; - this._tenderMethod = 'cash'; + this._tenderMethod = null; this._step = 'customer'; this._open = 'customer'; } catch (err: unknown) { @@ -664,6 +666,8 @@ export class POSTerminal extends LitElement { await this._removeItem(id); if (this._error) return; } + this._tenderMethod = null; + this._tenderAmount = ''; } private async _removeItem(itemId: string) { @@ -688,10 +692,11 @@ export class POSTerminal extends LitElement { /** * The tender being entered: the chosen method and the typed amount, which - * is what is still owed while the field is empty. Null when it does not - * parse to a positive amount. + * is what is still owed while the field is empty. Null when no method is + * chosen or the amount does not parse to a positive number. */ private get _entry(): TenderIn | null { + if (this._tenderMethod === null) return null; const text = this._tenderAmount.trim(); const cents = text === '' ? this._owedCents : dollarsToCents(text); if (isNaN(cents) || cents <= 0) return null; @@ -727,8 +732,8 @@ export class POSTerminal extends LitElement { } this._pendingTenders = [...this._pendingTenders, entry]; this._tenderAmount = ''; - // The next tender starts on a method the sale can still take. - if (this._tenderMethod === 'account') this._tenderMethod = 'cash'; + // The next tender is chosen again, never assumed. + this._tenderMethod = null; } /** Pay: the added tenders plus the entry when they do not cover the total. */ @@ -770,6 +775,7 @@ export class POSTerminal extends LitElement { this._success = null; this._pendingTenders = []; this._tenderAmount = ''; + this._tenderMethod = null; this._step = 'summary'; this._open = null; if (tenders.some((t) => t.method === 'account')) void this._refreshStanding(); @@ -1145,11 +1151,11 @@ export class POSTerminal extends LitElement { class="mt-2 min-h-11 rounded border border-white/20 px-3 py-2 text-sm font-semibold text-white hover:bg-white/5">Enter the purchase order in Customer` : nothing} ` : nothing} 0 ? (owed / 100).toFixed(2) : ''} .value=${this._tenderAmount} - ?disabled=${!hasLines || !open} + ?disabled=${!hasLines || !open || this._tenderMethod === null} @input=${(e: Event) => { this._tenderAmount = (e.target as HTMLInputElement).value; }} @blur=${() => this._formatTenderAmount()} class="mt-1 w-full rounded border border-white/10 bg-deep-space px-3 py-2 text-right font-mono text-xl font-bold text-white outline-none focus:border-gable-green" @@ -1160,10 +1166,10 @@ export class POSTerminal extends LitElement { ` : nothing}
${this._pendingTenders.length > 0 ? html` - ` : nothing}
@@ -1226,6 +1232,7 @@ export class POSTerminal extends LitElement { : completed ? 'Paid in full' : this._till === null ? `Pay ${fmt(total)}` : plan.ok ? `Pay ${fmt(total)}` + : hasLines && open && this._tenderMethod === null ? 'Choose how the customer pays' : hasLines ? `Still owed ${fmt(plan.owedCents)}` : `Pay ${fmt(total)}`; return html`
From e179ff548d24972aebb7c05abf317fe1644a594b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:20:47 -0700 Subject: [PATCH 03/10] test(counter): type the new sale's lines in the tender legs Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/pos/POSTerminal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 648f1f03a..79fc0756e 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -1829,7 +1829,7 @@ describe('no tender until the cashier chooses one', () => { q(el, 'pos-pay').click(); await settle(); await el.updateComplete; - svc.startSale.mockResolvedValue(sale({ id: 's2', lines: [line('c', 5000)], total_cents: 5000 })); + svc.startSale.mockResolvedValue(sale({ id: 's2', lines: [line('c', 5000)] as unknown as Sale['lines'], total_cents: 5000 })); const newSale = [...q(el, 'pos-header').querySelectorAll('button')].find((x) => x.textContent?.trim() === 'New Sale') as HTMLElement; newSale.click(); await settle(); From cfa40b1c96a1e0ddc027af0eb7d53d22884f1258 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:34 -0700 Subject: [PATCH 04/10] test(e2e): the split tender walk chooses its tender before Pay shows the total Pay now reads "Choose how the customer pays" until a tender is pressed, so the walk asserts that, presses cash, and then reads the total on Pay. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/e2e/pos.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/e2e/pos.spec.ts b/web/e2e/pos.spec.ts index e1995b643..89733667a 100644 --- a/web/e2e/pos.spec.ts +++ b/web/e2e/pos.spec.ts @@ -97,9 +97,11 @@ test('the counter: split tender, return and void', async ({ page, request }) => // sale's revision; the page sends the whole tender list at once. const total = await saleTotal(page); expect(total).toBeGreaterThan(1); - await expect(page.getByTestId('pos-pay')).toContainText(`$${total.toFixed(2)}`); + // No tender is assumed: Pay says to choose one until the cashier does. + await expect(page.getByTestId('pos-pay')).toContainText('Choose how the customer pays'); await openPayment(page); await page.getByTestId('tender-cash').click(); + await expect(page.getByTestId('pos-pay')).toContainText(`$${total.toFixed(2)}`); await page.getByLabel('Tender amount').fill('1.00'); await page.getByRole('button', { name: 'Add as split tender' }).click(); const added = page.getByTestId('pos-added-tenders'); From ef99a66c48b659e53519b0b58010f9261a09019b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:57:20 -0700 Subject: [PATCH 05/10] test(counter): a leg that is about the amount presses its tender itself typeInto no longer chooses cash when none is pressed; each leg that types an amount chooses its tender, so a leg that means to type with no method is not tested on cash without saying so. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/pos/POSTerminal.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 79fc0756e..87f95f612 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -78,15 +78,13 @@ async function mount(s: Sale): Promise { return el; } const q = (el: Element, id: string) => el.querySelector(`[data-testid="${id}"]`) as HTMLElement; -// A tender is never assumed; where a leg is about the amount, not the choice, -// the cashier presses cash first. +// A tender is never assumed: a leg that is about the amount presses its tender +// itself, with chooseCash or the tender's own button. const chooseCash = async (el: POSTerminal) => { q(el, 'tender-cash').click(); await el.updateComplete; }; const typeInto = async (el: POSTerminal, v: string) => { - const anyPressed = el.querySelector('[data-testid^="tender-"][aria-pressed="true"]'); - if (!anyPressed) await chooseCash(el); const input = el.querySelector('#pos-tender-amount') as HTMLInputElement; input.value = v; input.dispatchEvent(new Event('input')); @@ -123,6 +121,7 @@ describe('the counter as the Panels layout', () => { it('pays in cash with the tendered amount, sending one cash tender on the sale revision', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed', change_cents: 1350 })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); expect(q(el, 'pos-change').textContent).toContain('$13.50'); q(el, 'pos-pay').click(); @@ -133,6 +132,7 @@ describe('the counter as the Panels layout', () => { it('takes a split between two tenders as the added tender plus the entry', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '50.00'); q(el, 'pos-add-tender').click(); await el.updateComplete; @@ -167,6 +167,7 @@ describe('the counter as the Panels layout', () => { expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); (el as unknown as { _sale: Sale })._sale = sale(); await el.updateComplete; + await chooseCash(el); await typeInto(el, '10.00'); const pay = q(el, 'pos-pay') as HTMLButtonElement; expect(pay.disabled).toBe(true); @@ -342,6 +343,7 @@ describe('the counter as the Panels layout', () => { it('does not start a second sale over the one the cashier began after a completion', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); @@ -443,6 +445,7 @@ describe('the counter as the Panels layout', () => { it('draws Void as a plain secondary action, never a red outline', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); @@ -461,6 +464,7 @@ describe('the counter as the Panels layout', () => { it('leaves the completion to the card: no green band over the page, so nothing shifts', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed', number: 'POS-000002', total_cents: 13810, change_cents: 0 })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '86.50'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); @@ -640,6 +644,7 @@ describe('the Panels board', () => { it('shows the payment value on a done Payment panel and the review on Summary', async () => { const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-next-summary').click(); await el.updateComplete; @@ -674,6 +679,7 @@ describe('the Panels board', () => { expect(q(el, 'pos-order-summary').contains(q(el, 'pos-pay'))).toBe(true); expect(q(el, 'pos-order-summary').textContent).toContain('Item a'); expect(q(el, 'pos-order-summary').textContent).toContain('1 EA at $50.00'); + await chooseCash(el); await typeInto(el, '100.00'); expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(false); const done = await mount(sale({ status: 'completed' })); @@ -1193,6 +1199,7 @@ describe('the account customer after a charge', () => { const el = await mount(sale({ customer_id: 'cu1' })); (el as unknown as Inner)._customer = account; await el.updateComplete; + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); @@ -1536,6 +1543,7 @@ describe('the counter before the till opens, and after a sale completes', () => async function completeOne(): Promise { svc.completeSale.mockResolvedValue(sale({ status: 'completed', number: 'POS-000001', change_cents: 1350 })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); @@ -1546,6 +1554,7 @@ describe('the counter before the till opens, and after a sale completes', () => it('stays on screen past the old timer, with its band, Void and Return', async () => { svc.completeSale.mockResolvedValue(sale({ status: 'completed', number: 'POS-000001', change_cents: 1350 })); const el = await mount(sale()); + await chooseCash(el); await typeInto(el, '100.00'); // fake timers go in before Pay, so a timer the completion arms is one // this test advances @@ -1664,6 +1673,7 @@ describe('the counter before the till opens, and after a sale completes', () => const el = await mount(sale({ customer_id: 'cu1' })); (el as unknown as { _customer: CounterCustomer | null })._customer = withJob; await el.updateComplete; + await chooseCash(el); await typeInto(el, '100.00'); q(el, 'pos-pay').click(); await new Promise((r) => setTimeout(r, 0)); From 628ad554b0b1a7da9d21ce91f4f78d3c0367997a Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:57:35 -0700 Subject: [PATCH 06/10] test(counter): a tender cannot be pressed while no till is open (red) Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/pos/POSTerminal.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 87f95f612..062dbb040 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -1539,6 +1539,18 @@ describe('the counter before the till opens, and after a sale completes', () => el.remove(); }); + it('cannot press a tender while no till is open', async () => { + const el = await mountNoTill(); + (el as unknown as { _sale: Sale })._sale = sale(); + await el.updateComplete; + for (const m of ['cash', 'check', 'card', 'account']) { + expect((q(el, `tender-${m}`) as HTMLButtonElement).disabled).toBe(true); + } + (el as unknown as { _chooseMethod(m: string): void })._chooseMethod('cash'); + expect((el as unknown as { _tenderMethod: string | null })._tenderMethod).toBeNull(); + el.remove(); + }); + describe('a completed sale', () => { async function completeOne(): Promise { svc.completeSale.mockResolvedValue(sale({ status: 'completed', number: 'POS-000001', change_cents: 1350 })); From 3f0eb8cd99f3a17700308dae5b72529560493885 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:57:57 -0700 Subject: [PATCH 07/10] test(counter): Clear sale drops the added tenders; Clear tenders drops the method (red) The Clear sale leg fails today: an $86.50 cash tender added as a split survives Clear sale, so a $50.00 line is payable with no tender pressed. The Clear tenders leg guards the existing reset against a later undo. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/pos/POSTerminal.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 062dbb040..290ad72ed 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -1873,3 +1873,56 @@ describe('no tender until the cashier chooses one', () => { el.remove(); }); }); + +describe('the resets after Clear tenders and Clear sale', () => { + beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); + + const pressed = (el: Element) => ['cash', 'check', 'card', 'account'] + .filter((m) => q(el, `tender-${m}`)?.getAttribute('aria-pressed') === 'true'); + const settle = () => new Promise((r) => setTimeout(r, 0)); + + it('puts the chosen method back to none on Clear tenders', async () => { + const el = await mount(sale()); + await chooseCash(el); + await typeInto(el, '50.00'); + q(el, 'pos-add-tender').click(); + await el.updateComplete; + q(el, 'tender-card').click(); + await el.updateComplete; + expect(pressed(el)).toEqual(['card']); + const clear = [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Clear tenders') as HTMLElement; + clear.click(); + await el.updateComplete; + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + el.remove(); + }); + + it('drops the added tenders on Clear sale, so the next lines are not paid on them', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + svc.removeItem + .mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] })) + .mockResolvedValueOnce(sale({ lines: [], subtotal_cents: 0, tax_cents: 0, total_cents: 0 })); + svc.completeSale.mockResolvedValue(sale({ status: 'completed' })); + const el = await mount(sale()); + await chooseCash(el); + await typeInto(el, '86.50'); + q(el, 'pos-add-tender').click(); + await el.updateComplete; + q(el, 'pos-clear-sale').click(); + await settle(); + await el.updateComplete; + // a new line lands on the emptied sale + (el as unknown as { _sale: Sale })._sale = sale({ + lines: [line('n', 5000)] as unknown as Sale['lines'], subtotal_cents: 5000, tax_cents: 0, total_cents: 5000, + }); + await el.updateComplete; + expect(pressed(el)).toEqual([]); + expect((q(el, 'pos-pay') as HTMLButtonElement).disabled).toBe(true); + q(el, 'pos-pay').click(); + await settle(); + expect(svc.completeSale).not.toHaveBeenCalled(); + confirm.mockRestore(); + el.remove(); + }); +}); From 11de8704e677c438c49b5f94944448bf48f64d52 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:57:58 -0700 Subject: [PATCH 08/10] test(counter): a changed customer clears the chosen and added tenders (red) Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/pos/POSTerminal.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 290ad72ed..0feabc4fd 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -1926,3 +1926,39 @@ describe('the resets after Clear tenders and Clear sale', () => { el.remove(); }); }); + +describe('a changed customer starts the tender over', () => { + beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); + + type Inner = { + _customer: CounterCustomer | null; _tenderMethod: string | null; _pendingTenders: unknown[]; + _chooseCustomer(c: CounterCustomer): Promise; _clearCustomer(): Promise; + }; + const inner = (el: POSTerminal) => el as unknown as Inner; + const withTender = async () => { + const el = await mount(sale({ lines: [], customer_id: 'cu1' })); + inner(el)._customer = account; + inner(el)._tenderMethod = 'account'; + inner(el)._pendingTenders = [{ method: 'cash', amount_cents: 1000 }]; + await el.updateComplete; + return el; + }; + + it('clears the chosen and the added tenders when another customer is named', async () => { + const el = await withTender(); + svc.attachCustomer.mockResolvedValue(sale({ lines: [], customer_id: 'cu2' })); + await inner(el)._chooseCustomer({ ...account, id: 'cu2', name: 'Other Builders' } as CounterCustomer); + expect(inner(el)._tenderMethod).toBeNull(); + expect(inner(el)._pendingTenders).toEqual([]); + el.remove(); + }); + + it('clears them when the sale goes back to a walk in', async () => { + const el = await withTender(); + svc.attachCustomer.mockResolvedValue(sale({ lines: [], customer_id: null })); + await inner(el)._clearCustomer(); + expect(inner(el)._tenderMethod).toBeNull(); + expect(inner(el)._pendingTenders).toEqual([]); + el.remove(); + }); +}); From 7346698e93463bebd8bfeacf836928bb568357f9 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:58:16 -0700 Subject: [PATCH 09/10] fix(counter): Clear sale, a changed customer and no till reset the tender Clear sale drops the tenders added for the cleared lines as well as the chosen method. Naming another customer or going back to a walk in sale clears the chosen and added tenders, so an account tender chosen for one customer never carries to another. A tender cannot be pressed while no till is open. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/pos/POSTerminal.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 22db183fc..b77f9bd6b 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -335,6 +335,7 @@ export class POSTerminal extends LitElement { this._error = null; this._sale = await posService.attachCustomer(this._sale.id, { customer_id: c.id }); this._customer = c; + this._resetTender(); this._custQuery = ''; this._custResults = []; this._poText = ''; @@ -377,6 +378,7 @@ export class POSTerminal extends LitElement { this._error = null; this._sale = await posService.attachCustomer(this._sale.id, { customer_id: null }); this._customer = null; + this._resetTender(); this._poText = ''; this._jobId = ''; this._step = 'customer'; @@ -668,6 +670,8 @@ export class POSTerminal extends LitElement { } this._tenderMethod = null; this._tenderAmount = ''; + // a tender added for the cleared lines is not the next lines' tender + this._pendingTenders = []; } private async _removeItem(itemId: string) { @@ -703,7 +707,15 @@ export class POSTerminal extends LitElement { return { method: this._tenderMethod, amount_cents: cents }; } + /** Nothing chosen and nothing added: the next tender is chosen again. */ + private _resetTender() { + this._tenderMethod = null; + this._tenderAmount = ''; + this._pendingTenders = []; + } + private _chooseMethod(method: TenderMethod) { + if (this._till === null) return; if (method === 'account') { const refusal = this._accountRefusal; if (refusal) { @@ -1135,7 +1147,7 @@ export class POSTerminal extends LitElement { const chosen = this._tenderMethod === m && this._till !== null; return html` ${this._pendingTenders.length > 0 ? html` - ` : nothing}