feat(POS-003): completed feature
This commit is contained in:
@@ -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,
|
||||
|
||||
22
project/src/modules/pos/application/close-cash-session.ts
Normal file
22
project/src/modules/pos/application/close-cash-session.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
project/src/modules/pos/application/get-pos-config.ts
Normal file
36
project/src/modules/pos/application/get-pos-config.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
project/src/modules/pos/application/list-stores.ts
Normal file
10
project/src/modules/pos/application/list-stores.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
project/src/modules/pos/application/list-terminals.ts
Normal file
10
project/src/modules/pos/application/list-terminals.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
project/src/modules/pos/application/open-cash-session.ts
Normal file
28
project/src/modules/pos/application/open-cash-session.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
project/src/modules/pos/domain/cash-session.ts
Normal file
32
project/src/modules/pos/domain/cash-session.ts
Normal 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;
|
||||
}
|
||||
51
project/src/modules/pos/domain/errors.ts
Normal file
51
project/src/modules/pos/domain/errors.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
24
project/src/modules/pos/domain/ports.ts
Normal file
24
project/src/modules/pos/domain/ports.ts
Normal 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 }>;
|
||||
}
|
||||
21
project/src/modules/pos/domain/store.ts
Normal file
21
project/src/modules/pos/domain/store.ts
Normal 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;
|
||||
}
|
||||
23
project/src/modules/pos/domain/terminal.ts
Normal file
23
project/src/modules/pos/domain/terminal.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
103
project/src/modules/pos/tests/cash-session.test.ts
Normal file
103
project/src/modules/pos/tests/cash-session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
42
project/src/modules/pos/tests/store-repository.test.ts
Normal file
42
project/src/modules/pos/tests/store-repository.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user