@voyantjs/legal 0.1.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 (36) hide show
  1. package/LICENSE +109 -0
  2. package/README.md +60 -0
  3. package/dist/contracts/index.d.ts +13 -0
  4. package/dist/contracts/index.d.ts.map +1 -0
  5. package/dist/contracts/index.js +19 -0
  6. package/dist/contracts/routes.d.ts +1297 -0
  7. package/dist/contracts/routes.d.ts.map +1 -0
  8. package/dist/contracts/routes.js +224 -0
  9. package/dist/contracts/schema.d.ts +1531 -0
  10. package/dist/contracts/schema.d.ts.map +1 -0
  11. package/dist/contracts/schema.js +227 -0
  12. package/dist/contracts/service.d.ts +1753 -0
  13. package/dist/contracts/service.d.ts.map +1 -0
  14. package/dist/contracts/service.js +570 -0
  15. package/dist/contracts/validation.d.ts +274 -0
  16. package/dist/contracts/validation.d.ts.map +1 -0
  17. package/dist/contracts/validation.js +125 -0
  18. package/dist/index.d.ts +14 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +26 -0
  21. package/dist/policies/index.d.ts +16 -0
  22. package/dist/policies/index.d.ts.map +1 -0
  23. package/dist/policies/index.js +26 -0
  24. package/dist/policies/routes.d.ts +916 -0
  25. package/dist/policies/routes.d.ts.map +1 -0
  26. package/dist/policies/routes.js +162 -0
  27. package/dist/policies/schema.d.ts +1176 -0
  28. package/dist/policies/schema.d.ts.map +1 -0
  29. package/dist/policies/schema.js +189 -0
  30. package/dist/policies/service.d.ts +1384 -0
  31. package/dist/policies/service.d.ts.map +1 -0
  32. package/dist/policies/service.js +438 -0
  33. package/dist/policies/validation.d.ts +273 -0
  34. package/dist/policies/validation.d.ts.map +1 -0
  35. package/dist/policies/validation.js +140 -0
  36. package/package.json +83 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/policies/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,OAAO,EAAgB,MAAM,aAAa,CAAA;AAC9E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAS5B,OAAO,KAAK,EACV,+BAA+B,EAC/B,4BAA4B,EAC5B,4BAA4B,EAC5B,sBAAsB,EACtB,kBAAkB,EAClB,yBAAyB,EACzB,+BAA+B,EAC/B,+BAA+B,EAC/B,qBAAqB,EACrB,wBAAwB,EACxB,4BAA4B,EAC5B,sBAAsB,EACtB,kBAAkB,EAClB,yBAAyB,EAC1B,MAAM,iBAAiB,CAAA;AAExB,KAAK,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA;AAC5D,KAAK,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC3D,KAAK,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC3D,KAAK,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAA;AACzE,KAAK,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAA;AACzE,KAAK,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAA;AACnE,KAAK,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAA;AACnE,KAAK,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AAC/E,KAAK,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AAC/E,KAAK,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,+BAA+B,CAAC,CAAA;AAChF,KAAK,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AAC/E,KAAK,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,+BAA+B,CAAC,CAAA;AAChF,KAAK,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,+BAA+B,CAAC,CAAA;AAChF,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAA;AAoBlE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAA;IAChE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,gBAAgB,GAAG,MAAM,CAAA;IACzD,WAAW,EAAE,gBAAgB,GAAG,IAAI,CAAA;CACrC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,gBAAgB,EAAE,EACzB,KAAK,EAAE,yBAAyB,GAC/B,kBAAkB,CAiCpB;AAMD,eAAO,MAAM,eAAe;qBAGH,kBAAkB,SAAS,eAAe;;;;;;;;;;;;;;;;;sBA6BzC,kBAAkB,MAAM,MAAM;;;;;;;;;;;;wBAK5B,kBAAkB,QAAQ,MAAM;;;;;;;;;;;;qBAKnC,kBAAkB,QAAQ,iBAAiB;;;;;;;;;;;;qBAK3C,kBAAkB,MAAM,MAAM,QAAQ,iBAAiB;;;;;;;;;;;;qBASvD,kBAAkB,MAAM,MAAM;;;2BAU9B,kBAAkB,YAAY,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6BAQ5B,kBAAkB,MAAM,MAAM;;;;;;;;;;;;;;;4BAMvD,kBAAkB,YACZ,MAAM,QACV,wBAAwB;;;;;;;;;;;;;;;4BAkC1B,kBAAkB,aACX,MAAM,QACX,wBAAwB;;;;;;;;;;;;;;;IAWhC;;;OAGG;6BAC4B,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;4BAsCtC,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;wBAW/C,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yBAQ9B,kBAAkB,aAAa,MAAM,QAAQ,qBAAqB;;;;;;;;;;;;;;;;;yBA4BlE,kBAAkB,UAAU,MAAM,QAAQ,qBAAqB;;;;;;;;;;;;;;;;;yBAS/D,kBAAkB,UAAU,MAAM;;;8BAU7B,kBAAkB,SAAS,yBAAyB;;;;;;;;;;;;;;;;;;;;;+BAyBnD,kBAAkB,QAAQ,2BAA2B;;;;;;;;;;;;;;;;+BAqBhF,kBAAkB,MAClB,MAAM,QACJ,2BAA2B;;;;;;;;;;;;;;;;+BAUF,kBAAkB,MAAM,MAAM;;;IAU/D;;;;OAIG;sBACqB,kBAAkB,SAAS,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6BA6H/D,kBAAkB,YACZ,MAAM,SACT,yBAAyB;8BAwBF,kBAAkB,SAAS,yBAAyB;;;;;;;;;;;;;;;;;;;;+BAsBnD,kBAAkB,QAAQ,2BAA2B;;;;;;;;;;;;;;;CAkBvF,CAAA;AAID,eAAO,MAAM,OAAO,gBAAU,CAAA"}
