# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

CON-1 **Language/Framework**: PHP 8.x on Slim 2.6.2 framework with Twig 1.44.8 templating. No new frameworks or languages. All server-side logic in PHP.

CON-2 **Database Architecture**: Multi-store MySQL architecture. Central databases (`kiosk_buykiosk`, `kiosk_users`) for shared data. Per-store databases (`kiosk_{typeNum}`) for store-specific data. All billing data MUST reside in the central `kiosk_buykiosk` database for cross-store reporting.

CON-3 **Migration System**: All schema changes via conductor migration system using JSON files in `userfrosting/migrations/input/`. No direct table modifications.

CON-4 **UI Components**: Syncfusion EJ2 components preferred over Bootstrap or custom implementations. Existing license available. Use for grids, charts, and PDF generation.

CON-5 **Existing Billing Fields**: The `stores` table already has `billingActive`, `billingType`, `billingRate`, `billingDiscount`, `billingDiscountDate`, `nextBillingDate`. These MUST be preserved for backward compatibility with existing `Bill.php` and `BillingController.php`.

CON-6 **User Tables**: Use `kiosk_users.users` table (not deprecated store-level `employees`). User IDs reference `users.id`.

CON-7 **Naming Conventions**: camelCase for new database columns and tables. PSR-4 autoloading under `BuyerKiosk\` namespace.

CON-8 **No Payment Processing**: This system tracks and reports billing only. No Stripe, PayPal, or credit card integration.

CON-9 **TaskEngine**: Scheduled jobs run via the existing TaskEngine infrastructure with BaseJob, JobDispatcher, and JobRegistry.

## Implementation Context

### Required Context Sources

- ICO-1 Billing Infrastructure (existing)
 ```yaml
 - file: userfrosting/src/BuyerKiosk/Billing/Bill.php
   relevance: HIGH
   why: "Existing billing calculation logic. Must preserve but not extend."

 - file: userfrosting/src/BuyerKiosk/Billing/Invoice.php
   relevance: HIGH
   why: "Existing file-based PDF invoice system. Legacy invoices must remain accessible."

 - file: userfrosting/src/BuyerKiosk/Billing/Controllers/BillingController.php
   relevance: MEDIUM
   why: "Existing cross-store billing aggregation. Reference pattern only."
 ```

- ICO-2 SMS Infrastructure
 ```yaml
 - file: userfrosting/src/BuyerKiosk/SMS/TextMessageService/TextMessageService.php
   relevance: CRITICAL
   sections: [sendBuyText, sendServiceText, sendSurveyText, sendCustomText]
   why: "Primary SMS hook point. Every send*() method must log billing usage."

 - file: userfrosting/src/BuyerKiosk/Chat/Services/ChatBillingService.php
   relevance: HIGH
   why: "Existing chat SMS usage tracking. Read from at invoice time, don't replace."

 - file: userfrosting/src/BuyerKiosk/SMS/TextMessageService/TwilioTextSender.php
   relevance: MEDIUM
   why: "Twilio provider implementation. Returns segment count."

 - file: userfrosting/src/BuyerKiosk/SMS/TextMessageService/VonageTextSender.php
   relevance: MEDIUM
   why: "Vonage provider implementation. Returns segment count."
 ```

- ICO-3 Premium Module (pattern reference)
 ```yaml
 - file: userfrosting/src/BuyerKiosk/Premium/PremiumService.php
   relevance: HIGH
   why: "Pattern for service layer with Redis caching. Follow for BillingService."

 - file: userfrosting/src/BuyerKiosk/Premium/PremiumRepository.php
   relevance: HIGH
   why: "Pattern for repository with whitelisted columns and atomic operations."

 - file: userfrosting/src/BuyerKiosk/Premium/PremiumEventLogger.php
   relevance: MEDIUM
   why: "Pattern for event logging to database table."

 - file: userfrosting/migrations/input/20260209_035_001_premium_columns.json
   relevance: HIGH
   why: "Migration JSON format reference for adding columns to stores table."

 - file: userfrosting/migrations/input/20260209_035_002_premium_event_log.json
   relevance: HIGH
   why: "Migration JSON format reference for creating new tables."
 ```

- ICO-4 TaskEngine
 ```yaml
 - file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/BaseJob.php
   relevance: HIGH
   why: "Base class for scheduled jobs. InvoiceGenerationJob extends this."

 - file: userfrosting/src/BuyerKiosk/TaskEngine/Jobs/TrialExpirationJob.php
   relevance: HIGH
   why: "Reference implementation for global scheduled job."

 - file: userfrosting/src/BuyerKiosk/TaskEngine/Application/JobDispatcher.php
   relevance: MEDIUM
   why: "Job dispatch and idempotency key patterns."
 ```

- ICO-5 UI Patterns
 ```yaml
 - file: userfrosting/templates/themes/default/analytics/financial.html
   relevance: HIGH
   why: "Syncfusion chart + KPI card dashboard pattern. Follow for billing dashboard."

 - file: userfrosting/templates/themes/default/fivestars/point_report.html
   relevance: HIGH
   why: "Syncfusion EJ2 Grid with filtering, sorting, export."

 - file: userfrosting/routes/admin/scheduling.php
   relevance: HIGH
   why: "Route group pattern for multi-page admin area."

 - file: userfrosting/routes/premium.php
   relevance: HIGH
   why: "API route pattern with CSRF validation."

 - file: userfrosting/templates/themes/default/menus/sidebar.html
   relevance: HIGH
   why: "Sidebar menu item pattern for adding Billing entry."
 ```

- ICO-6 Store Entity
 ```yaml
 - file: userfrosting/src/BuyerKiosk/Core/Store.php
   relevance: CRITICAL
   sections: [billingActive, billingRate, storeType, textMessageService, createStoreFromRowArray]
   why: "Core store entity. Has existing billing fields. storeType determines default base rate."
 ```

### Implementation Boundaries

- **Must Preserve**: Existing `Bill.php`, `Invoice.php`, `BillingController.php` — legacy billing continues to work. Existing `ChatBillingService` tracks chat SMS as-is. All existing `TextMessageService` send*() method signatures unchanged.
- **Can Modify**: `TextMessageService.php` (add required SmsUsageTrackerInterface param, add billing hook after send). `Store.php` (update `setTextMessageService()` to inject SmsUsageTracker). `ChatApiController.php` (add dual-write to billingSmsUsage). `stores` table (add new billing config columns via migration). Sidebar template (add billing menu item). `TaskCommandFactory` (register new job).
- **Must Not Touch**: Per-store database schemas. `kiosk_users` tables. Existing SMS provider implementations (Twilio/Vonage senders). `floodProtector` system.
- **Note on `marketing` SMS category**: The marketing SMS system (SellerMarketingService, sms_queue) was removed per spec 028-text-marketing-removal. The `marketing` enum value is retained in the `smsCategory` schema for forward compatibility but has no active send path. If marketing SMS is re-introduced in the future, its send path must integrate with `SmsUsageTracker`. No billing hook is needed for marketing in the current implementation.

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Admin[Platform Admin<br>Ryan] --> BillingDashboard[Billing Dashboard<br>Admin View]
    StoreOwner[Store Owner<br>Casey] --> StoreBilling[Store Billing View]

    BillingDashboard --> BillingAPI[Billing API<br>/api/billing/]
    StoreBilling --> BillingAPI

    BillingAPI --> BillingService[BillingService]
    BillingService --> CentralDB[(kiosk_buykiosk<br>Central DB)]

    TextMessageService[TextMessageService<br>SMS Sends] --> SmsUsageTracker[SMS Usage Tracker]
    SmsUsageTracker --> CentralDB

    ChatBillingService[ChatBillingService<br>Chat SMS] --> CentralDB

    TaskEngine[TaskEngine<br>Scheduler] --> InvoiceJob[Invoice Generation Job]
    InvoiceJob --> BillingService
    InvoiceJob --> CentralDB

    PremiumService[PremiumService<br>Premium Status] --> CentralDB

    MobileAPI[Mobile App API<br>Future] -.-> BillingAPI
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Admin Web Interface"
    type: HTTP/HTTPS
    format: Twig-rendered HTML + AJAX
    authentication: Session (UF auth)
    data_flow: "Admin views billing dashboard, drills into store invoices, configures billing"

  - name: "Store Owner Web Interface"
    type: HTTP/HTTPS
    format: Twig-rendered HTML + AJAX
    authentication: Session (UF auth) + checkStoreGroup()
    data_flow: "Store owners view their invoices and usage"

  - name: "Billing REST API"
    type: HTTPS
    format: JSON REST
    authentication: Session + CSRF token
    data_flow: "AJAX calls for billing data, configuration updates"

  - name: "Mobile App API (Future)"
    type: HTTPS
    format: JSON REST
    authentication: JWT
    data_flow: "Store billing data for mobile app"

# Internal Interfaces
internal:
  - name: "TextMessageService Hook"
    type: PHP method call
    format: Direct function call
    data_flow: "SMS send events → billing usage log"

  - name: "TaskEngine Scheduler"
    type: Cron → PHP job
    format: BaseJob handler
    data_flow: "Monthly trigger → invoice generation"

# Data Interfaces
data:
  - name: "Central Database (kiosk_buykiosk)"
    type: MySQL 8.x
    connection: PDO via dbConnectByName()
    data_flow: "All billing tables: invoices, line items, usage tracking, configuration"

  - name: "Redis Cache"
    type: Redis via Predis
    connection: $_ENV['REDIS_URL']
    data_flow: "Billing config cache (TTL 300s)"
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit                    # Run all unit tests
cd userfrosting && ./vendor/bin/phpunit --filter "Billing"  # Run billing tests only

# CSS Build
php userfrosting/conductor build-css           # Development build
php userfrosting/conductor build-css --minify  # Production build

# Database Migrations
php userfrosting/conductor run                 # Run all pending migrations

# TaskEngine
php userfrosting/bin/task job:list             # Verify job registered
php userfrosting/bin/task job:dispatch invoice-generation  # Manual dispatch

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Billing/
```

