# 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 **Framework**: PHP 8.x, Slim 2.6.2, Twig 1.44.8, MySQL/MariaDB (multi-store), Redis (Predis), Ably (REST). All catch blocks must use `\Throwable` not `\Exception`.

CON-2 **Database**: All schema changes via the conductor migration system (`userfrosting/migrations/input/*.json`). No manual SQL. Store DBs use `{{store}}` placeholder. Central DB is `kiosk_buykiosk`.

CON-3 **UI**: Bootstrap 5.3.3 + Syncfusion EJ2 components. Design tokens in `public_html/css/admin/tokens.css`. Font Awesome 6. All new pages follow the existing admin page pattern (`$app->render()` with standard template variables).

CON-4 **QBO API**: 500 requests/min/realmId rate limit. Access tokens expire in 1 hour. Refresh tokens expire in 100 days. SDK: `quickbooks/v3-php-sdk`.

CON-5 **Security**: QB tokens encrypted at rest (AES-256-CBC via `QB_ENCRYPTION_KEY`). CSRF on all POST routes. Store-scoped authorization via `checkStoreGroup()`.

CON-6 **Permissions**: Single `quickbooks_config` permission for all QB pages.

## Implementation Context

### Required Context Sources

- ICO-1 General Application Context
```yaml
- doc: CLAUDE.md
  relevance: HIGH
  why: "Project structure, routing patterns, migration system, CSS framework"

- doc: docs/patterns/psr4-autoloading.md
  relevance: MEDIUM
  why: "PSR-4 autoloading under BuyerKiosk\\ namespace"

- url: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/journalentry
  relevance: HIGH
  sections: [Create, Read, Update, Delete, Query]
  why: "QBO JournalEntry API — create, query by DocNumber, sparse update, void"

- url: https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0
  relevance: MEDIUM
  why: "OAuth2 token lifecycle, refresh flow, environment differences"
```

- ICO-2 QuickBooks Module (existing)
```yaml
- file: userfrosting/src/BuyerKiosk/QuickBooks/QuickBooksService.php
  relevance: HIGH
  why: "OAuth, token management, account mapping CRUD — will be extended"

- file: userfrosting/src/BuyerKiosk/QuickBooks/JournalEntryService.php
  relevance: HIGH
  sections: [createJournalEntry, syncDailyClose, logSyncAttempt, buildJournalEntryObject]
  why: "Core sync logic — will be refactored for staging and approval flow"

- file: userfrosting/src/BuyerKiosk/QuickBooks/Controllers/QuickBooksController.php
  relevance: HIGH
  why: "Existing controller — will be split into page and API controllers"

- file: userfrosting/routes/groups/quickbooks.php
  relevance: HIGH
  why: "API routes — will be extended with new endpoints"

- file: userfrosting/templates/themes/default/qbconnect/setup.html
  relevance: MEDIUM
  why: "Existing setup page — will be replaced by 7 dedicated pages"

- file: userfrosting/src/BuyerKiosk/TaskEngine/Jobs/QuickBooksSyncJob.php
  relevance: HIGH
  why: "Nightly sync job — must support staging mode for manual approval"
```

- ICO-3 Infrastructure
```yaml
- file: userfrosting/models/BaseModel.php
  relevance: MEDIUM
  sections: [getAblyClient, getRedisClient]
  why: "Ably notification publishing and Redis client patterns"

- file: userfrosting/templates/themes/default/menus/sidebar.html
  relevance: MEDIUM
  sections: [lines 588-625 Integrations section]
  why: "Sidebar structure — new QB section will be added here"

- file: public_html/css/admin/tokens.css
  relevance: LOW
  why: "Design tokens for consistent UI styling"
```

### Implementation Boundaries

- **Must Preserve**: Existing OAuth flow, token encryption, `qb_account_mapping` table structure, `qb_sync_log` table structure (additive changes only), all existing API endpoints (backward compat), `QuickBooksSyncJob` interface
- **Can Modify**: `JournalEntryService` (refactor for staging), `QuickBooksController` (split into page + API), `QuickBooksService` (add mutex, rate limiting), sidebar template (add QB section), nightly job logic (add staging mode)
- **Must Not Touch**: `oauthStateTokens` table (shared with ConstantContact), `drsDailySFileData` schema, `BaseModel.php` global functions, `Store.php` core methods (only add new QB methods)

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Owner[Store Owner/Operator] --> BK[BuyerKiosk Admin UI]
    Manager[Store Manager] --> BK
    Bookkeeper[Bookkeeper/Accountant] --> CSV[CSV Export]

    BK --> QBAPI[QuickBooks Online API]
    BK --> StoreDB[(Store Database)]
    BK --> CentralDB[(Central Database)]
    BK --> Redis[(Redis Cache)]
    BK --> Ably[Ably Notifications]

    POS[POS System] -->|S-file JSON| BK
    TaskEngine[TaskEngine Worker] -->|Nightly 2AM| BK
    QBOAuth[QBO OAuth Server] -->|Token Exchange| BK
```

#### Interface Specifications

```yaml
inbound:
  - name: "Admin Web UI"
    type: HTTPS
    format: "Twig-rendered HTML + AJAX JSON"
    authentication: "Session (UserFrosting)"
    data_flow: "Page rendering + API calls for CRUD operations"

  - name: "POS S-file Ingestion"
    type: HTTPS
    format: REST/JSON
    authentication: "Session + CSRF"
    data_flow: "Daily close data → drsDailySFileData"

  - name: "TaskEngine Worker"
    type: "Internal PHP process"
    format: "Job payload"
    authentication: "N/A (internal)"
    data_flow: "Nightly sync trigger with date + store context"

  - name: "QBO OAuth Callback"
    type: HTTPS
    format: "Query parameters (code, realmId, state)"
    authentication: "CSRF state token"
    data_flow: "OAuth code exchange → token storage"

outbound:
  - name: "QuickBooks Online API"
    type: HTTPS
    format: REST/JSON
    authentication: "OAuth2 Bearer Token"
    data_flow: "JournalEntry CRUD, Account queries, CompanyInfo"
    criticality: HIGH

  - name: "Ably Notifications"
    type: HTTPS
    format: "Ably REST publish"
    authentication: "API Key"
    data_flow: "Sync failure alerts, pending approval reminders"
    criticality: LOW

