@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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/admin/index.d.ts +31 -0
  4. package/dist/admin/index.d.ts.map +1 -0
  5. package/dist/admin/index.js +32 -0
  6. package/dist/admin/index.js.map +1 -0
  7. package/dist/admin/pages/CreditLimitsPage.d.ts +4 -0
  8. package/dist/admin/pages/CreditLimitsPage.d.ts.map +1 -0
  9. package/dist/admin/pages/CreditLimitsPage.js +144 -0
  10. package/dist/admin/pages/CreditLimitsPage.js.map +1 -0
  11. package/dist/backend/entities/credit-limit-reservation.entity.d.ts +35 -0
  12. package/dist/backend/entities/credit-limit-reservation.entity.d.ts.map +1 -0
  13. package/dist/backend/entities/credit-limit-reservation.entity.js +99 -0
  14. package/dist/backend/entities/credit-limit-reservation.entity.js.map +1 -0
  15. package/dist/backend/entities/credit-limit-return-topup.entity.d.ts +29 -0
  16. package/dist/backend/entities/credit-limit-return-topup.entity.d.ts.map +1 -0
  17. package/dist/backend/entities/credit-limit-return-topup.entity.js +71 -0
  18. package/dist/backend/entities/credit-limit-return-topup.entity.js.map +1 -0
  19. package/dist/backend/entities/credit-limit.entity.d.ts +21 -0
  20. package/dist/backend/entities/credit-limit.entity.d.ts.map +1 -0
  21. package/dist/backend/entities/credit-limit.entity.js +66 -0
  22. package/dist/backend/entities/credit-limit.entity.js.map +1 -0
  23. package/dist/backend/index.d.ts +80 -0
  24. package/dist/backend/index.d.ts.map +1 -0
  25. package/dist/backend/index.js +112 -0
  26. package/dist/backend/index.js.map +1 -0
  27. package/dist/backend/routes.d.ts +59 -0
  28. package/dist/backend/routes.d.ts.map +1 -0
  29. package/dist/backend/routes.js +114 -0
  30. package/dist/backend/routes.js.map +1 -0
  31. package/dist/backend/services/credit-limit-read.d.ts +23 -0
  32. package/dist/backend/services/credit-limit-read.d.ts.map +1 -0
  33. package/dist/backend/services/credit-limit-read.js +35 -0
  34. package/dist/backend/services/credit-limit-read.js.map +1 -0
  35. package/dist/backend/services/credit-limit-service.d.ts +179 -0
  36. package/dist/backend/services/credit-limit-service.d.ts.map +1 -0
  37. package/dist/backend/services/credit-limit-service.js +487 -0
  38. package/dist/backend/services/credit-limit-service.js.map +1 -0
  39. package/dist/backend/services/credit-topup.d.ts +33 -0
  40. package/dist/backend/services/credit-topup.d.ts.map +1 -0
  41. package/dist/backend/services/credit-topup.js +46 -0
  42. package/dist/backend/services/credit-topup.js.map +1 -0
  43. package/dist/manifest.d.ts +169 -0
  44. package/dist/manifest.d.ts.map +1 -0
  45. package/dist/manifest.js +138 -0
  46. package/dist/manifest.js.map +1 -0
  47. package/dist/migrations/20260425T063333_credit_limits_init.d.ts +15 -0
  48. package/dist/migrations/20260425T063333_credit_limits_init.d.ts.map +1 -0
  49. package/dist/migrations/20260425T063333_credit_limits_init.js +56 -0
  50. package/dist/migrations/20260425T063333_credit_limits_init.js.map +1 -0
  51. package/dist/migrations/20260817T201111_credit_limits_return_topups.d.ts +17 -0
  52. package/dist/migrations/20260817T201111_credit_limits_return_topups.d.ts.map +1 -0
  53. package/dist/migrations/20260817T201111_credit_limits_return_topups.js +36 -0
  54. package/dist/migrations/20260817T201111_credit_limits_return_topups.js.map +1 -0
  55. package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.d.ts +44 -0
  56. package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.d.ts.map +1 -0
  57. package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.js +81 -0
  58. package/dist/migrations/20260818T081252_credit_limits_credit_limit_reservation_order_fk.js.map +1 -0
  59. package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.d.ts +29 -0
  60. package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.d.ts.map +1 -0
  61. package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.js +45 -0
  62. package/dist/migrations/20260821T140323_credit_limits_reservation_reserving_organization.js.map +1 -0
  63. package/dist/migrations/index.d.ts +39 -0
  64. package/dist/migrations/index.d.ts.map +1 -0
  65. package/dist/migrations/index.js +44 -0
  66. package/dist/migrations/index.js.map +1 -0
  67. package/dist/ports/index.d.ts +93 -0
  68. package/dist/ports/index.d.ts.map +1 -0
  69. package/dist/ports/index.js +2 -0
  70. package/dist/ports/index.js.map +1 -0
  71. package/docs/credit_limits.md +53 -0
  72. package/i18n/en.json +9 -0
  73. package/i18n/pl.json +9 -0
  74. package/package.json +92 -0
  75. 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