## Solution Strategy

- **Architecture Pattern**: Layered Service Architecture following existing Premium module pattern. Controller → Service → Repository layers with clear separation of concerns.
- **Integration Approach**: Minimal-invasion hooks into existing systems. TextMessageService gets a billing logger injected. ChatBillingService data consumed at invoice generation time (not migrated). TaskEngine runs monthly invoice job.
- **Justification**: The codebase already uses this pattern successfully (PremiumService/PremiumRepository/PremiumEventLogger). Following established patterns reduces risk and allows future developers to understand the billing system by analogy with the premium module.
- **Key Decisions**:
  1. All billing data in central `kiosk_buykiosk` database (not per-store)
  2. SMS usage tracking via hook in TextMessageService (single point of truth)
  3. Invoice generation as a global TaskEngine job (not per-store)
  4. Billing configuration as new columns on `stores` table + new `billingSmsCategoryConfig` table
  5. Syncfusion EJ2 Grid for all tabular views, Charts for usage trends
  6. **Config timing split**: The `billable` flag is snapshotted at SMS send time (written to `billingSmsUsage.billable`). The `rate` is applied at invoice generation time using the store's current config. This means: changing a category from "included" to "billable" mid-month does NOT retroactively bill already-sent texts (their `billable=0` is locked in), but changing the per-message rate DOES apply the new rate to all usage in that period at invoice time. This is the intended "changes take effect next billing period" behavior for the billable flag, while rates are always current at generation time.

## Building Block View

### Components

```mermaid
graph TB
    subgraph "Presentation Layer"
        AdminDash[Admin Billing Dashboard<br>billing/dashboard.html]
        StoreView[Store Billing View<br>billing/store-billing.html]
        ConfigUI[Billing Config UI<br>billing/configuration.html]
    end

    subgraph "API Layer"
        BillingAPI[BillingApiController]
        BillingPageCtrl[BillingPageController]
    end

    subgraph "Service Layer"
        BillingSvc[BillingService]
        InvoiceSvc[InvoiceService]
        SmsUsageTracker[SmsUsageTracker]
        BillingConfigSvc[BillingConfigService]
    end

    subgraph "Repository Layer"
        InvoiceRepo[InvoiceRepository]
        UsageRepo[SmsUsageRepository]
        ConfigRepo[BillingConfigRepository]
    end

    subgraph "Job Layer"
        InvoiceJob[InvoiceGenerationJob<br>TaskEngine BaseJob]
    end

    subgraph "Integration Points"
        TMS[TextMessageService<br>SMS Hook]
        CBS[ChatBillingService<br>Read-only]
        PremSvc[PremiumService<br>Status Check]
        LegacyInv[Invoice.php<br>Legacy PDFs]
    end

    subgraph "Data Layer"
        DB[(kiosk_buykiosk<br>billingInvoices<br>billingLineItems<br>billingSmsCategoryConfig<br>billingSmsUsage)]
        Redis[(Redis Cache<br>billing config)]
    end

    AdminDash --> BillingPageCtrl
    StoreView --> BillingPageCtrl
    ConfigUI --> BillingAPI

    BillingPageCtrl --> BillingSvc
    BillingAPI --> BillingSvc
    BillingAPI --> BillingConfigSvc
    BillingAPI --> InvoiceSvc

    BillingSvc --> InvoiceRepo
    BillingSvc --> UsageRepo
    InvoiceSvc --> InvoiceRepo
    SmsUsageTracker --> UsageRepo
    BillingConfigSvc --> ConfigRepo
    BillingConfigSvc --> Redis

    InvoiceJob --> BillingSvc
    InvoiceJob --> InvoiceSvc

    TMS --> SmsUsageTracker
    BillingSvc --> CBS
    BillingSvc --> PremSvc
    StoreView --> LegacyInv

    InvoiceRepo --> DB
    UsageRepo --> DB
    ConfigRepo --> DB
```

### Directory Map

```
userfrosting/src/BuyerKiosk/Billing/
├── Bill.php                              # PRESERVE: Existing billing calculation
├── Invoice.php                           # PRESERVE: Legacy PDF invoice file serving
├── Controllers/
│   ├── BillingController.php             # PRESERVE: Existing cross-store billing
│   ├── BillingPageController.php         # NEW: Admin + store billing pages
│   └── BillingApiController.php          # NEW: REST API endpoints
├── Services/
│   ├── BillingService.php                # NEW: Core billing logic, invoice calculation
│   ├── InvoiceService.php                # NEW: Invoice CRUD, PDF generation
│   ├── BillingConfigService.php          # NEW: Per-store config with Redis cache
│   ├── SmsUsageTrackerInterface.php      # NEW: Interface for SMS billing tracking
│   ├── SmsUsageTracker.php               # NEW: Production SMS billing usage logger
│   └── NullSmsUsageTracker.php           # NEW: No-op implementation for tests
├── Repositories/
│   ├── InvoiceRepository.php             # NEW: Invoice + line item DB operations
│   ├── SmsUsageRepository.php            # NEW: SMS usage log DB operations
│   └── BillingConfigRepository.php       # NEW: Billing config DB operations
├── Models/
│   ├── BillingInvoice.php                # NEW: Invoice value object
│   ├── BillingLineItem.php               # NEW: Line item value object
│   ├── SmsUsageRecord.php                # NEW: SMS usage record value object
│   └── BillingConfig.php                 # NEW: Per-store billing config value object
└── Enums/
    ├── LineItemType.php                   # NEW: Enum for line item types
    ├── SmsCategory.php                    # NEW: Enum for SMS categories
    └── InvoiceStatus.php                  # NEW: Enum for invoice statuses

userfrosting/src/BuyerKiosk/TaskEngine/Jobs/
└── InvoiceGenerationJob.php              # NEW: Monthly invoice generation job

userfrosting/routes/
├── admin/billing.php                     # NEW: Admin billing page routes
└── groups/billing.php                    # NEW: Billing API routes

userfrosting/templates/themes/default/billing/
├── dashboard.html                        # NEW: Admin billing dashboard
├── store-billing.html                    # NEW: Store owner billing view
├── invoice-detail.html                   # NEW: Invoice detail view
├── configuration.html                    # NEW: Admin billing config
└── partials/
    ├── invoice-grid.html                 # NEW: Syncfusion grid partial
    ├── usage-charts.html                 # NEW: Syncfusion chart partial
    └── kpi-cards.html                    # NEW: KPI summary cards

public_html/css/admin/modules/
└── billing.css                           # NEW: Billing module styles

userfrosting/migrations/input/
├── 20260210_036_001_billing_sms_usage.json       # NEW: billingSmsUsage table
├── 20260210_036_002_billing_invoices.json        # NEW: billingInvoices table
├── 20260210_036_003_billing_line_items.json      # NEW: billingLineItems table
├── 20260210_036_004_billing_sms_config.json      # NEW: billingSmsCategoryConfig table
├── 20260210_036_005_billing_store_columns.json   # NEW: Add billing config columns to stores
└── 20260210_036_006_billing_job_definition.json  # NEW: TaskEngine job definition
```

### Interface Specifications

#### Data Storage Changes