@@ -0,0 +1,438 @@
1
+ import { and, desc, eq, gte, ilike, inArray, lte, or, sql } from "drizzle-orm";
2
+ import { policies, policyAcceptances, policyAssignments, policyRules, policyVersions, } from "./schema.js";
3
+ async function paginate(rowsQuery, countQuery, limit, offset) {
4
+ const [data, countResult] = await Promise.all([rowsQuery, countQuery]);
5
+ return { data, total: countResult[0]?.total ?? 0, limit, offset };
6
+ }
7
+ function toDateString(value) {
8
+ return value ?? null;
9
+ }
10
+ /**
11
+ * Evaluate a cancellation policy against a context. Rules are sorted descending
12
+ * by `daysBeforeDeparture`; the first rule whose threshold is satisfied by the
13
+ * input's `daysBeforeDeparture` is applied. `refundPercent` is expressed in
14
+ * basis points (0-10000 where 10000 = 100%).
15
+ */
16
+ export function evaluateCancellationPolicy(rules, input) {
17
+ if (rules.length === 0) {
18
+ return { refundPercent: 0, refundCents: 0, refundType: "none", appliedRule: null };
19
+ }
20
+ // Sort by daysBeforeDeparture DESC; nulls float to the bottom
21
+ const sorted = [...rules].sort((a, b) => {
22
+ const ad = a.daysBeforeDeparture ?? Number.NEGATIVE_INFINITY;
23
+ const bd = b.daysBeforeDeparture ?? Number.NEGATIVE_INFINITY;
24
+ return bd - ad;
25
+ });
26
+ const applied = sorted.find((rule) => rule.daysBeforeDeparture !== null && input.daysBeforeDeparture >= rule.daysBeforeDeparture) ??
27
+ // fallback: last rule (lowest threshold) applies when caller is inside
28
+ // the tightest window
29
+ sorted[sorted.length - 1] ??
30
+ null;
31
+ if (!applied) {
32
+ return { refundPercent: 0, refundCents: 0, refundType: "none", appliedRule: null };
33
+ }
34
+ const refundPercent = applied.refundPercent ?? 0;
35
+ const refundType = applied.refundType ?? "none";
36
+ // basis points → amount
37
+ const percentageRefundCents = Math.floor((input.totalCents * refundPercent) / 10000);
38
+ const refundCents = applied.flatAmountCents ?? percentageRefundCents;
39
+ return { refundPercent, refundCents, refundType, appliedRule: applied };
40
+ }
41
+ // ============================================================================
42
+ // Service
43
+ // ============================================================================
44
+ export const policiesService = {
45
+ // ---------- policies ----------
46
+ async listPolicies(db, query) {
47
+ const conditions = [];
48
+ if (query.kind)
49
+ conditions.push(eq(policies.kind, query.kind));
50
+ if (query.language)
51
+ conditions.push(eq(policies.language, query.language));
52
+ if (query.search) {
53
+ const term = `%${query.search}%`;
54
+ conditions.push(or(ilike(policies.name, term), ilike(policies.slug, term), ilike(policies.description, term)));
55
+ }
56
+ const where = conditions.length ? and(...conditions) : undefined;
57
+ return paginate(db
58
+ .select()
59
+ .from(policies)
60
+ .where(where)
61
+ .limit(query.limit)
62
+ .offset(query.offset)
63
+ .orderBy(desc(policies.updatedAt)), db.select({ total: sql `count(*)::int` }).from(policies).where(where), query.limit, query.offset);
64
+ },
65
+ async getPolicyById(db, id) {
66
+ const [row] = await db.select().from(policies).where(eq(policies.id, id)).limit(1);
67
+ return row ?? null;
68
+ },
69
+ async getPolicyBySlug(db, slug) {
70
+ const [row] = await db.select().from(policies).where(eq(policies.slug, slug)).limit(1);
71
+ return row ?? null;
72
+ },
73
+ async createPolicy(db, data) {
74
+ const [row] = await db.insert(policies).values(data).returning();
75
+ return row ?? null;
76
+ },
77
+ async updatePolicy(db, id, data) {
78
+ const [row] = await db
79
+ .update(policies)
80
+ .set({ ...data, updatedAt: new Date() })
81
+ .where(eq(policies.id, id))
82
+ .returning();
83
+ return row ?? null;
84
+ },
85
+ async deletePolicy(db, id) {
86
+ const [row] = await db
87
+ .delete(policies)
88
+ .where(eq(policies.id, id))
89
+ .returning({ id: policies.id });
90
+ return row ?? null;
91
+ },
92
+ // ---------- versions ----------
93
+ listPolicyVersions(db, policyId) {
94
+ return db
95
+ .select()
96
+ .from(policyVersions)
97
+ .where(eq(policyVersions.policyId, policyId))
98
+ .orderBy(desc(policyVersions.version));
99
+ },
100
+ async getPolicyVersionById(db, id) {
101
+ const [row] = await db.select().from(policyVersions).where(eq(policyVersions.id, id)).limit(1);
102
+ return row ?? null;
103
+ },
104
+ async createPolicyVersion(db, policyId, data) {
105
+ return db.transaction(async (tx) => {
106
+ const [policy] = await tx
107
+ .select({ id: policies.id })
108
+ .from(policies)
109
+ .where(eq(policies.id, policyId))
110
+ .limit(1);
111
+ if (!policy)
112
+ return null;
113
+ const [maxRow] = await tx
114
+ .select({ max: sql `coalesce(max(${policyVersions.version}), 0)::int` })
115
+ .from(policyVersions)
116
+ .where(eq(policyVersions.policyId, policyId));
117
+ const nextVersion = (maxRow?.max ?? 0) + 1;
118
+ const [row] = await tx
119
+ .insert(policyVersions)
120
+ .values({
121
+ policyId,
122
+ version: nextVersion,
123
+ status: "draft",
124
+ title: data.title,
125
+ bodyFormat: data.bodyFormat,
126
+ body: data.body ?? null,
127
+ publishedBy: data.publishedBy ?? null,
128
+ metadata: data.metadata ?? null,
129
+ })
130
+ .returning();
131
+ return row ?? null;
132
+ });
133
+ },
134
+ async updatePolicyVersion(db, versionId, data) {
135
+ // Only draft versions are editable
136
+ const [row] = await db
137
+ .update(policyVersions)
138
+ .set({ ...data, updatedAt: new Date() })
139
+ .where(and(eq(policyVersions.id, versionId), eq(policyVersions.status, "draft")))
140
+ .returning();
141
+ return row ?? null;
142
+ },
143
+ /**
144
+ * Publish a draft version: retires any currently-published version of the
145
+ * same policy, marks the target as published, updates `policies.currentVersionId`.
146
+ */
147
+ async publishPolicyVersion(db, versionId) {
148
+ return db.transaction(async (tx) => {
149
+ const [version] = await tx
150
+ .select()
151
+ .from(policyVersions)
152
+ .where(eq(policyVersions.id, versionId))
153
+ .limit(1);
154
+ if (!version)
155
+ return { status: "not_found" };
156
+ if (version.status !== "draft")
157
+ return { status: "not_draft" };
158
+ const now = new Date();
159
+ // Retire existing published version(s) for the same policy
160
+ await tx
161
+ .update(policyVersions)
162
+ .set({ status: "retired", retiredAt: now, updatedAt: now })
163
+ .where(and(eq(policyVersions.policyId, version.policyId), eq(policyVersions.status, "published")));
164
+ const [published] = await tx
165
+ .update(policyVersions)
166
+ .set({ status: "published", publishedAt: now, updatedAt: now })
167
+ .where(eq(policyVersions.id, versionId))
168
+ .returning();
169
+ await tx
170
+ .update(policies)
171
+ .set({ currentVersionId: versionId, updatedAt: now })
172
+ .where(eq(policies.id, version.policyId));
173
+ return { status: "published", version: published ?? null };
174
+ });
175
+ },
176
+ async retirePolicyVersion(db, versionId) {
177
+ const [row] = await db
178
+ .update(policyVersions)
179
+ .set({ status: "retired", retiredAt: new Date(), updatedAt: new Date() })
180
+ .where(eq(policyVersions.id, versionId))
181
+ .returning();
182
+ return row ?? null;
183
+ },
184
+ // ---------- rules ----------
185
+ listPolicyRules(db, versionId) {
186
+ return db
187
+ .select()
188
+ .from(policyRules)
189
+ .where(eq(policyRules.policyVersionId, versionId))
190
+ .orderBy(policyRules.sortOrder, policyRules.createdAt);
191
+ },
192
+ async createPolicyRule(db, versionId, data) {
193
+ const [version] = await db
194
+ .select({ id: policyVersions.id })
195
+ .from(policyVersions)
196
+ .where(eq(policyVersions.id, versionId))
197
+ .limit(1);
198
+ if (!version)
199
+ return null;
200
+ const [row] = await db
201
+ .insert(policyRules)
202
+ .values({
203
+ policyVersionId: versionId,
204
+ ruleType: data.ruleType,
205
+ label: data.label ?? null,
206
+ daysBeforeDeparture: data.daysBeforeDeparture ?? null,
207
+ refundPercent: data.refundPercent ?? null,
208
+ refundType: data.refundType ?? null,
209
+ flatAmountCents: data.flatAmountCents ?? null,
210
+ currency: data.currency ?? null,
211
+ validFrom: toDateString(data.validFrom),
212
+ validTo: toDateString(data.validTo),
213
+ conditions: data.conditions ?? null,
214
+ sortOrder: data.sortOrder,
215
+ })
216
+ .returning();
217
+ return row ?? null;
218
+ },
219
+ async updatePolicyRule(db, ruleId, data) {
220
+ const [row] = await db
221
+ .update(policyRules)
222
+ .set({ ...data, updatedAt: new Date() })
223
+ .where(eq(policyRules.id, ruleId))
224
+ .returning();
225
+ return row ?? null;
226
+ },
227
+ async deletePolicyRule(db, ruleId) {
228
+ const [row] = await db
229
+ .delete(policyRules)
230
+ .where(eq(policyRules.id, ruleId))
231
+ .returning({ id: policyRules.id });
232
+ return row ?? null;
233
+ },
234
+ // ---------- assignments ----------
235
+ async listPolicyAssignments(db, query) {
236
+ const conditions = [];
237
+ if (query.policyId)
238
+ conditions.push(eq(policyAssignments.policyId, query.policyId));
239
+ if (query.scope)
240
+ conditions.push(eq(policyAssignments.scope, query.scope));
241
+ if (query.productId)
242
+ conditions.push(eq(policyAssignments.productId, query.productId));
243
+ if (query.channelId)
244
+ conditions.push(eq(policyAssignments.channelId, query.channelId));
245
+ if (query.supplierId)
246
+ conditions.push(eq(policyAssignments.supplierId, query.supplierId));
247
+ if (query.marketId)
248
+ conditions.push(eq(policyAssignments.marketId, query.marketId));
249
+ if (query.organizationId)
250
+ conditions.push(eq(policyAssignments.organizationId, query.organizationId));
251
+ const where = conditions.length ? and(...conditions) : undefined;
252
+ return paginate(db
253
+ .select()
254
+ .from(policyAssignments)
255
+ .where(where)
256
+ .limit(query.limit)
257
+ .offset(query.offset)
258
+ .orderBy(desc(policyAssignments.priority), desc(policyAssignments.createdAt)), db.select({ total: sql `count(*)::int` }).from(policyAssignments).where(where), query.limit, query.offset);
259
+ },
260
+ async createPolicyAssignment(db, data) {
261
+ const [row] = await db
262
+ .insert(policyAssignments)
263
+ .values({
264
+ policyId: data.policyId,
265
+ scope: data.scope,
266
+ productId: data.productId ?? null,
267
+ channelId: data.channelId ?? null,
268
+ supplierId: data.supplierId ?? null,
269
+ marketId: data.marketId ?? null,
270
+ organizationId: data.organizationId ?? null,
271
+ validFrom: toDateString(data.validFrom),
272
+ validTo: toDateString(data.validTo),
273
+ priority: data.priority,
274
+ metadata: data.metadata ?? null,
275
+ })
276
+ .returning();
277
+ return row ?? null;
278
+ },
279
+ async updatePolicyAssignment(db, id, data) {
280
+ const [row] = await db
281
+ .update(policyAssignments)
282
+ .set({ ...data, updatedAt: new Date() })
283
+ .where(eq(policyAssignments.id, id))
284
+ .returning();
285
+ return row ?? null;
286
+ },
287
+ async deletePolicyAssignment(db, id) {
288
+ const [row] = await db
289
+ .delete(policyAssignments)
290
+ .where(eq(policyAssignments.id, id))
291
+ .returning({ id: policyAssignments.id });
292
+ return row ?? null;
293
+ },
294
+ // ---------- resolution ----------
295
+ /**
296
+ * Resolve the best-matching policy for a context. Matches candidate
297
+ * assignments on scope column values and (optional) validity window, then
298
+ * picks the highest-priority one for the requested policy kind.
299
+ */
300
+ async resolvePolicy(db, input) {
301
+ const conditions = [];
302
+ const atDate = input.at ?? new Date().toISOString().slice(0, 10);
303
+ // Scope conditions: collect all provided target IDs; an assignment
304
+ // matches if its column equals the provided ID OR the column is null
305
+ // (global within that dimension).
306
+ const scopeConditions = [];
307
+ if (input.productId) {
308
+ scopeConditions.push(or(eq(policyAssignments.productId, input.productId), sql `${policyAssignments.productId} IS NULL`));
309
+ }
310
+ if (input.channelId) {
311
+ scopeConditions.push(or(eq(policyAssignments.channelId, input.channelId), sql `${policyAssignments.channelId} IS NULL`));
312
+ }
313
+ if (input.supplierId) {
314
+ scopeConditions.push(or(eq(policyAssignments.supplierId, input.supplierId), sql `${policyAssignments.supplierId} IS NULL`));
315
+ }
316
+ if (input.marketId) {
317
+ scopeConditions.push(or(eq(policyAssignments.marketId, input.marketId), sql `${policyAssignments.marketId} IS NULL`));
318
+ }
319
+ if (input.organizationId) {
320
+ scopeConditions.push(or(eq(policyAssignments.organizationId, input.organizationId), sql `${policyAssignments.organizationId} IS NULL`));
321
+ }
322
+ // Validity filter
323
+ const validity = and(or(sql `${policyAssignments.validFrom} IS NULL`, lte(policyAssignments.validFrom, atDate)), or(sql `${policyAssignments.validTo} IS NULL`, gte(policyAssignments.validTo, atDate)));
324
+ const candidates = await db
325
+ .select({
326
+ assignment: policyAssignments,
327
+ policy: policies,
328
+ })
329
+ .from(policyAssignments)
330
+ .innerJoin(policies, eq(policyAssignments.policyId, policies.id))
331
+ .where(and(eq(policies.kind, input.kind), validity, ...conditions, ...(scopeConditions.length ? [and(...scopeConditions)] : [])))
332
+ .orderBy(desc(policyAssignments.priority), desc(policyAssignments.createdAt));
333
+ if (candidates.length === 0)
334
+ return null;
335
+ // Specificity ranking: count non-null scope columns (more specific = higher score)
336
+ const scored = candidates.map((c) => {
337
+ const a = c.assignment;
338
+ const specificity = (a.productId ? 1 : 0) +
339
+ (a.channelId ? 1 : 0) +
340
+ (a.supplierId ? 1 : 0) +
341
+ (a.marketId ? 1 : 0) +
342
+ (a.organizationId ? 1 : 0);
343
+ return { ...c, specificity };
344
+ });
345
+ scored.sort((a, b) => {
346
+ if (b.assignment.priority !== a.assignment.priority) {
347
+ return b.assignment.priority - a.assignment.priority;
348
+ }
349
+ if (b.specificity !== a.specificity)
350
+ return b.specificity - a.specificity;
351
+ return b.assignment.createdAt.getTime() - a.assignment.createdAt.getTime();
352
+ });
353
+ const winner = scored[0];
354
+ if (!winner)
355
+ return null;
356
+ // Load current published version + rules
357
+ if (!winner.policy.currentVersionId) {
358
+ return { policy: winner.policy, assignment: winner.assignment, version: null, rules: [] };
359
+ }
360
+ const [version] = await db
361
+ .select()
362
+ .from(policyVersions)
363
+ .where(eq(policyVersions.id, winner.policy.currentVersionId))
364
+ .limit(1);
365
+ const rules = version
366
+ ? await db
367
+ .select()
368
+ .from(policyRules)
369
+ .where(eq(policyRules.policyVersionId, version.id))
370
+ .orderBy(policyRules.sortOrder)
371
+ : [];
372
+ return {
373
+ policy: winner.policy,
374
+ assignment: winner.assignment,
375
+ version: version ?? null,
376
+ rules,
377
+ };
378
+ },
379
+ async evaluateCancellation(db, policyId, input) {
380
+ const [policy] = await db.select().from(policies).where(eq(policies.id, policyId)).limit(1);
381
+ if (!policy?.currentVersionId)
382
+ return null;
383
+ const rules = await db
384
+ .select()
385
+ .from(policyRules)
386
+ .where(eq(policyRules.policyVersionId, policy.currentVersionId));
387
+ const mapped = rules.map((r) => ({
388
+ id: r.id,
389
+ daysBeforeDeparture: r.daysBeforeDeparture,
390
+ refundPercent: r.refundPercent,
391
+ refundType: r.refundType,
392
+ flatAmountCents: r.flatAmountCents,
393
+ label: r.label,
394
+ }));
395
+ return evaluateCancellationPolicy(mapped, input);
396
+ },
397
+ // ---------- acceptances ----------
398
+ async listPolicyAcceptances(db, query) {
399
+ const conditions = [];
400
+ if (query.policyVersionId)
401
+ conditions.push(eq(policyAcceptances.policyVersionId, query.policyVersionId));
402
+ if (query.personId)
403
+ conditions.push(eq(policyAcceptances.personId, query.personId));
404
+ if (query.bookingId)
405
+ conditions.push(eq(policyAcceptances.bookingId, query.bookingId));
406
+ if (query.orderId)
407
+ conditions.push(eq(policyAcceptances.orderId, query.orderId));
408
+ const where = conditions.length ? and(...conditions) : undefined;
409
+ return paginate(db
410
+ .select()
411
+ .from(policyAcceptances)
412
+ .where(where)
413
+ .limit(query.limit)
414
+ .offset(query.offset)
415
+ .orderBy(desc(policyAcceptances.acceptedAt)), db.select({ total: sql `count(*)::int` }).from(policyAcceptances).where(where), query.limit, query.offset);
416
+ },
417
+ async recordPolicyAcceptance(db, data) {
418
+ const [row] = await db
419
+ .insert(policyAcceptances)
420
+ .values({
421
+ policyVersionId: data.policyVersionId,
422
+ personId: data.personId ?? null,
423
+ bookingId: data.bookingId ?? null,
424
+ orderId: data.orderId ?? null,
425
+ offerId: data.offerId ?? null,
426
+ acceptedBy: data.acceptedBy ?? null,
427
+ method: data.method,
428
+ ipAddress: data.ipAddress ?? null,
429
+ userAgent: data.userAgent ?? null,
430
+ metadata: data.metadata ?? null,
431
+ })
432
+ .returning();
433
+ return row ?? null;
434
+ },
435
+ };
436
+ // Remove unused imports placeholder — `inArray` is referenced to allow callers
437
+ // to use it via re-export if needed but not used internally. Kept minimal.
438
+ export const _unused = inArray;