From 4edef96187c02f7187b09e2fec59da186a0f426e Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 1 May 2026 22:23:13 +0000 Subject: [PATCH] 1 --- backend/src/routes/invoices.js | 17 +- backend/src/services/invoices.js | 926 ++++++++++- frontend/src/components/AsideMenuLayer.tsx | 4 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/index.tsx | 442 +++-- frontend/src/pages/revenue-desk.tsx | 1719 ++++++++++++++++++++ frontend/src/pages/search.tsx | 4 +- 9 files changed, 2946 insertions(+), 178 deletions(-) create mode 100644 frontend/src/pages/revenue-desk.tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('EFRIS Accounting Suite')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

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. +

+
+ + + + + - +
+ +
+
+
+ Built for SME operators, accountants, and org admins +
+ +
+

+ Modern accounting, invoicing, CRM context, and URA EFRIS readiness in one SaaS. +

+

+ 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

+

Revenue desk

+
+
+ Live admin workflow +
+
+ +
+ {[ + 'Create an invoice from real customer + product data', + 'Optionally take payment and auto-create the receipt', + 'Generate an EFRIS-ready submission record per tenant', + 'Review recent invoices, payments, and fiscal events together', + ].map((item) => ( +
+
+ +
+

{item}

+
+ ))} +
+ +
+

What your team gets today

+
+ {featureCards.map((feature) => ( +
+
+
+ +
+

{feature.title}

+
+

{feature.description}

+
+ ))} +
+
+
+
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - + +
+
+
+

Platform highlights

+

+ The foundation already spans accounting, operations, customer context, and compliance. +

+

+ 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. +

+
+ +
+ {moduleHighlights.map((item) => ( + +
+

{item.label}

+

{item.text}

+
+
+ ))} +
+
+
+
+
+
+

Initial workflow

+

+ One thin slice that behaves like a real product, not just a brochure. +

+

+ 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. +

+ + + + +
+
+ +
+ {workflowSteps.map((step, index) => ( +
+
+
+ {index + 1} +
+
+

{step.title}

+

{step.description}

+
+
+
+ ))} +
+
+
+
+ +
+
+
+
+
+

Next action

+

+ Sign in, open the admin interface, and try the first end-to-end flow. +

+

+ 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

+
+ {[ + { href: '/login', label: 'Login' }, + { href: '/dashboard', label: 'Open admin interface' }, + { href: '/revenue-desk', label: 'Launch revenue desk' }, + ].map((item) => ( + + {item.label} + + + ))} +
+
+
+
+ +
+

© 2026 EFRIS Accounting Suite. Multi-tenant accounting + compliance for modern SMEs.

