# Zoho Receipts Pipeline — Architecture Reference

**Status:** As-designed (pre-provisioning). At provisioning, this doc transitions to as-built and is updated with real resource IDs, principals, and policy GUIDs.
**Last updated:** 2026-06-13
**Owns:** Live configuration state — resource names, IDs, what currently exists.
**Does NOT own:** Day-to-day procedures (Operating Runbook), rebuild instructions (Rebuild Guide), script details (`cbb-utils/zoho/README.md`).

---

## 1. Scope

This document describes the as-built (currently: as-designed) state of the Zoho Books receipts ingestion + backup + consistency-check pipeline, plus the separate scoped Asset Register service. It is the SOT for **what exists** in the Zoho infrastructure domain — resource names, IDs, principals, policy GUIDs, account references.

Procedural how-tos live in the Operating Runbook. Rebuild steps live in the Rebuild Guide.

## 2. External services consumed

| Service | Account/Org | Tier | Notes |
|---|---|---|---|
| **Zoho Books** | Cold Bore Ballistics LLC org (ID `927659458`) | Standard ($20/mo) | US edition. Org ID stored in Key Vault. Validated 2026-06-13. |
| **Microsoft 365 / Exchange Online** | CBB tenant `d553115d-3f7e-4bb8-8052-946b328cca87` | M365 BSP (existing) | `finance@coldboreballisticsllc.com` shared mailbox, no per-mailbox license needed. |
| **Microsoft Graph — Mail** | Same tenant | n/a | App-only `Mail.ReadWrite`, scoped by ApplicationAccessPolicy. Held by receipts ingest MI only. |
| **Microsoft Graph — SharePoint Sites** | Same tenant | n/a | App-only `Sites.Selected`, scoped to the Asset Register site only. Held by asset-register MI only. |
| **SharePoint Online — CBB Asset Register list** | `coldboreballistics.sharepoint.com/sites/ColdBoreBallistics` (M365 SharePoint exception) | included in M365 | List displayName `CBB Asset Register`. Site/list IDs stored in `kv-cbb-asset-register-prod`. |

## 3. Azure resources

**All resources live in region `northcentralus` per CLAUDE.md "Azure Region — MANDATORY".**

### 3.1 Resource Group

| Name | Region | Tags | Notes |
|---|---|---|---|
| `rg-cbb-receipts-prod` | northcentralus | `service=cbb-receipts`, `env=prod`, `product=cbb` | Container for all pipeline resources. |

### 3.2 Container Apps Environment

| Name | SKU | Notes |
|---|---|---|
| `cae-cbb-receipts-prod` | Consumption | No dedicated workload profile. Hosts all pipeline jobs across both services. |

### 3.3 Container Apps Jobs

| Name | Trigger | Cadence | vCPU | RAM | Purpose |
|---|---|---|---|---|---|
| `caj-cbb-receipts-ingest-prod` | Schedule (cron) | `0 * * * *` (hourly, top of hour) | 0.5 | 1 GiB | Pull mail from `finance@`, normalize, render to PDF, import to Zoho Books, emit asset events |
| `caj-cbb-receipts-backup-nightly-prod` | Schedule (cron) | `0 5 * * *` (05:00 UTC daily = ~midnight CT) | 0.5 | 1 GiB | JSON-per-module export to Storage |
| `caj-cbb-receipts-backup-weekly-prod` | Schedule (cron) | `0 6 * * 0` (06:00 UTC Sunday) | 0.5 | 1 GiB | Full Zoho org export ZIP to Storage |
| `caj-cbb-receipts-consistency-prod` | Schedule (cron) | `0 14 * * 2` (14:00 UTC Tuesday ≈ 09:00 CT) | 0.5 | 1 GiB | Weekly Zoho data consistency report |
| `caj-cbb-asset-register-prod` | Event (KEDA Storage Queue scaler) | On queue depth ≥ 1 | 0.5 | 1 GiB | Consume asset-candidate events, dedupe against SharePoint list, POST new items via Graph |

