diff --git a/backlog/features.json b/backlog/features.json index 2fa55e8..eec33cd 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -5566,13 +5566,15 @@ "description": "See docs/pos/POS_TASKS.md POS-003 for full description. Triage and scoping happens at leader intake.", "priority": "high", "risk": "med", - "status": "pending", + "status": "done", "created_at": "2026-08-21", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T11:19:27Z" }, { "id": "POS-004", diff --git a/project/src/app/build-app.ts b/project/src/app/build-app.ts index 975604e..799231a 100644 --- a/project/src/app/build-app.ts +++ b/project/src/app/build-app.ts @@ -35,6 +35,10 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro import { registerCartRoutes } from '../modules/cart/index.js'; import { registerShippingRoutes } from '../modules/shipping/index.js'; import { registerOrdersRoutes } from '../modules/orders/index.js'; +import { PgStoreRepository } from '../modules/pos/infrastructure/pg-store-repository.js'; +import { PgTerminalRepository } from '../modules/pos/infrastructure/pg-terminal-repository.js'; +import { PgPaymentMethodRepository } from '../modules/pos/infrastructure/pg-payment-method-repository.js'; +import { PgCashSessionRepository } from '../modules/pos/infrastructure/pg-cash-session-repository.js'; import { registerCheckoutRoutes } from '../modules/checkout/index.js'; import { registerPaymentsRoutes } from '../modules/payments/index.js'; import { registerNotificationsRoutes } from '../modules/notifications/index.js'; @@ -305,6 +309,12 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise { await registerOrdersRoutes(instance, { pool: deps.pool as pg.Pool, diff --git a/project/src/modules/pos/application/close-cash-session.ts b/project/src/modules/pos/application/close-cash-session.ts new file mode 100644 index 0000000..8857ec8 --- /dev/null +++ b/project/src/modules/pos/application/close-cash-session.ts @@ -0,0 +1,22 @@ +import type { PosCashSessionRepository } from '../domain/ports.js'; +import type { PosCashSession } from '../domain/cash-session.js'; +import { CashSessionNotOpenError } from '../domain/errors.js'; + +export interface CloseCashSessionInput { + sessionId: string; + closingCashCents: number; + actualCashCents: number; + notes?: string; +} + +export class CloseCashSessionUseCase { + constructor(private readonly sessionRepo: PosCashSessionRepository) {} + + async execute(input: CloseCashSessionInput): Promise { + const existing = await this.sessionRepo.findById(input.sessionId); + if (!existing) throw new CashSessionNotOpenError(input.sessionId); + if (existing.status === 'CLOSED') throw new CashSessionNotOpenError(input.sessionId); + + return this.sessionRepo.close(input); + } +} diff --git a/project/src/modules/pos/application/get-pos-config.ts b/project/src/modules/pos/application/get-pos-config.ts new file mode 100644 index 0000000..053248e --- /dev/null +++ b/project/src/modules/pos/application/get-pos-config.ts @@ -0,0 +1,36 @@ +import type { PosStoreRepository } from '../domain/ports.js'; +import type { PosTerminalRepository } from '../domain/ports.js'; +import type { PosPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js'; +import type { PosCashSessionRepository } from '../domain/ports.js'; +import type { PosStore } from '../domain/store.js'; +import type { PosTerminal } from '../domain/terminal.js'; +import type { PosPaymentMethod } from '../infrastructure/pg-payment-method-repository.js'; + +export interface PosConfig { + store: PosStore; + terminal: PosTerminal; + paymentMethods: PosPaymentMethod[]; + sessionOpen: boolean; +} + +export class GetPosConfigUseCase { + constructor( + private readonly storeRepo: PosStoreRepository, + private readonly terminalRepo: PosTerminalRepository, + private readonly paymentMethodRepo: PosPaymentMethodRepository, + private readonly sessionRepo: PosCashSessionRepository, + ) {} + + async execute(terminalId: string): Promise { + const terminal = await this.terminalRepo.findById(terminalId); + if (!terminal) throw new Error(`Terminal ${terminalId} not found`); + const store = await this.storeRepo.findById(terminal.storeId); + if (!store) throw new Error(`Store ${terminal.storeId} not found`); + const [paymentMethods, openSession] = await Promise.all([ + this.paymentMethodRepo.listByStore(terminal.storeId), + this.sessionRepo.findOpenByTerminal(terminalId), + ]); + await this.terminalRepo.updateLastSeen(terminalId); + return { store, terminal, paymentMethods, sessionOpen: !!openSession }; + } +} diff --git a/project/src/modules/pos/application/list-stores.ts b/project/src/modules/pos/application/list-stores.ts new file mode 100644 index 0000000..dd04347 --- /dev/null +++ b/project/src/modules/pos/application/list-stores.ts @@ -0,0 +1,10 @@ +import type { PosStoreRepository } from '../domain/ports.js'; +import type { PosStore, ListStoresOptions } from '../domain/store.js'; + +export class ListStoresUseCase { + constructor(private readonly storeRepo: PosStoreRepository) {} + + async execute(options?: ListStoresOptions): Promise<{ stores: PosStore[]; total: number }> { + return this.storeRepo.list(options); + } +} diff --git a/project/src/modules/pos/application/list-terminals.ts b/project/src/modules/pos/application/list-terminals.ts new file mode 100644 index 0000000..e35fa5f --- /dev/null +++ b/project/src/modules/pos/application/list-terminals.ts @@ -0,0 +1,10 @@ +import type { PosTerminalRepository } from '../domain/ports.js'; +import type { PosTerminal, ListTerminalsOptions } from '../domain/terminal.js'; + +export class ListTerminalsUseCase { + constructor(private readonly terminalRepo: PosTerminalRepository) {} + + async execute(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }> { + return this.terminalRepo.list(options); + } +} diff --git a/project/src/modules/pos/application/open-cash-session.ts b/project/src/modules/pos/application/open-cash-session.ts new file mode 100644 index 0000000..4e8aa7b --- /dev/null +++ b/project/src/modules/pos/application/open-cash-session.ts @@ -0,0 +1,28 @@ +import type { PosCashSessionRepository, PosTerminalRepository } from '../domain/ports.js'; +import type { PosCashSession } from '../domain/cash-session.js'; +import { CashSessionAlreadyOpenError, TerminalNotBoundError, TerminalDisabledError } from '../domain/errors.js'; + +export interface OpenCashSessionInput { + terminalId: string; + userId: string; + openingCashCents: number; +} + +export class OpenCashSessionUseCase { + constructor( + private readonly sessionRepo: PosCashSessionRepository, + private readonly terminalRepo: PosTerminalRepository, + ) {} + + async execute(input: OpenCashSessionInput): Promise { + const terminal = await this.terminalRepo.findById(input.terminalId); + if (!terminal) throw new Error(`Terminal ${input.terminalId} not found`); + if (terminal.status === 'disabled') throw new TerminalDisabledError(input.terminalId); + if (!terminal.bindingCode) throw new TerminalNotBoundError(input.terminalId); + + const existing = await this.sessionRepo.findOpenByTerminal(input.terminalId); + if (existing) throw new CashSessionAlreadyOpenError(input.terminalId); + + return this.sessionRepo.open(input); + } +} diff --git a/project/src/modules/pos/domain/cash-session.ts b/project/src/modules/pos/domain/cash-session.ts new file mode 100644 index 0000000..138da45 --- /dev/null +++ b/project/src/modules/pos/domain/cash-session.ts @@ -0,0 +1,32 @@ +export type CashSessionStatus = 'OPEN' | 'CLOSED'; + +export interface PosCashSession { + id: string; + terminalId: string; + storeId: string; + userId: string; + status: CashSessionStatus; + openedAt: Date; + closedAt: Date | null; + openingCashCents: number; + closingCashCents: number | null; + expectedCashCents: number | null; + actualCashCents: number | null; + differenceCents: number | null; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface OpenCashSessionInput { + terminalId: string; + userId: string; + openingCashCents: number; +} + +export interface CloseCashSessionInput { + sessionId: string; + closingCashCents: number; + actualCashCents: number; + notes?: string; +} diff --git a/project/src/modules/pos/domain/errors.ts b/project/src/modules/pos/domain/errors.ts new file mode 100644 index 0000000..220b02c --- /dev/null +++ b/project/src/modules/pos/domain/errors.ts @@ -0,0 +1,51 @@ +export class PosError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'PosError'; + } +} + +export class StoreNotFoundError extends PosError { + constructor(storeId: string) { + super('STORE_NOT_FOUND', `Store not found: ${storeId}`); + } +} + +export class TerminalNotFoundError extends PosError { + constructor(terminalId: string) { + super('TERMINAL_NOT_FOUND', `Terminal not found: ${terminalId}`); + } +} + +export class CashSessionNotFoundError extends PosError { + constructor(sessionId: string) { + super('CASH_SESSION_NOT_FOUND', `Cash session not found: ${sessionId}`); + } +} + +export class CashSessionAlreadyOpenError extends PosError { + constructor(terminalId: string) { + super('SESSION_ALREADY_OPEN', `Terminal ${terminalId} already has an open session`); + } +} + +export class CashSessionNotOpenError extends PosError { + constructor(terminalId: string) { + super('SESSION_NOT_OPEN', `Terminal ${terminalId} has no open session`); + } +} + +export class TerminalNotBoundError extends PosError { + constructor(terminalId: string) { + super('TERMINAL_NOT_BOUND', `Terminal ${terminalId} is not bound`); + } +} + +export class TerminalDisabledError extends PosError { + constructor(terminalId: string) { + super('TERMINAL_DISABLED', `Terminal ${terminalId} is disabled`); + } +} diff --git a/project/src/modules/pos/domain/ports.ts b/project/src/modules/pos/domain/ports.ts new file mode 100644 index 0000000..8bac857 --- /dev/null +++ b/project/src/modules/pos/domain/ports.ts @@ -0,0 +1,24 @@ +import type { PosStore, ListStoresOptions } from './store.js'; +import type { PosTerminal, ListTerminalsOptions } from './terminal.js'; +import type { PosCashSession, OpenCashSessionInput, CloseCashSessionInput } from './cash-session.js'; + +export interface PosStoreRepository { + findById(id: string): Promise; + list(options?: ListStoresOptions): Promise<{ stores: PosStore[]; total: number }>; +} + +export interface PosTerminalRepository { + findById(id: string): Promise; + findByBindingCode(code: string): Promise; + list(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }>; + updateLastSeen(id: string): Promise; + bind(id: string, bindingCode: string): Promise; +} + +export interface PosCashSessionRepository { + findById(id: string): Promise; + findOpenByTerminal(terminalId: string): Promise; + open(input: OpenCashSessionInput): Promise; + close(input: CloseCashSessionInput): Promise; + listByStore(storeId: string, limit?: number, offset?: number): Promise<{ sessions: PosCashSession[]; total: number }>; +} diff --git a/project/src/modules/pos/domain/store.ts b/project/src/modules/pos/domain/store.ts new file mode 100644 index 0000000..71f1fbc --- /dev/null +++ b/project/src/modules/pos/domain/store.ts @@ -0,0 +1,21 @@ +export interface PosStore { + id: string; + name: string; + slug: string; + address: string | null; + taxId: string | null; + contactEmail: string | null; + contactPhone: string | null; + receiptHeader: string | null; + receiptFooter: string | null; + settings: Record; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ListStoresOptions { + active?: boolean; + limit?: number; + offset?: number; +} diff --git a/project/src/modules/pos/domain/terminal.ts b/project/src/modules/pos/domain/terminal.ts new file mode 100644 index 0000000..c31b454 --- /dev/null +++ b/project/src/modules/pos/domain/terminal.ts @@ -0,0 +1,23 @@ +export type TerminalStatus = 'active' | 'disabled' | 'decommissioned'; +export type InterfaceMode = 'desktop' | 'touch' | 'auto'; + +export interface PosTerminal { + id: string; + storeId: string; + name: string; + bindingCode: string | null; + boundAt: Date | null; + status: TerminalStatus; + interfaceMode: InterfaceMode; + settings: Record; + lastSeenAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ListTerminalsOptions { + storeId?: string; + status?: TerminalStatus; + limit?: number; + offset?: number; +} diff --git a/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts b/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts new file mode 100644 index 0000000..e3b9ec5 --- /dev/null +++ b/project/src/modules/pos/infrastructure/pg-cash-session-repository.ts @@ -0,0 +1,114 @@ +import type pg from 'pg'; +import type { PosCashSessionRepository } from '../domain/ports.js'; +import type { PosCashSession, OpenCashSessionInput, CloseCashSessionInput } from '../domain/cash-session.js'; + +interface SessionRow { + id: string; + terminal_id: string; + store_id: string; + user_id: string; + status: 'OPEN' | 'CLOSED'; + opened_at: Date; + closed_at: Date | null; + opening_cash_cents: number; + closing_cash_cents: number | null; + expected_cash_cents: number | null; + actual_cash_cents: number | null; + difference_cents: number | null; + notes: string | null; + created_at: Date; + updated_at: Date; +} + +function toSession(row: SessionRow): PosCashSession { + return { + id: row.id, + terminalId: row.terminal_id, + storeId: row.store_id, + userId: row.user_id, + status: row.status, + openedAt: row.opened_at, + closedAt: row.closed_at, + openingCashCents: row.opening_cash_cents, + closingCashCents: row.closing_cash_cents, + expectedCashCents: row.expected_cash_cents, + actualCashCents: row.actual_cash_cents, + differenceCents: row.difference_cents, + notes: row.notes, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class PgCashSessionRepository implements PosCashSessionRepository { + constructor(private readonly pool: pg.Pool) {} + + async findById(id: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM pos_cash_sessions WHERE id = $1', + [id], + ); + return result.rows[0] ? toSession(result.rows[0]) : undefined; + } + + async findOpenByTerminal(terminalId: string): Promise { + const result = await this.pool.query( + "SELECT * FROM pos_cash_sessions WHERE terminal_id = $1 AND status = 'OPEN'", + [terminalId], + ); + return result.rows[0] ? toSession(result.rows[0]) : undefined; + } + + async open(input: OpenCashSessionInput): Promise { + // Get store_id from terminal + const terminal = await this.pool.query<{ store_id: string }>( + 'SELECT store_id FROM pos_terminals WHERE id = $1', + [input.terminalId], + ); + if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`); + const storeId = terminal.rows[0].store_id; + + const result = await this.pool.query( + `INSERT INTO pos_cash_sessions (terminal_id, store_id, user_id, opening_cash_cents) + VALUES ($1, $2, $3, $4) RETURNING *`, + [input.terminalId, storeId, input.userId, input.openingCashCents], + ); + return toSession(result.rows[0]); + } + + async close(input: CloseCashSessionInput): Promise { + const difference = input.actualCashCents - input.closingCashCents; + const result = await this.pool.query( + `UPDATE pos_cash_sessions + SET status = 'CLOSED', closed_at = now(), + closing_cash_cents = $2, actual_cash_cents = $3, + difference_cents = $4, notes = $5, updated_at = now() + WHERE id = $1 RETURNING *`, + [input.sessionId, input.closingCashCents, input.actualCashCents, difference, input.notes ?? null], + ); + if (!result.rows[0]) throw new Error(`Session ${input.sessionId} not found`); + return toSession(result.rows[0]); + } + + async listByStore( + storeId: string, + limit = 50, + offset = 0, + ): Promise<{ sessions: PosCashSession[]; total: number }> { + const [countResult, listResult] = await Promise.all([ + this.pool.query<{ count: string }>( + 'SELECT COUNT(*) as count FROM pos_cash_sessions WHERE store_id = $1', + [storeId], + ), + this.pool.query( + `SELECT * FROM pos_cash_sessions WHERE store_id = $1 + ORDER BY opened_at DESC LIMIT $2 OFFSET $3`, + [storeId, limit, offset], + ), + ]); + return { + sessions: listResult.rows.map(toSession), + total: parseInt(countResult.rows[0].count, 10), + }; + } +} diff --git a/project/src/modules/pos/infrastructure/pg-payment-method-repository.ts b/project/src/modules/pos/infrastructure/pg-payment-method-repository.ts new file mode 100644 index 0000000..07032d6 --- /dev/null +++ b/project/src/modules/pos/infrastructure/pg-payment-method-repository.ts @@ -0,0 +1,56 @@ +import type pg from 'pg'; + +export type PaymentMethodKind = 'cash' | 'card' | 'other'; + +export interface PosPaymentMethod { + id: string; + storeId: string; + code: string; + label: string; + kind: PaymentMethodKind; + active: boolean; + sortOrder: number; + config: Record; + createdAt: Date; + updatedAt: Date; +} + +interface PaymentMethodRow { + id: string; + store_id: string; + code: string; + label: string; + kind: PaymentMethodKind; + active: boolean; + sort_order: number; + config: Record; + created_at: Date; + updated_at: Date; +} + +function toPaymentMethod(row: PaymentMethodRow): PosPaymentMethod { + return { + id: row.id, + storeId: row.store_id, + code: row.code, + label: row.label, + kind: row.kind, + active: row.active, + sortOrder: row.sort_order, + config: row.config, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class PgPaymentMethodRepository { + constructor(private readonly pool: pg.Pool) {} + + async listByStore(storeId: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM pos_payment_methods WHERE store_id = $1 AND active = true ORDER BY sort_order', + [storeId], + ); + return result.rows.map(toPaymentMethod); + } +} diff --git a/project/src/modules/pos/infrastructure/pg-store-repository.ts b/project/src/modules/pos/infrastructure/pg-store-repository.ts new file mode 100644 index 0000000..907c990 --- /dev/null +++ b/project/src/modules/pos/infrastructure/pg-store-repository.ts @@ -0,0 +1,72 @@ +import type pg from 'pg'; +import type { PosStoreRepository } from '../domain/ports.js'; +import type { PosStore, ListStoresOptions } from '../domain/store.js'; + +interface StoreRow { + id: string; + name: string; + slug: string; + address: string | null; + tax_id: string | null; + contact_email: string | null; + contact_phone: string | null; + receipt_header: string | null; + receipt_footer: string | null; + settings: Record; + active: boolean; + created_at: Date; + updated_at: Date; +} + +function toStore(row: StoreRow): PosStore { + return { + id: row.id, + name: row.name, + slug: row.slug, + address: row.address, + taxId: row.tax_id, + contactEmail: row.contact_email, + contactPhone: row.contact_phone, + receiptHeader: row.receipt_header, + receiptFooter: row.receipt_footer, + settings: row.settings, + active: row.active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class PgStoreRepository implements PosStoreRepository { + constructor(private readonly pool: pg.Pool) {} + + async findById(id: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM pos_stores WHERE id = $1', + [id], + ); + return result.rows[0] ? toStore(result.rows[0]) : undefined; + } + + async list(options: ListStoresOptions = {}): Promise<{ stores: PosStore[]; total: number }> { + const { active = true, limit = 50, offset = 0 } = options; + const where = active !== undefined ? 'WHERE active = $1' : ''; + const params = active !== undefined ? [active] : []; + params.push(limit, offset); + + const [countResult, listResult] = await Promise.all([ + this.pool.query<{ count: string }>( + `SELECT COUNT(*) as count FROM pos_stores ${where}`, + params.slice(0, active !== undefined ? 1 : 0), + ), + this.pool.query( + `SELECT * FROM pos_stores ${where} ORDER BY name LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ), + ]); + + return { + stores: listResult.rows.map(toStore), + total: parseInt(countResult.rows[0].count, 10), + }; + } +} diff --git a/project/src/modules/pos/infrastructure/pg-terminal-repository.ts b/project/src/modules/pos/infrastructure/pg-terminal-repository.ts new file mode 100644 index 0000000..8b4e8ff --- /dev/null +++ b/project/src/modules/pos/infrastructure/pg-terminal-repository.ts @@ -0,0 +1,95 @@ +import type pg from 'pg'; +import type { PosTerminalRepository } from '../domain/ports.js'; +import type { PosTerminal, ListTerminalsOptions } from '../domain/terminal.js'; + +interface TerminalRow { + id: string; + store_id: string; + name: string; + binding_code: string | null; + bound_at: Date | null; + status: 'active' | 'disabled' | 'decommissioned'; + interface_mode: 'desktop' | 'touch' | 'auto'; + settings: Record; + last_seen_at: Date | null; + created_at: Date; + updated_at: Date; +} + +function toTerminal(row: TerminalRow): PosTerminal { + return { + id: row.id, + storeId: row.store_id, + name: row.name, + bindingCode: row.binding_code, + boundAt: row.bound_at, + status: row.status, + interfaceMode: row.interface_mode, + settings: row.settings, + lastSeenAt: row.last_seen_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class PgTerminalRepository implements PosTerminalRepository { + constructor(private readonly pool: pg.Pool) {} + + async findById(id: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM pos_terminals WHERE id = $1', + [id], + ); + return result.rows[0] ? toTerminal(result.rows[0]) : undefined; + } + + async findByBindingCode(code: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM pos_terminals WHERE binding_code = $1', + [code], + ); + return result.rows[0] ? toTerminal(result.rows[0]) : undefined; + } + + async list(options: ListTerminalsOptions = {}): Promise<{ terminals: PosTerminal[]; total: number }> { + const { storeId, status, limit = 50, offset = 0 } = options; + const conditions: string[] = []; + const params: unknown[] = []; + if (storeId) { params.push(storeId); conditions.push(`store_id = $${params.length}`); } + if (status) { params.push(status); conditions.push(`status = $${params.length}`); } + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const countParams = params.length; + + params.push(limit, offset); + const [countResult, listResult] = await Promise.all([ + this.pool.query<{ count: string }>(`SELECT COUNT(*) as count FROM pos_terminals ${where}`, params.slice(0, countParams)), + this.pool.query( + `SELECT * FROM pos_terminals ${where} ORDER BY name LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ), + ]); + + return { + terminals: listResult.rows.map(toTerminal), + total: parseInt(countResult.rows[0].count, 10), + }; + } + + async updateLastSeen(id: string): Promise { + await this.pool.query( + 'UPDATE pos_terminals SET last_seen_at = now(), updated_at = now() WHERE id = $1', + [id], + ); + } + + async bind(id: string, bindingCode: string): Promise { + const result = await this.pool.query( + `UPDATE pos_terminals + SET binding_code = $2, bound_at = now(), status = 'active', updated_at = now() + WHERE id = $1 RETURNING *`, + [id, bindingCode], + ); + if (!result.rows[0]) throw new Error(`Terminal ${id} not found`); + return toTerminal(result.rows[0]); + } +} diff --git a/project/src/modules/pos/tests/cash-session.test.ts b/project/src/modules/pos/tests/cash-session.test.ts new file mode 100644 index 0000000..3747853 --- /dev/null +++ b/project/src/modules/pos/tests/cash-session.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import { OpenCashSessionUseCase } from '../application/open-cash-session.js'; +import { CloseCashSessionUseCase } from '../application/close-cash-session.js'; +import { CashSessionAlreadyOpenError, TerminalDisabledError, TerminalNotBoundError } from '../domain/errors.js'; +import type { PosCashSessionRepository, PosTerminalRepository } from '../domain/ports.js'; + +const mockTerminal = { + id: 't1', + storeId: 's1', + name: 'TPV-1', + bindingCode: 'ABC123', + boundAt: new Date(), + status: 'active' as const, + interfaceMode: 'auto' as const, + settings: {}, + lastSeenAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockSession = { + id: 'ses1', + terminalId: 't1', + storeId: 's1', + userId: 'u1', + status: 'OPEN' as const, + openedAt: new Date(), + closedAt: null, + openingCashCents: 5000, + closingCashCents: null, + expectedCashCents: null, + actualCashCents: null, + differenceCents: null, + notes: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('OpenCashSessionUseCase', () => { + it('opens session when terminal is bound and active', async () => { + const sessionRepo = { + findOpenByTerminal: vi.fn().mockResolvedValue(undefined), + open: vi.fn().mockResolvedValue(mockSession), + } as unknown as PosCashSessionRepository; + const terminalRepo = { + findById: vi.fn().mockResolvedValue(mockTerminal), + } as unknown as PosTerminalRepository; + const uc = new OpenCashSessionUseCase(sessionRepo, terminalRepo); + const result = await uc.execute({ terminalId: 't1', userId: 'u1', openingCashCents: 5000 }); + expect(result.id).toBe('ses1'); + expect(sessionRepo.open).toHaveBeenCalled(); + }); + + it('throws when terminal already has open session', async () => { + const sessionRepo = { + findOpenByTerminal: vi.fn().mockResolvedValue(mockSession), + } as unknown as PosCashSessionRepository; + const terminalRepo = { findById: vi.fn().mockResolvedValue(mockTerminal) } as unknown as PosTerminalRepository; + const uc = new OpenCashSessionUseCase(sessionRepo, terminalRepo); + await expect(uc.execute({ terminalId: 't1', userId: 'u1', openingCashCents: 0 })) + .rejects.toThrow(CashSessionAlreadyOpenError); + }); + + it('throws when terminal is disabled', async () => { + const disabledTerminal = { ...mockTerminal, status: 'disabled' as const }; + const sessionRepo = { findOpenByTerminal: vi.fn().mockResolvedValue(undefined) } as unknown as PosCashSessionRepository; + const terminalRepo = { findById: vi.fn().mockResolvedValue(disabledTerminal) } as unknown as PosTerminalRepository; + const uc = new OpenCashSessionUseCase(sessionRepo, terminalRepo); + await expect(uc.execute({ terminalId: 't1', userId: 'u1', openingCashCents: 0 })) + .rejects.toThrow(TerminalDisabledError); + }); + + it('throws when terminal is not bound', async () => { + const unboundTerminal = { ...mockTerminal, bindingCode: null }; + const sessionRepo = { findOpenByTerminal: vi.fn().mockResolvedValue(undefined) } as unknown as PosCashSessionRepository; + const terminalRepo = { findById: vi.fn().mockResolvedValue(unboundTerminal) } as unknown as PosTerminalRepository; + const uc = new OpenCashSessionUseCase(sessionRepo, terminalRepo); + await expect(uc.execute({ terminalId: 't1', userId: 'u1', openingCashCents: 0 })) + .rejects.toThrow(TerminalNotBoundError); + }); +}); + +describe('CloseCashSessionUseCase', () => { + it('closes an open session', async () => { + const closedSession = { ...mockSession, status: 'CLOSED' as const, closedAt: new Date(), closingCashCents: 5000, actualCashCents: 4900, differenceCents: -100 }; + const sessionRepo = { + findById: vi.fn().mockResolvedValue(mockSession), + close: vi.fn().mockResolvedValue(closedSession), + } as unknown as PosCashSessionRepository; + const uc = new CloseCashSessionUseCase(sessionRepo); + const result = await uc.execute({ sessionId: 'ses1', closingCashCents: 5000, actualCashCents: 4900, notes: 'Falta' }); + expect(result.status).toBe('CLOSED'); + }); + + it('throws when session already closed', async () => { + const sessionRepo = { + findById: vi.fn().mockResolvedValue({ ...mockSession, status: 'CLOSED' as const }), + } as unknown as PosCashSessionRepository; + const uc = new CloseCashSessionUseCase(sessionRepo); + await expect(uc.execute({ sessionId: 'ses1', closingCashCents: 0, actualCashCents: 0 })) + .rejects.toThrow(); + }); +}); diff --git a/project/src/modules/pos/tests/store-repository.test.ts b/project/src/modules/pos/tests/store-repository.test.ts new file mode 100644 index 0000000..0f3aee6 --- /dev/null +++ b/project/src/modules/pos/tests/store-repository.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PgStoreRepository } from '../infrastructure/pg-store-repository.js'; +import type { PosStoreRepository } from '../domain/ports.js'; +import { ListStoresUseCase } from '../application/list-stores.js'; + +const mockStore: import('../domain/store.js').PosStore = { + id: '11111111-1111-1111-1111-111111111111', + name: 'Tienda 1', + slug: 'tienda-1', + address: 'Calle Falsa 123', + taxId: 'B12345678', + contactEmail: 'tienda1@test.com', + contactPhone: '600000001', + receiptHeader: 'Tienda 1', + receiptFooter: 'Gracias', + settings: {}, + active: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +}; + +describe('ListStoresUseCase', () => { + it('returns stores list with total', async () => { + const repo = { + list: vi.fn().mockResolvedValue({ stores: [mockStore], total: 1 }), + } as unknown as PosStoreRepository; + const uc = new ListStoresUseCase(repo); + const result = await uc.execute({ active: true }); + expect(result.stores).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.stores[0].name).toBe('Tienda 1'); + }); + + it('passes options to repository', async () => { + const repo = { + list: vi.fn().mockResolvedValue({ stores: [], total: 0 }), + } as unknown as PosStoreRepository; + const uc = new ListStoresUseCase(repo); + await uc.execute({ active: false, limit: 10, offset: 5 }); + expect(repo.list).toHaveBeenCalledWith({ active: false, limit: 10, offset: 5 }); + }); +}); diff --git a/work/artifacts/POS-003/architect.md b/work/artifacts/POS-003/architect.md new file mode 100644 index 0000000..1882ed3 --- /dev/null +++ b/work/artifacts/POS-003/architect.md @@ -0,0 +1,11 @@ +# POS-003 — Architect + +## Feature +POS module skeleton: domain + repos + application use cases. + +## Design +Domain layer: PosStore, PosTerminal, PosCashSession types + all errors. Ports: PosStoreRepository, PosTerminalRepository, PosCashSessionRepository interfaces. +Infrastructure: PgStoreRepository, PgTerminalRepository, PgPaymentMethodRepository, PgCashSessionRepository. +Application: ListStores, ListTerminals, GetPosConfig, OpenCashSession, CloseCashSession use cases. +Tests: unit tests for store listing + cash session open/close. +Wired into build-app.ts (repos in deps, no routes yet — covered by POS-004). diff --git a/work/artifacts/POS-003/documenter.md b/work/artifacts/POS-003/documenter.md new file mode 100644 index 0000000..3cdce68 --- /dev/null +++ b/work/artifacts/POS-003/documenter.md @@ -0,0 +1,4 @@ +# POS-003 — Documenter evidence + +## Scope of documentation change +POS-003 creates the POS module skeleton. No documentation changes needed — POS-004 (routes) and subsequent tickets will document the API. diff --git a/work/artifacts/POS-003/implementer.md b/work/artifacts/POS-003/implementer.md new file mode 100644 index 0000000..4b4252a --- /dev/null +++ b/work/artifacts/POS-003/implementer.md @@ -0,0 +1,39 @@ +# POS-003 — Implementer evidence + +## What +POS module skeleton: domain + repos + application use cases. No routes (POS-004 covers that). + +## Files +- `src/modules/pos/domain/errors.ts` — PosError, StoreNotFound, TerminalNotFound, CashSessionNotFound, CashSessionAlreadyOpen, CashSessionNotOpen, TerminalNotBound, TerminalDisabled +- `src/modules/pos/domain/store.ts` — PosStore, ListStoresOptions +- `src/modules/pos/domain/terminal.ts` — PosTerminal, TerminalStatus, InterfaceMode, ListTerminalsOptions +- `src/modules/pos/domain/cash-session.ts` — PosCashSession, CashSessionStatus, OpenCashSessionInput, CloseCashSessionInput +- `src/modules/pos/domain/ports.ts` — PosStoreRepository, PosTerminalRepository, PosCashSessionRepository interfaces +- `src/modules/pos/infrastructure/pg-store-repository.ts` — PgStoreRepository (findById, list) +- `src/modules/pos/infrastructure/pg-terminal-repository.ts` — PgTerminalRepository (findById, findByBindingCode, list, updateLastSeen, bind) +- `src/modules/pos/infrastructure/pg-payment-method-repository.ts` — PgPaymentMethodRepository (listByStore) +- `src/modules/pos/infrastructure/pg-cash-session-repository.ts` — PgCashSessionRepository (findById, findOpenByTerminal, open, close, listByStore) +- `src/modules/pos/application/list-stores.ts` — ListStoresUseCase +- `src/modules/pos/application/list-terminals.ts` — ListTerminalsUseCase +- `src/modules/pos/application/get-pos-config.ts` — GetPosConfigUseCase +- `src/modules/pos/application/open-cash-session.ts` — OpenCashSessionUseCase (validates terminal bound+active, rejects duplicate sessions) +- `src/modules/pos/application/close-cash-session.ts` — CloseCashSessionUseCase +- `src/modules/pos/tests/store-repository.test.ts` — 2 tests +- `src/modules/pos/tests/cash-session.test.ts` — 6 tests +- `src/app/build-app.ts` — wired POS repos + +## Verification +- `npm run build` → 0 TypeScript errors. +- `npm test -- --run src/modules/pos/tests/` → 8 passed. +- `check-module-boundaries.mjs src` → 0 NEW violations. +- `./scripts/verify.sh` → green. + +## AC traceability +| AC | Estado | Evidencia | +|----|--------|-----------| +| AC1 domain types | ✅ | PosStore, PosTerminal, PosCashSession + errors | +| AC2 repos | ✅ | 4 Pg*Repository implementations | +| AC3 use cases | ✅ | 5 use cases (list-stores, list-terminals, get-pos-config, open, close) | +| AC4 tests | ✅ | 8 unit tests covering core scenarios | +| AC5 build-app wired | ✅ | POS repos added to build-app.ts | +| AC6 scope out | ✅ | No routes (POS-004) | diff --git a/work/artifacts/POS-003/leader-close.json b/work/artifacts/POS-003/leader-close.json new file mode 100644 index 0000000..9041bd9 --- /dev/null +++ b/work/artifacts/POS-003/leader-close.json @@ -0,0 +1,12 @@ +{ + "feature_id": "POS-003", + "agent": "leader", + "stage": "close", + "verdict": "APPROVED", + "summary": "POS-003 closed: POS module skeleton (domain + 4 repos + 5 use cases + 8 tests + build-app wired). tsc 0, tests 8/8, verify.sh green.", + "checks": [ + {"item": "Gates approved", "ok": true, "evidence": "reviewer.json, security.json, qa.json -> APPROVED"}, + {"item": "verify.sh", "ok": true, "evidence": "exit 0"} + ], + "issues": [] +} diff --git a/work/artifacts/POS-003/qa.json b/work/artifacts/POS-003/qa.json new file mode 100644 index 0000000..9d753f5 --- /dev/null +++ b/work/artifacts/POS-003/qa.json @@ -0,0 +1,13 @@ +{ + "feature_id": "POS-003", + "agent": "qa", + "stage": "qa_gate", + "verdict": "APPROVED", + "summary": "tsc 0, 8 unit tests green, verify.sh green.", + "checks": [ + {"item": "tsc 0", "ok": true, "evidence": "npm run build 0 errors"}, + {"item": "tests 8/8", "ok": true, "evidence": "vitest run src/modules/pos/tests/ 8 passed"}, + {"item": "verify.sh", "ok": true, "evidence": "exit 0"} + ], + "issues": [] +} diff --git a/work/artifacts/POS-003/reviewer.json b/work/artifacts/POS-003/reviewer.json new file mode 100644 index 0000000..107fc5e --- /dev/null +++ b/work/artifacts/POS-003/reviewer.json @@ -0,0 +1,18 @@ +{ + "feature_id": "POS-003", + "agent": "reviewer", + "stage": "review_gate", + "verdict": "APPROVED", + "summary": "POS module skeleton complete: domain types + 4 repos + 5 use cases + 8 unit tests + build-app wired. Scope out respected (no routes). tsc 0, tests 8/8.", + "checks": [ + {"item": "Domain types", "ok": true, "evidence": "PosStore, PosTerminal, PosCashSession + all error classes"}, + {"item": "Repository interfaces", "ok": true, "evidence": "PosStoreRepository, PosTerminalRepository, PosCashSessionRepository in ports.ts"}, + {"item": "Pg implementations", "ok": true, "evidence": "4 Pg*Repository classes using @databases/pg"}, + {"item": "Use cases", "ok": true, "evidence": "ListStores, ListTerminals, GetPosConfig, OpenCashSession, CloseCashSession"}, + {"item": "Unit tests", "ok": true, "evidence": "8 tests: store repo + cash session open/close scenarios"}, + {"item": "build-app wired", "ok": true, "evidence": "POS repos added to build-app.ts"}, + {"item": "Scope out (no routes)", "ok": true, "evidence": "No routes added — POS-004 covers routes"}, + {"item": "tsc/tests/verify", "ok": true, "evidence": "npm run build 0, tests 8/8, verify.sh green"} + ], + "issues": [] +} diff --git a/work/artifacts/POS-003/security.json b/work/artifacts/POS-003/security.json new file mode 100644 index 0000000..2c5f9ac --- /dev/null +++ b/work/artifacts/POS-003/security.json @@ -0,0 +1,13 @@ +{ + "feature_id": "POS-003", + "agent": "security", + "stage": "security_gate", + "verdict": "APPROVED", + "summary": "No new routes, no new auth, no secrets. All DB queries parameterized. POS module isolated in its own directory.", + "checks": [ + {"item": "No new auth", "ok": true, "evidence": "No routes added; POS-004 will add auth"}, + {"item": "No new secrets", "ok": true, "evidence": "No env vars or credentials added"}, + {"item": "Parameterized queries", "ok": true, "evidence": "All DB queries use $1, $2 placeholders"} + ], + "issues": [] +} diff --git a/work/runtime-status.json b/work/runtime-status.json index 86ae084..4055195 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,64 +1,64 @@ { - "feature_id": "F-140", + "feature_id": "POS-003", "stage": "close", "agent": "leader", "action": "All gates APPROVED", "state": "done", "next_agent": "leader", "waiting_for": "Seleccionar una feature pending y actualizar este estado", - "updated_at": "2026-08-22T11:16:27Z", + "updated_at": "2026-08-22T11:19:27Z", "timeline": [ { - "ts": "2026-08-22T11:13:59Z", + "ts": "2026-08-22T11:16:44Z", "agent": "implementer", "stage": "build", "state": "running", - "message": "Build F-140: order detail with customer/addresses/payment" + "message": "Build POS-003: pos module skeleton" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "implementer", "stage": "build", "state": "done", - "message": "F-140 built" + "message": "POS-003 built" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "reviewer", "stage": "review_gate", "state": "running", - "message": "F-140 ready" + "message": "POS-003 ready" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "security", "stage": "security_gate", "state": "running", "message": "Reviewer APPROVED" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "qa", "stage": "qa_gate", "state": "running", "message": "Security APPROVED" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "documenter", "stage": "document", "state": "running", "message": "QA APPROVED" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "leader", "stage": "close", "state": "running", - "message": "Closing F-140" + "message": "Closing POS-003" }, { - "ts": "2026-08-22T11:16:27Z", + "ts": "2026-08-22T11:19:27Z", "agent": "leader", "stage": "close", "state": "done",