@revolugo/common 7.12.0-alpha.4 → 7.12.0-alpha.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revolugo/common",
3
- "version": "7.12.0-alpha.4",
3
+ "version": "7.12.0-alpha.6",
4
4
  "private": false,
5
5
  "description": "Revolugo common",
6
6
  "author": "Revolugo",
@@ -5,6 +5,7 @@ import { describe, expect, test } from 'vitest'
5
5
  import {
6
6
  getCurrentPenaltyPercentage,
7
7
  getPenaltyPercentage,
8
+ isFreelyCancellableUntilHoursBeforeCheckIn,
8
9
  sanitizeCancellationPolicies,
9
10
  } from './cancellation-policies.ts'
10
11
  import { type Dayjs, dayjs } from './utils/dayjs.ts'
@@ -1044,3 +1045,162 @@ describe('getCurrentPenaltyPercentage', () => {
1044
1045
  ).to.equal(PP_4)
1045
1046
  })
1046
1047
  })
1048
+
1049
+ describe('isFreelyCancellableUntilHoursBeforeCheckIn', () => {
1050
+ // Check-in is at the hotel's local midnight; policy dates are anchored to it.
1051
+ const cancelTimezone = 'Europe/Paris'
1052
+ const checkInDate = '2025-09-18'
1053
+ const freeFrom = '2025-08-01T00:00:00Z'
1054
+ // Local midnight of check-in in Paris (UTC+2 in September) expressed in UTC.
1055
+ const checkInInstant = dayjs.tz(checkInDate, cancelTimezone)
1056
+
1057
+ function freeUntil(dateTo: string): ICancellationPolicy[] {
1058
+ return [{ dateFrom: freeFrom, dateTo, penaltyPercentage: 0 }]
1059
+ }
1060
+
1061
+ test('offers Flex when there are no policies', () => {
1062
+ expect(
1063
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1064
+ cancellationPolicies: [],
1065
+ checkInDate,
1066
+ hoursBeforeCheckIn: 48,
1067
+ timezone: cancelTimezone,
1068
+ }),
1069
+ ).toBe(false)
1070
+ })
1071
+
1072
+ test('offers Flex when the booking is non-refundable from the start', () => {
1073
+ expect(
1074
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1075
+ cancellationPolicies: [
1076
+ { dateFrom: freeFrom, dateTo: checkInDate, penaltyPercentage: 100 },
1077
+ ],
1078
+ checkInDate,
1079
+ hoursBeforeCheckIn: 48,
1080
+ timezone: cancelTimezone,
1081
+ }),
1082
+ ).toBe(false)
1083
+ })
1084
+
1085
+ test('offers Flex when the free window stops short of the 48h mark', () => {
1086
+ // Free only until 7 days before check-in → Flex extends the free window.
1087
+ const dateTo = checkInInstant.subtract(7, 'days').format()
1088
+ expect(
1089
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1090
+ cancellationPolicies: freeUntil(dateTo),
1091
+ checkInDate,
1092
+ hoursBeforeCheckIn: 48,
1093
+ timezone: cancelTimezone,
1094
+ }),
1095
+ ).toBe(false)
1096
+ })
1097
+
1098
+ test('suppresses Flex when free continuously until exactly the 48h mark', () => {
1099
+ const dateTo = checkInInstant.subtract(48, 'hours').format()
1100
+ expect(
1101
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1102
+ cancellationPolicies: freeUntil(dateTo),
1103
+ checkInDate,
1104
+ hoursBeforeCheckIn: 48,
1105
+ timezone: cancelTimezone,
1106
+ }),
1107
+ ).toBe(true)
1108
+ })
1109
+
1110
+ test('suppresses Flex when the free window reaches closer to check-in than 48h', () => {
1111
+ const dateTo = checkInInstant.subtract(24, 'hours').format()
1112
+ expect(
1113
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1114
+ cancellationPolicies: freeUntil(dateTo),
1115
+ checkInDate,
1116
+ hoursBeforeCheckIn: 48,
1117
+ timezone: cancelTimezone,
1118
+ }),
1119
+ ).toBe(true)
1120
+ })
1121
+
1122
+ test('offers Flex when a penalty head start precedes the free window', () => {
1123
+ // 50% early then free in the middle: Flex refunds that early period in full,
1124
+ // so it is strictly better and must be offered.
1125
+ expect(
1126
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1127
+ cancellationPolicies: [
1128
+ {
1129
+ dateFrom: '2026-06-04T00:00:00+00:00',
1130
+ dateTo: '2026-06-15T00:00:00+00:00',
1131
+ penaltyPercentage: 50,
1132
+ },
1133
+ {
1134
+ dateFrom: '2026-06-15T00:00:00+00:00',
1135
+ dateTo: '2026-06-16T00:00:00+00:00',
1136
+ penaltyPercentage: 0,
1137
+ },
1138
+ {
1139
+ dateFrom: '2026-06-16T00:00:00+00:00',
1140
+ dateTo: '2026-06-18T00:00:00+00:00',
1141
+ penaltyPercentage: 100,
1142
+ },
1143
+ ],
1144
+ checkInDate: '2026-06-18',
1145
+ hoursBeforeCheckIn: 48,
1146
+ timezone: 'UTC',
1147
+ }),
1148
+ ).toBe(false)
1149
+ })
1150
+
1151
+ test('suppresses Flex across two contiguous free windows reaching the 48h mark', () => {
1152
+ expect(
1153
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1154
+ cancellationPolicies: [
1155
+ {
1156
+ dateFrom: '2026-06-04T00:00:00+00:00',
1157
+ dateTo: '2026-06-10T00:00:00+00:00',
1158
+ penaltyPercentage: 0,
1159
+ },
1160
+ {
1161
+ dateFrom: '2026-06-10T00:00:00+00:00',
1162
+ dateTo: '2026-06-16T00:00:00+00:00',
1163
+ penaltyPercentage: 0,
1164
+ },
1165
+ {
1166
+ dateFrom: '2026-06-16T00:00:00+00:00',
1167
+ dateTo: '2026-06-18T00:00:00+00:00',
1168
+ penaltyPercentage: 100,
1169
+ },
1170
+ ],
1171
+ checkInDate: '2026-06-18',
1172
+ hoursBeforeCheckIn: 48,
1173
+ timezone: 'UTC',
1174
+ }),
1175
+ ).toBe(true)
1176
+ })
1177
+
1178
+ test('suppresses Flex for a UTC+2 policy free until local midnight 48h before check-in', () => {
1179
+ // Real-world data: free window ends at 22:00Z = local midnight (UTC+2),
1180
+ // exactly 48h before a check-in stored as a bare date.
1181
+ expect(
1182
+ isFreelyCancellableUntilHoursBeforeCheckIn({
1183
+ cancellationPolicies: [
1184
+ {
1185
+ dateFrom: '2026-06-04T12:47:07+00:00',
1186
+ dateTo: '2026-06-15T22:00:00+00:00',
1187
+ penaltyPercentage: 0,
1188
+ },
1189
+ {
1190
+ dateFrom: '2026-06-15T22:00:00+00:00',
1191
+ dateTo: '2026-06-16T22:00:00+00:00',
1192
+ penaltyPercentage: 50,
1193
+ },
1194
+ {
1195
+ dateFrom: '2026-06-16T22:00:00+00:00',
1196
+ dateTo: '2026-06-17T22:00:00+00:00',
1197
+ penaltyPercentage: 100,
1198
+ },
1199
+ ],
1200
+ checkInDate: '2026-06-18',
1201
+ hoursBeforeCheckIn: 48,
1202
+ timezone: cancelTimezone,
1203
+ }),
1204
+ ).toBe(true)
1205
+ })
1206
+ })
@@ -1,3 +1,4 @@
1
+ // oxlint-disable max-lines
1
2
  import { compact } from './utils/compact.ts'
