@voyantjs/finance 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.
@@ -0,0 +1,1786 @@
1
+ import { and, asc, desc, eq, gte, ilike, lte, or, sql } from "drizzle-orm";
2
+ import { bookingGuarantees, bookingItemCommissions, bookingItemTaxLines, bookingPaymentSchedules, creditNoteLineItems, creditNotes, financeNotes, invoiceExternalRefs, invoiceLineItems, invoiceNumberSeries, invoiceRenditions, invoices, invoiceTemplates, paymentAuthorizations, paymentCaptures, paymentInstruments, paymentSessions, payments, supplierPayments, taxRegimes, } from "./schema.js";
3
+ function toTimestamp(value) {
4
+ return value ? new Date(value) : null;
5
+ }
6
+ function toDateString(value) {
7
+ return value.toISOString().slice(0, 10);
8
+ }
9
+ function startOfUtcDay(value) {
10
+ return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
11
+ }
12
+ function parseDateString(value) {
13
+ return new Date(`${value}T00:00:00.000Z`);
14
+ }
15
+ function derivePaymentSessionTarget(input) {
16
+ if (input.targetType && input.targetType !== "other") {
17
+ return {
18
+ targetType: input.targetType,
19
+ targetId: input.targetId ??
20
+ (input.targetType === "booking"
21
+ ? input.bookingId
22
+ : input.targetType === "order"
23
+ ? input.orderId
24
+ : input.targetType === "invoice"
25
+ ? input.invoiceId
26
+ : input.targetType === "booking_payment_schedule"
27
+ ? input.bookingPaymentScheduleId
28
+ : input.targetType === "booking_guarantee"
29
+ ? input.bookingGuaranteeId
30
+ : input.targetId),
31
+ };
32
+ }
33
+ if (input.bookingPaymentScheduleId) {
34
+ return { targetType: "booking_payment_schedule", targetId: input.bookingPaymentScheduleId };
35
+ }
36
+ if (input.bookingGuaranteeId) {
37
+ return { targetType: "booking_guarantee", targetId: input.bookingGuaranteeId };
38
+ }
39
+ if (input.invoiceId) {
40
+ return { targetType: "invoice", targetId: input.invoiceId };
41
+ }
42
+ if (input.orderId) {
43
+ return { targetType: "order", targetId: input.orderId };
44
+ }
45
+ if (input.bookingId) {
46
+ return { targetType: "booking", targetId: input.bookingId };
47
+ }
48
+ return {
49
+ targetType: (input.targetType ?? "other"),
50
+ targetId: input.targetId ?? null,
51
+ };
52
+ }
53
+ // ============================================================================
54
+ // Invoice number allocation (transactional)
55
+ // ============================================================================
56
+ function currentPeriodBoundary(strategy, now) {
57
+ if (strategy === "never")
58
+ return null;
59
+ if (strategy === "annual") {
60
+ return new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
61
+ }
62
+ // monthly
63
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
64
+ }
65
+ function formatNumber(prefix, separator, padLength, sequence) {
66
+ const padded = String(sequence).padStart(padLength, "0");
67
+ return `${prefix}${separator}${padded}`;
68
+ }
69
+ // ============================================================================
70
+ // Template rendering (mustache)
71
+ // ============================================================================
72
+ function resolveMustachePath(path, scope) {
73
+ const parts = path.match(/[^.[\]]+/g) ?? [];
74
+ let current = scope;
75
+ for (const part of parts) {
76
+ if (current == null || typeof current !== "object")
77
+ return undefined;
78
+ current = current[part];
79
+ }
80
+ return current;
81
+ }
82
+ function stringifyMustacheValue(value) {
83
+ if (value == null)
84
+ return "";
85
+ if (typeof value === "string")
86
+ return value;
87
+ if (typeof value === "number" || typeof value === "boolean")
88
+ return String(value);
89
+ return JSON.stringify(value);
90
+ }
91
+ function renderMustache(body, variables) {
92
+ return body.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, path) => {
93
+ const value = resolveMustachePath(path.trim(), variables);
94
+ return stringifyMustacheValue(value);
95
+ });
96
+ }
97
+ export function renderInvoiceBody(body, bodyFormat, variables) {
98
+ if (bodyFormat === "lexical_json") {
99
+ try {
100
+ const parsed = JSON.parse(body);
101
+ return JSON.stringify(renderLexicalNode(parsed, variables));
102
+ }
103
+ catch {
104
+ return renderMustache(body, variables);
105
+ }
106
+ }
107
+ return renderMustache(body, variables);
108
+ }
109
+ function renderLexicalNode(node, variables) {
110
+ if (node == null || typeof node !== "object")
111
+ return node;
112
+ if (Array.isArray(node)) {
113
+ return node.map((n) => renderLexicalNode(n, variables));
114
+ }
115
+ const obj = node;
116
+ const result = { ...obj };
117
+ if (typeof obj.text === "string") {
118
+ result.text = renderMustache(obj.text, variables);
119
+ }
120
+ if (obj.children) {
121
+ result.children = renderLexicalNode(obj.children, variables);
122
+ }
123
+ if (obj.root) {
124
+ result.root = renderLexicalNode(obj.root, variables);
125
+ }
126
+ return result;
127
+ }
128
+ async function paginate(rowsQuery, countQuery, limit, offset) {
129
+ const [data, countResult] = await Promise.all([rowsQuery, countQuery]);
130
+ return { data, total: countResult[0]?.total ?? 0, limit, offset };
131
+ }
132
+ export const financeService = {
133
+ async listPaymentInstruments(db, query) {
134
+ const conditions = [];
135
+ if (query.ownerType)
136
+ conditions.push(eq(paymentInstruments.ownerType, query.ownerType));
137
+ if (query.personId)
138
+ conditions.push(eq(paymentInstruments.personId, query.personId));
139
+ if (query.organizationId)
140
+ conditions.push(eq(paymentInstruments.organizationId, query.organizationId));
141
+ if (query.supplierId)
142
+ conditions.push(eq(paymentInstruments.supplierId, query.supplierId));
143
+ if (query.channelId)
144
+ conditions.push(eq(paymentInstruments.channelId, query.channelId));
145
+ if (query.status)
146
+ conditions.push(eq(paymentInstruments.status, query.status));
147
+ if (query.instrumentType)
148
+ conditions.push(eq(paymentInstruments.instrumentType, query.instrumentType));
149
+ if (query.search) {
150
+ const term = `%${query.search}%`;
151
+ conditions.push(or(ilike(paymentInstruments.label, term), ilike(paymentInstruments.provider, term)));
152
+ }
153
+ const where = conditions.length ? and(...conditions) : undefined;
154
+ return paginate(db
155
+ .select()
156
+ .from(paymentInstruments)
157
+ .where(where)
158
+ .limit(query.limit)
159
+ .offset(query.offset)
160
+ .orderBy(desc(paymentInstruments.updatedAt)), db.select({ total: sql `count(*)::int` }).from(paymentInstruments).where(where), query.limit, query.offset);
161
+ },
162
+ async getPaymentInstrumentById(db, id) {
163
+ const [row] = await db
164
+ .select()
165
+ .from(paymentInstruments)
166
+ .where(eq(paymentInstruments.id, id))
167
+ .limit(1);
168
+ return row ?? null;
169
+ },
170
+ async createPaymentInstrument(db, data) {
171
+ const [row] = await db.insert(paymentInstruments).values(data).returning();
172
+ return row ?? null;
173
+ },
174
+ async updatePaymentInstrument(db, id, data) {
175
+ const [row] = await db
176
+ .update(paymentInstruments)
177
+ .set({ ...data, updatedAt: new Date() })
178
+ .where(eq(paymentInstruments.id, id))
179
+ .returning();
180
+ return row ?? null;
181
+ },
182
+ async deletePaymentInstrument(db, id) {
183
+ const [row] = await db
184
+ .delete(paymentInstruments)
185
+ .where(eq(paymentInstruments.id, id))
186
+ .returning({ id: paymentInstruments.id });
187
+ return row ?? null;
188
+ },
189
+ async listPaymentSessions(db, query) {
190
+ const conditions = [];
191
+ if (query.bookingId)
192
+ conditions.push(eq(paymentSessions.bookingId, query.bookingId));
193
+ if (query.orderId)
194
+ conditions.push(eq(paymentSessions.orderId, query.orderId));
195
+ if (query.invoiceId)
196
+ conditions.push(eq(paymentSessions.invoiceId, query.invoiceId));
197
+ if (query.bookingPaymentScheduleId) {
198
+ conditions.push(eq(paymentSessions.bookingPaymentScheduleId, query.bookingPaymentScheduleId));
199
+ }
200
+ if (query.bookingGuaranteeId) {
201
+ conditions.push(eq(paymentSessions.bookingGuaranteeId, query.bookingGuaranteeId));
202
+ }
203
+ if (query.targetType)
204
+ conditions.push(eq(paymentSessions.targetType, query.targetType));
205
+ if (query.status)
206
+ conditions.push(eq(paymentSessions.status, query.status));
207
+ if (query.provider)
208
+ conditions.push(eq(paymentSessions.provider, query.provider));
209
+ if (query.providerSessionId) {
210
+ conditions.push(eq(paymentSessions.providerSessionId, query.providerSessionId));
211
+ }
212
+ if (query.providerPaymentId) {
213
+ conditions.push(eq(paymentSessions.providerPaymentId, query.providerPaymentId));
214
+ }
215
+ if (query.externalReference) {
216
+ conditions.push(eq(paymentSessions.externalReference, query.externalReference));
217
+ }
218
+ if (query.clientReference) {
219
+ conditions.push(eq(paymentSessions.clientReference, query.clientReference));
220
+ }
221
+ if (query.idempotencyKey) {
222
+ conditions.push(eq(paymentSessions.idempotencyKey, query.idempotencyKey));
223
+ }
224
+ const where = conditions.length ? and(...conditions) : undefined;
225
+ return paginate(db
226
+ .select()
227
+ .from(paymentSessions)
228
+ .where(where)
229
+ .limit(query.limit)
230
+ .offset(query.offset)
231
+ .orderBy(desc(paymentSessions.createdAt)), db.select({ total: sql `count(*)::int` }).from(paymentSessions).where(where), query.limit, query.offset);
232
+ },
233
+ async getPaymentSessionById(db, id) {
234
+ const [row] = await db.select().from(paymentSessions).where(eq(paymentSessions.id, id)).limit(1);
235
+ return row ?? null;
236
+ },
237
+ async createPaymentSession(db, data) {
238
+ if (data.idempotencyKey) {
239
+ const [existing] = await db
240
+ .select()
241
+ .from(paymentSessions)
242
+ .where(eq(paymentSessions.idempotencyKey, data.idempotencyKey))
243
+ .limit(1);
244
+ if (existing) {
245
+ return existing;
246
+ }
247
+ }
248
+ const target = derivePaymentSessionTarget(data);
249
+ const [row] = await db
250
+ .insert(paymentSessions)
251
+ .values({
252
+ ...data,
253
+ ...target,
254
+ paymentInstrumentId: data.paymentInstrumentId ?? null,
255
+ paymentAuthorizationId: data.paymentAuthorizationId ?? null,
256
+ paymentCaptureId: data.paymentCaptureId ?? null,
257
+ paymentId: data.paymentId ?? null,
258
+ completedAt: toTimestamp(data.completedAt),
259
+ failedAt: toTimestamp(data.failedAt),
260
+ cancelledAt: toTimestamp(data.cancelledAt),
261
+ expiredAt: toTimestamp(data.expiredAt),
262
+ expiresAt: toTimestamp(data.expiresAt),
263
+ })
264
+ .returning();
265
+ return row ?? null;
266
+ },
267
+ async updatePaymentSession(db, id, data) {
268
+ const target = derivePaymentSessionTarget(data);
269
+ const [row] = await db
270
+ .update(paymentSessions)
271
+ .set({
272
+ ...data,
273
+ ...target,
274
+ paymentInstrumentId: data.paymentInstrumentId === undefined ? undefined : (data.paymentInstrumentId ?? null),
275
+ paymentAuthorizationId: data.paymentAuthorizationId === undefined
276
+ ? undefined
277
+ : (data.paymentAuthorizationId ?? null),
278
+ paymentCaptureId: data.paymentCaptureId === undefined ? undefined : (data.paymentCaptureId ?? null),
279
+ paymentId: data.paymentId === undefined ? undefined : (data.paymentId ?? null),
280
+ completedAt: data.completedAt === undefined ? undefined : toTimestamp(data.completedAt),
281
+ failedAt: data.failedAt === undefined ? undefined : toTimestamp(data.failedAt),
282
+ cancelledAt: data.cancelledAt === undefined ? undefined : toTimestamp(data.cancelledAt),
283
+ expiredAt: data.expiredAt === undefined ? undefined : toTimestamp(data.expiredAt),
284
+ expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
285
+ updatedAt: new Date(),
286
+ })
287
+ .where(eq(paymentSessions.id, id))
288
+ .returning();
289
+ return row ?? null;
290
+ },
291
+ async markPaymentSessionRequiresRedirect(db, id, data) {
292
+ const [row] = await db
293
+ .update(paymentSessions)
294
+ .set({
295
+ status: "requires_redirect",
296
+ provider: data.provider ?? undefined,
297
+ providerSessionId: data.providerSessionId ?? undefined,
298
+ providerPaymentId: data.providerPaymentId ?? undefined,
299
+ externalReference: data.externalReference ?? undefined,
300
+ redirectUrl: data.redirectUrl,
301
+ returnUrl: data.returnUrl ?? undefined,
302
+ cancelUrl: data.cancelUrl ?? undefined,
303
+ callbackUrl: data.callbackUrl ?? undefined,
304
+ expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
305
+ providerPayload: data.providerPayload ?? undefined,
306
+ metadata: data.metadata ?? undefined,
307
+ notes: data.notes ?? undefined,
308
+ updatedAt: new Date(),
309
+ })
310
+ .where(eq(paymentSessions.id, id))
311
+ .returning();
312
+ return row ?? null;
313
+ },
314
+ async failPaymentSession(db, id, data) {
315
+ const [row] = await db
316
+ .update(paymentSessions)
317
+ .set({
318
+ status: "failed",
319
+ providerSessionId: data.providerSessionId ?? undefined,
320
+ providerPaymentId: data.providerPaymentId ?? undefined,
321
+ externalReference: data.externalReference ?? undefined,
322
+ failureCode: data.failureCode ?? undefined,
323
+ failureMessage: data.failureMessage ?? undefined,
324
+ failedAt: new Date(),
325
+ providerPayload: data.providerPayload ?? undefined,
326
+ metadata: data.metadata ?? undefined,
327
+ notes: data.notes ?? undefined,
328
+ updatedAt: new Date(),
329
+ })
330
+ .where(eq(paymentSessions.id, id))
331
+ .returning();
332
+ return row ?? null;
333
+ },
334
+ async cancelPaymentSession(db, id, data) {
335
+ const [row] = await db
336
+ .update(paymentSessions)
337
+ .set({
338
+ status: "cancelled",
339
+ cancelledAt: data.cancelledAt ? toTimestamp(data.cancelledAt) : new Date(),
340
+ providerPayload: data.providerPayload ?? undefined,
341
+ metadata: data.metadata ?? undefined,
342
+ notes: data.notes ?? undefined,
343
+ updatedAt: new Date(),
344
+ })
345
+ .where(eq(paymentSessions.id, id))
346
+ .returning();
347
+ return row ?? null;
348
+ },
349
+ async expirePaymentSession(db, id, data) {
350
+ const [row] = await db
351
+ .update(paymentSessions)
352
+ .set({
353
+ status: "expired",
354
+ expiredAt: data.expiredAt ? toTimestamp(data.expiredAt) : new Date(),
355
+ providerPayload: data.providerPayload ?? undefined,
356
+ metadata: data.metadata ?? undefined,
357
+ notes: data.notes ?? undefined,
358
+ updatedAt: new Date(),
359
+ })
360
+ .where(eq(paymentSessions.id, id))
361
+ .returning();
362
+ return row ?? null;
363
+ },
364
+ async completePaymentSession(db, id, data) {
365
+ const [session] = await db
366
+ .select()
367
+ .from(paymentSessions)
368
+ .where(eq(paymentSessions.id, id))
369
+ .limit(1);
370
+ if (!session) {
371
+ return null;
372
+ }
373
+ return db.transaction(async (tx) => {
374
+ let authorizationId = session.paymentAuthorizationId;
375
+ let captureId = session.paymentCaptureId;
376
+ let paymentId = session.paymentId;
377
+ if (!authorizationId) {
378
+ const [authorization] = await tx
379
+ .insert(paymentAuthorizations)
380
+ .values({
381
+ bookingId: session.bookingId ?? null,
382
+ orderId: session.orderId ?? null,
383
+ invoiceId: session.invoiceId ?? null,
384
+ bookingGuaranteeId: session.bookingGuaranteeId ?? null,
385
+ paymentInstrumentId: data.paymentInstrumentId ?? session.paymentInstrumentId ?? null,
386
+ status: data.status === "paid" ? "captured" : "authorized",
387
+ captureMode: data.captureMode,
388
+ currency: session.currency,
389
+ amountCents: session.amountCents,
390
+ provider: session.provider ?? null,
391
+ externalAuthorizationId: data.externalAuthorizationId ??
392
+ data.providerPaymentId ??
393
+ session.providerPaymentId ??
394
+ null,
395
+ approvalCode: data.approvalCode ?? null,
396
+ authorizedAt: toTimestamp(data.authorizedAt) ?? new Date(),
397
+ expiresAt: toTimestamp(data.expiresAt),
398
+ notes: data.notes ?? session.notes ?? null,
399
+ })
400
+ .returning({ id: paymentAuthorizations.id });
401
+ authorizationId = authorization?.id ?? null;
402
+ }
403
+ else if (data.status === "paid") {
404
+ await tx
405
+ .update(paymentAuthorizations)
406
+ .set({
407
+ status: "captured",
408
+ paymentInstrumentId: data.paymentInstrumentId ?? session.paymentInstrumentId ?? undefined,
409
+ externalAuthorizationId: data.externalAuthorizationId === undefined
410
+ ? undefined
411
+ : (data.externalAuthorizationId ?? null),
412
+ approvalCode: data.approvalCode ?? undefined,
413
+ authorizedAt: data.authorizedAt === undefined ? undefined : toTimestamp(data.authorizedAt),
414
+ expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
415
+ updatedAt: new Date(),
416
+ })
417
+ .where(eq(paymentAuthorizations.id, authorizationId));
418
+ }
419
+ if (data.status === "paid" && !captureId) {
420
+ const [capture] = await tx
421
+ .insert(paymentCaptures)
422
+ .values({
423
+ paymentAuthorizationId: authorizationId,
424
+ invoiceId: session.invoiceId ?? null,
425
+ status: "completed",
426
+ currency: session.currency,
427
+ amountCents: session.amountCents,
428
+ provider: session.provider ?? null,
429
+ externalCaptureId: data.externalCaptureId ?? data.providerPaymentId ?? session.providerPaymentId ?? null,
430
+ capturedAt: toTimestamp(data.capturedAt) ?? new Date(),
431
+ settledAt: toTimestamp(data.settledAt),
432
+ notes: data.notes ?? session.notes ?? null,
433
+ })
434
+ .returning({ id: paymentCaptures.id });
435
+ captureId = capture?.id ?? null;
436
+ }
437
+ if (data.status === "paid" && session.invoiceId && !paymentId) {
438
+ const [invoice] = await tx
439
+ .select()
440
+ .from(invoices)
441
+ .where(eq(invoices.id, session.invoiceId))
442
+ .limit(1);
443
+ if (invoice) {
444
+ const [payment] = await tx
445
+ .insert(payments)
446
+ .values({
447
+ invoiceId: session.invoiceId,
448
+ amountCents: session.amountCents,
449
+ currency: session.currency,
450
+ paymentMethod: data.paymentMethod ?? session.paymentMethod ?? "other",
451
+ paymentInstrumentId: data.paymentInstrumentId ?? session.paymentInstrumentId ?? null,
452
+ paymentAuthorizationId: authorizationId,
453
+ paymentCaptureId: captureId,
454
+ status: "completed",
455
+ referenceNumber: data.referenceNumber ?? data.externalReference ?? session.externalReference ?? null,
456
+ paymentDate: (data.paymentDate ? new Date(data.paymentDate) : new Date())
457
+ .toISOString()
458
+ .slice(0, 10),
459
+ notes: data.notes ?? session.notes ?? null,
460
+ })
461
+ .returning({ id: payments.id });
462
+ paymentId = payment?.id ?? null;
463
+ const [sumResult] = await tx
464
+ .select({ total: sql `coalesce(sum(amount_cents), 0)::int` })
465
+ .from(payments)
466
+ .where(and(eq(payments.invoiceId, session.invoiceId), eq(payments.status, "completed")));
467
+ const paidCents = sumResult?.total ?? 0;
468
+ const balanceDueCents = Math.max(0, invoice.totalCents - paidCents);
469
+ await tx
470
+ .update(invoices)
471
+ .set({
472
+ paidCents,
473
+ balanceDueCents,
474
+ status: paidCents >= invoice.totalCents
475
+ ? "paid"
476
+ : paidCents > 0
477
+ ? "partially_paid"
478
+ : invoice.status,
479
+ updatedAt: new Date(),
480
+ })
481
+ .where(eq(invoices.id, session.invoiceId));
482
+ }
483
+ }
484
+ if (data.status === "paid" && session.bookingPaymentScheduleId) {
485
+ await tx
486
+ .update(bookingPaymentSchedules)
487
+ .set({ status: "paid", updatedAt: new Date() })
488
+ .where(eq(bookingPaymentSchedules.id, session.bookingPaymentScheduleId));
489
+ }
490
+ if (session.bookingGuaranteeId && authorizationId) {
491
+ await tx
492
+ .update(bookingGuarantees)
493
+ .set({
494
+ paymentAuthorizationId: authorizationId,
495
+ paymentInstrumentId: data.paymentInstrumentId ?? session.paymentInstrumentId ?? undefined,
496
+ status: "active",
497
+ guaranteedAt: toTimestamp(data.authorizedAt) ?? new Date(),
498
+ updatedAt: new Date(),
499
+ })
500
+ .where(eq(bookingGuarantees.id, session.bookingGuaranteeId));
501
+ }
502
+ const [updated] = await tx
503
+ .update(paymentSessions)
504
+ .set({
505
+ status: data.status,
506
+ paymentMethod: data.paymentMethod ?? session.paymentMethod ?? undefined,
507
+ paymentInstrumentId: data.paymentInstrumentId ?? session.paymentInstrumentId ?? undefined,
508
+ paymentAuthorizationId: authorizationId,
509
+ paymentCaptureId: captureId,
510
+ paymentId,
511
+ providerSessionId: data.providerSessionId ?? session.providerSessionId ?? undefined,
512
+ providerPaymentId: data.providerPaymentId ?? session.providerPaymentId ?? undefined,
513
+ externalReference: data.externalReference ?? session.externalReference ?? undefined,
514
+ providerPayload: data.providerPayload ?? undefined,
515
+ metadata: data.metadata ?? undefined,
516
+ notes: data.notes ?? session.notes ?? undefined,
517
+ redirectUrl: data.status === "paid" ? null : session.redirectUrl,
518
+ failureCode: null,
519
+ failureMessage: null,
520
+ expiresAt: data.expiresAt === undefined ? session.expiresAt : toTimestamp(data.expiresAt),
521
+ completedAt: new Date(),
522
+ updatedAt: new Date(),
523
+ })
524
+ .where(eq(paymentSessions.id, id))
525
+ .returning();
526
+ return updated ?? null;
527
+ });
528
+ },
529
+ async listPaymentAuthorizations(db, query) {
530
+ const conditions = [];
531
+ if (query.bookingId)
532
+ conditions.push(eq(paymentAuthorizations.bookingId, query.bookingId));
533
+ if (query.orderId)
534
+ conditions.push(eq(paymentAuthorizations.orderId, query.orderId));
535
+ if (query.invoiceId)
536
+ conditions.push(eq(paymentAuthorizations.invoiceId, query.invoiceId));
537
+ if (query.bookingGuaranteeId)
538
+ conditions.push(eq(paymentAuthorizations.bookingGuaranteeId, query.bookingGuaranteeId));
539
+ if (query.paymentInstrumentId)
540
+ conditions.push(eq(paymentAuthorizations.paymentInstrumentId, query.paymentInstrumentId));
541
+ if (query.status)
542
+ conditions.push(eq(paymentAuthorizations.status, query.status));
543
+ const where = conditions.length ? and(...conditions) : undefined;
544
+ return paginate(db
545
+ .select()
546
+ .from(paymentAuthorizations)
547
+ .where(where)
548
+ .limit(query.limit)
549
+ .offset(query.offset)
550
+ .orderBy(desc(paymentAuthorizations.createdAt)), db.select({ total: sql `count(*)::int` }).from(paymentAuthorizations).where(where), query.limit, query.offset);
551
+ },
552
+ async getPaymentAuthorizationById(db, id) {
553
+ const [row] = await db
554
+ .select()
555
+ .from(paymentAuthorizations)
556
+ .where(eq(paymentAuthorizations.id, id))
557
+ .limit(1);
558
+ return row ?? null;
559
+ },
560
+ async createPaymentAuthorization(db, data) {
561
+ const [row] = await db
562
+ .insert(paymentAuthorizations)
563
+ .values({
564
+ ...data,
565
+ authorizedAt: toTimestamp(data.authorizedAt),
566
+ expiresAt: toTimestamp(data.expiresAt),
567
+ voidedAt: toTimestamp(data.voidedAt),
568
+ })
569
+ .returning();
570
+ return row ?? null;
571
+ },
572
+ async updatePaymentAuthorization(db, id, data) {
573
+ const [row] = await db
574
+ .update(paymentAuthorizations)
575
+ .set({
576
+ ...data,
577
+ authorizedAt: data.authorizedAt === undefined ? undefined : toTimestamp(data.authorizedAt),
578
+ expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
579
+ voidedAt: data.voidedAt === undefined ? undefined : toTimestamp(data.voidedAt),
580
+ updatedAt: new Date(),
581
+ })
582
+ .where(eq(paymentAuthorizations.id, id))
583
+ .returning();
584
+ return row ?? null;
585
+ },
586
+ async deletePaymentAuthorization(db, id) {
587
+ const [row] = await db
588
+ .delete(paymentAuthorizations)
589
+ .where(eq(paymentAuthorizations.id, id))
590
+ .returning({ id: paymentAuthorizations.id });
591
+ return row ?? null;
592
+ },
593
+ async listPaymentCaptures(db, query) {
594
+ const conditions = [];
595
+ if (query.paymentAuthorizationId)
596
+ conditions.push(eq(paymentCaptures.paymentAuthorizationId, query.paymentAuthorizationId));
597
+ if (query.invoiceId)
598
+ conditions.push(eq(paymentCaptures.invoiceId, query.invoiceId));
599
+ if (query.status)
600
+ conditions.push(eq(paymentCaptures.status, query.status));
601
+ const where = conditions.length ? and(...conditions) : undefined;
602
+ return paginate(db
603
+ .select()
604
+ .from(paymentCaptures)
605
+ .where(where)
606
+ .limit(query.limit)
607
+ .offset(query.offset)
608
+ .orderBy(desc(paymentCaptures.createdAt)), db.select({ total: sql `count(*)::int` }).from(paymentCaptures).where(where), query.limit, query.offset);
609
+ },
610
+ async getPaymentCaptureById(db, id) {
611
+ const [row] = await db.select().from(paymentCaptures).where(eq(paymentCaptures.id, id)).limit(1);
612
+ return row ?? null;
613
+ },
614
+ async createPaymentCapture(db, data) {
615
+ const [row] = await db
616
+ .insert(paymentCaptures)
617
+ .values({
618
+ ...data,
619
+ capturedAt: toTimestamp(data.capturedAt),
620
+ settledAt: toTimestamp(data.settledAt),
621
+ })
622
+ .returning();
623
+ return row ?? null;
624
+ },
625
+ async updatePaymentCapture(db, id, data) {
626
+ const [row] = await db
627
+ .update(paymentCaptures)
628
+ .set({
629
+ ...data,
630
+ capturedAt: data.capturedAt === undefined ? undefined : toTimestamp(data.capturedAt),
631
+ settledAt: data.settledAt === undefined ? undefined : toTimestamp(data.settledAt),
632
+ updatedAt: new Date(),
633
+ })
634
+ .where(eq(paymentCaptures.id, id))
635
+ .returning();
636
+ return row ?? null;
637
+ },
638
+ async deletePaymentCapture(db, id) {
639
+ const [row] = await db
640
+ .delete(paymentCaptures)
641
+ .where(eq(paymentCaptures.id, id))
642
+ .returning({ id: paymentCaptures.id });
643
+ return row ?? null;
644
+ },
645
+ listBookingPaymentSchedules(db, bookingId) {
646
+ return db
647
+ .select()
648
+ .from(bookingPaymentSchedules)
649
+ .where(eq(bookingPaymentSchedules.bookingId, bookingId))
650
+ .orderBy(asc(bookingPaymentSchedules.dueDate), asc(bookingPaymentSchedules.createdAt));
651
+ },
652
+ async createBookingPaymentSchedule(db, bookingId, data) {
653
+ const [row] = await db
654
+ .insert(bookingPaymentSchedules)
655
+ .values({ ...data, bookingId })
656
+ .returning();
657
+ return row ?? null;
658
+ },
659
+ async applyDefaultBookingPaymentPlan(db, bookingId, data) {
660
+ const bookingsModule = await import("@voyantjs/bookings/schema");
661
+ const { bookings } = bookingsModule;
662
+ const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
663
+ if (!booking) {
664
+ return null;
665
+ }
666
+ const totalAmountCents = booking.sellAmountCents ?? 0;
667
+ if (totalAmountCents <= 0) {
668
+ return [];
669
+ }
670
+ const today = startOfUtcDay(new Date());
671
+ const depositDueDate = data.depositDueDate ? parseDateString(data.depositDueDate) : today;
672
+ const startDate = booking.startDate ? parseDateString(booking.startDate) : null;
673
+ const rawBalanceDueDate = startDate
674
+ ? new Date(startDate.getTime() - data.balanceDueDaysBeforeStart * 24 * 60 * 60 * 1000)
675
+ : today;
676
+ const balanceDueDate = rawBalanceDueDate < today ? today : rawBalanceDueDate;
677
+ let depositAmountCents = 0;
678
+ if (data.depositMode === "fixed_amount") {
679
+ depositAmountCents = Math.min(totalAmountCents, data.depositValue);
680
+ }
681
+ else if (data.depositMode === "percentage") {
682
+ depositAmountCents = Math.min(totalAmountCents, Math.round((totalAmountCents * data.depositValue) / 100));
683
+ }
684
+ if (data.clearExistingPending) {
685
+ await db
686
+ .delete(bookingPaymentSchedules)
687
+ .where(and(eq(bookingPaymentSchedules.bookingId, bookingId), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))));
688
+ }
689
+ const scheduleRows = [];
690
+ if (depositAmountCents > 0 && depositAmountCents < totalAmountCents) {
691
+ scheduleRows.push({
692
+ bookingItemId: null,
693
+ scheduleType: "deposit",
694
+ status: depositDueDate <= today ? "due" : "pending",
695
+ dueDate: toDateString(depositDueDate),
696
+ currency: booking.sellCurrency,
697
+ amountCents: depositAmountCents,
698
+ notes: data.notes ?? null,
699
+ });
700
+ scheduleRows.push({
701
+ bookingItemId: null,
702
+ scheduleType: "balance",
703
+ status: balanceDueDate <= today ? "due" : "pending",
704
+ dueDate: toDateString(balanceDueDate),
705
+ currency: booking.sellCurrency,
706
+ amountCents: Math.max(0, totalAmountCents - depositAmountCents),
707
+ notes: data.notes ?? null,
708
+ });
709
+ }
710
+ else {
711
+ const singleDueDate = balanceDueDate <= today ? today : balanceDueDate;
712
+ scheduleRows.push({
713
+ bookingItemId: null,
714
+ scheduleType: "balance",
715
+ status: singleDueDate <= today ? "due" : "pending",
716
+ dueDate: toDateString(singleDueDate),
717
+ currency: booking.sellCurrency,
718
+ amountCents: totalAmountCents,
719
+ notes: data.notes ?? null,
720
+ });
721
+ }
722
+ const createdSchedules = await db
723
+ .insert(bookingPaymentSchedules)
724
+ .values(scheduleRows.map((row) => ({
725
+ ...row,
726
+ bookingId,
727
+ bookingItemId: row.bookingItemId ?? null,
728
+ notes: row.notes ?? null,
729
+ })))
730
+ .returning();
731
+ if (data.createGuarantee) {
732
+ const depositSchedule = createdSchedules.find((schedule) => schedule.scheduleType === "deposit");
733
+ if (depositSchedule) {
734
+ await db
735
+ .insert(bookingGuarantees)
736
+ .values({
737
+ bookingId,
738
+ bookingPaymentScheduleId: depositSchedule.id,
739
+ bookingItemId: null,
740
+ guaranteeType: data.guaranteeType,
741
+ status: "pending",
742
+ paymentInstrumentId: null,
743
+ paymentAuthorizationId: null,
744
+ currency: depositSchedule.currency,
745
+ amountCents: depositSchedule.amountCents,
746
+ provider: null,
747
+ referenceNumber: null,
748
+ guaranteedAt: null,
749
+ expiresAt: null,
750
+ releasedAt: null,
751
+ notes: data.notes ?? null,
752
+ });
753
+ }
754
+ }
755
+ return createdSchedules;
756
+ },
757
+ async updateBookingPaymentSchedule(db, scheduleId, data) {
758
+ const [row] = await db
759
+ .update(bookingPaymentSchedules)
760
+ .set({ ...data, updatedAt: new Date() })
761
+ .where(eq(bookingPaymentSchedules.id, scheduleId))
762
+ .returning();
763
+ return row ?? null;
764
+ },
765
+ async deleteBookingPaymentSchedule(db, scheduleId) {
766
+ const [row] = await db
767
+ .delete(bookingPaymentSchedules)
768
+ .where(eq(bookingPaymentSchedules.id, scheduleId))
769
+ .returning({ id: bookingPaymentSchedules.id });
770
+ return row ?? null;
771
+ },
772
+ async createPaymentSessionFromBookingSchedule(db, scheduleId, data) {
773
+ const [schedule] = await db
774
+ .select()
775
+ .from(bookingPaymentSchedules)
776
+ .where(eq(bookingPaymentSchedules.id, scheduleId))
777
+ .limit(1);
778
+ if (!schedule) {
779
+ return null;
780
+ }
781
+ if (schedule.status === "paid" || schedule.status === "waived" || schedule.status === "cancelled") {
782
+ throw new Error(`Cannot create payment session for schedule in status "${schedule.status}"`);
783
+ }
784
+ return this.createPaymentSession(db, {
785
+ targetType: "booking_payment_schedule",
786
+ targetId: schedule.id,
787
+ bookingId: schedule.bookingId,
788
+ bookingPaymentScheduleId: schedule.id,
789
+ status: "pending",
790
+ provider: data.provider ?? null,
791
+ externalReference: data.externalReference ?? null,
792
+ idempotencyKey: data.idempotencyKey ?? null,
793
+ clientReference: data.clientReference ?? schedule.id,
794
+ currency: schedule.currency,
795
+ amountCents: schedule.amountCents,
796
+ paymentMethod: data.paymentMethod ?? null,
797
+ payerPersonId: data.payerPersonId ?? null,
798
+ payerOrganizationId: data.payerOrganizationId ?? null,
799
+ payerEmail: data.payerEmail ?? null,
800
+ payerName: data.payerName ?? null,
801
+ returnUrl: data.returnUrl ?? null,
802
+ cancelUrl: data.cancelUrl ?? null,
803
+ callbackUrl: data.callbackUrl ?? null,
804
+ expiresAt: data.expiresAt ?? null,
805
+ notes: data.notes ?? schedule.notes ?? null,
806
+ providerPayload: data.providerPayload ?? null,
807
+ metadata: data.metadata ?? {
808
+ scheduleType: schedule.scheduleType,
809
+ dueDate: schedule.dueDate,
810
+ },
811
+ });
812
+ },
813
+ async createPaymentSessionFromInvoice(db, invoiceId, data) {
814
+ const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
815
+ if (!invoice) {
816
+ return null;
817
+ }
818
+ if (invoice.status === "paid" || invoice.status === "void") {
819
+ throw new Error(`Cannot create payment session for invoice in status "${invoice.status}"`);
820
+ }
821
+ if (invoice.balanceDueCents <= 0) {
822
+ throw new Error("Invoice must have an outstanding balance before creating a payment session");
823
+ }
824
+ return this.createPaymentSession(db, {
825
+ targetType: "invoice",
826
+ targetId: invoice.id,
827
+ bookingId: invoice.bookingId,
828
+ invoiceId: invoice.id,
829
+ status: "pending",
830
+ provider: data.provider ?? null,
831
+ externalReference: data.externalReference ?? invoice.invoiceNumber,
832
+ idempotencyKey: data.idempotencyKey ?? null,
833
+ clientReference: data.clientReference ?? invoice.id,
834
+ currency: invoice.currency,
835
+ amountCents: invoice.balanceDueCents,
836
+ paymentMethod: data.paymentMethod ?? null,
837
+ payerPersonId: data.payerPersonId ?? invoice.personId ?? null,
838
+ payerOrganizationId: data.payerOrganizationId ?? invoice.organizationId ?? null,
839
+ payerEmail: data.payerEmail ?? null,
840
+ payerName: data.payerName ?? null,
841
+ returnUrl: data.returnUrl ?? null,
842
+ cancelUrl: data.cancelUrl ?? null,
843
+ callbackUrl: data.callbackUrl ?? null,
844
+ expiresAt: data.expiresAt ?? null,
845
+ notes: data.notes ?? invoice.notes ?? null,
846
+ providerPayload: data.providerPayload ?? null,
847
+ metadata: data.metadata ?? {
848
+ invoiceNumber: invoice.invoiceNumber,
849
+ invoiceType: invoice.invoiceType,
850
+ dueDate: invoice.dueDate,
851
+ },
852
+ });
853
+ },
854
+ listBookingGuarantees(db, bookingId) {
855
+ return db
856
+ .select()
857
+ .from(bookingGuarantees)
858
+ .where(eq(bookingGuarantees.bookingId, bookingId))
859
+ .orderBy(desc(bookingGuarantees.createdAt));
860
+ },
861
+ async createBookingGuarantee(db, bookingId, data) {
862
+ const [row] = await db
863
+ .insert(bookingGuarantees)
864
+ .values({
865
+ bookingId,
866
+ bookingPaymentScheduleId: data.bookingPaymentScheduleId ?? null,
867
+ bookingItemId: data.bookingItemId ?? null,
868
+ guaranteeType: data.guaranteeType,
869
+ status: data.status,
870
+ paymentInstrumentId: data.paymentInstrumentId ?? null,
871
+ paymentAuthorizationId: data.paymentAuthorizationId ?? null,
872
+ currency: data.currency ?? null,
873
+ amountCents: data.amountCents ?? null,
874
+ provider: data.provider ?? null,
875
+ referenceNumber: data.referenceNumber ?? null,
876
+ guaranteedAt: toTimestamp(data.guaranteedAt),
877
+ expiresAt: toTimestamp(data.expiresAt),
878
+ releasedAt: toTimestamp(data.releasedAt),
879
+ notes: data.notes ?? null,
880
+ })
881
+ .returning();
882
+ return row ?? null;
883
+ },
884
+ async createPaymentSessionFromBookingGuarantee(db, guaranteeId, data) {
885
+ const [guarantee] = await db
886
+ .select()
887
+ .from(bookingGuarantees)
888
+ .where(eq(bookingGuarantees.id, guaranteeId))
889
+ .limit(1);
890
+ if (!guarantee) {
891
+ return null;
892
+ }
893
+ if (guarantee.status === "active" || guarantee.status === "released" || guarantee.status === "cancelled") {
894
+ throw new Error(`Cannot create payment session for guarantee in status "${guarantee.status}"`);
895
+ }
896
+ const currency = guarantee.currency;
897
+ const amountCents = guarantee.amountCents;
898
+ if (!currency || amountCents === null || amountCents === undefined || amountCents <= 0) {
899
+ throw new Error("Booking guarantee must have currency and amount before creating a payment session");
900
+ }
901
+ return this.createPaymentSession(db, {
902
+ targetType: "booking_guarantee",
903
+ targetId: guarantee.id,
904
+ bookingId: guarantee.bookingId,
905
+ bookingGuaranteeId: guarantee.id,
906
+ paymentInstrumentId: guarantee.paymentInstrumentId ?? null,
907
+ paymentAuthorizationId: guarantee.paymentAuthorizationId ?? null,
908
+ status: "pending",
909
+ provider: data.provider ?? guarantee.provider ?? null,
910
+ externalReference: data.externalReference ?? guarantee.referenceNumber ?? null,
911
+ idempotencyKey: data.idempotencyKey ?? null,
912
+ clientReference: data.clientReference ?? guarantee.id,
913
+ currency,
914
+ amountCents,
915
+ paymentMethod: data.paymentMethod ?? null,
916
+ payerPersonId: data.payerPersonId ?? null,
917
+ payerOrganizationId: data.payerOrganizationId ?? null,
918
+ payerEmail: data.payerEmail ?? null,
919
+ payerName: data.payerName ?? null,
920
+ returnUrl: data.returnUrl ?? null,
921
+ cancelUrl: data.cancelUrl ?? null,
922
+ callbackUrl: data.callbackUrl ?? null,
923
+ expiresAt: data.expiresAt ?? null,
924
+ notes: data.notes ?? guarantee.notes ?? null,
925
+ providerPayload: data.providerPayload ?? null,
926
+ metadata: data.metadata ?? {
927
+ guaranteeType: guarantee.guaranteeType,
928
+ },
929
+ });
930
+ },
931
+ async updateBookingGuarantee(db, guaranteeId, data) {
932
+ const [row] = await db
933
+ .update(bookingGuarantees)
934
+ .set({
935
+ ...data,
936
+ guaranteedAt: data.guaranteedAt === undefined ? undefined : toTimestamp(data.guaranteedAt),
937
+ expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
938
+ releasedAt: data.releasedAt === undefined ? undefined : toTimestamp(data.releasedAt),
939
+ updatedAt: new Date(),
940
+ })
941
+ .where(eq(bookingGuarantees.id, guaranteeId))
942
+ .returning();
943
+ return row ?? null;
944
+ },
945
+ async deleteBookingGuarantee(db, guaranteeId) {
946
+ const [row] = await db
947
+ .delete(bookingGuarantees)
948
+ .where(eq(bookingGuarantees.id, guaranteeId))
949
+ .returning({ id: bookingGuarantees.id });
950
+ return row ?? null;
951
+ },
952
+ listBookingItemTaxLines(db, bookingItemId) {
953
+ return db
954
+ .select()
955
+ .from(bookingItemTaxLines)
956
+ .where(eq(bookingItemTaxLines.bookingItemId, bookingItemId))
957
+ .orderBy(asc(bookingItemTaxLines.sortOrder), asc(bookingItemTaxLines.createdAt));
958
+ },
959
+ async createBookingItemTaxLine(db, bookingItemId, data) {
960
+ const [row] = await db
961
+ .insert(bookingItemTaxLines)
962
+ .values({ ...data, bookingItemId })
963
+ .returning();
964
+ return row ?? null;
965
+ },
966
+ async updateBookingItemTaxLine(db, taxLineId, data) {
967
+ const [row] = await db
968
+ .update(bookingItemTaxLines)
969
+ .set({ ...data, updatedAt: new Date() })
970
+ .where(eq(bookingItemTaxLines.id, taxLineId))
971
+ .returning();
972
+ return row ?? null;
973
+ },
974
+ async deleteBookingItemTaxLine(db, taxLineId) {
975
+ const [row] = await db
976
+ .delete(bookingItemTaxLines)
977
+ .where(eq(bookingItemTaxLines.id, taxLineId))
978
+ .returning({ id: bookingItemTaxLines.id });
979
+ return row ?? null;
980
+ },
981
+ listBookingItemCommissions(db, bookingItemId) {
982
+ return db
983
+ .select()
984
+ .from(bookingItemCommissions)
985
+ .where(eq(bookingItemCommissions.bookingItemId, bookingItemId))
986
+ .orderBy(desc(bookingItemCommissions.createdAt));
987
+ },
988
+ async createBookingItemCommission(db, bookingItemId, data) {
989
+ const [row] = await db
990
+ .insert(bookingItemCommissions)
991
+ .values({ ...data, bookingItemId })
992
+ .returning();
993
+ return row ?? null;
994
+ },
995
+ async updateBookingItemCommission(db, commissionId, data) {
996
+ const [row] = await db
997
+ .update(bookingItemCommissions)
998
+ .set({ ...data, updatedAt: new Date() })
999
+ .where(eq(bookingItemCommissions.id, commissionId))
1000
+ .returning();
1001
+ return row ?? null;
1002
+ },
1003
+ async deleteBookingItemCommission(db, commissionId) {
1004
+ const [row] = await db
1005
+ .delete(bookingItemCommissions)
1006
+ .where(eq(bookingItemCommissions.id, commissionId))
1007
+ .returning({ id: bookingItemCommissions.id });
1008
+ return row ?? null;
1009
+ },
1010
+ getRevenueReport(db, query) {
1011
+ return db
1012
+ .select({
1013
+ month: sql `to_char(date_trunc('month', ${invoices.issueDate}::date), 'YYYY-MM')`,
1014
+ totalCents: sql `coalesce(sum(${invoices.totalCents}), 0)::int`,
1015
+ count: sql `count(*)::int`,
1016
+ })
1017
+ .from(invoices)
1018
+ .where(and(gte(invoices.issueDate, query.from), lte(invoices.issueDate, query.to)))
1019
+ .groupBy(sql `date_trunc('month', ${invoices.issueDate}::date)`)
1020
+ .orderBy(sql `date_trunc('month', ${invoices.issueDate}::date)`);
1021
+ },
1022
+ getAgingReport(db, query) {
1023
+ const asOf = query.asOf ?? new Date().toISOString().slice(0, 10);
1024
+ return db
1025
+ .select({
1026
+ bucket: sql `
1027
+ case
1028
+ when ${invoices.dueDate}::date >= ${asOf}::date then 'current'
1029
+ when ${asOf}::date - ${invoices.dueDate}::date <= 30 then '1-30'
1030
+ when ${asOf}::date - ${invoices.dueDate}::date <= 60 then '31-60'
1031
+ when ${asOf}::date - ${invoices.dueDate}::date <= 90 then '61-90'
1032
+ else '90+'
1033
+ end`,
1034
+ totalCents: sql `coalesce(sum(${invoices.balanceDueCents}), 0)::int`,
1035
+ count: sql `count(*)::int`,
1036
+ })
1037
+ .from(invoices)
1038
+ .where(and(sql `${invoices.balanceDueCents} > 0`, sql `${invoices.status} != 'void'`, sql `${invoices.status} != 'paid'`))
1039
+ .groupBy(sql `1`);
1040
+ },
1041
+ /**
1042
+ * @deprecated Use a template-level query that joins bookings + finance data.
1043
+ * This stub is retained for backward compatibility — it returns an empty array.
1044
+ * The profitability report requires cross-module data (bookings + finance) and
1045
+ * should be implemented at the template level where both modules are available.
1046
+ */
1047
+ async getProfitabilityReport(_db, _query) {
1048
+ return [];
1049
+ },
1050
+ async listSupplierPayments(db, query) {
1051
+ const conditions = [];
1052
+ if (query.bookingId) {
1053
+ conditions.push(eq(supplierPayments.bookingId, query.bookingId));
1054
+ }
1055
+ if (query.supplierId) {
1056
+ conditions.push(eq(supplierPayments.supplierId, query.supplierId));
1057
+ }
1058
+ if (query.status) {
1059
+ conditions.push(eq(supplierPayments.status, query.status));
1060
+ }
1061
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
1062
+ const [rows, countResult] = await Promise.all([
1063
+ db
1064
+ .select()
1065
+ .from(supplierPayments)
1066
+ .where(where)
1067
+ .limit(query.limit)
1068
+ .offset(query.offset)
1069
+ .orderBy(desc(supplierPayments.createdAt)),
1070
+ db.select({ count: sql `count(*)::int` }).from(supplierPayments).where(where),
1071
+ ]);
1072
+ return {
1073
+ data: rows,
1074
+ total: countResult[0]?.count ?? 0,
1075
+ limit: query.limit,
1076
+ offset: query.offset,
1077
+ };
1078
+ },
1079
+ async createSupplierPayment(db, data) {
1080
+ const [row] = await db
1081
+ .insert(supplierPayments)
1082
+ .values({ ...data, paymentInstrumentId: data.paymentInstrumentId ?? null })
1083
+ .returning();
1084
+ return row;
1085
+ },
1086
+ async updateSupplierPayment(db, id, data) {
1087
+ const [row] = await db
1088
+ .update(supplierPayments)
1089
+ .set({ ...data, updatedAt: new Date() })
1090
+ .where(eq(supplierPayments.id, id))
1091
+ .returning();
1092
+ return row ?? null;
1093
+ },
1094
+ async listInvoices(db, query) {
1095
+ const conditions = [];
1096
+ if (query.status) {
1097
+ conditions.push(eq(invoices.status, query.status));
1098
+ }
1099
+ if (query.bookingId) {
1100
+ conditions.push(eq(invoices.bookingId, query.bookingId));
1101
+ }
1102
+ if (query.search) {
1103
+ const term = `%${query.search}%`;
1104
+ conditions.push(or(ilike(invoices.invoiceNumber, term), ilike(invoices.notes, term)));
1105
+ }
1106
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
1107
+ const [rows, countResult] = await Promise.all([
1108
+ db
1109
+ .select()
1110
+ .from(invoices)
1111
+ .where(where)
1112
+ .limit(query.limit)
1113
+ .offset(query.offset)
1114
+ .orderBy(desc(invoices.createdAt)),
1115
+ db.select({ count: sql `count(*)::int` }).from(invoices).where(where),
1116
+ ]);
1117
+ return {
1118
+ data: rows,
1119
+ total: countResult[0]?.count ?? 0,
1120
+ limit: query.limit,
1121
+ offset: query.offset,
1122
+ };
1123
+ },
1124
+ async createInvoice(db, data) {
1125
+ const [row] = await db.insert(invoices).values(data).returning();
1126
+ return row;
1127
+ },
1128
+ async createInvoiceFromBooking(db, data, bookingData) {
1129
+ const { booking, items } = bookingData;
1130
+ const itemIds = items.map((item) => item.id);
1131
+ const taxes = itemIds.length === 0
1132
+ ? []
1133
+ : await db
1134
+ .select()
1135
+ .from(bookingItemTaxLines)
1136
+ .where(or(...itemIds.map((id) => eq(bookingItemTaxLines.bookingItemId, id))));
1137
+ const commissions = itemIds.length === 0
1138
+ ? []
1139
+ : await db
1140
+ .select()
1141
+ .from(bookingItemCommissions)
1142
+ .where(or(...itemIds.map((id) => eq(bookingItemCommissions.bookingItemId, id))));
1143
+ const lineItems = items.length > 0
1144
+ ? items.map((item, sortOrder) => ({
1145
+ bookingItemId: item.id,
1146
+ description: item.title,
1147
+ quantity: item.quantity,
1148
+ unitPriceCents: item.unitSellAmountCents ??
1149
+ (item.totalSellAmountCents !== null && item.totalSellAmountCents !== undefined
1150
+ ? Math.floor(item.totalSellAmountCents / Math.max(item.quantity, 1))
1151
+ : 0),
1152
+ totalCents: item.totalSellAmountCents ??
1153
+ (item.unitSellAmountCents ?? 0) * Math.max(item.quantity, 1),
1154
+ taxRate: null,
1155
+ sortOrder,
1156
+ }))
1157
+ : [
1158
+ {
1159
+ bookingItemId: null,
1160
+ description: `Booking ${booking.bookingNumber}`,
1161
+ quantity: 1,
1162
+ unitPriceCents: booking.sellAmountCents ?? 0,
1163
+ totalCents: booking.sellAmountCents ?? 0,
1164
+ taxRate: null,
1165
+ sortOrder: 0,
1166
+ },
1167
+ ];
1168
+ const subtotalCents = lineItems.reduce((sum, line) => sum + line.totalCents, 0);
1169
+ const taxCents = taxes.reduce((sum, tax) => {
1170
+ if (tax.scope === "withheld" || tax.includedInPrice) {
1171
+ return sum;
1172
+ }
1173
+ return sum + tax.amountCents;
1174
+ }, 0);
1175
+ const totalCents = subtotalCents + taxCents;
1176
+ const commissionAmountCents = commissions.reduce((sum, commission) => {
1177
+ return sum + (commission.amountCents ?? 0);
1178
+ }, 0);
1179
+ return db.transaction(async (tx) => {
1180
+ const [invoice] = await tx
1181
+ .insert(invoices)
1182
+ .values({
1183
+ invoiceNumber: data.invoiceNumber,
1184
+ bookingId: booking.id,
1185
+ personId: booking.personId,
1186
+ organizationId: booking.organizationId,
1187
+ status: "draft",
1188
+ currency: booking.sellCurrency,
1189
+ baseCurrency: booking.baseCurrency,
1190
+ fxRateSetId: booking.fxRateSetId,
1191
+ subtotalCents,
1192
+ baseSubtotalCents: booking.baseSellAmountCents,
1193
+ taxCents,
1194
+ baseTaxCents: null,
1195
+ totalCents,
1196
+ baseTotalCents: booking.baseSellAmountCents,
1197
+ paidCents: 0,
1198
+ basePaidCents: 0,
1199
+ balanceDueCents: totalCents,
1200
+ baseBalanceDueCents: booking.baseSellAmountCents,
1201
+ commissionAmountCents: commissionAmountCents > 0 ? commissionAmountCents : null,
1202
+ issueDate: data.issueDate,
1203
+ dueDate: data.dueDate,
1204
+ notes: data.notes ?? null,
1205
+ })
1206
+ .returning();
1207
+ if (!invoice) {
1208
+ return null;
1209
+ }
1210
+ await tx.insert(invoiceLineItems).values(lineItems.map((line) => ({
1211
+ invoiceId: invoice.id,
1212
+ bookingItemId: line.bookingItemId,
1213
+ description: line.description,
1214
+ quantity: line.quantity,
1215
+ unitPriceCents: line.unitPriceCents,
1216
+ totalCents: line.totalCents,
1217
+ taxRate: line.taxRate,
1218
+ sortOrder: line.sortOrder,
1219
+ })));
1220
+ return invoice;
1221
+ });
1222
+ },
1223
+ async getInvoiceById(db, id) {
1224
+ const [row] = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
1225
+ return row ?? null;
1226
+ },
1227
+ async updateInvoice(db, id, data) {
1228
+ const [row] = await db
1229
+ .update(invoices)
1230
+ .set({ ...data, updatedAt: new Date() })
1231
+ .where(eq(invoices.id, id))
1232
+ .returning();
1233
+ return row ?? null;
1234
+ },
1235
+ async deleteInvoice(db, id) {
1236
+ const [existing] = await db
1237
+ .select({ id: invoices.id, status: invoices.status })
1238
+ .from(invoices)
1239
+ .where(eq(invoices.id, id))
1240
+ .limit(1);
1241
+ if (!existing) {
1242
+ return { status: "not_found" };
1243
+ }
1244
+ if (existing.status !== "draft") {
1245
+ return { status: "not_draft" };
1246
+ }
1247
+ await db.delete(invoices).where(eq(invoices.id, id));
1248
+ return { status: "deleted" };
1249
+ },
1250
+ listInvoiceLineItems(db, invoiceId) {
1251
+ return db
1252
+ .select()
1253
+ .from(invoiceLineItems)
1254
+ .where(eq(invoiceLineItems.invoiceId, invoiceId))
1255
+ .orderBy(asc(invoiceLineItems.sortOrder));
1256
+ },
1257
+ async createInvoiceLineItem(db, invoiceId, data) {
1258
+ const [invoice] = await db
1259
+ .select({ id: invoices.id })
1260
+ .from(invoices)
1261
+ .where(eq(invoices.id, invoiceId))
1262
+ .limit(1);
1263
+ if (!invoice) {
1264
+ return null;
1265
+ }
1266
+ const [row] = await db
1267
+ .insert(invoiceLineItems)
1268
+ .values({ ...data, invoiceId })
1269
+ .returning();
1270
+ return row;
1271
+ },
1272
+ async updateInvoiceLineItem(db, lineId, data) {
1273
+ const [row] = await db
1274
+ .update(invoiceLineItems)
1275
+ .set(data)
1276
+ .where(eq(invoiceLineItems.id, lineId))
1277
+ .returning();
1278
+ return row ?? null;
1279
+ },
1280
+ async deleteInvoiceLineItem(db, lineId) {
1281
+ const [row] = await db
1282
+ .delete(invoiceLineItems)
1283
+ .where(eq(invoiceLineItems.id, lineId))
1284
+ .returning({ id: invoiceLineItems.id });
1285
+ return row ?? null;
1286
+ },
1287
+ listPayments(db, invoiceId) {
1288
+ return db
1289
+ .select()
1290
+ .from(payments)
1291
+ .where(eq(payments.invoiceId, invoiceId))
1292
+ .orderBy(desc(payments.paymentDate));
1293
+ },
1294
+ async createPayment(db, invoiceId, data) {
1295
+ const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
1296
+ if (!invoice) {
1297
+ return null;
1298
+ }
1299
+ return db.transaction(async (tx) => {
1300
+ const [payment] = await tx
1301
+ .insert(payments)
1302
+ .values({
1303
+ ...data,
1304
+ invoiceId,
1305
+ paymentInstrumentId: data.paymentInstrumentId ?? null,
1306
+ paymentAuthorizationId: data.paymentAuthorizationId ?? null,
1307
+ paymentCaptureId: data.paymentCaptureId ?? null,
1308
+ })
1309
+ .returning();
1310
+ const [sumResult] = await tx
1311
+ .select({ total: sql `coalesce(sum(amount_cents), 0)::int` })
1312
+ .from(payments)
1313
+ .where(and(eq(payments.invoiceId, invoiceId), eq(payments.status, "completed")));
1314
+ const paidCents = sumResult?.total ?? 0;
1315
+ const balanceDueCents = Math.max(0, invoice.totalCents - paidCents);
1316
+ let newStatus = invoice.status;
1317
+ if (paidCents >= invoice.totalCents) {
1318
+ newStatus = "paid";
1319
+ }
1320
+ else if (paidCents > 0) {
1321
+ newStatus = "partially_paid";
1322
+ }
1323
+ await tx
1324
+ .update(invoices)
1325
+ .set({ paidCents, balanceDueCents, status: newStatus, updatedAt: new Date() })
1326
+ .where(eq(invoices.id, invoiceId));
1327
+ return payment;
1328
+ });
1329
+ },
1330
+ listCreditNotes(db, invoiceId) {
1331
+ return db
1332
+ .select()
1333
+ .from(creditNotes)
1334
+ .where(eq(creditNotes.invoiceId, invoiceId))
1335
+ .orderBy(desc(creditNotes.createdAt));
1336
+ },
1337
+ async createCreditNote(db, invoiceId, data) {
1338
+ const [invoice] = await db
1339
+ .select({ id: invoices.id })
1340
+ .from(invoices)
1341
+ .where(eq(invoices.id, invoiceId))
1342
+ .limit(1);
1343
+ if (!invoice) {
1344
+ return null;
1345
+ }
1346
+ const [row] = await db
1347
+ .insert(creditNotes)
1348
+ .values({ ...data, invoiceId })
1349
+ .returning();
1350
+ return row;
1351
+ },
1352
+ async updateCreditNote(db, creditNoteId, data) {
1353
+ const [row] = await db
1354
+ .update(creditNotes)
1355
+ .set({ ...data, updatedAt: new Date() })
1356
+ .where(eq(creditNotes.id, creditNoteId))
1357
+ .returning();
1358
+ return row ?? null;
1359
+ },
1360
+ listCreditNoteLineItems(db, creditNoteId) {
1361
+ return db
1362
+ .select()
1363
+ .from(creditNoteLineItems)
1364
+ .where(eq(creditNoteLineItems.creditNoteId, creditNoteId))
1365
+ .orderBy(asc(creditNoteLineItems.sortOrder));
1366
+ },
1367
+ async createCreditNoteLineItem(db, creditNoteId, data) {
1368
+ const [creditNote] = await db
1369
+ .select({ id: creditNotes.id })
1370
+ .from(creditNotes)
1371
+ .where(eq(creditNotes.id, creditNoteId))
1372
+ .limit(1);
1373
+ if (!creditNote) {
1374
+ return null;
1375
+ }
1376
+ const [row] = await db
1377
+ .insert(creditNoteLineItems)
1378
+ .values({ ...data, creditNoteId })
1379
+ .returning();
1380
+ return row;
1381
+ },
1382
+ listNotes(db, invoiceId) {
1383
+ return db
1384
+ .select()
1385
+ .from(financeNotes)
1386
+ .where(eq(financeNotes.invoiceId, invoiceId))
1387
+ .orderBy(financeNotes.createdAt);
1388
+ },
1389
+ async createNote(db, invoiceId, userId, data) {
1390
+ const [invoice] = await db
1391
+ .select({ id: invoices.id })
1392
+ .from(invoices)
1393
+ .where(eq(invoices.id, invoiceId))
1394
+ .limit(1);
1395
+ if (!invoice) {
1396
+ return null;
1397
+ }
1398
+ const [row] = await db
1399
+ .insert(financeNotes)
1400
+ .values({
1401
+ invoiceId,
1402
+ authorId: userId,
1403
+ content: data.content,
1404
+ })
1405
+ .returning();
1406
+ return row;
1407
+ },
1408
+ // ============================================================================
1409
+ // Invoice number series
1410
+ // ============================================================================
1411
+ async listInvoiceNumberSeries(db, query) {
1412
+ const conditions = [];
1413
+ if (query.scope)
1414
+ conditions.push(eq(invoiceNumberSeries.scope, query.scope));
1415
+ if (typeof query.active === "boolean")
1416
+ conditions.push(eq(invoiceNumberSeries.active, query.active));
1417
+ const where = conditions.length ? and(...conditions) : undefined;
1418
+ return paginate(db
1419
+ .select()
1420
+ .from(invoiceNumberSeries)
1421
+ .where(where)
1422
+ .limit(query.limit)
1423
+ .offset(query.offset)
1424
+ .orderBy(desc(invoiceNumberSeries.updatedAt)), db.select({ total: sql `count(*)::int` }).from(invoiceNumberSeries).where(where), query.limit, query.offset);
1425
+ },
1426
+ async getInvoiceNumberSeriesById(db, id) {
1427
+ const [row] = await db
1428
+ .select()
1429
+ .from(invoiceNumberSeries)
1430
+ .where(eq(invoiceNumberSeries.id, id))
1431
+ .limit(1);
1432
+ return row ?? null;
1433
+ },
1434
+ async createInvoiceNumberSeries(db, data) {
1435
+ const [row] = await db
1436
+ .insert(invoiceNumberSeries)
1437
+ .values({
1438
+ code: data.code,
1439
+ name: data.name,
1440
+ prefix: data.prefix,
1441
+ separator: data.separator,
1442
+ padLength: data.padLength,
1443
+ currentSequence: data.currentSequence,
1444
+ resetStrategy: data.resetStrategy,
1445
+ resetAt: toTimestamp(data.resetAt),
1446
+ scope: data.scope,
1447
+ active: data.active,
1448
+ })
1449
+ .returning();
1450
+ return row ?? null;
1451
+ },
1452
+ async updateInvoiceNumberSeries(db, id, data) {
1453
+ const { resetAt, ...rest } = data;
1454
+ const [row] = await db
1455
+ .update(invoiceNumberSeries)
1456
+ .set({
1457
+ ...rest,
1458
+ ...(resetAt !== undefined ? { resetAt: toTimestamp(resetAt) } : {}),
1459
+ updatedAt: new Date(),
1460
+ })
1461
+ .where(eq(invoiceNumberSeries.id, id))
1462
+ .returning();
1463
+ return row ?? null;
1464
+ },
1465
+ async deleteInvoiceNumberSeries(db, id) {
1466
+ const [row] = await db
1467
+ .delete(invoiceNumberSeries)
1468
+ .where(eq(invoiceNumberSeries.id, id))
1469
+ .returning({ id: invoiceNumberSeries.id });
1470
+ return row ?? null;
1471
+ },
1472
+ /**
1473
+ * Transactionally allocate the next invoice number from a series. Uses a
1474
+ * `SELECT ... FOR UPDATE` row lock to ensure concurrent callers each receive
1475
+ * a distinct sequence. Honours the series' `resetStrategy` (annual/monthly)
1476
+ * by resetting `currentSequence` to 1 at period boundaries.
1477
+ */
1478
+ async allocateInvoiceNumber(db, seriesId) {
1479
+ return db.transaction(async (tx) => {
1480
+ const lockResult = await tx.execute(sql `SELECT id, prefix, separator, pad_length, current_sequence, reset_strategy, reset_at, active FROM invoice_number_series WHERE id = ${seriesId} FOR UPDATE`);
1481
+ const row = lockResult[0];
1482
+ if (!row)
1483
+ return { status: "not_found" };
1484
+ if (!row.active)
1485
+ return { status: "inactive" };
1486
+ const now = new Date();
1487
+ const boundary = currentPeriodBoundary(row.reset_strategy, now);
1488
+ const shouldReset = boundary !== null && (row.reset_at === null || row.reset_at < boundary);
1489
+ const nextSequence = shouldReset ? 1 : row.current_sequence + 1;
1490
+ const nextResetAt = boundary ?? row.reset_at;
1491
+ await tx
1492
+ .update(invoiceNumberSeries)
1493
+ .set({
1494
+ currentSequence: nextSequence,
1495
+ resetAt: nextResetAt,
1496
+ updatedAt: now,
1497
+ })
1498
+ .where(eq(invoiceNumberSeries.id, seriesId));
1499
+ const formattedNumber = formatNumber(row.prefix, row.separator, row.pad_length, nextSequence);
1500
+ return {
1501
+ status: "allocated",
1502
+ seriesId,
1503
+ sequence: nextSequence,
1504
+ formattedNumber,
1505
+ };
1506
+ });
1507
+ },
1508
+ // ============================================================================
1509
+ // Invoice templates
1510
+ // ============================================================================
1511
+ async listInvoiceTemplates(db, query) {
1512
+ const conditions = [];
1513
+ if (query.language)
1514
+ conditions.push(eq(invoiceTemplates.language, query.language));
1515
+ if (query.jurisdiction)
1516
+ conditions.push(eq(invoiceTemplates.jurisdiction, query.jurisdiction));
1517
+ if (typeof query.active === "boolean")
1518
+ conditions.push(eq(invoiceTemplates.active, query.active));
1519
+ if (query.search) {
1520
+ const term = `%${query.search}%`;
1521
+ conditions.push(or(ilike(invoiceTemplates.name, term), ilike(invoiceTemplates.slug, term)));
1522
+ }
1523
+ const where = conditions.length ? and(...conditions) : undefined;
1524
+ return paginate(db
1525
+ .select()
1526
+ .from(invoiceTemplates)
1527
+ .where(where)
1528
+ .limit(query.limit)
1529
+ .offset(query.offset)
1530
+ .orderBy(desc(invoiceTemplates.updatedAt)), db.select({ total: sql `count(*)::int` }).from(invoiceTemplates).where(where), query.limit, query.offset);
1531
+ },
1532
+ async getInvoiceTemplateById(db, id) {
1533
+ const [row] = await db
1534
+ .select()
1535
+ .from(invoiceTemplates)
1536
+ .where(eq(invoiceTemplates.id, id))
1537
+ .limit(1);
1538
+ return row ?? null;
1539
+ },
1540
+ async createInvoiceTemplate(db, data) {
1541
+ const [row] = await db
1542
+ .insert(invoiceTemplates)
1543
+ .values({
1544
+ name: data.name,
1545
+ slug: data.slug,
1546
+ language: data.language,
1547
+ jurisdiction: data.jurisdiction ?? null,
1548
+ bodyFormat: data.bodyFormat,
1549
+ body: data.body,
1550
+ cssStyles: data.cssStyles ?? null,
1551
+ isDefault: data.isDefault,
1552
+ active: data.active,
1553
+ metadata: data.metadata ?? null,
1554
+ })
1555
+ .returning();
1556
+ return row ?? null;
1557
+ },
1558
+ async updateInvoiceTemplate(db, id, data) {
1559
+ const [row] = await db
1560
+ .update(invoiceTemplates)
1561
+ .set({ ...data, updatedAt: new Date() })
1562
+ .where(eq(invoiceTemplates.id, id))
1563
+ .returning();
1564
+ return row ?? null;
1565
+ },
1566
+ async deleteInvoiceTemplate(db, id) {
1567
+ const [row] = await db
1568
+ .delete(invoiceTemplates)
1569
+ .where(eq(invoiceTemplates.id, id))
1570
+ .returning({ id: invoiceTemplates.id });
1571
+ return row ?? null;
1572
+ },
1573
+ // ============================================================================
1574
+ // Invoice renditions
1575
+ // ============================================================================
1576
+ async listInvoiceRenditions(db, invoiceId) {
1577
+ return db
1578
+ .select()
1579
+ .from(invoiceRenditions)
1580
+ .where(eq(invoiceRenditions.invoiceId, invoiceId))
1581
+ .orderBy(desc(invoiceRenditions.createdAt));
1582
+ },
1583
+ async getInvoiceRenditionById(db, id) {
1584
+ const [row] = await db
1585
+ .select()
1586
+ .from(invoiceRenditions)
1587
+ .where(eq(invoiceRenditions.id, id))
1588
+ .limit(1);
1589
+ return row ?? null;
1590
+ },
1591
+ async createInvoiceRendition(db, invoiceId, data) {
1592
+ const [invoice] = await db
1593
+ .select({ id: invoices.id })
1594
+ .from(invoices)
1595
+ .where(eq(invoices.id, invoiceId))
1596
+ .limit(1);
1597
+ if (!invoice)
1598
+ return null;
1599
+ const [row] = await db
1600
+ .insert(invoiceRenditions)
1601
+ .values({
1602
+ invoiceId,
1603
+ templateId: data.templateId ?? null,
1604
+ format: data.format,
1605
+ status: data.status,
1606
+ storageKey: data.storageKey ?? null,
1607
+ fileSize: data.fileSize ?? null,
1608
+ checksum: data.checksum ?? null,
1609
+ language: data.language ?? null,
1610
+ errorMessage: data.errorMessage ?? null,
1611
+ generatedAt: toTimestamp(data.generatedAt),
1612
+ metadata: data.metadata ?? null,
1613
+ })
1614
+ .returning();
1615
+ return row ?? null;
1616
+ },
1617
+ async updateInvoiceRendition(db, id, data) {
1618
+ const { generatedAt, ...rest } = data;
1619
+ const [row] = await db
1620
+ .update(invoiceRenditions)
1621
+ .set({
1622
+ ...rest,
1623
+ ...(generatedAt !== undefined ? { generatedAt: toTimestamp(generatedAt) } : {}),
1624
+ updatedAt: new Date(),
1625
+ })
1626
+ .where(eq(invoiceRenditions.id, id))
1627
+ .returning();
1628
+ return row ?? null;
1629
+ },
1630
+ async deleteInvoiceRendition(db, id) {
1631
+ const [row] = await db
1632
+ .delete(invoiceRenditions)
1633
+ .where(eq(invoiceRenditions.id, id))
1634
+ .returning({ id: invoiceRenditions.id });
1635
+ return row ?? null;
1636
+ },
1637
+ /**
1638
+ * Request an invoice rendition. Creates a `pending` rendition row pointing
1639
+ * to a template; the actual rendering (HTML→PDF) is expected to be
1640
+ * performed out-of-band by a background job that updates the rendition to
1641
+ * `ready` with `storageKey` set.
1642
+ */
1643
+ async renderInvoice(db, invoiceId, input) {
1644
+ const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
1645
+ if (!invoice)
1646
+ return { status: "not_found" };
1647
+ // Resolve template: explicit input > invoice.templateId > default template
1648
+ let templateId = input.templateId ?? invoice.templateId ?? null;
1649
+ if (!templateId) {
1650
+ const [defaultTemplate] = await db
1651
+ .select({ id: invoiceTemplates.id })
1652
+ .from(invoiceTemplates)
1653
+ .where(and(eq(invoiceTemplates.isDefault, true), eq(invoiceTemplates.active, true)))
1654
+ .limit(1);
1655
+ templateId = defaultTemplate?.id ?? null;
1656
+ }
1657
+ const [row] = await db
1658
+ .insert(invoiceRenditions)
1659
+ .values({
1660
+ invoiceId,
1661
+ templateId,
1662
+ format: input.format,
1663
+ status: "pending",
1664
+ language: input.language ?? invoice.language ?? null,
1665
+ })
1666
+ .returning();
1667
+ return { status: "requested", rendition: row ?? null };
1668
+ },
1669
+ // ============================================================================
1670
+ // Tax regimes
1671
+ // ============================================================================
1672
+ async listTaxRegimes(db, query) {
1673
+ const conditions = [];
1674
+ if (query.code)
1675
+ conditions.push(eq(taxRegimes.code, query.code));
1676
+ if (query.jurisdiction)
1677
+ conditions.push(eq(taxRegimes.jurisdiction, query.jurisdiction));
1678
+ if (typeof query.active === "boolean")
1679
+ conditions.push(eq(taxRegimes.active, query.active));
1680
+ const where = conditions.length ? and(...conditions) : undefined;
1681
+ return paginate(db
1682
+ .select()
1683
+ .from(taxRegimes)
1684
+ .where(where)
1685
+ .limit(query.limit)
1686
+ .offset(query.offset)
1687
+ .orderBy(desc(taxRegimes.updatedAt)), db.select({ total: sql `count(*)::int` }).from(taxRegimes).where(where), query.limit, query.offset);
1688
+ },
1689
+ async getTaxRegimeById(db, id) {
1690
+ const [row] = await db.select().from(taxRegimes).where(eq(taxRegimes.id, id)).limit(1);
1691
+ return row ?? null;
1692
+ },
1693
+ async createTaxRegime(db, data) {
1694
+ const [row] = await db
1695
+ .insert(taxRegimes)
1696
+ .values({
1697
+ code: data.code,
1698
+ name: data.name,
1699
+ jurisdiction: data.jurisdiction ?? null,
1700
+ ratePercent: data.ratePercent ?? null,
1701
+ description: data.description ?? null,
1702
+ legalReference: data.legalReference ?? null,
1703
+ active: data.active,
1704
+ metadata: data.metadata ?? null,
1705
+ })
1706
+ .returning();
1707
+ return row ?? null;
1708
+ },
1709
+ async updateTaxRegime(db, id, data) {
1710
+ const [row] = await db
1711
+ .update(taxRegimes)
1712
+ .set({ ...data, updatedAt: new Date() })
1713
+ .where(eq(taxRegimes.id, id))
1714
+ .returning();
1715
+ return row ?? null;
1716
+ },
1717
+ async deleteTaxRegime(db, id) {
1718
+ const [row] = await db
1719
+ .delete(taxRegimes)
1720
+ .where(eq(taxRegimes.id, id))
1721
+ .returning({ id: taxRegimes.id });
1722
+ return row ?? null;
1723
+ },
1724
+ // ============================================================================
1725
+ // Invoice external refs (e-invoicing provider ids)
1726
+ // ============================================================================
1727
+ async listInvoiceExternalRefs(db, invoiceId) {
1728
+ return db
1729
+ .select()
1730
+ .from(invoiceExternalRefs)
1731
+ .where(eq(invoiceExternalRefs.invoiceId, invoiceId))
1732
+ .orderBy(desc(invoiceExternalRefs.createdAt));
1733
+ },
1734
+ /**
1735
+ * Idempotent upsert on (invoiceId, provider). Used by e-invoicing plugins
1736
+ * (SmartBill, e-Factura, Stripe) to register the external reference
1737
+ * immediately after a successful provider call.
1738
+ */
1739
+ async registerInvoiceExternalRef(db, invoiceId, data) {
1740
+ const [invoice] = await db
1741
+ .select({ id: invoices.id })
1742
+ .from(invoices)
1743
+ .where(eq(invoices.id, invoiceId))
1744
+ .limit(1);
1745
+ if (!invoice)
1746
+ return null;
1747
+ const [existing] = await db
1748
+ .select()
1749
+ .from(invoiceExternalRefs)
1750
+ .where(and(eq(invoiceExternalRefs.invoiceId, invoiceId), eq(invoiceExternalRefs.provider, data.provider)))
1751
+ .limit(1);
1752
+ const values = {
1753
+ externalId: data.externalId ?? null,
1754
+ externalNumber: data.externalNumber ?? null,
1755
+ externalUrl: data.externalUrl ?? null,
1756
+ status: data.status ?? null,
1757
+ metadata: data.metadata ?? null,
1758
+ syncedAt: toTimestamp(data.syncedAt),
1759
+ syncError: data.syncError ?? null,
1760
+ };
1761
+ if (existing) {
1762
+ const [row] = await db
1763
+ .update(invoiceExternalRefs)
1764
+ .set({ ...values, updatedAt: new Date() })
1765
+ .where(eq(invoiceExternalRefs.id, existing.id))
1766
+ .returning();
1767
+ return row ?? null;
1768
+ }
1769
+ const [row] = await db
1770
+ .insert(invoiceExternalRefs)
1771
+ .values({
1772
+ invoiceId,
1773
+ provider: data.provider,
1774
+ ...values,
1775
+ })
1776
+ .returning();
1777
+ return row ?? null;
1778
+ },
1779
+ async deleteInvoiceExternalRef(db, id) {
1780
+ const [row] = await db
1781
+ .delete(invoiceExternalRefs)
1782
+ .where(eq(invoiceExternalRefs.id, id))
1783
+ .returning({ id: invoiceExternalRefs.id });
1784
+ return row ?? null;
1785
+ },
1786
+ };