mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({
|
||||
config={config}
|
||||
layout="compact"
|
||||
readOnly={readOnly}
|
||||
onEdit={onEdit ?? (() => { })}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete ?? (() => { })}
|
||||
onViewChain={onViewChain}
|
||||
onSubmitRate={onSubmitRate}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal file
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal file
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
106
apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts
Normal file
106
apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user