@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.
- package/LICENSE +109 -0
- package/README.md +47 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/routes.d.ts +3603 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +632 -0
- package/dist/schema.d.ts +5722 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +816 -0
- package/dist/service.d.ts +5659 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1786 -0
- package/dist/validation.d.ts +1316 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +657 -0
- package/package.json +52 -0
package/dist/service.js
ADDED
|
@@ -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
|
+
};
|