Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false
}
}

View File

@@ -0,0 +1,75 @@
{
"name": "@edr/finance-api",
"version": "0.0.0",
"private": true,
"description": "EDR Finance API — general ledger, chart of accounts, AR/AP, budgeting, fixed assets and financial reporting for the EDR platform",
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"type-check": "tsc --noEmit",
"migration:run": "node dist/scripts/migrate.js",
"seed:accounts": "node dist/scripts/seed-chart-of-accounts.js",
"seed:revenue-mappings": "node dist/scripts/seed-revenue-mappings.js",
"seed:finance": "APP_MODULE_PATH=./dist/app.module node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.3.4.tgz",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "0.3.30"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,100 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSource, DataSourceOptions } from "typeorm";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { IamModule } from "@tria-plc/iamapi-common/iam.module";
import databaseConfig from "./config/database.config";
import {
APPLICATION_SEARCH_PATH,
ensurePostgresSchemas,
} from "./config/ensure-postgres-schemas";
import { MeModule } from "./modules/me/me.module";
import { AccountsModule } from "./modules/accounts/accounts.module";
import { PeriodsModule } from "./modules/periods/periods.module";
import { JournalsModule } from "./modules/journals/journals.module";
import { RevenueModule } from "./modules/revenue/revenue.module";
import { PayablesModule } from "./modules/payables/payables.module";
import { BudgetingModule } from "./modules/budgeting/budgeting.module";
import { AssetsModule } from "./modules/assets/assets.module";
import { ReportsModule } from "./modules/reports/reports.module";
import { CutoverModule } from "./modules/cutover/cutover.module";
import {
FINANCE_APPLICATION,
FINANCE_PERMISSIONS,
FINANCE_ROLES,
FINANCE_ROLE_PERMISSIONS,
} from "./seed/finance-permissions.registry";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) throw new Error("Missing TypeORM DataSource options");
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
await dataSource.initialize();
// The database sits behind a connection pooler that rejects the Postgres
// `options` startup parameter (08P01), so search_path is set per physical
// connection: the pg Pool emits `connect` for every new client (initial
// fill, growth, reconnect). Without it, Finance's own schema-qualified
// reads still work, but IAM's unqualified SQL would not.
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* connection is validated on its first real query */
});
});
}
return dataSource;
},
}),
// Authentication is IAM's, unchanged: SharedAuthModule provides JwtGuard,
// which validates the session against `iam.sessions` directly. Finance
// issues no tokens and stores no credentials.
SharedAuthModule,
// IAM, embedded — the platform pattern: every service takes the IAM package
// as a dependency, declares its own application + permissions, and IAM gates
// each action by the caller's position. Same shape as edr-hr-api.
//
// This also brings IAM's own controllers onto this app, which is intended:
// Finance is another front end to IAM's capabilities, not a fork of them.
//
// Requires the entity globs in config/database.config.ts and
// `autoLoadEntities: false` — the partial forFeature set is what broke boot
// in HR with `Entity metadata for User#sessions was not found`.
IamModule.forRoot({
applications: [FINANCE_APPLICATION],
permissions: FINANCE_PERMISSIONS,
roles: FINANCE_ROLES,
rolePermissions: FINANCE_ROLE_PERMISSIONS,
}),
MeModule,
AccountsModule,
PeriodsModule,
JournalsModule,
RevenueModule,
PayablesModule,
BudgetingModule,
AssetsModule,
ReportsModule,
CutoverModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,69 @@
import { ForbiddenException } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import {
currentEmployeeId,
isSuperAdmin,
type MeLikeUser,
} from "./finance-permission.util";
/**
* Who is acting, resolved once per request.
*
* Finance records the actor on every posting, so this is not only a scoping key
* — it is part of the audit trail a ledger is required to keep.
*/
export type ActorContext = {
/** `iam.employees.id` — null for a super admin with no staff record. */
employeeId: string | null;
userId: string;
/** `iam.organizations.id`. Empty string only for a super admin. */
organizationId: string;
/** Super admins read and write across every organization (locked decision). */
isSuperAdmin: boolean;
};
/**
* The one place a request's actor is derived from the token.
*
* `organizationId` comes from the token's employee block, never from the request
* body or a query parameter — it is the row-scoping key for every Finance read
* and write, so letting a client supply it would be a tenancy hole.
*/
export function actorFrom(user: TCurrentUser): ActorContext {
const me = user as unknown as MeLikeUser;
const employee = me.employee;
const organizationId = Array.isArray(employee)
? employee[0]?.organizationId
: employee?.organizationId;
const superAdmin = isSuperAdmin(me);
// A super admin reads across organizations, so a missing employee record is
// not disqualifying for them. Everyone else must have one: it is the scoping
// key for every Finance read and write.
if (!organizationId && !superAdmin) {
throw new ForbiddenException(
"This account has no organization context — Finance requires a staff account",
);
}
return {
employeeId: currentEmployeeId(me),
userId: me.id ?? "",
organizationId: organizationId ?? "",
isSuperAdmin: superAdmin,
};
}
/**
* The organization filter for a scoped query, or `null` meaning "every
* organization".
*
* Every scoped query goes through this rather than reading
* `actor.organizationId` directly, so a new query cannot forget the super-admin
* case — the same helper HR settled on in `employees.service.ts`.
*/
export function orgScope(actor: ActorContext): string | null {
return actor.isSuperAdmin ? null : actor.organizationId;
}

View File

@@ -0,0 +1,24 @@
import { applyDecorators, UseGuards } from "@nestjs/common";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { FinancePermissionGuard } from "./finance-permission.guard";
/**
* The single route decorator for this app: authenticate, then require one of the
* given Finance permission keys.
*
* Separation of duty (prepare vs post, enter vs approve) is enforced by giving
* those routes DIFFERENT keys — see the role matrix in
* `seed/finance-permissions.registry.ts`. The guard only checks the key; any
* rule about *who* may act on a *specific* record (e.g. "not the same person who
* prepared it") belongs in the service, never here.
*/
export const FinanceStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FinancePermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);

View File

@@ -0,0 +1,63 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Type,
UnauthorizedException,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { hasFinancePermission, isSuperAdmin } from "./finance-permission.util";
// String literals on purpose, matching hr-api's and freight's guards: these are
// wire-format values of `iam.users.user_type`, and importing the vendored enum
// would couple this file to the package's internal layout for no gain.
const EMPLOYEE_USER_TYPE = "employee";
const userTypeOf = (user: TCurrentUser): string | undefined =>
(user as { userType?: string }).userType;
/**
* Finance is staff-only end to end — there is no customer-facing surface here at
* all, unlike freight (which has a portal). A missing userType (stale session)
* also fails.
*/
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === EMPLOYEE_USER_TYPE || isSuperAdmin(user);
/**
* Guard factory. Passing several keys means "any one of these" — used on the
* class gate, which must list every key its routes use: Nest runs class AND
* method guards, so a key missing from the class list denies before the route's
* own key is ever evaluated.
*/
export function FinancePermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FinancePermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException("Authentication required");
}
if (!isEmployee(user)) {
throw new ForbiddenException("Staff account required");
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasFinancePermission(user, p))) return true;
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(", ")}`,
);
}
}
return FinancePermissionsGuard;
}

View File

@@ -0,0 +1,109 @@
import { ForbiddenException } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
const SUPER_ADMIN_ROLE = "super_admin";
const ORGANIZATION_ADMIN_ROLE = "organization_admin";
type PermissionLike = { key?: string };
type PositionLike = { permissions?: PermissionLike[] };
/**
* The two token shapes IAM issues. `employee` is an object on a session token
* (`TCurrentUser`) and an array on the raw payload (`TCurrentTokenUser`); both
* reach controllers depending on how the session was minted, so every reader has
* to handle both. Mirrors hr-api's `hr-permission.util.ts` and freight's
* `collectPermissionKeys`.
*/
export type MeLikeUser = {
id?: string;
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
id?: string;
organizationId?: string;
position?: PositionLike;
delegatedPositions?: PositionLike[];
}
| { id?: string; organizationId?: string; positions?: PositionLike[] }[]
| null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
return Boolean(user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE));
}
export function isOrganizationAdmin(
user: MeLikeUser | null | undefined,
): boolean {
return Boolean(user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE));
}
/** Flat permission keys from roles and every position the token carries. */
export function collectPermissionKeys(
user: MeLikeUser | null | undefined,
): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
const employee = user.employee;
if (!employee) return [...keys];
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
return [...keys];
}
export function hasFinancePermission(
user: MeLikeUser | null | undefined,
permissionKey: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return collectPermissionKeys(user).includes(permissionKey);
}
export function assertFinancePermission(
user: TCurrentUser | MeLikeUser | null | undefined,
permissionKey: string,
): void {
if (hasFinancePermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/**
* The caller's `iam.employees.id`.
*
* Finance records who prepared and who posted each entry, and that identity is
* the employee — not the user — because it is the employee that carries the
* organization. Returns null for a token with no employee.
*/
export function currentEmployeeId(
user: MeLikeUser | null | undefined,
): string | null {
const employee = user?.employee;
if (!employee) return null;
if (Array.isArray(employee)) return employee[0]?.id ?? null;
return employee.id ?? null;
}

View File

@@ -0,0 +1,91 @@
import type { ColumnOptions, ValueTransformer } from "typeorm";
/**
* How money is represented in the `finance` schema, and how amounts from every
* other schema are normalized on the way in.
*
* ─── The one rule ────────────────────────────────────────────────────────────
* The ledger stores exact major-unit decimals — `numeric(14,2)`, ETB. No float
* arithmetic ever touches the posting path: a journal must balance to the cent,
* and floats do not add up exactly.
*
* ─── Per-source conventions (verified against the live database 2026-08-21) ──
* Every one of these was checked against real rows, because the column NAMES lie
* in two of the five cases. Never infer the unit from the name.
*
* freight.* numeric(14,2) MAJOR take as-is
* passenger.* (Prisma Int columns) integer MINOR divide by 100
* passenger."PaymentIntent" double MAJOR despite `amountMinor`
* edr_payment.payment_intent double MAJOR despite `amount_minor`
* payment events (`amountMinor`) JSON number MAJOR despite the name
* hr.* payroll numeric(14,2) MAJOR take as-is
*
* The `*Minor` names on the payment-intent path are historical and are a wire
* contract across service APIs and event payloads, so they are not worth a
* cross-service rename. This file is the authority instead.
*
* ─── Known bad upstream data ─────────────────────────────────────────────────
* 18 `passenger."PaymentIntent"` rows are 100x overstated: the force-confirm
* path writes `Booking.totalMinor` (cents) into the major-unit `amountMinor`
* column (`providerRef LIKE 'FORCE-%'`). That write path has NOT been fixed
* upstream, so new bad rows can still appear.
*
* Consequence for Finance, and it is not optional: passenger revenue is
* projected from `Booking.totalMinor` (genuine cents, reliable) via
* `fromMinorUnits`, NEVER from `PaymentIntent.amountMinor`.
*/
export const MONEY_PRECISION = 14;
export const MONEY_SCALE = 2;
/** The ledger's single currency. Non-ETB amounts convert at an explicit, recorded rate. */
export const LEDGER_CURRENCY = "ETB";
/**
* pg returns `numeric` as a string, because its exactness can exceed IEEE 754.
* `numeric(14,2)` always fits a JS number exactly, so the transformer hands
* services a `number` while the database keeps the exact decimal.
*/
export const numericTransformer: ValueTransformer = {
to: (value: number | string | null | undefined) => value,
from: (value: string | null): number | null =>
value === null ? null : Number(value),
};
/** Column options for every money column in the `finance` schema. */
export const moneyColumn = (options: ColumnOptions = {}): ColumnOptions => ({
type: "numeric",
precision: MONEY_PRECISION,
scale: MONEY_SCALE,
transformer: numericTransformer,
...options,
});
/**
* Half-up to 2 dp — applied ONCE, at the projection boundary.
*
* Never round intermediate sums: sum the exact values and the total is already
* exact. Rounding twice is how a trial balance ends up off by a cent.
*/
export function roundMoney(amount: number): number {
return Math.round((amount + Number.EPSILON) * 100) / 100;
}
/**
* Normalize a minor-unit (cents) integer from the passenger schema into the
* ledger's major units.
*/
export function fromMinorUnits(minor: number): number {
return roundMoney(minor / 100);
}
/**
* True when the two sides of a journal balance.
*
* Compared with an exact-cent epsilon rather than `===` so a value that has been
* through a float somewhere upstream cannot fail a balance check it should pass;
* anything genuinely off by a cent or more still fails.
*/
export function balances(totalDebit: number, totalCredit: number): boolean {
return Math.abs(roundMoney(totalDebit) - roundMoney(totalCredit)) < 0.005;
}

View File

@@ -0,0 +1,54 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
/** Envelope every list endpoint returns, so the frontend table is one component. */
export type Paginated<T> = {
items: T[];
total: number;
page: number;
limit: number;
pageCount: number;
};
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number = 25;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "DESC";
}
export function paginate<T>(
items: T[],
total: number,
page: number,
limit: number,
): Paginated<T> {
return {
items,
total,
page,
limit,
pageCount: limit > 0 ? Math.ceil(total / limit) : 0,
};
}

View File

@@ -0,0 +1,110 @@
import { dirname } from "path";
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSourceOptions } from "typeorm";
/**
* Finance migrations are recorded in `finance.migrations`, alongside
* `iam.typeorm_migrations`, `freight.migrations` and `hr.migrations` in the same
* database. Each app owns exactly one history table; Finance never writes
* another app's.
*/
export const FINANCE_MIGRATIONS = {
schema: "finance",
table: "migrations",
} as const;
/**
* IAM entity registration.
*
* `IamModule` is embedded in this app (the platform pattern: every service takes
* the IAM package as a dependency and IAM gates each action by the caller's
* position), so its entities must be on this DataSource.
*
* Registered as GLOBS over the two package dists rather than as a hand-written
* class list — copied from `apps/edr-hr-api/src/config/database.config.ts`,
* which documents why: freight-api's hand-listed array goes stale on every
* package bump, and globs cannot.
*
* BOTH dists are required. Some IAM entities relate to notification entities
* that physically live in `@tria-plc/api-common` — the IAM barrel only
* re-exports them — so registering only the IAM dist throws
* `Entity metadata for User#sessions was not found` at boot.
*/
function resolvePackageDist(pkg: string): string {
// Node honours each package's `exports` map at runtime even though TypeScript's
// resolution does not, so require.resolve on the barrel lands in dist/.
return dirname(require.resolve(pkg)).replace(/\\/g, "/");
}
const IAM_ENTITY_GLOBS = [
`${resolvePackageDist("@tria-plc/iamapi-common")}/entities/**/*.entity.{ts,js}`,
`${resolvePackageDist("@tria-plc/api-common")}/entities/**/*.entity.{ts,js}`,
];
function buildConnectionOptions() {
return {
type: "postgres" as const,
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
username: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "edr_secret",
database: process.env.DB_NAME ?? "edr_database",
// Do NOT pass `extra.options: '-c search_path=...'` — the connection pooler
// fronting the dev database rejects the Postgres `options` startup parameter
// with `08P01`. search_path is applied per physical connection in a pool
// `connect` handler instead (see app.module.ts).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true"
? true
: (["error", "warn"] as DataSourceOptions["logging"]),
};
}
/**
* Runtime options for the API. Carries no migrations — see `migration:run`.
*
* One connection serves both schemas: Finance entities are
* `@Entity({schema:"finance"})` and IAM's are `schema:"iam"`, so they cannot
* collide. `autoLoadEntities` is OFF because everything is listed explicitly
* here — leaving it on lets IamModule's `forFeature` registrations add a PARTIAL
* entity set, which is the exact configuration that failed at boot in HR.
*/
export function buildDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: FINANCE_MIGRATIONS.schema,
entities: [__dirname + "/../**/*.entity.{ts,js}", ...IAM_ENTITY_GLOBS],
migrations: [],
};
}
/**
* Finance migrations only, recorded in `finance.migrations`.
*
* Deliberately no entities: this DataSource exists to run Finance's own DDL.
* Finance never migrates `iam`, `freight`, `passenger`, `hr` or `edr_payment` —
* it only ever READS those, and each belongs to its own owner.
*/
export function buildFinanceMigrationDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: FINANCE_MIGRATIONS.schema,
entities: [],
migrations: [__dirname + "/../migrations/*.js"],
migrationsTableName: FINANCE_MIGRATIONS.table,
migrationsTransactionMode: "each",
};
}
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: false,
// Migrations run as a separate one-shot step, never on API boot (house rule).
migrationsRun: false,
}),
);

View File

@@ -0,0 +1,67 @@
import { DataSource, DataSourceOptions } from "typeorm";
/**
* Schemas this app touches directly. `finance` is the only one it owns and
* migrates; `iam` is read-only (owned by edr-freight-api's migrations) and is
* listed so the search path resolves unqualified IAM lookups, and so a brand-new
* database is usable before freight has ever booted against it.
*
* Deliberately NOT listed: `freight`, `passenger`, `hr`, `edr_payment`. Finance
* projects from those schemas, but every such read is schema-qualified raw SQL,
* so they need no search-path entry — and creating them here would have Finance
* bringing another app's schema into existence, which is not its business.
*/
export const APPLICATION_SCHEMAS = ["public", "iam", "finance", "audit"] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* Extensions the migrations call into but never create themselves — the Finance
* baseline defaults primary keys to `gen_random_uuid()`.
*/
export const APPLICATION_EXTENSIONS = ["uuid-ossp", "pgcrypto"] as const;
/**
* TypeORM creates the migrations table before any migration runs, so the schema
* that table lives in has to exist first. Mirrors
* `apps/edr-hr-api/src/config/ensure-postgres-schemas.ts` — deliberately, so the
* apps cannot disagree about how the shared database is prepared.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA "${schema}" TO public`);
}
}
// Best effort: creating an extension needs rights the app user may not have.
// On an established database they are already installed and this is a no-op.
for (const extension of APPLICATION_EXTENSIONS) {
try {
await bootstrap.query(`CREATE EXTENSION IF NOT EXISTS "${extension}"`);
} catch (err) {
console.warn(
`could not ensure extension "${extension}": ${(err as Error).message}`,
);
}
}
await bootstrap.destroy();
}

View File

@@ -0,0 +1,10 @@
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildFinanceMigrationDataSourceOptions } from "./config/database.config";
/** Standalone DataSource for the TypeORM CLI and the migrate script. */
export const AppDataSource = new DataSource(
buildFinanceMigrationDataSourceOptions(),
);
export default AppDataSource;

View File

@@ -0,0 +1,71 @@
import "reflect-metadata";
import "dotenv/config";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api/v1");
// The Finance frontend is a separate origin (its own Vite dev server, its own
// host in production), so every call from it is cross-origin and dies at the
// preflight without this. `credentials: true` is required because the client
// sends the session cookie alongside the bearer token.
const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5186")
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: corsOrigins,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"Accept-Language",
"X-Client-App",
"X-Request-ID",
],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidUnknownValues: false,
}),
);
const config = new DocumentBuilder()
.setTitle("EDR Finance API")
.setDescription(
"Finance — general ledger, chart of accounts, receivables, payables, " +
"budgeting, fixed assets and financial reporting. Finance OWNS the " +
"`finance` schema and writes nothing else: revenue, cash and payroll " +
"are projected read-only from freight, passenger, edr_payment and hr, " +
"and cash receipts additionally arrive as payment events. Cross-schema " +
"references are soft UUIDs, never foreign keys.",
)
.setVersion("1.0.0")
.addBearerAuth()
.build();
SwaggerModule.setup(
"api-docs",
app,
SwaggerModule.createDocument(app, config),
{
customSiteTitle: "EDR Finance API",
swaggerOptions: { persistAuthorization: true },
},
);
const port = process.env.PORT ?? 3004;
await app.listen(port);
console.log(`🚀 EDR Finance API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
void bootstrap();

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Finance baseline — Module 4.0 (scaffold).
*
* Creates only the schema and the extension its DDL depends on. The ledger
* tables themselves arrive in 4.1 (chart of accounts, fiscal periods, journal
* entries); this migration exists so that `finance.migrations` is established
* and the runner is proven end to end before any table depends on it.
*
* All DDL is idempotent (`IF NOT EXISTS`), per the house rule, so a
* partially-applied run can be repeated safely.
*
* Timestamp 3700000000000 continues the platform's 3xxx convention (freight
* 32xx/35xx, HR 36xx) and is unique within this app's migration folder.
*/
export class FinanceBaseline3700000000000 implements MigrationInterface {
name = "FinanceBaseline3700000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "finance"`);
// Primary keys default to gen_random_uuid(), which pgcrypto provides.
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
}
/**
* Deliberately a no-op.
*
* Dropping the schema would take every posted journal with it, and reverting a
* baseline must never be the thing that destroys the ledger. Removing Finance
* from a database is a manual, deliberate act — not a migration revert.
*/
public async down(): Promise<void> {
// intentionally empty — see above
}
}

View File

@@ -0,0 +1,271 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Module 4.1 — general ledger foundation.
*
* Five tables: the chart of accounts, the fiscal calendar (years + periods) and
* double-entry journals (headers + lines).
*
* Design notes that are enforced HERE rather than only in the service, because
* these are the invariants whose violation cannot be repaired afterwards:
* - `ck_journal_entries_balanced` — debits must equal credits on every header.
* - `ck_journal_lines_one_side` — a line is a debit or a credit, never both,
* never neither.
* - `journal_lines.journal_entry_id` cascades on delete, which is safe ONLY
* because a POSTED entry is never deleted; the cascade exists to clean up
* draft lines when a draft is discarded.
*
* Deliberate omissions:
* - No foreign keys into `iam.*`. Cross-schema references are soft UUIDs
* (platform stance — zero FKs from `freight.*` into `iam.*`).
* - No FK from `journal_lines.cost_center_id`: cost centers arrive in 4.4, and
* the column points at `iam.units` until then.
*
* All DDL is idempotent (`IF NOT EXISTS`), per the house rule.
*/
export class FinanceGeneralLedger3700000000001 implements MigrationInterface {
name = "FinanceGeneralLedger3700000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "finance"`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
// ── accounts ──────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"account_type" varchar(16) NOT NULL,
"normal_balance" varchar(8) NOT NULL,
"parent_account_id" uuid REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"is_group" boolean NOT NULL DEFAULT false,
"is_contra" boolean NOT NULL DEFAULT false,
"is_active" boolean NOT NULL DEFAULT true,
"is_system" boolean NOT NULL DEFAULT false,
"description" jsonb,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_accounts_type"
CHECK ("account_type" IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
CONSTRAINT "ck_accounts_normal_balance"
CHECK ("normal_balance" IN ('DEBIT','CREDIT')),
CONSTRAINT "ck_accounts_not_self_parent"
CHECK ("parent_account_id" IS NULL OR "parent_account_id" <> "id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_accounts_org_code"
ON "finance"."accounts" ("organization_id", "code")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_accounts_organization_id"
ON "finance"."accounts" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_accounts_parent_id"
ON "finance"."accounts" ("parent_account_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_accounts_type"
ON "finance"."accounts" ("account_type")
`);
// ── fiscal_years ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."fiscal_years" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"start_date" date NOT NULL,
"end_date" date NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'OPEN',
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_fiscal_years_status" CHECK ("status" IN ('OPEN','CLOSED')),
CONSTRAINT "ck_fiscal_years_range" CHECK ("end_date" > "start_date")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_fiscal_years_org_code"
ON "finance"."fiscal_years" ("organization_id", "code")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fiscal_years_organization_id"
ON "finance"."fiscal_years" ("organization_id")
`);
// ── fiscal_periods ────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."fiscal_periods" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"fiscal_year_id" uuid NOT NULL
REFERENCES "finance"."fiscal_years"("id") ON DELETE RESTRICT,
"period_number" integer NOT NULL,
"name" jsonb NOT NULL,
"start_date" date NOT NULL,
"end_date" date NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'OPEN',
"closed_at" timestamptz,
"closed_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_fiscal_periods_status"
CHECK ("status" IN ('OPEN','CLOSED','LOCKED')),
CONSTRAINT "ck_fiscal_periods_range" CHECK ("end_date" >= "start_date"),
CONSTRAINT "ck_fiscal_periods_number" CHECK ("period_number" >= 1)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_fiscal_periods_year_number"
ON "finance"."fiscal_periods" ("fiscal_year_id", "period_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fiscal_periods_year_id"
ON "finance"."fiscal_periods" ("fiscal_year_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fiscal_periods_dates"
ON "finance"."fiscal_periods" ("start_date", "end_date")
`);
// ── journal_entries ───────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."journal_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"entry_number" varchar(32) NOT NULL,
"entry_date" date NOT NULL,
"fiscal_period_id" uuid NOT NULL
REFERENCES "finance"."fiscal_periods"("id") ON DELETE RESTRICT,
"journal_type" varchar(24) NOT NULL DEFAULT 'GENERAL',
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"memo" text NOT NULL,
"reference" varchar(128),
"source_module" varchar(32),
"source_id" varchar(128),
"total_debit" numeric(14,2) NOT NULL DEFAULT 0,
"total_credit" numeric(14,2) NOT NULL DEFAULT 0,
"currency" varchar(8) NOT NULL DEFAULT 'ETB',
"prepared_by" uuid,
"posted_by" uuid,
"posted_at" timestamptz,
"reversed_by_entry_id" uuid,
"reverses_entry_id" uuid,
"reversal_reason" text,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_journal_entries_status"
CHECK ("status" IN ('DRAFT','POSTED','REVERSED')),
CONSTRAINT "ck_journal_entries_totals_non_negative"
CHECK ("total_debit" >= 0 AND "total_credit" >= 0),
CONSTRAINT "ck_journal_entries_balanced"
CHECK ("total_debit" = "total_credit")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_journal_entries_org_number"
ON "finance"."journal_entries" ("organization_id", "entry_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_entries_organization_id"
ON "finance"."journal_entries" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_entries_period_id"
ON "finance"."journal_entries" ("fiscal_period_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_entries_date"
ON "finance"."journal_entries" ("entry_date")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_entries_status"
ON "finance"."journal_entries" ("status")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_entries_source"
ON "finance"."journal_entries" ("source_module", "source_id")
`);
// Automated posting (4.2+) must be idempotent: one source document produces
// one entry, however many times its event is redelivered. Partial, so the
// many hand-keyed entries with no source are unaffected.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_journal_entries_source_document"
ON "finance"."journal_entries" ("organization_id", "source_module", "source_id")
WHERE "source_module" IS NOT NULL
AND "source_id" IS NOT NULL
AND "deleted_at" IS NULL
`);
// ── journal_lines ─────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."journal_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"journal_entry_id" uuid NOT NULL
REFERENCES "finance"."journal_entries"("id") ON DELETE CASCADE,
"line_number" integer NOT NULL,
"account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"debit" numeric(14,2) NOT NULL DEFAULT 0,
"credit" numeric(14,2) NOT NULL DEFAULT 0,
"description" text,
"cost_center_id" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_journal_lines_non_negative"
CHECK ("debit" >= 0 AND "credit" >= 0),
CONSTRAINT "ck_journal_lines_one_side"
CHECK (("debit" > 0 AND "credit" = 0) OR ("credit" > 0 AND "debit" = 0))
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_journal_lines_entry_line"
ON "finance"."journal_lines" ("journal_entry_id", "line_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_lines_entry_id"
ON "finance"."journal_lines" ("journal_entry_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_lines_account_id"
ON "finance"."journal_lines" ("account_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_lines_cost_center_id"
ON "finance"."journal_lines" ("cost_center_id")
`);
// The trial balance and every account-activity report group by account over
// POSTED entries only; this covers that access path without touching the
// draft rows.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_journal_lines_account_entry"
ON "finance"."journal_lines" ("account_id", "journal_entry_id")
`);
}
/**
* Drops only what this migration created, youngest first.
*
* Safe to run only while the ledger is still empty — which is the only
* situation a 4.1 revert can legitimately arise in. Once entries exist,
* reverting is not a migration concern.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."journal_lines"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."journal_entries"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."fiscal_periods"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."fiscal_years"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."accounts"`);
}
}

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Module 4.2 — receivables and revenue.
*
* Two tables, and neither of them stores revenue. That is the point: revenue
* LIVES in the source systems (freight invoices, passenger bookings) and is
* read from there by projection. Finance only stores the two things the source
* systems cannot supply —
*
* 1. `revenue_mappings` — which GL account a given charge type earns into.
* Today that knowledge lives in freight's report code as a hard-coded SQL
* CASE with fourteen categories, which means adding a charge type requires
* a deploy. Here it is data.
*
* 2. `inbound_events` — every payment event this service has received, with
* what it did about it. Delivery is at-least-once, so the unique index on
* `event_id` is what makes reprocessing harmless; and an event that could
* NOT be posted (no mapping, closed period, unknown currency) is recorded
* as FAILED rather than dropped, because a payment that vanishes silently
* is the worst outcome available.
*
* All DDL is idempotent, per the house rule.
*/
export class FinanceReceivables3700000000002 implements MigrationInterface {
name = "FinanceReceivables3700000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── revenue_mappings ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."revenue_mappings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"source_module" varchar(32) NOT NULL,
"match_value" varchar(64) NOT NULL,
"account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"revenue_category" varchar(48) NOT NULL,
"is_active" boolean NOT NULL DEFAULT true,
"notes" text,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_revenue_mappings_source"
CHECK ("source_module" IN ('freight','passenger','payment'))
)
`);
// One mapping per charge type per source. Without this a charge type could
// resolve to two accounts and the same revenue would post to whichever the
// query happened to return first.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_revenue_mappings_lookup"
ON "finance"."revenue_mappings"
("organization_id", "source_module", "match_value")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_revenue_mappings_organization_id"
ON "finance"."revenue_mappings" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_revenue_mappings_account_id"
ON "finance"."revenue_mappings" ("account_id")
`);
// ── inbound_events ────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."inbound_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"event_id" varchar(128) NOT NULL,
"routing_key" varchar(64) NOT NULL,
"organization_id" uuid,
"source_module" varchar(32),
"source_id" varchar(128),
"payload" jsonb NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'RECEIVED',
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"error" text,
"attempts" integer NOT NULL DEFAULT 0,
"received_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processed_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_inbound_events_status"
CHECK ("status" IN ('RECEIVED','POSTED','SKIPPED','FAILED'))
)
`);
/**
* THE idempotency guard.
*
* The broker guarantees at-least-once, so the same payment event WILL
* arrive twice — on redelivery after a consumer restart, or when the
* publisher's outbox retries. Inserting this row first means the second
* delivery collides here and is acknowledged without posting anything, so
* one payment can never become two journal entries.
*/
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_inbound_events_event_id"
ON "finance"."inbound_events" ("event_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_inbound_events_status"
ON "finance"."inbound_events" ("status")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_inbound_events_source"
ON "finance"."inbound_events" ("source_module", "source_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_inbound_events_received_at"
ON "finance"."inbound_events" ("received_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."inbound_events"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."revenue_mappings"`);
}
}

View File

@@ -0,0 +1,229 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Module 4.3 — payables and statutory remittances.
*
* Five tables:
* - `suppliers` the vendor master
* - `supplier_bills` what is owed, with a prepare/approve split
* - `supplier_bill_lines` the expense breakdown
* - `supplier_payments` what has been paid against a bill
* - `statutory_remittances` PAYE, pension, VAT and withholding handed over
*
* Payroll deliberately has NO table here. A payroll run already exists in full
* in `hr.payroll_runs`/`hr.payslips`; copying it into `finance` would create a
* second version of the same fact that can drift. Finance posts FROM it and
* records only the journal link, using the same `source_module`/`source_id`
* idempotency key every other automated posting uses.
*
* All DDL is idempotent, per the house rule.
*/
export class FinancePayables3700000000003 implements MigrationInterface {
name = "FinancePayables3700000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── suppliers ─────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."suppliers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" varchar(200) NOT NULL,
"tin" varchar(32),
"contact_person" varchar(160),
"phone" varchar(48),
"email" varchar(160),
"address" text,
"bank_name" varchar(120),
"bank_account" varchar(64),
"payable_account_id" uuid
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"is_active" boolean NOT NULL DEFAULT true,
"notes" text,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_suppliers_org_code"
ON "finance"."suppliers" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_suppliers_organization_id"
ON "finance"."suppliers" ("organization_id")
`);
// ── supplier_bills ────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."supplier_bills" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"supplier_id" uuid NOT NULL
REFERENCES "finance"."suppliers"("id") ON DELETE RESTRICT,
"bill_number" varchar(64) NOT NULL,
"supplier_invoice_number" varchar(64),
"bill_date" date NOT NULL,
"due_date" date,
"currency" varchar(8) NOT NULL DEFAULT 'ETB',
"subtotal" numeric(14,2) NOT NULL DEFAULT 0,
"tax_amount" numeric(14,2) NOT NULL DEFAULT 0,
"withholding_amount" numeric(14,2) NOT NULL DEFAULT 0,
"total_amount" numeric(14,2) NOT NULL DEFAULT 0,
"paid_amount" numeric(14,2) NOT NULL DEFAULT 0,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"description" text,
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"prepared_by" uuid,
"approved_by" uuid,
"approved_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_supplier_bills_status"
CHECK ("status" IN ('DRAFT','APPROVED','PARTIALLY_PAID','PAID','CANCELLED')),
CONSTRAINT "ck_supplier_bills_amounts"
CHECK ("total_amount" >= 0 AND "paid_amount" >= 0),
-- A bill can never be over-paid. Catching it here means an
-- over-payment cannot exist even if a service forgets to check.
CONSTRAINT "ck_supplier_bills_not_overpaid"
CHECK ("paid_amount" <= "total_amount")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_supplier_bills_org_number"
ON "finance"."supplier_bills" ("organization_id", "bill_number")
WHERE "deleted_at" IS NULL
`);
// The same supplier invoice must not be entered twice — the classic
// duplicate-payment route. Partial, so bills with no supplier reference
// (petty expenses) are unaffected.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_supplier_bills_supplier_invoice"
ON "finance"."supplier_bills" ("organization_id", "supplier_id", "supplier_invoice_number")
WHERE "supplier_invoice_number" IS NOT NULL AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_supplier_bills_supplier_id"
ON "finance"."supplier_bills" ("supplier_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_supplier_bills_status"
ON "finance"."supplier_bills" ("status")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_supplier_bills_due_date"
ON "finance"."supplier_bills" ("due_date")
`);
// ── supplier_bill_lines ───────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."supplier_bill_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"supplier_bill_id" uuid NOT NULL
REFERENCES "finance"."supplier_bills"("id") ON DELETE CASCADE,
"line_number" integer NOT NULL,
"account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"description" text,
"quantity" numeric(12,2) NOT NULL DEFAULT 1,
"unit_price" numeric(14,2) NOT NULL DEFAULT 0,
"amount" numeric(14,2) NOT NULL,
"cost_center_id" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_supplier_bill_lines_amount" CHECK ("amount" > 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_supplier_bill_lines_number"
ON "finance"."supplier_bill_lines" ("supplier_bill_id", "line_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_supplier_bill_lines_bill_id"
ON "finance"."supplier_bill_lines" ("supplier_bill_id")
`);
// ── supplier_payments ─────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."supplier_payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"supplier_bill_id" uuid NOT NULL
REFERENCES "finance"."supplier_bills"("id") ON DELETE RESTRICT,
"payment_number" varchar(32) NOT NULL,
"payment_date" date NOT NULL,
"amount" numeric(14,2) NOT NULL,
"method" varchar(24) NOT NULL DEFAULT 'BANK',
"paid_from_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"reference" varchar(128),
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"recorded_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_supplier_payments_amount" CHECK ("amount" > 0),
CONSTRAINT "ck_supplier_payments_method"
CHECK ("method" IN ('BANK','CASH','CHEQUE','MOBILE'))
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_supplier_payments_org_number"
ON "finance"."supplier_payments" ("organization_id", "payment_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_supplier_payments_bill_id"
ON "finance"."supplier_payments" ("supplier_bill_id")
`);
// ── statutory_remittances ─────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."statutory_remittances" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"statutory_type" varchar(24) NOT NULL,
"period" varchar(7) NOT NULL,
"liability_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"amount" numeric(14,2) NOT NULL,
"paid_date" date NOT NULL,
"paid_from_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"reference" varchar(128),
"receipt_number" varchar(64),
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"recorded_by" uuid,
"notes" text,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_statutory_remittances_amount" CHECK ("amount" > 0),
CONSTRAINT "ck_statutory_remittances_type"
CHECK ("statutory_type" IN ('INCOME_TAX','PENSION','VAT','WITHHOLDING'))
)
`);
// One remittance per statutory type per period. Paying PAYE for the same
// month twice is a real and expensive mistake; the database refuses it.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_statutory_remittances_type_period"
ON "finance"."statutory_remittances"
("organization_id", "statutory_type", "period")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_statutory_remittances_period"
ON "finance"."statutory_remittances" ("period")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."statutory_remittances"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."supplier_payments"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."supplier_bill_lines"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."supplier_bills"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."suppliers"`);
}
}

View File

@@ -0,0 +1,206 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Module 4.4 — cost centers and budgets.
*
* Three tables plus one repair:
* - `cost_centers` the analysis tree. Soft-references `iam.units` so an
* existing department can carry a budget without Finance
* owning, copying, or constraining the org chart.
* - `budgets` one approved plan for a fiscal year, with a prepare/approve
* split like every other commitment in this service.
* - `budget_lines` the figure for one account × cost center × period.
*
* The repair: `journal_lines.cost_center_id` has existed since 4.1 as a bare
* UUID pointing at `iam.units` "until cost centers arrive". They have arrived,
* so it now references `finance.cost_centers` properly — added GUARDED, because
* a database that already holds unit ids in that column must not have the
* migration fail halfway. Where old values exist the constraint is skipped and
* reported rather than forced.
*
* All DDL is idempotent, per the house rule.
*/
export class FinanceBudgeting3700000000004 implements MigrationInterface {
name = "FinanceBudgeting3700000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── cost_centers ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."cost_centers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"parent_id" uuid REFERENCES "finance"."cost_centers"("id") ON DELETE RESTRICT,
"unit_id" uuid,
"manager_employee_id" uuid,
"is_group" boolean NOT NULL DEFAULT false,
"is_active" boolean NOT NULL DEFAULT true,
"description" text,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_cost_centers_not_self_parent"
CHECK ("parent_id" IS NULL OR "parent_id" <> "id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cost_centers_org_code"
ON "finance"."cost_centers" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_cost_centers_organization_id"
ON "finance"."cost_centers" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_cost_centers_parent_id"
ON "finance"."cost_centers" ("parent_id")
`);
// One cost center per IAM unit. Two would make "the warehouse's spend" an
// ambiguous question, which is the one thing a cost center exists to answer.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cost_centers_unit"
ON "finance"."cost_centers" ("organization_id", "unit_id")
WHERE "unit_id" IS NOT NULL AND "deleted_at" IS NULL
`);
// ── budgets ───────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."budgets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"fiscal_year_id" uuid NOT NULL
REFERENCES "finance"."fiscal_years"("id") ON DELETE RESTRICT,
"name" varchar(160) NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"description" text,
"prepared_by" uuid,
"approved_by" uuid,
"approved_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_budgets_status"
CHECK ("status" IN ('DRAFT','APPROVED','CLOSED'))
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_budgets_organization_id"
ON "finance"."budgets" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_budgets_fiscal_year_id"
ON "finance"."budgets" ("fiscal_year_id")
`);
/**
* At most ONE approved budget per fiscal year.
*
* Several drafts may be prepared and compared, but once a year has two
* approved budgets, "are we over budget?" has two different answers, and
* every variance report becomes a question about which plan you meant.
*/
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_budgets_one_approved_per_year"
ON "finance"."budgets" ("organization_id", "fiscal_year_id")
WHERE "status" = 'APPROVED' AND "deleted_at" IS NULL
`);
// ── budget_lines ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."budget_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"budget_id" uuid NOT NULL
REFERENCES "finance"."budgets"("id") ON DELETE CASCADE,
"account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"cost_center_id" uuid
REFERENCES "finance"."cost_centers"("id") ON DELETE RESTRICT,
"fiscal_period_id" uuid NOT NULL
REFERENCES "finance"."fiscal_periods"("id") ON DELETE RESTRICT,
"amount" numeric(14,2) NOT NULL DEFAULT 0,
"note" text,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_budget_lines_amount" CHECK ("amount" >= 0)
)
`);
/**
* One figure per account × cost center × period.
*
* `COALESCE(cost_center_id, uuid-zero)` rather than the bare column,
* because in Postgres NULLs are distinct in a unique index — without this
* an unallocated line could be entered any number of times and the budget
* would quietly double.
*/
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_budget_lines_slot"
ON "finance"."budget_lines"
("budget_id", "account_id",
COALESCE("cost_center_id", '00000000-0000-0000-0000-000000000000'::uuid),
"fiscal_period_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_budget_lines_budget_id"
ON "finance"."budget_lines" ("budget_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_budget_lines_account_period"
ON "finance"."budget_lines" ("account_id", "fiscal_period_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_budget_lines_cost_center_id"
ON "finance"."budget_lines" ("cost_center_id")
`);
// ── repair: point journal_lines.cost_center_id at a real table ─────────
/**
* Guarded on purpose. Since 4.1 this column has been a bare UUID that the
* comment said pointed at `iam.units`; a database carrying such values
* would fail an unconditional ADD CONSTRAINT partway through the migration
* and leave the schema half-changed. Here it is added only when nothing
* would violate it, and skipped with a notice otherwise — the FK is an
* improvement, not something worth breaking a deployment over.
*/
await queryRunner.query(`
DO $$
DECLARE orphans bigint;
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_journal_lines_cost_center'
AND conrelid = 'finance.journal_lines'::regclass
) THEN
RETURN;
END IF;
SELECT COUNT(*) INTO orphans
FROM finance.journal_lines l
WHERE l.cost_center_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM finance.cost_centers c WHERE c.id = l.cost_center_id
);
IF orphans = 0 THEN
ALTER TABLE finance.journal_lines
ADD CONSTRAINT fk_journal_lines_cost_center
FOREIGN KEY (cost_center_id)
REFERENCES finance.cost_centers(id) ON DELETE RESTRICT;
ELSE
RAISE NOTICE 'journal_lines.cost_center_id left unconstrained: % row(s) reference a non-existent cost center (most likely legacy iam.units ids). Map them, then add fk_journal_lines_cost_center by hand.', orphans;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "finance"."journal_lines"
DROP CONSTRAINT IF EXISTS "fk_journal_lines_cost_center"
`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."budget_lines"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."budgets"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."cost_centers"`);
}
}

View File

@@ -0,0 +1,217 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Module 4.5 — fixed assets and depreciation.
*
* Four tables:
* - `asset_categories` depreciation defaults per class, and which accounts
* the cost, the accumulated depreciation and the
* charge land in.
* - `fixed_assets` the register. Carries `accumulated_depreciation` as
* a running total AND is reconcilable against the
* ledger — see the note below.
* - `depreciation_runs` one run per period, posting one journal entry.
* - `depreciation_entries` what each asset was charged in a run.
* - `asset_disposals` sale or write-off, with the gain or loss.
*
* On storing `accumulated_depreciation` on the asset: it duplicates what the
* ledger already knows, which is normally the thing to avoid. It is kept
* because depreciation must STOP at the depreciable base, and that decision is
* per-asset and made on every run — deriving it from the ledger each time would
* mean an aggregate query per asset per month. The ledger stays authoritative;
* this is a cache, and the run refuses to post if the two disagree.
*
* All DDL is idempotent, per the house rule.
*/
export class FinanceFixedAssets3700000000005 implements MigrationInterface {
name = "FinanceFixedAssets3700000000005";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── asset_categories ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."asset_categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"asset_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"accumulated_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"expense_account_id" uuid NOT NULL
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"default_life_months" integer NOT NULL,
"default_salvage_rate" numeric(6,4) NOT NULL DEFAULT 0,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_asset_categories_life" CHECK ("default_life_months" > 0),
CONSTRAINT "ck_asset_categories_salvage"
CHECK ("default_salvage_rate" >= 0 AND "default_salvage_rate" < 1)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_asset_categories_org_code"
ON "finance"."asset_categories" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
// ── fixed_assets ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."fixed_assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"asset_category_id" uuid NOT NULL
REFERENCES "finance"."asset_categories"("id") ON DELETE RESTRICT,
"asset_code" varchar(48) NOT NULL,
"name" varchar(200) NOT NULL,
"description" text,
"serial_number" varchar(96),
"cost_center_id" uuid
REFERENCES "finance"."cost_centers"("id") ON DELETE RESTRICT,
"acquisition_date" date NOT NULL,
"in_service_date" date NOT NULL,
"acquisition_cost" numeric(14,2) NOT NULL,
"salvage_value" numeric(14,2) NOT NULL DEFAULT 0,
"useful_life_months" integer NOT NULL,
"depreciation_method" varchar(24) NOT NULL DEFAULT 'STRAIGHT_LINE',
"accumulated_depreciation" numeric(14,2) NOT NULL DEFAULT 0,
"status" varchar(16) NOT NULL DEFAULT 'ACTIVE',
"supplier_bill_id" uuid
REFERENCES "finance"."supplier_bills"("id") ON DELETE SET NULL,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_fixed_assets_status"
CHECK ("status" IN ('ACTIVE','FULLY_DEPRECIATED','DISPOSED','WRITTEN_OFF')),
CONSTRAINT "ck_fixed_assets_method"
CHECK ("depreciation_method" IN ('STRAIGHT_LINE')),
CONSTRAINT "ck_fixed_assets_cost" CHECK ("acquisition_cost" > 0),
CONSTRAINT "ck_fixed_assets_life" CHECK ("useful_life_months" > 0),
-- Salvage below cost, or the asset would depreciate by a negative amount.
CONSTRAINT "ck_fixed_assets_salvage"
CHECK ("salvage_value" >= 0 AND "salvage_value" < "acquisition_cost"),
-- THE cap: an asset can never depreciate past what it is allowed to.
CONSTRAINT "ck_fixed_assets_accumulated"
CHECK ("accumulated_depreciation" >= 0
AND "accumulated_depreciation" <= "acquisition_cost" - "salvage_value"),
CONSTRAINT "ck_fixed_assets_in_service"
CHECK ("in_service_date" >= "acquisition_date")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_fixed_assets_org_code"
ON "finance"."fixed_assets" ("organization_id", "asset_code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fixed_assets_status"
ON "finance"."fixed_assets" ("status")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fixed_assets_category"
ON "finance"."fixed_assets" ("asset_category_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_fixed_assets_cost_center"
ON "finance"."fixed_assets" ("cost_center_id")
`);
// ── depreciation_runs ─────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."depreciation_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"fiscal_period_id" uuid NOT NULL
REFERENCES "finance"."fiscal_periods"("id") ON DELETE RESTRICT,
"run_date" date NOT NULL,
"asset_count" integer NOT NULL DEFAULT 0,
"total_amount" numeric(14,2) NOT NULL DEFAULT 0,
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"posted_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_depreciation_runs_total" CHECK ("total_amount" >= 0)
)
`);
/**
* One depreciation run per period.
*
* Running the same month twice would charge the same wear twice and push
* every asset toward its cap early — the kind of error nobody notices until
* the register and the ledger disagree by a year's depreciation.
*/
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_depreciation_runs_period"
ON "finance"."depreciation_runs" ("organization_id", "fiscal_period_id")
`);
// ── depreciation_entries ──────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."depreciation_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"depreciation_run_id" uuid NOT NULL
REFERENCES "finance"."depreciation_runs"("id") ON DELETE CASCADE,
"fixed_asset_id" uuid NOT NULL
REFERENCES "finance"."fixed_assets"("id") ON DELETE RESTRICT,
"amount" numeric(14,2) NOT NULL,
"accumulated_after" numeric(14,2) NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ck_depreciation_entries_amount" CHECK ("amount" > 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_depreciation_entries_run_asset"
ON "finance"."depreciation_entries" ("depreciation_run_id", "fixed_asset_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_depreciation_entries_asset"
ON "finance"."depreciation_entries" ("fixed_asset_id")
`);
// ── asset_disposals ───────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."asset_disposals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"fixed_asset_id" uuid NOT NULL
REFERENCES "finance"."fixed_assets"("id") ON DELETE RESTRICT,
"disposal_date" date NOT NULL,
"disposal_type" varchar(16) NOT NULL,
"proceeds" numeric(14,2) NOT NULL DEFAULT 0,
"net_book_value" numeric(14,2) NOT NULL,
"gain_loss" numeric(14,2) NOT NULL,
"proceeds_account_id" uuid
REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT,
"reference" varchar(128),
"notes" text,
"journal_entry_id" uuid
REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL,
"recorded_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "ck_asset_disposals_type"
CHECK ("disposal_type" IN ('SALE','SCRAP','WRITE_OFF')),
CONSTRAINT "ck_asset_disposals_proceeds" CHECK ("proceeds" >= 0)
)
`);
// One disposal per asset — an asset is disposed of once, and a second row
// would double the gain or loss reported on it.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_asset_disposals_asset"
ON "finance"."asset_disposals" ("fixed_asset_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."asset_disposals"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."depreciation_entries"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."depreciation_runs"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."fixed_assets"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."asset_categories"`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair: `fixed_assets.status` was varchar(16), but one of the values its own
* CHECK constraint permits — `FULLY_DEPRECIATED` — is 17 characters.
*
* The effect was invisible until an asset actually reached the end of its life:
* the depreciation run computed the right numbers, posted the right journal
* entry, and then failed on the UPDATE with "value too long for type character
* varying(16)". The CHECK and the column width disagreed, and nothing catches
* that until the longest value is first used.
*
* Widened to 24 to leave room, matching how `journal_type` is sized.
*/
export class FinanceAssetStatusWidth3700000000006
implements MigrationInterface
{
name = "FinanceAssetStatusWidth3700000000006";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "finance"."fixed_assets"
ALTER COLUMN "status" TYPE varchar(24)`,
);
}
/**
* Deliberately a no-op.
*
* Narrowing back to 16 would fail on any row that has since reached
* FULLY_DEPRECIATED — reverting a repair must not re-introduce the outage it
* fixed.
*/
public async down(): Promise<void> {
// intentionally empty — see above
}
}

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* P7 cutover — the two things go-live needs that the schema could not express.
*
* 1. `finance.org_settings` holds the cutover date. It is a table rather than an
* env var because the date is per-organization, because it is an accounting
* fact that belongs in the audit trail alongside everything else, and because
* every other Finance policy in this service is already data.
*
* 2. `fixed_assets.opening_periods_charged` closes a real trap. Depreciation
* counts periods from `depreciation_entries` — deliberately, since dividing
* accumulated by the nominal monthly charge is ambiguous once rounded. But a
* MIGRATED asset has accumulated depreciation and NO entry rows, so its
* period count is zero, the cumulative target lands one period's worth below
* what is already accumulated, and the charge computes as negative and is
* skipped. Nothing is written, the count therefore never grows, and the asset
* silently never depreciates again. Verified against the calculator before
* writing this: 120,000 over 60 months with 48,000 already accumulated
* charges 0.00 with count 0, and the correct 2,000.00 with count 24.
*/
export class FinanceCutover3700000000007 implements MigrationInterface {
name = "FinanceCutover3700000000007";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "finance"."org_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz,
"deleted_at" timestamptz,
"organization_id" uuid NOT NULL,
"cutover_date" date,
"cutover_note" text,
"created_by" uuid,
"updated_by" uuid
)
`);
// One live settings row per organization. Partial, so a soft-deleted row
// never blocks a new one.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "ux_finance_org_settings_org"
ON "finance"."org_settings" ("organization_id")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
ALTER TABLE "finance"."fixed_assets"
ADD COLUMN IF NOT EXISTS "opening_periods_charged" integer NOT NULL DEFAULT 0
`);
// A migrated asset cannot have been depreciating for longer than it is meant
// to exist, and a negative count would corrupt the cumulative target.
await queryRunner.query(`
ALTER TABLE "finance"."fixed_assets"
DROP CONSTRAINT IF EXISTS "ck_fixed_assets_opening_periods"
`);
await queryRunner.query(`
ALTER TABLE "finance"."fixed_assets"
ADD CONSTRAINT "ck_fixed_assets_opening_periods"
CHECK ("opening_periods_charged" >= 0
AND "opening_periods_charged" <= "useful_life_months")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "finance"."fixed_assets" DROP CONSTRAINT IF EXISTS "ck_fixed_assets_opening_periods"`,
);
await queryRunner.query(
`ALTER TABLE "finance"."fixed_assets" DROP COLUMN IF EXISTS "opening_periods_charged"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."org_settings"`);
}
}

View File

@@ -0,0 +1,98 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { AccountsService } from "./accounts.service";
import {
AccountQueryDto,
CreateAccountDto,
UpdateAccountDto,
} from "./dto/account.dto";
import { ACCOUNT_TYPES } from "./entities/account.entity";
@ApiTags("chart-of-accounts")
@ApiBearerAuth()
@Controller("accounts")
// Class gate lists EVERY key the routes below use: Nest runs class AND method
// guards, so a key missing here denies before the route's own key is evaluated.
@FinanceStaff([FINANCE_PERMS.account.view, FINANCE_PERMS.account.manage])
export class AccountsController {
constructor(private readonly accounts: AccountsService) {}
@Get()
@ApiOperation({ summary: "Flat list of accounts" })
@FinanceStaff(FINANCE_PERMS.account.view)
list(@CurrentUser() user: TCurrentUser, @Query() query: AccountQueryDto) {
return this.accounts.list(actorFrom(user), query);
}
@Get("tree")
@ApiOperation({ summary: "The chart of accounts as a tree" })
@FinanceStaff(FINANCE_PERMS.account.view)
tree(@CurrentUser() user: TCurrentUser, @Query() query: AccountQueryDto) {
return this.accounts.tree(actorFrom(user), query);
}
@Get("types")
@ApiOperation({ summary: "Account types and their normal balances" })
@FinanceStaff(FINANCE_PERMS.account.view)
types() {
return ACCOUNT_TYPES;
}
@Get(":id")
@ApiOperation({ summary: "One account" })
@FinanceStaff(FINANCE_PERMS.account.view)
findOne(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.accounts.findOne(actorFrom(user), id);
}
@Post()
@ApiOperation({ summary: "Open a new account" })
@FinanceStaff(FINANCE_PERMS.account.manage)
create(@CurrentUser() user: TCurrentUser, @Body() dto: CreateAccountDto) {
return this.accounts.create(actorFrom(user), dto);
}
@Patch(":id")
@ApiOperation({
summary: "Amend an account (its TYPE can never change — retire it instead)",
})
@FinanceStaff(FINANCE_PERMS.account.manage)
update(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateAccountDto,
) {
return this.accounts.update(actorFrom(user), id, dto);
}
@Delete(":id")
@ApiOperation({
summary: "Delete an unused account (refused once anything is posted to it)",
})
@FinanceStaff(FINANCE_PERMS.account.manage)
remove(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.accounts.remove(actorFrom(user), id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Account } from "./entities/account.entity";
import { AccountsRepository } from "./accounts.repository";
import { AccountsService } from "./accounts.service";
import { AccountsController } from "./accounts.controller";
@Module({
imports: [TypeOrmModule.forFeature([Account])],
controllers: [AccountsController],
providers: [AccountsRepository, AccountsService],
// The journals module resolves and validates every posting account through
// this service, so the postability rules live in exactly one place.
exports: [AccountsRepository, AccountsService],
})
export class AccountsModule {}

View File

@@ -0,0 +1,111 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { Account, type AccountType } from "./entities/account.entity";
@Injectable()
export class AccountsRepository extends BaseRepository<Account> {
constructor(@InjectRepository(Account) repository: Repository<Account>) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<Account | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
/**
* Every account for an organization, ordered by code.
*
* The chart is small (hundreds of rows, not millions) and the UI renders it as
* a tree, so it is fetched whole and assembled in memory rather than paged —
* a paged tree shows partial branches, which is worse than useless.
*/
findAllForOrg(
organizationId: string | null,
filters: { search?: string; accountType?: AccountType; isActive?: boolean },
): Promise<Account[]> {
const qb = this.repository.createQueryBuilder("account").where("1 = 1");
// `null` = every organization (super admin).
if (organizationId) {
qb.andWhere("account.organization_id = :organizationId", {
organizationId,
});
}
if (filters.accountType) {
qb.andWhere("account.account_type = :accountType", {
accountType: filters.accountType,
});
}
if (filters.isActive !== undefined) {
qb.andWhere("account.is_active = :isActive", {
isActive: filters.isActive,
});
}
if (filters.search) {
qb.andWhere(
`(account.code ILIKE :search
OR account.name->>'en' ILIKE :search
OR account.name->>'am' ILIKE :search)`,
{ search: `%${filters.search}%` },
);
}
return qb.orderBy("account.code", "ASC").getMany();
}
/**
* How many POSTED journal lines reference this account.
*
* The guard against deleting an account that the ledger explains itself
* through. Draft lines do not count — a draft can be discarded.
*/
async countPostedLines(accountId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM finance.journal_lines line
JOIN finance.journal_entries entry
ON entry.id = line.journal_entry_id
WHERE line.account_id = $1
AND entry.status <> 'DRAFT'
AND entry.deleted_at IS NULL`,
[accountId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
async countChildren(accountId: string): Promise<number> {
return this.repository.count({
where: { parentAccountId: accountId },
});
}
/**
* Walks up the ancestry collecting ids.
*
* Used to reject a re-parent that would create a cycle: if the proposed parent
* is already a descendant, the move would detach the subtree from the root and
* make the chart unreadable (and any recursive report non-terminating).
* Bounded by a depth limit so a cycle that somehow already exists in the data
* cannot hang the request.
*/
async collectAncestorIds(accountId: string): Promise<string[]> {
const rows = await this.repository.manager.query<{ id: string }[]>(
`WITH RECURSIVE ancestry AS (
SELECT id, parent_account_id, 1 AS depth
FROM finance.accounts
WHERE id = $1
UNION ALL
SELECT parent.id, parent.parent_account_id, ancestry.depth + 1
FROM finance.accounts parent
JOIN ancestry ON parent.id = ancestry.parent_account_id
WHERE ancestry.depth < 32
)
SELECT id FROM ancestry WHERE id <> $1`,
[accountId],
);
return rows.map((row) => row.id);
}
}

View File

@@ -0,0 +1,292 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import { AccountsRepository } from "./accounts.repository";
import {
Account,
NORMAL_BALANCE_BY_TYPE,
type AccountType,
} from "./entities/account.entity";
import type {
AccountQueryDto,
CreateAccountDto,
UpdateAccountDto,
} from "./dto/account.dto";
/** An account plus its children — what the chart screen renders. */
export type AccountNode = Account & { children: AccountNode[] };
@Injectable()
export class AccountsService {
constructor(private readonly accounts: AccountsRepository) {}
async list(actor: ActorContext, query: AccountQueryDto): Promise<Account[]> {
return this.accounts.findAllForOrg(orgScope(actor), query);
}
/**
* The chart as a tree.
*
* Assembled in memory from one flat query rather than by recursive SQL: the
* chart is small, and one round trip that cannot half-load a branch is
* simpler to reason about than a recursive CTE per request.
*
* An account whose parent was filtered out is promoted to the root instead of
* being dropped, so a search can never hide a matching account behind a
* non-matching ancestor.
*/
async tree(actor: ActorContext, query: AccountQueryDto): Promise<AccountNode[]> {
const flat = await this.accounts.findAllForOrg(orgScope(actor), query);
const byId = new Map<string, AccountNode>(
flat.map((account) => [account.id, { ...account, children: [] }]),
);
const roots: AccountNode[] = [];
for (const node of byId.values()) {
const parent = node.parentAccountId
? byId.get(node.parentAccountId)
: undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const sortByCode = (nodes: AccountNode[]): AccountNode[] => {
nodes.sort((a, b) => a.code.localeCompare(b.code));
nodes.forEach((node) => sortByCode(node.children));
return nodes;
};
return sortByCode(roots);
}
async findOne(actor: ActorContext, id: string): Promise<Account> {
const account = await this.accounts.findById(id);
if (!account) throw new NotFoundException("Account not found");
this.assertSameOrg(actor, account);
return account;
}
async create(actor: ActorContext, dto: CreateAccountDto): Promise<Account> {
const organizationId = this.resolveOrganizationId(actor);
const existing = await this.accounts.findByCode(organizationId, dto.code);
if (existing) {
throw new ConflictException(
`Account code ${dto.code} is already in use in this organization`,
);
}
const parent = await this.resolveParent(actor, dto.parentAccountId ?? null);
if (parent && parent.accountType !== dto.accountType) {
// Silently allowing this would let an EXPENSE sit under ASSET, and the
// group's total would then mix two things that must never be added.
throw new BadRequestException(
`A ${dto.accountType} account cannot sit under ${parent.code}, which is ${parent.accountType}`,
);
}
return this.accounts.create({
organizationId,
code: dto.code,
name: dto.name,
accountType: dto.accountType,
// Derived, never taken from the caller — see NORMAL_BALANCE_BY_TYPE.
normalBalance: NORMAL_BALANCE_BY_TYPE[dto.accountType],
parentAccountId: parent?.id ?? null,
isGroup: dto.isGroup ?? false,
isContra: dto.isContra ?? false,
isActive: true,
isSystem: false,
description: dto.description ?? null,
createdBy: actor.employeeId,
});
}
async update(
actor: ActorContext,
id: string,
dto: UpdateAccountDto,
): Promise<Account> {
const account = await this.findOne(actor, id);
const patch: Partial<Account> = {};
if (dto.name !== undefined) patch.name = dto.name;
if (dto.description !== undefined) patch.description = dto.description;
if (dto.isContra !== undefined) patch.isContra = dto.isContra;
if (dto.isActive !== undefined && dto.isActive !== account.isActive) {
if (!dto.isActive && account.isSystem) {
throw new BadRequestException(
`${account.code} is a system account and cannot be deactivated — automated postings resolve it by code`,
);
}
patch.isActive = dto.isActive;
}
if (dto.parentAccountId !== undefined) {
patch.parentAccountId = await this.resolveNewParentId(
actor,
account,
dto.parentAccountId,
);
}
if (Object.keys(patch).length === 0) return account;
const updated = await this.accounts.update(id, patch);
if (!updated) throw new NotFoundException("Account not found");
return updated;
}
/**
* Soft delete, and only while the account is genuinely unused.
*
* An account that anything has been posted to is never removed: the entries
* referencing it must stay explicable for as long as they exist. Deactivating
* is the supported way to retire one.
*/
async remove(actor: ActorContext, id: string): Promise<{ deleted: true }> {
const account = await this.findOne(actor, id);
if (account.isSystem) {
throw new BadRequestException(
`${account.code} is a system account and cannot be deleted`,
);
}
const children = await this.accounts.countChildren(id);
if (children > 0) {
throw new BadRequestException(
`${account.code} has ${children} child account(s) — move or remove them first`,
);
}
const posted = await this.accounts.countPostedLines(id);
if (posted > 0) {
throw new BadRequestException(
`${account.code} carries ${posted} posted journal line(s) and cannot be deleted. Deactivate it instead.`,
);
}
await this.accounts.softDelete(id);
return { deleted: true };
}
/**
* Resolves an account for POSTING and refuses every reason it must not be
* used. Called by the journal service for each line, so the rules live in one
* place rather than being restated per caller.
*/
async assertPostable(
actor: ActorContext,
accountId: string,
): Promise<Account> {
const account = await this.accounts.findById(accountId);
if (!account) {
throw new BadRequestException(`Account ${accountId} does not exist`);
}
this.assertSameOrg(actor, account);
if (account.isGroup) {
throw new BadRequestException(
`${account.code} is a group account — post to one of its children, or its total would double-count`,
);
}
if (!account.isActive) {
throw new BadRequestException(
`${account.code} is inactive and cannot accept new postings`,
);
}
return account;
}
private async resolveParent(
actor: ActorContext,
parentAccountId: string | null,
): Promise<Account | null> {
if (!parentAccountId) return null;
const parent = await this.accounts.findById(parentAccountId);
if (!parent) throw new BadRequestException("Parent account does not exist");
this.assertSameOrg(actor, parent);
if (!parent.isGroup) {
throw new BadRequestException(
`${parent.code} is a postable account, not a group — only group accounts can have children`,
);
}
return parent;
}
private async resolveNewParentId(
actor: ActorContext,
account: Account,
parentAccountId: string | null,
): Promise<string | null> {
if (!parentAccountId) return null;
if (parentAccountId === account.id) {
throw new BadRequestException("An account cannot be its own parent");
}
const parent = await this.resolveParent(actor, parentAccountId);
if (!parent) return null;
if (parent.accountType !== account.accountType) {
throw new BadRequestException(
`${account.code} is ${account.accountType} and cannot move under ${parent.code}, which is ${parent.accountType}`,
);
}
// Moving an account beneath its own descendant would detach the subtree
// from the root: the branch would still exist but nothing would reach it,
// and a recursive report would never terminate.
const ancestors = await this.accounts.collectAncestorIds(parentAccountId);
if (ancestors.includes(account.id)) {
throw new BadRequestException(
`${parent.code} sits beneath ${account.code}; moving it there would create a cycle`,
);
}
return parent.id;
}
/**
* Which organization a create belongs to.
*
* Taken from the actor, never from the payload. A super admin with no
* employee record has no organization to create into — they read across all
* organizations but cannot conjure a chart for one they are not in.
*/
private resolveOrganizationId(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot create finance records",
);
}
return actor.organizationId;
}
private assertSameOrg(actor: ActorContext, account: Account): void {
if (actor.isSuperAdmin) return;
if (account.organizationId !== actor.organizationId) {
// Deliberately 404, not 403: confirming the row exists would leak another
// organization's chart structure.
throw new NotFoundException("Account not found");
}
}
/** Exposed for the journal service's error messages and seeding. */
findByCode(organizationId: string, code: string): Promise<Account | null> {
return this.accounts.findByCode(organizationId, code);
}
/** Account types, for the UI's pickers. */
static accountTypes(): readonly AccountType[] {
return Object.keys(NORMAL_BALANCE_BY_TYPE) as AccountType[];
}
}

View File

@@ -0,0 +1,135 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
ValidateNested,
} from "class-validator";
import { ACCOUNT_TYPES, type AccountType } from "../entities/account.entity";
export class LocalizedNameDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(160)
en!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(160)
am!: string;
}
export class CreateAccountDto {
/**
* Digits only. Account codes are ordered and grouped by prefix throughout the
* chart and every report, so letters or punctuation would sort unpredictably.
*/
@ApiProperty({ example: "1111" })
@IsString()
@Matches(/^[0-9]{2,12}$/, {
message: "code must be 212 digits",
})
code!: string;
@ApiProperty({ type: LocalizedNameDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name!: LocalizedNameDto;
@ApiProperty({ enum: ACCOUNT_TYPES })
@IsIn(ACCOUNT_TYPES)
accountType!: AccountType;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
parentAccountId?: string;
@ApiPropertyOptional({
description:
"Group accounts total their children and can never be posted to.",
})
@IsOptional()
@IsBoolean()
isGroup?: boolean;
@ApiPropertyOptional({
description:
"Contra accounts are subtracted from their type's total (accumulated depreciation).",
})
@IsOptional()
@IsBoolean()
isContra?: boolean;
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
description?: LocalizedNameDto;
}
/**
* `accountType` is absent on purpose: changing an account's type would silently
* re-sign every balance it has ever carried. Retire the account and open a new
* one instead.
*/
export class UpdateAccountDto {
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name?: LocalizedNameDto;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
parentAccountId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isContra?: boolean;
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
description?: LocalizedNameDto;
}
export class AccountQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ACCOUNT_TYPES })
@IsOptional()
@IsIn(ACCOUNT_TYPES)
accountType?: AccountType;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,121 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
/**
* The five roots of double-entry bookkeeping. Everything in the chart hangs off
* one of them, and the type decides which side of an entry increases the
* balance — see `NORMAL_BALANCE_BY_TYPE`.
*/
export const ACCOUNT_TYPES = [
"ASSET",
"LIABILITY",
"EQUITY",
"REVENUE",
"EXPENSE",
] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export const NORMAL_BALANCES = ["DEBIT", "CREDIT"] as const;
export type NormalBalance = (typeof NORMAL_BALANCES)[number];
/**
* Which side increases each type. Assets and expenses grow on the debit side;
* liabilities, equity and revenue grow on the credit side. This is arithmetic,
* not policy — it is derived on create rather than accepted from the caller, so
* a mistyped payload cannot invert an account's meaning.
*/
export const NORMAL_BALANCE_BY_TYPE: Record<AccountType, NormalBalance> = {
ASSET: "DEBIT",
EXPENSE: "DEBIT",
LIABILITY: "CREDIT",
EQUITY: "CREDIT",
REVENUE: "CREDIT",
};
/**
* One line of the chart of accounts.
*
* The chart is a TREE, and the tree has two kinds of node:
* - **group** accounts (`is_group = true`) exist to total their children.
* Nothing may ever be posted to one, or the total would double-count.
* - **postable** accounts (leaves) are what journal lines reference.
*
* A contra account (accumulated depreciation, allowance for doubtful debts)
* keeps its parent's TYPE but carries `is_contra`, so a report can subtract it
* instead of adding it without pattern-matching on the account name.
*/
@Entity({ schema: "finance", name: "accounts" })
@Unique("uq_accounts_org_code", ["organizationId", "code"])
@Index("idx_accounts_organization_id", ["organizationId"])
@Index("idx_accounts_parent_id", ["parentAccountId"])
@Index("idx_accounts_type", ["accountType"])
@Check(
"ck_accounts_type",
`"account_type" IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')`,
)
@Check("ck_accounts_normal_balance", `"normal_balance" IN ('DEBIT','CREDIT')`)
// An account cannot be its own parent. Deeper cycles are impossible to express
// as a CHECK and are rejected in the service by walking the ancestry.
@Check("ck_accounts_not_self_parent", `"parent_account_id" <> "id"`)
export class Account extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
/** Soft reference to `iam.organizations` — never an FK (platform stance). */
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
/** Numeric account code, e.g. "1111". Unique per organization. */
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "varchar", length: 16, name: "account_type" })
accountType!: AccountType;
/** Derived from `accountType` on write; stored so reports need no lookup. */
@Column({ type: "varchar", length: 8, name: "normal_balance" })
normalBalance!: NormalBalance;
@Column({ type: "uuid", name: "parent_account_id", nullable: true })
parentAccountId?: string | null;
/** Group accounts total their children and can never be posted to. */
@Column({ type: "boolean", name: "is_group", default: false })
isGroup!: boolean;
/** Subtracted from, rather than added to, its type's total in reports. */
@Column({ type: "boolean", name: "is_contra", default: false })
isContra!: boolean;
/**
* Inactive accounts stay readable and keep their history, but reject new
* postings. This is how an account is retired: never by deletion, because the
* entries that reference it must remain explicable forever.
*/
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
/**
* Marks the accounts the system itself posts to (AR control, cash clearing,
* payroll payables). They cannot be deleted or have their type changed, since
* automated postings resolve them by code.
*/
@Column({ type: "boolean", name: "is_system", default: false })
isSystem!: boolean;
@Column({ type: "jsonb", name: "description", nullable: true })
description?: { am: string; en: string } | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,126 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { AssetsService } from "./assets.service";
import {
AssetQueryDto,
CreateAssetCategoryDto,
CreateAssetDto,
DisposeAssetDto,
RunDepreciationDto,
} from "./dto/assets.dto";
@ApiTags("fixed-assets")
@ApiBearerAuth()
@Controller("assets")
@FinanceStaff([
FINANCE_PERMS.asset.view,
FINANCE_PERMS.asset.manage,
FINANCE_PERMS.asset.depreciate,
FINANCE_PERMS.asset.dispose,
])
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get("categories")
@ApiOperation({ summary: "Asset categories and their depreciation defaults" })
@FinanceStaff(FINANCE_PERMS.asset.view)
listCategories(@CurrentUser() user: TCurrentUser) {
return this.assets.listCategories(actorFrom(user));
}
@Post("categories")
@ApiOperation({ summary: "Define an asset category" })
@FinanceStaff(FINANCE_PERMS.asset.manage)
createCategory(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateAssetCategoryDto,
) {
return this.assets.createCategory(actorFrom(user), dto);
}
@Get()
@ApiOperation({ summary: "The asset register" })
@FinanceStaff(FINANCE_PERMS.asset.view)
list(@CurrentUser() user: TCurrentUser, @Query() query: AssetQueryDto) {
return this.assets.listAssets(actorFrom(user), query);
}
@Get("depreciation/runs")
@ApiOperation({ summary: "Depreciation runs" })
@FinanceStaff(FINANCE_PERMS.asset.view)
listRuns(@CurrentUser() user: TCurrentUser) {
return this.assets.listRuns(actorFrom(user));
}
@Get("depreciation/runs/:id/entries")
@ApiOperation({ summary: "What each asset was charged in a run" })
@FinanceStaff(FINANCE_PERMS.asset.view)
runEntries(@Param("id", ParseUUIDPipe) id: string) {
return this.assets.listRunEntries(id);
}
@Get("disposals")
@ApiOperation({ summary: "Assets taken off the books, with gain or loss" })
@FinanceStaff(FINANCE_PERMS.asset.view)
listDisposals(@CurrentUser() user: TCurrentUser) {
return this.assets.listDisposals(actorFrom(user));
}
@Get(":id/schedule")
@ApiOperation({ summary: "An asset's month-by-month depreciation schedule" })
@FinanceStaff(FINANCE_PERMS.asset.view)
schedule(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.assets.schedule(actorFrom(user), id);
}
@Post()
@ApiOperation({
summary:
"Add an asset. Pass fundingAccountId to post the acquisition; omit it when the asset came through an approved supplier bill.",
})
@FinanceStaff(FINANCE_PERMS.asset.manage)
create(@CurrentUser() user: TCurrentUser, @Body() dto: CreateAssetDto) {
return this.assets.createAsset(actorFrom(user), dto);
}
@Post("depreciation/run")
@ApiOperation({
summary:
"Charge one period's depreciation across the register (once per period)",
})
@FinanceStaff(FINANCE_PERMS.asset.depreciate)
runDepreciation(
@CurrentUser() user: TCurrentUser,
@Body() dto: RunDepreciationDto,
) {
return this.assets.runDepreciation(actorFrom(user), dto);
}
@Post(":id/dispose")
@ApiOperation({ summary: "Take an asset off the books, computing gain or loss" })
@FinanceStaff(FINANCE_PERMS.asset.dispose)
dispose(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DisposeAssetDto,
) {
return this.assets.dispose(actorFrom(user), id, dto);
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
AssetCategory,
AssetDisposal,
DepreciationEntry,
DepreciationRun,
FixedAsset,
} from "./entities/fixed-asset.entity";
import {
AssetCategoriesRepository,
AssetDisposalsRepository,
DepreciationRunsRepository,
FixedAssetsRepository,
} from "./assets.repository";
import { AssetsService } from "./assets.service";
import { AssetsController } from "./assets.controller";
import { AccountsModule } from "../accounts/accounts.module";
import { PeriodsModule } from "../periods/periods.module";
import { JournalsModule } from "../journals/journals.module";
@Module({
imports: [
TypeOrmModule.forFeature([
AssetCategory,
FixedAsset,
DepreciationRun,
DepreciationEntry,
AssetDisposal,
]),
AccountsModule,
PeriodsModule,
JournalsModule,
],
controllers: [AssetsController],
providers: [
AssetCategoriesRepository,
FixedAssetsRepository,
DepreciationRunsRepository,
AssetDisposalsRepository,
AssetsService,
],
exports: [AssetsService],
})
export class AssetsModule {}

View File

@@ -0,0 +1,275 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import {
AssetCategory,
AssetDisposal,
DepreciationEntry,
DepreciationRun,
FixedAsset,
} from "./entities/fixed-asset.entity";
@Injectable()
export class AssetCategoriesRepository extends BaseRepository<AssetCategory> {
constructor(
@InjectRepository(AssetCategory) repository: Repository<AssetCategory>,
) {
super(repository);
}
findByCode(organizationId: string, code: string) {
return this.repository.findOne({ where: { organizationId, code } });
}
findAllForOrg(organizationId: string): Promise<AssetCategory[]> {
return this.repository.find({
where: { organizationId },
order: { code: "ASC" },
});
}
async countAssets(assetCategoryId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count FROM finance.fixed_assets
WHERE asset_category_id = $1 AND deleted_at IS NULL`,
[assetCategoryId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
}
@Injectable()
export class FixedAssetsRepository extends BaseRepository<FixedAsset> {
constructor(@InjectRepository(FixedAsset) repository: Repository<FixedAsset>) {
super(repository);
}
findByCode(organizationId: string, assetCode: string) {
return this.repository.findOne({ where: { organizationId, assetCode } });
}
/** The register, with category and cost center attached for display. */
findRegister(
organizationId: string,
filters: { status?: string; search?: string } = {},
): Promise<Record<string, unknown>[]> {
const clauses: string[] = ["a.organization_id = $1", "a.deleted_at IS NULL"];
const params: unknown[] = [organizationId];
if (filters.status) {
params.push(filters.status);
clauses.push(`a.status = $${params.length}`);
}
if (filters.search) {
params.push(`%${filters.search}%`);
clauses.push(
`(a.asset_code ILIKE $${params.length} OR a.name ILIKE $${params.length} OR a.serial_number ILIKE $${params.length})`,
);
}
return this.repository.manager.query(
`SELECT a.id,
a.asset_code AS "assetCode",
a.name,
a.serial_number AS "serialNumber",
a.acquisition_date::text AS "acquisitionDate",
a.in_service_date::text AS "inServiceDate",
a.acquisition_cost AS "acquisitionCost",
a.salvage_value AS "salvageValue",
a.useful_life_months AS "usefulLifeMonths",
a.accumulated_depreciation AS "accumulatedDepreciation",
ROUND(a.acquisition_cost - a.accumulated_depreciation, 2) AS "netBookValue",
a.status,
a.depreciation_method AS "depreciationMethod",
c.id AS "categoryId",
c.code AS "categoryCode",
c.name AS "categoryName",
cc.code AS "costCenterCode",
cc.name AS "costCenterName"
FROM finance.fixed_assets a
JOIN finance.asset_categories c ON c.id = a.asset_category_id
LEFT JOIN finance.cost_centers cc ON cc.id = a.cost_center_id
WHERE ${clauses.join(" AND ")}
ORDER BY a.asset_code ASC`,
params,
);
}
/**
* Assets eligible for depreciation in a period, with their category's posting
* accounts.
*
* DISPOSED and WRITTEN_OFF assets are excluded here rather than filtered
* later, and assets already at their cap are dropped too — so a run over a
* mature register does not load thousands of rows only to charge them zero.
*/
findDepreciable(
organizationId: string,
periodEnd: string,
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT a.id,
a.asset_code AS "assetCode",
a.name,
a.acquisition_cost AS "acquisitionCost",
a.salvage_value AS "salvageValue",
a.useful_life_months AS "usefulLifeMonths",
a.accumulated_depreciation AS "accumulatedDepreciation",
a.in_service_date::text AS "inServiceDate",
a.depreciation_method AS "depreciationMethod",
a.status,
a.cost_center_id AS "costCenterId",
c.expense_account_id AS "expenseAccountId",
c.accumulated_account_id AS "accumulatedAccountId",
-- Counted, not inferred from the accumulated total: the moment a
-- charge is rounded, dividing accumulated by the nominal figure
-- becomes ambiguous.
--
-- opening_periods_charged is added because a MIGRATED asset
-- arrives with accumulated depreciation and no entry rows at all.
-- Counting rows alone would put it at zero, the cumulative target
-- would land below what is already accumulated, the charge would
-- compute as negative and be skipped — and since a skip writes no
-- row, the count could never grow. The asset would silently never
-- depreciate again.
(a.opening_periods_charged
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
WHERE d.fixed_asset_id = a.id)) AS "periodsCharged"
FROM finance.fixed_assets a
JOIN finance.asset_categories c ON c.id = a.asset_category_id
WHERE a.organization_id = $1
AND a.deleted_at IS NULL
AND a.status IN ('ACTIVE')
AND a.in_service_date <= $2::date
AND a.accumulated_depreciation < a.acquisition_cost - a.salvage_value
ORDER BY a.asset_code ASC`,
[organizationId, periodEnd],
);
}
/**
* What the LEDGER says has accumulated, per accumulated-depreciation account.
*
* The register's running totals are a cache; this is the authority. The run
* compares them and refuses to post if they disagree, because a silent drift
* between the two is exactly the error that surfaces a year later as an
* unexplainable balance sheet.
*
* DISPOSED and WRITTEN_OFF assets are excluded from the register side: a
* disposal debits their accumulated depreciation back out of the ledger while
* the register row keeps its historical total. Counting them would make the
* two sides disagree by the whole of every disposal ever made.
*/
ledgerAccumulated(organizationId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT c.accumulated_account_id AS "accountId",
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2) AS "ledgerAccumulated",
ROUND(COALESCE((
SELECT SUM(a2.accumulated_depreciation)
FROM finance.fixed_assets a2
WHERE a2.asset_category_id IN (
SELECT c2.id FROM finance.asset_categories c2
WHERE c2.accumulated_account_id = c.accumulated_account_id
AND c2.organization_id = $1)
AND a2.deleted_at IS NULL
AND a2.status NOT IN ('DISPOSED','WRITTEN_OFF')
), 0), 2) AS "registerAccumulated"
FROM finance.asset_categories c
LEFT JOIN finance.journal_lines l ON l.account_id = c.accumulated_account_id
LEFT JOIN finance.journal_entries e
ON e.id = l.journal_entry_id
AND e.status <> 'DRAFT'
AND e.deleted_at IS NULL
AND e.organization_id = $1
WHERE c.organization_id = $1 AND c.deleted_at IS NULL
GROUP BY c.accumulated_account_id`,
[organizationId],
);
}
}
@Injectable()
export class DepreciationRunsRepository extends BaseRepository<DepreciationRun> {
constructor(
@InjectRepository(DepreciationRun) repository: Repository<DepreciationRun>,
) {
super(repository);
}
findForPeriod(organizationId: string, fiscalPeriodId: string) {
return this.repository.findOne({
where: { organizationId, fiscalPeriodId },
});
}
listWithPeriods(organizationId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT r.id,
r.run_date::text AS "runDate",
r.asset_count AS "assetCount",
r.total_amount AS "totalAmount",
r.journal_entry_id AS "journalEntryId",
e.entry_number AS "entryNumber",
p.name AS "periodName",
p.period_number AS "periodNumber"
FROM finance.depreciation_runs r
JOIN finance.fiscal_periods p ON p.id = r.fiscal_period_id
LEFT JOIN finance.journal_entries e ON e.id = r.journal_entry_id
WHERE r.organization_id = $1
ORDER BY r.run_date DESC`,
[organizationId],
);
}
listEntries(runId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT d.id,
d.amount,
d.accumulated_after AS "accumulatedAfter",
a.asset_code AS "assetCode",
a.name AS "assetName"
FROM finance.depreciation_entries d
JOIN finance.fixed_assets a ON a.id = d.fixed_asset_id
WHERE d.depreciation_run_id = $1
ORDER BY a.asset_code ASC`,
[runId],
);
}
}
@Injectable()
export class AssetDisposalsRepository extends BaseRepository<AssetDisposal> {
constructor(
@InjectRepository(AssetDisposal) repository: Repository<AssetDisposal>,
) {
super(repository);
}
findByAsset(fixedAssetId: string) {
return this.repository.findOne({ where: { fixedAssetId } });
}
listWithAssets(organizationId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT d.id,
d.disposal_date::text AS "disposalDate",
d.disposal_type AS "disposalType",
d.proceeds,
d.net_book_value AS "netBookValue",
d.gain_loss AS "gainLoss",
d.reference,
d.journal_entry_id AS "journalEntryId",
a.asset_code AS "assetCode",
a.name AS "assetName"
FROM finance.asset_disposals d
JOIN finance.fixed_assets a ON a.id = d.fixed_asset_id
WHERE d.organization_id = $1
ORDER BY d.disposal_date DESC`,
[organizationId],
);
}
}
export { DepreciationEntry };

View File

@@ -0,0 +1,667 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { roundMoney } from "../../common/money";
import { AccountsService } from "../accounts/accounts.service";
import { PeriodsService } from "../periods/periods.service";
import { JournalsService } from "../journals/journals.service";
import {
AssetCategoriesRepository,
AssetDisposalsRepository,
DepreciationRunsRepository,
FixedAssetsRepository,
} from "./assets.repository";
import {
AssetCategory,
AssetDisposal,
DepreciationEntry,
DepreciationRun,
FixedAsset,
} from "./entities/fixed-asset.entity";
import {
depreciationFor,
depreciationSchedule,
disposalResult,
netBookValue,
type DepreciableAsset,
} from "./depreciation.calculator";
import type {
AssetQueryDto,
CreateAssetCategoryDto,
CreateAssetDto,
DisposeAssetDto,
RunDepreciationDto,
} from "./dto/assets.dto";
/** Gain and loss on disposal land here. */
const GAIN_ACCOUNT_CODE = "4910";
const LOSS_ACCOUNT_CODE = "5900";
const num = (v: unknown): number => Number(v ?? 0);
@Injectable()
export class AssetsService {
constructor(
private readonly categories: AssetCategoriesRepository,
private readonly assets: FixedAssetsRepository,
private readonly runs: DepreciationRunsRepository,
private readonly disposals: AssetDisposalsRepository,
private readonly accounts: AccountsService,
private readonly periods: PeriodsService,
private readonly journals: JournalsService,
private readonly dataSource: DataSource,
) {}
// ── categories ───────────────────────────────────────────────────────────
listCategories(actor: ActorContext): Promise<AssetCategory[]> {
return this.categories.findAllForOrg(this.requireOrganization(actor));
}
async createCategory(
actor: ActorContext,
dto: CreateAssetCategoryDto,
): Promise<AssetCategory> {
const organizationId = this.requireOrganization(actor);
const existing = await this.categories.findByCode(organizationId, dto.code);
if (existing) {
throw new ConflictException(`Asset category ${dto.code} already exists`);
}
// Each of the three accounts must be the right TYPE, or the postings would
// balance while describing something else entirely.
const asset = await this.accounts.assertPostable(actor, dto.assetAccountId);
this.assertType(asset, "ASSET", "the asset cost account");
const accumulated = await this.accounts.assertPostable(
actor,
dto.accumulatedAccountId,
);
this.assertType(accumulated, "ASSET", "the accumulated depreciation account");
if (!accumulated.isContra) {
throw new BadRequestException(
`${accumulated.code} is not marked as a contra account. Accumulated depreciation must be contra, or the balance sheet would ADD it to assets instead of subtracting it.`,
);
}
const expense = await this.accounts.assertPostable(
actor,
dto.expenseAccountId,
);
this.assertType(expense, "EXPENSE", "the depreciation expense account");
return this.categories.create({
organizationId,
code: dto.code,
name: dto.name,
assetAccountId: asset.id,
accumulatedAccountId: accumulated.id,
expenseAccountId: expense.id,
defaultLifeMonths: dto.defaultLifeMonths,
defaultSalvageRate: String(dto.defaultSalvageRate ?? 0),
isActive: true,
createdBy: actor.employeeId,
});
}
// ── register ─────────────────────────────────────────────────────────────
listAssets(actor: ActorContext, query: AssetQueryDto) {
return this.assets.findRegister(this.requireOrganization(actor), query);
}
async findAsset(actor: ActorContext, id: string): Promise<FixedAsset> {
const asset = await this.assets.findById(id);
if (!asset) throw new NotFoundException("Asset not found");
if (!actor.isSuperAdmin && asset.organizationId !== actor.organizationId) {
throw new NotFoundException("Asset not found");
}
return asset;
}
/** The full month-by-month schedule for one asset. */
async schedule(actor: ActorContext, id: string) {
const asset = await this.findAsset(actor, id);
// Opening count + charged count — see the note in assets.repository.ts.
// A migrated asset has no entry rows, so rows alone understate it.
const [counted] = await this.dataSource.query<{ n: string }[]>(
`SELECT (a.opening_periods_charged
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
WHERE d.fixed_asset_id = a.id))::text AS n
FROM finance.fixed_assets a
WHERE a.id = $1`,
[id],
);
const periodsCharged = parseInt(counted?.n ?? "0", 10);
return {
periodsCharged,
assetCode: asset.assetCode,
acquisitionCost: Number(asset.acquisitionCost),
salvageValue: Number(asset.salvageValue),
usefulLifeMonths: asset.usefulLifeMonths,
accumulatedDepreciation: Number(asset.accumulatedDepreciation),
netBookValue: netBookValue(this.toDepreciable(asset)),
schedule: depreciationSchedule(this.toDepreciable(asset)),
};
}
/**
* Adds an asset to the register, optionally posting its acquisition.
*
* `fundingAccountId` is omitted when the asset already reached the books
* through an approved supplier bill — posting again would double the cost.
*/
async createAsset(actor: ActorContext, dto: CreateAssetDto) {
const organizationId = this.requireOrganization(actor);
const existing = await this.assets.findByCode(organizationId, dto.assetCode);
if (existing) {
throw new ConflictException(`Asset code ${dto.assetCode} already exists`);
}
const category = await this.categories.findById(dto.assetCategoryId);
if (!category || category.organizationId !== organizationId) {
throw new BadRequestException("Asset category not found");
}
const cost = roundMoney(dto.acquisitionCost);
const salvage = roundMoney(
dto.salvageValue ?? cost * Number(category.defaultSalvageRate),
);
if (salvage >= cost) {
throw new BadRequestException(
`Salvage value ${salvage.toFixed(2)} must be below the cost ${cost.toFixed(2)} — otherwise there is nothing to depreciate`,
);
}
const inServiceDate = dto.inServiceDate ?? dto.acquisitionDate;
if (inServiceDate < dto.acquisitionDate) {
throw new BadRequestException(
"An asset cannot enter service before it was acquired",
);
}
// ── Cutover: an asset migrated mid-life ────────────────────────────────
const openingAccumulated = roundMoney(
dto.openingAccumulatedDepreciation ?? 0,
);
const openingPeriods = dto.openingPeriodsCharged ?? 0;
const usefulLifeMonths = dto.usefulLifeMonths ?? category.defaultLifeMonths;
if ((openingAccumulated > 0 || openingPeriods > 0) && dto.fundingAccountId) {
throw new BadRequestException(
"An opening asset already has its cost and accumulated depreciation in the opening balance entry. Posting an acquisition as well would count it twice — omit fundingAccountId.",
);
}
// The two must travel together. Accumulated depreciation with no period
// count leaves the asset unable to depreciate ever again; a period count
// with nothing accumulated would immediately over-charge to catch up.
if (openingAccumulated > 0 && openingPeriods === 0) {
throw new BadRequestException(
"openingAccumulatedDepreciation needs openingPeriodsCharged — without a period count this asset would never depreciate again.",
);
}
if (openingPeriods > 0 && openingAccumulated === 0) {
throw new BadRequestException(
"openingPeriodsCharged needs openingAccumulatedDepreciation — otherwise the next run charges every one of those periods at once.",
);
}
if (openingPeriods > usefulLifeMonths) {
throw new BadRequestException(
`openingPeriodsCharged ${openingPeriods} exceeds the ${usefulLifeMonths}-month life`,
);
}
if (openingAccumulated > roundMoney(cost - salvage)) {
throw new BadRequestException(
`openingAccumulatedDepreciation ${openingAccumulated.toFixed(2)} exceeds the depreciable base ${roundMoney(cost - salvage).toFixed(2)}`,
);
}
const asset = await this.assets.create({
organizationId,
assetCategoryId: category.id,
assetCode: dto.assetCode,
name: dto.name,
description: dto.description ?? null,
serialNumber: dto.serialNumber ?? null,
costCenterId: dto.costCenterId ?? null,
acquisitionDate: dto.acquisitionDate,
inServiceDate,
acquisitionCost: cost,
salvageValue: salvage,
usefulLifeMonths,
depreciationMethod: "STRAIGHT_LINE",
accumulatedDepreciation: openingAccumulated,
openingPeriodsCharged: openingPeriods,
status: "ACTIVE",
createdBy: actor.employeeId,
});
if (dto.fundingAccountId) {
const funding = await this.accounts.assertPostable(
actor,
dto.fundingAccountId,
);
await this.journals.createPosted(actor, {
entryDate: dto.acquisitionDate,
journalType: "GENERAL",
memo: `Acquisition of ${dto.assetCode}${dto.name}`,
reference: dto.assetCode,
sourceModule: "asset-acquisition",
sourceId: asset.id,
lines: [
{
accountId: category.assetAccountId,
debit: cost,
description: dto.name,
costCenterId: dto.costCenterId,
},
{
accountId: funding.id,
credit: cost,
description: `Funding for ${dto.assetCode}`,
},
],
});
}
return this.findAsset(actor, asset.id);
}
// ── depreciation ─────────────────────────────────────────────────────────
listRuns(actor: ActorContext) {
return this.runs.listWithPeriods(this.requireOrganization(actor));
}
listRunEntries(runId: string) {
return this.runs.listEntries(runId);
}
/**
* Charges one period's depreciation across the register and posts it as ONE
* journal entry.
*
* Dr 5400 Depreciation expense (per category)
* Cr 1290 Accumulated depreciation
*
* Summarized by category rather than per asset: a register of a thousand
* items would otherwise produce a thousand journal lines every month, and the
* per-asset detail is already kept in `depreciation_entries`.
*
* Before charging anything it reconciles the register's running totals
* against the ledger. A drift there means an earlier run was posted and then
* reversed (or vice versa), and charging on top of it would bury the
* discrepancy under another month.
*/
async runDepreciation(actor: ActorContext, dto: RunDepreciationDto) {
const organizationId = this.requireOrganization(actor);
const period = await this.periods.findPeriod(actor, dto.fiscalPeriodId);
const already = await this.runs.findForPeriod(organizationId, period.id);
if (already) {
throw new ConflictException(
`Depreciation for ${period.name?.en ?? "this period"} has already been run. Running it twice would charge the same wear twice.`,
);
}
if (period.status !== "OPEN") {
throw new BadRequestException(
`${period.name?.en ?? "That period"} is ${period.status} — depreciation must be charged to an open period`,
);
}
await this.assertRegisterReconciles(organizationId);
const candidates = await this.assets.findDepreciable(
organizationId,
period.endDate,
);
const charges: {
assetId: string;
assetCode: string;
amount: number;
accumulatedAfter: number;
expenseAccountId: string;
accumulatedAccountId: string;
costCenterId: string | null;
fullyDepreciated: boolean;
}[] = [];
for (const row of candidates) {
const charge = depreciationFor(
{
acquisitionCost: num(row.acquisitionCost),
salvageValue: num(row.salvageValue),
usefulLifeMonths: Number(row.usefulLifeMonths),
accumulatedDepreciation: num(row.accumulatedDepreciation),
inServiceDate: String(row.inServiceDate),
depreciationMethod: String(row.depreciationMethod),
status: String(row.status),
periodsCharged: Number(row.periodsCharged ?? 0),
},
period.endDate,
);
if (charge.amount <= 0) continue;
charges.push({
assetId: String(row.id),
assetCode: String(row.assetCode),
amount: charge.amount,
accumulatedAfter: charge.accumulatedAfter,
expenseAccountId: String(row.expenseAccountId),
accumulatedAccountId: String(row.accumulatedAccountId),
costCenterId: row.costCenterId ? String(row.costCenterId) : null,
fullyDepreciated: charge.fullyDepreciated,
});
}
if (charges.length === 0) {
throw new BadRequestException(
"Nothing to depreciate in this period — every asset is either not yet in service, disposed of, or fully depreciated",
);
}
// Summarize by (expense account, cost center) on the debit side and by
// accumulated account on the credit side.
const debits = new Map<string, { accountId: string; costCenterId: string | null; amount: number }>();
const credits = new Map<string, number>();
for (const c of charges) {
const dKey = `${c.expenseAccountId}|${c.costCenterId ?? "-"}`;
const d = debits.get(dKey);
if (d) d.amount = roundMoney(d.amount + c.amount);
else debits.set(dKey, { accountId: c.expenseAccountId, costCenterId: c.costCenterId, amount: c.amount });
credits.set(
c.accumulatedAccountId,
roundMoney((credits.get(c.accumulatedAccountId) ?? 0) + c.amount),
);
}
const total = roundMoney(charges.reduce((s, c) => s + c.amount, 0));
const result = await this.dataSource.transaction(async (manager) => {
// The manager is passed through so the journal entry is written on THIS
// transaction. Without it the entry committed on its own connection, and
// a failure in the writes below left a posted depreciation entry with no
// run and no register update to explain it.
const entry = await this.journals.createPosted(
actor,
{
entryDate: period.endDate,
journalType: "DEPRECIATION",
memo: `Depreciation for ${period.name?.en ?? period.endDate} (${charges.length} assets)`,
reference: period.name?.en,
sourceModule: "depreciation",
sourceId: period.id,
lines: [
...[...debits.values()].map((d) => ({
accountId: d.accountId,
debit: d.amount,
description: "Depreciation",
costCenterId: d.costCenterId ?? undefined,
})),
...[...credits.entries()].map(([accountId, amount]) => ({
accountId,
credit: amount,
description: "Accumulated depreciation",
})),
],
},
manager,
);
const runRepo = manager.getRepository(DepreciationRun);
const run = await runRepo.save(
runRepo.create({
organizationId,
fiscalPeriodId: period.id,
runDate: period.endDate,
assetCount: charges.length,
totalAmount: total,
journalEntryId: entry.id,
postedBy: actor.employeeId,
}),
);
const entryRepo = manager.getRepository(DepreciationEntry);
await entryRepo.save(
charges.map((c) =>
entryRepo.create({
depreciationRunId: run.id,
fixedAssetId: c.assetId,
amount: c.amount,
accumulatedAfter: c.accumulatedAfter,
}),
),
);
// Advance each asset's running total, and retire the ones that have
// reached their cap so they are not reconsidered next month.
const assetRepo = manager.getRepository(FixedAsset);
for (const c of charges) {
await assetRepo.update(c.assetId, {
accumulatedDepreciation: c.accumulatedAfter,
...(c.fullyDepreciated ? { status: "FULLY_DEPRECIATED" as const } : {}),
});
}
return { run, entryNumber: entry.entryNumber, journalEntryId: entry.id };
});
return {
runId: result.run.id,
entryNumber: result.entryNumber,
journalEntryId: result.journalEntryId,
assetCount: charges.length,
total,
fullyDepreciated: charges.filter((c) => c.fullyDepreciated).length,
};
}
// ── disposal ─────────────────────────────────────────────────────────────
listDisposals(actor: ActorContext) {
return this.disposals.listWithAssets(this.requireOrganization(actor));
}
/**
* Takes an asset off the books.
*
* Dr cash/receivable proceeds, if any
* Dr 1290 Accumulated depn everything charged so far, reversed out
* Dr 5900 Loss on disposal when the books valued it above what it fetched
* Cr 121x Asset cost the original cost leaves
* Cr 4910 Gain on disposal when it fetched more than book value
*
* The two sides balance because accumulated + proceeds + loss always equals
* cost + gain — that is what makes gain/loss the plug, and why it is computed
* rather than entered.
*/
async dispose(actor: ActorContext, id: string, dto: DisposeAssetDto) {
const asset = await this.findAsset(actor, id);
if (asset.status === "DISPOSED" || asset.status === "WRITTEN_OFF") {
throw new ConflictException(
`${asset.assetCode} is already ${asset.status}`,
);
}
if (dto.disposalDate < asset.inServiceDate) {
throw new BadRequestException(
"An asset cannot be disposed of before it entered service",
);
}
const proceeds = roundMoney(dto.proceeds ?? 0);
if (proceeds > 0 && !dto.proceedsAccountId) {
throw new BadRequestException(
"Name the account the proceeds landed in — money received has to go somewhere",
);
}
const { netBookValue: nbv, gainLoss } = disposalResult(
this.toDepreciable(asset),
proceeds,
);
const category = await this.categories.findById(asset.assetCategoryId);
if (!category) throw new BadRequestException("Asset category not found");
const accumulated = roundMoney(Number(asset.accumulatedDepreciation));
const cost = roundMoney(Number(asset.acquisitionCost));
const lines: {
accountId: string;
debit?: number;
credit?: number;
description: string;
}[] = [];
if (proceeds > 0) {
const proceedsAccount = await this.accounts.assertPostable(
actor,
dto.proceedsAccountId as string,
);
lines.push({
accountId: proceedsAccount.id,
debit: proceeds,
description: `Proceeds from ${asset.assetCode}`,
});
}
if (accumulated > 0) {
lines.push({
accountId: category.accumulatedAccountId,
debit: accumulated,
description: `Accumulated depreciation removed for ${asset.assetCode}`,
});
}
if (gainLoss < 0) {
const loss = await this.requireAccount(actor, LOSS_ACCOUNT_CODE);
lines.push({
accountId: loss.id,
debit: roundMoney(Math.abs(gainLoss)),
description: `Loss on disposal of ${asset.assetCode}`,
});
}
lines.push({
accountId: category.assetAccountId,
credit: cost,
description: `Cost of ${asset.assetCode} removed`,
});
if (gainLoss > 0) {
const gain = await this.requireAccount(actor, GAIN_ACCOUNT_CODE);
lines.push({
accountId: gain.id,
credit: roundMoney(gainLoss),
description: `Gain on disposal of ${asset.assetCode}`,
});
}
const entry = await this.journals.createPosted(actor, {
entryDate: dto.disposalDate,
journalType: "GENERAL",
memo: `${dto.disposalType} of ${asset.assetCode}${asset.name}`,
reference: dto.reference ?? asset.assetCode,
sourceModule: "asset-disposal",
sourceId: asset.id,
lines,
});
const disposal = await this.disposals.create({
organizationId: asset.organizationId,
fixedAssetId: asset.id,
disposalDate: dto.disposalDate,
disposalType: dto.disposalType,
proceeds,
netBookValue: nbv,
gainLoss,
proceedsAccountId: dto.proceedsAccountId ?? null,
reference: dto.reference ?? null,
notes: dto.notes ?? null,
journalEntryId: entry.id,
recordedBy: actor.employeeId,
});
await this.assets.update(asset.id, {
status: dto.disposalType === "WRITE_OFF" ? "WRITTEN_OFF" : "DISPOSED",
});
return { disposal, entryNumber: entry.entryNumber, netBookValue: nbv, gainLoss };
}
// ── internals ────────────────────────────────────────────────────────────
/**
* The register's running totals must agree with the ledger before another
* month is charged on top of them.
*/
private async assertRegisterReconciles(organizationId: string): Promise<void> {
const rows = await this.assets.ledgerAccumulated(organizationId);
for (const row of rows) {
const ledger = num(row.ledgerAccumulated);
const register = num(row.registerAccumulated);
if (Math.abs(ledger - register) >= 0.005) {
throw new BadRequestException(
`The asset register and the ledger disagree: the register shows ${register.toFixed(2)} of accumulated depreciation, the ledger ${ledger.toFixed(2)}. Reconcile them before charging another period — most likely a depreciation entry was reversed without the register being adjusted.`,
);
}
}
}
private toDepreciable(
asset: FixedAsset,
periodsCharged = 0,
): DepreciableAsset {
return {
acquisitionCost: Number(asset.acquisitionCost),
salvageValue: Number(asset.salvageValue),
usefulLifeMonths: asset.usefulLifeMonths,
accumulatedDepreciation: Number(asset.accumulatedDepreciation),
inServiceDate: String(asset.inServiceDate).slice(0, 10),
depreciationMethod: asset.depreciationMethod,
status: asset.status,
periodsCharged,
};
}
private assertType(
account: { code: string; accountType: string },
expected: string,
label: string,
): void {
if (account.accountType !== expected) {
throw new BadRequestException(
`${account.code} is a ${account.accountType} account, but ${label} must be ${expected}`,
);
}
}
private async requireAccount(actor: ActorContext, code: string) {
const organizationId = this.requireOrganization(actor);
const account = await this.accounts.findByCode(organizationId, code);
if (!account) {
throw new BadRequestException(
`Account ${code} is missing from this organization's chart`,
);
}
return account;
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot manage assets",
);
}
return actor.organizationId;
}
}
export { AssetDisposal };

View File

@@ -0,0 +1,201 @@
import { roundMoney } from "../../common/money";
/**
* Straight-line depreciation, as a PURE function.
*
* No database, no clock, no injected services — the same shape HR's payroll
* calculator settled on, for the same reason: depreciation is arithmetic an
* accountant will want to check by hand, and a function that reads nothing can
* be checked by hand.
*
* ── Conventions, stated because they are choices, not laws ──────────────────
*
* FULL-MONTH: an asset earns a whole month's charge in the month it enters
* service, and none in the month it is disposed of. The alternative (pro-rating
* by day) is defensible but produces amounts nobody can verify mentally, and
* the difference washes out over a life measured in years.
*
* CUMULATIVE TARGET, not a repeated monthly figure. Each charge is
* `round(base × periodsCharged / life) accumulated`, so rounding error can
* never accumulate: whatever a period rounds away, the next one picks up, and
* the final period lands exactly on the base because `base × life / life` is
* the base.
*
* Charging a fixed `round(base / life)` every month instead is the obvious
* implementation and it is wrong: 1,000 over 3 months gives 333.33 three times,
* totalling 999.99, and that last cent never depreciates because the cap stops
* the next charge. The cumulative form gives 333.33 / 333.34 / 333.33.
*/
export type DepreciableAsset = {
acquisitionCost: number;
salvageValue: number;
usefulLifeMonths: number;
accumulatedDepreciation: number;
inServiceDate: string;
depreciationMethod: string;
status: string;
/**
* How many periods have already been charged for this asset — counted from
* `depreciation_entries`, not inferred from the accumulated total.
*
* Counting rows rather than dividing `accumulated / nominal` matters: the
* division would be ambiguous the moment a charge was rounded, which is
* exactly the case this whole approach exists to handle.
*/
periodsCharged: number;
};
export type DepreciationCharge = {
/** What to charge this period. Zero means nothing is due. */
amount: number;
accumulatedAfter: number;
/** True when this charge takes the asset to its cap. */
fullyDepreciated: boolean;
/** Why nothing is due, when amount is 0. */
skipReason?: string;
};
/** The most an asset can ever depreciate: cost less what it will be worth. */
export function depreciableBase(asset: DepreciableAsset): number {
return roundMoney(asset.acquisitionCost - asset.salvageValue);
}
/** What the books say the asset is worth now. */
export function netBookValue(asset: DepreciableAsset): number {
return roundMoney(asset.acquisitionCost - asset.accumulatedDepreciation);
}
/**
* The charge for one period.
*
* `periodEnd` is the last day of the period being run. An asset that entered
* service after that date is not yet depreciating.
*/
export function depreciationFor(
asset: DepreciableAsset,
periodEnd: string,
): DepreciationCharge {
const accumulated = roundMoney(asset.accumulatedDepreciation);
if (asset.depreciationMethod !== "STRAIGHT_LINE") {
// Refused rather than silently treated as straight line — a wrong method
// that quietly produces plausible numbers is worse than one that stops.
return {
amount: 0,
accumulatedAfter: accumulated,
fullyDepreciated: false,
skipReason: `${asset.depreciationMethod} is not implemented`,
};
}
if (asset.status === "DISPOSED" || asset.status === "WRITTEN_OFF") {
return {
amount: 0,
accumulatedAfter: accumulated,
fullyDepreciated: false,
skipReason: `asset is ${asset.status}`,
};
}
if (asset.inServiceDate > periodEnd) {
return {
amount: 0,
accumulatedAfter: accumulated,
fullyDepreciated: false,
skipReason: `not in service until ${asset.inServiceDate}`,
};
}
const base = depreciableBase(asset);
const remaining = roundMoney(base - accumulated);
if (remaining <= 0) {
return {
amount: 0,
accumulatedAfter: accumulated,
fullyDepreciated: true,
skipReason: "fully depreciated",
};
}
// Where accumulated depreciation SHOULD stand once this period is charged.
// Capped at the base so the final period lands exactly on it.
const periodsAfter = Math.min(
asset.periodsCharged + 1,
asset.usefulLifeMonths,
);
const target = Math.min(
roundMoney((base * periodsAfter) / asset.usefulLifeMonths),
base,
);
// The charge is the gap to that target, never more than what is left.
const amount = roundMoney(Math.min(roundMoney(target - accumulated), remaining));
if (amount <= 0) {
return {
amount: 0,
accumulatedAfter: accumulated,
fullyDepreciated: accumulated >= base,
skipReason: "nothing further is due this period",
};
}
const accumulatedAfter = roundMoney(accumulated + amount);
return {
amount,
accumulatedAfter,
fullyDepreciated: accumulatedAfter >= base,
};
}
/**
* The whole schedule for an asset, month by month.
*
* Used by the UI to show what an asset will cost over its life, and by the
* tests to assert that the periods sum to exactly the depreciable base.
*/
export function depreciationSchedule(
asset: DepreciableAsset,
): { month: number; amount: number; accumulated: number; netBookValue: number }[] {
const base = depreciableBase(asset);
const schedule: {
month: number;
amount: number;
accumulated: number;
netBookValue: number;
}[] = [];
let accumulated = 0;
for (let month = 1; month <= asset.usefulLifeMonths; month += 1) {
// Same cumulative target the per-period charge uses, so the schedule shown
// to a user and the amounts actually posted cannot diverge.
const target = Math.min(
roundMoney((base * month) / asset.usefulLifeMonths),
base,
);
const amount = roundMoney(target - accumulated);
if (amount <= 0) break;
accumulated = roundMoney(accumulated + amount);
schedule.push({
month,
amount,
accumulated,
netBookValue: roundMoney(asset.acquisitionCost - accumulated),
});
}
return schedule;
}
/**
* Gain or loss on disposal: what was received less what the books still carried.
*
* Positive is a gain, negative a loss. A scrap with no proceeds is simply a loss
* equal to whatever value was left.
*/
export function disposalResult(
asset: DepreciableAsset,
proceeds: number,
): { netBookValue: number; gainLoss: number } {
const nbv = netBookValue(asset);
return { netBookValue: nbv, gainLoss: roundMoney(proceeds - nbv) };
}

View File

@@ -0,0 +1,220 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsIn,
IsISO8601,
IsInt,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedNameDto } from "../../accounts/dto/account.dto";
import {
ASSET_STATUSES,
DISPOSAL_TYPES,
type AssetStatus,
type DisposalType,
} from "../entities/fixed-asset.entity";
const MAX_AMOUNT = 999_999_999_999.99;
export class CreateAssetCategoryDto {
@ApiProperty({ example: "ROLLING-STOCK" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedNameDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name!: LocalizedNameDto;
@ApiProperty({ description: "Where the cost is carried (e.g. 1213)" })
@IsUUID()
assetAccountId!: string;
@ApiProperty({ description: "The contra account depreciation accrues in (1290)" })
@IsUUID()
accumulatedAccountId!: string;
@ApiProperty({ description: "Where the monthly charge is expensed (5400)" })
@IsUUID()
expenseAccountId!: string;
@ApiProperty({ example: 120, description: "Useful life in months" })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1200)
defaultLifeMonths!: number;
@ApiPropertyOptional({
example: 0.05,
description: "Fraction of cost expected to remain at end of life",
})
@IsOptional()
@IsNumber({ maxDecimalPlaces: 4 })
@Min(0)
@Max(0.99)
defaultSalvageRate?: number;
}
export class CreateAssetDto {
@ApiProperty()
@IsUUID()
assetCategoryId!: string;
@ApiProperty({ example: "FA-0001" })
@IsString()
@IsNotEmpty()
@MaxLength(48)
assetCode!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(96)
serialNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
costCenterId?: string;
@ApiProperty({ example: "2026-08-01" })
@IsISO8601()
acquisitionDate!: string;
@ApiPropertyOptional({
description:
"When it started being used. Depreciation runs from here, not from acquisition. Defaults to the acquisition date.",
})
@IsOptional()
@IsISO8601()
inServiceDate?: string;
@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(MAX_AMOUNT)
acquisitionCost!: number;
@ApiPropertyOptional({
description: "Defaults to the category's salvage rate applied to cost",
})
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
salvageValue?: number;
@ApiPropertyOptional({ description: "Defaults to the category's life" })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1200)
usefulLifeMonths?: number;
@ApiPropertyOptional({
description:
"Post the acquisition to the ledger (Dr asset / Cr the funding account). Omit when the asset already reached the books through a supplier bill.",
})
@IsOptional()
@IsUUID()
fundingAccountId?: string;
@ApiPropertyOptional({
description:
"Cutover only: depreciation already accumulated in the system Finance is replacing. The ledger side comes from the opening balance entry, so this must be used WITHOUT fundingAccountId or the cost would be posted twice.",
})
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
openingAccumulatedDepreciation?: number;
@ApiPropertyOptional({
description:
"Cutover only: how many periods that accumulated depreciation represents. Required alongside it — without a period count the asset never depreciates again.",
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(1200)
openingPeriodsCharged?: number;
}
export class RunDepreciationDto {
@ApiProperty({ description: "The period to charge" })
@IsUUID()
fiscalPeriodId!: string;
}
export class DisposeAssetDto {
@ApiProperty({ enum: DISPOSAL_TYPES })
@IsIn(DISPOSAL_TYPES)
disposalType!: DisposalType;
@ApiProperty({ example: "2026-08-31" })
@IsISO8601()
disposalDate!: string;
@ApiPropertyOptional({ default: 0, description: "What was received" })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_AMOUNT)
proceeds?: number;
@ApiPropertyOptional({
description: "Where the proceeds landed. Required when proceeds are non-zero.",
})
@IsOptional()
@IsUUID()
proceedsAccountId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
reference?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class AssetQueryDto {
@ApiPropertyOptional({ enum: ASSET_STATUSES })
@IsOptional()
@IsIn(ASSET_STATUSES)
status?: AssetStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,329 @@
import { Audit, SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* Only straight line is implemented.
*
* The column is an enum rather than a boolean so reducing-balance can be added
* without a migration, but nothing else is supported today and the calculator
* rejects anything else rather than silently treating it as straight line.
*/
export const DEPRECIATION_METHODS = ["STRAIGHT_LINE"] as const;
export type DepreciationMethod = (typeof DEPRECIATION_METHODS)[number];
export const ASSET_STATUSES = [
"ACTIVE",
"FULLY_DEPRECIATED",
"DISPOSED",
"WRITTEN_OFF",
] as const;
export type AssetStatus = (typeof ASSET_STATUSES)[number];
export const DISPOSAL_TYPES = ["SALE", "SCRAP", "WRITE_OFF"] as const;
export type DisposalType = (typeof DISPOSAL_TYPES)[number];
/**
* Depreciation defaults for a class of asset, and the three accounts its
* postings touch: where the cost sits, where the accumulated depreciation
* accrues, and where the charge lands.
*
* Holding the accounts here rather than resolving them by code per asset means
* a chart can name its asset accounts whatever it likes; only the category has
* to be set up once.
*/
@Entity({ schema: "finance", name: "asset_categories" })
@Check("ck_asset_categories_life", `"default_life_months" > 0`)
@Check(
"ck_asset_categories_salvage",
`"default_salvage_rate" >= 0 AND "default_salvage_rate" < 1`,
)
export class AssetCategory extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
/** Where the asset's cost is carried (1211 Land, 1213 Locomotives…). */
@Column({ type: "uuid", name: "asset_account_id" })
assetAccountId!: string;
/** The contra account depreciation accrues in (1290). */
@Column({ type: "uuid", name: "accumulated_account_id" })
accumulatedAccountId!: string;
/** Where the monthly charge is expensed (5400). */
@Column({ type: "uuid", name: "expense_account_id" })
expenseAccountId!: string;
@Column({ type: "int", name: "default_life_months" })
defaultLifeMonths!: number;
/** Fraction of cost expected to remain at the end of life, e.g. 0.05. */
@Column({
type: "numeric",
precision: 6,
scale: 4,
name: "default_salvage_rate",
default: 0,
})
defaultSalvageRate!: string;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}
/**
* One item in the asset register.
*
* `accumulatedDepreciation` is a running total kept ON the asset. It duplicates
* what the ledger holds in the accumulated-depreciation account, which is
* normally the thing to avoid — it is here because depreciation must STOP at
* the depreciable base, and that is a per-asset decision made on every run.
* Deriving it from the ledger each time would be an aggregate query per asset
* per month. The ledger stays authoritative and the run reconciles against it.
*
* The database enforces the cap directly: `accumulated <= cost - salvage`. An
* arithmetic slip cannot over-depreciate an asset, only fail loudly.
*/
@Entity({ schema: "finance", name: "fixed_assets" })
@Index("idx_fixed_assets_status", ["status"])
@Index("idx_fixed_assets_category", ["assetCategoryId"])
@Index("idx_fixed_assets_cost_center", ["costCenterId"])
@Check(
"ck_fixed_assets_status",
`"status" IN ('ACTIVE','FULLY_DEPRECIATED','DISPOSED','WRITTEN_OFF')`,
)
@Check("ck_fixed_assets_method", `"depreciation_method" IN ('STRAIGHT_LINE')`)
@Check("ck_fixed_assets_cost", `"acquisition_cost" > 0`)
@Check("ck_fixed_assets_life", `"useful_life_months" > 0`)
@Check(
"ck_fixed_assets_salvage",
`"salvage_value" >= 0 AND "salvage_value" < "acquisition_cost"`,
)
@Check(
"ck_fixed_assets_accumulated",
`"accumulated_depreciation" >= 0
AND "accumulated_depreciation" <= "acquisition_cost" - "salvage_value"`,
)
@Check("ck_fixed_assets_in_service", `"in_service_date" >= "acquisition_date"`)
export class FixedAsset extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "asset_category_id" })
assetCategoryId!: string;
@Column({ type: "varchar", length: 48, name: "asset_code" })
assetCode!: string;
@Column({ type: "varchar", length: 200, name: "name" })
name!: string;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
@Column({ type: "varchar", length: 96, name: "serial_number", nullable: true })
serialNumber?: string | null;
@Column({ type: "uuid", name: "cost_center_id", nullable: true })
costCenterId?: string | null;
@Column({ type: "date", name: "acquisition_date" })
acquisitionDate!: string;
/**
* When the asset started being used — depreciation runs from HERE, not from
* acquisition. An asset bought in March and commissioned in June was not
* wearing out in between.
*/
@Column({ type: "date", name: "in_service_date" })
inServiceDate!: string;
@Column(moneyColumn({ name: "acquisition_cost" }))
acquisitionCost!: number;
@Column(moneyColumn({ name: "salvage_value", default: 0 }))
salvageValue!: number;
@Column({ type: "int", name: "useful_life_months" })
usefulLifeMonths!: number;
@Column({
type: "varchar",
length: 24,
name: "depreciation_method",
default: "STRAIGHT_LINE",
})
depreciationMethod!: DepreciationMethod;
@Column(moneyColumn({ name: "accumulated_depreciation", default: 0 }))
accumulatedDepreciation!: number;
/**
* Periods already charged BEFORE this asset reached Finance — the cutover
* count for an asset migrated mid-life. Zero for anything bought since.
*
* Depreciation counts periods from `depreciation_entries` rows, which a
* migrated asset has none of. Without this the count would read zero, the
* cumulative target would land below what is already accumulated, the charge
* would compute as negative and be skipped — and a skip writes no row, so the
* count could never grow and the asset would silently never depreciate again.
*/
@Column({
type: "int",
name: "opening_periods_charged",
default: 0,
})
openingPeriodsCharged!: number;
@Column({ type: "varchar", length: 16, name: "status", default: "ACTIVE" })
status!: AssetStatus;
/** The bill it was bought on, when it came through payables. */
@Column({ type: "uuid", name: "supplier_bill_id", nullable: true })
supplierBillId?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}
/** One month's depreciation across the register, posted as one journal entry. */
@Entity({ schema: "finance", name: "depreciation_runs" })
@Check("ck_depreciation_runs_total", `"total_amount" >= 0`)
export class DepreciationRun extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fiscal_period_id" })
fiscalPeriodId!: string;
@Column({ type: "date", name: "run_date" })
runDate!: string;
@Column({ type: "int", name: "asset_count", default: 0 })
assetCount!: number;
@Column(moneyColumn({ name: "total_amount", default: 0 }))
totalAmount!: number;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "posted_by", nullable: true })
postedBy?: string | null;
}
/**
* What one asset was charged in one run.
*
* `accumulatedAfter` is stored so the register can be replayed: without it,
* reconstructing an asset's book value at a past date would mean re-deriving
* every prior run's arithmetic.
*/
@Entity({ schema: "finance", name: "depreciation_entries" })
@Index("idx_depreciation_entries_asset", ["fixedAssetId"])
@Check("ck_depreciation_entries_amount", `"amount" > 0`)
export class DepreciationEntry {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "depreciation_run_id" })
depreciationRunId!: string;
@Column({ type: "uuid", name: "fixed_asset_id" })
fixedAssetId!: string;
@Column(moneyColumn({ name: "amount" }))
amount!: number;
@Column(moneyColumn({ name: "accumulated_after" }))
accumulatedAfter!: number;
@Column({
type: "timestamptz",
name: "created_at",
default: () => "CURRENT_TIMESTAMP",
})
createdAt!: Date;
}
/**
* The end of an asset's life on the books.
*
* `gainLoss` is proceeds net book value: positive is a gain (the asset was
* worth less on paper than it sold for), negative a loss. Stored rather than
* derived because the net book value at the moment of disposal is a frozen
* fact, and later depreciation runs must not be able to change what a past
* disposal reported.
*/
@Entity({ schema: "finance", name: "asset_disposals" })
@Check(
"ck_asset_disposals_type",
`"disposal_type" IN ('SALE','SCRAP','WRITE_OFF')`,
)
@Check("ck_asset_disposals_proceeds", `"proceeds" >= 0`)
export class AssetDisposal extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fixed_asset_id" })
fixedAssetId!: string;
@Column({ type: "date", name: "disposal_date" })
disposalDate!: string;
@Column({ type: "varchar", length: 16, name: "disposal_type" })
disposalType!: DisposalType;
@Column(moneyColumn({ name: "proceeds", default: 0 }))
proceeds!: number;
@Column(moneyColumn({ name: "net_book_value" }))
netBookValue!: number;
@Column(moneyColumn({ name: "gain_loss" }))
gainLoss!: number;
/** Where the sale proceeds landed. Null for a scrap or write-off. */
@Column({ type: "uuid", name: "proceeds_account_id", nullable: true })
proceedsAccountId?: string | null;
@Column({ type: "varchar", length: 128, name: "reference", nullable: true })
reference?: string | null;
@Column({ type: "text", name: "notes", nullable: true })
notes?: string | null;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "recorded_by", nullable: true })
recordedBy?: string | null;
}

View File

@@ -0,0 +1,196 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { BudgetingService } from "./budgeting.service";
import {
CreateBudgetDto,
CreateCostCenterDto,
SetBudgetLinesDto,
SpreadManyDto,
UpdateCostCenterDto,
VarianceQueryDto,
} from "./dto/budgeting.dto";
@ApiTags("budgeting")
@ApiBearerAuth()
@Controller("budgeting")
// Class gate lists every key the routes use — Nest runs class AND method
// guards, so a key missing here denies before the route's own is evaluated.
@FinanceStaff([
FINANCE_PERMS.budget.view,
FINANCE_PERMS.budget.manage,
FINANCE_PERMS.budget.approve,
FINANCE_PERMS.budget.manageCostCenter,
])
export class BudgetingController {
constructor(private readonly budgeting: BudgetingService) {}
// ── cost centers ─────────────────────────────────────────────────────────
@Get("cost-centers")
@ApiOperation({ summary: "Cost centers, with their linked IAM unit" })
@FinanceStaff(FINANCE_PERMS.budget.view)
listCostCenters(@CurrentUser() user: TCurrentUser) {
return this.budgeting.listCostCentersWithUnits(actorFrom(user));
}
@Get("cost-centers/tree")
@ApiOperation({ summary: "The cost-center tree" })
@FinanceStaff(FINANCE_PERMS.budget.view)
tree(@CurrentUser() user: TCurrentUser, @Query("search") search?: string) {
return this.budgeting.costCenterTree(actorFrom(user), { search });
}
@Get("cost-centers/unlinked-units")
@ApiOperation({
summary: "IAM units with no cost center yet — the link picker's options",
})
@FinanceStaff(FINANCE_PERMS.budget.view)
unlinkedUnits(@CurrentUser() user: TCurrentUser) {
return this.budgeting.listUnlinkedUnits(actorFrom(user));
}
@Post("cost-centers")
@ApiOperation({ summary: "Open a cost center" })
@FinanceStaff(FINANCE_PERMS.budget.manageCostCenter)
createCostCenter(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateCostCenterDto,
) {
return this.budgeting.createCostCenter(actorFrom(user), dto);
}
@Patch("cost-centers/:id")
@ApiOperation({ summary: "Amend a cost center" })
@FinanceStaff(FINANCE_PERMS.budget.manageCostCenter)
updateCostCenter(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCostCenterDto,
) {
return this.budgeting.updateCostCenter(actorFrom(user), id, dto);
}
@Delete("cost-centers/:id")
@ApiOperation({
summary: "Delete an unused cost center (deactivate one that has postings)",
})
@FinanceStaff(FINANCE_PERMS.budget.manageCostCenter)
removeCostCenter(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.budgeting.removeCostCenter(actorFrom(user), id);
}
// ── budgets ──────────────────────────────────────────────────────────────
@Get("budgets")
@ApiOperation({ summary: "Budgets" })
@FinanceStaff(FINANCE_PERMS.budget.view)
listBudgets(@CurrentUser() user: TCurrentUser) {
return this.budgeting.listBudgets(actorFrom(user));
}
@Get("budgets/:id")
@ApiOperation({ summary: "One budget with its lines" })
@FinanceStaff(FINANCE_PERMS.budget.view)
findBudget(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.budgeting.findBudget(actorFrom(user), id);
}
@Get("budgets/:id/variance")
@ApiOperation({
summary:
"Budget vs actual vs committed. Committed counts toward percentUsed — spent-and-promised is what leaves no room.",
})
@FinanceStaff(FINANCE_PERMS.budget.view)
variance(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Query() query: VarianceQueryDto,
) {
return this.budgeting.variance(
actorFrom(user),
id,
query.fiscalPeriodId,
);
}
@Post("budgets")
@ApiOperation({ summary: "Start a draft budget for a fiscal year" })
@FinanceStaff(FINANCE_PERMS.budget.manage)
createBudget(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateBudgetDto,
) {
return this.budgeting.createBudget(actorFrom(user), dto);
}
@Post("budgets/:id/lines")
@ApiOperation({ summary: "Replace a DRAFT budget's lines" })
@FinanceStaff(FINANCE_PERMS.budget.manage)
setLines(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SetBudgetLinesDto,
) {
return this.budgeting.setLines(actorFrom(user), id, dto);
}
@Post("budgets/:id/spread")
@ApiOperation({
summary:
"Spread annual figures across the year's periods (remainder on the last, so periods sum exactly)",
})
@FinanceStaff(FINANCE_PERMS.budget.manage)
spread(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SpreadManyDto,
) {
return this.budgeting.spread(actorFrom(user), id, dto);
}
/**
* A DIFFERENT permission from preparing — the person who proposes the numbers
* must not be the one who commits the organisation to them.
*/
@Post("budgets/:id/approve")
@ApiOperation({ summary: "Approve a budget (one per fiscal year)" })
@FinanceStaff(FINANCE_PERMS.budget.approve)
approveBudget(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.budgeting.approveBudget(actorFrom(user), id);
}
@Post("budgets/:id/close")
@ApiOperation({ summary: "Close an approved budget at year end" })
@FinanceStaff(FINANCE_PERMS.budget.approve)
closeBudget(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.budgeting.closeBudget(actorFrom(user), id);
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CostCenter } from "./entities/cost-center.entity";
import { Budget, BudgetLine } from "./entities/budget.entity";
import {
BudgetLinesRepository,
BudgetsRepository,
CostCentersRepository,
} from "./budgeting.repository";
import { BudgetingService } from "./budgeting.service";
import { BudgetingController } from "./budgeting.controller";
import { AccountsModule } from "../accounts/accounts.module";
import { PeriodsModule } from "../periods/periods.module";
@Module({
imports: [
TypeOrmModule.forFeature([CostCenter, Budget, BudgetLine]),
AccountsModule,
PeriodsModule,
],
controllers: [BudgetingController],
providers: [
CostCentersRepository,
BudgetsRepository,
BudgetLinesRepository,
BudgetingService,
],
exports: [BudgetingService, CostCentersRepository],
})
export class BudgetingModule {}

View File

@@ -0,0 +1,292 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { EntityManager, Repository } from "typeorm";
import { CostCenter } from "./entities/cost-center.entity";
import { Budget, BudgetLine } from "./entities/budget.entity";
@Injectable()
export class CostCentersRepository extends BaseRepository<CostCenter> {
constructor(@InjectRepository(CostCenter) repository: Repository<CostCenter>) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<CostCenter | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
findByUnit(organizationId: string, unitId: string): Promise<CostCenter | null> {
return this.repository.findOne({ where: { organizationId, unitId } });
}
/**
* Every cost center for an organization, ordered by code.
*
* Fetched whole like the chart of accounts: the tree is small, and a paged
* tree shows partial branches, which is worse than useless.
*/
findAllForOrg(
organizationId: string | null,
filters: { search?: string; isActive?: boolean } = {},
): Promise<CostCenter[]> {
const qb = this.repository.createQueryBuilder("cc").where("1 = 1");
if (organizationId) {
qb.andWhere("cc.organization_id = :organizationId", { organizationId });
}
if (filters.isActive !== undefined) {
qb.andWhere("cc.is_active = :isActive", { isActive: filters.isActive });
}
if (filters.search) {
qb.andWhere(
`(cc.code ILIKE :search OR cc.name->>'en' ILIKE :search OR cc.name->>'am' ILIKE :search)`,
{ search: `%${filters.search}%` },
);
}
return qb.orderBy("cc.code", "ASC").getMany();
}
/**
* Cost centers with the IAM unit they are linked to.
*
* The unit name is read through at query time — Finance stores no copy, the
* same rule HR follows for employees.
*/
findAllWithUnits(organizationId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT cc.id,
cc.code,
cc.name,
cc.parent_id AS "parentId",
cc.unit_id AS "unitId",
cc.is_group AS "isGroup",
cc.is_active AS "isActive",
cc.manager_employee_id AS "managerEmployeeId",
u.name AS "unitName",
COALESCE(mgr.name->>'en', mgr.name->>'am') AS "managerName"
FROM finance.cost_centers cc
LEFT JOIN iam.units u ON u.id = cc.unit_id
LEFT JOIN iam.employees mgr ON mgr.id = cc.manager_employee_id
WHERE cc.organization_id = $1 AND cc.deleted_at IS NULL
ORDER BY cc.code ASC`,
[organizationId],
);
}
/** IAM units not yet linked to a cost center — the picker's options. */
findUnlinkedUnits(organizationId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT u.id, u.name
FROM iam.units u
WHERE u.organization_id = $1
AND u.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM finance.cost_centers cc
WHERE cc.unit_id = u.id AND cc.deleted_at IS NULL
)
ORDER BY u.name->>'en' ASC
LIMIT 500`,
[organizationId],
);
}
async countPostedLines(costCenterId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM finance.journal_lines l
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE l.cost_center_id = $1
AND e.status <> 'DRAFT'
AND e.deleted_at IS NULL`,
[costCenterId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
async countChildren(costCenterId: string): Promise<number> {
return this.repository.count({ where: { parentId: costCenterId } });
}
/** Ancestry, depth-capped — the cycle guard for a re-parent. */
async collectAncestorIds(costCenterId: string): Promise<string[]> {
const rows = await this.repository.manager.query<{ id: string }[]>(
`WITH RECURSIVE ancestry AS (
SELECT id, parent_id, 1 AS depth FROM finance.cost_centers WHERE id = $1
UNION ALL
SELECT p.id, p.parent_id, a.depth + 1
FROM finance.cost_centers p
JOIN ancestry a ON p.id = a.parent_id
WHERE a.depth < 32
)
SELECT id FROM ancestry WHERE id <> $1`,
[costCenterId],
);
return rows.map((row) => row.id);
}
}
@Injectable()
export class BudgetsRepository extends BaseRepository<Budget> {
constructor(@InjectRepository(Budget) repository: Repository<Budget>) {
super(repository);
}
findAllForOrg(organizationId: string | null): Promise<Budget[]> {
const qb = this.repository.createQueryBuilder("b").where("1 = 1");
if (organizationId) {
qb.andWhere("b.organization_id = :organizationId", { organizationId });
}
return qb.orderBy("b.created_at", "DESC").getMany();
}
findApprovedForYear(
organizationId: string,
fiscalYearId: string,
): Promise<Budget | null> {
return this.repository.findOne({
where: { organizationId, fiscalYearId, status: "APPROVED" },
});
}
}
@Injectable()
export class BudgetLinesRepository extends BaseRepository<BudgetLine> {
constructor(@InjectRepository(BudgetLine) repository: Repository<BudgetLine>) {
super(repository);
}
findByBudget(budgetId: string): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT bl.id,
bl.account_id AS "accountId",
bl.cost_center_id AS "costCenterId",
bl.fiscal_period_id AS "fiscalPeriodId",
bl.amount,
bl.note,
a.code AS "accountCode",
a.name AS "accountName",
cc.code AS "costCenterCode",
cc.name AS "costCenterName",
p.period_number AS "periodNumber",
p.name AS "periodName"
FROM finance.budget_lines bl
JOIN finance.accounts a ON a.id = bl.account_id
JOIN finance.fiscal_periods p ON p.id = bl.fiscal_period_id
LEFT JOIN finance.cost_centers cc ON cc.id = bl.cost_center_id
WHERE bl.budget_id = $1
ORDER BY a.code ASC, p.period_number ASC`,
[budgetId],
);
}
/**
* Replaces a draft budget's lines wholesale.
*
* Line identity carries no meaning before approval, and a full replace cannot
* leave a stale slot behind — the same reasoning the journal uses for drafts.
*/
async replaceForBudget(
manager: EntityManager,
budgetId: string,
lines: {
accountId: string;
costCenterId: string | null;
fiscalPeriodId: string;
amount: number;
note: string | null;
}[],
): Promise<void> {
const repo = manager.getRepository(BudgetLine);
await repo.delete({ budgetId });
if (lines.length > 0) await repo.save(lines.map((l) => repo.create({ budgetId, ...l })));
}
/**
* Budget vs actual vs commitment, per account × cost center.
*
* Three numbers that answer different questions and are routinely confused:
* - BUDGET what was approved for the periods in range.
* - ACTUAL what has been POSTED. For an expense that is debits credits;
* for revenue it is credits debits, so the figure is positive
* in the direction the account naturally runs. Without that
* flip every revenue line would report as negative spend.
* - COMMITTED approved supplier bills not yet paid. Money not gone, but
* already promised — a department that is "50% spent" and 95%
* committed is not, in any useful sense, halfway through its
* budget.
*
* Built as a FULL OUTER JOIN of budget and actual so a line that was budgeted
* but never spent, and spend that was never budgeted, both still appear. A
* plain join would hide the second, which is the one worth seeing.
*/
budgetVsActual(
organizationId: string,
budgetId: string,
periodIds: string[],
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`WITH budgeted AS (
SELECT bl.account_id, bl.cost_center_id, SUM(bl.amount) AS amount
FROM finance.budget_lines bl
WHERE bl.budget_id = $1
AND bl.fiscal_period_id = ANY($3::uuid[])
GROUP BY bl.account_id, bl.cost_center_id
),
actual AS (
SELECT l.account_id,
l.cost_center_id,
SUM(CASE WHEN a.normal_balance = 'DEBIT'
THEN l.debit - l.credit
ELSE l.credit - l.debit END) AS amount
FROM finance.journal_lines l
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
JOIN finance.accounts a ON a.id = l.account_id
WHERE e.organization_id = $2
AND e.status <> 'DRAFT'
AND e.deleted_at IS NULL
AND e.fiscal_period_id = ANY($3::uuid[])
GROUP BY l.account_id, l.cost_center_id
),
committed AS (
SELECT bll.account_id,
bll.cost_center_id,
SUM(bll.amount) AS amount
FROM finance.supplier_bill_lines bll
JOIN finance.supplier_bills b ON b.id = bll.supplier_bill_id
WHERE b.organization_id = $2
AND b.deleted_at IS NULL
AND b.status IN ('APPROVED','PARTIALLY_PAID')
GROUP BY bll.account_id, bll.cost_center_id
),
slots AS (
SELECT account_id, cost_center_id FROM budgeted
UNION
SELECT account_id, cost_center_id FROM actual
UNION
SELECT account_id, cost_center_id FROM committed
)
SELECT s.account_id AS "accountId",
acc.code AS "accountCode",
acc.name AS "accountName",
acc.account_type AS "accountType",
s.cost_center_id AS "costCenterId",
cc.code AS "costCenterCode",
cc.name AS "costCenterName",
ROUND(COALESCE(bg.amount, 0), 2) AS budget,
ROUND(COALESCE(ac.amount, 0), 2) AS actual,
ROUND(COALESCE(cm.amount, 0), 2) AS committed,
ROUND(COALESCE(bg.amount, 0) - COALESCE(ac.amount, 0), 2) AS variance
FROM slots s
JOIN finance.accounts acc ON acc.id = s.account_id
LEFT JOIN finance.cost_centers cc ON cc.id = s.cost_center_id
LEFT JOIN budgeted bg ON bg.account_id = s.account_id
AND bg.cost_center_id IS NOT DISTINCT FROM s.cost_center_id
LEFT JOIN actual ac ON ac.account_id = s.account_id
AND ac.cost_center_id IS NOT DISTINCT FROM s.cost_center_id
LEFT JOIN committed cm ON cm.account_id = s.account_id
AND cm.cost_center_id IS NOT DISTINCT FROM s.cost_center_id
ORDER BY acc.code ASC, cc.code ASC NULLS FIRST`,
[budgetId, organizationId, periodIds],
);
}
}

View File

@@ -0,0 +1,572 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import { roundMoney } from "../../common/money";
import { AccountsService } from "../accounts/accounts.service";
import { PeriodsService } from "../periods/periods.service";
import {
BudgetLinesRepository,
BudgetsRepository,
CostCentersRepository,
} from "./budgeting.repository";
import { CostCenter } from "./entities/cost-center.entity";
import { Budget } from "./entities/budget.entity";
import type {
CreateBudgetDto,
CreateCostCenterDto,
SetBudgetLinesDto,
SpreadManyDto,
UpdateCostCenterDto,
} from "./dto/budgeting.dto";
export type CostCenterNode = CostCenter & { children: CostCenterNode[] };
@Injectable()
export class BudgetingService {
constructor(
private readonly costCenters: CostCentersRepository,
private readonly budgets: BudgetsRepository,
private readonly budgetLines: BudgetLinesRepository,
private readonly accounts: AccountsService,
private readonly periods: PeriodsService,
private readonly dataSource: DataSource,
) {}
// ── cost centers ─────────────────────────────────────────────────────────
listCostCenters(
actor: ActorContext,
filters: { search?: string; isActive?: boolean },
): Promise<CostCenter[]> {
return this.costCenters.findAllForOrg(orgScope(actor), filters);
}
listCostCentersWithUnits(actor: ActorContext) {
return this.costCenters.findAllWithUnits(this.requireOrganization(actor));
}
listUnlinkedUnits(actor: ActorContext) {
return this.costCenters.findUnlinkedUnits(this.requireOrganization(actor));
}
/** The cost-center tree, assembled from one flat read. */
async costCenterTree(
actor: ActorContext,
filters: { search?: string },
): Promise<CostCenterNode[]> {
const flat = await this.costCenters.findAllForOrg(orgScope(actor), filters);
const byId = new Map<string, CostCenterNode>(
flat.map((cc) => [cc.id, { ...cc, children: [] }]),
);
const roots: CostCenterNode[] = [];
for (const node of byId.values()) {
const parent = node.parentId ? byId.get(node.parentId) : undefined;
// A node whose parent was filtered out is promoted rather than dropped,
// so a search can never hide a match behind a non-matching ancestor.
if (parent) parent.children.push(node);
else roots.push(node);
}
const sort = (nodes: CostCenterNode[]): CostCenterNode[] => {
nodes.sort((a, b) => a.code.localeCompare(b.code));
nodes.forEach((n) => sort(n.children));
return nodes;
};
return sort(roots);
}
async createCostCenter(
actor: ActorContext,
dto: CreateCostCenterDto,
): Promise<CostCenter> {
const organizationId = this.requireOrganization(actor);
const existing = await this.costCenters.findByCode(organizationId, dto.code);
if (existing) {
throw new ConflictException(`Cost center ${dto.code} already exists`);
}
if (dto.unitId) {
const linked = await this.costCenters.findByUnit(organizationId, dto.unitId);
if (linked) {
throw new ConflictException(
`That unit is already represented by ${linked.code}. Two cost centers on one unit would make "what did this department spend?" ambiguous.`,
);
}
await this.assertUnitExists(organizationId, dto.unitId);
}
const parent = await this.resolveParent(actor, dto.parentId ?? null);
return this.costCenters.create({
organizationId,
code: dto.code,
name: dto.name,
parentId: parent?.id ?? null,
unitId: dto.unitId ?? null,
managerEmployeeId: dto.managerEmployeeId ?? null,
isGroup: dto.isGroup ?? false,
isActive: true,
description: dto.description ?? null,
createdBy: actor.employeeId,
});
}
async updateCostCenter(
actor: ActorContext,
id: string,
dto: UpdateCostCenterDto,
): Promise<CostCenter> {
const cc = await this.findCostCenter(actor, id);
const patch: Partial<CostCenter> = {};
if (dto.name !== undefined) patch.name = dto.name;
if (dto.description !== undefined) patch.description = dto.description;
if (dto.isActive !== undefined) patch.isActive = dto.isActive;
if (dto.managerEmployeeId !== undefined) {
patch.managerEmployeeId = dto.managerEmployeeId;
}
if (dto.unitId !== undefined) {
if (dto.unitId) {
const linked = await this.costCenters.findByUnit(
cc.organizationId,
dto.unitId,
);
if (linked && linked.id !== id) {
throw new ConflictException(
`That unit is already represented by ${linked.code}`,
);
}
await this.assertUnitExists(cc.organizationId, dto.unitId);
}
patch.unitId = dto.unitId;
}
if (dto.parentId !== undefined) {
patch.parentId = await this.resolveNewParentId(actor, cc, dto.parentId);
}
if (Object.keys(patch).length === 0) return cc;
const updated = await this.costCenters.update(id, patch);
if (!updated) throw new NotFoundException("Cost center not found");
return updated;
}
async removeCostCenter(
actor: ActorContext,
id: string,
): Promise<{ deleted: true }> {
const cc = await this.findCostCenter(actor, id);
const children = await this.costCenters.countChildren(id);
if (children > 0) {
throw new BadRequestException(
`${cc.code} has ${children} child center(s) — move or remove them first`,
);
}
const posted = await this.costCenters.countPostedLines(id);
if (posted > 0) {
throw new BadRequestException(
`${cc.code} carries ${posted} posted journal line(s) and cannot be deleted. Deactivate it instead.`,
);
}
await this.costCenters.softDelete(id);
return { deleted: true };
}
async findCostCenter(actor: ActorContext, id: string): Promise<CostCenter> {
const cc = await this.costCenters.findById(id);
if (!cc) throw new NotFoundException("Cost center not found");
if (!actor.isSuperAdmin && cc.organizationId !== actor.organizationId) {
throw new NotFoundException("Cost center not found");
}
return cc;
}
// ── budgets ──────────────────────────────────────────────────────────────
listBudgets(actor: ActorContext): Promise<Budget[]> {
return this.budgets.findAllForOrg(orgScope(actor));
}
async findBudget(actor: ActorContext, id: string) {
const budget = await this.budgets.findById(id);
if (!budget) throw new NotFoundException("Budget not found");
if (!actor.isSuperAdmin && budget.organizationId !== actor.organizationId) {
throw new NotFoundException("Budget not found");
}
const lines = await this.budgetLines.findByBudget(id);
return { ...budget, lines };
}
async createBudget(
actor: ActorContext,
dto: CreateBudgetDto,
): Promise<Budget> {
const organizationId = this.requireOrganization(actor);
await this.periods.findYear(actor, dto.fiscalYearId);
return this.budgets.create({
organizationId,
fiscalYearId: dto.fiscalYearId,
name: dto.name,
status: "DRAFT",
description: dto.description ?? null,
preparedBy: actor.employeeId,
});
}
/** Replaces a DRAFT budget's lines. */
async setLines(actor: ActorContext, id: string, dto: SetBudgetLinesDto) {
const budget = await this.loadDraft(actor, id, "edited");
const normalized: {
accountId: string;
costCenterId: string | null;
fiscalPeriodId: string;
amount: number;
note: string | null;
}[] = [];
for (const [index, line] of dto.lines.entries()) {
const amount = roundMoney(line.amount);
// Every budget line must name a postable account: budgeting against a
// group account would double-count once its children are budgeted too.
const account = await this.accounts.assertPostable(actor, line.accountId);
const period = await this.periods.findPeriod(actor, line.fiscalPeriodId);
if (period.fiscalYearId !== budget.fiscalYearId) {
throw new BadRequestException(
`Line ${index + 1} names a period outside this budget's fiscal year`,
);
}
const costCenterId = line.costCenterId
? (await this.assertPostableCostCenter(actor, line.costCenterId)).id
: null;
normalized.push({
accountId: account.id,
costCenterId,
fiscalPeriodId: period.id,
amount,
note: line.note ?? null,
});
}
this.assertNoDuplicateSlots(normalized);
await this.dataSource.transaction((manager) =>
this.budgetLines.replaceForBudget(manager, id, normalized),
);
return this.findBudget(actor, id);
}
/**
* Spreads annual figures evenly across the fiscal year's periods.
*
* Entering twelve rows per account by hand is how budgets end up half-filled,
* so this is the path most callers use. The remainder lands on the last
* period, which keeps the periods summing to exactly the annual figure — a
* spread that quietly loses the rounding is a budget that does not add up.
*/
async spread(actor: ActorContext, id: string, dto: SpreadManyDto) {
const budget = await this.loadDraft(actor, id, "edited");
const periods = await this.periods.listPeriodsOfYear(
actor,
budget.fiscalYearId,
);
if (periods.length === 0) {
throw new BadRequestException(
"That fiscal year has no periods — nothing to spread across",
);
}
const existing = await this.budgetLines.findByBudget(id);
const lines = existing.map((row) => ({
accountId: String(row.accountId),
costCenterId: row.costCenterId ? String(row.costCenterId) : null,
fiscalPeriodId: String(row.fiscalPeriodId),
amount: Number(row.amount),
note: row.note ? String(row.note) : null,
}));
for (const entry of dto.entries) {
const account = await this.accounts.assertPostable(actor, entry.accountId);
const costCenterId = entry.costCenterId
? (await this.assertPostableCostCenter(actor, entry.costCenterId)).id
: null;
const total = roundMoney(entry.annualAmount);
const per = roundMoney(Math.floor((total * 100) / periods.length) / 100);
const remainder = roundMoney(total - per * periods.length);
periods.forEach((period, index) => {
const amount =
index === periods.length - 1 ? roundMoney(per + remainder) : per;
// Replace any existing figure for the same slot rather than adding a
// second one — the unique index would reject the insert anyway, and a
// silent duplicate is worse than an explicit overwrite.
const slot = lines.findIndex(
(l) =>
l.accountId === account.id &&
l.costCenterId === costCenterId &&
l.fiscalPeriodId === period.id,
);
const line = {
accountId: account.id,
costCenterId,
fiscalPeriodId: period.id,
amount,
note: entry.note ?? null,
};
if (slot >= 0) lines[slot] = line;
else lines.push(line);
});
}
await this.dataSource.transaction((manager) =>
this.budgetLines.replaceForBudget(manager, id, lines),
);
return this.findBudget(actor, id);
}
/**
* Approves a budget — it becomes THE plan for its fiscal year.
*
* At most one approved budget per year, enforced by a unique index as well as
* here: with two, every variance report would depend on which plan the reader
* had in mind.
*/
async approveBudget(actor: ActorContext, id: string) {
const budget = await this.loadDraft(actor, id, "approved");
const lines = await this.budgetLines.findByBudget(id);
if (lines.length === 0) {
throw new BadRequestException(
"This budget has no lines — there is nothing to approve",
);
}
const already = await this.budgets.findApprovedForYear(
budget.organizationId,
budget.fiscalYearId,
);
if (already) {
throw new ConflictException(
`${already.name} is already the approved budget for this fiscal year. Close it before approving another.`,
);
}
const updated = await this.budgets.update(id, {
status: "APPROVED",
approvedBy: actor.employeeId,
approvedAt: new Date(),
});
if (!updated) throw new NotFoundException("Budget not found");
return this.findBudget(actor, id);
}
async closeBudget(actor: ActorContext, id: string) {
const budget = await this.budgets.findById(id);
if (!budget) throw new NotFoundException("Budget not found");
if (!actor.isSuperAdmin && budget.organizationId !== actor.organizationId) {
throw new NotFoundException("Budget not found");
}
if (budget.status !== "APPROVED") {
throw new BadRequestException(
`Only an APPROVED budget can be closed; this one is ${budget.status}`,
);
}
await this.budgets.update(id, { status: "CLOSED" });
return this.findBudget(actor, id);
}
/**
* Budget vs actual vs committed for a budget, optionally one period.
*
* `percentUsed` counts COMMITTED as well as actual, because a department that
* has spent half its budget and committed the rest has no room left — a
* figure based on actuals alone would say it was fine.
*/
async variance(
actor: ActorContext,
id: string,
fiscalPeriodId?: string,
) {
const budget = await this.budgets.findById(id);
if (!budget) throw new NotFoundException("Budget not found");
if (!actor.isSuperAdmin && budget.organizationId !== actor.organizationId) {
throw new NotFoundException("Budget not found");
}
const periods = await this.periods.listPeriodsOfYear(
actor,
budget.fiscalYearId,
);
const periodIds = fiscalPeriodId
? periods.filter((p) => p.id === fiscalPeriodId).map((p) => p.id)
: periods.map((p) => p.id);
if (periodIds.length === 0) {
throw new BadRequestException(
"That period does not belong to this budget's fiscal year",
);
}
const rows = await this.budgetLines.budgetVsActual(
budget.organizationId,
id,
periodIds,
);
return rows.map((row) => {
const budgetAmount = Number(row.budget ?? 0);
const actual = Number(row.actual ?? 0);
const committed = Number(row.committed ?? 0);
const consumed = roundMoney(actual + committed);
return {
...row,
budget: budgetAmount,
actual,
committed,
variance: roundMoney(budgetAmount - actual),
remaining: roundMoney(budgetAmount - consumed),
percentUsed:
budgetAmount > 0
? Math.round((consumed / budgetAmount) * 1000) / 10
: null,
/** Over budget once actual + committed exceeds what was approved. */
isOverBudget: budgetAmount > 0 && consumed > budgetAmount,
/** Spent with nothing budgeted — the case a plain join would hide. */
isUnbudgeted: budgetAmount === 0 && consumed !== 0,
};
});
}
// ── internals ────────────────────────────────────────────────────────────
private async loadDraft(
actor: ActorContext,
id: string,
verb: string,
): Promise<Budget> {
const budget = await this.budgets.findById(id);
if (!budget) throw new NotFoundException("Budget not found");
if (!actor.isSuperAdmin && budget.organizationId !== actor.organizationId) {
throw new NotFoundException("Budget not found");
}
if (budget.status !== "DRAFT") {
throw new ForbiddenException(
`${budget.name} is ${budget.status} and can no longer be ${verb}. Approved figures are what variance is measured against, so changing them would rewrite history.`,
);
}
return budget;
}
private assertNoDuplicateSlots(
lines: {
accountId: string;
costCenterId: string | null;
fiscalPeriodId: string;
}[],
): void {
const seen = new Set<string>();
for (const line of lines) {
const key = `${line.accountId}|${line.costCenterId ?? "-"}|${line.fiscalPeriodId}`;
if (seen.has(key)) {
throw new BadRequestException(
"Two lines budget the same account, cost center and period. One slot holds one figure — combine them.",
);
}
seen.add(key);
}
}
private async assertPostableCostCenter(
actor: ActorContext,
id: string,
): Promise<CostCenter> {
const cc = await this.findCostCenter(actor, id);
if (cc.isGroup) {
throw new BadRequestException(
`${cc.code} is a group cost center — budget against one of its children, or its total would double-count`,
);
}
if (!cc.isActive) {
throw new BadRequestException(`${cc.code} is inactive`);
}
return cc;
}
private async resolveParent(
actor: ActorContext,
parentId: string | null,
): Promise<CostCenter | null> {
if (!parentId) return null;
const parent = await this.findCostCenter(actor, parentId);
if (!parent.isGroup) {
throw new BadRequestException(
`${parent.code} is a postable cost center, not a group — only group centers can have children`,
);
}
return parent;
}
private async resolveNewParentId(
actor: ActorContext,
cc: CostCenter,
parentId: string | null,
): Promise<string | null> {
if (!parentId) return null;
if (parentId === cc.id) {
throw new BadRequestException("A cost center cannot be its own parent");
}
const parent = await this.resolveParent(actor, parentId);
if (!parent) return null;
const ancestors = await this.costCenters.collectAncestorIds(parentId);
if (ancestors.includes(cc.id)) {
throw new BadRequestException(
`${parent.code} sits beneath ${cc.code}; moving it there would create a cycle`,
);
}
return parent.id;
}
/**
* The linked unit must exist and belong to the same organization.
*
* A soft reference is not an excuse for a dangling one — nothing enforces
* this at the database level, so it is checked here, once, on write.
*/
private async assertUnitExists(
organizationId: string,
unitId: string,
): Promise<void> {
const rows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.units
WHERE id = $1 AND organization_id = $2 AND deleted_at IS NULL`,
[unitId, organizationId],
);
if (rows.length === 0) {
throw new BadRequestException(
"That unit does not exist in this organization",
);
}
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot manage budgets",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,200 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedNameDto } from "../../accounts/dto/account.dto";
const MAX_AMOUNT = 999_999_999_999.99;
export class CreateCostCenterDto {
@ApiProperty({ example: "CC-100" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
@Matches(/^[A-Za-z0-9_-]+$/, {
message: "code may contain only letters, digits, hyphen and underscore",
})
code!: string;
@ApiProperty({ type: LocalizedNameDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name!: LocalizedNameDto;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
parentId?: string;
@ApiPropertyOptional({ description: "The iam.units row this center represents" })
@IsOptional()
@IsUUID()
unitId?: string;
@ApiPropertyOptional({ description: "iam.employees id of who answers for it" })
@IsOptional()
@IsUUID()
managerEmployeeId?: string;
@ApiPropertyOptional({
description: "Group centers total their children and cannot be posted to",
})
@IsOptional()
@IsBoolean()
isGroup?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}
export class UpdateCostCenterDto {
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name?: LocalizedNameDto;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
parentId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
unitId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
managerEmployeeId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}
export class CreateBudgetDto {
@ApiProperty()
@IsUUID()
fiscalYearId!: string;
@ApiProperty({ example: "FY 2026/27 operating budget" })
@IsString()
@IsNotEmpty()
@MaxLength(160)
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}
export class BudgetLineDto {
@ApiProperty()
@IsUUID()
accountId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
costCenterId?: string;
@ApiProperty()
@IsUUID()
fiscalPeriodId!: string;
@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_AMOUNT)
amount!: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
export class SetBudgetLinesDto {
@ApiProperty({ type: [BudgetLineDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => BudgetLineDto)
lines!: BudgetLineDto[];
}
/**
* Enter one annual figure and have it spread across the year's periods.
*
* The spread is even except for the remainder, which lands on the LAST period —
* so the periods always add back to exactly the annual figure. Dropping the
* remainder would make a budget that silently totals less than it was set at.
*/
export class SpreadBudgetDto {
@ApiProperty()
@IsUUID()
accountId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
costCenterId?: string;
@ApiProperty({ description: "Total for the whole fiscal year" })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_AMOUNT)
annualAmount!: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
export class SpreadManyDto {
@ApiProperty({ type: [SpreadBudgetDto], minItems: 1 })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SpreadBudgetDto)
entries!: SpreadBudgetDto[];
}
export class VarianceQueryDto {
@ApiPropertyOptional({
description: "Limit to one period; omit for the whole fiscal year",
})
@IsOptional()
@IsUUID()
fiscalPeriodId?: string;
}

View File

@@ -0,0 +1,102 @@
import { Audit, SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* DRAFT — being prepared. Editable, and not what variance reports measure against.
* APPROVED — the plan. At most one per fiscal year (unique index), because two
* approved budgets make "are we over budget?" two different questions.
* CLOSED — the year is done; kept for history, never edited again.
*/
export const BUDGET_STATUSES = ["DRAFT", "APPROVED", "CLOSED"] as const;
export type BudgetStatus = (typeof BUDGET_STATUSES)[number];
/**
* One plan for one fiscal year.
*
* Preparing and approving are separate permissions, the same segregation the
* journal and supplier-bill flows use — the person who proposes the numbers
* must not be the one who commits the organisation to them.
*/
@Entity({ schema: "finance", name: "budgets" })
@Index("idx_budgets_organization_id", ["organizationId"])
@Index("idx_budgets_fiscal_year_id", ["fiscalYearId"])
@Check("ck_budgets_status", `"status" IN ('DRAFT','APPROVED','CLOSED')`)
export class Budget extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fiscal_year_id" })
fiscalYearId!: string;
@Column({ type: "varchar", length: 160, name: "name" })
name!: string;
@Column({ type: "varchar", length: 16, name: "status", default: "DRAFT" })
status!: BudgetStatus;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
@Column({ type: "uuid", name: "prepared_by", nullable: true })
preparedBy?: string | null;
@Column({ type: "uuid", name: "approved_by", nullable: true })
approvedBy?: string | null;
@Column({ type: "timestamptz", name: "approved_at", nullable: true })
approvedAt?: Date | null;
}
/**
* The budgeted figure for one account × cost center × period.
*
* Held per PERIOD rather than per year so a variance report can ask "how are we
* doing this month?" — an annual figure can only answer that by assuming an
* even spread, which is exactly the assumption that makes seasonal overspend
* invisible until the year is nearly over.
*
* `costCenterId` is nullable: not every budget line is attributable to a
* department, and forcing one would corrupt the analysis more than leaving it
* blank. The unique index COALESCEs it so "unallocated" is still one slot.
*
* Extends `Audit`, not `SoftDeleteAudit`: lines of an approved budget are never
* soft deleted, and draft lines are replaced wholesale.
*/
@Entity({ schema: "finance", name: "budget_lines" })
@Index("idx_budget_lines_budget_id", ["budgetId"])
@Index("idx_budget_lines_account_period", ["accountId", "fiscalPeriodId"])
@Index("idx_budget_lines_cost_center_id", ["costCenterId"])
@Check("ck_budget_lines_amount", `"amount" >= 0`)
export class BudgetLine extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "budget_id" })
budgetId!: string;
@Column({ type: "uuid", name: "account_id" })
accountId!: string;
@Column({ type: "uuid", name: "cost_center_id", nullable: true })
costCenterId?: string | null;
@Column({ type: "uuid", name: "fiscal_period_id" })
fiscalPeriodId!: string;
@Column(moneyColumn({ name: "amount", default: 0 }))
amount!: number;
@Column({ type: "text", name: "note", nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,69 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
/**
* The analysis dimension every cost can be attributed to.
*
* A cost center MAY be linked to an `iam.units` row (`unitId`), which is how a
* real department gets a budget — but the link is a soft UUID and is optional
* in both directions. Finance does not own the org chart, and a cost center
* that outlives the unit it was named for still has to explain the entries
* posted against it.
*
* Like the chart of accounts, the tree has group nodes that total their
* children and leaves that can actually be posted to.
*/
@Entity({ schema: "finance", name: "cost_centers" })
@Index("idx_cost_centers_organization_id", ["organizationId"])
@Index("idx_cost_centers_parent_id", ["parentId"])
@Check(
"ck_cost_centers_not_self_parent",
`"parent_id" IS NULL OR "parent_id" <> "id"`,
)
export class CostCenter extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "uuid", name: "parent_id", nullable: true })
parentId?: string | null;
/**
* Soft reference to `iam.units` — never an FK (the platform stance). Unique
* per organization where set: two cost centers on one unit would make
* "what did this department spend?" ambiguous.
*/
@Column({ type: "uuid", name: "unit_id", nullable: true })
unitId?: string | null;
/** Soft reference to `iam.employees` — who answers for this budget. */
@Column({ type: "uuid", name: "manager_employee_id", nullable: true })
managerEmployeeId?: string | null;
/** Group centers total their children and can never be posted to. */
@Column({ type: "boolean", name: "is_group", default: false })
isGroup!: boolean;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,84 @@
import { Body, Controller, Get, Post, Put, Query } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { CutoverService } from "./cutover.service";
import { SetCutoverDto } from "./dto/set-cutover.dto";
import { ImportOpeningBalancesDto } from "./dto/opening-balances.dto";
/**
* Guarded with the fiscal-period permissions rather than a new key of its own.
* The cutover is a boundary in the fiscal calendar, and whoever is trusted to
* open and close periods is exactly who should set it — a separate permission
* would have to be seeded, granted and mirrored in the frontend to express the
* same thing.
*/
@ApiTags("cutover")
@ApiBearerAuth()
@Controller("cutover")
@FinanceStaff([
FINANCE_PERMS.period.view,
FINANCE_PERMS.period.manage,
FINANCE_PERMS.journal.create,
])
export class CutoverController {
constructor(private readonly cutover: CutoverService) {}
@Get()
@ApiOperation({ summary: "The organization's cutover date, if one is set" })
@FinanceStaff(FINANCE_PERMS.period.view)
get(
@CurrentUser() user: TCurrentUser,
@Query("organizationId") organizationId?: string,
) {
return this.cutover.get(actorFrom(user), organizationId);
}
@Get("readiness")
@ApiOperation({
summary: "Go-live readiness — every check, and whether all of them pass",
description:
"Reads live: the suspense account must be zero, no operational entry may predate the cutover, and the asset register must agree with the ledger.",
})
@FinanceStaff(FINANCE_PERMS.period.view)
readiness(
@CurrentUser() user: TCurrentUser,
@Query("organizationId") organizationId?: string,
) {
return this.cutover.readiness(actorFrom(user), organizationId);
}
@Post("opening-balances")
@ApiOperation({
summary: "Import opening balances as a DRAFT opening entry",
description:
"Lines arrive by account code. The 3900 suspense plug is calculated, never supplied. Nothing is posted — review the draft and post it through the normal journal path.",
})
@FinanceStaff(FINANCE_PERMS.journal.create)
importOpeningBalances(
@CurrentUser() user: TCurrentUser,
@Body() dto: ImportOpeningBalancesDto,
@Query("organizationId") organizationId?: string,
) {
return this.cutover.importOpeningBalances(
actorFrom(user),
dto,
organizationId,
);
}
@Put()
@ApiOperation({ summary: "Set or move the cutover date" })
@FinanceStaff(FINANCE_PERMS.period.manage)
set(
@CurrentUser() user: TCurrentUser,
@Body() dto: SetCutoverDto,
@Query("organizationId") organizationId?: string,
) {
return this.cutover.set(actorFrom(user), dto, organizationId);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AccountsModule } from "../accounts/accounts.module";
import { JournalsModule } from "../journals/journals.module";
import { OrgSettings } from "./entities/org-settings.entity";
import { CutoverController } from "./cutover.controller";
import { CutoverService } from "./cutover.service";
@Module({
imports: [TypeOrmModule.forFeature([OrgSettings]), AccountsModule, JournalsModule],
controllers: [CutoverController],
providers: [CutoverService],
// Exported so the payment-event consumer can ask for the boundary without
// taking a dependency on the controller.
exports: [CutoverService],
})
export class CutoverModule {}

View File

@@ -0,0 +1,485 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DataSource, IsNull, Repository } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { AccountsService } from "../accounts/accounts.service";
import { JournalsService } from "../journals/journals.service";
import { OrgSettings } from "./entities/org-settings.entity";
import type { SetCutoverDto } from "./dto/set-cutover.dto";
import type { ImportOpeningBalancesDto } from "./dto/opening-balances.dto";
/** The suspense account every opening balance is plugged against. */
const SUSPENSE_CODE = "3900";
/** Money is only ever two decimals here; keep the arithmetic from drifting. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
export type ReadinessCheck = {
key: string;
title: string;
status: "PASS" | "FAIL" | "PENDING";
detail: string;
};
export type CutoverReadiness = {
cutoverDate: string | null;
cutoverNote: string | null;
ready: boolean;
checks: ReadinessCheck[];
};
@Injectable()
export class CutoverService {
constructor(
@InjectRepository(OrgSettings)
private readonly settings: Repository<OrgSettings>,
private readonly accounts: AccountsService,
private readonly journals: JournalsService,
private readonly dataSource: DataSource,
) {}
/**
* The ONE organization this call is about.
*
* Deliberately not `orgScope()`. That helper answers "which rows may this
* actor see", and for a super admin the answer is "all of them" — expressed
* as null. A cutover date is not a filter, it is a property OF one
* organization, so "all" is not a usable answer. A super admin acting without
* naming a target therefore falls back to the organization on their own
* token, and may name another explicitly; everyone else is pinned to theirs.
*/
private resolveOrg(actor: ActorContext, requested?: string): string {
if (requested) {
if (!actor.isSuperAdmin && requested !== actor.organizationId) {
throw new ForbiddenException(
"You may only read or set the cutover for your own organization.",
);
}
return requested;
}
if (!actor.organizationId) {
throw new BadRequestException(
"No organization in context — name one with ?organizationId=, since a cutover date belongs to a single organization.",
);
}
return actor.organizationId;
}
async get(
actor: ActorContext,
organizationIdParam?: string,
): Promise<OrgSettings | null> {
const organizationId = this.resolveOrg(actor, organizationIdParam);
return this.settings.findOne({
where: { organizationId, deletedAt: IsNull() },
});
}
/**
* Upsert the cutover date.
*
* Deliberately NOT blocked once entries exist. Moving the date after posting
* has begun is a real accounting decision an organization is entitled to make
* — usually to correct a mistake — and refusing it would leave them stuck with
* no path but direct SQL. The readiness check reports the consequence instead.
*/
async set(
actor: ActorContext,
dto: SetCutoverDto,
organizationIdParam?: string,
): Promise<OrgSettings> {
const organizationId = this.resolveOrg(actor, organizationIdParam);
const existing = await this.settings.findOne({
where: { organizationId, deletedAt: IsNull() },
});
if (existing) {
existing.cutoverDate = dto.cutoverDate;
existing.cutoverNote = dto.cutoverNote ?? null;
existing.updatedBy = actor.employeeId ?? null;
return this.settings.save(existing);
}
return this.settings.save(
this.settings.create({
organizationId,
cutoverDate: dto.cutoverDate,
cutoverNote: dto.cutoverNote ?? null,
createdBy: actor.employeeId ?? null,
}),
);
}
/** The cutover date alone, for callers that only need the boundary. */
async cutoverDateFor(organizationId: string): Promise<string | null> {
const row = await this.settings.findOne({
where: { organizationId, deletedAt: IsNull() },
});
return row?.cutoverDate ?? null;
}
/**
* Is this organization ready to go live?
*
* Every check is a question with a factual answer, and the answers are read
* fresh rather than cached — this is the screen someone stares at on the
* morning of the migration.
*/
async readiness(
actor: ActorContext,
organizationIdParam?: string,
): Promise<CutoverReadiness> {
const organizationId = this.resolveOrg(actor, organizationIdParam);
const checks: ReadinessCheck[] = [];
const settings = await this.settings.findOne({
where: { organizationId, deletedAt: IsNull() },
});
const cutoverDate = settings?.cutoverDate ?? null;
checks.push(
cutoverDate
? {
key: "cutover_date",
title: "A cutover date is set",
status: "PASS",
detail: `Go-live is ${cutoverDate}.`,
}
: {
key: "cutover_date",
title: "A cutover date is set",
status: "PENDING",
detail:
"Not set. This is a business decision and nothing else can be judged without it.",
},
);
// ── The suspense account must end at zero ──────────────────────────────
// This is THE check that the opening balances were entered correctly. A
// non-zero balance means the migration is unbalanced by exactly that much.
const suspense = await this.dataSource.query<
{ balance: string | null; lines: string }[]
>(
// The posted-only condition belongs on the LINE join, not on a second
// LEFT JOIN to the entry. Written the other way a DRAFT line still joins
// — the filter only nulls the entry, never the line — and its amount is
// still summed, so an unposted opening batch reads as a broken migration.
// The account row must survive with no lines at all, which is what
// separates "not started" from "out of balance", so the join stays LEFT.
`SELECT ROUND(COALESCE(SUM(l.debit - l.credit), 0), 2)::text AS balance,
COUNT(l.id)::text AS lines
FROM finance.accounts a
LEFT JOIN finance.journal_lines l
ON l.account_id = a.id
AND EXISTS (SELECT 1
FROM finance.journal_entries e
WHERE e.id = l.journal_entry_id
AND e.status IN ('POSTED','REVERSED'))
WHERE a.organization_id = $1
AND a.code = $2
AND a.deleted_at IS NULL`,
[organizationId, SUSPENSE_CODE],
);
const suspenseBalance = Number(suspense[0]?.balance ?? 0);
const suspenseLines = Number(suspense[0]?.lines ?? 0);
checks.push(
suspenseLines === 0
? {
key: "suspense",
title: `${SUSPENSE_CODE} Opening Balance Suspense is zero`,
status: "PENDING",
detail:
"Nothing has been posted to the suspense account yet — no opening balances have been entered.",
}
: suspenseBalance === 0
? {
key: "suspense",
title: `${SUSPENSE_CODE} Opening Balance Suspense is zero`,
status: "PASS",
detail: `Balanced across ${suspenseLines} line(s). The opening balances account for themselves.`,
}
: {
key: "suspense",
title: `${SUSPENSE_CODE} Opening Balance Suspense is zero`,
status: "FAIL",
detail: `Standing at ${suspenseBalance.toFixed(
2,
)}. The migration is out by exactly that much — something was entered on one side only.`,
},
);
// ── The ledger must not already contain pre-cutover postings ───────────
if (cutoverDate) {
const early = await this.dataSource.query<{ n: string; first: string | null }[]>(
`SELECT COUNT(*)::text AS n, MIN(e.entry_date)::text AS first
FROM finance.journal_entries e
WHERE e.organization_id = $1
AND e.status IN ('POSTED','REVERSED')
AND e.journal_type <> 'OPENING'
AND e.entry_date < $2`,
[organizationId, cutoverDate],
);
const earlyCount = Number(early[0]?.n ?? 0);
checks.push(
earlyCount === 0
? {
key: "no_pre_cutover",
title: "No operational entries predate the cutover",
status: "PASS",
detail:
"Only OPENING entries sit before the cutover date, which is what should be there.",
}
: {
key: "no_pre_cutover",
title: "No operational entries predate the cutover",
status: "FAIL",
detail: `${earlyCount} non-OPENING entr${
earlyCount === 1 ? "y" : "ies"
} dated before ${cutoverDate} (earliest ${early[0]?.first}). Those periods are also covered by the opening balances, so the amounts are counted twice.`,
},
);
}
// ── The asset register must agree with the ledger ──────────────────────
// 4.5's depreciation run reconciles these before charging, so a mismatch
// here becomes a failed run later. Better to see it now.
const assets = await this.dataSource.query<
{ register: string | null; assets: string }[]
>(
`SELECT ROUND(COALESCE(SUM(a.accumulated_depreciation), 0), 2)::text AS register,
COUNT(*)::text AS assets
FROM finance.fixed_assets a
WHERE a.organization_id = $1
AND a.deleted_at IS NULL
AND a.status NOT IN ('DISPOSED','WRITTEN_OFF')`,
[organizationId],
);
const registerAccum = Number(assets[0]?.register ?? 0);
const assetCount = Number(assets[0]?.assets ?? 0);
if (assetCount === 0) {
checks.push({
key: "asset_register",
title: "The asset register agrees with the ledger",
status: "PENDING",
detail: "No assets in the register yet.",
});
} else {
const ledger = await this.dataSource.query<{ balance: string | null }[]>(
`SELECT ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2)::text AS balance
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE a.organization_id = $1
AND a.account_type = 'ASSET'
AND a.is_contra = true
AND a.deleted_at IS NULL
AND e.status IN ('POSTED','REVERSED')`,
[organizationId],
);
const ledgerAccum = Number(ledger[0]?.balance ?? 0);
const drift = Math.round((registerAccum - ledgerAccum) * 100) / 100;
checks.push(
drift === 0
? {
key: "asset_register",
title: "The asset register agrees with the ledger",
status: "PASS",
detail: `${assetCount} asset(s); accumulated depreciation ${registerAccum.toFixed(
2,
)} on both sides.`,
}
: {
key: "asset_register",
title: "The asset register agrees with the ledger",
status: "FAIL",
detail: `Register says ${registerAccum.toFixed(
2,
)}, the ledger says ${ledgerAccum.toFixed(2)} — out by ${drift.toFixed(
2,
)}. The next depreciation run will refuse to charge until they agree.`,
},
);
// A migrated asset carries accumulated depreciation but has no
// depreciation_entries rows. Unless its opening period count came with it,
// the cumulative target sits below what is already accumulated, the charge
// computes as negative, nothing is written — and because nothing is
// written the count never grows. The asset silently never depreciates
// again. This check names those assets before that can happen.
const stalled = await this.dataSource.query<
{ n: string; codes: string | null }[]
>(
`SELECT COUNT(*)::text AS n,
STRING_AGG(a.asset_code, ', ' ORDER BY a.asset_code) AS codes
FROM finance.fixed_assets a
WHERE a.organization_id = $1
AND a.deleted_at IS NULL
AND a.status NOT IN ('DISPOSED','WRITTEN_OFF')
AND a.accumulated_depreciation > 0
AND a.opening_periods_charged = 0
AND NOT EXISTS (SELECT 1 FROM finance.depreciation_entries d
WHERE d.fixed_asset_id = a.id)`,
[organizationId],
);
const stalledCount = Number(stalled[0]?.n ?? 0);
checks.push(
stalledCount === 0
? {
key: "migrated_assets",
title: "Migrated assets carry their period count",
status: "PASS",
detail:
"No asset has accumulated depreciation without a period count to explain it.",
}
: {
key: "migrated_assets",
title: "Migrated assets carry their period count",
status: "FAIL",
detail: `${stalledCount} asset(s) have accumulated depreciation but no periods charged (${stalled[0]?.codes}). They would never depreciate again. Set their opening period count.`,
},
);
}
const ready =
checks.length > 0 && checks.every((check) => check.status === "PASS");
return {
cutoverDate,
cutoverNote: settings?.cutoverNote ?? null,
ready,
checks,
};
}
/**
* Turn a prepared list of opening balances into a DRAFT `OPENING` entry.
*
* Three deliberate choices:
*
* 1. **It creates a DRAFT, never a posted entry.** Posting goes through
* `JournalsService.post` exactly as a hand-typed entry does. A migration is
* the single largest thing this ledger will ever swallow and it is
* irreversible once posted, so a human sees it first — and there is only
* one posting path to trust.
*
* 2. **Lines arrive by account CODE.** Opening balances are prepared in a
* spreadsheet by someone reading a trial balance, who knows "1111" and has
* never seen a UUID.
*
* 3. **The suspense plug is COMPUTED, never entered** — the same rule disposal
* gain/loss follows. A batch that does not balance on its own is normal and
* expected: cash first, receivables next, equity last. The plug is what
* makes each batch a legal entry, and 3900 falling to zero once every batch
* is in is the proof the migration was entered correctly. Letting a caller
* type their own 3900 line would let two plugs cancel out and hide an error.
*/
async importOpeningBalances(
actor: ActorContext,
dto: ImportOpeningBalancesDto,
organizationIdParam?: string,
) {
const organizationId = this.resolveOrg(actor, organizationIdParam);
const lines: {
accountId: string;
debit: number;
credit: number;
description?: string;
}[] = [];
let totalDebit = 0;
let totalCredit = 0;
for (const [index, raw] of dto.lines.entries()) {
const where = `line ${index + 1} (${raw.accountCode})`;
const debit = round2(raw.debit ?? 0);
const credit = round2(raw.credit ?? 0);
if (debit > 0 && credit > 0) {
throw new BadRequestException(
`${where}: a line carries a debit or a credit, never both.`,
);
}
if (debit === 0 && credit === 0) {
throw new BadRequestException(
`${where}: no amount. Drop the row rather than importing a zero.`,
);
}
if (raw.accountCode.trim() === SUSPENSE_CODE) {
throw new BadRequestException(
`${where}: ${SUSPENSE_CODE} is the balancing account and is calculated, not entered. Remove it and let the import compute the plug.`,
);
}
const account = await this.accounts.findByCode(
organizationId,
raw.accountCode.trim(),
);
if (!account) {
throw new BadRequestException(
`${where}: no such account in the chart.`,
);
}
if (account.isGroup) {
throw new BadRequestException(
`${where}: ${account.code} is a group account and totals its children — post to one of them instead.`,
);
}
if (account.isActive === false) {
throw new BadRequestException(`${where}: ${account.code} is inactive.`);
}
totalDebit = round2(totalDebit + debit);
totalCredit = round2(totalCredit + credit);
lines.push({
accountId: account.id,
debit,
credit,
description: raw.description,
});
}
// The plug. Positive net debits are balanced by crediting suspense.
const plug = round2(totalDebit - totalCredit);
if (plug !== 0) {
const suspense = await this.accounts.findByCode(
organizationId,
SUSPENSE_CODE,
);
if (!suspense) {
throw new BadRequestException(
`Account ${SUSPENSE_CODE} Opening Balance Suspense is missing from the chart — seed the chart of accounts before importing.`,
);
}
lines.push({
accountId: suspense.id,
debit: plug < 0 ? Math.abs(plug) : 0,
credit: plug > 0 ? plug : 0,
description: "Opening balance suspense (calculated)",
});
}
const entry = await this.journals.create(actor, {
entryDate: dto.entryDate,
journalType: "OPENING",
memo: dto.memo?.trim() || `Opening balances as at ${dto.entryDate}`,
lines,
} as Parameters<JournalsService["create"]>[1]);
return {
entry,
imported: dto.lines.length,
totalDebit,
totalCredit,
suspensePlug: plug,
};
}
}

View File

@@ -0,0 +1,72 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
export class OpeningBalanceLineDto {
@ApiProperty({
example: "1111",
description:
"The account CODE, not its id — an opening balance is prepared in a spreadsheet by someone who knows codes, not UUIDs.",
})
@IsString()
@MinLength(1)
@MaxLength(32)
accountCode!: string;
@ApiPropertyOptional({ description: "Debit amount. Use one side per line." })
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
debit?: number;
@ApiPropertyOptional({ description: "Credit amount. Use one side per line." })
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
credit?: number;
@ApiPropertyOptional({ maxLength: 500 })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}
export class ImportOpeningBalancesDto {
@ApiProperty({
example: "2026-07-08",
description:
"Normally the cutover date. Must fall inside an OPEN fiscal period, like any other entry.",
})
@IsDateString()
entryDate!: string;
@ApiPropertyOptional({
description: "What this batch covers. Defaults to a generic description.",
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
memo?: string;
@ApiProperty({ type: [OpeningBalanceLineDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => OpeningBalanceLineDto)
lines!: OpeningBalanceLineDto[];
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsDateString, IsOptional, IsString, MaxLength } from "class-validator";
export class SetCutoverDto {
@ApiProperty({
example: "2026-07-08",
description:
"The go-live boundary, as a calendar date. Before it Finance reports by projection; on or after it the ledger is authoritative.",
})
@IsDateString()
cutoverDate!: string;
@ApiPropertyOptional({
description: "Why this date. Recorded permanently.",
maxLength: 1000,
})
@IsOptional()
@IsString()
@MaxLength(1000)
cutoverNote?: string;
}

View File

@@ -0,0 +1,43 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
/**
* Per-organization Finance settings. Today it holds one thing: the cutover date.
*
* The cutover date is the boundary between the two ways Finance can answer a
* question about the past. BEFORE it, the ledger is empty and history is
* reported by read-only projection over the source systems. ON or AFTER it, the
* ledger is authoritative and events post live. Storing it makes that boundary a
* fact the service can enforce rather than a date living in someone's head.
*
* It is a table and not an env var for three reasons: it is per-organization, a
* redeploy must not be able to move an accounting boundary, and changing it
* needs to leave an audit trail like every other Finance decision.
*
* `date`, not `timestamptz` — a cutover is a calendar fact and must not shift
* with the reader's time zone. That trap has already been paid for once here
* (see the DATE handling note in the platform's known traps).
*/
@Entity({ schema: "finance", name: "org_settings" })
@Index("idx_org_settings_organization_id", ["organizationId"])
export class OrgSettings extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
/** Null until the organization has decided. Nothing may post before it. */
@Column({ type: "date", name: "cutover_date", nullable: true })
cutoverDate!: string | null;
/** Why this date — the note an auditor reads years later. */
@Column({ type: "text", name: "cutover_note", nullable: true })
cutoverNote!: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy!: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy!: string | null;
}

View File

@@ -0,0 +1,192 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsIn,
IsISO8601,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { PaginationQueryDto } from "../../../common/pagination.dto";
import {
JOURNAL_STATUSES,
JOURNAL_TYPES,
type JournalStatus,
type JournalType,
} from "../entities/journal-entry.entity";
/**
* Amounts are bounded at both ends on purpose. `Min(0)` because a negative
* amount is a sign error dressed up as data — the SIDE carries the sign in
* double entry. The upper bound keeps a value inside `numeric(14,2)`, so a
* mistyped figure is refused with a readable message instead of a database
* overflow error.
*/
const MAX_LINE_AMOUNT = 999_999_999_999.99;
export class JournalLineDto {
@ApiProperty()
@IsUUID()
accountId!: string;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_LINE_AMOUNT)
debit?: number;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_LINE_AMOUNT)
credit?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({
description: "Soft reference to iam.units until cost centers arrive in 4.4",
})
@IsOptional()
@IsUUID()
costCenterId?: string;
}
export class CreateJournalEntryDto {
@ApiProperty({ example: "2026-08-21", description: "ISO date (YYYY-MM-DD)" })
@IsISO8601()
entryDate!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(1000)
memo!: string;
@ApiPropertyOptional({ enum: JOURNAL_TYPES, default: "GENERAL" })
@IsOptional()
@IsIn(JOURNAL_TYPES)
journalType?: JournalType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
reference?: string;
/**
* At least two lines: one debit and one credit. A single-line "entry" is not
* a transaction, it is half of one.
*/
@ApiProperty({ type: [JournalLineDto], minItems: 2 })
@IsArray()
@ArrayMinSize(2)
@ValidateNested({ each: true })
@Type(() => JournalLineDto)
lines!: JournalLineDto[];
}
export class UpdateJournalEntryDto {
@ApiPropertyOptional({ example: "2026-08-21" })
@IsOptional()
@IsISO8601()
entryDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(1000)
memo?: string;
@ApiPropertyOptional({ enum: JOURNAL_TYPES })
@IsOptional()
@IsIn(JOURNAL_TYPES)
journalType?: JournalType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
reference?: string;
@ApiPropertyOptional({ type: [JournalLineDto], minItems: 2 })
@IsOptional()
@IsArray()
@ArrayMinSize(2)
@ValidateNested({ each: true })
@Type(() => JournalLineDto)
lines?: JournalLineDto[];
}
export class ReverseJournalEntryDto {
/**
* Required, and not merely descriptive: the reversal is a permanent public
* record of why the ledger changed its mind, and an unexplained one is
* indistinguishable from a mistake.
*/
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(1000)
reason!: string;
@ApiPropertyOptional({
description:
"Date to post the reversal on. Defaults to today; must fall in an open period.",
})
@IsOptional()
@IsISO8601()
reversalDate?: string;
}
export class JournalQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: JOURNAL_STATUSES })
@IsOptional()
@IsIn(JOURNAL_STATUSES)
status?: JournalStatus;
@ApiPropertyOptional({ enum: JOURNAL_TYPES })
@IsOptional()
@IsIn(JOURNAL_TYPES)
journalType?: JournalType;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
fiscalPeriodId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
accountId?: string;
@ApiPropertyOptional({ description: "ISO date (inclusive)" })
@IsOptional()
@IsISO8601()
dateFrom?: string;
@ApiPropertyOptional({ description: "ISO date (inclusive)" })
@IsOptional()
@IsISO8601()
dateTo?: string;
}

View File

@@ -0,0 +1,156 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* DRAFT — being prepared. Editable, and NOT part of any balance.
* POSTED — in the ledger. Immutable forever.
* REVERSED — was posted, then undone by a linked reversing entry. The original
* row is still POSTED-immutable; this status only records that a
* reversal exists, so a reader is not surprised by the pair.
*
* There is deliberately no CANCELLED or DELETED. A posted entry is never
* removed — that is the difference between a ledger and a spreadsheet.
*/
export const JOURNAL_STATUSES = ["DRAFT", "POSTED", "REVERSED"] as const;
export type JournalStatus = (typeof JOURNAL_STATUSES)[number];
/**
* Where the entry came from. GENERAL is a human-keyed entry; the rest are
* written by the automated flows in 4.24.5. OPENING carries the cutover
* balances and REVERSAL is generated, never hand-picked.
*/
export const JOURNAL_TYPES = [
"GENERAL",
"SALES",
"PURCHASE",
"CASH_RECEIPT",
"CASH_PAYMENT",
"PAYROLL",
"DEPRECIATION",
"OPENING",
"REVERSAL",
] as const;
export type JournalType = (typeof JOURNAL_TYPES)[number];
/**
* The header of one double-entry transaction.
*
* Invariants the service enforces inside a single transaction, because a ledger
* that violates any of them is not repairable after the fact:
* 1. `total_debit` = `total_credit`, and both > 0.
* 2. At least two lines.
* 3. The named period is OPEN at the moment of posting.
* 4. Once POSTED, neither the header nor its lines may be modified.
* 5. A correction is a REVERSAL entry, never an edit.
*
* `total_debit`/`total_credit` are stored rather than summed on read: they are
* frozen evidence of what balanced at posting time, so a later change to a line
* (which cannot happen, but the ledger should not depend on that) could never
* silently rewrite history.
*/
@Entity({ schema: "finance", name: "journal_entries" })
@Unique("uq_journal_entries_org_number", ["organizationId", "entryNumber"])
@Index("idx_journal_entries_organization_id", ["organizationId"])
@Index("idx_journal_entries_period_id", ["fiscalPeriodId"])
@Index("idx_journal_entries_date", ["entryDate"])
@Index("idx_journal_entries_status", ["status"])
@Index("idx_journal_entries_source", ["sourceModule", "sourceId"])
@Check(
"ck_journal_entries_status",
`"status" IN ('DRAFT','POSTED','REVERSED')`,
)
@Check("ck_journal_entries_totals_non_negative", `"total_debit" >= 0 AND "total_credit" >= 0`)
// The balance rule, at the database level as well as in the service. Belt and
// braces on purpose: this is the one invariant whose violation is unrecoverable.
@Check("ck_journal_entries_balanced", `"total_debit" = "total_credit"`)
export class JournalEntry extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
/** Human-facing sequence, e.g. "JV-2026-000042". Unique per organization. */
@Column({ type: "varchar", length: 32, name: "entry_number" })
entryNumber!: string;
/**
* The date the transaction is recognised on — NOT when the row was created.
* It decides which period the entry falls in, so a receipt banked late is
* still recognised in the month it belongs to.
*/
@Column({ type: "date", name: "entry_date" })
entryDate!: string;
@Column({ type: "uuid", name: "fiscal_period_id" })
fiscalPeriodId!: string;
@Column({ type: "varchar", length: 24, name: "journal_type", default: "GENERAL" })
journalType!: JournalType;
@Column({ type: "varchar", length: 16, name: "status", default: "DRAFT" })
status!: JournalStatus;
@Column({ type: "text", name: "memo" })
memo!: string;
/** Free-text pointer to the paper/source document (invoice no., receipt no.). */
@Column({ type: "varchar", length: 128, name: "reference", nullable: true })
reference?: string | null;
/**
* Which upstream system produced this entry ("freight", "passenger", "hr",
* "payment"), and its id there. Soft reference, never an FK — Finance must
* stay readable when a source row is gone. Together they are also the
* idempotency key for automated posting: one source document posts once.
*/
@Column({ type: "varchar", length: 32, name: "source_module", nullable: true })
sourceModule?: string | null;
@Column({ type: "varchar", length: 128, name: "source_id", nullable: true })
sourceId?: string | null;
@Column(moneyColumn({ name: "total_debit", default: 0 }))
totalDebit!: number;
@Column(moneyColumn({ name: "total_credit", default: 0 }))
totalCredit!: number;
/** ETB unless an entry deliberately records another currency's conversion. */
@Column({ type: "varchar", length: 8, name: "currency", default: "ETB" })
currency!: string;
/** `iam.employees.id` of whoever prepared it. */
@Column({ type: "uuid", name: "prepared_by", nullable: true })
preparedBy?: string | null;
/**
* `iam.employees.id` of whoever posted it. Separate from `preparedBy` because
* preparing and posting are separate permissions — see the role matrix.
*/
@Column({ type: "uuid", name: "posted_by", nullable: true })
postedBy?: string | null;
@Column({ type: "timestamptz", name: "posted_at", nullable: true })
postedAt?: Date | null;
/** Set on the ORIGINAL, pointing at the entry that reverses it. */
@Column({ type: "uuid", name: "reversed_by_entry_id", nullable: true })
reversedByEntryId?: string | null;
/** Set on the REVERSAL, pointing back at what it undoes. */
@Column({ type: "uuid", name: "reverses_entry_id", nullable: true })
reversesEntryId?: string | null;
@Column({ type: "text", name: "reversal_reason", nullable: true })
reversalReason?: string | null;
}

View File

@@ -0,0 +1,68 @@
import { Audit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* One debit or credit against one account.
*
* A line is exactly ONE side — never both. Storing a signed amount instead
* would be smaller, but every report and every trial balance would then have to
* re-derive the side, and one wrong sign anywhere silently unbalances the
* ledger. The CHECK below makes the wrong shape unrepresentable.
*
* Extends `Audit`, not `SoftDeleteAudit`: a posted line must never be soft
* deleted, and offering the column would invite exactly that. Draft lines are
* hard-deleted and replaced when an unposted entry is edited.
*/
@Entity({ schema: "finance", name: "journal_lines" })
@Unique("uq_journal_lines_entry_line", ["journalEntryId", "lineNumber"])
@Index("idx_journal_lines_entry_id", ["journalEntryId"])
@Index("idx_journal_lines_account_id", ["accountId"])
@Index("idx_journal_lines_cost_center_id", ["costCenterId"])
@Check("ck_journal_lines_non_negative", `"debit" >= 0 AND "credit" >= 0`)
// Exactly one side carries a value, and it is strictly positive. A zero-zero
// line is meaningless; a both-sides line is a netting error.
@Check(
"ck_journal_lines_one_side",
`("debit" > 0 AND "credit" = 0) OR ("credit" > 0 AND "debit" = 0)`,
)
export class JournalLine extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "journal_entry_id" })
journalEntryId!: string;
/** 1-based display order within the entry. */
@Column({ type: "int", name: "line_number" })
lineNumber!: number;
@Column({ type: "uuid", name: "account_id" })
accountId!: string;
@Column(moneyColumn({ name: "debit", default: 0 }))
debit!: number;
@Column(moneyColumn({ name: "credit", default: 0 }))
credit!: number;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
/**
* Optional analysis dimension — a soft reference to `iam.units` until 4.4
* introduces `finance.cost_centers`. Nullable because not every posting is
* attributable to a unit, and forcing a default would corrupt the analysis
* more than leaving it blank.
*/
@Column({ type: "uuid", name: "cost_center_id", nullable: true })
costCenterId?: string | null;
}

View File

@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { JournalsService } from "./journals.service";
import {
CreateJournalEntryDto,
JournalQueryDto,
ReverseJournalEntryDto,
UpdateJournalEntryDto,
} from "./dto/journal.dto";
@ApiTags("journals")
@ApiBearerAuth()
@Controller("journals")
@FinanceStaff([
FINANCE_PERMS.journal.view,
FINANCE_PERMS.journal.create,
FINANCE_PERMS.journal.post,
FINANCE_PERMS.journal.reverse,
])
export class JournalsController {
constructor(private readonly journals: JournalsService) {}
@Get()
@ApiOperation({ summary: "Journal entries" })
@FinanceStaff(FINANCE_PERMS.journal.view)
list(@CurrentUser() user: TCurrentUser, @Query() query: JournalQueryDto) {
return this.journals.list(actorFrom(user), query);
}
@Get(":id")
@ApiOperation({ summary: "One entry with its lines" })
@FinanceStaff(FINANCE_PERMS.journal.view)
findOne(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.journals.findOne(actorFrom(user), id);
}
@Post()
@ApiOperation({ summary: "Prepare a draft entry (must already balance)" })
@FinanceStaff(FINANCE_PERMS.journal.create)
create(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateJournalEntryDto,
) {
return this.journals.create(actorFrom(user), dto);
}
@Patch(":id")
@ApiOperation({ summary: "Edit a DRAFT (refused once posted)" })
@FinanceStaff(FINANCE_PERMS.journal.create)
update(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateJournalEntryDto,
) {
return this.journals.update(actorFrom(user), id, dto);
}
/**
* Separate permission from `create` on purpose — preparing and posting are a
* segregation of duty, not two names for the same act. See the role matrix.
*/
@Post(":id/post")
@ApiOperation({
summary: "Post a draft into the ledger — after this it is immutable",
})
@FinanceStaff(FINANCE_PERMS.journal.post)
postEntry(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.journals.post(actorFrom(user), id);
}
@Post(":id/reverse")
@ApiOperation({
summary: "Reverse a posted entry by writing a mirrored one (the only correction)",
})
@FinanceStaff(FINANCE_PERMS.journal.reverse)
reverse(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReverseJournalEntryDto,
) {
return this.journals.reverse(actorFrom(user), id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Discard a DRAFT (posted entries are never deleted)" })
@FinanceStaff(FINANCE_PERMS.journal.create)
discard(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.journals.discard(actorFrom(user), id);
}
}

View File

@@ -0,0 +1,32 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { JournalEntry } from "./entities/journal-entry.entity";
import { JournalLine } from "./entities/journal-line.entity";
import {
JournalEntriesRepository,
JournalLinesRepository,
} from "./journals.repository";
import { JournalsService } from "./journals.service";
import { JournalsController } from "./journals.controller";
import { AccountsModule } from "../accounts/accounts.module";
import { PeriodsModule } from "../periods/periods.module";
@Module({
imports: [
TypeOrmModule.forFeature([JournalEntry, JournalLine]),
AccountsModule,
PeriodsModule,
],
controllers: [JournalsController],
providers: [
JournalEntriesRepository,
JournalLinesRepository,
JournalsService,
],
// 4.24.5 post through this service rather than writing journal rows
// directly, so every automated entry passes the same balance, period and
// account checks a hand-keyed one does.
exports: [JournalsService, JournalEntriesRepository, JournalLinesRepository],
})
export class JournalsModule {}

View File

@@ -0,0 +1,174 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { JournalEntry } from "./entities/journal-entry.entity";
import { JournalLine } from "./entities/journal-line.entity";
import type { JournalQueryDto } from "./dto/journal.dto";
@Injectable()
export class JournalEntriesRepository extends BaseRepository<JournalEntry> {
constructor(
@InjectRepository(JournalEntry) repository: Repository<JournalEntry>,
) {
super(repository);
}
async findPage(
organizationId: string | null,
filters: JournalQueryDto,
): Promise<[JournalEntry[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.repository.createQueryBuilder("entry").where("1 = 1");
// `null` = every organization (super admin).
if (organizationId) {
qb.andWhere("entry.organization_id = :organizationId", {
organizationId,
});
}
if (filters.status) {
qb.andWhere("entry.status = :status", { status: filters.status });
}
if (filters.journalType) {
qb.andWhere("entry.journal_type = :journalType", {
journalType: filters.journalType,
});
}
if (filters.fiscalPeriodId) {
qb.andWhere("entry.fiscal_period_id = :fiscalPeriodId", {
fiscalPeriodId: filters.fiscalPeriodId,
});
}
if (filters.dateFrom) {
qb.andWhere("entry.entry_date >= :dateFrom", {
dateFrom: filters.dateFrom,
});
}
if (filters.dateTo) {
qb.andWhere("entry.entry_date <= :dateTo", { dateTo: filters.dateTo });
}
if (filters.search) {
qb.andWhere(
"(entry.entry_number ILIKE :search OR entry.memo ILIKE :search OR entry.reference ILIKE :search)",
{ search: `%${filters.search}%` },
);
}
// "Every entry touching this account" — the account-activity view. EXISTS
// rather than a join, so an entry with two lines on the same account is
// still returned once.
if (filters.accountId) {
qb.andWhere(
`EXISTS (
SELECT 1 FROM finance.journal_lines line
WHERE line.journal_entry_id = entry.id
AND line.account_id = :accountId
)`,
{ accountId: filters.accountId },
);
}
return qb
.orderBy("entry.entry_date", filters.sortOrder ?? "DESC")
.addOrderBy("entry.entry_number", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
/**
* The entry already posted for a source document, if any.
*
* The read side of the automated-posting idempotency guard: callers check
* this to report "already posted" rather than surfacing a unique-violation
* stack trace.
*/
findBySource(
organizationId: string,
sourceModule: string,
sourceId: string,
): Promise<JournalEntry | null> {
return this.repository.findOne({
where: { organizationId, sourceModule, sourceId },
});
}
/**
* The next entry number for an organization, as `JV-<year>-<seq>`.
*
* Derived from the highest existing number with the same prefix rather than
* from a row count, because a count would repeat a number after any deletion
* and the unique index would then reject the insert. Callers must run this
* inside the same transaction as the insert; the unique index is the real
* guarantee against a concurrent duplicate, and the caller retries on it.
*/
async nextEntryNumber(
organizationId: string,
year: number,
manager = this.repository.manager,
): Promise<string> {
const prefix = `JV-${year}-`;
const rows = await manager.query<{ max: string | null }[]>(
`SELECT MAX(entry_number) AS max
FROM finance.journal_entries
WHERE organization_id = $1
AND entry_number LIKE $2`,
[organizationId, `${prefix}%`],
);
const highest = rows[0]?.max;
const sequence = highest
? parseInt(highest.slice(prefix.length), 10) + 1
: 1;
return `${prefix}${String(sequence).padStart(6, "0")}`;
}
}
@Injectable()
export class JournalLinesRepository extends BaseRepository<JournalLine> {
constructor(
@InjectRepository(JournalLine) repository: Repository<JournalLine>,
) {
super(repository);
}
findByEntry(journalEntryId: string): Promise<JournalLine[]> {
return this.repository.find({
where: { journalEntryId },
order: { lineNumber: "ASC" },
});
}
/**
* Lines of an entry with each account's code and name attached — what the
* entry-detail screen needs, in one query instead of N.
*/
async findByEntryWithAccounts(journalEntryId: string): Promise<
(JournalLine & {
accountCode: string;
accountName: { am: string; en: string };
})[]
> {
return this.repository.manager.query(
`SELECT line.id,
line.journal_entry_id AS "journalEntryId",
line.line_number AS "lineNumber",
line.account_id AS "accountId",
line.debit,
line.credit,
line.description,
line.cost_center_id AS "costCenterId",
line.created_at AS "createdAt",
account.code AS "accountCode",
account.name AS "accountName"
FROM finance.journal_lines line
JOIN finance.accounts account ON account.id = line.account_id
WHERE line.journal_entry_id = $1
ORDER BY line.line_number ASC`,
[journalEntryId],
);
}
}

View File

@@ -0,0 +1,575 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import { balances, roundMoney } from "../../common/money";
import { paginate, type Paginated } from "../../common/pagination.dto";
import { AccountsService } from "../accounts/accounts.service";
import { PeriodsService } from "../periods/periods.service";
import {
JournalEntriesRepository,
JournalLinesRepository,
} from "./journals.repository";
import { JournalEntry } from "./entities/journal-entry.entity";
import { JournalLine } from "./entities/journal-line.entity";
import type {
CreateJournalEntryDto,
JournalLineDto,
JournalQueryDto,
ReverseJournalEntryDto,
UpdateJournalEntryDto,
} from "./dto/journal.dto";
type NormalizedLine = {
accountId: string;
debit: number;
credit: number;
description: string | null;
costCenterId: string | null;
};
export type JournalEntryDetail = JournalEntry & {
lines: (JournalLine & {
accountCode: string;
accountName: { am: string; en: string };
})[];
};
const today = (): string => new Date().toISOString().slice(0, 10);
@Injectable()
export class JournalsService {
constructor(
private readonly entries: JournalEntriesRepository,
private readonly lines: JournalLinesRepository,
private readonly accounts: AccountsService,
private readonly periods: PeriodsService,
private readonly dataSource: DataSource,
) {}
async list(
actor: ActorContext,
query: JournalQueryDto,
): Promise<Paginated<JournalEntry>> {
const [items, total] = await this.entries.findPage(orgScope(actor), query);
return paginate(items, total, query.page ?? 1, query.limit ?? 25);
}
async findOne(actor: ActorContext, id: string): Promise<JournalEntryDetail> {
const entry = await this.entries.findById(id);
if (!entry) throw new NotFoundException("Journal entry not found");
this.assertSameOrg(actor, entry);
const lines = await this.lines.findByEntryWithAccounts(id);
return { ...entry, lines };
}
/**
* Creates a DRAFT entry.
*
* A draft is validated as strictly as a posting — it must balance, every
* account must be postable, and the date must fall in an open period. Letting
* a draft be saved in a state that can never be posted just moves the failure
* to a later, more confusing moment.
*/
async create(
actor: ActorContext,
dto: CreateJournalEntryDto,
): Promise<JournalEntryDetail> {
const organizationId = this.resolveOrganizationId(actor);
const normalized = await this.normalizeLines(actor, dto.lines);
const totals = this.totalsOf(normalized);
const period = await this.periods.resolveOpenPeriodForDate(
organizationId,
dto.entryDate,
);
const entry = await this.dataSource.transaction(async (manager) => {
const entryNumber = await this.entries.nextEntryNumber(
organizationId,
new Date(`${dto.entryDate}T00:00:00Z`).getUTCFullYear(),
manager,
);
const entryRepo = manager.getRepository(JournalEntry);
const saved = await entryRepo.save(
entryRepo.create({
organizationId,
entryNumber,
entryDate: dto.entryDate,
fiscalPeriodId: period.id,
journalType: dto.journalType ?? "GENERAL",
status: "DRAFT" as const,
memo: dto.memo,
reference: dto.reference ?? null,
totalDebit: totals.debit,
totalCredit: totals.credit,
preparedBy: actor.employeeId,
}),
);
await this.writeLines(manager, saved.id, normalized);
return saved;
});
return this.findOne(actor, entry.id);
}
/**
* Creates an entry that is POSTED immediately — the automated-posting path
* used by 4.2's payment consumer and revenue recognition.
*
* There is no draft stage because there is no human to prepare it: a machine
* either has a complete, balanced transaction or it has nothing. Every rule a
* hand-keyed entry obeys still applies — it goes through the same
* `normalizeLines` (balance, postable accounts, one side per line) and the
* same open-period gate.
*
* `sourceModule`/`sourceId` are the idempotency key. The partial unique index
* on (organization, source_module, source_id) means posting the same source
* document twice raises a conflict rather than double-counting, which is what
* makes at-least-once event delivery safe.
*
* `manager` lets a caller that is ALREADY in a transaction have the entry
* written on the same connection. Without it, a caller like the depreciation
* run — which writes its own records around this call — would commit the
* journal independently, and a later failure would leave a posted entry with
* nothing explaining it. That happened; hence the parameter.
*/
async createPosted(
actor: ActorContext,
dto: CreateJournalEntryDto & {
sourceModule: string;
sourceId: string;
},
manager?: EntityManager,
): Promise<JournalEntryDetail> {
const organizationId = this.resolveOrganizationId(actor);
const normalized = await this.normalizeLines(actor, dto.lines);
const totals = this.totalsOf(normalized);
const period = await this.periods.resolveOpenPeriodForDate(
organizationId,
dto.entryDate,
);
const write = async (tx: EntityManager) => {
const entryNumber = await this.entries.nextEntryNumber(
organizationId,
new Date(`${dto.entryDate}T00:00:00Z`).getUTCFullYear(),
tx,
);
const entryRepo = tx.getRepository(JournalEntry);
const saved = await entryRepo.save(
entryRepo.create({
organizationId,
entryNumber,
entryDate: dto.entryDate,
fiscalPeriodId: period.id,
journalType: dto.journalType ?? "GENERAL",
status: "POSTED" as const,
memo: dto.memo,
reference: dto.reference ?? null,
sourceModule: dto.sourceModule,
sourceId: dto.sourceId,
totalDebit: totals.debit,
totalCredit: totals.credit,
preparedBy: actor.employeeId,
postedBy: actor.employeeId,
postedAt: new Date(),
}),
);
await this.writeLines(tx, saved.id, normalized);
return saved;
};
if (manager) {
// Inside the caller's transaction: the lines are read back on the SAME
// manager, because they are not visible to any other connection yet.
const saved = await write(manager);
const lines = await manager.query(
`SELECT line.id, line.journal_entry_id AS "journalEntryId",
line.line_number AS "lineNumber", line.account_id AS "accountId",
line.debit, line.credit, line.description,
line.cost_center_id AS "costCenterId", line.created_at AS "createdAt",
account.code AS "accountCode", account.name AS "accountName"
FROM finance.journal_lines line
JOIN finance.accounts account ON account.id = line.account_id
WHERE line.journal_entry_id = $1
ORDER BY line.line_number ASC`,
[saved.id],
);
return { ...saved, lines };
}
const entry = await this.dataSource.transaction(write);
return this.findOne(actor, entry.id);
}
/** Has this source document already been posted? The replay guard's read side. */
async findBySource(
organizationId: string,
sourceModule: string,
sourceId: string,
): Promise<JournalEntry | null> {
return this.entries.findBySource(organizationId, sourceModule, sourceId);
}
/**
* Edits a DRAFT.
*
* Refuses outright once the entry is POSTED — that refusal is the ledger's
* central promise, and it is enforced here rather than by convention.
*/
async update(
actor: ActorContext,
id: string,
dto: UpdateJournalEntryDto,
): Promise<JournalEntryDetail> {
const entry = await this.entries.findById(id);
if (!entry) throw new NotFoundException("Journal entry not found");
this.assertSameOrg(actor, entry);
this.assertDraft(entry, "edited");
const entryDate = dto.entryDate ?? entry.entryDate;
const period = await this.periods.resolveOpenPeriodForDate(
entry.organizationId,
entryDate,
);
const normalized = dto.lines
? await this.normalizeLines(actor, dto.lines)
: null;
const totals = normalized ? this.totalsOf(normalized) : null;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(JournalEntry).update(id, {
entryDate,
fiscalPeriodId: period.id,
...(dto.memo !== undefined ? { memo: dto.memo } : {}),
...(dto.journalType !== undefined
? { journalType: dto.journalType }
: {}),
...(dto.reference !== undefined ? { reference: dto.reference } : {}),
...(totals
? { totalDebit: totals.debit, totalCredit: totals.credit }
: {}),
});
if (normalized) {
// Draft lines are replaced wholesale rather than diffed: line identity
// carries no meaning before posting, and a full replace cannot leave a
// stale line behind.
await manager.getRepository(JournalLine).delete({ journalEntryId: id });
await this.writeLines(manager, id, normalized);
}
});
return this.findOne(actor, id);
}
/**
* Posts a draft into the ledger.
*
* Everything is re-checked here rather than trusted from create time: the
* period may have closed, an account may have been deactivated, and the
* balance is re-derived from the lines as they stand right now. After this
* returns, the entry is immutable.
*/
async post(actor: ActorContext, id: string): Promise<JournalEntryDetail> {
const entry = await this.entries.findById(id);
if (!entry) throw new NotFoundException("Journal entry not found");
this.assertSameOrg(actor, entry);
this.assertDraft(entry, "posted");
const lines = await this.lines.findByEntry(id);
if (lines.length < 2) {
throw new BadRequestException(
"An entry needs at least two lines — one debit and one credit",
);
}
// Re-validate every account at posting time. One may have been deactivated
// or turned into a group since the draft was written.
for (const line of lines) {
await this.accounts.assertPostable(actor, line.accountId);
}
const totalDebit = roundMoney(
lines.reduce((sum, line) => sum + Number(line.debit), 0),
);
const totalCredit = roundMoney(
lines.reduce((sum, line) => sum + Number(line.credit), 0),
);
this.assertBalanced(totalDebit, totalCredit);
// The period is re-resolved from the date, so an entry drafted before a
// month-end close cannot slip into the closed month.
const period = await this.periods.resolveOpenPeriodForDate(
entry.organizationId,
entry.entryDate,
);
await this.entries.update(id, {
status: "POSTED",
fiscalPeriodId: period.id,
totalDebit,
totalCredit,
postedBy: actor.employeeId,
postedAt: new Date(),
});
return this.findOne(actor, id);
}
/**
* Reverses a POSTED entry by writing a new, mirrored one.
*
* The original is never touched beyond a back-reference: a reversal is an
* additional fact ("we undid this"), not the deletion of an earlier one. The
* reversal is created already POSTED, because an unposted reversal would
* leave the books overstated for as long as it sat in a queue.
*
* It is dated TODAY by default rather than on the original's date, so a
* correction never reaches back into a month that has already been reported.
*/
async reverse(
actor: ActorContext,
id: string,
dto: ReverseJournalEntryDto,
): Promise<JournalEntryDetail> {
const original = await this.entries.findById(id);
if (!original) throw new NotFoundException("Journal entry not found");
this.assertSameOrg(actor, original);
if (original.status === "DRAFT") {
throw new BadRequestException(
"A draft has not affected any balance — edit or discard it instead of reversing it",
);
}
if (original.status === "REVERSED" || original.reversedByEntryId) {
throw new BadRequestException(
`${original.entryNumber} has already been reversed`,
);
}
const reversalDate = dto.reversalDate ?? today();
const period = await this.periods.resolveOpenPeriodForDate(
original.organizationId,
reversalDate,
);
const originalLines = await this.lines.findByEntry(id);
const reversal = await this.dataSource.transaction(async (manager) => {
const entryNumber = await this.entries.nextEntryNumber(
original.organizationId,
new Date(`${reversalDate}T00:00:00Z`).getUTCFullYear(),
manager,
);
const entryRepo = manager.getRepository(JournalEntry);
const saved = await entryRepo.save(
entryRepo.create({
organizationId: original.organizationId,
entryNumber,
entryDate: reversalDate,
fiscalPeriodId: period.id,
journalType: "REVERSAL" as const,
status: "POSTED" as const,
memo: `Reversal of ${original.entryNumber}: ${dto.reason}`,
reference: original.reference ?? null,
// The source pointer is deliberately NOT copied: it is a uniqueness
// key for automated posting, and duplicating it would make the
// reversal collide with the entry it reverses.
totalDebit: original.totalCredit,
totalCredit: original.totalDebit,
preparedBy: actor.employeeId,
postedBy: actor.employeeId,
postedAt: new Date(),
reversesEntryId: original.id,
reversalReason: dto.reason,
}),
);
// Debits become credits and vice versa — that is the whole mechanism.
await this.writeLines(
manager,
saved.id,
originalLines.map((line) => ({
accountId: line.accountId,
debit: Number(line.credit),
credit: Number(line.debit),
description: line.description ?? null,
costCenterId: line.costCenterId ?? null,
})),
);
await entryRepo.update(original.id, {
status: "REVERSED",
reversedByEntryId: saved.id,
});
return saved;
});
return this.findOne(actor, reversal.id);
}
/**
* Discards a DRAFT. Lines go with it via ON DELETE CASCADE.
*
* Hard delete, not soft: an unposted draft never entered the ledger, so there
* is no history to preserve and keeping tombstones would clutter the very
* numbering sequence the ledger relies on.
*/
async discard(actor: ActorContext, id: string): Promise<{ deleted: true }> {
const entry = await this.entries.findById(id);
if (!entry) throw new NotFoundException("Journal entry not found");
this.assertSameOrg(actor, entry);
this.assertDraft(entry, "discarded");
await this.entries.hardDelete(id);
return { deleted: true };
}
// ── internals ────────────────────────────────────────────────────────────
/**
* Validates and normalizes incoming lines.
*
* Rounds to the cent ONCE, here, at the boundary — see `money.ts`. After this
* the values are exact and every later sum is trustworthy.
*/
private async normalizeLines(
actor: ActorContext,
lines: JournalLineDto[],
): Promise<NormalizedLine[]> {
if (lines.length < 2) {
throw new BadRequestException(
"An entry needs at least two lines — one debit and one credit",
);
}
const normalized: NormalizedLine[] = [];
for (const [index, line] of lines.entries()) {
const debit = roundMoney(line.debit ?? 0);
const credit = roundMoney(line.credit ?? 0);
const label = `Line ${index + 1}`;
if (debit > 0 && credit > 0) {
throw new BadRequestException(
`${label} has both a debit and a credit. A line is one side or the other — split it into two lines.`,
);
}
if (debit === 0 && credit === 0) {
throw new BadRequestException(
`${label} has no amount. Every line must carry a debit or a credit.`,
);
}
const account = await this.accounts.assertPostable(actor, line.accountId);
normalized.push({
accountId: account.id,
debit,
credit,
description: line.description ?? null,
costCenterId: line.costCenterId ?? null,
});
}
const totals = this.totalsOf(normalized);
this.assertBalanced(totals.debit, totals.credit);
return normalized;
}
private totalsOf(lines: NormalizedLine[]): { debit: number; credit: number } {
return {
debit: roundMoney(lines.reduce((sum, line) => sum + line.debit, 0)),
credit: roundMoney(lines.reduce((sum, line) => sum + line.credit, 0)),
};
}
/**
* The rule the whole module exists to protect.
*
* The message names both totals and the difference, because "does not
* balance" without the numbers leaves the user hunting for a figure the
* server already knows.
*/
private assertBalanced(totalDebit: number, totalCredit: number): void {
if (totalDebit <= 0) {
throw new BadRequestException("An entry must move a non-zero amount");
}
if (!balances(totalDebit, totalCredit)) {
const difference = roundMoney(Math.abs(totalDebit - totalCredit));
throw new BadRequestException(
`Entry does not balance: debits ${totalDebit.toFixed(2)}, credits ${totalCredit.toFixed(2)} — a difference of ${difference.toFixed(2)}`,
);
}
}
private async writeLines(
manager: EntityManager,
journalEntryId: string,
lines: NormalizedLine[],
): Promise<void> {
const repo = manager.getRepository(JournalLine);
await repo.save(
lines.map((line, index) =>
repo.create({
journalEntryId,
lineNumber: index + 1,
accountId: line.accountId,
debit: line.debit,
credit: line.credit,
description: line.description,
costCenterId: line.costCenterId,
}),
),
);
}
/**
* The immutability gate.
*
* Says what to do instead, because a bare refusal invites someone to look for
* a way around it — and the correct route (a reversal) is exactly the thing
* that keeps the audit trail intact.
*/
private assertDraft(entry: JournalEntry, verb: string): void {
if (entry.status === "DRAFT") return;
throw new ForbiddenException(
`${entry.entryNumber} is ${entry.status} and can never be ${verb}. Post a reversing entry to correct it.`,
);
}
private assertSameOrg(actor: ActorContext, entry: JournalEntry): void {
if (actor.isSuperAdmin) return;
if (entry.organizationId !== actor.organizationId) {
// 404 rather than 403 — confirming existence would leak another
// organization's activity.
throw new NotFoundException("Journal entry not found");
}
}
private resolveOrganizationId(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot create finance records",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
/**
* Who the caller is, according to THIS app's guard.
*
* finance-api issues no tokens — login happens wherever the IAM auth controller
* is hosted. But the frontend needs the permission set to gate its UI, and
* asking the issuing service for it would hard-wire the frontend to whichever
* service that is today.
*
* Instead it asks finance-api, which already resolves the session on every
* request: JwtGuard reads `iam.sessions."userInfo"` and attaches it. So this
* returns exactly the identity finance-api will enforce with — no second source
* of truth.
*
* Deliberately NOT behind FinancePermissionGuard: a signed-in user with no
* Finance permissions at all must still be able to discover that, or they cannot
* be shown a meaningful "you have no access" screen.
*/
@ApiTags("me")
@ApiBearerAuth()
@Controller("me")
@UseGuards(JwtGuard)
export class MeController {
@Get()
@ApiOperation({
summary:
"The signed-in user, with the permission set finance-api enforces on",
})
me(@CurrentUser() user: TCurrentUser): TCurrentUser {
return user;
}
}

View File

@@ -0,0 +1,6 @@
import { Module } from "@nestjs/common";
import { MeController } from "./me.controller";
@Module({ controllers: [MeController] })
export class MeModule {}

View File

@@ -0,0 +1,356 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
IsISO8601,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { PaginationQueryDto } from "../../../common/pagination.dto";
import {
BILL_STATUSES,
type BillStatus,
} from "../entities/supplier-bill.entity";
import {
PAYMENT_METHODS,
STATUTORY_TYPES,
type PaymentMethod,
type StatutoryType,
} from "../entities/supplier-payment.entity";
const MAX_AMOUNT = 999_999_999_999.99;
export class CreateSupplierDto {
@ApiProperty({ example: "SUP-001" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
@ApiPropertyOptional({ description: "Taxpayer identification number" })
@IsOptional()
@IsString()
@MaxLength(32)
tin?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
contactPerson?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(48)
phone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
email?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
bankName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
bankAccount?: string;
@ApiPropertyOptional({
description: "Overrides the default trade-payables control account",
})
@IsOptional()
@IsUUID()
payableAccountId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateSupplierDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(32)
tin?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(48)
phone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
email?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
bankAccount?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class BillLineDto {
@ApiProperty({ description: "The expense or asset account this cost lands in" })
@IsUUID()
accountId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
quantity?: number;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(MAX_AMOUNT)
unitPrice?: number;
@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(MAX_AMOUNT)
amount!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
costCenterId?: string;
}
export class CreateBillDto {
@ApiProperty()
@IsUUID()
supplierId!: string;
@ApiPropertyOptional({ description: "The number on the supplier's own invoice" })
@IsOptional()
@IsString()
@MaxLength(64)
supplierInvoiceNumber?: string;
@ApiProperty({ example: "2026-08-21" })
@IsISO8601()
billDate!: string;
@ApiPropertyOptional({ example: "2026-09-20" })
@IsOptional()
@IsISO8601()
dueDate?: string;
@ApiPropertyOptional({ default: 0, description: "VAT charged by the supplier" })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
taxAmount?: number;
@ApiPropertyOptional({
default: 0,
description:
"Tax withheld at source — reduces what the supplier is paid without reducing the expense",
})
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
withholdingAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@ApiProperty({ type: [BillLineDto], minItems: 1 })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BillLineDto)
lines!: BillLineDto[];
}
export class UpdateBillDto {
@ApiPropertyOptional()
@IsOptional()
@IsISO8601()
billDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsISO8601()
dueDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
supplierInvoiceNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
taxAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
withholdingAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@ApiPropertyOptional({ type: [BillLineDto], minItems: 1 })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BillLineDto)
lines?: BillLineDto[];
}
export class RecordPaymentDto {
@ApiProperty()
@IsISO8601()
paymentDate!: string;
@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(MAX_AMOUNT)
amount!: number;
@ApiProperty({ enum: PAYMENT_METHODS })
@IsIn(PAYMENT_METHODS)
method!: PaymentMethod;
@ApiProperty({ description: "The cash or bank account the money leaves" })
@IsUUID()
paidFromAccountId!: string;
@ApiPropertyOptional({ description: "Cheque number, transfer reference…" })
@IsOptional()
@IsString()
@MaxLength(128)
reference?: string;
}
export class BillQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: BILL_STATUSES })
@IsOptional()
@IsIn(BILL_STATUSES)
status?: BillStatus;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
supplierId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
export class RecordRemittanceDto {
@ApiProperty({ enum: STATUTORY_TYPES })
@IsIn(STATUTORY_TYPES)
statutoryType!: StatutoryType;
@ApiProperty({ example: "2026-08", description: "YYYY-MM" })
@Matches(/^\d{4}-\d{2}$/, { message: "period must be YYYY-MM" })
period!: string;
@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(MAX_AMOUNT)
amount!: number;
@ApiProperty()
@IsISO8601()
paidDate!: string;
@ApiProperty()
@IsUUID()
paidFromAccountId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
reference?: string;
@ApiPropertyOptional({ description: "The authority's receipt number" })
@IsOptional()
@IsString()
@MaxLength(64)
receiptNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,172 @@
import { Audit, SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* DRAFT — being entered. Editable, and affects no balance.
* APPROVED — authorised and posted to the ledger. Immutable.
* PARTIALLY_PAID — some cash has gone out.
* PAID — settled in full.
* CANCELLED — abandoned before approval. An APPROVED bill is never
* cancelled; it is reversed, like any other posted entry.
*/
export const BILL_STATUSES = [
"DRAFT",
"APPROVED",
"PARTIALLY_PAID",
"PAID",
"CANCELLED",
] as const;
export type BillStatus = (typeof BILL_STATUSES)[number];
/**
* What is owed to a supplier.
*
* Entering a bill and authorising it are separate permissions
* (`manage:supplier_bill` vs `approve:supplier_bill`) — the person who keys an
* invoice must not be the one who commits the organisation to paying it. That
* is enforced at the route, and the status machine here is what makes it mean
* something: nothing reaches the ledger until APPROVED.
*/
@Entity({ schema: "finance", name: "supplier_bills" })
@Index("idx_supplier_bills_supplier_id", ["supplierId"])
@Index("idx_supplier_bills_status", ["status"])
@Index("idx_supplier_bills_due_date", ["dueDate"])
@Check(
"ck_supplier_bills_status",
`"status" IN ('DRAFT','APPROVED','PARTIALLY_PAID','PAID','CANCELLED')`,
)
@Check(
"ck_supplier_bills_amounts",
`"total_amount" >= 0 AND "paid_amount" >= 0`,
)
@Check("ck_supplier_bills_not_overpaid", `"paid_amount" <= "total_amount"`)
export class SupplierBill extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "supplier_id" })
supplierId!: string;
/** Our own sequence, e.g. "BILL-2026-000012". */
@Column({ type: "varchar", length: 64, name: "bill_number" })
billNumber!: string;
/**
* The number printed on the supplier's own invoice. Uniqueness per supplier
* is enforced by a partial index — entering the same invoice twice is the
* classic route to paying it twice.
*/
@Column({
type: "varchar",
length: 64,
name: "supplier_invoice_number",
nullable: true,
})
supplierInvoiceNumber?: string | null;
@Column({ type: "date", name: "bill_date" })
billDate!: string;
@Column({ type: "date", name: "due_date", nullable: true })
dueDate?: string | null;
@Column({ type: "varchar", length: 8, name: "currency", default: "ETB" })
currency!: string;
@Column(moneyColumn({ name: "subtotal", default: 0 }))
subtotal!: number;
@Column(moneyColumn({ name: "tax_amount", default: 0 }))
taxAmount!: number;
/**
* Tax withheld at source and owed to the revenue authority rather than to the
* supplier. It REDUCES what the supplier is paid without reducing the
* expense, which is why it is tracked separately from `taxAmount`.
*/
@Column(moneyColumn({ name: "withholding_amount", default: 0 }))
withholdingAmount!: number;
@Column(moneyColumn({ name: "total_amount", default: 0 }))
totalAmount!: number;
@Column(moneyColumn({ name: "paid_amount", default: 0 }))
paidAmount!: number;
@Column({ type: "varchar", length: 16, name: "status", default: "DRAFT" })
status!: BillStatus;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
/** The entry raised on approval. Null while the bill is still a draft. */
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "prepared_by", nullable: true })
preparedBy?: string | null;
@Column({ type: "uuid", name: "approved_by", nullable: true })
approvedBy?: string | null;
@Column({ type: "timestamptz", name: "approved_at", nullable: true })
approvedAt?: Date | null;
}
/**
* One expense line of a bill.
*
* Extends `Audit`, not `SoftDeleteAudit`: a line of an APPROVED bill must never
* be soft deleted, and offering the column would invite it. Draft lines are
* replaced wholesale on edit.
*/
@Entity({ schema: "finance", name: "supplier_bill_lines" })
@Index("idx_supplier_bill_lines_bill_id", ["supplierBillId"])
@Check("ck_supplier_bill_lines_amount", `"amount" > 0`)
export class SupplierBillLine extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "supplier_bill_id" })
supplierBillId!: string;
@Column({ type: "int", name: "line_number" })
lineNumber!: number;
/** The EXPENSE (or asset) account this cost lands in. */
@Column({ type: "uuid", name: "account_id" })
accountId!: string;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
@Column({
type: "numeric",
precision: 12,
scale: 2,
name: "quantity",
default: 1,
})
quantity!: string;
@Column(moneyColumn({ name: "unit_price", default: 0 }))
unitPrice!: number;
@Column(moneyColumn({ name: "amount" }))
amount!: number;
/** Soft reference to `iam.units` until cost centers arrive in 4.4. */
@Column({ type: "uuid", name: "cost_center_id", nullable: true })
costCenterId?: string | null;
}

View File

@@ -0,0 +1,144 @@
import { Audit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
export const PAYMENT_METHODS = ["BANK", "CASH", "CHEQUE", "MOBILE"] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
/**
* Cash going out against a bill.
*
* Append-only: a payment is never edited or deleted. A mistaken payment is
* corrected by reversing its journal entry, the same rule the rest of the
* ledger follows — which is why this extends `Audit` and not `SoftDeleteAudit`.
*
* Partial payments are ordinary: several rows may point at one bill, and the
* bill's `paid_amount` is the running total. The database refuses to let that
* exceed the bill total.
*/
@Entity({ schema: "finance", name: "supplier_payments" })
@Index("idx_supplier_payments_bill_id", ["supplierBillId"])
@Check("ck_supplier_payments_amount", `"amount" > 0`)
@Check(
"ck_supplier_payments_method",
`"method" IN ('BANK','CASH','CHEQUE','MOBILE')`,
)
export class SupplierPayment extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "supplier_bill_id" })
supplierBillId!: string;
@Column({ type: "varchar", length: 32, name: "payment_number" })
paymentNumber!: string;
@Column({ type: "date", name: "payment_date" })
paymentDate!: string;
@Column(moneyColumn({ name: "amount" }))
amount!: number;
@Column({ type: "varchar", length: 24, name: "method", default: "BANK" })
method!: PaymentMethod;
/** Which cash/bank account the money left. */
@Column({ type: "uuid", name: "paid_from_account_id" })
paidFromAccountId!: string;
/** Cheque number, transfer reference, mobile-money id. */
@Column({ type: "varchar", length: 128, name: "reference", nullable: true })
reference?: string | null;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "recorded_by", nullable: true })
recordedBy?: string | null;
}
export const STATUTORY_TYPES = [
"INCOME_TAX",
"PENSION",
"VAT",
"WITHHOLDING",
] as const;
export type StatutoryType = (typeof STATUTORY_TYPES)[number];
/**
* A statutory liability handed over to the authority that is owed it.
*
* Payroll CREATES these liabilities (PAYE withheld from staff, pension owed to
* the fund); this records them being discharged. Keeping them distinct from
* supplier payments matters because they are not commercial debts — they are
* money held on someone else's behalf, and a late remittance carries a penalty
* that a late supplier payment does not.
*
* One remittance per type per period, enforced by a unique index: paying the
* same month's PAYE twice is both real and expensive.
*/
@Entity({ schema: "finance", name: "statutory_remittances" })
@Index("idx_statutory_remittances_period", ["period"])
@Check("ck_statutory_remittances_amount", `"amount" > 0`)
@Check(
"ck_statutory_remittances_type",
`"statutory_type" IN ('INCOME_TAX','PENSION','VAT','WITHHOLDING')`,
)
export class StatutoryRemittance extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 24, name: "statutory_type" })
statutoryType!: StatutoryType;
/** The period the liability arose in, as YYYY-MM. */
@Column({ type: "varchar", length: 7, name: "period" })
period!: string;
/** The liability account being cleared (2121, 2122, 2123, 2124). */
@Column({ type: "uuid", name: "liability_account_id" })
liabilityAccountId!: string;
@Column(moneyColumn({ name: "amount" }))
amount!: number;
@Column({ type: "date", name: "paid_date" })
paidDate!: string;
@Column({ type: "uuid", name: "paid_from_account_id" })
paidFromAccountId!: string;
@Column({ type: "varchar", length: 128, name: "reference", nullable: true })
reference?: string | null;
/** The authority's own receipt — the evidence the obligation was met. */
@Column({
type: "varchar",
length: 64,
name: "receipt_number",
nullable: true,
})
receiptNumber?: string | null;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "recorded_by", nullable: true })
recordedBy?: string | null;
@Column({ type: "text", name: "notes", nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,61 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
/**
* The vendor master.
*
* `payableAccountId` is optional and overrides the default trade-payables
* control account. Most suppliers use the default; the exception is one whose
* balance must be reported separately (a related party, a government body),
* and forcing those through one control account is what makes a payables
* breakdown impossible later.
*/
@Entity({ schema: "finance", name: "suppliers" })
@Index("idx_suppliers_organization_id", ["organizationId"])
export class Supplier extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "varchar", length: 200, name: "name" })
name!: string;
/** Taxpayer identification number — required to claim VAT and to withhold. */
@Column({ type: "varchar", length: 32, name: "tin", nullable: true })
tin?: string | null;
@Column({ type: "varchar", length: 160, name: "contact_person", nullable: true })
contactPerson?: string | null;
@Column({ type: "varchar", length: 48, name: "phone", nullable: true })
phone?: string | null;
@Column({ type: "varchar", length: 160, name: "email", nullable: true })
email?: string | null;
@Column({ type: "text", name: "address", nullable: true })
address?: string | null;
@Column({ type: "varchar", length: 120, name: "bank_name", nullable: true })
bankName?: string | null;
@Column({ type: "varchar", length: 64, name: "bank_account", nullable: true })
bankAccount?: string | null;
@Column({ type: "uuid", name: "payable_account_id", nullable: true })
payableAccountId?: string | null;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "text", name: "notes", nullable: true })
notes?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,237 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { PayablesService } from "./payables.service";
import { PayrollPostingService } from "./payroll-posting.service";
import {
BillQueryDto,
CreateBillDto,
CreateSupplierDto,
RecordPaymentDto,
RecordRemittanceDto,
UpdateBillDto,
UpdateSupplierDto,
} from "./dto/payables.dto";
@ApiTags("payables")
@ApiBearerAuth()
@Controller("payables")
// Class gate lists every key the routes use — Nest runs class AND method
// guards, so a key missing here denies before the route's own is evaluated.
@FinanceStaff([
FINANCE_PERMS.payable.view,
FINANCE_PERMS.payable.manageSupplier,
FINANCE_PERMS.payable.manageBill,
FINANCE_PERMS.payable.approveBill,
FINANCE_PERMS.payable.recordPayment,
FINANCE_PERMS.payable.postPayroll,
FINANCE_PERMS.payable.manageStatutory,
])
export class PayablesController {
constructor(
private readonly payables: PayablesService,
private readonly payroll: PayrollPostingService,
) {}
// ── suppliers ────────────────────────────────────────────────────────────
@Get("suppliers")
@ApiOperation({ summary: "Suppliers" })
@FinanceStaff(FINANCE_PERMS.payable.view)
listSuppliers(
@CurrentUser() user: TCurrentUser,
@Query("search") search?: string,
) {
return this.payables.listSuppliers(actorFrom(user), { search });
}
@Post("suppliers")
@ApiOperation({ summary: "Add a supplier" })
@FinanceStaff(FINANCE_PERMS.payable.manageSupplier)
createSupplier(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateSupplierDto,
) {
return this.payables.createSupplier(actorFrom(user), dto);
}
@Patch("suppliers/:id")
@ApiOperation({ summary: "Amend a supplier" })
@FinanceStaff(FINANCE_PERMS.payable.manageSupplier)
updateSupplier(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateSupplierDto,
) {
return this.payables.updateSupplier(actorFrom(user), id, dto);
}
@Delete("suppliers/:id")
@ApiOperation({
summary: "Delete a supplier with no bills (deactivate one that has them)",
})
@FinanceStaff(FINANCE_PERMS.payable.manageSupplier)
removeSupplier(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.payables.removeSupplier(actorFrom(user), id);
}
// ── bills ────────────────────────────────────────────────────────────────
@Get("bills")
@ApiOperation({ summary: "Supplier bills" })
@FinanceStaff(FINANCE_PERMS.payable.view)
listBills(@CurrentUser() user: TCurrentUser, @Query() query: BillQueryDto) {
return this.payables.listBills(actorFrom(user), query);
}
@Get("bills/detailed")
@ApiOperation({ summary: "Bills with their supplier attached" })
@FinanceStaff(FINANCE_PERMS.payable.view)
listBillsDetailed(@CurrentUser() user: TCurrentUser) {
return this.payables.listBillsWithSupplier(actorFrom(user));
}
@Get("aging")
@ApiOperation({ summary: "Payables aging" })
@FinanceStaff(FINANCE_PERMS.payable.view)
aging(@CurrentUser() user: TCurrentUser, @Query("asOf") asOf?: string) {
return this.payables.agingAsOf(
actorFrom(user),
asOf ?? new Date().toISOString().slice(0, 10),
);
}
@Get("bills/:id")
@ApiOperation({ summary: "One bill with its lines and payments" })
@FinanceStaff(FINANCE_PERMS.payable.view)
findBill(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.payables.findBill(actorFrom(user), id);
}
@Post("bills")
@ApiOperation({ summary: "Enter a bill as a draft" })
@FinanceStaff(FINANCE_PERMS.payable.manageBill)
createBill(@CurrentUser() user: TCurrentUser, @Body() dto: CreateBillDto) {
return this.payables.createBill(actorFrom(user), dto);
}
@Patch("bills/:id")
@ApiOperation({ summary: "Edit a DRAFT bill" })
@FinanceStaff(FINANCE_PERMS.payable.manageBill)
updateBill(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBillDto,
) {
return this.payables.updateBill(actorFrom(user), id, dto);
}
/**
* A DIFFERENT permission from entering the bill — the person who keys an
* invoice must not be the one who commits to paying it.
*/
@Post("bills/:id/approve")
@ApiOperation({ summary: "Authorise a bill and post it to the ledger" })
@FinanceStaff(FINANCE_PERMS.payable.approveBill)
approveBill(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.payables.approveBill(actorFrom(user), id);
}
@Post("bills/:id/payments")
@ApiOperation({ summary: "Record cash paid against an approved bill" })
@FinanceStaff(FINANCE_PERMS.payable.recordPayment)
recordPayment(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RecordPaymentDto,
) {
return this.payables.recordPayment(actorFrom(user), id, dto);
}
// ── payroll ──────────────────────────────────────────────────────────────
@Get("payroll/runs")
@ApiOperation({
summary: "Approved HR payroll runs, and whether each is posted to the GL",
})
@FinanceStaff(FINANCE_PERMS.payable.view)
payrollRuns(@CurrentUser() user: TCurrentUser) {
return this.payroll.listRuns(actorFrom(user));
}
@Get("payroll/runs/:id/disbursement")
@ApiOperation({
summary: "Salary disbursement register — who is paid what, and how",
})
@FinanceStaff(FINANCE_PERMS.payable.view)
disbursement(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.payroll.disbursementRegister(actorFrom(user), id);
}
@Post("payroll/runs/:id/post")
@ApiOperation({ summary: "Post an approved payroll run to the general ledger" })
@FinanceStaff(FINANCE_PERMS.payable.postPayroll)
postPayroll(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.payroll.postRun(actorFrom(user), id);
}
// ── statutory ────────────────────────────────────────────────────────────
@Get("statutory/outstanding")
@ApiOperation({
summary: "What each statutory liability still owes, read off the ledger",
})
@FinanceStaff(FINANCE_PERMS.payable.view)
outstandingStatutory(@CurrentUser() user: TCurrentUser) {
return this.payables.outstandingStatutory(actorFrom(user));
}
@Get("statutory/remittances")
@ApiOperation({ summary: "Statutory remittances already made" })
@FinanceStaff(FINANCE_PERMS.payable.view)
listRemittances(@CurrentUser() user: TCurrentUser) {
return this.payables.listRemittances(actorFrom(user));
}
@Post("statutory/remittances")
@ApiOperation({
summary: "Record a statutory remittance (one per type per period)",
})
@FinanceStaff(FINANCE_PERMS.payable.manageStatutory)
recordRemittance(
@CurrentUser() user: TCurrentUser,
@Body() dto: RecordRemittanceDto,
) {
return this.payables.recordRemittance(actorFrom(user), dto);
}
}

View File

@@ -0,0 +1,53 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Supplier } from "./entities/supplier.entity";
import {
SupplierBill,
SupplierBillLine,
} from "./entities/supplier-bill.entity";
import {
StatutoryRemittance,
SupplierPayment,
} from "./entities/supplier-payment.entity";
import {
StatutoryRemittancesRepository,
SupplierBillLinesRepository,
SupplierBillsRepository,
SupplierPaymentsRepository,
SuppliersRepository,
} from "./payables.repository";
import { PayablesService } from "./payables.service";
import { PayrollPostingService } from "./payroll-posting.service";
import { PayablesController } from "./payables.controller";
import { AccountsModule } from "../accounts/accounts.module";
import { JournalsModule } from "../journals/journals.module";
@Module({
imports: [
TypeOrmModule.forFeature([
Supplier,
SupplierBill,
SupplierBillLine,
SupplierPayment,
StatutoryRemittance,
]),
AccountsModule,
JournalsModule,
],
controllers: [PayablesController],
providers: [
SuppliersRepository,
SupplierBillsRepository,
SupplierBillLinesRepository,
SupplierPaymentsRepository,
StatutoryRemittancesRepository,
PayablesService,
// Reads `hr.payroll_runs` / `hr.payslips` READ-ONLY and posts the result.
// Finance never writes HR's schema — the run stays HR's fact, and only the
// journal link lives here.
PayrollPostingService,
],
exports: [PayablesService, PayrollPostingService],
})
export class PayablesModule {}

View File

@@ -0,0 +1,338 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { EntityManager, Repository } from "typeorm";
import { Supplier } from "./entities/supplier.entity";
import {
SupplierBill,
SupplierBillLine,
type BillStatus,
} from "./entities/supplier-bill.entity";
import {
StatutoryRemittance,
SupplierPayment,
} from "./entities/supplier-payment.entity";
@Injectable()
export class SuppliersRepository extends BaseRepository<Supplier> {
constructor(@InjectRepository(Supplier) repository: Repository<Supplier>) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<Supplier | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
findAllForOrg(
organizationId: string | null,
filters: { search?: string; isActive?: boolean } = {},
): Promise<Supplier[]> {
const qb = this.repository.createQueryBuilder("supplier").where("1 = 1");
if (organizationId) {
qb.andWhere("supplier.organization_id = :organizationId", { organizationId });
}
if (filters.isActive !== undefined) {
qb.andWhere("supplier.is_active = :isActive", { isActive: filters.isActive });
}
if (filters.search) {
qb.andWhere(
"(supplier.code ILIKE :search OR supplier.name ILIKE :search OR supplier.tin ILIKE :search)",
{ search: `%${filters.search}%` },
);
}
return qb.orderBy("supplier.name", "ASC").getMany();
}
/** Bills referencing this supplier — the guard against deleting one. */
async countBills(supplierId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM finance.supplier_bills
WHERE supplier_id = $1 AND deleted_at IS NULL`,
[supplierId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
}
@Injectable()
export class SupplierBillsRepository extends BaseRepository<SupplierBill> {
constructor(
@InjectRepository(SupplierBill) repository: Repository<SupplierBill>,
) {
super(repository);
}
async findPage(
organizationId: string | null,
filters: {
status?: BillStatus;
supplierId?: string;
search?: string;
page?: number;
limit?: number;
},
): Promise<[SupplierBill[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.repository.createQueryBuilder("bill").where("1 = 1");
if (organizationId) {
qb.andWhere("bill.organization_id = :organizationId", { organizationId });
}
if (filters.status) qb.andWhere("bill.status = :status", { status: filters.status });
if (filters.supplierId) {
qb.andWhere("bill.supplier_id = :supplierId", { supplierId: filters.supplierId });
}
if (filters.search) {
qb.andWhere(
"(bill.bill_number ILIKE :search OR bill.supplier_invoice_number ILIKE :search OR bill.description ILIKE :search)",
{ search: `%${filters.search}%` },
);
}
return qb
.orderBy("bill.bill_date", "DESC")
.addOrderBy("bill.bill_number", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
/** Bills with their supplier's name attached — what the list screen shows. */
findPageWithSupplier(
organizationId: string,
limit = 100,
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT b.id,
b.bill_number AS "billNumber",
b.supplier_invoice_number AS "supplierInvoiceNumber",
b.bill_date::text AS "billDate",
b.due_date::text AS "dueDate",
b.currency,
b.total_amount AS "totalAmount",
b.paid_amount AS "paidAmount",
b.status,
b.description,
b.journal_entry_id AS "journalEntryId",
s.id AS "supplierId",
s.code AS "supplierCode",
s.name AS "supplierName"
FROM finance.supplier_bills b
JOIN finance.suppliers s ON s.id = b.supplier_id
WHERE b.organization_id = $1 AND b.deleted_at IS NULL
ORDER BY b.bill_date DESC, b.bill_number DESC
LIMIT $2`,
[organizationId, limit],
);
}
/**
* Payables aging — what is owed, by how overdue.
*
* Ages on `due_date` and falls back to the bill date, because a bill with no
* due date is already payable; treating it as not-yet-due would understate
* the oldest bucket, which is the one that matters.
*/
agingAsOf(
organizationId: string,
asOf: string,
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`WITH outstanding AS (
SELECT b.currency,
(b.total_amount - b.paid_amount) AS balance,
($2::date - COALESCE(b.due_date, b.bill_date)) AS days_overdue
FROM finance.supplier_bills b
WHERE b.organization_id = $1
AND b.deleted_at IS NULL
AND b.status IN ('APPROVED','PARTIALLY_PAID')
AND (b.total_amount - b.paid_amount) > 0
)
SELECT CASE
WHEN days_overdue <= 0 THEN 'Not yet due'
WHEN days_overdue <= 30 THEN '1-30 days'
WHEN days_overdue <= 60 THEN '31-60 days'
WHEN days_overdue <= 90 THEN '61-90 days'
ELSE 'Over 90 days'
END AS bucket,
currency,
COUNT(*)::int AS "documentCount",
ROUND(SUM(balance), 2) AS amount
FROM outstanding
GROUP BY 1, 2
ORDER BY 1, 2`,
[organizationId, asOf],
);
}
/**
* Next bill number, from the highest existing one rather than a row count —
* a count would repeat a number after any deletion and collide on insert.
*/
async nextBillNumber(
organizationId: string,
year: number,
manager = this.repository.manager,
): Promise<string> {
const prefix = `BILL-${year}-`;
const rows = await manager.query<{ max: string | null }[]>(
`SELECT MAX(bill_number) AS max FROM finance.supplier_bills
WHERE organization_id = $1 AND bill_number LIKE $2`,
[organizationId, `${prefix}%`],
);
const highest = rows[0]?.max;
const seq = highest ? parseInt(highest.slice(prefix.length), 10) + 1 : 1;
return `${prefix}${String(seq).padStart(6, "0")}`;
}
}
@Injectable()
export class SupplierBillLinesRepository extends BaseRepository<SupplierBillLine> {
constructor(
@InjectRepository(SupplierBillLine) repository: Repository<SupplierBillLine>,
) {
super(repository);
}
findByBill(supplierBillId: string): Promise<SupplierBillLine[]> {
return this.repository.find({
where: { supplierBillId },
order: { lineNumber: "ASC" },
});
}
findByBillWithAccounts(
supplierBillId: string,
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT l.id,
l.line_number AS "lineNumber",
l.account_id AS "accountId",
l.description,
l.quantity,
l.unit_price AS "unitPrice",
l.amount,
a.code AS "accountCode",
a.name AS "accountName"
FROM finance.supplier_bill_lines l
JOIN finance.accounts a ON a.id = l.account_id
WHERE l.supplier_bill_id = $1
ORDER BY l.line_number ASC`,
[supplierBillId],
);
}
async replaceForBill(
manager: EntityManager,
supplierBillId: string,
lines: {
accountId: string;
description: string | null;
quantity: number;
unitPrice: number;
amount: number;
costCenterId: string | null;
}[],
): Promise<void> {
const repo = manager.getRepository(SupplierBillLine);
await repo.delete({ supplierBillId });
await repo.save(
lines.map((line, index) =>
repo.create({
supplierBillId,
lineNumber: index + 1,
accountId: line.accountId,
description: line.description,
quantity: String(line.quantity),
unitPrice: line.unitPrice,
amount: line.amount,
costCenterId: line.costCenterId,
}),
),
);
}
}
@Injectable()
export class SupplierPaymentsRepository extends BaseRepository<SupplierPayment> {
constructor(
@InjectRepository(SupplierPayment) repository: Repository<SupplierPayment>,
) {
super(repository);
}
findByBill(supplierBillId: string): Promise<SupplierPayment[]> {
return this.repository.find({
where: { supplierBillId },
order: { paymentDate: "ASC" },
});
}
async nextPaymentNumber(
organizationId: string,
year: number,
manager = this.repository.manager,
): Promise<string> {
const prefix = `PAY-${year}-`;
const rows = await manager.query<{ max: string | null }[]>(
`SELECT MAX(payment_number) AS max FROM finance.supplier_payments
WHERE organization_id = $1 AND payment_number LIKE $2`,
[organizationId, `${prefix}%`],
);
const highest = rows[0]?.max;
const seq = highest ? parseInt(highest.slice(prefix.length), 10) + 1 : 1;
return `${prefix}${String(seq).padStart(6, "0")}`;
}
}
@Injectable()
export class StatutoryRemittancesRepository extends BaseRepository<StatutoryRemittance> {
constructor(
@InjectRepository(StatutoryRemittance)
repository: Repository<StatutoryRemittance>,
) {
super(repository);
}
findAllForOrg(organizationId: string): Promise<StatutoryRemittance[]> {
return this.repository.find({
where: { organizationId },
order: { period: "DESC", statutoryType: "ASC" },
});
}
/**
* What each statutory liability account currently carries, net of what has
* already been remitted.
*
* Read straight off the POSTED ledger rather than from a running total: the
* ledger is the authority, and a stored total is one more thing that can
* drift from it. Liabilities are credit-balance accounts, so the balance is
* credits debits.
*/
outstandingByAccount(
organizationId: string,
): Promise<Record<string, unknown>[]> {
return this.repository.manager.query(
`SELECT a.code AS "accountCode",
a.name AS "accountName",
a.id AS "accountId",
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2) AS outstanding
FROM finance.accounts a
LEFT JOIN finance.journal_lines l ON l.account_id = a.id
LEFT JOIN finance.journal_entries e
ON e.id = l.journal_entry_id
AND e.status <> 'DRAFT'
AND e.deleted_at IS NULL
WHERE a.organization_id = $1
AND a.code IN ('2121','2122','2123','2124')
AND a.deleted_at IS NULL
GROUP BY a.id, a.code, a.name
ORDER BY a.code`,
[organizationId],
);
}
}

View File

@@ -0,0 +1,660 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import { LEDGER_CURRENCY, roundMoney } from "../../common/money";
import { paginate, type Paginated } from "../../common/pagination.dto";
import { AccountsService } from "../accounts/accounts.service";
import { JournalsService } from "../journals/journals.service";
import {
StatutoryRemittancesRepository,
SupplierBillLinesRepository,
SupplierBillsRepository,
SupplierPaymentsRepository,
SuppliersRepository,
} from "./payables.repository";
import { Supplier } from "./entities/supplier.entity";
import { SupplierBill } from "./entities/supplier-bill.entity";
import {
StatutoryRemittance,
SupplierPayment,
} from "./entities/supplier-payment.entity";
import type {
BillQueryDto,
CreateBillDto,
CreateSupplierDto,
RecordPaymentDto,
RecordRemittanceDto,
UpdateBillDto,
UpdateSupplierDto,
} from "./dto/payables.dto";
/** Default control account when a supplier does not name its own. */
const TRADE_PAYABLES_CODE = "2111";
const WITHHOLDING_PAYABLE_CODE = "2124";
const VAT_PAYABLE_CODE = "2123";
/** Which liability account each statutory type clears. */
const STATUTORY_ACCOUNTS: Record<string, string> = {
INCOME_TAX: "2121",
PENSION: "2122",
VAT: "2123",
WITHHOLDING: "2124",
};
@Injectable()
export class PayablesService {
constructor(
private readonly suppliers: SuppliersRepository,
private readonly bills: SupplierBillsRepository,
private readonly billLines: SupplierBillLinesRepository,
private readonly payments: SupplierPaymentsRepository,
private readonly remittances: StatutoryRemittancesRepository,
private readonly accounts: AccountsService,
private readonly journals: JournalsService,
private readonly dataSource: DataSource,
) {}
// ── suppliers ────────────────────────────────────────────────────────────
listSuppliers(
actor: ActorContext,
filters: { search?: string; isActive?: boolean },
): Promise<Supplier[]> {
return this.suppliers.findAllForOrg(orgScope(actor), filters);
}
async createSupplier(
actor: ActorContext,
dto: CreateSupplierDto,
): Promise<Supplier> {
const organizationId = this.requireOrganization(actor);
const existing = await this.suppliers.findByCode(organizationId, dto.code);
if (existing) {
throw new ConflictException(`Supplier code ${dto.code} is already in use`);
}
if (dto.payableAccountId) {
const account = await this.accounts.findOne(actor, dto.payableAccountId);
if (account.accountType !== "LIABILITY") {
throw new BadRequestException(
`${account.code} is a ${account.accountType} account. A payable control account must be a LIABILITY.`,
);
}
}
return this.suppliers.create({
organizationId,
...dto,
isActive: true,
createdBy: actor.employeeId,
});
}
async updateSupplier(
actor: ActorContext,
id: string,
dto: UpdateSupplierDto,
): Promise<Supplier> {
await this.findSupplier(actor, id);
const updated = await this.suppliers.update(id, dto);
if (!updated) throw new NotFoundException("Supplier not found");
return updated;
}
async removeSupplier(
actor: ActorContext,
id: string,
): Promise<{ deleted: true }> {
const supplier = await this.findSupplier(actor, id);
const bills = await this.suppliers.countBills(id);
if (bills > 0) {
throw new BadRequestException(
`${supplier.name} has ${bills} bill(s) and cannot be deleted — the bills must stay explicable. Deactivate the supplier instead.`,
);
}
await this.suppliers.softDelete(id);
return { deleted: true };
}
async findSupplier(actor: ActorContext, id: string): Promise<Supplier> {
const supplier = await this.suppliers.findById(id);
if (!supplier) throw new NotFoundException("Supplier not found");
if (!actor.isSuperAdmin && supplier.organizationId !== actor.organizationId) {
throw new NotFoundException("Supplier not found");
}
return supplier;
}
// ── bills ────────────────────────────────────────────────────────────────
async listBills(
actor: ActorContext,
query: BillQueryDto,
): Promise<Paginated<SupplierBill>> {
const [items, total] = await this.bills.findPage(orgScope(actor), query);
return paginate(items, total, query.page ?? 1, query.limit ?? 25);
}
listBillsWithSupplier(actor: ActorContext) {
return this.bills.findPageWithSupplier(this.requireOrganization(actor));
}
async findBill(actor: ActorContext, id: string) {
const bill = await this.bills.findById(id);
if (!bill) throw new NotFoundException("Bill not found");
if (!actor.isSuperAdmin && bill.organizationId !== actor.organizationId) {
throw new NotFoundException("Bill not found");
}
const [lines, payments, supplier] = await Promise.all([
this.billLines.findByBillWithAccounts(id),
this.payments.findByBill(id),
this.suppliers.findById(bill.supplierId),
]);
return { ...bill, lines, payments, supplier };
}
/**
* Enters a bill as a DRAFT.
*
* A draft affects no balance — nothing reaches the ledger until someone with
* the separate `approve:supplier_bill` permission authorises it.
*/
async createBill(actor: ActorContext, dto: CreateBillDto) {
const organizationId = this.requireOrganization(actor);
const supplier = await this.findSupplier(actor, dto.supplierId);
if (!supplier.isActive) {
throw new BadRequestException(`${supplier.name} is inactive`);
}
if (dto.supplierInvoiceNumber) {
await this.assertInvoiceNotDuplicated(
organizationId,
dto.supplierId,
dto.supplierInvoiceNumber,
);
}
const lines = await this.normalizeLines(actor, dto.lines);
const totals = this.billTotals(lines, dto.taxAmount ?? 0);
const bill = await this.dataSource.transaction(async (manager) => {
const billNumber = await this.bills.nextBillNumber(
organizationId,
new Date(`${dto.billDate}T00:00:00Z`).getUTCFullYear(),
manager,
);
const repo = manager.getRepository(SupplierBill);
const saved = await repo.save(
repo.create({
organizationId,
supplierId: dto.supplierId,
billNumber,
supplierInvoiceNumber: dto.supplierInvoiceNumber ?? null,
billDate: dto.billDate,
dueDate: dto.dueDate ?? null,
currency: LEDGER_CURRENCY,
subtotal: totals.subtotal,
taxAmount: totals.taxAmount,
withholdingAmount: roundMoney(dto.withholdingAmount ?? 0),
totalAmount: totals.total,
paidAmount: 0,
status: "DRAFT" as const,
description: dto.description ?? null,
preparedBy: actor.employeeId,
}),
);
await this.billLines.replaceForBill(manager, saved.id, lines);
return saved;
});
return this.findBill(actor, bill.id);
}
async updateBill(actor: ActorContext, id: string, dto: UpdateBillDto) {
const bill = await this.bills.findById(id);
if (!bill) throw new NotFoundException("Bill not found");
if (!actor.isSuperAdmin && bill.organizationId !== actor.organizationId) {
throw new NotFoundException("Bill not found");
}
this.assertDraft(bill, "edited");
const lines = dto.lines
? await this.normalizeLines(actor, dto.lines)
: null;
const taxAmount = dto.taxAmount ?? Number(bill.taxAmount);
const totals = lines
? this.billTotals(lines, taxAmount)
: {
subtotal: Number(bill.subtotal),
taxAmount: roundMoney(taxAmount),
total: roundMoney(Number(bill.subtotal) + taxAmount),
};
if (
dto.supplierInvoiceNumber &&
dto.supplierInvoiceNumber !== bill.supplierInvoiceNumber
) {
await this.assertInvoiceNotDuplicated(
bill.organizationId,
bill.supplierId,
dto.supplierInvoiceNumber,
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(SupplierBill).update(id, {
...(dto.billDate ? { billDate: dto.billDate } : {}),
...(dto.dueDate !== undefined ? { dueDate: dto.dueDate } : {}),
...(dto.supplierInvoiceNumber !== undefined
? { supplierInvoiceNumber: dto.supplierInvoiceNumber }
: {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.withholdingAmount !== undefined
? { withholdingAmount: roundMoney(dto.withholdingAmount) }
: {}),
subtotal: totals.subtotal,
taxAmount: totals.taxAmount,
totalAmount: totals.total,
});
if (lines) await this.billLines.replaceForBill(manager, id, lines);
});
return this.findBill(actor, id);
}
/**
* Approves a bill and posts it to the ledger.
*
* Dr expense/asset accounts (the bill lines)
* Dr 2123 VAT Payable (recoverable input VAT, if any)
* Cr 2111 Trade Payables what the supplier is owed
* Cr 2124 Withholding Payable tax withheld and owed to the authority
*
* Withholding is the subtle part: the full expense is recognised, but the
* supplier is credited only with what they will actually receive, and the
* withheld portion becomes a liability to the revenue authority instead.
*/
async approveBill(actor: ActorContext, id: string) {
const bill = await this.bills.findById(id);
if (!bill) throw new NotFoundException("Bill not found");
if (!actor.isSuperAdmin && bill.organizationId !== actor.organizationId) {
throw new NotFoundException("Bill not found");
}
this.assertDraft(bill, "approved");
const lines = await this.billLines.findByBill(id);
if (lines.length === 0) {
throw new BadRequestException("A bill needs at least one line");
}
const supplier = await this.suppliers.findById(bill.supplierId);
const payableAccount = supplier?.payableAccountId
? await this.accounts.findOne(actor, supplier.payableAccountId)
: await this.requireAccount(actor, TRADE_PAYABLES_CODE);
const total = roundMoney(Number(bill.totalAmount));
const withholding = roundMoney(Number(bill.withholdingAmount));
const tax = roundMoney(Number(bill.taxAmount));
if (withholding > total) {
throw new BadRequestException(
`Withholding ${withholding.toFixed(2)} exceeds the bill total ${total.toFixed(2)}`,
);
}
const journalLines: {
accountId: string;
debit?: number;
credit?: number;
description: string;
}[] = lines.map((line) => ({
accountId: line.accountId,
debit: roundMoney(Number(line.amount)),
description: line.description ?? `Bill ${bill.billNumber}`,
}));
if (tax > 0) {
const vat = await this.requireAccount(actor, VAT_PAYABLE_CODE);
journalLines.push({
accountId: vat.id,
debit: tax,
description: "Input VAT",
});
}
journalLines.push({
accountId: payableAccount.id,
credit: roundMoney(total - withholding),
description: `${supplier?.name ?? "Supplier"}${bill.billNumber}`,
});
if (withholding > 0) {
const wht = await this.requireAccount(actor, WITHHOLDING_PAYABLE_CODE);
journalLines.push({
accountId: wht.id,
credit: withholding,
description: `Withheld on ${bill.billNumber}`,
});
}
const entry = await this.journals.createPosted(actor, {
entryDate: bill.billDate,
journalType: "PURCHASE",
memo: `${supplier?.name ?? "Supplier"} bill ${bill.billNumber}${
bill.supplierInvoiceNumber ? ` (inv ${bill.supplierInvoiceNumber})` : ""
}`,
reference: bill.supplierInvoiceNumber ?? bill.billNumber,
sourceModule: "supplier-bill",
sourceId: bill.id,
lines: journalLines,
});
await this.bills.update(id, {
status: "APPROVED",
journalEntryId: entry.id,
approvedBy: actor.employeeId,
approvedAt: new Date(),
});
return this.findBill(actor, id);
}
/**
* Records cash going out against an approved bill.
*
* Dr 2111 Trade Payables (the debt shrinks)
* Cr cash/bank (the money leaves)
*/
async recordPayment(
actor: ActorContext,
billId: string,
dto: RecordPaymentDto,
) {
const bill = await this.bills.findById(billId);
if (!bill) throw new NotFoundException("Bill not found");
if (!actor.isSuperAdmin && bill.organizationId !== actor.organizationId) {
throw new NotFoundException("Bill not found");
}
if (!["APPROVED", "PARTIALLY_PAID"].includes(bill.status)) {
throw new BadRequestException(
`Bill is ${bill.status}. Only an approved, unpaid bill can be paid — paying a draft would move cash for a debt nobody authorised.`,
);
}
const amount = roundMoney(dto.amount);
const outstanding = roundMoney(
Number(bill.totalAmount) - Number(bill.paidAmount),
);
if (amount > outstanding) {
throw new BadRequestException(
`Payment ${amount.toFixed(2)} exceeds the ${outstanding.toFixed(2)} still outstanding on ${bill.billNumber}`,
);
}
const cashAccount = await this.accounts.findOne(actor, dto.paidFromAccountId);
if (cashAccount.accountType !== "ASSET") {
throw new BadRequestException(
`${cashAccount.code} is a ${cashAccount.accountType} account — payment must come from an ASSET (cash or bank) account`,
);
}
const supplier = await this.suppliers.findById(bill.supplierId);
const payableAccount = supplier?.payableAccountId
? await this.accounts.findOne(actor, supplier.payableAccountId)
: await this.requireAccount(actor, TRADE_PAYABLES_CODE);
const result = await this.dataSource.transaction(async (manager) => {
const paymentNumber = await this.payments.nextPaymentNumber(
bill.organizationId,
new Date(`${dto.paymentDate}T00:00:00Z`).getUTCFullYear(),
manager,
);
const entry = await this.journals.createPosted(actor, {
entryDate: dto.paymentDate,
journalType: "CASH_PAYMENT",
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
reference: dto.reference ?? paymentNumber,
sourceModule: "supplier-payment",
sourceId: `${bill.id}:${paymentNumber}`,
lines: [
{
accountId: payableAccount.id,
debit: amount,
description: `Settle ${bill.billNumber}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
},
],
});
const repo = manager.getRepository(SupplierPayment);
const payment = await repo.save(
repo.create({
organizationId: bill.organizationId,
supplierBillId: bill.id,
paymentNumber,
paymentDate: dto.paymentDate,
amount,
method: dto.method,
paidFromAccountId: cashAccount.id,
reference: dto.reference ?? null,
journalEntryId: entry.id,
recordedBy: actor.employeeId,
}),
);
const paidAmount = roundMoney(Number(bill.paidAmount) + amount);
await manager.getRepository(SupplierBill).update(bill.id, {
paidAmount,
status:
paidAmount >= roundMoney(Number(bill.totalAmount))
? "PAID"
: "PARTIALLY_PAID",
});
return payment;
});
return { payment: result, bill: await this.findBill(actor, billId) };
}
agingAsOf(actor: ActorContext, asOf: string) {
return this.bills.agingAsOf(this.requireOrganization(actor), asOf);
}
// ── statutory remittances ────────────────────────────────────────────────
/** What each statutory liability account still owes, read off the ledger. */
outstandingStatutory(actor: ActorContext) {
return this.remittances.outstandingByAccount(
this.requireOrganization(actor),
);
}
listRemittances(actor: ActorContext): Promise<StatutoryRemittance[]> {
return this.remittances.findAllForOrg(this.requireOrganization(actor));
}
/**
* Records a statutory liability being handed over.
*
* Dr 212x liability (the obligation is discharged)
* Cr cash/bank (the money leaves)
*
* The unique index on (organization, type, period) is what stops the same
* month's PAYE being paid twice — an expensive and entirely real mistake.
*/
async recordRemittance(actor: ActorContext, dto: RecordRemittanceDto) {
const organizationId = this.requireOrganization(actor);
const liabilityCode = STATUTORY_ACCOUNTS[dto.statutoryType];
const [liability, cashAccount] = await Promise.all([
this.requireAccount(actor, liabilityCode),
this.accounts.findOne(actor, dto.paidFromAccountId),
]);
if (cashAccount.accountType !== "ASSET") {
throw new BadRequestException(
`${cashAccount.code} is a ${cashAccount.accountType} account — remittance must come from an ASSET (cash or bank) account`,
);
}
const amount = roundMoney(dto.amount);
// Warn-by-refusing when more is being paid than the ledger says is owed:
// that means either the liability was never posted or the amount is wrong,
// and both are worth stopping for.
const outstanding = await this.remittances.outstandingByAccount(organizationId);
const row = outstanding.find((r) => r.accountCode === liabilityCode);
const owed = roundMoney(Number(row?.outstanding ?? 0));
if (amount > owed) {
throw new BadRequestException(
`${liabilityCode} shows ${owed.toFixed(2)} outstanding but ${amount.toFixed(2)} is being remitted. Post the liability first, or correct the amount.`,
);
}
const existing = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM finance.statutory_remittances
WHERE organization_id = $1 AND statutory_type = $2 AND period = $3`,
[organizationId, dto.statutoryType, dto.period],
);
if (existing.length > 0) {
throw new ConflictException(
`${dto.statutoryType} for ${dto.period} has already been remitted`,
);
}
const entry = await this.journals.createPosted(actor, {
entryDate: dto.paidDate,
journalType: "CASH_PAYMENT",
memo: `${dto.statutoryType} remittance for ${dto.period}`,
reference: dto.receiptNumber ?? dto.reference,
sourceModule: "statutory-remittance",
sourceId: `${dto.statutoryType}:${dto.period}`,
lines: [
{
accountId: liability.id,
debit: amount,
description: `${dto.statutoryType} ${dto.period}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: dto.receiptNumber ?? dto.reference ?? "Remittance",
},
],
});
return this.remittances.create({
organizationId,
statutoryType: dto.statutoryType,
period: dto.period,
liabilityAccountId: liability.id,
amount,
paidDate: dto.paidDate,
paidFromAccountId: cashAccount.id,
reference: dto.reference ?? null,
receiptNumber: dto.receiptNumber ?? null,
journalEntryId: entry.id,
recordedBy: actor.employeeId,
notes: dto.notes ?? null,
});
}
// ── internals ────────────────────────────────────────────────────────────
private async normalizeLines(
actor: ActorContext,
lines: CreateBillDto["lines"],
) {
const normalized = [];
for (const [index, line] of lines.entries()) {
const amount = roundMoney(line.amount);
if (!(amount > 0)) {
throw new BadRequestException(`Line ${index + 1} must carry an amount`);
}
// Every line must be postable — the same rule a journal line obeys, so a
// bill cannot be approved into a group or inactive account.
const account = await this.accounts.assertPostable(actor, line.accountId);
normalized.push({
accountId: account.id,
description: line.description ?? null,
quantity: line.quantity ?? 1,
unitPrice: roundMoney(line.unitPrice ?? 0),
amount,
costCenterId: line.costCenterId ?? null,
});
}
return normalized;
}
private billTotals(
lines: { amount: number }[],
taxAmount: number,
): { subtotal: number; taxAmount: number; total: number } {
const subtotal = roundMoney(
lines.reduce((sum, line) => sum + line.amount, 0),
);
const tax = roundMoney(taxAmount);
return { subtotal, taxAmount: tax, total: roundMoney(subtotal + tax) };
}
private async assertInvoiceNotDuplicated(
organizationId: string,
supplierId: string,
supplierInvoiceNumber: string,
): Promise<void> {
const rows = await this.dataSource.query<{ bill_number: string }[]>(
`SELECT bill_number FROM finance.supplier_bills
WHERE organization_id = $1 AND supplier_id = $2
AND supplier_invoice_number = $3 AND deleted_at IS NULL`,
[organizationId, supplierId, supplierInvoiceNumber],
);
if (rows.length > 0) {
throw new ConflictException(
`Invoice ${supplierInvoiceNumber} from this supplier is already entered as ${rows[0].bill_number}. Entering it twice is how an invoice gets paid twice.`,
);
}
}
private assertDraft(bill: SupplierBill, verb: string): void {
if (bill.status === "DRAFT") return;
throw new ForbiddenException(
`${bill.billNumber} is ${bill.status} and can no longer be ${verb}. Reverse its journal entry to correct it.`,
);
}
private async requireAccount(actor: ActorContext, code: string) {
const organizationId = this.requireOrganization(actor);
const account = await this.accounts.findByCode(organizationId, code);
if (!account) {
throw new BadRequestException(
`Account ${code} is missing from this organization's chart. Seed the chart of accounts first.`,
);
}
return account;
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot manage payables",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,455 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { LEDGER_CURRENCY, roundMoney } from "../../common/money";
import { AccountsService } from "../accounts/accounts.service";
import { JournalsService } from "../journals/journals.service";
/**
* Accounts the payroll posting resolves BY CODE. All are `is_system` in the
* chart so they cannot be deleted or deactivated out from under this.
*/
const ACCOUNTS = {
basicSalary: "5110",
allowances: "5120",
pensionEmployer: "5140",
incomeTaxPayable: "2121",
pensionPayable: "2122",
salariesPayable: "2130",
accruedExpenses: "2160",
} as const;
export type PayrollRunSummary = {
id: string;
periodStart: string;
periodEnd: string;
paymentDate: string | null;
status: string;
employeeCount: number;
totalGross: number;
totalDeductions: number;
totalNet: number;
totalIncomeTax: number;
totalPensionEmployee: number;
totalPensionEmployer: number;
/** The journal entry this run was posted as, if it has been. */
journalEntryId: string | null;
entryNumber: string | null;
};
export type DisbursementRow = {
employeeId: string;
employeeNumber: string;
employeeName: string;
salaryMode: string;
bankAccount: string | null;
netPay: number;
};
export type DisbursementRegister = {
runId: string;
periodStart: string;
periodEnd: string;
paymentDate: string | null;
byMode: { salaryMode: string; count: number; total: number }[];
rows: DisbursementRow[];
total: number;
};
const num = (value: unknown): number => Number(value ?? 0);
@Injectable()
export class PayrollPostingService {
constructor(
private readonly dataSource: DataSource,
private readonly accounts: AccountsService,
private readonly journals: JournalsService,
) {}
/** Whether HR's payroll tables are readable in this deployment. */
async available(): Promise<boolean> {
const [row] = await this.dataSource.query<{ ok: boolean }[]>(
`SELECT to_regclass('hr.payroll_runs') IS NOT NULL
AND to_regclass('hr.payslips') IS NOT NULL AS ok`,
);
return Boolean(row?.ok);
}
/**
* Approved payroll runs, with whether Finance has already posted each.
*
* Only APPROVED and PAID runs are listed. A DRAFT or CALCULATED run can still
* change, and posting one would put figures in the ledger that HR may then
* recalculate — the ledger would be right about a payroll that no longer
* exists.
*/
async listRuns(actor: ActorContext): Promise<PayrollRunSummary[]> {
if (!(await this.available())) return [];
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
// `::text` on every DATE — see loadRun for why a JS Date round-trip
// silently shifts the day east of UTC.
`SELECT r.id,
r.period_start::text AS "periodStart",
r.period_end::text AS "periodEnd",
r.payment_date::text AS "paymentDate",
r.status,
r.employee_count AS "employeeCount",
r.total_gross AS "totalGross",
r.total_deductions AS "totalDeductions",
r.total_net AS "totalNet",
r.total_income_tax AS "totalIncomeTax",
r.total_pension_employee AS "totalPensionEmployee",
r.total_pension_employer AS "totalPensionEmployer",
e.id AS "journalEntryId",
e.entry_number AS "entryNumber"
FROM hr.payroll_runs r
LEFT JOIN finance.journal_entries e
ON e.organization_id = r.organization_id
AND e.source_module = 'hr-payroll'
AND e.source_id = r.id::text
AND e.deleted_at IS NULL
WHERE r.organization_id = $1
AND r.deleted_at IS NULL
AND r.status IN ('APPROVED', 'PAID')
ORDER BY r.period_start DESC`,
[organizationId],
);
return rows.map((row) => ({
id: String(row.id),
periodStart: String(row.periodStart),
periodEnd: String(row.periodEnd),
paymentDate: row.paymentDate ? String(row.paymentDate) : null,
status: String(row.status),
employeeCount: num(row.employeeCount),
totalGross: num(row.totalGross),
totalDeductions: num(row.totalDeductions),
totalNet: num(row.totalNet),
totalIncomeTax: num(row.totalIncomeTax),
totalPensionEmployee: num(row.totalPensionEmployee),
totalPensionEmployer: num(row.totalPensionEmployer),
journalEntryId: row.journalEntryId ? String(row.journalEntryId) : null,
entryNumber: row.entryNumber ? String(row.entryNumber) : null,
}));
}
/**
* The salary disbursement register — who gets paid what, and how.
*
* HR calculates payslips but produces no such list, so the payment run is
* assembled by hand today. It reads through to `iam.employees` for the name
* rather than storing a copy, which is the same rule HR itself follows.
*/
async disbursementRegister(
actor: ActorContext,
runId: string,
): Promise<DisbursementRegister> {
if (!(await this.available())) {
throw new BadRequestException(
"HR payroll tables are not present in this database",
);
}
const organizationId = this.requireOrganization(actor);
const run = await this.loadRun(organizationId, runId);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
// `iam.employees.name` is a single JSONB `{am, en}` — there are no
// first/middle/last columns. Falls back to the Amharic form so a name
// recorded in only one language still prints on the payment list.
`SELECT p.employee_id AS "employeeId",
p.employee_number AS "employeeNumber",
COALESCE(emp.name->>'en', emp.name->>'am', '') AS "employeeName",
COALESCE(p.salary_mode, 'BANK') AS "salaryMode",
p.bank_account AS "bankAccount",
p.net_pay AS "netPay"
FROM hr.payslips p
LEFT JOIN iam.employees emp ON emp.id = p.employee_id
WHERE p.payroll_run_id = $1
ORDER BY p.employee_number ASC`,
[runId],
);
const register: DisbursementRow[] = rows.map((row) => ({
employeeId: String(row.employeeId),
employeeNumber: String(row.employeeNumber ?? ""),
employeeName: String(row.employeeName ?? "").trim(),
salaryMode: String(row.salaryMode),
bankAccount: row.bankAccount ? String(row.bankAccount) : null,
netPay: num(row.netPay),
}));
const byMode = new Map<string, { count: number; total: number }>();
for (const row of register) {
const entry = byMode.get(row.salaryMode) ?? { count: 0, total: 0 };
entry.count += 1;
entry.total = roundMoney(entry.total + row.netPay);
byMode.set(row.salaryMode, entry);
}
return {
runId,
periodStart: run.periodStart,
periodEnd: run.periodEnd,
paymentDate: run.paymentDate,
byMode: [...byMode.entries()].map(([salaryMode, v]) => ({
salaryMode,
...v,
})),
rows: register,
total: roundMoney(register.reduce((sum, row) => sum + row.netPay, 0)),
};
}
/**
* Posts an approved payroll run to the general ledger.
*
* Dr 5110 Basic Salary basic pay
* Dr 5120 Allowances everything else earned
* Dr 5140 Pension — Employer the employer's own contribution
* Cr 2121 Income Tax Payable PAYE withheld from staff
* Cr 2122 Pension Payable employee + employer, owed to the fund
* Cr 2130 Salaries Payable net pay owed to staff
* Cr 2160 Accrued Expenses any other deduction withheld
*
* The employer's pension is BOTH a debit (our cost) and part of the credit to
* the fund; the employee's is only a credit, because it comes out of pay
* already counted in gross. Getting that wrong is the classic payroll posting
* error — it still balances, which is why the identity below is checked
* explicitly rather than trusted.
*/
async postRun(
actor: ActorContext,
runId: string,
): Promise<{ entryNumber: string; journalEntryId: string; total: number }> {
if (!(await this.available())) {
throw new BadRequestException(
"HR payroll tables are not present in this database",
);
}
const organizationId = this.requireOrganization(actor);
const run = await this.loadRun(organizationId, runId);
if (!["APPROVED", "PAID"].includes(run.status)) {
throw new BadRequestException(
`Payroll run is ${run.status}. Only an APPROVED run can be posted — a run that can still be recalculated would leave the ledger describing a payroll that no longer exists.`,
);
}
const existing = await this.journals.findBySource(
organizationId,
"hr-payroll",
runId,
);
if (existing) {
throw new BadRequestException(
`This payroll run is already posted as ${existing.entryNumber}. Reverse that entry if it was wrong.`,
);
}
// Aggregate from the PAYSLIPS rather than the run header: the payslips are
// what was actually calculated per employee, and a header total that has
// drifted from them is exactly the kind of error worth catching here.
const [totals] = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT COUNT(*)::int AS "payslipCount",
COALESCE(SUM(p.basic_salary),0) AS "basic",
COALESCE(SUM(p.gross_pay),0) AS "gross",
COALESCE(SUM(p.income_tax),0) AS "incomeTax",
COALESCE(SUM(p.pension_employee),0) AS "pensionEmployee",
COALESCE(SUM(p.pension_employer),0) AS "pensionEmployer",
COALESCE(SUM(p.total_deductions),0) AS "totalDeductions",
COALESCE(SUM(p.net_pay),0) AS "netPay"
FROM hr.payslips p
WHERE p.payroll_run_id = $1`,
[runId],
);
const payslipCount = num(totals?.payslipCount);
if (payslipCount === 0) {
throw new BadRequestException(
"This payroll run has no payslips — there is nothing to post",
);
}
const basic = roundMoney(num(totals?.basic));
const gross = roundMoney(num(totals?.gross));
const incomeTax = roundMoney(num(totals?.incomeTax));
const pensionEmployee = roundMoney(num(totals?.pensionEmployee));
const pensionEmployer = roundMoney(num(totals?.pensionEmployer));
const totalDeductions = roundMoney(num(totals?.totalDeductions));
const netPay = roundMoney(num(totals?.netPay));
// Everything earned that is not basic pay.
const allowances = roundMoney(gross - basic);
// Whatever was withheld beyond tax and pension (loans, union dues…).
const otherDeductions = roundMoney(
totalDeductions - incomeTax - pensionEmployee,
);
this.assertPayrollIdentity({
gross,
totalDeductions,
netPay,
allowances,
otherDeductions,
});
const [
basicAcct,
allowanceAcct,
pensionExpense,
taxPayable,
pensionPayable,
salariesPayable,
accrued,
] = await Promise.all([
this.requireAccount(actor, ACCOUNTS.basicSalary),
this.requireAccount(actor, ACCOUNTS.allowances),
this.requireAccount(actor, ACCOUNTS.pensionEmployer),
this.requireAccount(actor, ACCOUNTS.incomeTaxPayable),
this.requireAccount(actor, ACCOUNTS.pensionPayable),
this.requireAccount(actor, ACCOUNTS.salariesPayable),
this.requireAccount(actor, ACCOUNTS.accruedExpenses),
]);
// Zero-value lines are omitted: the ledger's own CHECK forbids a line with
// neither side, and a nil allowance line would be noise on every payslip
// run where nobody claimed one.
const lines: {
accountId: string;
debit?: number;
credit?: number;
description: string;
}[] = [];
const push = (
accountId: string,
side: "debit" | "credit",
amount: number,
description: string,
) => {
if (amount > 0) lines.push({ accountId, [side]: amount, description });
};
push(basicAcct.id, "debit", basic, "Basic salary");
push(allowanceAcct.id, "debit", allowances, "Allowances and other earnings");
push(pensionExpense.id, "debit", pensionEmployer, "Employer pension contribution");
push(taxPayable.id, "credit", incomeTax, "PAYE withheld");
push(
pensionPayable.id,
"credit",
roundMoney(pensionEmployee + pensionEmployer),
"Pension owed to the fund (employee + employer)",
);
push(salariesPayable.id, "credit", netPay, "Net pay owed to staff");
push(accrued.id, "credit", otherDeductions, "Other deductions withheld");
const entry = await this.journals.createPosted(actor, {
// Dated the period END, not the payment date: the cost belongs to the
// month worked, even when the money leaves in the following one.
entryDate: run.periodEnd,
journalType: "PAYROLL",
memo: `Payroll ${run.periodStart}${run.periodEnd} (${payslipCount} employees)`,
reference: run.paymentDate ?? undefined,
sourceModule: "hr-payroll",
sourceId: runId,
lines,
});
return {
entryNumber: entry.entryNumber,
journalEntryId: entry.id,
total: roundMoney(gross + pensionEmployer),
};
}
/**
* The arithmetic a payroll must satisfy before it can be posted.
*
* `gross - totalDeductions = netPay` is the whole of payroll in one line. If
* it does not hold, the numbers upstream disagree with each other and posting
* them would put a balanced but wrong entry in the ledger — the hardest kind
* of error to find later.
*/
private assertPayrollIdentity(totals: {
gross: number;
totalDeductions: number;
netPay: number;
allowances: number;
otherDeductions: number;
}): void {
const implied = roundMoney(totals.gross - totals.totalDeductions);
if (Math.abs(implied - totals.netPay) >= 0.005) {
throw new BadRequestException(
`Payroll does not reconcile: gross ${totals.gross.toFixed(2)} deductions ${totals.totalDeductions.toFixed(2)} = ${implied.toFixed(2)}, but net pay is ${totals.netPay.toFixed(2)}. Fix the payroll run before posting it.`,
);
}
if (totals.allowances < 0) {
throw new BadRequestException(
`Basic salary exceeds gross pay by ${Math.abs(totals.allowances).toFixed(2)} — the payroll figures are inconsistent`,
);
}
if (totals.otherDeductions < 0) {
throw new BadRequestException(
`Income tax and pension exceed total deductions by ${Math.abs(totals.otherDeductions).toFixed(2)} — the payroll figures are inconsistent`,
);
}
}
private async loadRun(
organizationId: string,
runId: string,
): Promise<{
periodStart: string;
periodEnd: string;
paymentDate: string | null;
status: string;
currency: string;
}> {
// DATE columns are cast to text IN SQL rather than parsed into a JS Date.
// node-postgres turns a DATE into local midnight, so east of UTC
// `toISOString().slice(0,10)` yields the PREVIOUS day — a payroll for a
// period ending 31 Aug posted as 30 Aug, which can land it in the wrong
// fiscal period entirely. Casting in SQL removes the timezone from the
// path completely instead of correcting for it.
const [run] = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT period_start::text AS "periodStart",
period_end::text AS "periodEnd",
payment_date::text AS "paymentDate",
status
FROM hr.payroll_runs
WHERE id = $1 AND organization_id = $2 AND deleted_at IS NULL`,
[runId, organizationId],
);
if (!run) throw new BadRequestException("Payroll run not found");
return {
periodStart: String(run.periodStart),
periodEnd: String(run.periodEnd),
paymentDate: run.paymentDate ? String(run.paymentDate) : null,
status: String(run.status),
currency: LEDGER_CURRENCY,
};
}
private async requireAccount(actor: ActorContext, code: string) {
const organizationId = this.requireOrganization(actor);
const account = await this.accounts.findByCode(organizationId, code);
if (!account) {
throw new BadRequestException(
`Account ${code} is missing from this organization's chart. Seed the chart of accounts before posting payroll.`,
);
}
return account;
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot post finance records",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,68 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsInt,
IsISO8601,
IsObject,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedNameDto } from "../../accounts/dto/account.dto";
export class CreateFiscalYearDto {
@ApiProperty({ example: "2018EC" })
@IsString()
@MaxLength(32)
@Matches(/^[A-Za-z0-9_-]+$/, {
message: "code may contain only letters, digits, hyphen and underscore",
})
code!: string;
@ApiProperty({ type: LocalizedNameDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedNameDto)
name!: LocalizedNameDto;
/**
* The Ethiopian fiscal year runs 8 July 7 July, but the dates are supplied
* rather than assumed: this is configuration, not a constant, so a change of
* statute is a new row instead of a new release.
*/
@ApiProperty({ example: "2026-07-08", description: "ISO date (YYYY-MM-DD)" })
@IsISO8601()
startDate!: string;
@ApiProperty({ example: "2027-07-07", description: "ISO date (YYYY-MM-DD)" })
@IsISO8601()
endDate!: string;
/**
* How many periods to generate across the year. Defaults to 12 calendar
* months; 13 expresses the Ethiopian calendar's Pagume as its own period.
*/
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 13 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(13)
periodCount?: number = 12;
}
export class ClosePeriodDto {
@ApiPropertyOptional({
description:
"LOCKED seals the period permanently — it can never be reopened. Use CLOSED for a routine month-end.",
enum: ["CLOSED", "LOCKED"],
})
@IsOptional()
@IsString()
status?: "CLOSED" | "LOCKED";
}

View File

@@ -0,0 +1,69 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
/**
* OPEN — entries may be posted into it.
* CLOSED — no new postings; reopenable by someone holding `close:fiscal_period`.
* LOCKED — permanently sealed (the year has been reported or audited). A LOCKED
* period is never reopened, so a correction to it must be posted as a
* dated entry in an open period instead. This distinction is the whole
* point of having two closed states.
*/
export const FISCAL_PERIOD_STATUSES = ["OPEN", "CLOSED", "LOCKED"] as const;
export type FiscalPeriodStatus = (typeof FISCAL_PERIOD_STATUSES)[number];
/**
* One posting window inside a fiscal year.
*
* Every journal entry names exactly one period, resolved from its entry date.
* That is what makes "close the month" mean something: once the period is not
* OPEN, nothing further can land in it, and a report over it cannot change
* afterwards.
*/
@Entity({ schema: "finance", name: "fiscal_periods" })
@Unique("uq_fiscal_periods_year_number", ["fiscalYearId", "periodNumber"])
@Index("idx_fiscal_periods_year_id", ["fiscalYearId"])
@Index("idx_fiscal_periods_dates", ["startDate", "endDate"])
@Check("ck_fiscal_periods_status", `"status" IN ('OPEN','CLOSED','LOCKED')`)
@Check("ck_fiscal_periods_range", `"end_date" >= "start_date"`)
@Check("ck_fiscal_periods_number", `"period_number" >= 1`)
export class FiscalPeriod extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fiscal_year_id" })
fiscalYearId!: string;
/** 1-based within the year. Not capped at 12 — see FiscalYear's note. */
@Column({ type: "int", name: "period_number" })
periodNumber!: number;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "date", name: "start_date" })
startDate!: string;
@Column({ type: "date", name: "end_date" })
endDate!: string;
@Column({ type: "varchar", length: 16, name: "status", default: "OPEN" })
status!: FiscalPeriodStatus;
@Column({ type: "timestamptz", name: "closed_at", nullable: true })
closedAt?: Date | null;
/** `iam.employees.id` of whoever closed it — part of the audit trail. */
@Column({ type: "uuid", name: "closed_by", nullable: true })
closedBy?: string | null;
}

View File

@@ -0,0 +1,57 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
export const FISCAL_YEAR_STATUSES = ["OPEN", "CLOSED"] as const;
export type FiscalYearStatus = (typeof FISCAL_YEAR_STATUSES)[number];
/**
* An accounting year.
*
* The Ethiopian fiscal year runs **8 July 7 July** (Hamle 1 Sene 30), which
* is why the boundary dates are stored as DATA rather than derived from the
* calendar year in code: a government that moves the year-end should be a row
* change, not a deploy. Nothing here assumes 12 months either — the period
* generator takes a count, so a 13-month layout (matching the Ethiopian
* calendar's Pagume) is expressible without touching this table.
*
* Dates are `date`, not `timestamptz`. A fiscal boundary is a calendar fact and
* must not shift with the reader's time zone.
*/
@Entity({ schema: "finance", name: "fiscal_years" })
@Unique("uq_fiscal_years_org_code", ["organizationId", "code"])
@Index("idx_fiscal_years_organization_id", ["organizationId"])
@Check("ck_fiscal_years_status", `"status" IN ('OPEN','CLOSED')`)
@Check("ck_fiscal_years_range", `"end_date" > "start_date"`)
export class FiscalYear extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
/** e.g. "2018EC" or "2025-26". Unique per organization. */
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "date", name: "start_date" })
startDate!: string;
@Column({ type: "date", name: "end_date" })
endDate!: string;
@Column({ type: "varchar", length: 16, name: "status", default: "OPEN" })
status!: FiscalYearStatus;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,86 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { PeriodsService } from "./periods.service";
import { ClosePeriodDto, CreateFiscalYearDto } from "./dto/period.dto";
@ApiTags("fiscal-calendar")
@ApiBearerAuth()
@Controller("fiscal")
@FinanceStaff([
FINANCE_PERMS.period.view,
FINANCE_PERMS.period.manage,
FINANCE_PERMS.period.close,
])
export class PeriodsController {
constructor(private readonly periods: PeriodsService) {}
@Get("years")
@ApiOperation({ summary: "Fiscal years" })
@FinanceStaff(FINANCE_PERMS.period.view)
listYears(@CurrentUser() user: TCurrentUser) {
return this.periods.listYears(actorFrom(user));
}
@Get("periods")
@ApiOperation({ summary: "Fiscal periods, optionally for one year" })
@FinanceStaff(FINANCE_PERMS.period.view)
listPeriods(
@CurrentUser() user: TCurrentUser,
@Query("fiscalYearId") fiscalYearId?: string,
) {
const actor = actorFrom(user);
return fiscalYearId
? this.periods.listPeriodsOfYear(actor, fiscalYearId)
: this.periods.listPeriods(actor);
}
@Post("years")
@ApiOperation({
summary:
"Open a fiscal year and generate its periods (one transaction — a year without periods is unusable)",
})
@FinanceStaff(FINANCE_PERMS.period.manage)
createYear(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateFiscalYearDto,
) {
return this.periods.createYear(actorFrom(user), dto);
}
@Post("periods/:id/close")
@ApiOperation({
summary: "Close a period (CLOSED is reopenable; LOCKED is permanent)",
})
@FinanceStaff(FINANCE_PERMS.period.close)
close(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ClosePeriodDto,
) {
return this.periods.closePeriod(actorFrom(user), id, dto);
}
@Post("periods/:id/reopen")
@ApiOperation({ summary: "Reopen a CLOSED period (never a LOCKED one)" })
@FinanceStaff(FINANCE_PERMS.period.close)
reopen(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.periods.reopenPeriod(actorFrom(user), id);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FiscalYear } from "./entities/fiscal-year.entity";
import { FiscalPeriod } from "./entities/fiscal-period.entity";
import {
FiscalPeriodsRepository,
FiscalYearsRepository,
} from "./periods.repository";
import { PeriodsService } from "./periods.service";
import { PeriodsController } from "./periods.controller";
@Module({
imports: [TypeOrmModule.forFeature([FiscalYear, FiscalPeriod])],
controllers: [PeriodsController],
providers: [FiscalYearsRepository, FiscalPeriodsRepository, PeriodsService],
// Journals resolve every entry date to an OPEN period through this service —
// the single gate that makes closing a period mean anything.
exports: [FiscalYearsRepository, FiscalPeriodsRepository, PeriodsService],
})
export class PeriodsModule {}

View File

@@ -0,0 +1,121 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { FiscalYear } from "./entities/fiscal-year.entity";
import { FiscalPeriod } from "./entities/fiscal-period.entity";
@Injectable()
export class FiscalYearsRepository extends BaseRepository<FiscalYear> {
constructor(@InjectRepository(FiscalYear) repository: Repository<FiscalYear>) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<FiscalYear | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
findAllForOrg(organizationId: string | null): Promise<FiscalYear[]> {
const qb = this.repository.createQueryBuilder("year").where("1 = 1");
if (organizationId) {
qb.andWhere("year.organization_id = :organizationId", { organizationId });
}
return qb.orderBy("year.start_date", "DESC").getMany();
}
/**
* Any existing year whose dates overlap the proposed range.
*
* Overlapping years would make a date resolve to two periods, and the entry
* would land in whichever the query happened to return first.
*/
findOverlapping(
organizationId: string,
startDate: string,
endDate: string,
excludeId?: string,
): Promise<FiscalYear | null> {
const qb = this.repository
.createQueryBuilder("year")
.where("year.organization_id = :organizationId", { organizationId })
// Two ranges overlap unless one ends before the other starts.
.andWhere("year.start_date <= :endDate", { endDate })
.andWhere("year.end_date >= :startDate", { startDate });
if (excludeId) qb.andWhere("year.id <> :excludeId", { excludeId });
return qb.getOne();
}
}
@Injectable()
export class FiscalPeriodsRepository extends BaseRepository<FiscalPeriod> {
constructor(
@InjectRepository(FiscalPeriod) repository: Repository<FiscalPeriod>,
) {
super(repository);
}
findByYear(fiscalYearId: string): Promise<FiscalPeriod[]> {
return this.repository.find({
where: { fiscalYearId },
order: { periodNumber: "ASC" },
});
}
findAllForOrg(organizationId: string | null): Promise<FiscalPeriod[]> {
const qb = this.repository.createQueryBuilder("period").where("1 = 1");
if (organizationId) {
qb.andWhere("period.organization_id = :organizationId", {
organizationId,
});
}
return qb.orderBy("period.start_date", "DESC").getMany();
}
/**
* The period a given date falls in — how every journal entry finds its home.
*
* Returns null when the date is outside every defined year, which the caller
* must treat as "you have not set up that year yet", not as "use the nearest
* one".
*/
findByDate(
organizationId: string,
date: string,
): Promise<FiscalPeriod | null> {
return this.repository
.createQueryBuilder("period")
.where("period.organization_id = :organizationId", { organizationId })
.andWhere("period.start_date <= :date", { date })
.andWhere("period.end_date >= :date", { date })
.orderBy("period.start_date", "ASC")
.getOne();
}
/** Posted entries in a period — what makes closing it meaningful. */
async countPostedEntries(fiscalPeriodId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM finance.journal_entries
WHERE fiscal_period_id = $1
AND status <> 'DRAFT'
AND deleted_at IS NULL`,
[fiscalPeriodId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
/** Drafts blocking a close — they would be stranded in a closed period. */
async countDraftEntries(fiscalPeriodId: string): Promise<number> {
const rows = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM finance.journal_entries
WHERE fiscal_period_id = $1
AND status = 'DRAFT'
AND deleted_at IS NULL`,
[fiscalPeriodId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
}

View File

@@ -0,0 +1,331 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import {
FiscalPeriodsRepository,
FiscalYearsRepository,
} from "./periods.repository";
import { FiscalYear } from "./entities/fiscal-year.entity";
import { FiscalPeriod } from "./entities/fiscal-period.entity";
import type { ClosePeriodDto, CreateFiscalYearDto } from "./dto/period.dto";
/** A day in ISO form — the only date shape this service moves around. */
const isoDate = (date: Date): string => date.toISOString().slice(0, 10);
const MONTH_NAMES_EN = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
@Injectable()
export class PeriodsService {
constructor(
private readonly years: FiscalYearsRepository,
private readonly periods: FiscalPeriodsRepository,
private readonly dataSource: DataSource,
) {}
listYears(actor: ActorContext): Promise<FiscalYear[]> {
return this.years.findAllForOrg(orgScope(actor));
}
listPeriods(actor: ActorContext): Promise<FiscalPeriod[]> {
return this.periods.findAllForOrg(orgScope(actor));
}
async listPeriodsOfYear(
actor: ActorContext,
fiscalYearId: string,
): Promise<FiscalPeriod[]> {
await this.findYear(actor, fiscalYearId);
return this.periods.findByYear(fiscalYearId);
}
async findYear(actor: ActorContext, id: string): Promise<FiscalYear> {
const year = await this.years.findById(id);
if (!year) throw new NotFoundException("Fiscal year not found");
if (!actor.isSuperAdmin && year.organizationId !== actor.organizationId) {
throw new NotFoundException("Fiscal year not found");
}
return year;
}
async findPeriod(actor: ActorContext, id: string): Promise<FiscalPeriod> {
const period = await this.periods.findById(id);
if (!period) throw new NotFoundException("Fiscal period not found");
if (!actor.isSuperAdmin && period.organizationId !== actor.organizationId) {
throw new NotFoundException("Fiscal period not found");
}
return period;
}
/**
* Creates a year and generates its periods in ONE transaction.
*
* A year without periods is unusable — nothing could be posted into it —
* so a partial creation is worse than no creation at all.
*/
async createYear(
actor: ActorContext,
dto: CreateFiscalYearDto,
): Promise<{ year: FiscalYear; periods: FiscalPeriod[] }> {
const organizationId = this.resolveOrganizationId(actor);
if (dto.endDate <= dto.startDate) {
throw new BadRequestException("endDate must be after startDate");
}
const duplicate = await this.years.findByCode(organizationId, dto.code);
if (duplicate) {
throw new ConflictException(
`Fiscal year ${dto.code} already exists in this organization`,
);
}
const overlapping = await this.years.findOverlapping(
organizationId,
dto.startDate,
dto.endDate,
);
if (overlapping) {
throw new ConflictException(
`Dates overlap fiscal year ${overlapping.code} (${overlapping.startDate}${overlapping.endDate}). A date must resolve to exactly one period.`,
);
}
const slices = this.splitIntoPeriods(
dto.startDate,
dto.endDate,
dto.periodCount ?? 12,
);
return this.dataSource.transaction(async (manager) => {
const year = await manager.getRepository(FiscalYear).save(
manager.getRepository(FiscalYear).create({
organizationId,
code: dto.code,
name: dto.name,
startDate: dto.startDate,
endDate: dto.endDate,
status: "OPEN",
createdBy: actor.employeeId,
}),
);
const periodRepo = manager.getRepository(FiscalPeriod);
const periods = await periodRepo.save(
slices.map((slice, index) =>
periodRepo.create({
organizationId,
fiscalYearId: year.id,
periodNumber: index + 1,
name: slice.name,
startDate: slice.startDate,
endDate: slice.endDate,
status: "OPEN" as const,
}),
),
);
return { year, periods };
});
}
/**
* Splits a year into contiguous periods that exactly cover it.
*
* Contiguity matters more than equal length: every day of the year must fall
* in exactly one period, or a legitimate entry date would have nowhere to go.
* Calendar-month boundaries are used when the count is 12, since that is what
* a month-end close means; otherwise the span is divided evenly and the final
* period absorbs the remainder so the last day is always covered.
*/
private splitIntoPeriods(
startDate: string,
endDate: string,
count: number,
): { name: { am: string; en: string }; startDate: string; endDate: string }[] {
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
if (count === 12) {
const slices: {
name: { am: string; en: string };
startDate: string;
endDate: string;
}[] = [];
let cursor = start;
for (let index = 0; index < 12; index += 1) {
const isLast = index === 11;
// The day before the same day-of-month one month on — so a year that
// starts on the 8th produces 8th→7th periods, matching the Ethiopian
// fiscal boundary rather than forcing calendar months onto it.
const nextStart = new Date(
Date.UTC(
cursor.getUTCFullYear(),
cursor.getUTCMonth() + 1,
cursor.getUTCDate(),
),
);
const sliceEnd = isLast
? end
: new Date(nextStart.getTime() - 86_400_000);
slices.push({
name: {
en: `${MONTH_NAMES_EN[cursor.getUTCMonth()]} ${cursor.getUTCFullYear()}`,
am: `${MONTH_NAMES_EN[cursor.getUTCMonth()]} ${cursor.getUTCFullYear()}`,
},
startDate: isoDate(cursor),
endDate: isoDate(sliceEnd > end ? end : sliceEnd),
});
if (nextStart > end) break;
cursor = nextStart;
}
return slices;
}
const totalDays =
Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
const perPeriod = Math.floor(totalDays / count);
return Array.from({ length: count }, (_, index) => {
const sliceStart = new Date(
start.getTime() + index * perPeriod * 86_400_000,
);
const isLast = index === count - 1;
const sliceEnd = isLast
? end
: new Date(start.getTime() + ((index + 1) * perPeriod - 1) * 86_400_000);
return {
name: {
en: `Period ${index + 1}`,
am: `ክፍለ ጊዜ ${index + 1}`,
},
startDate: isoDate(sliceStart),
endDate: isoDate(sliceEnd),
};
});
}
/**
* Resolves the period an entry date belongs to, and refuses if it cannot
* accept a posting.
*
* This is THE gate that makes a closed period mean something — every posting
* path goes through it.
*/
async resolveOpenPeriodForDate(
organizationId: string,
entryDate: string,
): Promise<FiscalPeriod> {
const period = await this.periods.findByDate(organizationId, entryDate);
if (!period) {
throw new BadRequestException(
`No fiscal period covers ${entryDate}. Create the fiscal year before posting into it.`,
);
}
if (period.status !== "OPEN") {
throw new BadRequestException(
`${this.periodLabel(period)} is ${period.status} — nothing further can be posted into it. Post the correction to an open period instead.`,
);
}
return period;
}
/**
* Closes a period, or seals it permanently.
*
* Drafts block the close: leaving one behind would strand it in a period it
* can never be posted to, which surfaces later as an entry that cannot be
* completed and cannot be explained.
*/
async closePeriod(
actor: ActorContext,
id: string,
dto: ClosePeriodDto,
): Promise<FiscalPeriod> {
const period = await this.findPeriod(actor, id);
const target = dto.status ?? "CLOSED";
if (period.status === "LOCKED") {
throw new BadRequestException(
`${this.periodLabel(period)} is LOCKED and cannot be changed`,
);
}
if (period.status === target) {
throw new BadRequestException(
`${this.periodLabel(period)} is already ${target}`,
);
}
const drafts = await this.periods.countDraftEntries(id);
if (drafts > 0) {
throw new BadRequestException(
`${this.periodLabel(period)} still has ${drafts} draft entr${drafts === 1 ? "y" : "ies"}. Post or discard them first — a draft left in a closed period can never be posted.`,
);
}
const updated = await this.periods.update(id, {
status: target,
closedAt: new Date(),
closedBy: actor.employeeId,
});
if (!updated) throw new NotFoundException("Fiscal period not found");
return updated;
}
/**
* Reopens a CLOSED period. A LOCKED one is never reopened — that is the
* entire difference between the two states.
*/
async reopenPeriod(actor: ActorContext, id: string): Promise<FiscalPeriod> {
const period = await this.findPeriod(actor, id);
if (period.status === "LOCKED") {
throw new BadRequestException(
`${this.periodLabel(period)} is LOCKED. A locked period is never reopened — post a dated correction to an open period instead.`,
);
}
if (period.status === "OPEN") {
throw new BadRequestException(
`${this.periodLabel(period)} is already open`,
);
}
const updated = await this.periods.update(id, {
status: "OPEN",
closedAt: null,
closedBy: null,
});
if (!updated) throw new NotFoundException("Fiscal period not found");
return updated;
}
/** Posted-entry counts per period, for the close screen. */
countPostedEntries(fiscalPeriodId: string): Promise<number> {
return this.periods.countPostedEntries(fiscalPeriodId);
}
private periodLabel(period: FiscalPeriod): string {
return period.name?.en || `Period ${period.periodNumber}`;
}
private resolveOrganizationId(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot create finance records",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,128 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { IsISO8601, IsOptional, IsUUID } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { ReportsService } from "./reports.service";
export class RangeQueryDto {
@ApiProperty({ example: "2026-08-01" })
@IsISO8601()
dateFrom!: string;
@ApiProperty({ example: "2026-08-31" })
@IsISO8601()
dateTo!: string;
}
export class AsOfQueryDto {
@ApiProperty({ example: "2026-08-31" })
@IsISO8601()
asOf!: string;
}
export class LedgerQueryDto extends RangeQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
accountId?: string;
}
@ApiTags("financial-reports")
@ApiBearerAuth()
@Controller("reports")
@FinanceStaff([FINANCE_PERMS.report.view, FINANCE_PERMS.report.export])
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get("trial-balance")
@ApiOperation({
summary:
"Trial balance. Reports whether it balances — a mismatch means something bypassed the journal service.",
})
@FinanceStaff(FINANCE_PERMS.report.view)
trialBalance(
@CurrentUser() user: TCurrentUser,
@Query() query: RangeQueryDto,
) {
return this.reports.trialBalance(
actorFrom(user),
query.dateFrom,
query.dateTo,
);
}
@Get("profit-and-loss")
@ApiOperation({ summary: "Revenue less expenses for a date range" })
@FinanceStaff(FINANCE_PERMS.report.view)
profitAndLoss(
@CurrentUser() user: TCurrentUser,
@Query() query: RangeQueryDto,
) {
return this.reports.profitAndLoss(
actorFrom(user),
query.dateFrom,
query.dateTo,
);
}
@Get("balance-sheet")
@ApiOperation({
summary:
"Position as at a date, including the result earned but not yet closed into equity",
})
@FinanceStaff(FINANCE_PERMS.report.view)
balanceSheet(@CurrentUser() user: TCurrentUser, @Query() query: AsOfQueryDto) {
return this.reports.balanceSheet(actorFrom(user), query.asOf);
}
@Get("cash-movement")
@ApiOperation({ summary: "Opening, in, out and closing per cash/bank account" })
@FinanceStaff(FINANCE_PERMS.report.view)
cashMovement(
@CurrentUser() user: TCurrentUser,
@Query() query: RangeQueryDto,
) {
return this.reports.cashMovement(
actorFrom(user),
query.dateFrom,
query.dateTo,
);
}
@Get("general-ledger")
@ApiOperation({
summary: "Every line on one account, with a running balance",
})
@FinanceStaff(FINANCE_PERMS.report.view)
generalLedger(
@CurrentUser() user: TCurrentUser,
@Query() query: LedgerQueryDto,
) {
return this.reports.generalLedger(
actorFrom(user),
query.accountId as string,
query.dateFrom,
query.dateTo,
);
}
@Get("revenue-by-category")
@ApiOperation({ summary: "Revenue grouped by its mapped commercial category" })
@FinanceStaff(FINANCE_PERMS.report.view)
revenueByCategory(
@CurrentUser() user: TCurrentUser,
@Query() query: RangeQueryDto,
) {
return this.reports.revenueByCategory(
actorFrom(user),
query.dateFrom,
query.dateTo,
);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { ReportsService } from "./reports.service";
import { ReportsController } from "./reports.controller";
/**
* No `TypeOrmModule.forFeature` — every report is raw SQL over the DataSource,
* the sanctioned approach for cross-table aggregates and the one HR's 3.7
* reports use. There are no entities to register because there is nothing to
* write: this module is read-only end to end.
*/
@Module({
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,458 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
import type { ActorContext } from "../../common/current-actor.util";
import { LEDGER_CURRENCY, roundMoney } from "../../common/money";
/**
* Module 4.6 — financial reports.
*
* Read-only, raw SQL, exactly the approach HR's 3.7 reports settled on: these
* are cross-table aggregates over posted entries, and expressing them through
* an ORM would obscure arithmetic that has to be readable to be trusted.
*
* Two rules hold throughout:
*
* 1. **Only POSTED entries count.** A draft has not happened. Every query
* filters `status <> 'DRAFT'`, and REVERSED entries ARE included — a
* reversal is a real transaction, and its own mirrored entry is what
* cancels it out. Excluding reversed entries would leave the reversal
* unmatched and unbalance the books.
*
* 2. **Sign by normal balance.** A debit-balance account reports
* debits credits; a credit-balance account the reverse. Reporting raw
* debits credits everywhere would show all revenue as negative.
*/
const num = (v: unknown): number => Number(v ?? 0);
/** Posted, non-deleted, in this organization — the base every report shares. */
const POSTED = `e.status <> 'DRAFT' AND e.deleted_at IS NULL AND e.organization_id = $1`;
export type TrialBalanceRow = {
accountCode: string;
accountName: { am: string; en: string };
accountType: string;
normalBalance: string;
debit: number;
credit: number;
};
@Injectable()
export class ReportsService {
constructor(private readonly dataSource: DataSource) {}
/**
* Trial balance — every account's net movement, in the column its balance
* naturally falls in.
*
* The totals MUST match. If they do not, something has bypassed the journal
* service (a hand-written UPDATE, a restore from a bad backup), and the
* report says so rather than presenting two different numbers side by side
* and leaving the reader to notice.
*/
async trialBalance(
actor: ActorContext,
dateFrom: string,
dateTo: string,
): Promise<{
rows: TrialBalanceRow[];
totalDebit: number;
totalCredit: number;
balanced: boolean;
difference: number;
}> {
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT a.code AS "accountCode",
a.name AS "accountName",
a.account_type AS "accountType",
a.normal_balance AS "normalBalance",
ROUND(COALESCE(SUM(l.debit), 0), 2) AS "debitTotal",
ROUND(COALESCE(SUM(l.credit), 0), 2) AS "creditTotal"
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE ${POSTED}
AND e.entry_date >= $2::date
AND e.entry_date <= $3::date
AND a.deleted_at IS NULL
GROUP BY a.id, a.code, a.name, a.account_type, a.normal_balance
HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0
ORDER BY a.code ASC`,
[organizationId, dateFrom, dateTo],
);
const mapped: TrialBalanceRow[] = rows.map((row) => {
const debitTotal = num(row.debitTotal);
const creditTotal = num(row.creditTotal);
const net = roundMoney(debitTotal - creditTotal);
// Each account reports ONE side — its net, in the column it belongs in.
// Showing gross debits and credits for every account would inflate both
// totals and hide whether the account is actually in an odd position.
return {
accountCode: String(row.accountCode),
accountName: row.accountName as { am: string; en: string },
accountType: String(row.accountType),
normalBalance: String(row.normalBalance),
debit: net > 0 ? net : 0,
credit: net < 0 ? roundMoney(-net) : 0,
};
});
const totalDebit = roundMoney(mapped.reduce((s, r) => s + r.debit, 0));
const totalCredit = roundMoney(mapped.reduce((s, r) => s + r.credit, 0));
const difference = roundMoney(totalDebit - totalCredit);
return {
rows: mapped,
totalDebit,
totalCredit,
balanced: Math.abs(difference) < 0.005,
difference,
};
}
/**
* Profit and loss for a date range.
*
* `is_contra` is deliberately NOT applied as a sign flip here. Signing by the
* account's normal balance already produces the right answer: a contra
* revenue account (sales returns) accrues on the debit side, so
* `credit debit` comes out negative and reduces revenue on its own.
* Flipping again would turn a deduction into an addition. The flag is
* presentational — it tells the UI to indent the line, not the arithmetic to
* invert it.
*/
async profitAndLoss(actor: ActorContext, dateFrom: string, dateTo: string) {
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT a.code AS "accountCode",
a.name AS "accountName",
a.account_type AS "accountType",
a.is_contra AS "isContra",
ROUND(COALESCE(SUM(
CASE WHEN a.normal_balance = 'CREDIT'
THEN l.credit - l.debit
ELSE l.debit - l.credit END), 0), 2) AS amount
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE ${POSTED}
AND e.entry_date >= $2::date
AND e.entry_date <= $3::date
AND a.account_type IN ('REVENUE','EXPENSE')
AND a.deleted_at IS NULL
GROUP BY a.id, a.code, a.name, a.account_type, a.is_contra
HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0
ORDER BY a.code ASC`,
[organizationId, dateFrom, dateTo],
);
const signed = rows.map((row) => ({
accountCode: String(row.accountCode),
accountName: row.accountName as { am: string; en: string },
accountType: String(row.accountType),
isContra: Boolean(row.isContra),
amount: num(row.amount),
}));
const revenue = signed.filter((r) => r.accountType === "REVENUE");
const expenses = signed.filter((r) => r.accountType === "EXPENSE");
const totalRevenue = roundMoney(revenue.reduce((s, r) => s + r.amount, 0));
const totalExpenses = roundMoney(expenses.reduce((s, r) => s + r.amount, 0));
return {
dateFrom,
dateTo,
currency: LEDGER_CURRENCY,
revenue,
expenses,
totalRevenue,
totalExpenses,
netResult: roundMoney(totalRevenue - totalExpenses),
};
}
/**
* Balance sheet as at a date.
*
* Everything up to `asOf`, not a range — a balance sheet is a position, not a
* movement. The accounting equation is checked and reported: assets must
* equal liabilities plus equity plus the result earned so far, and that last
* term is why the P&L has to be folded in rather than shown separately.
*
* As in the P&L, `is_contra` is NOT applied as a sign flip. Accumulated
* depreciation is an ASSET-type account that carries a credit balance, so
* signing by type already yields a negative figure that reduces total assets.
* Flipping it as well made it positive and ADDED the depreciation to the
* asset side — which is precisely how the equation came out wrong by twice
* the accumulated depreciation.
*/
async balanceSheet(actor: ActorContext, asOf: string) {
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT a.code AS "accountCode",
a.name AS "accountName",
a.account_type AS "accountType",
a.is_contra AS "isContra",
ROUND(COALESCE(SUM(
CASE WHEN a.normal_balance = 'DEBIT'
THEN l.debit - l.credit
ELSE l.credit - l.debit END), 0), 2) AS balance
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE ${POSTED}
AND e.entry_date <= $2::date
AND a.account_type IN ('ASSET','LIABILITY','EQUITY')
AND a.deleted_at IS NULL
GROUP BY a.id, a.code, a.name, a.account_type, a.is_contra
HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0
ORDER BY a.code ASC`,
[organizationId, asOf],
);
const signed = rows.map((row) => ({
accountCode: String(row.accountCode),
accountName: row.accountName as { am: string; en: string },
accountType: String(row.accountType),
isContra: Boolean(row.isContra),
balance: num(row.balance),
}));
const assets = signed.filter((r) => r.accountType === "ASSET");
const liabilities = signed.filter((r) => r.accountType === "LIABILITY");
const equity = signed.filter((r) => r.accountType === "EQUITY");
const totalAssets = roundMoney(assets.reduce((s, r) => s + r.balance, 0));
const totalLiabilities = roundMoney(
liabilities.reduce((s, r) => s + r.balance, 0),
);
const totalEquity = roundMoney(equity.reduce((s, r) => s + r.balance, 0));
// Revenue less expenses to date is part of equity but has not been closed
// into it. Omitting it is why a balance sheet "does not balance".
const [result] = await this.dataSource.query<Record<string, unknown>[]>(
// Revenue counts positively, expenses negatively. No contra flip, for the
// same reason as above — the natural side of the account already carries
// the sign.
`SELECT ROUND(COALESCE(SUM(
CASE WHEN a.account_type = 'REVENUE'
THEN l.credit - l.debit
ELSE -(l.debit - l.credit) END), 0), 2) AS "netResult"
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE ${POSTED}
AND e.entry_date <= $2::date
AND a.account_type IN ('REVENUE','EXPENSE')
AND a.deleted_at IS NULL`,
[organizationId, asOf],
);
const retainedResult = num(result?.netResult);
const equityWithResult = roundMoney(totalEquity + retainedResult);
const difference = roundMoney(
totalAssets - (totalLiabilities + equityWithResult),
);
return {
asOf,
currency: LEDGER_CURRENCY,
assets,
liabilities,
equity,
totalAssets,
totalLiabilities,
totalEquity,
/** Revenue less expenses to date — part of equity, not yet closed into it. */
retainedResult,
equityWithResult,
balanced: Math.abs(difference) < 0.005,
difference,
};
}
/**
* Cash movement over a range, by cash/bank account.
*
* A direct cash view rather than an indirect cash-flow statement: the
* indirect method needs opening/closing working-capital positions that only
* mean something once a full year has been posted, and presenting a
* half-derived one would be worse than presenting the movements plainly.
*/
async cashMovement(actor: ActorContext, dateFrom: string, dateTo: string) {
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT a.code AS "accountCode",
a.name AS "accountName",
ROUND(COALESCE(SUM(
CASE WHEN e.entry_date < $2::date THEN l.debit - l.credit ELSE 0 END), 0), 2) AS "opening",
ROUND(COALESCE(SUM(
CASE WHEN e.entry_date BETWEEN $2::date AND $3::date THEN l.debit ELSE 0 END), 0), 2) AS "cashIn",
ROUND(COALESCE(SUM(
CASE WHEN e.entry_date BETWEEN $2::date AND $3::date THEN l.credit ELSE 0 END), 0), 2) AS "cashOut",
ROUND(COALESCE(SUM(
CASE WHEN e.entry_date <= $3::date THEN l.debit - l.credit ELSE 0 END), 0), 2) AS "closing"
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
WHERE ${POSTED}
AND a.account_type = 'ASSET'
AND a.code LIKE '111%'
AND a.deleted_at IS NULL
GROUP BY a.id, a.code, a.name
ORDER BY a.code ASC`,
[organizationId, dateFrom, dateTo],
);
const accounts = rows.map((row) => ({
accountCode: String(row.accountCode),
accountName: row.accountName as { am: string; en: string },
opening: num(row.opening),
cashIn: num(row.cashIn),
cashOut: num(row.cashOut),
closing: num(row.closing),
}));
return {
dateFrom,
dateTo,
currency: LEDGER_CURRENCY,
accounts,
totalOpening: roundMoney(accounts.reduce((s, a) => s + a.opening, 0)),
totalIn: roundMoney(accounts.reduce((s, a) => s + a.cashIn, 0)),
totalOut: roundMoney(accounts.reduce((s, a) => s + a.cashOut, 0)),
totalClosing: roundMoney(accounts.reduce((s, a) => s + a.closing, 0)),
};
}
/**
* The general ledger for one account — every line, with a running balance.
*
* The running balance is computed in SQL with a window function rather than
* in JavaScript, so a paged view still shows the correct balance at each row
* instead of one that restarts at the top of every page.
*/
async generalLedger(
actor: ActorContext,
accountId: string,
dateFrom: string,
dateTo: string,
) {
const organizationId = this.requireOrganization(actor);
const [opening] = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT ROUND(COALESCE(SUM(
CASE WHEN a.normal_balance = 'DEBIT'
THEN l.debit - l.credit
ELSE l.credit - l.debit END), 0), 2) AS "openingBalance"
FROM finance.journal_lines l
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
JOIN finance.accounts a ON a.id = l.account_id
WHERE ${POSTED} AND l.account_id = $2 AND e.entry_date < $3::date`,
[organizationId, accountId, dateFrom],
);
const lines = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT e.entry_date::text AS "entryDate",
e.entry_number AS "entryNumber",
e.memo,
e.journal_type AS "journalType",
l.description,
l.debit,
l.credit,
cc.code AS "costCenterCode",
SUM(CASE WHEN a.normal_balance = 'DEBIT'
THEN l.debit - l.credit
ELSE l.credit - l.debit END)
OVER (ORDER BY e.entry_date, e.entry_number, l.line_number
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "movementToDate"
FROM finance.journal_lines l
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
JOIN finance.accounts a ON a.id = l.account_id
LEFT JOIN finance.cost_centers cc ON cc.id = l.cost_center_id
WHERE ${POSTED}
AND l.account_id = $2
AND e.entry_date >= $3::date
AND e.entry_date <= $4::date
ORDER BY e.entry_date ASC, e.entry_number ASC, l.line_number ASC`,
[organizationId, accountId, dateFrom, dateTo],
);
const openingBalance = num(opening?.openingBalance);
return {
openingBalance,
lines: lines.map((row) => ({
entryDate: String(row.entryDate),
entryNumber: String(row.entryNumber),
memo: String(row.memo ?? ""),
journalType: String(row.journalType),
description: row.description ? String(row.description) : null,
costCenterCode: row.costCenterCode ? String(row.costCenterCode) : null,
debit: num(row.debit),
credit: num(row.credit),
balance: roundMoney(openingBalance + num(row.movementToDate)),
})),
closingBalance: roundMoney(
openingBalance + num(lines[lines.length - 1]?.movementToDate),
),
};
}
/**
* Revenue by mapped category — what the revenue mappings are for.
*
* Grouped on the mapping's `revenue_category` rather than the account, so
* several accounts rolling up to one commercial category report as one line.
*/
async revenueByCategory(actor: ActorContext, dateFrom: string, dateTo: string) {
const organizationId = this.requireOrganization(actor);
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT COALESCE(m.revenue_category, 'UNMAPPED') AS category,
a.code AS "accountCode",
a.name AS "accountName",
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2) AS amount
FROM finance.accounts a
JOIN finance.journal_lines l ON l.account_id = a.id
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
LEFT JOIN LATERAL (
SELECT rm.revenue_category
FROM finance.revenue_mappings rm
WHERE rm.account_id = a.id
AND rm.organization_id = $1
AND rm.deleted_at IS NULL
LIMIT 1
) m ON TRUE
WHERE ${POSTED}
AND e.entry_date >= $2::date
AND e.entry_date <= $3::date
AND a.account_type = 'REVENUE'
AND a.deleted_at IS NULL
GROUP BY m.revenue_category, a.id, a.code, a.name
HAVING COALESCE(SUM(l.credit - l.debit), 0) <> 0
ORDER BY 1 ASC, 2 ASC`,
[organizationId, dateFrom, dateTo],
);
return rows.map((row) => ({
category: String(row.category),
accountCode: String(row.accountCode),
accountName: row.accountName as { am: string; en: string },
amount: num(row.amount),
}));
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot read finance reports",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,113 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsIn,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from "class-validator";
import {
MAPPING_SOURCES,
type MappingSource,
} from "../entities/revenue-mapping.entity";
import {
INBOUND_EVENT_STATUSES,
type InboundEventStatus,
} from "../entities/inbound-event.entity";
export class RevenueMappingQueryDto {
@ApiPropertyOptional({ enum: MAPPING_SOURCES })
@IsOptional()
@IsIn(MAPPING_SOURCES)
sourceModule?: MappingSource;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
export class CreateRevenueMappingDto {
@ApiProperty({ enum: MAPPING_SOURCES })
@IsIn(MAPPING_SOURCES)
sourceModule!: MappingSource;
@ApiProperty({
example: "STORAGE_FEE",
description: "A freight invoice_lines.charge_type, or a passenger revenue key",
})
@IsString()
@IsNotEmpty()
@MaxLength(64)
matchValue!: string;
@ApiProperty()
@IsUUID()
accountId!: string;
@ApiProperty({ example: "INCIDENTAL" })
@IsString()
@IsNotEmpty()
@MaxLength(48)
revenueCategory!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateRevenueMappingDto {
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
accountId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(48)
revenueCategory?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class RevenueQueryDto {
@ApiProperty({ example: "2026-08-01", description: "ISO date (inclusive)" })
@IsISO8601()
dateFrom!: string;
@ApiProperty({ example: "2026-08-31", description: "ISO date (inclusive)" })
@IsISO8601()
dateTo!: string;
}
export class RecognizeRevenueDto {
@ApiProperty({ enum: MAPPING_SOURCES, example: "passenger" })
@IsIn(MAPPING_SOURCES)
sourceModule!: MappingSource;
@ApiProperty({ example: "2026-08", description: "YYYY-MM" })
@Matches(/^\d{4}-\d{2}$/, { message: "period must be YYYY-MM" })
period!: string;
}
export class InboundEventQueryDto {
@ApiPropertyOptional({ enum: INBOUND_EVENT_STATUSES })
@IsOptional()
@IsIn(INBOUND_EVENT_STATUSES)
status?: InboundEventStatus;
}

View File

@@ -0,0 +1,100 @@
import { Audit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
/**
* RECEIVED — logged, not yet acted on.
* POSTED — a journal entry was created; `journalEntryId` names it.
* SKIPPED — deliberately not posted, and that is correct (a failed payment, a
* currency the ledger does not carry). `error` says which.
* FAILED — should have posted and could not. Needs a human. Never silently
* dropped, because a payment that vanishes is the worst outcome.
*/
export const INBOUND_EVENT_STATUSES = [
"RECEIVED",
"POSTED",
"SKIPPED",
"FAILED",
] as const;
export type InboundEventStatus = (typeof INBOUND_EVENT_STATUSES)[number];
/**
* Every payment event this service has received, and what it did about it.
*
* Delivery from the broker is AT-LEAST-ONCE: the same event will arrive twice
* after a consumer restart or an outbox retry. The unique index on `eventId` is
* what makes that harmless — the second delivery collides on insert and is
* acknowledged without posting anything, so one payment can never become two
* journal entries.
*
* The raw `payload` is kept verbatim. When a mapping is added later, a FAILED
* event can be replayed from exactly what the publisher sent rather than from
* whatever this service managed to parse out of it at the time.
*/
@Entity({ schema: "finance", name: "inbound_events" })
@Index("idx_inbound_events_status", ["status"])
@Index("idx_inbound_events_source", ["sourceModule", "sourceId"])
@Index("idx_inbound_events_received_at", ["receivedAt"])
@Check(
"ck_inbound_events_status",
`"status" IN ('RECEIVED','POSTED','SKIPPED','FAILED')`,
)
export class InboundEvent extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* The publisher's own event id — the idempotency key.
*
* Unique across the whole table rather than per organization: an event id
* comes from the payment service's outbox and is globally unique there, and
* scoping it per organization would let the same event post twice if the
* organization were resolved differently on a redelivery.
*/
@Column({ type: "varchar", length: 128, name: "event_id", unique: true })
eventId!: string;
@Column({ type: "varchar", length: 64, name: "routing_key" })
routingKey!: string;
/** Null when the event could not be attributed to an organization. */
@Column({ type: "uuid", name: "organization_id", nullable: true })
organizationId?: string | null;
@Column({ type: "varchar", length: 32, name: "source_module", nullable: true })
sourceModule?: string | null;
@Column({ type: "varchar", length: 128, name: "source_id", nullable: true })
sourceId?: string | null;
@Column({ type: "jsonb", name: "payload" })
payload!: Record<string, unknown>;
@Column({ type: "varchar", length: 16, name: "status", default: "RECEIVED" })
status!: InboundEventStatus;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
/** Why it was skipped or how it failed — the operator's only clue. */
@Column({ type: "text", name: "error", nullable: true })
error?: string | null;
@Column({ type: "int", name: "attempts", default: 0 })
attempts!: number;
@Column({
type: "timestamptz",
name: "received_at",
default: () => "CURRENT_TIMESTAMP",
})
receivedAt!: Date;
@Column({ type: "timestamptz", name: "processed_at", nullable: true })
processedAt?: Date | null;
}

View File

@@ -0,0 +1,70 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
export const MAPPING_SOURCES = ["freight", "passenger", "payment"] as const;
export type MappingSource = (typeof MAPPING_SOURCES)[number];
/**
* Which GL account a given charge type earns into.
*
* This exists because the same knowledge currently lives in freight's report
* code as a hard-coded SQL `CASE` over fourteen categories
* (`revenue-classification.ts`), where adding a charge type means editing SQL
* and deploying. The audit found two live consequences of that: `GL_FINAL` is
* written by the contracts module but appears in neither classifier, and
* `RATE_ADJUSTMENT` is classified but never written. Both are the kind of drift
* a table makes visible and a CASE statement hides.
*
* A charge type with no mapping is NOT an error — it posts to
* `4900 Unclassified Revenue`, which is a real, visible account precisely so
* that an unexpected balance there is the signal to add a row here.
*/
@Entity({ schema: "finance", name: "revenue_mappings" })
@Index("idx_revenue_mappings_organization_id", ["organizationId"])
@Index("idx_revenue_mappings_account_id", ["accountId"])
@Check(
"ck_revenue_mappings_source",
`"source_module" IN ('freight','passenger','payment')`,
)
export class RevenueMapping extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "source_module" })
sourceModule!: MappingSource;
/**
* The value matched against — a freight `invoice_lines.charge_type`, or one
* of the passenger revenue keys this service defines. Matching is exact:
* prefix rules were considered and rejected, because `CUSTOMS_CLEARANCE_20FT`
* and `CUSTOMS_CLEARANCE_40FT` are two rows a reader can see, whereas a
* prefix rule is a rule they have to work out.
*/
@Column({ type: "varchar", length: 64, name: "match_value" })
matchValue!: string;
@Column({ type: "uuid", name: "account_id" })
accountId!: string;
/** Reporting bucket, so 4.6 can group without re-deriving from charge types. */
@Column({ type: "varchar", length: 48, name: "revenue_category" })
revenueCategory!: string;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "text", name: "notes", nullable: true })
notes?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,186 @@
import { Injectable, Logger, SetMetadata } from "@nestjs/common";
import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE } from "@edr/types";
import type { PaymentEvent } from "@edr/types";
import { DataSource } from "typeorm";
import { InboundEventsRepository } from "./revenue.repository";
import { RevenuePostingService } from "./revenue-posting.service";
import type { ActorContext } from "../../common/current-actor.util";
/**
* Finance's own queue on the shared payment exchange.
*
* NOT one of `PAYMENT_QUEUES` — that map has entries only for PASSENGER and
* FREIGHT, the two services that OWN payments. Finance is a third, read-only
* subscriber, so it declares its own queue and must never share theirs: a
* shared queue would mean each message went to whichever consumer grabbed it
* first, and freight would start losing payments to the ledger.
*/
export const FINANCE_PAYMENT_QUEUE = "finance.payment-events";
export const FINANCE_PAYMENT_DLQ = "finance.payment-events.dlq";
/**
* `#` matches zero or more words; `*` matches exactly one.
*
* The keys on the wire are three words — `payment.passenger.succeeded`,
* `payment.freight.failed` — because the publisher builds them as
* `payment.${service}.${event}`. A `payment.*` binding would therefore match
* NOTHING, which is the kind of mistake that looks like "the broker is down"
* for a day. `payment.#` catches every service, including any added later.
*/
export const FINANCE_PAYMENT_BINDING = "payment.#";
@Injectable()
// The audit logger's global interceptor assumes an HTTP context and throws on a
// RabbitMQ one; in passenger that produced a requeue storm. Finance does not
// currently install @tria-plc/auditlog, but the metadata costs nothing and
// stops a future dependency bump from reintroducing the same outage.
@SetMetadata("ignoreAuditLogger", true)
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);
constructor(
private readonly inbound: InboundEventsRepository,
private readonly posting: RevenuePostingService,
private readonly dataSource: DataSource,
) {}
/**
* One settled payment → one cash-receipt journal entry.
*
* The flow is deliberately "record first, then act":
*
* 1. Claim the event by inserting an `inbound_events` row. The unique index
* on `event_id` makes a redelivery a no-op — one payment can never become
* two journal entries.
* 2. Post. Whatever happens, the outcome is written back onto that row.
* 3. ACK.
*
* Acking even on a posting failure is the deliberate part. The event is
* already durably stored with its full payload and the reason it failed, and
* this service can replay it once a human fixes the cause (reopens the
* period, adds the missing account). Dead-lettering as well would create a
* second, divergent recovery path for something already safely recorded.
*
* The one case that DOES dead-letter is failing to record at all — if the
* database is unreachable, the broker is the only thing still holding the
* event, so it must keep it.
*/
@IsPublic()
@RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: FINANCE_PAYMENT_BINDING,
queue: FINANCE_PAYMENT_QUEUE,
queueOptions: {
durable: true,
deadLetterExchange: PAYMENT_EVENTS_DLX,
},
})
async handle(event: PaymentEvent, amqpMsg?: unknown): Promise<Nack | void> {
const routingKey =
(amqpMsg as { fields?: { routingKey?: string } })?.fields?.routingKey ??
`payment.${String(event?.service ?? "unknown").toLowerCase()}.${String(
event?.eventType ?? "",
).split(".")[1] ?? "unknown"}`;
// `eventId` is the publisher's outbox row id and is stable across every
// redelivery. Without one there is no way to deduplicate, so the event is
// dead-lettered rather than risking a double post.
if (!event?.eventId) {
this.logger.error(
`payment event with no eventId on ${routingKey} — dead-lettering, cannot deduplicate`,
);
return new Nack(false);
}
let inboundId: string | null;
try {
inboundId = await this.dataSource.transaction((manager) =>
this.inbound.claim(manager, {
eventId: event.eventId,
routingKey,
organizationId: null,
sourceModule: "payment",
sourceId: event.referenceId ?? null,
payload: event as unknown as Record<string, unknown>,
}),
);
} catch (error) {
// Could not even record it. The broker is now the only copy.
this.logger.error(
`could not record payment event ${event.eventId}: ${
error instanceof Error ? error.message : String(error)
} — dead-lettering`,
);
return new Nack(false);
}
if (!inboundId) {
// Already seen. This is the normal, expected path for a redelivery.
this.logger.log(`payment event ${event.eventId} already processed — ack`);
return;
}
try {
const actor = this.systemActor(event);
const result = await this.posting.postPaymentReceipt(actor, event);
if (result.outcome === "POSTED") {
await this.inbound.settle(inboundId, "POSTED", {
journalEntryId: result.journalEntryId,
});
this.logger.log(
`payment ${event.eventId} posted as ${result.entryNumber}`,
);
return;
}
if (result.outcome === "SKIPPED") {
await this.inbound.settle(inboundId, "SKIPPED", {
error: result.reason,
});
return;
}
await this.inbound.settle(inboundId, "FAILED", { error: result.reason });
this.logger.error(
`payment ${event.eventId} could not be posted: ${result.reason} — recorded as FAILED for replay`,
);
return;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
// Best effort: if this write also fails the row stays RECEIVED, which the
// operations screen surfaces as stuck — still visible, never lost.
await this.inbound
.settle(inboundId, "FAILED", { error: reason })
.catch(() => undefined);
this.logger.error(`payment ${event.eventId} threw: ${reason}`);
return;
}
}
/**
* The actor an automated posting runs as.
*
* `isSuperAdmin` is true because there is no human here and the event may
* belong to any organization; `employeeId` is null, so the journal records
* "posted by nobody", which is honest — the audit trail is the inbound event
* row plus the entry's `source_module`/`source_id`.
*
* The organization comes from configuration rather than the event, because
* the payment envelope carries no organization at all. A deployment serving
* several organizations must set FINANCE_DEFAULT_ORG_ID, and until it does,
* events are recorded and reported as FAILED rather than posted to a guess.
*/
private systemActor(event: PaymentEvent): ActorContext {
void event;
return {
employeeId: null,
userId: "",
organizationId: process.env.FINANCE_DEFAULT_ORG_ID ?? "",
isSuperAdmin: true,
};
}
}

View File

@@ -0,0 +1,155 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import type { ActorContext } from "../../common/current-actor.util";
import { orgScope } from "../../common/current-actor.util";
import { AccountsService } from "../accounts/accounts.service";
import { RevenueMappingsRepository } from "./revenue.repository";
import { RevenueMapping } from "./entities/revenue-mapping.entity";
import type {
CreateRevenueMappingDto,
RevenueMappingQueryDto,
UpdateRevenueMappingDto,
} from "./dto/revenue.dto";
@Injectable()
export class RevenueMappingsService {
constructor(
private readonly mappings: RevenueMappingsRepository,
private readonly accounts: AccountsService,
) {}
list(
actor: ActorContext,
query: RevenueMappingQueryDto,
): Promise<RevenueMapping[]> {
return this.mappings.findAllForOrg(orgScope(actor), query);
}
/** Mappings with their account code attached — what the screen renders. */
async listWithAccounts(actor: ActorContext) {
const organizationId = this.requireOrganization(actor);
return this.mappings.findAllWithAccounts(organizationId);
}
async create(
actor: ActorContext,
dto: CreateRevenueMappingDto,
): Promise<RevenueMapping> {
const organizationId = this.requireOrganization(actor);
const existing = await this.mappings.findMatch(
organizationId,
dto.sourceModule,
dto.matchValue,
);
if (existing) {
throw new ConflictException(
`${dto.sourceModule}:${dto.matchValue} is already mapped. Edit that mapping instead — two mappings for one charge type would post the same revenue to whichever the query returned first.`,
);
}
const account = await this.accounts.findOne(actor, dto.accountId);
this.assertRevenueAccount(account);
return this.mappings.create({
organizationId,
sourceModule: dto.sourceModule,
matchValue: dto.matchValue,
accountId: account.id,
revenueCategory: dto.revenueCategory,
isActive: true,
notes: dto.notes ?? null,
createdBy: actor.employeeId,
});
}
async update(
actor: ActorContext,
id: string,
dto: UpdateRevenueMappingDto,
): Promise<RevenueMapping> {
const mapping = await this.findOne(actor, id);
const patch: Partial<RevenueMapping> = {};
if (dto.revenueCategory !== undefined) {
patch.revenueCategory = dto.revenueCategory;
}
if (dto.isActive !== undefined) patch.isActive = dto.isActive;
if (dto.notes !== undefined) patch.notes = dto.notes;
if (dto.accountId !== undefined) {
const account = await this.accounts.findOne(actor, dto.accountId);
this.assertRevenueAccount(account);
patch.accountId = account.id;
}
if (Object.keys(patch).length === 0) return mapping;
const updated = await this.mappings.update(id, patch);
if (!updated) throw new NotFoundException("Revenue mapping not found");
return updated;
}
async remove(actor: ActorContext, id: string): Promise<{ deleted: true }> {
await this.findOne(actor, id);
// Soft delete. Removing a mapping does not rewrite history — entries
// already posted through it keep pointing at the account they used, which
// is what makes an old trial balance still explicable.
await this.mappings.softDelete(id);
return { deleted: true };
}
async findOne(actor: ActorContext, id: string): Promise<RevenueMapping> {
const mapping = await this.mappings.findById(id);
if (!mapping) throw new NotFoundException("Revenue mapping not found");
if (
!actor.isSuperAdmin &&
mapping.organizationId !== actor.organizationId
) {
throw new NotFoundException("Revenue mapping not found");
}
return mapping;
}
/**
* Revenue must map to a REVENUE account.
*
* Mapping a charge type to, say, a cash account would produce an entry that
* still balances but means something entirely different — and a balanced
* wrong entry is the hardest kind to notice.
*/
private assertRevenueAccount(account: {
code: string;
accountType: string;
isGroup: boolean;
isActive: boolean;
}): void {
if (account.accountType !== "REVENUE") {
throw new BadRequestException(
`${account.code} is a ${account.accountType} account. Revenue must map to a REVENUE account, or the entry would balance while meaning something else.`,
);
}
if (account.isGroup) {
throw new BadRequestException(
`${account.code} is a group account and cannot be posted to`,
);
}
if (!account.isActive) {
throw new BadRequestException(`${account.code} is inactive`);
}
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot manage revenue mappings",
);
}
return actor.organizationId;
}
}

View File

@@ -0,0 +1,381 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import type { PaymentEvent } from "@edr/types";
import type { ActorContext } from "../../common/current-actor.util";
import { LEDGER_CURRENCY, roundMoney } from "../../common/money";
import { AccountsService } from "../accounts/accounts.service";
import { JournalsService } from "../journals/journals.service";
import { RevenueMappingsRepository } from "./revenue.repository";
import { RevenueProjectionService } from "./revenue-projection.service";
import { CutoverService } from "../cutover/cutover.service";
import { UNCLASSIFIED_ACCOUNT_CODE } from "../../seed/revenue-mappings.seed";
import type { MappingSource } from "./entities/revenue-mapping.entity";
/**
* Accounts the automated postings resolve BY CODE.
*
* They are marked `is_system` in the chart precisely so they cannot be deleted
* or deactivated out from under this map. Changing a code here means changing
* the seed too.
*/
const ACCOUNTS = {
/** Money in from a payment provider, before bank reconciliation. */
gatewayClearing: "1114",
cashOnHand: "1111",
receivableFreight: "1121",
receivablePassenger: "1122",
unclassifiedRevenue: UNCLASSIFIED_ACCOUNT_CODE,
} as const;
export type PaymentPostingResult =
| { outcome: "POSTED"; journalEntryId: string; entryNumber: string }
| { outcome: "SKIPPED"; reason: string }
| { outcome: "FAILED"; reason: string };
export type RecognitionResult = {
posted: boolean;
entryNumber?: string;
journalEntryId?: string;
/** Revenue this run deliberately did not post, and why. */
excluded: { revenueKey: string; currency: string; amount: number; reason: string }[];
lines: { accountCode: string; revenueKey: string; amount: number }[];
total: number;
};
@Injectable()
export class RevenuePostingService {
private readonly logger = new Logger(RevenuePostingService.name);
constructor(
private readonly mappings: RevenueMappingsRepository,
private readonly accounts: AccountsService,
private readonly journals: JournalsService,
private readonly projection: RevenueProjectionService,
private readonly cutover: CutoverService,
) {}
/**
* Turns one settled payment into a cash-receipt journal entry.
*
* Dr 1114 Payment Gateway Clearing (money arrived)
* Cr 112x Trade Receivables (what was owed is now settled)
*
* Note what this deliberately does NOT do: it does not credit revenue. A
* payment is not revenue — the revenue was earned when the booking or invoice
* was raised, and posting both here would count the same sale twice. Revenue
* recognition is `recognizeRevenue` below, and the two halves meet at the
* receivable control account.
*
* `event.amountMinor` carries MAJOR units despite the name — verified against
* the publisher and both existing consumers. See `common/money.ts`.
*/
async postPaymentReceipt(
actor: ActorContext,
event: PaymentEvent,
): Promise<PaymentPostingResult> {
if (event.eventType !== "payment.succeeded") {
// A failed payment moves no money. Recording it as a journal entry would
// put a zero-value transaction in the ledger for every abandoned
// checkout, which is noise, not accounting.
return {
outcome: "SKIPPED",
reason: `${event.eventType} moves no money — nothing to post`,
};
}
// The date this entry would carry, decided before anything else is judged
// about it, because whether it is in scope at all depends on WHEN.
const paidAt =
"paidAt" in event && event.paidAt
? String(event.paidAt).slice(0, 10)
: String(event.occurredAt).slice(0, 10);
// ── The cutover boundary ───────────────────────────────────────────────
//
// A payment settled before go-live is ALREADY in the ledger: it is inside
// the receivable and cash figures the opening balances brought over. Posting
// it again would count the same money twice, and the trial balance would
// still balance while being wrong — the worst kind of error this system can
// make.
//
// This matters most on a REPLAY. The broker is at-least-once and the outbox
// can be re-driven, so a backlog of historical events reaching a freshly
// migrated ledger is an ordinary operational event, not a freak one.
//
// SKIPPED, not FAILED: nothing went wrong. The event is still recorded in
// `inbound_events` with this reason, so it stays auditable and can be
// replayed if the cutover date is later moved back.
//
// With no cutover date set there is no boundary and everything posts. That
// is deliberate — an organization mid-setup should not have its ledger
// silently frozen; the readiness check is what reports the missing date.
const cutoverDate = actor.organizationId
? await this.cutover.cutoverDateFor(actor.organizationId)
: null;
if (cutoverDate && paidAt < cutoverDate) {
return {
outcome: "SKIPPED",
reason:
`Settled ${paidAt}, before the cutover on ${cutoverDate} — already carried by the opening balances, so posting it would count the money twice`,
};
}
const currency = (event.currency ?? LEDGER_CURRENCY).toUpperCase();
if (currency !== LEDGER_CURRENCY) {
// The ledger is single-currency. Guessing a rate would silently invent a
// number, so this is recorded as FAILED and waits for a human decision.
return {
outcome: "FAILED",
reason:
`Payment is in ${currency}; the ledger carries ${LEDGER_CURRENCY} only. ` +
`Post it manually with an explicit conversion rate.`,
};
}
const amount = roundMoney(Number(event.amountMinor ?? 0));
if (!(amount > 0)) {
return {
outcome: "FAILED",
reason: `Payment amount is ${event.amountMinor} — cannot post a non-positive receipt`,
};
}
const receivableCode =
event.service === "FREIGHT"
? ACCOUNTS.receivableFreight
: ACCOUNTS.receivablePassenger;
const [clearing, receivable] = await Promise.all([
this.requireAccount(actor, ACCOUNTS.gatewayClearing),
this.requireAccount(actor, receivableCode),
]);
try {
const entry = await this.journals.createPosted(actor, {
entryDate: paidAt,
journalType: "CASH_RECEIPT",
memo:
`${event.provider} payment for ${event.referenceType} ${event.referenceId}` +
(event.merchantOrderId ? ` (${event.merchantOrderId})` : ""),
reference: event.merchantOrderId ?? event.intentId,
// The idempotency key for automated posting. The partial unique index
// on (org, source_module, source_id) means a redelivery that somehow
// gets past the inbound-event guard still cannot post twice.
sourceModule: "payment",
sourceId: event.eventId,
lines: [
{
accountId: clearing.id,
debit: amount,
description: `${event.provider} ${event.providerTxnId ?? event.intentId}`,
},
{
accountId: receivable.id,
credit: amount,
description: `${event.referenceType} ${event.referenceId}`,
},
],
});
return {
outcome: "POSTED",
journalEntryId: entry.id,
entryNumber: entry.entryNumber,
};
} catch (error) {
// A closed period, a deactivated account, an unbalanced entry — all of
// these are recoverable once a human acts, so the event is kept as FAILED
// with the reason rather than discarded.
return {
outcome: "FAILED",
reason: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Recognizes a period's revenue as ONE summarized journal entry.
*
* Dr 112x Trade Receivables (total)
* Cr 4xxx revenue accounts (one line per mapped charge type)
*
* Summarized on purpose. The dev replica has 33,063 confirmed bookings in a
* single month; posting one entry each would make the ledger unreadable and
* the trial balance unusable, and no accountant would ask for it. The detail
* stays where it already lives — in the source system — and the projection
* endpoints show it.
*
* NON-ETB revenue is excluded and reported, never converted at a guessed
* rate: on the dev replica that is 6.18M DJF and 133k USD, and folding those
* into the ETB total would overstate revenue by 6.3M.
*/
async recognizeRevenue(
actor: ActorContext,
params: { sourceModule: MappingSource; period: string },
): Promise<RecognitionResult> {
const organizationId = this.requireOrganization(actor);
const { dateFrom, dateTo } = this.monthBounds(params.period);
const buckets = (
await this.projection.revenueByKey(dateFrom, dateTo)
).filter((bucket) => bucket.sourceModule === params.sourceModule);
const excluded: RecognitionResult["excluded"] = [];
const postable = buckets.filter((bucket) => {
if (bucket.currency !== LEDGER_CURRENCY) {
excluded.push({
revenueKey: bucket.revenueKey,
currency: bucket.currency,
amount: bucket.amount,
reason: `${bucket.currency} needs an explicit conversion rate — the ledger carries ${LEDGER_CURRENCY} only`,
});
return false;
}
if (!(bucket.amount > 0)) {
excluded.push({
revenueKey: bucket.revenueKey,
currency: bucket.currency,
amount: bucket.amount,
reason: "Nothing to recognize",
});
return false;
}
return true;
});
if (postable.length === 0) {
return { posted: false, excluded, lines: [], total: 0 };
}
// Resolve each revenue key to an account. An unmapped key is NOT an error —
// it lands in 4900 Unclassified Revenue, a real visible account, so the
// balance itself becomes the prompt to add a mapping.
const unclassified = await this.requireAccount(
actor,
ACCOUNTS.unclassifiedRevenue,
);
const byAccount = new Map<
string,
{ accountId: string; accountCode: string; revenueKeys: string[]; amount: number }
>();
for (const bucket of postable) {
const mapping = await this.mappings.findMatch(
organizationId,
params.sourceModule,
bucket.revenueKey,
);
let accountId = unclassified.id;
let accountCode = unclassified.code;
if (mapping) {
const account = await this.accounts.findOne(actor, mapping.accountId);
accountId = account.id;
accountCode = account.code;
} else {
this.logger.warn(
`No revenue mapping for ${params.sourceModule}:${bucket.revenueKey} — posting to ${unclassified.code}`,
);
}
const existing = byAccount.get(accountId);
if (existing) {
existing.amount = roundMoney(existing.amount + bucket.amount);
existing.revenueKeys.push(bucket.revenueKey);
} else {
byAccount.set(accountId, {
accountId,
accountCode,
revenueKeys: [bucket.revenueKey],
amount: bucket.amount,
});
}
}
const revenueLines = [...byAccount.values()];
const total = roundMoney(
revenueLines.reduce((sum, line) => sum + line.amount, 0),
);
const receivable = await this.requireAccount(
actor,
params.sourceModule === "freight"
? ACCOUNTS.receivableFreight
: ACCOUNTS.receivablePassenger,
);
const entry = await this.journals.createPosted(actor, {
// Dated the LAST day of the period, which is when a period's revenue is
// recognized — not today, or a re-run in a later month would land the
// same revenue in the wrong period.
entryDate: dateTo,
journalType: "SALES",
memo: `${params.sourceModule} revenue recognition — ${params.period}`,
reference: params.period,
sourceModule: `${params.sourceModule}-revenue`,
sourceId: params.period,
lines: [
{
accountId: receivable.id,
debit: total,
description: `${params.sourceModule} revenue ${params.period}`,
},
...revenueLines.map((line) => ({
accountId: line.accountId,
credit: line.amount,
description: line.revenueKeys.join(", "),
})),
],
});
return {
posted: true,
entryNumber: entry.entryNumber,
journalEntryId: entry.id,
excluded,
lines: revenueLines.map((line) => ({
accountCode: line.accountCode,
revenueKey: line.revenueKeys.join(", "),
amount: line.amount,
})),
total,
};
}
private async requireAccount(actor: ActorContext, code: string) {
const organizationId = this.requireOrganization(actor);
const account = await this.accounts.findByCode(organizationId, code);
if (!account) {
throw new BadRequestException(
`Account ${code} is missing from this organization's chart. Seed the chart of accounts before posting.`,
);
}
return account;
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot post finance records",
);
}
return actor.organizationId;
}
/** "2026-08" → the first and last day of that month. */
private monthBounds(period: string): { dateFrom: string; dateTo: string } {
if (!/^\d{4}-\d{2}$/.test(period)) {
throw new BadRequestException("period must be YYYY-MM");
}
const [year, month] = period.split("-").map(Number);
const from = new Date(Date.UTC(year, month - 1, 1));
// Day 0 of the NEXT month is the last day of this one — leap years and
// 31-day months included, without a table.
const to = new Date(Date.UTC(year, month, 0));
return {
dateFrom: from.toISOString().slice(0, 10),
dateTo: to.toISOString().slice(0, 10),
};
}
}

View File

@@ -0,0 +1,301 @@
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
import { LEDGER_CURRENCY } from "../../common/money";
/**
* Which upstream tables this deployment can actually read.
*
* Finance projects out of four schemas it does not own, and they are deployed
* independently — a database can legitimately have `passenger` populated and no
* `freight.invoices` at all (that is exactly the state of the current dev
* replica, whose freight restore predates the billing module). A projection
* that assumed otherwise would 500 with `relation does not exist`, and the
* screen would read as "no revenue" rather than "this source is not here".
*
* So availability is checked, reported, and shown in the UI.
*/
export type SourceAvailability = {
freightInvoices: boolean;
freightInvoiceLines: boolean;
passengerBookings: boolean;
passengerExcessBaggage: boolean;
paymentIntents: boolean;
};
/**
* Revenue for one currency.
*
* Currency is part of the KEY, never collapsed. Confirmed passenger bookings on
* the dev replica are 151.4M ETB, 6.18M DJF and 133k USD; adding those three
* numbers together yields 157.7M of nothing at all — a 6.3M overstatement of
* ETB revenue. The ledger is single-currency, so non-ETB is surfaced separately
* and posts only once someone supplies a rate.
*/
export type RevenueBucket = {
sourceModule: string;
revenueKey: string;
currency: string;
documentCount: number;
amount: number;
/** False when `currency` is not the ledger's — needs conversion before posting. */
postable: boolean;
};
export type RevenuePeriodRow = {
period: string;
currency: string;
documentCount: number;
amount: number;
};
export type ReceivableAgingRow = {
bucket: string;
currency: string;
documentCount: number;
amount: number;
};
const num = (value: unknown): number => Number(value ?? 0);
@Injectable()
export class RevenueProjectionService {
constructor(private readonly dataSource: DataSource) {}
/**
* `to_regclass` returns NULL instead of throwing for a missing relation,
* which is exactly the probe needed here — one round trip, no exceptions to
* catch, and no dependency on the caller's search_path.
*/
async availability(): Promise<SourceAvailability> {
const [row] = await this.dataSource.query<
Record<string, string | null>[]
>(`SELECT to_regclass('freight.invoices') AS "freightInvoices",
to_regclass('freight.invoice_lines') AS "freightInvoiceLines",
to_regclass('passenger."Booking"') AS "passengerBookings",
to_regclass('passenger."ExcessBaggageCharge"') AS "passengerExcessBaggage",
to_regclass('edr_payment.payment_intent') AS "paymentIntents"`);
return {
freightInvoices: Boolean(row?.freightInvoices),
freightInvoiceLines: Boolean(row?.freightInvoiceLines),
passengerBookings: Boolean(row?.passengerBookings),
passengerExcessBaggage: Boolean(row?.passengerExcessBaggage),
paymentIntents: Boolean(row?.paymentIntents),
};
}
/**
* Revenue by source, key and CURRENCY, over a date range.
*
* Read-only, and every statement is schema-qualified against another app's
* tables — Finance never writes them (the platform stance). Sources that are
* not present are skipped rather than failed, and the caller is told which
* via `availability()`.
*/
async revenueByKey(
dateFrom: string,
dateTo: string,
): Promise<RevenueBucket[]> {
const available = await this.availability();
const buckets: RevenueBucket[] = [];
if (available.passengerBookings) {
// `totalMinor` is genuine minor units here (cents) — divided by 100. The
// money contract is explicit that this is the reliable passenger figure,
// NOT `PaymentIntent.amountMinor`, whose force-confirm path wrote cents
// into a major-unit column and left 18 rows 100x overstated.
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT 'TICKET' AS "revenueKey",
b.currency AS currency,
COUNT(*)::int AS "documentCount",
ROUND(SUM(b."totalMinor") / 100.0, 2) AS amount
FROM passenger."Booking" b
WHERE b.status = 'CONFIRMED'
AND COALESCE(b."paidAt", b."createdAt") >= $1::date
AND COALESCE(b."paidAt", b."createdAt") < ($2::date + INTERVAL '1 day')
GROUP BY b.currency`,
[dateFrom, dateTo],
);
buckets.push(...rows.map((row) => this.toBucket("passenger", row)));
}
if (available.passengerExcessBaggage) {
// CASH_COLLECTED is money taken at the counter with NO payment intent
// behind it (excess-baggage.service.ts) — real revenue that never touches
// the payment rails, so a projection that only followed intents would
// miss it entirely.
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT 'EXCESS_BAGGAGE' AS "revenueKey",
e.currency AS currency,
COUNT(*)::int AS "documentCount",
ROUND(SUM(e."totalMinor") / 100.0, 2) AS amount
FROM passenger."ExcessBaggageCharge" e
WHERE e.status IN ('PAID', 'CASH_COLLECTED')
AND COALESCE(e."paidAt", e."createdAt") >= $1::date
AND COALESCE(e."paidAt", e."createdAt") < ($2::date + INTERVAL '1 day')
GROUP BY e.currency`,
[dateFrom, dateTo],
);
buckets.push(...rows.map((row) => this.toBucket("passenger", row)));
}
if (available.freightInvoices && available.freightInvoiceLines) {
// Freight amounts are already numeric(14,2) MAJOR units — taken as-is.
// DRAFT and CANCELLED invoices are excluded: neither is earned revenue.
// `invoice_lines.amount` — NOT `line_total`. The column is named `amount`
// in `invoice-line.entity.ts`; the plausible-sounding name is wrong and
// would be a runtime 500 no type-checker would have caught.
// The line carries its own currency, but the INVOICE's is used: an
// invoice is settled as one document, so its currency is the one the
// receivable is denominated in.
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT line.charge_type AS "revenueKey",
COALESCE(inv.currency, $3) AS currency,
COUNT(DISTINCT inv.id)::int AS "documentCount",
ROUND(SUM(line.amount), 2) AS amount
FROM freight.invoice_lines line
JOIN freight.invoices inv ON inv.id = line.invoice_id
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DRAFT', 'CANCELLED')
AND COALESCE(inv.issued_at, inv.created_at) >= $1::date
AND COALESCE(inv.issued_at, inv.created_at) < ($2::date + INTERVAL '1 day')
GROUP BY line.charge_type, COALESCE(inv.currency, $3)`,
[dateFrom, dateTo, LEDGER_CURRENCY],
);
buckets.push(...rows.map((row) => this.toBucket("freight", row)));
}
return buckets.sort(
(a, b) =>
a.sourceModule.localeCompare(b.sourceModule) ||
a.revenueKey.localeCompare(b.revenueKey) ||
a.currency.localeCompare(b.currency),
);
}
/** Confirmed passenger revenue by month and currency — the trend view. */
async passengerRevenueByMonth(
dateFrom: string,
dateTo: string,
): Promise<RevenuePeriodRow[]> {
const available = await this.availability();
if (!available.passengerBookings) return [];
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT to_char(COALESCE(b."paidAt", b."createdAt"), 'YYYY-MM') AS period,
b.currency AS currency,
COUNT(*)::int AS "documentCount",
ROUND(SUM(b."totalMinor") / 100.0, 2) AS amount
FROM passenger."Booking" b
WHERE b.status = 'CONFIRMED'
AND COALESCE(b."paidAt", b."createdAt") >= $1::date
AND COALESCE(b."paidAt", b."createdAt") < ($2::date + INTERVAL '1 day')
GROUP BY 1, 2
ORDER BY 1 DESC, 2 ASC`,
[dateFrom, dateTo],
);
return rows.map((row) => ({
period: String(row.period),
currency: String(row.currency),
documentCount: num(row.documentCount),
amount: num(row.amount),
}));
}
/**
* Receivables aging over freight invoices.
*
* Ages on `due_at` where present and falls back to the issue date, because an
* invoice with no due date is already collectible — treating it as not-yet-due
* would understate the oldest bucket, which is the one that matters.
*
* Returns an empty list where freight billing is not deployed; the caller
* distinguishes that from "nothing outstanding" via `availability()`.
*/
async receivablesAging(asOf: string): Promise<ReceivableAgingRow[]> {
const available = await this.availability();
if (!available.freightInvoices) return [];
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`WITH outstanding AS (
SELECT COALESCE(inv.currency, $2) AS currency,
inv.balance_amount AS balance,
($1::date - COALESCE(inv.due_at, inv.issued_at, inv.created_at)::date) AS days_overdue
FROM freight.invoices inv
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DRAFT', 'CANCELLED', 'PAID', 'REFUNDED')
AND inv.balance_amount > 0
)
SELECT CASE
WHEN days_overdue <= 0 THEN 'Not yet due'
WHEN days_overdue <= 30 THEN '1-30 days'
WHEN days_overdue <= 60 THEN '31-60 days'
WHEN days_overdue <= 90 THEN '61-90 days'
ELSE 'Over 90 days'
END AS bucket,
currency,
COUNT(*)::int AS "documentCount",
ROUND(SUM(balance), 2) AS amount
FROM outstanding
GROUP BY 1, 2
ORDER BY 1, 2`,
[asOf, LEDGER_CURRENCY],
);
return rows.map((row) => ({
bucket: String(row.bucket),
currency: String(row.currency),
documentCount: num(row.documentCount),
amount: num(row.amount),
}));
}
/**
* Refund obligations recorded by passenger but never settled.
*
* The audit established that `BookingCancellation` rows are terminal at
* creation — `refundStatus` is never updated and `processedAt` is never set,
* and no `PaymentRefund` row is ever written. So a recorded refund is an
* OBLIGATION, not a cash movement, and belongs against `2150 Refunds Payable`
* rather than any cash account.
*/
async refundObligations(): Promise<
{ refundStatus: string; documentCount: number; amount: number }[]
> {
const available = await this.availability();
if (!available.passengerBookings) return [];
const rows = await this.dataSource.query<Record<string, unknown>[]>(
`SELECT c."refundStatus" AS "refundStatus",
COUNT(*)::int AS "documentCount",
ROUND(SUM(c."refundAmount") / 100.0, 2) AS amount
FROM passenger."BookingCancellation" c
WHERE c."processedAt" IS NULL
GROUP BY 1
ORDER BY 3 DESC NULLS LAST`,
);
return rows.map((row) => ({
refundStatus: String(row.refundStatus ?? "UNKNOWN"),
documentCount: num(row.documentCount),
amount: num(row.amount),
}));
}
private toBucket(
sourceModule: string,
row: Record<string, unknown>,
): RevenueBucket {
const currency = String(row.currency ?? LEDGER_CURRENCY);
return {
sourceModule,
revenueKey: String(row.revenueKey),
currency,
documentCount: num(row.documentCount),
amount: num(row.amount),
postable: currency === LEDGER_CURRENCY,
};
}
}

View File

@@ -0,0 +1,180 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { actorFrom } from "../../common/current-actor.util";
import { FinanceStaff } from "../../common/finance-guards";
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
import { RevenueMappingsService } from "./revenue-mappings.service";
import { RevenueProjectionService } from "./revenue-projection.service";
import { RevenuePostingService } from "./revenue-posting.service";
import { InboundEventsRepository } from "./revenue.repository";
import {
CreateRevenueMappingDto,
InboundEventQueryDto,
RecognizeRevenueDto,
RevenueMappingQueryDto,
RevenueQueryDto,
UpdateRevenueMappingDto,
} from "./dto/revenue.dto";
@ApiTags("receivables-and-revenue")
@ApiBearerAuth()
@Controller("revenue")
// Class gate lists every key the routes use — Nest runs class AND method
// guards, so a key missing here denies before the route's own is evaluated.
@FinanceStaff([
FINANCE_PERMS.receivable.view,
FINANCE_PERMS.receivable.manageRevenueMapping,
FINANCE_PERMS.receivable.recordReceipt,
])
export class RevenueController {
constructor(
private readonly mappings: RevenueMappingsService,
private readonly projection: RevenueProjectionService,
private readonly posting: RevenuePostingService,
private readonly inbound: InboundEventsRepository,
) {}
@Get("sources")
@ApiOperation({
summary:
"Which upstream tables this deployment can read — distinguishes 'no revenue' from 'source not deployed'",
})
@FinanceStaff(FINANCE_PERMS.receivable.view)
sources() {
return this.projection.availability();
}
@Get()
@ApiOperation({
summary:
"Revenue by source, key and CURRENCY. Currencies are never summed together.",
})
@FinanceStaff(FINANCE_PERMS.receivable.view)
revenue(@Query() query: RevenueQueryDto) {
return this.projection.revenueByKey(query.dateFrom, query.dateTo);
}
@Get("by-month")
@ApiOperation({ summary: "Confirmed passenger revenue by month and currency" })
@FinanceStaff(FINANCE_PERMS.receivable.view)
byMonth(@Query() query: RevenueQueryDto) {
return this.projection.passengerRevenueByMonth(query.dateFrom, query.dateTo);
}
@Get("aging")
@ApiOperation({ summary: "Receivables aging over freight invoices" })
@FinanceStaff(FINANCE_PERMS.receivable.view)
aging(@Query("asOf") asOf?: string) {
return this.projection.receivablesAging(
asOf ?? new Date().toISOString().slice(0, 10),
);
}
@Get("refund-obligations")
@ApiOperation({
summary:
"Refunds recorded by passenger but never settled — a liability, not a cash movement",
})
@FinanceStaff(FINANCE_PERMS.receivable.view)
refunds() {
return this.projection.refundObligations();
}
// ── revenue mappings ─────────────────────────────────────────────────────
@Get("mappings")
@ApiOperation({ summary: "Charge type → GL account mappings" })
@FinanceStaff(FINANCE_PERMS.receivable.view)
listMappings(
@CurrentUser() user: TCurrentUser,
@Query() query: RevenueMappingQueryDto,
) {
return this.mappings.list(actorFrom(user), query);
}
@Get("mappings/detailed")
@ApiOperation({ summary: "Mappings with their account codes attached" })
@FinanceStaff(FINANCE_PERMS.receivable.view)
listMappingsDetailed(@CurrentUser() user: TCurrentUser) {
return this.mappings.listWithAccounts(actorFrom(user));
}
@Post("mappings")
@ApiOperation({ summary: "Map a charge type to a revenue account" })
@FinanceStaff(FINANCE_PERMS.receivable.manageRevenueMapping)
createMapping(
@CurrentUser() user: TCurrentUser,
@Body() dto: CreateRevenueMappingDto,
) {
return this.mappings.create(actorFrom(user), dto);
}
@Patch("mappings/:id")
@ApiOperation({ summary: "Amend a mapping" })
@FinanceStaff(FINANCE_PERMS.receivable.manageRevenueMapping)
updateMapping(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateRevenueMappingDto,
) {
return this.mappings.update(actorFrom(user), id, dto);
}
@Delete("mappings/:id")
@ApiOperation({
summary: "Retire a mapping (entries already posted through it are untouched)",
})
@FinanceStaff(FINANCE_PERMS.receivable.manageRevenueMapping)
removeMapping(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.mappings.remove(actorFrom(user), id);
}
// ── recognition ──────────────────────────────────────────────────────────
@Post("recognize")
@ApiOperation({
summary:
"Recognize a period's revenue as ONE summarized journal entry (idempotent per source+period)",
})
@FinanceStaff(FINANCE_PERMS.receivable.recordReceipt)
recognize(
@CurrentUser() user: TCurrentUser,
@Body() dto: RecognizeRevenueDto,
) {
return this.posting.recognizeRevenue(actorFrom(user), dto);
}
// ── inbound payment events ───────────────────────────────────────────────
@Get("events")
@ApiOperation({
summary: "Payment events received, and what the ledger did about each",
})
@FinanceStaff(FINANCE_PERMS.receivable.view)
events(@Query() query: InboundEventQueryDto) {
return this.inbound.findPage({ status: query.status, limit: 200 });
}
@Get("events/summary")
@ApiOperation({ summary: "Event counts per status — the ingest health tile" })
@FinanceStaff(FINANCE_PERMS.receivable.view)
eventSummary() {
return this.inbound.statusCounts();
}
}

View File

@@ -0,0 +1,102 @@
import { DynamicModule, Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE } from "@edr/types";
import { CutoverModule } from "../cutover/cutover.module";
import { RevenueMapping } from "./entities/revenue-mapping.entity";
import { InboundEvent } from "./entities/inbound-event.entity";
import {
InboundEventsRepository,
RevenueMappingsRepository,
} from "./revenue.repository";
import { RevenueMappingsService } from "./revenue-mappings.service";
import { RevenueProjectionService } from "./revenue-projection.service";
import { RevenuePostingService } from "./revenue-posting.service";
import { RevenueController } from "./revenue.controller";
import {
FINANCE_PAYMENT_BINDING,
FINANCE_PAYMENT_DLQ,
PaymentEventsConsumer,
} from "./payment-events.consumer";
import { AccountsModule } from "../accounts/accounts.module";
import { JournalsModule } from "../journals/journals.module";
/**
* The broker is OPTIONAL.
*
* Without `PAYMENT_RABBITMQ_URL` this returns nothing and the app boots with
* every screen and projection working — only live payment ingest is off. That
* matters because Finance is useful long before it is wired to a broker (the
* chart, journals, periods and projections need no messaging at all), and
* because a developer without RabbitMQ should not be blocked from running it.
*
* Same gate freight and passenger use, so all three behave alike.
*/
function rabbitMQImport(): DynamicModule[] {
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
return [
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri:
config.get<string>("rabbitmq.url") ??
process.env.PAYMENT_RABBITMQ_URL ??
"",
exchanges: [
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
// The dead-letter queue is declared HERE, not by @RabbitSubscribe —
// that decorator only declares the main queue and its DLX pointer. With
// no bound DLQ, a dead-lettered message is silently discarded by the
// broker. Freight and passenger declare theirs the same way.
queues: [
{
name: FINANCE_PAYMENT_DLQ,
exchange: PAYMENT_EVENTS_DLX,
routingKey: FINANCE_PAYMENT_BINDING,
options: { durable: true },
},
],
prefetchCount: Number(process.env.PAYMENT_EVENTS_PREFETCH ?? 10),
// Do not block boot on the broker: the API must still serve its
// read-only screens when messaging is down.
connectionInitOptions: { wait: false },
}),
}),
];
}
@Module({
imports: [
TypeOrmModule.forFeature([RevenueMapping, InboundEvent]),
AccountsModule,
JournalsModule,
// For the cutover boundary: a payment settled before go-live is already
// inside the opening balances and must not post again.
CutoverModule,
...rabbitMQImport(),
],
controllers: [RevenueController],
providers: [
RevenueMappingsRepository,
InboundEventsRepository,
RevenueMappingsService,
RevenueProjectionService,
RevenuePostingService,
// Registered unconditionally. Without RabbitMQModule the @RabbitSubscribe
// decorator is inert, so the class is simply never invoked — and it stays
// directly callable, which is how the consumer's idempotency is tested
// without standing up a broker.
PaymentEventsConsumer,
],
exports: [
RevenueProjectionService,
RevenuePostingService,
RevenueMappingsRepository,
],
})
export class RevenueModule {}

View File

@@ -0,0 +1,193 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { EntityManager, Repository } from "typeorm";
import {
RevenueMapping,
type MappingSource,
} from "./entities/revenue-mapping.entity";
import {
InboundEvent,
type InboundEventStatus,
} from "./entities/inbound-event.entity";
@Injectable()
export class RevenueMappingsRepository extends BaseRepository<RevenueMapping> {
constructor(
@InjectRepository(RevenueMapping) repository: Repository<RevenueMapping>,
) {
super(repository);
}
findAllForOrg(
organizationId: string | null,
filters: { sourceModule?: MappingSource; search?: string } = {},
): Promise<RevenueMapping[]> {
const qb = this.repository.createQueryBuilder("mapping").where("1 = 1");
// `null` = every organization (super admin).
if (organizationId) {
qb.andWhere("mapping.organization_id = :organizationId", {
organizationId,
});
}
if (filters.sourceModule) {
qb.andWhere("mapping.source_module = :sourceModule", {
sourceModule: filters.sourceModule,
});
}
if (filters.search) {
qb.andWhere(
"(mapping.match_value ILIKE :search OR mapping.revenue_category ILIKE :search)",
{ search: `%${filters.search}%` },
);
}
return qb
.orderBy("mapping.source_module", "ASC")
.addOrderBy("mapping.match_value", "ASC")
.getMany();
}
findMatch(
organizationId: string,
sourceModule: MappingSource,
matchValue: string,
): Promise<RevenueMapping | null> {
return this.repository.findOne({
where: { organizationId, sourceModule, matchValue, isActive: true },
});
}
/**
* Mappings joined to their account code — what both the screen and the
* posting run need, without a second query per row.
*/
findAllWithAccounts(organizationId: string): Promise<
{
id: string;
sourceModule: MappingSource;
matchValue: string;
revenueCategory: string;
accountId: string;
accountCode: string;
accountName: { am: string; en: string };
isActive: boolean;
notes: string | null;
}[]
> {
return this.repository.manager.query(
`SELECT m.id,
m.source_module AS "sourceModule",
m.match_value AS "matchValue",
m.revenue_category AS "revenueCategory",
m.account_id AS "accountId",
a.code AS "accountCode",
a.name AS "accountName",
m.is_active AS "isActive",
m.notes
FROM finance.revenue_mappings m
JOIN finance.accounts a ON a.id = m.account_id
WHERE m.organization_id = $1
AND m.deleted_at IS NULL
ORDER BY m.source_module ASC, m.match_value ASC`,
[organizationId],
);
}
}
@Injectable()
export class InboundEventsRepository extends BaseRepository<InboundEvent> {
constructor(
@InjectRepository(InboundEvent) repository: Repository<InboundEvent>,
) {
super(repository);
}
findByEventId(eventId: string): Promise<InboundEvent | null> {
return this.repository.findOne({ where: { eventId } });
}
/**
* Claims an event for processing.
*
* `ON CONFLICT DO NOTHING` on the unique `event_id` is the idempotency gate:
* the FIRST delivery inserts and gets a row back, every redelivery gets none
* and is acknowledged without posting. Doing this as one statement rather
* than SELECT-then-INSERT matters — two concurrent deliveries of the same
* event would both pass a prior read.
*
* Returns null when the event has already been seen.
*/
async claim(
manager: EntityManager,
event: {
eventId: string;
routingKey: string;
organizationId: string | null;
sourceModule: string | null;
sourceId: string | null;
payload: Record<string, unknown>;
},
): Promise<string | null> {
const rows = await manager.query<{ id: string }[]>(
`INSERT INTO finance.inbound_events
(event_id, routing_key, organization_id, source_module, source_id, payload, status, attempts)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'RECEIVED', 1)
ON CONFLICT (event_id) DO NOTHING
RETURNING id`,
[
event.eventId,
event.routingKey,
event.organizationId,
event.sourceModule,
event.sourceId,
JSON.stringify(event.payload),
],
);
return rows[0]?.id ?? null;
}
async settle(
id: string,
status: InboundEventStatus,
detail: { journalEntryId?: string | null; error?: string | null } = {},
): Promise<void> {
await this.repository.update(id, {
status,
journalEntryId: detail.journalEntryId ?? null,
error: detail.error ?? null,
processedAt: new Date(),
});
}
async findPage(
filters: { status?: InboundEventStatus; limit?: number },
): Promise<InboundEvent[]> {
const qb = this.repository.createQueryBuilder("event").where("1 = 1");
if (filters.status) {
qb.andWhere("event.status = :status", { status: filters.status });
}
return qb
.orderBy("event.received_at", "DESC")
.take(filters.limit ?? 100)
.getMany();
}
/** Counts per status — the health tile on the receivables screen. */
async statusCounts(): Promise<{ status: string; count: number }[]> {
const rows = await this.repository.manager.query<
{ status: string; count: string }[]
>(
`SELECT status, COUNT(*)::text AS count
FROM finance.inbound_events
GROUP BY status
ORDER BY status`,
);
return rows.map((row) => ({
status: row.status,
count: parseInt(row.count, 10),
}));
}
}

View File

@@ -0,0 +1,55 @@
import "reflect-metadata";
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildFinanceMigrationDataSourceOptions } from "../config/database.config";
import {
APPLICATION_SEARCH_PATH,
ensurePostgresSchemas,
} from "../config/ensure-postgres-schemas";
/**
* One-shot migration runner. Migrations never run on API boot (house rule), so
* this is the only path that applies them — CI runs it before deploying the app
* image.
*
* Note this compiles to `dist/scripts/migrate.js` and the migrations glob only
* matches `dist/migrations/*.js`. Running it through ts-node would apply zero
* migrations while exiting 0 — the same trap documented for freight and HR.
*/
async function run(): Promise<void> {
const options = buildFinanceMigrationDataSourceOptions();
await ensurePostgresSchemas(options);
const dataSource = new DataSource(options);
await dataSource.initialize();
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* validated on first real query */
});
});
}
const applied = await dataSource.runMigrations({ transaction: "each" });
if (applied.length === 0) {
console.log("finance: no pending migrations");
} else {
for (const migration of applied) {
console.log(`finance: applied ${migration.name}`);
}
}
await dataSource.destroy();
}
run().catch((err) => {
console.error("finance migration failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,142 @@
import "reflect-metadata";
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildFinanceMigrationDataSourceOptions } from "../config/database.config";
import {
APPLICATION_SEARCH_PATH,
ensurePostgresSchemas,
} from "../config/ensure-postgres-schemas";
import {
CHART_OF_ACCOUNTS,
parentCodeOf,
} from "../seed/chart-of-accounts.seed";
import { NORMAL_BALANCE_BY_TYPE } from "../modules/accounts/entities/account.entity";
/**
* Seeds the starting chart of accounts for one organization.
*
* ORG_ID=<uuid> pnpm run seed:accounts
*
* IDEMPOTENT: accounts are matched on (organization_id, code) and inserted only
* when absent. An account that already exists is left exactly as it is — this
* script must never overwrite a chart someone has since edited, and it never
* deletes.
*
* Parents are resolved from the code numbering after the first pass, so the
* insert order does not have to be topological.
*/
async function run(): Promise<void> {
const organizationId = process.env.ORG_ID;
if (!organizationId) {
console.error(
"ORG_ID is required — pass the iam.organizations id to seed the chart into.\n" +
" ORG_ID=<uuid> pnpm run seed:accounts",
);
process.exit(1);
}
const options = buildFinanceMigrationDataSourceOptions();
await ensurePostgresSchemas(options);
const dataSource = new DataSource(options);
await dataSource.initialize();
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* validated on first real query */
});
});
}
const orgRows = await dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.organizations WHERE id = $1`,
[organizationId],
);
if (orgRows.length === 0) {
console.error(`No iam.organizations row with id ${organizationId}`);
await dataSource.destroy();
process.exit(1);
}
let inserted = 0;
let skipped = 0;
await dataSource.transaction(async (manager) => {
// Pass 1 — insert every missing account, parents left null for now.
for (const seed of CHART_OF_ACCOUNTS) {
const existing = await manager.query<{ id: string }[]>(
`SELECT id FROM finance.accounts
WHERE organization_id = $1 AND code = $2 AND deleted_at IS NULL`,
[organizationId, seed.code],
);
if (existing.length > 0) {
skipped += 1;
continue;
}
await manager.query(
`INSERT INTO finance.accounts
(organization_id, code, name, account_type, normal_balance,
is_group, is_contra, is_active, is_system)
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, true, $8)`,
[
organizationId,
seed.code,
JSON.stringify({ en: seed.en, am: seed.am }),
seed.type,
NORMAL_BALANCE_BY_TYPE[seed.type],
seed.group ?? false,
seed.contra ?? false,
seed.system ?? false,
],
);
inserted += 1;
}
// Pass 2 — wire parents from the code numbering. Only fills a NULL parent,
// so a re-parent done by hand in the UI survives a re-run.
const known = new Set(CHART_OF_ACCOUNTS.map((seed) => seed.code));
let linked = 0;
for (const seed of CHART_OF_ACCOUNTS) {
const parentCode = parentCodeOf(seed.code, known);
if (!parentCode) continue;
const result = await manager.query(
`UPDATE finance.accounts child
SET parent_account_id = parent.id
FROM finance.accounts parent
WHERE child.organization_id = $1
AND child.code = $2
AND child.parent_account_id IS NULL
AND parent.organization_id = $1
AND parent.code = $3`,
[organizationId, seed.code, parentCode],
);
// node-postgres reports affected rows as the second element for UPDATE.
if (Array.isArray(result) && typeof result[1] === "number") {
linked += result[1];
}
}
console.log(`finance: linked ${linked} parent reference(s)`);
});
console.log(
`finance: chart of accounts seeded for ${organizationId}${inserted} inserted, ${skipped} already present`,
);
await dataSource.destroy();
}
run().catch((err) => {
console.error("finance chart-of-accounts seed failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,114 @@
import "reflect-metadata";
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildFinanceMigrationDataSourceOptions } from "../config/database.config";
import {
APPLICATION_SEARCH_PATH,
ensurePostgresSchemas,
} from "../config/ensure-postgres-schemas";
import { REVENUE_MAPPINGS } from "../seed/revenue-mappings.seed";
/**
* Seeds charge-type → account mappings for one organization.
*
* ORG_ID=<uuid> pnpm run seed:revenue-mappings
*
* IDEMPOTENT: matched on (organization, source_module, match_value) and
* inserted only when absent. A mapping someone has since repointed at a
* different account is left exactly as it is — this script never overwrites a
* human decision, and it never deletes.
*
* Requires the chart of accounts to be seeded first: mappings resolve their
* account BY CODE, and a missing code is reported rather than skipped silently.
*/
async function run(): Promise<void> {
const organizationId = process.env.ORG_ID;
if (!organizationId) {
console.error(
"ORG_ID is required.\n ORG_ID=<uuid> pnpm run seed:revenue-mappings",
);
process.exit(1);
}
const options = buildFinanceMigrationDataSourceOptions();
await ensurePostgresSchemas(options);
const dataSource = new DataSource(options);
await dataSource.initialize();
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* validated on first real query */
});
});
}
let inserted = 0;
let skipped = 0;
const missingAccounts: string[] = [];
await dataSource.transaction(async (manager) => {
for (const seed of REVENUE_MAPPINGS) {
const account = await manager.query<{ id: string }[]>(
`SELECT id FROM finance.accounts
WHERE organization_id = $1 AND code = $2 AND deleted_at IS NULL`,
[organizationId, seed.account],
);
if (account.length === 0) {
missingAccounts.push(`${seed.match}${seed.account}`);
continue;
}
const existing = await manager.query<{ id: string }[]>(
`SELECT id FROM finance.revenue_mappings
WHERE organization_id = $1 AND source_module = $2
AND match_value = $3 AND deleted_at IS NULL`,
[organizationId, seed.source, seed.match],
);
if (existing.length > 0) {
skipped += 1;
continue;
}
await manager.query(
`INSERT INTO finance.revenue_mappings
(organization_id, source_module, match_value, account_id, revenue_category, is_active, notes)
VALUES ($1, $2, $3, $4, $5, true, $6)`,
[
organizationId,
seed.source,
seed.match,
account[0].id,
seed.category,
seed.notes ?? null,
],
);
inserted += 1;
}
});
console.log(
`finance: revenue mappings for ${organizationId}${inserted} inserted, ${skipped} already present`,
);
if (missingAccounts.length > 0) {
console.warn(
`finance: ${missingAccounts.length} mapping(s) SKIPPED — account code not in this chart:\n ` +
missingAccounts.join("\n ") +
"\nSeed the chart of accounts first (pnpm run seed:accounts).",
);
}
await dataSource.destroy();
}
run().catch((err) => {
console.error("finance revenue-mapping seed failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,188 @@
import type { AccountType } from "../modules/accounts/entities/account.entity";
export type AccountSeed = {
code: string;
en: string;
am: string;
type: AccountType;
/** Group accounts total their children and can never be posted to. */
group?: boolean;
/** Subtracted from its type's total rather than added (accumulated depreciation). */
contra?: boolean;
/**
* System accounts are resolved BY CODE by the automated postings in 4.24.5,
* so they cannot be deleted or deactivated. Changing one of these codes means
* changing the code that looks it up.
*/
system?: boolean;
};
/**
* A starting chart of accounts for EDR.
*
* Structured as a conventional 5-block numeric chart (1 assets, 2 liabilities,
* 3 equity, 4 revenue, 5 expenses), with the parent derived from the code
* prefix — so the tree is implied by the numbering rather than stated twice.
*
* It is a STARTING POINT, not a fixed schedule: accounts are data, and an
* organization is expected to extend this through the UI. What is fixed is the
* handful marked `system`, because automated postings resolve them by code.
*
* Revenue is split freight/passenger because those are the two businesses the
* platform actually runs, and the 4.6 reports are expected to answer "which
* one earned this" without re-deriving it from charge types.
*/
export const CHART_OF_ACCOUNTS: AccountSeed[] = [
// ── 1 Assets ──────────────────────────────────────────────────────────────
{ code: "1000", en: "Assets", am: "ንብረቶች", type: "ASSET", group: true },
{ code: "1100", en: "Current Assets", am: "ተንቀሳቃሽ ንብረቶች", type: "ASSET", group: true },
{ code: "1110", en: "Cash and Cash Equivalents", am: "ጥሬ ገንዘብ", type: "ASSET", group: true },
{ code: "1111", en: "Cash on Hand", am: "በእጅ ያለ ጥሬ ገንዘብ", type: "ASSET", system: true },
{ code: "1112", en: "Cash at Bank", am: "በባንክ ያለ ገንዘብ", type: "ASSET", system: true },
{ code: "1113", en: "Mobile Money Clearing", am: "የሞባይል ገንዘብ ማወራረጃ", type: "ASSET", system: true },
{ code: "1114", en: "Payment Gateway Clearing", am: "የክፍያ መተላለፊያ ማወራረጃ", type: "ASSET", system: true },
{ code: "1120", en: "Accounts Receivable", am: "ተቀባይ ሒሳብ", type: "ASSET", group: true },
{ code: "1121", en: "Trade Receivables — Freight", am: "ተቀባይ ሒሳብ — ጭነት", type: "ASSET", system: true },
{ code: "1122", en: "Trade Receivables — Passenger", am: "ተቀባይ ሒሳብ — ተሳፋሪ", type: "ASSET", system: true },
{ code: "1123", en: "Staff Receivables", am: "የሠራተኛ ተቀባይ ሒሳብ", type: "ASSET" },
{ code: "1129", en: "Allowance for Doubtful Debts", am: "ለአጠራጣሪ ዕዳ የተያዘ", type: "ASSET", contra: true },
{ code: "1130", en: "Prepaid Expenses", am: "በቅድሚያ የተከፈለ ወጪ", type: "ASSET" },
{ code: "1140", en: "Inventory and Supplies", am: "ዕቃና አቅርቦት", type: "ASSET" },
{ code: "1150", en: "Advances to Suppliers", am: "ለአቅራቢዎች ቅድሚያ ክፍያ", type: "ASSET" },
{ code: "1200", en: "Non-Current Assets", am: "ቋሚ ንብረቶች", type: "ASSET", group: true },
{ code: "1210", en: "Property, Plant and Equipment", am: "ቋሚ ንብረትና መሣሪያ", type: "ASSET", group: true },
{ code: "1211", en: "Land", am: "መሬት", type: "ASSET" },
{ code: "1212", en: "Buildings", am: "ሕንፃዎች", type: "ASSET" },
{ code: "1213", en: "Locomotives", am: "ባቡር አንቀሳቃሾች", type: "ASSET" },
{ code: "1214", en: "Wagons and Coaches", am: "ፉርጎዎች", type: "ASSET" },
{ code: "1215", en: "Motor Vehicles", am: "ተሽከርካሪዎች", type: "ASSET" },
{ code: "1216", en: "Furniture and Fixtures", am: "የቢሮ ዕቃዎች", type: "ASSET" },
{ code: "1217", en: "Computer and Office Equipment", am: "የኮምፒውተርና ቢሮ መሣሪያ", type: "ASSET" },
{ code: "1290", en: "Accumulated Depreciation", am: "የተጠራቀመ የእርጅና ቅናሽ", type: "ASSET", contra: true, system: true },
// ── 2 Liabilities ─────────────────────────────────────────────────────────
{ code: "2000", en: "Liabilities", am: "ዕዳዎች", type: "LIABILITY", group: true },
{ code: "2100", en: "Current Liabilities", am: "የአጭር ጊዜ ዕዳዎች", type: "LIABILITY", group: true },
{ code: "2110", en: "Accounts Payable", am: "ከፋይ ሒሳብ", type: "LIABILITY", group: true },
{ code: "2111", en: "Trade Payables", am: "የአቅራቢዎች ከፋይ ሒሳብ", type: "LIABILITY", system: true },
{ code: "2120", en: "Statutory Payables", am: "ሕጋዊ ከፋይ ሒሳብ", type: "LIABILITY", group: true },
{ code: "2121", en: "Income Tax Payable (PAYE)", am: "የገቢ ግብር ከፋይ", type: "LIABILITY", system: true },
{ code: "2122", en: "Pension Contribution Payable", am: "የጡረታ መዋጮ ከፋይ", type: "LIABILITY", system: true },
{ code: "2123", en: "VAT Payable", am: "ተጨማሪ እሴት ታክስ ከፋይ", type: "LIABILITY", system: true },
{ code: "2124", en: "Withholding Tax Payable", am: "ተቀናሽ ግብር ከፋይ", type: "LIABILITY", system: true },
{ code: "2130", en: "Salaries Payable", am: "የደመወዝ ከፋይ ሒሳብ", type: "LIABILITY", system: true },
{ code: "2140", en: "Deferred Revenue", am: "ገና ያልተገኘ ገቢ", type: "LIABILITY", group: true },
{ code: "2141", en: "Unearned Passenger Revenue", am: "ገና ያልተገኘ የተሳፋሪ ገቢ", type: "LIABILITY", system: true },
{ code: "2142", en: "Customer Wallet Balances", am: "የደንበኛ ቦርሳ ቀሪ ሒሳብ", type: "LIABILITY", system: true },
/**
* Refunds owed but never settled by the source systems. The audit found that
* passenger cancellations record an 80% refund which nothing ever pays out,
* so it is a standing OBLIGATION rather than a cash movement — it belongs
* here, not in a cash account.
*/
{ code: "2150", en: "Refunds Payable", am: "ተመላሽ ገንዘብ ከፋይ", type: "LIABILITY", system: true },
{ code: "2160", en: "Accrued Expenses", am: "የተጠራቀሙ ወጪዎች", type: "LIABILITY" },
{ code: "2170", en: "Customer Deposits", am: "የደንበኛ ተቀማጭ", type: "LIABILITY" },
{ code: "2200", en: "Non-Current Liabilities", am: "የረጅም ጊዜ ዕዳዎች", type: "LIABILITY", group: true },
{ code: "2210", en: "Long-term Loans", am: "የረጅም ጊዜ ብድር", type: "LIABILITY" },
// ── 3 Equity ──────────────────────────────────────────────────────────────
{ code: "3000", en: "Equity", am: "ካፒታል", type: "EQUITY", group: true },
{ code: "3100", en: "Capital", am: "መነሻ ካፒታል", type: "EQUITY" },
{ code: "3200", en: "Retained Earnings", am: "ያልተከፋፈለ ትርፍ", type: "EQUITY", system: true },
{ code: "3300", en: "Current Year Result", am: "የዓመቱ ውጤት", type: "EQUITY", system: true },
/**
* The counter-entry for cutover balances (P7). Opening balances are entered
* as a normal balanced journal, and this account absorbs the difference —
* once the cutover is complete it should be zero, which is itself the check
* that the migration was entered correctly.
*/
{ code: "3900", en: "Opening Balance Suspense", am: "የመክፈቻ ቀሪ ሒሳብ ማንጠልጠያ", type: "EQUITY", system: true },
// ── 4 Revenue ─────────────────────────────────────────────────────────────
{ code: "4000", en: "Revenue", am: "ገቢ", type: "REVENUE", group: true },
{ code: "4100", en: "Freight Revenue", am: "የጭነት ገቢ", type: "REVENUE", group: true },
{ code: "4110", en: "Rail Freight Revenue", am: "የባቡር ጭነት ገቢ", type: "REVENUE", system: true },
{ code: "4120", en: "First and Last Mile Revenue", am: "የመጀመሪያና መጨረሻ ማይል ገቢ", type: "REVENUE", system: true },
{ code: "4130", en: "Customs Clearance Revenue", am: "የጉምሩክ አገልግሎት ገቢ", type: "REVENUE", system: true },
{ code: "4140", en: "Demurrage and Storage Revenue", am: "የማከማቻና መዘግየት ገቢ", type: "REVENUE", system: true },
{ code: "4150", en: "Handling and Loading Revenue", am: "የጭነት አያያዝ ገቢ", type: "REVENUE", system: true },
{ code: "4190", en: "Other Freight Revenue", am: "ሌላ የጭነት ገቢ", type: "REVENUE", system: true },
{ code: "4200", en: "Passenger Revenue", am: "የተሳፋሪ ገቢ", type: "REVENUE", group: true },
{ code: "4210", en: "Ticket Revenue", am: "የትኬት ገቢ", type: "REVENUE", system: true },
{ code: "4220", en: "Excess Baggage Revenue", am: "የተጨማሪ ሻንጣ ገቢ", type: "REVENUE", system: true },
{ code: "4230", en: "Cancellation and Change Fees", am: "የስረዛና ለውጥ ክፍያ", type: "REVENUE", system: true },
{ code: "4290", en: "Other Passenger Revenue", am: "ሌላ የተሳፋሪ ገቢ", type: "REVENUE", system: true },
/**
* Where a charge type that no mapping recognises is posted. It is deliberately
* a real, visible account rather than a silent fallback: an unexpected balance
* here is the signal that the revenue mapping table (4.2) needs a new row.
*/
{ code: "4900", en: "Unclassified Revenue", am: "ያልተመደበ ገቢ", type: "REVENUE", system: true },
{ code: "4910", en: "Other Income", am: "ሌላ ገቢ", type: "REVENUE" },
// ── 5 Expenses ────────────────────────────────────────────────────────────
{ code: "5000", en: "Expenses", am: "ወጪዎች", type: "EXPENSE", group: true },
{ code: "5100", en: "Personnel Costs", am: "የሠራተኛ ወጪ", type: "EXPENSE", group: true },
{ code: "5110", en: "Basic Salary", am: "መሠረታዊ ደመወዝ", type: "EXPENSE", system: true },
{ code: "5120", en: "Allowances", am: "አበሎች", type: "EXPENSE", system: true },
{ code: "5130", en: "Overtime", am: "የትርፍ ሰዓት ክፍያ", type: "EXPENSE", system: true },
{ code: "5140", en: "Pension — Employer Contribution", am: "የአሠሪ የጡረታ መዋጮ", type: "EXPENSE", system: true },
{ code: "5150", en: "Staff Benefits", am: "የሠራተኛ ጥቅማጥቅም", type: "EXPENSE" },
{ code: "5160", en: "Training and Development", am: "ስልጠናና ልማት", type: "EXPENSE" },
{ code: "5200", en: "Operating Costs", am: "የሥራ ማስኬጃ ወጪ", type: "EXPENSE", group: true },
{ code: "5210", en: "Fuel and Lubricants", am: "ነዳጅና ቅባት", type: "EXPENSE" },
{ code: "5220", en: "Repairs and Maintenance", am: "ጥገናና እድሳት", type: "EXPENSE" },
{ code: "5230", en: "Utilities", am: "የመገልገያ ወጪ", type: "EXPENSE" },
{ code: "5240", en: "Rent and Leases", am: "የኪራይ ወጪ", type: "EXPENSE" },
{ code: "5250", en: "Insurance", am: "የመድን ወጪ", type: "EXPENSE" },
{ code: "5260", en: "Third-party Transport", am: "የሦስተኛ ወገን ትራንስፖርት", type: "EXPENSE" },
{ code: "5300", en: "Administrative Costs", am: "የአስተዳደር ወጪ", type: "EXPENSE", group: true },
{ code: "5310", en: "Office Supplies", am: "የቢሮ አቅርቦት", type: "EXPENSE" },
{ code: "5320", en: "Communication", am: "የመገናኛ ወጪ", type: "EXPENSE" },
{ code: "5330", en: "Professional Fees", am: "የባለሙያ ክፍያ", type: "EXPENSE" },
{ code: "5340", en: "Travel and Per Diem", am: "የጉዞና አበል ወጪ", type: "EXPENSE" },
{ code: "5350", en: "Bank Charges", am: "የባንክ አገልግሎት ክፍያ", type: "EXPENSE", system: true },
{ code: "5360", en: "Payment Gateway Fees", am: "የክፍያ መተላለፊያ ክፍያ", type: "EXPENSE", system: true },
{ code: "5400", en: "Depreciation and Amortization", am: "የእርጅናና ማሟሟቅ ወጪ", type: "EXPENSE", system: true },
{ code: "5500", en: "Bad Debt Expense", am: "የማይሰበሰብ ዕዳ ወጪ", type: "EXPENSE" },
{ code: "5900", en: "Other Expenses", am: "ሌሎች ወጪዎች", type: "EXPENSE" },
];
/**
* The parent of an account, by code prefix.
*
* The chart is numbered so that a child's parent is the nearest shorter code
* that exists — "1111" → "1110" → "1100" → "1000". Deriving it beats listing it
* because the two could otherwise disagree, and the numbering is the thing
* people actually read.
*/
export function parentCodeOf(
code: string,
known: ReadonlySet<string>,
): string | null {
// "1111" → try "1110", then "1100", then "1000".
for (let position = code.length - 1; position >= 1; position -= 1) {
const candidate = code.slice(0, position).padEnd(code.length, "0");
if (candidate !== code && known.has(candidate)) return candidate;
}
return null;
}

View File

@@ -0,0 +1,274 @@
/**
* Finance permission catalogue.
*
* Keys follow the IAM convention already in use across the platform
* (`can:<action>:<resource>`), and every one is scoped to the `finance`
* application so it can be granted independently of hr/freight/passenger keys.
*
* IDs are stable and MUST NEVER change — the IAM seeder upserts by id, so
* editing one orphans every grant already issued against it.
*/
export const FINANCE_APPLICATION = {
id: "c4e1a7b2-9f3d-5e08-b16c-2d7a48f9e531",
key: "finance",
name: { am: "ፋይናንስ", en: "Finance" },
} as const;
export type FinancePermissionSeed = {
id: string;
key: string;
name: { am: string; en: string };
applicationKey: string;
};
export type FinanceRoleSeed = {
id: string;
key: string;
name: { am: string; en: string };
};
export type FinanceRolePermissionSeed = {
roleKey: string;
permissionKeys: string[];
};
const perm = (
id: string,
key: string,
am: string,
en: string,
): FinancePermissionSeed => ({
id,
key,
name: { am, en },
applicationKey: FINANCE_APPLICATION.key,
});
/**
* The keys controllers reference. Module 4.1 implements the `account`,
* `journal` and `period` groups; the AR/AP/budget/asset/report groups are
* declared here because the role→permission matrix and the IAM seed are one
* unit — a role cannot be granted a key that does not exist yet — and 4.24.6
* attach routes to keys that are already seeded rather than re-seeding on every
* sub-module. This mirrors how HR declared 3.23.7 up front.
*/
export const FINANCE_PERMS = {
account: {
view: "can:view:gl_account",
manage: "can:manage:gl_account",
},
journal: {
create: "can:create:journal_entry",
view: "can:view:journal_entry",
post: "can:post:journal_entry",
reverse: "can:reverse:journal_entry",
},
period: {
view: "can:view:fiscal_period",
manage: "can:manage:fiscal_period",
close: "can:close:fiscal_period",
},
receivable: {
view: "can:view:receivable",
manageCustomer: "can:manage:finance_customer",
recordReceipt: "can:record:receipt",
manageRevenueMapping: "can:manage:revenue_mapping",
},
payable: {
view: "can:view:payable",
manageSupplier: "can:manage:supplier",
manageBill: "can:manage:supplier_bill",
approveBill: "can:approve:supplier_bill",
recordPayment: "can:record:supplier_payment",
postPayroll: "can:post:payroll_to_gl",
manageStatutory: "can:manage:statutory_payable",
},
budget: {
view: "can:view:budget",
manage: "can:manage:budget",
approve: "can:approve:budget",
manageCostCenter: "can:manage:cost_center",
},
asset: {
view: "can:view:fixed_asset",
manage: "can:manage:fixed_asset",
depreciate: "can:run:depreciation",
dispose: "can:dispose:fixed_asset",
},
report: {
view: "can:view:finance_report",
export: "can:export:finance_report",
},
} as const;
export const FINANCE_PERMISSIONS: FinancePermissionSeed[] = [
// ── Chart of accounts (4.1) ───────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a01", FINANCE_PERMS.account.view, "የሒሳብ መዝገብ ማየት", "View chart of accounts"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a02", FINANCE_PERMS.account.manage, "የሒሳብ መዝገብ ማስተዳደር", "Manage chart of accounts"),
// ── Journals (4.1) ────────────────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a03", FINANCE_PERMS.journal.create, "የመዝገብ ግቤት ማዘጋጀት", "Prepare journal entry"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a04", FINANCE_PERMS.journal.view, "የመዝገብ ግቤት ማየት", "View journal entries"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a05", FINANCE_PERMS.journal.post, "የመዝገብ ግቤት ማጽደቅ", "Post journal entry"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a06", FINANCE_PERMS.journal.reverse, "የመዝገብ ግቤት መመለስ", "Reverse journal entry"),
// ── Fiscal periods (4.1) ──────────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a07", FINANCE_PERMS.period.view, "የሒሳብ ዘመን ማየት", "View fiscal periods"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a08", FINANCE_PERMS.period.manage, "የሒሳብ ዘመን ማስተዳደር", "Manage fiscal periods"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a09", FINANCE_PERMS.period.close, "የሒሳብ ዘመን መዝጋት", "Close fiscal period"),
// ── Receivables & revenue (4.2) ───────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a10", FINANCE_PERMS.receivable.view, "ተቀባይ ሒሳብ ማየት", "View receivables"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a11", FINANCE_PERMS.receivable.manageCustomer, "የደንበኛ ሒሳብ ማስተዳደር", "Manage finance customers"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a12", FINANCE_PERMS.receivable.recordReceipt, "ገቢ መመዝገብ", "Record receipt"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a13", FINANCE_PERMS.receivable.manageRevenueMapping, "የገቢ ምድብ ማስተዳደር", "Manage revenue mappings"),
// ── Payables & statutory (4.3) ────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a20", FINANCE_PERMS.payable.view, "ከፋይ ሒሳብ ማየት", "View payables"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a21", FINANCE_PERMS.payable.manageSupplier, "አቅራቢ ማስተዳደር", "Manage suppliers"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a22", FINANCE_PERMS.payable.manageBill, "የአቅራቢ ደረሰኝ ማዘጋጀት", "Prepare supplier bill"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a23", FINANCE_PERMS.payable.approveBill, "የአቅራቢ ደረሰኝ ማጽደቅ", "Approve supplier bill"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a24", FINANCE_PERMS.payable.recordPayment, "ክፍያ መመዝገብ", "Record supplier payment"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a25", FINANCE_PERMS.payable.postPayroll, "ደመወዝ ወደ መዝገብ መላክ", "Post payroll to general ledger"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a26", FINANCE_PERMS.payable.manageStatutory, "ሕጋዊ ክፍያ ማስተዳደር", "Manage statutory payables"),
// ── Budgeting & cost centers (4.4) ────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a30", FINANCE_PERMS.budget.view, "በጀት ማየት", "View budgets"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a31", FINANCE_PERMS.budget.manage, "በጀት ማዘጋጀት", "Prepare budgets"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a32", FINANCE_PERMS.budget.approve, "በጀት ማጽደቅ", "Approve budget"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a33", FINANCE_PERMS.budget.manageCostCenter, "የወጪ ማዕከል ማስተዳደር", "Manage cost centers"),
// ── Fixed assets (4.5) ────────────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a40", FINANCE_PERMS.asset.view, "ቋሚ ንብረት ማየት", "View fixed assets"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a41", FINANCE_PERMS.asset.manage, "ቋሚ ንብረት ማስተዳደር", "Manage fixed assets"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a42", FINANCE_PERMS.asset.depreciate, "የእርጅና ስሌት ማስኬድ", "Run depreciation"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a43", FINANCE_PERMS.asset.dispose, "ቋሚ ንብረት ማስወገድ", "Dispose fixed asset"),
// ── Reports (4.6) ─────────────────────────────────────────────────────────
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a50", FINANCE_PERMS.report.view, "የፋይናንስ ሪፖርት ማየት", "View finance reports"),
perm("1b2d5b5f-4d3f-4b2c-8a7e-1c2d3e4f5a51", FINANCE_PERMS.report.export, "የፋይናንስ ሪፖርት ማውጣት", "Export finance reports"),
];
/** Read-only keys every finance role holds, whatever else it can do. */
const READ_ONLY_KEYS = [
FINANCE_PERMS.account.view,
FINANCE_PERMS.journal.view,
FINANCE_PERMS.period.view,
FINANCE_PERMS.report.view,
];
export const FINANCE_ROLES: FinanceRoleSeed[] = [
{
id: "3f7c1e94-8a2b-5d6f-9c03-4e8b1a5d7f26",
key: "finance_manager",
name: { am: "የፋይናንስ ሥራ አስኪያጅ", en: "Finance Manager" },
},
{
id: "7d2a5b81-c4e6-5f39-a708-3b9d6c2e4f15",
key: "accountant",
name: { am: "ሒሳብ ሠራተኛ", en: "Accountant" },
},
{
id: "b8e3f427-16d9-5c4a-8f52-7a1c9e3b6d08",
key: "cashier",
name: { am: "ገንዘብ ያዥ", en: "Cashier" },
},
{
id: "5c9d2e73-4b81-5a6e-bd14-9f27c3a5e8b0",
key: "finance_auditor",
name: { am: "የሒሳብ ተቆጣጣሪ", en: "Finance Auditor" },
},
];
/**
* Role → permission matrix.
*
* FOUR separations of duty are structural here and must not be collapsed into
* one role later. Each is the accounting equivalent of HR's run/approve payroll
* split, and each exists because one person doing both sides is the classic
* route to an unnoticed misstatement:
*
* - `create:journal_entry` (accountant) vs `post:journal_entry`
* (finance_manager) — a journal is PREPARED by one person and POSTED by
* another. Posting is what makes it immutable and real.
* - `manage:supplier_bill` (accountant) vs `approve:supplier_bill`
* (finance_manager) — whoever enters a bill must not be the one who
* authorises paying it.
* - `manage:budget` (accountant) vs `approve:budget` (finance_manager).
* - `close:fiscal_period` is finance_manager ONLY. Closing a period freezes
* everything posted into it; it is not a bookkeeping action.
*
* `finance_auditor` deliberately holds NO write key at all — not even
* `create:journal_entry`. An auditor who can post entries is not an auditor.
*/
export const FINANCE_ROLE_PERMISSIONS: FinanceRolePermissionSeed[] = [
{
roleKey: "finance_manager",
permissionKeys: [
...READ_ONLY_KEYS,
FINANCE_PERMS.account.manage,
FINANCE_PERMS.journal.create,
FINANCE_PERMS.journal.post,
FINANCE_PERMS.journal.reverse,
FINANCE_PERMS.period.manage,
FINANCE_PERMS.period.close,
FINANCE_PERMS.receivable.view,
FINANCE_PERMS.receivable.manageCustomer,
FINANCE_PERMS.receivable.manageRevenueMapping,
FINANCE_PERMS.payable.view,
FINANCE_PERMS.payable.manageSupplier,
FINANCE_PERMS.payable.approveBill,
FINANCE_PERMS.payable.postPayroll,
FINANCE_PERMS.payable.manageStatutory,
FINANCE_PERMS.budget.view,
FINANCE_PERMS.budget.approve,
FINANCE_PERMS.budget.manageCostCenter,
FINANCE_PERMS.asset.view,
FINANCE_PERMS.asset.manage,
FINANCE_PERMS.asset.depreciate,
FINANCE_PERMS.asset.dispose,
FINANCE_PERMS.report.export,
],
},
{
roleKey: "accountant",
permissionKeys: [
...READ_ONLY_KEYS,
FINANCE_PERMS.journal.create,
FINANCE_PERMS.receivable.view,
FINANCE_PERMS.receivable.manageCustomer,
FINANCE_PERMS.receivable.recordReceipt,
FINANCE_PERMS.payable.view,
FINANCE_PERMS.payable.manageSupplier,
FINANCE_PERMS.payable.manageBill,
FINANCE_PERMS.payable.recordPayment,
FINANCE_PERMS.budget.view,
FINANCE_PERMS.budget.manage,
FINANCE_PERMS.asset.view,
FINANCE_PERMS.asset.manage,
FINANCE_PERMS.report.export,
],
},
{
roleKey: "cashier",
permissionKeys: [
...READ_ONLY_KEYS,
FINANCE_PERMS.receivable.view,
FINANCE_PERMS.receivable.recordReceipt,
FINANCE_PERMS.payable.view,
FINANCE_PERMS.payable.recordPayment,
],
},
{
roleKey: "finance_auditor",
permissionKeys: [
...READ_ONLY_KEYS,
FINANCE_PERMS.receivable.view,
FINANCE_PERMS.payable.view,
FINANCE_PERMS.budget.view,
FINANCE_PERMS.asset.view,
FINANCE_PERMS.report.export,
],
},
];

View File

@@ -0,0 +1,103 @@
import type { MappingSource } from "../modules/revenue/entities/revenue-mapping.entity";
export type RevenueMappingSeed = {
source: MappingSource;
/** freight `invoice_lines.charge_type`, or a passenger revenue key. */
match: string;
/** Account CODE — resolved to an id at seed time. */
account: string;
category: string;
notes?: string;
};
/**
* The account every unmapped charge type falls back to.
*
* Deliberately a real, visible account rather than a silent ELSE arm: a balance
* appearing here is the signal that a new charge type has been introduced
* upstream and needs a row below.
*/
export const UNCLASSIFIED_ACCOUNT_CODE = "4900";
/**
* Charge type → GL account.
*
* The freight half is seeded from `KNOWN_CHARGE_TYPES` in
* `apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts` —
* the codebase's own assertion of every value it writes. That list is the
* authority, not the fourteen-category CASE in `revenue-classification.ts`,
* because the spec is what fails when a ninth producer adds a spelling.
*
* TWO DRIFT CASES the audit found, both preserved here on purpose:
* - `GL_FINAL` IS written (`gl-operations.service.ts:521`) but appears in
* neither of freight's classifiers, so today it reports as UNCLASSIFIED.
* It is mapped here.
* - `RATE_ADJUSTMENT` is classified by freight but no code path writes it. It
* is mapped anyway — a mapping for a charge type that never arrives costs
* nothing, whereas the reverse is silent misclassification.
*/
export const REVENUE_MAPPINGS: RevenueMappingSeed[] = [
// ── freight: base rail freight ────────────────────────────────────────────
{ source: "freight", match: "CONTAINER_IMPORT", account: "4110", category: "CONTAINER_IMPORT" },
{ source: "freight", match: "CONTAINER_EXPORT", account: "4110", category: "CONTAINER_EXPORT" },
{ source: "freight", match: "CONTAINER_20FT", account: "4110", category: "CONTAINER_IMPORT" },
{ source: "freight", match: "CONTAINER_40FT", account: "4110", category: "CONTAINER_IMPORT" },
{ source: "freight", match: "BULK_IMPORT", account: "4110", category: "OTHER_IMPORT_BULK" },
{ source: "freight", match: "BULK_EXPORT", account: "4110", category: "OTHER_EXPORT_CARGO" },
{ source: "freight", match: "INTERCITY_BULK", account: "4110", category: "DOMESTIC" },
{ source: "freight", match: "INTERCITY_CONTAINER", account: "4110", category: "DOMESTIC" },
{ source: "freight", match: "FREIGHT", account: "4110", category: "OTHER_IMPORT_BULK",
notes: "Fallback written by booking-invoice.service when a booking has no pricing snapshot" },
{ source: "freight", match: "GL_FINAL", account: "4110", category: "CONTAINER_IMPORT",
notes: "Written by gl-operations.service but absent from freight's own classifiers — reported as UNCLASSIFIED there" },
// ── freight: surcharges ───────────────────────────────────────────────────
{ source: "freight", match: "FUEL_SURCHARGE", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "LASHING", account: "4150", category: "INCIDENTAL" },
{ source: "freight", match: "OVERWEIGHT_PER_TON", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "HAZARD_SURCHARGE", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "REEFER_SURCHARGE", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "PIL_EXTRA_FEE", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "RETURN_SURCHARGE", account: "4190", category: "EMPTY_CONTAINER_REEXPORT" },
{ source: "freight", match: "RETURN_SURCHARGE_20FT", account: "4190", category: "EMPTY_CONTAINER_REEXPORT" },
{ source: "freight", match: "RETURN_SURCHARGE_40FT", account: "4190", category: "EMPTY_CONTAINER_REEXPORT" },
{ source: "freight", match: "CONTAINER_WITH_RETURN", account: "4110", category: "EMPTY_CONTAINER_REEXPORT" },
{ source: "freight", match: "ADJUSTMENT", account: "4190", category: "INCIDENTAL",
notes: "Staff price-override delta" },
{ source: "freight", match: "RATE_ADJUSTMENT", account: "4190", category: "INCIDENTAL",
notes: "Classified by freight's report code but no writer exists — mapped defensively" },
// ── freight: customs ──────────────────────────────────────────────────────
{ source: "freight", match: "CUSTOMS_CLEARANCE", account: "4130", category: "CUSTOMS_CLEARANCE" },
{ source: "freight", match: "CUSTOMS_CLEARANCE_20FT", account: "4130", category: "CUSTOMS_CLEARANCE" },
{ source: "freight", match: "CUSTOMS_CLEARANCE_40FT", account: "4130", category: "CUSTOMS_CLEARANCE" },
// ── freight: first / last mile ────────────────────────────────────────────
{ source: "freight", match: "FIRST_MILE", account: "4120", category: "FIRST_LAST_MILE" },
{ source: "freight", match: "LAST_MILE", account: "4120", category: "FIRST_LAST_MILE" },
{ source: "freight", match: "DELIVERY", account: "4120", category: "FIRST_LAST_MILE" },
{ source: "freight", match: "LAST_MILE_ADVANCE", account: "4120", category: "FIRST_LAST_MILE" },
// ── freight: warehouse fees ───────────────────────────────────────────────
{ source: "freight", match: "CONTAINER_DEMURRAGE", account: "4140", category: "INCIDENTAL" },
{ source: "freight", match: "BULK_DEMURRAGE", account: "4140", category: "INCIDENTAL" },
{ source: "freight", match: "DEMURRAGE", account: "4140", category: "INCIDENTAL" },
{ source: "freight", match: "STORAGE_FEE", account: "4140", category: "INCIDENTAL" },
{ source: "freight", match: "HANDLING_FEE", account: "4150", category: "INCIDENTAL" },
{ source: "freight", match: "DOUBLE_HANDLING", account: "4150", category: "INCIDENTAL" },
{ source: "freight", match: "TRUCK_DETENTION", account: "4140", category: "INCIDENTAL" },
// ── freight: other producers ──────────────────────────────────────────────
{ source: "freight", match: "CANCELLATION_FEE", account: "4190", category: "INCIDENTAL" },
{ source: "freight", match: "SHIPPING_LINE_SERVICE", account: "4190", category: "INCIDENTAL" },
// ── passenger ─────────────────────────────────────────────────────────────
// Keys defined by THIS service (passenger has no charge-type column), so they
// are named for what the projection actually reads.
{ source: "passenger", match: "TICKET", account: "4210", category: "PASSENGER_TICKET",
notes: "Booking.totalMinor on a CONFIRMED booking — minor units, divided by 100" },
{ source: "passenger", match: "EXCESS_BAGGAGE", account: "4220", category: "PASSENGER_ANCILLARY",
notes: "ExcessBaggageCharge.totalMinor where PAID or CASH_COLLECTED" },
{ source: "passenger", match: "CANCELLATION_FEE", account: "4230", category: "PASSENGER_ANCILLARY" },
{ source: "passenger", match: "PACKAGE", account: "4290", category: "PASSENGER_OTHER" },
];

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "@edr/tsconfig/nestjs.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo",
"preserveWatchOutput": true,
"module": "node16",
"moduleResolution": "node16"
},
"include": ["src"]
}