From b141425440e42dfb12faa8132ba57440c6a723e0 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 2 May 2026 08:26:44 +0000 Subject: [PATCH] Entreprise POS System --- backend/src/index.js | 3 + backend/src/routes/pos.js | 31 ++++ backend/src/services/pos.js | 334 ++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 backend/src/routes/pos.js create mode 100644 backend/src/services/pos.js diff --git a/backend/src/index.js b/backend/src/index.js index 0bf7832..3ee0264 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -68,6 +68,7 @@ const receipt_templatesRoutes = require('./routes/receipt_templates'); const devicesRoutes = require('./routes/devices'); const audit_eventsRoutes = require('./routes/audit_events'); +const posRoutes = require('./routes/pos'); const getBaseUrl = (url) => { @@ -173,6 +174,8 @@ app.use('/api/devices', passport.authenticate('jwt', {session: false}), devicesR app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes); +app.use('/api/pos', passport.authenticate('jwt', {session: false}), posRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/pos.js b/backend/src/routes/pos.js new file mode 100644 index 0000000..6b334aa --- /dev/null +++ b/backend/src/routes/pos.js @@ -0,0 +1,31 @@ +const express = require('express'); + +const PosService = require('../services/pos'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.post( + '/open-session', + checkPermissions('CREATE_REGISTER_SESSIONS'), + wrapAsync(async (req, res) => { + const payload = await PosService.openSession(req.body, req.currentUser); + res.status(200).send(payload); + }), +); + +router.post( + '/checkout', + checkPermissions('CREATE_SALES'), + checkPermissions('CREATE_SALE_ITEMS'), + checkPermissions('CREATE_PAYMENTS'), + wrapAsync(async (req, res) => { + const payload = await PosService.checkout(req.body, req.currentUser); + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/pos.js b/backend/src/services/pos.js new file mode 100644 index 0000000..3c2c0ae --- /dev/null +++ b/backend/src/services/pos.js @@ -0,0 +1,334 @@ +const db = require('../db/models'); +const DesksDBApi = require('../db/api/desks'); +const PaymentsDBApi = require('../db/api/payments'); +const Register_sessionsDBApi = require('../db/api/register_sessions'); +const Sale_itemsDBApi = require('../db/api/sale_items'); +const SalesDBApi = require('../db/api/sales'); + +const createBadRequest = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const toNumber = (value) => { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + return 0; + } + + return Math.round(parsed * 100) / 100; +}; + +const toMoneyString = (value) => toNumber(value).toFixed(2); + +const getOrganizationId = (currentUser) => + currentUser?.organizations?.id || currentUser?.organizationsId || null; + +const buildReceiptNumber = () => { + const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + const suffix = Math.floor(1000 + Math.random() * 9000); + + return `POS-${timestamp}-${suffix}`; +}; + +module.exports = class PosService { + static async openSession(data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const deskId = data?.deskId; + + if (!deskId) { + throw createBadRequest('Select a desk before opening a register session.'); + } + + const desk = await DesksDBApi.findBy({ id: deskId }, { transaction }); + + if (!desk) { + throw createBadRequest('The selected desk could not be found.'); + } + + const organizationId = getOrganizationId(currentUser) || desk?.organizations?.id || null; + const existingSession = await db.register_sessions.findOne({ + where: { + deskId, + status: 'open', + ...(organizationId ? { organizationsId: organizationId } : {}), + }, + transaction, + }); + + if (existingSession) { + const payload = await Register_sessionsDBApi.findBy( + { id: existingSession.id }, + { transaction }, + ); + + await transaction.commit(); + + return { + reused: true, + session: payload, + }; + } + + const openingCashAmount = toNumber(data?.openingCashAmount); + + if (openingCashAmount < 0) { + throw createBadRequest('Opening cash cannot be negative.'); + } + + const session = await Register_sessionsDBApi.create( + { + opened_at: new Date(), + status: 'open', + opening_cash_amount: toMoneyString(openingCashAmount), + expected_cash_amount: toMoneyString(openingCashAmount), + desk: deskId, + store: desk?.store?.id || desk?.storeId || null, + opened_by_user: currentUser.id, + organizations: organizationId, + }, + { + currentUser, + transaction, + }, + ); + + const payload = await Register_sessionsDBApi.findBy( + { id: session.id }, + { transaction }, + ); + + await transaction.commit(); + + return { + reused: false, + session: payload, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async checkout(data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const registerSessionId = data?.register_sessionId; + const deskId = data?.deskId; + const cartItems = Array.isArray(data?.items) ? data.items : []; + const paymentMethod = data?.payment?.method; + const paymentReference = data?.payment?.reference || null; + const paymentLast4 = data?.payment?.card_last4 || null; + const notes = data?.notes || null; + + if (!registerSessionId) { + throw createBadRequest('An open register session is required to complete checkout.'); + } + + if (!deskId) { + throw createBadRequest('Select a desk before taking payment.'); + } + + if (!cartItems.length) { + throw createBadRequest('Add at least one product to the cart before checkout.'); + } + + if (!['cash', 'card'].includes(paymentMethod)) { + throw createBadRequest('Select cash or card as the payment method.'); + } + + const registerSession = await Register_sessionsDBApi.findBy( + { id: registerSessionId }, + { transaction }, + ); + + if (!registerSession) { + throw createBadRequest('The selected register session could not be found.'); + } + + if (registerSession.status !== 'open') { + throw createBadRequest('Only open register sessions can accept new sales.'); + } + + if (registerSession?.desk?.id && registerSession.desk.id !== deskId) { + throw createBadRequest('The selected desk does not match the active register session.'); + } + + const organizationId = getOrganizationId(currentUser) || registerSession?.organizations?.id || null; + const storeId = + registerSession?.store?.id || + registerSession?.storeId || + registerSession?.desk?.store?.id || + registerSession?.desk?.storeId || + null; + + if (!storeId) { + throw createBadRequest('The selected desk must belong to a store before checkout can continue.'); + } + + const normalizedItems = []; + let subtotalAmount = 0; + let taxAmount = 0; + let totalAmount = 0; + + for (const item of cartItems) { + if (!item?.productId) { + throw createBadRequest('Each cart line must reference a product.'); + } + + const product = await db.products.findByPk(item.productId, { + include: [ + { + model: db.tax_rates, + as: 'tax_rate', + }, + ], + transaction, + }); + + if (!product) { + throw createBadRequest('One of the products in the cart no longer exists.'); + } + + const quantity = Number(item.quantity); + + if (!Number.isFinite(quantity) || quantity <= 0) { + throw createBadRequest('Each cart line must have a quantity greater than zero.'); + } + + const unitPrice = toNumber(item.unitPrice ?? product.default_price); + const lineSubtotalAmount = toNumber(unitPrice * quantity); + const taxRate = product.is_taxable ? toNumber(product?.tax_rate?.rate_percent) : 0; + const lineTaxAmount = toNumber((lineSubtotalAmount * taxRate) / 100); + const lineTotalAmount = toNumber(lineSubtotalAmount + lineTaxAmount); + + subtotalAmount += lineSubtotalAmount; + taxAmount += lineTaxAmount; + totalAmount += lineTotalAmount; + + normalizedItems.push({ + product, + quantity, + unitPrice, + lineTaxAmount, + lineTotalAmount, + }); + } + + subtotalAmount = toNumber(subtotalAmount); + taxAmount = toNumber(taxAmount); + totalAmount = toNumber(totalAmount); + + const amountPaid = + paymentMethod === 'cash' + ? toNumber(data?.payment?.amount_paid) + : totalAmount; + + if (paymentMethod === 'cash' && amountPaid < totalAmount) { + throw createBadRequest('Cash tendered must be greater than or equal to the sale total.'); + } + + const changeDueAmount = + paymentMethod === 'cash' + ? toNumber(amountPaid - totalAmount) + : 0; + + const sale = await SalesDBApi.create( + { + receipt_number: buildReceiptNumber(), + sold_at: new Date(), + status: 'paid', + subtotal_amount: toMoneyString(subtotalAmount), + discount_amount: toMoneyString(0), + tax_amount: toMoneyString(taxAmount), + total_amount: toMoneyString(totalAmount), + amount_paid: toMoneyString(amountPaid), + change_due_amount: toMoneyString(changeDueAmount), + notes, + store: storeId, + desk: deskId, + register_session: registerSessionId, + cashier_user: currentUser.id, + organizations: organizationId, + }, + { + currentUser, + transaction, + }, + ); + + for (const item of normalizedItems) { + await Sale_itemsDBApi.create( + { + item_name_snapshot: item.product.product_name, + sku_snapshot: item.product.sku, + unit_price: toMoneyString(item.unitPrice), + quantity: String(item.quantity), + discount_amount: toMoneyString(0), + tax_amount: toMoneyString(item.lineTaxAmount), + line_total_amount: toMoneyString(item.lineTotalAmount), + sale: sale.id, + product: item.product.id, + organizations: organizationId, + }, + { + currentUser, + transaction, + }, + ); + } + + await PaymentsDBApi.create( + { + paid_at: new Date(), + method: paymentMethod, + amount: toMoneyString(totalAmount), + status: 'captured', + reference: paymentReference, + card_last4: paymentMethod === 'card' ? paymentLast4 : null, + provider: paymentMethod === 'card' ? 'Card Terminal' : 'Cash Drawer', + sale: sale.id, + organizations: organizationId, + }, + { + currentUser, + transaction, + }, + ); + + if (paymentMethod === 'cash') { + const baseExpectedCash = + registerSession?.expected_cash_amount !== null && registerSession?.expected_cash_amount !== undefined + ? toNumber(registerSession.expected_cash_amount) + : toNumber(registerSession?.opening_cash_amount); + + await Register_sessionsDBApi.update( + registerSessionId, + { + expected_cash_amount: toMoneyString(baseExpectedCash + totalAmount), + }, + { + currentUser, + transaction, + }, + ); + } + + const payload = await SalesDBApi.findBy({ id: sale.id }, { transaction }); + + await transaction.commit(); + + return { + sale: payload, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +};