from typing import Optional, Sequence from .dates_days_between import days_between from .hospitality_cancellation_charge_types import CancellationBand, CancellationCharge from .money_amount import Money from .money_apply_rate import apply_rate from .money_sum import sum_money def _whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def cancellation_charge( policy: Sequence[CancellationBand], nightly_rates: Sequence[Money], arrival: str, cancelled_on: str, ) -> CancellationCharge: """The cancellation fee a policy sets for the notice given. Notice is counted in calendar days from the day of cancelling to the day of arrival, so cancelling on 4 December for an 18 December arrival is 14 days. The band with the most notice that the cancellation still meets applies. A cancellation on or after the arrival day is 0 days' notice, the no-show band. """ if len(policy) == 0: raise ValueError("policy must have at least one band") if len(nightly_rates) == 0: raise ValueError("nightlyRates must have at least one night") seen = set() for band in policy: if not _whole(band.min_days_notice) or band.min_days_notice < 0: raise ValueError("minDaysNotice must not be negative, received %s" % (band.min_days_notice,)) if band.min_days_notice in seen: raise ValueError("policy has two bands for %d days' notice" % (band.min_days_notice,)) seen.add(band.min_days_notice) if band.nights is not None and (not _whole(band.nights) or band.nights < 1): raise ValueError("nights must be at least 1 or null for the whole stay, received %s" % (band.nights,)) if not _whole(band.basis_points) or band.basis_points < 0 or band.basis_points > 10000: raise ValueError( "basisPoints must be a whole number from 0 to 10000, received %s" % (band.basis_points,) ) days_notice = days_between(cancelled_on, arrival) effective = max(days_notice, 0) chosen: Optional[CancellationBand] = None for band in policy: if band.min_days_notice <= effective and (chosen is None or band.min_days_notice > chosen.min_days_notice): chosen = band if chosen is None: raise ValueError("no band in the policy covers %d days' notice" % (effective,)) charged_nights = len(nightly_rates) if chosen.nights is None else min(chosen.nights, len(nightly_rates)) currency = nightly_rates[0].currency charge_base = sum_money(list(nightly_rates[:charged_nights]), currency) # The whole stay is checked for one currency, not only the nights charged. sum_money(nightly_rates, currency) return CancellationCharge( days_notice=days_notice, min_days_notice=chosen.min_days_notice, charged_nights=charged_nights, charge_base=charge_base, basis_points=chosen.basis_points, charge=apply_rate(charge_base, chosen.basis_points, "half-up"), )