data:
  - name: "Store Database"
    type: MySQL/MariaDB
    connection: "PDO via dbConnectByName()"
    data_flow: "qb_sync_log, qb_audit_log, qb_staged_entries, qb_account_mapping"

  - name: "Central Database (kiosk_buykiosk)"
    type: MySQL/MariaDB
    connection: "PDO via dbConnectByName()"
    data_flow: "stores table QB columns, qb_store_settings"

  - name: "Redis"
    type: Redis
    connection: "Predis client"
    data_flow: "Token refresh mutex, rate limiter state"
```

### Project Commands

```bash
# Environment Setup
cd userfrosting && composer install

# Testing
./test.sh --testsuite unit
cd userfrosting && ./vendor/bin/phpunit --filter "QuickBooks"

# Database Migrations
php userfrosting/conductor run

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

# TaskEngine
php userfrosting/bin/task job:dispatch quickbooks-sync --store=pc00
php userfrosting/bin/task worker:start --queues=default

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

## Solution Strategy

- **Architecture Pattern**: Layered modular architecture within the existing Slim 2 MVC framework. New QB v2 code follows the Service → Controller → Route → Template layering established by the backstock and TaskEngine modules. Each of the 7 pages gets its own Twig template with JS loaded via `<script>` blocks. API endpoints serve JSON for AJAX operations.

- **Integration Approach**: Extend the existing `QuickBooks/` namespace. Refactor `JournalEntryService` to support a staging pipeline (stage → edit → approve → post). Add new services (`ApprovalService`, `AuditService`, `ReconciliationService`) alongside existing ones. Modify `QuickBooksSyncJob` to respect sync mode.

- **Justification**: The codebase already has a mature QB module with OAuth, token encryption, field mappings, and sync logging. Extending this is safer and faster than rewriting. The service-per-domain pattern (one service per feature area) keeps the code navigable and testable.

- **Key Decisions**:
  1. Event-sourced audit log (append-only `qb_audit_log` table) separate from the operational `qb_sync_log`
  2. Staged entries stored in a new `qb_staged_entries` table with full payload snapshots
  3. Redis mutex for token refresh (SETNX with TTL)
  4. Redis-based sliding window for QBO API rate limiting
  5. New page routes under `/admin/:typeNum/quickbooks/` with 7 sub-paths
  6. API routes stay under `/api/quickbooks/:typeNum/` (extended)

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Admin UI (Twig + JS)"
        Dashboard[Dashboard Page]
        ApprovalQ[Approval Queue Page]
        SyncLog[Sync Log Page]
        AuditLog[Audit Log Page]
        Mapping[Account Mapping Page]
        Recon[Reconciliation Page]
        Settings[Settings Page]
    end

    subgraph "Controllers"
        PageCtrl[QBPageController]
        APICtrl[QBApiController]
    end

    subgraph "Services"
        ApprovalSvc[ApprovalService]
        AuditSvc[AuditService]
        JESvc[JournalEntryService]
        ReconSvc[ReconciliationService]
        QBSvc[QuickBooksService]
        RateLimiter[QBRateLimiter]
        TokenMutex[TokenRefreshMutex]
    end

    subgraph "Data"
        SyncLogTbl[(qb_sync_log)]
        AuditTbl[(qb_audit_log)]
        StagedTbl[(qb_staged_entries)]
        MappingTbl[(qb_account_mapping)]
        SettingsTbl[(qb_store_settings)]
        StoresTbl[(stores)]
    end

    subgraph "External"
        QBOAPI[QBO API]
        AblyPub[Ably]
        RedisStore[(Redis)]
    end

    Dashboard --> PageCtrl
    ApprovalQ --> PageCtrl
    ApprovalQ --> APICtrl
    SyncLog --> APICtrl
    AuditLog --> APICtrl
    Mapping --> APICtrl
    Recon --> APICtrl
    Settings --> APICtrl

    PageCtrl --> ApprovalSvc
    PageCtrl --> AuditSvc
    APICtrl --> ApprovalSvc
    APICtrl --> AuditSvc
    APICtrl --> JESvc
    APICtrl --> ReconSvc
    APICtrl --> QBSvc

    ApprovalSvc --> StagedTbl
    ApprovalSvc --> AuditSvc
    ApprovalSvc --> JESvc
    AuditSvc --> AuditTbl
    JESvc --> SyncLogTbl
    JESvc --> QBSvc
    ReconSvc --> QBSvc
    ReconSvc --> SyncLogTbl
    QBSvc --> QBOAPI
    QBSvc --> RateLimiter
    QBSvc --> TokenMutex
    RateLimiter --> RedisStore
    TokenMutex --> RedisStore
    ApprovalSvc --> AblyPub
```

### Directory Map

```
userfrosting/
├── src/BuyerKiosk/QuickBooks/
│   ├── Controllers/
│   │   ├── QuickBooksController.php     # MODIFY: Keep existing API methods, add page methods
│   │   ├── QBPageController.php         # NEW: Page rendering for all 7 QB pages
│   │   └── QBApiController.php          # NEW: New API endpoints (approval, audit, recon, settings)
│   ├── Services/
│   │   ├── ApprovalService.php          # NEW: Stage, approve, reject, bulk operations
│   │   ├── AuditService.php             # NEW: Event logging, history queries, CSV export
│   │   ├── ReconciliationService.php    # NEW: Variance calculation, QBO comparison
│   │   ├── QBRateLimiter.php            # NEW: Redis sliding-window rate limiter
│   │   └── TokenRefreshMutex.php        # NEW: Redis SETNX mutex for token refresh
│   ├── QuickBooksService.php            # MODIFY: Add mutex, rate limiter, DocNumber helpers
│   ├── JournalEntryService.php          # MODIFY: Add staging, hash computation, de-dupe check
│   ├── QuickBooks.php                   # NO CHANGE (deprecated, backward compat)
│   ├── AccountMapper.php                # NO CHANGE (deprecated)
│   └── Config.php                       # NO CHANGE (deprecated)
├── routes/
│   ├── groups/quickbooks.php            # MODIFY: Add new API endpoints
│   └── admin/quickbooks.php             # NEW: Page routes for 7 QB pages
├── templates/themes/default/
│   ├── quickbooks/                      # NEW: All 7 page templates
│   │   ├── dashboard.html
│   │   ├── approval-queue.html
│   │   ├── sync-log.html
│   │   ├── audit-log.html
│   │   ├── account-mapping.html
│   │   ├── reconciliation.html
│   │   └── settings.html
│   ├── menus/sidebar.html               # MODIFY: Add QB sidebar section
│   └── qbconnect/setup.html             # DEPRECATE: Replaced by new pages
├── migrations/input/
│   ├── 048_001_qb_staged_entries.json   # NEW: qb_staged_entries table
│   ├── 048_002_qb_audit_log.json        # NEW: qb_audit_log table
│   ├── 048_003_qb_store_settings.json   # NEW: qb_store_settings table
│   ├── 048_004_qb_sync_log_expand.json  # NEW: Add columns to qb_sync_log
│   └── 048_005_stores_qb_columns.json   # NEW: Add sync mode columns to stores
└── src/BuyerKiosk/TaskEngine/Jobs/
    └── QuickBooksSyncJob.php            # MODIFY: Support staging mode

