# Zoho Receipts Pipeline — Integration Guide

**Status:** Active
**Last updated:** 2026-06-13
**Owns:** Repeatable pattern for wiring a new CBB service into the Zoho Books data flow.
**Does NOT own:** Pipeline as-built state (Architecture Reference), day-to-day operations (Operating Runbook), rebuild (Rebuild Guide).

This guide describes the canonical pattern for adding a **new data source or consumer** to the Zoho Books receipts pipeline. It is written so a buyer's engineering team or a future CBB engineer can extend the pipeline without re-deriving the architecture.

---

## 1. When to use this guide

Use this guide when **any** of these are true:

- A new CBB service (e.g., cbb-sign, a future ScopeDOPE store, an event-sponsorship program) generates expenses that should be auto-imported to Zoho Books.
- A new vendor with a unique receipt format starts sending mail to `finance@` and a dedicated extractor is warranted.
- A new outbound consumer (e.g., a dashboard, a BI tool) needs read access to the normalized receipts corpus or the Zoho backup data.
- A new mailbox other than `finance@` needs ingestion (e.g., `finance-eu@` if CBB ever has an EU entity).

Do **not** use this guide for one-off receipt fixes — those go through the Operating Runbook §4.

## 2. Design constraints any integration must respect

Empirically verified 2026-06-13 against the live CBB Zoho org. New integrations must respect these constraints:

| Constraint | Why |
|---|---|
| **Zoho rejects $0.00 expenses** (`code 5015`) | Zero-dollar receipts must be filtered upstream and not POST'd as expenses. The pipeline's zero-dollar filter is mandatory. |
| **Zoho `/expenses/{id}/receipt` accepts image/PDF only** (`code 40` on `.md`/`.txt`) | Any new service that attaches receipts must convert to PDF first. The Markdown index lives in OneDrive; never attached. |
| **Each Zoho expense needs both `account_id` (expense category) AND `paid_through_account_id` (bank/cash/credit-card)** | Both must exist in the COA before any POST. |
| **Vendor creation requires `contact_type: "vendor"` + `customer_sub_type` (`business` or `individual`)** | Default `customer` type is wrong for receipt-source counterparties. |
| **Service Identity Ownership (CLAUDE.md)** | New consumers/services must have their own managed identity, not borrow an existing one. |
| **Credential Sharing — VERBOTEN (CLAUDE.md)** | New consumers/services must have their own Key Vault secrets, not share existing ones. |

## 3. Patterns supported

This pipeline supports three integration patterns. Pick the one that matches your case.

### 3.1 Pattern A — New mailbox source

A new mailbox needs to be polled and its receipts ingested using the same normalization + Zoho-import logic.

**Use when:** A second `finance@` exists (separate entity, separate region, separate purpose).

**Cost:** New Container Apps Job (within free grant), new ApplicationAccessPolicy. ~$0/mo.

### 3.2 Pattern B — New vendor extractor

A vendor's receipts arrive at the existing `finance@` mailbox but don't match an existing extractor's format.

**Use when:** A new recurring vendor shows up in `_needs_review/` more than twice.

**Cost:** $0 — extends existing infrastructure.

### 3.3 Pattern C — New downstream consumer

A new system needs to read either the normalized receipts corpus or the Zoho backup data — but does not write.

**Use when:** Building a dashboard, BI tool, audit-prep tool, or any other read-only system.

**Cost:** Depends on consumer; the pipeline side contributes $0 (read-only RBAC role).

## 4. Pattern A — New mailbox source

### 4.1 Prereqs

- The new mailbox exists in the same M365 tenant (or its tenant has been federated). Cross-tenant Graph access is out of scope for this guide.
- The mailbox is provisioned as a shared mailbox (no license required) or as a regular user mailbox (license required).
- The expense scheme for the new source is documented — i.e., which Zoho line / product attribution applies (CBB, ScopeDOPE, new product line).

### 4.2 Steps