Each job has its own system-assigned managed identity (see §3.6). **`caj-cbb-asset-register-prod` is a separate service** with strictly scoped permissions — Graph `Sites.Selected` on the Asset Register site only, no mail access, no Zoho access. See §9b for the event-driven boundary between the receipts pipeline and the asset service.

### 3.4 Storage Account

| Name | Type | Redundancy | Access tier default | Purpose |
|---|---|---|---|---|
| `stcbbreceiptsprod` | StorageV2 | LRS | Hot (with lifecycle) | Backup destination + event queue host |

**Containers:**

| Container | Purpose | Lifecycle |
|---|---|---|
| `zoho-backup-nightly` | JSON-per-module nightly exports | Hot 0–30d → Cool 31–90d → Cold 91–365d → Archive 366d+ → delete at 7y |
| `zoho-backup-weekly` | Full org export ZIPs | Same lifecycle |
| `pipeline-logs-overflow` | Optional sink for large/binary logs not suitable for Log Analytics | Cool tier from day 1, delete at 1y |

**Queues:**

| Queue | Purpose | Retention |
|---|---|---|
| `asset-candidate-events` | Asset-candidate events from receipts → asset register service | 7-day TTL (Azure default) |
| `asset-candidate-events-poison` | Auto-populated by the asset service after 5 failed processing attempts on a single message | manual drain via Operating Runbook §10b.4 |

### 3.5 Key Vaults

Two strictly separate vaults — one per service — per CLAUDE.md "Credential Sharing — VERBOTEN — MANDATORY".

| Name | SKU | Soft-delete | Purge protection | RBAC mode | Purpose |
|---|---|---|---|---|---|
| `kv-cbb-receipts-prod` | Standard | enabled (90 d) | enabled | Azure RBAC | Zoho Books credentials only |
| `kv-cbb-asset-register-prod` | Standard | enabled (90 d) | enabled | Azure RBAC | SharePoint site/list IDs and asset-register secrets only |

**Secrets stored in `kv-cbb-receipts-prod`:**

| Name | Source | Notes |
|---|---|---|
| `zoho-books-client-id` | Zoho Self Client app registration | Set once at bootstrap |
| `zoho-books-client-secret` | Zoho Self Client app registration | Set once at bootstrap; never logged |
| `zoho-books-refresh-token` | Zoho OAuth (offline grant) | Long-lived; rotated only on explicit revoke |
| `zoho-books-organization-id` | Zoho `/organizations` API | Set once at bootstrap. CBB value: `927659458`. |

**Secrets stored in `kv-cbb-asset-register-prod`:**

| Name | Source | Notes |
|---|---|---|
| `sharepoint-site-id` | Graph `/sites/coldboreballistics.sharepoint.com:/sites/ColdBoreBallistics` | Resolved once at bootstrap |
| `sharepoint-list-id` | Graph `/sites/{site-id}/lists` filter by displayName="CBB Asset Register" | Resolved once at bootstrap |
| `sharepoint-list-display-name` | Constant: `CBB Asset Register` | Recorded for human reference / runtime fallback if list ID rotates |

**RBAC role assignments — `kv-cbb-receipts-prod`:**

| Principal | Role | Scope | Purpose |
|---|---|---|---|
| `caj-cbb-receipts-ingest-prod` (managed identity) | Key Vault Secrets User | the vault | Read Zoho credentials at runtime |
| `caj-cbb-receipts-backup-nightly-prod` | Key Vault Secrets User | the vault | Same |
| `caj-cbb-receipts-backup-weekly-prod` | Key Vault Secrets User | the vault | Same |
| `caj-cbb-receipts-consistency-prod` | Key Vault Secrets User | the vault | Same |
| Owner (user `jschwefel@coldboreballisticsllc.com`) | Key Vault Secrets Officer | the vault | Bootstrap + rotation |

**RBAC role assignments — `kv-cbb-asset-register-prod`:**

| Principal | Role | Scope | Purpose |
|---|---|---|---|
| `caj-cbb-asset-register-prod` (managed identity) | Key Vault Secrets User | the vault | Read SharePoint IDs at runtime |
| Owner (user `jschwefel@coldboreballisticsllc.com`) | Key Vault Secrets Officer | the vault | Bootstrap + rotation |