public_html/
├── js/quickbooks/                       # NEW: JS for QB pages
│   ├── dashboard.js
│   ├── approval-queue.js
│   ├── sync-log.js
│   ├── audit-log.js
│   ├── account-mapping.js
│   ├── reconciliation.js
│   └── settings.js
└── css/admin/modules/
    └── quickbooks.css                   # NEW: QB-specific styles
```

### Interface Specifications

#### Data Storage Changes

```yaml
# NEW TABLE: qb_staged_entries (per-store DB)
Table: qb_staged_entries
  id: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  syncDate: DATE NOT NULL
  status: ENUM('pending_approval','approved','rejected','posted','voided') NOT NULL DEFAULT 'pending_approval'
  originalPayload: JSON NOT NULL              # Raw S-file data snapshot
  editedPayload: JSON NULL                    # Modified payload (if edited)
  payloadHash: VARCHAR(64) NOT NULL           # SHA-256 of originalPayload for change detection
  editedPayloadHash: VARCHAR(64) NULL         # SHA-256 of editedPayload
  docNumber: VARCHAR(50) NOT NULL             # BK-{typeNum}-{yyyymmdd}
  totalDebits: DECIMAL(12,2) NOT NULL DEFAULT 0
  totalCredits: DECIMAL(12,2) NOT NULL DEFAULT 0
  lineCount: INT NOT NULL DEFAULT 0
  journalEntryId: VARCHAR(50) NULL            # QBO JE ID after posting
  stagedAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  stagedBy: VARCHAR(20) NULL                  # 'system' or userId
  editedAt: DATETIME NULL
  editedBy: INT UNSIGNED NULL
  approvedAt: DATETIME NULL
  approvedBy: INT UNSIGNED NULL
  rejectedAt: DATETIME NULL
  rejectedBy: INT UNSIGNED NULL
  rejectedReason: TEXT NULL
  postedAt: DATETIME NULL
  voidedAt: DATETIME NULL
  voidedBy: INT UNSIGNED NULL
  voidedReason: TEXT NULL
  createdAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  updatedAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  ADD UNIQUE KEY: uk_sync_date (syncDate)     # One staged entry per date
  ADD INDEX: idx_status (status)
  ADD INDEX: idx_doc_number (docNumber)

# NEW TABLE: qb_audit_log (per-store DB)
Table: qb_audit_log
  id: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  eventType: ENUM('sync_staged','sync_posted','sync_failed','edited','approved','rejected','voided','re_synced','mapping_updated','settings_changed','connected','disconnected') NOT NULL
  syncDate: DATE NULL                         # The business date this event relates to
  stagedEntryId: INT UNSIGNED NULL            # FK to qb_staged_entries
  actorId: INT UNSIGNED NULL                  # users.id
  actorName: VARCHAR(100) NULL                # Denormalized for export
  details: JSON NULL                          # Event-specific payload (edits, reasons, etc.)
  ipAddress: VARCHAR(45) NULL
  createdAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  ADD INDEX: idx_event_type (eventType)
  ADD INDEX: idx_sync_date (syncDate)
  ADD INDEX: idx_created (createdAt)
  ADD INDEX: idx_actor (actorId)

# NEW TABLE: qb_store_settings (per-store DB)
Table: qb_store_settings
  id: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  settingKey: VARCHAR(100) NOT NULL UNIQUE
  settingValue: TEXT NULL
  updatedAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  updatedBy: INT UNSIGNED NULL
  # Default rows seeded by migration:
  # syncMode = 'manual'
  # syncCadence = 'daily'
  # updateBehavior = 'manual_decision'
  # memoTemplate = 'Daily Sales - {companyName} ({typeNum}) - {date}'
  # reminderDays = '3'

# MODIFY TABLE: qb_sync_log (per-store DB)
Table: qb_sync_log
  ADD COLUMN: stagedEntryId INT UNSIGNED NULL AFTER syncedBy
  ADD COLUMN: payloadHash VARCHAR(64) NULL AFTER retryCount
  ADD COLUMN: docNumber VARCHAR(50) NULL AFTER journalEntryDocNum  # Alias; existing journalEntryDocNum kept for backward compat

# MODIFY TABLE: stores (central DB kiosk_buykiosk)
Table: stores
  ADD COLUMN: qbSyncMode ENUM('auto','manual','disabled') NOT NULL DEFAULT 'manual' AFTER qbLastSync
```

#### Internal API Changes

```yaml
# ========== NEW PAGE ROUTES ==========
# File: routes/admin/quickbooks.php
# Prefix: /admin/:typeNum/quickbooks

Page: QB Dashboard
  Method: GET
  Path: /admin/:typeNum/quickbooks/
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/dashboard.html

Page: Approval Queue
  Method: GET
  Path: /admin/:typeNum/quickbooks/approval-queue
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/approval-queue.html

Page: Sync Log
  Method: GET
  Path: /admin/:typeNum/quickbooks/sync-log
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/sync-log.html

