# Charles Schwab Broker Integration

**Version:** 1.0
**Status:** Implemented
**Date:** 2026-05-08
**Migration:** 190

## Overview

Charles Schwab integration enables trading US stocks, ETFs, and options through the Schwab Trader API. Uses OAuth 2.0 with PKCE for secure authorization.

### Key Difference: 7-Day Token Expiry

Unlike Pepperstone (30-day tokens), Schwab has a **hard 7-day refresh token limit**. After 7 days, tokens cannot be refreshed and the user must re-authenticate via Schwab's login page.

| Aspect | Schwab | Pepperstone |
|--------|--------|-------------|
| Access Token | 30 minutes | 30 days |
| Refresh Token | **7 days (hard limit)** | 30 days (refreshable) |
| Re-auth Required | Weekly | Monthly (if refreshed) |

## Schwab API Details

| Item | Value |
|------|-------|
| Trader API Base | `https://api.schwabapi.com/trader/v1` |
| Market Data Base | `https://api.schwabapi.com/marketdata/v1` |
| Auth URL | `https://api.schwabapi.com/v1/oauth/authorize` |
| Token URL | `https://api.schwabapi.com/v1/oauth/token` |
| Auth Method | OAuth 2.0 + PKCE (S256) |
| Token Auth | Basic (base64 client_id:client_secret) |
| Markets | US Stocks, ETFs, Options |
| Paper/Live | Same endpoint (account type determines) |

**Note:** The Schwab API has two base URLs:
- **Trader API** (`/trader/v1`): Account management, orders, positions
- **Market Data API** (`/marketdata/v1`): Real-time quotes, price history

## Implementation Files

### Backend (`packages/be/src/brokers/`)

| File | Purpose |
|------|---------|
| `schwab-types.ts` | TypeScript types for Schwab API |
| `schwab-oauth.ts` | OAuth flow, token management, 7-day expiry tracking |
| `schwab.ts` | `SchwabAdapter` implementing `BrokerAdapter` |
| `service.ts` | Updated to support Schwab adapter factory |
| `api/handlers.ts` | OAuth initiate/callback handlers |

### Scheduler (`packages/be/src/scheduler/`)

| File | Purpose |
|------|---------|
| `jobs/schwab-token-refresh.ts` | Token refresh job (access tokens + expiry warnings) |
| `index.ts` | Job registration (6-hour interval) |

### Frontend (`apps/web/src/app/`)

| File | Purpose |
|------|---------|
| `settings/brokers/page.tsx` | Schwab in `BROKER_GUIDES` with 7-day warning |
| `api/predict/v1/user/brokers/schwab/oauth/initiate/route.ts` | Start OAuth flow |
| `api/predict/v1/user/brokers/schwab/oauth/callback/route.ts` | Handle OAuth callback |

### Database

| Migration | Changes |
|-----------|---------|
| `190_schwab_broker.sql` | Adds Schwab to `broker_configs`, `refresh_token_expires_at` column, `token_status` column |

## OAuth Flow

```
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Frontend  │     │   Backend   │     │   Schwab    │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       │ POST /initiate    │                   │
       │──────────────────>│                   │
       │                   │ Store state/PKCE  │
       │                   │──────────────────>│
       │ authorizationUrl  │                   │
       │<──────────────────│                   │
       │                   │                   │
       │ Redirect to Schwab                    │
       │──────────────────────────────────────>│
       │                   │                   │
       │                   │     User login    │
       │                   │     + authorize   │
       │                   │                   │
       │ Redirect with code + state            │
       │<──────────────────────────────────────│
       │                   │                   │
       │ GET /callback?code=...&state=...      │
       │──────────────────>│                   │
       │                   │ Validate state    │
       │                   │ Exchange code     │
       │                   │──────────────────>│
       │                   │ Access + Refresh  │
       │                   │<──────────────────│
       │                   │ Store encrypted   │
       │ Redirect success  │                   │
       │<──────────────────│                   │
```

