Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions electron/database/repositories/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,36 @@ export function addCreditPayment(
}

const transaction = db.transaction(() => {
// If the payment date falls after due_date and the surcharge has not been
// applied yet, apply it now so the payment is settled against the correct
// total_due. Using the payment date (not today) so backdated payments
// within the grace period keep the original amount.
const existingCredit = getCreditById(creditId);
if (!existingCredit) {
throw new Error('El credito no existe');
}

const paymentDatePart = paymentCreatedAt.slice(0, 10);
if (
existingCredit.status !== 'paid'
&& existingCredit.surcharge_applied === 0
&& existingCredit.surcharge_percent > 0
&& paymentDatePart > existingCredit.due_date
) {
const newTotalDue = roundMoney(
existingCredit.original_amount * (1 + existingCredit.surcharge_percent / 100)
);
const surchargeAmount = roundMoney(newTotalDue - existingCredit.original_amount);

db.prepare(
"UPDATE credits SET total_due = ?, surcharge_applied = 1, status = 'overdue' WHERE id = ?"
).run(newTotalDue, creditId);

db.prepare(
'UPDATE sales SET surcharge = ?, total = subtotal + ? WHERE id = ?'
).run(surchargeAmount, surchargeAmount, existingCredit.sale_id);
}

// Insert payment record
db.prepare(
'INSERT INTO credit_payments (credit_id, amount, cash_register_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?)'
Expand Down
52 changes: 40 additions & 12 deletions electron/database/repositories/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,31 @@ interface AddMovementData {
export function addInventoryMovement(data: AddMovementData): InventoryMovement {
const db = getDatabase();

if (!Number.isInteger(data.product_id) || data.product_id < 1) {
throw new Error('ID de producto invalido');
}

if (data.type !== 'in' && data.type !== 'out' && data.type !== 'adjustment') {
throw new Error('Tipo de movimiento invalido');
}

if (!Number.isFinite(data.quantity) || !Number.isInteger(data.quantity) || data.quantity === 0) {
throw new Error('La cantidad debe ser un entero distinto de cero');
}

if ((data.type === 'in' || data.type === 'out') && data.quantity < 0) {
throw new Error('La cantidad debe ser positiva para entradas y salidas');
}

const transaction = db.transaction(() => {
const product = db
.prepare('SELECT id, stock FROM products WHERE id = ?')
.get(data.product_id) as { id: number; stock: number } | undefined;

if (!product) {
throw new Error('El producto no existe');
}

if (data.type === 'in' && (data.cost_price !== undefined || data.margin_percent !== undefined)) {
if (data.cost_price !== undefined && (!Number.isFinite(data.cost_price) || data.cost_price <= 0)) {
throw new Error('El precio de costo debe ser mayor a 0');
Expand Down Expand Up @@ -45,6 +69,20 @@ export function addInventoryMovement(data: AddMovementData): InventoryMovement {
}
}

// Compute resulting stock and block adjustments that would leave it negative
let nextStock = product.stock;
if (data.type === 'in') {
nextStock = product.stock + Math.abs(data.quantity);
} else if (data.type === 'out') {
nextStock = product.stock - Math.abs(data.quantity);
} else {
nextStock = product.stock + data.quantity;
}

if (nextStock < 0) {
throw new Error('El ajuste dejaria el stock en negativo');
}

const result = db.prepare(`
INSERT INTO inventory_movements (product_id, type, quantity, reference_id, notes)
VALUES (?, ?, ?, ?, ?)
Expand All @@ -56,18 +94,8 @@ export function addInventoryMovement(data: AddMovementData): InventoryMovement {
data.notes ?? null
);

// Update product stock based on movement type
if (data.type === 'in') {
db.prepare('UPDATE products SET stock = stock + ? WHERE id = ?')
.run(Math.abs(data.quantity), data.product_id);
} else if (data.type === 'out') {
db.prepare('UPDATE products SET stock = stock - ? WHERE id = ?')
.run(Math.abs(data.quantity), data.product_id);
} else {
// adjustment: quantity is the new absolute value to set
db.prepare('UPDATE products SET stock = stock + ? WHERE id = ?')
.run(data.quantity, data.product_id);
}
db.prepare('UPDATE products SET stock = ? WHERE id = ?')
.run(nextStock, data.product_id);

return Number(result.lastInsertRowid);
});
Expand Down
74 changes: 71 additions & 3 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,11 +885,79 @@ function registerIpcHandlers(): void {
});

// Inventory
ipcMain.handle(IPC_CHANNELS.INVENTORY_ADD_MOVEMENT, (_, data) => {
ipcMain.handle(IPC_CHANNELS.INVENTORY_ADD_MOVEMENT, (_, data: unknown) => {
const raw = (typeof data === 'object' && data !== null ? data : {}) as Record<string, unknown>;

const productId = normalizePositiveIntegerId(raw.product_id, 'producto');

if (raw.type !== 'in' && raw.type !== 'out' && raw.type !== 'adjustment') {
throw new Error('Tipo de movimiento invalido');
}
const type: 'in' | 'out' | 'adjustment' = raw.type;

const quantityRaw = typeof raw.quantity === 'string' ? Number(raw.quantity) : raw.quantity;
if (
typeof quantityRaw !== 'number'
|| !Number.isFinite(quantityRaw)
|| !Number.isInteger(quantityRaw)
|| quantityRaw === 0
) {
throw new Error('La cantidad debe ser un entero distinto de cero');
}
if ((type === 'in' || type === 'out') && quantityRaw < 0) {
throw new Error('La cantidad debe ser positiva para entradas y salidas');
}

let referenceId: number | null = null;
if (raw.reference_id !== undefined && raw.reference_id !== null) {
const parsedRef = typeof raw.reference_id === 'string' ? Number(raw.reference_id) : raw.reference_id;
if (typeof parsedRef !== 'number' || !Number.isInteger(parsedRef) || parsedRef < 1) {
throw new Error('Referencia invalida');
}
referenceId = parsedRef;
}

let notes: string | null = null;
if (raw.notes !== undefined && raw.notes !== null) {
if (typeof raw.notes !== 'string') {
throw new Error('Las notas deben ser texto');
}
const trimmed = raw.notes.trim();
notes = trimmed.length === 0 ? null : trimmed.slice(0, 500);
}

let costPrice: number | undefined;
if (raw.cost_price !== undefined) {
const parsed = typeof raw.cost_price === 'string' ? Number(raw.cost_price) : raw.cost_price;
if (typeof parsed !== 'number' || !Number.isFinite(parsed) || parsed <= 0) {
throw new Error('El precio de costo debe ser mayor a 0');
}
costPrice = parsed;
}

let marginPercent: number | undefined;
if (raw.margin_percent !== undefined) {
const parsed = typeof raw.margin_percent === 'string' ? Number(raw.margin_percent) : raw.margin_percent;
if (typeof parsed !== 'number' || !Number.isFinite(parsed) || parsed < 0) {
throw new Error('El porcentaje de utilidad no puede ser negativo');
}
marginPercent = parsed;
}

const sanitized = {
product_id: productId,
type,
quantity: quantityRaw,
reference_id: referenceId,
notes,
cost_price: costPrice,
margin_percent: marginPercent,
};

if (canUseCloudApi()) {
return cloudApi.addInventoryMovement(data);
return cloudApi.addInventoryMovement(sanitized);
}
return inventoryRepo.addInventoryMovement(data);
return inventoryRepo.addInventoryMovement(sanitized);
});
ipcMain.handle(IPC_CHANNELS.INVENTORY_GET_BY_PRODUCT, (_, productId: number) => {
if (canUseCloudApi()) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "store-internal",
"private": true,
"version": "0.8.15",
"version": "0.8.16",
"license": "Apache-2.0",
"type": "module",
"main": "dist-electron/main.js",
Expand Down
8 changes: 8 additions & 0 deletions src/pages/InventoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ export function InventoryPage() {

const qty = parseInt(formQuantity);
if (isNaN(qty) || qty === 0) {
setFormError(
activeTab === 'adjustment'
? 'La cantidad del ajuste no puede ser 0'
: 'La cantidad debe ser mayor a 0'
);
return;
}
if (activeTab === 'restock' && qty < 0) {
setFormError('La cantidad debe ser mayor a 0');
return;
}
Expand Down
Loading