+
+ + Login + + + Admin interface + + + Privacy policy + +
+
+
+
); } @@ -163,4 +324,3 @@ export default function Starter() { Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/revenue-desk.tsx b/frontend/src/pages/revenue-desk.tsx new file mode 100644 index 0000000..cc5bcae --- /dev/null +++ b/frontend/src/pages/revenue-desk.tsx @@ -0,0 +1,1719 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { + ReactElement, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import LoadingSpinner from '../components/LoadingSpinner'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type PaymentMethod = 'cash' | 'bank_transfer' | 'card' | 'mobile_money' | 'cheque' | 'other'; + +type DeskCustomer = { + id: string; + display_name: string; + email?: string; + tin?: string; +}; + +type DeskProduct = { + id: string; + name: string; + description?: string; + sales_price: string | number; + item_type?: string; + sku?: string; + is_taxable: boolean; + tax_rate: number; + tax_code?: { + id: string; + name?: string; + code?: string; + rate: string | number; + } | null; +}; + +type DeskInvoice = { + id: string; + invoice_number: string; + status: string; + issue_date?: string; + due_date?: string; + total_amount: string | number; + amount_paid: string | number; + balance_due: string | number; + createdAt: string; + customerName: string; +}; + +type DeskPayment = { + id: string; + payment_reference: string; + payment_date?: string; + amount: string | number; + method: string; + status: string; + customerName: string; + invoiceNumber?: string | null; +}; + +type DeskSubmission = { + id: string; + document_type: string; + submission_status: string; + efris_invoice_no?: string | null; + efris_receipt_no?: string | null; + verification_code?: string | null; + error_message?: string | null; + createdAt: string; + invoiceId?: string | null; + invoiceNumber?: string | null; + receiptId?: string | null; + receiptNumber?: string | null; +}; + +type WorkbenchData = { + organization: { + id: string; + name: string; + } | null; + summary: { + customerCount: number; + productCount: number; + openInvoiceCount: number; + pendingEfrisCount: number; + paidInvoiceCount: number; + collectedThisMonth: number; + }; + connection: { + id: string; + environment: string; + status: string; + branch_code?: string; + device_number?: string; + business_name_on_efris?: string; + tin_on_efris?: string; + api_base_url?: string; + last_connected_at?: string; + last_error_message?: string; + } | null; + settings: { + default_currency_code: string; + invoice_prefix: string; + receipt_prefix: string; + default_vat_rate: number; + invoice_footer_note?: string; + }; + suggestedDefaults: { + issueDate: string; + dueDate: string; + currencyCode: string; + termsAndConditions: string; + }; + customers: DeskCustomer[]; + products: DeskProduct[]; + recentInvoices: DeskInvoice[]; + recentPayments: DeskPayment[]; + recentSubmissions: DeskSubmission[]; +}; + +type WorkflowSuccess = { + message: string; + invoice: { + id: string; + invoice_number: string; + status: string; + total_amount: string | number; + amount_paid: string | number; + balance_due: string | number; + }; + customer: { + id: string; + display_name: string; + }; + payment?: { + id: string; + payment_reference: string; + amount: string | number; + method: string; + status: string; + } | null; + receipt?: { + id: string; + receipt_number: string; + receipt_amount: string | number; + status: string; + } | null; + efrisSubmission?: { + id: string; + document_type: string; + submission_status: string; + efris_invoice_no?: string | null; + efris_receipt_no?: string | null; + verification_code?: string | null; + error_message?: string | null; + preview_mode?: boolean; + } | null; + summary: { + totalAmount: number; + amountPaid: number; + balanceDue: number; + currencyCode: string; + connectorStatus: string; + }; +}; + +type LineItemState = { + id: string; + productId: string; + description: string; + quantity: string; + unitPrice: string; + discountRate: string; +}; + +type FormState = { + customerId: string; + issueDate: string; + dueDate: string; + currencyCode: string; + customerNotes: string; + termsAndConditions: string; + takePaymentNow: boolean; + paymentAmount: string; + paymentMethod: PaymentMethod; + paymentReference: string; + paymentDate: string; + submitToEfris: boolean; + lineItems: LineItemState[]; +}; + +type PreviewLine = { + id: string; + productName: string; + taxRate: number; + lineTotal: number; +}; + +type PreviewTotals = { + lines: PreviewLine[]; + subtotal: number; + discount: number; + tax: number; + total: number; +}; + +const PAYMENT_METHOD_OPTIONS: Array<{ value: PaymentMethod; label: string }> = [ + { value: 'mobile_money', label: 'Mobile money' }, + { value: 'cash', label: 'Cash' }, + { value: 'bank_transfer', label: 'Bank transfer' }, + { value: 'card', label: 'Card' }, + { value: 'cheque', label: 'Cheque' }, + { value: 'other', label: 'Other' }, +]; + +const STATUS_STYLES: Record = { + accepted: 'bg-emerald-100 text-emerald-700', + active: 'bg-emerald-100 text-emerald-700', + paid: 'bg-emerald-100 text-emerald-700', + posted: 'bg-emerald-100 text-emerald-700', + issued: 'bg-emerald-100 text-emerald-700', + sent: 'bg-blue-100 text-blue-700', + submitted: 'bg-blue-100 text-blue-700', + partially_paid: 'bg-amber-100 text-amber-700', + queued: 'bg-amber-100 text-amber-700', + draft: 'bg-slate-100 text-slate-700', + pending: 'bg-slate-100 text-slate-700', + disabled: 'bg-slate-100 text-slate-700', + overdue: 'bg-rose-100 text-rose-700', + failed: 'bg-rose-100 text-rose-700', + error: 'bg-rose-100 text-rose-700', + void: 'bg-slate-200 text-slate-700', +}; + +const inputClassName = + 'h-12 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm outline-none transition focus:border-blue-600 focus:ring focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white'; +const textAreaClassName = + 'min-h-[128px] w-full rounded border border-gray-300 bg-white px-3 py-3 text-sm text-gray-900 shadow-sm outline-none transition focus:border-blue-600 focus:ring focus:ring-blue-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white'; + +function createLineItem(): LineItemState { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + productId: '', + description: '', + quantity: '1', + unitPrice: '', + discountRate: '0', + }; +} + +function createInitialFormState(): FormState { + const today = new Date().toISOString().slice(0, 10); + const dueDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + return { + customerId: '', + issueDate: today, + dueDate, + currencyCode: 'UGX', + customerNotes: '', + termsAndConditions: '', + takePaymentNow: true, + paymentAmount: '', + paymentMethod: 'mobile_money', + paymentReference: '', + paymentDate: today, + submitToEfris: true, + lineItems: [createLineItem()], + }; +} + +function parseAmount(value: string | number | undefined | null): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function roundAmount(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function formatCurrency(value: number | string, currencyCode: string): string { + try { + return new Intl.NumberFormat('en-UG', { + style: 'currency', + currency: currencyCode || 'UGX', + maximumFractionDigits: 2, + }).format(parseAmount(value)); + } catch (error) { + return `${currencyCode || 'UGX'} ${parseAmount(value).toFixed(2)}`; + } +} + +function formatDate(value?: string | null): string { + if (!value) { + return '—'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '—'; + } + + return new Intl.DateTimeFormat('en-UG', { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(date); +} + +function humanizeStatus(value?: string | null): string { + if (!value) { + return 'Unknown'; + } + + return value.replace(/_/g, ' '); +} + +function computePreviewTotals( + lineItems: LineItemState[], + productsById: Record, +): PreviewTotals { + return lineItems.reduce( + (accumulator, lineItem) => { + const product = productsById[lineItem.productId]; + const quantity = parseAmount(lineItem.quantity); + const unitPrice = + lineItem.unitPrice !== '' ? parseAmount(lineItem.unitPrice) : parseAmount(product?.sales_price); + const discountRate = parseAmount(lineItem.discountRate); + const gross = roundAmount(quantity * unitPrice); + const discount = roundAmount(gross * (discountRate / 100)); + const subtotal = roundAmount(gross - discount); + const taxRate = product?.is_taxable ? parseAmount(product.tax_rate) : 0; + const tax = roundAmount(subtotal * (taxRate / 100)); + const total = roundAmount(subtotal + tax); + + return { + lines: [ + ...accumulator.lines, + { + id: lineItem.id, + productName: product?.name || 'Select a product', + taxRate, + lineTotal: total, + }, + ], + subtotal: roundAmount(accumulator.subtotal + subtotal), + discount: roundAmount(accumulator.discount + discount), + tax: roundAmount(accumulator.tax + tax), + total: roundAmount(accumulator.total + total), + }; + }, + { + lines: [], + subtotal: 0, + discount: 0, + tax: 0, + total: 0, + }, + ); +} + +function getApiError(error: unknown): string { + if (axios.isAxiosError(error)) { + return typeof error.response?.data === 'string' + ? error.response.data + : error.message || 'A request failed.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Something went wrong. Please try again.'; +} + +function StatusBadge({ value }: { value?: string | null }) { + const normalized = value || 'unknown'; + return ( + + {humanizeStatus(normalized)} + + ); +} + +function MetricCard({ + title, + value, + caption, + iconPath, +}: { + title: string; + value: string; + caption: string; + iconPath: string; +}) { + return ( + +
+
+

{title}

+

{value}

+

{caption}

+
+
+ +
+
+
+ ); +} + +function ActionCard({ + enabled, + href, + className, + children, +}: { + enabled: boolean; + href: string; + className: string; + children: ReactNode; +}) { + if (!enabled) { + return
{children}
; + } + + return ( + + {children} + + ); +} + +const RevenueDesk = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const canCreateInvoices = hasPermission(currentUser, 'CREATE_INVOICES'); + const canReadInvoices = hasPermission(currentUser, 'READ_INVOICES'); + const canReadPayments = hasPermission(currentUser, 'READ_PAYMENTS'); + const canReadEfrisSubmissions = hasPermission(currentUser, 'READ_EFRIS_SUBMISSIONS'); + const canReadEfrisConnections = hasPermission(currentUser, 'READ_EFRIS_CONNECTIONS'); + const canReadContacts = hasPermission(currentUser, 'READ_CONTACTS'); + const canReadProducts = hasPermission(currentUser, 'READ_PRODUCTS'); + const canCreateContacts = hasPermission(currentUser, 'CREATE_CONTACTS'); + const canCreateProducts = hasPermission(currentUser, 'CREATE_PRODUCTS'); + const canManageEfris = hasPermission(currentUser, 'CREATE_EFRIS_CONNECTIONS'); + + const [workbench, setWorkbench] = useState(null); + const [form, setForm] = useState(createInitialFormState); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loadError, setLoadError] = useState(''); + const [submitError, setSubmitError] = useState(''); + const [successState, setSuccessState] = useState(null); + const defaultsApplied = useRef(false); + + const productsById = useMemo(() => { + return Object.fromEntries((workbench?.products || []).map((product) => [product.id, product])); + }, [workbench?.products]); + + const previewTotals = useMemo( + () => computePreviewTotals(form.lineItems, productsById), + [form.lineItems, productsById], + ); + + const loadWorkbench = useCallback(async () => { + setIsLoading(true); + setLoadError(''); + + try { + const { data } = await axios.post('/invoices/workbench', {}); + setWorkbench(data); + } catch (error) { + setLoadError(getApiError(error)); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadWorkbench(); + }, [loadWorkbench]); + + useEffect(() => { + if (!workbench || defaultsApplied.current) { + return; + } + + setForm((current) => ({ + ...current, + issueDate: workbench.suggestedDefaults.issueDate, + dueDate: workbench.suggestedDefaults.dueDate, + currencyCode: workbench.suggestedDefaults.currencyCode, + paymentDate: workbench.suggestedDefaults.issueDate, + termsAndConditions: + current.termsAndConditions || workbench.suggestedDefaults.termsAndConditions || '', + })); + + defaultsApplied.current = true; + }, [workbench]); + + useEffect(() => { + if (!form.takePaymentNow || form.paymentAmount) { + return; + } + + if (previewTotals.total > 0) { + setForm((current) => ({ + ...current, + paymentAmount: String(previewTotals.total), + })); + } + }, [form.takePaymentNow, form.paymentAmount, previewTotals.total]); + + const validateForm = useCallback(() => { + if (!canCreateInvoices) { + return 'You do not have permission to create invoices from this workspace.'; + } + + if (!form.customerId) { + return 'Select a customer before issuing the invoice.'; + } + + if (!form.lineItems.length) { + return 'Add at least one line item.'; + } + + for (const [index, lineItem] of form.lineItems.entries()) { + if (!lineItem.productId) { + return `Choose a product or service on line ${index + 1}.`; + } + + if (parseAmount(lineItem.quantity) <= 0) { + return `Line ${index + 1} needs a quantity greater than zero.`; + } + + const currentProduct = productsById[lineItem.productId]; + const unitPrice = + lineItem.unitPrice !== '' + ? parseAmount(lineItem.unitPrice) + : parseAmount(currentProduct?.sales_price); + + if (unitPrice < 0 || !Number.isFinite(unitPrice)) { + return `Line ${index + 1} needs a valid unit price.`; + } + + const discountRate = parseAmount(lineItem.discountRate); + if (discountRate < 0 || discountRate > 100) { + return `Line ${index + 1} has an invalid discount percentage.`; + } + } + + if (form.takePaymentNow) { + const immediatePayment = parseAmount(form.paymentAmount || previewTotals.total); + + if (immediatePayment <= 0) { + return 'Enter a payment amount greater than zero or switch off “take payment now”.'; + } + + if (immediatePayment > previewTotals.total) { + return 'The payment amount cannot be greater than the invoice total.'; + } + } + + return ''; + }, [ + canCreateInvoices, + form.customerId, + form.lineItems, + form.paymentAmount, + form.takePaymentNow, + previewTotals.total, + productsById, + ]); + + const handleLineChange = (lineId: string, field: keyof LineItemState, value: string) => { + setForm((current) => ({ + ...current, + lineItems: current.lineItems.map((lineItem) => + lineItem.id === lineId ? { ...lineItem, [field]: value } : lineItem, + ), + })); + }; + + const handleProductChange = (lineId: string, productId: string) => { + const product = productsById[productId]; + + setForm((current) => ({ + ...current, + lineItems: current.lineItems.map((lineItem) => + lineItem.id === lineId + ? { + ...lineItem, + productId, + description: product?.description || product?.name || '', + unitPrice: product ? String(parseAmount(product.sales_price)) : '', + } + : lineItem, + ), + })); + }; + + const addLineItem = () => { + setForm((current) => ({ + ...current, + lineItems: [...current.lineItems, createLineItem()], + })); + }; + + const removeLineItem = (lineId: string) => { + setForm((current) => ({ + ...current, + lineItems: + current.lineItems.length === 1 + ? current.lineItems + : current.lineItems.filter((lineItem) => lineItem.id !== lineId), + })); + }; + + const resetForm = useCallback(() => { + setForm((current) => ({ + ...createInitialFormState(), + issueDate: workbench?.suggestedDefaults.issueDate || current.issueDate, + dueDate: workbench?.suggestedDefaults.dueDate || current.dueDate, + paymentDate: workbench?.suggestedDefaults.issueDate || current.paymentDate, + currencyCode: workbench?.suggestedDefaults.currencyCode || current.currencyCode, + termsAndConditions: workbench?.suggestedDefaults.termsAndConditions || '', + submitToEfris: true, + takePaymentNow: true, + })); + }, [workbench?.suggestedDefaults]); + + const handleSubmitWorkflow = async () => { + const validationMessage = validateForm(); + + if (validationMessage) { + setSubmitError(validationMessage); + return; + } + + setIsSubmitting(true); + setSubmitError(''); + + try { + const payload = { + customerId: form.customerId, + issueDate: form.issueDate, + dueDate: form.dueDate, + currencyCode: form.currencyCode, + customerNotes: form.customerNotes, + termsAndConditions: form.termsAndConditions, + takePaymentNow: form.takePaymentNow, + paymentAmount: form.takePaymentNow ? parseAmount(form.paymentAmount || previewTotals.total) : 0, + paymentMethod: form.paymentMethod, + paymentReference: form.paymentReference, + paymentDate: form.paymentDate, + submitToEfris: form.submitToEfris, + lineItems: form.lineItems.map((lineItem) => ({ + productId: lineItem.productId, + description: lineItem.description, + quantity: parseAmount(lineItem.quantity), + unitPrice: + lineItem.unitPrice !== '' + ? parseAmount(lineItem.unitPrice) + : parseAmount(productsById[lineItem.productId]?.sales_price), + discountRate: parseAmount(lineItem.discountRate), + })), + }; + + const { data } = await axios.post('/invoices/issue-workflow', { + data: payload, + }); + + setSuccessState(data); + resetForm(); + await loadWorkbench(); + + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } catch (error) { + setSubmitError(getApiError(error)); + } finally { + setIsSubmitting(false); + } + }; + + const connectionStatus = workbench?.connection?.status || 'not_connected'; + const organizationName = workbench?.organization?.name || currentUser?.organizations?.name || 'Your organization'; + const hasSetupData = Boolean(workbench?.customers.length && workbench?.products.length); + + return ( + <> + + {getPageTitle('Revenue desk')} + + + + + + {canReadInvoices && } + {canReadPayments && } + {canReadEfrisSubmissions && } + + + + {successState && ( + + {canReadInvoices && ( + + )} + {successState.payment && canReadPayments && ( + + )} + {successState.efrisSubmission && canReadEfrisSubmissions && ( + + )} + + } + > + {successState.invoice.invoice_number} was issued for{' '} + {successState.customer.display_name}. Total{' '} + + {formatCurrency(successState.summary.totalAmount, successState.summary.currencyCode)} + + ; connector status: {humanizeStatus(successState.summary.connectorStatus)}. + + )} + + {submitError && ( + + {submitError} + + )} + + {!canCreateInvoices && ( + + You have read-only access in the Revenue Desk. You can review activity here, but only users with invoice creation rights can issue invoices. + + )} + + {isLoading ? ( + + ) : loadError ? ( + +
+

Unable to load the revenue desk.

+

{loadError}

+ + loadWorkbench()} /> + +
+
+ ) : ( +
+ {!hasSetupData && ( + + {!workbench?.customers.length && canCreateContacts && ( + + )} + {!workbench?.products.length && canCreateProducts && ( + + )} + + } + > + The workflow is ready, but you still need at least one active customer and one active product or service before you can issue an invoice from this desk. + + )} + +
+
+ +
+
+
+
+
+
+ Multi-tenant finance cockpit +
+
+

+ Issue a compliant invoice, capture payment, and create the EFRIS trail in one move. +

+

+ 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?.environment || 'setup needed'} + +
+

+ {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

+

+ New invoice + optional payment + EFRIS entry +

+

+ 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. +

+
+ + {canReadContacts && } + {canReadProducts && } + +
+ +
+
+
+
+ + +
+
+ + + setForm((current) => ({ + ...current, + currencyCode: event.target.value.toUpperCase(), + })) + } + maxLength={5} + placeholder="UGX" + /> +
+
+ + + setForm((current) => ({ + ...current, + issueDate: event.target.value, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + dueDate: event.target.value, + })) + } + /> +
+
+ +
+
+
+

Line items

+

+ Use products or services already configured in your tenant. +

+
+ +
+ +
+ {form.lineItems.map((lineItem, index) => { + const currentProduct = productsById[lineItem.productId]; + const linePreview = previewTotals.lines.find((line) => line.id === lineItem.id); + + return ( +
+
+
+

+ Line {index + 1} +

+

+ Tax {currentProduct?.is_taxable ? `${currentProduct.tax_rate}%` : '0%'} +

+
+ removeLineItem(lineItem.id)} + disabled={form.lineItems.length === 1} + /> +
+ +
+
+ + +
+
+ + + handleLineChange(lineItem.id, 'quantity', event.target.value) + } + /> +
+
+ + + handleLineChange(lineItem.id, 'unitPrice', event.target.value) + } + placeholder={String(parseAmount(currentProduct?.sales_price))} + /> +
+
+ + + handleLineChange(lineItem.id, 'discountRate', event.target.value) + } + /> +
+
+ +
+
+ +