feat(F-189): completed feature

This commit is contained in:
chattie
2026-08-22 23:01:59 +02:00
parent 0e3c488c85
commit 6b93e91ef4
24 changed files with 1535 additions and 63 deletions

View File

@@ -5,6 +5,7 @@ import DiscountPanel from '@/components/DiscountPanel';
import FreeItemModal from '@/components/FreeItemModal';
import PaymentModal from '@/components/PaymentModal';
import ReceiptModal from '@/components/ReceiptModal';
import ReturnModal from '@/components/ReturnModal';
import { posApi } from '@/lib/api-client';
import { generateIdempotencyKey } from '@/lib/idempotency';
import { formatPrice } from '@/lib/money';
@@ -112,6 +113,9 @@ export default function RegisterPage() {
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
null,
);
const loadConfig = useCallback(async () => {
setConfigError('');
@@ -1163,6 +1167,26 @@ export default function RegisterPage() {
receipt={receipt}
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
onDelivered={resetCashier}
onReturn={
config?.session && config?.terminal
? (orderId, latest) => {
setReturnOrder({ orderId, receipt: latest });
}
: undefined
}
/>
)}
{returnOrder && config?.session && config?.terminal && (
<ReturnModal
receipt={returnOrder.receipt}
orderId={returnOrder.orderId}
cashSessionId={config.session.id}
terminalId={config.terminal.id}
onClose={() => setReturnOrder(null)}
onReturned={(latest) => {
setReturnOrder(null);
setReceipt(latest);
}}
/>
)}
</div>

View File

@@ -9,12 +9,14 @@ interface ReceiptModalProps {
receipt: PosReceipt;
initialEmail?: string;
onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void;
}
export default function ReceiptModal({
receipt,
initialEmail = '',
onDelivered,
onReturn,
}: ReceiptModalProps) {
const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false);
@@ -166,6 +168,15 @@ export default function ReceiptModal({
>
🖨 Imprimir ticket
</button>
{onReturn && !receipt.isReturn && (
<button
type="button"
onClick={() => onReturn(receipt.orderId, receipt)}
className="min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800"
>
Devolver artículos
</button>
)}
<form onSubmit={emailReceipt} className="flex gap-2">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket

View File

@@ -0,0 +1,233 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import { generateIdempotencyKey } from '@/lib/idempotency';
import type { PosReceipt, PosSaleResponse } from '@/types/checkout';
interface OrderItem {
id: string;
name: string;
quantity: number;
returnedQuantity: number;
freeItem: boolean;
unitPriceCents: number;
discountCents: number;
}
interface ReturnModalProps {
receipt: PosReceipt;
orderId: string;
cashSessionId: string;
terminalId: string;
onClose: () => void;
onReturned: (newReceipt: PosReceipt) => void;
}
export default function ReturnModal({
receipt,
orderId,
cashSessionId,
terminalId,
onClose,
onReturned,
}: ReturnModalProps) {
const [items, setItems] = useState<OrderItem[]>([]);
const [lines, setLines] = useState<Record<string, number>>({});
const [reason, setReason] = useState('Cambio/devolución');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void (async () => {
try {
const data = await posApi.listOrderItems<{ items: OrderItem[] }>(orderId);
setItems(
data.items.map((item) => ({
id: item.id,
name: item.name,
quantity: item.quantity,
returnedQuantity: item.returnedQuantity,
freeItem: item.freeItem,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
})),
);
setLines(
Object.fromEntries(data.items.map((item) => [item.id, 0])),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudieron cargar las líneas');
} finally {
setLoading(false);
}
})();
}, [orderId]);
const totalRefund = useMemo(() => {
return items.reduce((sum, item) => {
const requested = lines[item.id] ?? 0;
if (requested <= 0) return sum;
const perUnit = Math.max(item.unitPriceCents - item.discountCents, 0);
return sum + perUnit * requested;
}, 0);
}, [lines, items]);
const submit = async () => {
setError('');
const itemsPayload = items
.map((item) => ({ orderItemId: item.id, returnedQuantity: lines[item.id] ?? 0 }))
.filter((line) => line.returnedQuantity > 0);
if (itemsPayload.length === 0) {
setError('Selecciona al menos una unidad para devolver');
return;
}
setSaving(true);
try {
const result = await posApi.createReturn<PosSaleResponse>(orderId, {
idempotencyKey: generateIdempotencyKey(),
cashSessionId,
terminalId,
reason: reason.trim() || 'Devolución',
items: itemsPayload,
});
onReturned(result.receipt);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la devolución');
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="return-title"
>
<div className="w-full max-w-md space-y-4 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-amber-600">
Devolución · ticket {receipt.receiptNumber}
</p>
<h2 id="return-title" className="text-2xl font-bold text-gray-900">
Devolver artículos
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
{loading ? (
<p className="text-sm text-gray-500">Cargando</p>
) : (
<>
<ul className="space-y-2">
{items.map((item) => {
const requested = lines[item.id] ?? 0;
const max = item.quantity - item.returnedQuantity;
const step = item.freeItem ? max : 1;
return (
<li
key={item.id}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm"
>
<div>
<p className="font-medium text-gray-800">{item.name}</p>
<p className="text-xs text-gray-500">
Vendidas {item.quantity} · devueltas {item.returnedQuantity}
{item.freeItem ? ' · artículo libre' : ''}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.max(0, requested - step),
}))
}
disabled={requested <= 0}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
</button>
<span className="w-8 text-center text-lg font-bold">{requested}</span>
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.min(max, requested + step),
}))
}
disabled={requested === max}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
+
</button>
</div>
</li>
);
})}
</ul>
<label className="block text-sm font-medium text-gray-700">
Motivo
<input
type="text"
required
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2"
/>
</label>
<div className="rounded-xl bg-amber-50 p-3 text-sm">
<p className="flex justify-between font-bold">
<span>Reembolso estimado</span>
<span>{formatPrice(totalRefund)}</span>
</p>
</div>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="flex-1 rounded-xl border border-gray-300 py-3 text-sm font-bold disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void submit()}
disabled={saving || totalRefund === 0}
className="flex-1 rounded-xl bg-amber-500 py-3 text-sm font-bold text-white disabled:opacity-50"
>
{saving ? 'Procesando…' : 'Confirmar devolución'}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -66,6 +66,15 @@ export const posApi = {
method: 'POST',
body: JSON.stringify(data),
}),
/** Apply a partial/full return to a POS sale. */
createReturn: <T>(orderId: string, data: unknown) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/returns`, {
method: 'POST',
body: JSON.stringify(data),
}),
/** List order items for a sale (used by the return UI). */
listOrderItems: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
/** List POS sales for the session, optionally filtered by state. */
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
const qs = new URLSearchParams();

View File

@@ -61,6 +61,8 @@ export interface PosReceipt {
header: string | null;
returnPolicy: string;
footer: string | null;
originalReceiptNumber?: string;
isReturn?: boolean;
}
export interface PosSaleResponse {

View File

@@ -0,0 +1,94 @@
/**
* F-189 — POS returns per-line counters + pos_returns table.
*
* 1. Adds `orders_items.returned_quantity integer NOT NULL DEFAULT 0` with
* `CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)`.
* 2. Creates `pos_returns` as the historical ledger of returns with a unique
* `(order_id, idempotency_key)` index for replay.
* 3. Allows `'return'` in `inventory_movements.operation` so cashiers can
* see the movement in inventory history.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
// ── orders_items.returned_quantity ─────────────────────────────────────
pgm.sql(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'orders_items' AND column_name = 'returned_quantity'
) THEN
ALTER TABLE orders_items
ADD COLUMN returned_quantity integer NOT NULL DEFAULT 0;
END IF;
END $$
`);
pgm.sql(`ALTER TABLE orders_items DROP CONSTRAINT IF EXISTS orders_items_returned_quantity_check`);
pgm.sql(`
ALTER TABLE orders_items
ADD CONSTRAINT orders_items_returned_quantity_check
CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)
`);
// ── inventory_movements.operation — allow 'return' ─────────────────────
pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_operation_check`);
pgm.sql(`
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation IN ('reserve','release','confirm','set_available','return'))
`);
// ── pos_returns ─────────────────────────────────────────────────────────
pgm.createTable('pos_returns', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
order_id: { type: 'uuid', notNull: true, references: 'orders_orders(id)', onDelete: 'RESTRICT' },
idempotency_key: { type: 'text', notNull: true },
terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' },
cash_session_id: { type: 'uuid', notNull: true, references: 'pos_cash_sessions(id)', onDelete: 'RESTRICT' },
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' },
user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' },
total_cents: { type: 'integer', notNull: true, check: 'total_cents > 0' },
status: {
type: 'text',
notNull: true,
check: "status IN ('refund','partial_refund')",
},
reason: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.sql(`
CREATE UNIQUE INDEX IF NOT EXISTS pos_returns_idempotency_unique_idx
ON pos_returns (order_id, idempotency_key)
`);
pgm.createIndex('pos_returns', ['store_id', 'created_at'], {
name: 'pos_returns_store_created_idx',
});
pgm.createIndex('pos_returns', 'terminal_id', {
name: 'pos_returns_terminal_idx',
where: 'terminal_id IS NOT NULL',
});
};
export const down = (pgm) => {
pgm.dropTable('pos_returns', { ifExists: true });
pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_operation_check`);
pgm.sql(`
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation IN ('reserve','release','confirm','set_available'))
`);
pgm.sql(`ALTER TABLE orders_items DROP CONSTRAINT IF EXISTS orders_items_returned_quantity_check`);
pgm.sql(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'orders_items' AND column_name = 'returned_quantity'
) THEN
ALTER TABLE orders_items DROP COLUMN returned_quantity;
END IF;
END $$
`);
};

View File

@@ -0,0 +1,338 @@
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const TERMINAL_ID = '20000000-0000-4000-8000-000000000189';
const SESSION_ID = '30000000-0000-4000-8000-000000000189';
const USER_ID = '10000000-0000-4000-8000-000000000189';
const SESSION_TOKEN = 'f189-session';
const PRODUCT_ID = '40000000-0000-4000-8000-000000000189';
const VARIANT_ID = '50000000-0000-4000-8000-000000000189';
function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function cookie(token: string): string {
return `backoffice_session=${token}`;
}
describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
const passwordHash = await argon2.hash('cashier-189');
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES ($1, 'cashier-f189@example.test', $2, 'pos_cashier')`,
[USER_ID, passwordHash],
);
await pool.query(
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '1 hour')`,
[USER_ID, tokenHash(SESSION_TOKEN)],
);
await pool.query(
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
VALUES ($1, $2, 'Caja F-189', 'F189CODE', now())`,
[TERMINAL_ID, STORE_ID],
);
await pool.query(
`INSERT INTO pos_cash_sessions (
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
) VALUES ($1, $2, $3, $4, 1000, 1000)`,
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
);
await pool.query(
`INSERT INTO catalog_products (id, name, slug, state)
VALUES ($1, 'Producto F-189', 'producto-f189', 'active')`,
[PRODUCT_ID],
);
await pool.query(
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
VALUES ($1, $2, 'SKU-F189', '8412345678903')`,
[VARIANT_ID, PRODUCT_ID],
);
await pool.query(
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
VALUES ($1, 1000, 'reduced')`,
[VARIANT_ID],
);
await pool.query(
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, 10)`,
[VARIANT_ID, STORE_ID],
);
app = await buildApp({ pool, cookieSecure: false });
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('partially returns a sale, restores partial stock and stays PARTIALLY_REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const firstItemId = items.rows[0]?.id;
expect(firstItemId).toBeTruthy();
const stockBefore = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockBefore.rows[0]?.available)).toBe(7);
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Cliente cambió de opinión',
items: [{ orderItemId: firstItemId!, returnedQuantity: 1 }],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000);
expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000);
const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
[sale.orderId],
);
expect(order.rows[0]?.state).toBe('PARTIALLY_REFUNDED');
const itemRow = await pool.query<{ returned_quantity: number }>(
`SELECT returned_quantity FROM orders_items WHERE id = $1`,
[firstItemId!],
);
expect(Number(itemRow.rows[0]?.returned_quantity)).toBe(1);
const stockAfter = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockAfter.rows[0]?.available)).toBe(8);
const event = await pool.query<{ event_type: string }>(
`SELECT event_type FROM orders_order_history WHERE order_id = $1 AND event_type = 'RETURNED'`,
[sale.orderId],
);
expect(event.rows.length).toBe(1);
const reporting = await pool.query<{ status: string; amount_cents: number }>(
`SELECT status, amount_cents FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('partial_refund');
expect(Number(reporting.rows[0]?.amount_cents)).toBe(1000);
const session = await pool.query<{ expected_cash_cents: number }>(
`SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1`,
[SESSION_ID],
);
expect(Number(session.rows[0]?.expected_cash_cents)).toBeLessThanOrEqual(4000);
});
it('fully returns a sale and transitions to REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
],
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string; is_free_item: boolean }>(
`SELECT id, is_free_item FROM orders_items WHERE order_id = $1 ORDER BY is_free_item DESC, id`,
[sale.orderId],
);
const stockItemId = items.rows.find((r) => !r.is_free_item)?.id;
const freeItemId = items.rows.find((r) => r.is_free_item)?.id;
expect(stockItemId).toBeTruthy();
expect(freeItemId).toBeTruthy();
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Devolución completa',
items: [
{ orderItemId: stockItemId!, returnedQuantity: 1 },
{ orderItemId: freeItemId!, returnedQuantity: 1 },
],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(8);
const reporting = await pool.query<{ status: string }>(
`SELECT status FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('refund');
});
it('rejects over-return with POS_RETURN_EXCEEDS_AVAILABLE', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-overreturn',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-over',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Exceso',
items: [{ orderItemId: itemId, returnedQuantity: 5 }],
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe('POS_RETURN_EXCEEDS_AVAILABLE');
});
it('replays the same idempotency key without duplicating rows', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const first = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(first.statusCode).toBe(201);
expect(first.json().returnId).toBeTruthy();
const second = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(second.statusCode).toBe(201);
expect(second.json().returnId).toBeTruthy();
expect(second.json().returnId).toBe(first.json().returnId);
console.log('FIRST', first.json());
console.log('SECOND', second.json());
const returns = await pool.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pos_returns WHERE order_id = $1`,
[sale.orderId],
);
expect(returns.rows[0]?.count).toBe(1);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(6);
});
it('returns 404 on the legacy /refund route', async () => {
const response = await app.inject({
method: 'POST',
url: '/pos/sales/00000000-0000-4000-8000-000000000000/refund',
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: { refundAmountCents: 100, reason: 'legacy' },
});
expect(response.statusCode).toBe(404);
});
});