## API Endpoints

### User Endpoints

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/predict/v1/user/brokers/schwab/oauth/initiate` | Start OAuth flow |
| GET | `/api/predict/v1/user/brokers/schwab/oauth/callback` | Handle OAuth callback |
| PUT | `/api/predict/v1/user/brokers/schwab` | Test saved connection |
| DELETE | `/api/predict/v1/user/brokers/schwab` | Disconnect broker |

### Schwab Trader API Endpoints Used

| Action | Method | Endpoint |
|--------|--------|----------|
| Get Accounts | GET | `/accounts` |
| Get Account Details | GET | `/accounts/{accountNumber}` |
| Get Positions | GET | `/accounts/{accountNumber}?fields=positions` |
| Get Orders | GET | `/accounts/{accountNumber}/orders` |
| Place Order | POST | `/accounts/{accountNumber}/orders` |
| Cancel Order | DELETE | `/accounts/{accountNumber}/orders/{orderId}` |
| Get Quotes | GET | `/marketdata/v1/quotes?symbols={symbols}` |

## Token Management

### Token Status States

```typescript
type TokenStatus = 'active' | 'expiring_soon' | 'expired' | 'refresh_failed';
```

| Status | Meaning | Action |
|--------|---------|--------|
| `active` | Tokens are valid | None |
| `expiring_soon` | Refresh token expires in <24h | Show warning, prompt re-auth |
| `expired` | Refresh token has expired | Must re-authenticate |
| `refresh_failed` | Access token refresh failed | Check logs, may need re-auth |

### Automatic Token Refresh

- Access tokens (30 min) are refreshed 5 minutes before expiry
- Refresh tokens (7 days) **cannot** be extended - hard limit
- Scheduler job `schwab-token-refresh` runs every 6 hours

**Scheduler Job:** `packages/be/src/scheduler/jobs/schwab-token-refresh.ts`

```typescript
// Job registered in scheduler/index.ts
registerJob('schwab-token-refresh', 6 * 60 * 60 * 1000, async () => {
  const { runSchwabTokenRefreshJob } = await import('./jobs/schwab-token-refresh');
  const r = await runSchwabTokenRefreshJob();
  // Logs: refreshed/checked, failed, expired, expiringSoon
});
```

### User Notifications

When refresh token is within 24 hours of expiry:
1. `token_status` set to `expiring_soon`
2. UI shows warning banner on broker settings
3. Email notification sent (if enabled)
4. "Re-authenticate" button displayed

## Environment Variables

```bash
# Required for Schwab OAuth
SCHWAB_CLIENT_ID=<from developer.schwab.com>
SCHWAB_CLIENT_SECRET=<from developer.schwab.com>
OAUTH_REDIRECT_BASE_URL=https://predict.agencio.cloud
```

## Setup Instructions

### 1. Register at Schwab Developer Portal

1. Go to https://developer.schwab.com
2. Create a developer account
3. Create a new application
4. Select "Individual Trader API"
5. Get your Client ID and Client Secret

### 2. Configure Callback URL

Add this callback URL to your Schwab app:
```
https://predict.agencio.cloud/api/predict/v1/user/brokers/schwab/oauth/callback
```

### 3. Set Environment Variables

Add to Vercel or local `.env`:
```bash
SCHWAB_CLIENT_ID=your_client_id
SCHWAB_CLIENT_SECRET=your_client_secret
```

### 4. Enable Broker in Admin

After deployment:
1. Go to `/admin/brokers`
2. Find "Charles Schwab"
3. Toggle "Enabled" to ON
4. (Optional) Enable "Live Trading" when ready

## Supported Features

### Trading Capabilities

| Feature | Supported | Notes |
|---------|-----------|-------|
| Market Orders | Yes | |
| Limit Orders | Yes | |
| Stop Orders | Yes | |
| Stop-Limit Orders | Yes | |
| Options | Yes | Single-leg only currently |
| Extended Hours | Yes | Via session parameter |

### Account Types

- Individual Brokerage
- Margin
- IRA (Traditional, Roth, SEP, SIMPLE)
- 401(k)
- Trust
- Joint
- Custodial (UTMA/UGMA)

### Unsupported (Future)

- Multi-leg options strategies
- Mutual funds
- Fixed income
- Futures

## Error Handling

### Common Errors

| Error | Cause | Resolution |
|-------|-------|------------|
| `TokenExpired` | Refresh token past 7 days | User must re-authenticate |
| `InsufficientFunds` | Not enough buying power | Check account balance |
| `MarketClosed` | Market hours restriction | Wait for market open |
| `RateLimitExceeded` | Too many requests | Back off, retry later |
| `InvalidToken` | Token revoked or invalid | Re-authenticate |

### Logging

All OAuth events logged to `predict.broker_audit_log`:
- `oauth_connected` - Successful connection
- `go_live_mfa_verified` - Live mode enabled with MFA

## Security Considerations

1. **PKCE Required**: All OAuth flows use S256 code challenge
2. **State Parameter**: CSRF protection on all OAuth requests
3. **Token Encryption**: AES-256-GCM for stored tokens
4. **Credential Display**: Only last 4 chars shown in UI
5. **No Long-Lived Secrets**: 7-day limit forces regular re-auth

## Database Schema Changes

### Migration 190

```sql
-- Add Schwab to broker_configs
INSERT INTO predict.broker_configs (broker_id, ...)
VALUES ('schwab', ...);