Page: Audit Log
  Method: GET
  Path: /admin/:typeNum/quickbooks/audit-log
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/audit-log.html

Page: Account Mapping
  Method: GET
  Path: /admin/:typeNum/quickbooks/mapping
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/account-mapping.html

Page: Reconciliation
  Method: GET
  Path: /admin/:typeNum/quickbooks/reconciliation
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/reconciliation.html

Page: Settings
  Method: GET
  Path: /admin/:typeNum/quickbooks/settings
  Auth: checkAccess('quickbooks_config') + checkStoreGroup
  Renders: quickbooks/settings.html

# ========== NEW API ENDPOINTS ==========
# File: routes/groups/quickbooks.php (extended)
# Prefix: /api/quickbooks/:typeNum

# --- Staged Entries / Approval ---
Endpoint: Get Pending Entries
  Method: GET
  Path: /api/quickbooks/:typeNum/staged
  Query: ?status=pending_approval&from=2026-04-01&to=2026-05-04
  Response:
    success: boolean
    entries: array of StagedEntry objects
    pendingCount: integer

Endpoint: Get Single Staged Entry
  Method: GET
  Path: /api/quickbooks/:typeNum/staged/:id
  Response:
    success: boolean
    entry: StagedEntry with full payload + lines

Endpoint: Edit Staged Entry
  Method: PUT
  Path: /api/quickbooks/:typeNum/staged/:id
  Request:
    editedPayload: JSON (full snapshot)
    editReason: string (required if any value changes > $1.00)
    adjustmentLines: array of {description, amount, type, qbAccountId, qbAccountName}
  Response:
    success: boolean
    entry: Updated StagedEntry
    validation: {balanced: bool, totalDebits, totalCredits, difference}

Endpoint: Approve Staged Entry
  Method: POST
  Path: /api/quickbooks/:typeNum/staged/:id/approve
  Request:
    csrf_token: string
  Response:
    success: boolean
    journalEntryId: string (QBO ID)
    docNumber: string

Endpoint: Bulk Approve
  Method: POST
  Path: /api/quickbooks/:typeNum/staged/bulk-approve
  Request:
    entryIds: array of integers
    csrf_token: string
  Response:
    success: boolean
    results: array of {id, success, journalEntryId, error}

Endpoint: Reject Staged Entry
  Method: POST
  Path: /api/quickbooks/:typeNum/staged/:id/reject
  Request:
    reason: string (min 10 chars)
    csrf_token: string
  Response:
    success: boolean

# --- Audit Log ---
Endpoint: Get Audit Log
  Method: GET
  Path: /api/quickbooks/:typeNum/audit
  Query: ?from=2026-04-01&to=2026-05-04&eventType=approved&userId=28&limit=50&offset=0
  Response:
    success: boolean
    events: array of AuditEvent objects
    total: integer

Endpoint: Export Audit Log CSV
  Method: GET
  Path: /api/quickbooks/:typeNum/audit/export
  Query: ?from=2026-04-01&to=2026-05-04&eventType=all
  Response: CSV file download (Content-Type: text/csv)

# --- Reconciliation ---
Endpoint: Get Variance Report
  Method: GET
  Path: /api/quickbooks/:typeNum/reconciliation
  Query: ?from=2026-04-01&to=2026-04-30
  Response:
    success: boolean
    days: array of {date, posTotal, qboTotal, variance, status, hasDetail}

Endpoint: Get Day Detail
  Method: GET
  Path: /api/quickbooks/:typeNum/reconciliation/:date
  Response:
    success: boolean
    posLines: array of {fieldName, description, amount, type}
    qboLines: array of {description, accountId, accountName, amount, type}
    variance: {total, perLine: array}

# --- Void ---
Endpoint: Void Posted JE
  Method: POST
  Path: /api/quickbooks/:typeNum/staged/:id/void
  Request:
    reason: string (min 10 chars)
    csrf_token: string
  Response:
    success: boolean

# --- Settings ---
Endpoint: Get Settings
  Method: GET
  Path: /api/quickbooks/:typeNum/settings
  Response:
    success: boolean
    settings: {syncMode, syncCadence, updateBehavior, memoTemplate, reminderDays, environment}

Endpoint: Update Settings
  Method: PUT
  Path: /api/quickbooks/:typeNum/settings
  Request:
    settings: object with one or more setting keys
    csrf_token: string
  Response:
    success: boolean
    settings: Updated settings object

# --- Dashboard Data ---
Endpoint: Get Dashboard Summary
  Method: GET
  Path: /api/quickbooks/:typeNum/dashboard
  Response:
    success: boolean
    connection: {connected, companyName, realmId, environment}
    syncMode: string
    lastSync: datetime
    pendingCount: integer
    failedCount7d: integer
    mappingProgress: {mapped, total, percent}
    recentActivity: array of last 10 audit events
```

#### Application Data Models

```pseudocode
ENTITY: StagedEntry (NEW)
  FIELDS:
    id: int
    syncDate: date
    status: enum(pending_approval, approved, rejected, posted, voided)
    originalPayload: JSON
    editedPayload: JSON|null
    payloadHash: string (SHA-256)
    editedPayloadHash: string|null
    docNumber: string
    totalDebits: decimal
    totalCredits: decimal
    lineCount: int
    journalEntryId: string|null
    stagedBy: string
    editedBy: int|null
    approvedBy: int|null
    rejectedBy: int|null
    rejectedReason: string|null
    voidedBy: int|null
    voidedReason: string|null
    timestamps: stagedAt, editedAt, approvedAt, rejectedAt, postedAt, voidedAt

  BEHAVIORS:
    + getEffectivePayload(): JSON  # Returns editedPayload if exists, else originalPayload
    + getJournalLines(): array     # Builds line items from effective payload + mappings
    + isBalanced(): bool           # totalDebits == totalCredits within $0.01
    + computeHash(payload): string # SHA-256 of sorted JSON-encoded payload

ENTITY: AuditEvent (NEW)
  FIELDS:
    id: int
    eventType: enum
    syncDate: date|null
    stagedEntryId: int|null
    actorId: int|null
    actorName: string|null
    details: JSON|null
    ipAddress: string|null
    createdAt: datetime

  BEHAVIORS:
    + toArray(): array       # Serializable for API response
    + toCsvRow(): array      # Flat array for CSV export

