chore(checkpoint): save club core backend and pending pos fixes

This commit is contained in:
Deploy
2026-08-26 17:58:45 +02:00
parent cf3c906ed2
commit 49dfd00406
44 changed files with 2241 additions and 219 deletions

View File

@@ -82,6 +82,28 @@ async function resolveStoreIdForReceipt(
return id;
}
const POS_GROSS_PRICE_SQL = `COALESCE(
pp.offer_cents,
ROUND(
pp.net_unit_amount_cents * CASE pp.vat_rate
WHEN 'general' THEN 1.21
WHEN 'reduced' THEN 1.10
WHEN 'super-reduced' THEN 1.04
ELSE 1.21
END
)::int,
0
)`;
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
@@ -295,7 +317,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = variantIds.length > 0
? await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
@@ -305,7 +327,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
)
: await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
@@ -942,7 +964,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
@@ -1008,7 +1030,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents,
${POS_GROSS_PRICE_SQL} AS price_cents,
c.name AS category, b.name AS brand
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
@@ -1064,7 +1086,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (
@@ -1106,7 +1128,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (
@@ -2145,23 +2167,36 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
is_free_item: boolean;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
}>(
`SELECT id, name, sku, quantity, returned_quantity, is_free_item,
unit_price_cents, discount_cents
unit_price_cents, discount_cents, tax_cents, vat_rate
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),
})),
items: itemRows.rows.map((row) => {
const unitNetCents = Number(row.unit_price_cents);
const unitTaxCents = Number(row.tax_cents);
const unitGrossCents = row.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, row.vat_rate);
const discountGrossCents = Math.max(
0,
unitGrossCents - (unitNetCents - Number(row.discount_cents) + unitTaxCents),
);
return {
id: row.id,
name: row.name,
sku: row.sku,
quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item,
unitPriceCents: unitGrossCents,
discountCents: discountGrossCents,
};
}),
});
},
);

View File

@@ -175,7 +175,7 @@ export class ApplyPosReturnUseCase {
);
}
totalRefundCents +=
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity;
(item.unit_price_cents - item.discount_cents + item.tax_cents) * line.returnedQuantity;
if (!item.is_free_item && item.variant_id) {
stockUpdates.push({
variantId: item.variant_id,

View File

@@ -11,6 +11,7 @@ interface ReturnedItemRow {
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
name: string;
sku: string;
is_free_item: boolean;
@@ -47,6 +48,7 @@ interface ReceiptItemRow {
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
is_free_item: boolean;
}
@@ -80,7 +82,7 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
const [itemResult, paymentResult] = await Promise.all([
queryable.query<ReceiptItemRow>(
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, is_free_item
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, vat_rate, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
),
@@ -124,18 +126,25 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
sessionId: order.cash_session_id,
customerEmail: order.customer_email,
items: itemResult.rows.map((item) => {
const subtotalCents = Number(item.unit_price_cents) * Number(item.quantity);
const discountCents = Number(item.discount_cents) * Number(item.quantity);
const taxCents = Number(item.tax_cents) * Number(item.quantity);
const quantity = Number(item.quantity);
const unitNetCents = Number(item.unit_price_cents);
const unitTaxCents = Number(item.tax_cents);
const unitGrossCents = item.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, item.vat_rate);
const totalCents = (unitNetCents - Number(item.discount_cents) + unitTaxCents) * quantity;
const subtotalCents = unitGrossCents * quantity;
const discountCents = Math.max(0, subtotalCents - totalCents);
const taxCents = unitTaxCents * quantity;
return {
name: item.name,
sku: item.sku,
quantity: Number(item.quantity),
unitPriceCents: Number(item.unit_price_cents),
quantity,
unitPriceCents: unitGrossCents,
subtotalCents,
discountCents,
taxCents,
totalCents: subtotalCents - discountCents + taxCents,
totalCents,
freeItem: item.is_free_item,
};
}),
@@ -152,6 +161,15 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
};
}
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other';
}
@@ -196,7 +214,7 @@ export async function buildPosReturnReceipt(
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
vat_rate, name, sku, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
);
@@ -204,18 +222,20 @@ export async function buildPosReturnReceipt(
.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;
const unitNetCents = Number(row.unit_price_cents);
const unitTaxCents = Number(row.tax_cents);
const unitGrossCents = row.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, row.vat_rate);
const total = (unitNetCents - Number(row.discount_cents) + unitTaxCents) * returnedQuantity;
const subtotal = unitGrossCents * returnedQuantity;
const discountCents = Math.max(0, subtotal - total);
const taxCents = unitTaxCents * returnedQuantity;
return {
name: row.name,
sku: row.sku,
quantity: returnedQuantity,
unitPriceCents: unitPrice,
unitPriceCents: unitGrossCents,
subtotalCents: subtotal,
discountCents,
taxCents,

View File

@@ -24,10 +24,17 @@ interface CatalogLineRow {
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
vat_rate: string;
net_unit_amount_cents: number;
offer_cents: number | null;
vat_rate: 'general' | 'reduced' | 'super-reduced';
}
const POS_VAT_BASIS_POINTS: Record<CatalogLineRow['vat_rate'], number> = {
general: 2100,
reduced: 1000,
'super-reduced': 400,
};
export interface ConfiguredPaymentMethod {
id: string;
code: string;
@@ -114,6 +121,18 @@ export function validatePaymentAllocations(
return validated;
}
function grossFromNet(netUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(netUnitAmountCents * (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function netFromGross(grossUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(grossUnitAmountCents / (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function effectiveGrossUnitPrice(catalog: CatalogLineRow): number {
return catalog.offer_cents ?? grossFromNet(Number(catalog.net_unit_amount_cents), catalog.vat_rate);
}
export class CreatePosSaleUseCase {
constructor(private readonly pool: pg.Pool) {}
@@ -153,6 +172,9 @@ export class CreatePosSaleUseCase {
throw new AppError(400, 'POS_EMPTY_CART', 'El carrito está vacío');
const lineDiscountsEnabled = session.terminal_settings?.lineDiscountsEnabled !== false;
let subtotalCents = 0;
let discountCents = 0;
let taxCents = 0;
const items: PosSaleLineItem[] = [];
for (const inputItem of input.items) {
if (inputItem.kind === 'free') {
@@ -168,6 +190,7 @@ export class CreatePosSaleUseCase {
'El artículo libre requiere nombre y precio positivo',
);
}
subtotalCents += inputItem.unitPriceCents * inputItem.quantity;
items.push({
kind: 'free',
variantId: null,
@@ -187,7 +210,8 @@ export class CreatePosSaleUseCase {
const catalogResult = await client.query<CatalogLineRow>(
`SELECT variant.id AS variant_id, variant.product_id, variant.sku, variant.ean,
product.name,
COALESCE(price.offer_cents, price.net_unit_amount_cents) AS unit_price_cents,
price.net_unit_amount_cents,
price.offer_cents,
price.vat_rate
FROM catalog_product_variants variant
JOIN catalog_products product ON product.id = variant.product_id
@@ -198,15 +222,16 @@ export class CreatePosSaleUseCase {
const catalog = catalogResult.rows[0];
if (!catalog)
throw new AppError(404, 'POS_PRODUCT_NOT_FOUND', 'El producto ya no está disponible');
const discountCents = inputItem.discountCents ?? 0;
const grossUnitPriceCents = effectiveGrossUnitPrice(catalog);
const discountGrossCents = inputItem.discountCents ?? 0;
if (
!Number.isInteger(discountCents) ||
discountCents < 0 ||
discountCents > Number(catalog.unit_price_cents)
!Number.isInteger(discountGrossCents) ||
discountGrossCents < 0 ||
discountGrossCents > grossUnitPriceCents
) {
throw new AppError(400, 'POS_INVALID_DISCOUNT', 'El descuento de línea no es válido');
}
if (!lineDiscountsEnabled && discountCents > 0) {
if (!lineDiscountsEnabled && discountGrossCents > 0) {
throw new AppError(
403,
'POS_DISCOUNTS_DISABLED',
@@ -226,6 +251,18 @@ export class CreatePosSaleUseCase {
`Stock insuficiente para ${catalog.name}`,
);
}
const netUnitPriceCents =
catalog.offer_cents === null
? Number(catalog.net_unit_amount_cents)
: netFromGross(grossUnitPriceCents, catalog.vat_rate);
const discountedGrossUnitCents = grossUnitPriceCents - discountGrossCents;
const discountedNetUnitCents = netFromGross(discountedGrossUnitCents, catalog.vat_rate);
const discountNetCents = Math.max(0, netUnitPriceCents - discountedNetUnitCents);
const taxUnitCents = discountedGrossUnitCents - discountedNetUnitCents;
subtotalCents += grossUnitPriceCents * inputItem.quantity;
discountCents += discountGrossCents * inputItem.quantity;
taxCents += taxUnitCents * inputItem.quantity;
items.push({
kind: 'stock',
variantId: catalog.variant_id,
@@ -233,24 +270,15 @@ export class CreatePosSaleUseCase {
sku: catalog.sku,
ean: catalog.ean,
name: catalog.name,
unitPriceCents: Number(catalog.unit_price_cents),
discountCents,
taxCents: 0,
unitPriceCents: netUnitPriceCents,
discountCents: discountNetCents,
taxCents: taxUnitCents,
quantity: inputItem.quantity,
vatRate: catalog.vat_rate,
});
}
const subtotalCents = items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountCents = items.reduce(
(sum, item) => sum + item.discountCents * item.quantity,
0,
);
const taxCents = items.reduce((sum, item) => sum + item.taxCents * item.quantity, 0);
const totalCents = subtotalCents - discountCents + taxCents;
const totalCents = subtotalCents - discountCents;
if (totalCents <= 0)
throw new AppError(400, 'POS_INVALID_TOTAL', 'El total de la venta debe ser positivo');