@voyantjs/finance 0.6.9 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index.d.ts +9 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -1
  4. package/dist/routes-bookings-quick-create.d.ts +3 -0
  5. package/dist/routes-bookings-quick-create.d.ts.map +1 -0
  6. package/dist/routes-bookings-quick-create.js +103 -0
  7. package/dist/routes-public.d.ts +22 -22
  8. package/dist/routes.d.ts +279 -18
  9. package/dist/routes.d.ts.map +1 -1
  10. package/dist/routes.js +57 -1
  11. package/dist/schema.d.ts +451 -0
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +79 -0
  14. package/dist/service-aggregates.d.ts +47 -0
  15. package/dist/service-aggregates.d.ts.map +1 -0
  16. package/dist/service-aggregates.js +106 -0
  17. package/dist/service-bookings-dual-create.d.ts +185 -0
  18. package/dist/service-bookings-dual-create.d.ts.map +1 -0
  19. package/dist/service-bookings-dual-create.js +131 -0
  20. package/dist/service-bookings-quick-create.d.ts +168 -0
  21. package/dist/service-bookings-quick-create.d.ts.map +1 -0
  22. package/dist/service-bookings-quick-create.js +312 -0
  23. package/dist/service-public.d.ts +11 -11
  24. package/dist/service-public.d.ts.map +1 -1
  25. package/dist/service-public.js +79 -36
  26. package/dist/service-vouchers-migration.d.ts +44 -0
  27. package/dist/service-vouchers-migration.d.ts.map +1 -0
  28. package/dist/service-vouchers-migration.js +147 -0
  29. package/dist/service-vouchers.d.ts +157 -0
  30. package/dist/service-vouchers.d.ts.map +1 -0
  31. package/dist/service-vouchers.js +191 -0
  32. package/dist/service.d.ts +180 -17
  33. package/dist/service.d.ts.map +1 -1
  34. package/dist/service.js +4 -0
  35. package/dist/validation-public.d.ts +2 -2
  36. package/dist/validation-public.d.ts.map +1 -1
  37. package/dist/validation-public.js +4 -1
  38. package/dist/validation-shared.d.ts +17 -0
  39. package/dist/validation-shared.d.ts.map +1 -1
  40. package/dist/validation-shared.js +12 -0
  41. package/dist/validation-vouchers.d.ts +62 -0
  42. package/dist/validation-vouchers.d.ts.map +1 -0
  43. package/dist/validation-vouchers.js +46 -0
  44. package/dist/validation.d.ts +1 -0
  45. package/dist/validation.d.ts.map +1 -1
  46. package/dist/validation.js +1 -0
  47. package/package.json +9 -8
