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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion backend/api_gateway/src/controllers/invoiceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export const getAllInvoices = async (
throw new Error('User not authenticated');
}
const token = req.headers.authorization?.split(' ')[1] || '';
const invoices = await invoiceAggregationService.getAllInvoices(user, token);
const invoices = await invoiceAggregationService.getAllInvoices(
user,
token,
req.query as Record<string, unknown>,
);
return res.status(200).json({
success: true,
data: invoices,
Expand Down Expand Up @@ -185,6 +189,25 @@ export const financeApproveQuotation = async (req: Request, res: Response, next:
/**
* Employee converts an approved quotation into an active Sale/Rent/Lease transaction.
*/
export const confirmServiceEstimateToAccounts = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const id = req.params.id as string;
const token = req.headers.authorization?.split(' ')[1] || '';
const invoice = await invoiceAggregationService.confirmServiceEstimateToAccounts(id, token);
return res.status(200).json({
success: true,
data: invoice,
message: 'Service estimate taken into accounts — receivable raised.',
});
} catch (error) {
next(error);
}
};

export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
Expand Down
11 changes: 11 additions & 0 deletions backend/api_gateway/src/routes/invoiceRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
processReturn,
financeApproveQuotation,
convertToTransaction,
confirmServiceEstimateToAccounts,
createDirectSale,
createQuotationTemplate,
getQuotationTemplates,
Expand Down Expand Up @@ -269,6 +270,16 @@ router.post(
convertToTransaction,
);

/**
* Accounts confirms a customer-accepted service estimate into the books, raising the
* receivable. Finance/Admin only — it is a bookkeeping act, not a service-desk one.
*/
router.post(
'/:id/confirm-service-to-accounts',
requireRole(UserRole.ADMIN, UserRole.FINANCE),
confirmServiceEstimateToAccounts,
);

/**
* Manager or Employee approval for a next-step action.
*/
Expand Down
44 changes: 44 additions & 0 deletions backend/api_gateway/src/services/invoiceAggregationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,28 @@ export class InvoiceAggregationService {
async getAllInvoices(
user: { role: string; branchId?: string },
token: string,
/**
* The caller's own filters (billType, status, …).
*
* These used to stop here: the gateway read none of req.query, so billing was always
* asked for every invoice and the "service estimates awaiting approval" request came
* back with paid sales and refunds in it. Forwarding them is what makes the filter
* billing already applies actually reachable from the browser.
*/
query?: Record<string, unknown>,
): Promise<AggregatedInvoice[]> {
try {
const forwarded: Record<string, string> = {};
for (const key of ['billType', 'status', 'saleType', 'customerId'] as const) {
const v = query?.[key];
if (typeof v === 'string' && v.trim()) forwarded[key] = v.trim();
}

const billingResponse = await axios.get<{ data: Invoice[] }>(
`${BILLING_SERVICE_URL}/invoices`,
{
headers: { Authorization: `Bearer ${token}` },
params: forwarded,
},
);
let invoices = billingResponse.data.data;
Expand Down Expand Up @@ -833,6 +849,34 @@ export class InvoiceAggregationService {
/**
* Employee converts an approved quotation into a transaction.
*/
/** Accounts taking a customer-accepted service estimate into the books. */
async confirmServiceEstimateToAccounts(id: string, token: string) {
try {
const response = await axios.post<{ data: Invoice }>(
`${BILLING_SERVICE_URL}/invoices/${id}/confirm-service-to-accounts`,
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
return response.data.data;
} catch (error: unknown) {
// Pass billing's own status and message through. Without this the gateway turned a
// precise 400 ("this one is FINANCE_APPROVED, not customer-accepted") into a bare
// 500, which tells the person clicking nothing about why it was refused.
if (axios.isAxiosError(error)) {
logger.error('Axios error confirming service estimate to accounts', {
message: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
throw new AppError(
error.response?.data?.message || 'Failed to take the estimate into accounts',
error.response?.status || 500,
);
}
throw new AppError('Internal Gateway Error confirming service estimate', 500);
}
}

async convertToTransaction(id: string, token: string) {
try {
const response = await axios.post<{ data: Invoice }>(
Expand Down
99 changes: 96 additions & 3 deletions backend/billing_service/src/controllers/invoiceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,71 @@ export const financeApproveQuotation = async (req: Request, res: Response, next:
/**
* Employee converts an approved quotation into an active Sale/Rent/Lease transaction.
*/
/**
* POST /invoices/:id/confirm-service-to-accounts
*
* Accounts taking a customer-accepted service estimate into the books.
*/
export const confirmServiceEstimateToAccounts = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const id = req.params.id as string;
if (!req.user?.userId) throw new AppError('User context missing', 401);
const invoice = await billingService.confirmServiceEstimateToAccounts(id, req.user.userId);
return res.status(200).json({
success: true,
data: invoice,
message: 'Service estimate taken into accounts — receivable raised.',
});
} catch (error) {
next(error);
}
};

/**
* POST /invoices/:id/service-completion-payment
*
* Internal: called by ven_inv when a technician closes a job having taken payment.
*/
export const recordServiceCompletionPayment = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const id = req.params.id as string;
const {
amount,
paymentMode,
accountId,
chequeNumber,
chequeBankName,
chequeDate,
remarks,
branchId,
collectedBy,
} = req.body;
const result = await billingService.recordServiceCompletionPayment({
invoiceId: id,
userId: collectedBy || req.user?.userId || 'SYSTEM',
amount: Number(amount) || 0,
paymentMode,
accountId,
chequeNumber,
chequeBankName,
chequeDate,
remarks,
branchId,
});
return res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};

export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
Expand Down Expand Up @@ -496,9 +561,24 @@ export const getAllInvoices = async (req: Request, res: Response, next: NextFunc
try {
const branchId = req.user?.role === 'ADMIN' ? undefined : req.user?.branchId;
const invoices = await billingService.getAllInvoices(branchId);

// `?billType=` and `?status=` were accepted and then silently ignored: this handler
// never looked at req.query, so a caller asking for "service estimates awaiting
// finance approval" was handed every invoice in the branch — paid sales, refunds and
// all. The Finance estimates page only looked right because it re-filtered the same
// list again in the browser; anything trusting the URL got the wrong rows.
const billType = (req.query.billType as string | undefined)?.trim();
const status = (req.query.status as string | undefined)?.trim();

const filtered = invoices.filter((inv) => {
if (billType && inv.billType !== billType) return false;
if (status && inv.status !== status) return false;
return true;
});

return res.status(200).json({
success: true,
data: invoices,
data: filtered,
});
} catch (error) {
next(error);
Expand Down Expand Up @@ -1611,6 +1691,7 @@ export const createServiceContractInvoice = async (
paymentDate: initialPayment.paymentDate,
referenceNumber: initialPayment.referenceNumber,
remarks: initialPayment.remarks,
cashAccountId: initialPayment.cashAccountId,
}
: undefined,
});
Expand Down Expand Up @@ -1689,14 +1770,19 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next
paymentMode,
accountId,
remarks,
collectedByName,
collectedByRole,
chequeNumber,
chequeBankName,
chequeDate,
} = req.body;
if (!serviceTicketId || !branchId) {
return res.status(400).json({
success: false,
message: 'serviceTicketId and branchId are required',
});
}
const invoice = await billingService.recordServiceVisitCharge({
const { invoice, paymentRequestId } = await billingService.recordServiceVisitCharge({
serviceTicketId,
ticketNumber,
customerId,
Expand All @@ -1706,8 +1792,15 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next
paymentMode,
accountId,
remarks,
collectedByName,
collectedByRole,
chequeNumber,
chequeBankName,
chequeDate,
});
return res.status(201).json({ success: true, data: invoice });
// paymentRequestId is what the caller stores on the ticket so it can follow the
// approval; the invoice alone no longer tells you whether the money has moved.
return res.status(201).json({ success: true, data: { ...invoice, paymentRequestId } });
} catch (error) {
next(error);
}
Expand Down
35 changes: 35 additions & 0 deletions backend/billing_service/src/controllers/saleWorkflowController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { syncVisitChargeDecision } from '../utils/serviceTicketSync';
import {
renderReceipt,
PAGE as RECEIPT_PAGE,
Expand Down Expand Up @@ -2043,6 +2044,7 @@ function receiptContextLabel(request: SalePaymentRequest): string | undefined {
LEASE_ADVANCE: 'Lease — Advance',
LEASE_PERIODIC: 'Lease — Periodic Collection',
LEASE_SECURITY_DEPOSIT: 'Lease — Security Deposit',
SERVICE_VISIT_CHARGE: 'Service — Visit Charge',
};
return map[ctx] ?? ctx.replace(/_/g, ' ');
}
Expand Down Expand Up @@ -2578,6 +2580,23 @@ export const approveSalePayment = async (req: Request, res: Response, next: Next
}

await queryRunner.commitTransaction();

// Mirror the decision onto the service ticket so the desk stops showing "awaiting
// approval". After the commit on purpose: the money is posted either way, and this
// must never be able to roll the approval back.
if (request.paymentContext === 'SERVICE_VISIT_CHARGE') {
const inv = await Source.getRepository(Invoice).findOne({
where: { id: request.invoiceId },
});
if (inv?.serviceTicketId) {
await syncVisitChargeDecision({
serviceTicketId: inv.serviceTicketId,
status: 'COLLECTED',
paymentRequestId: request.id,
});
}
}

res.json({ success: true, data: { ...request, paymentTransactionId: savedTxn.id } });
} catch (err) {
await queryRunner.rollbackTransaction();
Expand Down Expand Up @@ -2615,6 +2634,22 @@ export const rejectSalePayment = async (req: Request, res: Response, next: NextF
request.rejectionReason = rejectionReason;
await repo.save(request);

// A rejected visit charge is still owed — the ticket returns to "collect" so the desk
// or the technician can take it again, rather than the charge quietly vanishing.
if (request.paymentContext === 'SERVICE_VISIT_CHARGE') {
const inv = await Source.getRepository(Invoice).findOne({
where: { id: request.invoiceId },
});
if (inv?.serviceTicketId) {
await syncVisitChargeDecision({
serviceTicketId: inv.serviceTicketId,
status: 'REJECTED',
paymentRequestId: request.id,
rejectionReason: rejectionReason ?? null,
});
}
}

res.json({ success: true, data: request });
} catch (err) {
next(err);
Expand Down
16 changes: 16 additions & 0 deletions backend/billing_service/src/routes/invoiceRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
financeReject,
financeApproveQuotation,
convertToTransaction,
recordServiceCompletionPayment,
confirmServiceEstimateToAccounts,
generateFinalInvoice,
getAllInvoices,
getInvoiceById,
Expand Down Expand Up @@ -288,6 +290,20 @@ router.post(
*/
router.post('/:id/convert-to-transaction', authMiddleware, convertToTransaction);

/** Internal: payment a technician collected at job completion. */
router.post('/:id/service-completion-payment', authMiddleware, recordServiceCompletionPayment);

/**
* Accounts confirming a customer-accepted service estimate into the books. Finance only —
* this raises a real receivable, which is a bookkeeping act, not a service-desk one.
*/
router.post(
'/:id/confirm-service-to-accounts',
authMiddleware,
requireRole(EmployeeRole.ADMIN, EmployeeRole.FINANCE),
confirmServiceEstimateToAccounts,
);

/**
* Record a deposit or initial payment from the customer.
*/
Expand Down
Loading
Loading