-- Track refresh token expiry (Schwab's 7-day limit)
ALTER TABLE predict.user_broker_credentials
ADD COLUMN refresh_token_expires_at TIMESTAMP WITH TIME ZONE;

-- Track token health for UI warnings
ALTER TABLE predict.user_broker_credentials
ADD COLUMN token_status VARCHAR(20) DEFAULT 'active';
```

## Testing

### Manual Testing Checklist

- [ ] OAuth flow completes successfully
- [ ] Tokens stored and encrypted
- [ ] Ping test returns account info
- [ ] Positions fetched correctly
- [ ] Orders can be placed (paper mode)
- [ ] Token refresh works (wait 25+ min)
- [ ] 7-day expiry warning appears
- [ ] Re-auth flow after expiry works
- [ ] Disconnect removes credentials

### Test API Calls

```bash
# Test ping
curl -X PUT https://predict.agencio.cloud/api/predict/v1/user/brokers/schwab \
  -H "Authorization: Bearer <jwt>"

# Get token status
curl https://predict.agencio.cloud/api/predict/v1/user/brokers/schwab/token-status \
  -H "Authorization: Bearer <jwt>"
```

## Code Architecture

### Wiring Overview

```
┌─────────────────────────────────────────────────────────────────────────┐
│                           FRONTEND (Next.js)                            │
├─────────────────────────────────────────────────────────────────────────┤
│  /settings/brokers/page.tsx                                             │
│    └── BROKER_GUIDES['schwab'] → "Connect with Schwab" button          │
│        └── POST /api/predict/v1/user/brokers/schwab/oauth/initiate     │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │
┌──────────────────────────────────▼──────────────────────────────────────┐
│                           API ROUTES                                     │
├─────────────────────────────────────────────────────────────────────────┤
│  apps/web/src/app/api/predict/v1/user/brokers/schwab/oauth/             │
│    ├── initiate/route.ts  → handleOAuthInitiate(req, 'schwab')         │
│    └── callback/route.ts  → handleOAuthCallback(req, 'schwab')         │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │
┌──────────────────────────────────▼──────────────────────────────────────┐
│                           BACKEND HANDLERS                               │
├─────────────────────────────────────────────────────────────────────────┤
│  packages/be/src/brokers/api/handlers.ts                                │
│    ├── handleOAuthInitiate()  → schwab-oauth.storeOAuthState()         │
│    │                          → schwab-oauth.buildAuthorizationUrl()    │
│    └── handleOAuthCallback()  → schwab-oauth.validateOAuthState()      │
│                               → schwab-oauth.exchangeCodeForTokens()    │
│                               → schwab-oauth.storeTokens()              │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │
┌──────────────────────────────────▼──────────────────────────────────────┐
│                           BROKER SERVICE                                 │
├─────────────────────────────────────────────────────────────────────────┤
│  packages/be/src/brokers/service.ts                                     │
│    └── getAdapterForUser(userId, 'schwab')                             │
│        └── getSchwabTokens() → SchwabAdapter                           │
│                                                                          │
│  packages/be/src/brokers/schwab.ts                                      │
│    └── SchwabAdapter implements BrokerAdapter                          │
│        ├── ping()                                                       │
│        ├── getAccount()                                                 │
│        ├── getPositions()                                               │
│        ├── getOpenOrders()                                              │
│        ├── submitOrder()                                                │
│        ├── cancelOrder()                                                │
│        ├── flatten()                                                    │
│        ├── closeAll()                                                   │
│        └── getQuotes()  ← Uses SCHWAB_MARKETDATA_BASE                  │
└─────────────────────────────────────────────────────────────────────────┘
```

### Key Code Locations

| Component | File | Line Range |
|-----------|------|------------|
| OAuth state management | `schwab-oauth.ts` | 104-160 |
| Authorization URL builder | `schwab-oauth.ts` | 162-180 |
| Token exchange | `schwab-oauth.ts` | 182-220 |
| Token storage (encrypted) | `schwab-oauth.ts` | 222-270 |
| Token retrieval + auto-refresh | `schwab-oauth.ts` | 280-400 |
| Adapter factory (service.ts) | `service.ts` | 430-460 |
| Request wrapper | `schwab.ts` | 85-130 |
| Order submission | `schwab.ts` | 310-400 |
| Position flattening | `schwab.ts` | 430-480 |
| Quote fetching (marketdata API) | `schwab.ts` | 490-530 |

### Database Tables Used

| Table | Purpose |
|-------|---------|
| `predict.broker_configs` | Schwab broker configuration |
| `predict.user_broker_credentials` | OAuth tokens (encrypted), expiry tracking |
| `predict.broker_oauth_states` | PKCE state for OAuth flow |
| `predict.broker_audit_log` | OAuth connection events |

## Migration Applied

**Migration 190** was applied to production on 2026-05-08:

```sql
-- Applied to: postgresql://...@54.255.100.122:5432/predict_db

-- 1. Added Schwab to broker_configs
INSERT INTO predict.broker_configs (
  broker_id, display_name, enabled, fee_schedule_json, ...
) VALUES ('schwab', 'Charles Schwab', FALSE, ...);

-- 2. Added refresh_token_expires_at column
ALTER TABLE predict.user_broker_credentials
ADD COLUMN refresh_token_expires_at TIMESTAMP WITH TIME ZONE;

-- 3. Added token_status column
ALTER TABLE predict.user_broker_credentials
ADD COLUMN token_status VARCHAR(20) DEFAULT 'active';
```

**Verification:**
```bash
# Verify Schwab broker exists
psql $DATABASE_URL -c "SELECT broker_id, display_name, enabled FROM predict.broker_configs WHERE broker_id = 'schwab';"
# → schwab | Charles Schwab | f

# Verify columns exist
psql $DATABASE_URL -c "SELECT column_name FROM information_schema.columns WHERE table_name = 'user_broker_credentials' AND column_name IN ('refresh_token_expires_at', 'token_status');"
# → refresh_token_expires_at, token_status
```

## Related Documentation

- [Broker Integration Overview](./13-broker-integration.md)
- [Pepperstone Integration](./55-pepperstone-broker-integration.md)
- [API Routes](./24-api-routes.md)
- [Schwab Developer Docs](https://developer.schwab.com/products/trader-api--individual)