ENTITY: QuickBooksService (MODIFIED)
  FIELDS:
    (existing fields)
    + rateLimiter: QBRateLimiter
    + tokenMutex: TokenRefreshMutex

  BEHAVIORS:
    (existing methods)
    + getAuthenticatedDataService(): DataService  # MODIFIED: Uses mutex for token refresh
    + queryJournalEntryByDocNumber(docNumber): ?object  # NEW: De-dupe lookup
    + voidJournalEntry(journalEntryId): bool  # NEW: Void/delete a JE in QBO
    + generateDocNumber(typeNum, date): string  # NEW: BK-{typeNum}-{yyyymmdd}

ENTITY: JournalEntryService (MODIFIED)
  FIELDS:
    (existing fields)

  BEHAVIORS:
    (existing methods)
    ~ syncDailyClose(): array  # MODIFIED: Checks sync mode, stages if manual
    + stageEntry(date, dailyData, syncType, userId): StagedEntry  # NEW
    + postStagedEntry(stagedEntryId, userId): array  # NEW: Posts to QBO
    + computePayloadHash(payload): string  # NEW
    ~ buildJournalEntryObject(): object  # MODIFIED: Uses BK- DocNumber format
```

### Implementation Examples

#### Example: Token Refresh with Mutex

**Why this example**: Concurrent token refresh is a known race condition. This shows the Redis mutex pattern specific to this project.

```php
class TokenRefreshMutex
{
    private $redis;
    private const LOCK_TTL = 30; // seconds

    public function __construct()
    {
        $this->redis = getRedisClient();
    }

    public function acquireRefreshLock(string $typeNum): bool
    {
        $key = "qb:token_refresh:{$typeNum}";
        // SETNX returns true only if key didn't exist
        $acquired = $this->redis->setnx($key, time());
        if ($acquired) {
            $this->redis->expire($key, self::LOCK_TTL);
        }
        return $acquired;
    }

    public function releaseLock(string $typeNum): void
    {
        $key = "qb:token_refresh:{$typeNum}";
        $this->redis->del($key);
    }

    public function waitForRefresh(string $typeNum, int $maxWaitMs = 5000): bool
    {
        $start = microtime(true);
        $key = "qb:token_refresh:{$typeNum}";
        while ($this->redis->exists($key)) {
            if ((microtime(true) - $start) * 1000 > $maxWaitMs) {
                return false; // Timed out
            }
            usleep(100000); // 100ms
        }
        return true;
    }
}
```

#### Example: Approval Flow

**Why this example**: This is the most complex new business logic, showing the stage → edit → approve → post pipeline.

```php
class ApprovalService
{
    public function approveAndPost(int $stagedEntryId, int $userId): array
    {
        $entry = $this->getStagedEntry($stagedEntryId);

        if ($entry['status'] !== 'pending_approval') {
            return ['success' => false, 'error' => 'Entry is not pending approval'];
        }

        // Validate balance
        $payload = $entry['editedPayload'] ?? $entry['originalPayload'];
        $lines = $this->jeService->buildJournalLines(
            json_decode($payload, true),
            $this->jeService->getAccountMappingsKeyed()
        );

        $validation = $this->jeService->validateBalance($lines);
        if (!$validation['balanced']) {
            return ['success' => false, 'error' => 'Entry is not balanced'];
        }

        // Check for existing JE in QBO (de-dupe)
        $existing = $this->qbService->queryJournalEntryByDocNumber($entry['docNumber']);
        if ($existing) {
            // Flag for manual decision based on update behavior setting
            $updateBehavior = $this->getSettingValue('updateBehavior');
            if ($updateBehavior === 'manual_decision') {
                return ['success' => false, 'error' => 'JE already exists in QBO', 'existingId' => $existing->Id];
            }
            // Handle void-and-repost or update-in-place...
        }

        // Post to QBO
        $result = $this->jeService->postStagedEntry($stagedEntryId, $userId);

        if ($result['success']) {
            // Update staged entry
            $this->updateEntryStatus($stagedEntryId, 'posted', [
                'approvedBy' => $userId,
                'approvedAt' => date('Y-m-d H:i:s'),
                'postedAt' => date('Y-m-d H:i:s'),
                'journalEntryId' => $result['journalEntryId'],
            ]);

            // Audit log
            $this->auditService->log('approved', $entry['syncDate'], $stagedEntryId, $userId);
            $this->auditService->log('sync_posted', $entry['syncDate'], $stagedEntryId, $userId, [
                'journalEntryId' => $result['journalEntryId'],
                'docNumber' => $entry['docNumber'],
            ]);
        }

        return $result;
    }
}
```

#### Example: QBO Rate Limiter

**Why this example**: Shows the Redis sliding-window pattern needed for reconciliation bulk queries.

```php
class QBRateLimiter
{
    private $redis;
    private const MAX_REQUESTS = 450; // Leave 50 buffer from 500 limit
    private const WINDOW_SECONDS = 60;

    public function __construct()
    {
        $this->redis = getRedisClient();
    }

