Building a Private Payment Intake System: Deposit Tracking Without Crew Visibility
When a client sends a deposit via Zelle, we need a clean signal that triggers a cascade of confirmations, calendar updates, and crew notifications—without ever exposing payment amounts or receipts to the crew. This post covers the architecture we built to handle deposit intake, the privacy-first storage model, and the state machine that gates crew departure authorization.
The Problem Statement
Previously, there was no structured way to report incoming deposits. The result: manual follow-ups, calendar desync, and crew-visible payment artifacts (screenshots, emails) that created unnecessary complexity and exposed sensitive business logic.
We needed:
- A private intake mechanism that doesn't expose amounts to crew
- Proof-of-payment storage (Zelle screenshots) that is completely crew-inaccessible
- Automated downstream triggers: confirmation emails, calendar updates, crew page state transitions
- A phone-friendly UX for the coordinator to log payments
- A state machine that enforces "no departure until full payment"
Architecture Overview: Two-Layer Intake Pattern
We separated the audit artifact (the Zelle screenshot) from the automation signal (the deposit received event). This decoupling is critical:
- Layer 1 (Private): Screenshot → S3 private bucket → never distributed, never referenced in crew-visible APIs
- Layer 2 (Signal): Structured event (date, booking ID, amount) → CB Notes daemon → Haiku parser → trigger chain
This means crew queries and API responses never reference the existence or location of receipts.
Implementation: The Payment Intake Flow
Option 1 (Immediate): Extend the CB Notes Daemon
The fastest path leverages the existing handle_cb_notes.py Lambda that already monitors the JADA callback line. The coordinator texts or calls in:
DEPOSIT RECEIVED - 2026-06-14-sarah-sunset - Zelle $500
The daemon's Haiku parser recognizes this pattern, extracts the booking ID and amount, and triggers the payment handler Lambda (process_deposit_received.py).
Advantages: Zero new infrastructure, fits existing workflow, voice or SMS agnostic.
File paths:
lambdas/handle_cb_notes.py— receives note, routes to Haikulambdas/process_deposit_received.py— triggers confirmationshaiku/prompts/payment_events.txt— pattern definitions for parser
Option 2 (Recommended Long-Term): Private Admin Form
A dedicated intake form at ops.queenofsandiego.com/payment-received (CloudFront distribution ID: E2ABCD1234EF, Route53 CNAME pointing to the ops CloudFront domain). The coordinator:
- Selects the booking from a dropdown (queries DynamoDB
bookingstable, filters by unpaid status) - Enters the payment method (Zelle, Venmo, check) and amount
- Optionally uploads a screenshot via pre-signed S3 POST URL
- Clicks "Log Deposit"
The form is mobile-responsive (critical for phone-based intake) and uses AWS Cognito for auth (same identity as the crew page).
File paths:
frontend/pages/ops/payment-received.tsx— React form componentlambdas/generate_s3_presigned_url.py— generates temporary upload credentiallambdas/finalize_payment_received.py— atomically updates booking state + triggers chainiac/s3_jada_private_receipts.tf— Terraform definition for private bucket
Storage: Private S3 Bucket Architecture
Payment receipts live in a dedicated S3 bucket with fortress-level privacy:
Bucket: jada-private-receipts-prod
Prefix: deposit-receipts/{YYYY-MM-DD-booking-id}/{filename}
Security model:
- No public ACL, no CloudFront distribution, no presigned public URLs
- Block all public access (S3 block policy enabled)
- Server-side encryption (AES-256 default)
- Versioning enabled for audit trail
- Coordinator and accounting role only (via IAM policy in
iac/iam_coordinator_role.tf) - Crew role has zero S3 permissions (statement explicitly denies
s3:Get*on this bucket)
Pre-signed URLs generated by generate_s3_presigned_url.py expire after 15 minutes. The Lambda function logs all upload attempts to CloudWatch (bucket, file path, timestamp, uploader) for audit.
State Machine: Three Booking States
The DynamoDB bookings table includes a payment_status attribute with three canonical values:
unpaid— no deposit logged; crew page shows red banner "Booking pending deposit"; guest page not accessibledeposit_received— deposit logged; crew page shows amber banner "Do not depart until full payment"; guest page accessible and marked "Confirmed"paid_in_full— final payment logged; crew page shows green "Clear to depart"; guest page marked "Paid"
The crew page query (in lambdas/get_crew_bookings.py) always filters by payment_status to determine UI state. The query returns the status enum but never returns amount or receipt references.
Triggering the Downstream Chain
When finalize_payment_received.py is invoked, it performs an atomic transaction:
1. Update DynamoDB booking: payment_status = "deposit_received"
2. Publish SNS message: topic arn:aws:sns:us-west-2:ACCOUNT-ID:jada-payment-events
3. Return HTTP 200 to client
The SNS message triggers four Lambda subscriptions in parallel:
lambdas/email_client_deposit_confirmation.py— sends branded HTML email to client with remaining balance, due date, waiver link, "arrive 30 min early"lambdas/update_jada_internal_gcal.py— updates the JADA Internal calendar event title to include "[DEPOSIT RECEIVED]"lambdas/update_crew_dispatch.py