"""Offboarding-buyback price calculator. Computes the minimum buyback amount that Helix offers an employee for a laptop they keep on departure. The rule was handed down by IT policy (2026-05-12): * While the asset is **less than 36 months old**: linear depreciation — ``purchase_cost / 36 × (36 - months_used)`` in the asset's purchase currency. * When the asset is **36 months or older**: a fixed floor table keyed by the CNY-equivalent of the purchase price: ======================= =========================================== CNY-equivalent Floor (CNY / JPY / SGD / USD) ======================= =========================================== ``< 8 000`` ``400 / 8 000 / 75 / 55`` ``< 20 000`` ``500 / 10 400 / 95 / 73`` ``< 30 000`` ``1 000 / 20 000 / 185 / 135`` ``≥ 30 000`` ``5 000 / 100 000 / 950 / 700`` † ======================= =========================================== † Only ``CNY 5 000`` was specified by IT policy; the other three currencies are derived from CNY 5 000 using the same implied policy rates that tiers 1-3 use (see :data:`POLICY_FX_PER_CNY`). This keeps every supported currency answerable for every asset. **Tier-boundary interpretation** (chosen 2026-05-12, documented for auditability): the IT policy wording — "低于 8000 / 低于 19999 / 低于 29999 / 高于 30000" — is read as the natural round-number bands ``[0, 8000) / [8000, 20000) / [20000, 30000) / [30000, ∞)``. The "19999" and "29999" thresholds are interpreted as the highest integer price in the band (`≤ 19999` ≡ `< 20000` for integer CNY purchase prices, which is how procurement records them). The literal exclusive reading (`< 19999`) would leave undefined gaps at exact integer 19999, 29999, and 30000 — those prices would not match any tier — which the policy cannot intend. The :data:`TIER_BANDS` definition + per-tier test cases encode this interpretation; deviation from IT's intent will show up at exactly those boundary prices. The implied policy rates fall out of the floor table itself (they are internally consistent across tiers 1-3 to within rounding): * 1 CNY ≈ 20 JPY ≈ 0.19 SGD ≈ 0.14 USD We treat these as *policy* rates, not live FX — employees want a predictable buyback figure quoted up-front, not a number that drifts with the ECB feed. Live FX (``app/core/fx_rate``) is reserved for finance / cost reporting flows where accuracy beats stability. The module is pure-Python (no DB I/O); the calling endpoint loads the ``AssetItem`` and (optionally) the ``AssetLegalEntity`` and hands them to :func:`calculate_buyback`. """ from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import ROUND_HALF_UP, Decimal from typing import Literal # ─── Currency catalogue ───────────────────────────────────────────── # The four currencies are the ones IT policy enumerates floor values # for. Frontend currency-switcher MUST stay aligned with this list — # offering EUR / MYR / THB / CAD would route the employee to a value the # policy has no answer for. (MYR is a known gap: PINGCAP SDN. BHD. # assets are real and the Feishu offboarding_buyback widget offers MYR, # but IT policy has not handed down MYR floor values yet — until it # does, a MYR asset raises UNSUPPORTED_PURCHASE_CURRENCY rather than # being quoted against a fabricated floor. CAD was dropped 2026-08-04: # there is no Canada legal entity and asset intake never accepts CAD, # so no real asset could ever feed a CAD quote — it only survived as a # manual MCP override that the Feishu widget can't even render.) SUPPORTED_CURRENCIES: tuple[str, ...] = ("CNY", "USD", "JPY", "SGD") DEFAULT_CURRENCY = "CNY" # ─── Policy FX rates ──────────────────────────────────────────────── # Units: how many of equals 1 CNY. Derived from the # floor-table consistency across tiers 1-3 (see module docstring). # These are intentionally *not* the live ECB / frankfurter rates — see # the docstring rationale. POLICY_FX_PER_CNY: dict[str, Decimal] = { "CNY": Decimal("1.000"), "JPY": Decimal("20.000"), "SGD": Decimal("0.190"), "USD": Decimal("0.140"), } # ─── Floor table ──────────────────────────────────────────────────── # Tier 1: purchase_cost (CNY-equivalent) < 8 000 # Tier 2: purchase_cost (CNY-equivalent) < 20 000 # Tier 3: purchase_cost (CNY-equivalent) < 30 000 # Tier 4: purchase_cost (CNY-equivalent) ≥ 30 000 # Values for tiers 1-3 are taken verbatim from the IT policy. Tier 4 # values for non-CNY currencies are derived from CNY 5 000 via # :data:`POLICY_FX_PER_CNY` (policy specified CNY only). FLOOR_TABLE: dict[int, dict[str, Decimal]] = { 1: { "CNY": Decimal("400"), "JPY": Decimal("8000"), "SGD": Decimal("75"), "USD": Decimal("55"), }, 2: { "CNY": Decimal("500"), "JPY": Decimal("10400"), "SGD": Decimal("95"), "USD": Decimal("73"), }, 3: { "CNY": Decimal("1000"), "JPY": Decimal("20000"), "SGD": Decimal("185"), "USD": Decimal("135"), }, 4: { # Policy: CNY 5 000. Others = CNY 5 000 × policy rate, rounded # to the same scale as the tiers above (no decimals for JPY, # whole units for everything else). "CNY": Decimal("5000"), "JPY": Decimal("100000"), # 5000 × 20 "SGD": Decimal("950"), # 5000 × 0.19 "USD": Decimal("700"), # 5000 × 0.14 }, } # Tier classification bands keyed by purchase_cost in CNY-equivalent. # Upper-bound exclusive, lower-bound inclusive — matches the policy # wording ("< 8000", "< 20000", "< 30000", ">= 30000"). TIER_BANDS: tuple[tuple[int, Decimal], ...] = ( (1, Decimal("8000")), (2, Decimal("20000")), (3, Decimal("30000")), ) TOP_TIER = 4 DEPRECIATION_LIFETIME_MONTHS = 36 BuybackMode = Literal["depreciation", "floor"] @dataclass(frozen=True) class BuybackQuote: amount: Decimal currency: str mode: BuybackMode tier: int months_used: int # Per-currency breakdown so the frontend can switch currency # without re-fetching. Keys: SUPPORTED_CURRENCIES. prices: dict[str, Decimal] def _months_between(start: date, end: date) -> int: """Whole months from ``start`` to ``end``, floored at 0. Day-of-month rolls back the count by one if ``end.day < start.day``, so an asset purchased on 2024-01-31 is still "in month 0" on 2024-02-15 — matching how policy treats partial months. """ if end <= start: return 0 months = (end.year - start.year) * 12 + (end.month - start.month) if end.day < start.day: months -= 1 return max(months, 0) def _classify_tier(cny_equivalent: Decimal) -> int: for tier, upper in TIER_BANDS: if cny_equivalent < upper: return tier return TOP_TIER def _to_cny(amount: Decimal, currency: str) -> Decimal: """Convert ``amount`` of ``currency`` to CNY using policy rates. Refuses to silently treat an unknown currency as CNY — the asset intake form accepts EUR / MYR / THB and a typo on the row would otherwise mis-tier the buyback (e.g. a EUR 25 000 laptop is actually CNY 200 000 / tier 4, but a silent fallback would classify it as CNY 25 000 / tier 3 and quote 4 000 CNY less than policy intends). The contract is therefore: every asset whose buyback we quote must carry a currency in :data:`POLICY_FX_PER_CNY`. Operators see the ``UNSUPPORTED_PURCHASE_CURRENCY`` error and fix the asset row. Empty / None currency is implicitly treated as CNY at the caller (``purchase_currency or "CNY"``) — that backwards-compat path is for legacy assets where ``asset.currency IS NULL`` and procurement confirms the purchase was CNY by default. """ normalised = currency.upper() rate = POLICY_FX_PER_CNY.get(normalised) if rate is None: raise ValueError( f"UNSUPPORTED_PURCHASE_CURRENCY: asset.currency={normalised!r} " f"is not in the supported buyback currency set " f"({', '.join(SUPPORTED_CURRENCIES)}); ask IT to correct the " f"asset record (or convert the purchase price to one of the " f"supported currencies) before quoting buyback.", ) if rate == 0: # Should never happen — the policy table is hand-curated — but # if a future edit lands a 0 we'd divide by zero. Treat as # configuration error, not data error. raise ValueError( f"UNSUPPORTED_PURCHASE_CURRENCY: policy FX rate for " f"{normalised!r} is 0; check POLICY_FX_PER_CNY.", ) return amount / rate def _from_cny(amount_cny: Decimal, target_currency: str) -> Decimal: rate = POLICY_FX_PER_CNY.get(target_currency.upper()) if rate is None: return amount_cny return amount_cny * rate def _round_amount(amount: Decimal, currency: str) -> Decimal: """Round to the per-currency scale used in the floor table. JPY has no minor unit; the other four use two decimals to match the Feishu form's amount input precision. """ if currency.upper() == "JPY": return amount.quantize(Decimal("1"), rounding=ROUND_HALF_UP) return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def resolve_default_currency( entity_currency: str | None, asset_currency: str | None = None, ) -> str: """Decide which currency to default the quote to. Per IT policy: default CNY, **except** when the asset's ``purchase_entity`` resolves to a legal entity whose default currency is in the supported set. ``asset_currency`` is a tertiary fallback so an asset bought in (say) SGD with no entity link still quotes in SGD by default. """ for candidate in (entity_currency, asset_currency): if candidate and candidate.upper() in SUPPORTED_CURRENCIES: return candidate.upper() return DEFAULT_CURRENCY def calculate_buyback( *, purchase_cost: Decimal | None, purchase_currency: str | None, purchase_date: date | None, today: date, target_currency: str | None = None, ) -> BuybackQuote: """Compute the buyback quote for a single asset. All inputs are plain values — the caller (router / MCP tool / pytest) loads the ``AssetItem`` and passes the fields. This keeps the calculator pure and trivially testable. Raises :class:`ValueError` with a stable error code prefix when the asset lacks the data needed to quote: * ``MISSING_PURCHASE_COST`` — no ``purchase_cost`` on the asset * ``MISSING_PURCHASE_DATE`` — no ``purchase_date`` on the asset * ``FUTURE_PURCHASE_DATE`` — ``purchase_date`` is after ``today`` * ``UNSUPPORTED_CURRENCY`` — ``target_currency`` outside the supported set * ``UNSUPPORTED_PURCHASE_CURRENCY`` — asset's recorded currency is outside the supported set """ if purchase_cost is None or purchase_cost <= 0: raise ValueError( "MISSING_PURCHASE_COST: asset has no purchase_cost recorded; " "ask IT to backfill the asset before submitting buyback.", ) if purchase_date is None: raise ValueError( "MISSING_PURCHASE_DATE: asset has no purchase_date recorded; " "ask IT to backfill the asset before submitting buyback.", ) if purchase_date > today: # A future purchase_date is a data-entry error (or pre-order # paperwork that hasn't been reconciled). Without this guard # ``_months_between`` returns 0 → depreciation factor = 1.0 → # we'd quote the FULL sticker price for an asset the employee # hasn't actually received yet. Fail loudly instead. raise ValueError( f"FUTURE_PURCHASE_DATE: asset.purchase_date={purchase_date} " f"is after today ({today}); ask IT to correct the asset " f"record before quoting buyback.", ) resolved_currency = (target_currency or DEFAULT_CURRENCY).upper() if resolved_currency not in SUPPORTED_CURRENCIES: raise ValueError( f"UNSUPPORTED_CURRENCY: {resolved_currency!r} is not in the " f"supported buyback currency set " f"({', '.join(SUPPORTED_CURRENCIES)}).", ) months_used = _months_between(purchase_date, today) cny_equivalent = _to_cny(purchase_cost, purchase_currency or "CNY") tier = _classify_tier(cny_equivalent) prices: dict[str, Decimal] = {} if months_used >= DEPRECIATION_LIFETIME_MONTHS: mode: BuybackMode = "floor" for currency in SUPPORTED_CURRENCIES: prices[currency] = _round_amount( FLOOR_TABLE[tier][currency], currency, ) else: mode = "depreciation" # Linear depreciation runs in the asset's own purchase currency # first (that's the figure the employee paid for, recorded on # the invoice). Then we convert *that* to each supported display # currency via the CNY pivot, so the cross-currency answer is # internally consistent with the tier-based path. remaining_factor = Decimal( DEPRECIATION_LIFETIME_MONTHS - months_used, ) / Decimal(DEPRECIATION_LIFETIME_MONTHS) native_amount = purchase_cost * remaining_factor # Express the depreciation in CNY, then fan out to every # supported currency. cny_amount = _to_cny(native_amount, purchase_currency or "CNY") for currency in SUPPORTED_CURRENCIES: prices[currency] = _round_amount( _from_cny(cny_amount, currency), currency, ) return BuybackQuote( amount=prices[resolved_currency], currency=resolved_currency, mode=mode, tier=tier, months_used=months_used, prices=prices, )