@voyantjs/finance 0.3.0 → 0.3.1

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,366 @@
1
+ import { bookings } from "@voyantjs/bookings/schema";
2
+ import { and, asc, desc, eq, or, sql } from "drizzle-orm";
3
+ import { bookingGuarantees, bookingPaymentSchedules, invoiceRenditions, invoices, paymentInstruments, } from "./schema.js";
4
+ import { financeService } from "./service.js";
5
+ function normalizeDateTime(value) {
6
+ if (!value) {
7
+ return null;
8
+ }
9
+ return value instanceof Date ? value.toISOString() : value;
10
+ }
11
+ function isDefaultInstrument(metadata) {
12
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
13
+ return false;
14
+ }
15
+ const record = metadata;
16
+ return record.default === true || record.isDefault === true;
17
+ }
18
+ function getMetadataRecord(metadata) {
19
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
20
+ return null;
21
+ }
22
+ return metadata;
23
+ }
24
+ function getMetadataString(record, key) {
25
+ const value = record?.[key];
26
+ return typeof value === "string" && value.length > 0 ? value : null;
27
+ }
28
+ function getMetadataNumber(record, key) {
29
+ const value = record?.[key];
30
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
31
+ }
32
+ function getMetadataStringArray(record, key) {
33
+ const value = record?.[key];
34
+ if (!Array.isArray(value)) {
35
+ return [];
36
+ }
37
+ return value.filter((entry) => typeof entry === "string" && entry.length > 0);
38
+ }
39
+ function maybeUrl(value) {
40
+ if (!value) {
41
+ return null;
42
+ }
43
+ return /^https?:\/\//i.test(value) ? value : null;
44
+ }
45
+ function getMetadataDownloadUrl(record) {
46
+ const directKeys = [
47
+ "downloadUrl",
48
+ "download_url",
49
+ "signedUrl",
50
+ "signed_url",
51
+ "publicUrl",
52
+ "public_url",
53
+ "fileUrl",
54
+ "file_url",
55
+ "url",
56
+ ];
57
+ for (const key of directKeys) {
58
+ const value = getMetadataString(record, key);
59
+ const url = maybeUrl(value);
60
+ if (url) {
61
+ return url;
62
+ }
63
+ }
64
+ const artifact = record?.artifact;
65
+ if (artifact && typeof artifact === "object" && !Array.isArray(artifact)) {
66
+ const nested = artifact;
67
+ for (const key of ["downloadUrl", "download_url", "signedUrl", "publicUrl", "url"]) {
68
+ const value = nested[key];
69
+ if (typeof value === "string") {
70
+ const url = maybeUrl(value);
71
+ if (url) {
72
+ return url;
73
+ }
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ function toPublicPaymentSession(session) {
80
+ return {
81
+ id: session.id,
82
+ targetType: session.targetType,
83
+ targetId: session.targetId ?? null,
84
+ bookingId: session.bookingId ?? null,
85
+ invoiceId: session.invoiceId ?? null,
86
+ bookingPaymentScheduleId: session.bookingPaymentScheduleId ?? null,
87
+ bookingGuaranteeId: session.bookingGuaranteeId ?? null,
88
+ status: session.status,
89
+ provider: session.provider ?? null,
90
+ providerSessionId: session.providerSessionId ?? null,
91
+ providerPaymentId: session.providerPaymentId ?? null,
92
+ externalReference: session.externalReference ?? null,
93
+ clientReference: session.clientReference ?? null,
94
+ currency: session.currency,
95
+ amountCents: session.amountCents,
96
+ paymentMethod: session.paymentMethod ?? null,
97
+ payerEmail: session.payerEmail ?? null,
98
+ payerName: session.payerName ?? null,
99
+ redirectUrl: session.redirectUrl ?? null,
100
+ returnUrl: session.returnUrl ?? null,
101
+ cancelUrl: session.cancelUrl ?? null,
102
+ expiresAt: normalizeDateTime(session.expiresAt),
103
+ completedAt: normalizeDateTime(session.completedAt),
104
+ failureCode: session.failureCode ?? null,
105
+ failureMessage: session.failureMessage ?? null,
106
+ };
107
+ }
108
+ export const publicFinanceService = {
109
+ async getBookingDocuments(db, bookingId) {
110
+ const [booking] = await db
111
+ .select({ id: bookings.id })
112
+ .from(bookings)
113
+ .where(eq(bookings.id, bookingId))
114
+ .limit(1);
115
+ if (!booking) {
116
+ return null;
117
+ }
118
+ const invoiceRows = await db
119
+ .select()
120
+ .from(invoices)
121
+ .where(eq(invoices.bookingId, bookingId))
122
+ .orderBy(desc(invoices.createdAt));
123
+ if (invoiceRows.length === 0) {
124
+ return { bookingId, documents: [] };
125
+ }
126
+ const renditionRows = await db
127
+ .select()
128
+ .from(invoiceRenditions)
129
+ .where(or(...invoiceRows.map((invoice) => eq(invoiceRenditions.invoiceId, invoice.id))))
130
+ .orderBy(desc(invoiceRenditions.createdAt));
131
+ const renditionByInvoiceId = new Map();
132
+ for (const rendition of renditionRows) {
133
+ const existing = renditionByInvoiceId.get(rendition.invoiceId) ?? [];
134
+ existing.push(rendition);
135
+ renditionByInvoiceId.set(rendition.invoiceId, existing);
136
+ }
137
+ return {
138
+ bookingId,
139
+ documents: invoiceRows.map((invoice) => {
140
+ const renditions = renditionByInvoiceId.get(invoice.id) ?? [];
141
+ const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
142
+ const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
143
+ const downloadUrl = getMetadataDownloadUrl(metadata) ?? maybeUrl(selectedRendition?.storageKey ?? null);
144
+ return {
145
+ invoiceId: invoice.id,
146
+ invoiceNumber: invoice.invoiceNumber,
147
+ invoiceType: invoice.invoiceType,
148
+ invoiceStatus: invoice.status,
149
+ currency: invoice.currency,
150
+ totalCents: invoice.totalCents,
151
+ paidCents: invoice.paidCents,
152
+ balanceDueCents: invoice.balanceDueCents,
153
+ issueDate: invoice.issueDate,
154
+ dueDate: invoice.dueDate,
155
+ renditionId: selectedRendition?.id ?? null,
156
+ documentStatus: selectedRendition?.status ?? "missing",
157
+ format: selectedRendition?.format ?? null,
158
+ language: selectedRendition?.language ?? null,
159
+ generatedAt: normalizeDateTime(selectedRendition?.generatedAt),
160
+ fileSize: selectedRendition?.fileSize ?? null,
161
+ checksum: selectedRendition?.checksum ?? null,
162
+ downloadUrl,
163
+ };
164
+ }),
165
+ };
166
+ },
167
+ async getBookingPaymentOptions(db, bookingId, query) {
168
+ const [booking] = await db
169
+ .select({ id: bookings.id })
170
+ .from(bookings)
171
+ .where(eq(bookings.id, bookingId))
172
+ .limit(1);
173
+ if (!booking) {
174
+ return null;
175
+ }
176
+ const instrumentConditions = [eq(paymentInstruments.ownerType, "client")];
177
+ if (!query.includeInactive) {
178
+ instrumentConditions.push(eq(paymentInstruments.status, "active"));
179
+ }
180
+ if (query.personId) {
181
+ instrumentConditions.push(eq(paymentInstruments.personId, query.personId));
182
+ }
183
+ if (query.organizationId) {
184
+ instrumentConditions.push(eq(paymentInstruments.organizationId, query.organizationId));
185
+ }
186
+ if (query.provider) {
187
+ instrumentConditions.push(eq(paymentInstruments.provider, query.provider));
188
+ }
189
+ if (query.instrumentType) {
190
+ instrumentConditions.push(eq(paymentInstruments.instrumentType, query.instrumentType));
191
+ }
192
+ const [accounts, schedules, guarantees] = await Promise.all([
193
+ db
194
+ .select()
195
+ .from(paymentInstruments)
196
+ .where(and(...instrumentConditions))
197
+ .orderBy(desc(paymentInstruments.updatedAt))
198
+ .limit(50),
199
+ db
200
+ .select()
201
+ .from(bookingPaymentSchedules)
202
+ .where(and(eq(bookingPaymentSchedules.bookingId, bookingId), or(eq(bookingPaymentSchedules.status, "pending"), eq(bookingPaymentSchedules.status, "due"))))
203
+ .orderBy(asc(bookingPaymentSchedules.dueDate), asc(bookingPaymentSchedules.createdAt)),
204
+ db
205
+ .select()
206
+ .from(bookingGuarantees)
207
+ .where(and(eq(bookingGuarantees.bookingId, bookingId), or(eq(bookingGuarantees.status, "pending"), eq(bookingGuarantees.status, "failed"), eq(bookingGuarantees.status, "expired"))))
208
+ .orderBy(desc(bookingGuarantees.createdAt)),
209
+ ]);
210
+ const recommendedSchedule = schedules[0] ?? null;
211
+ const recommendedGuarantee = recommendedSchedule === null ? (guarantees[0] ?? null) : null;
212
+ return {
213
+ bookingId,
214
+ accounts: accounts.map((account) => ({
215
+ id: account.id,
216
+ label: account.label,
217
+ provider: account.provider ?? null,
218
+ instrumentType: account.instrumentType,
219
+ status: account.status,
220
+ brand: account.brand ?? null,
221
+ last4: account.last4 ?? null,
222
+ expiryMonth: account.expiryMonth ?? null,
223
+ expiryYear: account.expiryYear ?? null,
224
+ isDefault: isDefaultInstrument(account.metadata),
225
+ })),
226
+ schedules: schedules.map((schedule) => ({
227
+ id: schedule.id,
228
+ scheduleType: schedule.scheduleType,
229
+ status: schedule.status,
230
+ dueDate: schedule.dueDate,
231
+ currency: schedule.currency,
232
+ amountCents: schedule.amountCents,
233
+ notes: schedule.notes ?? null,
234
+ })),
235
+ guarantees: guarantees.map((guarantee) => ({
236
+ id: guarantee.id,
237
+ bookingPaymentScheduleId: guarantee.bookingPaymentScheduleId ?? null,
238
+ guaranteeType: guarantee.guaranteeType,
239
+ status: guarantee.status,
240
+ currency: guarantee.currency ?? null,
241
+ amountCents: guarantee.amountCents ?? null,
242
+ provider: guarantee.provider ?? null,
243
+ referenceNumber: guarantee.referenceNumber ?? null,
244
+ expiresAt: normalizeDateTime(guarantee.expiresAt),
245
+ notes: guarantee.notes ?? null,
246
+ })),
247
+ recommendedTarget: recommendedSchedule || recommendedGuarantee
248
+ ? {
249
+ targetType: recommendedSchedule
250
+ ? "booking_payment_schedule"
251
+ : "booking_guarantee",
252
+ targetId: recommendedSchedule?.id ?? recommendedGuarantee?.id ?? null,
253
+ }
254
+ : null,
255
+ };
256
+ },
257
+ async getPaymentSession(db, sessionId) {
258
+ const session = await financeService.getPaymentSessionById(db, sessionId);
259
+ return session ? toPublicPaymentSession(session) : null;
260
+ },
261
+ async startBookingSchedulePaymentSession(db, bookingId, scheduleId, input) {
262
+ const [schedule] = await db
263
+ .select({
264
+ id: bookingPaymentSchedules.id,
265
+ })
266
+ .from(bookingPaymentSchedules)
267
+ .where(and(eq(bookingPaymentSchedules.id, scheduleId), eq(bookingPaymentSchedules.bookingId, bookingId)))
268
+ .limit(1);
269
+ if (!schedule) {
270
+ return null;
271
+ }
272
+ const session = await financeService.createPaymentSessionFromBookingSchedule(db, scheduleId, input);
273
+ return session ? toPublicPaymentSession(session) : null;
274
+ },
275
+ async startBookingGuaranteePaymentSession(db, bookingId, guaranteeId, input) {
276
+ const [guarantee] = await db
277
+ .select({
278
+ id: bookingGuarantees.id,
279
+ })
280
+ .from(bookingGuarantees)
281
+ .where(and(eq(bookingGuarantees.id, guaranteeId), eq(bookingGuarantees.bookingId, bookingId)))
282
+ .limit(1);
283
+ if (!guarantee) {
284
+ return null;
285
+ }
286
+ const session = await financeService.createPaymentSessionFromBookingGuarantee(db, guaranteeId, input);
287
+ return session ? toPublicPaymentSession(session) : null;
288
+ },
289
+ async startInvoicePaymentSession(db, invoiceId, input) {
290
+ const session = await financeService.createPaymentSessionFromInvoice(db, invoiceId, input);
291
+ return session ? toPublicPaymentSession(session) : null;
292
+ },
293
+ async validateVoucher(db, input) {
294
+ const normalizedCode = input.code.trim().toLowerCase();
295
+ const voucherConditions = [
296
+ eq(paymentInstruments.instrumentType, "voucher"),
297
+ or(sql `lower(coalesce(${paymentInstruments.externalToken}, '')) = ${normalizedCode}`, sql `lower(coalesce(${paymentInstruments.directBillReference}, '')) = ${normalizedCode}`, sql `lower(coalesce(${paymentInstruments.metadata} ->> 'code', '')) = ${normalizedCode}`),
298
+ ];
299
+ if (input.provider) {
300
+ voucherConditions.push(eq(paymentInstruments.provider, input.provider));
301
+ }
302
+ const [voucher] = await db
303
+ .select()
304
+ .from(paymentInstruments)
305
+ .where(and(...voucherConditions))
306
+ .orderBy(desc(paymentInstruments.updatedAt))
307
+ .limit(1);
308
+ if (!voucher) {
309
+ return { valid: false, reason: "not_found", voucher: null };
310
+ }
311
+ const metadata = getMetadataRecord(voucher.metadata);
312
+ const voucherCode = getMetadataString(metadata, "code") ??
313
+ voucher.externalToken ??
314
+ voucher.directBillReference ??
315
+ input.code;
316
+ const currency = getMetadataString(metadata, "currency");
317
+ const amountCents = getMetadataNumber(metadata, "amountCents");
318
+ const remainingAmountCents = getMetadataNumber(metadata, "remainingAmountCents") ?? amountCents;
319
+ const validFrom = getMetadataString(metadata, "validFrom");
320
+ const expiresAt = getMetadataString(metadata, "expiresAt");
321
+ const bookingIds = getMetadataStringArray(metadata, "bookingIds");
322
+ const bookingId = getMetadataString(metadata, "bookingId");
323
+ const appliesToBookingIds = bookingId ? [bookingId, ...bookingIds] : bookingIds;
324
+ const publicVoucher = {
325
+ id: voucher.id,
326
+ code: voucherCode,
327
+ label: voucher.label,
328
+ provider: voucher.provider ?? null,
329
+ currency,
330
+ amountCents,
331
+ remainingAmountCents,
332
+ expiresAt,
333
+ };
334
+ if (voucher.status !== "active") {
335
+ return { valid: false, reason: "inactive", voucher: publicVoucher };
336
+ }
337
+ if (validFrom && new Date(validFrom) > new Date()) {
338
+ return { valid: false, reason: "not_started", voucher: publicVoucher };
339
+ }
340
+ if (expiresAt && new Date(expiresAt) < new Date()) {
341
+ return { valid: false, reason: "expired", voucher: publicVoucher };
342
+ }
343
+ if (input.bookingId &&
344
+ appliesToBookingIds.length > 0 &&
345
+ !appliesToBookingIds.includes(input.bookingId)) {
346
+ return { valid: false, reason: "booking_mismatch", voucher: publicVoucher };
347
+ }
348
+ if (input.currency && currency && input.currency !== currency) {
349
+ return {
350
+ valid: false,
351
+ reason: "currency_mismatch",
352
+ voucher: publicVoucher,
353
+ };
354
+ }
355
+ if (input.amountCents &&
356
+ remainingAmountCents !== null &&
357
+ input.amountCents > remainingAmountCents) {
358
+ return {
359
+ valid: false,
360
+ reason: "insufficient_balance",
361
+ voucher: publicVoucher,
362
+ };
363
+ }
364
+ return { valid: true, reason: null, voucher: publicVoucher };
365
+ },
366
+ };