Co-Authored-By: Paperclip <noreply@paperclip.ing>
8.7 KiB
UAT Receipt Submission Path
Issue: CAR-812 Author: Barcode Betty Date: 2026-05-04
Overview
The UAT environment supports receipt submission via inbound email. This is the only supported submission method in UAT — there is no public REST API surface for receipt ingestion.
How It Works
Architecture
User composes email
↓
Email sent to <user_token>@cartsnitch.<env>.farh.net
↓
Mailgun webhook receives the email
↓
Email job enqueued to DragonflyDB stream: email:receipts
↓
email-worker (ReceiptWitness) consumes the job
↓
Worker resolves user via email_inbound_token lookup in DB
↓
Retailer detected from email content (meijer / kroger / target)
↓
Email parsed into Purchase + PurchaseItem records
↓
receipt.ingested event published to Redis
↓
MatchResult created with method=upc, confidence=1.0 for known UPCs
Key Components
| Component | Location | Role |
|---|---|---|
users.email_inbound_token |
DB (migration 001_add_email_inbound_token) |
22-char unique token per user; used as email routing identifier |
email:receipts stream |
DragonflyDB | Queue holding pending email jobs |
email-worker |
receiptwitness/src/receiptwitness/worker/email_worker.py |
Async worker consuming the stream |
BaseEmailParser |
receiptwitness/src/receiptwitness/parsers/email/base.py |
Abstract parser; subclasses for meijer/kroger/target |
| Retailer detectors | receiptwitness/src/receiptwitness/parsers/email/detector.py |
Sifts sender/subject to pick the right parser |
Email Address Format
Each user is assigned a unique inbound token. The receipt submission email address is shown in Settings → Receipt Email on the UI:
Address: receipts+<email_inbound_token>@receipts.cartsnitch.com
To find a user's token in the UAT database (requires kubectl access to cartsnitch-uat):
kubectl exec -n cartsnitch-uat deployment/cartsnitch-api -- \\
python -c "from cartsnitch_common.database import get_sync_session; \\
from cartsnitch_common.models.user import User; \\
from sqlalchemy import select; \\
s = get_sync_session('postgresql://cartsnitch:cartsnitch@cartsnitch-pg-rw:5432/cartsnitch'); \\
u = s.execute(select(User).where(User.email=='dottie@example.com')).scalar_one(); \\
print(u.email_inbound_token)"
Submitting a Test Receipt (Step-by-Step)
Prerequisites
- A test user account in UAT with a known
email_inbound_token - A sample receipt email with a known UPC from the seeded
normalized_productstable
Steps
-
Obtain the test user's inbound token. Use the UAT Settings → Receipt Email page in the UI to see the full address
receipts+<token>@receipts.cartsnitch.com, or query the DB directly (see above). -
Compose the email. Send to: the address shown in Settings → Receipt Email Subject: anything Body: plain-text or HTML receipt content
-
Expected behavior after email is processed:
- A
Receiptrow is created inpurchases PurchaseItemrows are created withupcmatching the seeded product UPC- A
MatchResultis created withmethod='upc'andconfidence=1.0
- A
Known UPC for Dottie (from UAT seed)
NOTE:
kubectlis not available in this execution environment. The UAT seed and DB query could not be executed. The sample receipt below uses a plausible placeholder UPC. Before Dottie runs the regression:
- Run
bash scripts/seed-env.sh uatfrom a machine with UAT kubecontext- Query:
SELECT id, canonical_name, upc_variants->0->>'upc' AS sample_upc FROM normalized_products WHERE jsonb_array_length(upc_variants) > 0 LIMIT 1;- Replace the placeholder values below with the real captured row
id: TBD — run seed and query UAT DBname: TBD — run seed and query UAT DBsample UPC: TBD — run seed and query UAT DB
Meijer Sample Receipt (plain text)
Meijer
===================================
Purchase Date: 03/15/2026
Store: Meijer #127 - Ann Arbor, MI
-----------------------------------
1 x Organic Whole Milk 1gal $4.99
1 x Whole Wheat Bread $3.29
1 x Bananas (2 lb) $0.67
1 x Chicken Breast (3 lb) $12.47
1 x Cheddar Cheese Block 8oz $5.99
-----------------------------------
Subtotal: $27.41
Tax: $1.93
Total: $29.34
===================================
THANK YOU FOR SHOPPING MEIJER
===================================
Meijer
Purchase Date: 03/15/2026 Store: Meijer #127 - Ann Arbor, MI
1 x Organic Whole Milk 1gal $4.99 1 x Whole Wheat Bread $3.29 1 x Bananas (2 lb) $0.67 1 x Chicken Breast (3 lb) $12.47 1 x Cheddar Cheese Block 8oz $5.99
Subtotal: $27.41 Tax: $1.93 Total: $29.34
THANK YOU FOR SHOPPING MEIJER
> **Note:** The `email-worker` parses the email body and extracts line items by retailer. The exact format and field mapping depends on the retailer parser. For Meijer, the parser looks for item lines matching `(\d+) x (.+?)\s+\$([\d.]+)`. UPCs in the `upc_variants` JSONB of seeded products will be matched during the normalization step.
### Kroger Sample Receipt (plain text)
KROGER
Purchase Date: 03/15/2026 Store: KROGER #412 - Ann Arbor MI
1 Organic Whole Milk 1gal $5.29 1 Whole Wheat Bread $3.49 1 Bananas (2 lb) $0.69 1 Chicken Breast (3 lb) $11.99 1 Sharp Cheddar Cheese 8oz $4.99
Subtotal: $26.45 Tax: $1.85 Total: $28.30
### Target Sample Receipt (plain text)
TARGET
03/15/2026 14:32 Store: 0874 Ann Arbor, MI
1 Organic Whole Milk 1G $5.49 1 Whole Wheat Bread $3.29 1 Bananas LB 2 $0.68 1 Chicken Breast 3# $12.99 1 Cheddar Cheese 8OZ $5.79
Subtotal: $28.24 Tax (6%): $1.69 Total: $29.93
---
## Troubleshooting
### Email not processed
1. Check the `email:receipts` stream has messages:
```bash
kubectl exec -n cartsnitch-uat deploy/email-worker -- python -c \\
"import asyncio; from receiptwitness.queue.email import get_redis; \\
async def chk(): c = await get_redis(); info = await c.xinfo_stream('email:receipts'); print(info); \\
asyncio.run(chk())"
-
Check
email-workerlogs for retailer detection failures:kubectl logs -n cartsnitch-uat deploy/email-worker -f -
Verify the token resolves to a user in the DB:
kubectl exec -n cartsnitch-uat deploy/cartsnitch-api -- \\ python -c "from cartsnitch_common.database import get_sync_session; \\ from cartsnitch_common.models.user import User; \\ from sqlalchemy import select; \\ s = get_sync_session('postgresql://...'); \\ r = s.execute(select(User.email_inbound_token).limit(5)).all(); \\ print(r)"
No MatchResult created
The normalization pipeline requires a normalized_product row with the submitted UPC in upc_variants. If the seed was run, the product should be found. Check the match_results table after submission:
SELECT mr.*, np.canonical_name
FROM match_results mr
JOIN normalized_products np ON np.id = mr.normalized_product_id
WHERE mr.match_method = 'upc'
ORDER BY mr.created_at DESC
LIMIT 10;
Related Files
| File | Role |
|---|---|
common/alembic/versions/001_add_email_inbound_token.py |
Adds email_inbound_token column |
receiptwitness/src/receiptwitness/worker/email_worker.py |
Consumes email jobs from stream |
receiptwitness/src/receiptwitness/queue/email.py |
DragonflyDB stream consumer group |
receiptwitness/src/receiptwitness/parsers/email/detector.py |
Retailer detection |
receiptwitness/src/receiptwitness/parsers/email/meijer.py |
Meijer email parser |
receiptwitness/src/receiptwitness/parsers/email/kroger.py |
Kroger email parser |
receiptwitness/src/receiptwitness/parsers/email/target.py |
Target email parser |
docs/uat-runbook.md |
UAT runbook (defect classification, entry/exit criteria) |