mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
|
|||||||
SEED_FREIGHT_STAFF=true
|
SEED_FREIGHT_STAFF=true
|
||||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
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 (used by @tria-plc/iamapi-common for file storage)
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
@@ -166,19 +172,29 @@ EIMS_SELLER_LOCALITY=
|
|||||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
# 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
|
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||||
# locally, naming the missing variables, until these are set.
|
# 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_TAX_RATE_PERCENT=0
|
||||||
EIMS_EXCISE_TAX_VALUE=0
|
EIMS_EXCISE_TAX_VALUE=0
|
||||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||||
# Document classification and payment presentation.
|
# Document classification and payment presentation.
|
||||||
EIMS_TRANSACTION_TYPE=B2B
|
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_MODE=CASH
|
||||||
EIMS_PAYMENT_TERM=IMMIDIATE
|
EIMS_PAYMENT_TERM=IMMIDIATE
|
||||||
EIMS_UNIT_DEFAULT=PCS
|
EIMS_UNIT_DEFAULT=PCS
|
||||||
# MoR numeric country code for the buyer; our companies store the country name.
|
# MoR numeric country code for the buyer; our companies store the country name.
|
||||||
EIMS_BUYER_COUNTRY_CODE=
|
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_CASHIER_NAME=
|
||||||
EIMS_SALESPERSON_NAME=
|
EIMS_SALESPERSON_NAME=
|
||||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export interface EimsInvoiceConfig {
|
|||||||
paymentTerm: string;
|
paymentTerm: string;
|
||||||
unitDefault: string;
|
unitDefault: string;
|
||||||
buyerCountryCode: string | null;
|
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;
|
cashierName: string | null;
|
||||||
salesPersonName: string | null;
|
salesPersonName: string | null;
|
||||||
}
|
}
|
||||||
@@ -103,6 +111,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n
|
|||||||
return value;
|
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. */
|
/** 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 => {
|
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||||
if (raw === undefined || raw === "") return null;
|
if (raw === undefined || raw === "") return null;
|
||||||
@@ -168,6 +186,8 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
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,
|
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
dynamicTemplate = dynamicSource
|
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
|
* ("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.
|
* 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> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
ALTER TABLE freight.invoices
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { PaginatedResponse } from '@edr/types';
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
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 {
|
export class ListUsersService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User) private readonly users: Repository<User>,
|
@InjectRepository(User) private readonly users: Repository<User>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
||||||
@@ -40,6 +42,29 @@ export class ListUsersService {
|
|||||||
])
|
])
|
||||||
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
.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) {
|
if (query.userType) {
|
||||||
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
transactionWithholdValue: 0,
|
transactionWithholdValue: 0,
|
||||||
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
|
buyerWeredaCodes: {},
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ describe("toEimsInvoice", () => {
|
|||||||
ExciseTaxValue: 0,
|
ExciseTaxValue: 0,
|
||||||
TotalLineAmount: 11500,
|
TotalLineAmount: 11500,
|
||||||
Unit: "PCS",
|
Unit: "PCS",
|
||||||
NatureOfSupplies: "Service",
|
NatureOfSupplies: "service",
|
||||||
HarmonizationCode: null,
|
HarmonizationCode: null,
|
||||||
});
|
});
|
||||||
expect(doc.ItemList[1]).toMatchObject({
|
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", () => {
|
describe("formatEimsDate", () => {
|
||||||
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
|
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");
|
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;
|
relatedDocument?: string | null;
|
||||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||||
buyerCountryCode?: string | null;
|
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;
|
buyerIdType?: string | null;
|
||||||
buyerIdNumber?: string | null;
|
buyerIdNumber?: string | null;
|
||||||
buyerCity?: string | null;
|
buyerCity?: string | null;
|
||||||
@@ -220,6 +236,22 @@ export interface EimsMapperContext {
|
|||||||
formatDate?: (issuedAt: Date) => string;
|
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 num = (v: number | string): number => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(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
|
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
||||||
* exchange rate.
|
* 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(
|
export function toEimsInvoice(
|
||||||
invoice: EimsMapperInvoice,
|
invoice: EimsMapperInvoice,
|
||||||
seller: EimsSellerDetails,
|
seller: EimsSellerDetails,
|
||||||
@@ -266,6 +325,14 @@ export function toEimsInvoice(
|
|||||||
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
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 ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||||
const lineNumber = index + 1;
|
const lineNumber = index + 1;
|
||||||
const tax = context.taxForLine(line, lineNumber);
|
const tax = context.taxForLine(line, lineNumber);
|
||||||
@@ -285,7 +352,7 @@ export function toEimsInvoice(
|
|||||||
Discount: 0,
|
Discount: 0,
|
||||||
ExciseTaxValue,
|
ExciseTaxValue,
|
||||||
HarmonizationCode: null,
|
HarmonizationCode: null,
|
||||||
NatureOfSupplies: context.natureOfSupplies,
|
NatureOfSupplies: natureOfSupplies,
|
||||||
ItemCode: line.chargeType,
|
ItemCode: line.chargeType,
|
||||||
ProductDescription: line.description?.trim() || line.chargeType,
|
ProductDescription: line.description?.trim() || line.chargeType,
|
||||||
PreTaxValue,
|
PreTaxValue,
|
||||||
@@ -329,12 +396,24 @@ export function toEimsInvoice(
|
|||||||
Tin: company.tin,
|
Tin: company.tin,
|
||||||
LegalName: company.name,
|
LegalName: company.name,
|
||||||
Phone: company.phone ?? null,
|
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,
|
Country: context.buyerCountryCode ?? null,
|
||||||
Zone: company.zone ?? null,
|
Zone: company.zone ?? null,
|
||||||
Kebele: company.kebele ?? null,
|
Kebele: company.kebele ?? null,
|
||||||
VatNumber: company.vatNumber ?? null,
|
VatNumber: company.vatNumber ?? null,
|
||||||
Wereda: company.woreda ?? null,
|
Wereda: resolveLocationCode(
|
||||||
|
"Wereda",
|
||||||
|
company.woreda,
|
||||||
|
context.buyerWeredaCodes,
|
||||||
|
"EIMS_BUYER_WEREDA_CODES",
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
DocumentDetails: {
|
DocumentDetails: {
|
||||||
DocumentNumber: context.documentNumber,
|
DocumentNumber: context.documentNumber,
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||||
eimsIrn?: string | null;
|
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. */
|
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
||||||
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
||||||
eimsInvoiceCounter?: number | null;
|
eimsInvoiceCounter?: number | null;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|||||||
import { ContractTemplatesService } from "./contract-templates.service";
|
import { ContractTemplatesService } from "./contract-templates.service";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticlesDto,
|
ReplaceArticlesDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
UpdateContractTemplateDto,
|
UpdateContractTemplateDto,
|
||||||
} from "./dto/contract-template.dto";
|
} 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")
|
@ApiTags("contract-templates")
|
||||||
@Controller("contract-templates")
|
@Controller("contract-templates")
|
||||||
export class ContractTemplatesController {
|
export class ContractTemplatesController {
|
||||||
constructor(private readonly service: ContractTemplatesService) {}
|
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()
|
@Get()
|
||||||
@BookingStaff([
|
@BookingStaff(TEMPLATE_READ)
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
|
||||||
])
|
|
||||||
@ApiOperation({ summary: "List the six contract document templates" })
|
|
||||||
list() {
|
list() {
|
||||||
return this.service.list();
|
return this.service.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":code")
|
@Post()
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.create,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||||
FREIGHT_PERMS.admin,
|
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" })
|
@ApiOperation({ summary: "Get one contract template by code" })
|
||||||
getByCode(@Param("code") code: string) {
|
getByCode(@Param("code") code: string) {
|
||||||
return this.service.getByCode(code);
|
return this.service.getByCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":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)" })
|
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
||||||
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
||||||
return this.service.update(code, dto);
|
return this.service.update(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/preview")
|
@Delete(":code")
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.delete,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
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({
|
@ApiOperation({
|
||||||
summary: "Render an HTML preview of the template against mock contract data",
|
summary: "Render an HTML preview of the template against mock contract data",
|
||||||
})
|
})
|
||||||
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
|
|||||||
/* ------------------------- article routes ------------------------- */
|
/* ------------------------- article routes ------------------------- */
|
||||||
|
|
||||||
@Put(":code/articles")
|
@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)" })
|
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
||||||
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
||||||
return this.service.replaceArticles(code, dto.articles);
|
return this.service.replaceArticles(code, dto.articles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/articles")
|
@Post(":code/articles")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Add an article to the template" })
|
@ApiOperation({ summary: "Add an article to the template" })
|
||||||
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
||||||
return this.service.addArticle(code, dto);
|
return this.service.addArticle(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":code/articles/:articleId")
|
@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" })
|
@ApiOperation({ summary: "Update an article's title or body" })
|
||||||
updateArticle(
|
updateArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":code/articles/:articleId")
|
@Delete(":code/articles/:articleId")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Remove an article from the template" })
|
@ApiOperation({ summary: "Remove an article from the template" })
|
||||||
removeArticle(
|
removeArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import {
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
ContractTemplate,
|
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||||
ContractTemplateCode,
|
|
||||||
} from "./entities/contract-template.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||||
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
|
findByCode(code: string): Promise<ContractTemplate | null> {
|
||||||
return this.repository.findOne({ where: { code } });
|
return this.repository.findOne({ where: { code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
override findAll(): Promise<ContractTemplate[]> {
|
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> {
|
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 { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||||
@@ -11,6 +16,7 @@ import {
|
|||||||
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticleDto,
|
ReplaceArticleDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
|
|||||||
async list(): Promise<ContractTemplate[]> {
|
async list(): Promise<ContractTemplate[]> {
|
||||||
const templates = await this.repository.findAll();
|
const templates = await this.repository.findAll();
|
||||||
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
||||||
return templates.sort(
|
// Seeded container templates first in canonical order, then staff-created
|
||||||
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
|
// 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> {
|
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) {
|
if (!template) {
|
||||||
throw new NotFoundException(`Contract template ${code} not found`);
|
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
|
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||||
* direction/freight/customs triple; null when missing or deactivated (the
|
* The cargo type must have hasContractTemplate enabled and the combination
|
||||||
* renderer then falls back to the built-in generic layout).
|
* 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(
|
async findActiveForContract(
|
||||||
tradeDirection?: string | null,
|
tradeDirection?: string | null,
|
||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
|
cargoTypeId?: string | null,
|
||||||
): Promise<ContractTemplate | 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(
|
const code = contractTemplateCodeFor(
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
|
|||||||
: this.sorted(template.articles),
|
: this.sorted(template.articles),
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = this.buildMockView(template.code, dynamicTemplate);
|
const view = this.buildMockView(template, dynamicTemplate);
|
||||||
return { html: this.renderer.render(view) };
|
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(
|
private buildMockView(
|
||||||
code: ContractTemplateCode,
|
template: ContractTemplate,
|
||||||
dynamicTemplate: ContractDynamicTemplateView,
|
dynamicTemplate: ContractDynamicTemplateView,
|
||||||
): ContractViewModel {
|
): ContractViewModel {
|
||||||
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
|
const code = template.code;
|
||||||
const isBulk = code.endsWith("_BULK");
|
const previewKey = this.previewKeyFor(template);
|
||||||
|
const meta = getTemplateMeta(previewKey);
|
||||||
|
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Representative rate schedule so the admin preview shows the live-rate
|
// Representative rate schedule so the admin preview shows the live-rate
|
||||||
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
|
|||||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||||
reference: "EDR/CT/2026/0042",
|
reference: "EDR/CT/2026/0042",
|
||||||
status: "CONTRACT_READY",
|
status: "CONTRACT_READY",
|
||||||
templateKey: PREVIEW_TEMPLATE_KEYS[code],
|
templateKey: previewKey,
|
||||||
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
||||||
contractDate: now.toLocaleDateString("en-GB", {
|
contractDate: now.toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Static, representative rate schedule for the admin preview only. */
|
/** 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")
|
const dir = code.startsWith("IMPORT")
|
||||||
? "import"
|
? "import"
|
||||||
: code.startsWith("EXPORT")
|
: 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[] {
|
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
||||||
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,41 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} 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 {
|
export class UpdateContractTemplateDto {
|
||||||
@ApiPropertyOptional({ description: "Display name of the template" })
|
@ApiPropertyOptional({ description: "Display name of the template" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
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
|
* The five seeded container templates (import/export split by customs
|
||||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
* template). These are system rows: always present, never deletable.
|
||||||
* no customs leg at all (× freight type = 2).
|
*
|
||||||
|
* 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
|
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||||
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "contract_templates" })
|
@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 {
|
export class ContractTemplate extends BaseEntity {
|
||||||
@Column({ name: "code", type: "varchar", length: 40, unique: true })
|
@Column({ name: "code", type: "varchar", length: 80 })
|
||||||
code!: ContractTemplateCode;
|
code!: string;
|
||||||
|
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "is_active", type: "boolean", default: true })
|
@Column({ name: "is_active", type: "boolean", default: true })
|
||||||
isActive!: boolean;
|
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.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
|||||||
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
|
`(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 {
|
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
||||||
@@ -112,6 +143,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
exchangeRate: input.exchangeRate ?? null,
|
exchangeRate: input.exchangeRate ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config";
|
|||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
@@ -91,9 +92,11 @@ class FakeDb {
|
|||||||
id: "state-1",
|
id: "state-1",
|
||||||
systemNumber: SYSTEM_NUMBER,
|
systemNumber: SYSTEM_NUMBER,
|
||||||
nextInvoiceCounter: 7,
|
nextInvoiceCounter: 7,
|
||||||
|
nextDocumentNumber: 5,
|
||||||
previousIrn: null,
|
previousIrn: null,
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
inFlightCounter: null,
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
...state,
|
...state,
|
||||||
} as EimsSystemState;
|
} as EimsSystemState;
|
||||||
@@ -130,7 +133,10 @@ class FakeDb {
|
|||||||
return {
|
return {
|
||||||
manager: this.manager,
|
manager: this.manager,
|
||||||
getRepository: this.manager.getRepository,
|
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>) => {
|
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||||
this.onTransaction?.();
|
this.onTransaction?.();
|
||||||
return body(this.manager);
|
return body(this.manager);
|
||||||
@@ -147,17 +153,26 @@ const build = (
|
|||||||
postSigned: jest.Mock,
|
postSigned: jest.Mock,
|
||||||
cfg: EimsConfig = config(),
|
cfg: EimsConfig = config(),
|
||||||
postBearer: jest.Mock = jest.fn(),
|
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(
|
new EimsInvoiceRegistrationService(
|
||||||
db.asDataSource(),
|
db.asDataSource(),
|
||||||
{ get: () => cfg } as unknown as ConfigService,
|
{ get: () => cfg } as unknown as ConfigService,
|
||||||
{ postSigned, postBearer } as unknown as EimsClientService,
|
{ 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
|
* `/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;
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
||||||
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
|
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);
|
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
previousIrn: 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);
|
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 db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
const postSigned = jest
|
const postSigned = jest
|
||||||
.fn()
|
.fn()
|
||||||
@@ -404,8 +421,72 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
);
|
);
|
||||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||||
|
|
||||||
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
|
// The two numbers move differently, because MoR constrains them differently: the counter must
|
||||||
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
|
// 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", () => {
|
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||||
const blocked = () =>
|
const blocked = () =>
|
||||||
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
|
new FakeDb(
|
||||||
|
[
|
||||||
|
invoiceRow({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
|
eimsInvoiceCounter: 7,
|
||||||
|
eimsDocumentNumber: DOCUMENT_NUMBER,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
inFlightInvoiceId: INVOICE_ID,
|
inFlightInvoiceId: INVOICE_ID,
|
||||||
inFlightCounter: 7,
|
inFlightCounter: 7,
|
||||||
nextInvoiceCounter: 8,
|
nextInvoiceCounter: 8,
|
||||||
@@ -498,13 +587,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
|||||||
const db = blocked();
|
const db = blocked();
|
||||||
const postBearer = jest.fn().mockResolvedValue(
|
const postBearer = jest.fn().mockResolvedValue(
|
||||||
verifyResponse({
|
verifyResponse({
|
||||||
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
|
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
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({
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
@@ -547,7 +636,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
|||||||
|
|
||||||
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
||||||
const db = blocked();
|
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());
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
EimsMapperLine,
|
EimsMapperLine,
|
||||||
toEimsInvoice,
|
toEimsInvoice,
|
||||||
} from "../billing/eims-invoice.mapper";
|
} 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 { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
@@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
|
|||||||
interface Reservation {
|
interface Reservation {
|
||||||
stateId: string;
|
stateId: string;
|
||||||
invoiceCounter: number;
|
invoiceCounter: number;
|
||||||
|
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
|
||||||
|
documentNumber: string;
|
||||||
previousIrn: string;
|
previousIrn: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly client: EimsClientService,
|
private readonly client: EimsClientService,
|
||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
invoice,
|
invoice,
|
||||||
buildEimsSeller(cfg),
|
buildEimsSeller(cfg),
|
||||||
buildEimsContext(cfg, {
|
buildEimsContext(cfg, {
|
||||||
// Our own invoice number is the document number; EIMS only requires it to be unique.
|
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||||
documentNumber: invoice.invoiceNumber,
|
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||||
|
documentNumber: reservation.documentNumber,
|
||||||
invoiceCounter: reservation.invoiceCounter,
|
invoiceCounter: reservation.invoiceCounter,
|
||||||
previousIrn: reservation.previousIrn,
|
previousIrn: reservation.previousIrn,
|
||||||
session,
|
session,
|
||||||
@@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
|
* 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.
|
* IRN is the one it holds, and that it belongs to this invoice.
|
||||||
*
|
*
|
||||||
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
|
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
|
||||||
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
|
* 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
|
* 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
|
* 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
|
// Outside the transaction: no lock is held across the wire, and a refused verification must
|
||||||
// leave the block exactly as it was.
|
// leave the block exactly as it was.
|
||||||
if (irn) {
|
if (irn) {
|
||||||
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
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.
|
// 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 } : {}),
|
...(irn ? { previousIrn: irn } : {}),
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
inFlightCounter: null,
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService {
|
|||||||
if (invoice.eimsIrn) return null;
|
if (invoice.eimsIrn) return null;
|
||||||
|
|
||||||
const invoiceCounter = Number(state.nextInvoiceCounter);
|
const invoiceCounter = Number(state.nextInvoiceCounter);
|
||||||
|
const documentNumber = String(Number(state.nextDocumentNumber));
|
||||||
const previousIrn = state.previousIrn ?? "";
|
const previousIrn = state.previousIrn ?? "";
|
||||||
|
|
||||||
// Counter consumed here, not on success: once an attempt begins it can never be reused,
|
// 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.
|
// whatever happens next. A gap is harmless at MoR; a collision is not.
|
||||||
await manager.update(EimsSystemState, state.id, {
|
await manager.update(EimsSystemState, state.id, {
|
||||||
nextInvoiceCounter: invoiceCounter + 1,
|
nextInvoiceCounter: invoiceCounter + 1,
|
||||||
|
nextDocumentNumber: Number(documentNumber) + 1,
|
||||||
inFlightInvoiceId: invoiceId,
|
inFlightInvoiceId: invoiceId,
|
||||||
inFlightCounter: invoiceCounter,
|
inFlightCounter: invoiceCounter,
|
||||||
|
inFlightDocumentNumber: Number(documentNumber),
|
||||||
});
|
});
|
||||||
await manager.update(Invoice, invoiceId, {
|
await manager.update(Invoice, invoiceId, {
|
||||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||||
eimsInvoiceCounter: invoiceCounter,
|
eimsInvoiceCounter: invoiceCounter,
|
||||||
|
eimsDocumentNumber: documentNumber,
|
||||||
eimsSubmittedAt: new Date(),
|
eimsSubmittedAt: new Date(),
|
||||||
eimsLastError: null,
|
eimsLastError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { stateId: state.id, invoiceCounter, previousIrn };
|
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,15 +386,26 @@ export class EimsInvoiceRegistrationService {
|
|||||||
previousIrn: irn,
|
previousIrn: irn,
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
inFlightCounter: null,
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
|
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
||||||
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
|
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||||
* The counter stays consumed either way.
|
* 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(
|
private async settleFailure(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
@@ -386,7 +433,15 @@ export class EimsInvoiceRegistrationService {
|
|||||||
EimsSystemState,
|
EimsSystemState,
|
||||||
reservation.stateId,
|
reservation.stateId,
|
||||||
deterministic
|
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:
|
blockedReason:
|
||||||
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
|
`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}`);
|
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 ────────────────────────────────────────────────────────────────────────────────
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -488,6 +578,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||||
eimsIrn: invoice.eimsIrn ?? null,
|
eimsIrn: invoice.eimsIrn ?? null,
|
||||||
|
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
|
|||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
eimsStatus: EimsInvoiceStatus;
|
eimsStatus: EimsInvoiceStatus;
|
||||||
eimsIrn: string | null;
|
eimsIrn: string | null;
|
||||||
|
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
|
||||||
|
eimsDocumentNumber: string | null;
|
||||||
eimsInvoiceCounter: number | null;
|
eimsInvoiceCounter: number | null;
|
||||||
eimsSubmittedAt: Date | null;
|
eimsSubmittedAt: Date | null;
|
||||||
eimsAckDate: string | null;
|
eimsAckDate: string | null;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
paymentTerm: "IMMIDIATE",
|
paymentTerm: "IMMIDIATE",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
buyerCountryCode: null,
|
buyerCountryCode: null,
|
||||||
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
cashierName: null,
|
cashierName: null,
|
||||||
salesPersonName: null,
|
salesPersonName: null,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
@@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||||
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
|
|||||||
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
|
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
|
||||||
nextInvoiceCounter!: number;
|
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. */
|
/** IRN of the last successful registration; null until the first one succeeds. */
|
||||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
||||||
previousIrn?: string | null;
|
previousIrn?: string | null;
|
||||||
|
|||||||
@@ -60,14 +60,38 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
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({
|
@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)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
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)" })
|
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||||
appId?: string;
|
appId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasLashing?: boolean;
|
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 })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||||
hasLashing!: boolean;
|
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 })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,35 @@ export class CargoTypesService {
|
|||||||
return map;
|
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. */
|
/** Create a new cargo type. */
|
||||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||||
const code = generateCode(dto.cargoTypeName);
|
const code = generateCode(dto.cargoTypeName);
|
||||||
@@ -144,6 +173,9 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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', {
|
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
||||||
explicitOrder: dto.displayOrder,
|
explicitOrder: dto.displayOrder,
|
||||||
@@ -160,6 +192,7 @@ export class CargoTypesService {
|
|||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
parentGroupId: dto.parentGroupId ?? null,
|
parentGroupId: dto.parentGroupId ?? null,
|
||||||
|
hasContractTemplate: dto.hasContractTemplate ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
@@ -183,6 +216,18 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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 {
|
const {
|
||||||
wagonTypeIds,
|
wagonTypeIds,
|
||||||
itemsPerWagonMap,
|
itemsPerWagonMap,
|
||||||
|
|||||||
@@ -1273,6 +1273,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:settings:contract_templates:manage",
|
"edr_freight_app:settings:contract_templates:manage",
|
||||||
"Edit contract templates & articles",
|
"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,
|
// N. Previously-ungated staff surfaces (support inbox, procurement, compliance,
|
||||||
@@ -1816,6 +1838,10 @@ export const FREIGHT_PERMS = {
|
|||||||
contractTemplates: {
|
contractTemplates: {
|
||||||
view: "edr_freight_app:settings:contract_templates:view",
|
view: "edr_freight_app:settings:contract_templates:view",
|
||||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
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: {
|
audit: {
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
|
|||||||
# observability stays off (the app works either way). Self-hosted instance.
|
# observability stays off (the app works either way). Self-hosted instance.
|
||||||
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
VITE_POSTHOG_HOST=https://posthog.example.com
|
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
|
<Route
|
||||||
path="contract-templates"
|
path="contract-templates"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractTemplatesPage />
|
<ContractTemplatesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -843,7 +848,12 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contract-templates/:code"
|
path="contract-templates/:code"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractTemplateEditorPage />
|
<ContractTemplateEditorPage />
|
||||||
</RequirePermission>
|
</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",
|
label: "Contract templates",
|
||||||
href: "/dashboard/contract-templates",
|
href: "/dashboard/contract-templates",
|
||||||
icon: <ScrollText />,
|
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",
|
label: "Audit logs",
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({
|
|||||||
config={config}
|
config={config}
|
||||||
layout="compact"
|
layout="compact"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onEdit={onEdit ?? (() => { })}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete ?? (() => { })}
|
onDelete={onDelete ?? (() => { })}
|
||||||
onViewChain={onViewChain}
|
onViewChain={onViewChain}
|
||||||
onSubmitRate={onSubmitRate}
|
onSubmitRate={onSubmitRate}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { RuleEngineRecord } from "@/types/rule-engine";
|
|||||||
export interface RuleEngineRecordActionsProps {
|
export interface RuleEngineRecordActionsProps {
|
||||||
record: RuleEngineRecord;
|
record: RuleEngineRecord;
|
||||||
config: RuleEngineResourceConfig;
|
config: RuleEngineResourceConfig;
|
||||||
onEdit: (record: RuleEngineRecord) => void;
|
onEdit?: (record: RuleEngineRecord) => void;
|
||||||
onDelete: (record: RuleEngineRecord) => void;
|
onDelete: (record: RuleEngineRecord) => void;
|
||||||
onViewChain?: () => void;
|
onViewChain?: () => void;
|
||||||
onSubmitRate?: (id: string) => void;
|
onSubmitRate?: (id: string) => void;
|
||||||
@@ -93,17 +93,19 @@ const RuleEngineRecordActions = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={actionGroupStyle}>
|
<div style={actionGroupStyle}>
|
||||||
<Button
|
{onEdit ? (
|
||||||
variant="subtle"
|
<Button
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="compact-sm"
|
color="gray"
|
||||||
radius="md"
|
size="compact-sm"
|
||||||
onClick={() => onEdit(record)}
|
radius="md"
|
||||||
leftSection={<Pencil size={14} />}
|
onClick={() => onEdit(record)}
|
||||||
styles={{ root: { fontWeight: 600 } }}
|
leftSection={<Pencil size={14} />}
|
||||||
>
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
Edit
|
>
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -151,21 +153,23 @@ const RuleEngineRecordActions = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={actionGroupStyle}>
|
<div style={actionGroupStyle}>
|
||||||
<Tooltip label="Edit">
|
{onEdit ? (
|
||||||
<ActionIcon
|
<Tooltip label="Edit">
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="md"
|
color="gray"
|
||||||
radius="md"
|
size="md"
|
||||||
onClick={() => onEdit(record)}
|
radius="md"
|
||||||
aria-label="Edit record"
|
onClick={() => onEdit(record)}
|
||||||
style={{
|
aria-label="Edit record"
|
||||||
background: "white",
|
style={{
|
||||||
}}
|
background: "white",
|
||||||
>
|
}}
|
||||||
<Pencil size={16} />
|
>
|
||||||
</ActionIcon>
|
<Pencil size={16} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<Tooltip label="Delete">
|
<Tooltip label="Delete">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const QUERY_KEYS = {
|
|||||||
list: (filter?: InvoiceListFilter) =>
|
list: (filter?: InvoiceListFilter) =>
|
||||||
["invoices", "list", filter ?? {}] as const,
|
["invoices", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||||
|
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ export const URL_CONSTANTS = {
|
|||||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
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: {
|
CUSTOMERS_API: {
|
||||||
BASE: "/api/customers",
|
BASE: "/api/customers",
|
||||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
|
|||||||
import {
|
import {
|
||||||
contractTemplatesService,
|
contractTemplatesService,
|
||||||
type ArticlePayload,
|
type ArticlePayload,
|
||||||
|
type CreateContractTemplatePayload,
|
||||||
type UpdateContractTemplatePayload,
|
type UpdateContractTemplatePayload,
|
||||||
} from "@/services/contract-templates.service";
|
} 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) {
|
export function useUpdateContractTemplate(code: string) {
|
||||||
return useTemplateMutation(
|
return useTemplateMutation(
|
||||||
(payload: UpdateContractTemplatePayload) =>
|
(payload: UpdateContractTemplatePayload) =>
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ export const FREIGHT_PERMS = {
|
|||||||
invoices: {
|
invoices: {
|
||||||
view: "edr_freight_app:invoices:view",
|
view: "edr_freight_app:invoices:view",
|
||||||
export: "edr_freight_app:invoices:export",
|
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: {
|
firstMile: {
|
||||||
view: "edr_freight_app:first_mile:view",
|
view: "edr_freight_app:first_mile:view",
|
||||||
@@ -317,6 +321,10 @@ export const FREIGHT_PERMS = {
|
|||||||
contractTemplates: {
|
contractTemplates: {
|
||||||
view: "edr_freight_app:settings:contract_templates:view",
|
view: "edr_freight_app:settings:contract_templates:view",
|
||||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
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: {
|
audit: {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -19,11 +23,21 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
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 type { ContractTemplate } from "@/services/contract-templates.service";
|
||||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||||
|
|
||||||
@@ -39,22 +53,18 @@ const DIRECTION_DOT: Record<string, string> = {
|
|||||||
INTERCITY: "var(--mantine-color-orange-5)",
|
INTERCITY: "var(--mantine-color-orange-5)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function templateDirection(code: ContractTemplate["code"]): string {
|
function isBulk(template: ContractTemplate): boolean {
|
||||||
return code.split("_")[0];
|
return Boolean(template.cargoTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
|
||||||
// the second one — never the suffix.
|
// domestic and crosses no border, so it has no customs variant at all — hence
|
||||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
// null rather than false, which would wrongly read as a deliberate "client
|
||||||
return code.split("_")[1] === "BULK";
|
// clears its own customs" choice.
|
||||||
}
|
function customsVariant(template: ContractTemplate): boolean | null {
|
||||||
|
if (isBulk(template)) return template.withCustoms ?? null;
|
||||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
if (template.code.endsWith("_NO_CUSTOMS")) return false;
|
||||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
if (template.code.endsWith("_CUSTOMS")) return true;
|
||||||
// "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;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +76,28 @@ function formatUpdated(value: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CargoTypeOption {
|
||||||
|
id: string;
|
||||||
|
cargoTypeName?: string;
|
||||||
|
hasContractTemplate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplatesPage() {
|
export default function ContractTemplatesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: templates, isLoading } = useContractTemplates();
|
const { data: templates, isLoading } = useContractTemplates();
|
||||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
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);
|
const previewTemplate = templates?.find((t) => t.code === previewCode);
|
||||||
|
|
||||||
@@ -77,20 +105,34 @@ export default function ContractTemplatesPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contract templates"
|
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">
|
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||||
: (templates ?? []).map((template) => (
|
: (templates ?? []).map((template) => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={template.code}
|
key={template.code}
|
||||||
template={template}
|
template={template}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
onPreview={() => setPreviewCode(template.code)}
|
onPreview={() => setPreviewCode(template.code)}
|
||||||
onEdit={() =>
|
onEdit={() =>
|
||||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||||
}
|
}
|
||||||
|
onDelete={() => setDeleteTarget(template)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
|
|||||||
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
||||||
onClose={() => setPreviewCode(null)}
|
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>
|
</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({
|
function TemplateCard({
|
||||||
template,
|
template,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
template: ContractTemplate;
|
template: ContractTemplate;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const direction = templateDirection(template.code);
|
const bulk = isBulk(template);
|
||||||
const bulk = isBulk(template.code);
|
const direction = template.code.split("_")[0];
|
||||||
const customs = customsVariant(template.code);
|
const customs = customsVariant(template);
|
||||||
|
const kicker = bulk
|
||||||
|
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
|
||||||
|
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -142,12 +336,13 @@ function TemplateCard({
|
|||||||
style={{
|
style={{
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
flexShrink: 0,
|
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">
|
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||||
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
{kicker}
|
||||||
{bulk ? "Bulk" : "Container"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -166,6 +361,18 @@ function TemplateCard({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</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 && (
|
{!template.isActive && (
|
||||||
<Tooltip label="Not used for new contracts" withArrow>
|
<Tooltip label="Not used for new contracts" withArrow>
|
||||||
<Badge size="sm" variant="light" color="red">
|
<Badge size="sm" variant="light" color="red">
|
||||||
@@ -221,16 +428,34 @@ function TemplateCard({
|
|||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="light"
|
{canDelete && !template.isSystem && (
|
||||||
color="edr-green"
|
<Tooltip label="Delete template" withArrow>
|
||||||
size="compact-sm"
|
<Button
|
||||||
radius="md"
|
variant="subtle"
|
||||||
leftSection={<Pencil size={14} />}
|
color="red"
|
||||||
onClick={onEdit}
|
size="compact-sm"
|
||||||
>
|
radius="md"
|
||||||
Edit articles
|
px={8}
|
||||||
</Button>
|
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>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.serv
|
|||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
// Same default key + env override the portal's LocationPicker uses.
|
// 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 =
|
const GOOGLE_MAPS_API_KEY =
|
||||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { ArrowLeft, Download } from "lucide-react";
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -165,6 +166,8 @@ export default function InvoiceDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<EimsFilingCard invoiceId={invoice.id} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
||||||
hasLashing?: boolean;
|
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. */
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
unitOfMeasure?: string | null;
|
unitOfMeasure?: string | null;
|
||||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
/** 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
|
// When on, every booking of this cargo type is charged the flat LASHING
|
||||||
// surcharge (a rate with trigger = Lashing).
|
// surcharge (a rate with trigger = Lashing).
|
||||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
{ 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" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -576,6 +582,13 @@ function CargoRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{node.unitOfMeasure ? (
|
||||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||||
|
|||||||
@@ -543,10 +543,14 @@ const RuleEngineResourcePage = () => {
|
|||||||
config={config}
|
config={config}
|
||||||
layout="row"
|
layout="row"
|
||||||
readOnly={!canUpdateControls}
|
readOnly={!canUpdateControls}
|
||||||
onEdit={(record) => {
|
onEdit={
|
||||||
setEditing(record);
|
config.slug === "container-types"
|
||||||
setFormOpen(true);
|
? undefined
|
||||||
}}
|
: (record) => {
|
||||||
|
setEditing(record);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules"
|
config.slug === "approval-rules"
|
||||||
@@ -881,7 +885,9 @@ const RuleEngineResourcePage = () => {
|
|||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onPaginationChange={setPagination}
|
onPaginationChange={setPagination}
|
||||||
readOnly={!canUpdate && !canDelete}
|
readOnly={!canUpdate && !canDelete}
|
||||||
onEdit={canUpdate ? openEdit : undefined}
|
onEdit={
|
||||||
|
canUpdate && config.slug !== "container-types" ? openEdit : undefined
|
||||||
|
}
|
||||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules"
|
config.slug === "approval-rules"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
History,
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
Send,
|
||||||
@@ -311,19 +310,6 @@ export default function WagonTransfersPage() {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{canRequest ? (
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Plus size={15} />}
|
|
||||||
onClick={() => {
|
|
||||||
setCarryOver(null);
|
|
||||||
setFormOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New request
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ import {
|
|||||||
import { containerTypesService } from "./container-types.service";
|
import { containerTypesService } from "./container-types.service";
|
||||||
import { containerService, type Container } from "./containerService";
|
import { containerService, type Container } from "./containerService";
|
||||||
import { customersService } from "./customers.service";
|
import { customersService } from "./customers.service";
|
||||||
|
import { eimsService } from "./eims.service";
|
||||||
|
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||||
import { invoicesService } from "./invoices.service";
|
import { invoicesService } from "./invoices.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -2914,6 +2916,36 @@ export const api = {
|
|||||||
({ id }) => invoicesService.getById(id),
|
({ id }) => invoicesService.getById(id),
|
||||||
({ id }) => QUERY_KEYS.INVOICES.byId(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: {
|
overview: {
|
||||||
|
|||||||
@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
|
|||||||
|
|
||||||
export interface ContractTemplate {
|
export interface ContractTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
|
||||||
// border, and so has a single template.
|
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
|
||||||
code:
|
code: string;
|
||||||
| "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";
|
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
whereasClauses: string[];
|
whereasClauses: string[];
|
||||||
articles: ContractTemplateArticle[];
|
articles: ContractTemplateArticle[];
|
||||||
isActive: boolean;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateContractTemplatePayload {
|
||||||
|
cargoTypeId: string;
|
||||||
|
withCustoms: boolean;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateContractTemplatePayload {
|
export interface UpdateContractTemplatePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
|
|||||||
return data;
|
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> {
|
async getByCode(code: string): Promise<ContractTemplate> {
|
||||||
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
||||||
return data;
|
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
|
// 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
|
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
||||||
// override the default key without a code change.
|
// 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 =
|
const GOOGLE_MAPS_API_KEY =
|
||||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
|
|||||||
export const CUTOFF_MINUTES = 30;
|
export const CUTOFF_MINUTES = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
|
* 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.
|
||||||
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
|
|
||||||
* checkinMinutesBefore so that each route's own window is respected.
|
|
||||||
*/
|
*/
|
||||||
|
export const PAYMENT_SESSION_MINUTES = 6;
|
||||||
|
|
||||||
|
|
||||||
|
export const MIN_PAYMENT_WINDOW_MINUTES = 7;
|
||||||
|
|
||||||
|
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
|
||||||
|
|
||||||
|
|
||||||
export function computePaymentDeadline(
|
export function computePaymentDeadline(
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
departureAt: Date,
|
departureAt: Date,
|
||||||
@@ -27,3 +33,19 @@ export function computePaymentDeadline(
|
|||||||
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
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 {
|
class PaySupplementaryChargeDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
@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")
|
@ApiTags("Payment")
|
||||||
@@ -307,7 +307,14 @@ export class PaymentsController {
|
|||||||
})
|
})
|
||||||
@ApiQuery({ name: "bookingId", required: true })
|
@ApiQuery({ name: "bookingId", required: true })
|
||||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, 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")
|
@ApiProduces("text/html")
|
||||||
async checkout(
|
async checkout(
|
||||||
@Query("bookingId") bookingId: string,
|
@Query("bookingId") bookingId: string,
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum {
|
|||||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
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 {
|
export class InitiatePaymentDto {
|
||||||
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||||
@@ -45,12 +46,14 @@ export class InitiatePaymentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
paymentMethodId?: string;
|
paymentMethodId?: string;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ["web", "mobile"],
|
enum: ["web", "mobile", "inapp"],
|
||||||
default: "web",
|
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()
|
@IsOptional()
|
||||||
@IsIn(["web", "mobile"])
|
@IsIn(["web", "mobile", "inapp"])
|
||||||
platform?: PaymentPlatformDto;
|
platform?: PaymentPlatformDto;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
@@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto {
|
|||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({
|
@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)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
url?: string;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
@@ -134,6 +148,18 @@ export class ClientActionDto {
|
|||||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||||
})
|
})
|
||||||
shortCode?: string;
|
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)" })
|
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
|
||||||
providerOrderId?: string;
|
providerOrderId?: string;
|
||||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||||
@@ -154,6 +180,10 @@ export class InitiateResponseDto {
|
|||||||
@ApiPropertyOptional({ type: ClientActionDto })
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
clientAction?: ClientActionDto;
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@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 {
|
export class IntentStatusDto {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
MAX_PAYMENT_HOURS,
|
||||||
|
MIN_PAYMENT_WINDOW_MINUTES,
|
||||||
|
PAYMENT_SESSION_MINUTES,
|
||||||
|
} from "../../common/utils/payment-deadline.utils";
|
||||||
|
|
||||||
describe("PaymentsService", () => {
|
describe("PaymentsService", () => {
|
||||||
let service: PaymentsService;
|
let service: PaymentsService;
|
||||||
@@ -163,6 +168,69 @@ describe("PaymentsService", () => {
|
|||||||
).rejects.toThrow(BadRequestException);
|
).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 () => {
|
it("should initiate a provider payment through the payment microservice", async () => {
|
||||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPaymentClient.initiate.mockResolvedValue(
|
mockPaymentClient.initiate.mockResolvedValue(
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ import {
|
|||||||
MarkPaidResponseDto,
|
MarkPaidResponseDto,
|
||||||
BillQueryResponseDto,
|
BillQueryResponseDto,
|
||||||
} from "./internal-payments.dto";
|
} 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 {
|
import {
|
||||||
PaymentClientService,
|
PaymentClientService,
|
||||||
PaymentDiagnostic,
|
PaymentDiagnostic,
|
||||||
@@ -260,6 +266,27 @@ export class PaymentsService {
|
|||||||
return this.initiateWalletPayment(booking);
|
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
|
// 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.
|
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||||
@@ -317,9 +344,7 @@ export class PaymentsService {
|
|||||||
payerName =
|
payerName =
|
||||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||||
booking.seats[0]?.passengerName;
|
booking.seats[0]?.passengerName;
|
||||||
expiresAt = (
|
expiresAt = paymentDeadline?.toISOString();
|
||||||
await this.computeBookingPaymentDeadline(booking.id)
|
|
||||||
)?.toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
@@ -350,7 +375,11 @@ export class PaymentsService {
|
|||||||
where: { id: intent.id },
|
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") {
|
if (booking.status !== "PENDING_PAYMENT") {
|
||||||
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
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);
|
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: false, reason: "EXPIRED" };
|
||||||
}
|
}
|
||||||
return { ...base, stillPayable: true, reason: null };
|
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 { EmailClientService } from '../notifications/email-client.service';
|
||||||
import { PaymentClientService } from './payment-client.service';
|
import { PaymentClientService } from './payment-client.service';
|
||||||
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
||||||
|
import { PaymentPlatformDto } from './payments.dto';
|
||||||
|
|
||||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ export class SupplementaryChargesService {
|
|||||||
async pay(
|
async pay(
|
||||||
token: string,
|
token: string,
|
||||||
method: string,
|
method: string,
|
||||||
platform?: 'web' | 'mobile',
|
platform?: PaymentPlatformDto,
|
||||||
requestOrigin?: string | null,
|
requestOrigin?: string | null,
|
||||||
) {
|
) {
|
||||||
const charge = await this.getByToken(token); // validates status/expiry
|
const charge = await this.getByToken(token); // validates status/expiry
|
||||||
|
|||||||
@@ -30,13 +30,26 @@ export default function ReportsPage() {
|
|||||||
const getDateRange = () => {
|
const getDateRange = () => {
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setHours(23, 59, 59, 999);
|
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) {
|
switch (dateRange) {
|
||||||
case '7': start.setDate(end.getDate() - 7); break;
|
case '7': start.setDate(end.getDate() - 7); break;
|
||||||
case '30': start.setDate(end.getDate() - 30); break;
|
case '30': start.setDate(end.getDate() - 30); break;
|
||||||
case '90': start.setDate(end.getDate() - 90); break;
|
case '90': start.setDate(end.getDate() - 90); break;
|
||||||
default:
|
|
||||||
if (startDate && endDate) return { startDate, endDate };
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
startDate: start.toISOString().split('T')[0],
|
startDate: start.toISOString().split('T')[0],
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default function PassengersReportPage() {
|
|||||||
const [tab, setTab] = useState<Tab>("occupancy");
|
const [tab, setTab] = useState<Tab>("occupancy");
|
||||||
const [listSearch, setListSearch] = useState("");
|
const [listSearch, setListSearch] = useState("");
|
||||||
const [filterOrigin, setFilterOrigin] = useState("");
|
const [filterOrigin, setFilterOrigin] = useState("");
|
||||||
|
const [filterDestination, setFilterDestination] = useState("");
|
||||||
const [filterSeatClass, setFilterSeatClass] = useState("");
|
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||||
const [filterCoachNumber, setFilterCoachNumber] = useState("");
|
const [filterCoachNumber, setFilterCoachNumber] = useState("");
|
||||||
|
|
||||||
@@ -110,17 +111,23 @@ export default function PassengersReportPage() {
|
|||||||
const originOptions = [
|
const originOptions = [
|
||||||
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
||||||
].sort() as string[];
|
].sort() as string[];
|
||||||
|
const destinationOptions = [
|
||||||
|
...new Set(passengerList.map((p) => p.destination).filter(Boolean)),
|
||||||
|
].sort() as string[];
|
||||||
|
|
||||||
const filteredList = passengerList
|
const filteredList = passengerList
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
|
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
|
||||||
if (filterOrigin && p.origin !== filterOrigin) return false;
|
if (filterOrigin && p.origin !== filterOrigin) return false;
|
||||||
|
if (filterDestination && p.destination !== filterDestination) return false;
|
||||||
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
||||||
if (listSearch.trim()) {
|
if (listSearch.trim()) {
|
||||||
const q = listSearch.toLowerCase();
|
const q = listSearch.toLowerCase();
|
||||||
return (
|
return (
|
||||||
p.passengerName.toLowerCase().includes(q) ||
|
p.passengerName.toLowerCase().includes(q) ||
|
||||||
p.bookingRef.toLowerCase().includes(q) ||
|
p.bookingRef.toLowerCase().includes(q) ||
|
||||||
|
(p.origin ?? '').toLowerCase().includes(q) ||
|
||||||
|
(p.destination ?? '').toLowerCase().includes(q) ||
|
||||||
(p.idDocumentNumber ?? "").toLowerCase().includes(q) ||
|
(p.idDocumentNumber ?? "").toLowerCase().includes(q) ||
|
||||||
(p.passportNumber ?? "").toLowerCase().includes(q)
|
(p.passportNumber ?? "").toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
@@ -207,6 +214,7 @@ export default function PassengersReportPage() {
|
|||||||
setListSearch("");
|
setListSearch("");
|
||||||
setFilterCoachNumber("");
|
setFilterCoachNumber("");
|
||||||
setFilterOrigin("");
|
setFilterOrigin("");
|
||||||
|
setFilterDestination("");
|
||||||
setFilterSeatClass("");
|
setFilterSeatClass("");
|
||||||
}}
|
}}
|
||||||
disabled={loadingSchedules}
|
disabled={loadingSchedules}
|
||||||
@@ -499,6 +507,18 @@ export default function PassengersReportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 && (
|
{passengerList.length > 0 && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Download}
|
icon={Download}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store";
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
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 { PaymentMethod } from "@/types";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||||
@@ -55,6 +60,17 @@ export default function PaymentPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [billCopied, setBillCopied] = useState(false);
|
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';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
|
|
||||||
// Use the same display currency as the review page — stored on the schedule at search time.
|
// 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;
|
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.
|
// Derive charge currency directly from the selected method — no separate state that can lag.
|
||||||
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
|
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
|
||||||
|
|
||||||
@@ -128,6 +162,70 @@ export default function PaymentPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
|
}, [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({
|
const paymentMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
return await apiClient.post("/payments/initiate", {
|
return await apiClient.post("/payments/initiate", {
|
||||||
@@ -135,7 +233,7 @@ export default function PaymentPage() {
|
|||||||
method: data.method,
|
method: data.method,
|
||||||
paymentMethodId: data.paymentMethodId,
|
paymentMethodId: data.paymentMethodId,
|
||||||
payerAccount: data.payerAccount,
|
payerAccount: data.payerAccount,
|
||||||
platform: 'web',
|
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (data: any) => {
|
onSuccess: async (data: any) => {
|
||||||
@@ -163,6 +261,23 @@ export default function PaymentPage() {
|
|||||||
return;
|
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') {
|
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
|
||||||
setPaymentIntent(data.intentId);
|
setPaymentIntent(data.intentId);
|
||||||
updateStatus("REQUIRES_ACTION");
|
updateStatus("REQUIRES_ACTION");
|
||||||
@@ -505,7 +620,15 @@ export default function PaymentPage() {
|
|||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<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">
|
<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" />
|
<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>
|
<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">
|
<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>
|
<p className="text-red-800 dark:text-red-200 text-sm">Failed to load payment methods. Please refresh.</p>
|
||||||
</div>
|
</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">
|
<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>
|
<p className="text-yellow-800 dark:text-yellow-200 text-sm">No payment methods available at the moment.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.filter(m => m.enabled).map((method) => {
|
{availableMethods.map((method) => {
|
||||||
const Icon = getIconForMethod(method.type);
|
const Icon = getIconForMethod(method.type);
|
||||||
const isSelected = selectedMethod === method.type;
|
const isSelected = selectedMethod === method.type;
|
||||||
return (
|
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)
|
@IsEnum(ProviderMethod)
|
||||||
provider!: 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()
|
@IsOptional()
|
||||||
@IsIn(["web", "mobile"])
|
@IsIn(["web", "mobile", "inapp"])
|
||||||
platform?: PaymentPlatform;
|
platform?: PaymentPlatform;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get timeoutExpress(): string {
|
private get timeoutExpress(): string {
|
||||||
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
|
return this.config.get<string>("dmoney.timeoutExpress") ?? "5m";
|
||||||
}
|
}
|
||||||
private get language(): string {
|
private get language(): string {
|
||||||
return this.config.get<string>("dmoney.language") ?? "en";
|
return this.config.get<string>("dmoney.language") ?? "en";
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
|
ClientAction,
|
||||||
|
PaymentPlatform,
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
@@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
requestBody.biz_content.timeout_express,
|
requestBody.biz_content.timeout_express,
|
||||||
);
|
);
|
||||||
const platform = input.platform ?? "web";
|
const platform = input.platform ?? "web";
|
||||||
const clientAction =
|
const clientAction = this.buildClientAction(platform, prepayId, response);
|
||||||
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) };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: prepayId,
|
providerOrderId: prepayId,
|
||||||
@@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
input: ProviderInitiationInput,
|
input: ProviderInitiationInput,
|
||||||
): CreateOrderRequest {
|
): CreateOrderRequest {
|
||||||
const totalAmount = String(input.amountMinor);
|
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 = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
@@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}),
|
...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(
|
const sign = signRequestObject(
|
||||||
@@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
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 {
|
private buildCheckoutUrl(prepayId: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export enum ProviderMethod {
|
|||||||
CBE_BILL = "CBE_BILL",
|
CBE_BILL = "CBE_BILL",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentPlatform = "web" | "mobile";
|
|
||||||
|
export type PaymentPlatform = "web" | "mobile" | "inapp";
|
||||||
|
|
||||||
export type ClientAction =
|
export type ClientAction =
|
||||||
| { type: "REDIRECT"; url: string }
|
| { type: "REDIRECT"; url: string }
|
||||||
@@ -42,6 +43,16 @@ export type ClientAction =
|
|||||||
receiveCode?: string;
|
receiveCode?: string;
|
||||||
shortCode: 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";
|
type: "COLLECT_OTP";
|
||||||
providerOrderId: string;
|
providerOrderId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user