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 Haiku
  • lambdas/process_deposit_received.py — triggers confirmations
  • haiku/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:

  1. Selects the booking from a dropdown (queries DynamoDB bookings table, filters by unpaid status)
  2. Enters the payment method (Zelle, Venmo, check) and amount
  3. Optionally uploads a screenshot via pre-signed S3 POST URL
  4. 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 component
  • lambdas/generate_s3_presigned_url.py — generates temporary upload credential
  • lambdas/finalize_payment_received.py — atomically updates booking state + triggers chain
  • iac/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 accessible
  • deposit_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