@@ -0,0 +1,312 @@
1
+ import { bookingGroupsService, bookingsService } from "@voyantjs/bookings";
2
+ import { bookingTravelers } from "@voyantjs/bookings/schema";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { bookingPaymentSchedules, vouchers } from "./schema.js";
6
+ import { VoucherServiceError, vouchersService } from "./service-vouchers.js";
7
+ import { paymentScheduleStatusSchema, paymentScheduleTypeSchema } from "./validation-shared.js";
8
+ // ---------- validation ----------
9
+ const travelerInputSchema = z.object({
10
+ firstName: z.string().min(1).max(255),
11
+ lastName: z.string().min(1).max(255),
12
+ email: z.string().email().optional().nullable(),
13
+ phone: z.string().max(50).optional().nullable(),
14
+ personId: z.string().optional().nullable(),
15
+ participantType: z.enum(["traveler", "occupant", "other"]).default("traveler"),
16
+ travelerCategory: z.enum(["adult", "child", "infant", "senior", "other"]).optional().nullable(),
17
+ preferredLanguage: z.string().max(35).optional().nullable(),
18
+ accessibilityNeeds: z.string().optional().nullable(),
19
+ specialRequests: z.string().optional().nullable(),
20
+ /**
21
+ * option_unit_id the passenger is assigned to. Accepted by the input
22
+ * schema so the UI's PassengerListValue can round-trip, but not yet
23
+ * persisted — bookingTravelers has no room-assignment column and the
24
+ * allocation flow is owned by the items slice. Follow-up: add a traveler
25
+ * metadata JSONB or wire into booking_allocations.
26
+ */
27
+ roomUnitId: z.string().optional().nullable(),
28
+ isPrimary: z.boolean().optional().nullable(),
29
+ notes: z.string().optional().nullable(),
30
+ });
31
+ const paymentScheduleInputSchema = z.object({
32
+ scheduleType: paymentScheduleTypeSchema.default("balance"),
33
+ status: paymentScheduleStatusSchema.default("pending"),
34
+ dueDate: z.string().min(1),
35
+ currency: z.string().min(3).max(3),
36
+ amountCents: z.number().int().min(0),
37
+ notes: z.string().optional().nullable(),
38
+ });
39
+ const voucherRedemptionInputSchema = z.object({
40
+ voucherId: z.string().min(1),
41
+ amountCents: z.number().int().min(1),
42
+ });
43
+ const groupJoinSchema = z.object({
44
+ action: z.literal("join"),
45
+ groupId: z.string().min(1),
46
+ role: z.enum(["primary", "shared"]).default("shared"),
47
+ });
48
+ const groupCreateSchema = z.object({
49
+ action: z.literal("create"),
50
+ kind: z.enum(["shared_room", "other"]).default("shared_room"),
51
+ label: z.string().max(255).optional().nullable(),
52
+ optionUnitId: z.string().optional().nullable(),
53
+ /**
54
+ * When true (the default), the freshly-created booking becomes the group's
55
+ * primary booking. Operators creating a dual-booking can set this false and
56
+ * supply a different primaryBookingId — not wired in this slice, but the
57
+ * field is reserved.
58
+ */
59
+ makeBookingPrimary: z.boolean().default(true),
60
+ });
61
+ const groupMembershipInputSchema = z.discriminatedUnion("action", [
62
+ groupJoinSchema,
63
+ groupCreateSchema,
64
+ ]);
65
+ export const quickCreateBookingSchema = z.object({
66
+ // Convert-product fields (mirrors convertProductSchema in bookings)
67
+ productId: z.string().min(1),
68
+ optionId: z.string().optional().nullable(),
69
+ slotId: z.string().optional().nullable(),
70
+ bookingNumber: z.string().min(1),
71
+ personId: z.string().optional().nullable(),
72
+ organizationId: z.string().optional().nullable(),
73
+ internalNotes: z.string().optional().nullable(),
74
+ // Orchestration fields
75
+ travelers: z.array(travelerInputSchema).optional(),
76
+ paymentSchedules: z.array(paymentScheduleInputSchema).optional(),
77
+ voucherRedemption: voucherRedemptionInputSchema.optional(),
78
+ groupMembership: groupMembershipInputSchema.optional(),
79
+ });
80
+ // ---------- service ----------
81
+ /**
82
+ * Atomic booking-create orchestrator. Runs product conversion + travelers +
83
+ * payment schedules + voucher redemption + group membership inside a single
84
+ * transaction so partial failures (e.g. voucher insufficient-balance after
85
+ * schedules have been written) roll the whole thing back.
86
+ *
87
+ * Event emission is post-commit — if the tx rolls back, subscribers never
88
+ * hear about it.
89
+ *
90
+ * Why the orchestrator lives in `@voyantjs/finance`: finance already imports
91
+ * from `@voyantjs/bookings` (invoices-from-bookings, voucher service, payment
92
+ * schedules all sit here), so this is the one place that can compose the
93
+ * three packages without creating a new workspace dep cycle. The route wires
94
+ * it under `/v1/admin/bookings/quick-create` via a HonoExtension whose
95
+ * `module` targets `"bookings"`.
96
+ */
97
+ /**
98
+ * Sentinel thrown inside the tx to force drizzle to roll back. Returning a
99
+ * non-ok result from the tx callback doesn't abort the tx — only a thrown
100
+ * error does — so the orchestrator uses this to unwind cleanly when a
101
+ * downstream step discovers a precondition failure.
102
+ */
103
+ class QuickCreateAbort extends Error {
104
+ outcome;
105
+ constructor(outcome) {
106
+ super(`quick-create aborted: ${outcome.status}`);
107
+ this.outcome = outcome;
108
+ this.name = "QuickCreateAbort";
109
+ }
110
+ }
111
+ export async function quickCreateBooking(db, rawInput, options = {}) {
112
+ const { userId, runtime } = options;
113
+ // Parse through the schema so defaults (makeBookingPrimary, role,
114
+ // participantType, etc.) are applied even when callers bypass validation —
115
+ // unit tests and hand-written integrations commonly do.
116
+ const input = quickCreateBookingSchema.parse(rawInput);
117
+ // Validate voucher up-front so we can short-circuit before the tx starts.
118
+ // This is a cheap read — the authoritative balance check still happens
119
+ // inside the redeem savepoint so two concurrent redemptions can't double-
120
+ // spend.
121
+ if (input.voucherRedemption) {
122
+ const [voucher] = await db
123
+ .select()
124
+ .from(vouchers)
125
+ .where(eq(vouchers.id, input.voucherRedemption.voucherId))
126
+ .limit(1);
127
+ if (!voucher)
128
+ return { status: "voucher_not_found" };
129
+ if (voucher.status !== "active")
130
+ return { status: "voucher_inactive" };
131
+ if (voucher.validFrom && voucher.validFrom.getTime() > Date.now()) {
132
+ return { status: "voucher_not_started" };
133
+ }
134
+ if (voucher.expiresAt && voucher.expiresAt.getTime() < Date.now()) {
135
+ return { status: "voucher_expired" };
136
+ }
137
+ if (input.voucherRedemption.amountCents > voucher.remainingAmountCents) {
138
+ return { status: "voucher_insufficient_balance" };
139
+ }
140
+ }
141
+ let result;
142
+ try {
143
+ result = await db.transaction(async (tx) => {
144
+ // 1. Booking from product
145
+ const booking = await bookingsService.createBookingFromProduct(tx, {
146
+ productId: input.productId,
147
+ optionId: input.optionId ?? null,
148
+ slotId: input.slotId ?? null,
149
+ bookingNumber: input.bookingNumber,
150
+ personId: input.personId ?? null,
151
+ organizationId: input.organizationId ?? null,
152
+ internalNotes: input.internalNotes ?? null,
153
+ });
154
+ if (!booking) {
155
+ // Caller gave us a product that doesn't resolve. Throw so drizzle
156
+ // rolls back any writes the convert helper may have made.
157
+ throw new QuickCreateAbort({ status: "product_not_found" });
158
+ }
159
+ // 2. Travelers. roomUnitId is accepted on the input but not persisted
160
+ // yet — see travelerInputSchema for the follow-up note.
161
+ const travelers = [];
162
+ for (const traveler of input.travelers ?? []) {
163
+ const [row] = await tx
164
+ .insert(bookingTravelers)
165
+ .values({
166
+ bookingId: booking.id,
167
+ personId: traveler.personId ?? null,
168
+ participantType: traveler.participantType,
169
+ travelerCategory: traveler.travelerCategory ?? null,
170
+ firstName: traveler.firstName,
171
+ lastName: traveler.lastName,
172
+ email: traveler.email ?? null,
173
+ phone: traveler.phone ?? null,
174
+ preferredLanguage: traveler.preferredLanguage ?? null,
175
+ accessibilityNeeds: traveler.accessibilityNeeds ?? null,
176
+ specialRequests: traveler.specialRequests ?? null,
177
+ isPrimary: traveler.isPrimary ?? false,
178
+ notes: traveler.notes ?? null,
179
+ })
180
+ .returning();
181
+ if (row)
182
+ travelers.push(row);
183
+ }
184
+ // 3. Payment schedules
185
+ const paymentSchedules = [];
186
+ for (const schedule of input.paymentSchedules ?? []) {
187
+ const [row] = await tx
188
+ .insert(bookingPaymentSchedules)
189
+ .values({
190
+ bookingId: booking.id,
191
+ scheduleType: schedule.scheduleType,
192
+ status: schedule.status,
193
+ dueDate: schedule.dueDate,
194
+ currency: schedule.currency,
195
+ amountCents: schedule.amountCents,
196
+ notes: schedule.notes ?? null,
197
+ })
198
+ .returning();
199
+ if (row)
200
+ paymentSchedules.push(row);
201
+ }
202
+ // 4. Voucher redemption. Delegates to vouchersService so the balance
203
+ // decrement + redemption-log insert share the savepoint. If anything
204
+ // goes wrong (race with a concurrent redemption, mostly), the thrown
205
+ // VoucherServiceError surfaces as the outcome below.
206
+ let voucherRedemption = null;
207
+ if (input.voucherRedemption) {
208
+ const { voucher, redemption } = await vouchersService.redeem(tx, input.voucherRedemption.voucherId, {
209
+ bookingId: booking.id,
210
+ amountCents: input.voucherRedemption.amountCents,
211
+ }, userId);
212
+ if (redemption) {
213
+ voucherRedemption = { voucher, redemption };
214
+ }
215
+ }
216
+ // 5. Group membership (partaj). Either attach to an existing group or
217
+ // spin up a new one with this booking as the primary.
218
+ let groupMembership = null;
219
+ if (input.groupMembership) {
220
+ if (input.groupMembership.action === "create") {
221
+ const group = await bookingGroupsService.createBookingGroup(tx, {
222
+ kind: input.groupMembership.kind,
223
+ label: input.groupMembership.label ?? `Shared — ${booking.bookingNumber}`,
224
+ productId: input.productId,
225
+ optionUnitId: input.groupMembership.optionUnitId ?? null,
226
+ primaryBookingId: input.groupMembership.makeBookingPrimary ? booking.id : null,
227
+ });
228
+ const memberResult = await bookingGroupsService.addGroupMember(tx, group.id, {
229
+ bookingId: booking.id,
230
+ role: input.groupMembership.makeBookingPrimary ? "primary" : "shared",
231
+ });
232
+ if (memberResult.status !== "ok") {
233
+ // Shouldn't happen — we just created both rows — but throw so
234
+ // the tx rolls back instead of leaving a half-created group.
235
+ throw new QuickCreateAbort({ status: "group_not_found" });
236
+ }
237
+ groupMembership = { groupId: group.id, member: memberResult.member };
238
+ }
239
+ else {
240
+ const memberResult = await bookingGroupsService.addGroupMember(tx, input.groupMembership.groupId, {
241
+ bookingId: booking.id,
242
+ role: input.groupMembership.role,
243
+ });
244
+ if (memberResult.status === "group_not_found") {
245
+ throw new QuickCreateAbort({ status: "group_not_found" });
246
+ }
247
+ if (memberResult.status === "booking_not_found") {
248
+ // Same booking we just inserted. Pg transaction visibility should
249
+ // prevent this; surface as group_not_found for the caller — we
250
+ // can't tell them the booking we created doesn't exist.
251
+ throw new QuickCreateAbort({ status: "group_not_found" });
252
+ }
253
+ if (memberResult.status === "already_in_group") {
254
+ throw new QuickCreateAbort({
255
+ status: "booking_already_in_group",
256
+ currentGroupId: memberResult.currentGroupId,
257
+ });
258
+ }
259
+ groupMembership = {
260
+ groupId: input.groupMembership.groupId,
261
+ member: memberResult.member,
262
+ };
263
+ }
264
+ }
265
+ return {
266
+ booking,
267
+ travelers,
268
+ paymentSchedules,
269
+ voucherRedemption,
270
+ groupMembership,
271
+ };
272
+ });
273
+ }
274
+ catch (error) {
275
+ if (error instanceof QuickCreateAbort) {
276
+ return error.outcome;
277
+ }
278
+ if (error instanceof VoucherServiceError) {
279
+ if (error.code === "voucher_not_found")
280
+ return { status: "voucher_not_found" };
281
+ if (error.code === "voucher_inactive")
282
+ return { status: "voucher_inactive" };
283
+ if (error.code === "voucher_not_started")
284
+ return { status: "voucher_not_started" };
285
+ if (error.code === "voucher_expired")
286
+ return { status: "voucher_expired" };
287
+ if (error.code === "insufficient_balance")
288
+ return { status: "voucher_insufficient_balance" };
289
+ }
290
+ throw error;
291
+ }
292
+ // Post-commit event emission. Fire-and-forget (the eventBus contract
293
+ // handles subscriber errors); callers that need strict delivery can
294
+ // re-emit from their own subscriber chain.
295
+ if (runtime?.eventBus) {
296
+ const event = {
297
+ bookingId: result.booking.id,
298
+ bookingNumber: result.booking.bookingNumber,
299
+ productId: input.productId,
300
+ travelerCount: result.travelers.length,
301
+ paymentScheduleCount: result.paymentSchedules.length,
302
+ voucherRedeemedCents: result.voucherRedemption
303
+ ? result.voucherRedemption.redemption.amountCents
304
+ : null,
305
+ groupId: result.groupMembership?.groupId ?? null,
306
+ createdByUserId: userId ?? null,
307
+ occurredAt: new Date(),
308
+ };
309
+ await runtime.eventBus.emit("booking.quick-created", event);
310
+ }
311
+ return { status: "ok", result };
312
+ }
@@ -156,16 +156,12 @@ export declare const publicFinanceService: {
156
156
  failureMessage: string | null;
157
157
  } | null>;
