@bash-app/bash-common 30.183.0 → 30.193.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 (68) hide show
  1. package/dist/__tests__/stripeListingSubscriptionMessages.test.d.ts +2 -0
  2. package/dist/__tests__/stripeListingSubscriptionMessages.test.d.ts.map +1 -0
  3. package/dist/__tests__/stripeListingSubscriptionMessages.test.js +10 -0
  4. package/dist/__tests__/stripeListingSubscriptionMessages.test.js.map +1 -0
  5. package/dist/extendedSchemas.d.ts +138 -0
  6. package/dist/extendedSchemas.d.ts.map +1 -1
  7. package/dist/extendedSchemas.js +3 -0
  8. package/dist/extendedSchemas.js.map +1 -1
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/membershipDefinitions.d.ts +5 -0
  14. package/dist/membershipDefinitions.d.ts.map +1 -1
  15. package/dist/membershipDefinitions.js +12 -7
  16. package/dist/membershipDefinitions.js.map +1 -1
  17. package/dist/stripeListingSubscriptionMessages.d.ts +7 -0
  18. package/dist/stripeListingSubscriptionMessages.d.ts.map +1 -0
  19. package/dist/stripeListingSubscriptionMessages.js +13 -0
  20. package/dist/stripeListingSubscriptionMessages.js.map +1 -0
  21. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.d.ts +6 -0
  22. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.d.ts.map +1 -0
  23. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.js +104 -0
  24. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.js.map +1 -0
  25. package/dist/utils/addressUtils.d.ts.map +1 -1
  26. package/dist/utils/addressUtils.js +157 -54
  27. package/dist/utils/addressUtils.js.map +1 -1
  28. package/dist/utils/discountEngine/__tests__/eligibilityValidator.test.js +6 -2
  29. package/dist/utils/discountEngine/__tests__/eligibilityValidator.test.js.map +1 -1
  30. package/dist/utils/mediaClientDefaults.d.ts +7 -0
  31. package/dist/utils/mediaClientDefaults.d.ts.map +1 -0
  32. package/dist/utils/mediaClientDefaults.js +7 -0
  33. package/dist/utils/mediaClientDefaults.js.map +1 -0
  34. package/dist/utils/service/__tests__/partnerServiceProfileWizardPaymentMethod.test.d.ts +2 -0
  35. package/dist/utils/service/__tests__/partnerServiceProfileWizardPaymentMethod.test.d.ts.map +1 -0
  36. package/dist/utils/service/__tests__/partnerServiceProfileWizardPaymentMethod.test.js +18 -0
  37. package/dist/utils/service/__tests__/partnerServiceProfileWizardPaymentMethod.test.js.map +1 -0
  38. package/dist/utils/service/cancellationPolicyRefundResolver.d.ts +30 -0
  39. package/dist/utils/service/cancellationPolicyRefundResolver.d.ts.map +1 -0
  40. package/dist/utils/service/cancellationPolicyRefundResolver.js +82 -0
  41. package/dist/utils/service/cancellationPolicyRefundResolver.js.map +1 -0
  42. package/dist/utils/service/frontendServiceBookingUtils.d.ts +0 -1
  43. package/dist/utils/service/frontendServiceBookingUtils.d.ts.map +1 -1
  44. package/dist/utils/service/frontendServiceBookingUtils.js +3 -9
  45. package/dist/utils/service/frontendServiceBookingUtils.js.map +1 -1
  46. package/dist/utils/service/serviceUtils.d.ts +7 -0
  47. package/dist/utils/service/serviceUtils.d.ts.map +1 -1
  48. package/dist/utils/service/serviceUtils.js +173 -11
  49. package/dist/utils/service/serviceUtils.js.map +1 -1
  50. package/dist/utils/slugUtils.d.ts +5 -0
  51. package/dist/utils/slugUtils.d.ts.map +1 -1
  52. package/dist/utils/slugUtils.js +12 -0
  53. package/dist/utils/slugUtils.js.map +1 -1
  54. package/package.json +3 -2
  55. package/prisma/schema.prisma +6 -0
  56. package/src/__tests__/stripeListingSubscriptionMessages.test.ts +20 -0
  57. package/src/extendedSchemas.ts +3 -0
  58. package/src/index.ts +3 -0
  59. package/src/membershipDefinitions.ts +13 -7
  60. package/src/stripeListingSubscriptionMessages.ts +18 -0
  61. package/src/utils/addressUtils.ts +175 -59
  62. package/src/utils/discountEngine/__tests__/eligibilityValidator.test.ts +6 -2
  63. package/src/utils/mediaClientDefaults.ts +6 -0
  64. package/src/utils/service/__tests__/partnerServiceProfileWizardPaymentMethod.test.ts +29 -0
  65. package/src/utils/service/cancellationPolicyRefundResolver.ts +112 -0
  66. package/src/utils/service/frontendServiceBookingUtils.ts +6 -11
  67. package/src/utils/service/serviceUtils.ts +215 -15
  68. package/src/utils/slugUtils.ts +16 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * User-facing copy for listing-subscription (Connect billing customer) payment flows.