    public function throttle(string $realmId): void
    {
        $key = "qb:rate:{$realmId}";
        $now = microtime(true);
        $windowStart = $now - self::WINDOW_SECONDS;

        // Remove old entries outside the window
        $this->redis->zremrangebyscore($key, '-inf', $windowStart);

        // Count requests in current window
        $count = $this->redis->zcard($key);

        if ($count >= self::MAX_REQUESTS) {
            // Get the oldest entry to calculate wait time
            $oldest = $this->redis->zrange($key, 0, 0, 'WITHSCORES');
            $waitSeconds = ceil(reset($oldest) + self::WINDOW_SECONDS - $now);
            usleep($waitSeconds * 1000000);
        }

        // Record this request
        $this->redis->zadd($key, $now, $now . ':' . uniqid());
        $this->redis->expire($key, self::WINDOW_SECONDS + 5);
    }
}
```

## Runtime View

### Primary Flow: Nightly Sync (Manual Approval Mode)

1. TaskEngine triggers `QuickBooksSyncJob::handle()` at 2AM
2. Job reads `qbSyncMode` from `stores` table
3. If mode is `manual`: job calls `JournalEntryService::stageEntry()`
4. `stageEntry()` loads daily close data from `drsDailySFileData`
5. Builds journal lines from payload + account mappings
6. Computes SHA-256 hash of payload
7. Inserts into `qb_staged_entries` with status `pending_approval`
8. Logs `sync_staged` event to `qb_audit_log`
9. Checks if pending items > reminderDays threshold → sends Ably notification if needed
10. Job returns `JobResult::success(['staged' => true])`

```mermaid
sequenceDiagram
    actor TaskEngine
    participant Job as QuickBooksSyncJob
    participant JES as JournalEntryService
    participant StoreDB as Store DB
    participant AuditSvc as AuditService
    participant Ably as Ably

    TaskEngine->>Job: handle()
    Job->>StoreDB: Read stores.qbSyncMode
    alt Mode = manual
        Job->>JES: stageEntry(date, data)
        JES->>StoreDB: Read drsDailySFileData
        JES->>JES: buildJournalLines()
        JES->>JES: computePayloadHash()
        JES->>StoreDB: INSERT qb_staged_entries (pending_approval)
        JES->>AuditSvc: log('sync_staged')
        AuditSvc->>StoreDB: INSERT qb_audit_log
        Job->>StoreDB: Check pending count > reminderDays
        opt Reminder needed
            Job->>Ably: Publish notification
        end
        Job-->>TaskEngine: JobResult::success(staged=true)
    else Mode = auto
        Job->>JES: syncDailyClose()
        JES->>JES: Pre-post de-dupe check
        JES-->>Job: {success, journalEntryId}
        Job-->>TaskEngine: JobResult::success(posted=true)
    else Mode = disabled
        Job-->>TaskEngine: JobResult::success(skipped=true)
    end
```

### Primary Flow: Approve & Post

```mermaid
sequenceDiagram
    actor User
    participant UI as Approval Queue UI
    participant API as QBApiController
    participant ApprSvc as ApprovalService
    participant JES as JournalEntryService
    participant QBS as QuickBooksService
    participant QBO as QBO API
    participant Audit as AuditService
    participant DB as Store DB

    User->>UI: Click "Approve & Post"
    UI->>API: POST /staged/:id/approve
    API->>ApprSvc: approveAndPost(id, userId)
    ApprSvc->>DB: Read qb_staged_entries
    ApprSvc->>ApprSvc: Validate balance
    ApprSvc->>QBS: queryJournalEntryByDocNumber()
    QBS->>QBO: GET /query?query=JournalEntry WHERE DocNumber='BK-pc00-20260504'
    QBO-->>QBS: No results (or existing)
    alt No existing JE
        ApprSvc->>JES: postStagedEntry(id, userId)
        JES->>QBS: getAuthenticatedDataService()
        QBS->>QBO: POST /journalentry (Create)
        QBO-->>QBS: {Id, DocNumber}
        JES->>DB: UPDATE qb_staged_entries SET status='posted'
        JES->>DB: INSERT/UPDATE qb_sync_log
        ApprSvc->>Audit: log('approved')
        ApprSvc->>Audit: log('sync_posted')
        Audit->>DB: INSERT qb_audit_log (x2)
        ApprSvc-->>API: {success, journalEntryId}
    else JE exists + manual_decision
        ApprSvc-->>API: {success: false, error: 'JE exists'}
    end
    API-->>UI: JSON response
    UI->>UI: Update UI, remove from queue
```

### Error Handling

```yaml
# Error Classification
validation_errors:
  - Unbalanced JE (debits ≠ credits): Block save/approve, show imbalance in red
  - Missing edit reason (change > $1.00): Block save, highlight reason field
  - Tax zeroed out: Block save, show "Tax collected cannot be zeroed out"
  - Adjustment memo < 10 chars: Block save, show validation message
  - Incomplete mappings: Block sync/approve, show "Complete account mappings first"

api_errors:
  - QBO 401 (token expired): Auto-refresh via mutex, retry once
  - QBO 429 (rate limited): Rate limiter handles via exponential backoff
  - QBO 500/503 (server error): Log, set sync status to 'failed', notify via Ably
  - Network timeout: Log, set sync status to 'failed', retry on next cycle
  - QBO duplicate DocNumber: Flag for manual decision per update behavior setting

system_errors:
  - Redis unavailable: Degrade gracefully — skip mutex (warn in log), skip rate limiting
  - DB connection failure: Log error, return 500 with user-friendly message
  - Ably unavailable: Silent skip — notifications are best-effort
  - PHP TypeError/ValueError: Caught by \Throwable, logged with full stack trace
