diff --git a/backend/src/routes/invoices.js b/backend/src/routes/invoices.js index 2629fc4..9221ff4 100644 --- a/backend/src/routes/invoices.js +++ b/backend/src/routes/invoices.js @@ -5,8 +5,6 @@ const InvoicesService = require('../services/invoices'); const InvoicesDBApi = require('../db/api/invoices'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); @@ -116,6 +114,21 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.get('/workbench', wrapAsync(async (req, res) => { + const payload = await InvoicesService.getWorkbench(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/workbench', wrapAsync(async (req, res) => { + const payload = await InvoicesService.getWorkbench(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/issue-workflow', wrapAsync(async (req, res) => { + const payload = await InvoicesService.issueWorkflow(req.body.data, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/budgets/bulk-import: diff --git a/backend/src/services/invoices.js b/backend/src/services/invoices.js index b0356af..5b83515 100644 --- a/backend/src/services/invoices.js +++ b/backend/src/services/invoices.js @@ -1,17 +1,900 @@ const db = require('../db/models'); const InvoicesDBApi = require('../db/api/invoices'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); +const Invoice_linesDBApi = require('../db/api/invoice_lines'); +const PaymentsDBApi = require('../db/api/payments'); +const ReceiptsDBApi = require('../db/api/receipts'); +const Efris_submissionsDBApi = require('../db/api/efris_submissions'); +const processFile = require('../middlewares/upload'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const { Op } = db.Sequelize; +const PAYMENT_METHODS = new Set([ + 'cash', + 'bank_transfer', + 'card', + 'mobile_money', + 'cheque', + 'other', +]); +function createBadRequestError(message) { + const error = new Error(message); + error.code = 400; + return error; +} +function roundCurrency(value) { + return Math.round((Number(value) + Number.EPSILON) * 100) / 100; +} + +function toNumber(value, fallback = 0) { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : fallback; +} + +function ensureValidDate(value, fallback) { + const date = value ? new Date(value) : new Date(fallback); + + if (Number.isNaN(date.getTime())) { + throw createBadRequestError('Please enter a valid date.'); + } + + return date; +} + +function addDays(date, days) { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + days); + return nextDate; +} + +function formatDateForInput(date) { + return date.toISOString().slice(0, 10); +} + +function buildDocumentNumber(prefix = 'INV') { + const now = new Date(); + const datePart = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String( + now.getDate(), + ).padStart(2, '0')}`; + const timePart = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart( + 2, + '0', + )}${String(now.getSeconds()).padStart(2, '0')}`; + const entropy = `${Math.floor(100 + Math.random() * 900)}`; + + return `${prefix}-${datePart}-${timePart}${entropy}`; +} + +function getContactLabel(contact) { + if (!contact) { + return 'Unknown customer'; + } + + return ( + contact.display_name || + contact.company_name || + [contact.first_name, contact.last_name].filter(Boolean).join(' ').trim() || + contact.email || + 'Unnamed customer' + ); +} + +function buildWorkflowUser(currentUser, organizationId) { + const organization = + currentUser.organization || + currentUser.organizations || + (organizationId + ? { + id: organizationId, + } + : null); + + return { + ...currentUser, + organization, + organizations: currentUser.organizations || organization, + organizationsId: currentUser.organizationsId || organizationId, + }; +} + +function getOrganizationId(currentUser) { + const membershipOrganizationId = + currentUser?.memberships_user?.find((membership) => membership.status === 'active')?.organizationId || + currentUser?.memberships_user?.[0]?.organizationId || + null; + + return ( + currentUser?.organization?.id || + currentUser?.organizations?.id || + currentUser?.organizationId || + currentUser?.organizationsId || + membershipOrganizationId || + null + ); +} + +function serializeJson(payload) { + return JSON.stringify(payload, null, 2); +} + +function mapWorkbenchInvoice(invoice) { + return { + id: invoice.id, + invoice_number: invoice.invoice_number, + status: invoice.status, + issue_date: invoice.issue_date, + due_date: invoice.due_date, + total_amount: invoice.total_amount, + amount_paid: invoice.amount_paid, + balance_due: invoice.balance_due, + createdAt: invoice.createdAt, + customerName: getContactLabel(invoice.customer), + }; +} + +function mapWorkbenchPayment(payment) { + return { + id: payment.id, + payment_reference: payment.payment_reference, + payment_date: payment.payment_date, + amount: payment.amount, + method: payment.method, + status: payment.status, + customerName: getContactLabel(payment.customer), + invoiceNumber: payment.invoice?.invoice_number || null, + }; +} + +function mapWorkbenchSubmission(submission) { + return { + id: submission.id, + document_type: submission.document_type, + submission_status: submission.submission_status, + efris_invoice_no: submission.efris_invoice_no, + efris_receipt_no: submission.efris_receipt_no, + verification_code: submission.verification_code, + error_message: submission.error_message, + createdAt: submission.createdAt, + invoiceId: submission.invoice?.id || null, + invoiceNumber: submission.invoice?.invoice_number || null, + receiptId: submission.receipt?.id || null, + receiptNumber: submission.receipt?.receipt_number || null, + }; +} module.exports = class InvoicesService { + static async getWorkbench(currentUser) { + const organizationId = getOrganizationId(currentUser); + + if (!organizationId) { + throw createBadRequestError('Your account is not linked to an organization yet.'); + } + + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const [ + organization, + settings, + activeConnection, + latestConnection, + customerCount, + allContactCount, + productCount, + openInvoiceCount, + pendingEfrisCount, + paidInvoiceCount, + collectedThisMonth, + customers, + fallbackContacts, + products, + recentInvoices, + recentPayments, + recentSubmissions, + ] = await Promise.all([ + db.organizations.findByPk(organizationId, { + attributes: ['id', 'name'], + }), + db.organization_settings.findOne({ + where: { organizationId }, + order: [['updatedAt', 'DESC']], + }), + db.efris_connections.findOne({ + where: { organizationId, status: 'active' }, + order: [['updatedAt', 'DESC']], + }), + db.efris_connections.findOne({ + where: { organizationId }, + order: [['updatedAt', 'DESC']], + }), + db.contacts.count({ + where: { + organizationId, + contact_type: 'customer', + status: 'active', + }, + }), + db.contacts.count({ + where: { + organizationId, + }, + }), + db.products.count({ + where: { + organizationId, + is_active: true, + }, + }), + db.invoices.count({ + where: { + organizationId, + status: { + [Op.in]: ['sent', 'partially_paid', 'overdue'], + }, + }, + }), + db.efris_submissions.count({ + where: { + organizationId, + submission_status: { + [Op.in]: ['queued', 'submitted', 'failed'], + }, + }, + }), + db.invoices.count({ + where: { + organizationId, + status: 'paid', + }, + }), + db.payments.sum('amount', { + where: { + organizationId, + status: 'posted', + payment_date: { + [Op.gte]: startOfMonth, + }, + }, + }), + db.contacts.findAll({ + where: { + organizationId, + contact_type: 'customer', + status: 'active', + }, + attributes: ['id', 'display_name', 'company_name', 'email', 'tin'], + order: [['display_name', 'ASC']], + limit: 50, + }), + db.contacts.findAll({ + where: { + organizationId, + }, + attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email', 'tin'], + order: [['display_name', 'ASC']], + limit: 50, + }), + db.products.findAll({ + where: { + organizationId, + is_active: true, + }, + attributes: ['id', 'name', 'description', 'sales_price', 'is_taxable', 'item_type', 'sku'], + include: [ + { + model: db.tax_codes, + as: 'tax_code', + attributes: ['id', 'name', 'code', 'rate'], + required: false, + }, + ], + order: [['name', 'ASC']], + limit: 75, + }), + db.invoices.findAll({ + where: { organizationId }, + include: [ + { + model: db.contacts, + as: 'customer', + attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email'], + required: false, + }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + db.payments.findAll({ + where: { organizationId }, + include: [ + { + model: db.contacts, + as: 'customer', + attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email'], + required: false, + }, + { + model: db.invoices, + as: 'invoice', + attributes: ['id', 'invoice_number'], + required: false, + }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + db.efris_submissions.findAll({ + where: { organizationId }, + include: [ + { + model: db.invoices, + as: 'invoice', + attributes: ['id', 'invoice_number'], + required: false, + }, + { + model: db.receipts, + as: 'receipt', + attributes: ['id', 'receipt_number'], + required: false, + }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + ]); + + const connection = activeConnection || latestConnection; + const availableCustomers = customers.length ? customers : fallbackContacts; + const visibleCustomerCount = customerCount || allContactCount; + const defaultIssueDate = new Date(); + const defaultDueDate = addDays(defaultIssueDate, 7); + + return { + organization: organization + ? { + id: organization.id, + name: organization.name, + } + : null, + summary: { + customerCount: visibleCustomerCount, + productCount, + openInvoiceCount, + pendingEfrisCount, + paidInvoiceCount, + collectedThisMonth: roundCurrency(toNumber(collectedThisMonth, 0)), + }, + connection: connection + ? { + id: connection.id, + environment: connection.environment, + status: connection.status, + branch_code: connection.branch_code, + device_number: connection.device_number, + business_name_on_efris: connection.business_name_on_efris, + tin_on_efris: connection.tin_on_efris, + api_base_url: connection.api_base_url, + last_connected_at: connection.last_connected_at, + last_error_message: connection.last_error_message, + } + : null, + settings: { + default_currency_code: settings?.default_currency_code || 'UGX', + invoice_prefix: settings?.invoice_prefix || 'INV', + receipt_prefix: settings?.receipt_prefix || 'RCT', + default_vat_rate: roundCurrency(toNumber(settings?.default_vat_rate, 0)), + invoice_footer_note: settings?.invoice_footer_note || '', + }, + suggestedDefaults: { + issueDate: formatDateForInput(defaultIssueDate), + dueDate: formatDateForInput(defaultDueDate), + currencyCode: settings?.default_currency_code || 'UGX', + termsAndConditions: settings?.invoice_footer_note || '', + }, + customers: availableCustomers.map((customer) => ({ + id: customer.id, + display_name: getContactLabel(customer), + email: customer.email, + tin: customer.tin, + })), + products: products.map((product) => ({ + id: product.id, + name: product.name, + description: product.description, + sales_price: product.sales_price, + item_type: product.item_type, + sku: product.sku, + is_taxable: product.is_taxable, + tax_rate: product.is_taxable + ? roundCurrency(toNumber(product.tax_code?.rate, settings?.default_vat_rate || 0)) + : 0, + tax_code: product.tax_code + ? { + id: product.tax_code.id, + name: product.tax_code.name, + code: product.tax_code.code, + rate: product.tax_code.rate, + } + : null, + })), + recentInvoices: recentInvoices.map(mapWorkbenchInvoice), + recentPayments: recentPayments.map(mapWorkbenchPayment), + recentSubmissions: recentSubmissions.map(mapWorkbenchSubmission), + }; + } + + static async issueWorkflow(data, currentUser) { + const organizationId = getOrganizationId(currentUser); + + if (!organizationId) { + throw createBadRequestError('Your account is not linked to an organization yet.'); + } + + if (!data || typeof data !== 'object') { + throw createBadRequestError('No invoice data was provided.'); + } + + if (!data.customerId) { + throw createBadRequestError('Please choose a customer before issuing an invoice.'); + } + + if (!Array.isArray(data.lineItems) || !data.lineItems.length) { + throw createBadRequestError('Add at least one product or service line.'); + } + + const workflowUser = buildWorkflowUser(currentUser, organizationId); + const transaction = await db.sequelize.transaction(); + + try { + const settings = await db.organization_settings.findOne({ + where: { organizationId }, + order: [['updatedAt', 'DESC']], + transaction, + }); + + const activeConnection = await db.efris_connections.findOne({ + where: { organizationId, status: 'active' }, + order: [['updatedAt', 'DESC']], + transaction, + }); + + const customer = await db.contacts.findOne({ + where: { + id: data.customerId, + organizationId, + }, + transaction, + }); + + if (!customer) { + throw createBadRequestError('The selected customer could not be found in this organization.'); + } + + const issueDate = ensureValidDate(data.issueDate, new Date()); + const dueDate = ensureValidDate(data.dueDate, addDays(issueDate, 7)); + + if (dueDate.getTime() < issueDate.getTime()) { + throw createBadRequestError('The due date cannot be earlier than the issue date.'); + } + + const uniqueProductIds = [...new Set(data.lineItems.map((item) => item.productId).filter(Boolean))]; + + if (!uniqueProductIds.length) { + throw createBadRequestError('Each line needs a product or service.'); + } + + const products = await db.products.findAll({ + where: { + id: { + [Op.in]: uniqueProductIds, + }, + organizationId, + }, + include: [ + { + model: db.tax_codes, + as: 'tax_code', + attributes: ['id', 'name', 'code', 'rate'], + required: false, + }, + ], + transaction, + }); + + const productsById = new Map(products.map((product) => [product.id, product])); + const fallbackVatRate = roundCurrency(toNumber(settings?.default_vat_rate, 0)); + + const normalizedLineItems = data.lineItems.map((item, index) => { + if (!item?.productId) { + throw createBadRequestError(`Line ${index + 1} is missing a product or service.`); + } + + const product = productsById.get(item.productId); + + if (!product) { + throw createBadRequestError(`Line ${index + 1} refers to a product outside this organization.`); + } + + const quantity = toNumber(item.quantity, NaN); + + if (!Number.isFinite(quantity) || quantity <= 0) { + throw createBadRequestError(`Line ${index + 1} must have a quantity greater than zero.`); + } + + const unitPrice = + item.unitPrice !== undefined && item.unitPrice !== null && item.unitPrice !== '' + ? toNumber(item.unitPrice, NaN) + : toNumber(product.sales_price, NaN); + + if (!Number.isFinite(unitPrice) || unitPrice < 0) { + throw createBadRequestError(`Line ${index + 1} has an invalid unit price.`); + } + + const discountRate = toNumber(item.discountRate, 0); + + if (discountRate < 0 || discountRate > 100) { + throw createBadRequestError(`Line ${index + 1} has an invalid discount percentage.`); + } + + const grossAmount = roundCurrency(quantity * unitPrice); + const discountAmount = roundCurrency(grossAmount * (discountRate / 100)); + const lineSubtotal = roundCurrency(grossAmount - discountAmount); + const taxRate = product.is_taxable ? roundCurrency(toNumber(product.tax_code?.rate, fallbackVatRate)) : 0; + const taxAmount = roundCurrency(lineSubtotal * (taxRate / 100)); + const lineTotal = roundCurrency(lineSubtotal + taxAmount); + + return { + product, + quantity, + unitPrice, + discountRate, + discountAmount, + taxRate, + lineSubtotal, + taxAmount, + lineTotal, + description: item.description || product.description || product.name, + taxCodeId: product.tax_code?.id || null, + }; + }); + + const subtotalAmount = roundCurrency( + normalizedLineItems.reduce((sum, item) => sum + item.lineSubtotal, 0), + ); + const discountAmount = roundCurrency( + normalizedLineItems.reduce((sum, item) => sum + item.discountAmount, 0), + ); + const taxAmount = roundCurrency( + normalizedLineItems.reduce((sum, item) => sum + item.taxAmount, 0), + ); + const totalAmount = roundCurrency( + normalizedLineItems.reduce((sum, item) => sum + item.lineTotal, 0), + ); + + const takePaymentNow = Boolean(data.takePaymentNow); + const paymentAmount = takePaymentNow + ? roundCurrency( + toNumber( + data.paymentAmount !== undefined && data.paymentAmount !== null && data.paymentAmount !== '' + ? data.paymentAmount + : totalAmount, + NaN, + ), + ) + : 0; + + if (takePaymentNow) { + if (!Number.isFinite(paymentAmount) || paymentAmount <= 0) { + throw createBadRequestError('Enter a valid payment amount to capture now.'); + } + + if (paymentAmount > totalAmount) { + throw createBadRequestError('The payment amount cannot be greater than the invoice total.'); + } + } + + const balanceDue = roundCurrency(totalAmount - paymentAmount); + const paymentMethod = takePaymentNow ? data.paymentMethod || 'mobile_money' : null; + + if (paymentMethod && !PAYMENT_METHODS.has(paymentMethod)) { + throw createBadRequestError('Choose a supported payment method.'); + } + + const invoicePrefix = settings?.invoice_prefix || 'INV'; + const receiptPrefix = settings?.receipt_prefix || 'RCT'; + const currencyCode = data.currencyCode || settings?.default_currency_code || 'UGX'; + const invoiceStatus = !takePaymentNow ? 'sent' : balanceDue <= 0 ? 'paid' : 'partially_paid'; + + const invoice = await InvoicesDBApi.create( + { + customer: customer.id, + invoice_number: data.invoiceNumber || buildDocumentNumber(invoicePrefix), + invoice_type: 'standard', + status: invoiceStatus, + issue_date: issueDate, + due_date: dueDate, + currency_code: currencyCode, + exchange_rate: 1, + subtotal_amount: subtotalAmount, + tax_amount: taxAmount, + discount_amount: discountAmount, + total_amount: totalAmount, + amount_paid: paymentAmount, + balance_due: balanceDue, + customer_notes: data.customerNotes || null, + terms_and_conditions: data.termsAndConditions || settings?.invoice_footer_note || null, + invoice_lines: [], + }, + { + currentUser: workflowUser, + transaction, + }, + ); + + const createdLineItems = []; + + for (const item of normalizedLineItems) { + const createdLine = await Invoice_linesDBApi.create( + { + invoice: invoice.id, + product: item.product.id, + tax_code: item.taxCodeId, + organizations: organizationId, + line_description: item.description, + quantity: item.quantity, + unit_price: item.unitPrice, + discount_rate: item.discountRate, + line_subtotal: item.lineSubtotal, + tax_amount: item.taxAmount, + line_total: item.lineTotal, + }, + { + currentUser: workflowUser, + transaction, + }, + ); + + createdLineItems.push(createdLine); + } + + let payment = null; + let receipt = null; + + if (takePaymentNow) { + const paymentDate = ensureValidDate(data.paymentDate, new Date()); + + payment = await PaymentsDBApi.create( + { + invoice: invoice.id, + customer: customer.id, + payment_reference: data.paymentReference || buildDocumentNumber('PAY'), + payment_date: paymentDate, + amount: paymentAmount, + method: paymentMethod, + currency_code: currencyCode, + exchange_rate: 1, + status: 'posted', + notes: `Captured from Revenue Desk for ${invoice.invoice_number}`, + }, + { + currentUser: workflowUser, + transaction, + }, + ); + + receipt = await ReceiptsDBApi.create( + { + invoice: invoice.id, + payment: payment.id, + receipt_number: data.receiptNumber || buildDocumentNumber(receiptPrefix), + receipt_date: paymentDate, + receipt_amount: paymentAmount, + status: 'issued', + notes: `Receipt generated from Revenue Desk for ${invoice.invoice_number}`, + }, + { + currentUser: workflowUser, + transaction, + }, + ); + } + + let efrisSubmission = null; + const shouldSubmitToEfris = data.submitToEfris !== false; + + if (shouldSubmitToEfris) { + const verificationCode = activeConnection + ? `VC-${Math.floor(100000 + Math.random() * 900000)}` + : null; + const efrisInvoiceNo = activeConnection + ? `UG-EFR-${Math.floor(100000 + Math.random() * 900000)}` + : null; + const efrisReceiptNo = activeConnection && receipt + ? `UG-RCT-${Math.floor(100000 + Math.random() * 900000)}` + : null; + const documentType = receipt ? 'receipt' : 'invoice'; + const requestPayload = { + organizationId, + customer: { + id: customer.id, + displayName: getContactLabel(customer), + tin: customer.tin || null, + }, + invoice: { + id: invoice.id, + invoiceNumber: invoice.invoice_number, + totalAmount, + currencyCode, + }, + receipt: receipt + ? { + id: receipt.id, + receiptNumber: receipt.receipt_number, + receiptAmount: receipt.receipt_amount, + } + : null, + lineItems: normalizedLineItems.map((item) => ({ + productId: item.product.id, + productName: item.product.name, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + lineTotal: item.lineTotal, + })), + }; + + const responsePayload = activeConnection + ? { + status: 'ACCEPTED_PREVIEW', + simulation: true, + environment: activeConnection.environment, + efrisInvoiceNo, + efrisReceiptNo, + verificationCode, + note: 'Revenue Desk created a sandbox-ready preview payload for the first MVP iteration.', + } + : { + status: 'QUEUED', + note: 'No active EFRIS connection was found. Configure one to push this document upstream.', + }; + + efrisSubmission = await Efris_submissionsDBApi.create( + { + invoice: invoice.id, + receipt: receipt?.id || null, + document_type: documentType, + submission_status: activeConnection ? 'accepted' : 'queued', + submitted_at: activeConnection ? new Date() : null, + last_attempt_at: activeConnection ? new Date() : null, + attempt_count: activeConnection ? 1 : 0, + request_payload_json: serializeJson(requestPayload), + response_payload_json: serializeJson(responsePayload), + efris_invoice_no: efrisInvoiceNo, + efris_receipt_no: efrisReceiptNo, + verification_code: verificationCode, + qr_code_data: activeConnection + ? `URA|${activeConnection.tin_on_efris || 'TIN'}|${invoice.invoice_number}|${verificationCode}` + : null, + error_message: activeConnection + ? 'Sandbox preview generated. Replace preview mode with the live URA API handshake next.' + : 'Awaiting an active EFRIS connection.', + }, + { + currentUser: workflowUser, + transaction, + }, + ); + } + + await db.audit_events.create( + { + event_type: shouldSubmitToEfris ? 'submit_efris' : 'create', + entity_name: 'invoices', + entity_reference: invoice.invoice_number, + event_time: new Date(), + details_json: serializeJson({ + source: 'Revenue Desk', + invoiceId: invoice.id, + paymentId: payment?.id || null, + receiptId: receipt?.id || null, + efrisSubmissionId: efrisSubmission?.id || null, + totalAmount, + balanceDue, + }), + organizationId, + actorId: currentUser.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + + return { + message: shouldSubmitToEfris + ? 'Invoice workflow completed and an EFRIS record was created.' + : 'Invoice workflow completed.', + invoice: { + id: invoice.id, + invoice_number: invoice.invoice_number, + status: invoice.status, + total_amount: invoice.total_amount, + amount_paid: invoice.amount_paid, + balance_due: invoice.balance_due, + issue_date: invoice.issue_date, + due_date: invoice.due_date, + }, + customer: { + id: customer.id, + display_name: getContactLabel(customer), + email: customer.email, + tin: customer.tin, + }, + lineItems: normalizedLineItems.map((item) => ({ + productId: item.product.id, + productName: item.product.name, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + lineTotal: item.lineTotal, + discountRate: item.discountRate, + })), + payment: payment + ? { + id: payment.id, + payment_reference: payment.payment_reference, + payment_date: payment.payment_date, + amount: payment.amount, + method: payment.method, + status: payment.status, + } + : null, + receipt: receipt + ? { + id: receipt.id, + receipt_number: receipt.receipt_number, + receipt_date: receipt.receipt_date, + receipt_amount: receipt.receipt_amount, + status: receipt.status, + } + : null, + efrisSubmission: efrisSubmission + ? { + id: efrisSubmission.id, + document_type: efrisSubmission.document_type, + submission_status: efrisSubmission.submission_status, + efris_invoice_no: efrisSubmission.efris_invoice_no, + efris_receipt_no: efrisSubmission.efris_receipt_no, + verification_code: efrisSubmission.verification_code, + qr_code_data: efrisSubmission.qr_code_data, + error_message: efrisSubmission.error_message, + preview_mode: Boolean(activeConnection), + } + : null, + summary: { + subtotalAmount, + discountAmount, + taxAmount, + totalAmount, + amountPaid: paymentAmount, + balanceDue, + currencyCode, + connectorStatus: activeConnection ? `${activeConnection.status} (${activeConnection.environment})` : 'not_connected', + }, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { @@ -28,9 +911,9 @@ module.exports = class InvoicesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,24 +921,24 @@ module.exports = class InvoicesService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream .pipe(csv()) - .on('data', (data) => results.push(data)) + .on('data', (row) => results.push(row)) .on('end', async () => { console.log('CSV results', results); resolve(); }) .on('error', (error) => reject(error)); - }) + }); await InvoicesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,15 +951,13 @@ module.exports = class InvoicesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let invoices = await InvoicesDBApi.findBy( - {id}, - {transaction}, + const invoices = await InvoicesDBApi.findBy( + { id }, + { transaction }, ); if (!invoices) { - throw new ValidationError( - 'invoicesNotFound', - ); + throw createBadRequestError('Invoice not found.'); } const updatedInvoices = await InvoicesDBApi.update( @@ -90,12 +971,11 @@ module.exports = class InvoicesService { await transaction.commit(); return updatedInvoices; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -131,8 +1011,4 @@ module.exports = class InvoicesService { throw error; } } - - }; - - diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index b8fb5fb..0bb1d16 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index f6188b4..ba7b3ef 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/revenue-desk', + icon: icon.mdiChartTimelineVariant, + label: 'Revenue desk', + permissions: 'CREATE_INVOICES' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ad66d42..8bba5d3 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,161 +1,322 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import * as icon from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import LayoutGuest from '../layouts/Guest'; +const featureCards = [ + { + title: 'Revenue desk', + description: 'Issue invoices, capture payment, and create the fiscal trail in one operator flow.', + iconPath: icon.mdiChartTimelineVariant, + }, + { + title: 'CRM + contacts', + description: 'Keep customer context, tax IDs, and billing details close to every invoice.', + iconPath: icon.mdiAccountMultiple, + }, + { + title: 'Inventory + services', + description: 'Blend stocked products and service lines in the same workflow.', + iconPath: icon.mdiPackageVariantClosed, + }, + { + title: 'EFRIS-ready records', + description: 'Store fiscal submission status, verification codes, and receipt references per tenant.', + iconPath: icon.mdiReceiptText, + }, +]; + +const workflowSteps = [ + { + title: 'Select customer + line items', + description: 'Start from an active customer, choose products/services, and preview totals with tax and discount applied.', + }, + { + title: 'Issue invoice', + description: 'Generate an invoice number, due date, notes, and tenant-specific defaults in one screen.', + }, + { + title: 'Capture payment when needed', + description: 'If the customer pays immediately, create the payment and receipt without bouncing between pages.', + }, + { + title: 'Create EFRIS trail', + description: 'Store the submission record, status, and verification metadata to keep the compliance path visible.', + }, +]; + +const moduleHighlights = [ + { + label: 'Accounting core', + text: 'Customers, products, invoices, payments, receipts, expenses, and lightweight reporting in one tenant-aware workspace.', + }, + { + label: 'Zoho-style context', + text: 'CRM-flavoured contact management, projects/tasks extensions, and room for approval flows without overwhelming the operator.', + }, + { + label: 'Compliance first', + text: 'URA EFRIS integration points live next to the day-to-day billing flow, not as a separate afterthought.', + }, + { + label: 'Multi-tenant by design', + text: 'Each organization keeps isolated data, settings, prefixes, users, and connector state inside the same SaaS app.', + }, +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'EFRIS Accounting Suite' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-For guides and documentation please check - your local README.md and the Flatlogic documentation
-+ EFRIS Accounting Suite +
++ QuickBooks-style finance ops with Zoho-style business context. +
++ This workspace is designed for small and mid-sized businesses that need cleaner books, faster invoice-to-cash workflows, and tenant-aware fiscal records without juggling disconnected tools. +
+Daily operator flow
+Invoice → Payment → Receipt
+Compliance
+EFRIS submission trail
+Deployment model
+Multi-tenant SaaS
+First MVP slice
+{item}
+What your team gets today
+{feature.title}
+{feature.description}
+© 2026 {title}. All rights reserved
- - Privacy Policy - + +Platform highlights
++ Instead of rebuilding generic CRUD, the app now exposes the first operator workflow that ties those modules together in a way a business owner can actually use daily. +
+{item.label}
+{item.text}
+Initial workflow
++ The Revenue Desk is the first concrete win: create/input, confirmation, recent list review, and drill-through detail pages are all wired together. +
+ +Why this first
++ It turns your current entities—customers, products, invoices, payments, receipts, EFRIS submissions—into the core day-to-day workflow a business owner actually cares about. +
+{step.title}
+{step.description}
+Next action
++ You can start from the Revenue Desk immediately, then expand into quotes, expenses, reporting, approvals, and deeper live EFRIS API handshakes in the next iteration. +
+Direct links
+{title}
+{value}
+{caption}
+Unable to load the revenue desk.
+{loadError}
++ Built for {organizationName}. This first workflow compresses quoting follow-up, invoicing, payment capture, and fiscal submission into a single operator-friendly screen. +
+Connector
++ {workbench?.connection?.business_name_on_efris || 'No active EFRIS connection yet'} +
++ Default currency {workbench?.settings.default_currency_code} · Prefix {workbench?.settings.invoice_prefix} +
+Active customers
+{workbench?.summary.customerCount ?? 0}
+Ready for invoicing in this tenant
+Active products
+{workbench?.summary.productCount ?? 0}
+SKU and service catalog ready
+Open invoices
+{workbench?.summary.openInvoiceCount ?? 0}
+Still awaiting full settlement
+Collected this month
++ {formatCurrency( + workbench?.summary.collectedThisMonth ?? 0, + workbench?.settings.default_currency_code || 'UGX', + )} +
+Posted payments this month
+Issue workflow
++ Start with a customer, add lines, choose whether to capture payment immediately, and the system will create the invoice, payment, receipt, and fiscal trail together. +
+Line items
++ Use products or services already configured in your tenant. +
++ Line {index + 1} +
++ Tax {currentProduct?.is_taxable ? `${currentProduct.tax_rate}%` : '0%'} +
++ Live total +
++ {formatCurrency(linePreview?.lineTotal || 0, form.currencyCode)} +
++ SKU: {currentProduct?.sku || '—'} +
++ Type: {currentProduct?.item_type || '—'} +
++ Tax code: {currentProduct?.tax_code?.code || 'Default'} +
++ Settlement +
++ Fiscalization +
++ {workbench?.connection?.business_name_on_efris || 'Connector not configured'} +
++ {workbench?.connection + ? `Branch ${workbench.connection.branch_code || '—'} · TIN ${workbench.connection.tin_on_efris || '—'}` + : 'No active EFRIS connection is attached to this organization yet. The workflow will still create the invoice and queue the fiscal submission.'} +
+ {workbench?.connection?.last_error_message && ( ++ Last connector message: {workbench.connection.last_error_message} +
+ )} +Live totals
+Recent invoices
+{invoice.invoice_number}
+{invoice.customerName}
++ Issued {formatDate(invoice.issue_date)} · Due {formatDate(invoice.due_date)} +
+Total
++ {formatCurrency(invoice.total_amount, workbench.settings.default_currency_code)} +
+Balance due
++ {formatCurrency(invoice.balance_due, workbench.settings.default_currency_code)} +
+Recent payments
+{payment.payment_reference}
+{payment.customerName}
++ {payment.invoiceNumber ? `Invoice ${payment.invoiceNumber} · ` : ''} + {formatDate(payment.payment_date)} +
+EFRIS connector
++ {workbench.connection.business_name_on_efris || 'Connected organization'} +
++ {workbench.connection.environment} +
+TIN: {workbench.connection.tin_on_efris || '—'}
+Device: {workbench.connection.device_number || '—'}
+Branch: {workbench.connection.branch_code || '—'}
+Last connected: {formatDate(workbench.connection.last_connected_at)}
+Submission trail
++ {submission.invoiceNumber || submission.receiptNumber || 'Submission'} +
++ {humanizeStatus(submission.document_type)} +
+EFRIS invoice no: {submission.efris_invoice_no || 'Pending'}
+Receipt no: {submission.efris_receipt_no || '—'}
+Verification: {submission.verification_code || 'Pending'}
+Operator guide
+{item.title}
+{item.description}
+