**The receipts-pipeline managed identities have NO access to `kv-cbb-asset-register-prod`. The asset-register managed identity has NO access to `kv-cbb-receipts-prod`. This is the credential-ownership boundary.**

No service principal/managed identity has `Set` permission on any secret at runtime — only the owner. Rotation is a manual admin operation.

### 3.6 Managed Identities

System-assigned managed identities on each Container Apps Job. Object IDs (principal IDs) and Graph app IDs to be recorded here at provisioning time:

| Job | Object ID (principal) | Graph App Role assignments |
|---|---|---|
| `caj-cbb-receipts-ingest-prod` | TBD at provisioning | `Mail.ReadWrite` (Graph), scoped to `finance@` via ApplicationAccessPolicy |
| `caj-cbb-receipts-backup-nightly-prod` | TBD at provisioning | none (Zoho-only) |
| `caj-cbb-receipts-backup-weekly-prod` | TBD at provisioning | none (Zoho-only) |
| `caj-cbb-receipts-consistency-prod` | TBD at provisioning | none (Zoho-only) |
| `caj-cbb-asset-register-prod` | TBD at provisioning | `Sites.Selected` (Graph), scoped to the Asset Register SharePoint site only |

### 3.7 Log Analytics Workspace

| Name | SKU | Retention | Purpose |
|---|---|---|---|
| `log-cbb-receipts-prod` | PerGB2018 | 30 d default | Container Apps Job logs (stdout/stderr) for both services |

Dedicated workspace per service domain (acquisition lens: clean per-service cost attribution).

## 4. Microsoft Graph configuration

### 4.1 Graph permission grant — receipts ingest

The `caj-cbb-receipts-ingest-prod` managed identity holds the **Microsoft Graph** `Mail.ReadWrite` app role, scoped to the `finance@` shared mailbox via Exchange Online `ApplicationAccessPolicy`.

### 4.2 ApplicationAccessPolicy

| Field | Value |
|---|---|
| Policy name | `app-access-receipts-ingestion-to-finance` |
| Policy GUID | TBD at provisioning |
| App ID | TBD (managed identity principal of `caj-cbb-receipts-ingest-prod`) |
| Scope | `finance@coldboreballisticsllc.com` only |
| Access right | RestrictAccess |

### 4.3 Graph permission grant — asset register

The `caj-cbb-asset-register-prod` managed identity holds **Graph `Sites.Selected`** plus a per-site write permission on the Asset Register SharePoint site only. See §9b.1–9b.2.

## 5. Zoho Books configuration

### 5.1 Org

| Field | Value |
|---|---|
| Org name | Cold Bore Ballistics LLC |
| Org ID | `927659458` (also stored in Key Vault as `zoho-books-organization-id`) |
| Edition | US |
| Plan | Standard ($20/mo) |
| Account created | 2026-06-12 |
| Time zone | America/Chicago |
| Currency | USD |
| WI Entity ID | `C139664` (custom field, populated at creation) |
| API region | https://www.zohoapis.com/books/v3 |
| Accounts region | https://accounts.zoho.com/oauth/v2 |

### 5.2 Self Client app registration

| Field | Value |
|---|---|
| Owner | `finance@coldboreballisticsllc.com` (Zoho admin user) |
| Client ID | stored in Key Vault as `zoho-books-client-id` |
| Client secret | stored in Key Vault as `zoho-books-client-secret` |
| Scopes granted | `ZohoBooks.contacts.ALL`, `ZohoBooks.expenses.ALL`, `ZohoBooks.bills.READ`, `ZohoBooks.settings.READ`, `ZohoBooks.accountants.READ` |
| Refresh token | stored in Key Vault as `zoho-books-refresh-token` |

Scope rationale:
- `contacts.ALL` — needed to auto-create vendors
- `expenses.ALL` — needed to create expense records and upload attachments
- `bills.READ` — needed for consistency-check job
- `settings.READ` — needed for chart-of-accounts and category lookups
- `accountants.READ` — needed for consistency-check to verify reporting structures

