@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.
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/routes-bookings-quick-create.d.ts +3 -0
- package/dist/routes-bookings-quick-create.d.ts.map +1 -0
- package/dist/routes-bookings-quick-create.js +103 -0
- package/dist/routes-public.d.ts +22 -22
- package/dist/routes.d.ts +279 -18
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +57 -1
- package/dist/schema.d.ts +451 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +79 -0
- package/dist/service-aggregates.d.ts +47 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +106 -0
- package/dist/service-bookings-dual-create.d.ts +185 -0
- package/dist/service-bookings-dual-create.d.ts.map +1 -0
- package/dist/service-bookings-dual-create.js +131 -0
- package/dist/service-bookings-quick-create.d.ts +168 -0
- package/dist/service-bookings-quick-create.d.ts.map +1 -0
- package/dist/service-bookings-quick-create.js +312 -0
- package/dist/service-public.d.ts +11 -11
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +79 -36
- package/dist/service-vouchers-migration.d.ts +44 -0
- package/dist/service-vouchers-migration.d.ts.map +1 -0
- package/dist/service-vouchers-migration.js +147 -0
- package/dist/service-vouchers.d.ts +157 -0
- package/dist/service-vouchers.d.ts.map +1 -0
- package/dist/service-vouchers.js +191 -0
- package/dist/service.d.ts +180 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -0
- package/dist/validation-public.d.ts +2 -2
- package/dist/validation-public.d.ts.map +1 -1
- package/dist/validation-public.js +4 -1
- package/dist/validation-shared.d.ts +17 -0
- package/dist/validation-shared.d.ts.map +1 -1
- package/dist/validation-shared.js +12 -0
- package/dist/validation-vouchers.d.ts +62 -0
- package/dist/validation-vouchers.d.ts.map +1 -0
- package/dist/validation-vouchers.js +46 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +1 -0
- 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
|
+
}
|
package/dist/service-public.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/service-public.js
CHANGED
|
@@ -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()
|
|
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}, '')) = ${
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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"}
|