feat(F-188): completed feature
This commit is contained in:
@@ -12,6 +12,7 @@ import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
||||
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 { buildPosReceipt } from '../application/build-pos-receipt.js';
|
||||
import { sendTransactionalEmail } from '../../notifications/index.js';
|
||||
import { Argon2PasswordHasher } from '../../identity/index.js';
|
||||
@@ -62,6 +63,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
|
||||
const sessionRepo = new PgCashSessionRepository(pool);
|
||||
const createPosSale = new CreatePosSaleUseCase(pool);
|
||||
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
|
||||
|
||||
const listStores = new ListStoresUseCase(storeRepo);
|
||||
const listTerminals = new ListTerminalsUseCase(terminalRepo);
|
||||
@@ -1321,6 +1323,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', format: 'uuid' },
|
||||
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
},
|
||||
},
|
||||
@@ -1330,25 +1334,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const { sessionId, limit = 20 } = request.query as { sessionId?: string; limit?: number };
|
||||
let query = `SELECT o.id, o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
|
||||
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
|
||||
u.email AS "userEmail"
|
||||
FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
WHERE o.idempotency_key IS NOT NULL`;
|
||||
const params: unknown[] = [];
|
||||
const params = request.query as {
|
||||
sessionId?: string;
|
||||
state?: string;
|
||||
storeId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const sessionId = params.sessionId;
|
||||
const state = params.state;
|
||||
const storeId = params.storeId;
|
||||
const limit = Math.min(Math.max(params.limit ?? 20, 1), 100);
|
||||
const conditions: string[] = [`o.idempotency_key IS NOT NULL`];
|
||||
const values: unknown[] = [];
|
||||
if (sessionId) {
|
||||
params.push(sessionId);
|
||||
query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`;
|
||||
values.push(sessionId);
|
||||
conditions.push(`o.cash_session_id = $${values.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`;
|
||||
const result = await pool.query(query, params);
|
||||
if (state) {
|
||||
values.push(state);
|
||||
conditions.push(`o.state = $${values.length}`);
|
||||
}
|
||||
if (storeId) {
|
||||
values.push(storeId);
|
||||
conditions.push(`o.store_id = $${values.length}`);
|
||||
}
|
||||
values.push(limit);
|
||||
const query = `
|
||||
SELECT o.id, o.state,
|
||||
o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
|
||||
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
|
||||
o.store_id AS "storeId", o.terminal_id AS "terminalId",
|
||||
o.cash_session_id AS "cashSessionId",
|
||||
COALESCE(payments.sum_paid, 0)::int AS "paidCents",
|
||||
(o.total_cents - COALESCE(payments.sum_paid, 0))::int AS "outstandingCents",
|
||||
u.email AS "userEmail"
|
||||
FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
LEFT JOIN (
|
||||
SELECT order_id, SUM(amount_cents) AS sum_paid
|
||||
FROM payments_transactions
|
||||
WHERE status = 'succeeded'
|
||||
GROUP BY order_id
|
||||
) payments ON payments.order_id = o.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $${values.length}
|
||||
`;
|
||||
const result = await pool.query(query, values);
|
||||
return reply.send({ items: result.rows });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/pos/sales/:id/payments',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Apply additional payments to a pending POS sale',
|
||||
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'payments'],
|
||||
properties: {
|
||||
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
cashSessionId: { type: 'string', format: 'uuid' },
|
||||
terminalId: { type: 'string', format: 'uuid' },
|
||||
payments: { type: 'array', minItems: 1, items: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
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 payment = z
|
||||
.object({
|
||||
methodCode: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_-]{1,32}$/)
|
||||
.optional(),
|
||||
kind: z.enum(['cash', 'card', 'other']).optional(),
|
||||
amountCents: z.number().int().min(1),
|
||||
tenderedCents: z.number().int().min(0).optional(),
|
||||
last4: z
|
||||
.string()
|
||||
.regex(/^\d{1,4}$/)
|
||||
.optional(),
|
||||
})
|
||||
.refine((value) => Boolean(value.methodCode || value.kind), {
|
||||
message: 'methodCode is required',
|
||||
});
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
idempotencyKey: z.string().min(1).max(128),
|
||||
cashSessionId: z.string().uuid(),
|
||||
terminalId: z.string().uuid(),
|
||||
payments: z.array(payment).min(1),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
const boundTerminalId = request.headers['x-terminal-id'];
|
||||
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
|
||||
throw new AppError(
|
||||
403,
|
||||
'POS_TERMINAL_MISMATCH',
|
||||
'La venta no pertenece al terminal vinculado',
|
||||
);
|
||||
}
|
||||
const result = await receiveRestPayment.execute({
|
||||
orderId: (request.params as { id: string }).id,
|
||||
userId: user.id,
|
||||
...body,
|
||||
});
|
||||
return reply.code(201).send(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/pos/sales/:id/void',
|
||||
{
|
||||
|
||||
@@ -103,13 +103,11 @@ export function validatePaymentAllocations(
|
||||
};
|
||||
});
|
||||
const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0);
|
||||
if (allocatedCents !== totalCents) {
|
||||
if (allocatedCents > totalCents) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'POS_PAYMENT_TOTAL_MISMATCH',
|
||||
allocatedCents < totalCents
|
||||
? 'Falta importe por asignar antes de confirmar'
|
||||
: 'Los pagos asignados superan el total',
|
||||
'POS_PAYMENT_OVERPAYMENT',
|
||||
'Los pagos asignados superan el total de la venta',
|
||||
);
|
||||
}
|
||||
return validated;
|
||||
@@ -290,7 +288,7 @@ export class CreatePosSaleUseCase {
|
||||
`INSERT INTO orders_orders (
|
||||
user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents,
|
||||
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number
|
||||
) VALUES ($1, $2, 'COMPLETED', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
|
||||
) VALUES ($1, $2, 'PENDING', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
|
||||
RETURNING id, created_at`,
|
||||
[
|
||||
input.customerId ?? null,
|
||||
@@ -392,6 +390,20 @@ export class CreatePosSaleUseCase {
|
||||
if (payment.kind === 'cash') cashAppliedCents += payment.amountCents;
|
||||
paymentResults.push({ id: paymentId, ...payment });
|
||||
}
|
||||
// Recompute initial paidCents from current payments only when this is a
|
||||
// first-time creation. Rest-payment use case has its own loader.
|
||||
const initialPaidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
|
||||
const orderState: 'PENDING' | 'COMPLETED' =
|
||||
initialPaidCents < totalCents ? 'PENDING' : 'COMPLETED';
|
||||
if (orderState === 'COMPLETED') {
|
||||
await client.query(
|
||||
`UPDATE orders_orders SET state = 'COMPLETED',
|
||||
state_changed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[order.id],
|
||||
);
|
||||
}
|
||||
|
||||
if (cashAppliedCents > 0) {
|
||||
await client.query(
|
||||
@@ -409,7 +421,10 @@ export class CreatePosSaleUseCase {
|
||||
orderId: order.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
receiptNumber,
|
||||
state: orderState,
|
||||
totalCents,
|
||||
paidCents: initialPaidCents,
|
||||
outstandingCents: totalCents - initialPaidCents,
|
||||
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
|
||||
items,
|
||||
payments: paymentResults,
|
||||
@@ -426,6 +441,12 @@ export class CreatePosSaleUseCase {
|
||||
|
||||
private async loadResult(orderId: string, idempotencyKey: string): Promise<PosSaleResult> {
|
||||
const receipt = await buildPosReceipt(this.pool, orderId);
|
||||
const orderRows = await this.pool.query<{ state: string }>(
|
||||
`SELECT state FROM orders_orders WHERE id = $1`,
|
||||
[orderId],
|
||||
);
|
||||
const orderState: 'PENDING' | 'COMPLETED' =
|
||||
orderRows.rows[0]?.state === 'COMPLETED' ? 'COMPLETED' : 'PENDING';
|
||||
const paymentRows = await this.pool.query<{
|
||||
id: string;
|
||||
amount_cents: number;
|
||||
@@ -459,11 +480,15 @@ export class CreatePosSaleUseCase {
|
||||
quantity: item.quantity,
|
||||
vatRate: null,
|
||||
}));
|
||||
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
|
||||
return {
|
||||
orderId,
|
||||
idempotencyKey,
|
||||
receiptNumber: receipt.receiptNumber,
|
||||
state: orderState,
|
||||
totalCents: receipt.totalCents,
|
||||
paidCents,
|
||||
outstandingCents: receipt.totalCents - paidCents,
|
||||
changeCents: receipt.changeCents,
|
||||
items,
|
||||
payments,
|
||||
|
||||
237
project/src/modules/pos/application/receive-rest-payment.ts
Normal file
237
project/src/modules/pos/application/receive-rest-payment.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type pg from 'pg';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import type {
|
||||
PosPaymentInput,
|
||||
PosPaymentKind,
|
||||
PosPaymentResult,
|
||||
PosSaleResult,
|
||||
} from '../domain/pos-sale.js';
|
||||
import { validatePaymentAllocations } from './create-pos-sale.js';
|
||||
import { buildPosReceipt } from './build-pos-receipt.js';
|
||||
|
||||
export interface ReceiveRestPaymentInput {
|
||||
orderId: string;
|
||||
idempotencyKey: string;
|
||||
cashSessionId: string;
|
||||
terminalId: string;
|
||||
userId: string;
|
||||
payments: PosPaymentInput[];
|
||||
}
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
state: string;
|
||||
source: string;
|
||||
total_cents: string;
|
||||
terminal_id: string;
|
||||
cash_session_id: string;
|
||||
store_id: string;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
terminal_id: string;
|
||||
store_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply additional payments to a pending POS sale.
|
||||
*
|
||||
* Transitions the order to COMPLETED when the new cumulative allocation
|
||||
* equals the order total. Each payment immediately writes reporting
|
||||
* lines and accumulates expected cash. The order row is locked during the
|
||||
* transaction so concurrent rest-payment attempts cannot overpay.
|
||||
*/
|
||||
export class ReceiveRestPaymentUseCase {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async execute(input: ReceiveRestPaymentInput): Promise<PosSaleResult> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const orderRow = await client.query<OrderRow>(
|
||||
`SELECT id, state, source, total_cents, terminal_id, cash_session_id, store_id
|
||||
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') {
|
||||
throw new AppError(409, 'POS_SALE_NOT_PENDING', 'La venta ya está completamente cobrada');
|
||||
}
|
||||
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 totalCents = Number(order.total_cents);
|
||||
const paidBefore = 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'`,
|
||||
[input.orderId],
|
||||
);
|
||||
const paidCentsBefore = Number(paidBefore.rows[0]?.sum ?? '0');
|
||||
const outstandingBefore = totalCents - paidCentsBefore;
|
||||
if (outstandingBefore <= 0) {
|
||||
throw new AppError(409, 'POS_SALE_NOT_PENDING', 'La venta ya está completamente cobrada');
|
||||
}
|
||||
|
||||
const methodsResult = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
kind: PosPaymentKind;
|
||||
}>(
|
||||
`SELECT id, code, label, kind
|
||||
FROM pos_payment_methods
|
||||
WHERE store_id = $1 AND active = true
|
||||
ORDER BY sort_order, label`,
|
||||
[session.store_id],
|
||||
);
|
||||
// Validate that the new payments do not overpay the outstanding balance.
|
||||
const validated = validatePaymentAllocations(
|
||||
outstandingBefore,
|
||||
input.payments,
|
||||
methodsResult.rows,
|
||||
);
|
||||
|
||||
let cashAppliedCents = 0;
|
||||
const paymentResults: PosPaymentResult[] = [];
|
||||
for (const [index, payment] of validated.entries()) {
|
||||
// Unique per (orderId, idempotencyKey, payment index). Rest-payments are
|
||||
// idempotent for the same key so retries do not duplicate transactions.
|
||||
const providerEventId = `rest-${input.orderId}-${input.idempotencyKey}-${index}`;
|
||||
const existing = await client.query<{ id: string }>(
|
||||
`SELECT id FROM payments_transactions WHERE provider_event_id = $1`,
|
||||
[providerEventId],
|
||||
);
|
||||
let paymentId = existing.rows[0]?.id;
|
||||
if (!paymentId) {
|
||||
const raw = {
|
||||
methodCode: payment.methodCode,
|
||||
methodLabel: payment.methodLabel,
|
||||
kind: payment.kind,
|
||||
tenderedCents: payment.tenderedCents,
|
||||
changeCents: payment.changeCents,
|
||||
...(payment.last4 ? { last4: payment.last4 } : {}),
|
||||
restPayment: true,
|
||||
};
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO payments_transactions (
|
||||
provider, provider_event_id, provider_payment_id, order_id,
|
||||
amount_cents, currency, status, raw
|
||||
) VALUES ($1, $2, $3, $4, $5, 'EUR', 'succeeded', $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
`pos_${payment.methodCode}`,
|
||||
providerEventId,
|
||||
providerEventId,
|
||||
input.orderId,
|
||||
payment.amountCents,
|
||||
JSON.stringify(raw),
|
||||
],
|
||||
);
|
||||
paymentId = inserted.rows[0]?.id;
|
||||
if (!paymentId)
|
||||
throw new AppError(500, 'PAYMENT_INSERT_FAILED', 'No se pudo registrar el pago');
|
||||
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, $5, $6, $7, 'EUR', 'payment', $8)`,
|
||||
[
|
||||
input.orderId,
|
||||
session.store_id,
|
||||
input.terminalId,
|
||||
input.cashSessionId,
|
||||
payment.methodId,
|
||||
`pos_${payment.methodCode}`,
|
||||
payment.amountCents,
|
||||
paymentId,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (payment.kind === 'cash') cashAppliedCents += payment.amountCents;
|
||||
paymentResults.push({ id: paymentId, ...payment });
|
||||
}
|
||||
|
||||
if (cashAppliedCents > 0) {
|
||||
await client.query(
|
||||
`UPDATE pos_cash_sessions
|
||||
SET expected_cash_cents = COALESCE(expected_cash_cents, opening_cash_cents) + $1,
|
||||
updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[cashAppliedCents, input.cashSessionId],
|
||||
);
|
||||
}
|
||||
|
||||
const paidAfterRow = 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'`,
|
||||
[input.orderId],
|
||||
);
|
||||
const paidCentsAfter = Number(paidAfterRow.rows[0]?.sum ?? '0');
|
||||
let finalState: 'PENDING' | 'COMPLETED' = 'PENDING';
|
||||
if (paidCentsAfter >= totalCents) {
|
||||
finalState = 'COMPLETED';
|
||||
await client.query(
|
||||
`UPDATE orders_orders
|
||||
SET state = 'COMPLETED', state_changed_at = now(), updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[input.orderId],
|
||||
);
|
||||
}
|
||||
|
||||
const receipt = await buildPosReceipt(client, input.orderId);
|
||||
await client.query(
|
||||
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||
VALUES ($1, $2, $3, jsonb_build_object('paidCents', $4::int, 'totalCents', $5::int))`,
|
||||
[
|
||||
input.userId,
|
||||
finalState === 'COMPLETED' ? 'pos.sale.fulfilled' : 'pos.sale.partial',
|
||||
input.orderId,
|
||||
paidCentsAfter,
|
||||
totalCents,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
orderId: input.orderId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
receiptNumber: receipt.receiptNumber,
|
||||
state: finalState,
|
||||
totalCents,
|
||||
paidCents: paidCentsAfter,
|
||||
outstandingCents: totalCents - paidCentsAfter,
|
||||
changeCents: paymentResults.reduce((sum, payment) => sum + payment.changeCents, 0),
|
||||
items: [],
|
||||
payments: paymentResults,
|
||||
receipt,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,10 @@ export interface PosSaleResult {
|
||||
orderId: string;
|
||||
idempotencyKey: string;
|
||||
receiptNumber: string;
|
||||
state: 'PENDING' | 'COMPLETED';
|
||||
totalCents: number;
|
||||
paidCents: number;
|
||||
outstandingCents: number;
|
||||
changeCents: number;
|
||||
items: PosSaleLineItem[];
|
||||
payments: PosPaymentResult[];
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
type ConfiguredPaymentMethod,
|
||||
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 type {
|
||||
PosSaleInput,
|
||||
|
||||
@@ -28,16 +28,27 @@ describe('POS payment allocation', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires applied amounts to equal the total exactly', () => {
|
||||
expectCode(
|
||||
() =>
|
||||
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods),
|
||||
'POS_PAYMENT_TOTAL_MISMATCH',
|
||||
);
|
||||
it('accepts under-allocated amounts (orders may remain PENDING)', () => {
|
||||
expect(
|
||||
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods),
|
||||
).toEqual([
|
||||
{
|
||||
methodId: 'card-id',
|
||||
methodCode: 'card',
|
||||
methodLabel: 'Tarjeta',
|
||||
kind: 'card',
|
||||
amountCents: 1999,
|
||||
tenderedCents: null,
|
||||
changeCents: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects overpayments while still under total', () => {
|
||||
expectCode(
|
||||
() =>
|
||||
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 2_001 }], methods),
|
||||
'POS_PAYMENT_TOTAL_MISMATCH',
|
||||
'POS_PAYMENT_OVERPAYMENT',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user