2
3
  import { type Dayjs, dayjs } from './utils/dayjs.ts'
3
4
  import { isEmpty } from './utils/is-empty.ts'
@@ -523,6 +524,67 @@ export function adjustForCustomers(
523
524
  }))
524
525
  }
525
526
 
527
+ /**
528
+ * Whether the native cancellation policy is at least as good as Revolugo Flex
529
+ * (Meetch): the booking can be cancelled for free continuously from booking time
530
+ * until `hoursBeforeCheckIn` before check-in. When it can, Flex — which only
531
+ * guarantees a full refund up to that same window — brings no extra benefit and
532
+ * must not be offered as an addon.
533
+ *
534
+ * Flex IS better (and must be offered) as soon as the native policy charges any
535
+ * penalty inside that window — e.g. a non-refundable/penalty head start before a
536
+ * free window, a gap in coverage, or a free window that stops short of the 48h
537
+ * mark.
538
+ *
539
+ * `checkInDate` is interpreted at the start of day in `timezone` so the
540
+ * threshold shares the same time base as the policy dates (which are anchored to
541
+ * the hotel's local midnight). Without this, a date-only check-in parsed as UTC
542
+ * drifts by the hotel's UTC offset and the comparison misses by a few hours.
543
+ */
544
+ export function isFreelyCancellableUntilHoursBeforeCheckIn({
545
+ cancellationPolicies,
546
+ checkInDate,
547
+ hoursBeforeCheckIn,
548
+ timezone,
549
+ }: {
550
+ cancellationPolicies: ICancellationPolicy[]
551
+ checkInDate: string
552
+ hoursBeforeCheckIn: number
553
+ timezone: string
554
+ }): boolean {
555
+ const threshold = dayjs
556
+ .tz(checkInDate, timezone)
557
+ .subtract(hoursBeforeCheckIn, 'hours')
558
+
559
+ const sanitizedPolicies = getSanitizedCancellationPolicies({
560
+ cancellationPolicies,
561
+ checkInDate,
562
+ timezone,
563
+ })
564
+
565
+ // Walk the policies from booking time, following an unbroken chain of free
566
+ // (0% penalty) windows. The first penalty or gap stops the chain.
567
+ let freeUntil: Dayjs | null = null
568
+ for (const policy of sanitizedPolicies) {
569
+ if (policy.penaltyPercentage !== 0) {
570
+ break
571
+ }
572
+ if (freeUntil && dayjs(policy.dateFrom).isAfter(freeUntil)) {
573
+ break
574
+ }
575
+
576
+ const dateTo = dayjs(policy.dateTo)
577
+ if (!freeUntil || dateTo.isAfter(freeUntil)) {
578
+ freeUntil = dateTo
579
+ }
580
+ if (freeUntil.isSameOrAfter(threshold)) {
581
+ return true
582
+ }
583
+ }
584
+
585
+ return false
586
+ }
587
+
526
588
  export function isFreeCancellable(cps: ICancellationPolicy[]): boolean {
527
589
  const freeCp = cps.find(cp => cp.penaltyPercentage === 0)
528
590
  return (