diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9420a00d9..3bab613fb 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -112,7 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; -import { LoggerMiddleware } from "./logger.middleware"; +import { RequestLogMiddleware } from "@edr/api-common"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -376,7 +376,9 @@ export class AppModule implements OnApplicationBootstrap { } configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware).forRoutes("*"); + // FIRST: opens the request log context every later middleware/guard/service + // writes into via logCtx(). Anything applied above it logs into the void. + consumer.apply(RequestLogMiddleware).forRoutes("*"); consumer .apply(LoginAudienceMiddleware) .forRoutes( diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts new file mode 100644 index 000000000..0aefe3695 --- /dev/null +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -0,0 +1,102 @@ +import { Logger } from "@nestjs/common"; +import { + RequestLogMiddleware, + getLogContext, + logCtx, + runWithLogContext, +} from "@edr/api-common"; + +describe("logCtx", () => { + it("is a no-op outside a request", () => { + expect(() => logCtx({ bookingId: "b1" })).not.toThrow(); + expect(getLogContext()).toBeUndefined(); + }); + + it("collects data points across the request and isolates concurrent ones", async () => { + const collect = async (id: string) => + runWithLogContext({ requestId: id }, async () => { + logCtx({ bookingId: id }); + await Promise.resolve(); + logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" }); + logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" }); + logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx("PAID", { path: "payment.state", mode: "set" }); + logCtx({ ignored: true }, (ctx) => { + ctx.custom = "yes"; + }); + return getLogContext(); + }); + + const [a, b] = await Promise.all([collect("r1"), collect("r2")]); + + expect(a).toEqual({ + requestId: "r1", + bookingId: "r1", + booking: { status: { from: "DRAFT", to: "SUBMITTED" } }, + wagons: [{ wagonId: "w1" }, { wagonId: "w2" }], + smsSent: 2, + payment: { state: "PAID" }, + custom: "yes", + }); + expect(b?.requestId).toBe("r2"); + expect(b?.bookingId).toBe("r2"); + }); +}); + +describe("RequestLogMiddleware", () => { + it("emits one canonical JSON line carrying the collected context", () => { + const lines: string[] = []; + jest + .spyOn(Logger.prototype, "warn") + .mockImplementation((m) => lines.push(String(m))); + jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + + const listeners: Record void> = {}; + const req = { + method: "POST", + url: "/api/bookings/1/submit", + originalUrl: "/api/bookings/1/submit?dry=1", + baseUrl: "/api/bookings", + route: { path: "/:id/submit" }, + headers: { "user-agent": "jest", "x-request-id": "req-42" }, + ip: "10.0.0.1", + query: { dry: "1" }, + user: { id: "u-7" }, + }; + const res = { + statusCode: 409, + writableEnded: true, + setHeader: jest.fn(), + on: (event: string, fn: () => void) => { + listeners[event] = fn; + }, + }; + + new RequestLogMiddleware().use(req, res, () => { + logCtx({ bookingId: "b-1" }); + logCtx("REJECTED", { path: "booking.outcome", mode: "set" }); + }); + listeners.finish(); + listeners.close(); // aborts/close after finish must not double-log + + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + type: "http_request", + requestId: "req-42", + method: "POST", + route: "/api/bookings/:id/submit", + url: "/api/bookings/1/submit?dry=1", + status: 409, + userId: "u-7", + ip: "10.0.0.1", + userAgent: "jest", + query: { dry: "1" }, + bookingId: "b-1", + booking: { outcome: "REJECTED" }, + }); + expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); + jest.restoreAllMocks(); + }); +}); diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts deleted file mode 100644 index dd7532ec8..000000000 --- a/apps/edr-freight-api/src/logger.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; -import { Request, Response, NextFunction } from "express"; - -@Injectable() -export class LoggerMiddleware implements NestMiddleware { - private readonly logger = new Logger("HTTP"); - - use(req: Request, res: Response, next: NextFunction) { - const start = Date.now(); - - res.on("finish", () => { - const duration = Date.now() - start; - - this.logger.log( - `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, - ); - }); - - next(); - } -} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index cf2f2e84d..98b30f006 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -256,11 +256,15 @@ export class CompaniesService { }, ]; - /** The nationality-based document setting code for a company. */ - private documentSettingCodeFor( - nationality: CompanyNationality | null | undefined, - ): string { - return nationality === CompanyNationality.Foreign + /** + * The document setting code for a company: one of three mutually exclusive + * sets. A co-operative union or farm resolves to its own set regardless of + * nationality — it holds no business licence, so it owes a different list of + * papers rather than the nationality list plus extras. + */ + private documentSettingCodeFor(company: Company): string { + if (isCooperative(company)) return COOPERATIVE_ONBOARDING_CODE; + return company.nationality === CompanyNationality.Foreign ? "company_onboarding_documents_foreign" : "company_onboarding_documents_ethiopian"; } @@ -387,13 +391,16 @@ export class CompaniesService { const current = needsCompany ? await this.companiesRepo.findById(companyId) : null; - this.assertRolesAllowedForCooperative( - cooperative ?? isCooperative(current), - roles, - ); + const isCoop = cooperative ?? isCooperative(current); + this.assertRolesAllowedForCooperative(isCoop, roles); + this.assertNationalityAllowedForCooperative(isCoop, nationality); await this.syncCompanyProfiles(companyId, companyType, roles); const updates: Partial = {}; if (nationality) updates.nationality = nationality; + // Ticking the box on a draft that was saved as foreign has to correct the + // stored nationality too, or the company keeps resolving to the foreign + // document set. + if (isCoop) updates.nationality = CompanyNationality.Ethiopian; if (cooperative !== undefined) { updates.attributes = { ...(current?.attributes ?? {}), @@ -407,6 +414,7 @@ export class CompaniesService { } this.assertRolesAllowedForCooperative(cooperative === true, roles); + this.assertNationalityAllowedForCooperative(cooperative === true, nationality); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); @@ -458,6 +466,24 @@ export class CompaniesService { } } + /** + * A co-operative union or farm is registered in Ethiopia by the co-operative + * promotion agency, so it is always an Ethiopian company — "foreign" is not a + * combination that exists, and allowing it would resolve the company to a + * document set built around an investment licence it cannot hold. + */ + private assertNationalityAllowedForCooperative( + cooperative: boolean, + nationality: CompanyNationality | undefined, + ): void { + if (!cooperative) return; + if (nationality === CompanyNationality.Foreign) { + throw new BadRequestException( + "A co-operative union or farm is registered in Ethiopia — it cannot onboard as a foreign company.", + ); + } + } + /** * Reconcile the company's operational profiles with the roles the user has * selected: create the missing ones, drop the ones they deselected. @@ -1252,7 +1278,7 @@ export class CompaniesService { uploaded: FileRecord[], ): Promise { const setting = await this.fileUploadSettingsService - .getByCode(this.documentSettingCodeFor(company.nationality)) + .getByCode(this.documentSettingCodeFor(company)) .catch(() => null); const fields = setting?.fields ?? []; const singleFileCodes = new Set( @@ -2036,35 +2062,20 @@ export class CompaniesService { .filter((f) => !f.get(company)) .map((f) => ({ key: f.key, label: f.label })); - // 2. Nationality-based company documents + which are already uploaded. A - // co-operative adds its own set on top: it provides everything its - // nationality demands, plus the papers standing in for the business licence - // it does not hold. + // 2. Company documents + which are already uploaded. One set applies: the + // company's nationality set, or the co-operative set in its place — a union + // or farm holds no business licence, so it owes its own list rather than the + // nationality list plus extras. const cooperative = isCooperative(company); - const documentSettingCode = this.documentSettingCodeFor( - company.nationality, - ); - const [setting, coopSetting, uploadedFiles] = await Promise.all([ + const documentSettingCode = this.documentSettingCodeFor(company); + const [setting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService .getByCode(documentSettingCode) .catch(() => null), - cooperative - ? this.fileUploadSettingsService - .getByCode(COOPERATIVE_ONBOARDING_CODE) - .catch(() => null) - : Promise.resolve(null), this.filesService.findByResource(company.id, "companies"), ]); const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); - // The co-op set is admin-managed and could name a fileKey the nationality - // set already carries; the nationality field wins so the same slot is never - // rendered (or required) twice. - const baseFields = setting?.fields ?? []; - const baseKeys = new Set(baseFields.map((f) => f.fileKey)); - const documents = [ - ...baseFields, - ...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)), - ] + const documents = (setting?.fields ?? []) .slice() .sort((a, b) => a.displayOrder - b.displayOrder) .map((f) => ({ @@ -2203,9 +2214,6 @@ export class CompaniesService { return new OnboardingRequirementsResponseDto({ documentSettingCode, - cooperativeDocumentSettingCode: cooperative - ? COOPERATIVE_ONBOARDING_CODE - : null, nationality: company.nationality ?? CompanyNationality.Ethiopian, cooperative, companyInfo: { diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 8d47dbb4e..20bd0ab06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -66,15 +66,11 @@ export interface OnboardingPoaState { } export class OnboardingRequirementsResponseDto { - /** Resolved document setting code (by nationality) the docs were drawn from. */ - documentSettingCode: string; /** - * The co-operative document set, merged on top of the nationality one — null - * for every other company. `documents` below already carries the merged - * result; this is only so the portal can fetch the same extra fields when it - * renders the pickers from the file-settings endpoint. + * Resolved document setting code the docs were drawn from: the company's + * nationality set, or the co-operative set in its place. */ - cooperativeDocumentSettingCode: string | null; + documentSettingCode: string; nationality: string; /** * The company trades as a co-operative: no business licence, so no eTrade @@ -118,7 +114,6 @@ export class OnboardingRequirementsResponseDto { constructor(init: Omit) { this.documentSettingCode = init.documentSettingCode; - this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode; this.nationality = init.nationality; this.cooperative = init.cooperative; this.companyInfo = init.companyInfo; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts index 0719d7fe8..86117d306 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -63,6 +63,21 @@ describe('ETradeService business selection', () => { expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); }); + it('survives the null entries eTrade puts in SubGroups', () => { + const { service } = build(); + const info = companyInfo(); + (info.Businesses[0] as any).SubGroups = [ + null, + { Code: 66331, Description: 'Export trade in minerals' }, + { Code: 1, Description: null }, + ]; + const data = service.extractRegistrationData( + { LicenceNumber: 'x' } as ETradeBusinessInfo, + info, + ); + expect(data.businesses?.[0].activity).toBe('Export trade in minerals'); + }); + it('lists every licence for the picker, code prefixes stripped', () => { const { service } = build(); const data = service.extractRegistrationData( diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 45cc8e840..fac57238a 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -142,7 +142,8 @@ export class ETradeService { tradeName: b.TradesName?.trim() || "", activity: (b.SubGroups ?? []) // Some descriptions repeat the code inline ("(65611)Import trade …"). - .map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim()) + // eTrade also puts null entries in this array, so every hop is optional. + .map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim()) .filter(Boolean) .join(", "), renewedTo: b.RenewedTo || "", diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 21ddc4c91..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -17,7 +17,6 @@ import { } from "./interfaces/file-upload-settings.repository.interface"; import { COMPANY_ONBOARDING_CODE_PREFIX, - COOPERATIVE_ONBOARDING_CODE, POA_DELEGATION_FILE_KEY, poaDelegationField, } from "./poa-delegation.constants"; @@ -57,10 +56,6 @@ export class FileUploadSettingsService { */ private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; - // The co-operative set is merged ON TOP of a nationality set that already - // carries the paper; injecting it here too would hand the portal the same - // slot twice. - if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting; const fields = setting.fields ?? []; if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts index e8d593ded..e23a87818 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -27,10 +27,11 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; /** - * The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE — - * merged on top of the company's `_ethiopian`/`_foreign` set rather than - * replacing it — which is why the delegation paper is not injected into it: the - * set it is merged onto already carries one. + * The co-operative onboarding set — the third alternative to `_ethiopian` and + * `_foreign`, not an addition to them: a union or farm resolves to this set + * INSTEAD of its nationality's, because it holds no business licence and so + * owes a different list of papers. The delegation paper is injected into it + * like any other company onboarding set. */ export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index abc4e613c..4f314453a 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -155,16 +155,27 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // ]; /** - * Extra documents a co-operative union or farm provides, merged on top of its - * nationality set. It has a TIN but no business licence, so the papers that - * evidence the co-operative itself stand in for the trade licence every other - * company uploads. + * Documents required from a co-operative union or farm — the third alternative + * to the two nationality sets, not an addition to them. A co-op is always + * registered in Ethiopia and has a TIN but no business licence, so its + * registration certificate stands in for the commercial registration every + * other Ethiopian company uploads. * - * Only the registration certificate is seeded, and the set is admin-managed - * like every other onboarding set — what these members must actually produce - * is a backoffice decision, edited in the file-settings editor. + * Admin-managed like every other onboarding set: what these members must + * actually produce is a backoffice decision, edited in the file-settings editor. */ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 1, + }, { fileKey: "cooperative_registration_certificate", fileLabel: "Co-operative Union / Farm Registration Certificate", @@ -175,8 +186,20 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, maxSizeMb: 50, - displayOrder: 1, + displayOrder: 2, }, + { + fileKey: "national_id", + fileLabel: "National ID", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 3, + }, + poaDelegationDefault(4), ]; interface OnboardingDocumentSetting { @@ -201,11 +224,11 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, - // Additive, not a nationality of its own: a union or farm still uploads - // everything its nationality set demands, and these on top. + // The third set: a union or farm resolves here INSTEAD of a nationality set + // (it is always Ethiopian, and holds no business licence). { code: "company_onboarding_documents_cooperative", - label: "Co-operative union / farm onboarding documents (additional)", + label: "Co-operative union / farm onboarding documents", entity: "customer", fields: COOPERATIVE_ONBOARDING_FIELDS, }, diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts index 3bdc653c4..8feb7cfb8 100644 --- a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts +++ b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts @@ -101,7 +101,8 @@ export function mapEtradeBusinessLicenses( const tradeName = String(business.TradesName ?? "").trim(); const tradeNameAmh = String(business.TradeNameAmh ?? "").trim(); const activities = (business.SubGroups ?? []) - .map((group) => String(group.Description ?? "").trim()) + // eTrade returns null entries in this array, not just a null array. + .map((group) => String(group?.Description ?? "").trim()) .filter(Boolean); const displayTradeName = diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 6d75e502f..998f733da 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -39,6 +39,7 @@ import type { } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service"; import type { UpdateProfilePayload } from "@/types/profile"; +import { documentSettingCode } from "@/utils/documentSettingCode"; import { extractApiError } from "@/utils/result"; /** Form steps rendered by CompanyProfileForm. */ @@ -116,13 +117,6 @@ function companyTypeForRoles(_roles: string[]): string { return "customer"; } -/** Document upload setting code per company nationality. */ -function documentSettingCode(nationality: CompanyNationality): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - /** * First-run onboarding wizard with a "draft-first" flow: picking the role(s) * immediately creates a draft company + profile on the backend, so every @@ -168,11 +162,15 @@ export default function OnboardingWizardDialog({ const [cooperative, setCooperative] = useState( company?.company?.attributes?.cooperative === true, ); - // Ticking the box drops a role the company can no longer hold, rather than - // letting Continue fail on a selection the API refuses. + // Ticking the box drops the selections the company can no longer hold, rather + // than letting Continue fail on ones the API refuses: a co-op cannot forward + // freight, and is registered in Ethiopia so it is never foreign. const handleCooperativeChange = useCallback((checked: boolean) => { setCooperative(checked); - if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); + if (checked) { + setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); + setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev)); + } }, []); const [documentFiles, setDocumentFiles] = useState< Record @@ -419,7 +417,7 @@ export default function OnboardingWizardDialog({ // after the draft — and thus the requirements — exist). const resolvedDocumentSettingCode = requirementsQuery.data?.documentSettingCode ?? - documentSettingCode(effectiveNationality); + documentSettingCode(effectiveNationality, cooperative); // Server-confirmed document state, used both to badge already-uploaded fields // and to keep a refreshed resume from over-shooting the documents step. @@ -480,8 +478,6 @@ export default function OnboardingWizardDialog({ // startOnboarding has persisted it, and the form's whole company step // branches on it. cooperative: requirementsQuery.data?.cooperative ?? cooperative, - extraDocumentSettingCode: - requirementsQuery.data?.cooperativeDocumentSettingCode ?? null, // A freight forwarder cannot answer the power-of-attorney question — the // API forces "yes" — so the step offers no way to change it. declarationLocked: requirementsQuery.data?.poa?.locked ?? false, @@ -551,6 +547,10 @@ export default function OnboardingWizardDialog({ value={nationality} onChange={setNationality} embedded + // A co-op is registered in Ethiopia by the co-operative + // promotion agency — foreign is not on offer rather than + // refused later. + excludeForeign={cooperative} /> {/* A co-operative union or farm registers on a TIN alone. It changes what the next step asks for (typed registration, no diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 59fcc2223..aa445974f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -66,7 +66,6 @@ export default function CompanyProfileForm({ onIdentityChange, cooperative = false, declarationLocked = false, - extraDocumentSettingCode, }: { documentSettingCode: string; documentFiles?: Record; @@ -115,8 +114,8 @@ export default function CompanyProfileForm({ /** * The company trades as a co-operative: a TIN but no business licence, so the * eTrade lookup is replaced by typed registration details, the per-role - * licence upload is not owed, and its own document set applies on top of the - * nationality one. + * licence upload is not owed, and its own document set applies instead of the + * nationality one (resolved by the caller into `documentSettingCode`). */ cooperative?: boolean; /** @@ -124,8 +123,6 @@ export default function CompanyProfileForm({ * answer is forced to "yes" and cannot be changed here. */ declarationLocked?: boolean; - /** Additional document set merged in (the co-operative one), if any. */ - extraDocumentSettingCode?: string | null; }) { // A Fayda claim carries the phone as the national registry holds it, which is // often a local number the form's E.164 validation (and the API's @@ -206,36 +203,15 @@ export default function CompanyProfileForm({ const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery( + // One set applies: the company's nationality set, or the co-operative one in + // its place — the caller resolves which (the API resolves the same way when + // it decides what is outstanding). + const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false, }), ); - // A co-operative's own documents come as a second, additive set — it uploads - // everything its nationality demands, plus the papers standing in for the - // business licence it does not hold. The API merges the same two sets when it - // decides what is outstanding. - const { data: extraSetting } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: extraDocumentSettingCode ?? "" }, - enabled: Boolean(extraDocumentSettingCode), - refetchOnMount: false, - }), - ); - const uploadSetting = useMemo(() => { - if (!nationalitySetting) return nationalitySetting; - if (!extraSetting?.fields?.length) return nationalitySetting; - // Nationality wins a fileKey collision, so a slot is never rendered twice. - const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey)); - return { - ...nationalitySetting, - fields: [ - ...nationalitySetting.fields, - ...extraSetting.fields.filter((f) => !seen.has(f.fileKey)), - ], - }; - }, [nationalitySetting, extraSetting]); // Which fields the current step renders an input for and therefore requires. // Filled in further down (it depends on values this form owns), and read at diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index de105bf21..d75092655 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -6,6 +6,7 @@ import { type UseFormReturn } from "react-hook-form"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; +import { documentSettingCode } from "@/utils/documentSettingCode"; import { operationToProfileType, type BookingDocuments, @@ -20,13 +21,6 @@ type BookingForm = UseFormReturn< BookingFormValues >; -/** Onboarding document setting code for the company's nationality. */ -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - function formatSize(bytes?: number): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; @@ -48,14 +42,17 @@ function formatSize(bytes?: number): string { export function StepDocuments({ form }: { form: BookingForm }) { const auth = useAuth(); - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; + const company = auth.company?.company; + const nationality = company?.nationality as string | null | undefined; const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, + input: { + code: documentSettingCode( + nationality, + company?.attributes?.cooperative === true, + ), + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts index 65774f97f..31d42f6cd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts @@ -2,15 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; +import { documentSettingCode } from "@/utils/documentSettingCode"; -/** Onboarding document setting code for the company's nationality. */ -export function documentSettingCode( - nationality: string | null | undefined, -): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} +export { documentSettingCode }; /** * Fetches the FileUploadSetting that describes the documents a booking requires @@ -22,14 +16,17 @@ export function documentSettingCode( */ export function useBookingDocumentSetting() { const auth = useAuth(); - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; + const company = auth.company?.company; + const nationality = company?.nationality as string | null | undefined; return useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, + input: { + code: documentSettingCode( + nationality, + company?.attributes?.cooperative === true, + ), + }, }), ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx index 4d966e010..9d045c4e7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx @@ -7,6 +7,7 @@ import type { Freight } from "@edr/types"; import { useQuery } from "@tanstack/react-query"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; +import { documentSettingCode } from "@/utils/documentSettingCode"; import type { CompanyDocument } from "@/services/companies.service"; import { downloadStoredFile } from "@/services/files.service"; import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; @@ -15,13 +16,6 @@ import { BORDER, GREEN, INK } from "../contract-ui"; type DocumentsValue = Record; type ContractFile = NonNullable[number]; -/** Onboarding document setting code for the company's nationality. */ -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - function hasFile(value: File | File[] | null | undefined): boolean { if (!value) return false; return Array.isArray(value) ? value.length > 0 : true; @@ -95,13 +89,16 @@ export function ContractDocsEditor({ }) { const auth = useAuth(); - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; + const company = auth.company?.company; + const nationality = company?.nationality as string | null | undefined; const settingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, + input: { + code: documentSettingCode( + nationality, + company?.attributes?.cooperative === true, + ), + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx index de4b2bd66..b0b870f72 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx @@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; +import { documentSettingCode } from "@/utils/documentSettingCode"; import { type ContractDocuments, type ContractFormInputValues, @@ -21,12 +22,6 @@ type ContractForm = UseFormReturn< ContractFormValues >; -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - function formatSize(bytes?: number): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; @@ -61,14 +56,17 @@ export function StepDocuments({ const auth = useAuth(); const [errors, setErrors] = useState>({}); - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; + const company = auth.company?.company; + const nationality = company?.nationality as string | null | undefined; const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, + input: { + code: documentSettingCode( + nationality, + company?.attributes?.cooperative === true, + ), + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 2115ec083..99569e748 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -9,6 +9,8 @@ interface NationalitySelectProps { onChange: (next: CompanyNationality) => void; /** Render only the option grid — the wizard supplies its own header/card. */ embedded?: boolean; + /** Hide the foreign option (a co-operative union or farm is always Ethiopian). */ + excludeForeign?: boolean; } /** @@ -21,9 +23,10 @@ export default function NationalitySelect({ value, onChange, embedded = false, + excludeForeign = false, }: NationalitySelectProps) { const grid = ( - + onChange("ethiopian")} /> - } - selected={value === "foreign"} - onClick={() => onChange("foreign")} - /> + {!excludeForeign && ( + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 72d6738c8..584fb70d6 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -6,6 +6,7 @@ import { type LicenseFileStatus, } from "@/services/companies.service"; import { getMinFiles } from "@/types/fileUploadSettings"; +import { documentSettingCode } from "@/utils/documentSettingCode"; import type { ProfileResponse } from "@/types/profile"; import { SmartFileInput, @@ -57,14 +58,8 @@ interface TabDocumentsProps { onContinue?: () => void; } -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - /** - * The DARS delegation paper ships in the same nationality document set, but it is + * The DARS delegation paper ships in the same document set, but it is * edited on the Power of Attorney tab (where it is staged for review alongside * the PoA details), so it is excluded from this tab's uploader. */ @@ -83,7 +78,9 @@ export default function TabDocuments({ const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(profile.nationality) }, + input: { + code: documentSettingCode(profile.nationality, profile.cooperative), + }, }), ); diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index e9cf478c3..4c1b93713 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -183,14 +183,8 @@ export interface OnboardingPoaState { * outstanding, so the client never hardcodes required fields or document sets. */ export interface OnboardingRequirements { + /** The set the docs came from: the nationality one, or the co-operative one. */ documentSettingCode: string; - /** - * Extra document set merged on top of the nationality one for a co-operative, - * null otherwise. `documents` already carries the merged list; this is only - * so the pickers, which render from the file-settings endpoint, can fetch the - * same extra fields. - */ - cooperativeDocumentSettingCode: string | null; nationality: string; /** No business licence: registration typed by hand, no eTrade lookup. */ cooperative: boolean; diff --git a/apps/edr-freight-web/portal/src/utils/documentSettingCode.test.ts b/apps/edr-freight-web/portal/src/utils/documentSettingCode.test.ts new file mode 100644 index 000000000..6063f1b87 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/documentSettingCode.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { documentSettingCode } from "./documentSettingCode"; + +describe("documentSettingCode", () => { + it("resolves the nationality set", () => { + expect(documentSettingCode("ethiopian")).toBe( + "company_onboarding_documents_ethiopian", + ); + expect(documentSettingCode("foreign")).toBe( + "company_onboarding_documents_foreign", + ); + expect(documentSettingCode(null)).toBe( + "company_onboarding_documents_ethiopian", + ); + }); + + it("replaces the nationality set for a co-operative", () => { + expect(documentSettingCode("ethiopian", true)).toBe( + "company_onboarding_documents_cooperative", + ); + // A co-op is never foreign, but a stale flag must not fall back to the + // foreign set — the co-op answer wins. + expect(documentSettingCode("foreign", true)).toBe( + "company_onboarding_documents_cooperative", + ); + }); +}); diff --git a/apps/edr-freight-web/portal/src/utils/documentSettingCode.ts b/apps/edr-freight-web/portal/src/utils/documentSettingCode.ts new file mode 100644 index 000000000..e27407c27 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/documentSettingCode.ts @@ -0,0 +1,18 @@ +/** + * The company document set a company resolves to — one of three, never a + * combination: a co-operative union or farm uploads its own papers INSTEAD of + * its nationality's (it holds no business licence), and is always Ethiopian. + * + * Mirrors `CompaniesService.documentSettingCodeFor` on the API; prefer the + * server-resolved `documentSettingCode` from onboarding requirements where one + * is available, and use this where only the company is at hand. + */ +export function documentSettingCode( + nationality: string | null | undefined, + cooperative?: boolean | null, +): string { + if (cooperative) return "company_onboarding_documents_cooperative"; + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 1eea923fd..725ddbbfc 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -8,7 +8,7 @@ import { ApiProduces, } from "@nestjs/swagger"; import { ReportsService } from "./reports.service"; -import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto"; +import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -83,6 +83,35 @@ export class ReportsController { return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); } + // ── Finance Summary ────────────────────────────────────────────────────── + + @Get("finance") + @ApiOperation({ + summary: "Finance summary — revenue by period, origin/destination segment, and payment method", + description: + "Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " + + "destination station pair, and payment method. Filter by originStationId and/or destinationStationId " + + "independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " + + "Returns per-bucket rows plus roll-ups by period, segment, and method for charting.", + }) + getFinanceSummary(@Query() query: FinanceSummaryQueryDto) { + return this.service.getFinanceSummary(query); + } + + @Get("finance/export") + @ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" }) + @ApiProduces("text/csv") + @ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) + async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise { + const csv = await this.service.exportFinanceSummaryCsv(query); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`, + ); + res.send(csv); + } + // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── @Get("blocked-seats-revenue-loss") diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index 8b8049807..6c6b304d4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -1,6 +1,7 @@ import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaymentMethodType } from '@prisma/client'; import { SeatBlockReasonCategory } from '../seats/seats.dto'; export enum ReportType { @@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto { }) @IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy; } + +// ── Finance Summary ────────────────────────────────────────────────────────── + +export enum FinanceGranularity { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', +} + +export class FinanceSummaryQueryDto { + @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) + @IsDateString() dateFrom: string; + + @ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' }) + @IsDateString() dateTo: string; + + @ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY }) + @IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity; + + @ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' }) + @IsOptional() @IsString() originStationId?: string; + + @ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' }) + @IsOptional() @IsString() destinationStationId?: string; + + @ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' }) + @IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType; +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e899fdb4b..571df697e 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service"; import { BlockedSeatsLossSortBy, BlockedSeatsRevenueLossQueryDto, + FinanceGranularity, + FinanceSummaryQueryDto, GenerateReportDto, ReportType, } from "./reports.dto"; @@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string { return `"${String(value).replace(/"/g, '""')}"`; } +/** + * Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort + * chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC). + */ +function periodKeyFor(date: Date, granularity: FinanceGranularity): string { + if (granularity === FinanceGranularity.MONTHLY) { + return date.toISOString().slice(0, 7); + } + if (granularity === FinanceGranularity.WEEKLY) { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7 + d.setUTCDate(d.getUTCDate() - (isoDay - 1)); + return d.toISOString().split("T")[0]; + } + return date.toISOString().split("T")[0]; +} + +export interface FinanceBucket { + period: string; + originStationId: string; + destinationStationId: string; + segmentLabel: string; + method: string; + bookingCount: number; + revenueEtbMinor: number; +} + @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); @@ -1051,6 +1080,161 @@ export class ReportsService { return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows }; } + // ── Finance Summary ──────────────────────────────────────────────────────── + + /** + * Revenue collected in the window, grouped by reporting period (day/week/month), origin → + * destination station pair, and payment method — the shape finance reconciles against + * provider settlement statements. + * + * Grouped by the booking's own origin/destination, not the parent Route — a route like + * "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any + * sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any + * A→B pair, not just whole routes. + * + * Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`, + * so a booking made in one period but paid in another lands in the period it was paid. + */ + async getFinanceSummary(query: FinanceSummaryQueryDto) { + const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z"); + const dateTo = new Date(query.dateTo + "T23:59:59.999Z"); + const granularity = query.granularity ?? FinanceGranularity.DAILY; + + const rateRows = await this.prisma.currencyExchangeRate.findMany({ + where: { toCurrency: "ETB" as any }, + orderBy: { effectiveDate: "desc" }, + }); + const rateToEtb = new Map(); + for (const r of rateRows) { + if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate)); + } + const toEtbMinor = (minor: number, currency: string): number => { + if (currency === "ETB") return minor; + const rate = rateToEtb.get(currency); + return rate ? Math.round(minor * rate) : minor; + }; + + const bookings = await this.prisma.booking.findMany({ + where: { + paymentIntent: { + paidAt: { gte: dateFrom, lte: dateTo }, + ...(query.method ? { method: query.method } : {}), + }, + ...(query.originStationId ? { originStationId: query.originStationId } : {}), + ...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}), + }, + select: { + totalMinor: true, + currency: true, + originStationId: true, + destinationStationId: true, + schedule: { select: { originStationId: true, destinationStationId: true } }, + paymentIntent: { select: { paidAt: true, method: true } }, + }, + }); + + // Booking.originStationId/destinationStationId are set on every create path (guest and + // authenticated booking both pass them from the DTO); the schedule's own endpoints are + // only a fallback for the rare legacy row that predates those columns. + const stationIds = new Set(); + for (const b of bookings) { + const origin = b.originStationId ?? b.schedule.originStationId; + const destination = b.destinationStationId ?? b.schedule.destinationStationId; + if (origin) stationIds.add(origin); + if (destination) stationIds.add(destination); + } + const stations = stationIds.size > 0 + ? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map((s) => [s.id, s.name])); + + const buckets = new Map(); + const bucketFor = ( + period: string, + originStationId: string, + destinationStationId: string, + segmentLabel: string, + method: string, + ): FinanceBucket => { + const key = `${period}|${originStationId}|${destinationStationId}|${method}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 }; + buckets.set(key, bucket); + } + return bucket; + }; + + for (const b of bookings) { + const pi = b.paymentIntent!; + const period = periodKeyFor(pi.paidAt!, granularity); + const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN"; + const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN"; + const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`; + const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method); + bucket.bookingCount += 1; + bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency); + } + + const rows = [...buckets.values()].sort((a, b) => + a.period === b.period + ? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) + : a.period.localeCompare(b.period), + ); + + const totals = rows.reduce( + (acc, r) => { + acc.bookingCount += r.bookingCount; + acc.revenueEtbMinor += r.revenueEtbMinor; + return acc; + }, + { bookingCount: 0, revenueEtbMinor: 0 }, + ); + + const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => { + const map = new Map(); + for (const r of rows) { + const key = keyOf(r); + let entry = map.get(key); + if (!entry) { + entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 }; + map.set(key, entry); + } + entry.revenueEtbMinor += r.revenueEtbMinor; + entry.bookingCount += r.bookingCount; + } + return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor); + }; + + return { + granularity, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + currency: "ETB", + totals, + byPeriod: rollUp((r) => r.period, (r) => r.period), + bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel), + byMethod: rollUp((r) => r.method, (r) => r.method), + rows, + }; + } + + /** CSV of the finance summary, one row per period + origin/destination segment + payment method. */ + async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { + const report = await this.getFinanceSummary(query); + + const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"]; + const rows = report.rows.map((r) => [ + r.period, + r.segmentLabel, + r.method, + r.bookingCount, + (r.revenueEtbMinor / 100).toFixed(2), + ]); + + return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n"); + } + async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { search?: string; seatClass?: string; diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 9d4327a42..737baeda1 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -16,6 +16,8 @@ "axios": "^1.7.7", "clsx": "^2.1.1", "date-fns": "^3.0.0", + "exceljs": "^4.4.0", + "html-to-image": "^1.11.11", "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx new file mode 100644 index 000000000..8e3e2a23b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx @@ -0,0 +1,521 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react'; +import { + Bar, BarChart, CartesianGrid, Line, LineChart, + ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis, +} from 'recharts'; +import { toPng } from 'html-to-image'; +import { apiClient } from '@/lib/api-client'; +import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance'; +import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook'; +import ActionButton from '@/components/ui/ActionButton'; +import Pagination from '@/components/ui/Pagination'; +import Skeleton from '@/components/ui/Skeleton'; +import { usePagination } from '@/lib/use-pagination'; +import { formatCurrency } from '@/lib/utils'; +import { categoricalColor, getChartPalette } from '@/lib/chart-palette'; +import { useTheme } from '@/lib/theme-store'; + +interface StationOption { + id: string; + name: string; + code: string; +} + +// Fixed order so a method keeps its colour/slot when the method filter narrows the set. +const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const; +const PAYMENT_METHOD_LABELS: Record = { + TELEBIRR: 'Telebirr', + CBE_BIRR: 'CBE Birr', + EBIRR: 'eBirr', + WAAFI: 'Waafi', + CARD: 'Card', + WALLET: 'Wallet', + DMONEY: 'DMoney', + CAC_BANK: 'CAC Bank', + CBE_BILL: 'CBE Bill', +}; +function methodLabel(method: string): string { + return PAYMENT_METHOD_LABELS[method] ?? method; +} + +function periodLabel(period: string, granularity: FinanceGranularity): string { + if (granularity === 'monthly') { + return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + } + return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +const TABLE_PAGE_SIZE = 25; + +/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */ +function FinanceReportSkeleton() { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+ + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ +
+
+ +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ); +} + +export default function FinanceReportPage() { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const [dateRangePreset, setDateRangePreset] = useState('90'); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [granularity, setGranularity] = useState('daily'); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [method, setMethod] = useState(''); + const [exporting, setExporting] = useState(false); + + const trendCardRef = useRef(null); + const segmentCardRef = useRef(null); + const methodCardRef = useRef(null); + + const { dateFrom, dateTo } = useMemo(() => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + + if (dateRangePreset === 'custom') { + if (customFrom && customTo) { + return customFrom <= customTo + ? { dateFrom: customFrom, dateTo: customTo } + : { dateFrom: customTo, dateTo: customFrom }; + } + const fallbackStart = new Date(end); + fallbackStart.setDate(end.getDate() - 90); + return { + dateFrom: fallbackStart.toISOString().split('T')[0], + dateTo: end.toISOString().split('T')[0], + }; + } + + const start = new Date(end); + start.setDate(end.getDate() - Number(dateRangePreset)); + return { + dateFrom: start.toISOString().split('T')[0], + dateTo: end.toISOString().split('T')[0], + }; + }, [dateRangePreset, customFrom, customTo]); + + const filters: FinanceSummaryFilters = useMemo( + () => ({ + dateFrom, + dateTo, + granularity, + originStationId: originStationId || undefined, + destinationStationId: destinationStationId || undefined, + method: method || undefined, + }), + [dateFrom, dateTo, granularity, originStationId, destinationStationId, method], + ); + + const { data: stations = [] } = useQuery({ + queryKey: ['stations'], + queryFn: () => apiClient.get('/stations'), + }); + + const { data, isLoading, isFetching, isError } = useQuery({ + queryKey: ['reports-finance', filters], + placeholderData: (previous) => previous, + queryFn: () => financeApi.getSummary(filters), + }); + + const rows = data?.rows ?? []; + const pg = usePagination(rows, TABLE_PAGE_SIZE); + + const resetFilters = () => { + setDateRangePreset('90'); + setCustomFrom(''); + setCustomTo(''); + setGranularity('daily'); + setOriginStationId(''); + setDestinationStationId(''); + setMethod(''); + }; + + /** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */ + const captureCard = async (node: HTMLDivElement | null): Promise => { + if (!node) return undefined; + const rect = node.getBoundingClientRect(); + const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface }); + return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) }; + }; + + const doExport = async () => { + if (!data) return; + setExporting(true); + try { + const [trend, segment, method_] = await Promise.all([ + captureCard(trendCardRef.current), + captureCard(segmentCardRef.current), + captureCard(methodCardRef.current), + ]); + + const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any'; + + const blob = await buildFinanceWorkbook({ + report: data, + filters: { + dateFrom, + dateTo, + granularity, + originLabel: originStationId ? stationLabel(originStationId) : 'Any', + destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any', + methodLabel: method ? methodLabel(method) : 'All', + }, + methodLabel, + periodLabel, + images: { trend, segment, method: method_ }, + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }; + + const trendData = useMemo( + () => + (data?.byPeriod ?? []) + .slice() + .sort((a, b) => a.key.localeCompare(b.key)) + .map((p) => ({ + label: periodLabel(p.key, data!.granularity), + revenue: p.revenueEtbMinor / 100, + })), + [data], + ); + + const segmentData = useMemo( + () => + (data?.bySegment ?? []) + .slice() + .sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor) + .map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })), + [data], + ); + + const methodBreakdown = useMemo(() => { + const rowsByMethod = data?.byMethod ?? []; + const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0); + return rowsByMethod + .slice() + .sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)), + sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0, + })); + }, [data, palette]); + + const totals = data?.totals; + const hasData = (totals?.bookingCount ?? 0) > 0; + const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0; + + return ( +
+
+
+

Finance Summary

+

+ Revenue collected by period, origin/destination, and payment method — for daily, weekly, or monthly finance reporting. +

+
+ + Export + +
+ + {/* Filters */} +
+
+
+ + +
+ {dateRangePreset === 'custom' && ( + <> +
+ + setCustomFrom(e.target.value)} /> +
+
+ + setCustomTo(e.target.value)} /> +
+ + )} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {isFetching && Refreshing…} +
+ {isError &&

Failed to load the finance summary. Check the filters and try again.

} +
+ + {isLoading && !data ? ( + + ) : !hasData ? ( +
+ +

No paid bookings in this window.

+

Widen the date range, or clear the origin/destination/method filters.

+
+ ) : ( +
+ {/* KPI tiles */} +
+
+
+

Revenue

+
+ +
+
+

+ {formatCurrency(totals!.revenueEtbMinor, 'ETB')} +

+
+
+
+

Bookings

+
+ +
+
+

{totals!.bookingCount.toLocaleString()}

+
+
+
+

Avg. per Booking

+
+ +
+
+

{formatCurrency(avgPerBookingMinor, 'ETB')}

+
+
+ + {/* Trend + route charts */} +
+
+

+ Revenue Trend (ETB, {granularity}) +

+ {trendData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )} +
+ +
+

Revenue by Segment

+ {segmentData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )} +
+
+ + {/* Payment method breakdown — part-to-whole stacked bar + legend table */} +
+