1. **Create a new Container Apps Job** in the same `cae-cbb-receipts-prod` environment:
   - Name: `caj-cbb-receipts-ingest-<mailbox-slug>-prod`
   - Schedule: hourly (or per the source's expected receipt cadence)
   - System-assigned managed identity
   - Reference the same Container image as the primary ingest job, but pass `--mailbox <new-mailbox-address>` as the entrypoint argument
2. **Grant Graph `Mail.ReadWrite`** to the new managed identity (per Rebuild Guide §7.2)
3. **Create an ApplicationAccessPolicy** scoping the new managed identity to **only** the new mailbox (per Rebuild Guide §7.3)
4. **Reuse the same Key Vault** (`kv-cbb-receipts-prod`) and grant the new managed identity Key Vault Secrets User
5. **If the new source uses a different Zoho org**, provision a new Key Vault `kv-cbb-receipts-<source>-prod` with that org's secrets. **Never share Zoho credentials across orgs** per CLAUDE.md Credential Sharing rule.
6. **Add a product-line directory** under `Receipts/` if the new source attributes to a new product line (e.g., `Receipts/CbbSign/<YYYY>/<MM>/`)
7. **Add the new categories to Zoho Books** if any new expense categories are needed
8. **Confirm zero-dollar filter and PDF rendering work for the new mailbox's typical receipts** before activating the schedule
9. **Update the Architecture Reference** to reflect the new resources

### 4.3 Acquisition lens

A new product line implies a new column in any combined P&L exercise. If the new line is a separate Zoho org, the buyer's view of consolidated P&L requires the offline-combine step. Document the consolidation method in the new product-line's own Acquisition Brief.

## 5. Pattern B — New vendor extractor

### 5.1 Prereqs

- At least two sample receipts from the new vendor saved in `Receipts/_raw/`
- The vendor's sender domain identified
- The vendor's category mapped to a Zoho expense account

### 5.2 Steps

1. **Capture sample receipts**:
   - Forward two existing receipts from the vendor to your own inbox.
   - Save the raw EML files as test fixtures in `cbb-utils/zoho/extractors/tests/fixtures/<vendor>/`.
2. **Add the expense category to Zoho Books** if it doesn't exist (per the doubled-COA convention — `<Base> - CBB` or `<Base> - ScopeDOPE`).
3. **Create the extractor**:
   - Copy the closest existing extractor in `cbb-utils/zoho/extractors/` as a starting template.
   - File location: `cbb-utils/zoho/extractors/<vendor>.py`
   - Implement: `def matches(email: Email) -> bool` and `def extract(email: Email) -> ExtractedReceipt`
   - **The extractor MUST set `total: 0.00` correctly when the receipt is zero-dollar** — the zero-dollar filter relies on this to skip POSTing the expense.
4. **Write extractor tests**:
   - File location: `cbb-utils/zoho/extractors/tests/test_<vendor>.py`
   - Use the saved fixtures.
   - Test cases: at minimum, "happy path extracts all fields", "missing field falls back to None and flags needs_review", and "$0.00 receipt extracts cleanly with total=0".
5. **Register the extractor**:
   - Add the import to `cbb-utils/zoho/extractors/__init__.py`.
   - Order matters — generic-fallback extractor must be last in the registry.
6. **Run tests locally**:
   ```bash
   cd infra/cbb-utils/zoho
   python3 -m pytest extractors/tests/test_<vendor>.py
   ```
7. **Commit and push** — cbb-utils commits go directly to `main` per CLAUDE.md.
8. **Wait for the next hourly run** — the new extractor is picked up automatically. The next time the vendor sends a receipt, it'll be normalized and imported correctly.

### 5.3 Acquisition lens

Extractor diversity is a positive acquisition signal: it shows the pipeline handles real vendor variance, not just one happy path. Keep the test fixtures committed (with vendor-PII redacted) so the buyer's engineering team can verify the extractor library on day one.

## 6. Pattern C — New downstream consumer

### 6.1 Prereqs

- The consumer is identified (system name, owner, purpose).
- The consumer's access model is decided: managed identity (preferred), service principal, or human user.
- The data scope is identified: normalized Markdown corpus only? Canonical PDFs from `_raw/`? Backup blobs only? All three?

### 6.2 Steps

1. **For Markdown corpus access (OneDrive)**:
   - The consumer needs M365 access. Grant it via a SharePoint/OneDrive sharing rule, not via blanket tenant access.
   - **Read-only.** The consumer must not be able to write to `Receipts/`.
   - File access goes through Microsoft Graph `/sites/<site-id>/drive/root:/Financial_Records/Receipts/:/children` with the consumer's identity holding `Sites.Read.All` or a more scoped equivalent.
   - **Confidentiality reminder**: the corpus is `03_-_Confidential`. The consumer must be auditable and access-logged.
2. **For canonical PDF access (the `_raw/` PDFs)**: same as above — same SharePoint/OneDrive read-only grant; access at `Receipts/_raw/<YYYY>/<MM>/<msg-id>/canonical.pdf`.
3. **For Zoho backup blob access**:
   - Grant the consumer's managed identity / SP the **Storage Blob Data Reader** RBAC role on the specific container (`zoho-backup-nightly` or `zoho-backup-weekly`), not the whole storage account.
   - The consumer reads blobs directly via the Storage SDK.
   - **Never grant Storage Blob Data Contributor or Owner** to a downstream consumer — they should never write.
4. **For combined access**:
   - Both/all of the above, granted separately. Never bundle credentials.
5. **Document the new consumer in the Architecture Reference §9 Identity table** so a buyer's TDD can reconstruct the access map.

### 6.3 Acquisition lens

Every downstream consumer with credentials is a question a buyer will ask: "What is this and why does it have access?" Keep §9 of the Architecture Reference current. Any consumer that no longer exists should have its credentials revoked promptly and its row removed.

### 6.4 Worked example — the CBB Asset Register service

The `caj-cbb-asset-register-prod` Container Apps Job is the canonical worked example of Pattern C — a separate scoped consumer that reads events from the receipts pipeline and writes to a downstream system (the CBB Asset Register SharePoint list). It is in production at v1.0.

**Why this example matters as a template:**

- It demonstrates the **event-queue boundary** between two services that share the same OneDrive corpus and same Azure environment but must not share identities.
- It demonstrates **Graph `Sites.Selected`** — the SharePoint minimum-scope pattern (different from Mail's `ApplicationAccessPolicy` pattern).
- It demonstrates **dedicated Key Vault per service** (`kv-cbb-asset-register-prod` vs. `kv-cbb-receipts-prod`) — two stores, never shared.
- It demonstrates **idempotent consumer design** (dedup-by-key before write) which any queue-consumer should mirror.

When building a new downstream consumer that fits this shape, copy the asset service's structure rather than reinventing it. The service code lives in `cbb-utils/asset-register/`.

## 7. Forbidden integration patterns

Some integrations look reasonable but violate CBB governance. Refuse these and propose the alternative pattern above.

| Forbidden pattern | Why | Alternative |
|---|---|---|
| Sharing the Zoho refresh token with a second service | Violates Credential Sharing rule (CLAUDE.md) | Provision a second Self Client in Zoho with its own credentials |
| Reusing `caj-cbb-receipts-ingest-prod`'s managed identity for a new service | Violates Service Identity Ownership rule (CLAUDE.md) | Provision a new managed identity per Pattern A or C |