@bash-app/bash-common 30.2.0 → 30.4.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.
@@ -0,0 +1,222 @@
1
+ import {
2
+ ApiBookingParamsLuxon,
3
+ ApiServiceBookingParams,
4
+ ApiServiceBookedDayParamsLuxon,
5
+ ApiServiceAddonParams,
6
+ ApiServiceBookedDayParams,
7
+ ApiServicePriceToBookResult,
8
+ } from "../../definitions";
9
+ import {
10
+ ServiceAddonExt,
11
+ ServiceBookingAddOnExt,
12
+ ServiceBookingDayExt,
13
+ ServiceBookingExt,
14
+ ServiceBookingFeeExt,
15
+ ServiceBookingPriceBreakdownExt,
16
+ ServiceExt,
17
+ } from "../../extendedSchemas";
18
+ import { dateTimeFromString, dateTimeRangeFromDates } from "../luxonUtils";
19
+ import { serviceAttendeeOptions } from "./attendeeOptionUtils";
20
+ import { ServiceBookingPriceBreakdownBaseT } from "./serviceBookingTypes";
21
+ import {
22
+ ServiceAddonInput,
23
+ FrontendServiceGetPriceToBookResult,
24
+ FrontendServiceBookingDayInfo,
25
+ } from "./frontendServiceBookingUtils";
26
+
27
+ export type ApiServiceCantBookReason = {
28
+ type: string;
29
+ msg: string;
30
+ };
31
+
32
+ export const ApiServiceCantBookReasons = {
33
+ notInBusinessHours: {
34
+ msg: "Time not within normal Business Hours",
35
+ } as ApiServiceCantBookReason,
36
+ minimumTimeBlock: {
37
+ msg: "Minimum time block not satisfied",
38
+ } as ApiServiceCantBookReason,
39
+ dateBlocked: {
40
+ msg: "The selected date is blocked.",
41
+ } as ApiServiceCantBookReason,
42
+ alreadyBooked: {
43
+ msg: "This service has already been booked.",
44
+ } as ApiServiceCantBookReason,
45
+ requestDeclined: {
46
+ msg: "The booking request has been declined.",
47
+ } as ApiServiceCantBookReason,
48
+ } as const;
49
+
50
+ export type ApiServiceCantBookReasons = keyof typeof ApiServiceCantBookReasons;
51
+
52
+ export function apiTransformAddOnsToAddonInputs<
53
+ AddonParams_t extends ApiServiceAddonParams
54
+ >(
55
+ addOnsApi: AddonParams_t[],
56
+ serviceAddons: ServiceAddonExt[]
57
+ ): ServiceAddonInput[] {
58
+ // Create a Map for faster lookup by addonId
59
+ const addonMap = new Map<string, ServiceAddonExt>(
60
+ serviceAddons.map((addon) => [addon.id, addon])
61
+ );
62
+
63
+ return addOnsApi.map((addOnApi) => {
64
+ const matchedAddon = addonMap.get(addOnApi.addonId);
65
+ if (!matchedAddon) {
66
+ throw new Error(`Failed to match addon with id: ${addOnApi.addonId}`);
67
+ }
68
+ return {
69
+ ...matchedAddon,
70
+ ...addOnApi,
71
+ } as ServiceAddonInput;
72
+ });
73
+ }
74
+
75
+ export function apiTransformBookedDays(
76
+ priceToBook: ApiServicePriceToBookResult
77
+ ): Partial<ServiceBookingDayExt>[] {
78
+ return priceToBook.daysToBook.map((day): Partial<ServiceBookingDayExt> => {
79
+ // Transform addOns using the helper function, then map to the expected output.
80
+ const addOns = day.addOns.map(
81
+ (addOn): Partial<ServiceBookingAddOnExt> => ({
82
+ addOnId: addOn.addOnId,
83
+ addOn: addOn.addOn,
84
+ chosenQuantity: addOn.chosenQuantity,
85
+ costPQCents: addOn.costPQCents,
86
+ })
87
+ );
88
+
89
+ const priceBreakdown = day.priceBreakdown.map(
90
+ (pb): Partial<ServiceBookingPriceBreakdownExt> => {
91
+ const { dateTimeRange, ...pbData } = pb;
92
+ return {
93
+ ...pbData,
94
+ startDate: pb.dateTimeRange.start.toJSDate(),
95
+ endDate: pb.dateTimeRange.end.toJSDate(),
96
+ };
97
+ }
98
+ );
99
+
100
+ return {
101
+ startDate: day.dateTimeRange.start.toJSDate(),
102
+ endDate: day.dateTimeRange.end.toJSDate(),
103
+ addOns: addOns,
104
+ packages: [],
105
+ fees: day.fees as ServiceBookingFeeExt[],
106
+ priceBreakdown: priceBreakdown,
107
+ totalBeforeTaxesCents: day.totalBeforeTaxesCents,
108
+ } as Partial<ServiceBookingDayExt>;
109
+ });
110
+ }
111
+
112
+ export function serviceGetPriceToBookFromBooking(
113
+ service: ServiceExt,
114
+ booking: ServiceBookingExt
115
+ ): FrontendServiceGetPriceToBookResult {
116
+ const daysToBook = booking.bookedDays
117
+ .map((day) => {
118
+ const priceBreakdown = day.priceBreakdown.map(
119
+ (pb): ServiceBookingPriceBreakdownBaseT => ({
120
+ ...pb,
121
+ dateTimeRange: dateTimeRangeFromDates(
122
+ pb.startDate,
123
+ pb.endDate,
124
+ "UTC",
125
+ booking.timezone
126
+ ),
127
+ })
128
+ );
129
+
130
+ return {
131
+ dateTimeRange: dateTimeRangeFromDates(
132
+ day.startDate,
133
+ day.endDate,
134
+ "UTC",
135
+ booking.timezone
136
+ ),
137
+ addOns: day.addOns,
138
+ priceBreakdown: priceBreakdown,
139
+ fees: day.fees,
140
+ totalBeforeTaxesCents: day.totalBeforeTaxesCents,
141
+ timezone: booking.timezone,
142
+ } as FrontendServiceBookingDayInfo;
143
+ })
144
+ .sort(
145
+ (lhs, rhs) =>
146
+ lhs.dateTimeRange.start.toMillis() - rhs.dateTimeRange.start.toMillis()
147
+ );
148
+
149
+ return {
150
+ serviceId: service.id,
151
+ daysToBook: daysToBook,
152
+ selectedAttendeeOption: serviceAttendeeOptions[0],
153
+ additionalFees: booking.additionalFees,
154
+ timezone: booking.timezone,
155
+ daysTotalBeforeTaxesCents: booking.daysTotalATBCents,
156
+ totalBeforeTaxesCents: booking.totalATBCents,
157
+ } as FrontendServiceGetPriceToBookResult;
158
+ }
159
+
160
+ export function serviceBookingParamsToLuxon(
161
+ params: ApiServiceBookingParams
162
+ ): ApiBookingParamsLuxon {
163
+ const bookedDays = params.bookedDays.reduce(
164
+ (sofar, day): ApiServiceBookedDayParamsLuxon[] => {
165
+ sofar.push({
166
+ ...day,
167
+ dateTimeRange: {
168
+ start: dateTimeFromString(day.startDate),
169
+ end: dateTimeFromString(day.endDate),
170
+ },
171
+ });
172
+
173
+ return sofar;
174
+ },
175
+ [] as ApiServiceBookedDayParamsLuxon[]
176
+ );
177
+
178
+ return {
179
+ ...params,
180
+ bookedDays,
181
+ };
182
+ }
183
+
184
+ export function servicePriceToBookToApiDays(
185
+ serviceId: string,
186
+ userId: string,
187
+ priceToBook: FrontendServiceGetPriceToBookResult
188
+ ): ApiServiceBookedDayParams[] {
189
+ const bookedDays: ApiServiceBookedDayParams[] =
190
+ priceToBook.daysToBook
191
+ .map((day): ApiServiceBookedDayParams | null => {
192
+ const addOns: ApiServiceAddonParams[] = day.addOns.map(
193
+ (addOn): ApiServiceAddonParams => ({
194
+ addonId: addOn.addOnId,
195
+ chosenQuantity: addOn.chosenQuantity,
196
+ })
197
+ );
198
+ const startDate = day.dateTimeRange.start.toISO();
199
+ const endDate = day.dateTimeRange.end.toISO();
200
+
201
+ if (!startDate || !endDate) {
202
+ console.log(`Invalid startDate/endDate: ${startDate}/${endDate}`);
203
+ return null;
204
+ }
205
+
206
+ return {
207
+ serviceId: serviceId,
208
+ forUserId: userId,
209
+ startDate,
210
+ endDate,
211
+ addOns,
212
+ allowPromiseToPay: false,
213
+ isFreeGuest: false,
214
+ };
215
+ })
216
+ .filter(
217
+ (dayParams): dayParams is ApiServiceBookedDayParams =>
218
+ dayParams !== null
219
+ ) ?? [];
220
+
221
+ return bookedDays;
222
+ }
@@ -0,0 +1,310 @@
1
+ import {
2
+ ServiceAddon,
3
+ ServiceBookingFee,
4
+ ServiceBookingFeeType,
5
+ } from "@prisma/client";
6
+ import {
7
+ ServiceBookingAddOnExt,
8
+ ServiceBookingDayExt,
9
+ ServiceBookingFeeExt,
10
+ ServiceBookingPriceBreakdownExt,
11
+ ServiceDailyRatesExt,
12
+ ServiceRatesAssociationExt,
13
+ ServiceSpecialRatesExt,
14
+ } from "../../extendedSchemas";
15
+ import { ServiceAttendeeOption } from "./attendeeOptionUtils";
16
+ import {
17
+ getServiceRatePricingInfo,
18
+ SERVICE_DAILY_RATE_HOURS_MIN,
19
+ serviceGetFilteredRates,
20
+ serviceRatesFilter,
21
+ serviceRatesHasRates,
22
+ ServiceRatesLuxon,
23
+ } from "./serviceRateUtils";
24
+ import {
25
+ dateTimeDiffHours,
26
+ dateTimeRangeHours,
27
+ LuxonDateRange,
28
+ } from "../luxonUtils";
29
+ import {
30
+ ServiceBookingFeeBase,
31
+ ServiceBookingPriceBreakdownBaseT,
32
+ ServiceBookingAddOnBase,
33
+ } from "./serviceBookingTypes";
34
+ import { convertDollarsToCents } from "../paymentUtils";
35
+
36
+ export const SERVICE_BOOKING_PROCESSING_FEE_PERCENT = 0.15;
37
+
38
+ export interface ServiceAddonInput extends ServiceAddon {
39
+ chosenQuantity?: number;
40
+ }
41
+
42
+ export interface FrontendServiceBookingDayInfoParams {
43
+ dateTimeRange: LuxonDateRange;
44
+ fees: ServiceBookingFeeBase[];
45
+ addOns: ServiceAddonInput[];
46
+ }
47
+
48
+ export interface FrontendServiceBookingDayInfo {
49
+ priceBreakdown: ServiceBookingPriceBreakdownBaseT[];
50
+ addOns: ServiceBookingAddOnBase[];
51
+ fees: ServiceBookingFeeBase[];
52
+ // discounts: ServiceRatePricingInfo[];
53
+
54
+ dateTimeRange: LuxonDateRange;
55
+ timezone: string;
56
+
57
+ totalBeforeTaxesCents: number;
58
+ }
59
+
60
+ export interface FrontendServiceGetPriceToBookResult {
61
+ serviceId: string;
62
+
63
+ daysToBook: FrontendServiceBookingDayInfo[];
64
+ additionalFees: ServiceBookingFeeBase[];
65
+
66
+ daysTotalBeforeTaxesCents: number;
67
+ totalBeforeTaxesCents: number;
68
+
69
+ minimumTimeBlockHours?: number; //only valid on generalRate
70
+ }
71
+
72
+ export interface FrontendServiceGetBookingDayInfoFrontParams {
73
+ filteredRates: ServiceRatesLuxon;
74
+ // overlappingBusinessHours: LuxonDateRange[] | null;
75
+
76
+ timezone: string | null | undefined;
77
+ daysToBookParams: FrontendServiceBookingDayInfoParams[];
78
+ }
79
+
80
+ export function frontendServiceGetBookingDayInfo({
81
+ filteredRates,
82
+ daysToBookParams,
83
+ // overlappingBusinessHours,
84
+ timezone,
85
+ }: FrontendServiceGetBookingDayInfoFrontParams): FrontendServiceBookingDayInfo[] {
86
+ const bookingDayResults: FrontendServiceBookingDayInfo[] =
87
+ daysToBookParams.map((day) => {
88
+ const rates = serviceRatesFilter(filteredRates, [day.dateTimeRange]);
89
+ const { generalRate, specialRates, dailyRates } = rates;
90
+
91
+ let priceBreakdown: ServiceBookingPriceBreakdownBaseT[] = [];
92
+ const durationHours = dateTimeDiffHours(
93
+ day.dateTimeRange.start,
94
+ day.dateTimeRange.end
95
+ );
96
+ // console.log(
97
+ // `serviceGetBookingDayInfo: durationHours: ${JSON.stringify(
98
+ // durationHours
99
+ // )}, dtRange: ${JSON.stringify(day.dateTimeRange)}`
100
+ // );
101
+ let hoursRemaining = durationHours;
102
+ let currentRange = { ...day.dateTimeRange };
103
+ let baseCostDiscountedCents = 0;
104
+ while (serviceRatesHasRates(rates) && hoursRemaining > 0) {
105
+ const pricingInfo = getServiceRatePricingInfo(
106
+ currentRange,
107
+ generalRate,
108
+ dailyRates,
109
+ specialRates,
110
+ // overlappingBusinessHours,
111
+ timezone,
112
+ hoursRemaining
113
+ );
114
+
115
+ if (pricingInfo.hours == 0 || pricingInfo.unitCount == 0) {
116
+ throw new Error(`Invalid pricingInfo`);
117
+ }
118
+
119
+ priceBreakdown.push(pricingInfo);
120
+
121
+ // currentRange.start = currentRange.start.plus({
122
+ // hours: pricingInfo.hours,
123
+ // });
124
+ currentRange.start = pricingInfo.dateTimeRange.end;
125
+ hoursRemaining -= pricingInfo.hours;
126
+ baseCostDiscountedCents += pricingInfo.piTotalCents;
127
+
128
+ if (pricingInfo.hours < SERVICE_DAILY_RATE_HOURS_MIN) {
129
+ if (pricingInfo.rateType == "Special") {
130
+ specialRates.pop();
131
+ } else if (pricingInfo.rateType == "Weekday") {
132
+ dailyRates.pop();
133
+ }
134
+ }
135
+ }
136
+
137
+ const addOns = day.addOns.map((addOn): ServiceBookingAddOnBase => {
138
+ return {
139
+ addOn: addOn,
140
+ addOnId: addOn.id,
141
+ chosenQuantity: addOn.chosenQuantity ?? 0,
142
+ costPQCents: addOn.priceCents,
143
+ };
144
+ });
145
+
146
+ const feesTotalCents = day.fees.reduce(
147
+ (sofar, fee) => sofar + fee.costPQCents * fee.quantity,
148
+ 0
149
+ );
150
+
151
+ const addOnsTotalCents = addOns.reduce(
152
+ (sofar, addOn) => sofar + addOn.costPQCents * addOn.chosenQuantity,
153
+ 0
154
+ );
155
+
156
+ const totalBeforeTaxesCents =
157
+ baseCostDiscountedCents + addOnsTotalCents + feesTotalCents;
158
+
159
+ return {
160
+ priceBreakdown: priceBreakdown,
161
+ dateTimeRange: day.dateTimeRange,
162
+ timezone: day.dateTimeRange.start.zoneName,
163
+ fees: day.fees,
164
+ addOns: addOns,
165
+ totalBeforeTaxesCents: totalBeforeTaxesCents,
166
+ } as FrontendServiceBookingDayInfo;
167
+ });
168
+
169
+ return bookingDayResults;
170
+ }
171
+
172
+ export interface FrontendServiceGetPriceToBookFeesParams {
173
+ daysToBookParams: FrontendServiceBookingDayInfoParams[];
174
+ selectedAttendeeOption: ServiceAttendeeOption;
175
+
176
+ additionalFees: ServiceBookingFeeBase[];
177
+
178
+ // overlappingBusinessHours: LuxonDateRange[] | null;
179
+ timezone: string | null | undefined;
180
+ }
181
+
182
+ export function frontendServiceGetPriceToBookFees(
183
+ serviceRatesAssociation: ServiceRatesAssociationExt | null | undefined,
184
+ {
185
+ daysToBookParams,
186
+ selectedAttendeeOption,
187
+ additionalFees,
188
+ // overlappingBusinessHours,
189
+ timezone,
190
+ }: FrontendServiceGetPriceToBookFeesParams
191
+ ): FrontendServiceGetPriceToBookResult {
192
+ const bookingDateTimeRanges = daysToBookParams.map(
193
+ (daysToBookParams) => daysToBookParams.dateTimeRange
194
+ );
195
+ const filteredRates = serviceGetFilteredRates(
196
+ serviceRatesAssociation,
197
+ bookingDateTimeRanges,
198
+ timezone
199
+ );
200
+
201
+ const bookingDayResults: FrontendServiceBookingDayInfo[] =
202
+ frontendServiceGetBookingDayInfo({
203
+ filteredRates: filteredRates,
204
+ daysToBookParams: daysToBookParams,
205
+ // overlappingBusinessHours: overlappingBusinessHours,
206
+ timezone: timezone,
207
+ });
208
+
209
+ const daysTotalBeforeTaxesCents = bookingDayResults.reduce((sofar, curr) => {
210
+ return sofar + curr.totalBeforeTaxesCents;
211
+ }, 0);
212
+
213
+ const minimumTimeBlockHours =
214
+ serviceRatesAssociation?.serviceGeneralRates?.minimumTimeBlockHours;
215
+
216
+ // const attendeeRate = selectedAttendeeOption.rate * durationHours;
217
+
218
+ const processingFeeCents =
219
+ SERVICE_BOOKING_PROCESSING_FEE_PERCENT * daysTotalBeforeTaxesCents;
220
+
221
+ const hasCustomProcessingFee = additionalFees.some(
222
+ (fee) => fee.feeType == ServiceBookingFeeType.ProcessingFee
223
+ );
224
+
225
+ let allAdditionalFees = [...additionalFees];
226
+
227
+ if (!hasCustomProcessingFee) {
228
+ allAdditionalFees.push({
229
+ feeType: ServiceBookingFeeType.ProcessingFee,
230
+ name: "Processing Fee",
231
+ description: "Processing Fee",
232
+ costPQCents: processingFeeCents,
233
+ quantity: 1,
234
+ });
235
+ }
236
+
237
+ allAdditionalFees = allAdditionalFees.filter((fee) => fee.costPQCents > 0);
238
+
239
+ const allAdditionalFeesCents = allAdditionalFees.reduce(
240
+ (sofar, fee) => sofar + fee.costPQCents * fee.quantity,
241
+ 0
242
+ );
243
+
244
+ const totalBeforeTaxesCents =
245
+ daysTotalBeforeTaxesCents + allAdditionalFeesCents;
246
+
247
+ return {
248
+ serviceId: serviceRatesAssociation?.serviceId,
249
+ daysToBook: bookingDayResults,
250
+ daysTotalBeforeTaxesCents: daysTotalBeforeTaxesCents,
251
+ totalBeforeTaxesCents: totalBeforeTaxesCents,
252
+ additionalFees: allAdditionalFees,
253
+ minimumTimeBlockHours: minimumTimeBlockHours,
254
+ } as FrontendServiceGetPriceToBookResult;
255
+ }
256
+
257
+ export interface FrontendServiceGetPriceToBookParams {
258
+ addOns: ServiceAddonInput[];
259
+
260
+ selectedAttendeeOption: ServiceAttendeeOption;
261
+
262
+ bookingDateTimeRanges: LuxonDateRange[];
263
+
264
+ // overlappingBusinessHours: LuxonDateRange[] | null;
265
+ timezone: string | null | undefined;
266
+ }
267
+
268
+ export function frontendServiceGetPriceToBook(
269
+ serviceRatesAssociation: ServiceRatesAssociationExt | null | undefined,
270
+ {
271
+ addOns,
272
+ selectedAttendeeOption,
273
+ bookingDateTimeRanges, //list of dateTime ranges to book
274
+ // overlappingBusinessHours,
275
+ timezone,
276
+ }: FrontendServiceGetPriceToBookParams
277
+ ): FrontendServiceGetPriceToBookResult {
278
+ const cleaningFeePerBookingCents =
279
+ serviceRatesAssociation?.serviceGeneralRates?.cleaningFeePerBookingCents ??
280
+ 0;
281
+
282
+ const feesPerBooking = (
283
+ [
284
+ {
285
+ feeType: ServiceBookingFeeType.CleaningFee,
286
+ name: "Cleaning Fee",
287
+ description: "Cleaning Fee",
288
+ costPQCents: cleaningFeePerBookingCents,
289
+ quantity: 1,
290
+ },
291
+ ] as ServiceBookingFeeBase[]
292
+ ).filter((fee) => fee.costPQCents > 0);
293
+
294
+ const daysToBookParams = bookingDateTimeRanges.map(
295
+ (dtRange): FrontendServiceBookingDayInfoParams => {
296
+ return {
297
+ dateTimeRange: dtRange,
298
+ fees: feesPerBooking,
299
+ addOns: addOns,
300
+ };
301
+ }
302
+ );
303
+
304
+ return frontendServiceGetPriceToBookFees(serviceRatesAssociation, {
305
+ daysToBookParams: daysToBookParams,
306
+ selectedAttendeeOption: selectedAttendeeOption,
307
+ additionalFees: [],
308
+ timezone: timezone,
309
+ });
310
+ }