Merge pull request #1178 from Tria-plc/dev

freight
This commit is contained in:
marshal
2026-08-08 11:52:38 +03:00
committed by GitHub
63 changed files with 2234 additions and 228 deletions

View File

@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
# Unset = every employee. A key matching no organization returns no users.
# Dev seed key: edr_freight
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
FREIGHT_ORG_KEY=edr_freight
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
@@ -166,19 +172,29 @@ EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
EIMS_TAX_CODE=0
# Required, and deliberately unset: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
EIMS_NATURE_OF_SUPPLIES=Service
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).

View File

@@ -81,6 +81,14 @@ export interface EimsInvoiceConfig {
paymentTerm: string;
unitDefault: string;
buyerCountryCode: string | null;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
* locally rather than being filed with a guessed one.
*/
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
cashierName: string | null;
salesPersonName: string | null;
}
@@ -103,6 +111,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n
return value;
};
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
const map: Record<string, string> = {};
for (const pair of (raw ?? "").split(",")) {
const [name, code] = pair.split("=");
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
}
return map;
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
@@ -168,6 +186,8 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
},

View File

@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -0,0 +1,100 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const CONTAINER_CODES = [
'IMPORT_CONTAINER_CUSTOMS',
'IMPORT_CONTAINER_NO_CUSTOMS',
'EXPORT_CONTAINER_CUSTOMS',
'EXPORT_CONTAINER_NO_CUSTOMS',
'INTERCITY_CONTAINER',
];
const BULK_CODES = [
'IMPORT_BULK_CUSTOMS',
'IMPORT_BULK_NO_CUSTOMS',
'EXPORT_BULK_CUSTOMS',
'EXPORT_BULK_NO_CUSTOMS',
'INTERCITY_BULK',
];
/**
* Bulk contract templates become staff-created, keyed by (cargo type, customs
* clearing) instead of the fixed direction codes. The five container templates
* stay seeded and become undeletable system rows; the five seeded bulk rows are
* retired (soft-deleted). cargo_types gains has_contract_template, marking
* which bulk commodities may carry their own template.
*/
export class BulkContractTemplates3320000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id),
ADD COLUMN IF NOT EXISTS with_customs boolean,
ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false
`);
// Generated bulk codes (BULK_<cargo code>_NO_CUSTOMS) outgrow varchar(40).
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ALTER COLUMN code TYPE varchar(80)
`);
await queryRunner.query(
`UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`,
[CONTAINER_CODES],
);
// Retire the fixed bulk templates; staff recreate them per cargo type.
await queryRunner.query(
`UPDATE freight.contract_templates SET deleted_at = now()
WHERE code = ANY($1) AND deleted_at IS NULL`,
[BULK_CODES],
);
// Code stays unique among live rows only, so a deleted combo can be
// recreated under the same generated code.
await queryRunner.query(
`ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code
ON freight.contract_templates (code) WHERE deleted_at IS NULL
`);
// One template per (bulk cargo type, customs option) — the "same
// combination" rule, enforced even under concurrent creates.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
ON freight.contract_templates (cargo_type_id, with_customs)
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT uq_contract_templates_code UNIQUE (code)
`);
await queryRunner.query(
`UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`,
[BULK_CODES],
);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS with_customs,
DROP COLUMN IF EXISTS is_system
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template
`);
}
}

View File

@@ -24,7 +24,7 @@ import { MigrationInterface, QueryRunner } from "typeorm";
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
* verbatim so a compliance value is never mangled by a parse.
*/
export class EimsInvoiceRegistration3300000000000 implements MigrationInterface {
export class EimsInvoiceRegistration3330000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS document numbering.
*
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
* on the invoice so a filed document can be traced back to it.
*/
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
`);
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS next_document_number,
DROP COLUMN IF EXISTS in_flight_document_number
`);
}
}

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { PaginatedResponse } from '@edr/types';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
export class ListUsersService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
private readonly config: ConfigService,
) {}
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
@@ -40,6 +42,29 @@ export class ListUsersService {
])
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
// Restrict to one IAM organization when configured. The org key differs per
// environment (dev seeds `edr_freight`, production uses the registered
// company key), so this is config rather than a constant. An unset key
// means no restriction; a key matching no organization matches no user —
// failing closed rather than silently widening to every org.
const orgKey = this.config.get<string>('FREIGHT_ORG_KEY');
if (orgKey) {
// EXISTS, not a join: a user with several employee rows would otherwise
// be returned once per row, duplicating them in the list and inflating
// `getManyAndCount`'s total.
qb.andWhere(
`EXISTS (
SELECT 1
FROM iam.employees emp
JOIN iam.organizations org ON org.id = emp.organization_id
WHERE emp.user_id = "user".id
AND org.key = :orgKey
AND org.deleted_at IS NULL
)`,
{ orgKey },
);
}
if (query.userType) {
qb.andWhere('user.userType = :userType', { userType: query.userType });
}

View File

