from typing import Optional from .math_round_div import round_div from .money_add import subtract_money from .money_amount import Money, assert_same_currency, money from .money_compare import compare_money from .retail_loyalty_points_types import LoyaltyScheme, LoyaltyOutcome def _whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def loyalty_points( scheme: LoyaltyScheme, spend: Money, qualifying_spend: Money, balance: int, redeem_points: int, ) -> LoyaltyOutcome: """Redeem points against a purchase, then earn on the amount actually paid, at the member's tier multiplier. """ for other in [scheme.earn_per, scheme.block_value, qualifying_spend] + [t.threshold for t in scheme.tiers]: assert_same_currency(spend, other) if spend.minor < 0: raise ValueError("spend must not be negative, received %d" % spend.minor) if scheme.earn_per.minor <= 0: raise ValueError("earnPer must be more than 0, received %d" % scheme.earn_per.minor) if scheme.earn_points < 0: raise ValueError("earnPoints must not be negative, received %d" % scheme.earn_points) if scheme.redeem_block < 1: raise ValueError("redeemBlock must be 1 or more, received %d" % scheme.redeem_block) if not _whole(balance) or balance < 0: raise ValueError("balance must not be negative, received %r" % (balance,)) if not _whole(redeem_points) or redeem_points < 0: raise ValueError("redeemPoints must not be negative, received %r" % (redeem_points,)) if redeem_points > balance: raise ValueError("cannot redeem %d points from a balance of %d" % (redeem_points, balance)) if redeem_points % scheme.redeem_block != 0: raise ValueError("points are redeemed in blocks of %d, received %d" % (scheme.redeem_block, redeem_points)) discount = money(redeem_points // scheme.redeem_block * scheme.block_value.minor, spend.currency) if compare_money(discount, spend) > 0: raise ValueError( "redeeming %d points is worth %d, more than the spend of %d" % (redeem_points, discount.minor, spend.minor) ) amount_payable = subtract_money(spend, discount) tier: Optional[str] = None multiplier_basis_points = 10000 best: Optional[Money] = None for t in scheme.tiers: if compare_money(t.threshold, qualifying_spend) > 0: continue if best is None or compare_money(t.threshold, best) > 0: best = t.threshold tier = t.name multiplier_basis_points = t.multiplier_basis_points base = round_div(amount_payable.minor * scheme.earn_points, scheme.earn_per.minor, scheme.earn_rounding) earned_points = round_div(base * multiplier_basis_points, 10000, scheme.earn_rounding) return LoyaltyOutcome( tier=tier, multiplier_basis_points=multiplier_basis_points, redeemed_points=redeem_points, discount=discount, amount_payable=amount_payable, earned_points=earned_points, new_balance=balance - redeem_points + earned_points, )