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 timestamp
  • recorded_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.

Key Decisions & Trade-offs

Why extend the existing daemon instead of a new form