View File

@@ -13,6 +13,7 @@ import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
import { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/index.js';
@@ -64,6 +65,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool);
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
const applyPosReturn = new ApplyPosReturnUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -1558,56 +1560,82 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
// ── POS-012: Refund + receipt print + analytics ────────────────────────────
// ── POS-012: POS returns + receipt print + analytics ──────────────────────
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/refund',
'/pos/sales/:id/returns',
{
schema: {
tags: ['POS Terminal'],
summary: 'Refund a POS sale',
summary: 'Apply a partial or full return to a POS sale',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['refundAmountCents', 'reason'],
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'reason', 'items'],
properties: {
refundAmountCents: { type: 'integer', minimum: 1 },
reason: { type: 'string', minLength: 1 },
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
reason: { type: 'string', minLength: 1, maxLength: 500 },
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['orderItemId', 'returnedQuantity'],
properties: {
orderItemId: { type: 'string', format: 'uuid' },
returnedQuantity: { type: 'integer', minimum: 1 },
},
},
},
},
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
response: {
400: errorSchema,
401: errorSchema,
403: errorSchema,
404: errorSchema,
409: errorSchema,
},
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = request.params;
const body = (request.body ?? {}) as { refundAmountCents?: number; reason?: string };
const order = await pool.query<{ id: string; total_cents: number }>(
'SELECT id, total_cents FROM orders_orders WHERE id = $1',
[id],
const body = parseJson(
z.object({
idempotencyKey: z.string().min(1).max(128),
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
reason: z.string().min(1).max(500),
items: z
.array(
z.object({
orderItemId: z.string().uuid(),
returnedQuantity: z.number().int().min(1),
}),
)
.min(1),
}),
request.body ?? {},
);
if (!order.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Order not found');
if ((body.refundAmountCents ?? 0) > (order.rows[0].total_cents ?? 0))
throw new AppError(400, 'REFUND_EXCEEDS', 'Refund exceeds amount');
await pool.query(
`INSERT INTO payments_transactions (provider, provider_event_id, provider_payment_id, order_id, amount_cents, currency, status, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
'pos_refund',
`ref-${id}`,
`ref-${Date.now()}`,
id,
body.refundAmountCents,
'EUR',
'COMPLETED',
JSON.stringify({ reason: body.reason, by: user.id }),
],
);
await pool.query(
`INSERT INTO orders_order_events (order_id, event, actor_id, metadata) VALUES ($1, 'REFUNDED', $2, $3)`,
[id, user.id, JSON.stringify({ amt: body.refundAmountCents, reason: body.reason })],
);
return reply.send({ ok: true, refundedCents: body.refundAmountCents });
const boundTerminalId = request.headers['x-terminal-id'];
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
throw new AppError(
403,
'POS_TERMINAL_MISMATCH',
'La devolución no pertenece al terminal vinculado',
);
}
const result = await applyPosReturn.execute({
orderId: id,
userId: user.id,
...body,
lines: body.items,
});
return reply.code(201).send(result);
},
);
@@ -1630,6 +1658,57 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get<{ Params: { id: string } }>(
'/pos/sales/:id/items',
{
schema: {
tags: ['POS Terminal'],
summary: 'List order items of a POS sale (for returns)',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const order = await pool.query<{ id: string; source: string }>(
`SELECT id, source FROM orders_orders WHERE id = $1`,
[id],
);
if (!order.rows[0] || order.rows[0].source !== 'pos') {
throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
}
const itemRows = await pool.query<{
id: string;
name: string;
sku: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
unit_price_cents: number;
discount_cents: number;
}>(
`SELECT id, name, sku, quantity, returned_quantity, is_free_item,
unit_price_cents, discount_cents
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[id],
);
return reply.send({
items: itemRows.rows.map((row) => ({
id: row.id,
name: row.name,
sku: row.sku,
quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item,
unitPriceCents: Number(row.unit_price_cents),
discountCents: Number(row.discount_cents),
})),
});
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/receipt/email',
{

View File

@@ -0,0 +1,368 @@
import type pg from 'pg';
import { AppError } from '../../../shared/errors.js';
import type { PosReturnInput, PosReturnResult } from '../domain/pos-sale.js';
import { buildPosReturnReceipt } from './build-pos-receipt.js';
interface OrderRow {
id: string;
source: string;
state: string;
terminal_id: string;
cash_session_id: string;
store_id: string;
total_cents: string;
}
interface OrderItemRow {
id: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
variant_id: string | null;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
}
interface SessionRow {
id: string;
terminal_id: string;
store_id: string;
status: string;
}
interface ReplayReturnRow {
id: string;
total_cents: string;
status: string;
}
/**
* Apply a partial or full return to a POS sale.
*
* - Locks the order and stock rows.
* - Restores inventory for returned stock lines.
* - Updates per-line `returned_quantity` counters.
* - Emits a single `reporting_payment_lines` row (`status='refund'` or `'partial_refund'`).
* - Adjusts `pos_cash_sessions.expected_cash_cents` only when cash is refunded.
* - Transitions the order to `PARTIALLY_REFUNDED` or `REFUNDED`.
* - Records audit + history events. Returns the return receipt payload.
*/
export class ApplyPosReturnUseCase {
constructor(private readonly pool: pg.Pool) {}
async execute(input: PosReturnInput): Promise<PosReturnResult> {
if (input.lines.length === 0) {
throw new AppError(400, 'POS_RETURN_EMPTY', 'Selecciona al menos una línea a devolver');
}
for (const line of input.lines) {
if (!Number.isInteger(line.returnedQuantity) || line.returnedQuantity <= 0) {
throw new AppError(
400,
'POS_RETURN_INVALID_QUANTITY',
'La cantidad devuelta debe ser un entero positivo',
);
}
}
const client = await this.pool.connect();
try {
// Idempotent replay check uses a small dedicated transaction so the
// read does not stay open while we do the (possibly heavyweight)
// mutation work.
const replay = await client.query<ReplayReturnRow>(
`SELECT id, total_cents, status
FROM pos_returns
WHERE order_id = $1 AND idempotency_key = $2`,
[input.orderId, input.idempotencyKey],
);
if (replay.rows[0]) {
const previous = replay.rows[0];
const receipt = await buildPosReturnReceipt(
client,
input.orderId,
Number(previous.total_cents),
);
return {
orderId: input.orderId,
returnId: previous.id,
idempotencyKey: input.idempotencyKey,
state: previous.status === 'refund' ? 'REFUNDED' : 'PARTIALLY_REFUNDED',
refundedCents: Number(previous.total_cents),
receipt,
};
}
await client.query('BEGIN');
const orderRow = await client.query<OrderRow>(
`SELECT id, source, state, terminal_id, cash_session_id, store_id, total_cents
FROM orders_orders WHERE id = $1 FOR UPDATE`,
[input.orderId],
);
const order = orderRow.rows[0];
if (!order || order.source !== 'pos') {
throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
}
if (order.state !== 'COMPLETED' && order.state !== 'PARTIALLY_REFUNDED') {
throw new AppError(
409,
'POS_RETURN_NOT_ALLOWED',
'La venta no admite devoluciones en su estado actual',
);
}
if (order.terminal_id !== input.terminalId || order.cash_session_id !== input.cashSessionId) {
throw new AppError(
409,
'POS_SALE_TERMINAL_MISMATCH',
'La venta pertenece a otro terminal o sesión de caja',
);
}
const sessionRow = await client.query<SessionRow>(
`SELECT id, terminal_id, store_id, status
FROM pos_cash_sessions WHERE id = $1 FOR UPDATE`,
[input.cashSessionId],
);
const session = sessionRow.rows[0];
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión de caja no encontrada');
if (session.status !== 'OPEN') {
throw new AppError(409, 'SESSION_CLOSED', 'La sesión de caja está cerrada');
}
const itemRows = await client.query<OrderItemRow>(
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
unit_price_cents, discount_cents, tax_cents
FROM orders_items
WHERE order_id = $1
FOR UPDATE`,
[input.orderId],
);
const itemsById = new Map(itemRows.rows.map((row) => [row.id, row]));
let totalRefundCents = 0;
const stockUpdates: Array<{
variantId: string;
storeId: string;
quantity: number;
}> = [];
for (const line of input.lines) {
const item = itemsById.get(line.orderItemId);
if (!item) {
throw new AppError(404, 'POS_RETURN_ITEM_NOT_FOUND', 'Línea de venta no encontrada');
}
const remaining = item.quantity - item.returned_quantity;
if (remaining <= 0) {
throw new AppError(
409,
'POS_RETURN_LINE_FULLY_RETURNED',
'La línea ya está devuelta por completo',
);
}
if (item.is_free_item) {
if (line.returnedQuantity !== item.quantity - item.returned_quantity) {
throw new AppError(
400,
'POS_RETURN_FREE_ITEM_PARTIAL',
'Los artículos libres solo pueden devolverse en su totalidad',
);
}
} else if (line.returnedQuantity > remaining) {
throw new AppError(
409,
'POS_RETURN_EXCEEDS_AVAILABLE',
`Solo quedan ${remaining} unidades de la línea ${item.id}`,
);
}
totalRefundCents +=
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity;
if (!item.is_free_item && item.variant_id) {
stockUpdates.push({
variantId: item.variant_id,
storeId: order.store_id,
quantity: line.returnedQuantity,
});
}
}
// Lock every affected stock row.
for (const stock of stockUpdates) {
await client.query(
`SELECT available FROM inventory_stock
WHERE variant_id = $1 AND store_id = $2 FOR UPDATE`,
[stock.variantId, stock.storeId],
);
}
for (const stock of stockUpdates) {
await client.query(
`UPDATE inventory_stock
SET available = available + $3,
sold = GREATEST(0, sold - $3),
updated_at = now()
WHERE variant_id = $1 AND store_id = $2`,
[stock.variantId, stock.storeId, stock.quantity],
);
await client.query(
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, 'return', $3)`,
[stock.variantId, stock.storeId, stock.quantity],
);
}
for (const line of input.lines) {
await client.query(
`UPDATE orders_items
SET returned_quantity = returned_quantity + $2
WHERE id = $1`,
[line.orderItemId, line.returnedQuantity],
);
}
// Decide new state. A line is considered fully returned when
// `returned_quantity == quantity`; free items are treated the same way.
const updatedItemsResult = await client.query<{
quantity: number;
returned_quantity: number;
is_free_item: boolean;
}>(
`SELECT quantity, returned_quantity, is_free_item
FROM orders_items WHERE order_id = $1`,
[input.orderId],
);
let allFullyReturned = true;
let anyReturn = false;
for (const row of updatedItemsResult.rows) {
if (row.returned_quantity === 0) {
allFullyReturned = false;
} else {
anyReturn = true;
if (row.returned_quantity < row.quantity) {
allFullyReturned = false;
}
}
}
if (!anyReturn) {
// Nothing changed (defensive).
allFullyReturned = false;
}
const reportingStatus: 'refund' | 'partial_refund' = allFullyReturned
? 'refund'
: 'partial_refund';
const newOrderState: 'REFUNDED' | 'PARTIALLY_REFUNDED' = allFullyReturned
? 'REFUNDED'
: 'PARTIALLY_REFUNDED';
// Cash portion of the refund reduces expected_cash_cents proportional to
// the share of the original cash payment.
const cashResult = await client.query<{ sum: string | null }>(
`SELECT COALESCE(SUM(amount_cents), 0)::text AS sum FROM payments_transactions
WHERE order_id = $1 AND status = 'succeeded' AND provider = 'pos_cash'`,
[input.orderId],
);
const cashPaidCents = Number(cashResult.rows[0]?.sum ?? '0');
const totalCents = Number(order.total_cents);
const cashRefundPortion =
totalCents > 0
? Math.min(
Math.round((cashPaidCents / totalCents) * totalRefundCents),
cashPaidCents,
)
: 0;
if (cashRefundPortion > 0) {
await client.query(
`UPDATE pos_cash_sessions
SET expected_cash_cents = GREATEST(0, COALESCE(expected_cash_cents, opening_cash_cents) - $1),
updated_at = now()
WHERE id = $2`,
[cashRefundPortion, input.cashSessionId],
);
}
const insertedReturn = await client.query<{ id: string }>(
`INSERT INTO pos_returns (
order_id, idempotency_key, terminal_id, cash_session_id, store_id, user_id,
total_cents, status, reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
input.orderId,
input.idempotencyKey,
input.terminalId,
input.cashSessionId,
order.store_id,
input.userId,
totalRefundCents,
reportingStatus,
input.reason,
],
);
const returnId = insertedReturn.rows[0]?.id;
if (!returnId) throw new AppError(500, 'POS_RETURN_INSERT_FAILED', 'No se pudo registrar la devolución');
await client.query(
`INSERT INTO reporting_payment_lines (
order_id, store_id, terminal_id, cash_session_id, payment_method_id,
provider, amount_cents, currency, status, provider_ref
) VALUES ($1, $2, $3, $4, NULL, 'pos_return', $5, 'EUR', $6, $7)`,
[
input.orderId,
order.store_id,
input.terminalId,
input.cashSessionId,
totalRefundCents,
reportingStatus,
returnId,
],
);
await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
[
input.orderId,
JSON.stringify({
returnId,
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
}),
input.userId,
],
);
await client.query(
`UPDATE orders_orders SET state = $2, state_changed_at = now(), updated_at = now()
WHERE id = $1`,
[input.orderId, newOrderState],
);
await client.query(
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
VALUES ($1, $2, $3, jsonb_build_object('refundedCents', $4::int, 'returnId', $5::text))`,
[
input.userId,
newOrderState === 'REFUNDED' ? 'pos.sale.returned' : 'pos.sale.partial_returned',
input.orderId,
totalRefundCents,
returnId,
],
);
const receipt = await buildPosReturnReceipt(client, input.orderId, totalRefundCents);
await client.query('COMMIT');
return {
orderId: input.orderId,
returnId,
idempotencyKey: input.idempotencyKey,
state: newOrderState,
refundedCents: totalRefundCents,
receipt,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}

View File

@@ -4,6 +4,18 @@ import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
interface ReturnedItemRow {
id: string;
quantity: number;
returned_quantity: number;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
name: string;
sku: string;
is_free_item: boolean;
}
interface ReceiptOrderRow {
id: string;
receipt_number: string | null;
@@ -165,3 +177,75 @@ function integerOrNull(value: unknown): number | null {
function integerOrZero(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
}
/**
* Build a return/reversal receipt based on the original POS order.
*
* The returned payload uses negative line totals and prices `R-<original>` as
* its receipt number without persisting a separate sequence. The `paidCents`
* block is replaced with a `refundedCents` flag and the receipt is flagged
* `isReturn=true` so renderers can style it accordingly.
*/
export async function buildPosReturnReceipt(
queryable: Queryable,
orderId: string,
refundedCents: number,
): Promise<PosReceipt> {
const original = await buildPosReceipt(queryable, orderId);
const itemResult = await queryable.query<ReturnedItemRow>(
`SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents,
name, sku, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
);
const items = itemResult.rows
.filter((row) => row.returned_quantity > 0)
.map((row) => {
const returnedQuantity = Number(row.returned_quantity);
const unitPrice = Number(row.unit_price_cents);
const discount = Number(row.discount_cents);
const tax = Number(row.tax_cents);
const subtotal = unitPrice * returnedQuantity;
const discountCents = discount * returnedQuantity;
const taxCents = tax * returnedQuantity;
const total = subtotal - discountCents + taxCents;
return {
name: row.name,
sku: row.sku,
quantity: returnedQuantity,
unitPriceCents: unitPrice,
subtotalCents: subtotal,
discountCents,
taxCents,
totalCents: -total,
freeItem: row.is_free_item,
};
});
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
const discount = items.reduce((sum, item) => sum + item.discountCents, 0);
const tax = items.reduce((sum, item) => sum + item.taxCents, 0);
return {
receiptNumber: original.receiptNumber.startsWith('R-')
? original.receiptNumber
: `R-${original.receiptNumber}`,
orderId: original.orderId,
issuedAt: original.issuedAt,
company: original.company,
terminal: original.terminal,
cashier: original.cashier,
sessionId: original.sessionId,
customerEmail: original.customerEmail,
items,
subtotalCents: -subtotal,
discountCents: -discount,
taxCents: -tax,
totalCents: -refundedCents,
payments: [],
changeCents: 0,
header: original.header,
returnPolicy: original.returnPolicy,
footer: original.footer,
originalReceiptNumber: original.receiptNumber,
isReturn: true,
};
}

View File

@@ -105,6 +105,10 @@ export interface PosReceipt {
header: string | null;
returnPolicy: string;
footer: string | null;
/** Present on return receipts. References the original ticket number. */
originalReceiptNumber?: string;
/** True for return/reversal receipts that should print negative totals. */
isReturn?: boolean;
}
export interface PosSaleResult {
@@ -125,3 +129,27 @@ export interface PosSaleResult {
export interface PosPaymentResult extends PosReceiptPayment {
id: string;
}
export interface PosReturnLineInput {
orderItemId: string;
returnedQuantity: number;
}
export interface PosReturnInput {
orderId: string;
idempotencyKey: string;
cashSessionId: string;
terminalId: string;
userId: string;
reason: string;
lines: PosReturnLineInput[];
}
export interface PosReturnResult {
orderId: string;
returnId: string;
idempotencyKey: string;
state: 'PARTIALLY_REFUNDED' | 'REFUNDED';
refundedCents: number;
receipt: PosReceipt;
}

View File

@@ -7,7 +7,8 @@ export {
type ValidatedPayment,
} from './application/create-pos-sale.js';
export { ReceiveRestPaymentUseCase } from './application/receive-rest-payment.js';
export { buildPosReceipt } from './application/build-pos-receipt.js';
export { ApplyPosReturnUseCase } from './application/apply-pos-return.js';
export { buildPosReceipt, buildPosReturnReceipt } from './application/build-pos-receipt.js';
export type {
PosSaleInput,
PosSaleResult,
@@ -15,5 +16,8 @@ export type {
PosSaleLineItem,
PosPaymentInput,
PosPaymentResult,
PosReturnInput,
PosReturnResult,
PosReturnLineInput,
PosReceipt,
} from './domain/pos-sale.js';