chore(checkpoint): save club core backend and pending pos fixes
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user