158
158
  validateVoucher(db: PostgresJsDatabase, input: PublicValidateVoucherInput): Promise<{
159
- valid: false;
160
- reason: "not_found";
161
- voucher: null;
162
- } | {
163
159
  valid: false;
164
160
  reason: "inactive";
165
161
  voucher: {
166
162
  id: string;
167
163
  code: string;
168
- label: string;
164
+ label: string | null;
169
165
  provider: string | null;
170
166
  currency: string | null;
171
167
  amountCents: number | null;
@@ -178,7 +174,7 @@ export declare const publicFinanceService: {
178
174
  voucher: {
179
175
  id: string;
180
176
  code: string;
181
- label: string;
177
+ label: string | null;
182
178
  provider: string | null;
183
179
  currency: string | null;
184
180
  amountCents: number | null;
@@ -191,7 +187,7 @@ export declare const publicFinanceService: {
191
187
  voucher: {
192
188
  id: string;
193
189
  code: string;
194
- label: string;
190
+ label: string | null;
195
191
  provider: string | null;
196
192
  currency: string | null;
197
193
  amountCents: number | null;
@@ -204,7 +200,7 @@ export declare const publicFinanceService: {
204
200
  voucher: {
205
201
  id: string;
206
202
  code: string;
207
- label: string;
203
+ label: string | null;
208
204
  provider: string | null;
209
205
  currency: string | null;
210
206
  amountCents: number | null;
@@ -217,7 +213,7 @@ export declare const publicFinanceService: {
217
213
  voucher: {
218
214
  id: string;
219
215
  code: string;
220
- label: string;
216
+ label: string | null;
221
217
  provider: string | null;
222
218
  currency: string | null;
223
219
  amountCents: number | null;
@@ -230,7 +226,7 @@ export declare const publicFinanceService: {
230
226
  voucher: {
231
227
  id: string;
232
228
  code: string;
233
- label: string;
229
+ label: string | null;
234
230
  provider: string | null;
235
231
  currency: string | null;
236
232
  amountCents: number | null;
@@ -243,13 +239,17 @@ export declare const publicFinanceService: {
243
239
  voucher: {
244
240
  id: string;
245
241
  code: string;
246
- label: string;
242
+ label: string | null;
247
243
  provider: string | null;
248
244
  currency: string | null;
249
245
  amountCents: number | null;
250
246
  remainingAmountCents: number | null;
251
247
  expiresAt: string | null;
252
248
  };
249
+ } | {
250
+ valid: false;
251
+ reason: "not_found";
252
+ voucher: null;
253
253
  }>;
254
254
  };
255
255
  //# sourceMappingURL=service-public.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service-public.d.ts","sourceRoot":"","sources":["../src/service-public.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAWjE,OAAO,KAAK,EACV,6BAA6B,EAC7B,4BAA4B,EAE5B,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,0BAA0B,EAC3B,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,2BAA2B;IAC1C,0BAA0B,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI,CAAA;CAC5F;AA8HD,eAAO,MAAM,oBAAoB;4BAEzB,kBAAkB,aACX,MAAM,YACR,2BAA2B,GACnC,OAAO,CAAC,6BAA6B,GAAG,IAAI,CAAC;+BA6C1C,kBAAkB,aACX,MAAM,YACR,2BAA2B,GACnC,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC;iCA0CxC,kBAAkB,aACX,MAAM,SACV,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAoH5B,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,4BAA4B,GAAG,IAAI,CAAC;0BA2DnB,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAM3D,kBAAkB,aACX,MAAM,cACL,MAAM,SACX,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA4BjC,kBAAkB,aACX,MAAM,eACJ,MAAM,SACZ,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAuBjC,kBAAkB,aACX,MAAM,SACV,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAMb,kBAAkB,SAAS,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8FhF,CAAA"}
1
+ {"version":3,"file":"service-public.d.ts","sourceRoot":"","sources":["../src/service-public.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAYjE,OAAO,KAAK,EACV,6BAA6B,EAC7B,4BAA4B,EAE5B,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,0BAA0B,EAC3B,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,2BAA2B;IAC1C,0BAA0B,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI,CAAA;CAC5F;AA8HD,eAAO,MAAM,oBAAoB;4BAEzB,kBAAkB,aACX,MAAM,YACR,2BAA2B,GACnC,OAAO,CAAC,6BAA6B,GAAG,IAAI,CAAC;+BA6C1C,kBAAkB,aACX,MAAM,YACR,2BAA2B,GACnC,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC;iCA0CxC,kBAAkB,aACX,MAAM,SACV,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAoH5B,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,4BAA4B,GAAG,IAAI,CAAC;0BA2DnB,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAM3D,kBAAkB,aACX,MAAM,cACL,MAAM,SACX,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA4BjC,kBAAkB,aACX,MAAM,eACJ,MAAM,SACZ,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAuBjC,kBAAkB,aACX,MAAM,SACV,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAMb,kBAAkB,SAAS,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEhF,CAAA"}
@@ -1,6 +1,6 @@
1
1
  import { bookings } from "@voyantjs/bookings/schema";
2
2
  import { and, asc, desc, eq, or, sql } from "drizzle-orm";
3
- import { bookingGuarantees, bookingPaymentSchedules, invoiceRenditions, invoices, paymentInstruments, payments, } from "./schema.js";
3
+ import { bookingGuarantees, bookingPaymentSchedules, invoiceRenditions, invoices, paymentInstruments, payments, vouchers, } from "./schema.js";
4
4
  import { financeService } from "./service.js";
5
5
  function normalizeDateTime(value) {
6
6
  if (!value) {
@@ -350,10 +350,20 @@ export const publicFinanceService = {
350
350
  return session ? toPublicPaymentSession(session) : null;
351
351
  },
352
352
  async validateVoucher(db, input) {
353
- const normalizedCode = input.code.trim().toLowerCase();
353
+ const normalizedCode = input.code.trim();
354
+ const normalizedCodeLower = normalizedCode.toLowerCase();
355
+ // New path: first-class `vouchers` table. Covers any voucher issued via
356
+ // POST /v1/finance/vouchers.
357
+ const resolvedFromNewTable = await resolveVoucherFromNewTable(db, normalizedCode);
358
+ if (resolvedFromNewTable) {
359
+ return evaluateVoucherValidity(resolvedFromNewTable, input);
360
+ }
361
+ // Fallback path: legacy payment_instruments rows with instrumentType =
362
+ // 'voucher' and balance carried in metadata JSONB. Kept working until the
363
+ // migration script flips remaining rows over to the new table.
354
364
  const voucherConditions = [
355
365
  eq(paymentInstruments.instrumentType, "voucher"),
356
- or(sql `lower(coalesce(${paymentInstruments.externalToken}, '')) = ${normalizedCode}`, sql `lower(coalesce(${paymentInstruments.directBillReference}, '')) = ${normalizedCode}`, sql `lower(coalesce(${paymentInstruments.metadata} ->> 'code', '')) = ${normalizedCode}`),
366
+ or(sql `lower(coalesce(${paymentInstruments.externalToken}, '')) = ${normalizedCodeLower}`, sql `lower(coalesce(${paymentInstruments.directBillReference}, '')) = ${normalizedCodeLower}`, sql `lower(coalesce(${paymentInstruments.metadata} ->> 'code', '')) = ${normalizedCodeLower}`),
357
367
  ];
358
368
  if (input.provider) {
359
369
  voucherConditions.push(eq(paymentInstruments.provider, input.provider));
@@ -380,7 +390,7 @@ export const publicFinanceService = {
380
390
  const bookingIds = getMetadataStringArray(metadata, "bookingIds");
381
391
  const bookingId = getMetadataString(metadata, "bookingId");
382
392
  const appliesToBookingIds = bookingId ? [bookingId, ...bookingIds] : bookingIds;
383
- const publicVoucher = {
393
+ return evaluateVoucherValidity({
384
394
  id: voucher.id,
385
395
  code: voucherCode,
386
396
  label: voucher.label,
@@ -388,38 +398,71 @@ export const publicFinanceService = {
388
398
  currency,
389
399
  amountCents,
390
400
  remainingAmountCents,
401
+ validFrom,
391
402
  expiresAt,
392
- };
393
- if (voucher.status !== "active") {
394
- return { valid: false, reason: "inactive", voucher: publicVoucher };
395
- }
396
- if (validFrom && new Date(validFrom) > new Date()) {
397
- return { valid: false, reason: "not_started", voucher: publicVoucher };
398
- }
399
- if (expiresAt && new Date(expiresAt) < new Date()) {
400
- return { valid: false, reason: "expired", voucher: publicVoucher };
401
- }
402
- if (input.bookingId &&
403
- appliesToBookingIds.length > 0 &&
404
- !appliesToBookingIds.includes(input.bookingId)) {
405
- return { valid: false, reason: "booking_mismatch", voucher: publicVoucher };
406
- }
407
- if (input.currency && currency && input.currency !== currency) {
408
- return {
409
- valid: false,
410
- reason: "currency_mismatch",
411
- voucher: publicVoucher,
412
- };
413
- }
414
- if (input.amountCents &&
415
- remainingAmountCents !== null &&
416
- input.amountCents > remainingAmountCents) {
417
- return {
418
- valid: false,
419
- reason: "insufficient_balance",
420
- voucher: publicVoucher,
421
- };
422
- }
423
- return { valid: true, reason: null, voucher: publicVoucher };
403
+ appliesToBookingIds,
404
+ status: voucher.status === "active" ? "active" : "inactive",
405
+ }, input);
424
406
  },
425
407
  };
408
+ async function resolveVoucherFromNewTable(db, code) {
409
+ const [row] = await db
410
+ .select()
411
+ .from(vouchers)
412
+ .where(sql `lower(${vouchers.code}) = ${code.toLowerCase()}`)
413
+ .limit(1);
414
+ if (!row)
415
+ return null;
416
+ return {
417
+ id: row.id,
418
+ code: row.code,
419
+ label: null,
420
+ provider: null,
421
+ currency: row.currency,
422
+ amountCents: row.initialAmountCents,
423
+ remainingAmountCents: row.remainingAmountCents,
424
+ validFrom: row.validFrom ? row.validFrom.toISOString() : null,
425
+ expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
426
+ appliesToBookingIds: row.sourceBookingId ? [row.sourceBookingId] : [],
427
+ status: row.status === "active" ? "active" : "inactive",
428
+ };
429
+ }
430
+ function evaluateVoucherValidity(voucher, input) {
431
+ const publicVoucher = {
432
+ id: voucher.id,
433
+ code: voucher.code,
434
+ label: voucher.label,
435
+ provider: voucher.provider,
436
+ currency: voucher.currency,
437
+ amountCents: voucher.amountCents,
438
+ remainingAmountCents: voucher.remainingAmountCents,
439
+ expiresAt: voucher.expiresAt,
440
+ };
441
+ if (voucher.status !== "active") {
442
+ return { valid: false, reason: "inactive", voucher: publicVoucher };
443
+ }
444
+ if (voucher.validFrom && new Date(voucher.validFrom) > new Date()) {
445
+ return { valid: false, reason: "not_started", voucher: publicVoucher };
446
+ }
447
+ if (voucher.expiresAt && new Date(voucher.expiresAt) < new Date()) {
448
+ return { valid: false, reason: "expired", voucher: publicVoucher };
449
+ }
450
+ if (input.bookingId &&
451
+ voucher.appliesToBookingIds.length > 0 &&
452
+ !voucher.appliesToBookingIds.includes(input.bookingId)) {
453
+ return { valid: false, reason: "booking_mismatch", voucher: publicVoucher };
454
+ }
455
+ if (input.currency && voucher.currency && input.currency !== voucher.currency) {
456
+ return { valid: false, reason: "currency_mismatch", voucher: publicVoucher };
457
+ }
458
+ if (input.amountCents &&
459
+ voucher.remainingAmountCents !== null &&
460
+ input.amountCents > voucher.remainingAmountCents) {
461
+ return {
462
+ valid: false,
463
+ reason: "insufficient_balance",
464
+ voucher: publicVoucher,
465
+ };
466
+ }
467
+ return { valid: true, reason: null, voucher: publicVoucher };
468
+ }
@@ -0,0 +1,44 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ export interface VoucherMigrationOptions {
3
+ /**
4
+ * When true, report what would happen without writing. Defaults to false.
5
+ */
6
+ dryRun?: boolean;
7
+ /**
8
+ * Per-row hook for progress reporting. Not called on skipped rows.
9
+ */
10
+ onRowMigrated?: (info: {
11
+ paymentInstrumentId: string;
12
+ voucherCode: string;
13
+ }) => void;
14
+ }
15
+ export interface VoucherMigrationSkip {
16
+ paymentInstrumentId: string;
17
+ reason: "already_migrated" | "missing_code" | "missing_currency" | "missing_amount" | "duplicate_code_collision";
18
+ }
19
+ export interface VoucherMigrationResult {
20
+ candidates: number;
21
+ migrated: number;
22
+ skipped: VoucherMigrationSkip[];
23
+ dryRun: boolean;
24
+ }
25
+ /**
26
+ * Backfill the `vouchers` table from legacy voucher rows in
27
+ * `payment_instruments`. A legacy voucher is a row with `instrumentType =
28
+ * 'voucher'` whose code lives in one of `metadata.code`, `external_token`, or
29
+ * `direct_bill_reference`, and whose balance lives in
30
+ * `metadata.remainingAmountCents` (falling back to `metadata.amountCents` when
31
+ * no redemption has touched the row).
32
+ *
33
+ * The migration is idempotent: rows whose code already exists in the new
34
+ * `vouchers` table are skipped so re-running the script after a partial run
35
+ * (or after issuing new vouchers via the first-class API) is safe.
36
+ *
37
+ * Why skip rather than update: the new table treats `code` as a primary lookup
38
+ * key and the legacy path has already been read-only-fallback since #256
39
+ * landed, so any voucher that exists in both tables is by definition already
40
+ * migrated. Picking one source of truth avoids clobbering balances the
41
+ * operator may have adjusted through the new redemption flow.
42
+ */
43
+ export declare function migrateVouchersFromPaymentInstruments(db: PostgresJsDatabase, options?: VoucherMigrationOptions): Promise<VoucherMigrationResult>;
44
+ //# sourceMappingURL=service-vouchers-migration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-vouchers-migration.d.ts","sourceRoot":"","sources":["../src/service-vouchers-migration.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAsCjE,MAAM,WAAW,uBAAuB;IACtC;;OAEG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,mBAAmB,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CACrF;AAED,MAAM,WAAW,oBAAoB;IACnC,mBAAmB,EAAE,MAAM,CAAA;IAC3B,MAAM,EACF,kBAAkB,GAClB,cAAc,GACd,kBAAkB,GAClB,gBAAgB,GAChB,0BAA0B,CAAA;CAC/B;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,oBAAoB,EAAE,CAAA;IAC/B,MAAM,EAAE,OAAO,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,qCAAqC,CACzD,EAAE,EAAE,kBAAkB,EACtB,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,sBAAsB,CAAC,CA4GjC"}