From a345ac46435e32a9f1a2fee80a22da9ffa4129da Mon Sep 17 00:00:00 2001 From: nicolotognoni Date: Tue, 12 May 2026 18:18:04 +0200 Subject: [PATCH] fix(0.6.1): direction- and country-aware Twilio telephony billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default twilio telephony billing was a flat $0.0085/min — the US inbound local rate. Correct for the 99% case of an agent receiving calls on a US local number, but under-estimated outbound by 9-40x for international mobile destinations (US → IT mobile is $0.3473/min, ~40x the default). On a mixed-traffic dashboard the cost.telephony rollup could swing total margin reporting by 10x or more. Add a per-country, per-direction, per-line-type matrix sourced from Twilio's public pricing pages (verified 2026-05-12), plus a stateless E.164 → ISO-2 parser (no new deps, tiny hand-rolled country code map). calculateTelephonyCost / calculate_telephony_cost gain optional direction, destCountry, destType arguments. CallMetricsAccumulator gains a setTelephonyContext / set_telephony_context setter the carrier handler can call once direction and remote number are known. Backward compatibility is exact: omitting all new arguments keeps the legacy code path billing at pricing.twilio.price as before. Sources (all verified 2026-05-12, US-account perspective): - https://www.twilio.com/en-us/voice/pricing/us - https://www.twilio.com/en-us/voice/pricing/{it,gb,de,fr,es,nl,br,mx,in,jp,au,ca} Operators with negotiated Twilio rates can override the entire matrix via pricing.twilio_outbound_matrix. Parity: Python ↔ TypeScript matrices, country codes, and API shape are identical. 65 Python + 57 TS pricing tests cover the matrix, parser, override surface, and backward-compat fallback. Full suites green (1858 Py + 1533 TS). --- CHANGELOG.md | 31 ++ libraries/python/getpatter/__init__.py | 13 + libraries/python/getpatter/pricing.py | 46 ++- .../python/getpatter/services/metrics.py | 51 ++- .../services/telephony_pricing_matrix.py | 276 ++++++++++++++++ libraries/python/tests/test_pricing.py | 193 +++++++++++ libraries/typescript/src/index.ts | 16 +- libraries/typescript/src/metrics.ts | 57 +++- libraries/typescript/src/pricing.ts | 56 +++- .../src/services/telephony-pricing-matrix.ts | 304 ++++++++++++++++++ libraries/typescript/tests/pricing.test.ts | 149 +++++++++ 11 files changed, 1183 insertions(+), 9 deletions(-) create mode 100644 libraries/python/getpatter/services/telephony_pricing_matrix.py create mode 100644 libraries/typescript/src/services/telephony-pricing-matrix.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a9088..ced3417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ ## Unreleased +### Changed — Twilio telephony billing is now direction- and country-aware + +`calculateTelephonyCost` / `calculate_telephony_cost` accept new optional +`direction`, `destCountry` / `dest_country`, `destType` / `dest_type` +arguments. When threaded through by the carrier adapter, the +`cost.telephony` figure on the call log and dashboard resolves from a +per-country rate table (`TWILIO_PRICING_MATRIX`) instead of the flat +$0.0085/min US-inbound-local rate. The legacy rate was correct for the +99% case of an agent receiving calls on a US local number, but +under-estimated outbound by 9-40x for international mobile destinations +(US → IT mobile is $0.3473/min vs $0.0085/min, ~40x). New module: +`libraries/typescript/src/services/telephony-pricing-matrix.ts` and +`libraries/python/getpatter/services/telephony_pricing_matrix.py`. New +helper `parseE164Country` / `parse_e164_country` performs longest-prefix +lookup over the included tiny ISO map (no new dependencies). New setter +`CallMetricsAccumulator.setTelephonyContext` / +`CallMetricsAccumulator.set_telephony_context` lets the carrier handler +populate the context once direction and remote number are known. + +Backward compatibility: omitting all new arguments keeps the legacy code +path intact and bills at `pricing["twilio"]["price"]` exactly as before +— no integration that didn't opt in changes its numbers. + +Source: Twilio public pricing pages +(`https://www.twilio.com/en-us/voice/pricing/`) as of 2026-05-12, +US-account perspective. Operators with negotiated Twilio rates should +override via +`new Patter({ pricing: { twilio_outbound_matrix: {...} } })` / +`Patter(pricing={"twilio_outbound_matrix": {...}})` (matrix shape: +`Record`). + ## 0.6.1 (2026-05-12) ### Changed — `StreamHandler` adopt-capability check now uses duck typing diff --git a/libraries/python/getpatter/__init__.py b/libraries/python/getpatter/__init__.py index 058319d..63f8fe6 100644 --- a/libraries/python/getpatter/__init__.py +++ b/libraries/python/getpatter/__init__.py @@ -258,6 +258,13 @@ def __getattr__(name): calculate_tts_cost, merge_pricing, ) +from getpatter.services.telephony_pricing_matrix import ( + COUNTRY_CODE_TO_ISO2, + TWILIO_DEFAULT_FALLBACK_RATE, + TWILIO_PRICING_MATRIX, + parse_e164_country, + resolve_twilio_rate, +) # Per-call metrics accumulator (TypeScript: ``CallMetricsAccumulator``). from getpatter.services.metrics import CallMetricsAccumulator @@ -504,6 +511,12 @@ def mix_pcm(agent: bytes, bg: bytes, ratio: float) -> bytes: "calculate_tts_cost", "calculate_realtime_cost", "calculate_telephony_cost", + # Telephony pricing matrix (direction- and country-aware billing). + "TWILIO_PRICING_MATRIX", + "TWILIO_DEFAULT_FALLBACK_RATE", + "COUNTRY_CODE_TO_ISO2", + "parse_e164_country", + "resolve_twilio_rate", # Per-call metrics. "CallMetricsAccumulator", # Observability extras. diff --git a/libraries/python/getpatter/pricing.py b/libraries/python/getpatter/pricing.py index be01789..16483f3 100644 --- a/libraries/python/getpatter/pricing.py +++ b/libraries/python/getpatter/pricing.py @@ -644,21 +644,57 @@ def calculate_llm_cost( def calculate_telephony_cost( - provider: str, duration_seconds: float, pricing: dict + provider: str, + duration_seconds: float, + pricing: dict, + *, + direction: str | None = None, + dest_country: str | None = None, + dest_type: str | None = None, ) -> float: """Calculate telephony cost from call duration. - Twilio bills in whole-minute increments (any partial minute rounded up - per twilio.com/help/223132307). Telnyx bills per-second. Detection is - by provider name. + Twilio bills in whole-minute increments (any partial minute rounded + up per twilio.com/help/223132307). Telnyx bills per-second. Detection + is by provider name. + + When ``direction`` and ``dest_country`` are supplied for the + ``"twilio"`` provider, the rate resolves from the per-country + :data:`~getpatter.services.telephony_pricing_matrix.TWILIO_PRICING_MATRIX` + instead of the flat ``pricing["twilio"]["price"]`` default. Operators + can override the entire matrix per call by setting + ``pricing["twilio_outbound_matrix"]`` to a free-form dict matching + the shape of ``TWILIO_PRICING_MATRIX``. + + ``dest_type`` defaults to ``"mobile"`` when omitted: international + mobile rates are universally higher than landline, and under-billing + is worse than over-billing on a margin-reporting dashboard. + + Backward compatibility: omitting all three keyword arguments keeps + the legacy code path intact and bills at + ``pricing["twilio"]["price"]`` exactly as before. """ import math config = pricing.get(provider, {}) if config.get("unit") != "minute": return 0.0 + per_minute = config.get("price", 0.0) + if provider == "twilio" and direction and dest_country: + # Lazy import to keep ``pricing.py``'s import graph shallow — + # the matrix module is dependency-free so the cost is + # negligible. + from getpatter.services.telephony_pricing_matrix import resolve_twilio_rate + + override_matrix = pricing.get("twilio_outbound_matrix") + per_minute = resolve_twilio_rate( + direction, # type: ignore[arg-type] + dest_country, + dest_type or "mobile", # type: ignore[arg-type] + override_matrix, + ) if provider == "twilio": minutes = math.ceil(duration_seconds / 60.0) else: minutes = duration_seconds / 60.0 - return minutes * config.get("price", 0.0) + return minutes * per_minute diff --git a/libraries/python/getpatter/services/metrics.py b/libraries/python/getpatter/services/metrics.py index 1637816..1fc5c25 100644 --- a/libraries/python/getpatter/services/metrics.py +++ b/libraries/python/getpatter/services/metrics.py @@ -105,6 +105,15 @@ def __init__( # Actual provider costs (from post-call API queries) self._actual_telephony_cost: float | None = None self._actual_stt_cost: float | None = None + # Direction-aware telephony billing context. Populated by the + # telephony adapter (Twilio / Telnyx handler) once the call's + # direction and remote number are known. When unset, the legacy + # flat ``pricing["twilio"]["price"]`` rate is used so existing + # integrations bill identically to before 0.6.1's + # direction-aware matrix shipped. + self._telephony_direction: str | None = None + self._telephony_dest_country: str | None = None + self._telephony_dest_type: str | None = None # LLM token usage accumulated across all turns (pipeline mode) self._llm_total_input_tokens: int = 0 self._llm_total_output_tokens: int = 0 @@ -647,6 +656,41 @@ def set_actual_telephony_cost(self, cost: float) -> None: """ self._actual_telephony_cost = cost + def set_telephony_context( + self, + *, + direction: str | None = None, + dest_number: str | None = None, + dest_country: str | None = None, + dest_type: str | None = None, + ) -> None: + """Set direction-aware billing context for the telephony estimate. + + Called by the Twilio / Telnyx adapter once direction and the + remote E.164 number are known. ``dest_number`` is parsed into an + ISO-2 country via :func:`parse_e164_country` — operators that + pre-compute the country can pass ``dest_country`` directly. Both + paths short-circuit when the country is unknown and the legacy + flat rate kicks in transparently. + + All arguments are optional / nullable so adapters can call this + in a single pass with whatever subset of metadata they have. + Re-calling with a new value overrides the previous context for + the rest of the call. + """ + if direction is not None: + self._telephony_direction = direction + if dest_country is not None: + self._telephony_dest_country = dest_country.upper() + elif dest_number: + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + parsed = parse_e164_country(dest_number) + if parsed: + self._telephony_dest_country = parsed + if dest_type is not None: + self._telephony_dest_type = dest_type + def set_actual_stt_cost(self, cost: float) -> None: """Set the actual STT cost from the provider API (post-call). @@ -943,7 +987,12 @@ def _compute_cost(self, duration_seconds: float) -> CostBreakdown: telephony_cost = self._actual_telephony_cost else: telephony_cost = calculate_telephony_cost( - self.telephony_provider, duration_seconds, self._pricing + self.telephony_provider, + duration_seconds, + self._pricing, + direction=self._telephony_direction, + dest_country=self._telephony_dest_country, + dest_type=self._telephony_dest_type, ) total = stt_cost + tts_cost + llm_cost + telephony_cost diff --git a/libraries/python/getpatter/services/telephony_pricing_matrix.py b/libraries/python/getpatter/services/telephony_pricing_matrix.py new file mode 100644 index 0000000..c1783ad --- /dev/null +++ b/libraries/python/getpatter/services/telephony_pricing_matrix.py @@ -0,0 +1,276 @@ +"""Direction-aware, country-aware telephony pricing matrix. + +The default Twilio telephony billing in ``pricing.py`` is a flat +$0.0085/min — the US **inbound local** rate. That number is correct for +the 99% case of an agent receiving calls on a US local number, but it +understates the true carrier cost for every other shape: US outbound +local ($0.014/min), US toll-free inbound ($0.022/min), and especially +international outbound (US → IT mobile is $0.3473/min — ~40x the +default). When the dashboard rolls up ``cost.telephony`` across a mixed +traffic mix, the under-estimation can swing total margin reporting by +10x or more. + +This module exposes a per-country, per-direction, per-line-type rate +table sourced from Twilio's public per-country pricing pages +(``https://www.twilio.com/en-us/voice/pricing/``), verified +2026-05-12, plus a stateless E.164 → ISO-2 country code parser. The +caller of :func:`getpatter.pricing.calculate_telephony_cost` opts in by +passing ``direction``, ``dest_country``, ``dest_type``; everything else +falls back to the legacy provider flat rate so existing integrations +bill identically to before this change. + +Mobile-vs-landline detection from an E.164 number alone requires an +external HLR-lookup database we deliberately do not ship — picking +``"mobile"`` as the default ``dest_type`` makes the estimate +conservative (top-of-cost) and prevents systematic under-billing. +Operators with negotiated Twilio rates can override the entire matrix +via:: + + Patter(pricing={"twilio_outbound_matrix": {...}}) + +Sources (all verified 2026-05-12, US-account perspective): + - https://www.twilio.com/en-us/voice/pricing/us + - https://www.twilio.com/en-us/voice/pricing/ (per destination) + +Parity: keep this file in lockstep with +``libraries/typescript/src/services/telephony-pricing-matrix.ts``. +""" + +from __future__ import annotations + +import logging +import re +from typing import Literal + +logger = logging.getLogger("getpatter") + +CallDirection = Literal["inbound", "outbound"] +DestLineType = Literal["landline", "mobile", "tollfree"] + +# Twilio public per-country voice pricing (USD/min), US-account +# perspective. Verified 2026-05-12 against the public pricing pages. +# Operators with negotiated rates should override via +# ``Patter(pricing={"twilio_outbound_matrix": {...}})``. +TWILIO_PRICING_MATRIX: dict[str, dict] = { + # United States — the legacy default ($0.0085/min) is inbound.local. + "US": { + "inbound": {"local": 0.0085, "tollfree": 0.022}, + "outbound": {"landline": 0.014, "mobile": 0.014, "tollfree": 0.014}, + }, + # Canada bundled with US under Twilio's "United States & Canada" rate. + "CA": { + "inbound": {"local": 0.0085}, + "outbound": {"landline": 0.014, "mobile": 0.014, "tollfree": 0.014}, + }, + # Italy — outbound mobile rate is dramatic: ~40x the US default. + "IT": { + "inbound": {"mobile": 0.01}, + "outbound": {"landline": 0.0168, "mobile": 0.3473}, + }, + # United Kingdom. + "GB": {"outbound": {"landline": 0.0158, "mobile": 0.0305}}, + # Germany. + "DE": {"outbound": {"landline": 0.021, "mobile": 0.042}}, + # France — non-EEA origin (US accounts pay this rate to FR mobile). + "FR": {"outbound": {"landline": 0.0187, "mobile": 0.1603}}, + # Spain. + "ES": {"outbound": {"landline": 0.0178, "mobile": 0.0388}}, + # Netherlands — non-EEA origin (US accounts hit the high rate). + "NL": {"outbound": {"landline": 0.3675, "mobile": 0.2763}}, + # Brazil. + "BR": {"outbound": {"landline": 0.031, "mobile": 0.0663}}, + # Mexico. + "MX": {"outbound": {"landline": 0.016, "mobile": 0.0473}}, + # India. + "IN": {"outbound": {"landline": 0.0497, "mobile": 0.0405}}, + # Japan. + "JP": {"outbound": {"landline": 0.0746, "mobile": 0.185}}, + # Australia. + "AU": {"outbound": {"landline": 0.0252, "mobile": 0.075}}, +} + +# Fallback outbound rate (USD/min) when the destination country is not +# present in :data:`TWILIO_PRICING_MATRIX`. Matches the legacy default +# exposed by ``DEFAULT_PRICING["twilio"]["price"]`` so existing +# integrations continue billing the same number when they don't pass a +# country. +TWILIO_DEFAULT_FALLBACK_RATE: float = 0.0085 + +# E.164 country-calling-code → ISO-2 country code lookup. Intentionally +# tiny: country-detection from a phone number is a hard problem at scale +# (NANPA shares +1 across 24 countries, +44 covers UK + Crown +# Dependencies) and a complete map would require an external dependency +# we don't ship. The leading ``+`` is stripped before lookup. Unknown +# numbers return ``None`` and the caller bills at the fallback rate. +COUNTRY_CODE_TO_ISO2: dict[str, str] = { + "1": "US", # Also covers CA, but matrix collapses both to the same rate. + "7": "RU", + "20": "EG", + "27": "ZA", + "30": "GR", + "31": "NL", + "32": "BE", + "33": "FR", + "34": "ES", + "36": "HU", + "39": "IT", + "40": "RO", + "41": "CH", + "43": "AT", + "44": "GB", + "45": "DK", + "46": "SE", + "47": "NO", + "48": "PL", + "49": "DE", + "51": "PE", + "52": "MX", + "53": "CU", + "54": "AR", + "55": "BR", + "56": "CL", + "57": "CO", + "58": "VE", + "60": "MY", + "61": "AU", + "62": "ID", + "63": "PH", + "64": "NZ", + "65": "SG", + "66": "TH", + "81": "JP", + "82": "KR", + "84": "VN", + "86": "CN", + "90": "TR", + "91": "IN", + "92": "PK", + "93": "AF", + "94": "LK", + "95": "MM", + "98": "IR", + "212": "MA", + "213": "DZ", + "216": "TN", + "218": "LY", + "220": "GM", + "221": "SN", + "234": "NG", + "254": "KE", + "255": "TZ", + "256": "UG", + "351": "PT", + "352": "LU", + "353": "IE", + "354": "IS", + "358": "FI", + "420": "CZ", + "421": "SK", + "852": "HK", + "853": "MO", + "855": "KH", + "856": "LA", + "880": "BD", + "886": "TW", + "960": "MV", + "961": "LB", + "962": "JO", + "963": "SY", + "964": "IQ", + "965": "KW", + "966": "SA", + "967": "YE", + "968": "OM", + "971": "AE", + "972": "IL", + "973": "BH", + "974": "QA", + "975": "BT", + "976": "MN", + "977": "NP", +} + +_NON_DIGIT = re.compile(r"\D+") + + +def parse_e164_country(phone_number: str | None) -> str | None: + """Parse an E.164 number into an ISO-2 country code, longest-prefix match. + + Returns ``None`` when the input is empty, malformed, or the country + code is not in :data:`COUNTRY_CODE_TO_ISO2`. Pure / synchronous — + no I/O. + """ + if not phone_number: + return None + # Strip the leading + and any non-digit characters (spaces, dashes, + # parentheses) that loose carriers occasionally include in To / From + # headers. We do NOT validate length — invalid numbers fall through + # to the no-match branch below. + digits = _NON_DIGIT.sub("", phone_number) + if not digits: + return None + # Longest-prefix match: try 3-digit prefix, then 2, then 1. + for length in (3, 2, 1): + if len(digits) < length: + continue + prefix = digits[:length] + iso = COUNTRY_CODE_TO_ISO2.get(prefix) + if iso: + return iso + return None + + +def resolve_twilio_rate( + direction: CallDirection | None, + dest_country: str | None, + dest_type: DestLineType = "mobile", + override_matrix: dict[str, dict] | None = None, +) -> float: + """Resolve the Twilio per-minute rate (USD) for a single call segment. + + Args: + direction: ``"inbound"`` (agent received) or ``"outbound"`` + (agent placed). When ``None``, falls back to + :data:`TWILIO_DEFAULT_FALLBACK_RATE`. + dest_country: ISO-2 country code of the remote party. When + ``None`` or unknown, falls back to + :data:`TWILIO_DEFAULT_FALLBACK_RATE`. + dest_type: ``"mobile"`` is the conservative default for unknown + line types — international mobile rates are universally + higher than landline, and under-billing is worse than + over-billing on a margin-reporting dashboard. + override_matrix: Optional user-supplied override matrix that + wins over :data:`TWILIO_PRICING_MATRIX`. Lets operators + inject negotiated carrier rates without forking the SDK. + """ + if not direction or not dest_country: + return TWILIO_DEFAULT_FALLBACK_RATE + matrix = override_matrix if override_matrix is not None else TWILIO_PRICING_MATRIX + country = matrix.get(dest_country.upper()) + if not country: + logger.debug( + "telephony pricing: unknown destination country %r, " + "billing at fallback rate $%.4f/min", + dest_country, + TWILIO_DEFAULT_FALLBACK_RATE, + ) + return TWILIO_DEFAULT_FALLBACK_RATE + if direction == "inbound": + inbound = country.get("inbound") + if not inbound: + return TWILIO_DEFAULT_FALLBACK_RATE + if dest_type == "tollfree" and "tollfree" in inbound: + return inbound["tollfree"] + if dest_type == "mobile" and "mobile" in inbound: + return inbound["mobile"] + if "local" in inbound: + return inbound["local"] + return TWILIO_DEFAULT_FALLBACK_RATE + # Outbound. + outbound = country.get("outbound") or {} + if dest_type == "tollfree" and "tollfree" in outbound: + return outbound["tollfree"] + if dest_type == "landline" and "landline" in outbound: + return outbound["landline"] + # Default to mobile when dest_type is None or "mobile". + return outbound.get("mobile", TWILIO_DEFAULT_FALLBACK_RATE) diff --git a/libraries/python/tests/test_pricing.py b/libraries/python/tests/test_pricing.py index c03b186..4851e35 100644 --- a/libraries/python/tests/test_pricing.py +++ b/libraries/python/tests/test_pricing.py @@ -258,6 +258,199 @@ def test_zero_duration(self): assert cost == 0.0 +class TestTwilioDirectionCountryAware: + """Direction- and country-aware Twilio billing (0.6.1). + + Source: Twilio public pricing pages, verified 2026-05-12. Backward + compatibility: omitting any of ``direction`` / ``dest_country`` + falls back to the legacy flat ``pricing["twilio"]["price"]`` rate. + """ + + def test_no_context_keeps_legacy_flat_rate(self): + """Existing callers that don't pass context bill the legacy $0.0085/min.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost("twilio", 60.0, pricing) + # 60s rounded to 1 min * $0.0085 = $0.0085 + assert abs(cost - 0.0085) < 1e-6 + + def test_outbound_us_to_us_mobile(self): + """US → US mobile bills outbound rate $0.014/min — not inbound default.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="outbound", + dest_country="US", + dest_type="mobile", + ) + assert abs(cost - 0.014) < 1e-6 + + def test_outbound_us_to_italy_mobile(self): + """US → IT mobile bills at $0.3473/min — ~40x the legacy default. + + This was the bug class flagged on 0.6.1 audit: outbound + international mobile costs were under-estimated by 1-2 orders + of magnitude on the dashboard. + """ + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 120.0, + pricing, + direction="outbound", + dest_country="IT", + dest_type="mobile", + ) + # 2 min * $0.3473 = $0.6946 + assert abs(cost - 0.6946) < 1e-4 + + def test_outbound_us_to_gb_landline(self): + """US → GB landline bills $0.0158/min.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="outbound", + dest_country="GB", + dest_type="landline", + ) + assert abs(cost - 0.0158) < 1e-6 + + def test_outbound_default_dest_type_is_mobile(self): + """Omitting ``dest_type`` falls back to mobile (conservative).""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="outbound", + dest_country="DE", + ) + # DE mobile = $0.042/min, landline = $0.021/min + assert abs(cost - 0.042) < 1e-6 + + def test_unknown_country_falls_back_to_default(self): + """Unknown destination country bills at $0.0085/min fallback.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="outbound", + dest_country="ZZ", # not in matrix + ) + assert abs(cost - 0.0085) < 1e-6 + + def test_inbound_us_local_matches_default(self): + """Inbound to US local bills $0.0085/min — same as legacy default.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="inbound", + dest_country="US", + dest_type="landline", + ) + assert abs(cost - 0.0085) < 1e-6 + + def test_inbound_us_tollfree_billed_higher(self): + """Inbound to US toll-free bills $0.022/min — 2.6x local rate.""" + pricing = merge_pricing(None) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="inbound", + dest_country="US", + dest_type="tollfree", + ) + assert abs(cost - 0.022) < 1e-6 + + def test_user_override_matrix_wins(self): + """Operator-supplied ``twilio_outbound_matrix`` overrides defaults. + + Lets operators with negotiated Twilio rates correct billing on + the dashboard without forking the SDK. + """ + pricing = merge_pricing( + { + "twilio_outbound_matrix": { + "IT": { + "outbound": {"landline": 0.005, "mobile": 0.010}, + } + } + } + ) + cost = calculate_telephony_cost( + "twilio", + 60.0, + pricing, + direction="outbound", + dest_country="IT", + dest_type="mobile", + ) + assert abs(cost - 0.010) < 1e-6 + + def test_partial_minute_still_rounded_up(self): + """Twilio per-minute billing rounds partial minutes up (preserved).""" + pricing = merge_pricing(None) + # 30s = 1 billable minute at $0.3473/min + cost = calculate_telephony_cost( + "twilio", + 30.0, + pricing, + direction="outbound", + dest_country="IT", + dest_type="mobile", + ) + assert abs(cost - 0.3473) < 1e-6 + + +class TestParseE164Country: + """E.164 → ISO-2 country code lookup with longest-prefix match.""" + + def test_us_plus_one(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + assert parse_e164_country("+14155551234") == "US" + + def test_italy_plus_39(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + assert parse_e164_country("+393331234567") == "IT" + + def test_uk_plus_44(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + assert parse_e164_country("+447700900123") == "GB" + + def test_portugal_three_digit_prefix(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + # +351 must win over +35 (which doesn't exist). + assert parse_e164_country("+351912345678") == "PT" + + def test_strips_non_digits(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + assert parse_e164_country("+39 (033) 123-4567") == "IT" + + def test_unknown_returns_none(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + # +999 is not assigned. + assert parse_e164_country("+999123") is None + + def test_empty_returns_none(self): + from getpatter.services.telephony_pricing_matrix import parse_e164_country + + assert parse_e164_country("") is None + assert parse_e164_country(None) is None + + class TestRealtime2Pricing: """Per-model rates for ``gpt-realtime-2`` live under ``openai_realtime.models``.""" diff --git a/libraries/typescript/src/index.ts b/libraries/typescript/src/index.ts index 54e33ca..969ddf1 100644 --- a/libraries/typescript/src/index.ts +++ b/libraries/typescript/src/index.ts @@ -56,7 +56,21 @@ export { } from "./providers"; export type { RealtimeConfig } from "./providers"; export { DEFAULT_PRICING, mergePricing, calculateSttCost, calculateTtsCost, calculateRealtimeCost, calculateTelephonyCost } from "./pricing"; -export type { ProviderPricing } from "./pricing"; +export type { ProviderPricing, TelephonyBillingContext } from "./pricing"; +export { + TWILIO_PRICING_MATRIX, + TWILIO_DEFAULT_FALLBACK_RATE, + COUNTRY_CODE_TO_ISO2, + parseE164Country, + resolveTwilioRate, +} from "./services/telephony-pricing-matrix"; +export type { + CallDirection, + DestLineType, + CountryPricing, + OutboundRates, + InboundRates, +} from "./services/telephony-pricing-matrix"; export { CallMetricsAccumulator } from "./metrics"; export type { LatencyBreakdown, CostBreakdown, TurnMetrics, CallMetrics, CallControl } from "./metrics"; export type { LocalConfig } from "./server"; diff --git a/libraries/typescript/src/metrics.ts b/libraries/typescript/src/metrics.ts index 96c3dce..a9c5b40 100644 --- a/libraries/typescript/src/metrics.ts +++ b/libraries/typescript/src/metrics.ts @@ -14,6 +14,7 @@ import { mergePricing, type ProviderPricing, } from './pricing'; +import { parseE164Country } from './services/telephony-pricing-matrix'; import type { EventBus } from './observability/event-bus'; import type { EOUMetrics, @@ -256,6 +257,16 @@ export class CallMetricsAccumulator { private _sttBytesPerSample = 2; private _actualTelephonyCost: number | null = null; private _actualSttCost: number | null = null; + /** + * Direction-aware telephony billing context. Populated by the telephony + * adapter (Twilio / Telnyx handler) once the call's direction and remote + * number are known. When unset, the legacy flat ``pricing.twilio.price`` + * rate is used so existing integrations bill identically to before + * 0.6.1's direction-aware matrix shipped. + */ + private _telephonyDirection: 'inbound' | 'outbound' | null = null; + private _telephonyDestCountry: string | null = null; + private _telephonyDestType: 'landline' | 'mobile' | 'tollfree' | null = null; // Fix 10: accumulated LLM token cost for non-Realtime pipeline mode. private _totalLlmCost = 0; // Last LLM model identifier from a recordLlmUsage call — emitted on @@ -775,6 +786,40 @@ export class CallMetricsAccumulator { this._actualTelephonyCost = cost; } + /** + * Set direction-aware billing context for the telephony estimate. + * + * Called by the Twilio / Telnyx adapter once direction and the remote + * E.164 number are known. ``destNumber`` is parsed into an ISO-2 country + * via :func:`parseE164Country` — operators that pre-compute the country + * can pass ``destCountry`` directly. Both paths short-circuit when the + * country is unknown and the legacy flat rate kicks in transparently. + * + * All arguments are optional / nullable so adapters can call this in a + * single pass with whatever subset of metadata they have. Re-calling + * with a new value overrides the previous context for the rest of the + * call. + */ + setTelephonyContext(opts: { + direction?: 'inbound' | 'outbound' | null; + destNumber?: string | null; + destCountry?: string | null; + destType?: 'landline' | 'mobile' | 'tollfree' | null; + }): void { + if (opts.direction !== undefined && opts.direction !== null) { + this._telephonyDirection = opts.direction; + } + if (opts.destCountry !== undefined && opts.destCountry !== null) { + this._telephonyDestCountry = opts.destCountry.toUpperCase(); + } else if (opts.destNumber) { + const parsed = parseE164Country(opts.destNumber); + if (parsed) this._telephonyDestCountry = parsed; + } + if (opts.destType !== undefined && opts.destType !== null) { + this._telephonyDestType = opts.destType; + } + } + /** Override the provider-billed STT cost when an exact figure is available. */ setActualSttCost(cost: number): void { this._actualSttCost = cost; @@ -1063,7 +1108,17 @@ export class CallMetricsAccumulator { const telephony = this._actualTelephonyCost !== null ? this._actualTelephonyCost - : calculateTelephonyCost(this.telephonyProvider, durationSeconds, this._pricing); + : calculateTelephonyCost(this.telephonyProvider, durationSeconds, this._pricing, { + ...(this._telephonyDirection !== null + ? { direction: this._telephonyDirection } + : {}), + ...(this._telephonyDestCountry !== null + ? { destCountry: this._telephonyDestCountry } + : {}), + ...(this._telephonyDestType !== null + ? { destType: this._telephonyDestType } + : {}), + }); const total = stt + tts + llm + telephony; diff --git a/libraries/typescript/src/pricing.ts b/libraries/typescript/src/pricing.ts index f194d19..8e3054d 100644 --- a/libraries/typescript/src/pricing.ts +++ b/libraries/typescript/src/pricing.ts @@ -665,22 +665,76 @@ export function calculateLlmCost( return Math.max(0, cost); } +import { + resolveTwilioRate as _resolveTwilioRate, + type CountryPricing as _CountryPricing, +} from './services/telephony-pricing-matrix'; + +/** + * Optional context for direction- and country-aware telephony billing. + * + * When provided alongside ``calculateTelephonyCost``, the call falls into + * the per-country rate table from + * :module:`services/telephony-pricing-matrix` instead of the flat default + * stored under ``pricing.twilio.price``. All fields are optional — if any + * are missing the call bills at the legacy flat rate, preserving exact + * backward compatibility for callers that don't thread carrier metadata + * through. + */ +export interface TelephonyBillingContext { + /** ``"inbound"`` (agent received) or ``"outbound"`` (agent placed). */ + direction?: 'inbound' | 'outbound'; + /** ISO 3166-1 alpha-2 country code (``"US"``, ``"IT"``, ``"GB"``…). */ + destCountry?: string | null; + /** + * Line-type bucket. Defaults to ``"mobile"`` — the conservative choice + * because mobile rates exceed landline rates everywhere except a few + * Twilio-bundled markets, and under-billing on a margin dashboard is + * worse than over-billing. + */ + destType?: 'landline' | 'mobile' | 'tollfree'; +} + /** * Calculate telephony cost from call duration. * * Twilio bills in whole-minute increments (any partial minute is rounded up * to the next full minute per twilio.com/help/223132307). Telnyx bills * per-second. We detect Twilio by provider name and apply the round-up. + * + * When ``context`` is supplied for the ``"twilio"`` provider, the rate + * resolves from the per-country + * :data:`~services/telephony-pricing-matrix.TWILIO_PRICING_MATRIX` instead + * of the flat ``pricing.twilio.price`` default. Operators can override the + * entire matrix per call by setting + * ``pricing.twilio_outbound_matrix`` to a free-form record matching + * :data:`~services/telephony-pricing-matrix.CountryPricing`. + * + * Backward compatibility: omitting ``context`` keeps the legacy code path + * intact and bills at ``pricing.twilio.price`` exactly as before. */ export function calculateTelephonyCost( provider: string, durationSeconds: number, pricing: Record, + context?: TelephonyBillingContext, ): number { const config = pricing[provider]; if (!config || config.unit !== 'minute') return 0; + let perMinute = config.price ?? 0; + if (provider === 'twilio' && context && context.direction && context.destCountry) { + const overrideRaw = (pricing as Record).twilio_outbound_matrix as + | Readonly> + | undefined; + perMinute = _resolveTwilioRate( + context.direction, + context.destCountry, + context.destType ?? 'mobile', + overrideRaw, + ); + } const minutes = provider === 'twilio' ? Math.ceil(durationSeconds / 60) : durationSeconds / 60; - return minutes * (config.price ?? 0); + return minutes * perMinute; } diff --git a/libraries/typescript/src/services/telephony-pricing-matrix.ts b/libraries/typescript/src/services/telephony-pricing-matrix.ts new file mode 100644 index 0000000..3954a46 --- /dev/null +++ b/libraries/typescript/src/services/telephony-pricing-matrix.ts @@ -0,0 +1,304 @@ +/** + * Direction-aware, country-aware telephony pricing matrix. + * + * Default Twilio telephony billing in ``pricing.ts`` is a flat $0.0085/min + * — the US **inbound local** rate. That number is correct for the 99% case + * of an agent receiving calls on a US local number, but it understates the + * true carrier cost for every other shape: US outbound local ($0.014/min), + * US toll-free inbound ($0.022/min), and especially international outbound + * (US → IT mobile is $0.3473/min — ~40x the default). When the dashboard + * rolls up ``cost.telephony`` across a mixed traffic mix, the under- + * estimation can swing total margin reporting by 10x or more. + * + * This module exposes a per-country, per-direction, per-line-type rate + * table sourced from Twilio's public per-country pricing pages + * (``https://www.twilio.com/en-us/voice/pricing/``), verified + * 2026-05-12, plus a stateless E.164 → ISO-2 country code parser. The + * caller of ``calculateTelephonyCost`` opts in by passing + * ``{ direction, destCountry, destType }``; everything else falls back to + * the legacy provider flat rate so existing integrations bill identically + * to before this change. + * + * Mobile-vs-landline detection from an E.164 number alone requires an + * external HLR-lookup database we deliberately do not ship — picking + * ``"mobile"`` as the default ``destType`` makes the estimate conservative + * (top-of-cost) and prevents systematic under-billing. Operators with + * negotiated Twilio rates can override the entire matrix via:: + * + * new Patter({ pricing: { twilio_outbound_matrix: {...} } }) + * + * Sources (all verified 2026-05-12, US-account perspective): + * - https://www.twilio.com/en-us/voice/pricing/us + * - https://www.twilio.com/en-us/voice/pricing/ (per destination) + * + * Parity: keep this file in lockstep with + * ``libraries/python/getpatter/services/telephony_pricing_matrix.py``. + */ + +/** Direction of a single phone call relative to the agent. */ +export type CallDirection = 'inbound' | 'outbound'; + +/** Line-type bucket used by Twilio's per-country pricing pages. */ +export type DestLineType = 'landline' | 'mobile' | 'tollfree'; + +/** Per-country outbound rates in USD per minute. */ +export interface OutboundRates { + readonly landline: number; + readonly mobile: number; + readonly tollfree?: number; +} + +/** Per-country inbound rates in USD per minute. */ +export interface InboundRates { + readonly local?: number; + readonly tollfree?: number; + readonly mobile?: number; +} + +/** Combined inbound + outbound entry keyed by ISO 3166-1 alpha-2 country code. */ +export interface CountryPricing { + readonly inbound?: InboundRates; + readonly outbound: OutboundRates; +} + +/** + * Twilio public per-country voice pricing (USD/min), US-account perspective. + * + * Verified 2026-05-12 against the public pricing pages. Operators with + * negotiated rates should override via + * ``new Patter({ pricing: { twilio_outbound_matrix: {...} } })``. + */ +export const TWILIO_PRICING_MATRIX: Readonly> = { + // United States — the legacy default ($0.0085/min) is inbound.local. + US: { + inbound: { local: 0.0085, tollfree: 0.022 }, + outbound: { landline: 0.014, mobile: 0.014, tollfree: 0.014 }, + }, + // Canada bundled with US under Twilio's "United States & Canada" rate. + CA: { + inbound: { local: 0.0085 }, + outbound: { landline: 0.014, mobile: 0.014, tollfree: 0.014 }, + }, + // Italy — outbound mobile rate is dramatic: 40x the US default. + IT: { + inbound: { mobile: 0.01 }, + outbound: { landline: 0.0168, mobile: 0.3473 }, + }, + // United Kingdom. + GB: { + outbound: { landline: 0.0158, mobile: 0.0305 }, + }, + // Germany. + DE: { + outbound: { landline: 0.021, mobile: 0.042 }, + }, + // France — non-EEA origin (US accounts pay this rate to FR mobile). + FR: { + outbound: { landline: 0.0187, mobile: 0.1603 }, + }, + // Spain. + ES: { + outbound: { landline: 0.0178, mobile: 0.0388 }, + }, + // Netherlands — non-EEA origin (US accounts hit the high rate). + NL: { + outbound: { landline: 0.3675, mobile: 0.2763 }, + }, + // Brazil. + BR: { + outbound: { landline: 0.031, mobile: 0.0663 }, + }, + // Mexico. + MX: { + outbound: { landline: 0.016, mobile: 0.0473 }, + }, + // India. + IN: { + outbound: { landline: 0.0497, mobile: 0.0405 }, + }, + // Japan. + JP: { + outbound: { landline: 0.0746, mobile: 0.185 }, + }, + // Australia. + AU: { + outbound: { landline: 0.0252, mobile: 0.075 }, + }, +}; + +/** + * Fallback outbound rate (USD/min) when the destination country is not + * present in :data:`TWILIO_PRICING_MATRIX`. Matches the legacy default + * exposed by ``DEFAULT_PRICING.twilio.price`` so existing integrations + * continue billing the same number when they don't pass a country. + */ +export const TWILIO_DEFAULT_FALLBACK_RATE = 0.0085; + +/** + * E.164 country-calling-code → ISO-2 country code lookup. + * + * Covers the destinations in :data:`TWILIO_PRICING_MATRIX` plus the most + * common +-prefix shapes a deployed Patter caller might see. Intentionally + * tiny: country-detection from a phone number is a hard problem at scale + * (NANPA shares +1 across 24 countries, +44 covers UK + Crown Dependencies) + * and a complete map would require an external dependency we don't ship. + * + * The leading ``+`` is stripped before lookup. Longest-prefix match wins + * so the three-digit ``+351`` (Portugal) resolves before any one-digit + * ambiguity is considered. Unknown numbers return ``null`` and the caller + * bills at the fallback rate. + */ +export const COUNTRY_CODE_TO_ISO2: Readonly> = { + '1': 'US', // Also covers CA, but matrix collapses both to the same rate. + '7': 'RU', + '20': 'EG', + '27': 'ZA', + '30': 'GR', + '31': 'NL', + '32': 'BE', + '33': 'FR', + '34': 'ES', + '36': 'HU', + '39': 'IT', + '40': 'RO', + '41': 'CH', + '43': 'AT', + '44': 'GB', + '45': 'DK', + '46': 'SE', + '47': 'NO', + '48': 'PL', + '49': 'DE', + '51': 'PE', + '52': 'MX', + '53': 'CU', + '54': 'AR', + '55': 'BR', + '56': 'CL', + '57': 'CO', + '58': 'VE', + '60': 'MY', + '61': 'AU', + '62': 'ID', + '63': 'PH', + '64': 'NZ', + '65': 'SG', + '66': 'TH', + '81': 'JP', + '82': 'KR', + '84': 'VN', + '86': 'CN', + '90': 'TR', + '91': 'IN', + '92': 'PK', + '93': 'AF', + '94': 'LK', + '95': 'MM', + '98': 'IR', + '212': 'MA', + '213': 'DZ', + '216': 'TN', + '218': 'LY', + '220': 'GM', + '221': 'SN', + '234': 'NG', + '254': 'KE', + '255': 'TZ', + '256': 'UG', + '351': 'PT', + '352': 'LU', + '353': 'IE', + '354': 'IS', + '358': 'FI', + '420': 'CZ', + '421': 'SK', + '852': 'HK', + '853': 'MO', + '855': 'KH', + '856': 'LA', + '880': 'BD', + '886': 'TW', + '960': 'MV', + '961': 'LB', + '962': 'JO', + '963': 'SY', + '964': 'IQ', + '965': 'KW', + '966': 'SA', + '967': 'YE', + '968': 'OM', + '971': 'AE', + '972': 'IL', + '973': 'BH', + '974': 'QA', + '975': 'BT', + '976': 'MN', + '977': 'NP', +}; + +/** + * Parse an E.164 number into an ISO-2 country code, longest-prefix match. + * + * Returns ``null`` when the input is empty, malformed, or the country code + * is not in :data:`COUNTRY_CODE_TO_ISO2`. Pure / synchronous — no I/O. + */ +export function parseE164Country(phoneNumber: string | undefined | null): string | null { + if (!phoneNumber) return null; + // Strip the leading + and any non-digit characters (spaces, dashes, + // parentheses) that loose carriers occasionally include in the To / From + // headers. We do NOT validate length — invalid numbers fall through to + // the no-match branch below. + const digits = phoneNumber.replace(/[^\d]/g, ''); + if (!digits) return null; + // Longest-prefix match: try 3-digit prefix, then 2, then 1. + for (let len = 3; len >= 1; len -= 1) { + if (digits.length < len) continue; + const prefix = digits.slice(0, len); + const iso = COUNTRY_CODE_TO_ISO2[prefix]; + if (iso) return iso; + } + return null; +} + +/** + * Resolve the Twilio per-minute rate (USD) for a single call segment. + * + * @param direction - ``"inbound"`` (agent received) or ``"outbound"`` + * (agent placed). When ``undefined``, falls back to + * :data:`TWILIO_DEFAULT_FALLBACK_RATE`. + * @param destCountry - ISO-2 country code of the remote party. When + * ``undefined`` or unknown, falls back to + * :data:`TWILIO_DEFAULT_FALLBACK_RATE`. + * @param destType - ``"mobile"`` is the conservative default for + * unknown line types because international mobile rates are + * universally higher than landline — under-billing is worse than + * over-billing on a margin-reporting dashboard. + * @param overrideMatrix - Optional user-supplied override matrix that + * wins over :data:`TWILIO_PRICING_MATRIX`. Lets operators inject + * negotiated carrier rates without forking the SDK. + */ +export function resolveTwilioRate( + direction: CallDirection | undefined, + destCountry: string | undefined | null, + destType: DestLineType = 'mobile', + overrideMatrix?: Readonly>, +): number { + if (!direction || !destCountry) return TWILIO_DEFAULT_FALLBACK_RATE; + const matrix = overrideMatrix ?? TWILIO_PRICING_MATRIX; + const country = matrix[destCountry.toUpperCase()]; + if (!country) return TWILIO_DEFAULT_FALLBACK_RATE; + if (direction === 'inbound') { + const inbound = country.inbound; + if (!inbound) return TWILIO_DEFAULT_FALLBACK_RATE; + if (destType === 'tollfree' && inbound.tollfree !== undefined) return inbound.tollfree; + if (destType === 'mobile' && inbound.mobile !== undefined) return inbound.mobile; + if (inbound.local !== undefined) return inbound.local; + return TWILIO_DEFAULT_FALLBACK_RATE; + } + // Outbound. + const outbound = country.outbound; + if (destType === 'tollfree' && outbound.tollfree !== undefined) return outbound.tollfree; + if (destType === 'landline') return outbound.landline; + // Default to mobile when destType is undefined or "mobile". + return outbound.mobile; +} diff --git a/libraries/typescript/tests/pricing.test.ts b/libraries/typescript/tests/pricing.test.ts index 28ef3e5..6b230e8 100644 --- a/libraries/typescript/tests/pricing.test.ts +++ b/libraries/typescript/tests/pricing.test.ts @@ -231,6 +231,155 @@ describe('calculateTelephonyCost', () => { }); }); +describe('calculateTelephonyCost — direction- and country-aware Twilio matrix (0.6.1)', () => { + // Source: Twilio public pricing pages, verified 2026-05-12. + // Backward compatibility: omitting any of direction / destCountry + // falls back to the legacy flat ``pricing.twilio.price`` rate. + + it('omitting context keeps the legacy $0.0085/min flat rate', () => { + const pricing = mergePricing(); + // 60s rounded to 1 min * $0.0085 = $0.0085 + const cost = calculateTelephonyCost('twilio', 60, pricing); + expect(cost).toBeCloseTo(0.0085, 6); + }); + + it('US → US mobile bills outbound $0.014/min (not inbound default)', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'outbound', + destCountry: 'US', + destType: 'mobile', + }); + expect(cost).toBeCloseTo(0.014, 6); + }); + + it('US → IT mobile bills $0.3473/min — ~40x the legacy default', () => { + // This was the bug class flagged on 0.6.1 audit: outbound + // international mobile costs under-estimated by 1-2 orders of magnitude. + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 120, pricing, { + direction: 'outbound', + destCountry: 'IT', + destType: 'mobile', + }); + // 2 min * $0.3473 = $0.6946 + expect(cost).toBeCloseTo(0.6946, 4); + }); + + it('US → GB landline bills $0.0158/min', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'outbound', + destCountry: 'GB', + destType: 'landline', + }); + expect(cost).toBeCloseTo(0.0158, 6); + }); + + it('omitting destType defaults to mobile (conservative)', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'outbound', + destCountry: 'DE', + }); + // DE mobile = $0.042/min, landline = $0.021/min + expect(cost).toBeCloseTo(0.042, 6); + }); + + it('unknown country falls back to $0.0085/min default', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'outbound', + destCountry: 'ZZ', + }); + expect(cost).toBeCloseTo(0.0085, 6); + }); + + it('inbound US local matches legacy $0.0085/min', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'inbound', + destCountry: 'US', + destType: 'landline', + }); + expect(cost).toBeCloseTo(0.0085, 6); + }); + + it('inbound US toll-free bills $0.022/min (2.6x local)', () => { + const pricing = mergePricing(); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'inbound', + destCountry: 'US', + destType: 'tollfree', + }); + expect(cost).toBeCloseTo(0.022, 6); + }); + + it('user-supplied twilio_outbound_matrix override wins over defaults', () => { + const pricing = mergePricing({ + twilio_outbound_matrix: { + IT: { outbound: { landline: 0.005, mobile: 0.01 } }, + }, + } as unknown as Parameters[0]); + const cost = calculateTelephonyCost('twilio', 60, pricing, { + direction: 'outbound', + destCountry: 'IT', + destType: 'mobile', + }); + expect(cost).toBeCloseTo(0.01, 6); + }); + + it('partial minute still rounds up per Twilio billing rules', () => { + const pricing = mergePricing(); + // 30s = 1 billable minute at $0.3473/min + const cost = calculateTelephonyCost('twilio', 30, pricing, { + direction: 'outbound', + destCountry: 'IT', + destType: 'mobile', + }); + expect(cost).toBeCloseTo(0.3473, 4); + }); +}); + +describe('parseE164Country — E.164 → ISO-2 lookup', () => { + it('+1 maps to US', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+14155551234')).toBe('US'); + }); + + it('+39 maps to IT', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+393331234567')).toBe('IT'); + }); + + it('+44 maps to GB', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+447700900123')).toBe('GB'); + }); + + it('+351 (Portugal) wins longest-prefix match', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+351912345678')).toBe('PT'); + }); + + it('strips non-digit characters before lookup', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+39 (033) 123-4567')).toBe('IT'); + }); + + it('unknown country code returns null', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('+999123')).toBeNull(); + }); + + it('empty / null / undefined returns null', async () => { + const { parseE164Country } = await import('../src/services/telephony-pricing-matrix'); + expect(parseE164Country('')).toBeNull(); + expect(parseE164Country(null)).toBeNull(); + expect(parseE164Country(undefined)).toBeNull(); + }); +}); + describe('per-model rates under openai_realtime.models', () => { it('exposes gpt-realtime, gpt-realtime-2, mini, and 4o-preview', () => { const models = DEFAULT_PRICING.openai_realtime.models!;