@spinekit/assets 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/LICENSE +75 -0
- package/README.md +41 -0
- package/dist/index.d.mts +110 -0
- package/dist/index.mjs +817 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 — 2026-08-20
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **License:** relicensed from MIT to the **Classytic Source-Available License**
|
|
7
|
+
(Community & Commercial). Evaluation and development use remain free; production
|
|
8
|
+
use now requires a commercial license from Classytic LLC. See `LICENSE`.
|
|
9
|
+
|
|
10
|
+
### Notes
|
|
11
|
+
- First stable **1.0.0** release of the Classytic `arc` suite. Versions published
|
|
12
|
+
before 1.0.0 remain under their original MIT terms; the new license applies from
|
|
13
|
+
1.0.0 onward.
|
|
14
|
+
|
|
15
|
+
## 0.2.0 — 2026-07-20 (amended 2026-07-21, pre-publish)
|
|
16
|
+
|
|
17
|
+
- **Engine teardown symmetry**: a module-created engine now registers `onClose` → `engine.destroy()` (closes the kernel's event transport); a BYO engine is never closed by the module.
|
|
18
|
+
|
|
19
|
+
- **scheduledJobs arm**: assetsMaintenanceSchedules (automated depreciation posting, daily) wired into createAssetsModule (factory form — resolves post-bootstrap); opt out with maintenance: false (the deployment-tier decision stays host policy). Peer floor raised @classytic/arc >=2.24.0.
|
|
20
|
+
- TENANCY: sweeps default to a bare cron context; tenant-enabled engines (the kernel default) MUST pass maintenance.context (org scoping) or set maintenance:false — otherwise every tick fails behind arc's fail-open scheduler. Company-wide hosts need nothing.
|
|
21
|
+
- **owns colocation (PACKAGE_RULES §1.5)**: createAssetsModule now declares `ArcModule.owns: ['fixed-asset']`, mirroring its single resource. Hosts remove their hand-maintained supersession lists.
|
|
22
|
+
- Long-interval schedules set explicit leaseMs (~90% of every) — arc's default lease caps at 5min, which would let independently-phased replicas re-run hourly/daily sweeps.
|
|
23
|
+
- Sweep is sequential per asset (idempotent via dep:<scheduleId>:<sequence>); large registers (10k+ assets) should expect a long daily tick — batching is a known future hardening.
|
|
24
|
+
|
|
25
|
+
## 0.1.1 — 2026-07-18
|
|
26
|
+
|
|
27
|
+
- Peer floor bumps: `@classytic/arc >=2.23.0`, `@classytic/assets >=0.4.1`,
|
|
28
|
+
`@classytic/mongokit >=3.23.0` (tracks assets 0.4 API surface + arc 2.23
|
|
29
|
+
module system).
|
|
30
|
+
- Integrated `permissionMatrix` from `@classytic/arc/permissions` for
|
|
31
|
+
fine-grained action-level gates on lifecycle transition and valuation routes.
|
|
32
|
+
- Dev toolchain: `tsdown ^0.22.5`, `typescript ^7.0.2`.
|
|
33
|
+
|
|
34
|
+
## 0.1.1 — 2026-07-11
|
|
35
|
+
|
|
36
|
+
Initial release.
|
|
37
|
+
|
|
38
|
+
- **`createAssetsModule(deps)`** — single composition surface that boots the
|
|
39
|
+
`@classytic/assets` engine (or accepts a BYO engine via `deps.engine` for
|
|
40
|
+
strangler migrations) and mounts an arc resource serving the full fixed-asset
|
|
41
|
+
register API: acquisition, lifecycle transitions (commission, place-in-service,
|
|
42
|
+
retire, dispose), 6-method multi-book depreciation (straight-line, declining-
|
|
43
|
+
balance, double-declining, sum-of-years, units-of-production, MACRS), IAS 16
|
|
44
|
+
revaluation, IAS 36 impairment, IAS 8 estimate changes, market/appraisal value
|
|
45
|
+
history, and disposal with gain/loss calculation.
|
|
46
|
+
- **Ledger bridge injection** — `deps.ledger` (sync or `(app) => LedgerPort`) wires
|
|
47
|
+
journal posting for depreciation + disposal entries; omit to run without GL
|
|
48
|
+
posting (read-only register or deferred wiring).
|
|
49
|
+
- **Host-policy surface** — `currency`, `categoryEnum`, `tenantField`, `prefix`,
|
|
50
|
+
`schemaOptions` let the host inject IAS classification codes, functional currency,
|
|
51
|
+
and tenant scoping without forking the module.
|
|
52
|
+
- **Event transport + outbox** — `deps.eventTransport` / `deps.outbox` thread through
|
|
53
|
+
to the engine for durable lifecycle event delivery.
|
|
54
|
+
- Peers: `@classytic/arc >=2.20.0`, `@classytic/assets >=0.3.0`,
|
|
55
|
+
`@classytic/mongokit >=3.19.0`, `mongoose >=9.4.1`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Classytic Source-Available License (Community & Commercial), Version 1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Classytic LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is source-available, not open source. Please read this License
|
|
6
|
+
before using it.
|
|
7
|
+
|
|
8
|
+
1. DEFINITIONS
|
|
9
|
+
"Software" means the contents of this package and any accompanying source
|
|
10
|
+
code, object code, or documentation, in any form.
|
|
11
|
+
"Licensor" means Classytic LLC, a limited liability company organized in the
|
|
12
|
+
United States.
|
|
13
|
+
"You" means the individual or legal entity exercising rights under this
|
|
14
|
+
License.
|
|
15
|
+
"Evaluation Use" means use for evaluation, development, testing,
|
|
16
|
+
experimentation, or internal non-commercial purposes only.
|
|
17
|
+
"Production Use" means any use of the Software that is live, commercial,
|
|
18
|
+
revenue-generating, or customer-facing, or any use other than Evaluation Use.
|
|
19
|
+
"Commercial License" means a separate written agreement executed with
|
|
20
|
+
Licensor that grants Production Use rights.
|
|
21
|
+
|
|
22
|
+
2. OWNERSHIP
|
|
23
|
+
The Software is licensed, not sold. Licensor retains all right, title, and
|
|
24
|
+
interest in and to the Software, including all intellectual property rights.
|
|
25
|
+
All rights not expressly granted in this License are reserved by Licensor.
|
|
26
|
+
|
|
27
|
+
3. EVALUATION GRANT
|
|
28
|
+
Subject to Your compliance with this License, Licensor grants You a
|
|
29
|
+
worldwide, royalty-free, non-exclusive, non-transferable, revocable license
|
|
30
|
+
to download, install, run, and modify the Software solely for Evaluation Use.
|
|
31
|
+
|
|
32
|
+
4. PRODUCTION USE REQUIRES A COMMERCIAL LICENSE
|
|
33
|
+
Production Use of the Software is NOT permitted under this License and
|
|
34
|
+
requires a Commercial License from Licensor. A Commercial License may be
|
|
35
|
+
granted at Licensor's discretion, with or without a fee, and may confer
|
|
36
|
+
perpetual, irrevocable, and transferable Production Use rights as set out in
|
|
37
|
+
that agreement. Contact licensing@classytic.com to obtain one.
|
|
38
|
+
|
|
39
|
+
5. RESTRICTIONS
|
|
40
|
+
You may not:
|
|
41
|
+
(a) make any Production Use of the Software without a Commercial License;
|
|
42
|
+
(b) redistribute, resell, rent, sublicense, or otherwise make the Software
|
|
43
|
+
(in whole or in part, original or modified) available to third parties as
|
|
44
|
+
a stand-alone product, framework, library, or hosted/managed service that
|
|
45
|
+
competes with the Software or with Licensor;
|
|
46
|
+
(c) remove, obscure, or alter any copyright, license, or attribution notice.
|
|
47
|
+
|
|
48
|
+
6. FEEDBACK AND CONTRIBUTIONS
|
|
49
|
+
Any feedback, suggestions, or contributions You provide relating to the
|
|
50
|
+
Software may be used by Licensor for any purpose without obligation to You.
|
|
51
|
+
|
|
52
|
+
7. TERMINATION
|
|
53
|
+
This License and the Evaluation Grant terminate automatically if You breach
|
|
54
|
+
any term. Upon termination You must cease all use of the Software and destroy
|
|
55
|
+
all copies in Your possession. Rights under a Commercial License are governed
|
|
56
|
+
by that agreement's own terms and survive termination of this License to the
|
|
57
|
+
extent stated there.
|
|
58
|
+
|
|
59
|
+
8. DISCLAIMER OF WARRANTY
|
|
60
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
61
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
62
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
63
|
+
|
|
64
|
+
9. LIMITATION OF LIABILITY
|
|
65
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL LICENSOR BE LIABLE
|
|
66
|
+
FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
67
|
+
TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
|
|
68
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
69
|
+
|
|
70
|
+
10. GOVERNING LAW
|
|
71
|
+
This License is governed by the laws of the State of Delaware, United States,
|
|
72
|
+
without regard to its conflict-of-laws principles. (Adjust to Your LLC's state
|
|
73
|
+
of formation.)
|
|
74
|
+
|
|
75
|
+
For commercial licensing, contact: licensing@classytic.com
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @spinekit/assets
|
|
2
|
+
|
|
3
|
+
> Arc module for @classytic/assets — fixed-asset register + multi-book depreciation + IAS 16/36 valuation as composable resources.
|
|
4
|
+
|
|
5
|
+
Part of **Spine**, Classytic's ERP — the arc-module layer composed into an
|
|
6
|
+
[`@classytic/arc`](https://www.npmjs.com/package/@classytic/arc) application via
|
|
7
|
+
`createApp({ modules })`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @spinekit/assets @classytic/arc @classytic/mongokit mongoose zod
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Kernels and the framework are **peer dependencies** — the host installs a single
|
|
16
|
+
copy; this module bundles nothing.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createApp } from '@classytic/arc/factory';
|
|
22
|
+
import { createAssetsModule } from '@spinekit/assets';
|
|
23
|
+
|
|
24
|
+
const app = await createApp({
|
|
25
|
+
modules: [
|
|
26
|
+
createAssetsModule({
|
|
27
|
+
connection, // your mongoose connection
|
|
28
|
+
permissions, // role gates
|
|
29
|
+
// BYO engine + host seams (extraActions, extraRoutes, bridges) as needed
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> This is a standard scaffold — expand Usage with the module's specific options.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
Proprietary — Classytic Source-Available License (see [LICENSE](LICENSE)).
|
|
40
|
+
Free for evaluation/development; production use requires a commercial license
|
|
41
|
+
from Classytic LLC.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { SpineTenantInput } from "@spinekit/kit/tenant";
|
|
2
|
+
import { AssetsContext, AssetsEngine, LedgerPort } from "@classytic/assets";
|
|
3
|
+
import { ScheduleDefinition } from "@classytic/arc/plugins";
|
|
4
|
+
import { PermissionGate } from "@spinekit/kit/permissions";
|
|
5
|
+
import { EventTransport } from "@classytic/primitives/events";
|
|
6
|
+
import { OutboxStore } from "@classytic/primitives/outbox";
|
|
7
|
+
import { Connection } from "mongoose";
|
|
8
|
+
import { FastifyInstance } from "fastify";
|
|
9
|
+
//#region src/schedules.d.ts
|
|
10
|
+
interface AssetsMaintenanceOptions {
|
|
11
|
+
/** Automated depreciation posting sweep interval. Default daily (with jitter). */
|
|
12
|
+
depreciationEvery?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Extra kernel-context fields merged over the default `{ actorId: 'cron' }`.
|
|
15
|
+
* REQUIRED when the host composes the register tenant-scoped (`tenantField`
|
|
16
|
+
* set): an org-less sweep then fails per-asset under the tenant plugin —
|
|
17
|
+
* `posted: 0` forever with only error logs. Company-wide registers
|
|
18
|
+
* (`tenantField: false`, the module default) need nothing.
|
|
19
|
+
*/
|
|
20
|
+
context?: Partial<AssetsContext>;
|
|
21
|
+
}
|
|
22
|
+
declare function assetsMaintenanceSchedules(engine: AssetsEngine, options?: AssetsMaintenanceOptions): ScheduleDefinition[];
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/index.d.ts
|
|
25
|
+
interface AssetsPermissions {
|
|
26
|
+
/** Read register / schedules / valuations. */
|
|
27
|
+
view: PermissionGate;
|
|
28
|
+
/** Acquire, lifecycle transitions, valuation, onboard. */
|
|
29
|
+
manage: PermissionGate;
|
|
30
|
+
}
|
|
31
|
+
interface AssetsModuleDeps {
|
|
32
|
+
connection: Connection;
|
|
33
|
+
/** BYO engine (strangler) - module skips engine creation. */
|
|
34
|
+
engine?: AssetsEngine;
|
|
35
|
+
permissions: AssetsPermissions;
|
|
36
|
+
/**
|
|
37
|
+
* GL bridge (host policy - category -> chart account). Function form
|
|
38
|
+
* receives the live app so a host composing arc-accounting FIRST wires
|
|
39
|
+
* off f.arc.modules.accounting. Omit to run without journal posting.
|
|
40
|
+
*/
|
|
41
|
+
ledger?: LedgerPort | ((app: FastifyInstance) => LedgerPort | undefined);
|
|
42
|
+
/**
|
|
43
|
+
* Modules that MUST bootstrap before this one (arc 2.20 topological order).
|
|
44
|
+
*
|
|
45
|
+
* Defaults to `['accounting']` when `ledger` is given in FACTORY form, since
|
|
46
|
+
* that factory reads `f.arc.modules.accounting`. Without the edge the
|
|
47
|
+
* factory can receive an undefined module and yield `undefined`, which
|
|
48
|
+
* silently disables depreciation/disposal journal posting on a financial
|
|
49
|
+
* register. Pass `[]` to opt out, or a list to add edges.
|
|
50
|
+
*/
|
|
51
|
+
dependsOn?: readonly string[];
|
|
52
|
+
/** Functional currency for money fields (ISO 4217). Host-owned — a money
|
|
53
|
+
* module must know its currency; there is no jurisdiction default. */
|
|
54
|
+
currency: string;
|
|
55
|
+
/** Allowed category values (host policy - NBR/IFRS classes). */
|
|
56
|
+
categoryEnum?: readonly string[];
|
|
57
|
+
/** Canonical persistence tenancy. Default false (company-wide register). */
|
|
58
|
+
tenant?: SpineTenantInput;
|
|
59
|
+
/** Route prefix. Default /assets. */
|
|
60
|
+
prefix?: string;
|
|
61
|
+
/** Extra mongoose schema fields (e.g. host category enum on category). */
|
|
62
|
+
schemaOptions?: Record<string, unknown>;
|
|
63
|
+
eventTransport?: EventTransport;
|
|
64
|
+
outbox?: OutboxStore;
|
|
65
|
+
/**
|
|
66
|
+
* Re-register the asset models when they are already on the connection.
|
|
67
|
+
*
|
|
68
|
+
* Mongoose locks a schema on the first `model()` call, so a second
|
|
69
|
+
* registration cannot add fields or indexes — it can only silently drop them,
|
|
70
|
+
* which is why mongokit refuses instead. Right for production; wrong for a
|
|
71
|
+
* suite that boots the app twice on one connection, and this module had no way
|
|
72
|
+
* to say so. (mongokit itself refuses `replace` under NODE_ENV=production, so
|
|
73
|
+
* this cannot become a production hot-reload path.)
|
|
74
|
+
*/
|
|
75
|
+
forceRecreate?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Accept a backend without multi-document transactions. DEV/TEST ONLY.
|
|
78
|
+
*
|
|
79
|
+
* Depreciation posting writes the schedule row and its journal entry
|
|
80
|
+
* together, so the kernel wants a replica set. Absent, this module built an
|
|
81
|
+
* engine that REFUSED to bind on a standalone mongod — the host could only
|
|
82
|
+
* get past it by constructing the engine itself, which is exactly the BYO
|
|
83
|
+
* escape hatch this arm removes the need for.
|
|
84
|
+
*/
|
|
85
|
+
allowNonTransactional?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Automated-depreciation sweep interval. Omit for the daily default; pass
|
|
88
|
+
* `false` to opt OUT of the `scheduledJobs` arm — the DEPLOYMENT-TIER
|
|
89
|
+
* decision (auto-posting vs the manual `post-period` action) is host policy.
|
|
90
|
+
* See `assetsMaintenanceSchedules`.
|
|
91
|
+
*/
|
|
92
|
+
maintenance?: AssetsMaintenanceOptions | false;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build the fixed-asset resource over a live assets engine. Exported so a
|
|
96
|
+
* host can mount it directly (custom module composition).
|
|
97
|
+
*/
|
|
98
|
+
declare function createAssetResource(args: {
|
|
99
|
+
engine: AssetsEngine;
|
|
100
|
+
permissions: AssetsPermissions;
|
|
101
|
+
/** ISO 4217 — host-owned, required (a money module has no default currency). */
|
|
102
|
+
currency: string;
|
|
103
|
+
categoryEnum?: readonly string[];
|
|
104
|
+
prefix?: string;
|
|
105
|
+
}): import("@classytic/arc").ResourceDefinition<import("@classytic/assets").AssetDocument | (import("mongoose").Document<unknown, {}, import("@classytic/assets").AssetDocument, {}, import("mongoose").DefaultSchemaOptions> & import("@classytic/assets").Asset & import("mongoose").Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & {
|
|
106
|
+
id: string;
|
|
107
|
+
})>;
|
|
108
|
+
declare function createAssetsModule(deps: AssetsModuleDeps): import("@classytic/arc/factory").ArcModule<AssetsEngine>;
|
|
109
|
+
//#endregion
|
|
110
|
+
export { type AssetsMaintenanceOptions, AssetsModuleDeps, AssetsPermissions, assetsMaintenanceSchedules, createAssetResource, createAssetsModule };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import { defineEngineModule } from "@spinekit/kit/engine-module";
|
|
2
|
+
import { resolveSpineTenant } from "@spinekit/kit/tenant";
|
|
3
|
+
import { defineResource } from "@classytic/arc";
|
|
4
|
+
import { permissionMatrix } from "@classytic/arc/permissions";
|
|
5
|
+
import { scopeFirstCtx } from "@classytic/arc/scope";
|
|
6
|
+
import { ValidationError } from "@classytic/arc/utils";
|
|
7
|
+
import { createMongooseAdapter } from "@classytic/mongokit/adapter";
|
|
8
|
+
import { QueryParser } from "@classytic/mongokit";
|
|
9
|
+
import { DEPRECIATION_METHODS, defineAssets } from "@classytic/assets";
|
|
10
|
+
import { isCurrencyCode } from "@classytic/primitives/currency";
|
|
11
|
+
import { money } from "@classytic/primitives/money";
|
|
12
|
+
//#region src/schedules.ts
|
|
13
|
+
const DAY = 864e5;
|
|
14
|
+
function assetsMaintenanceSchedules(engine, options = {}) {
|
|
15
|
+
return [{
|
|
16
|
+
name: "assets.depreciation",
|
|
17
|
+
every: options.depreciationEvery ?? DAY,
|
|
18
|
+
leaseMs: Math.floor((options.depreciationEvery ?? DAY) * .9),
|
|
19
|
+
jitterMs: 9e5,
|
|
20
|
+
handler: async (fastify) => {
|
|
21
|
+
const ctx = {
|
|
22
|
+
actorId: "cron",
|
|
23
|
+
...options.context
|
|
24
|
+
};
|
|
25
|
+
const now = /* @__PURE__ */ new Date();
|
|
26
|
+
const active = await engine.models.FixedAsset.find({
|
|
27
|
+
status: "active",
|
|
28
|
+
deletedAt: null
|
|
29
|
+
}).select("_id name").lean();
|
|
30
|
+
let posted = 0;
|
|
31
|
+
for (const asset of active) try {
|
|
32
|
+
const schedule = await engine.repositories.schedule.findByAssetAndBook(String(asset._id), "accounting", ctx);
|
|
33
|
+
if (!schedule) continue;
|
|
34
|
+
const due = schedule.periods.filter((per) => per.status === "scheduled" && new Date(per.periodEnd) <= now).sort((a, b) => a.sequence - b.sequence);
|
|
35
|
+
for (const per of due) {
|
|
36
|
+
await engine.services.depreciation.postPeriod(String(asset._id), per.sequence, {}, ctx);
|
|
37
|
+
posted += 1;
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
fastify.log.error({
|
|
41
|
+
err,
|
|
42
|
+
assetId: String(asset._id)
|
|
43
|
+
}, "Depreciation posting failed for asset");
|
|
44
|
+
}
|
|
45
|
+
if (posted > 0) fastify.log.info({ posted }, "Depreciation periods auto-posted");
|
|
46
|
+
}
|
|
47
|
+
}];
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/index.ts
|
|
51
|
+
const createAssetWireBody = {
|
|
52
|
+
type: "object",
|
|
53
|
+
required: [
|
|
54
|
+
"assetNumber",
|
|
55
|
+
"name",
|
|
56
|
+
"depreciation"
|
|
57
|
+
],
|
|
58
|
+
properties: {
|
|
59
|
+
assetNumber: {
|
|
60
|
+
type: "string",
|
|
61
|
+
minLength: 1
|
|
62
|
+
},
|
|
63
|
+
name: {
|
|
64
|
+
type: "string",
|
|
65
|
+
minLength: 1
|
|
66
|
+
},
|
|
67
|
+
category: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Default: 'office_equipment'"
|
|
70
|
+
},
|
|
71
|
+
cost: {
|
|
72
|
+
type: "integer",
|
|
73
|
+
minimum: 0,
|
|
74
|
+
description: "Acquisition cost in minor units (paisa)"
|
|
75
|
+
},
|
|
76
|
+
salvageValue: {
|
|
77
|
+
type: "integer",
|
|
78
|
+
minimum: 0,
|
|
79
|
+
description: "Salvage value in minor units"
|
|
80
|
+
},
|
|
81
|
+
depreciation: {
|
|
82
|
+
type: "object",
|
|
83
|
+
required: ["method", "periods"],
|
|
84
|
+
properties: {
|
|
85
|
+
method: {
|
|
86
|
+
type: "string",
|
|
87
|
+
enum: [...DEPRECIATION_METHODS]
|
|
88
|
+
},
|
|
89
|
+
periods: {
|
|
90
|
+
type: "integer",
|
|
91
|
+
minimum: 1
|
|
92
|
+
},
|
|
93
|
+
decliningRate: {
|
|
94
|
+
type: "number",
|
|
95
|
+
exclusiveMinimum: 0,
|
|
96
|
+
exclusiveMaximum: 1
|
|
97
|
+
},
|
|
98
|
+
totalUnits: {
|
|
99
|
+
type: "integer",
|
|
100
|
+
minimum: 1
|
|
101
|
+
},
|
|
102
|
+
manualSchedule: {
|
|
103
|
+
type: "array",
|
|
104
|
+
items: { type: "integer" }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
placedInServiceAt: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Date — parsed by the service"
|
|
111
|
+
},
|
|
112
|
+
location: { type: "string" },
|
|
113
|
+
custodian: { type: "string" },
|
|
114
|
+
branchId: { type: "string" },
|
|
115
|
+
sourceId: { type: "string" },
|
|
116
|
+
sourceModel: { type: "string" },
|
|
117
|
+
metadata: {
|
|
118
|
+
type: "object",
|
|
119
|
+
additionalProperties: true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const updateAssetWireBody = {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
name: {
|
|
127
|
+
type: "string",
|
|
128
|
+
minLength: 1
|
|
129
|
+
},
|
|
130
|
+
category: { type: "string" },
|
|
131
|
+
location: { type: "string" },
|
|
132
|
+
custodian: { type: "string" },
|
|
133
|
+
metadata: {
|
|
134
|
+
type: "object",
|
|
135
|
+
additionalProperties: true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Build the fixed-asset resource over a live assets engine. Exported so a
|
|
141
|
+
* host can mount it directly (custom module composition).
|
|
142
|
+
*/
|
|
143
|
+
function createAssetResource(args) {
|
|
144
|
+
const { engine, permissions } = args;
|
|
145
|
+
const currency = args.currency;
|
|
146
|
+
if (!isCurrencyCode(currency)) throw new Error(`createAssetResource: \`currency\` must be an ISO 4217 code, got ${JSON.stringify(currency)}. It is host config with no default -- an asset register must know its functional currency.`);
|
|
147
|
+
const categoryEnum = args.categoryEnum;
|
|
148
|
+
const prefix = args.prefix;
|
|
149
|
+
const money$1 = (amount) => money(amount, currency);
|
|
150
|
+
/** Repo verbs return hydrated docs OR lean claim() results - normalize. */
|
|
151
|
+
const plain = (d) => {
|
|
152
|
+
const doc = d;
|
|
153
|
+
return typeof doc?.toObject === "function" ? doc.toObject() : d;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Actor context from the arc request (company-wide - no org scope).
|
|
157
|
+
*
|
|
158
|
+
* Scope-FIRST (arc 2.25): `request.scope` is the validated identity every
|
|
159
|
+
* auth adapter populates, so this resolves a human `userId` OR a service
|
|
160
|
+
* `clientId`. Reading `request.user._id` directly — as this did before —
|
|
161
|
+
* silently attributed EVERY fixed-asset mutation to `'system'` on any host
|
|
162
|
+
* using scope-based auth (service tokens, API keys), destroying the audit
|
|
163
|
+
* trail on a financial register. `scopeFirstCtx` is structural: it accepts a
|
|
164
|
+
* raw `FastifyRequest` AND arc's flat `IRequestContext` projection, so one
|
|
165
|
+
* resolver serves routes, actions, and controller overrides alike.
|
|
166
|
+
*/
|
|
167
|
+
const ctxOf = (req) => scopeFirstCtx(req, { fallbackActorId: "system" });
|
|
168
|
+
const DEPRECIATION_SCHEMA = {
|
|
169
|
+
type: "object",
|
|
170
|
+
required: ["method", "periods"],
|
|
171
|
+
properties: {
|
|
172
|
+
method: {
|
|
173
|
+
type: "string",
|
|
174
|
+
enum: [
|
|
175
|
+
"straight_line",
|
|
176
|
+
"declining_balance",
|
|
177
|
+
"double_declining",
|
|
178
|
+
"sum_of_years_digits",
|
|
179
|
+
"units_of_production",
|
|
180
|
+
"manual"
|
|
181
|
+
]
|
|
182
|
+
},
|
|
183
|
+
periods: {
|
|
184
|
+
type: "integer",
|
|
185
|
+
minimum: 1,
|
|
186
|
+
maximum: 1200
|
|
187
|
+
},
|
|
188
|
+
decliningRate: {
|
|
189
|
+
type: "number",
|
|
190
|
+
exclusiveMinimum: 0,
|
|
191
|
+
maximum: 1
|
|
192
|
+
},
|
|
193
|
+
totalUnits: {
|
|
194
|
+
type: "integer",
|
|
195
|
+
minimum: 1
|
|
196
|
+
},
|
|
197
|
+
manualSchedule: {
|
|
198
|
+
type: "array",
|
|
199
|
+
items: {
|
|
200
|
+
type: "integer",
|
|
201
|
+
minimum: 0
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
additionalProperties: false
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* CREATE is the domain verb, declared as a WRITE VERB (arc 2.34 `writes`)
|
|
209
|
+
* rather than a controller override.
|
|
210
|
+
*
|
|
211
|
+
* The override replaced arc's whole write pipeline for this slot, so the
|
|
212
|
+
* seven `systemManaged: true` rules below were declared and never enforced on
|
|
213
|
+
* `POST /` — as were tenant injection, `createdBy` and the hook sandwich.
|
|
214
|
+
* `acquire()` happens to build an explicit whitelist, so nothing forged got
|
|
215
|
+
* through in practice; that is the kernel saving the resource, not the
|
|
216
|
+
* resource being safe.
|
|
217
|
+
*
|
|
218
|
+
* It also fixed a drift the override could not: the controller hardcoded
|
|
219
|
+
* `tenantField: false` while the resource declares `tenantField ?? false`, so
|
|
220
|
+
* a host that passed one got a resource scoped by it and a controller that
|
|
221
|
+
* ignored it. Arc auto-builds the controller now, from the resource's own
|
|
222
|
+
* value.
|
|
223
|
+
*/
|
|
224
|
+
const createFixedAsset = async (data, ctx) => {
|
|
225
|
+
const asset = await engine.repositories.asset.acquire({
|
|
226
|
+
assetNumber: String(data.assetNumber ?? ""),
|
|
227
|
+
name: String(data.name ?? ""),
|
|
228
|
+
category: data.category ?? "office_equipment",
|
|
229
|
+
cost: money$1(Number(data.cost ?? 0)),
|
|
230
|
+
salvageValue: money$1(Number(data.salvageValue ?? 0)),
|
|
231
|
+
depreciation: data.depreciation,
|
|
232
|
+
...data.placedInServiceAt ? { placedInServiceAt: new Date(String(data.placedInServiceAt)) } : {},
|
|
233
|
+
...data.branchId ? { branchId: String(data.branchId) } : {},
|
|
234
|
+
...data.location ? { location: String(data.location) } : {},
|
|
235
|
+
...data.custodian ? { custodian: String(data.custodian) } : {},
|
|
236
|
+
...data.metadata ? { metadata: data.metadata } : {}
|
|
237
|
+
}, ctxOf(ctx));
|
|
238
|
+
return {
|
|
239
|
+
...plain(asset),
|
|
240
|
+
id: String(asset._id)
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
return defineResource({
|
|
244
|
+
name: "fixed-asset",
|
|
245
|
+
displayName: "Fixed Assets",
|
|
246
|
+
tag: "Finance - Fixed Assets",
|
|
247
|
+
prefix: prefix ?? "/assets",
|
|
248
|
+
audit: true,
|
|
249
|
+
adapter: createMongooseAdapter({
|
|
250
|
+
model: engine.models.FixedAsset,
|
|
251
|
+
repository: engine.repositories.asset
|
|
252
|
+
}),
|
|
253
|
+
customSchemas: {
|
|
254
|
+
create: { body: createAssetWireBody },
|
|
255
|
+
update: { body: updateAssetWireBody }
|
|
256
|
+
},
|
|
257
|
+
queryParser: new QueryParser({
|
|
258
|
+
maxLimit: 100,
|
|
259
|
+
allowedFilterFields: [
|
|
260
|
+
"status",
|
|
261
|
+
"category",
|
|
262
|
+
"assetNumber",
|
|
263
|
+
"branchId",
|
|
264
|
+
"location",
|
|
265
|
+
"custodian"
|
|
266
|
+
]
|
|
267
|
+
}),
|
|
268
|
+
tenantField: resolveSpineTenant(engine.config.tenant).resourceTenantField,
|
|
269
|
+
/**
|
|
270
|
+
* The CREATE slot IS the kernel's `acquire()` verb — arc keeps the pipeline
|
|
271
|
+
* (field-rule sanitization, tenant injection, actor stamp, hooks) and calls
|
|
272
|
+
* the command where it would have called `repository.create`.
|
|
273
|
+
*/
|
|
274
|
+
writes: { create: (data, wctx) => createFixedAsset(data, wctx.req) },
|
|
275
|
+
schemaOptions: { fieldRules: {
|
|
276
|
+
status: { systemManaged: true },
|
|
277
|
+
accumulatedDepreciation: { systemManaged: true },
|
|
278
|
+
bookValue: { systemManaged: true },
|
|
279
|
+
grossCarryingAmount: { systemManaged: true },
|
|
280
|
+
accumulatedImpairment: { systemManaged: true },
|
|
281
|
+
revaluationSurplus: { systemManaged: true },
|
|
282
|
+
version: { systemManaged: true }
|
|
283
|
+
} },
|
|
284
|
+
permissions: permissionMatrix({
|
|
285
|
+
read: permissions.view,
|
|
286
|
+
write: permissions.manage
|
|
287
|
+
}),
|
|
288
|
+
actions: {
|
|
289
|
+
/** draft → active. Optional body: { placedInServiceAt } (past dates OK). */
|
|
290
|
+
activate: {
|
|
291
|
+
permissions: permissions.manage,
|
|
292
|
+
handler: async (id, data, req) => {
|
|
293
|
+
const body = data ?? {};
|
|
294
|
+
return engine.repositories.asset.activate(id, ctxOf(req), body.placedInServiceAt ? new Date(body.placedInServiceAt) : void 0);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
/** Compute + persist the depreciation projection (safe pre-activation). */
|
|
298
|
+
"generate-schedule": {
|
|
299
|
+
permissions: permissions.manage,
|
|
300
|
+
schema: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
book: {
|
|
304
|
+
type: "string",
|
|
305
|
+
description: "Depreciation book — default 'accounting'; 'tax' is projection-only"
|
|
306
|
+
},
|
|
307
|
+
firstPeriodFraction: {
|
|
308
|
+
type: "number",
|
|
309
|
+
exclusiveMinimum: 0,
|
|
310
|
+
maximum: 1,
|
|
311
|
+
description: "Half-year rule = 0.5; mid-month proration"
|
|
312
|
+
},
|
|
313
|
+
methodOverride: { type: "string" },
|
|
314
|
+
periodsOverride: {
|
|
315
|
+
type: "integer",
|
|
316
|
+
minimum: 1
|
|
317
|
+
},
|
|
318
|
+
decliningRateOverride: {
|
|
319
|
+
type: "number",
|
|
320
|
+
exclusiveMinimum: 0,
|
|
321
|
+
maximum: 1
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
additionalProperties: false
|
|
325
|
+
},
|
|
326
|
+
handler: async (id, data, req) => engine.services.depreciation.generate(id, data ?? {}, ctxOf(req))
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Post ONE due period → JE `Dr 66xx Depreciation / Cr 1359 Accumulated`
|
|
330
|
+
* (accounting book; tax-book posts are projection flips only).
|
|
331
|
+
* Sequential-only; crash-retry dedupes via `dep:<scheduleId>:<sequence>`.
|
|
332
|
+
*/
|
|
333
|
+
"post-period": {
|
|
334
|
+
permissions: permissions.manage,
|
|
335
|
+
schema: {
|
|
336
|
+
type: "object",
|
|
337
|
+
required: ["sequence"],
|
|
338
|
+
properties: {
|
|
339
|
+
sequence: {
|
|
340
|
+
type: "integer",
|
|
341
|
+
minimum: 0
|
|
342
|
+
},
|
|
343
|
+
units: {
|
|
344
|
+
type: "integer",
|
|
345
|
+
minimum: 0
|
|
346
|
+
},
|
|
347
|
+
book: { type: "string" }
|
|
348
|
+
},
|
|
349
|
+
additionalProperties: false
|
|
350
|
+
},
|
|
351
|
+
handler: async (id, data, req) => {
|
|
352
|
+
const body = data;
|
|
353
|
+
return engine.services.depreciation.postPeriod(id, body.sequence, {
|
|
354
|
+
...body.units != null ? { units: body.units } : {},
|
|
355
|
+
...body.book ? { book: body.book } : {}
|
|
356
|
+
}, ctxOf(req));
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
/**
|
|
360
|
+
* active → disposed. The engine computes gain/loss and posts the
|
|
361
|
+
* BC-style derecognition JE via the ledger bridge (draft for finance
|
|
362
|
+
* review): Dr 1359 accumulated + Dr 1113 proceeds / Cr 13xx cost,
|
|
363
|
+
* plug → 6810 loss or 4304 gain. Idempotent on `disposal:<assetId>`.
|
|
364
|
+
*/
|
|
365
|
+
dispose: {
|
|
366
|
+
permissions: permissions.manage,
|
|
367
|
+
schema: {
|
|
368
|
+
type: "object",
|
|
369
|
+
properties: {
|
|
370
|
+
proceeds: {
|
|
371
|
+
type: "integer",
|
|
372
|
+
minimum: 0,
|
|
373
|
+
description: "Sale proceeds in paisa (0 = scrapped)"
|
|
374
|
+
},
|
|
375
|
+
disposedAt: { type: "string" },
|
|
376
|
+
note: {
|
|
377
|
+
type: "string",
|
|
378
|
+
maxLength: 512
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
additionalProperties: false
|
|
382
|
+
},
|
|
383
|
+
handler: async (id, data, req) => {
|
|
384
|
+
const body = data ?? {};
|
|
385
|
+
const { asset, gainLoss, ledgerEntryRef } = await engine.repositories.asset.dispose(id, {
|
|
386
|
+
proceeds: money$1(body.proceeds ?? 0),
|
|
387
|
+
...body.disposedAt ? { disposedAt: new Date(body.disposedAt) } : {},
|
|
388
|
+
...body.note ? { note: body.note } : {}
|
|
389
|
+
}, ctxOf(req));
|
|
390
|
+
return {
|
|
391
|
+
...plain(asset),
|
|
392
|
+
id: String(asset._id),
|
|
393
|
+
gainLoss,
|
|
394
|
+
disposalEntryRef: ledgerEntryRef
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
/** active → retired (kept on register, no longer depreciating). */
|
|
399
|
+
retire: {
|
|
400
|
+
permissions: permissions.manage,
|
|
401
|
+
handler: async (id, _data, req) => engine.repositories.asset.retire(id, ctxOf(req))
|
|
402
|
+
},
|
|
403
|
+
/** active → written_off (book value zeroed by decision; JE is finance's manual call). */
|
|
404
|
+
"write-off": {
|
|
405
|
+
permissions: permissions.manage,
|
|
406
|
+
handler: async (id, _data, req) => engine.repositories.asset.writeOff(id, ctxOf(req))
|
|
407
|
+
},
|
|
408
|
+
/** Move location / custodian / branch dimension. */
|
|
409
|
+
transfer: {
|
|
410
|
+
permissions: permissions.manage,
|
|
411
|
+
schema: {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
location: {
|
|
415
|
+
type: "string",
|
|
416
|
+
maxLength: 128
|
|
417
|
+
},
|
|
418
|
+
custodian: {
|
|
419
|
+
type: "string",
|
|
420
|
+
maxLength: 128
|
|
421
|
+
},
|
|
422
|
+
branchId: { type: "string" },
|
|
423
|
+
note: {
|
|
424
|
+
type: "string",
|
|
425
|
+
maxLength: 512
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
additionalProperties: false
|
|
429
|
+
},
|
|
430
|
+
handler: async (id, data, req) => engine.repositories.asset.transfer(id, data ?? {}, ctxOf(req))
|
|
431
|
+
},
|
|
432
|
+
/** Append an informational valuation (market / insurance / tax_assessed / liquidation). */
|
|
433
|
+
"record-valuation": {
|
|
434
|
+
permissions: permissions.view,
|
|
435
|
+
schema: {
|
|
436
|
+
type: "object",
|
|
437
|
+
required: ["kind", "value"],
|
|
438
|
+
properties: {
|
|
439
|
+
kind: {
|
|
440
|
+
type: "string",
|
|
441
|
+
enum: [
|
|
442
|
+
"market",
|
|
443
|
+
"insurance",
|
|
444
|
+
"tax_assessed",
|
|
445
|
+
"liquidation"
|
|
446
|
+
]
|
|
447
|
+
},
|
|
448
|
+
value: {
|
|
449
|
+
type: "integer",
|
|
450
|
+
minimum: 0,
|
|
451
|
+
description: "Value in paisa"
|
|
452
|
+
},
|
|
453
|
+
asOf: { type: "string" },
|
|
454
|
+
basis: {
|
|
455
|
+
type: "string",
|
|
456
|
+
enum: [
|
|
457
|
+
"market_approach",
|
|
458
|
+
"cost_approach",
|
|
459
|
+
"income_approach",
|
|
460
|
+
"index",
|
|
461
|
+
"manual"
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
appraiser: {
|
|
465
|
+
type: "string",
|
|
466
|
+
maxLength: 256
|
|
467
|
+
},
|
|
468
|
+
notes: {
|
|
469
|
+
type: "string",
|
|
470
|
+
maxLength: 1024
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
additionalProperties: false
|
|
474
|
+
},
|
|
475
|
+
handler: async (id, data, req) => {
|
|
476
|
+
const body = data;
|
|
477
|
+
return engine.repositories.valuation.record(id, {
|
|
478
|
+
kind: body.kind,
|
|
479
|
+
value: money$1(body.value),
|
|
480
|
+
...body.asOf ? { asOf: new Date(body.asOf) } : {},
|
|
481
|
+
...body.basis ? { basis: body.basis } : {},
|
|
482
|
+
...body.appraiser ? { appraiser: body.appraiser } : {},
|
|
483
|
+
...body.notes ? { notes: body.notes } : {}
|
|
484
|
+
}, ctxOf(req));
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
/** IAS 16 revaluation — restate carrying amount to fair value (prospective rebase). */
|
|
488
|
+
revalue: {
|
|
489
|
+
permissions: permissions.manage,
|
|
490
|
+
schema: {
|
|
491
|
+
type: "object",
|
|
492
|
+
required: ["fairValue"],
|
|
493
|
+
properties: {
|
|
494
|
+
fairValue: {
|
|
495
|
+
type: "integer",
|
|
496
|
+
minimum: 0,
|
|
497
|
+
description: "Fair value in paisa"
|
|
498
|
+
},
|
|
499
|
+
asOf: { type: "string" },
|
|
500
|
+
appraiser: {
|
|
501
|
+
type: "string",
|
|
502
|
+
maxLength: 256
|
|
503
|
+
},
|
|
504
|
+
notes: {
|
|
505
|
+
type: "string",
|
|
506
|
+
maxLength: 1024
|
|
507
|
+
},
|
|
508
|
+
remainingPeriods: {
|
|
509
|
+
type: "integer",
|
|
510
|
+
minimum: 1
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
additionalProperties: false
|
|
514
|
+
},
|
|
515
|
+
handler: async (id, data, req) => {
|
|
516
|
+
const body = data;
|
|
517
|
+
return engine.services.valuation.revalue(id, {
|
|
518
|
+
fairValue: money$1(body.fairValue),
|
|
519
|
+
...body.asOf ? { asOf: new Date(body.asOf) } : {},
|
|
520
|
+
...body.appraiser ? { appraiser: body.appraiser } : {},
|
|
521
|
+
...body.notes ? { notes: body.notes } : {},
|
|
522
|
+
...body.remainingPeriods != null ? { remainingPeriods: body.remainingPeriods } : {}
|
|
523
|
+
}, ctxOf(req));
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
/** IAS 36 impairment — write down to the recoverable amount (prospective rebase). */
|
|
527
|
+
impair: {
|
|
528
|
+
permissions: permissions.manage,
|
|
529
|
+
schema: {
|
|
530
|
+
type: "object",
|
|
531
|
+
required: ["recoverableAmount"],
|
|
532
|
+
properties: {
|
|
533
|
+
recoverableAmount: {
|
|
534
|
+
type: "integer",
|
|
535
|
+
minimum: 0,
|
|
536
|
+
description: "Recoverable amount in paisa"
|
|
537
|
+
},
|
|
538
|
+
asOf: { type: "string" },
|
|
539
|
+
appraiser: {
|
|
540
|
+
type: "string",
|
|
541
|
+
maxLength: 256
|
|
542
|
+
},
|
|
543
|
+
notes: {
|
|
544
|
+
type: "string",
|
|
545
|
+
maxLength: 1024
|
|
546
|
+
},
|
|
547
|
+
remainingPeriods: {
|
|
548
|
+
type: "integer",
|
|
549
|
+
minimum: 1
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
additionalProperties: false
|
|
553
|
+
},
|
|
554
|
+
handler: async (id, data, req) => {
|
|
555
|
+
const body = data;
|
|
556
|
+
return engine.services.valuation.impair(id, {
|
|
557
|
+
recoverableAmount: money$1(body.recoverableAmount),
|
|
558
|
+
...body.asOf ? { asOf: new Date(body.asOf) } : {},
|
|
559
|
+
...body.appraiser ? { appraiser: body.appraiser } : {},
|
|
560
|
+
...body.notes ? { notes: body.notes } : {},
|
|
561
|
+
...body.remainingPeriods != null ? { remainingPeriods: body.remainingPeriods } : {}
|
|
562
|
+
}, ctxOf(req));
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
/** IAS 36 impairment reversal — capped at the accumulated loss. */
|
|
566
|
+
"reverse-impairment": {
|
|
567
|
+
permissions: permissions.manage,
|
|
568
|
+
schema: {
|
|
569
|
+
type: "object",
|
|
570
|
+
required: ["amount"],
|
|
571
|
+
properties: {
|
|
572
|
+
amount: {
|
|
573
|
+
type: "integer",
|
|
574
|
+
minimum: 1,
|
|
575
|
+
description: "Reversal amount in paisa"
|
|
576
|
+
},
|
|
577
|
+
asOf: { type: "string" },
|
|
578
|
+
notes: {
|
|
579
|
+
type: "string",
|
|
580
|
+
maxLength: 1024
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
additionalProperties: false
|
|
584
|
+
},
|
|
585
|
+
handler: async (id, data, req) => {
|
|
586
|
+
const body = data;
|
|
587
|
+
return engine.services.valuation.reverseImpairment(id, {
|
|
588
|
+
amount: money$1(body.amount),
|
|
589
|
+
...body.asOf ? { asOf: new Date(body.asOf) } : {},
|
|
590
|
+
...body.notes ? { notes: body.notes } : {}
|
|
591
|
+
}, ctxOf(req));
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
/** IAS 8 estimate change — remaining life and/or salvage, prospective. */
|
|
595
|
+
"change-estimate": {
|
|
596
|
+
permissions: permissions.manage,
|
|
597
|
+
schema: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {
|
|
600
|
+
remainingPeriods: {
|
|
601
|
+
type: "integer",
|
|
602
|
+
minimum: 1
|
|
603
|
+
},
|
|
604
|
+
salvageValue: {
|
|
605
|
+
type: "integer",
|
|
606
|
+
minimum: 0,
|
|
607
|
+
description: "New salvage in paisa"
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
additionalProperties: false
|
|
611
|
+
},
|
|
612
|
+
handler: async (id, data, req) => {
|
|
613
|
+
const body = data ?? {};
|
|
614
|
+
return engine.services.valuation.changeEstimate(id, {
|
|
615
|
+
...body.remainingPeriods != null ? { remainingPeriods: body.remainingPeriods } : {},
|
|
616
|
+
...body.salvageValue != null ? { salvageValue: money$1(body.salvageValue) } : {}
|
|
617
|
+
}, ctxOf(req));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
routes: [
|
|
622
|
+
{
|
|
623
|
+
method: "GET",
|
|
624
|
+
path: "/:id/schedule",
|
|
625
|
+
summary: "Depreciation schedule (all periods with posting state; ?book=tax for alternate books)",
|
|
626
|
+
permissions: permissions.view,
|
|
627
|
+
rawHandler: async (req, reply) => {
|
|
628
|
+
const { id } = req.params;
|
|
629
|
+
const { book } = req.query;
|
|
630
|
+
const schedule = await engine.repositories.schedule.findByAssetAndBook(id, book ?? "accounting", {});
|
|
631
|
+
return reply.send(schedule ?? null);
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
method: "GET",
|
|
636
|
+
path: "/:id/valuations",
|
|
637
|
+
summary: "Append-only valuation history (market/insurance/tax + revaluation/impairment events)",
|
|
638
|
+
permissions: permissions.view,
|
|
639
|
+
rawHandler: async (req, reply) => {
|
|
640
|
+
const { id } = req.params;
|
|
641
|
+
const rows = await engine.models.FixedAssetValuation.find({ assetId: id }).sort({ asOf: -1 }).lean();
|
|
642
|
+
return reply.send(rows);
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
method: "GET",
|
|
647
|
+
path: "/:id/current-value",
|
|
648
|
+
summary: "Value resolution — ?kind=book|market|insurance|tax_assessed|liquidation (&asOf=ISO)",
|
|
649
|
+
permissions: permissions.view,
|
|
650
|
+
rawHandler: async (req, reply) => {
|
|
651
|
+
const { id } = req.params;
|
|
652
|
+
const { kind, asOf } = req.query;
|
|
653
|
+
const result = await engine.services.valuation.currentValue(id, {
|
|
654
|
+
...kind ? { kind } : {},
|
|
655
|
+
...asOf ? { asOf: new Date(asOf) } : {}
|
|
656
|
+
}, {});
|
|
657
|
+
return reply.send(result);
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
method: "POST",
|
|
662
|
+
path: "/onboard",
|
|
663
|
+
summary: "Onboard an EXISTING asset at go-live (net method — no JEs; GL values come from the opening-balance batch)",
|
|
664
|
+
permissions: permissions.manage,
|
|
665
|
+
schema: { body: {
|
|
666
|
+
type: "object",
|
|
667
|
+
required: [
|
|
668
|
+
"assetNumber",
|
|
669
|
+
"name",
|
|
670
|
+
"category",
|
|
671
|
+
"originalCost",
|
|
672
|
+
"accumulatedDepreciation",
|
|
673
|
+
"placedInServiceAt",
|
|
674
|
+
"depreciation"
|
|
675
|
+
],
|
|
676
|
+
properties: {
|
|
677
|
+
assetNumber: {
|
|
678
|
+
type: "string",
|
|
679
|
+
minLength: 1,
|
|
680
|
+
maxLength: 64
|
|
681
|
+
},
|
|
682
|
+
name: {
|
|
683
|
+
type: "string",
|
|
684
|
+
minLength: 1,
|
|
685
|
+
maxLength: 256
|
|
686
|
+
},
|
|
687
|
+
category: categoryEnum ? {
|
|
688
|
+
type: "string",
|
|
689
|
+
enum: categoryEnum
|
|
690
|
+
} : { type: "string" },
|
|
691
|
+
originalCost: {
|
|
692
|
+
type: "integer",
|
|
693
|
+
minimum: 1,
|
|
694
|
+
description: "Historical acquisition cost (paisa)"
|
|
695
|
+
},
|
|
696
|
+
accumulatedDepreciation: {
|
|
697
|
+
type: "integer",
|
|
698
|
+
minimum: 0,
|
|
699
|
+
description: "Depreciation already taken pre-migration (paisa)"
|
|
700
|
+
},
|
|
701
|
+
salvageValue: {
|
|
702
|
+
type: "integer",
|
|
703
|
+
minimum: 0,
|
|
704
|
+
default: 0
|
|
705
|
+
},
|
|
706
|
+
placedInServiceAt: {
|
|
707
|
+
type: "string",
|
|
708
|
+
description: "Original in-service date (past)"
|
|
709
|
+
},
|
|
710
|
+
depreciation: DEPRECIATION_SCHEMA,
|
|
711
|
+
branchId: { type: "string" },
|
|
712
|
+
metadata: {
|
|
713
|
+
type: "object",
|
|
714
|
+
additionalProperties: true
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
additionalProperties: false
|
|
718
|
+
} },
|
|
719
|
+
rawHandler: async (req, reply) => {
|
|
720
|
+
const body = req.body;
|
|
721
|
+
const salvage = body.salvageValue ?? 0;
|
|
722
|
+
const remainingBookValue = body.originalCost - body.accumulatedDepreciation;
|
|
723
|
+
if (remainingBookValue < salvage) throw new ValidationError(`accumulatedDepreciation leaves book value (${remainingBookValue}) below salvage (${salvage})`);
|
|
724
|
+
const ctx = ctxOf(req);
|
|
725
|
+
const asset = await engine.repositories.asset.acquire({
|
|
726
|
+
assetNumber: body.assetNumber,
|
|
727
|
+
name: body.name,
|
|
728
|
+
category: body.category,
|
|
729
|
+
cost: money$1(remainingBookValue),
|
|
730
|
+
salvageValue: money$1(salvage),
|
|
731
|
+
depreciation: body.depreciation,
|
|
732
|
+
placedInServiceAt: new Date(body.placedInServiceAt),
|
|
733
|
+
...body.branchId ? { branchId: body.branchId } : {},
|
|
734
|
+
metadata: {
|
|
735
|
+
...body.metadata ?? {},
|
|
736
|
+
onboarded: true,
|
|
737
|
+
originalCost: body.originalCost,
|
|
738
|
+
accumulatedAtMigration: body.accumulatedDepreciation
|
|
739
|
+
}
|
|
740
|
+
}, ctx);
|
|
741
|
+
const id = String(asset._id);
|
|
742
|
+
const activated = await engine.repositories.asset.activate(id, ctx, new Date(body.placedInServiceAt));
|
|
743
|
+
const schedule = await engine.services.depreciation.generate(id, {}, ctx);
|
|
744
|
+
return reply.code(201).send({
|
|
745
|
+
asset: {
|
|
746
|
+
...plain(activated),
|
|
747
|
+
id: String(activated._id)
|
|
748
|
+
},
|
|
749
|
+
schedule
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
]
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function createAssetsModule(deps) {
|
|
757
|
+
const maintenance = deps.maintenance;
|
|
758
|
+
const dependsOn = deps.dependsOn ?? (typeof deps.ledger === "function" ? ["accounting"] : []);
|
|
759
|
+
/**
|
|
760
|
+
* The live Fastify instance, captured for the FACTORY ledger form only.
|
|
761
|
+
*
|
|
762
|
+
* `defineEngineModule`'s `boot` is nullary by design, but a FACTORY ledger
|
|
763
|
+
* (`deps.ledger(fastify)`) must read `f.arc.modules.accounting` at bind
|
|
764
|
+
* time. Arc runs a module's `plugins` arm — which DOES receive the live
|
|
765
|
+
* instance — before ANY bootstrap, so the `extend` arm below captures it
|
|
766
|
+
* there and `boot` dereferences it. Registered only when the factory form
|
|
767
|
+
* is in play; the timing of the factory CALL is unchanged (still at
|
|
768
|
+
* bootstrap, still after accounting's own bootstrap via `dependsOn`).
|
|
769
|
+
*/
|
|
770
|
+
let app;
|
|
771
|
+
const { module } = defineEngineModule({
|
|
772
|
+
name: "assets",
|
|
773
|
+
...deps.engine !== void 0 ? { supplied: deps.engine } : {},
|
|
774
|
+
...dependsOn.length > 0 ? { dependsOn } : {},
|
|
775
|
+
/**
|
|
776
|
+
* Deferred by construction (the old hand-rolled dance said "NOT
|
|
777
|
+
* `ownedEngine()`" for the same two reasons): `defineAssets().bind()` is
|
|
778
|
+
* async, and a FACTORY ledger needs the live Fastify instance — so
|
|
779
|
+
* nothing here may run at composition. `defineEngineModule` runs this at
|
|
780
|
+
* arc's bootstrap, after `dependsOn` modules.
|
|
781
|
+
*/
|
|
782
|
+
boot: async () => {
|
|
783
|
+
let ledger;
|
|
784
|
+
if (typeof deps.ledger === "function") {
|
|
785
|
+
if (app === void 0) throw new Error("[spinekit/assets] a FACTORY ledger (`deps.ledger(fastify)`) is resolved with the live Fastify instance this module captures in its plugins phase — boot() ran without it, i.e. outside arc's lifecycle. Boot through arc, or resolve the LedgerPort yourself and pass it as a value.");
|
|
786
|
+
ledger = deps.ledger(app);
|
|
787
|
+
} else ledger = deps.ledger;
|
|
788
|
+
return defineAssets({
|
|
789
|
+
tenant: resolveSpineTenant(deps.tenant ?? false).input,
|
|
790
|
+
...deps.schemaOptions ? { schemaOptions: deps.schemaOptions } : {},
|
|
791
|
+
...deps.forceRecreate !== void 0 ? { forceRecreate: deps.forceRecreate } : {}
|
|
792
|
+
}).bind(deps.connection, {
|
|
793
|
+
...ledger ? { bridges: { ledger } } : {},
|
|
794
|
+
...deps.eventTransport ? { eventTransport: deps.eventTransport } : {},
|
|
795
|
+
...deps.outbox ? { outbox: deps.outbox } : {},
|
|
796
|
+
...deps.allowNonTransactional !== void 0 ? { allowNonTransactional: deps.allowNonTransactional } : {}
|
|
797
|
+
});
|
|
798
|
+
},
|
|
799
|
+
close: (engine) => engine.close(),
|
|
800
|
+
resources: (engine) => [createAssetResource({
|
|
801
|
+
engine,
|
|
802
|
+
permissions: deps.permissions,
|
|
803
|
+
currency: deps.currency,
|
|
804
|
+
...deps.categoryEnum ? { categoryEnum: deps.categoryEnum } : {},
|
|
805
|
+
...deps.prefix ? { prefix: deps.prefix } : {}
|
|
806
|
+
})],
|
|
807
|
+
extend: (get) => ({
|
|
808
|
+
...typeof deps.ledger === "function" ? { plugins: (fastify) => {
|
|
809
|
+
app = fastify;
|
|
810
|
+
} } : {},
|
|
811
|
+
...maintenance === false ? {} : { scheduledJobs: () => assetsMaintenanceSchedules(get(), maintenance) }
|
|
812
|
+
})
|
|
813
|
+
});
|
|
814
|
+
return module;
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
export { assetsMaintenanceSchedules, createAssetResource, createAssetsModule };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spinekit/assets",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Arc module for @classytic/assets — fixed-asset register + multi-book depreciation + IAS 16/36 valuation as composable resources. BYO-engine or module-created; ledger bridge is host-injected (any chart of accounts). Depreciation posts through the bridge; GL account mapping stays host policy.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@classytic/arc": ">=2.34.0",
|
|
23
|
+
"@spinekit/kit": ">=0.1.0",
|
|
24
|
+
"@classytic/assets": ">=0.5.0",
|
|
25
|
+
"@classytic/mongokit": ">=3.34.0",
|
|
26
|
+
"mongoose": ">=9.4.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@classytic/arc": "^2.34.0",
|
|
30
|
+
"@classytic/arc-testkit": "^0.4.0",
|
|
31
|
+
"@classytic/assets": ">=0.5.0",
|
|
32
|
+
"@classytic/mongokit": ">=3.34.0",
|
|
33
|
+
"@classytic/primitives": ">=0.23.0",
|
|
34
|
+
"@types/node": "^24.3.0",
|
|
35
|
+
"fastify": "^5.12.0",
|
|
36
|
+
"mongodb-memory-server": "^10.4.3",
|
|
37
|
+
"mongoose": "^9.7.2",
|
|
38
|
+
"tsdown": "^0.22.14",
|
|
39
|
+
"typescript": "^7.0.2",
|
|
40
|
+
"vitest": "^3.2.4",
|
|
41
|
+
"@spinekit/kit": "0.1.1"
|
|
42
|
+
},
|
|
43
|
+
"author": "Classytic",
|
|
44
|
+
"homepage": "https://www.npmjs.com/package/@spinekit/assets",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/classytic/spine.git",
|
|
48
|
+
"directory": "packages/spine-assets"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"arc",
|
|
52
|
+
"classytic",
|
|
53
|
+
"spine",
|
|
54
|
+
"erp",
|
|
55
|
+
"fixed-assets",
|
|
56
|
+
"depreciation",
|
|
57
|
+
"ias-16"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsdown",
|
|
64
|
+
"typecheck": "tsc --noEmit",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
|
|
67
|
+
}
|
|
68
|
+
}
|