Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 81 |
|
0.00% |
0 / 6 |
CRAP | |
0.00% |
0 / 1 |
| ChatBillingService | |
0.00% |
0 / 81 |
|
0.00% |
0 / 6 |
110 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| createWithDefaults | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| trackUsage | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
6 | |||
| getUsageReport | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| getUsageSummary | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
12 | |||
| getGlobalUsageSummary | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Chat\Services; |
| 4 | |
| 5 | use PDO; |
| 6 | use DateTime; |
| 7 | |
| 8 | /** |
| 9 | * ChatBillingService - SMS Usage Tracking for Billing |
| 10 | * |
| 11 | * Tracks SMS message usage for billing and analytics per PRD Feature 9 (lines 197-205): |
| 12 | * - Every outbound message logged with category (transactional/interactive) |
| 13 | * - Transactional messages marked as included (free) - NOT billable |
| 14 | * - Interactive messages marked as billable |
| 15 | * - Usage aggregated by store and billing period |
| 16 | * - Usage report available to admin |
| 17 | * |
| 18 | * Billing Rules: |
| 19 | * - Transactional messages: NOT billable (free) - e.g., "Your buy is ready" |
| 20 | * - Interactive messages: BILLABLE - customer replies, staff freetext responses |
| 21 | * - Inbound messages: Always 'interactive' category (billable) |
| 22 | * - Outbound with templateId: Check template category |
| 23 | * - Outbound freetext: Always 'interactive' (billable) |
| 24 | * |
| 25 | * @package BuyerKiosk\Chat\Services |
| 26 | */ |
| 27 | class ChatBillingService |
| 28 | { |
| 29 | /** |
| 30 | * Default cost per SMS segment (USD) |
| 31 | */ |
| 32 | private const DEFAULT_COST_PER_SEGMENT = 0.0075; |
| 33 | |
| 34 | /** |
| 35 | * @var PDO Central database connection (kiosk_buykiosk) |
| 36 | */ |
| 37 | private PDO $centralDb; |
| 38 | |
| 39 | /** |
| 40 | * @var float Cost per segment (can be overridden) |
| 41 | */ |
| 42 | private float $costPerSegment; |
| 43 | |
| 44 | /** |
| 45 | * Create a new ChatBillingService |
| 46 | * |
| 47 | * @param PDO $centralDb Central database connection (kiosk_buykiosk) |
| 48 | * @param float|null $costPerSegment Optional override for cost per segment |
| 49 | */ |
| 50 | public function __construct(PDO $centralDb, ?float $costPerSegment = null) |
| 51 | { |
| 52 | $this->centralDb = $centralDb; |
| 53 | $this->costPerSegment = $costPerSegment ?? self::DEFAULT_COST_PER_SEGMENT; |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * Create service with default dependencies using global functions |
| 58 | * |
| 59 | * Factory method for production use where we use the actual BaseModel functions. |
| 60 | * |
| 61 | * @return self |
| 62 | */ |
| 63 | public static function createWithDefaults(): self |
| 64 | { |
| 65 | global $db_name; |
| 66 | |
| 67 | $centralDb = dbConnectByName($db_name); |
| 68 | |
| 69 | return new self($centralDb); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Track SMS usage for billing |
| 74 | * |
| 75 | * Records a message in the chat_sms_usage table for billing and analytics. |
| 76 | * Automatically determines billable status based on category: |
| 77 | * - 'transactional' = NOT billable (free tier) |
| 78 | * - 'interactive' = billable |
| 79 | * |
| 80 | * @param string $typeNum Store identifier (e.g., 'ou00') |
| 81 | * @param int|null $threadId Reference to chat_threads.id in store DB (null for webhooks) |
| 82 | * @param int|null $messageId Reference to chat_messages.id in store DB (null for webhooks) |
| 83 | * @param string $direction Message direction: 'inbound' or 'outbound' |
| 84 | * @param string $category Message category: 'transactional' or 'interactive' |
| 85 | * @param string $provider SMS provider: 'vonage' or 'twilio' |
| 86 | * @param string|null $providerMessageId External message ID from provider |
| 87 | * @param int $segmentCount Number of SMS segments |
| 88 | * @param DateTime $sentAt When the message was sent |
| 89 | * @return int The inserted usage record ID |
| 90 | */ |
| 91 | public function trackUsage( |
| 92 | string $typeNum, |
| 93 | ?int $threadId, |
| 94 | ?int $messageId, |
| 95 | string $direction, |
| 96 | string $category, |
| 97 | string $provider, |
| 98 | ?string $providerMessageId, |
| 99 | int $segmentCount, |
| 100 | DateTime $sentAt |
| 101 | ): int { |
| 102 | // Determine billable status based on category |
| 103 | // Transactional = free tier (not billable) |
| 104 | // Interactive = billable |
| 105 | $billable = ($category === 'interactive') ? 1 : 0; |
| 106 | |
| 107 | // Calculate cost |
| 108 | $totalCost = $segmentCount * $this->costPerSegment; |
| 109 | |
| 110 | // Calculate billing period from sent_at |
| 111 | $billingPeriod = $sentAt->format('Y-m'); |
| 112 | |
| 113 | // Insert usage record |
| 114 | $sql = "INSERT INTO chat_sms_usage ( |
| 115 | typeNum, |
| 116 | thread_id, |
| 117 | message_id, |
| 118 | direction, |
| 119 | category, |
| 120 | provider, |
| 121 | provider_message_id, |
| 122 | segment_count, |
| 123 | cost_per_segment, |
| 124 | total_cost, |
| 125 | billable, |
| 126 | sent_at, |
| 127 | billing_period |
| 128 | ) VALUES ( |
| 129 | :typeNum, |
| 130 | :thread_id, |
| 131 | :message_id, |
| 132 | :direction, |
| 133 | :category, |
| 134 | :provider, |
| 135 | :provider_message_id, |
| 136 | :segment_count, |
| 137 | :cost_per_segment, |
| 138 | :total_cost, |
| 139 | :billable, |
| 140 | :sent_at, |
| 141 | :billing_period |
| 142 | )"; |
| 143 | |
| 144 | $stmt = $this->centralDb->prepare($sql); |
| 145 | $stmt->execute([ |
| 146 | ':typeNum' => $typeNum, |
| 147 | ':thread_id' => $threadId, |
| 148 | ':message_id' => $messageId, |
| 149 | ':direction' => $direction, |
| 150 | ':category' => $category, |
| 151 | ':provider' => $provider, |
| 152 | ':provider_message_id' => $providerMessageId, |
| 153 | ':segment_count' => $segmentCount, |
| 154 | ':cost_per_segment' => $this->costPerSegment, |
| 155 | ':total_cost' => $totalCost, |
| 156 | ':billable' => $billable, |
| 157 | ':sent_at' => $sentAt->format('Y-m-d H:i:s'), |
| 158 | ':billing_period' => $billingPeriod, |
| 159 | ]); |
| 160 | |
| 161 | return (int) $this->centralDb->lastInsertId(); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Get detailed usage report for a store and billing period |
| 166 | * |
| 167 | * Returns all messages for the specified store and billing period, |
| 168 | * ordered by sent_at descending (most recent first). |
| 169 | * |
| 170 | * @param string $typeNum Store identifier (e.g., 'ou00') |
| 171 | * @param string $billingPeriod Billing period in 'YYYY-MM' format |
| 172 | * @return array Array of usage records |
| 173 | */ |
| 174 | public function getUsageReport(string $typeNum, string $billingPeriod): array |
| 175 | { |
| 176 | $sql = "SELECT |
| 177 | id, |
| 178 | typeNum, |
| 179 | thread_id, |
| 180 | message_id, |
| 181 | direction, |
| 182 | category, |
| 183 | provider, |
| 184 | provider_message_id, |
| 185 | segment_count, |
| 186 | cost_per_segment, |
| 187 | total_cost, |
| 188 | billable, |
| 189 | sent_at, |
| 190 | billing_period, |
| 191 | created_at |
| 192 | FROM chat_sms_usage |
| 193 | WHERE typeNum = :typeNum |
| 194 | AND billing_period = :billing_period |
| 195 | ORDER BY sent_at DESC"; |
| 196 | |
| 197 | $stmt = $this->centralDb->prepare($sql); |
| 198 | $stmt->execute([ |
| 199 | ':typeNum' => $typeNum, |
| 200 | ':billing_period' => $billingPeriod, |
| 201 | ]); |
| 202 | |
| 203 | return $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Get usage summary for a store and billing period |
| 208 | * |
| 209 | * Returns aggregated statistics including: |
| 210 | * - total: Total message count |
| 211 | * - billable: Billable message count (interactive) |
| 212 | * - free: Free message count (transactional) |
| 213 | * - totalSegments: Total SMS segments |
| 214 | * - billableSegments: Billable segments only |
| 215 | * - totalCost: Total cost for all messages |
| 216 | * - billableCost: Cost for billable messages only |
| 217 | * |
| 218 | * @param string $typeNum Store identifier (e.g., 'ou00') |
| 219 | * @param string $billingPeriod Billing period in 'YYYY-MM' format |
| 220 | * @return array Summary statistics |
| 221 | */ |
| 222 | public function getUsageSummary(string $typeNum, string $billingPeriod): array |
| 223 | { |
| 224 | $sql = "SELECT |
| 225 | COUNT(*) as total_messages, |
| 226 | SUM(CASE WHEN billable = 1 THEN 1 ELSE 0 END) as billable_messages, |
| 227 | SUM(CASE WHEN billable = 0 THEN 1 ELSE 0 END) as free_messages, |
| 228 | SUM(segment_count) as total_segments, |
| 229 | SUM(CASE WHEN billable = 1 THEN segment_count ELSE 0 END) as billable_segments, |
| 230 | SUM(total_cost) as total_cost, |
| 231 | SUM(CASE WHEN billable = 1 THEN total_cost ELSE 0 END) as billable_cost |
| 232 | FROM chat_sms_usage |
| 233 | WHERE typeNum = :typeNum |
| 234 | AND billing_period = :billing_period"; |
| 235 | |
| 236 | $stmt = $this->centralDb->prepare($sql); |
| 237 | $stmt->execute([ |
| 238 | ':typeNum' => $typeNum, |
| 239 | ':billing_period' => $billingPeriod, |
| 240 | ]); |
| 241 | |
| 242 | $result = $stmt->fetch(PDO::FETCH_ASSOC); |
| 243 | |
| 244 | // Handle empty period (no data) |
| 245 | if (!$result || $result['total_messages'] === null) { |
| 246 | return [ |
| 247 | 'total' => 0, |
| 248 | 'billable' => 0, |
| 249 | 'free' => 0, |
| 250 | 'totalSegments' => 0, |
| 251 | 'billableSegments' => 0, |
| 252 | 'totalCost' => 0.0, |
| 253 | 'billableCost' => 0.0, |
| 254 | ]; |
| 255 | } |
| 256 | |
| 257 | return [ |
| 258 | 'total' => (int) $result['total_messages'], |
| 259 | 'billable' => (int) $result['billable_messages'], |
| 260 | 'free' => (int) $result['free_messages'], |
| 261 | 'totalSegments' => (int) $result['total_segments'], |
| 262 | 'billableSegments' => (int) $result['billable_segments'], |
| 263 | 'totalCost' => (float) $result['total_cost'], |
| 264 | 'billableCost' => (float) $result['billable_cost'], |
| 265 | ]; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Get usage summary across all stores for a billing period |
| 270 | * |
| 271 | * Returns aggregated statistics for all stores, useful for admin reports. |
| 272 | * |
| 273 | * @param string $billingPeriod Billing period in 'YYYY-MM' format |
| 274 | * @return array Array of summaries keyed by typeNum |
| 275 | */ |
| 276 | public function getGlobalUsageSummary(string $billingPeriod): array |
| 277 | { |
| 278 | $sql = "SELECT |
| 279 | typeNum, |
| 280 | COUNT(*) as total_messages, |
| 281 | SUM(CASE WHEN billable = 1 THEN 1 ELSE 0 END) as billable_messages, |
| 282 | SUM(CASE WHEN billable = 0 THEN 1 ELSE 0 END) as free_messages, |
| 283 | SUM(segment_count) as total_segments, |
| 284 | SUM(CASE WHEN billable = 1 THEN segment_count ELSE 0 END) as billable_segments, |
| 285 | SUM(total_cost) as total_cost, |
| 286 | SUM(CASE WHEN billable = 1 THEN total_cost ELSE 0 END) as billable_cost |
| 287 | FROM chat_sms_usage |
| 288 | WHERE billing_period = :billing_period |
| 289 | GROUP BY typeNum |
| 290 | ORDER BY total_messages DESC"; |
| 291 | |
| 292 | $stmt = $this->centralDb->prepare($sql); |
| 293 | $stmt->execute([ |
| 294 | ':billing_period' => $billingPeriod, |
| 295 | ]); |
| 296 | |
| 297 | $results = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 298 | $summaries = []; |
| 299 | |
| 300 | foreach ($results as $row) { |
| 301 | $summaries[$row['typeNum']] = [ |
| 302 | 'total' => (int) $row['total_messages'], |
| 303 | 'billable' => (int) $row['billable_messages'], |
| 304 | 'free' => (int) $row['free_messages'], |
| 305 | 'totalSegments' => (int) $row['total_segments'], |
| 306 | 'billableSegments' => (int) $row['billable_segments'], |
| 307 | 'totalCost' => (float) $row['total_cost'], |
| 308 | 'billableCost' => (float) $row['billable_cost'], |
| 309 | ]; |
| 310 | } |
| 311 | |
| 312 | return $summaries; |
| 313 | } |
| 314 | } |