Entreprise POS System
This commit is contained in:
parent
f5648adb2f
commit
b141425440
@ -68,6 +68,7 @@ const receipt_templatesRoutes = require('./routes/receipt_templates');
|
|||||||
const devicesRoutes = require('./routes/devices');
|
const devicesRoutes = require('./routes/devices');
|
||||||
|
|
||||||
const audit_eventsRoutes = require('./routes/audit_events');
|
const audit_eventsRoutes = require('./routes/audit_events');
|
||||||
|
const posRoutes = require('./routes/pos');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/pos', passport.authenticate('jwt', {session: false}), posRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
31
backend/src/routes/pos.js
Normal file
31
backend/src/routes/pos.js
Normal file
@ -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;
|
||||||
334
backend/src/services/pos.js
Normal file
334
backend/src/services/pos.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user