```

## Deployment View

### Single Application Deployment
- **Environment**: Existing dev2.buyerkiosk.com (ngrok to local dev machine), no additional infrastructure
- **Configuration**: No new environment variables needed — existing `QB_*` vars, `REDIS_URL`, `ABLY_KEY` are sufficient
- **Dependencies**: No new Composer packages — uses existing `quickbooks/v3-php-sdk`, `predis/predis`, `ably/ably-php`
- **Performance**: Reconciliation queries may take 2-5s for a 30-day range (30 QBO API calls throttled). Dashboard queries < 500ms. Approval queue queries < 200ms.

### Migration Sequencing
- Migrations run via `php userfrosting/conductor run` against all stores
- Order: 048_001 (staged_entries) → 048_002 (audit_log) → 048_003 (store_settings) → 048_004 (sync_log_expand) → 048_005 (stores_columns)
- All migrations are additive (no destructive changes) — safe to run alongside existing data
- Existing `qb_sync_log` data is preserved — new columns are nullable

## Cross-Cutting Concepts

### System-Wide Patterns

- **Security**: All QB pages gated by `quickbooks_config` permission + `checkStoreGroup()`. CSRF on all POST/PUT endpoints. Tokens encrypted at rest. No secrets in templates or JS.
- **Error Handling**: All catch blocks use `\Throwable`. Errors logged to `quickbooks.log` via KLogger. User-facing errors are sanitized (no stack traces in API responses).
- **Performance**: Redis caching for token refresh coordination and rate limiting only — QB page data is fetched fresh (not cached) to ensure accuracy. Syncfusion Grids handle client-side pagination/sorting.
- **Logging/Auditing**: Dual logging: KLogger for debug/error logs, `qb_audit_log` for business events. Audit log is append-only and immutable.

### Implementation Patterns

#### Code Patterns and Conventions
- Services take `\Store $store` in constructor, get DB via `dbConnectByName()`
- Controllers extend `BaseController`, use `$this->_app->render()` for pages and `jsonResponse()` for API
- Route groups follow existing pattern: page routes in `routes/admin/`, API routes in `routes/groups/`
- All new classes use PSR-4 autoloading under `BuyerKiosk\QuickBooks\` namespace

#### State Management Patterns
- Server-side state in database (no client-side state beyond the current page)
- AJAX calls update UI via jQuery DOM manipulation (consistent with existing QB setup page pattern)
- Syncfusion Grid dataSource bound to API responses, refreshed on CRUD operations

#### Error Handling Pattern
```pseudocode
FUNCTION: handleQBApiOperation(operation)
  TRY:
    rateLimiter.throttle(realmId)
    result = operation()
    RETURN result
  CATCH \Throwable $e:
    log.error(operation_name, e.getMessage(), e.getTrace())
    IF e is TokenExpiredException:
      IF tokenMutex.acquireRefreshLock(typeNum):
        refreshToken()
        tokenMutex.releaseLock(typeNum)
        RETURN RETRY operation once
      ELSE:
        tokenMutex.waitForRefresh(typeNum)
        RETURN RETRY operation once
    IF e is RateLimitException:
      SLEEP with exponential backoff
      RETURN RETRY
    RETURN {success: false, error: sanitizedMessage}
```

#### Component Structure Pattern (Twig + JS)
```pseudocode
TEMPLATE: quickbooks/{page}.html
  SET page_group = "quickbooks"
  INCLUDE components/head.html
  INCLUDE components/nav-account.html
  INCLUDE components/alerts.html

  RENDER page-specific content:
    IF Syncfusion Grid: container div with id
    IF forms: Bootstrap 5 cards with form groups
    IF status indicators: badges with design token colors

  INCLUDE components/footer.html

  SCRIPT:
    var typeNum = '{{ store.typeNum }}'
    var csrfToken = '{{ csrfToken }}'

    // Initialize Syncfusion components on DOM ready
    // Bind AJAX handlers for CRUD operations
    // Handle error display and validation feedback