**Table: `billingSmsUsage`** (NEW — Central DB: `kiosk_buykiosk`)
```sql
CREATE TABLE `billingSmsUsage` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `typeNum` varchar(10) NOT NULL COMMENT 'Store identifier',
  `smsCategory` enum('buy_completion','service_completion','survey','chat_transactional','chat_interactive','marketing','custom') NOT NULL,
  `segmentCount` tinyint unsigned NOT NULL DEFAULT 1,
  `provider` enum('twilio','vonage') NOT NULL,
  `providerMessageId` varchar(255) DEFAULT NULL,
  `direction` enum('outbound','inbound') NOT NULL DEFAULT 'outbound',
  `billable` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Snapshotted at send time from store config',
  `status` enum('success','failed') NOT NULL DEFAULT 'success',
  `billingPeriod` varchar(7) NOT NULL COMMENT 'YYYY-MM format',
  `sentAt` datetime NOT NULL,
  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_typenum_period` (`typeNum`, `billingPeriod`),
  KEY `idx_period_category` (`billingPeriod`, `smsCategory`),
  KEY `idx_typenum_category_period` (`typeNum`, `smsCategory`, `billingPeriod`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SMS usage tracking for billing';
```

**Table: `billingInvoices`** (NEW — Central DB: `kiosk_buykiosk`)
```sql
CREATE TABLE `billingInvoices` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `invoiceNumber` varchar(20) NOT NULL COMMENT 'Format: BK-YYMM-typeNum',
  `typeNum` varchar(10) NOT NULL,
  `billingPeriod` varchar(7) NOT NULL COMMENT 'YYYY-MM format',
  `periodStart` date NOT NULL,
  `periodEnd` date NOT NULL,
  `issueDate` date NOT NULL,
  `totalAmount` decimal(10,2) NOT NULL DEFAULT 0.00,
  `status` enum('finalized','voided') NOT NULL DEFAULT 'finalized',
  `voidedAt` datetime DEFAULT NULL,
  `voidedByUserId` int unsigned DEFAULT NULL,
  `replacedByInvoiceId` int unsigned DEFAULT NULL COMMENT 'Points to regenerated invoice',
  `generatedByJobId` int unsigned DEFAULT NULL COMMENT 'TaskEngine execution ID',
  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_active_invoice` (`typeNum`, `billingPeriod`, `status`),
  KEY `idx_typenum` (`typeNum`),
  KEY `idx_period` (`billingPeriod`),
  KEY `idx_invoice_number` (`invoiceNumber`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Billing invoices';
```

> **Note on uniqueness**: The `uq_active_invoice` unique index on (`typeNum`, `billingPeriod`, `status`) ensures only one `finalized` invoice per store per period. Voided invoices are allowed since they won't conflict. When voiding + regenerating, the old invoice is set to `voided` before the new `finalized` one is inserted.

**Table: `billingLineItems`** (NEW — Central DB: `kiosk_buykiosk`)
```sql
CREATE TABLE `billingLineItems` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `invoiceId` int unsigned NOT NULL,
  `lineItemType` enum('base_subscription','premium_module','sms_usage','sms_included','manual_adjustment') NOT NULL,
  `description` varchar(255) NOT NULL,
  `smsCategory` enum('buy_completion','service_completion','survey','chat_transactional','chat_interactive','marketing','custom') DEFAULT NULL COMMENT 'Only for SMS line items',
  `quantity` int NOT NULL DEFAULT 1,
  `unitRate` decimal(10,4) NOT NULL DEFAULT 0.0000,
  `totalAmount` decimal(10,2) NOT NULL DEFAULT 0.00,
  `sortOrder` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'Display ordering on invoice',
  `metadata` json DEFAULT NULL COMMENT 'Additional context (e.g., segment count for SMS)',
  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_invoice` (`invoiceId`),
  KEY `idx_type` (`lineItemType`),
  CONSTRAINT `fk_lineitem_invoice` FOREIGN KEY (`invoiceId`) REFERENCES `billingInvoices` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Invoice line items';
```

**Table: `billingSmsCategoryConfig`** (NEW — Central DB: `kiosk_buykiosk`)
```sql
CREATE TABLE `billingSmsCategoryConfig` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `typeNum` varchar(10) NOT NULL COMMENT 'Store identifier',
  `smsCategory` enum('buy_completion','service_completion','survey','chat_transactional','chat_interactive','marketing','custom') NOT NULL,
  `billable` tinyint(1) NOT NULL DEFAULT 1 COMMENT '0=included/free, 1=billable',
  `ratePerMessage` decimal(10,4) NOT NULL DEFAULT 0.0100 COMMENT 'Rate per message. Segment count stored in usage for future per-segment billing if needed.',
  `includedFreeCount` int unsigned NOT NULL DEFAULT 0 COMMENT 'Free messages per billing period',
  `isEnabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Whether this category is active for this store',
  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_store_category` (`typeNum`, `smsCategory`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Per-store SMS category billing configuration';
```

**Table: `stores`** (ALTER — Central DB: `kiosk_buykiosk`)
```sql
-- New columns for unified billing config
ALTER TABLE `stores` ADD COLUMN `billingBaseRateOverride` decimal(10,2) DEFAULT NULL
  COMMENT 'Custom base rate override. NULL = use concept default' AFTER `billingDiscountDate`;

ALTER TABLE `stores` ADD COLUMN `billingPremiumRate` decimal(10,2) NOT NULL DEFAULT 30.00
  COMMENT 'Premium module monthly rate' AFTER `billingBaseRateOverride`;

ALTER TABLE `stores` ADD COLUMN `billingContactEmail` varchar(255) DEFAULT NULL
  COMMENT 'Optional billing contact email. Falls back to store owner email' AFTER `billingPremiumRate`;

ALTER TABLE `stores` ADD COLUMN `billingConfigUpdatedAt` datetime DEFAULT NULL
  COMMENT 'Last billing config change timestamp' AFTER `billingContactEmail`;
```

#### Internal API Changes

```yaml
# Admin Billing Dashboard
Endpoint: Get All Stores Billing Summary
  Method: GET
  Path: /api/billing/summary
  Query:
    period: string (YYYY-MM, default current month)
    page: int (default 1)
    pageSize: int (default 50, max 200)
    sort: string (default "typeNum", options: typeNum, totalAmount, storeName)
    sortDir: string (default "asc", options: asc, desc)
  Response:
    success:
      data: array of { typeNum, storeName, storeType, baseRate, premiumAmount, smsAmount, totalAmount, invoiceStatus, invoiceId }
      summary: { totalBase, totalPremium, totalSms, grandTotal, storeCount }
      pagination: { page, pageSize, total }
    error:
      success: false
      error: string
  Auth: Session + uri_bkadmin permission

Endpoint: Get Store Billing Detail
  Method: GET
  Path: /api/billing/:typeNum
  Query:
    period: string (YYYY-MM, default current month)
  Response:
    success:
      store: { typeNum, name, storeType }
      currentPeriod: { period, baseRate, premiumAmount, smsUsage: { category, count, amount }[], total }
      latestInvoice: { id, invoiceNumber, period, total, status, issueDate } | null
      Note: "Use GET /api/billing/:typeNum/invoices for paginated invoice list"
    error:
      success: false
      error: string
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

Endpoint: Get Invoice Detail
  Method: GET
  Path: /api/billing/:typeNum/invoices/:invoiceId
  Response:
    success:
      invoice: { id, invoiceNumber, typeNum, period, periodStart, periodEnd, issueDate, total, status }
      lineItems: array of { type, description, category, quantity, unitRate, total, sortOrder }
    error:
      success: false
      error: string
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

Endpoint: Get Invoices List
  Method: GET
  Path: /api/billing/:typeNum/invoices
  Query:
    page: int (default 1)
    pageSize: int (default 50, max 200)
  Response:
    success:
      data: array of { id, invoiceNumber, period, total, status, issueDate }
      pagination: { page, pageSize, total }
    error:
      success: false
      error: string
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

Endpoint: Get Current Usage
  Method: GET
  Path: /api/billing/:typeNum/usage
  Query:
    period: string (YYYY-MM, default current month)
  Response:
    success:
      period: string
      usage: array of { category, messageCount, segmentCount, billableCount, includedCount, estimatedCost }
      total: { messages, segments, estimatedCost }
    error:
      success: false
      error: string
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

Endpoint: Get Usage Trends
  Method: GET
  Path: /api/billing/:typeNum/usage/trends
  Query:
    months: int (default 6, max 12)
  Response:
    success:
      periods: array of { period, totalMessages, totalCost, byCategory: { category, count, cost }[] }
    error:
      success: false
      error: string
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

# Admin Billing Configuration
Endpoint: Get Store Billing Config
  Method: GET
  Path: /api/billing/:typeNum/config
  Response:
    success:
      baseRate: { default, override, effective }
      premiumRate: decimal
      billingContactEmail: string|null
      smsCategories: array of { category, billable, ratePerMessage, includedFreeCount, isEnabled, isDefault }
    error:
      success: false
      error: string
  Auth: Session + uri_bkadmin

Endpoint: Update Store Billing Config
  Method: PUT
  Path: /api/billing/:typeNum/config
  Request:
    billingBaseRateOverride: decimal|null (null = reset to default)
    billingPremiumRate: decimal
    billingContactEmail: string|null
    smsCategories: array of { category, billable, ratePerMessage, includedFreeCount, isEnabled }
  Response:
    success:
      success: true
      csrfToken: string (refreshed)
      message: "Configuration saved. Changes take effect next billing period."
    error:
      success: false
      error: string
  Auth: Session + CSRF + uri_bkadmin

# Admin Invoice Management
Endpoint: Void Invoice
  Method: POST
  Path: /api/billing/:typeNum/invoices/:invoiceId/void
  Request:
    reason: string (optional)
  Response:
    success:
      success: true
      csrfToken: string
    error:
      success: false
      error: string
  Auth: Session + CSRF + uri_bkadmin

Endpoint: Regenerate Invoice
  Method: POST
  Path: /api/billing/:typeNum/invoices/regenerate
  Request:
    period: string (YYYY-MM)
  Response:
    success:
      success: true
      invoice: { id, invoiceNumber, total }
      csrfToken: string
    error:
      success: false
      error: string
  Auth: Session + CSRF + uri_bkadmin

# Invoice PDF
Endpoint: Download Invoice PDF
  Method: GET
  Path: /api/billing/:typeNum/invoices/:invoiceId/pdf
  Response: application/pdf binary stream
  Auth: Session + (uri_bkadmin OR checkStoreGroup)

# Billing Export
Endpoint: Export Billing Data
  Method: GET
  Path: /api/billing/export
  Query:
    period: string (YYYY-MM)
    format: string (csv|xlsx, default csv)
  Response: application/csv or application/xlsx binary stream
  Auth: Session + uri_bkadmin
```

#### Application Data Models

```pseudocode
ENTITY: BillingInvoice (NEW)
  FIELDS:
    id: int (auto-increment)
    invoiceNumber: string (BK-YYMM-typeNum)
    typeNum: string
    billingPeriod: string (YYYY-MM)
    periodStart: date
    periodEnd: date
    issueDate: date
    totalAmount: decimal(10,2)
    status: InvoiceStatus enum (finalized|voided)
    voidedAt: datetime|null
    voidedByUserId: int|null
    replacedByInvoiceId: int|null
    generatedByJobId: int|null
    lineItems: BillingLineItem[]

  BEHAVIORS:
    createFromRow(array $row): self
    toArray(): array
    isFinalized(): bool
    isVoided(): bool

ENTITY: BillingLineItem (NEW)
  FIELDS:
    id: int
    invoiceId: int
    lineItemType: LineItemType enum
    description: string
    smsCategory: SmsCategory|null
    quantity: int
    unitRate: decimal(10,4)
    totalAmount: decimal(10,2)
    sortOrder: int
    metadata: array|null

  BEHAVIORS:
    createFromRow(array $row): self
    toArray(): array
    isSmsBased(): bool

ENTITY: SmsUsageRecord (NEW)
  FIELDS:
    id: int
    typeNum: string
    smsCategory: SmsCategory enum
    segmentCount: int
    provider: string (twilio|vonage)
    providerMessageId: string|null
    direction: string (outbound|inbound)
    billable: bool
    status: string (success|failed)
    billingPeriod: string (YYYY-MM)
    sentAt: datetime

  BEHAVIORS:
    createFromRow(array $row): self
    toArray(): array

ENTITY: BillingConfig (NEW)
  FIELDS:
    typeNum: string
    baseRateOverride: decimal|null
    effectiveBaseRate: decimal (computed from storeType or override)
    premiumRate: decimal
    billingContactEmail: string|null
    smsCategories: SmsCategoryConfig[]

  BEHAVIORS:
    getEffectiveBaseRate(string $storeType): decimal
    getCategoryConfig(SmsCategory $category): SmsCategoryConfig
    toArray(): array

ENUM: LineItemType (NEW)
  VALUES: base_subscription, premium_module, sms_usage, sms_included, manual_adjustment

ENUM: SmsCategory (NEW)
  VALUES: buy_completion, service_completion, survey, chat_transactional, chat_interactive, marketing, custom

ENUM: InvoiceStatus (NEW)
  VALUES: finalized, voided
```

#### Integration Points

```yaml
# SMS Hook Integration (CRITICAL)
- from: TextMessageService
  to: SmsUsageTracker
  protocol: Direct PHP method call
  data_flow: "After each send*() call, log usage record"
  pattern: |
    In each send*() method, after the provider call returns:
    1. Determine smsCategory from method name
    2. Get billable flag from BillingConfigService for this store+category
    3. Call SmsUsageTracker::logUsage() with all fields
    4. If DB write fails, log to fallback file (never block SMS send)

    Category mapping:
    - sendBuyText()     → buy_completion
    - sendServiceText() → service_completion
    - sendSurveyText()  → survey
    - sendCustomText()  → custom
    NOTE: `marketing` category removed per spec 028-text-marketing-removal.
    If marketing is re-introduced, its send path must call SmsUsageTracker.

# ChatApiController Integration (DUAL-WRITE)
- from: ChatApiController
  to: SmsUsageTracker
  protocol: Direct PHP method call
  data_flow: "When chat SMS is sent, also log to billingSmsUsage for billing"
  pattern: |
    In ChatApiController where ChatBillingService::trackUsage() is called,
    also call SmsUsageTracker::logUsage() with chat_transactional or chat_interactive.
    ChatBillingService continues writing to chat_sms_usage for chat admin views.
    Invoice generation reads ONLY from billingSmsUsage (single source of truth).

# Premium Module Integration
- from: InvoiceGenerationJob
  to: PremiumService + PremiumRepository
  protocol: Direct PHP method call
  data_flow: "Check if premium was active during billing period"
  pattern: |
    Query premiumEventLog for any 'premium_activated' events in the period,
    OR check if premiumStatus was 'active' or 'trial' at any point.
    If yes, add premium_module line item.

# TaskEngine Integration
- from: TaskEngine Scheduler
  to: InvoiceGenerationJob
  protocol: Cron → JobDispatcher
  data_flow: "1st of month, midnight UTC → generate all invoices"
  schedule: "0 0 1 * *" (1st of every month, midnight UTC)

# Legacy Invoice Integration
- from: StoreBillingView
  to: Invoice.php (existing)
  protocol: Direct PHP method call
  data_flow: "Store billing view also shows legacy PDF invoices from file system"
```

### Implementation Examples

#### Example: SMS Usage Tracking Hook

**Why this example**: This is the most critical integration point — the hook that goes inside TextMessageService to log every SMS for billing. Getting this wrong means missed billing data.

```php
// In TextMessageService.php - after each send*() method's provider call
// Example for sendBuyText():

public function sendBuyText($customer): array
{
    // ... existing validation (phone, DNT list, flood protection) ...

    $result = $this->textSender->sendText($messageText, $customer->getPhone());

    // ... existing flood protection logging ...

    // NEW: Billing usage tracking (SmsUsageTrackerInterface is REQUIRED)
    $this->logBillingUsage(
        SmsCategory::BUY_COMPLETION,
        $result,
        $messageText
    );

    return $result;
}

// Constructor signature change:
// OLD: __construct(TextSenderInterface $textSender, $store, $storeDB)
// NEW: __construct(TextSenderInterface $textSender, $store, $storeDB, SmsUsageTrackerInterface $usageTracker)
// In tests: pass NullSmsUsageTracker (no-op implementation)

private function logBillingUsage(
    string $category,
    array $sendResult,
    string $messageText
): void {
    try {
        $segmentCount = $this->calculateSegmentCount($messageText);
        $billable = $this->usageTracker->isCategoryBillable(
            $this->store->getTypeNum(),
            $category
        );

        $this->usageTracker->logUsage(
            $this->store->getTypeNum(),
            $category,
            $segmentCount,
            $sendResult['provider'] ?? 'unknown',
            $sendResult['id'] ?? null,
            $sendResult['status'] === 'success',
            $billable
        );
    } catch (\Throwable $e) {
        // NEVER block SMS sending for billing failures
        // Log to fallback file for reconciliation
        error_log("Billing usage log failed: " . $e->getMessage());
        $this->logBillingFallback($category, $sendResult, $messageText);
    }
}
```

#### Example: Invoice Generation Algorithm

**Why this example**: The monthly invoice generation is the core billing logic. The algorithm must handle all edge cases (no usage, premium mid-month, included free counts).

```php
// In InvoiceGenerationJob::handle()
// Pseudocode for the invoice generation algorithm

public function handle(): JobResult
{
    $db = dbConnectByName($_ENV['DB_NAME']);
    $billingService = new BillingService($db);
    $period = $this->getPreviousMonth(); // e.g., "2026-01"

    // Get all active stores
    $stores = $billingService->getActiveStores();
    $generated = 0;
    $skipped = 0;
    $errors = 0;

    foreach ($stores as $store) {
        $this->checkpoint(); // Heartbeat + abort check

        // Idempotency: skip if invoice already exists for this period
        if ($billingService->hasActiveInvoice($store['typeNum'], $period)) {
            $skipped++;
            continue;
        }

        try {
            $invoice = $billingService->generateInvoice($store['typeNum'], $period);
            $generated++;
            $this->progress(
                ($generated + $skipped + $errors) / count($stores) * 100,
                "Generated invoice for {$store['typeNum']}: \${$invoice->totalAmount}"
            );
        } catch (\Throwable $e) {
            $errors++;
            $this->error("Failed for {$store['typeNum']}: {$e->getMessage()}");
        }
    }

    return JobResult::success([
        'period' => $period,
        'generated' => $generated,
        'skipped' => $skipped,
        'errors' => $errors,
        'totalStores' => count($stores),
    ]);
}

// BillingService::generateInvoice() algorithm:
// 1. Create invoice record
// 2. Add base_subscription line item (from effective base rate)
// 3. Check premium status during period → add premium_module line item
// 4. Aggregate billingSmsUsage for this store+period by category
//    (single source of truth — ALL categories including chat)
// 5. For each SMS category with usage:
//    a. Get store's config for this category
//    b. If billable: calculate (count - includedFreeCount) * ratePerMessage
//    c. Add sms_usage line item for billable portion
//    d. If included free count > 0: add sms_included line item for free portion
// 6. Calculate total = sum of all line item amounts
// 7. Update invoice totalAmount
// 8. Return completed invoice
```

#### Example: Concept-Based Default Base Rate

**Why this example**: The base rate logic uses `storeType` to determine concept defaults, which is a non-obvious business rule.

```php
// In BillingConfigService
class BillingConfigService
{
    // Concept-based default base rates from PRD
    private const CONCEPT_RATES = [
        // storeType values → monthly base rate
        1  => 175.00,  // PC (Play It Again Sports)
        2  => 175.00,  // OU (Once Upon A Child)
        3  => 175.00,  // SE (Style Encore)
        4  => 175.00,  // CM (Clothes Mentor)
        5  => 99.00,   // PIAS (Plato's Closet)
    ];

    private const DEFAULT_BASE_RATE = 175.00;

    // Platform-wide default SMS category configuration
    // Stores inherit these unless overridden in billingSmsCategoryConfig
    private const DEFAULT_SMS_CATEGORY_CONFIG = [
        'buy_completion'     => ['billable' => false, 'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'service_completion' => ['billable' => false, 'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'survey'             => ['billable' => true,  'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'chat_transactional' => ['billable' => false, 'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'chat_interactive'   => ['billable' => true,  'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'marketing'          => ['billable' => true,  'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
        'custom'             => ['billable' => true,  'ratePerMessage' => 0.0100, 'includedFreeCount' => 0],
    ];

    public function getEffectiveBaseRate(string $typeNum): float
    {
        // Check for per-store override first
        $override = $this->configRepo->getBaseRateOverride($typeNum);
        if ($override !== null) {
            return (float)$override;
        }

        // Fall back to concept default
        $storeType = $this->getStoreType($typeNum);
        return self::CONCEPT_RATES[$storeType] ?? self::DEFAULT_BASE_RATE;
    }

    public function getCategoryConfig(string $typeNum, string $category): array
    {
        // Check for per-store override in billingSmsCategoryConfig table (with Redis cache)
        $override = $this->configRepo->getCategoryOverride($typeNum, $category);
        if ($override !== null) {
            return $override;
        }

        // Fall back to platform defaults (code-defined constants)
        return self::DEFAULT_SMS_CATEGORY_CONFIG[$category]
            ?? ['billable' => true, 'ratePerMessage' => 0.0100, 'includedFreeCount' => 0];
    }
}
```

## Runtime View

### Primary Flow: Monthly Invoice Generation

1. TaskEngine scheduler fires `InvoiceGenerationJob` at midnight UTC on the 1st
2. Job retrieves all stores where `billingActive = 1`
3. For each store (with checkpoint/heartbeat):
   a. Check idempotency — skip if invoice exists for this period
   b. Load store's billing configuration (with Redis cache)
   c. Create invoice record in `billingInvoices`
   d. Generate base subscription line item
   e. Check premium status via `premiumEventLog` → add premium line item
   f. Query `billingSmsUsage` for this store+period, group by category (single source of truth — includes chat categories)
   g. For each SMS category: calculate billable vs included, apply rates
   i. Insert all line items into `billingLineItems`
   j. Update invoice total
4. Job completes with success metrics

```mermaid
sequenceDiagram
    participant Scheduler as TaskEngine Scheduler
    participant Job as InvoiceGenerationJob
    participant BS as BillingService
    participant IS as InvoiceService
    participant IR as InvoiceRepository
    participant UR as SmsUsageRepository
    participant PS as PremiumService
    participant DB as kiosk_buykiosk

    Scheduler->>Job: handle() [1st of month]
    Job->>BS: getActiveStores()
    BS->>DB: SELECT typeNum FROM stores WHERE billingActive = 1
    DB-->>BS: Store list

    loop Each store
        Job->>BS: hasActiveInvoice(typeNum, period)
        BS->>IR: findByStoreAndPeriod()
        IR->>DB: SELECT FROM billingInvoices
        alt Invoice exists
            Note over Job: Skip (idempotent)
        else No invoice
            Job->>BS: generateInvoice(typeNum, period)
            BS->>IS: createInvoice(typeNum, period)
            IS->>DB: INSERT INTO billingInvoices

            BS->>BS: addBaseRateLineItem()
            BS->>PS: wasActiveInPeriod(typeNum, period)
            PS->>DB: SELECT FROM premiumEventLog

            BS->>UR: getUsageSummary(typeNum, period)
            UR->>DB: SELECT FROM billingSmsUsage GROUP BY category
            Note over UR,DB: Single source: ALL categories incl. chat

            BS->>IS: addLineItems(invoiceId, items)
            IS->>DB: INSERT INTO billingLineItems

            BS->>IS: updateTotal(invoiceId)
            IS->>DB: UPDATE billingInvoices SET totalAmount
        end
    end

    Job-->>Scheduler: JobResult::success(metrics)
```

### Secondary Flow: Admin Views Dashboard

1. Admin navigates to `/admin/billing`
2. BillingPageController renders dashboard.html with Syncfusion components
3. Page loads, JS fires AJAX to `GET /api/billing/summary?period=2026-01`
4. BillingApiController queries InvoiceRepository for all store invoices
5. Returns JSON with store summaries + grand totals
6. Syncfusion EJ2 Grid renders the data with sorting, filtering, export

### Secondary Flow: Store Owner Views Invoice

1. Store owner clicks "Billing" in sidebar
2. BillingPageController renders store-billing.html
3. JS loads current period usage via `GET /api/billing/:typeNum/usage`
4. JS loads invoice list via `GET /api/billing/:typeNum/invoices`
5. Owner clicks an invoice → loads detail via `GET /api/billing/:typeNum/invoices/:id`
6. Owner clicks "Download PDF" → `GET /api/billing/:typeNum/invoices/:id/pdf`

### Error Handling

- **SMS Usage Tracking Failure**: Catch all exceptions in `logBillingUsage()`. Never block SMS send. Write to fallback file `logs/billing-sms-fallback.log` in JSON format. Reconciliation job can import missed records.
- **Invoice Generation Failure (single store)**: Log error, skip store, continue with next. Report error count in job result. Admin can manually regenerate failed stores.
- **Invoice Generation Failure (job-level)**: BaseJob's `failed()` handler logs error. Job can be manually re-dispatched (idempotent — skips already-generated invoices).
- **API Errors**: Return `{ success: false, error: "message" }` with appropriate HTTP status code. 400 for invalid input, 403 for permission denied, 404 for not found, 500 for server errors.
- **Database Unavailable**: PDO throws exception → caught by controller error handler → returns 500 JSON response.
- **Redis Cache Miss**: BillingConfigService falls through to database query. Cache rebuilt on next read (cache-aside pattern).

### Complex Logic: Free Count Deduction Algorithm

```
ALGORITHM: Calculate SMS Line Items for Invoice
INPUT: typeNum, billingPeriod, smsCategoryConfig[]
OUTPUT: BillingLineItem[]

NOTE ON TIMING: The `billable` flag on each usage record was snapshotted at send
time based on the store's config at that moment. The `rate` applied here comes from
the store's CURRENT config at invoice generation time. This means:
- Changing a category from "included" → "billable" mid-month does NOT retroactively
  bill already-sent texts (their billable=0 is locked in the usage record).
- Changing the per-message rate DOES apply the new rate to all billable usage in
  the period. This is intentional per PRD Rule 5.

1. For each SMS category in canonical list:
   a. GET totalMessages = COUNT(*) FROM billingSmsUsage
      WHERE typeNum AND billingPeriod AND smsCategory AND status='success'
   b. GET totalSegments = SUM(segmentCount) from same query
   c. GET billableMessages = COUNT(*) WHERE billable = 1
   d. GET includedMessages = totalMessages - billableMessages
   e. LOAD config = store's CURRENT billingSmsCategoryConfig for this category

   f. IF totalMessages == 0: SKIP (no line item for zero usage)

   g. IF billableMessages == 0:
      - CREATE sms_included line item: qty=totalMessages, rate=0.00, total=0.00

   h. ELSE IF config.includedFreeCount > 0:
      - freeMessages = MIN(config.includedFreeCount, billableMessages)
      - paidMessages = MAX(0, billableMessages - config.includedFreeCount)

      - IF (includedMessages + freeMessages) > 0:
        CREATE sms_included line item: qty=(includedMessages + freeMessages), rate=0.00, total=0.00
      - IF paidMessages > 0:
        CREATE sms_usage line item: qty=paidMessages, rate=config.ratePerMessage,
               total=paidMessages * config.ratePerMessage

   i. ELSE (billable, no free count):
      - IF includedMessages > 0:
        CREATE sms_included line item: qty=includedMessages, rate=0.00, total=0.00
      - CREATE sms_usage line item: qty=billableMessages, rate=config.ratePerMessage,
              total=billableMessages * config.ratePerMessage

2. NOTE: All SMS categories (including chat_transactional and chat_interactive) are in
   billingSmsUsage as the single source of truth. No need to query chat_sms_usage.
   ChatApiController dual-writes to both tables, but billing reads ONLY from billingSmsUsage.

3. RETURN all line items sorted by sortOrder
```

## Deployment View

### Single Application Deployment
- **Environment**: Existing LAMP stack. No new services required.
- **Configuration**: No new environment variables. Uses existing `$_ENV['DB_NAME']`, `$_ENV['REDIS_URL']`.
- **Dependencies**: No new Composer packages for core functionality. PDF generation may use existing TCPDF or a Syncfusion EJ2 PDF export.
- **Performance**: Invoice generation job should complete in < 10 minutes for 100+ stores. SMS usage tracking adds < 50ms p95 latency to sends.

### Deployment Steps
1. Run migrations: `php userfrosting/conductor run`
2. Register job in `TaskCommandFactory`
3. Build CSS: `php userfrosting/conductor build-css --minify`
4. Deploy code
5. Verify job registered: `php userfrosting/bin/task job:list`
6. First invoice generation will happen on next 1st of month (or manual dispatch for testing)

### Rollback Strategy
- Migrations are additive (new tables + new columns). No destructive changes.
- TextMessageService hook is guarded by null check on `$smsUsageTracker` — if not injected, no billing tracking occurs.
- Routes can be disabled by removing route file includes.
- TaskEngine job can be disabled via `isEnabled = 0` in `taskJobDefinitions`.

## PDF Invoice Template Specification (Feature 8)

### Layout
```
+--------------------------------------------------+
|  [BuyerKiosk Logo]                               |
|  BuyerKiosk, Inc.                                |
|                                                  |
|  INVOICE                                         |
|                                                  |
|  Invoice #: BK-2602-ou00                         |
|  Issue Date: March 1, 2026                       |
|  Billing Period: February 1-28, 2026             |
|                                                  |
|  Bill To:                                        |
|  [Store Name]                                    |
|  [Billing Contact Email or Owner Email]          |
|                                                  |
+--------------------------------------------------+
|  SUBSCRIPTIONS                          Subtotal |
|  ─────────────────────────────────────────────── |
|  Base Subscription (Monthly)   1 × $175.00  $175 |
|  Premium Scheduling Module     1 × $30.00    $30 |
|                                                  |
|  SMS USAGE                              Subtotal |
|  ─────────────────────────────────────────────── |
|  Survey Texts (Included)      50 × $0.00    $0.00|
|  Survey Texts (Billable)     100 × $0.01    $1.00|
|  Buy Completion (Included)    20 × $0.00    $0.00|
|  Chat Interactive (Billable)  30 × $0.01    $0.30|
|                                                  |
+--------------------------------------------------+
|                              TOTAL:      $206.30 |
+--------------------------------------------------+
|  Generated by BuyerKiosk Billing System          |
|  Questions? Contact support@buyerkiosk.com       |
+--------------------------------------------------+
```

### Implementation
- Use TCPDF (already available in vendor) or Syncfusion EJ2 PDF export
- PDF generated on-demand in `InvoiceService::generatePdf()` method
- Stream response with `Content-Type: application/pdf` and `Content-Disposition: attachment`
- Target: < 3 seconds generation time per invoice

## Deferred Features (Could-Have)

The following PRD features are **explicitly deferred** to a future phase. The data model supports their future addition without schema changes.

### Feature 10: Billing Alerts (Deferred)
- **Future approach**: Add `billingAlertThresholds` table (typeNum, smsCategory, threshold, notificationType). TaskEngine hourly job checks current usage against thresholds. In-app notification via existing notification system.
- **Why deferred**: Core billing tracking must be proven first. Alerts add complexity without immediate billing accuracy value.

### Feature 11: Invoice Notes and Adjustments (Deferred)
- **Future approach**: The `manual_adjustment` lineItemType is already in the enum. Add API endpoints for admin to POST adjustments with description, amount, and reason. Add `createdByUserId` and `reason` columns to `billingLineItems` (nullable, used only for manual adjustments).
- **Why deferred**: V1 focuses on automated billing. Manual adjustments can be handled by voiding and regenerating with corrected config.

### Feature 12: Billing Configuration Audit Log (Deferred)
- **Future approach**: Add `billingConfigAuditLog` table (id, typeNum, field, oldValue, newValue, changedByUserId, createdAt). BillingConfigService logs changes on config save.
- **Why deferred**: `billingConfigUpdatedAt` timestamp on stores table provides basic change tracking. Full audit log is valuable but not blocking for V1.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: Service-Repository pattern (PremiumService/PremiumRepository)
  relevance: CRITICAL
  why: "Primary pattern for all billing service classes"

- pattern: TaskEngine BaseJob pattern (TrialExpirationJob)
  relevance: HIGH
  why: "Pattern for InvoiceGenerationJob"

- pattern: Event logging pattern (PremiumEventLogger)
  relevance: MEDIUM
  why: "Reference for billing audit logging"

# New patterns created
- pattern: SMS Usage Tracking Hook
  relevance: HIGH
  why: "New pattern for injecting billing tracking into service methods"

- pattern: Billing Configuration with Redis Cache
  relevance: HIGH
  why: "New pattern for per-store config with caching and default inheritance"
```

### System-Wide Patterns

- **Security**: Session-based auth with `checkAccess()` and `checkStoreGroup()`. CSRF protection via `NoCSRF` on all mutation endpoints. Admin-only endpoints gated by `uri_bkadmin`. Store-level endpoints allow both admin and store owner access.
- **Error Handling**: All API endpoints wrapped in try/catch returning JSON errors. SMS usage tracking uses defensive try/catch with fallback logging. Invoice generation is per-store isolated (one store failure doesn't block others).
- **Performance**: Redis cache for billing config (TTL 300s). Database indexes on all query columns. Invoice generation processes stores sequentially with checkpoint heartbeats.
- **Logging/Auditing**: All billing config changes logged with userId and timestamp. Invoice void/regenerate tracked with audit trail. SMS usage logged with full context.

### Implementation Patterns

#### Code Patterns and Conventions
- PSR-4 autoloading under `BuyerKiosk\Billing\` namespace
- Constructor injection of PDO and Redis dependencies
- Static factory methods (e.g., `BillingService::createWithDefaults()`)
- Whitelisted columns on repository update methods (following PremiumRepository pattern)
- PHP enums (or class constants for PHP 8.0 compat) for type-safe categorization

#### State Management Patterns
- **Server-side**: All state in MySQL. Redis is cache only (can be cleared without data loss).
- **Client-side**: Syncfusion Grid manages table state (sort, filter, page). Period selector drives AJAX data reload. No complex client-side state management needed.

#### Performance Characteristics
- SMS usage INSERT: Single row insert per send. < 5ms typical. Target: < 50ms p95.
- Invoice generation: Sequential store processing. ~100ms per store. 100 stores = ~10s total.
- Dashboard API: Aggregation query across `billingInvoices`. Indexed on `billingPeriod`. Target: < 500ms.
- Config reads: Redis cache hit ~1ms. Cache miss ~10ms (DB query + cache write).

#### Integration Patterns
- **SMS Hook**: Dependency injection of `SmsUsageTracker` into `TextMessageService`. Null-safe — if not injected, no tracking occurs. This allows gradual rollout.
- **ChatBillingService**: Read-only integration at invoice time. No modifications to existing service.
- **PremiumService**: Read-only queries against `premiumEventLog` to determine if premium was active during billing period.

#### Error Handling Pattern

```pseudocode
FUNCTION: handleBillingApiRequest(operation)
  TRY:
    VALIDATE: input parameters, permissions
    EXECUTE: operation via BillingService
    RESPOND: { success: true, data: result, csrfToken: refreshed }
  CATCH ValidationException:
    RESPOND: HTTP 400, { success: false, error: message }
  CATCH AuthorizationException:
    RESPOND: HTTP 403, { success: false, error: "Unauthorized" }
  CATCH NotFoundException:
    RESPOND: HTTP 404, { success: false, error: "Not found" }
  CATCH Exception:
    LOG: error details with stack trace
    RESPOND: HTTP 500, { success: false, error: "An error occurred" }
```

#### Test Pattern

```pseudocode
# Unit tests for billing calculation logic
TEST_SCENARIO: "Invoice generation for store with mixed SMS usage"
  SETUP:
    - Mock BillingConfigRepository with test config
    - Mock SmsUsageRepository with known usage data
    - Mock ChatBillingService with known chat data
    - Mock PremiumService with premium active
  EXECUTE:
    - BillingService::generateInvoice('ou00', '2026-01')
  VERIFY:
    - Invoice created with correct period and invoice number
    - Base subscription line item present with correct rate
    - Premium module line item present ($30.00)
    - SMS line items: correct quantities, rates, free deductions
    - Total amount is sum of all line items
    - No duplicate line items

# Integration tests for SMS usage tracking
TEST_SCENARIO: "SMS send logs billing usage"
  SETUP:
    - Real TextMessageService with mock sender
    - Real SmsUsageTracker with test database
  EXECUTE:
    - textMessageService->sendBuyText($customer)
  VERIFY:
    - SMS sent successfully (mock provider)
    - billingSmsUsage record created with category='buy_completion'
    - billable flag matches store config
    - segment count calculated correctly
```

### Integration Points

- **Connection Points**: TextMessageService (SMS hook via SmsUsageTrackerInterface), ChatApiController (dual-write to billingSmsUsage), PremiumService (status check), TaskEngine (scheduled job), Sidebar (menu item), Store settings (config UI)
- **Data Flow**: SMS sends → `billingSmsUsage` table (single source of truth for ALL SMS categories). Chat sends → `billingSmsUsage` + `chat_sms_usage` (dual-write). Invoice generation reads → `billingSmsUsage` only → `billingLineItems`. Store config → `billingSmsCategoryConfig` + `stores` columns → Read by SMS tracker and invoice generator.
- **Events**: `billing.invoice_generated` logged after each invoice. `billing.sms_usage_logged` on each SMS track. `billing.config_updated` on config save.

## Architecture Decisions

- [x] ADR-1 **Central DB for all billing data**: All billing tables (`billingInvoices`, `billingLineItems`, `billingSmsUsage`, `billingSmsCategoryConfig`) reside in `kiosk_buykiosk`, not per-store databases.
  - Rationale: Cross-store reporting requires single-database aggregation. Avoids complex cross-DB joins. Follows pattern of `chat_sms_usage` and `premiumEventLog`.
  - Trade-offs: Per-store data lives in central DB (more rows in one DB). Acceptable given billing volume.
  - User confirmed: **Yes**

- [x] ADR-2 **Separate `billingSmsUsage` table (not extending `chat_sms_usage`)**: New dedicated table for ALL SMS billing (including chat categories) rather than extending the existing `chat_sms_usage` table.
  - Rationale: `chat_sms_usage` has chat-specific fields (`thread_id`, `message_id`, `direction`). Different schema needs. Clean separation of concerns. No risk to existing ChatBillingService.
  - Trade-offs: ChatApiController must dual-write to both `billingSmsUsage` (for billing) and `chat_sms_usage` (for chat admin views). Slight write redundancy, but billing reads are dramatically simpler — single table, no merge logic. (See ADR-5 for the dual-write decision.)
  - User confirmed: **Yes**

- [x] ADR-3 **Hook in TextMessageService (not in provider senders)**: SMS tracking hook placed in `TextMessageService.send*()` methods, not in `TwilioTextSender`/`VonageTextSender`.
  - Rationale: TextMessageService is the single point of truth for all SMS sends. Providers don't know about categories or billing. Easier to maintain — one integration point instead of two.
  - Trade-offs: TextMessageService grows slightly. Must inject SmsUsageTracker dependency.
  - User confirmed: **Yes**

- [x] ADR-4 **`SmsUsageTracker` required via constructor with Null Object pattern**: The tracker is a required dependency in TextMessageService constructor. A `NullSmsUsageTracker` (no-op implementation) is used in test environments and during the transition period.
  - Rationale: Required injection eliminates silent billing gaps. Null Object pattern provides explicit opt-out for tests without nullable complexity. All construction sites must provide a tracker — if they forget, it's a fatal error caught immediately.
  - Trade-offs: Must update all TextMessageService construction sites (primarily `Store::setTextMessageService()`). Small additional class (NullSmsUsageTracker). Worth it for reliability.
  - Implementation: Create `SmsUsageTrackerInterface` with `logUsage()` and `isCategoryBillable()`. `SmsUsageTracker` implements for production. `NullSmsUsageTracker` implements as no-op. TextMessageService constructor takes `SmsUsageTrackerInterface`.
  - User confirmed: **Yes** — Required + Null Object pattern

- [x] ADR-5 **Chat SMS billing migrated to `billingSmsUsage` as single source of truth**: Chat SMS billing data written to `billingSmsUsage` instead of relying on `chat_sms_usage` for billing calculations. `chat_sms_usage` continues to exist for chat admin reporting (thread context, direction stats) but is NOT used for invoice generation.
  - Rationale: Single source of truth for all SMS billing. Invoice generation queries one table only — no merge logic, no double-counting risk. Cleaner architecture.
  - Trade-offs: ChatApiController must be modified to also write to `billingSmsUsage` when tracking chat SMS. `chat_sms_usage` remains for chat-specific admin views (ChatAdminController still queries it for thread-level stats, direction breakdowns). Slight redundancy in write path but billing reads are dramatically simpler.
  - Implementation: In ChatApiController where `ChatBillingService::trackUsage()` is called, also call `SmsUsageTracker::logUsage()` with `chat_transactional` or `chat_interactive` category. ChatBillingService continues writing to `chat_sms_usage` for chat admin purposes. Invoice generation reads ONLY from `billingSmsUsage`.
  - User confirmed: **Yes** — Migrate chat billing to billingSmsUsage

- [x] ADR-6 **Syncfusion EJ2 Grid + Chart for dashboard UI**: Use Syncfusion Grid for invoice tables and Chart for usage trends, following existing analytics patterns.
  - Rationale: Syncfusion license already available. Existing patterns in `financial.html` and `point_report.html`. Built-in Excel/PDF export. Consistent UX.
  - Trade-offs: Syncfusion JS bundle size. Acceptable — already loaded on other admin pages.
  - User confirmed: **Yes**

- [x] ADR-7 **On-demand PDF generation (not pre-generated)**: Invoice PDFs generated on-demand when user clicks download, not pre-generated at invoice creation time.
  - Rationale: Saves storage. Ensures PDF always reflects current invoice data. Avoids regeneration complexity. Acceptable latency (< 2s for PDF generation).
  - Trade-offs: Slight delay on first PDF request. If PDF library has issues, affects all downloads at once.
  - User confirmed: **Yes**

- [x] ADR-8 **Billing config on `stores` table + separate `billingSmsCategoryConfig` table**: Base rate override, premium rate, and contact email as columns on `stores`. SMS category config in a separate normalized table.
  - Rationale: Store-level fields are simple scalars (good fit for columns). SMS category config is a set of rows per store per category (normalized table is cleaner). Follows existing pattern: premium fields on `stores`, event data in separate table.
  - Trade-offs: Two locations for billing config. BillingConfigService abstracts this into a single interface.
  - User confirmed: **Yes**

## Quality Requirements

- **Performance**:
  - SMS usage tracking: < 50ms p95 latency added to send operations
  - Invoice generation: < 10 minutes for 100+ stores
  - Dashboard API: < 500ms response time for summary endpoint
  - PDF generation: < 3 seconds per invoice
  - Billing config reads: < 10ms with Redis cache hit, < 50ms with miss

- **Reliability**:
  - Invoice generation: Idempotent. Re-running never creates duplicates.
  - SMS tracking: Fallback file logging when DB unavailable. Never blocks SMS send.
  - Zero data loss: Every billable SMS event tracked (direct or fallback).

- **Security**:
  - Admin endpoints: `uri_bkadmin` permission required
  - Store endpoints: `checkStoreGroup()` + session auth
  - All mutations: CSRF token validation with token refresh
  - No PII exposure: Billing APIs don't return customer phone numbers or message content

- **Accuracy**:
  - Invoice calculation: > 99% accuracy (extensive unit test coverage)
  - Zero duplicate invoices per store per period
  - All SMS categories tracked with correct billable flag

- **Usability**:
  - Dashboard loads with data in < 2 seconds
  - Billing menu accessible from sidebar in 1 click
  - Invoice detail view shows all line items with clear labeling
  - PDF download available in 1 click from invoice detail

## Risks and Technical Debt

### Known Technical Issues

- `Bill.php` extends `Store` class directly (tight coupling). The new billing system uses service layer pattern to avoid this.
- Existing `billingRate` column on `stores` is used by legacy `Bill.php`. New system adds `billingBaseRateOverride` to avoid conflicts.
- `ChatBillingService` hardcodes `$0.0075/segment`. The unified billing system applies its own rates at invoice time.

### Technical Debt

- **Legacy Bill.php**: Should eventually be deprecated in favor of the new BillingService. Keep both running in parallel initially.
- **File-based legacy invoices**: `Invoice.php` serves PDFs from filesystem. These should eventually be imported into the new system.
- **SMS fallback log**: The `logs/billing-sms-fallback.log` reconciliation job is a "should have" — initial implementation just logs to file without auto-import.

### Implementation Gotchas

- **PDO named parameter reuse**: PDO native prepared statements do NOT allow reusing named params (`:foo` twice = `HY093`). Use unique names and bind both.
- **TextMessageService constructor**: Currently takes `(TextSenderInterface, Store, PDO)`. Adding SmsUsageTracker as 4th param requires updating all construction sites (primarily `Store::setTextMessageService()`).
- **Segment count calculation**: `ChatMessage::calculateSegmentCount()` exists for chat messages. TextMessageService needs its own implementation or a shared utility.
- **Per-message billing (not per-segment)**: The PRD decisions say "$0.01/segment" but the implementation bills per-message for simplicity. The `ratePerMessage` column and free count algorithm both operate on message counts. Segment counts are stored in `billingSmsUsage.segmentCount` for reporting and potential future per-segment billing, but V1 invoice calculations use message count * ratePerMessage. This is simpler to understand for store owners and avoids confusion about multi-segment messages.
- **UTC billing periods**: All `billingPeriod` values use UTC. SMS `sentAt` must be stored in UTC. Store timezone only for display.
- **Unique index on billingInvoices**: The `uq_active_invoice` index allows multiple voided invoices but only one finalized per store per period. Void must happen BEFORE regeneration to avoid constraint violation.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Invoice Generation Happy Path**
```gherkin
Given: Store "ou00" is billingActive with base rate $175, premium active, 150 survey texts sent (50 free), 20 buy_completion texts
And: Billing period is "2026-01"
When: InvoiceGenerationJob runs
Then: Invoice BK-2601-ou00 created with status "finalized"
And: Line items include:
  - base_subscription: $175.00
  - premium_module: $30.00
  - sms_included (buy_completion): qty=20, $0.00
  - sms_included (survey): qty=50, $0.00
  - sms_usage (survey): qty=100, rate=$0.01, $1.00
And: Invoice total = $206.00
```

**Scenario 2: Idempotent Re-run**
```gherkin
Given: Invoice BK-2601-ou00 already exists with status "finalized"
When: InvoiceGenerationJob runs again for period "2026-01"
Then: No new invoice created for ou00
And: Job reports 1 skipped, 0 generated for this store
```

**Scenario 3: SMS Usage Tracking**
```gherkin
Given: Store "pc00" has survey texts configured as billable
When: TextMessageService::sendSurveyText() called and succeeds
Then: billingSmsUsage record created with:
  - typeNum = "pc00"
  - smsCategory = "survey"
  - billable = 1
  - status = "success"
  - billingPeriod = current month (YYYY-MM)
```

**Scenario 4: SMS Tracking with DB Failure**
```gherkin
Given: Database connection for billing usage is unavailable
When: TextMessageService::sendBuyText() called
Then: SMS sends successfully via provider
And: Error logged to billing-sms-fallback.log
And: No exception propagated to caller
```

**Scenario 5: Void and Regenerate Invoice**
```gherkin
Given: Invoice BK-2601-ou00 exists with status "finalized"
When: Admin voids and regenerates invoice for ou00, period "2026-01"
Then: Old invoice status changed to "voided"
And: New invoice BK-2601-ou00 created with status "finalized"
And: New invoice uses current rates (not rates from when usage was logged)
And: Voided invoice has replacedByInvoiceId pointing to new invoice
```

**Scenario 6: Store Owner Permission Check**
```gherkin
Given: User "Casey" owns store "pc00" but not "ou00"
When: Casey requests GET /api/billing/ou00/invoices
Then: HTTP 403 returned
And: No billing data for ou00 is exposed
```

### Test Coverage Requirements

- **Business Logic**: Invoice calculation with all line item types. Free count deduction. Concept-based default rates. Rate override logic. Billable flag snapshotting. Period boundary calculations.
- **User Interface**: Dashboard loads with data. Invoice detail renders all fields. PDF download triggers. Permission-gated visibility.
- **Integration Points**: TextMessageService → SmsUsageTracker hook. ChatBillingService read at invoice time. PremiumService status check. TaskEngine job execution.
- **Edge Cases**: Zero usage months. Mid-month premium activation/deactivation. Store with no billing config (uses defaults). Rate change mid-month. New store mid-month.
- **Performance**: SMS tracking latency under load. Invoice generation time for 100+ stores.
- **Security**: Permission checks on all API endpoints. CSRF validation on mutations. No cross-store data leakage.

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Billing Period | A calendar month (YYYY-MM format) for which charges are calculated | All billing data is grouped by billing period |
| Invoice | A record of charges for a single store for a single billing period | Generated monthly by TaskEngine job |
| Line Item | An individual charge or credit on an invoice | Types: base_subscription, premium_module, sms_usage, sms_included, manual_adjustment |
| Base Rate | Monthly subscription fee per store | Defaults by concept (storeType), can be overridden per store |
| Concept | The franchise brand a store belongs to (PC, OU, SE, CM, PIAS) | Determined by `storeType` column on `stores` table |
| Included Free Count | Number of free SMS messages per category per billing period | Configured per store per SMS category. Resets each period. |
| Billable Flag | Whether an SMS message should be charged | Snapshotted at send time based on store config |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| typeNum | Store identifier pattern `[a-z]{2}\d+` (e.g., `ou00`) | Primary key for all store-level operations |
| Central DB | `kiosk_buykiosk` database containing shared data | All billing tables reside here |
| SmsUsageTracker | Service that logs SMS sends for billing | Injected into TextMessageService |
| InvoiceGenerationJob | TaskEngine scheduled job for monthly invoicing | Runs on 1st of each month at midnight UTC |
| Fallback Log | JSON file log for billing data when DB is unavailable | `logs/billing-sms-fallback.log` |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| Invoice Number | Format: `BK-{YYMM}-{typeNum}` (e.g., `BK-2602-ou00`) | Unique per store per period. Human-readable identifier. |
| CSRF Token | Cross-Site Request Forgery protection token | Required on all mutation API endpoints. Refreshed on each response. |
| checkStoreGroup | Permission check verifying user has access to a specific store | Used on all store-level billing endpoints |
| uri_bkadmin | Super-admin permission flag | Required for cross-store billing dashboard and configuration |