@@ -60,6 +60,8 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...over,
});
@@ -130,7 +132,7 @@ describe("toEimsInvoice", () => {
ExciseTaxValue: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "Service",
NatureOfSupplies: "service",
HarmonizationCode: null,
});
expect(doc.ItemList[1]).toMatchObject({
@@ -207,6 +209,77 @@ describe("toEimsInvoice", () => {
});
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
});
it("maps a wereda name to its code", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
});
it("rejects a NatureOfSupplies MoR does not accept", () => {
expect(() =>
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
).toThrow(/must be one of goods, service/);
});
});
describe("formatEimsDate", () => {
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");

View File

@@ -210,6 +210,22 @@ export interface EimsMapperContext {
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -220,6 +236,22 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
* whole `ItemList` — the error reads "must be the constant value 'service'".
*/
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
@@ -240,6 +272,33 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* exchange rate.
*/
/**
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
* a guessed code onto a tax document is worse than refusing to file.
*/
function resolveLocationCode(
field: "Region" | "Wereda",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -266,6 +325,14 @@ export function toEimsInvoice(
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
}
const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
throw new Error(
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
`got "${context.natureOfSupplies}"`,
);
}
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber);
@@ -285,7 +352,7 @@ export function toEimsInvoice(
Discount: 0,
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: context.natureOfSupplies,
NatureOfSupplies: natureOfSupplies,
ItemCode: line.chargeType,
ProductDescription: line.description?.trim() || line.chargeType,
PreTaxValue,
@@ -329,12 +396,24 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: company.region ?? null,
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: company.woreda ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;

View File

@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ContractTemplatesService } from "./contract-templates.service";
import {
CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto,
ReplaceArticlesDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
// `view` opens the Templates page; `read` is API-read-only for other pages
// that show template data; create/update/delete gate each write. `manage` is
// the legacy write key and keeps working for roles that already hold it.
const TEMPLATE_READ = [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.read,
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
const TEMPLATE_UPDATE = [
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
@ApiTags("contract-templates")
@Controller("contract-templates")
export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {}
// Reads are staff-only (the backoffice Templates tab is the only consumer);
// writes are admin-guarded like other freight configuration resources.
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "List the six contract document templates" })
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
list() {
return this.service.list();
}
@Get(":code")
@Post()
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.create,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary:
"Create a bulk contract template for a (cargo type, customs option) pair",
})
create(@Body() dto: CreateContractTemplateDto) {
return this.service.create(dto);
}
@Get(":code")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Patch(":code")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto);
}
@Post(":code/preview")
@Delete(":code")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.settings.contractTemplates.delete,
FREIGHT_PERMS.admin,
])
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Delete a staff-created bulk template (system templates refuse)",
})
remove(@Param("code") code: string) {
return this.service.remove(code);
}
@Post(":code/preview")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({
summary: "Render an HTML preview of the template against mock contract data",
})
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
/* ------------------------- article routes ------------------------- */
@Put(":code/articles")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles);
}
@Post(":code/articles")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto);
}
@Patch(":code/articles/:articleId")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update an article's title or body" })
updateArticle(
@Param("code") code: string,
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
}
@Delete(":code/articles/:articleId")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Remove an article from the template" })
removeArticle(
@Param("code") code: string,

View File

@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
ContractTemplate,
ContractTemplateCode,
} from "./entities/contract-template.entity";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContractTemplate } from "./entities/contract-template.entity";
@Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
super(repository);
}
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
findByCode(code: string): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } });
}
override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } });
return this.repository.find({
relations: { cargoType: true },
order: { code: "ASC" },
});
}
findByCargoCombo(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
}
/**
* The active bulk template covering this cargo type: written against the
* cargo type itself or against its parent group (the two are mutually
* exclusive, so at most one row matches).
*/
findActiveBulkTemplate(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
.where("t.is_active = true")
.andWhere("t.with_customs = :withCustoms", { withCustoms })
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c
WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL
))`,
{ cargoTypeId },
)
.getOne();
}
findCargoType(id: string): Promise<CargoType | null> {
return this.repository.manager
.getRepository(CargoType)
.findOne({ where: { id } });
}
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {

View File

@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
@@ -11,6 +16,7 @@ import {
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto,
ReplaceArticleDto,
UpdateArticleDto,
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
async list(): Promise<ContractTemplate[]> {
const templates = await this.repository.findAll();
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
return templates.sort(
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
);
// Seeded container templates first in canonical order, then staff-created
// bulk templates alphabetically.
return templates.sort((a, b) => {
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
});
}
async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code));
const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
if (!template) {
throw new NotFoundException(`Contract template ${code} not found`);
}
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
}
/**
* The active template used when generating a contract document for the given
* direction/freight/customs triple; null when missing or deactivated (the
* renderer then falls back to the built-in generic layout).
* Staff-created bulk template for one (cargo type, customs option) pair.
* The cargo type must have hasContractTemplate enabled and the combination
* must not already exist — the same commodity + customs pairing is edited,
* never duplicated.
*/
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
if (!cargoType.hasContractTemplate) {
throw new BadRequestException(
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
);
}
const variant = dto.withCustoms ? "with" : "without";
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
dto.withCustoms,
);
if (existing) {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
const template = new ContractTemplate();
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
template.name =
dto.name ??
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
template.description = dto.description ?? null;
template.documentTitle = dto.withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
template.isActive = true;
template.cargoTypeId = cargoType.id;
template.withCustoms = dto.withCustoms;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
} catch (error) {
// Partial unique index backstop for concurrent creates of the same combo.
if ((error as { code?: string })?.code === "23505") {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
throw error;
}
}
/** Bulk templates only — the five seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
if (template.isSystem) {
throw new BadRequestException(
"System container templates cannot be deleted",
);
}
await this.repository.softDelete(template.id);
}
/**
* The active template used when generating a contract document. Container
* contracts resolve through the fixed direction/customs codes; bulk contracts
* resolve through the staff-created template for the contract's cargo type
* (or its parent group) and customs option. Null when nothing matches or the
* match is deactivated (the renderer then falls back to the built-in generic
* layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
freightType?: string | null,
customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
if (!cargoTypeId) return null;
return this.repository.findActiveBulkTemplate(
cargoTypeId,
Boolean(customsClearingEnabled),
);
}
const code = contractTemplateCodeFor(
tradeDirection,
freightType,
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
: this.sorted(template.articles),
};
const view = this.buildMockView(template.code, dynamicTemplate);
const view = this.buildMockView(template, dynamicTemplate);
return { html: this.renderer.render(view) };
}
/**
* Registry key the mock preview renders against. Staff-created bulk
* templates aren't in the fixed code map — they preview against the
* representative bulk import pack matching their customs option.
*/
private previewKeyFor(template: ContractTemplate): string {
if (template.cargoTypeId) {
return template.withCustoms
? "IMP_BULK_USD_FORWARDING"
: "IMP_BULK_USD_TRANSPORT_ONLY";
}
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
}
private buildMockView(
code: ContractTemplateCode,
template: ContractTemplate,
dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
const isBulk = code.endsWith("_BULK");
const code = template.code;
const previewKey = this.previewKeyFor(template);
const meta = getTemplateMeta(previewKey);
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
const now = new Date();
// Representative rate schedule so the admin preview shows the live-rate
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code],
templateKey: previewKey,
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", {
day: "numeric",
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
}
/** Static, representative rate schedule for the admin preview only. */
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
const dir = code.startsWith("IMPORT")
? "import"
: code.startsWith("EXPORT")
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
};
}
private assertCode(code: string): ContractTemplateCode {
const upper = code?.toUpperCase() as ContractTemplateCode;
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
throw new BadRequestException(
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
);
}
return upper;
}
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}

View File

@@ -6,12 +6,41 @@ import {
IsInt,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
export class CreateContractTemplateDto {
@ApiProperty({
description:
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
format: "uuid",
})
@IsUUID()
cargoTypeId!: string;
@ApiProperty({
description: "Whether this is the with-customs-clearing variant",
})
@IsBoolean()
withCustoms!: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ description: "Short description shown on the template card" })
@IsOptional()
@IsString()
description?: string;
}
export class UpdateContractTemplateDto {
@ApiPropertyOptional({ description: "Display name of the template" })
@IsOptional()

View File

@@ -1,11 +1,18 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
/**
* The ten canonical contract document templates. Import and export split by
* customs clearing (× freight type = 8); intercity does not, because it is a
* purely domestic Ethiopian movement that crosses no border and therefore has
* no customs leg at all (× freight type = 2).
* The five seeded container templates (import/export split by customs
* clearing; intercity is domestic, crosses no border, so it has a single
* template). These are system rows: always present, never deletable.
*
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
* The retired direction-keyed bulk codes remain listed so old frozen document
* snapshots still label correctly.
*
* Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
}
@Entity({ schema: "freight", name: "contract_templates" })
@Index(["code"], { unique: true })
// Uniqueness lives in partial DB indexes (live rows only): code, and
// (cargo_type_id, with_customs) for staff-created bulk templates.
@Index(["code"])
export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 40, unique: true })
code!: ContractTemplateCode;
@Column({ name: "code", type: "varchar", length: 80 })
code!: string;
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
/** Bulk templates only: the cargo type this template is written for. */
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true })
@JoinColumn({ name: "cargo_type_id" })
cargoType?: CargoType | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
@Column({ name: "is_system", type: "boolean", default: false })
isSystem!: boolean;
}

View File

@@ -424,6 +424,8 @@ export class ContractTransitionService {
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
);
if (!active) return null;
return {

View File

@@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
});
}
assertSellerFormats(config.invoice);
}
/**
* MoR's own patterns for the seller fields, checked here rather than at the gateway.
*
* A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a
* consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us.
*/
const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [
{ env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ },
{
env: "EIMS_SELLER_EMAIL",
value: (i) => i.sellerEmail,
pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/,
},
{ env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ },
{ env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ },
];
function assertSellerFormats(invoice: EimsConfig["invoice"]): void {
const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map(
({ env, pattern }) => `${env} (must match ${pattern.source})`,
);
if (bad.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`,
});
}
}
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
@@ -112,6 +143,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -91,9 +92,11 @@ class FakeDb {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
nextDocumentNumber: 5,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
...state,
} as EimsSystemState;
@@ -130,7 +133,10 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async () => LINES,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -147,17 +153,26 @@ const build = (
postSigned: jest.Mock,
cfg: EimsConfig = config(),
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer } as unknown as EimsClientService,
{ getSessionContext } as unknown as EimsAuthService,
{
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
);
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
const DOCUMENT_NUMBER = "INV-20260807-00042";
/**
* Document number the fixtures register under; `/v1/verify` must echo it back.
*
* A plain integer, not our `invoiceNumber`: MoR validates the field against
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
*/
const DOCUMENT_NUMBER = "5";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
@@ -220,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
@@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
// Returned, not consumed: MoR tracks the sequence and rejects a gap
// ("Invoice counter is not correct. expected : 1").
nextInvoiceCounter: 7,
});
});
@@ -391,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("never reuses a counter once an attempt has begun", async () => {
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -404,8 +421,72 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
// The two numbers move differently, because MoR constrains them differently: the counter must
// not skip (it returns), the document number must not repeat (it is burned).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("6");
});
});
describe("EimsInvoiceRegistrationService staff alerting", () => {
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.title).toMatch(/blocked/i);
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
});
it("raises a normal-priority alert for a deterministic rejection", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
});
it("does not alert on a successful filing", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn();
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
.registerInvoiceWithEims(INVOICE_ID);
expect(notify).not.toHaveBeenCalled();
});
it("lets the filing outcome stand even if the alert itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/EIMS register failed \(406\)/);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
});
});
@@ -447,7 +528,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsInvoiceCounter: 7,
eimsDocumentNumber: DOCUMENT_NUMBER,
}),
],
{
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
@@ -498,13 +587,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not INV-20260807-00042/);
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
@@ -547,7 +636,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
db.invoices.set(
OTHER_INVOICE_ID,
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
);
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(

View File

@@ -17,6 +17,9 @@ import {
EimsMapperLine,
toEimsInvoice,
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
interface Reservation {
stateId: string;
invoiceCounter: number;
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
documentNumber: string;
previousIrn: string;
}
@@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService {
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
) {}
private get cfg(): EimsConfig {
@@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService {
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Our own invoice number is the document number; EIMS only requires it to be unique.
documentNumber: invoice.invoiceNumber,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
@@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService {
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
* to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
@@ -231,11 +239,34 @@ export class EimsInvoiceRegistrationService {
});
}
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
// authoritative re-check happens under lock in the transaction below.
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
`SELECT in_flight_invoice_id FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
[(await this.auth.getSessionContext()).systemNumber],
);
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
});
}
// Outside the transaction: no lock is held across the wire, and a refused verification must
// leave the block exactly as it was.
if (irn) {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
if (!invoice.eimsDocumentNumber) {
throw new BadRequestException({
code: "EIMS_NO_DOCUMENT_NUMBER",
message:
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
"returned IRN cannot be tied back to it.",
});
}
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
@@ -266,6 +297,7 @@ export class EimsInvoiceRegistrationService {
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
@@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService {
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const documentNumber = String(Number(state.nextDocumentNumber));
const previousIrn = state.previousIrn ?? "";
// Counter consumed here, not on success: once an attempt begins it can never be reused,
// whatever happens next. A gap is harmless at MoR; a collision is not.
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: invoiceCounter + 1,
nextDocumentNumber: Number(documentNumber) + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
inFlightDocumentNumber: Number(documentNumber),
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsDocumentNumber: documentNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, previousIrn };
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
});
}
@@ -350,15 +386,26 @@ export class EimsInvoiceRegistrationService {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
* The counter stays consumed either way.
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
private async settleFailure(
invoiceId: string,
@@ -386,7 +433,15 @@ export class EimsInvoiceRegistrationService {
EimsSystemState,
reservation.stateId,
deterministic
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
nextInvoiceCounter: reservation.invoiceCounter,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
}
: {
blockedReason:
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
@@ -397,6 +452,41 @@ export class EimsInvoiceRegistrationService {
});
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* Tell the people who can act about a failed filing.
*
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
* number until a human resolves it, and nothing else in the system would surface that — the
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
* priority. Never throws: an alert that fails must not mask the filing outcome.
*/
private async alertStaff(
invoiceId: string,
status: EimsInvoiceStatus,
error: EimsInvoiceError,
deterministic: boolean,
): Promise<void> {
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
});
} catch (err) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
@@ -488,6 +578,7 @@ export class EimsInvoiceRegistrationService {
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,

View File

@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;

View File

@@ -33,6 +33,8 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
cashierName: null,
salesPersonName: null,
...over,

View File

@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsClientService } from "./eims-client.service";
@@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
NotificationInboxModule,
],
controllers: [EimsInvoiceController],
providers: [

View File

@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
nextInvoiceCounter!: number;
/**
* `DocumentDetails.DocumentNumber` for the next registration.
*
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
*/
@Column({ name: "next_document_number", type: "bigint", default: 1 })
nextDocumentNumber!: number;
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;

View File

@@ -60,14 +60,38 @@ export class RefundDto {
}
export class ClientActionDto {
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
// must be assignable here, but freight never requests platform=inapp and therefore never
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
enum: [
"REDIRECT",
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"SHOW_BILL_REFERENCE",
],
})
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
type!:
| "REDIRECT"
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
bridge?: "TELEBIRR";
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;

View File

@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
@IsBoolean()
hasLashing?: boolean;
@ApiPropertyOptional({
default: false,
description:
'Allow staff to write bulk contract templates for this cargo type. ' +
'Mutually exclusive with the parent group / children having it.',
})
@IsOptional()
@IsBoolean()
hasContractTemplate?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean;
/**
* Whether staff may write bulk contract templates against this cargo type.
* Mutually exclusive between a parent group and its children: if the parent
* provides the template, no child may, and vice versa.
*/
@Column({ name: 'has_contract_template', type: 'boolean', default: false })
hasContractTemplate!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -135,6 +135,35 @@ export class CargoTypesService {
return map;
}
/**
* A cargo type and its parent group may not BOTH offer a contract template —
* the template would be ambiguous for bookings of the child. To enable the
* child, the parent must be turned off first (and vice versa).
*/
private async assertContractTemplateExclusive(input: {
id?: string;
parentGroupId?: string | null;
}): Promise<void> {
if (input.parentGroupId) {
const parent = await this.repository.findById(input.parentGroupId);
if (parent?.hasContractTemplate) {
throw new BadRequestException(
`Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`,
);
}
}
if (input.id) {
const children = await this.repository.findAll({
where: { parentGroupId: input.id, hasContractTemplate: true },
});
if (children.length) {
throw new BadRequestException(
`Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`,
);
}
}
}
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName);
@@ -144,6 +173,9 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
if (dto.hasContractTemplate) {
await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId });
}
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
explicitOrder: dto.displayOrder,
@@ -160,6 +192,7 @@ export class CargoTypesService {
code,
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
hasContractTemplate: dto.hasContractTemplate ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
@@ -183,6 +216,18 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
// Re-check the parent/child template exclusivity whenever the flag or the
// parent moves and the row ends up flagged.
const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate;
if (
willHaveTemplate &&
(dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined)
) {
await this.assertContractTemplateExclusive({
id,
parentGroupId: dto.parentGroupId ?? existing.parentGroupId,
});
}
const {
wagonTypeIds,
itemsPerWagonMap,

View File

@@ -1273,6 +1273,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:contract_templates:manage",
"Edit contract templates & articles",
),
// Granular split of contract-template access. `view` opens the sidebar page;
// `read` is API-read-only for other pages that display template data.
perm(
"b4e00001-0001-4000-8000-000000000003",
"edr_freight_app:settings:contract_templates:create",
"Create bulk contract templates",
),
perm(
"b4e00001-0001-4000-8000-000000000004",
"edr_freight_app:settings:contract_templates:update",
"Update contract templates & articles",
),
perm(
"b4e00001-0001-4000-8000-000000000005",
"edr_freight_app:settings:contract_templates:delete",
"Delete bulk contract templates",
),
perm(
"b4e00001-0001-4000-8000-000000000006",
"edr_freight_app:settings:contract_templates:read",
"Read contract template data (API only)",
),
];
// N. Previously-ungated staff surfaces (support inbox, procurement, compliance,
@@ -1816,6 +1838,10 @@ export const FREIGHT_PERMS = {
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
create: "edr_freight_app:settings:contract_templates:create",
update: "edr_freight_app:settings:contract_templates:update",
delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read",
},
},
audit: {

View File

@@ -12,3 +12,9 @@ VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
# observability stays off (the app works either way). Self-hosted instance.
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_POSTHOG_HOST=https://posthog.example.com
# Maps JavaScript API key (fleet TrackingPage). Required — the hardcoded
# fallback in TrackingPage.tsx is expired (ExpiredKeyMapError), so without
# this set the tracking map renders blank. Get a key from the Google Cloud
# Console (Maps JavaScript API + Places API + Geocoding API enabled).
VITE_GOOGLE_MAPS_API_KEY=

View File

@@ -835,7 +835,12 @@ const App = () => {
<Route
path="contract-templates"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplatesPage />
</RequirePermission>
}
@@ -843,7 +848,12 @@ const App = () => {
<Route
path="contract-templates/:code"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplateEditorPage />
</RequirePermission>
}

View File

@@ -0,0 +1,158 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims";
import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "gray",
SUBMITTING: "yellow",
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "Not filed",
SUBMITTING: "Filing…",
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
};
function Field({ label, value }: { label: string; value?: string | number | null }) {
return (
<Stack gap={2}>
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{label}
</Text>
<Text size="sm" c="edr-text" style={{ wordBreak: "break-all" }}>
{value === null || value === undefined || value === "" ? "—" : value}
</Text>
</Stack>
);
}
/**
* MoR EIMS filing state for one invoice, with the manual actions.
*
* Filing normally happens on the API's cron sweep, not here — these controls exist for controlled
* testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that
* needs re-filing, and an unacknowledged one that has blocked all further filing.
*/
export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
);
const register = useMutation(
api.invoices.eimsRegister.mutationOptions({
onSuccess: (result) =>
toast({
title: result.eimsIrn ? "Filed with MoR" : "Filing finished",
description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`,
}),
}),
);
const verify = useMutation(
api.invoices.eimsVerify.mutationOptions({
onSuccess: (result) =>
toast({
title: "MoR confirmed the filing",
description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`,
}),
}),
);
if (isLoading || !eims) return null;
const status = eims.eimsStatus;
const busy = register.isPending || verify.isPending;
return (
<Card>
<Stack gap="lg">
<Group justify="space-between">
<Text fw={600} c="edr-text">
MoR e-invoicing
</Text>
<Badge color={STATUS_COLOR[status] ?? "gray"} variant="light" size="sm" radius="md" fw={600}>
{STATUS_LABEL[status] ?? status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<Field label="IRN" value={eims.eimsIrn} />
<Field label="Invoice counter" value={eims.eimsInvoiceCounter} />
<Field
label="Submitted"
value={eims.eimsSubmittedAt ? new Date(eims.eimsSubmittedAt).toLocaleString() : null}
/>
<Field label="Acknowledged" value={eims.eimsAckDate} />
</SimpleGrid>
{status === "UNKNOWN" && (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="All filing is blocked">
This invoice was sent but never acknowledged, so its IRN is unknown and no further
invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN
or discard the attempt.
</Alert>
)}
{eims.eimsLastError && (
<Alert
color={status === "FAILED" ? "red" : "orange"}
icon={<AlertTriangle size={16} />}
title={`MoR reported: ${eims.eimsLastError.kind}`}
>
{eims.eimsLastError.message}
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
)}
</Stack>
</Card>
);
}
export default EimsFilingCard;

View File

@@ -488,7 +488,11 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
// `view` opens the page; `read` alone is API-only and shows no menu.
permission: [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
],
},
{
label: "Audit logs",

View File

@@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({
config={config}
layout="compact"
readOnly={readOnly}
onEdit={onEdit ?? (() => { })}
onEdit={onEdit}
onDelete={onDelete ?? (() => { })}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}

View File

@@ -14,7 +14,7 @@ import type { RuleEngineRecord } from "@/types/rule-engine";
export interface RuleEngineRecordActionsProps {
record: RuleEngineRecord;
config: RuleEngineResourceConfig;
onEdit: (record: RuleEngineRecord) => void;
onEdit?: (record: RuleEngineRecord) => void;
onDelete: (record: RuleEngineRecord) => void;
onViewChain?: () => void;
onSubmitRate?: (id: string) => void;
@@ -93,17 +93,19 @@ const RuleEngineRecordActions = ({
) : null}
<div style={actionGroupStyle}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
onClick={() => onEdit(record)}
leftSection={<Pencil size={14} />}
styles={{ root: { fontWeight: 600 } }}
>
Edit
</Button>
{onEdit ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
onClick={() => onEdit(record)}
leftSection={<Pencil size={14} />}
styles={{ root: { fontWeight: 600 } }}
>
Edit
</Button>
) : null}
<Button
variant="subtle"
color="red"
@@ -151,21 +153,23 @@ const RuleEngineRecordActions = ({
) : null}
<div style={actionGroupStyle}>
<Tooltip label="Edit">
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="md"
onClick={() => onEdit(record)}
aria-label="Edit record"
style={{
background: "white",
}}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
{onEdit ? (
<Tooltip label="Edit">
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="md"
onClick={() => onEdit(record)}
aria-label="Edit record"
style={{
background: "white",
}}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
) : null}
<Tooltip label="Delete">
<ActionIcon
variant="subtle"

View File

@@ -51,6 +51,7 @@ export const QUERY_KEYS = {
list: (filter?: InvoiceListFilter) =>
["invoices", "list", filter ?? {}] as const,
byId: (id: string) => ["invoices", "detail", id] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
},
BOOKINGS: {

View File

@@ -107,6 +107,14 @@ export const URL_CONSTANTS = {
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
},
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
EIMS: {
STATUS: (id: string) => `/invoices/${id}/eims/status`,
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,

View File

@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
import {
contractTemplatesService,
type ArticlePayload,
type CreateContractTemplatePayload,
type UpdateContractTemplatePayload,
} from "@/services/contract-templates.service";
@@ -59,6 +60,21 @@ function useTemplateMutation<TVariables>(
});
}
export function useCreateContractTemplate() {
return useTemplateMutation(
(payload: CreateContractTemplatePayload) =>
contractTemplatesService.create(payload),
"Template created",
);
}
export function useDeleteContractTemplate() {
return useTemplateMutation(
(code: string) => contractTemplatesService.remove(code),
"Template deleted",
);
}
export function useUpdateContractTemplate(code: string) {
return useTemplateMutation(
(payload: UpdateContractTemplatePayload) =>

View File

@@ -128,6 +128,10 @@ export const FREIGHT_PERMS = {
invoices: {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
},
firstMile: {
view: "edr_freight_app:first_mile:view",
@@ -317,6 +321,10 @@ export const FREIGHT_PERMS = {
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
create: "edr_freight_app:settings:contract_templates:create",
update: "edr_freight_app:settings:contract_templates:update",
delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read",
},
},
audit: {

View File

@@ -1,11 +1,15 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
SegmentedControl,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -19,11 +23,21 @@ import {
Container,
Eye,
FileText,
Lock,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
import {
useContractTemplates,
useCreateContractTemplate,
useDeleteContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { cargoTypesService } from "@/services/cargo-types.service";
import type { ContractTemplate } from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
@@ -39,22 +53,18 @@ const DIRECTION_DOT: Record<string, string> = {
INTERCITY: "var(--mantine-color-orange-5)",
};
function templateDirection(code: ContractTemplate["code"]): string {
return code.split("_")[0];
function isBulk(template: ContractTemplate): boolean {
return Boolean(template.cargoTypeId);
}
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
// the second one — never the suffix.
function isBulk(code: ContractTemplate["code"]): boolean {
return code.split("_")[1] === "BULK";
}
// Intercity is domestic and crosses no border, so it has no customs variant at
// all — hence null rather than false, which would wrongly read as a deliberate
// "client clears its own customs" choice.
function customsVariant(code: ContractTemplate["code"]): boolean | null {
if (code.endsWith("_NO_CUSTOMS")) return false;
if (code.endsWith("_CUSTOMS")) return true;
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
// domestic and crosses no border, so it has no customs variant at all — hence
// null rather than false, which would wrongly read as a deliberate "client
// clears its own customs" choice.
function customsVariant(template: ContractTemplate): boolean | null {
if (isBulk(template)) return template.withCustoms ?? null;
if (template.code.endsWith("_NO_CUSTOMS")) return false;
if (template.code.endsWith("_CUSTOMS")) return true;
return null;
}
@@ -66,10 +76,28 @@ function formatUpdated(value: string): string {
});
}
interface CargoTypeOption {
id: string;
cargoTypeName?: string;
hasContractTemplate?: boolean;
}
export default function ContractTemplatesPage() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: templates, isLoading } = useContractTemplates();
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ContractTemplate | null>(null);
const perms = FREIGHT_PERMS.settings.contractTemplates;
const isAdmin = hasPermission(user, FREIGHT_PERMS.admin);
const canManage = isAdmin || hasPermission(user, perms.manage);
const canCreate = canManage || hasPermission(user, perms.create);
const canUpdate = canManage || hasPermission(user, perms.update);
const canDelete = isAdmin || hasPermission(user, perms.delete);
const deleteTemplate = useDeleteContractTemplate();
const previewTemplate = templates?.find((t) => t.code === previewCode);
@@ -77,20 +105,34 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
action={
canCreate ? (
<Button
color="edr-green"
leftSection={<Plus size={16} />}
onClick={() => setCreateOpen(true)}
>
New bulk template
</Button>
) : undefined
}
/>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{isLoading
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
: (templates ?? []).map((template) => (
<TemplateCard
key={template.code}
template={template}
canUpdate={canUpdate}
canDelete={canDelete}
onPreview={() => setPreviewCode(template.code)}
onEdit={() =>
navigate(`/dashboard/contract-templates/${template.code}`)
}
onDelete={() => setDeleteTarget(template)}
/>
))}
</SimpleGrid>
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
onClose={() => setPreviewCode(null)}
/>
<CreateTemplateModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(code) => {
setCreateOpen(false);
navigate(`/dashboard/contract-templates/${code}`);
}}
/>
{/* ── Delete confirm ─────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Delete contract template?"
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
This will delete{" "}
<Text span fw={600}>
{deleteTarget?.name}
</Text>{" "}
and its articles. Contracts already generated keep their frozen
document; new contracts for this combination fall back to the
generic layout until a new template is created.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteTemplate.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteTemplate.mutate(deleteTarget.code, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* Staff pick the customs option first, then a bulk cargo type that has
* "has contract template" enabled. One template per combination — the API
* rejects duplicates, so an existing pairing must be edited instead.
*/
function CreateTemplateModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: (code: string) => void;
}) {
const [withCustoms, setWithCustoms] = useState<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const create = useCreateContractTemplate();
const { data: cargoTypes, isLoading } = useQuery({
queryKey: ["cargo-types", "contract-template-options"],
queryFn: () => cargoTypesService.getCargoTypes(),
enabled: opened,
});
const options = useMemo(
() =>
((cargoTypes ?? []) as CargoTypeOption[])
.filter((cargoType) => cargoType.hasContractTemplate)
.map((cargoType) => ({
value: cargoType.id,
label: cargoType.cargoTypeName ?? "Untitled",
})),
[cargoTypes],
);
const close = () => {
setCargoTypeId(null);
onClose();
};
return (
<Modal opened={opened} onClose={close} title="New bulk contract template" centered>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
]}
/>
</div>
<Select
label="Bulk cargo type"
description="Only cargo types with “has contract template” enabled are listed"
placeholder={isLoading ? "Loading…" : "Select a cargo type"}
data={options}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!cargoTypeId}
loading={create.isPending}
onClick={() => {
if (!cargoTypeId) return;
create.mutate(
{ cargoTypeId, withCustoms: withCustoms === "true" },
{
onSuccess: (template) =>
onCreated((template as ContractTemplate).code),
},
);
}}
>
Create template
</Button>
</Group>
</Stack>
</Modal>
);
}
function TemplateCard({
template,
canUpdate,
canDelete,
onPreview,
onEdit,
onDelete,
}: {
template: ContractTemplate;
canUpdate: boolean;
canDelete: boolean;
onPreview: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const direction = templateDirection(template.code);
const bulk = isBulk(template.code);
const customs = customsVariant(template.code);
const bulk = isBulk(template);
const direction = template.code.split("_")[0];
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
<Card
@@ -142,12 +336,13 @@ function TemplateCard({
style={{
borderRadius: 999,
flexShrink: 0,
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
background: bulk
? "var(--mantine-color-teal-5)"
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
{bulk ? "Bulk" : "Container"}
{kicker}
</Text>
</Group>
</Group>
@@ -166,6 +361,18 @@ function TemplateCard({
</Badge>
</Tooltip>
)}
{template.isSystem && (
<Tooltip label="Built-in template — cannot be deleted" withArrow>
<Badge
size="sm"
variant="light"
color="gray"
leftSection={<Lock size={11} />}
>
System
</Badge>
</Tooltip>
)}
{!template.isActive && (
<Tooltip label="Not used for new contracts" withArrow>
<Badge size="sm" variant="light" color="red">
@@ -221,16 +428,34 @@ function TemplateCard({
>
Preview
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
<Group gap={6} wrap="nowrap">
{canDelete && !template.isSystem && (
<Tooltip label="Delete template" withArrow>
<Button
variant="subtle"
color="red"
size="compact-sm"
radius="md"
px={8}
onClick={onDelete}
>
<Trash2 size={14} />
</Button>
</Tooltip>
)}
{canUpdate && (
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
)}
</Group>
</Group>
</Box>
</Card>

View File

@@ -34,6 +34,8 @@ import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.serv
import { freightBrand } from "@/theme/freight-brand";
// Same default key + env override the portal's LocationPicker uses.
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";

View File

@@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -165,6 +166,8 @@ export default function InvoiceDetailPage() {
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">

View File

@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
@@ -112,6 +114,10 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.
{ name: "hasContractTemplate", label: "Has contract template", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -576,6 +582,13 @@ function CargoRow({
</Badge>
</Tooltip>
) : null}
{node.hasContractTemplate ? (
<Tooltip label="Bulk contract templates are written for this cargo type" withArrow>
<Badge size="xs" variant="light" color="grape" radius="sm">
Contract
</Badge>
</Tooltip>
) : null}
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">

View File

@@ -543,10 +543,14 @@ const RuleEngineResourcePage = () => {
config={config}
layout="row"
readOnly={!canUpdateControls}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onEdit={
config.slug === "container-types"
? undefined
: (record) => {
setEditing(record);
setFormOpen(true);
}
}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules"
@@ -881,7 +885,9 @@ const RuleEngineResourcePage = () => {
totalCount={totalCount}
onPaginationChange={setPagination}
readOnly={!canUpdate && !canDelete}
onEdit={canUpdate ? openEdit : undefined}
onEdit={
canUpdate && config.slug !== "container-types" ? openEdit : undefined
}
onDelete={canDelete ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules"

View File

@@ -18,7 +18,6 @@ import {
History,
Inbox,
PackageCheck,
Plus,
RefreshCw,
Search,
Send,
@@ -311,19 +310,6 @@ export default function WagonTransfersPage() {
>
Refresh
</Button>
{canRequest ? (
<Button
color="edr-green"
radius="md"
leftSection={<Plus size={15} />}
onClick={() => {
setCarryOver(null);
setFormOpen(true);
}}
>
New request
</Button>
) : null}
</Group>
}
/>

View File

@@ -148,6 +148,8 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -2914,6 +2916,36 @@ export const api = {
({ id }) => invoicesService.getById(id),
({ id }) => QUERY_KEYS.INVOICES.byId(id),
),
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsStatus",
({ id }) => eimsService.status(id),
({ id }) => QUERY_KEYS.INVOICES.eimsStatus(id),
),
// Both mutations refresh the filing panel; register also moves the invoice's own row.
eimsRegister: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsRegister",
({ id }) => eimsService.register(id),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsVerify: endpoint<{ id: string }, EimsVerifyResult>(
"invoices",
"eimsVerify",
({ id }) => eimsService.verify(id),
),
eimsResolve: endpoint<{ id: string; irn?: string; discard?: boolean }, EimsInvoiceStatusView>(
"invoices",
"eimsResolve",
({ id, irn, discard }) => eimsService.resolve(id, { irn, discard }),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
},
overview: {

View File

@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
export interface ContractTemplate {
id: string;
// Import/export split by customs clearing; intercity is domestic, crosses no
// border, and so has a single template.
code:
| "IMPORT_BULK_CUSTOMS"
| "IMPORT_BULK_NO_CUSTOMS"
| "EXPORT_BULK_CUSTOMS"
| "EXPORT_BULK_NO_CUSTOMS"
| "INTERCITY_BULK"
| "IMPORT_CONTAINER_CUSTOMS"
| "IMPORT_CONTAINER_NO_CUSTOMS"
| "EXPORT_CONTAINER_CUSTOMS"
| "EXPORT_CONTAINER_NO_CUSTOMS"
| "INTERCITY_CONTAINER";
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
code: string;
name: string;
description?: string | null;
documentTitle: string;
whereasClauses: string[];
articles: ContractTemplateArticle[];
isActive: boolean;
/** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: string } | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateContractTemplatePayload {
cargoTypeId: string;
withCustoms: boolean;
name?: string;
description?: string;
}
export interface UpdateContractTemplatePayload {
name?: string;
description?: string;
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
return data;
},
async create(payload: CreateContractTemplatePayload): Promise<ContractTemplate> {
const { data } = await client.post<ContractTemplate>(BASE, payload);
return data;
},
async remove(code: string): Promise<void> {
await client.delete(`${BASE}/${code}`);
},
async getByCode(code: string): Promise<ContractTemplate> {
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
return data;

View File

@@ -0,0 +1,39 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
/**
* MoR EIMS filing actions on an invoice.
*
* Registration is irreversible at the tax authority, so these are admin actions rather than part
* of the ordinary invoice screen: the normal production path is the API's cron sweep.
*/
export const eimsService = {
status(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.get<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.STATUS(invoiceId))
.then((r) => r.data);
},
register(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.REGISTER(invoiceId))
.then((r) => r.data);
},
verify(invoiceId: string): Promise<EimsVerifyResult> {
return apiClient
.post<EimsVerifyResult>(URL_CONSTANTS.EIMS.VERIFY(invoiceId))
.then((r) => r.data);
},
/** Record an IRN confirmed with MoR, or discard the attempt. Clears the system-wide block. */
resolve(
invoiceId: string,
input: { irn?: string; discard?: boolean },
): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
.then((r) => r.data);
},
};

View File

@@ -0,0 +1,44 @@
/**
* MoR EIMS filing state for one invoice.
*
* Mirrors `EimsInvoiceStatusView` in the freight API (`modules/eims/eims-registration.types.ts`).
* Kept local rather than in `@edr/types` because only the backoffice reads it.
*/
export type EimsInvoiceStatus =
| "NOT_SUBMITTED"
| "SUBMITTING"
| "REGISTERED"
| "FAILED"
| "UNKNOWN";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError {
kind: string;
message: string;
httpStatus?: number;
details?: Record<string, unknown>;
at: string;
}
export interface EimsInvoiceStatusView {
invoiceId: string;
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: string | null;
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}
/** `POST /v1/verify` response, echoed back from the gateway. */
export interface EimsVerifyResult {
statusCode?: number;
message?: string;
body?: {
Irn?: string;
DocumentDetails?: { Type?: string; DocumentNumber?: string; Date?: string };
[section: string]: unknown;
};
}

View File

@@ -47,6 +47,12 @@ interface PlacePrediction {
// Maps JavaScript API keys are public client-side keys (lock them down by
// HTTP-referrer in the Google Cloud console). The env var lets deployments
// override the default key without a code change.
//
// NOTE: the fallback key below is EXPIRED (confirmed via live request —
// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders
// this picker's map blank while the search box spins forever. Set
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't
// rely on this default.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";

View File

@@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
export const CUTOFF_MINUTES = 30;
/**
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
*
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
* checkinMinutesBefore so that each route's own window is respected.
* How long a passenger is given to finish one provider payment session, once opened.
* 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack.
*/
export const PAYMENT_SESSION_MINUTES = 6;
export const MIN_PAYMENT_WINDOW_MINUTES = 7;
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
@@ -27,3 +33,19 @@ export function computePaymentDeadline(
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}
export function canOpenPaymentSession(
paymentDeadline: Date,
now: Date = new Date(),
): boolean {
return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000;
}
export function computePaymentSessionExpiry(
paymentDeadline: Date,
now: Date = new Date(),
): Date {
const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000);
return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline;
}

View File

@@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto {
class PaySupplementaryChargeDto {
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
}
@ApiTags("Payment")
@@ -307,7 +307,14 @@ export class PaymentsController {
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiQuery({
name: "platform",
enum: ["web", "mobile"],
required: false,
description:
"Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " +
"to redirect and must go through POST /payments/initiate for the bridge payload.",
})
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,

View File

@@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum {
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
}
export type PaymentPlatformDto = "web" | "mobile";
/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */
export type PaymentPlatformDto = "web" | "mobile" | "inapp";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
@@ -45,12 +46,14 @@ export class InitiatePaymentDto {
@IsString()
paymentMethodId?: string;
@ApiPropertyOptional({
enum: ["web", "mobile"],
enum: ["web", "mobile", "inapp"],
default: "web",
description: "Payment platform (web or mobile)",
description:
"Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " +
"(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.",
})
@IsOptional()
@IsIn(["web", "mobile"])
@IsIn(["web", "mobile", "inapp"])
platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
@@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto {
export class ClientActionDto {
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
enum: [
"REDIRECT",
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"SHOW_BILL_REFERENCE",
],
})
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
type:
| "REDIRECT"
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({
@@ -134,6 +148,18 @@ export class ClientActionDto {
description: "Set when type=LAUNCH_APP (mobile flow)",
})
shortCode?: string;
@ApiPropertyOptional({
description:
"Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call",
enum: ["TELEBIRR"],
})
bridge?: "TELEBIRR";
@ApiPropertyOptional({
description:
"Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " +
"to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
@@ -154,6 +180,10 @@ export class InitiateResponseDto {
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
/** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */
@ApiPropertyOptional() sessionExpiresAt?: string;
/** The booking's payment deadline: after it, the booking is auto-cancelled. */
@ApiPropertyOptional() paymentDeadline?: string;
}
export class IntentStatusDto {

View File

@@ -16,6 +16,11 @@ import {
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import {
MAX_PAYMENT_HOURS,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SESSION_MINUTES,
} from "../../common/utils/payment-deadline.utils";
describe("PaymentsService", () => {
let service: PaymentsService;
@@ -163,6 +168,69 @@ describe("PaymentsService", () => {
).rejects.toThrow(BadRequestException);
});
/**
* A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is
* MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep
* departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it.
*/
const bookingWithDeadlineIn = (minutesLeft: number) => ({
...mockBooking,
createdAt: new Date(
Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000,
),
originStationId: null,
schedule: {
departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000),
stopTimes: [],
route: null,
},
});
it("should refuse to open a provider session that cannot finish before auto-cancel", async () => {
// 2 minutes left — the real incident: the session was opened, the provider captured the
// money, and the auto-cancel cron had already cancelled the booking by then.
mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2));
await expect(
service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(BadRequestException);
// Nothing may reach the provider — no session, no capture, no orphan payment.
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
});
it("should open a session and report its expiry when the window is wide enough", async () => {
const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3;
mockPrisma.booking.findUnique.mockResolvedValue(
bookingWithDeadlineIn(minutesLeft),
);
mockPaymentClient.initiate.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
});
expect(mockPaymentClient.initiate).toHaveBeenCalled();
// Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it.
const sessionMs =
new Date(result.sessionExpiresAt!).getTime() - Date.now();
expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000);
expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan(
new Date(result.paymentDeadline!).getTime(),
);
});
it("should initiate a provider payment through the payment microservice", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPaymentClient.initiate.mockResolvedValue(

View File

@@ -29,7 +29,13 @@ import {
MarkPaidResponseDto,
BillQueryResponseDto,
} from "./internal-payments.dto";
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
import {
computePaymentDeadline,
computePaymentSessionExpiry,
canOpenPaymentSession,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -260,6 +266,27 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Refuse to open a provider session that cannot finish before auto-cancel. Everything below
// this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes
// minutes; TasksService cancels the booking the first cron tick after its payment deadline.
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) {
const remainingMs = paymentDeadline.getTime() - Date.now();
throw new BadRequestException(
remainingMs <= 0
? "The payment window for this booking has expired. Please make a new booking."
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
);
}
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
// single passenger projection row (upserted by bookingId below) tracks the latest session.
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
@@ -317,9 +344,7 @@ export class PaymentsService {
payerName =
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName;
expiresAt = (
await this.computeBookingPaymentDeadline(booking.id)
)?.toISOString();
expiresAt = paymentDeadline?.toISOString();
}
const snapshot = await this.paymentClient.initiate({
@@ -350,7 +375,11 @@ export class PaymentsService {
where: { id: intent.id },
});
}
return this.formatIntentResponse(intent);
return {
...this.formatIntentResponse(intent),
sessionExpiresAt: sessionExpiresAt?.toISOString(),
paymentDeadline: paymentDeadline?.toISOString(),
};
}
/**
@@ -436,8 +465,15 @@ export class PaymentsService {
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
// A CBE debit confirmed now lands in seconds, so this doesn't need the full
// MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so
// close to the deadline that the auto-cancel cron cancels the booking before the capture is
// registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking.
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) {
if (
deadline &&
deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now()
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
return { ...base, stillPayable: true, reason: null };

View File

@@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
import { PaymentPlatformDto } from './payments.dto';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@@ -119,7 +120,7 @@ export class SupplementaryChargesService {
async pay(
token: string,
method: string,
platform?: 'web' | 'mobile',
platform?: PaymentPlatformDto,
requestOrigin?: string | null,
) {
const charge = await this.getByToken(token); // validates status/expiry

View File

@@ -30,13 +30,26 @@ export default function ReportsPage() {
const getDateRange = () => {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date();
if (dateRange === 'custom') {
if (startDate && endDate) {
return startDate <= endDate
? { startDate, endDate }
: { startDate: endDate, endDate: startDate };
}
const fallbackStart = new Date(end);
fallbackStart.setDate(end.getDate() - 30);
return {
startDate: fallbackStart.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
};
}
const start = new Date(end);
switch (dateRange) {
case '7': start.setDate(end.getDate() - 7); break;
case '30': start.setDate(end.getDate() - 30); break;
case '90': start.setDate(end.getDate() - 90); break;
default:
if (startDate && endDate) return { startDate, endDate };
}
return {
startDate: start.toISOString().split('T')[0],

View File

@@ -74,6 +74,7 @@ export default function PassengersReportPage() {
const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
const [filterOrigin, setFilterOrigin] = useState("");
const [filterDestination, setFilterDestination] = useState("");
const [filterSeatClass, setFilterSeatClass] = useState("");
const [filterCoachNumber, setFilterCoachNumber] = useState("");
@@ -110,17 +111,23 @@ export default function PassengersReportPage() {
const originOptions = [
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
].sort() as string[];
const destinationOptions = [
...new Set(passengerList.map((p) => p.destination).filter(Boolean)),
].sort() as string[];
const filteredList = passengerList
.filter((p) => {
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (filterDestination && p.destination !== filterDestination) return false;
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.origin ?? '').toLowerCase().includes(q) ||
(p.destination ?? '').toLowerCase().includes(q) ||
(p.idDocumentNumber ?? "").toLowerCase().includes(q) ||
(p.passportNumber ?? "").toLowerCase().includes(q)
);
@@ -207,6 +214,7 @@ export default function PassengersReportPage() {
setListSearch("");
setFilterCoachNumber("");
setFilterOrigin("");
setFilterDestination("");
setFilterSeatClass("");
}}
disabled={loadingSchedules}
@@ -499,6 +507,18 @@ export default function PassengersReportPage() {
</option>
))}
</select>
<select
className="input w-36"
value={filterDestination}
onChange={(e) => { setFilterDestination(e.target.value); resetListPage(); }}
>
<option value="">All destinations</option>
{destinationOptions.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
{passengerList.length > 0 && (
<ActionButton
icon={Download}

View File

@@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useState, useEffect } from "react";
import {
isTelebirrMiniApp,
onTelebirrPayResult,
startTelebirrPay,
} from "@/lib/telebirr-bridge";
import { useState, useEffect, useCallback, useMemo } from "react";
import { PaymentMethod } from "@/types";
import { format } from "date-fns";
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
@@ -55,6 +60,17 @@ export default function PaymentPage() {
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
// Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're
// polling our own status endpoint for the webhook-backed outcome.
const [verifyingPayment, setVerifyingPayment] = useState(false);
// Resolved once on mount — SSR has no `window`, so this must not be read during render
// of the first (server) pass.
const [inMiniApp, setInMiniApp] = useState(false);
useEffect(() => {
setInMiniApp(isTelebirrMiniApp());
}, []);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
// Use the same display currency as the review page — stored on the schedule at search time.
@@ -72,8 +88,26 @@ export default function PaymentPage() {
},
});
// Inside the telebirr SuperApp only telebirr can complete: every other method is a
// redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those
// gateways use. Offering them would strand the payer on a dead page.
// Memoised: this feeds an effect's dep array, and a fresh array identity every render
// would re-run that effect on every render.
const availableMethods = useMemo(
() => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')),
[paymentMethods, inMiniApp],
);
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
// A method chosen before the container was known (or carried over in state) may no longer
// be offerable — drop it rather than letting Pay fire against a hidden method.
useEffect(() => {
if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) {
setSelectedMethod(null);
}
}, [selectedMethod, availableMethods]);
// Derive charge currency directly from the selected method — no separate state that can lag.
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
@@ -128,6 +162,70 @@ export default function PaymentPage() {
}
}, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
/**
* Poll our own status endpoint until the payment reaches a terminal state.
*
* Used by the telebirr mini-app flow, where nothing navigates and therefore no return page
* ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome
* is the webhook-backed status the API reports here.
*/
const pollPaymentStatus = useCallback(
async (attemptsLeft: number): Promise<void> => {
if (!bookingId) return;
try {
const res: any = await apiClient.get(`/payments/status/${bookingId}`);
if (res?.status === 'SUCCEEDED') {
setVerifyingPayment(false);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (res?.status === 'FAILED' || res?.status === 'CANCELLED') {
setVerifyingPayment(false);
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(res?.failureMessage || "Payment was not completed. Please try again.");
return;
}
} catch {
// Transient read failure — keep polling; the attempt budget bounds it.
}
if (attemptsLeft <= 0) {
// Don't call it failed: telebirr may have taken the money and the webhook is simply
// still in flight. Stop spinning, tell the truth, and let the payer re-check.
setVerifyingPayment(false);
setIsProcessing(false);
setPaymentError(
"We haven't received confirmation yet. If you completed the payment, your booking " +
"will be confirmed shortly — check My Bookings in a moment before paying again.",
);
return;
}
setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500);
},
[bookingId, router, updateStatus],
);
/**
* Telebirr mini app reports the sheet outcome on a global callback rather than a redirect.
* Registered on mount — the SuperApp can call back the moment the sheet closes, so it must
* already be installed before the bridge is invoked.
*/
useEffect(() => {
return onTelebirrPayResult((succeeded) => {
if (!succeeded) {
setVerifyingPayment(false);
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError("Payment was cancelled or declined. Please try again.");
return;
}
setVerifyingPayment(true);
void pollPaymentStatus(15);
});
}, [pollPaymentStatus, updateStatus]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
return await apiClient.post("/payments/initiate", {
@@ -135,7 +233,7 @@ export default function PaymentPage() {
method: data.method,
paymentMethodId: data.paymentMethodId,
payerAccount: data.payerAccount,
platform: 'web',
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
});
},
onSuccess: async (data: any) => {
@@ -163,6 +261,23 @@ export default function PaymentPage() {
return;
}
// Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing
// navigates — telebirr draws its payment sheet over the WebView and reports back on
// the global callback registered above, which starts the status polling.
if (data?.clientAction?.type === 'INVOKE_BRIDGE') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
if (!startTelebirrPay(data.clientAction.rawRequest)) {
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(
"Couldn't open the telebirr payment sheet. Please reopen this page from the " +
"telebirr app and try again.",
);
}
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
@@ -505,7 +620,15 @@ export default function PaymentPage() {
{isProcessing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
{paymentMutation.isSuccess ? (
{verifyingPayment ? (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Confirming payment</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Checking with telebirr this only takes a moment.
</p>
</>
) : paymentMutation.isSuccess ? (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-bold mb-4 text-gray-900 dark:text-gray-100">Loading...</h3>
@@ -688,13 +811,13 @@ export default function PaymentPage() {
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200 text-sm">Failed to load payment methods. Please refresh.</p>
</div>
) : paymentMethods.length === 0 ? (
) : availableMethods.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-800 dark:text-yellow-200 text-sm">No payment methods available at the moment.</p>
</div>
) : (
<div className="space-y-3">
{paymentMethods.filter(m => m.enabled).map((method) => {
{availableMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (

View File

@@ -0,0 +1,106 @@
/**
* Telebirr SuperApp mini-app bridge.
*
* When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable:
* the H5 paygate page hands off to the native wallet with a custom scheme
* (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer
* only ever sees `net::ERR_UNKNOWN_URL_SCHEME`.
*
* The in-app flow never navigates. The API returns a signed `rawRequest` string
* (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr
* renders its own payment sheet over the WebView and reports the outcome on a global callback.
*
* See docs/telebirr-miniapp/inapp-payment-plan.md.
*/
/** Name of the global the SuperApp calls back into. Must be a property of `window`. */
export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback";
type ConsumerApp = { evaluate: (payload: string) => void };
declare global {
interface Window {
/** Injected by the telebirr SuperApp WebView. Absent everywhere else. */
consumerapp?: ConsumerApp;
[TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void;
}
}
/**
* True only when the telebirr host bridge is actually present.
*
* Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would
* make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use —
* there is no navigating our way out of that, because by then the server has already committed
* to the bridge payload. Gating on the bridge object keeps the decision and the capability in
* sync: if we can't call it, we don't ask for it.
*/
export function isTelebirrMiniApp(): boolean {
return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function";
}
/**
* Hand a signed rawRequest to the SuperApp to open its payment sheet.
*
* Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke
* it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the
* caller can surface an error instead of leaving the payer on a dead spinner.
*/
export function startTelebirrPay(rawRequest: string): boolean {
if (!isTelebirrMiniApp()) return false;
try {
window.consumerapp!.evaluate(
JSON.stringify({
functionName: "js_fun_start_pay",
params: {
rawRequest,
functionCallBackName: TELEBIRR_PAY_CALLBACK,
},
}),
);
return true;
} catch (err) {
console.error("[telebirr] bridge evaluate failed:", err);
return false;
}
}
/**
* Install the global result callback; returns a disposer for effect cleanup.
*
* The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet,
* the host can report success before settlement lands, and the payload shape is not a contract.
* Confirmation always comes from polling our own payment status (webhook-backed).
*/
export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void {
if (typeof window === "undefined") return () => {};
window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => {
handler(isSuccessResponse(response));
};
return () => {
delete window[TELEBIRR_PAY_CALLBACK];
};
}
/**
* Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a
* JSON string or an object depending on host version, and an unparseable payload is treated as
* success on purpose: polling is what decides the outcome, and a false "failed" would strand a
* payer who actually paid.
*/
function isSuccessResponse(response: unknown): boolean {
let parsed: unknown = response;
if (typeof response === "string") {
try {
parsed = JSON.parse(response);
} catch {
return true;
}
}
if (parsed && typeof parsed === "object" && "code" in parsed) {
const code = (parsed as { code: unknown }).code;
if (code === undefined || code === null) return true;
return code === 0 || code === "0";
}
return true;
}

View File

@@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@IsEnum(ProviderMethod)
provider!: ProviderMethod;
@ApiPropertyOptional({ enum: ["web", "mobile"] })
@ApiPropertyOptional({
enum: ["web", "mobile", "inapp"],
description:
"Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " +
"which cannot follow redirect/HPP flows and gets a bridge payload instead.",
})
@IsOptional()
@IsIn(["web", "mobile"])
@IsIn(["web", "mobile", "inapp"])
platform?: PaymentPlatform;
@ApiPropertyOptional({

View File

@@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider {
return this.config.get<string>("dmoney.returnUrl") ?? "";
}
private get timeoutExpress(): string {
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
return this.config.get<string>("dmoney.timeoutExpress") ?? "5m";
}
private get language(): string {
return this.config.get<string>("dmoney.language") ?? "en";

View File

@@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import {
ClientAction,
PaymentPlatform,
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
@@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider {
requestBody.biz_content.timeout_express,
);
const platform = input.platform ?? "web";
const clientAction =
platform === "mobile"
? {
type: "LAUNCH_APP" as const,
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) };
const clientAction = this.buildClientAction(platform, prepayId, response);
return {
providerOrderId: prepayId,
@@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider {
input: ProviderInitiationInput,
): CreateOrderRequest {
const totalAmount = String(input.amountMinor);
// In-app pays inside the SuperApp overlay and never navigates, so there is no browser
// to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it
// absent rather than undefined: a signed-but-unsent field is what produced the earlier
// "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined).
const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl;
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
@@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider {
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}),
...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}),
},
};
const sign = signRequestObject(
@@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider {
return { ...req, sign, sign_type: "SHA256WithRSA" };
}
/**
* Telebirr exposes the same pre-order three ways; only the launch payload differs.
*
* - `mobile` — native app hands off to the wallet app with a receiveCode.
* - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The
* H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`,
* which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The
* signed rawRequest goes to the host JS bridge instead — no navigation.
* - `web` — ordinary browser; redirect to the H5 checkout page.
*/
private buildClientAction(
platform: PaymentPlatform,
prepayId: string,
response: CreateOrderResponse,
): ClientAction {
switch (platform) {
case "mobile":
return {
type: "LAUNCH_APP",
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
};
case "inapp":
return {
type: "INVOKE_BRIDGE",
bridge: "TELEBIRR",
rawRequest: this.buildInAppRawRequest(prepayId),
};
default:
return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) };
}
}
/**
* Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`).
*
* Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that
* order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge
* takes the bare query string rather than a URL.
*
* `sign_type` sits in the map purely so it lands in the output in the right position;
* `buildCanonicalString` excludes it (as does telebirr's own reference implementation),
* so the signature covers the same five fields as the web checkout URL.
*
* Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads
* are consumed by different validators, and the web flow is live.
*/
private buildInAppRawRequest(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
sign_type: "SHA256WithRSA",
};
const sign = signRequestObject(map, this.privateKey);
const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`);
return [...fields, `sign=${sign}`].join("&");
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,

View File

@@ -32,7 +32,8 @@ export enum ProviderMethod {
CBE_BILL = "CBE_BILL",
}
export type PaymentPlatform = "web" | "mobile";
export type PaymentPlatform = "web" | "mobile" | "inapp";
export type ClientAction =
| { type: "REDIRECT"; url: string }
@@ -42,6 +43,16 @@ export type ClientAction =
receiveCode?: string;
shortCode: string;
}
| {
type: "INVOKE_BRIDGE";
/** Which SuperApp host bridge the payload targets. */
bridge: "TELEBIRR";
/**
* Signed query string handed verbatim to the host bridge (`js_fun_start_pay`).
* NOT a URL — it has no scheme or host and must never be navigated to.
*/
rawRequest: string;
}
| {
type: "COLLECT_OTP";
providerOrderId: string;