feat(POS-003): completed feature

This commit is contained in:
chattie
2026-08-22 13:19:27 +02:00
parent 926add3c97
commit 7ce6465054
26 changed files with 880 additions and 19 deletions

View File

@@ -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<FastifyInstance
});
});
// POS module repositories (wired here so POS-004+ can use them)
const posStoreRepo = deps.pool ? new PgStoreRepository(deps.pool) : null;
const posTerminalRepo = deps.pool ? new PgTerminalRepository(deps.pool) : null;
const posPaymentMethodRepo = deps.pool ? new PgPaymentMethodRepository(deps.pool) : null;
const posSessionRepo = deps.pool ? new PgCashSessionRepository(deps.pool) : null;
await app.register(async (instance) => {
await registerOrdersRoutes(instance, {
pool: deps.pool as pg.Pool,

View File

@@ -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<PosCashSession> {
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);
}
}

View File

@@ -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<PosConfig> {
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 };
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<PosCashSession> {
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);
}
}

View File

@@ -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;
}

View File

@@ -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`);
}
}

View File

@@ -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<PosStore | undefined>;
list(options?: ListStoresOptions): Promise<{ stores: PosStore[]; total: number }>;
}
export interface PosTerminalRepository {
findById(id: string): Promise<PosTerminal | undefined>;
findByBindingCode(code: string): Promise<PosTerminal | undefined>;
list(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }>;
updateLastSeen(id: string): Promise<void>;
bind(id: string, bindingCode: string): Promise<PosTerminal>;
}
export interface PosCashSessionRepository {
findById(id: string): Promise<PosCashSession | undefined>;
findOpenByTerminal(terminalId: string): Promise<PosCashSession | undefined>;
open(input: OpenCashSessionInput): Promise<PosCashSession>;
close(input: CloseCashSessionInput): Promise<PosCashSession>;
listByStore(storeId: string, limit?: number, offset?: number): Promise<{ sessions: PosCashSession[]; total: number }>;
}

View File

@@ -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<string, unknown>;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface ListStoresOptions {
active?: boolean;
limit?: number;
offset?: number;
}

View File

@@ -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<string, unknown>;
lastSeenAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface ListTerminalsOptions {
storeId?: string;
status?: TerminalStatus;
limit?: number;
offset?: number;
}

View File

@@ -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<PosCashSession | undefined> {
const result = await this.pool.query<SessionRow>(
'SELECT * FROM pos_cash_sessions WHERE id = $1',
[id],
);
return result.rows[0] ? toSession(result.rows[0]) : undefined;
}
async findOpenByTerminal(terminalId: string): Promise<PosCashSession | undefined> {
const result = await this.pool.query<SessionRow>(
"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<PosCashSession> {
// 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<SessionRow>(
`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<PosCashSession> {
const difference = input.actualCashCents - input.closingCashCents;
const result = await this.pool.query<SessionRow>(
`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<SessionRow>(
`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),
};
}
}

View File

@@ -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<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
interface PaymentMethodRow {
id: string;
store_id: string;
code: string;
label: string;
kind: PaymentMethodKind;
active: boolean;
sort_order: number;
config: Record<string, unknown>;
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<PosPaymentMethod[]> {
const result = await this.pool.query<PaymentMethodRow>(
'SELECT * FROM pos_payment_methods WHERE store_id = $1 AND active = true ORDER BY sort_order',
[storeId],
);
return result.rows.map(toPaymentMethod);
}
}

View File

@@ -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<string, unknown>;
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<PosStore | undefined> {
const result = await this.pool.query<StoreRow>(
'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<StoreRow>(
`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),
};
}
}

View File

@@ -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<string, unknown>;
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<PosTerminal | undefined> {
const result = await this.pool.query<TerminalRow>(
'SELECT * FROM pos_terminals WHERE id = $1',
[id],
);
return result.rows[0] ? toTerminal(result.rows[0]) : undefined;
}
async findByBindingCode(code: string): Promise<PosTerminal | undefined> {
const result = await this.pool.query<TerminalRow>(
'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<TerminalRow>(
`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<void> {
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<PosTerminal> {
const result = await this.pool.query<TerminalRow>(
`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]);
}
}

View File

@@ -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();
});
});

View File

@@ -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 });
});
});