@voyantjs/checkout 0.24.1 → 0.24.3

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,497 @@
1
+ import { bookingItems, bookings, bookingTravelers } from "@voyantjs/bookings";
2
+ import { bookingPaymentSchedules, financeService, invoiceLineItems, invoiceNumberSeries, invoices, } from "@voyantjs/finance";
3
+ import { notificationDeliveries, notificationReminderRules, notificationReminderRuns, notificationsService, } from "@voyantjs/notifications";
4
+ import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
5
+ const OUTSTANDING_SCHEDULE_STATUSES = ["pending", "due"];
6
+ const OUTSTANDING_INVOICE_STATUSES = [
7
+ "draft",
8
+ "sent",
9
+ "partially_paid",
10
+ "overdue",
11
+ ];
12
+ function normalizeRequiredDateTime(value) {
13
+ return value instanceof Date ? value.toISOString() : value;
14
+ }
15
+ function defaultPaymentPlan(options) {
16
+ return {
17
+ depositMode: options.defaultPaymentPlan?.depositMode ?? "percentage",
18
+ depositValue: options.defaultPaymentPlan?.depositValue ?? 30,
19
+ balanceDueDaysBeforeStart: options.defaultPaymentPlan?.balanceDueDaysBeforeStart ?? 30,
20
+ clearExistingPending: options.defaultPaymentPlan?.clearExistingPending ?? true,
21
+ createGuarantee: options.defaultPaymentPlan?.createGuarantee ?? false,
22
+ guaranteeType: options.defaultPaymentPlan?.guaranteeType ?? "deposit",
23
+ notes: options.defaultPaymentPlan?.notes ?? null,
24
+ };
25
+ }
26
+ export function resolvePaymentSessionTarget(method, stage, override, options) {
27
+ if (method === "bank_transfer")
28
+ return "invoice";
29
+ if (override)
30
+ return override;
31
+ if (stage === "reminder")
32
+ return options.defaultReminderCardCollectionTarget ?? "schedule";
33
+ return options.defaultCardCollectionTarget ?? "schedule";
34
+ }
35
+ function resolveDocumentType(method, target, options) {
36
+ if (method === "bank_transfer") {
37
+ return options.defaultBankTransferDocumentType ?? "proforma";
38
+ }
39
+ if (target === "invoice") {
40
+ return "invoice";
41
+ }
42
+ return null;
43
+ }
44
+ function fallbackInvoiceNumber(bookingNumber, documentType, amountCents) {
45
+ const stamp = Date.now().toString(36).toUpperCase();
46
+ const suffix = documentType === "proforma" ? "PF" : "INV";
47
+ return `${bookingNumber}-${suffix}-${amountCents}-${stamp}`;
48
+ }
49
+ function lineDescription(booking, schedule, stage) {
50
+ if (!schedule) {
51
+ return `Booking ${booking.bookingNumber}`;
52
+ }
53
+ const kind = schedule.scheduleType === "deposit" ? "deposit" : schedule.scheduleType;
54
+ if (stage === "reminder") {
55
+ return `Booking ${booking.bookingNumber} ${kind} reminder`;
56
+ }
57
+ return `Booking ${booking.bookingNumber} ${kind}`;
58
+ }
59
+ function normalizeExactAmountCents(amountCents) {
60
+ return typeof amountCents === "number" && Number.isFinite(amountCents) && amountCents > 0
61
+ ? Math.round(amountCents)
62
+ : null;
63
+ }
64
+ function resolveCheckoutIntent(input) {
65
+ if ("intent" in input && input.intent) {
66
+ return input.intent;
67
+ }
68
+ if (typeof input.amountCents === "number" &&
69
+ Number.isFinite(input.amountCents) &&
70
+ input.amountCents > 0) {
71
+ return "custom";
72
+ }
73
+ if (input.stage === "initial") {
74
+ return "deposit";
75
+ }
76
+ if (input.stage === "reminder") {
77
+ return "balance";
78
+ }
79
+ return "custom";
80
+ }
81
+ function resolveCheckoutSubject(input) {
82
+ if (input.bookingId && input.sessionId && input.bookingId !== input.sessionId) {
83
+ throw new Error("bookingId and sessionId must refer to the same booking session");
84
+ }
85
+ if (input.bookingId) {
86
+ return {
87
+ bookingId: input.bookingId,
88
+ sessionId: input.sessionId ?? input.bookingId,
89
+ sourceType: "booking",
90
+ };
91
+ }
92
+ if (input.sessionId) {
93
+ return {
94
+ bookingId: input.sessionId,
95
+ sessionId: input.sessionId,
96
+ sourceType: "session",
97
+ };
98
+ }
99
+ throw new Error("Provide a bookingId or sessionId");
100
+ }
101
+ function toInvoiceDueDateTime(value) {
102
+ return value ? `${value}T00:00:00.000Z` : null;
103
+ }
104
+ function buildBankTransferInstructions(invoice, details, callNotes) {
105
+ if (!details) {
106
+ return null;
107
+ }
108
+ return {
109
+ provider: details.provider ?? null,
110
+ invoiceId: invoice.id,
111
+ invoiceNumber: invoice.invoiceNumber,
112
+ documentType: invoice.invoiceType === "proforma" ? "proforma" : "invoice",
113
+ amountCents: invoice.balanceDueCents,
114
+ // Currency always tracks the invoice — the deploy-wide bank-transfer
115
+ // block can't predict what the customer is buying. EUR booking +
116
+ // RON-default env would have shown the wrong currency to the customer.
117
+ currency: invoice.currency,
118
+ dueDate: toInvoiceDueDateTime(invoice.dueDate),
119
+ beneficiary: details.beneficiary,
120
+ iban: details.iban,
121
+ bankName: details.bankName ?? null,
122
+ // Per-call notes win over deploy-wide boilerplate — the caller knows
123
+ // booking context (invoice number, due date, etc.) the env can't.
124
+ notes: callNotes ?? details.notes ?? null,
125
+ };
126
+ }
127
+ async function loadBookingContext(db, bookingId) {
128
+ const [booking] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
129
+ if (!booking)
130
+ return null;
131
+ const [items, participants, schedules, outstandingInvoices] = await Promise.all([
132
+ db
133
+ .select()
134
+ .from(bookingItems)
135
+ .where(eq(bookingItems.bookingId, bookingId))
136
+ .orderBy(bookingItems.createdAt),
137
+ db
138
+ .select()
139
+ .from(bookingTravelers)
140
+ .where(eq(bookingTravelers.bookingId, bookingId))
141
+ .orderBy(desc(bookingTravelers.isPrimary), bookingTravelers.createdAt),
142
+ db
143
+ .select()
144
+ .from(bookingPaymentSchedules)
145
+ .where(and(eq(bookingPaymentSchedules.bookingId, bookingId), inArray(bookingPaymentSchedules.status, OUTSTANDING_SCHEDULE_STATUSES)))
146
+ .orderBy(asc(bookingPaymentSchedules.dueDate), asc(bookingPaymentSchedules.createdAt)),
147
+ db
148
+ .select()
149
+ .from(invoices)
150
+ .where(and(eq(invoices.bookingId, bookingId), inArray(invoices.status, OUTSTANDING_INVOICE_STATUSES), gt(invoices.balanceDueCents, 0)))
151
+ .orderBy(desc(invoices.createdAt)),
152
+ ]);
153
+ return {
154
+ booking,
155
+ items,
156
+ participants,
157
+ schedules,
158
+ outstandingInvoices,
159
+ };
160
+ }
161
+ async function ensurePaymentPlanIfNeeded(db, bookingId, existingSchedules, input, options) {
162
+ if (existingSchedules.length > 0 || !input.ensureDefaultPaymentPlan) {
163
+ return existingSchedules;
164
+ }
165
+ const created = await financeService.applyDefaultBookingPaymentPlan(db, bookingId, {
166
+ ...defaultPaymentPlan(options),
167
+ ...(input.paymentPlan ?? {}),
168
+ });
169
+ return created ?? [];
170
+ }
171
+ async function allocateDocumentNumber(db, bookingNumber, documentType, amountCents) {
172
+ const [series] = await db
173
+ .select()
174
+ .from(invoiceNumberSeries)
175
+ .where(eq(invoiceNumberSeries.scope, documentType))
176
+ .orderBy(desc(invoiceNumberSeries.active), asc(invoiceNumberSeries.createdAt))
177
+ .limit(1);
178
+ if (!series) {
179
+ return fallbackInvoiceNumber(bookingNumber, documentType, amountCents);
180
+ }
181
+ const allocated = await financeService.allocateInvoiceNumber(db, series.id);
182
+ if (allocated.status === "allocated") {
183
+ return allocated.formattedNumber;
184
+ }
185
+ return fallbackInvoiceNumber(bookingNumber, documentType, amountCents);
186
+ }
187
+ function pickSchedule(schedules, scheduleId) {
188
+ if (scheduleId) {
189
+ return schedules.find((schedule) => schedule.id === scheduleId) ?? null;
190
+ }
191
+ return schedules[0] ?? null;
192
+ }
193
+ function pickInvoice(outstandingInvoices, invoiceId) {
194
+ if (invoiceId) {
195
+ return outstandingInvoices.find((invoice) => invoice.id === invoiceId) ?? null;
196
+ }
197
+ return outstandingInvoices[0] ?? null;
198
+ }
199
+ export async function previewCheckoutCollection(db, bookingId, input, options = {}) {
200
+ const context = await loadBookingContext(db, bookingId);
201
+ if (!context)
202
+ return null;
203
+ const schedules = await ensurePaymentPlanIfNeeded(db, bookingId, context.schedules, input, options);
204
+ let paymentSessionTarget = resolvePaymentSessionTarget(input.method, input.stage, input.paymentSessionTarget, options);
205
+ let documentType = resolveDocumentType(input.method, paymentSessionTarget, options);
206
+ let selectedSchedule = pickSchedule(schedules, input.scheduleId);
207
+ let selectedInvoice = pickInvoice(context.outstandingInvoices, input.invoiceId);
208
+ const requestedAmountCents = normalizeExactAmountCents(input.amountCents);
209
+ let amountCents = 0;
210
+ if (requestedAmountCents !== null) {
211
+ amountCents = requestedAmountCents;
212
+ if (paymentSessionTarget === "schedule" &&
213
+ selectedSchedule &&
214
+ selectedSchedule.amountCents === requestedAmountCents) {
215
+ selectedInvoice = null;
216
+ }
217
+ else {
218
+ paymentSessionTarget = "invoice";
219
+ documentType = resolveDocumentType(input.method, paymentSessionTarget, options);
220
+ selectedInvoice =
221
+ selectedInvoice && selectedInvoice.balanceDueCents === requestedAmountCents
222
+ ? selectedInvoice
223
+ : null;
224
+ selectedSchedule = null;
225
+ }
226
+ }
227
+ else if (paymentSessionTarget === "invoice") {
228
+ amountCents =
229
+ selectedInvoice?.balanceDueCents ??
230
+ selectedSchedule?.amountCents ??
231
+ context.booking.sellAmountCents ??
232
+ 0;
233
+ }
234
+ else if (paymentSessionTarget === "schedule") {
235
+ amountCents = selectedSchedule?.amountCents ?? context.booking.sellAmountCents ?? 0;
236
+ }
237
+ let recommendedAction = "none";
238
+ if (input.method === "bank_transfer") {
239
+ recommendedAction = "create_bank_transfer_document";
240
+ }
241
+ else if (paymentSessionTarget === "invoice") {
242
+ recommendedAction = selectedInvoice
243
+ ? "create_payment_session"
244
+ : "create_invoice_then_payment_session";
245
+ }
246
+ else if (paymentSessionTarget === "schedule") {
247
+ recommendedAction = "create_payment_session";
248
+ }
249
+ return {
250
+ bookingId,
251
+ method: input.method,
252
+ stage: input.stage,
253
+ paymentSessionTarget,
254
+ documentType,
255
+ willCreateDefaultPaymentPlan: context.schedules.length === 0 && schedules.length > 0,
256
+ selectedSchedule,
257
+ selectedInvoice,
258
+ amountCents,
259
+ currency: context.booking.sellCurrency,
260
+ recommendedAction,
261
+ };
262
+ }
263
+ async function createCollectionInvoice(db, context, plan, notes) {
264
+ const amountCents = plan.amountCents;
265
+ const issueDate = new Date().toISOString().slice(0, 10);
266
+ const dueDate = plan.selectedSchedule?.dueDate ?? issueDate;
267
+ const documentType = plan.documentType ?? "invoice";
268
+ const invoiceNumber = await allocateDocumentNumber(db, context.booking.bookingNumber, documentType, amountCents);
269
+ const [invoice] = await db
270
+ .insert(invoices)
271
+ .values({
272
+ invoiceNumber,
273
+ bookingId: context.booking.id,
274
+ personId: context.booking.personId,
275
+ organizationId: context.booking.organizationId,
276
+ invoiceType: documentType,
277
+ status: "sent",
278
+ currency: context.booking.sellCurrency,
279
+ baseCurrency: context.booking.baseCurrency,
280
+ fxRateSetId: null,
281
+ subtotalCents: amountCents,
282
+ baseSubtotalCents: context.booking.baseSellAmountCents,
283
+ taxCents: 0,
284
+ baseTaxCents: null,
285
+ totalCents: amountCents,
286
+ baseTotalCents: context.booking.baseSellAmountCents,
287
+ paidCents: 0,
288
+ basePaidCents: 0,
289
+ balanceDueCents: amountCents,
290
+ baseBalanceDueCents: context.booking.baseSellAmountCents,
291
+ commissionAmountCents: null,
292
+ issueDate,
293
+ dueDate,
294
+ notes: notes ?? plan.selectedSchedule?.notes ?? null,
295
+ })
296
+ .returning();
297
+ if (!invoice) {
298
+ throw new Error("Failed to create collection invoice");
299
+ }
300
+ await db.insert(invoiceLineItems).values({
301
+ invoiceId: invoice.id,
302
+ bookingItemId: plan.selectedSchedule?.bookingItemId ?? null,
303
+ description: lineDescription(context.booking, plan.selectedSchedule, plan.stage),
304
+ quantity: 1,
305
+ unitPriceCents: amountCents,
306
+ totalCents: amountCents,
307
+ taxRate: null,
308
+ sortOrder: 0,
309
+ });
310
+ return invoice;
311
+ }
312
+ export async function initiateCheckoutCollection(db, bookingId, input, options = {}, dispatcher, runtime = {}) {
313
+ const context = await loadBookingContext(db, bookingId);
314
+ if (!context)
315
+ return null;
316
+ const plan = await previewCheckoutCollection(db, bookingId, input, options);
317
+ if (!plan)
318
+ return null;
319
+ if (plan.amountCents <= 0) {
320
+ throw new Error("No outstanding amount available for collection");
321
+ }
322
+ let invoice = plan.selectedInvoice;
323
+ let paymentSession = null;
324
+ let invoiceNotification = null;
325
+ let paymentSessionNotification = null;
326
+ let bankTransferInstructions = null;
327
+ let providerStart = null;
328
+ if (input.method === "bank_transfer") {
329
+ invoice = await createCollectionInvoice(db, context, plan, input.notes ?? null);
330
+ bankTransferInstructions = buildBankTransferInstructions(invoice, runtime.bankTransferDetails ?? null, input.notes ?? null);
331
+ if (dispatcher && input.invoiceNotification) {
332
+ invoiceNotification = await notificationsService.sendInvoiceNotification(db, dispatcher, invoice.id, input.invoiceNotification);
333
+ }
334
+ }
335
+ else if (plan.paymentSessionTarget === "invoice") {
336
+ if (!invoice) {
337
+ invoice = await createCollectionInvoice(db, context, { ...plan, documentType: "invoice" }, input.notes ?? null);
338
+ }
339
+ paymentSession = await financeService.createPaymentSessionFromInvoice(db, invoice.id, {
340
+ ...(input.paymentSession ?? {}),
341
+ notes: input.notes ?? input.paymentSession?.notes ?? null,
342
+ });
343
+ if (!paymentSession) {
344
+ throw new Error("Failed to create payment session from invoice");
345
+ }
346
+ if (dispatcher && input.invoiceNotification) {
347
+ invoiceNotification = await notificationsService.sendInvoiceNotification(db, dispatcher, invoice.id, input.invoiceNotification);
348
+ }
349
+ if (dispatcher && input.paymentSessionNotification) {
350
+ paymentSessionNotification = await notificationsService.sendPaymentSessionNotification(db, dispatcher, paymentSession.id, input.paymentSessionNotification);
351
+ }
352
+ }
353
+ else {
354
+ if (!plan.selectedSchedule) {
355
+ throw new Error("No outstanding payment schedule available for collection");
356
+ }
357
+ paymentSession = await financeService.createPaymentSessionFromBookingSchedule(db, plan.selectedSchedule.id, {
358
+ ...(input.paymentSession ?? {}),
359
+ notes: input.notes ?? input.paymentSession?.notes ?? null,
360
+ });
361
+ if (!paymentSession) {
362
+ throw new Error("Failed to create payment session from booking schedule");
363
+ }
364
+ if (dispatcher && input.paymentSessionNotification) {
365
+ paymentSessionNotification = await notificationsService.sendPaymentSessionNotification(db, dispatcher, paymentSession.id, input.paymentSessionNotification);
366
+ }
367
+ }
368
+ if (input.startProvider) {
369
+ if (input.method !== "card") {
370
+ throw new Error("Provider start is only available for card collections");
371
+ }
372
+ if (!paymentSession) {
373
+ throw new Error("No payment session available for provider start");
374
+ }
375
+ const starter = runtime.paymentStarters?.[input.startProvider.provider];
376
+ if (!starter) {
377
+ throw new Error(`Payment provider "${input.startProvider.provider}" is not configured`);
378
+ }
379
+ providerStart = await starter({
380
+ db,
381
+ bookingId,
382
+ plan,
383
+ invoice: invoice ?? null,
384
+ paymentSession,
385
+ input,
386
+ startProvider: input.startProvider,
387
+ bindings: runtime.bindings ?? {},
388
+ });
389
+ if (providerStart.paymentSessionId !== paymentSession.id) {
390
+ const updatedSession = await financeService.getPaymentSessionById(db, providerStart.paymentSessionId);
391
+ paymentSession = updatedSession ?? paymentSession;
392
+ }
393
+ else {
394
+ const updatedSession = await financeService.getPaymentSessionById(db, paymentSession.id);
395
+ paymentSession = updatedSession ?? paymentSession;
396
+ }
397
+ }
398
+ return {
399
+ plan,
400
+ invoice: invoice ?? null,
401
+ paymentSession,
402
+ invoiceNotification,
403
+ paymentSessionNotification,
404
+ bankTransferInstructions,
405
+ providerStart,
406
+ };
407
+ }
408
+ export async function bootstrapCheckoutCollection(db, input, options = {}, dispatcher, runtime = {}) {
409
+ const subject = resolveCheckoutSubject(input);
410
+ const initiated = await initiateCheckoutCollection(db, subject.bookingId, {
411
+ method: input.method,
412
+ stage: input.stage,
413
+ scheduleId: input.scheduleId,
414
+ invoiceId: input.invoiceId,
415
+ amountCents: input.amountCents,
416
+ ensureDefaultPaymentPlan: input.ensureDefaultPaymentPlan,
417
+ paymentSessionTarget: input.paymentSessionTarget,
418
+ paymentPlan: input.paymentPlan,
419
+ paymentSession: input.paymentSession,
420
+ paymentSessionNotification: input.paymentSessionNotification,
421
+ invoiceNotification: input.invoiceNotification,
422
+ startProvider: input.startProvider,
423
+ notes: input.notes,
424
+ }, options, dispatcher, runtime);
425
+ if (!initiated) {
426
+ return null;
427
+ }
428
+ return {
429
+ bookingId: subject.bookingId,
430
+ sessionId: subject.sessionId,
431
+ sourceType: subject.sourceType,
432
+ intent: resolveCheckoutIntent(input),
433
+ ...initiated,
434
+ };
435
+ }
436
+ export async function listBookingReminderRuns(db, bookingId, query) {
437
+ const where = and(eq(notificationReminderRuns.bookingId, bookingId), ...(query.status ? [eq(notificationReminderRuns.status, query.status)] : []));
438
+ const [rows, countResult] = await Promise.all([
439
+ db
440
+ .select({
441
+ id: notificationReminderRuns.id,
442
+ reminderRuleId: notificationReminderRuns.reminderRuleId,
443
+ targetType: notificationReminderRuns.targetType,
444
+ targetId: notificationReminderRuns.targetId,
445
+ bookingId: notificationReminderRuns.bookingId,
446
+ paymentSessionId: notificationReminderRuns.paymentSessionId,
447
+ notificationDeliveryId: notificationReminderRuns.notificationDeliveryId,
448
+ status: notificationReminderRuns.status,
449
+ recipient: notificationReminderRuns.recipient,
450
+ scheduledFor: notificationReminderRuns.scheduledFor,
451
+ processedAt: notificationReminderRuns.processedAt,
452
+ errorMessage: notificationReminderRuns.errorMessage,
453
+ createdAt: notificationReminderRuns.createdAt,
454
+ reminderRuleSlug: notificationReminderRules.slug,
455
+ reminderRuleName: notificationReminderRules.name,
456
+ relativeDaysFromDueDate: notificationReminderRules.relativeDaysFromDueDate,
457
+ channel: notificationReminderRules.channel,
458
+ ruleProvider: notificationReminderRules.provider,
459
+ deliveryStatus: notificationDeliveries.status,
460
+ deliveryProvider: notificationDeliveries.provider,
461
+ })
462
+ .from(notificationReminderRuns)
463
+ .leftJoin(notificationReminderRules, eq(notificationReminderRules.id, notificationReminderRuns.reminderRuleId))
464
+ .leftJoin(notificationDeliveries, eq(notificationDeliveries.id, notificationReminderRuns.notificationDeliveryId))
465
+ .where(where)
466
+ .orderBy(desc(notificationReminderRuns.createdAt))
467
+ .limit(query.limit)
468
+ .offset(query.offset),
469
+ db.select({ count: sql `count(*)::int` }).from(notificationReminderRuns).where(where),
470
+ ]);
471
+ return {
472
+ data: rows.map((row) => ({
473
+ id: row.id,
474
+ reminderRuleId: row.reminderRuleId,
475
+ reminderRuleSlug: row.reminderRuleSlug ?? null,
476
+ reminderRuleName: row.reminderRuleName ?? null,
477
+ targetType: row.targetType,
478
+ targetId: row.targetId,
479
+ bookingId: row.bookingId ?? null,
480
+ paymentSessionId: row.paymentSessionId ?? null,
481
+ notificationDeliveryId: row.notificationDeliveryId ?? null,
482
+ status: row.status,
483
+ deliveryStatus: row.deliveryStatus ?? null,
484
+ channel: row.channel ?? null,
485
+ provider: row.deliveryProvider ?? row.ruleProvider ?? null,
486
+ recipient: row.recipient ?? null,
487
+ scheduledFor: normalizeRequiredDateTime(row.scheduledFor),
488
+ processedAt: normalizeRequiredDateTime(row.processedAt),
489
+ errorMessage: row.errorMessage ?? null,
490
+ relativeDaysFromDueDate: row.relativeDaysFromDueDate ?? null,
491
+ createdAt: normalizeRequiredDateTime(row.createdAt),
492
+ })),
493
+ total: countResult[0]?.count ?? 0,
494
+ limit: query.limit,
495
+ offset: query.offset,
496
+ };
497
+ }