Revenue by Payment Method

+

Share of revenue, in ETB

+
`${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`) + .join(', ')}`} + > + {methodBreakdown.map((m, i) => ( +
+ ))} +
+ + + + + + + + + + + {methodBreakdown.map((m) => ( + + + + + + + ))} + +
MethodBookingsShareRevenue
+ + + {m.bookingCount.toLocaleString()}{m.sharePercent.toFixed(1)}%{formatCurrency(m.revenueEtbMinor, 'ETB')}
+
+ + {/* Detail table */} +
+
+

+ Period × Segment × Method detail +

+
+
+ + + + {['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => ( + + ))} + + + + {pg.paged.map((r, i) => ( + + + + + + + + ))} + {pg.paged.length === 0 && ( + + + + )} + +
+ {h} +
{periodLabel(r.period, granularity)}{r.segmentLabel}{methodLabel(r.method)}{r.bookingCount.toLocaleString()}{formatCurrency(r.revenueEtbMinor, 'ETB')}
No rows on this page
+
+ +
+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 616488917..58a773b2f 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Analytics & Reports', items: [ { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx new file mode 100644 index 000000000..e6f24fb02 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/Skeleton.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/utils'; + +interface SkeletonProps { + className?: string; +} + +/** A shimmering placeholder block. Give it the size/shape of the content it stands in for. */ +export default function Skeleton({ className }: SkeletonProps) { + return