from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from anydi import singleton from cachelib import BaseCache as Cache from fansifter_common.auth.requests import AuthRequest from fansifter_common.auth.services import AuthService from fansifter_common.auth.types import Permission from fansifter_common.utils.cache import cached from ows_text_campaigns.campaigns.enums import MessageChannel from ows_text_campaigns.campaigns.exceptions import CampaignAudienceRequiredError from ows_text_campaigns.campaigns.services import CampaignService from ows_text_campaigns.twilio.models import TwilioMessagePricingByCountry from ows_text_campaigns.twilio.repositories import ( TwilioMessagePricingByCountryRepository, ) DEFAULT_CACHE_TIMEOUT = 60 * 60 # 1 hour @dataclass(kw_only=True) class Cost: """Represents a min/max cost range. Immutable and supports addition.""" min: float max: float def __post_init__(self): if self.min < 0 or self.max < 0: raise ValueError("Cost values must be non-negative.") def __add__(self, other: Cost) -> Cost: return Cost(min=self.min + other.min, max=self.max + other.max) def rounded(self) -> Cost: self.min = round(self.min, 2) self.max = round(self.max, 2) return self @dataclass(kw_only=True) class ChannelCost: name: MessageChannel number_of_fans: int outbound_cost: Cost inbound_cost: Cost @dataclass(kw_only=True) class CountryCost: name: str channels: list[ChannelCost] @dataclass(kw_only=True) class GetCampaignPricingEstimationResponse: total_cost: Cost countries: list[CountryCost] @dataclass(kw_only=True) class GetCampaignPricingEstimationRequest(AuthRequest): campaign_id: str reply_share: float segments_count: int | None = None @singleton class GetCampaignPricingEstimationHandler: permission = Permission("text_campaign", "view") mms_pricing_cost = Cost(min=0.0220, max=0.0220) def __init__( self, auth_service: AuthService, campaign_service: CampaignService, twilio_message_pricing_by_country_repository: TwilioMessagePricingByCountryRepository, cache: Cache, ) -> None: self.auth_service = auth_service self.campaign_service = campaign_service self.twilio_message_pricing_by_country_repository = ( twilio_message_pricing_by_country_repository ) self.cache = cache def handle( self, request: GetCampaignPricingEstimationRequest ) -> GetCampaignPricingEstimationResponse: campaign = self.campaign_service.get_campaign(request.campaign_id) if not campaign.audience_id: raise CampaignAudienceRequiredError self.auth_service.authorize_account( request.identity_id, account=campaign.account, permission=self.permission, ) message_channel_info = self.campaign_service.get_channel_message_info( campaign=campaign ) campaign_fan_info = self.campaign_service.get_campaign_fan_analytics( audience_id=campaign.audience_id ) if request.segments_count: segments_by_channel = { m.channel: request.segments_count for m in message_channel_info } else: segments_by_channel = { m.channel: m.info.segments_count for m in message_channel_info } campaign_countries = {fan.country for fan in campaign_fan_info} campaign_channels = {fan.channel for fan in campaign_fan_info} segment_pricing_map = self._get_pricing_map( countries=list(campaign_countries), channels=list(campaign_channels) ) total_cost = Cost(min=0.0, max=0.0) country_costs: dict[str, CountryCost] = {} has_media = campaign.get_media_url() is not None for fan_info in campaign_fan_info: channel = fan_info.channel country = fan_info.country fans_count = fan_info.count channel_cost = self._calculate_channel_cost( channel=channel, country=country, fans_count=fans_count, segments_count=segments_by_channel.get(channel), pricing_map=segment_pricing_map, has_media=has_media, reply_share=request.reply_share, ) total_cost += channel_cost.outbound_cost total_cost += channel_cost.inbound_cost if country not in country_costs: country_costs[country] = CountryCost(name=country, channels=[]) country_costs[country].channels.append(channel_cost) for cc in country_costs.values(): for channel in cc.channels: channel.inbound_cost = channel.inbound_cost.rounded() channel.outbound_cost = channel.outbound_cost.rounded() cc.channels.sort(key=lambda x: x.name.value) return GetCampaignPricingEstimationResponse( total_cost=total_cost.rounded(), countries=list(country_costs.values()), ) def _calculate_channel_cost( self, channel: MessageChannel, country: str, fans_count: int, segments_count: int | None, pricing_map: dict[MessageChannel, dict[str, Cost]], has_media: bool, reply_share: float, ) -> ChannelCost: if channel == MessageChannel.SMS: fan_cost = self._get_sms_fan_cost( country=country, segments_count=segments_count, pricing_map=pricing_map, has_media=has_media, ) else: raise NotImplementedError( f"Pricing logic not implemented for channel: {channel.value}" ) final_outbound_cost = Cost( min=fan_cost.min * fans_count, max=fan_cost.max * fans_count, ) final_inbound_cost = Cost( min=final_outbound_cost.min * reply_share, max=final_outbound_cost.max * reply_share, ) return ChannelCost( name=channel, number_of_fans=fans_count, outbound_cost=final_outbound_cost, inbound_cost=final_inbound_cost, ) def _get_sms_fan_cost( self, country: str, segments_count: int | None, pricing_map: dict[MessageChannel, dict[str, Cost]], has_media: bool, ) -> Cost: if has_media and country in ["US", "CA"]: return self.mms_pricing_cost if segments_count is None or segments_count <= 0: raise ValueError( "Segments count is required and must be positive for SMS campaigns." ) country_segment_cost = pricing_map.get(MessageChannel.SMS, {}).get(country) if not country_segment_cost: raise ValueError( f"SMS segment pricing data missing for country: {country}. " "Ensure the Twilio repository provided the required data." ) return Cost( min=country_segment_cost.min * segments_count, max=country_segment_cost.max * segments_count, ) def _get_pricing_map( self, countries: list[str], channels: list[MessageChannel] ) -> dict[MessageChannel, dict[str, Cost]]: message_country_channel_segment_pricing = ( self.get_message_country_channel_segment_pricing( countries=countries, channels=channels ) ) pricing_map: dict[MessageChannel, dict[str, Cost]] = {} for cost_entry in message_country_channel_segment_pricing: channel = cost_entry.message_type country = cost_entry.country_iso2 segment_cost = Cost( min=cost_entry.min_current_price, max=cost_entry.max_current_price, ) if channel not in pricing_map: pricing_map[channel] = {} pricing_map[channel][country] = segment_cost return pricing_map @cached( "global-message-segment-pricing-by-country-channel", timeout=DEFAULT_CACHE_TIMEOUT, ) def get_message_country_channel_segment_pricing( self, countries: list[str], channels: list[MessageChannel] ) -> Sequence[TwilioMessagePricingByCountry]: return self.twilio_message_pricing_by_country_repository.find_by_countries_and_channels( countries=countries, channels=channels )