Building a Private Payment Intake Pipeline: Handling Deposits Without Exposing Financial Data to Crew
When you're running a service business with both clients and crew, payment confirmation is a critical moment—but it creates a data sensitivity problem. Clients need to know their deposit landed. Crew needs to see that a booking is confirmed. But crew should never see financial amounts. This post documents how we implemented a clean separation between payment artifacts (screenshots, receipts) and the business events they trigger, using existing JADA infrastructure to minimize surface area and operational friction.
The Problem: Three Systems, One Event
A deposit arriving via Zelle needs to update at least three distinct systems:
- Client-facing: Confirmation email with booking details, balance due, arrival instructions
- Internal calendar: JADA GCal event status flips to "Deposit Received"
- Crew dispatch: DynamoDB crew page shows booking as "confirmed" (no $ amounts)
The naive approach—store the Zelle screenshot somewhere accessible and trigger automation from it—leaks financial detail to crew. The right approach treats the screenshot as a private accounting artifact, completely separate from the automation trigger. The trigger is a structured event, not a file.
Architecture: Event Intake via Existing Daemon
Rather than build new infrastructure, we extended the existing CB Notes daemon that already monitors incoming SMS and structured notes. The flow:
CB sends SMS → JADA phone line → Twilio webhook → handle_cb_notes.py
→ Haiku LLM parses intent → Lambda executes payment-received chain
→ SES email + GCal update + DynamoDB write + private S3 receipt storage
This approach has zero new endpoints, zero new polling, and fits exactly how CB already interacts with the system.
Technical Implementation
Step 1: Extend handle_cb_notes.py
The existing daemon at lambda/handlers/handle_cb_notes.py already parses structured notes. We added a new intent type:
INTENT_PAYMENT_RECEIVED = "payment_received"
# Example input from CB:
# "DEPOSIT RECEIVED - 2026-06-14-sarah-sunset - Zelle $500"
# Haiku extraction includes:
# - booking_id (parsed from date-name pattern)
# - amount (USD)
# - method (Zelle, Stripe, etc.)
# - timestamp (when CB sent the note)
The daemon already calls Haiku to extract structured data. We added a new prompt branch that recognizes the "DEPOSIT RECEIVED" prefix and extracts the booking ID, amount, and payment method into a standardized JSON object.
Step 2: Create payment_received.py Orchestrator
New Lambda function at lambda/handlers/payment_received.py executes the full chain once intent is confirmed:
def lambda_handler(event, context):
booking_id = event['booking_id']
amount = event['amount']
payment_method = event['method']
# 1. Write to private payment log
log_payment_to_dynamodb(booking_id, amount, payment_method)
# 2. Send client confirmation email
send_client_confirmation(booking_id, amount)
# 3. Update JADA GCal event
update_gcal_event_status(booking_id, 'deposit_received')
# 4. Update crew dispatch (DynamoDB)
update_crew_booking_status(booking_id, 'confirmed')
# 5. Update guest page (static gen or on-read)
invalidate_guest_page_cache(booking_id)
return {'status': 'success', 'booking_id': booking_id}
Step 3: Private Payment Log (DynamoDB)
New table: jada-payments-log
Schema:
PK:BOOKING#{booking_id}(partition key)SK:PAYMENT#{timestamp}(sort key; allows multiple payments per booking)amount:integer (cents)method:string ("zelle", "stripe", "cash")received_at:ISO timestamprecorded_by:string (CB's identifier; audit trail)receipt_s3_key:string (optional; private S3 path if screenshot uploaded)ttl:integer (optional; auto-delete after 7 years for compliance)
Access control: IAM policy restricts reads to CB + admin Lambda roles only. Never exposed to crew or guest endpoints.
Step 4: Private Receipt Storage in S3
New S3 prefix: s3://jada-private/receipts/
Bucket policy:
{
"Sid": "DenyPublicAccess",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::jada-private/receipts/*",
"arn:aws:s3:::jada-private/receipts"
]
}
No CloudFront distribution for this prefix — screenshots are never cached publicly. Only CB and admin Lambda can access them via presigned URLs or IAM role.
If CB later uploads a screenshot (via ops admin form, see below), the path structure is:
s3://jada-private/receipts/{booking_id}/{timestamp}-{method}.jpg
Step 5: Client Confirmation Email
Updated SES template at ses/templates/deposit-received.html:
Subject: Booking Confirmed — Deposit Received
Dear {client_name},
We received your ${amount} deposit for {booking_date} on {received_date}.
Your remaining balance: ${balance_due}
Due by: {due_date}
📋 Waiver link: https://queenofsandiego.com/waiver/{booking_id}
🎬 Boarding instructions: https://queenofsandiego.com/g/{booking_date}-{client_first_name}
⏰ Please arrive 30 minutes early.
Questions? Reply to this email.
No financial history, no crew details—only forward-looking info the client needs.
Step 6: Update Crew Dispatch
Crew visibility table in DynamoDB: jada-crew-dispatch
When payment is received, we update the booking record (indexed by date + captain):
UpdateExpression: "SET booking_status = :status, confirmed_at = :ts"
ExpressionAttributeValues: {
":status": "confirmed",
":ts": timestamp
}
The crew page queries this table and shows "✓ Confirmed" without ever accessing the payments table or S3 receipt bucket.