@voyantjs/finance 0.6.9 → 0.7.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,106 @@
|
|
|
1
|
+
import { and, inArray, ne, sql } from "drizzle-orm";
|
|
2
|
+
import { invoices } from "./schema.js";
|
|
3
|
+
const ALL_INVOICE_STATUSES = [
|
|
4
|
+
"draft",
|
|
5
|
+
"sent",
|
|
6
|
+
"partially_paid",
|
|
7
|
+
"paid",
|
|
8
|
+
"overdue",
|
|
9
|
+
"void",
|
|
10
|
+
];
|
|
11
|
+
/** Statuses where balance_due_cents > 0 is meaningful money we're owed. */
|
|
12
|
+
const OUTSTANDING_STATUSES = ["sent", "partially_paid", "overdue"];
|
|
13
|
+
export async function getFinanceAggregates(db, options = {}) {
|
|
14
|
+
const fromDate = options.from ? new Date(options.from) : undefined;
|
|
15
|
+
const toDate = options.to ? new Date(options.to) : undefined;
|
|
16
|
+
const rangeConditions = [];
|
|
17
|
+
if (fromDate)
|
|
18
|
+
rangeConditions.push(sql `${invoices.createdAt} >= ${fromDate}`);
|
|
19
|
+
if (toDate)
|
|
20
|
+
rangeConditions.push(sql `${invoices.createdAt} < ${toDate}`);
|
|
21
|
+
const rangeWhere = rangeConditions.length ? and(...rangeConditions) : undefined;
|
|
22
|
+
const [totalRow] = await db
|
|
23
|
+
.select({ count: sql `count(*)::int` })
|
|
24
|
+
.from(invoices)
|
|
25
|
+
.where(rangeWhere);
|
|
26
|
+
const statusRows = await db
|
|
27
|
+
.select({
|
|
28
|
+
status: invoices.status,
|
|
29
|
+
count: sql `count(*)::int`,
|
|
30
|
+
})
|
|
31
|
+
.from(invoices)
|
|
32
|
+
.where(rangeWhere)
|
|
33
|
+
.groupBy(invoices.status);
|
|
34
|
+
const countsByStatusMap = new Map(statusRows.map((row) => [row.status, row.count]));
|
|
35
|
+
const monthlyInvoiceCountsRows = await db
|
|
36
|
+
.select({
|
|
37
|
+
yearMonth: sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`,
|
|
38
|
+
count: sql `count(*)::int`,
|
|
39
|
+
})
|
|
40
|
+
.from(invoices)
|
|
41
|
+
.where(rangeWhere)
|
|
42
|
+
.groupBy(sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`)
|
|
43
|
+
.orderBy(sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`);
|
|
44
|
+
const monthlyRevenueRows = await db
|
|
45
|
+
.select({
|
|
46
|
+
yearMonth: sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`,
|
|
47
|
+
currency: invoices.currency,
|
|
48
|
+
totalCents: sql `coalesce(sum(${invoices.totalCents}), 0)::bigint`,
|
|
49
|
+
})
|
|
50
|
+
.from(invoices)
|
|
51
|
+
.where(and(...(rangeConditions.length ? rangeConditions : []), ne(invoices.status, "void")))
|
|
52
|
+
.groupBy(sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`, invoices.currency)
|
|
53
|
+
.orderBy(sql `to_char(${invoices.createdAt} at time zone 'UTC', 'YYYY-MM')`, invoices.currency);
|
|
54
|
+
// Outstanding + overdue always look at the whole book (not the date range),
|
|
55
|
+
// since "what are we owed right now" is a point-in-time question — bounding
|
|
56
|
+
// it by `from..to` would hide old unpaid invoices.
|
|
57
|
+
const outstandingRows = await db
|
|
58
|
+
.select({
|
|
59
|
+
currency: invoices.currency,
|
|
60
|
+
balanceDueCents: sql `coalesce(sum(${invoices.balanceDueCents}), 0)::bigint`,
|
|
61
|
+
count: sql `count(*)::int`,
|
|
62
|
+
})
|
|
63
|
+
.from(invoices)
|
|
64
|
+
.where(and(inArray(invoices.status, [...OUTSTANDING_STATUSES]), sql `${invoices.balanceDueCents} > 0`))
|
|
65
|
+
.groupBy(invoices.currency)
|
|
66
|
+
.orderBy(invoices.currency);
|
|
67
|
+
const todayUtc = new Date();
|
|
68
|
+
todayUtc.setUTCHours(0, 0, 0, 0);
|
|
69
|
+
const todayDateString = todayUtc.toISOString().slice(0, 10);
|
|
70
|
+
const overdueRows = await db
|
|
71
|
+
.select({
|
|
72
|
+
currency: invoices.currency,
|
|
73
|
+
balanceDueCents: sql `coalesce(sum(${invoices.balanceDueCents}), 0)::bigint`,
|
|
74
|
+
count: sql `count(*)::int`,
|
|
75
|
+
})
|
|
76
|
+
.from(invoices)
|
|
77
|
+
.where(and(inArray(invoices.status, [...OUTSTANDING_STATUSES]), sql `${invoices.balanceDueCents} > 0`, sql `${invoices.dueDate} < ${todayDateString}`))
|
|
78
|
+
.groupBy(invoices.currency)
|
|
79
|
+
.orderBy(invoices.currency);
|
|
80
|
+
return {
|
|
81
|
+
total: totalRow?.count ?? 0,
|
|
82
|
+
countsByStatus: ALL_INVOICE_STATUSES.map((status) => ({
|
|
83
|
+
status,
|
|
84
|
+
count: countsByStatusMap.get(status) ?? 0,
|
|
85
|
+
})),
|
|
86
|
+
monthlyRevenue: monthlyRevenueRows.map((row) => ({
|
|
87
|
+
yearMonth: row.yearMonth,
|
|
88
|
+
currency: row.currency,
|
|
89
|
+
totalCents: Number(row.totalCents),
|
|
90
|
+
})),
|
|
91
|
+
monthlyInvoiceCounts: monthlyInvoiceCountsRows.map((row) => ({
|
|
92
|
+
yearMonth: row.yearMonth,
|
|
93
|
+
count: row.count,
|
|
94
|
+
})),
|
|
95
|
+
outstanding: outstandingRows.map((row) => ({
|
|
96
|
+
currency: row.currency,
|
|
97
|
+
balanceDueCents: Number(row.balanceDueCents),
|
|
98
|
+
count: row.count,
|
|
99
|
+
})),
|
|
100
|
+
overdue: overdueRows.map((row) => ({
|
|
101
|
+
currency: row.currency,
|
|
102
|
+
balanceDueCents: Number(row.balanceDueCents),
|
|
103
|
+
count: row.count,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { BookingGroup, BookingGroupMember } from "@voyantjs/bookings/schema";
|
|
2
|
+
import type { EventBus } from "@voyantjs/core";
|
|
3
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { type QuickCreateBookingOutcome, type QuickCreateBookingResult } from "./service-bookings-quick-create.js";
|
|
6
|
+
export declare const dualCreateBookingSchema: z.ZodObject<{
|
|
7
|
+
primary: z.ZodObject<{
|
|
8
|
+
organizationId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
9
|
+
bookingNumber: z.ZodString;
|
|
10
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
11
|
+
internalNotes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
12
|
+
productId: z.ZodString;
|
|
13
|
+
optionId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
14
|
+
travelers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
15
|
+
firstName: z.ZodString;
|
|
16
|
+
lastName: z.ZodString;
|
|
17
|
+
email: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
18
|
+
phone: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
19
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
20
|
+
participantType: z.ZodDefault<z.ZodEnum<{
|
|
21
|
+
other: "other";
|
|
22
|
+
traveler: "traveler";
|
|
23
|
+
occupant: "occupant";
|
|
24
|
+
}>>;
|
|
25
|
+
travelerCategory: z.ZodNullable<z.ZodOptional<z.ZodEnum<{
|
|
26
|
+
other: "other";
|
|
27
|
+
adult: "adult";
|
|
28
|
+
child: "child";
|
|
29
|
+
infant: "infant";
|
|
30
|
+
senior: "senior";
|
|
31
|
+
}>>>;
|
|
32
|
+
preferredLanguage: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
33
|
+
accessibilityNeeds: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
34
|
+
specialRequests: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
35
|
+
roomUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
36
|
+
isPrimary: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
|
|
37
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
38
|
+
}, z.core.$strip>>>;
|
|
39
|
+
slotId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
40
|
+
paymentSchedules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
41
|
+
scheduleType: z.ZodDefault<z.ZodEnum<{
|
|
42
|
+
other: "other";
|
|
43
|
+
deposit: "deposit";
|
|
44
|
+
installment: "installment";
|
|
45
|
+
balance: "balance";
|
|
46
|
+
hold: "hold";
|
|
47
|
+
}>>;
|
|
48
|
+
status: z.ZodDefault<z.ZodEnum<{
|
|
49
|
+
pending: "pending";
|
|
50
|
+
expired: "expired";
|
|
51
|
+
cancelled: "cancelled";
|
|
52
|
+
paid: "paid";
|
|
53
|
+
due: "due";
|
|
54
|
+
waived: "waived";
|
|
55
|
+
}>>;
|
|
56
|
+
dueDate: z.ZodString;
|
|
57
|
+
currency: z.ZodString;
|
|
58
|
+
amountCents: z.ZodNumber;
|
|
59
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
60
|
+
}, z.core.$strip>>>;
|
|
61
|
+
voucherRedemption: z.ZodOptional<z.ZodObject<{
|
|
62
|
+
voucherId: z.ZodString;
|
|
63
|
+
amountCents: z.ZodNumber;
|
|
64
|
+
}, z.core.$strip>>;
|
|
65
|
+
}, z.core.$strip>;
|
|
66
|
+
secondary: z.ZodObject<{
|
|
67
|
+
organizationId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
68
|
+
bookingNumber: z.ZodString;
|
|
69
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
70
|
+
internalNotes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
71
|
+
productId: z.ZodString;
|
|
72
|
+
optionId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
73
|
+
travelers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
74
|
+
firstName: z.ZodString;
|
|
75
|
+
lastName: z.ZodString;
|
|
76
|
+
email: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
77
|
+
phone: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
78
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
79
|
+
participantType: z.ZodDefault<z.ZodEnum<{
|
|
80
|
+
other: "other";
|
|
81
|
+
traveler: "traveler";
|
|
82
|
+
occupant: "occupant";
|
|
83
|
+
}>>;
|
|
84
|
+
travelerCategory: z.ZodNullable<z.ZodOptional<z.ZodEnum<{
|
|
85
|
+
other: "other";
|
|
86
|
+
adult: "adult";
|
|
87
|
+
child: "child";
|
|
88
|
+
infant: "infant";
|
|
89
|
+
senior: "senior";
|
|
90
|
+
}>>>;
|
|
91
|
+
preferredLanguage: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
92
|
+
accessibilityNeeds: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
93
|
+
specialRequests: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
94
|
+
roomUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
95
|
+
isPrimary: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
|
|
96
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
97
|
+
}, z.core.$strip>>>;
|
|
98
|
+
slotId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
99
|
+
paymentSchedules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
100
|
+
scheduleType: z.ZodDefault<z.ZodEnum<{
|
|
101
|
+
other: "other";
|
|
102
|
+
deposit: "deposit";
|
|
103
|
+
installment: "installment";
|
|
104
|
+
balance: "balance";
|
|
105
|
+
hold: "hold";
|
|
106
|
+
}>>;
|
|
107
|
+
status: z.ZodDefault<z.ZodEnum<{
|
|
108
|
+
pending: "pending";
|
|
109
|
+
expired: "expired";
|
|
110
|
+
cancelled: "cancelled";
|
|
111
|
+
paid: "paid";
|
|
112
|
+
due: "due";
|
|
113
|
+
waived: "waived";
|
|
114
|
+
}>>;
|
|
115
|
+
dueDate: z.ZodString;
|
|
116
|
+
currency: z.ZodString;
|
|
117
|
+
amountCents: z.ZodNumber;
|
|
118
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
119
|
+
}, z.core.$strip>>>;
|
|
120
|
+
voucherRedemption: z.ZodOptional<z.ZodObject<{
|
|
121
|
+
voucherId: z.ZodString;
|
|
122
|
+
amountCents: z.ZodNumber;
|
|
123
|
+
}, z.core.$strip>>;
|
|
124
|
+
}, z.core.$strip>;
|
|
125
|
+
group: z.ZodDefault<z.ZodObject<{
|
|
126
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
127
|
+
other: "other";
|
|
128
|
+
shared_room: "shared_room";
|
|
129
|
+
}>>;
|
|
130
|
+
label: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
131
|
+
optionUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
132
|
+
}, z.core.$strip>>;
|
|
133
|
+
}, z.core.$strip>;
|
|
134
|
+
export type DualCreateBookingInput = z.infer<typeof dualCreateBookingSchema>;
|
|
135
|
+
export interface DualCreateBookingRuntime {
|
|
136
|
+
eventBus?: EventBus;
|
|
137
|
+
}
|
|
138
|
+
export interface BookingDualCreatedEvent {
|
|
139
|
+
groupId: string;
|
|
140
|
+
primaryBookingId: string;
|
|
141
|
+
secondaryBookingId: string;
|
|
142
|
+
productId: string;
|
|
143
|
+
createdByUserId: string | null;
|
|
144
|
+
occurredAt: Date;
|
|
145
|
+
}
|
|
146
|
+
export interface DualCreateBookingResult {
|
|
147
|
+
primary: QuickCreateBookingResult;
|
|
148
|
+
secondary: QuickCreateBookingResult;
|
|
149
|
+
group: BookingGroup;
|
|
150
|
+
primaryMember: BookingGroupMember;
|
|
151
|
+
secondaryMember: BookingGroupMember;
|
|
152
|
+
}
|
|
153
|
+
export type DualCreateBookingOutcome = {
|
|
154
|
+
status: "ok";
|
|
155
|
+
result: DualCreateBookingResult;
|
|
156
|
+
} | {
|
|
157
|
+
status: "primary_failed" | "secondary_failed";
|
|
158
|
+
reason: Exclude<QuickCreateBookingOutcome, {
|
|
159
|
+
status: "ok";
|
|
160
|
+
}>;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* Create two bookings linked via a new `booking_group`, atomically. The
|
|
164
|
+
* canonical operator flow: two travelers book a shared room ("partaj"), each
|
|
165
|
+
* gets their own booking, and both are attached to a new shared_room group
|
|
166
|
+
* so subsequent payment / cancellation decisions can fan out across the
|
|
167
|
+
* pair.
|
|
168
|
+
*
|
|
169
|
+
* Transaction shape:
|
|
170
|
+
* - Outer tx opens via `db.transaction`.
|
|
171
|
+
* - Inner: two savepoint-scoped `quickCreateBooking(tx, ...)` calls — the
|
|
172
|
+
* nested transactions drizzle opens use SAVEPOINTs, so partial failures
|
|
173
|
+
* surface up to this layer as non-ok outcomes.
|
|
174
|
+
* - If either fails, the outer tx throws `DualCreateAbort` so the whole
|
|
175
|
+
* thing rolls back (no orphan booking, no orphan group).
|
|
176
|
+
* - Group creation + both memberships run last, inside the same outer tx.
|
|
177
|
+
*
|
|
178
|
+
* Event emission (`booking.dual-created`) is post-commit — subscribers only
|
|
179
|
+
* hear about successful pairs.
|
|
180
|
+
*/
|
|
181
|
+
export declare function dualCreateBooking(db: PostgresJsDatabase, rawInput: DualCreateBookingInput, options?: {
|
|
182
|
+
userId?: string;
|
|
183
|
+
runtime?: DualCreateBookingRuntime;
|
|
184
|
+
}): Promise<DualCreateBookingOutcome>;
|
|
185
|
+
//# sourceMappingURL=service-bookings-dual-create.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-bookings-dual-create.d.ts","sourceRoot":"","sources":["../src/service-bookings-dual-create.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AACjF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,EACL,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAG9B,MAAM,oCAAoC,CAAA;AAkB3C,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAIlC,CAAA;AAEF,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAA;AAI5E,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAA;IACf,gBAAgB,EAAE,MAAM,CAAA;IACxB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,UAAU,EAAE,IAAI,CAAA;CACjB;AAID,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,wBAAwB,CAAA;IACjC,SAAS,EAAE,wBAAwB,CAAA;IACnC,KAAK,EAAE,YAAY,CAAA;IACnB,aAAa,EAAE,kBAAkB,CAAA;IACjC,eAAe,EAAE,kBAAkB,CAAA;CACpC;AAED,MAAM,MAAM,wBAAwB,GAChC;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,GACjD;IACE,MAAM,EAAE,gBAAgB,GAAG,kBAAkB,CAAA;IAC7C,MAAM,EAAE,OAAO,CAAC,yBAAyB,EAAE;QAAE,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;CAC7D,CAAA;AAqBL;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,kBAAkB,EACtB,QAAQ,EAAE,sBAAsB,EAChC,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,wBAAwB,CAAA;CAC9B,GACL,OAAO,CAAC,wBAAwB,CAAC,CAkFnC"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { bookingGroupsService } from "@voyantjs/bookings";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { quickCreateBooking, quickCreateBookingSchema, } from "./service-bookings-quick-create.js";
|
|
4
|
+
// ---------- validation ----------
|
|
5
|
+
/**
|
|
6
|
+
* Sub-booking input. Takes the full quick-create payload minus `group
|
|
7
|
+
* Membership` — dual-create owns the group lifecycle (one new group, both
|
|
8
|
+
* bookings linked as members) so accepting a nested group override would
|
|
9
|
+
* just be an opportunity for the caller to desync.
|
|
10
|
+
*/
|
|
11
|
+
const dualSubBookingSchema = quickCreateBookingSchema.omit({ groupMembership: true });
|
|
12
|
+
const dualCreateGroupSchema = z.object({
|
|
13
|
+
kind: z.enum(["shared_room", "other"]).default("shared_room"),
|
|
14
|
+
label: z.string().max(255).optional().nullable(),
|
|
15
|
+
optionUnitId: z.string().optional().nullable(),
|
|
16
|
+
});
|
|
17
|
+
export const dualCreateBookingSchema = z.object({
|
|
18
|
+
primary: dualSubBookingSchema,
|
|
19
|
+
secondary: dualSubBookingSchema,
|
|
20
|
+
group: dualCreateGroupSchema.default({ kind: "shared_room" }),
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* Thrown inside the outer tx to force drizzle to roll back both bookings +
|
|
24
|
+
* the group when one of the inner quick-create calls returns non-ok.
|
|
25
|
+
* Drizzle doesn't abort on a non-throwing tx callback, so we convert the
|
|
26
|
+
* discriminated outcome into a throw here.
|
|
27
|
+
*/
|
|
28
|
+
class DualCreateAbort extends Error {
|
|
29
|
+
outcome;
|
|
30
|
+
constructor(outcome) {
|
|
31
|
+
super(outcome.status === "ok"
|
|
32
|
+
? "dual-create aborted: ok (unexpected)"
|
|
33
|
+
: `dual-create aborted: ${outcome.status}:${outcome.reason.status}`);
|
|
34
|
+
this.outcome = outcome;
|
|
35
|
+
this.name = "DualCreateAbort";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ---------- service ----------
|
|
39
|
+
/**
|
|
40
|
+
* Create two bookings linked via a new `booking_group`, atomically. The
|
|
41
|
+
* canonical operator flow: two travelers book a shared room ("partaj"), each
|
|
42
|
+
* gets their own booking, and both are attached to a new shared_room group
|
|
43
|
+
* so subsequent payment / cancellation decisions can fan out across the
|
|
44
|
+
* pair.
|
|
45
|
+
*
|
|
46
|
+
* Transaction shape:
|
|
47
|
+
* - Outer tx opens via `db.transaction`.
|
|
48
|
+
* - Inner: two savepoint-scoped `quickCreateBooking(tx, ...)` calls — the
|
|
49
|
+
* nested transactions drizzle opens use SAVEPOINTs, so partial failures
|
|
50
|
+
* surface up to this layer as non-ok outcomes.
|
|
51
|
+
* - If either fails, the outer tx throws `DualCreateAbort` so the whole
|
|
52
|
+
* thing rolls back (no orphan booking, no orphan group).
|
|
53
|
+
* - Group creation + both memberships run last, inside the same outer tx.
|
|
54
|
+
*
|
|
55
|
+
* Event emission (`booking.dual-created`) is post-commit — subscribers only
|
|
56
|
+
* hear about successful pairs.
|
|
57
|
+
*/
|
|
58
|
+
export async function dualCreateBooking(db, rawInput, options = {}) {
|
|
59
|
+
const { userId, runtime } = options;
|
|
60
|
+
const input = dualCreateBookingSchema.parse(rawInput);
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await db.transaction(async (tx) => {
|
|
64
|
+
const primaryOutcome = await quickCreateBooking(tx, input.primary, { userId });
|
|
65
|
+
if (primaryOutcome.status !== "ok") {
|
|
66
|
+
throw new DualCreateAbort({ status: "primary_failed", reason: primaryOutcome });
|
|
67
|
+
}
|
|
68
|
+
const secondaryOutcome = await quickCreateBooking(tx, input.secondary, { userId });
|
|
69
|
+
if (secondaryOutcome.status !== "ok") {
|
|
70
|
+
throw new DualCreateAbort({ status: "secondary_failed", reason: secondaryOutcome });
|
|
71
|
+
}
|
|
72
|
+
const primaryBooking = primaryOutcome.result.booking;
|
|
73
|
+
const secondaryBooking = secondaryOutcome.result.booking;
|
|
74
|
+
const group = await bookingGroupsService.createBookingGroup(tx, {
|
|
75
|
+
kind: input.group.kind,
|
|
76
|
+
label: input.group.label ??
|
|
77
|
+
`Shared — ${primaryBooking.bookingNumber} + ${secondaryBooking.bookingNumber}`,
|
|
78
|
+
productId: input.primary.productId,
|
|
79
|
+
optionUnitId: input.group.optionUnitId ?? null,
|
|
80
|
+
primaryBookingId: primaryBooking.id,
|
|
81
|
+
});
|
|
82
|
+
const primaryMemberResult = await bookingGroupsService.addGroupMember(tx, group.id, {
|
|
83
|
+
bookingId: primaryBooking.id,
|
|
84
|
+
role: "primary",
|
|
85
|
+
});
|
|
86
|
+
if (primaryMemberResult.status !== "ok") {
|
|
87
|
+
// Shouldn't happen — we just created both rows in this tx — but bail
|
|
88
|
+
// through the same abort path to unwind cleanly.
|
|
89
|
+
throw new DualCreateAbort({
|
|
90
|
+
status: "primary_failed",
|
|
91
|
+
reason: { status: "group_not_found" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const secondaryMemberResult = await bookingGroupsService.addGroupMember(tx, group.id, {
|
|
95
|
+
bookingId: secondaryBooking.id,
|
|
96
|
+
role: "shared",
|
|
97
|
+
});
|
|
98
|
+
if (secondaryMemberResult.status !== "ok") {
|
|
99
|
+
throw new DualCreateAbort({
|
|
100
|
+
status: "secondary_failed",
|
|
101
|
+
reason: { status: "group_not_found" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
primary: primaryOutcome.result,
|
|
106
|
+
secondary: secondaryOutcome.result,
|
|
107
|
+
group,
|
|
108
|
+
primaryMember: primaryMemberResult.member,
|
|
109
|
+
secondaryMember: secondaryMemberResult.member,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof DualCreateAbort) {
|
|
115
|
+
return error.outcome;
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
if (runtime?.eventBus) {
|
|
120
|
+
const event = {
|
|
121
|
+
groupId: result.group.id,
|
|
122
|
+
primaryBookingId: result.primary.booking.id,
|
|
123
|
+
secondaryBookingId: result.secondary.booking.id,
|
|
124
|
+
productId: input.primary.productId,
|
|
125
|
+
createdByUserId: userId ?? null,
|
|
126
|
+
occurredAt: new Date(),
|
|
127
|
+
};
|
|
128
|
+
await runtime.eventBus.emit("booking.dual-created", event);
|
|
129
|
+
}
|
|
130
|
+
return { status: "ok", result };
|
|
131
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Booking, BookingGroupMember, BookingTraveler } from "@voyantjs/bookings/schema";
|
|
2
|
+
import type { EventBus } from "@voyantjs/core";
|
|
3
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { BookingPaymentSchedule, Voucher, VoucherRedemption } from "./schema.js";
|
|
6
|
+
declare const travelerInputSchema: z.ZodObject<{
|
|
7
|
+
firstName: z.ZodString;
|
|
8
|
+
lastName: z.ZodString;
|
|
9
|
+
email: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
10
|
+
phone: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
11
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
12
|
+
participantType: z.ZodDefault<z.ZodEnum<{
|
|
13
|
+
other: "other";
|
|
14
|
+
traveler: "traveler";
|
|
15
|
+
occupant: "occupant";
|
|
16
|
+
}>>;
|
|
17
|
+
travelerCategory: z.ZodNullable<z.ZodOptional<z.ZodEnum<{
|
|
18
|
+
other: "other";
|
|
19
|
+
adult: "adult";
|
|
20
|
+
child: "child";
|
|
21
|
+
infant: "infant";
|
|
22
|
+
senior: "senior";
|
|
23
|
+
}>>>;
|
|
24
|
+
preferredLanguage: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
25
|
+
accessibilityNeeds: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
26
|
+
specialRequests: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
27
|
+
roomUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
28
|
+
isPrimary: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
|
|
29
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
export declare const quickCreateBookingSchema: z.ZodObject<{
|
|
32
|
+
productId: z.ZodString;
|
|
33
|
+
optionId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
34
|
+
slotId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
35
|
+
bookingNumber: z.ZodString;
|
|
36
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
37
|
+
organizationId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
38
|
+
internalNotes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
39
|
+
travelers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
40
|
+
firstName: z.ZodString;
|
|
41
|
+
lastName: z.ZodString;
|
|
42
|
+
email: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
43
|
+
phone: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
44
|
+
personId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
45
|
+
participantType: z.ZodDefault<z.ZodEnum<{
|
|
46
|
+
other: "other";
|
|
47
|
+
traveler: "traveler";
|
|
48
|
+
occupant: "occupant";
|
|
49
|
+
}>>;
|
|
50
|
+
travelerCategory: z.ZodNullable<z.ZodOptional<z.ZodEnum<{
|
|
51
|
+
other: "other";
|
|
52
|
+
adult: "adult";
|
|
53
|
+
child: "child";
|
|
54
|
+
infant: "infant";
|
|
55
|
+
senior: "senior";
|
|
56
|
+
}>>>;
|
|
57
|
+
preferredLanguage: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
58
|
+
accessibilityNeeds: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
59
|
+
specialRequests: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
60
|
+
roomUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
61
|
+
isPrimary: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
|
|
62
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
63
|
+
}, z.core.$strip>>>;
|
|
64
|
+
paymentSchedules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
65
|
+
scheduleType: z.ZodDefault<z.ZodEnum<{
|
|
66
|
+
other: "other";
|
|
67
|
+
deposit: "deposit";
|
|
68
|
+
installment: "installment";
|
|
69
|
+
balance: "balance";
|
|
70
|
+
hold: "hold";
|
|
71
|
+
}>>;
|
|
72
|
+
status: z.ZodDefault<z.ZodEnum<{
|
|
73
|
+
pending: "pending";
|
|
74
|
+
expired: "expired";
|
|
75
|
+
cancelled: "cancelled";
|
|
76
|
+
paid: "paid";
|
|
77
|
+
due: "due";
|
|
78
|
+
waived: "waived";
|
|
79
|
+
}>>;
|
|
80
|
+
dueDate: z.ZodString;
|
|
81
|
+
currency: z.ZodString;
|
|
82
|
+
amountCents: z.ZodNumber;
|
|
83
|
+
notes: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
84
|
+
}, z.core.$strip>>>;
|
|
85
|
+
voucherRedemption: z.ZodOptional<z.ZodObject<{
|
|
86
|
+
voucherId: z.ZodString;
|
|
87
|
+
amountCents: z.ZodNumber;
|
|
88
|
+
}, z.core.$strip>>;
|
|
89
|
+
groupMembership: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
90
|
+
action: z.ZodLiteral<"join">;
|
|
91
|
+
groupId: z.ZodString;
|
|
92
|
+
role: z.ZodDefault<z.ZodEnum<{
|
|
93
|
+
primary: "primary";
|
|
94
|
+
shared: "shared";
|
|
95
|
+
}>>;
|
|
96
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
97
|
+
action: z.ZodLiteral<"create">;
|
|
98
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
99
|
+
other: "other";
|
|
100
|
+
shared_room: "shared_room";
|
|
101
|
+
}>>;
|
|
102
|
+
label: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
103
|
+
optionUnitId: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
104
|
+
makeBookingPrimary: z.ZodDefault<z.ZodBoolean>;
|
|
105
|
+
}, z.core.$strip>], "action">>;
|
|
106
|
+
}, z.core.$strip>;
|
|
107
|
+
export type QuickCreateBookingInput = z.infer<typeof quickCreateBookingSchema>;
|
|
108
|
+
export type QuickCreateTravelerInput = z.infer<typeof travelerInputSchema>;
|
|
109
|
+
/**
|
|
110
|
+
* Fire-and-forget post-commit events. The orchestrator only knows about
|
|
111
|
+
* `booking.quick-created` — downstream confirm/cancel lifecycle events stay
|
|
112
|
+
* with the booking service itself (the booking lands in `draft` status so no
|
|
113
|
+
* `booking.confirmed` should fire here).
|
|
114
|
+
*/
|
|
115
|
+
export interface BookingQuickCreateRuntime {
|
|
116
|
+
eventBus?: EventBus;
|
|
117
|
+
}
|
|
118
|
+
export interface BookingQuickCreatedEvent {
|
|
119
|
+
bookingId: string;
|
|
120
|
+
bookingNumber: string;
|
|
121
|
+
productId: string;
|
|
122
|
+
travelerCount: number;
|
|
123
|
+
paymentScheduleCount: number;
|
|
124
|
+
voucherRedeemedCents: number | null;
|
|
125
|
+
groupId: string | null;
|
|
126
|
+
createdByUserId: string | null;
|
|
127
|
+
occurredAt: Date;
|
|
128
|
+
}
|
|
129
|
+
export interface QuickCreateBookingResult {
|
|
130
|
+
booking: Booking;
|
|
131
|
+
travelers: BookingTraveler[];
|
|
132
|
+
paymentSchedules: BookingPaymentSchedule[];
|
|
133
|
+
voucherRedemption: {
|
|
134
|
+
voucher: Voucher;
|
|
135
|
+
redemption: VoucherRedemption;
|
|
136
|
+
} | null;
|
|
137
|
+
groupMembership: {
|
|
138
|
+
groupId: string;
|
|
139
|
+
member: BookingGroupMember;
|
|
140
|
+
} | null;
|
|
141
|
+
}
|
|
142
|
+
export type QuickCreateBookingOutcome = {
|
|
143
|
+
status: "ok";
|
|
144
|
+
result: QuickCreateBookingResult;
|
|
145
|
+
} | {
|
|
146
|
+
status: "product_not_found";
|
|
147
|
+
} | {
|
|
148
|
+
status: "voucher_not_found";
|
|
149
|
+
} | {
|
|
150
|
+
status: "voucher_inactive";
|
|
151
|
+
} | {
|
|
152
|
+
status: "voucher_not_started";
|
|
153
|
+
} | {
|
|
154
|
+
status: "voucher_expired";
|
|
155
|
+
} | {
|
|
156
|
+
status: "voucher_insufficient_balance";
|
|
157
|
+
} | {
|
|
158
|
+
status: "group_not_found";
|
|
159
|
+
} | {
|
|
160
|
+
status: "booking_already_in_group";
|
|
161
|
+
currentGroupId: string;
|
|
162
|
+
};
|
|
163
|
+
export declare function quickCreateBooking(db: PostgresJsDatabase, rawInput: QuickCreateBookingInput, options?: {
|
|
164
|
+
userId?: string;
|
|
165
|
+
runtime?: BookingQuickCreateRuntime;
|
|
166
|
+
}): Promise<QuickCreateBookingOutcome>;
|
|
167
|
+
export {};
|
|
168
|
+
//# sourceMappingURL=service-bookings-quick-create.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-bookings-quick-create.d.ts","sourceRoot":"","sources":["../src/service-bookings-quick-create.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAE7F,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,sBAAsB,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAOrF,QAAA,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;iBAqBvB,CAAA;AAyCF,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAenC,CAAA;AAEF,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAA;AAC9E,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAI1E;;;;;GAKG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,UAAU,EAAE,IAAI,CAAA;CACjB;AAID,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,eAAe,EAAE,CAAA;IAC5B,gBAAgB,EAAE,sBAAsB,EAAE,CAAA;IAC1C,iBAAiB,EAAE;QACjB,OAAO,EAAE,OAAO,CAAA;QAChB,UAAU,EAAE,iBAAiB,CAAA;KAC9B,GAAG,IAAI,CAAA;IACR,eAAe,EAAE;QACf,OAAO,EAAE,MAAM,CAAA;QACf,MAAM,EAAE,kBAAkB,CAAA;KAC3B,GAAG,IAAI,CAAA;CACT;AAED,MAAM,MAAM,yBAAyB,GACjC;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,wBAAwB,CAAA;CAAE,GAClD;IAAE,MAAM,EAAE,mBAAmB,CAAA;CAAE,GAC/B;IAAE,MAAM,EAAE,mBAAmB,CAAA;CAAE,GAC/B;IAAE,MAAM,EAAE,kBAAkB,CAAA;CAAE,GAC9B;IAAE,MAAM,EAAE,qBAAqB,CAAA;CAAE,GACjC;IAAE,MAAM,EAAE,iBAAiB,CAAA;CAAE,GAC7B;IAAE,MAAM,EAAE,8BAA8B,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,iBAAiB,CAAA;CAAE,GAC7B;IAAE,MAAM,EAAE,0BAA0B,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAA;AAiClE,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,kBAAkB,EACtB,QAAQ,EAAE,uBAAuB,EACjC,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,yBAAyB,CAAA;CAC/B,GACL,OAAO,CAAC,yBAAyB,CAAC,CAgNpC"}
|