3
+ * Kept in bash-common so api and bash-app stay aligned without casts.
4
+ */
5
+
6
+ const STRIPE_ALREADY_ATTACHED_SUBSTR = /already been attached to a customer/i;
7
+
8
+ export const LISTING_SUBSCRIPTION_PAYMENT_ALREADY_LINKING_USER_MESSAGE =
9
+ "Your card is still linking to billing. Wait a few seconds and tap Confirm again.";
10
+
11
+ export function userFacingMessageForListingSubscriptionStripeError(
12
+ raw: string
13
+ ): string {
14
+ if (STRIPE_ALREADY_ATTACHED_SUBSTR.test(raw)) {
15
+ return LISTING_SUBSCRIPTION_PAYMENT_ALREADY_LINKING_USER_MESSAGE;
16
+ }
17
+ return raw;
18
+ }
@@ -14,17 +14,98 @@ export function addressValuesToDatabaseAddressString(addressValues: IAddress): s
14
14
  return [place, street, city, state, zipCode, country].join(ADDRESS_DELIM);
15
15
  }
16
16
 
17
+ /**
18
+ * Older Where step saved locations with a " • " join and dropped empty segments,
19
+ * so the value no longer contained `|` and the full address was mis-parsed as `place`.
20
+ */
21
+ function parseLegacyBulletDelimitedAddress(trimmed: string): IAddress | null {
22
+ if (!trimmed.includes(' • ')) {
23
+ return null;
24
+ }
25
+ const parts = trimmed.split(' • ').map((p) => p.trim()).filter((p) => p.length > 0);
26
+ const zipLike = (s: string) => /^\d{5}(-\d{4})?$/.test(s);
27
+
28
+ if (parts.length === 6) {
29
+ return {
30
+ place: parts[0] ?? '',
31
+ street: parts[1] ?? '',
32
+ city: parts[2] ?? '',
33
+ state: parts[3] ?? '',
34
+ zipCode: parts[4] ?? '',
35
+ country: parts[5] ?? 'USA',
36
+ };
37
+ }
38
+
39
+ const zip3 = parts[3] ?? '';
40
+ const zip4 = parts[4] ?? '';
41
+ // Typical corruption: empty place dropped → "street • city • state • zip • USA"
42
+ if (
43
+ parts.length === 5 &&
44
+ zipLike(zip3) &&
45
+ /^(USA|United States)$/i.test(zip4)
46
+ ) {
47
+ return {
48
+ place: '',
49
+ street: parts[0] ?? '',
50
+ city: parts[1] ?? '',
51
+ state: parts[2] ?? '',
52
+ zipCode: parts[3] ?? '',
53
+ country: 'USA',
54
+ };
55
+ }
56
+
57
+ // "place • street • city • state • zip" (no country segment)
58
+ if (parts.length === 5 && zipLike(zip4)) {
59
+ return {
60
+ place: parts[0] ?? '',
61
+ street: parts[1] ?? '',
62
+ city: parts[2] ?? '',
63
+ state: parts[3] ?? '',
64
+ zipCode: parts[4] ?? '',
65
+ country: 'USA',
66
+ };
67
+ }
68
+
69
+ if (parts.length === 4 && zipLike(parts[3] ?? '')) {
70
+ return {
71
+ place: '',
72
+ street: parts[0] ?? '',
73
+ city: parts[1] ?? '',
74
+ state: parts[2] ?? '',
75
+ zipCode: parts[3] ?? '',
76
+ country: 'USA',
77
+ };
78
+ }
79
+
80
+ return null;
81
+ }
82
+
17
83
  export function databaseAddressStringToAddressValues(addressString: string | undefined | null): IAddress {
18
84
  if (addressString) {
19
- const addressArray = addressString.split(ADDRESS_DELIM);
20
- return {
21
- place: addressArray[0],
22
- street: addressArray[1],
23
- city: addressArray[2],
24
- state: addressArray[3],
25
- zipCode: addressArray[4],
26
- country: 'USA'
85
+ const trimmed = addressString.trim();
86
+ if (trimmed.includes(ADDRESS_DELIM)) {
87
+ const addressArray = trimmed.split(ADDRESS_DELIM);
88
+ return {
89
+ place: addressArray[0] ?? '',
90
+ street: addressArray[1] ?? '',
91
+ city: addressArray[2] ?? '',
92
+ state: addressArray[3] ?? '',
93
+ zipCode: addressArray[4] ?? '',
94
+ country: addressArray[5]?.trim() || 'USA',
95
+ };
96
+ }
97
+ const legacyBullet = parseLegacyBulletDelimitedAddress(trimmed);
98
+ if (legacyBullet) {
99
+ return legacyBullet;
27
100
  }
101
+ return {
102
+ place: trimmed,
103
+ street: '',
104
+ city: '',
105
+ state: '',
106
+ zipCode: '',
107
+ country: '',
108
+ };
28
109
  }
29
110
  return { place: '', street: '', city: '', state: '', zipCode: '', country: '' };
30
111
  }
@@ -46,54 +127,45 @@ export function formatLocalityForCardDisplay(
46
127
  }
47
128
 
48
129
  export function databaseAddressStringToOneLineString(addressString: string | undefined | null): string {
49
- if (addressString) {
50
- // Special handling for place-only addresses
51
- if (!addressString.includes(ADDRESS_DELIM)) {
52
- return addressString.trim();
53
- }
54
-
55
- const address = databaseAddressStringToAddressValues(addressString);
56
-
57
- // If only place exists, just return it
58
- if (address.place &&
59
- (!address.street || address.street === 'undefined') &&
60
- (!address.city || address.city === 'undefined') &&
61
- (!address.state || address.state === 'undefined')) {
62
- return address.place.trim();
63
- }
64
-
65
- let addressArr = address.place ? [address.place] : [];
66
-
67
- // Add non-empty and non-undefined parts
68
- if (address.street && address.street !== 'undefined') addressArr.push(address.street);
69
- if (address.city && address.city !== 'undefined') addressArr.push(address.city);
70
- if (address.state && address.state !== 'undefined') addressArr.push(address.state);
71
-
72
- const addressStr = addressArr.filter(str => !!str && str !== 'undefined').join(', ');
73
-
74
- // Only add zip code if it exists and isn't 'undefined'
75
- return address.zipCode && address.zipCode !== 'undefined'
76
- ? `${addressStr} ${address.zipCode}`
77
- : addressStr;
130
+ if (!addressString) {
131
+ return '';
132
+ }
133
+ const address = databaseAddressStringToAddressValues(addressString);
134
+
135
+ // If only place exists, just return it (includes place-only strings with no delimiters)
136
+ if (
137
+ address.place &&
138
+ (!address.street || address.street === 'undefined') &&
139
+ (!address.city || address.city === 'undefined') &&
140
+ (!address.state || address.state === 'undefined')
141
+ ) {
142
+ return address.place.trim();
78
143
  }
79
- return '';
144
+
145
+ let addressArr = address.place ? [address.place] : [];
146
+
147
+ if (address.street && address.street !== 'undefined') addressArr.push(address.street);
148
+ if (address.city && address.city !== 'undefined') addressArr.push(address.city);
149
+ if (address.state && address.state !== 'undefined') addressArr.push(address.state);
150
+
151
+ const addressStr = addressArr.filter((str) => !!str && str !== 'undefined').join(', ');
152
+
153
+ return address.zipCode && address.zipCode !== 'undefined'
154
+ ? `${addressStr} ${address.zipCode}`
155
+ : addressStr;
80
156
  }
81
157
 
82
158
  export function databaseAddressStringToDisplayString(addressString: string | undefined | null): string {
83
- if (addressString) {
84
- // Special handling for place-only addresses (no delimiters)
85
- if (!addressString.includes(ADDRESS_DELIM)) {
86
- return addressString.trim();
87
- }
88
-
89
- const oneLineString = databaseAddressStringToOneLineString(addressString);
90
- const formatted = oneLineString.replace(/([A-Z])(?=[A-Z][a-z])/g, "$1 ") // Add space between a single uppercase letter and a capitalized word
91
- .replace(/(\d)(?=[A-Z])/g, "$1 ") // Add space between numbers and letters
92
- .replace(/,/g, ", ") // Add space after commas
93
- .replace(/undefined/g, ""); // Remove any "undefined" strings
94
- return formatted;
159
+ if (!addressString) {
160
+ return '';
95
161
  }
96
- return '';
162
+ const oneLineString = databaseAddressStringToOneLineString(addressString);
163
+ const formatted = oneLineString
164
+ .replace(/([A-Z])(?=[A-Z][a-z])/g, '$1 ') // Add space between a single uppercase letter and a capitalized word
165
+ .replace(/(\d)(?=[A-Z])/g, '$1 ') // Add space between numbers and letters
166
+ .replace(/,/g, ', ') // Add space after commas
167
+ .replace(/undefined/g, ''); // Remove any "undefined" strings
168
+ return formatted;
97
169
  }
98
170
 
99
171
  export function addressToDisplayString(address: IAddress): string {
@@ -104,32 +176,76 @@ export function addressToDisplayString(address: IAddress): string {
104
176
  }
105
177
 
106
178
 
179
+ /** Prefer a precise street-level result; `results[0]` is often a coarse area (wrong street line). */
180
+ function pickBestGeocodeResult(
181
+ results: Array<{
182
+ types?: string[];
183
+ geometry?: { location_type?: string };
184
+ address_components?: GoogleAddressComponent[];
185
+ formatted_address?: string;
186
+ }>
187
+ ) {
188
+ if (!results?.length) return undefined;
189
+ const streetAddress = results.find((r) => r.types?.includes("street_address"));
190
+ if (streetAddress) return streetAddress;
191
+ const premise = results.find((r) => r.types?.includes("premise"));
192
+ if (premise) return premise;
193
+ const rooftop = results.find((r) => r.geometry?.location_type === "ROOFTOP");
194
+ if (rooftop) return rooftop;
195
+ const interpolated = results.find(
196
+ (r) => r.geometry?.location_type === "RANGE_INTERPOLATED"
197
+ );
198
+ if (interpolated) return interpolated;
199
+ return results[0];
200
+ }
201
+
107
202
  export async function getAddressFromCoordinates( lat: number, lng: number ): Promise<IAddress> {
108
203
  const apiUrl = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${googleMapsApiKey}&loading=async`;
109
204
  try {
110
205
  const response = await fetch(apiUrl);
111
206
  const data = await response.json();
112
207
  if (data.results.length > 0) {
113
- const addressComponents = data.results[0].address_components;
208
+ const firstResult = pickBestGeocodeResult(data.results);
209
+ if (!firstResult?.address_components) {
210
+ throw new Error("No address found");
211
+ }
212
+ const addressComponents = firstResult.address_components;
114
213
 
115
- let street = "";
214
+ let streetNumber = "";
215
+ let route = "";
116
216
  let city = "";
117
217
  let state = "";
118
218
  let zipCode = "";
119
219
 
120
220
  addressComponents.forEach((component: GoogleAddressComponent) => {
121
- if (component.types.includes("route")) {
122
- street = component.long_name;
123
- } else if (component.types.includes("locality")) {
221
+ const types = component.types;
222
+ if (types.includes("street_number")) {
223
+ streetNumber = component.long_name;
224
+ }
225
+ if (types.includes("route")) {
226
+ route = component.long_name;
227
+ } else if (types.includes("locality")) {
124
228
  city = component.long_name;
125
- } else if (component.types.includes("administrative_area_level_1")) {
229
+ } else if (types.includes("administrative_area_level_1")) {
126
230
  state = component.short_name;
127
- } else if (component.types.includes("postal_code")) {
231
+ } else if (types.includes("postal_code")) {
128
232
  zipCode = component.long_name;
129
233
  }
130
234
  });
131
235
 
132
- return { place: '', street, city, state, zipCode, country: 'USA' };
236
+ // Match extractAddressComponents: US addresses split street across street_number + route.
237
+ let street = `${streetNumber} ${route}`.trim();
238
+ if (!street && firstResult.formatted_address) {
239
+ const firstSeg = firstResult.formatted_address.split(",")[0]?.trim() ?? "";
240
+ if (firstSeg && city && !firstSeg.toLowerCase().includes(city.toLowerCase())) {
241
+ street = firstSeg;
242
+ } else if (firstSeg && !city) {
243
+ street = firstSeg;
244
+ }
245
+ }
246
+
247
+ // Place is intentionally empty here — only filled when user picks a Place in Maps UI or types it.
248
+ return { place: "", street, city, state, zipCode, country: "USA" };
133
249
  } else {
134
250
  throw new Error("No address found");
135
251
  }
@@ -645,11 +645,15 @@ describe("eligibilityValidator", () => {
645
645
  const currentDate = new Date("2024-07-15T23:59:59Z");
646
646
  const expiryDate = new Date("2024-07-16T00:00:01Z");
647
647
 
648
- const offer = { id: "offer-1", endDate: expiryDate } as any;
648
+ const offer = {
649
+ id: "offer-1",
650
+ isActive: true,
651
+ endDate: expiryDate,
652
+ } as any;
649
653
 
650
654
  const result = validateOfferEligibility(offer, "user-1", 2, currentDate, 0);
651
655
 
652
- expect(result.eligible).toBe(true); // Still valid by 2 seconds
656
+ expect(result.eligible).toBe(true); // Still valid by 2 seconds (UTC)
653
657
  });
654
658
  });
655
659
  });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shared defaults for browser-side image compression (e.g. bash-app uploads).
3
+ * Env-specific values (max MB) stay in the app; dimensions/worker flags live here.
4
+ */
5
+ export const CLIENT_IMAGE_COMPRESSION_MAX_WIDTH_OR_HEIGHT = 1920;
6
+ export const CLIENT_IMAGE_COMPRESSION_USE_WEB_WORKER = true;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Contract for Vendor / Exhibitor / Sponsor wizard “How” payment step (major 6, sub 3).
3
+ */
4
+ import {
5
+ PARTNER_SERVICE_PROFILE_WIZARD_PAYMENT_METHOD_MAJOR_STEP,
6
+ PARTNER_SERVICE_PROFILE_WIZARD_PAYMENT_METHOD_SUB_STEP,
7
+ partnerServiceProfileWizardPaymentMethodUrl,
8
+ } from "../serviceUtils";
9
+
10
+ describe("partnerServiceProfileWizardPaymentMethodUrl", () => {
11
+ it("uses How step 6 and sub-step 3", () => {
12
+ expect(PARTNER_SERVICE_PROFILE_WIZARD_PAYMENT_METHOD_MAJOR_STEP).toBe(6);
13
+ expect(PARTNER_SERVICE_PROFILE_WIZARD_PAYMENT_METHOD_SUB_STEP).toBe(3);
14
+ });
15
+
16
+ it.each([
17
+ ["Vendors", "s1", "p1"],
18
+ ["Exhibitors", "s2", "p2"],
19
+ ["Sponsors", "s3", "p3"],
20
+ ] as const)("builds wizard URL for %s", (serviceType, serviceId, specificId) => {
21
+ expect(
22
+ partnerServiceProfileWizardPaymentMethodUrl(
23
+ serviceId,
24
+ serviceType,
25
+ specificId
26
+ )
27
+ ).toBe(`/wz/services/${serviceId}/${serviceType}/${specificId}/6/3`);
28
+ });
29
+ });
@@ -0,0 +1,112 @@
1
+ import { ServiceCancellationPolicy } from "@prisma/client";
2
+ import {
3
+ SERVICE_CANCELLATION_POLICY_DATA,
4
+ type ServiceCancellationRefundPolicy,
5
+ } from "./serviceUtils.js";
6
+
7
+ /**
8
+ * Converts a tier threshold from SERVICE_CANCELLATION_POLICY_DATA to hours so
9
+ * rules that mix `days` and `hours` are compared on one timeline.
10
+ */
11
+ export function cancellationRuleThresholdHours(
12
+ rule: ServiceCancellationRefundPolicy
13
+ ): number {
14
+ if (rule.days !== undefined) {
15
+ return rule.days * 24;
16
+ }
17
+ if (rule.hours !== undefined) {
18
+ return rule.hours;
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ export type ResolveCancellationRefundOptions = {
24
+ /** Event start time (UTC or local — caller must be consistent). */
25
+ eventStart: Date;
26
+ /** Defaults to `new Date()`. */
27
+ now?: Date;
28
+ };
29
+
30
+ /**
31
+ * Hours from `now` until `eventStart` (non-negative). Returns 0 if the event
32
+ * has started or `eventStart` is invalid.
33
+ */
34
+ export function hoursUntilEventStart(
35
+ eventStart: Date,
36
+ now: Date = new Date()
37
+ ): number {
38
+ const ms = eventStart.getTime() - now.getTime();
39
+ if (ms <= 0 || Number.isNaN(ms)) {
40
+ return 0;
41
+ }
42
+ return ms / (1000 * 60 * 60);
43
+ }
44
+
45
+ /**
46
+ * Resolves refund as a fraction in [0, 1] from {@link SERVICE_CANCELLATION_POLICY_DATA}.
47
+ * For each rule, if time until start is at least that rule's threshold, the rule
48
+ * applies; when multiple apply, the highest `refundPercentage` wins (matches
49
+ * tiered "full → partial → none" semantics).
50
+ */
51
+ export function resolveCancellationRefundFraction(
52
+ policy: ServiceCancellationPolicy | null | undefined,
53
+ options: ResolveCancellationRefundOptions
54
+ ): number {
55
+ if (policy == null || policy === ServiceCancellationPolicy.None) {
56
+ return 0;
57
+ }
58
+ const data = SERVICE_CANCELLATION_POLICY_DATA[policy];
59
+ if (!data?.refundPolicy?.length) {
60
+ return 0;
61
+ }
62
+
63
+ const now = options.now ?? new Date();
64
+ const hoursUntil = hoursUntilEventStart(options.eventStart, now);
65
+ if (hoursUntil <= 0) {
66
+ return 0;
67
+ }
68
+
69
+ let bestPercent = 0;
70
+ for (const rule of data.refundPolicy) {
71
+ const thresholdHours = cancellationRuleThresholdHours(rule);
72
+ if (hoursUntil >= thresholdHours && rule.refundPercentage > bestPercent) {
73
+ bestPercent = rule.refundPercentage;
74
+ }
75
+ }
76
+
77
+ return bestPercent / 100;
78
+ }
79
+
80
+ /**
81
+ * The winning tier rule for display (e.g. service booking UI), or null if none.
82
+ */
83
+ export function getMatchingCancellationRefundRule(
84
+ policy: ServiceCancellationPolicy | null | undefined,
85
+ options: ResolveCancellationRefundOptions
86
+ ): ServiceCancellationRefundPolicy | null {
87
+ if (policy == null || policy === ServiceCancellationPolicy.None) {
88
+ return null;
89
+ }
90
+ const data = SERVICE_CANCELLATION_POLICY_DATA[policy];
91
+ if (!data?.refundPolicy?.length) {
92
+ return null;
93
+ }
94
+
95
+ const now = options.now ?? new Date();
96
+ const hoursUntil = hoursUntilEventStart(options.eventStart, now);
97
+ if (hoursUntil <= 0) {
98
+ return null;
99
+ }
100
+
101
+ let bestRule: ServiceCancellationRefundPolicy | null = null;
102
+ let bestPercent = 0;
103
+ for (const rule of data.refundPolicy) {
104
+ const thresholdHours = cancellationRuleThresholdHours(rule);
105
+ if (hoursUntil >= thresholdHours && rule.refundPercentage > bestPercent) {
106
+ bestPercent = rule.refundPercentage;
107
+ bestRule = rule;
108
+ }
109
+ }
110
+
111
+ return bestRule;
112
+ }
@@ -32,14 +32,10 @@ import {
32
32
  ServiceBookingPriceBreakdownBaseT,
33
33
  ServiceBookingAddOnBase,
34
34
  } from "./serviceBookingTypes.js";
35
- import { convertDollarsToCents } from "../paymentUtils.js";
36
- import { SERVICE_BOOKING_FEES } from "../../membershipDefinitions.js";
37
-
38
- // Use centralized service fee percentage (2.9% Stripe fee for Basic tier)
39
- // Note: This is ONLY the Stripe payment processing fee (2.9% + $0.30).
40
- // We charge 0% platform fee - this is transparent pass-through pricing.
41
- // See SERVICE_BOOKING_FEES in membershipDefinitions.ts for all tier-based fees.
42
- export const SERVICE_BOOKING_PROCESSING_FEE_PERCENT = SERVICE_BOOKING_FEES.Basic.percent;
35
+ import {
36
+ calculateStripeProcessingFeeCents,
37
+ convertDollarsToCents,
38
+ } from "../paymentUtils.js";
43
39
 
44
40
  export interface ServiceAddonInput extends ServiceAddon {
45
41
  chosenQuantity?: number;
@@ -293,10 +289,9 @@ export function frontendServiceGetPriceToBookFees(
293
289
  const subtotalBeforeTaxesCents = daysTotalBeforeTaxesCents + addOnsTotalCents;
294
290
 
295
291
  if (!hasCustomProcessingFee) {
296
- // Calculate Stripe's processing fee: 2.9% + $0.30
292
+ // Same pass-through formula as bash ticket/donation checkout (STRIPE_PROCESSING_FEE).
297
293
  const processingFeeCents =
298
- Math.floor(SERVICE_BOOKING_PROCESSING_FEE_PERCENT * subtotalBeforeTaxesCents) +
299
- SERVICE_BOOKING_FEES.Basic.fixedCents;
294
+ calculateStripeProcessingFeeCents(subtotalBeforeTaxesCents);
300
295
 
301
296
  allAdditionalFees.push({
302
297
  feeType: ServiceBookingFeeType.ProcessingFee,