Files
client-projects/clients/spice-nvybe/architecture/m01-bi-dashboard.md
T
jerome 3d344816cf feat: add Spice N'Vybe client project files
- SOW v2.0 PDF (client scope document)
- Meeting prep for July 4 Kay alignment
- M01 BI dashboard architecture spec
- API data source research (QBO, Plaid, SpotOn)
- Project README with structure overview
2026-07-04 20:31:01 -04:00

1877 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spice N'Vybe M01 Executive BI Dashboard — Architectural Specification
> **Version:** v1.0 | **Date:** 2026-07-01
> **Target:** Homelab LXC with future AWS migration
> **Document Type:** Architectural Blueprint (production)
---
## Table of Contents
1. [System Architecture Overview](#1-system-architecture-overview)
2. [Database Schema Design](#2-database-schema-design)
3. [Data Ingestion Pipeline](#3-data-ingestion-pipeline)
4. [API Endpoint Design](#4-api-endpoint-design)
5. [Frontend Component Architecture](#5-frontend-component-architecture)
6. [Alerting System](#6-alerting-system)
7. [Security Model](#7-security-model)
8. [Deployment Architecture (LXC)](#8-deployment-architecture-lxc)
9. [AWS Migration & Scaling Strategy](#9-aws-migration--scaling-strategy)
10. [Data Reconciliation & Consistency Strategy](#10-data-reconciliation--consistency-strategy)
---
## 1. System Architecture Overview
### 1.1 High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND (React/Next.js) │
│ ┌────────────────┐ ┌──────────────┐ ┌───────────┐ ┌──────────┐ │
│ │ KPI Dashboard │ │ Location Tabs│ │Alerts Panel│ │Settings │ │
│ └───────┬────────┘ └──────┬───────┘ └─────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └─────────────────┴───────────────┴────────────┘ │
│ │ REST API (HTTPS) │
└────────────────────────────┼────────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────────────────┐
│ API GATEWAY / REVERSE PROXY (nginx) │
│ (auth validation, rate limiting) │
└────────────────────────────┼────────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────────────────┐
│ BACKEND (Node.js/Express) │
│ ┌──────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
│ │ API Router │ │ Auth Service │ │ Alert Engine │ │
│ └──────────────────┘ └────────────────────┘ └─────────────────┘ │
│ ┌──────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
│ │ Ingestion Workers│ │ Reconciliation │ │ Rate Limiter │ │
│ │ (QBO/Plaid/SON) │ │ Engine │ │ (bottleneck) │ │
│ └──────────────────┘ └────────────────────┘ └─────────────────┘ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ Queue Manager │ │ Job Scheduler │ │
│ │ (Bull/BullMQ) │ │ (node-cron) │ │
│ └──────────────────┘ └────────────────────┘ │
└────────────────────────────┬────────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌───────────────────────────────────────┐ │
│ │ PostgreSQL 16 │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Raw Ingestion│ │ Normalized │ │ │
│ │ │ Tables │ │ Views │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Aggregations│ │ Metadata │ │ │
│ │ │ (Materialized)│ │ (Sync State)│ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └───────────────────────────────────────┘ │
│ ┌───────────────────────────────────────┐ │
│ │ Redis 7 │ │
│ │ (Queue broker, cache, rate limiter, │ │
│ │ token store for QBO OAuth) │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┐
│ │ │
┌───────────▼────┐ ┌────────▼───────┐ ┌─────▼───────────┐
│ QuickBooks │ │ Plaid │ │ SpotOn POS │
│ Online (QBO) │ │ (Banking) │ │ (2 locations) │
│ OAuth 2.0 │ │ API Key │ │ API Key per loc │
│ 500 req/min │ │ Cursor sync │ │ 26h window │
└────────────────┘ └────────────────┘ └─────────────────┘
```
### 1.2 Data Flow Summary
```
SpotOn ──► 5-min polls (business hours) ──► Raw Tables ──► Normalization ──► Aggregation
26h deep sync daily
Plaid ──► /transactions/sync (cursor) ───► Raw Tables ──► Normalization ──► Aggregation
SYNC_UPDATES_AVAILABLE webhook
QBO ──► CDC polling (30min) + webhooks ─► Raw Tables ──► Normalization ──► Aggregation
Re-auth required every 100 days
```
### 1.3 Technology Stack (Precise Versions)
| Layer | Technology | Version | Purpose |
|-------|-----------|---------|---------|
| Frontend | Next.js | 14.x | React framework with SSR/SSG |
| UI | React | 18.x | Component library |
| Charts | Recharts | 2.x | React-native charting |
| Styling | Tailwind CSS | 3.x | Utility-first CSS |
| Tables | TanStack Table | 8.x | Data grid/table |
| Backend | Node.js | 20.x LTS | Runtime |
| Framework | Express | 4.18+ | HTTP server |
| Queue | BullMQ | 5.x | Redis-backed job queue |
| Cache | Redis | 7.x | Queue, cache, rate limiter |
| Database | PostgreSQL | 16.x | Primary data store |
| ORM | Prisma | 5.x | Type-safe DB access |
| Scheduler | node-cron | 3.x | Cron-based sync triggers |
| Auth | JWT + Passport | latest | API auth |
| OAuth | node-quickbooks | latest | QBO OAuth 2.0 |
---
## 2. Database Schema Design
### 2.1 Naming Conventions
- `raw_*` — Tables that mirror API responses 1:1 (immutable append log)
- `normalized_*` — Cleaned, typed, deduplicated records
- `agg_*` — Pre-computed aggregates (materialized views)
- `meta_*` — Configuration, sync state, credentials
### 2.2 Location Reference Table
```sql
-- Core location catalog
CREATE TABLE meta_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(32) UNIQUE NOT NULL, -- 'oakland-park', 'ft-lauderdale'
name VARCHAR(128) NOT NULL, -- 'Oakland Park Flagship', 'Fort Lauderdale Cloud Kitchen'
spoton_site_id VARCHAR(64), -- SpotOn site identifier
spoton_api_key TEXT, -- Encrypted at application layer
qbo_class_id VARCHAR(64), -- QBO Class for cost center tracking
plaid_account_ids JSONB, -- Array of Plaid account IDs for this location
is_active BOOLEAN DEFAULT true,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Seed data
INSERT INTO meta_locations (slug, name) VALUES
('oakland-park', 'Oakland Park Flagship'),
('ft-lauderdale', 'Fort Lauderdale Cloud Kitchen');
```
### 2.3 SpotOn Raw & Normalized Tables
```sql
-- ── RAW: Mirror of SpotOn Orders API response ──
CREATE TABLE raw_spoton_orders (
id BIGSERIAL,
spoton_order_id VARCHAR(64) NOT NULL,
location_id UUID NOT NULL REFERENCES meta_locations(id),
raw_payload JSONB NOT NULL, -- Full API response
ingested_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id),
UNIQUE (spoton_order_id, location_id)
);
CREATE INDEX idx_raw_so_ingested ON raw_spoton_orders(ingested_at);
CREATE INDEX idx_raw_so_location ON raw_spoton_orders(location_id);
-- ── RAW: SpotOn Labor Reports ──
CREATE TABLE raw_spoton_labor (
id BIGSERIAL,
location_id UUID NOT NULL REFERENCES meta_locations(id),
report_date DATE NOT NULL,
raw_payload JSONB NOT NULL,
ingested_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id),
UNIQUE (location_id, report_date)
);
-- ── NORMALIZED: Parsed order line items ──
CREATE TABLE normalized_orders (
id BIGSERIAL,
spoton_order_id VARCHAR(64) NOT NULL,
location_id UUID NOT NULL REFERENCES meta_locations(id),
order_date TIMESTAMPTZ NOT NULL,
order_type VARCHAR(32), -- 'dine-in', 'takeout', 'delivery', 'online'
subtotal DECIMAL(12,2),
tax DECIMAL(12,2),
tip DECIMAL(12,2),
total DECIMAL(12,2) NOT NULL,
payment_method VARCHAR(32), -- 'credit', 'cash', 'gift_card', etc.
item_count INTEGER,
status VARCHAR(32), -- 'completed', 'refunded', 'voided'
raw_id BIGINT REFERENCES raw_spoton_orders(id),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
);
CREATE INDEX idx_norm_orders_date ON normalized_orders(order_date);
CREATE INDEX idx_norm_orders_location ON normalized_orders(location_id);
CREATE INDEX idx_norm_orders_status ON normalized_orders(status);
-- ── NORMALIZED: Labor costs from SpotOn ──
CREATE TABLE normalized_labor_costs (
id BIGSERIAL,
location_id UUID NOT NULL REFERENCES meta_locations(id),
report_date DATE NOT NULL,
total_hours DECIMAL(10,2),
total_wages DECIMAL(12,2),
total_labor_cost DECIMAL(12,2), -- wages + taxes + benefits
employee_count INTEGER,
labor_percent DECIMAL(5,2), -- labor cost as % of sales
raw_id BIGINT REFERENCES raw_spoton_labor(id),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id),
UNIQUE (location_id, report_date)
);
```
### 2.4 Plaid Raw & Normalized Tables
```sql
-- ── RAW: Plaid transactions sync state ──
CREATE TABLE meta_plaid_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plaid_item_id VARCHAR(64) UNIQUE NOT NULL,
access_token TEXT NOT NULL, -- Encrypted at application layer
next_cursor TEXT, -- Cursor for incremental sync
initial_cursor TEXT, -- Saved for recovery
last_sync_at TIMESTAMPTZ,
status VARCHAR(32) DEFAULT 'active', -- 'active', 'error', 'requires_update'
error_detail TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ── RAW: Plaid transactions (append-only) ──
CREATE TABLE raw_plaid_transactions (
id BIGSERIAL,
plaid_txn_id VARCHAR(64) NOT NULL UNIQUE,
plaid_item_id VARCHAR(64) NOT NULL REFERENCES meta_plaid_cursors(plaid_item_id),
account_id VARCHAR(64),
raw_payload JSONB NOT NULL,
sync_type VARCHAR(16), -- 'initial', 'added', 'modified', 'removed'
ingested_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
);
CREATE INDEX idx_raw_plaid_item ON raw_plaid_transactions(plaid_item_id);
CREATE INDEX idx_raw_plaid_ingested ON raw_plaid_transactions(ingested_at);
-- ── NORMALIZED: Cleaned Plaid transactions ──
CREATE TABLE normalized_bank_transactions (
id BIGSERIAL,
plaid_txn_id VARCHAR(64) UNIQUE NOT NULL,
location_id UUID REFERENCES meta_locations(id), -- Mapped via Plaid account
account_id VARCHAR(64),
transaction_date DATE NOT NULL,
amount DECIMAL(12,2) NOT NULL, -- Positive = debit, negative = credit
description TEXT,
merchant_name VARCHAR(256),
category VARCHAR(128),
personal_finance_category VARCHAR(128),
is_expense BOOLEAN GENERATED ALWAYS AS (amount > 0) STORED,
is_reconciled BOOLEAN DEFAULT false, -- Matched to SpotOn deposit?
reconciled_txn_id BIGINT REFERENCES normalized_orders(id),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
);
CREATE INDEX idx_bank_txn_date ON normalized_bank_transactions(transaction_date);
CREATE INDEX idx_bank_txn_location ON normalized_bank_transactions(location_id);
CREATE INDEX idx_bank_txn_category ON normalized_bank_transactions(category);
CREATE INDEX idx_bank_txn_reconciled ON normalized_bank_transactions(is_reconciled);
```
### 2.5 QuickBooks Raw & Normalized Tables
```sql
-- ── META: QBO OAuth state ──
CREATE TABLE meta_qbo_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
realm_id VARCHAR(64) UNIQUE NOT NULL,
access_token TEXT NOT NULL, -- Encrypted; rotated every 60 min
refresh_token TEXT NOT NULL, -- Encrypted; rotated every 100 days
token_expires_at TIMESTAMPTZ NOT NULL,
refresh_expires_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN DEFAULT true,
last_refreshed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ── META: QBO CDC sync cursor ──
CREATE TABLE meta_qbo_cdc_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
realm_id VARCHAR(64) NOT NULL REFERENCES meta_qbo_credentials(realm_id),
entity_name VARCHAR(64) NOT NULL, -- 'Invoice', 'Bill', 'Payment', etc.
last_cdc_timestamp TIMESTAMPTZ NOT NULL,
last_sync_at TIMESTAMPTZ,
UNIQUE (realm_id, entity_name)
);
-- ── RAW: QBO CDC events (append log) ──
CREATE TABLE raw_qbo_cdc_events (
id BIGSERIAL,
realm_id VARCHAR(64) NOT NULL,
entity_name VARCHAR(64) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
operation VARCHAR(16) NOT NULL, -- 'Create', 'Update', 'Delete'
raw_payload JSONB NOT NULL, -- Full entity snapshot
event_timestamp TIMESTAMPTZ NOT NULL,
ingested_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
);
CREATE INDEX idx_raw_qbo_event ON raw_qbo_cdc_events(entity_name, entity_id);
CREATE INDEX idx_raw_qbo_time ON raw_qbo_cdc_events(event_timestamp);
-- ── RAW: QBO P&L report snapshots ──
CREATE TABLE raw_qbo_profit_loss (
id BIGSERIAL,
realm_id VARCHAR(64) NOT NULL,
report_date DATE NOT NULL,
report_type VARCHAR(32) NOT NULL, -- 'monthly', 'quarterly', 'ytd'
raw_payload JSONB NOT NULL,
ingested_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (realm_id, report_date, report_type)
);
-- ── NORMALIZED: QBO Costs (COGS + Expenses) ──
CREATE TABLE normalized_qbo_costs (
id BIGSERIAL,
location_id UUID REFERENCES meta_locations(id),
cost_date DATE NOT NULL,
cost_category VARCHAR(64) NOT NULL, -- 'cogs', 'rent', 'utilities', 'payroll', 'marketing', etc.
account_name VARCHAR(128),
account_id VARCHAR(64),
amount DECIMAL(12,2) NOT NULL,
source VARCHAR(32) DEFAULT 'qbo', -- 'qbo', 'plaid', 'manual'
source_ref VARCHAR(128), -- Entity ID from source
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id)
);
CREATE INDEX idx_costs_date ON normalized_qbo_costs(cost_date);
CREATE INDEX idx_costs_location ON normalized_qbo_costs(location_id);
CREATE INDEX idx_costs_category ON normalized_qbo_costs(cost_category);
```
### 2.6 Aggregation Tables (Materialized Views)
```sql
-- ── Daily Sales Aggregation ──
CREATE MATERIALIZED VIEW agg_daily_sales AS
SELECT
location_id,
order_date::DATE AS sale_date,
COUNT(*) AS order_count,
SUM(total) AS gross_sales,
SUM(CASE WHEN status != 'refunded' THEN total ELSE 0 END) AS net_sales,
SUM(CASE WHEN status = 'refunded' THEN ABS(total) ELSE 0 END) AS refunds,
SUM(tax) AS tax_collected,
SUM(tip) AS tips,
SUM(item_count) AS total_items,
AVG(total) AS avg_order_value,
COUNT(DISTINCT payment_method) AS payment_methods_used
FROM normalized_orders
GROUP BY location_id, order_date::DATE;
CREATE UNIQUE INDEX ON agg_daily_sales(location_id, sale_date);
-- ── Daily Sales × Plaid Deposit Reconciliation ──
CREATE MATERIALIZED VIEW agg_daily_reconciliation AS
SELECT
COALESCE(s.location_id, b.location_id) AS location_id,
COALESCE(s.sale_date, b.transaction_date) AS report_date,
COALESCE(s.net_sales, 0) AS spoton_sales,
COALESCE(SUM(ABS(b.amount)), 0) AS plaid_deposits, -- Credits only
CASE
WHEN s.net_sales IS NULL THEN 'no_sales_data'
WHEN SUM(ABS(b.amount)) IS NULL THEN 'no_bank_data'
WHEN ABS(s.net_sales - SUM(ABS(b.amount))) < 0.01 THEN 'matched'
WHEN ABS(s.net_sales - SUM(ABS(b.amount))) / NULLIF(s.net_sales, 0) < 0.05 THEN 'minor_variance'
ELSE 'variance_detected'
END AS reconciliation_status,
(s.net_sales - COALESCE(SUM(ABS(b.amount)), 0)) AS variance
FROM agg_daily_sales s
FULL OUTER JOIN normalized_bank_transactions b
ON s.location_id = b.location_id
AND s.sale_date = b.transaction_date
AND b.amount < 0 -- Credits (deposits)
GROUP BY COALESCE(s.location_id, b.location_id),
COALESCE(s.sale_date, b.transaction_date),
s.net_sales;
CREATE UNIQUE INDEX ON agg_daily_reconciliation(location_id, report_date);
-- ── Daily P&L Summary ──
CREATE MATERIALIZED VIEW agg_daily_profit_loss AS
SELECT
COALESCE(s.location_id, c.location_id) AS location_id,
COALESCE(s.sale_date, c.cost_date) AS report_date,
COALESCE(s.net_sales, 0) AS total_revenue,
lc.total_labor_cost AS labor_cost,
lc.labor_percent,
COALESCE(SUM(c.amount) FILTER (WHERE c.cost_category = 'cogs'), 0) AS cogs,
COALESCE(SUM(c.amount) FILTER (WHERE c.cost_category != 'cogs'), 0) AS operating_expenses,
COALESCE(lc.total_labor_cost, 0) + COALESCE(SUM(c.amount), 0) AS total_costs,
COALESCE(s.net_sales, 0) - (COALESCE(lc.total_labor_cost, 0) + COALESCE(SUM(c.amount), 0)) AS net_profit
FROM agg_daily_sales s
FULL OUTER JOIN normalized_qbo_costs c
ON s.location_id = c.location_id AND s.sale_date = c.cost_date
LEFT JOIN normalized_labor_costs lc
ON s.location_id = lc.location_id AND s.sale_date = lc.report_date
GROUP BY COALESCE(s.location_id, c.location_id),
COALESCE(s.sale_date, c.cost_date),
s.net_sales,
lc.total_labor_cost,
lc.labor_percent;
CREATE UNIQUE INDEX ON agg_daily_profit_loss(location_id, report_date);
-- ── Monthly Location Comparison ──
CREATE MATERIALIZED VIEW agg_monthly_comparison AS
SELECT
location_id,
date_trunc('month', report_date) AS month,
SUM(total_revenue) AS revenue,
SUM(total_costs) AS costs,
SUM(net_profit) AS profit,
AVG(labor_percent) AS avg_labor_pct,
SUM(labor_cost) AS total_labor,
SUM(cogs) AS total_cogs,
SUM(operating_expenses) AS total_opex,
COUNT(*) AS days_with_data
FROM agg_daily_profit_loss
GROUP BY location_id, date_trunc('month', report_date);
CREATE UNIQUE INDEX ON agg_monthly_comparison(location_id, month);
```
### 2.7 Alerting & Audit Tables
```sql
-- ── Alert Log ──
CREATE TABLE meta_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
severity VARCHAR(16) NOT NULL CHECK (severity IN (
'CRITICAL', 'FAILURE', 'ERROR', 'SECURITY', 'WARNING', 'INFO'
)),
category VARCHAR(64) NOT NULL, -- 'ingestion', 'reconciliation', 'auth', 'system', 'kpi'
title VARCHAR(256) NOT NULL,
message TEXT NOT NULL,
source VARCHAR(32), -- 'qbo', 'plaid', 'spoton', 'system'
location_id UUID REFERENCES meta_locations(id),
kpi_name VARCHAR(64), -- 'sales', 'costs', 'net_profit', 'labor_pct'
kpi_value DECIMAL(12,2),
email_sent BOOLEAN DEFAULT false,
email_sent_at TIMESTAMPTZ,
acknowledged BOOLEAN DEFAULT false,
acknowledged_by VARCHAR(128),
acknowledged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_alerts_severity ON meta_alerts(severity);
CREATE INDEX idx_alerts_created ON meta_alerts(created_at);
CREATE INDEX idx_alerts_category ON meta_alerts(category);
-- ── Ingestion Job Log ──
CREATE TABLE meta_ingestion_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source VARCHAR(32) NOT NULL, -- 'qbo', 'plaid', 'spoton'
job_type VARCHAR(32) NOT NULL, -- 'full_sync', 'incremental', 'webhook', 'reconciliation'
status VARCHAR(16) NOT NULL CHECK (status IN (
'queued', 'running', 'completed', 'failed', 'retrying'
)),
records_fetched INTEGER DEFAULT 0,
records_ingested INTEGER DEFAULT 0,
records_failed INTEGER DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_detail TEXT,
retry_count INTEGER DEFAULT 0,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_ingestion_source ON meta_ingestion_jobs(source, created_at DESC);
-- ── Rate Limit Tracking ──
CREATE TABLE meta_rate_limit_log (
id BIGSERIAL,
source VARCHAR(32) NOT NULL,
endpoint VARCHAR(256),
status_code INTEGER,
retry_after_sec INTEGER,
occurred_at TIMESTAMPTZ DEFAULT now()
);
```
---
## 3. Data Ingestion Pipeline
### 3.1 Pipeline Architecture
```
EXT SOURCE ──► ADAPTER ──► RATE LIMITER ──► QUEUE ──► WORKER ──► RAW TABLE ──► TRANSFORM ──► NORMALIZED ──► AGGREGATION
ALERT EVALUATOR
```
### 3.2 QuickBooks Online Ingestion
#### 3.2.1 OAuth Token Lifecycle
```
┌──────────────────────────────────────────────────────────┐
│ TOKEN MANAGER (runs on loop every 50 minutes) │
│ │
│ 1. Check meta_qbo_credentials.token_expires_at │
│ 2. If expires within 10 min → refresh now │
│ 3. POST to /oauth2/v1/tokens/bearer with refresh_token │
│ 4. Store NEW access_token + refresh_token immediately │
│ 5. Old refresh_token is INVALIDATED after use │
│ 6. If refresh fails → queue SECURITY alert │
│ │
│ CRITICAL: Refresh token expires after 100 days total. │
│ If expired → user must re-authorize via OAuth flow. │
│ Track refresh_expires_at and alert at 90-day mark. │
└──────────────────────────────────────────────────────────┘
```
#### 3.2.2 Sync Strategy
| Sync Type | Frequency | Method | Details |
|-----------|-----------|--------|---------|
| **CDC Poll** | Every 30 min | `GET /cdc?entities=...&changedSince=` | Uses meta_qbo_cdc_cursors for timestamp tracking. Fetches up to 30 days back. |
| **Webhook** | Real-time | POST to `/api/webhooks/qbo` | Validates HMAC signature, enqueues entity fetch job |
| **Full Sync** | Daily (2am) | CDC with 48h window + P&L report fetch | Reconciles P&L, pulls full Cost data |
| **Periodic** | Weekly (Sun 3am) | Full query on all entities | Data integrity check, catches CDC gaps |
#### 3.2.3 Webhook Handler Flow
```
POST /api/webhooks/qbo
├─ 1. Verify HMAC-SHA256 signature (prevent forgery)
├─ 2. Parse eventNotifications[]
├─ 3. For each entity:
│ └─ Enqueue BullMQ job: { type: 'qbo:fetch_entity', entity, id, operation }
└─ 4. Return 200 immediately (don't process inline)
```
#### 3.2.4 Rate Limiter
```
const QBO_RATE_LIMIT = {
maxPerMinute: 450, // 10% headroom below 500
maxConcurrent: 35, // 5 below 40
};
// Token bucket algorithm in Redis
// Key: rate_limit:qbo:{realm_id}:{minute_bucket}
// TTL: 90 seconds
async function checkQboRateLimit(realmId) {
const key = `rate_limit:qbo:${realmId}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 90);
if (count > QBO_RATE_LIMIT.maxPerMinute) throw new RateLimitError('QBO rate limit approaching');
}
```
### 3.3 Plaid Ingestion
#### 3.3.1 Initial Sync
```
1. Call /transactions/sync with no cursor (first sync)
2. Paginate: while has_more === true, keep calling with next_cursor
3. For each page:
- added[] → INSERT INTO raw_plaid_transactions (sync_type='initial')
- modified[] → INSERT INTO raw_plaid_transactions (sync_type='modified')
- removed[] → INSERT INTO raw_plaid_transactions (sync_type='removed')
4. Save next_cursor to meta_plaid_cursors
5. Trigger normalized_bank_transactions rebuild for this item
```
#### 3.3.2 Incremental Sync
```
Scheduled every 15 minutes OR triggered by SYNC_UPDATES_AVAILABLE webhook:
1. Load cursor from meta_plaid_cursors
2. Call /transactions/sync with cursor
3. Handle pagination with cursor preservation:
- Temporarily preserve old cursor
- If TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION error:
→ restart from preserved cursor
4. Process added/modified/removed
5. Save new next_cursor
6. If has_more === true → schedule next page immediately (max 10 pages)
```
#### 3.3.3 Webhook Receiver
```
POST /api/webhooks/plaid
├─ 1. Verify webhook signature
├─ 2. Parse webhook_code:
│ - SYNC_UPDATES_AVAILABLE → enqueue incremental sync
│ - ITEM_LOGIN_REQUIRED → create SECURITY alert, flag item
│ - INITIAL_UPDATE → enqueue full sync
│ - HISTORICAL_UPDATE → enqueue full historical pull
└─ 3. Return 200
```
### 3.4 SpotOn Ingestion
#### 3.4.1 Orders Polling
```
Dual-strategy approach to deal with SpotOn's data availability window:
┌──────────────────────────────────────────────────────────────────┐
│ STRATEGY A: Real-time (5-min polls during business hours) │
│ │
│ Schedule: Mon-Sat, 8:00 AM - 11:00 PM (local) │
│ Endpoint: GET /orders?updatedSince={lastPoll}&site={location} │
│ Window: Poll frequency (5 min) │
│ │
│ STRATEGY B: Deep Sync (daily at 3:00 AM) │
│ │
│ Schedule: Every day at 3:00 AM │
│ Endpoint: GET /orders?updatedSince={T-26h}&site={location} │
│ Window: 26 hours (SpotOn's max) │
│ Purpose: Catch missed orders, data integrity check │
└──────────────────────────────────────────────────────────────────┘
Business hours detection:
- Read business hours from meta_locations.metadata
- Fallback: Mon-Sat 8AM-11PM, Sun 9AM-9PM
- CRON expression generated per location
```
#### 3.4.2 Labor Reports Polling
```
Schedule: Daily at 6:00 AM (after overnight batch processing completes)
Endpoint: GET /labor-reports?date={yesterday}&site={location}
Wait: SpotOn labor reports are ETL-lagged → add 1-2h buffer
Retry: If 404/empty, retry every 30 min for up to 4 hours
```
### 3.5 BullMQ Queue Architecture
```
Queue: qbo-sync
├─ Type: qbo:cdc_poll │ Priority: 20 │ Retry: 3
├─ Type: qbo:fetch_entity │ Priority: 10 │ Retry: 2
├─ Type: qbo:refresh_token │ Priority: 1 │ Retry: 3 (+ alert on fail)
└─ Type: qbo:pnl_report │ Priority: 30 │ Retry: 2
Queue: plaid-sync
├─ Type: plaid:incremental │ Priority: 20 │ Retry: 3
├─ Type: plaid:full_sync │ Priority: 30 │ Retry: 2
└─ Type: plaid:refresh_link │ Priority: 5 │ Retry: 3
Queue: spoton-sync
├─ Type: spoton:orders_realtime │ Priority: 20 │ Retry: 2
├─ Type: spoton:orders_deep │ Priority: 30 │ Retry: 2
└─ Type: spoton:labor_reports │ Priority: 30 │ Retry: 4 (30-min intervals)
Queue: reconciliation
├─ Type: recon:daily │ Priority: 40 │ Runs: 7AM daily
└─ Type: recon:monthly │ Priority: 50 │ Runs: 1st of month
```
### 3.6 Error Recovery Strategy
```
┌────────────────────────────────────────────────────────────┐
│ ERROR RECOVERY LAYERS │
│ │
│ L1: Retry (Transient Failures) │
│ - 429 rate limit → exponential backoff + jitter │
│ - 5xx server errors → retry (max 3, backoff ^2) │
│ - Network timeout → retry (max 3) │
│ - Default: 2^n * 1000ms + rand(0, 1000)ms │
│ │
│ L2: Dead Letter Queue (Persistent Failures) │
│ - After max retries exhausted │
│ - Job moved to {queue}:dead-letter │
│ - Triggers ERROR alert to email │
│ - Manual review required │
│ │
│ L3: Circut Breaker (Cascading Failures) │
│ - If >10 consecutive failures on same source: │
│ → Open circuit breaker for 5 minutes │
│ → Queue CRITICAL alert │
│ → After 5 min, half-open: try 1 request │
│ → If success, close; if fail, reopen for 10 min │
│ │
│ L4: Backfill (Data Gaps) │
│ - Daily integrity check compares record counts │
│ - ETL Gap Detection: │
│ SELECT location_id, sale_date FROM agg_daily_sales │
│ WHERE sale_date >= CURRENT_DATE - INTERVAL '7 days' │
│ AND order_count = 0 │
│ AND sale_date != CURRENT_DATE; │
│ - Gaps auto-queue a spoton:orders_deep job │
└────────────────────────────────────────────────────────────┘
```
---
## 4. API Endpoint Design
### 4.1 REST API Structure
Base URL: `https://bi.spicenvybe.com/api/v1`
#### 4.1.1 Authentication & Authorization
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | `/auth/login` | None (rate-limited) | Email + password auth, returns JWT |
| POST | `/auth/refresh` | JWT | Refresh access token |
| POST | `/auth/logout` | JWT | Invalidate token |
| GET | `/auth/me` | JWT | Current user profile |
| POST | `/auth/qbo/connect` | JWT+Admin | Initiate QBO OAuth flow |
| GET | `/auth/qbo/callback` | None | QBO OAuth callback handler |
#### 4.1.2 KPI Endpoints
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/kpi/sales` | JWT | Sales KPIs (today, MTD, vs prev period) |
| GET | `/kpi/sales/:locationSlug` | JWT | Sales per location |
| GET | `/kpi/net-profit` | JWT | Net Profit KPIs |
| GET | `/kpi/net-profit/:locationSlug` | JWT | Net Profit per location |
| GET | `/kpi/costs` | JWT | Cost breakdown KPIs |
| GET | `/kpi/costs/:locationSlug` | JWT | Costs per location |
| GET | `/kpi/labor` | JWT | Labor KPIs + % of sales |
| GET | `/kpi/labor/:locationSlug` | JWT | Labor per location |
| GET | `/kpi/comparison` | JWT | Side-by-side location comparison |
| GET | `/kpi/summary` | JWT | All KPIs in single response (dashboard load) |
#### 4.1.3 Data Endpoints
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/data/sales/timeseries` | JWT | Sales over time (query params: from, to, granularity, location) |
| GET | `/data/sales/by-category` | JWT | Sales breakdown by item category |
| GET | `/data/sales/by-payment` | JWT | Sales by payment method |
| GET | `/data/costs/breakdown` | JWT | Cost breakdown by category |
| GET | `/data/costs/trend` | JWT | Cost trends over time |
| GET | `/data/reconciliation` | JWT | SpotOn vs Plaid deposit reconciliation |
| GET | `/data/reconciliation/:locationSlug` | JWT | Per-location reconciliation |
| GET | `/data/labor/details` | JWT | Labor cost detail (hours, wages, headcount) |
| GET | `/data/transactions` | JWT | Raw transaction search (Plaid) |
| GET | `/data/orders` | JWT | Raw order search (SpotOn) |
#### 4.1.4 Alert Endpoints
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/alerts` | JWT | Recent alerts (paginated) |
| GET | `/alerts/active` | JWT | Unacknowledged alerts only |
| PATCH | `/alerts/:id/acknowledge` | JWT | Acknowledge an alert |
| GET | `/alerts/stats` | JWT | Alert statistics (by severity, by source) |
| PUT | `/alerts/config` | JWT+Admin | Alert threshold configuration |
| POST | `/alerts/test` | JWT+Admin | Send test alert email |
#### 4.1.5 Admin / System Endpoints
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/admin/health` | JWT+Admin | System health status |
| GET | `/admin/ingestion/status` | JWT+Admin | All ingestion job statuses |
| GET | `/admin/ingestion/:source` | JWT+Admin | Ingestion details per source |
| POST | `/admin/ingestion/trigger` | JWT+Admin | Manually trigger a sync job |
| GET | `/admin/rate-limits` | JWT+Admin | Rate limit utilization |
| GET | `/admin/token-status` | JWT+Admin | QBO token expiry info |
#### 4.1.6 Webhook Endpoints (no JWT, payload-signed)
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | `/webhooks/qbo` | HMAC-SHA256 | QBO data change notifications |
| POST | `/webhooks/plaid` | Plaid webhook secret | Plaid transaction updates |
### 4.2 Key Response Shapes
#### `GET /kpi/summary` — Dashboard Initial Load
```json
{
"period": {
"current": { "start": "2026-06-01", "end": "2026-06-30" },
"previous": { "start": "2026-05-01", "end": "2026-05-31" }
},
"overall": {
"sales": {
"current": 284750.00,
"previous": 261200.00,
"change_pct": 9.02,
"trend": "up"
},
"net_profit": {
"current": 71200.00,
"previous": 58700.00,
"change_pct": 21.29,
"trend": "up"
},
"costs": {
"current": 213550.00,
"previous": 202500.00,
"change_pct": 5.46,
"trend": "up"
},
"labor_pct": {
"current": 24.8,
"previous": 27.1,
"change_pct": -8.49,
"trend": "down",
"target": 25.0
}
},
"locations": [
{
"slug": "oakland-park",
"name": "Oakland Park Flagship",
"sales": { "current": 182400.00, "change_pct": 8.5 },
"net_profit": { "current": 46800.00, "change_pct": 19.2 },
"costs": { "current": 135600.00, "change_pct": 5.1 },
"labor_pct": { "current": 23.9, "target": 25.0 }
},
{
"slug": "ft-lauderdale",
"name": "Fort Lauderdale Cloud Kitchen",
"sales": { "current": 102350.00, "change_pct": 10.1 },
"net_profit": { "current": 24400.00, "change_pct": 25.8 },
"costs": { "current": 77950.00, "change_pct": 6.2 },
"labor_pct": { "current": 26.2, "target": 25.0 }
}
],
"reconciliation_status": "matched",
"last_updated": "2026-07-01T06:15:00Z"
}
```
#### `GET /kpi/labor/:locationSlug` — Labor Detail
```json
{
"location": "oakland-park",
"period": {
"current": { "start": "2026-06-01", "end": "2026-06-30" },
"previous": { "start": "2026-05-01", "end": "2026-05-31" }
},
"metrics": {
"labor_cost": { "current": 67100.00, "previous": 70800.00 },
"labor_pct": { "current": 23.9, "previous": 27.1 },
"total_hours": { "current": 3124.5, "previous": 3340.0 },
"avg_hourly_rate": { "current": 18.45, "previous": 18.27 },
"employee_count": { "current": 18, "previous": 20 },
"total_revenue": { "current": 280500.00, "previous": 261200.00 }
},
"target": 25.0,
"status": "on_target",
"daily_breakdown": [
{ "date": "2026-06-01", "labor_cost": 2140.00, "labor_pct": 24.1, "status": "on_target" },
{ "date": "2026-06-02", "labor_cost": 2280.00, "labor_pct": 26.3, "status": "over_target" }
]
}
```
---
## 5. Frontend Component Architecture
### 5.1 Next.js Application Structure
```
src/
├── app/ # Next.js 14 App Router
│ ├── layout.tsx # Root layout (auth boundary)
│ ├── page.tsx # Dashboard home (redirect to /dashboard)
│ ├── login/
│ │ └── page.tsx # Login page
│ ├── dashboard/
│ │ ├── layout.tsx # Dashboard shell (sidebar, header)
│ │ └── page.tsx # Main dashboard
│ ├── alerts/
│ │ └── page.tsx # Alert center
│ ├── settings/
│ │ └── page.tsx # Settings, connections
│ └── api/ # API routes (BFF pattern)
│ └── [...route]/route.ts # Proxy to Express backend
├── components/
│ ├── dashboard/
│ │ ├── KPIGrid.tsx # KPI card grid layout
│ │ ├── KPIWidget.tsx # Single KPI card
│ │ ├── LocationTabs.tsx # Location switcher tabs
│ │ ├── LocationComparison.tsx# Side-by-side comparison view
│ │ ├── SalesChart.tsx # Sales timeseries (Recharts)
│ │ ├── ProfitChart.tsx # Profit waterfall
│ │ ├── CostBreakdownChart.tsx# Cost donut/bar chart
│ │ ├── LaborGauge.tsx # Labor % gauge with target
│ │ ├── ReconciliationCard.tsx# Plaid vs SpotOn match status
│ │ └── DataFreshnessBanner.tsx# "Last updated X ago"
│ │
│ ├── alerts/
│ │ ├── AlertPanel.tsx # Alert sidebar/drawer
│ │ ├── AlertBadge.tsx # Unacknowledged count badge
│ │ └── AlertRow.tsx # Single alert in list
│ │
│ ├── charts/
│ │ ├── AreaChart.tsx # Reusable area chart
│ │ ├── BarChart.tsx # Reusable bar chart
│ │ ├── DonutChart.tsx # Reusable donut chart
│ │ ├── GaugeChart.tsx # Target gauge
│ │ └── ComparisonBar.tsx # Two-location comparison bar
│ │
│ ├── layout/
│ │ ├── Sidebar.tsx # Navigation sidebar
│ │ ├── Header.tsx # Top header bar
│ │ └── Footer.tsx
│ │
│ └── shared/
│ ├── LoadingSpinner.tsx
│ ├── ErrorBoundary.tsx
│ ├── EmptyState.tsx
│ ├── DataCard.tsx # Reusable metric card
│ └── PeriodSelector.tsx # Date range picker
├── hooks/
│ ├── useKPI.ts # KPI data fetching (SWR)
│ ├── useAlerts.ts # Alert polling
│ ├── useLocation.ts # Current location context
│ └── useAuth.ts # Auth state management
├── lib/
│ ├── api-client.ts # Axios/fetch wrapper
│ ├── formatters.ts # Currency, percent, date formatters
│ └── constants.ts # API URLs, thresholds
└── types/
├── kpi.ts # KPI type definitions
├── alerts.ts # Alert types
└── api.ts # API response types
```
### 5.2 Component Tree (Dashboard Page)
```
<DashboardLayout>
├── <Header>
│ ├── DataFreshnessBanner
│ └── AlertBadge (unacked count, links to alerts page)
├── <Sidebar>
│ ├── Nav: Dashboard, Alerts, Settings
│ └── Location filter controls
└── <main>
├── <PeriodSelector /> (7d, 30d, MTD, QTD, custom)
├── <LocationTabs>
│ ├── Tab: "Combined"
│ ├── Tab: "Oakland Park"
│ └── Tab: "Fort Lauderdale"
├── <KPIGrid>
│ ├── <KPIWidget metric="sales" value="$284,750" change="+9.0%" trend="up" />
│ ├── <KPIWidget metric="net_profit" value="$71,200" change="+21.3%" trend="up" />
│ ├── <KPIWidget metric="costs" value="$213,550" change="+5.5%" trend="up" />
│ └── <KPIWidget metric="labor_pct" value="24.8%" change="-8.5%" trend="down" />
├── <ReconciliationCard /> (SpotOn vs Plaid deposit match)
├── <div class="grid grid-cols-2">
│ ├── <SalesChart /> (Revenue timeseries)
│ └── <ProfitChart /> (Profit = Revenue - Costs stacked)
├── <div class="grid grid-cols-2">
│ ├── <CostBreakdownChart /> (COGS, Labor, Rent, Ops)
│ └── <LaborGauge /> (24.8% vs 25% target)
└── <LocationComparison /> (Oakland Park vs FTL table + bars)
</DashboardLayout>
```
### 5.3 Data Fetching Strategy
```
Primary Pattern: SWR (stale-while-revalidate)
- Dashboard fetches GET /kpi/summary on load (cache: 60s)
- Individual widgets can hydrate from summary OR fetch detailed endpoints
- Revalidation interval: 120 seconds (polls for fresh data)
- On focus revalidation: enabled (when user returns to tab)
Cache Strategy:
- API responses cached in Redis for 60s (backend)
- SWR client cache: 120s with stale-while-revalidate
- Manual refresh button available for instant re-fetch
Optimistic Loading:
- Show last-known data immediately (SWR cache)
- Show skeleton/Glimmer while first load is in progress
- Pulse animation on widgets that are being refreshed
- Error state: show stale data + "Last updated: X ago — refresh failed" banner
```
### 5.4 State Management
```
No Redux needed for this scope. Use:
1. SWR for server state (all API data)
2. React Context for:
- AuthProvider (user, token, permissions)
- LocationProvider (selected location filter)
- PeriodProvider (selected date range)
3. Local state for UI interactions (expanded sections, modal open/close)
```
---
## 6. Alerting System
### 6.1 Alert Severity Levels
| Level | Color | Trigger | Action |
|-------|-------|---------|--------|
| **CRITICAL** | 🔴 Red | System down, data loss, auth failure | Email + SMS (future) + persistent notification |
| **FAILURE** | 🟠 Orange | Ingestion job failed after retries, webhook delivery failure | Email + in-app |
| **ERROR** | 🟡 Yellow | Partial failure, single entity fetch failed, rate limit hit | Email (digest) + in-app |
| **SECURITY** | 🔴 Red | Token refresh failed, unauthorized access detected, credential expiry < 7 days | Email + in-app |
| **WARNING** | 🔵 Blue | KPI threshold breached, labor % > target, reconciliation variance > 5% | Email (digest) + in-app |
| **INFO** | ⚪ Gray | Successful sync, token refreshed, connection restored | In-app only |
### 6.2 Alert Rules Engine
```
╔══════════════════════════════════════════════════════════════════════╗
║ ALERT RULES ║
╠══════════════════════════════════════════════════════════════════════╣
║ ║
║ ┌─ SYSTEM HEALTH ──────────────────────────────────────────────┐ ║
║ │ QBO: 5+ consecutive auth failures → CRITICAL │ ║
║ │ Plaid: ITEM_LOGIN_REQUIRED webhook received → ERROR │ ║
║ │ SpotOn: API tunnel down for >15 min → CRITICAL │ ║
║ │ BullMQ queue backlog >1000 jobs → ERROR │ ║
║ │ Database connection failure → CRITICAL │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌─ INGESTION ─────────────────────────────────────────────────┐ ║
║ │ CDC sync returns 0 records for 3+ consecutive polls (business│ ║
║ │ hours) → WARNING │ ║
║ │ Plaid sync fails after all retries → FAILURE │ ║
║ │ Webhook processing fails → ERROR │ ║
║ │ Rate limit exceeded → ERROR │ ║
║ │ SpotOn orders deep sync gap detected → ERROR │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌─ RECONCILIATION ────────────────────────────────────────────┐ ║
║ │ SpotOn sales vs Plaid deposits vary >10% → WARNING │ ║
║ │ Net Profit mismatch with QBO P&L >5% → WARNING │ ║
║ │ Manual export data differs from API data → ERROR │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌─ KPI THRESHOLDS ────────────────────────────────────────────┐ ║
║ │ Labor cost % consistently above 27% (7d avg) → WARNING │ ║
║ │ Labor cost % above 30% (any single day) → ERROR │ ║
║ │ Cost growth exceeds revenue growth by 5%+ → WARNING │ ║
║ │ Daily net profit decline >20% WoW → WARNING │ ║
║ │ Sales decline >15% vs same day last week → WARNING │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌─ SECURITY ─────────────────────────────────────────────────┐ ║
║ │ QBO refresh token expires in <7 days → SECURITY │ ║
║ │ QBO refresh token expired → CRITICAL │ ║
║ │ Plaid Item requires user re-authentication → SECURITY │ ║
║ │ JWT token validation failure spike → SECURITY │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
╚══════════════════════════════════════════════════════════════════════╝
```
### 6.3 Email Alert Format
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🔴 [CRITICAL] Spice N'Vybe BI — Ingestion Failure │
│ │
│ Source: QuickBooks Online │
│ Time: 2026-07-01 08:30:00 EDT │
│ Job ID: j-abc12345 │
│ │
│ Detail: CDC sync failed after 3 retries. │
│ Last successful sync: 2026-06-30 22:00 EDT │
│ Error: HTTP 401 — token may be expired │
│ │
│ Action: Immediate attention required. │
│ → Check QBO token status in admin panel │
│ → Re-authorize via OAuth if refresh token expired │
│ → Dashboard data is stale since 2026-06-30 22:00 │
│ │
│ Dashboard: https://bi.spicenvybe.com/admin/ingestion │
│ │
│ This alert auto-resolves when the next successful sync completes. │
│ To silence, acknowledge in the dashboard. │
└─────────────────────────────────────────────────────────────────────┘
```
### 6.4 Alert Delivery Pipeline
```
┌──────────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Alert Triggered │────►│ Deduplication │────►│ Rate Limiter │
│ (code location) │ │ (same alert │ │ (max 5/min per │
│ │ │ within 1h) │ │ alert type) │
└──────────────────┘ └────────────────┘ └─────────────────┘
┌──────────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Email Sent │◄────│ SMTP Sender │◄────│ Queue │
│ (via Nodemailer) │ │ (Gmail App │ │ BullMQ │
│ │ │ Password) │ │ priority queue │
└──────────────────┘ └────────────────┘ └─────────────────┘
```
### 6.5 Email Implementation
```javascript
// nodemailer configuration
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.ALERT_EMAIL_USER,
pass: process.env.ALERT_EMAIL_PASS // Gmail App Password
}
});
// Dual recipient list
const ALERT_RECIPIENTS = {
CRITICAL: ['owner@spicenvybe.com', 'manager+urgent@spicenvybe.com'],
FAILURE: ['owner@spicenvybe.com', 'ops@spicenvybe.com'],
ERROR: ['ops@spicenvybe.com'],
SECURITY: ['owner@spicenvybe.com', 'it@spicenvybe.com'],
WARNING: [] // Dashboard-only, can enable later
};
```
---
## 7. Security Model
### 7.1 Authentication Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ AUTH FLOW │
│ │
│ 1. User navigates to login.bi.spicenvybe.com │
│ 2. Enters credentials → POST /api/v1/auth/login │
│ 3. Express validates against PostgreSQL users table (bcrypt hash) │
│ 4. JWT issued: { sub, role, exp, iat } │
│ - Access token: 2h expiry (short-lived) │
│ - Refresh token: 14d expiry (secure cookie, httpOnly) │
│ 5. All subsequent requests pass JWT in Authorization: Bearer header│
│ │
│ Auth Provider Decision: Google OAuth or Authentik (TBD) │
│ For now: email+password with bcrypt. Ready for OIDC migration. │
│ When OIDC is implemented, Authentik handles the IdP and Passport │
│ authenticates the JWT from Authentik's callback. │
└──────────────────────────────────────────────────────────────────────┘
```
### 7.2 Role-Based Access Control
| Role | Permissions | Description |
|------|-------------|-------------|
| `admin` | Full system access, triggers sync, acknowledges alerts | System administrator |
| `manager` | View all KPIs, acknowledge alerts, view admin status | Operations manager |
| `viewer` | View KPIs and reports only | Read-only access |
| `system` | Internal service accounts, webhook receivers | Machine-to-machine |
### 7.3 External API Credential Management
```
┌──────────────────────────────────────────────────────────────────────┐
│ CREDENTIAL STORAGE │
│ │
│ QBO: │
│ └─ Encrypted at rest in meta_qbo_credentials table │
│ └─ Encryption: AES-256-GCM with key from environment variable │
│ └─ Rotation: Access token auto-refreshed every 50 min │
│ └─ Refresh token: rotated on every use (OAuth 2.0 spec) │
│ └─ Token expiry alert: SECURITY at 7 days before refresh expiry │
│ │
│ Plaid: │
│ └─ client_id + secret in environment variables (server-side only) │
│ └─ access_token encrypted in meta_plaid_cursors table │
│ └─ Plaid Link token generated server-side, 1-time use │
│ └─ ITEM_LOGIN_REQUIRED triggers SECURITY alert for re-auth │
│ │
│ SpotOn: │
│ └─ API key per location, encrypted in meta_locations table │
│ └─ Keys stored with "Location" header in API calls │
└──────────────────────────────────────────────────────────────────────┘
```
### 7.4 API Security Measures
```
═══ LAYER 1: TRANSPORT ═══
• HTTPS only (TLS 1.2+), no HTTP in production
• HSTS headers enabled
• Self-signed certs for homelab → Let's Encrypt for production
═══ LAYER 2: REQUEST ═══
• Rate limiting per IP: 100 req/min (global), 10 req/min (auth endpoints)
• JWT validation on every authenticated endpoint
• CORS: restricted to dashboard origin only
• Request body size limit: 1MB
• Helmet.js security headers (XSS, clickjack, MIME sniffing)
═══ LAYER 3: EXTERNAL ═══
• QBO webhook: HMAC-SHA256 signature verification
• Plaid webhook: Plaid-Verification header + signing key verification
• SpotOn: x-api-key per location
• No external credentials in client-side code (ever)
• All API keys retrieved from encrypted DB storage at request time
═══ LAYER 4: INFRASTRUCTURE ═══
• Database: PostgreSQL user with SCHEMA-level permissions
• App DB user: SELECT/INSERT/UPDATE on app tables only, no DDL
• Redis: password-protected (requirepass)
• Backend runs as non-root user in container
• All secrets via environment variables, never in code
```
### 7.5 QBO OAuth 2.0 Flow (Detailed)
```
┌─────────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Dashboard Admin UI │ │ Express Backend │ │ Intuit OAuth│
└─────────┬───────────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │
│ POST /auth/qbo/connect │ │
│ (admin JWT) │ │
├─────────────────────────────►│ │
│ │ Generate OAuth URL: │
│ │ client_id, redirect_uri, │
│ │ scope, response_type=code│
│ │ state (random, stored) │
│ Redirect user to Intuit │ │
│◄─────────────────────────────┤ │
│ │ │
│ User authorizes at Intuit │ │
├─────────────────────────────────────────────────────────►│
│ │ │
│ Intuit redirects to: │ │
│ /auth/qbo/callback?code=X │ │
│ &state=Y&realmId=Z │ │
├─────────────────────────────►│ │
│ │ Validate state │
│ │ Exchange code for tokens │
│ ├──────────────────────────►│
│ │◄──────────────────────────┤
│ │ { access_token, │
│ │ refresh_token, │
│ │ expires_in, x_refresh_ │
│ │ token_expires_in } │
│ │ │
│ │ Encrypt & store in │
│ │ meta_qbo_credentials │
│ │ │
│ │ Init cursor entries in │
│ │ meta_qbo_cdc_cursors │
│ │ │
│ "QBO connected successfully" │ │
│◄─────────────────────────────┤ │
```
---
## 8. Deployment Architecture (LXC)
### 8.1 Container Topology
```
┌─────────────────────────────────────────────────────────────────────┐
│ Proxmox Host (spicenvybe-dev) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ LXC Container │ │ LXC Container │ │
│ │ spicenvybe-db │ │ spicenvybe-app │ │
│ │ (PostgreSQL + │ │ (Node.js + │ │
│ │ Redis) │ │ Next.js + │ │
│ │ │ │ nginx) │ │
│ │ RAM: 2GB │ │ │ │
│ │ CPU: 2 cores │ │ RAM: 4GB │ │
│ │ Disk: 20GB │ │ CPU: 4 cores │ │
│ └─────────────────┘ │ Disk: 30GB │ │
│ └─────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Mount: /mnt/spicenvybe-backups -> Proxmox host ZFS pool │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 8.2 Service Stack (within spicenvybe-app LXC)
```
┌─────────────────────────────────────────────────────┐
│ nginx (reverse proxy on host port 443 → local) │
│ ├─ /api/* → Express (port 3001) │
│ ├─ / → Next.js (port 3000) │
│ └─ /ws → WebSocket for live updates │
│ │
│ pm2 (process manager) │
│ ├─ next-app │ port 3000 │ Next.js server │
│ ├─ express-api │ port 3001 │ Express backend │
│ ├─ ingestion-worker │ — │ BullMQ worker │
│ ├─ scheduler │ — │ node-cron tasks │
│ └─ alert-worker │ — │ Alert evaluator │
│ │
│ Redis (port 6379, password-protected) │
│ ├─ Queue: qbo-sync │
│ ├─ Queue: plaid-sync │
│ ├─ Queue: spoton-sync │
│ ├─ Queue: reconciliation │
│ └─ Cache: API responses (60s TTL) │
│ │
│ PostgreSQL (port 5432, app DB user) │
│ ├─ spicenvybe_bi database │
│ └─ Tables: raw_*, normalized_*, agg_*, meta_* │
└─────────────────────────────────────────────────────┘
```
### 8.3 SystemD Integration
```ini
# /etc/systemd/system/spicenvybe-bi.target
[Unit]
Description=Spice N'Vybe BI Dashboard
Requires=spicenvybe-db.service
After=network.target
Wants=spicenvybe-next.service spicenvybe-api.service spicenvybe-worker.service spicenvybe-scheduler.service
# Individual services start via pm2, managed by:
[Unit]
Description=PM2 for Spice N'Vybe BI
[Service]
Type=forking
User=spicenvybe
ExecStart=/usr/bin/pm2 start /opt/spicenvybe-bi/ecosystem.config.js
ExecReload=/usr/bin/pm2 reload all
ExecStop=/usr/bin/pm2 kill
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
### 8.4 Environment Configuration
```bash
# /opt/spicenvybe-bi/.env (example, actual values in vault)
# Homelab config — will differ for production
# Database
DATABASE_URL="postgresql://spicenvybe:${DB_PASS}@localhost:5432/spicenvybe_bi"
REDIS_URL="redis://:${REDIS_PASS}@localhost:6379"
# Encryption (used for stored credentials)
CREDENTIAL_ENCRYPTION_KEY="<aes-256-gcm-key-32-bytes-base64>"
# JWT
JWT_SECRET="<random-64-char>"
JWT_ACCESS_EXPIRY="2h"
JWT_REFRESH_EXPIRY="14d"
# QuickBooks Online
QBO_CLIENT_ID="<from-intuit-developer-portal>"
QBO_CLIENT_SECRET="<from-intuit-developer-portal>"
QBO_REDIRECT_URI="https://bi.spicenvybe.com/api/v1/auth/qbo/callback"
QBO_WEBHOOK_VERIFIER="<from-intuit-webhooks>"
QBO_MINOR_VERSION="75"
# Plaid
PLAID_CLIENT_ID="<from-plaid-dashboard>"
PLAID_SECRET="<from-plaid-dashboard>"
PLAID_ENVIRONMENT="sandbox" # Change to 'production' for prod
PLAID_WEBHOOK_SECRET="<from-plaid-webhooks>"
# Email (Gmail App Password)
ALERT_EMAIL_USER="alerts@spicenvybe.com"
ALERT_EMAIL_PASS="<gmail-app-password>"
ALERT_EMAIL_FROM="Spice N'Vybe BI <alerts@spicenvybe.com>"
# Frontend
NEXT_PUBLIC_API_URL="https://bi.spicenvybe.com/api/v1"
```
### 8.5 Backup Strategy
```
Schedule: Daily at 12:00 AM
Target: PostgreSQL dump + Redis RDB snapshot
Retention: 7 daily + 4 weekly + 3 monthly
pg_dump -U spicenvybe -Fc spicenvybe_bi > /mnt/spicenvybe-backups/bi-$(date +%Y%m%d).dump
redis-cli SAVE # Creates dump.rdb
cp /var/lib/redis/dump.rdb /mnt/spicenvybe-backups/redis-$(date +%Y%m%d).rdb
Cleanup: Keep 7 days locally. Optional S3 sync for compliance.
```
---
## 9. AWS Migration & Scaling Strategy
### 9.1 Target AWS Architecture
```
┌──────────────────────────────────────────────────────────────────────────┐
│ AWS Cloud │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Route 53 │ │
│ │ bi.spicenvybe.com │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────▼───────────────────────┐ │
│ │ CloudFront CDN │ │
│ │ (Static assets, Next.js SSR cache) │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────▼───────────────────────┐ ┌─────────────────────────┐ │
│ │ Application Load Balancer │ │ WAF (Web ACL) │ │
│ │ (HTTPS termination) │ │ Rate limiting, SQLi, │ │
│ └──────────────┬───────────────────────┘ │ XSS protection │ │
│ │ └─────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ ECS Fargate Cluster (app) │ │ ElastiCache Redis │ │
│ │ │ │ (Clustered, multi-AZ) │ │
│ │ Service: next-app (x2) │ └──────────────────────────────────┘ │
│ │ Service: express-api (x2) │ │
│ │ Service: ingestion-queue │ ┌──────────────────────────────────┐ │
│ │ Service: scheduler │ │ RDS PostgreSQL │ │
│ │ │ │ (db.t3.medium → db.r6g.large) │ │
│ │ Auto-scaling: │ │ Multi-AZ standby │ │
│ │ CPU > 70% → +1 container │ └──────────────────────────────────┘ │
│ │ Scale max: 4 per service │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ S3 (static assets) │ │ Secrets Manager │ │
│ └──────────────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
### 9.2 Migration Phasing
```
PHASE 1 — HOMELAB (Now)
├── Complete system running in Proxmox LXC
├── PostgreSQL + Redis on same container
├── nginx self-signed certs
├── Daily backups to host ZFS pool
└── Monitoring: pm2 + systemd
PHASE 2 — AWS LIFT-AND-SHIFT (Month 1)
├── Deploy to single EC2 t3.medium (seed data)
├── RDS PostgreSQL (db.t3.small, Multi-AZ)
├── ElastiCache Redis (1 node, test)
├── Route 53 DNS cutover
├── CloudFront for static assets
└── ACM cert (Let's Encrypt or AWS)
PHASE 3 — CONTAINERIZATION (Month 2)
├── Dockerize all services
├── ECS Fargate deployment
├── ALB + WAF in front
├── Auto-scaling policies
└── Blue-green deployments
PHASE 4 — OPTIMIZATION (Month 3+)
├── RDS read replica for reporting queries
├── CloudFront for Next.js ISR caching
├── Redis cluster for queue resilience
├── S3 → Athena for long-term analytics
└── CloudWatch alarms + PagerDuty
```
### 9.3 Scaling Dimensions
| Dimension | Homelab Limit | AWS Scaling Strategy |
|-----------|---------------|---------------------|
| **Database connections** | 100 (PostgreSQL default) | RDS Proxy + connection pooling |
| **API throughput** | Single Express process | ECS Fargate horizontal scaling |
| **Redis memory** | 1GB host limit | ElastiCache: up to 340GB clustered |
| **Storage** | 30GB LXC disk | RDS storage auto-scaling (up to 64TB) |
| **Backups** | Local ZFS | RDS automated backups + S3 exports |
| **Ingestion workers** | Single worker process | Worker service with concurrency config |
| **Alert delivery** | Nodemailer direct | Amazon SES (send 50K+ emails/day) |
### 9.4 Production Readiness Checklist
```
Before going live with Phase 2:
┌─────────────────────────────────────────────────────────┐
│ ☐ Database migration scripts version-controlled │
│ ☐ Secrets in AWS Secrets Manager (not .env) │
│ ☐ WAF rules enabled (rate limiting, SQLi, XSS) │
│ ☐ RDS automated backups configured (7-day retention) │
│ ☐ Multi-AZ RDS enabled │
│ ☐ Health check endpoints for all services │
│ ☐ Prometheus/Grafana monitoring (or CloudWatch) │
│ ☐ Log aggregation (CloudWatch Logs or Loki) │
│ ☐ Deployment pipeline (GitHub Actions → ECR → ECS) │
│ ☐ Rollback procedure documented and tested │
│ ☐ Incident response runbook for each failure scenario │
│ ☐ Load test executed (k6 or Artillery) │
│ ☐ SSL certificate → auto-renewal configured │
│ ☐ Email deliverability tested (SPF, DKIM, DMARC) │
│ ☐ QBO Production app credentials (separate from sandbox)│
│ ☐ Plaid Production credentials (replace sandbox keys) │
└─────────────────────────────────────────────────────────┘
```
---
## 10. Data Reconciliation & Consistency Strategy
### 10.1 Three-Way Verification Model
```
╔══════════════════════════════════════════════════════════════════════╗
║ RECONCILIATION FRAMEWORK ║
╠══════════════════════════════════════════════════════════════════════╣
║ ║
║ KPI: Sales ║
║ ────────────────────────────────────────────────────────────────── ║
║ PRIMARY: SpotOn Orders (sum of completed order totals) ║
║ VERIFY: Plaid Bank Deposits (revenue-size credits) ║
║ GOLDEN: QBO P&L Income line (monthly anchor) ║
║ ║
║ Reconciliation: ║
║ Daily: SpotOn sales ≈ Plaid deposits (allow ±5% for tips/cash) ║
║ Monthly: SpotOn total vs QBO P&L Income (must match ±1%) ║
║ If variance: log alert, flag for manual review ║
║ ║
║ KPI: Costs ║
║ ────────────────────────────────────────────────────────────────── ║
║ PRIMARY: QBO (COGS from Purchase/Bill, Expenses from Account) ║
║ SUPPLEMENT: Plaid (categorized bank transactions for misc costs) ║
║ LABOR: SpotOn Labor Reports (wages + taxes, by location) ║
║ ║
║ Reconciliation: ║
║ Monthly: QBO Cost totals ≈ Plaid expense categories (allowing ║
║ for non-bank costs like credit card auto-pay) ║
║ Weekly: Labor cost (SpotOn) vs QBO Payroll account activity ║
║ ║
║ KPI: Net Profit ║
║ ────────────────────────────────────────────────────────────────── ║
║ CALCULATED: agg_daily_profit_loss (Sales - Costs) ║
║ VERIFY: QBO P&L Net Income (direct report) ║
║ ║
║ Reconciliation: ║
║ Monthly: Calculated net profit must match QBO P&L within 5% ║
║ If mismatch: flag all three sources for variance analysis ║
╚══════════════════════════════════════════════════════════════════════╝
```
### 10.2 Daily Reconciliation Pipeline
```
Daily at 7:00 AM (after all overnight syncs completed)
STEP 1: Extract SpotOn Daily Sales
SELECT location_id, sale_date, net_sales FROM agg_daily_sales
STEP 2: Extract Plaid Daily Deposits
SELECT location_id, transaction_date,
SUM(ABS(amount)) AS total_deposits
FROM normalized_bank_transactions
WHERE amount < 0 AND is_reconciled = false
GROUP BY location_id, transaction_date
STEP 3: Match & Update
For each location, for each date:
Compare SpotOn net_sales vs Plaid deposits
If within threshold (default 5%) → mark Reconciled
If outside → queue WARNING alert
STEP 4: Update agg_daily_reconciliation materialized view
REFRESH MATERIALIZED VIEW CONCURRENTLY agg_daily_reconciliation;
```
### 10.3 Monthly Reconciliation Pipeline
```
1st of every month at 6:00 AM
STEP 1: Calculate from Aggregations
SELECT location_id, month,
SUM(revenue) AS total_revenue_calc,
SUM(costs) AS total_costs_calc,
SUM(profit) AS net_profit_calc
FROM agg_monthly_comparison
WHERE month = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
GROUP BY location_id, month
STEP 2: Fetch QBO P&L Report
Poll QBO for the completed month's P&L report
Parse: parse ProfitAndLossReport columns:
- Income Total
- COGS Total
- Expenses Total
- Net Income
STEP 3: Compare
Revenue: calc vs QBO income total
Costs: calc vs QBO (COGS + Expenses)
Net Profit: calc vs QBO Net Income
STEP 4: Adjust or Alert
If variance < 1% → mark as fully reconciled
If variance 1-5% → log WARNING, trigger review
If variance > 5% → log ERROR, flag for immediate investigation
```
### 10.4 Handling Known Reconciliation Gaps
```
┌──────────────────────────────────────────────────────────────────────┐
│ GAP: SpotOn includes sales tax collected; QBO excludes tax │
│ FIX: Track tax separately, deduct from SpotOn before comparison │
│ │
│ GAP: Plaid deposits include credit card fees (net deposit) │
│ FIX: Apply estimated fee percentage (2.5-3.5%) when matching │
│ FUTURE: Map exact fees from QBO Payment Processing cost category │
│ │
│ GAP: Cash sales don't appear as Plaid deposits │
│ FIX: Track cash percentage from SpotOn, exclude from reconciliation│
│ FUTURE: Compare cash sales against cash drops recorded in QBO │
│ │
│ GAP: QBO costs include non-cash items (depreciation, accruals) │
│ FIX: Flag these categories in normalized_qbo_costs, exclude from │
│ cash-flow comparison but include in P&L comparison │
│ │
│ GAP: Timing differences (e.g., weekend batches settle Monday) │
│ FIX: Allow ±2 business day window for SpotOn→Plaid reconciliation │
│ Monthly reconciliation resolves all timing gaps definitively │
└──────────────────────────────────────────────────────────────────────┘
```
### 10.5 Data Integrity Checks (Automated)
```sql
-- Check 1: Orphaned raw records without normalized counterpart
SELECT 'SpotOn Orders' AS source, COUNT(*) AS orphaned
FROM raw_spoton_orders r
LEFT JOIN normalized_orders n ON r.spoton_order_id = n.spoton_order_id
WHERE n.id IS NULL AND r.ingested_at < NOW() - INTERVAL '1 hour'
UNION ALL
SELECT 'Plaid Transactions', COUNT(*)
FROM raw_plaid_transactions r
LEFT JOIN normalized_bank_transactions n ON r.plaid_txn_id = n.plaid_txn_id
WHERE n.id IS NULL AND r.ingested_at < NOW() - INTERVAL '1 hour';
-- Check 2: Duplicate detection
SELECT spoton_order_id, location_id, COUNT(*) as dupes
FROM normalized_orders
GROUP BY spoton_order_id, location_id
HAVING COUNT(*) > 1;
-- Check 3: Gap detection (business hours with no data)
SELECT l.name, gs.date_gap AS missing_date
FROM meta_locations l
CROSS JOIN generate_series(
CURRENT_DATE - INTERVAL '7 days', CURRENT_DATE - INTERVAL '1 day', '1 day'::interval
) AS gs(date_gap)
LEFT JOIN agg_daily_sales s ON s.location_id = l.id AND s.sale_date = gs.date_gap
WHERE s.order_count IS NULL OR s.order_count = 0;
-- Check 4: Negative cost amounts (data quality)
SELECT * FROM normalized_qbo_costs
WHERE amount < 0 AND cost_category NOT IN ('refund', 'credit');
-- Check 5: Labor % sanity (should be 10-50% of sales)
SELECT * FROM normalized_labor_costs
WHERE labor_percent < 0 OR labor_percent > 60;
```
---
## Appendix A: Project Directory Structure
```
/opt/spicenvybe-bi/
├── apps/
│ ├── web/ # Next.js frontend
│ │ ├── src/
│ │ │ ├── app/ # App router pages
│ │ │ ├── components/ # React components
│ │ │ ├── hooks/ # Custom hooks
│ │ │ ├── lib/ # Utilities
│ │ │ └── types/ # TypeScript types
│ │ ├── public/ # Static assets
│ │ ├── next.config.js
│ │ ├── tailwind.config.js
│ │ └── package.json
│ │
│ └── api/ # Express backend
│ ├── src/
│ │ ├── routes/ # Express route handlers
│ │ ├── services/ # Business logic
│ │ ├── adapters/ # External API adapters
│ │ │ ├── qbo/ # QBO OAuth + CDC
│ │ │ ├── plaid/ # Plaid sync
│ │ │ └── spoton/ # SpotOn polling
│ │ ├── workers/ # BullMQ job processors
│ │ ├── middleware/ # Auth, rate-limit, error handling
│ │ ├── alerts/ # Alert engine
│ │ └── jobs/ # Cron job definitions
│ │ └── index.ts # Entry point
│ ├── prisma/
│ │ └── schema.prisma # Database schema
│ └── package.json
├── docker/
│ ├── Dockerfile.web
│ ├── Dockerfile.api
│ ├── docker-compose.yml # Local dev
│ └── nginx/
│ ├── nginx.conf
│ └── sites-available/bi.spicenvybe.com
├── scripts/
│ ├── backup.sh # Daily backup
│ ├── restore.sh # Disaster recovery
│ ├── health-check.sh # System health
│ └── seed-data.sql # Initial seed
├── infra/
│ ├── terraform/ # AWS IaC (Phase 2+)
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ansible/ # LXC provisioning
│ ├── playbook.yml
│ └── roles/
├── .env.example
├── ecosystem.config.js # PM2 config
└── README.md
```
---
## Appendix B: BullMQ Job Definitions
```javascript
// ecosystem.config.js worker concurrency
module.exports = {
apps: [
{
name: 'ingestion-worker',
script: 'dist/workers/index.js',
env: {
WORKER_CONCURRENCY: 5,
QBO_QUEUE_CONCURRENCY: 2, // Respect 500 req/min
PLAID_QUEUE_CONCURRENCY: 3,
SPOTON_QUEUE_CONCURRENCY: 3
}
}
]
};
// BullMQ Queue Definitions
const queues = {
qboSync: {
name: 'qbo-sync',
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50
}
},
plaidSync: {
name: 'plaid-sync',
defaultJobOptions: {
attempts: 3,
backoff: { type: 'fixed', delay: 30000 },
removeOnComplete: 100
}
},
spotonSync: {
name: 'spoton-sync',
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 200
}
},
reconciliation: {
name: 'reconciliation',
defaultJobOptions: {
attempts: 1, // No retry — alert on failure
removeOnComplete: 30
}
}
};
```
---
## Appendix C: Cron Schedule Reference
```
┌───────── Source ─────────┬─────── Cron ───────┬─────────── Description ─────────────────┐
│ QBO CDC Poll │ */30 * * * * │ CDC sync every 30 minutes │
│ QBO Full Sync │ 0 2 * * * │ Full CDC + P&L report at 2am │
│ QBO Token Refresh │ */50 * * * * │ Token refresh every 50 min (before expiry)│
│ QBO Weekly Integrity │ 0 3 * * 0 │ Full entity query on Sunday │
│ │ │ │
│ Plaid Incremental │ */15 * * * * │ Incremental transaction sync │
│ Plaid Full Resync │ 0 4 * * 0 │ Full resync every Sunday at 4am │
│ │ │ │
│ SpotOn Orders (BH) │ */5 8-23 * * 1-6 │ 5-min polls during business hours │
│ SpotOn Orders (Sun BH) │ */5 9-21 * * 0 │ 5-min polls Sunday hours │
│ SpotOn Deep Sync │ 0 3 * * * │ 26h deep sync daily at 3am │
│ SpotOn Labor Reports │ 0 6 * * * │ Labor report fetch at 6am │
│ │ │ │
│ Daily Reconciliation │ 0 7 * * * │ Sales × Plaid match at 7am │
│ Monthly Reconciliation │ 0 6 1 * * │ Full QBO P&L reconciliation on 1st │
│ Database Backup │ 0 0 * * * │ PostgreSQL dump + Redis SAVE at midnight │
│ Data Freshness Check │ 0 8 * * * │ Integrity checks: orphans, gaps, dupes │
│ │ │ │
│ Token Expiry Check │ 0 9 * * * │ Check QBO token <7d expiry → SECURITY │
│ Dead Letter Alert │ 0 * * * * │ Check BullMQ DLQ, create FAILURE alerts │
└──────────────────────────┴────────────────────┴──────────────────────────────────────────┘
Note: All times in UTC unless otherwise noted. Business hours (BH) follow local Eastern Time.
```
---
## Appendix D: Key Design Decisions & Rationale
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Warehouse pattern** (not direct query) | Ingestion Workers → Normalization → Aggregation | Decouples API failures from dashboard. Dashboard always works from last-good data. Rate limits don't impact UX. |
| **BullMQ over in-process queue** | Redis-backed BullMQ | Survives server restarts. Provides retry, DLQ, concurrency control. Monitoring dashboard for job health. |
| **Materialized Views over computed-at-query** | Pre-aggregated | Dashboard loads in <100ms regardless of data volume. Refresh on schedule. Concurrent refresh for zero-downtime. |
| **Separate RAW + NORMALIZED tables** | Dual-table pattern | Raw = immutable audit log (reprocessable). Normalized = cleaned, typed, indexed for query. Never lose original data. |
| **CDC over polling for QBO** | Change Data Capture primary | 500 req/min limit makes entity-level polling infeasible. CDC returns all changed entities in 2-3 calls vs 20+ individual fetches. |
| **SpotOn 5-min + 26h dual sync** | Hybrid polling | 5-min catches real-time orders. 26h deep sync catches missed records. Compensates for SpotOn's limited API window. |
| **Reconciliation as separate pipeline stage** | Post-ingestion verification | Don't trust any single source. Cross-verify every KPI against a secondary source. Catch issues before they reach the dashboard. |
| **PM2 over Docker in homelab** | Process manager | Simpler networking, lower overhead, easier debugging in single-LXC setup. Docker reserved for AWS migration phase. |
| **SWR over WebSockets** | Stale-while-revalidate | Simplified architecture for v1. Dashboard only needs 120s refresh. WebSockets add complexity (reconnection, state sync) without proportional benefit. |
| **Gmail App Password over SMTP service** | Nodemailer + Gmail | Free, zero configuration for homelab. Replace with SES on AWS for production volume and deliverability. |
---
*End of Architectural Specification v1.0*
This document serves as the authoritative blueprint for all M01 development. All changes and deviations must be documented and approved through the project's change management process.