Skip to content

Commit 7fecfd8

Browse files
authored
feat(checks): require payment method on settlement (#26) (#32)
1 parent 10d5183 commit 7fecfd8

19 files changed

Lines changed: 156 additions & 32 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE checks ADD COLUMN payment_method TEXT;
2+
ALTER TABLE checks ADD COLUMN note TEXT;

backend/src/__tests__/checks.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,24 +173,32 @@ describe('check patch (discount/adjustments)', () => {
173173
});
174174

175175
describe('check settle / void', () => {
176-
it('settle closes the session and marks the check settled', async () => {
176+
it('settle requires and stores payment metadata, then closes the session', async () => {
177177
const db = checksDb();
178178
const sessionId = openSession(db);
179179
seedOrder(db, sessionId, 'Bruschetta', 750, 1);
180180
const id = ((await (await createCheck(db, sessionId)).json()) as { id: string }).id;
181-
const res = await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', headers: await adminHeaders(), env: adminEnv(db) });
181+
182+
const missing = await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', headers: await adminHeaders(), env: adminEnv(db) });
183+
expect(missing.status).toBe(400);
184+
185+
const res = await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', body: { paymentMethod: 'card', note: 'Visa ending 42' }, headers: await adminHeaders(), env: adminEnv(db) });
182186
expect(res.status).toBe(200);
183-
expect((await res.json() as { status: string; settledAt: number | null }).status).toBe('settled');
187+
const body = await res.json() as { status: string; settledAt: number | null; paymentMethod: string; note: string | null };
188+
expect(body.status).toBe('settled');
189+
expect(body.paymentMethod).toBe('card');
190+
expect(body.note).toBe('Visa ending 42');
184191
expect((db.raw.prepare('SELECT closed_at FROM table_sessions WHERE id = ?').get(sessionId) as { closed_at: number | null }).closed_at).not.toBeNull();
192+
expect((db.raw.prepare('SELECT payment_method, note FROM checks WHERE id = ?').get(id) as { payment_method: string; note: string })).toEqual({ payment_method: 'card', note: 'Visa ending 42' });
185193
});
186194

187195
it('refuses settling a non-open check (409)', async () => {
188196
const db = checksDb();
189197
const sessionId = openSession(db);
190198
seedOrder(db, sessionId, 'Bruschetta', 750, 1);
191199
const id = ((await (await createCheck(db, sessionId)).json()) as { id: string }).id;
192-
await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', headers: await adminHeaders(), env: adminEnv(db) });
193-
const again = await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', headers: await adminHeaders(), env: adminEnv(db) });
200+
await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', body: { paymentMethod: 'cash' }, headers: await adminHeaders(), env: adminEnv(db) });
201+
const again = await testRequest(`/admin/checks/${id}/settle`, { method: 'POST', body: { paymentMethod: 'cash' }, headers: await adminHeaders(), env: adminEnv(db) });
194202
expect(again.status).toBe(409);
195203
});
196204

backend/src/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,8 @@ export const checks = sqliteTable(
466466
adjustments: jsonColumn<{ label: string; amount: number }[]>('adjustments').notNull().default([]),
467467
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
468468
settledAt: integer('settled_at'),
469+
paymentMethod: text('payment_method'),
470+
note: text('note'),
469471
voidedAt: integer('voided_at'),
470472
},
471473
(table) => ({

backend/src/routes/admin-checks.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { requireAdmin } from '../middleware/admin-guard';
55
import { requireDb } from '../middleware/db';
66
import { parseBody } from '../lib/validate';
77
import { z } from 'zod';
8-
import { UpdateCheckBodySchema, computeCheckTotals, type CheckDTO, type CheckLine } from '@menu/schemas';
8+
import { UpdateCheckBodySchema, SettleCheckBodySchema, computeCheckTotals, type CheckDTO, type CheckLine } from '@menu/schemas';
99
import * as schema from '../db/schema';
1010
import type { AppBindings } from '../types';
1111
import type { DbInstance } from '../db';
@@ -41,6 +41,8 @@ function toCheckDTO(row: CheckRow): CheckDTO {
4141
total,
4242
createdAt: row.createdAt,
4343
settledAt: row.settledAt,
44+
paymentMethod: row.paymentMethod as CheckDTO['paymentMethod'],
45+
note: row.note,
4446
voidedAt: row.voidedAt,
4547
};
4648
}
@@ -262,9 +264,11 @@ admin.post('/checks/:id/settle', ...base, async (c) => {
262264
if (!row) return c.json({ error: 'Not Found' }, 404);
263265
if (row.status !== 'open') return c.json({ error: 'not_open' }, 409);
264266

267+
const body = await parseBody(c, SettleCheckBodySchema);
268+
if (body instanceof Response) return body;
265269
const now = Date.now();
266270
await db.batch([
267-
db.update(schema.checks).set({ status: 'settled', settledAt: now }).where(eq(schema.checks.id, id)),
271+
db.update(schema.checks).set({ status: 'settled', settledAt: now, paymentMethod: body.paymentMethod, note: body.note ?? null }).where(eq(schema.checks.id, id)),
268272
db.update(schema.tableSessions).set({ closedAt: now }).where(eq(schema.tableSessions.id, row.tableSessionId)),
269273
]);
270274
const [updated] = await db.select().from(schema.checks).where(eq(schema.checks.id, id)).limit(1);

packages/schemas/src/checks.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { StaffOrder, TableShape } from './staff.js';
99
// the client and server agree.
1010

1111
export type CheckStatus = 'open' | 'settled' | 'voided';
12+
export type PaymentMethod = 'cash' | 'card' | 'other';
1213

1314
export interface CheckLine {
1415
name: string;
@@ -39,6 +40,8 @@ export interface CheckDTO {
3940
total: number;
4041
createdAt: number;
4142
settledAt: number | null;
43+
paymentMethod: PaymentMethod | null;
44+
note: string | null;
4245
voidedAt: number | null;
4346
}
4447

@@ -81,6 +84,12 @@ export const UpdateCheckBodySchema = z.object({
8184
});
8285
export type UpdateCheckBody = z.infer<typeof UpdateCheckBodySchema>;
8386

87+
export const SettleCheckBodySchema = z.object({
88+
paymentMethod: z.enum(['cash', 'card', 'other']),
89+
note: z.string().trim().max(120).optional(),
90+
});
91+
export type SettleCheckBody = z.infer<typeof SettleCheckBodySchema>;
92+
8493
// ── Admin table detail (#15 follow-up) ──────────────────────────────
8594

8695
export interface AdminTableSessionOrders {

web/e2e/admin/admin-checks-real.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,15 @@ test.describe.serial("Checks (conto) — real backend", () => {
7474

7575
// Settle (confirm) → session closes, table free, settled in history.
7676
await page.getByTestId("settle").click();
77+
await expect(page.getByTestId("settle-confirm")).toBeDisabled();
78+
await page.getByTestId("payment-method-card").click();
7779
const settleRes = page.waitForResponse((res) => res.url().includes("/settle") && res.request().method() === "POST" && res.status() < 300);
7880
await page.getByTestId("settle-confirm").click();
7981
await settleRes;
8082

8183
await expect(page.getByTestId("table-free")).toBeVisible();
8284
await expect(page.getByText(/Pagato|Paid/).first()).toBeVisible();
85+
await expect(page.getByText(/Carta|Card/).first()).toBeVisible();
8386
});
8487
test("admin adds searchable items from the table page", async ({ page, request }) => {
8588
await seedSessionWithOrder(request);

web/messages/de.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,12 @@
662662
"tableDetail.noAvailableItems": "Keine verfügbaren Artikel gefunden.",
663663
"tableDetail.submitItems": "Artikel hinzufügen",
664664
"tableDetail.checkOpenAddItemsBlocked": "Begleiche oder storniere die offene Rechnung, bevor du weitere Artikel hinzufügst.",
665-
"tables.checkOpen": "Rechnung"
665+
"tables.checkOpen": "Rechnung",
666+
"tableDetail.settlementTitle": "Zahlung",
667+
"tableDetail.paymentMethod.cash": "Bar",
668+
"tableDetail.paymentMethod.card": "Karte",
669+
"tableDetail.paymentMethod.other": "Andere",
670+
"tableDetail.paymentNote": "Zahlungsnotiz (optional)"
666671
},
667672
"outOfStock": "AUSVERKAUFT",
668673
"staff": {

web/messages/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,12 @@
662662
"tableDetail.noAvailableItems": "No available items found.",
663663
"tableDetail.submitItems": "Add items",
664664
"tableDetail.checkOpenAddItemsBlocked": "Settle or void the open check before adding more items.",
665-
"tables.checkOpen": "Check"
665+
"tables.checkOpen": "Check",
666+
"tableDetail.settlementTitle": "Payment",
667+
"tableDetail.paymentMethod.cash": "Cash",
668+
"tableDetail.paymentMethod.card": "Card",
669+
"tableDetail.paymentMethod.other": "Other",
670+
"tableDetail.paymentNote": "Payment note (optional)"
666671
},
667672
"outOfStock": "OUT OF STOCK",
668673
"staff": {

web/messages/es.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,12 @@
662662
"tableDetail.noAvailableItems": "No se encontraron artículos disponibles.",
663663
"tableDetail.submitItems": "Añadir artículos",
664664
"tableDetail.checkOpenAddItemsBlocked": "Cobra o anula la cuenta abierta antes de añadir más artículos.",
665-
"tables.checkOpen": "Cuenta"
665+
"tables.checkOpen": "Cuenta",
666+
"tableDetail.settlementTitle": "Pago",
667+
"tableDetail.paymentMethod.cash": "Efectivo",
668+
"tableDetail.paymentMethod.card": "Tarjeta",
669+
"tableDetail.paymentMethod.other": "Otro",
670+
"tableDetail.paymentNote": "Nota de pago (opcional)"
666671
},
667672
"outOfStock": "AGOTADO",
668673
"staff": {

web/messages/fr.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,12 @@
662662
"tableDetail.noAvailableItems": "Aucun article disponible trouvé.",
663663
"tableDetail.submitItems": "Ajouter des articles",
664664
"tableDetail.checkOpenAddItemsBlocked": "Encaissez ou annulez l’addition ouverte avant d’ajouter d’autres articles.",
665-
"tables.checkOpen": "Note"
665+
"tables.checkOpen": "Note",
666+
"tableDetail.settlementTitle": "Paiement",
667+
"tableDetail.paymentMethod.cash": "Espèces",
668+
"tableDetail.paymentMethod.card": "Carte",
669+
"tableDetail.paymentMethod.other": "Autre",
670+
"tableDetail.paymentNote": "Note de paiement (facultatif)"
666671
},
667672
"outOfStock": "ÉPUISÉ",
668673
"staff": {

0 commit comments

Comments
 (0)