```

## Architecture Decisions

- [x] ADR-1 **Event-sourced audit log in separate table**: Use `qb_audit_log` (append-only) separate from operational `qb_sync_log`
  - Rationale: Audit data has different lifecycle (never deleted/updated) and query patterns (date range + event type filters) than sync operations. Separation prevents the sync log from growing unbounded with edit/approval events.
  - Trade-offs: Two tables to query for a complete picture of a day's sync history. Mitigated by the dashboard combining both.
  - User confirmed: ✅ Yes (initial Q&A)

- [x] ADR-2 **Full payload snapshots for edits**: Store complete modified payload alongside original in `qb_staged_entries`
  - Rationale: Simplest to implement, diff, and reconstruct. No complex delta-application logic. Storage cost is minimal (~2KB per entry).
  - Trade-offs: Slightly more storage than delta approach.
  - User confirmed: ✅ Yes (initial Q&A)

- [x] ADR-3 **Redis mutex for token refresh**: SETNX-based lock with 30s TTL
  - Rationale: QBO invalidates old refresh tokens on use. Without mutex, concurrent requests race and one gets an invalid token. Redis is already available in the stack.
  - Trade-offs: Redis dependency for mutex (graceful degradation if Redis is down — skip mutex, log warning).
  - User confirmed: ✅ Yes (included as hardening item)

- [x] ADR-4 **BK- prefix DocNumber with backward compat**: New format `BK-{typeNum}-{yyyymmdd}`, lookups check both formats
  - Rationale: Cleaner namespace in QBO. Backward compat prevents breaking existing posted JEs.
  - Trade-offs: Slightly more complex lookup query (OR condition on DocNumber).
  - User confirmed: ✅ Yes (initial Q&A)

- [x] ADR-5 **Syncfusion EJ2 for complex UI components**: Grids for audit/sync logs, dialogs for approvals
  - Rationale: Consistent with other BuyerKiosk modules (backstock, scheduling). Provides filtering, sorting, pagination out-of-the-box.
  - Trade-offs: Syncfusion gotchas (hidden container init, DDL filter in modals) — mitigated by documented patterns in MEMORY.md.
  - User confirmed: ✅ Yes (initial Q&A)

- [x] ADR-6 **New top-level sidebar section**: QuickBooks gets own collapsible section at same level as Store Settings
  - Rationale: 7 pages need their own navigation group. Nesting under Integrations makes QB feel secondary.
  - Trade-offs: Sidebar grows longer. Mitigated by collapsible groups.
  - User confirmed: ✅ Yes (initial Q&A)

## Quality Requirements

- **Performance**: Dashboard page load < 1s. Audit log with 1000+ entries paginates via Syncfusion Grid (client-side, 50 rows/page). Reconciliation for 30 days < 10s (rate-limited QBO queries). Approval queue < 500ms.
- **Usability**: Mapping completeness indicator visible on Dashboard and Mapping page. Balance indicator updates in real-time during editing. Status badges use consistent colors across all pages (green=success, red=failed, yellow=pending, gray=no data, purple=voided).
- **Security**: No QB tokens exposed in templates or API responses (realmId is an exception — it's not secret). All POST/PUT endpoints validate CSRF. Audit log is immutable. Edit reasons are permanently stored.
- **Reliability**: Nightly sync job tolerates QBO downtime (retries next cycle). Token refresh mutex prevents race conditions. Rate limiter prevents QBO throttling. Graceful Redis degradation.

## Risks and Technical Debt

### Known Technical Issues
- `JournalEntryService.php:121` uses `catch (\Exception $e)` — must change to `\Throwable` (F12)
- `logSyncAttempt()` uses `ON DUPLICATE KEY UPDATE` which masks retries with different syncTypes — will be addressed by new staging pipeline
- Existing `QuickBooksController` mixes page rendering and API logic — will be split into two controllers

### Technical Debt
- Legacy classes (`QuickBooks.php`, `Config.php`, `Base.php`, `AccountMapper.php`, `Ingest.php`) remain for backward compat — not touched
- Old sidebar link (`/admin/:typeNum/qb-connect/setup/`) must be removed when new pages are deployed
- `drsFieldMapping` table (referenced by legacy `AccountMapper.php`) is deprecated but not removed

### Implementation Gotchas
- **Syncfusion hidden container**: All Syncfusion Grids must be initialized AFTER the parent DOM element is visible. For tab-based layouts, init on tab activation.
- **Syncfusion DDL filter in Bootstrap 5 modals**: Use `data-bs-focus="false"` on modals + `patchDropdownFilter()` after `appendTo()` — per MEMORY.md.
- **PDO named param reuse**: Cannot use `:param` twice in a query — use unique names (`:param1`, `:param2`) bound to the same value.
- **INT column comparison**: Never compare INT columns with `<> ''` in MariaDB strict mode — use `> 0` or `IS NOT NULL`.
- **QBO token refresh timing**: Access token refresh returns a NEW refresh token too — must save BOTH. The old refresh token is immediately invalidated.
- **Migration ID stability**: Changing a migration's `description` text changes its `migration_id` (MD5 hash). Don't edit deployed migration descriptions.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Stage + Approve Happy Path**
```gherkin
Given: Store is in manual approval mode with complete mappings
And: Daily close data exists for 2026-05-03
When: Nightly sync job runs
Then: Entry is staged with status 'pending_approval'
And: Audit log records 'sync_staged' event
When: User approves the entry
Then: JE is posted to QBO with DocNumber 'BK-pc00-20260503'
And: Entry status changes to 'posted'
And: Audit log records 'approved' and 'sync_posted' events
```

**Scenario 2: Duplicate Prevention**
```gherkin
Given: A JE with DocNumber 'BK-pc00-20260503' already exists in QBO
And: Update behavior is 'manual_decision'
When: User tries to approve a staged entry for the same date
Then: Approval is blocked with error 'JE already exists in QBO'
And: Entry remains in 'pending_approval' status
And: User is shown option to void existing and re-post
```

**Scenario 3: Edit with Balance Validation**
```gherkin
Given: A pending entry with total debits = $1500.00 and credits = $1500.00
When: User changes cashTendered from $500.00 to $450.00
Then: Balance indicator shows: Debits $1450.00 | Credits $1500.00 | Diff -$50.00
And: Save button is disabled
When: User adds adjustment line: Debit $50.00 to Cash Over/Short
Then: Balance indicator shows: Debits $1500.00 | Credits $1500.00 | Balanced
And: Save button is enabled
```

**Scenario 4: Token Refresh Race Condition**
```gherkin
Given: QB access token is expired
And: Two concurrent requests arrive (nightly job + manual sync)
When: First request acquires the token refresh mutex
Then: Second request waits for refresh to complete
When: First request refreshes token and releases mutex
Then: Second request reads the new token and proceeds
And: Only one token refresh API call is made to QBO
```

**Scenario 5: Audit Log Immutability**
```gherkin
Given: An entry has been staged, edited, approved, and posted
When: Querying the audit log for that sync date
Then: Four events are returned in chronological order: sync_staged, edited, approved, sync_posted
And: Each event includes actor ID, actor name, timestamp, and event-specific details
And: No audit events have been modified or deleted
```

### Test Coverage Requirements

- **Business Logic**: All approval state transitions, balance validation, hash comparison, DocNumber generation, edit reason enforcement, tax zeroing prevention
- **User Interface**: Page rendering for all 7 pages, AJAX CRUD operations, Syncfusion Grid initialization, sidebar badge updates
- **Integration Points**: QBO API calls (mock DataService), token refresh (mock Redis), Ably notifications (mock client)
- **Edge Cases**: Empty daily close data, concurrent approvals, Redis unavailable, QBO rate limit hit, token expired mid-operation, re-staging after edit
- **Security**: CSRF validation on all POST/PUT, permission checks on all endpoints, no token leakage in responses

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Journal Entry (JE) | A double-entry bookkeeping record in QBO with debit and credit lines that must balance | Core unit of QB sync — each daily close produces one JE |
| DocNumber | A unique identifier for a JE in QBO, used for de-duplication | Format: `BK-{typeNum}-{yyyymmdd}` (e.g., `BK-pc00-20260504`) |
| Daily Close / S-file | End-of-day POS data summarizing sales, payments, COGS, and cash | Source data stored in `drsDailySFileData` with 90+ decimal columns |
| Account Mapping | Configuration linking BK S-file fields to QBO Chart of Accounts entries | Stored in `qb_account_mapping` with 37 default field mappings |
| Staging | The process of building a JE locally without posting to QBO | Entries in `qb_staged_entries` with status `pending_approval` |
| Void | Deleting/voiding a previously posted JE in QBO | Calls QBO delete API, logs in audit as 'voided' |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Payload Hash | SHA-256 digest of the JE payload, used to detect changes on re-sync | Stored in `qb_staged_entries.payloadHash` |
| Token Mutex | Redis-based mutual exclusion lock preventing concurrent token refresh | `SETNX` with 30s TTL on key `qb:token_refresh:{typeNum}` |
| Rate Limiter | Redis sorted-set sliding window tracking QBO API call frequency | Ensures < 450 calls/minute/realmId |
| Sync Mode | Per-store setting: auto (direct post), manual (stage for approval), disabled (skip) | Stored in `stores.qbSyncMode` |
| TypeNum | Store identifier pattern `[a-z]{2}\d+` (e.g., `pc00`, `ou00`) | Used in URLs, DocNumbers, and DB naming |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| RealmId | QBO company identifier, encrypted at rest in BK | Used in API calls and rate limiter key |
| DataService | QBO SDK class for making authenticated API calls | Obtained via `QuickBooksService::getAuthenticatedDataService()` |
| Bearer Token | OAuth2 access token for QBO API authentication | 1-hour lifespan, auto-refreshed by `QuickBooksService` |
