@endora-commerce/mod-credit-limits 0.100.0
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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/admin/index.d.ts +31 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +32 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/CreditLimitsPage.d.ts +4 -0
- package/dist/admin/pages/CreditLimitsPage.d.ts.map +1 -0
- package/dist/admin/pages/CreditLimitsPage.js +144 -0
- package/dist/admin/pages/CreditLimitsPage.js.map +1 -0
- package/dist/backend/entities/credit-limit-reservation.entity.d.ts +35 -0
- package/dist/backend/entities/credit-limit-reservation.entity.d.ts.map +1 -0
- package/dist/backend/entities/credit-limit-reservation.entity.js +99 -0
- package/dist/backend/entities/credit-limit-reservation.entity.js.map +1 -0
- package/dist/backend/entities/credit-limit-return-topup.entity.d.ts +29 -0
- package/dist/backend/entities/credit-limit-return-topup.entity.d.ts.map +1 -0
- package/dist/backend/entities/credit-limit-return-topup.entity.js +71 -0
- package/dist/backend/entities/credit-limit-return-topup.entity.js.map +1 -0
- package/dist/backend/entities/credit-limit.entity.d.ts +21 -0
- package/dist/backend/entities/credit-limit.entity.d.ts.map +1 -0
- package/dist/backend/entities/credit-limit.entity.js +66 -0
- package/dist/backend/entities/credit-limit.entity.js.map +1 -0
- package/dist/backend/index.d.ts +80 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +112 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.d.ts +59 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +114 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/credit-limit-read.d.ts +23 -0
- package/dist/backend/services/credit-limit-read.d.ts.map +1 -0
- package/dist/backend/services/credit-limit-read.js +35 -0
- package/dist/backend/services/credit-limit-read.js.map +1 -0
- package/dist/backend/services/credit-limit-service.d.ts +179 -0
- package/dist/backend/services/credit-limit-service.d.ts.map +1 -0
- package/dist/backend/services/credit-limit-service.js +487 -0
- package/dist/backend/services/credit-limit-service.js.map +1 -0
- package/dist/backend/services/credit-topup.d.ts +33 -0
- package/dist/backend/services/credit-topup.d.ts.map +1 -0
- package/dist/backend/services/credit-topup.js +46 -0
- package/dist/backend/services/credit-topup.js.map +1 -0
- package/dist/manifest.d.ts +169 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +138 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260425T063333_credit_limits_init.d.ts +15 -0
- package/dist/migrations/20260425T063333_credit_limits_init.d.ts.map +1 -0
- package/dist/migrations/20260425T063333_credit_limits_init.js +56 -0
- package/dist/migrations/20260425T063333_credit_limits_init.js.map +1 -0
- package/dist/migrations/20260817T201111_credit_limits_return_topups.d.ts +17 -0
- package/dist/migrations/20260817T201111_credit_limits_return_topups.d.ts.map +1 -0
- package/dist/migrations/20260817T201111_credit_limits_return_topups.js +36 -0
- package/dist/migrations/20260817T201111_credit_limits_return_topups.js.map +1 -0
- package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.d.ts +44 -0
- package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.d.ts.map +1 -0
- package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.js +81 -0
- package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.js.map +1 -0
- package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.d.ts +29 -0
- package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.d.ts.map +1 -0
- package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.js +45 -0
- package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.js.map +1 -0
- package/dist/migrations/index.d.ts +39 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +44 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/ports/index.d.ts +93 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +2 -0
- package/dist/ports/index.js.map +1 -0
- package/docs/credit_limits.md +53 -0
- package/i18n/en.json +9 -0
- package/i18n/pl.json +9 -0
- package/package.json +92 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { EventBase, EventBus } from '@endora-commerce/platform/events';
|
|
3
|
+
import type { CommandBus } from '@endora-commerce/platform/commands';
|
|
4
|
+
import { CreditLimit } from '../entities/credit-limit.entity.js';
|
|
5
|
+
import { CreditLimitReservation } from '../entities/credit-limit-reservation.entity.js';
|
|
6
|
+
import type { CreditTopupResult, OrganizationInheritancePort } from '@endora-commerce/contracts';
|
|
7
|
+
/**
|
|
8
|
+
* CreditLimitService (T214) — implements the contract documented in
|
|
9
|
+
* specs/001-b2b-platform-foundation/contracts/credit_limits.contract.md.
|
|
10
|
+
*
|
|
11
|
+
* - reserve: SELECT … FOR UPDATE on the credit_limits row to serialise
|
|
12
|
+
* concurrent placers (R-10, SC-011 zero-silent-breach). Returns a
|
|
13
|
+
* discriminated-union result instead of throwing — the caller (place-order
|
|
14
|
+
* path) maps `LIMIT_INSUFFICIENT` to a 409 response.
|
|
15
|
+
* - releaseByOrder: idempotent — second call for the same order returns
|
|
16
|
+
* { ok: false, code: 'ALREADY_RELEASED' } rather than re-crediting.
|
|
17
|
+
*
|
|
18
|
+
* Both methods may be called inside an outer transaction or alone; the
|
|
19
|
+
* service's `tx` factory parameter lets the caller pass its own EM.
|
|
20
|
+
*/
|
|
21
|
+
export interface CreditLimitEvents extends Record<string, EventBase> {
|
|
22
|
+
'credit_limit.granted.v1': EventBase & {
|
|
23
|
+
organizationId: string;
|
|
24
|
+
amount: number;
|
|
25
|
+
};
|
|
26
|
+
'credit_limit.adjusted.v1': EventBase & {
|
|
27
|
+
organizationId: string;
|
|
28
|
+
amount: number;
|
|
29
|
+
};
|
|
30
|
+
'credit_limit.revoked.v1': EventBase & {
|
|
31
|
+
organizationId: string;
|
|
32
|
+
};
|
|
33
|
+
'credit_limit.reserved.v1': EventBase & {
|
|
34
|
+
organizationId: string;
|
|
35
|
+
orderId: string;
|
|
36
|
+
amount: number;
|
|
37
|
+
};
|
|
38
|
+
'credit_limit.released.v1': EventBase & {
|
|
39
|
+
organizationId: string;
|
|
40
|
+
orderId: string;
|
|
41
|
+
amount: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export type CreditLimitEventBus = EventBus<CreditLimitEvents>;
|
|
46
|
+
export type ReserveResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
reservationId: string;
|
|
49
|
+
availableAmountAfter: number;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
code: 'LIMIT_INSUFFICIENT';
|
|
53
|
+
availableAmount: number;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
code: 'CREDIT_LIMIT_NOT_GRANTED';
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
code: 'CURRENCY_MISMATCH';
|
|
60
|
+
};
|
|
61
|
+
export type ReleaseResult = {
|
|
62
|
+
ok: true;
|
|
63
|
+
reservationId: string;
|
|
64
|
+
availableAmountAfter: number;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
code: 'RESERVATION_NOT_FOUND' | 'ALREADY_RELEASED';
|
|
68
|
+
};
|
|
69
|
+
export type AdjustResult = {
|
|
70
|
+
ok: true;
|
|
71
|
+
limit: CreditLimit;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
code: 'ADJUSTMENT_BELOW_ACTIVE';
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* What `creditFromReturn` answers (D-91).
|
|
78
|
+
*
|
|
79
|
+
* `CreditTopupResult` — the shape `returns` states and this module satisfies —
|
|
80
|
+
* plus the one fact the port does not carry: whether this call is the one that
|
|
81
|
+
* moved the grant. A retried settlement is `applied: true, alreadyApplied:
|
|
82
|
+
* true`, which is what keeps the audit row and the domain event to one per
|
|
83
|
+
* return case while the caller still sees a credited return.
|
|
84
|
+
*/
|
|
85
|
+
export interface CreditFromReturnResult extends CreditTopupResult {
|
|
86
|
+
alreadyApplied: boolean;
|
|
87
|
+
}
|
|
88
|
+
export declare class CreditLimitService {
|
|
89
|
+
#private;
|
|
90
|
+
private readonly emFactory;
|
|
91
|
+
private readonly events;
|
|
92
|
+
/**
|
|
93
|
+
* Feature 054 — when injected, `adjust` runs through the Command Bus so the
|
|
94
|
+
* mutation is audited co-transactionally (Principle XIII). Optional: existing
|
|
95
|
+
* tests construct this service without a bus and keep the legacy (unaudited)
|
|
96
|
+
* path, which stays byte-identical.
|
|
97
|
+
*/
|
|
98
|
+
private readonly commandBus?;
|
|
99
|
+
/**
|
|
100
|
+
* Feature 056 — organizations-owned resolution port. When injected, a
|
|
101
|
+
* descendant with no own `CreditLimit` row transacts against the nearest
|
|
102
|
+
* ancestor's per the effective mode (shared_pool / independent_default).
|
|
103
|
+
* Absent ⇒ flat behavior (byte-for-byte the pre-feature single-org path).
|
|
104
|
+
*/
|
|
105
|
+
private readonly inheritance?;
|
|
106
|
+
constructor(emFactory: () => EntityManager, events: CreditLimitEventBus,
|
|
107
|
+
/**
|
|
108
|
+
* Feature 054 — when injected, `adjust` runs through the Command Bus so the
|
|
109
|
+
* mutation is audited co-transactionally (Principle XIII). Optional: existing
|
|
110
|
+
* tests construct this service without a bus and keep the legacy (unaudited)
|
|
111
|
+
* path, which stays byte-identical.
|
|
112
|
+
*/
|
|
113
|
+
commandBus?: CommandBus | undefined,
|
|
114
|
+
/**
|
|
115
|
+
* Feature 056 — organizations-owned resolution port. When injected, a
|
|
116
|
+
* descendant with no own `CreditLimit` row transacts against the nearest
|
|
117
|
+
* ancestor's per the effective mode (shared_pool / independent_default).
|
|
118
|
+
* Absent ⇒ flat behavior (byte-for-byte the pre-feature single-org path).
|
|
119
|
+
*/
|
|
120
|
+
inheritance?: OrganizationInheritancePort | undefined);
|
|
121
|
+
getForOrganization(organizationId: string): Promise<CreditLimit | null>;
|
|
122
|
+
listAll(): Promise<CreditLimit[]>;
|
|
123
|
+
listActiveReservations(creditLimitId: string): Promise<CreditLimitReservation[]>;
|
|
124
|
+
grant(input: {
|
|
125
|
+
organizationId: string;
|
|
126
|
+
grantedAmount: number;
|
|
127
|
+
currency: string;
|
|
128
|
+
grantedByAdminUserId?: string;
|
|
129
|
+
}): Promise<CreditLimit>;
|
|
130
|
+
adjust(input: {
|
|
131
|
+
organizationId: string;
|
|
132
|
+
grantedAmount: number;
|
|
133
|
+
allowOverAllocation?: boolean;
|
|
134
|
+
}): Promise<AdjustResult>;
|
|
135
|
+
/**
|
|
136
|
+
* Credit an organization's grant for a settled return, **once per return
|
|
137
|
+
* case** (D-91).
|
|
138
|
+
*
|
|
139
|
+
* The settlement ordering law attempts every external effect before it writes
|
|
140
|
+
* any state, so a refusal from a later step leaves a case an admin can settle
|
|
141
|
+
* again — and the retry arrives here with the same `returnCaseId`. Until D-91
|
|
142
|
+
* this method's caller read the grant and added to it, ignoring the
|
|
143
|
+
* `returnCaseId` it was already being handed, so the second attempt credited
|
|
144
|
+
* the organization a second time.
|
|
145
|
+
*
|
|
146
|
+
* The credit and the `credit_limit_return_topups` row are written by one
|
|
147
|
+
* Command, so they commit together: the fact that a case was credited cannot
|
|
148
|
+
* outlive the credit, and the credit cannot outlive the fact. The unique
|
|
149
|
+
* index on `return_case_id` settles two concurrent retries.
|
|
150
|
+
*/
|
|
151
|
+
creditFromReturn(input: {
|
|
152
|
+
organizationId: string;
|
|
153
|
+
amount: number;
|
|
154
|
+
currency: string;
|
|
155
|
+
returnCaseId: string;
|
|
156
|
+
}): Promise<CreditFromReturnResult>;
|
|
157
|
+
reserve(input: {
|
|
158
|
+
organizationId: string;
|
|
159
|
+
orderId: string;
|
|
160
|
+
amount: number;
|
|
161
|
+
currency: string;
|
|
162
|
+
/**
|
|
163
|
+
* The order-placement transaction. **Required** since D-94.5: the one
|
|
164
|
+
* caller always passed it, and the optional shape is what let this method
|
|
165
|
+
* double as a standalone transaction — a lie about the seam.
|
|
166
|
+
* `credit_limit_reservations_order_fk` (`on delete restrict`) means the
|
|
167
|
+
* reservation row cannot exist before the order does, and the
|
|
168
|
+
* `PESSIMISTIC_WRITE` this takes on the organization's credit row has to
|
|
169
|
+
* be held until placement commits or credit is consumed for an order that
|
|
170
|
+
* rolled back.
|
|
171
|
+
*/
|
|
172
|
+
tx: EntityManager;
|
|
173
|
+
}): Promise<ReserveResult>;
|
|
174
|
+
releaseByOrder(input: {
|
|
175
|
+
orderId: string;
|
|
176
|
+
reason: 'invoice_paid' | 'order_cancelled' | 'admin_revocation';
|
|
177
|
+
}): Promise<ReleaseResult>;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=credit-limit-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credit-limit-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/credit-limit-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { LockMode } from '@mikro-orm/core';
|
|
3
|
+
import { CreditLimit } from '../entities/credit-limit.entity.js';
|
|
4
|
+
import { CreditLimitReservation } from '../entities/credit-limit-reservation.entity.js';
|
|
5
|
+
import { CreditLimitReturnTopup } from '../entities/credit-limit-return-topup.entity.js';
|
|
6
|
+
export class CreditLimitService {
|
|
7
|
+
emFactory;
|
|
8
|
+
events;
|
|
9
|
+
commandBus;
|
|
10
|
+
inheritance;
|
|
11
|
+
constructor(emFactory, events,
|
|
12
|
+
/**
|
|
13
|
+
* Feature 054 — when injected, `adjust` runs through the Command Bus so the
|
|
14
|
+
* mutation is audited co-transactionally (Principle XIII). Optional: existing
|
|
15
|
+
* tests construct this service without a bus and keep the legacy (unaudited)
|
|
16
|
+
* path, which stays byte-identical.
|
|
17
|
+
*/
|
|
18
|
+
commandBus,
|
|
19
|
+
/**
|
|
20
|
+
* Feature 056 — organizations-owned resolution port. When injected, a
|
|
21
|
+
* descendant with no own `CreditLimit` row transacts against the nearest
|
|
22
|
+
* ancestor's per the effective mode (shared_pool / independent_default).
|
|
23
|
+
* Absent ⇒ flat behavior (byte-for-byte the pre-feature single-org path).
|
|
24
|
+
*/
|
|
25
|
+
inheritance) {
|
|
26
|
+
this.emFactory = emFactory;
|
|
27
|
+
this.events = events;
|
|
28
|
+
this.commandBus = commandBus;
|
|
29
|
+
this.inheritance = inheritance;
|
|
30
|
+
}
|
|
31
|
+
async getForOrganization(organizationId) {
|
|
32
|
+
const em = this.emFactory();
|
|
33
|
+
const own = await em.findOne(CreditLimit, { organizationId });
|
|
34
|
+
if (own || !this.inheritance)
|
|
35
|
+
return own;
|
|
36
|
+
// Feature 056 — fall back to the nearest ancestor holding a limit. The owner
|
|
37
|
+
// may lie outside the caller's tenant scope, so the org filter is disabled
|
|
38
|
+
// for this resolution read (the reserve/enforcement path is separate).
|
|
39
|
+
const { ownerOrgId } = await this.inheritance.creditOwner(organizationId);
|
|
40
|
+
if (!ownerOrgId || ownerOrgId === organizationId)
|
|
41
|
+
return null;
|
|
42
|
+
return em.findOne(CreditLimit, { organizationId: ownerOrgId }, { filters: { org: false } });
|
|
43
|
+
}
|
|
44
|
+
async listAll() {
|
|
45
|
+
const em = this.emFactory();
|
|
46
|
+
return em.find(CreditLimit, {}, { orderBy: { createdAt: 'desc' } });
|
|
47
|
+
}
|
|
48
|
+
async listActiveReservations(creditLimitId) {
|
|
49
|
+
const em = this.emFactory();
|
|
50
|
+
return em.find(CreditLimitReservation, { creditLimitId, status: 'active' });
|
|
51
|
+
}
|
|
52
|
+
async grant(input) {
|
|
53
|
+
// Feature 054 — audited path via the Command Bus (one co-transactional audit
|
|
54
|
+
// row + the event on commit). Legacy fallback for bus-less constructions.
|
|
55
|
+
if (this.commandBus)
|
|
56
|
+
return this.commandBus.run(this.#grantCommand(input));
|
|
57
|
+
const em = this.emFactory();
|
|
58
|
+
const limit = this.#applyGrant(em, input);
|
|
59
|
+
await em.flush();
|
|
60
|
+
this.#emitGranted(input);
|
|
61
|
+
return limit;
|
|
62
|
+
}
|
|
63
|
+
#grantCommand(input) {
|
|
64
|
+
return {
|
|
65
|
+
action: 'credit_limit.grant',
|
|
66
|
+
objectType: 'credit_limit',
|
|
67
|
+
objectId: input.organizationId,
|
|
68
|
+
run: async ({ em }) => {
|
|
69
|
+
const limit = this.#applyGrant(em, input);
|
|
70
|
+
return {
|
|
71
|
+
result: limit,
|
|
72
|
+
after: {
|
|
73
|
+
organizationId: input.organizationId,
|
|
74
|
+
grantedAmount: limit.grantedAmount,
|
|
75
|
+
currency: input.currency,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
event: () => ({
|
|
80
|
+
eventName: 'credit_limit.granted.v1',
|
|
81
|
+
payload: {
|
|
82
|
+
eventId: randomUUID(),
|
|
83
|
+
occurredAt: new Date().toISOString(),
|
|
84
|
+
organizationId: input.organizationId,
|
|
85
|
+
amount: input.grantedAmount,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Pure grant write on the given em — creates the row (auto-persisted via
|
|
92
|
+
* MikroORM `persistOnCreate`; no explicit flush), no event. The caller's
|
|
93
|
+
* transaction (Command Bus) or explicit `flush` commits it.
|
|
94
|
+
*/
|
|
95
|
+
#applyGrant(em, input) {
|
|
96
|
+
return em.create(CreditLimit, {
|
|
97
|
+
organizationId: input.organizationId,
|
|
98
|
+
grantedAmount: input.grantedAmount.toFixed(2),
|
|
99
|
+
currency: input.currency,
|
|
100
|
+
...(input.grantedByAdminUserId !== undefined
|
|
101
|
+
? { grantedByAdminUserId: input.grantedByAdminUserId }
|
|
102
|
+
: {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
#emitGranted(input) {
|
|
106
|
+
this.events.emit('credit_limit.granted.v1', {
|
|
107
|
+
eventId: randomUUID(),
|
|
108
|
+
occurredAt: new Date().toISOString(),
|
|
109
|
+
organizationId: input.organizationId,
|
|
110
|
+
amount: input.grantedAmount,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async adjust(input) {
|
|
114
|
+
// Feature 054 — audited path: the Command Bus records the mutation
|
|
115
|
+
// co-transactionally and dispatches the event on commit.
|
|
116
|
+
if (this.commandBus) {
|
|
117
|
+
return this.commandBus.run(this.#adjustCommand(input));
|
|
118
|
+
}
|
|
119
|
+
// Legacy fallback (no bus injected — e.g. unit tests): unaudited, but
|
|
120
|
+
// byte-identical to the pre-054 behavior.
|
|
121
|
+
const em = this.emFactory();
|
|
122
|
+
return em.transactional(async (tx) => {
|
|
123
|
+
const r = await this.#applyAdjust(tx, input);
|
|
124
|
+
if (r.result.ok)
|
|
125
|
+
this.#emitAdjusted(input);
|
|
126
|
+
return r.result;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** The `adjust` write expressed as a Command (audited via the bus). */
|
|
130
|
+
#adjustCommand(input) {
|
|
131
|
+
return {
|
|
132
|
+
action: 'credit_limit.adjust',
|
|
133
|
+
objectType: 'credit_limit',
|
|
134
|
+
objectId: input.organizationId,
|
|
135
|
+
run: async ({ em }) => {
|
|
136
|
+
const r = await this.#applyAdjust(em, input);
|
|
137
|
+
if (!r.result.ok) {
|
|
138
|
+
// No mutation happened → commit without an audit row.
|
|
139
|
+
return { result: r.result, skipAudit: true };
|
|
140
|
+
}
|
|
141
|
+
return { result: r.result, before: r.before ?? null, after: r.after ?? null };
|
|
142
|
+
},
|
|
143
|
+
event: (result) => result.ok
|
|
144
|
+
? {
|
|
145
|
+
eventName: 'credit_limit.adjusted.v1',
|
|
146
|
+
payload: {
|
|
147
|
+
eventId: randomUUID(),
|
|
148
|
+
occurredAt: new Date().toISOString(),
|
|
149
|
+
organizationId: input.organizationId,
|
|
150
|
+
amount: input.grantedAmount,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
: undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Pure adjust write on the given em — no audit, no event. Returns the caller
|
|
158
|
+
* result plus the before/after snapshot for auditing. The pessimistic lock and
|
|
159
|
+
* over-allocation guard are preserved exactly.
|
|
160
|
+
*/
|
|
161
|
+
async #applyAdjust(em, input) {
|
|
162
|
+
const limit = await em.findOne(CreditLimit, { organizationId: input.organizationId }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
163
|
+
if (!limit) {
|
|
164
|
+
// Caller maps to 404; we don't have a separate "not granted" code in
|
|
165
|
+
// this enum — the route will translate.
|
|
166
|
+
throw new Error('CREDIT_LIMIT_NOT_GRANTED');
|
|
167
|
+
}
|
|
168
|
+
const reservedSum = await this.#sumActiveReservations(em, limit.id);
|
|
169
|
+
if (!input.allowOverAllocation && input.grantedAmount < reservedSum) {
|
|
170
|
+
return { result: { ok: false, code: 'ADJUSTMENT_BELOW_ACTIVE' } };
|
|
171
|
+
}
|
|
172
|
+
const before = { organizationId: input.organizationId, grantedAmount: limit.grantedAmount };
|
|
173
|
+
limit.grantedAmount = input.grantedAmount.toFixed(2);
|
|
174
|
+
const after = { organizationId: input.organizationId, grantedAmount: limit.grantedAmount };
|
|
175
|
+
return { result: { ok: true, limit }, before, after };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Credit an organization's grant for a settled return, **once per return
|
|
179
|
+
* case** (D-91).
|
|
180
|
+
*
|
|
181
|
+
* The settlement ordering law attempts every external effect before it writes
|
|
182
|
+
* any state, so a refusal from a later step leaves a case an admin can settle
|
|
183
|
+
* again — and the retry arrives here with the same `returnCaseId`. Until D-91
|
|
184
|
+
* this method's caller read the grant and added to it, ignoring the
|
|
185
|
+
* `returnCaseId` it was already being handed, so the second attempt credited
|
|
186
|
+
* the organization a second time.
|
|
187
|
+
*
|
|
188
|
+
* The credit and the `credit_limit_return_topups` row are written by one
|
|
189
|
+
* Command, so they commit together: the fact that a case was credited cannot
|
|
190
|
+
* outlive the credit, and the credit cannot outlive the fact. The unique
|
|
191
|
+
* index on `return_case_id` settles two concurrent retries.
|
|
192
|
+
*/
|
|
193
|
+
async creditFromReturn(input) {
|
|
194
|
+
if (this.commandBus) {
|
|
195
|
+
return this.commandBus.run(this.#creditFromReturnCommand(input));
|
|
196
|
+
}
|
|
197
|
+
// Legacy fallback (no bus injected — e.g. unit tests): the same write,
|
|
198
|
+
// unaudited, in the same single transaction.
|
|
199
|
+
const em = this.emFactory();
|
|
200
|
+
return em.transactional(async (tx) => {
|
|
201
|
+
const applied = await this.#applyCreditFromReturn(tx, input);
|
|
202
|
+
if (applied.result.applied && !applied.result.alreadyApplied) {
|
|
203
|
+
this.#emitAdjusted({
|
|
204
|
+
organizationId: input.organizationId,
|
|
205
|
+
grantedAmount: applied.result.availableAmountAfter ?? 0,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return applied.result;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** The `creditFromReturn` write expressed as a Command (audited via the bus). */
|
|
212
|
+
#creditFromReturnCommand(input) {
|
|
213
|
+
return {
|
|
214
|
+
action: 'credit_limit.credit_from_return',
|
|
215
|
+
objectType: 'credit_limit',
|
|
216
|
+
objectId: input.organizationId,
|
|
217
|
+
run: async ({ em }) => {
|
|
218
|
+
const applied = await this.#applyCreditFromReturn(em, input);
|
|
219
|
+
// Nothing moved: the organization has no grant, or this case was
|
|
220
|
+
// already credited. Commit without an audit row, as `adjust` does.
|
|
221
|
+
if (!applied.result.applied || applied.result.alreadyApplied) {
|
|
222
|
+
return { result: applied.result, skipAudit: true };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
result: applied.result,
|
|
226
|
+
before: applied.before ?? null,
|
|
227
|
+
after: applied.after ?? null,
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
event: (result) => result.applied && !result.alreadyApplied
|
|
231
|
+
? {
|
|
232
|
+
eventName: 'credit_limit.adjusted.v1',
|
|
233
|
+
payload: {
|
|
234
|
+
eventId: randomUUID(),
|
|
235
|
+
occurredAt: new Date().toISOString(),
|
|
236
|
+
organizationId: input.organizationId,
|
|
237
|
+
amount: result.availableAmountAfter ?? 0,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Pure credit-from-return write on the given em — no audit, no event.
|
|
245
|
+
*
|
|
246
|
+
* The marker row is read first: an organization that has already been
|
|
247
|
+
* credited for this case is answered with the grant as it stands, so a retry
|
|
248
|
+
* is a no-op rather than a second credit. The grant itself is read under the
|
|
249
|
+
* same pessimistic lock `adjust` uses, and the over-allocation guard does not
|
|
250
|
+
* apply — a credit raises the grant, so it can never fall below what is
|
|
251
|
+
* already reserved.
|
|
252
|
+
*/
|
|
253
|
+
async #applyCreditFromReturn(em, input) {
|
|
254
|
+
const alreadyCredited = await em.findOne(CreditLimitReturnTopup, {
|
|
255
|
+
returnCaseId: input.returnCaseId,
|
|
256
|
+
});
|
|
257
|
+
if (alreadyCredited) {
|
|
258
|
+
const limit = await em.findOne(CreditLimit, { organizationId: input.organizationId });
|
|
259
|
+
return {
|
|
260
|
+
result: {
|
|
261
|
+
applied: true,
|
|
262
|
+
alreadyApplied: true,
|
|
263
|
+
...(limit ? { availableAmountAfter: Number(limit.grantedAmount) } : {}),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const limit = await em.findOne(CreditLimit, { organizationId: input.organizationId }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
268
|
+
// No grant to credit. A declared outcome, not a failure: the settlement
|
|
269
|
+
// records `pending_manual` and an operator settles it out of band.
|
|
270
|
+
if (!limit)
|
|
271
|
+
return { result: { applied: false, alreadyApplied: false } };
|
|
272
|
+
const before = { organizationId: input.organizationId, grantedAmount: limit.grantedAmount };
|
|
273
|
+
const newAmount = this.#round2(Number(limit.grantedAmount) + input.amount);
|
|
274
|
+
limit.grantedAmount = newAmount.toFixed(2);
|
|
275
|
+
em.persist(em.create(CreditLimitReturnTopup, {
|
|
276
|
+
returnCaseId: input.returnCaseId,
|
|
277
|
+
organizationId: input.organizationId,
|
|
278
|
+
amount: input.amount.toFixed(2),
|
|
279
|
+
currency: input.currency,
|
|
280
|
+
}));
|
|
281
|
+
return {
|
|
282
|
+
result: { applied: true, alreadyApplied: false, availableAmountAfter: newAmount },
|
|
283
|
+
before,
|
|
284
|
+
after: {
|
|
285
|
+
organizationId: input.organizationId,
|
|
286
|
+
grantedAmount: limit.grantedAmount,
|
|
287
|
+
returnCaseId: input.returnCaseId,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
#emitAdjusted(input) {
|
|
292
|
+
this.events.emit('credit_limit.adjusted.v1', {
|
|
293
|
+
eventId: randomUUID(),
|
|
294
|
+
occurredAt: new Date().toISOString(),
|
|
295
|
+
organizationId: input.organizationId,
|
|
296
|
+
amount: input.grantedAmount,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async reserve(input) {
|
|
300
|
+
// `reserve` runs inside the caller's order-placement transaction — since
|
|
301
|
+
// D-94.5 that is structural rather than conditional, because `tx` is
|
|
302
|
+
// required — and is a system operation, not an admin action; the credit movement
|
|
303
|
+
// is captured by the credit_limit.reserved.v1 event and the reservation row,
|
|
304
|
+
// not the admin audit log. The exemption itself sits on `#reserveFlat` and
|
|
305
|
+
// `#reserveInherited`, which are where the write is — a marker here guarded
|
|
306
|
+
// nothing, and since D-89(c) the staleness half says so instead of counting
|
|
307
|
+
// it as a live exemption.
|
|
308
|
+
return this.inheritance
|
|
309
|
+
? this.#reserveInherited(input.tx, input)
|
|
310
|
+
: this.#reserveFlat(input.tx, input);
|
|
311
|
+
}
|
|
312
|
+
/** Pre-feature flat reservation — locks the org's own row (unchanged). */
|
|
313
|
+
async #reserveFlat(em, input) {
|
|
314
|
+
// command-coverage-ignore: reservation path invoked by reserve() inside the
|
|
315
|
+
// caller's order-placement transaction — a system operation, not an admin
|
|
316
|
+
// action. The credit movement is captured by the credit_limit.reserved.v1
|
|
317
|
+
// event and the reservation row, not the admin audit log.
|
|
318
|
+
const limit = await em.findOne(CreditLimit, { organizationId: input.organizationId }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
319
|
+
if (!limit)
|
|
320
|
+
return { ok: false, code: 'CREDIT_LIMIT_NOT_GRANTED' };
|
|
321
|
+
if (limit.currency !== input.currency) {
|
|
322
|
+
return { ok: false, code: 'CURRENCY_MISMATCH' };
|
|
323
|
+
}
|
|
324
|
+
const granted = Number(limit.grantedAmount);
|
|
325
|
+
const reservedSum = await this.#sumActiveReservations(em, limit.id);
|
|
326
|
+
const available = granted - reservedSum;
|
|
327
|
+
if (available < input.amount) {
|
|
328
|
+
return { ok: false, code: 'LIMIT_INSUFFICIENT', availableAmount: available };
|
|
329
|
+
}
|
|
330
|
+
const reservation = em.create(CreditLimitReservation, {
|
|
331
|
+
creditLimitId: limit.id,
|
|
332
|
+
orderId: input.orderId,
|
|
333
|
+
reservingOrganizationId: input.organizationId,
|
|
334
|
+
amount: input.amount.toFixed(2),
|
|
335
|
+
currency: input.currency,
|
|
336
|
+
status: 'active',
|
|
337
|
+
});
|
|
338
|
+
await em.persistAndFlush(reservation);
|
|
339
|
+
this.#emitReserved(input);
|
|
340
|
+
return { ok: true, reservationId: reservation.id, availableAmountAfter: available - input.amount };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Feature 056 — inherited reservation. Resolves the owning ancestor-or-self
|
|
344
|
+
* (`creditOwner`) and the effective mode:
|
|
345
|
+
* - shared_pool: FOR-UPDATE lock on the OWNER's row; `available = granted −
|
|
346
|
+
* Σ active reservations against the owner row` (every subtree reservation
|
|
347
|
+
* references the owner row, so this sums the whole subtree). Concurrent
|
|
348
|
+
* draws serialize on the one owner row → zero double-spend (SC-004).
|
|
349
|
+
* - independent_default: lock the owner's row but sum only THIS descendant's
|
|
350
|
+
* active reservations (via `reserving_organization_id`, this module's own
|
|
351
|
+
* record of who drew each one), so each branch draws its full inherited
|
|
352
|
+
* amount without affecting siblings.
|
|
353
|
+
*
|
|
354
|
+
* A root org with its own limit is its own owner (shared_pool default), which
|
|
355
|
+
* collapses to the flat behavior byte-for-byte.
|
|
356
|
+
*/
|
|
357
|
+
async #reserveInherited(em, input) {
|
|
358
|
+
// command-coverage-ignore: reservation path invoked by reserve() inside the
|
|
359
|
+
// caller's order-placement transaction — a system operation, not an admin
|
|
360
|
+
// action. The credit movement is captured by the credit_limit.reserved.v1
|
|
361
|
+
// event and the reservation row, not the admin audit log.
|
|
362
|
+
const { ownerOrgId, mode } = await this.inheritance.creditOwner(input.organizationId);
|
|
363
|
+
if (!ownerOrgId)
|
|
364
|
+
return { ok: false, code: 'CREDIT_LIMIT_NOT_GRANTED' };
|
|
365
|
+
// Lock the owning ancestor's row FOR UPDATE. Raw SQL bypasses the @OrgScoped
|
|
366
|
+
// filter (the owner may lie outside the reserving descendant's tenant scope)
|
|
367
|
+
// and gives precise lock control on the money path.
|
|
368
|
+
const ownerRows = (await em.getConnection().execute(`select "id", "granted_amount", "currency" from "credit_limits"
|
|
369
|
+
where "organization_id" = ? for update`, [ownerOrgId], 'all', em.getTransactionContext()));
|
|
370
|
+
const owner = ownerRows[0];
|
|
371
|
+
if (!owner)
|
|
372
|
+
return { ok: false, code: 'CREDIT_LIMIT_NOT_GRANTED' };
|
|
373
|
+
if (owner.currency !== input.currency) {
|
|
374
|
+
return { ok: false, code: 'CURRENCY_MISMATCH' };
|
|
375
|
+
}
|
|
376
|
+
const granted = Number(owner.granted_amount);
|
|
377
|
+
const reservedSum = mode === 'shared_pool'
|
|
378
|
+
? await this.#sumReservationsForLimit(em, owner.id)
|
|
379
|
+
: await this.#sumReservationsForOrg(em, input.organizationId);
|
|
380
|
+
const available = granted - reservedSum;
|
|
381
|
+
if (available < input.amount) {
|
|
382
|
+
return { ok: false, code: 'LIMIT_INSUFFICIENT', availableAmount: available };
|
|
383
|
+
}
|
|
384
|
+
const reservation = em.create(CreditLimitReservation, {
|
|
385
|
+
creditLimitId: owner.id,
|
|
386
|
+
orderId: input.orderId,
|
|
387
|
+
reservingOrganizationId: input.organizationId,
|
|
388
|
+
amount: input.amount.toFixed(2),
|
|
389
|
+
currency: input.currency,
|
|
390
|
+
status: 'active',
|
|
391
|
+
});
|
|
392
|
+
await em.persistAndFlush(reservation);
|
|
393
|
+
this.#emitReserved(input);
|
|
394
|
+
return { ok: true, reservationId: reservation.id, availableAmountAfter: available - input.amount };
|
|
395
|
+
}
|
|
396
|
+
#emitReserved(input) {
|
|
397
|
+
this.events.emit('credit_limit.reserved.v1', {
|
|
398
|
+
eventId: randomUUID(),
|
|
399
|
+
occurredAt: new Date().toISOString(),
|
|
400
|
+
organizationId: input.organizationId,
|
|
401
|
+
orderId: input.orderId,
|
|
402
|
+
amount: input.amount,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/** Σ active reservations against a credit_limits row (transaction-scoped). */
|
|
406
|
+
async #sumReservationsForLimit(em, creditLimitId) {
|
|
407
|
+
const rows = (await em.getConnection().execute(`select coalesce(sum(amount), 0) as total
|
|
408
|
+
from credit_limit_reservations
|
|
409
|
+
where credit_limit_id = ? and status = 'active'`, [creditLimitId], 'all', em.getTransactionContext()));
|
|
410
|
+
return Number(rows[0]?.total ?? 0);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Σ active reservations consumed by a specific organization, for the
|
|
414
|
+
* independent_default mode. Transaction-scoped.
|
|
415
|
+
*
|
|
416
|
+
* Reads `reserving_organization_id`, this module's own record of who drew the
|
|
417
|
+
* credit. Until feature 075 it joined `orders` for the same value — a read
|
|
418
|
+
* of another module's table, taken on the money path with a
|
|
419
|
+
* `PESSIMISTIC_WRITE` held on the owner's credit row, for a figure `reserve`
|
|
420
|
+
* is handed by its caller and writes onto the reservation row itself.
|
|
421
|
+
*/
|
|
422
|
+
async #sumReservationsForOrg(em, organizationId) {
|
|
423
|
+
const rows = (await em.getConnection().execute(`select coalesce(sum(amount), 0) as total
|
|
424
|
+
from credit_limit_reservations
|
|
425
|
+
where status = 'active' and reserving_organization_id = ?`, [organizationId], 'all', em.getTransactionContext()));
|
|
426
|
+
return Number(rows[0]?.total ?? 0);
|
|
427
|
+
}
|
|
428
|
+
async releaseByOrder(input) {
|
|
429
|
+
// command-coverage-ignore: releaseByOrder is an automatic system operation
|
|
430
|
+
// (invoice-paid / order-cancelled / admin-revocation) captured by the
|
|
431
|
+
// credit_limit.released.v1 event and the reservation row, not the admin audit
|
|
432
|
+
// log. It also runs in its own pessimistic-lock transaction.
|
|
433
|
+
const em = this.emFactory();
|
|
434
|
+
return em.transactional(async (tx) => {
|
|
435
|
+
const reservation = await tx.findOne(CreditLimitReservation, { orderId: input.orderId }, { orderBy: { createdAt: 'desc' }, lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
436
|
+
if (!reservation)
|
|
437
|
+
return { ok: false, code: 'RESERVATION_NOT_FOUND' };
|
|
438
|
+
if (reservation.status !== 'active') {
|
|
439
|
+
return { ok: false, code: 'ALREADY_RELEASED' };
|
|
440
|
+
}
|
|
441
|
+
const limit = await tx.findOne(CreditLimit, { id: reservation.creditLimitId }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
|
442
|
+
reservation.status = 'released';
|
|
443
|
+
reservation.releasedAt = new Date();
|
|
444
|
+
reservation.releasedReason = input.reason;
|
|
445
|
+
await tx.flush();
|
|
446
|
+
const reservedSum = limit ? await this.#sumActiveReservations(tx, limit.id) : 0;
|
|
447
|
+
const granted = limit ? Number(limit.grantedAmount) : 0;
|
|
448
|
+
this.events.emit('credit_limit.released.v1', {
|
|
449
|
+
eventId: randomUUID(),
|
|
450
|
+
occurredAt: new Date().toISOString(),
|
|
451
|
+
organizationId: limit?.organizationId ?? '',
|
|
452
|
+
orderId: input.orderId,
|
|
453
|
+
amount: Number(reservation.amount),
|
|
454
|
+
reason: input.reason,
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
reservationId: reservation.id,
|
|
459
|
+
availableAmountAfter: granted - reservedSum,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** Two-decimal rounding for a money amount held as a JS number. */
|
|
464
|
+
#round2(n) {
|
|
465
|
+
return Math.round((n + Number.EPSILON) * 100) / 100;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Σ active reservations against a credit_limits row — transaction-scoped, like
|
|
469
|
+
* its two feature-056 siblings below.
|
|
470
|
+
*
|
|
471
|
+
* `em.execute`, not `em.getConnection().execute`: a connection carries no
|
|
472
|
+
* transaction context. `releaseByOrder` flips the reservation to `released`
|
|
473
|
+
* and flushes *inside* its transaction before asking this question, so read on
|
|
474
|
+
* a pooled connection the sum still counted the reservation it had just
|
|
475
|
+
* freed — every release understated `availableAmountAfter` by exactly the
|
|
476
|
+
* amount released (issue #207).
|
|
477
|
+
* `test/integration/credit_limits/release-reads-its-transaction.test.ts` is
|
|
478
|
+
* that case.
|
|
479
|
+
*/
|
|
480
|
+
async #sumActiveReservations(em, creditLimitId) {
|
|
481
|
+
const rows = await em.execute(`select coalesce(sum(amount), 0) as total
|
|
482
|
+
from credit_limit_reservations
|
|
483
|
+
where credit_limit_id = ? and status = 'active'`, [creditLimitId]);
|
|
484
|
+
return Number(rows[0]?.total ?? 0);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
//# sourceMappingURL=credit-limit-service.js.map
|