No `*.DELETE`-bearing scopes granted at runtime — destructive operations are out of scope for the pipeline.

### 5.3 Chart of Accounts (doubled-COA convention)

Per the COA decision recorded in Recommendations R-1: product-line attribution lives in the **expense account name suffix**, not in a reporting tag (tags are not available on Standard).

Pattern: `<Account Base Name> - <Product Line>` where Product Line is one of `CBB`, `ScopeDOPE`.

Examples (to be created during COA hydration — see Rebuild Guide §10):
- `Computer Hardware - CBB` / `Computer Hardware - ScopeDOPE`
- `Software & Subscriptions - CBB` / `Software & Subscriptions - ScopeDOPE`
- `Domain Registration - CBB`
- `Legal & Filing Fees - CBB`
- `R&D Hardware - ScopeDOPE`

**Liability account:** `Due to Owner` (existing per receipts schema).

Existing COA accounts confirmed in live org (2026-06-13):
- Banks: `Business Savings`, `Landmark CU - Checking`, `Landmark CU - Debit`
- Cash: `Petty Cash`, `Undeposited Funds`
- Equity: `Owner's Equity`, `Drawings`, `Retained Earnings`, `Opening Balance Offset`
- 22 generic expense accounts including `Other Expenses`, `IT and Internet Expenses`, `Office Supplies`, `Travel Expense`

The doubled-COA accounts (`* - CBB`, `* - ScopeDOPE`) do not exist yet — they will be created at COA hydration time.

Per-product P&L is generated by running the Standard Profit & Loss report with the Filter Accounts option scoped to `*-CBB` or `*-ScopeDOPE`. Two reports must be saved as custom reports for one-click access. Side-by-side comparison is not available on Standard; see Recommendations R-1.

### 5.4 Zoho API constraints (empirically verified 2026-06-13)

Findings from the integration test that constrain the pipeline design:

| Constraint | Error / Behavior | Pipeline handling |
|---|---|---|
| **Minimum expense amount > $0.00** | `code 5015 "Enter a valid expense amount"` on POST /expenses with `amount: 0.00` | Pipeline filters zero-dollar receipts out entirely; does not create a Zoho expense for them. See R-15 for free-trial tracking. |
| **Receipt attachment endpoint accepts image / PDF only** | `code 40 "File types with extension X are not supported"` on POST /expenses/{id}/receipt with `.md` or `.txt` | Pipeline includes a mandatory "render to PDF" stage before attaching. See R-16 and §6.2. |
| **Attachments visible in response as `documents` array** | Not a top-level `attachment_name` field | Pipeline reads back via `expense.documents[].file_name` |
| **Vendor creation requires `contact_type: "vendor"` and `customer_sub_type` (`business` or `individual`)** | Default is `customer`, which is wrong for receipt-source counterparties | Pipeline always sets both fields on `POST /contacts` for receipt vendors |
| **Each expense requires a `paid_through_account_id`** (bank, cash, or credit_card type) in addition to `account_id` (the expense category) | Otherwise 4xx error | Pipeline maps the `Paid By` line from the Markdown to a paid-through account ID via a lookup table |

## 6. Receipt handling pipeline stages

The canonical receipt is **whatever the vendor sent, converted to PDF if not already PDF**. The Markdown summary is the human-readable index, not the Zoho attachment.

### 6.1 Source preservation

For every email pulled from `finance@`, the pipeline saves:

```
Receipts/_raw/<YYYY>/<MM>/<message-id>/
├── original.eml                  ← full RFC822, never mutated
├── attachments/<original-names>  ← every attachment, as-received
├── classification.json           ← per-vendor classifier output
└── extracted.json                ← structured field extraction
```

### 6.2 Canonical receipt → PDF (R-16)

Per the Zoho API constraint in §5.4:

1. **PDF attachment present** in the source email → use the original PDF as-is. Save to `Receipts/_raw/<YYYY>/<MM>/<message-id>/canonical.pdf`.
2. **Inline HTML body only** → render the HTML to PDF via the rendering library bundled in the ingest container (recommended: `weasyprint`). Save the rendered PDF to the same path.
3. **Image attachment** (rare) 