Building a Private Payment Intake System: Deposit Tracking Without Crew Visibility

What Was Done

We implemented a three-tier payment state machine for JADA bookings that cleanly separates private financial data from crew-visible authorization states. The system allows Captain to report deposit and full-payment events via an existing SMS-to-daemon pipeline, triggering automated confirmations, calendar updates, and crew-page status changes—without exposing amounts or payment details to crew members.

The core insight: payment reporting and payment artifacts (screenshots) are separate concerns. We store Zelle screenshots in a private S3 prefix with zero crew visibility, while using structured event data to drive state transitions. This keeps the automation fast and the crew page clean.

Technical Details: The Three-State Model

Each booking now carries a payment_state field with three canonical values:

  • tentative — No deposit logged. Crew page shows gray status; red banner says "Do not depart until deposit is confirmed."
  • deposit_received — Deposit logged (e.g., $500 Zelle). Crew page shows green confirmation; red banner escalates: "Do not depart until full payment is received and logged."
  • paid_in_full — Final payment logged. Green status, no red banner, clear to depart.

This model lives in DynamoDB. The booking record in jada-bookings table now includes:

{
  "booking_id": "2026-06-14-sarah-sunset",
  "payment_state": "deposit_received",
  "deposit_amount": null,  // Never crew-visible
  "full_amount": null,     // Never crew-visible
  "payment_events": [      // Internal log only
    {
      "timestamp": "2026-06-10T14:23:00Z",
      "type": "deposit_received",
      "method": "zelle",
      "reported_by": "cb",
      "receipt_s3_key": "jada-private/receipts/2026-06-14-sarah-sunset/zelle-2026-06-10.png"
    }
  ]
}

Crew members query the booking via the crew page API (GET /api/crew/booking/{booking_id}) and receive only the payment_state enum and the authorization-level banner text—never amounts.

Intake Pipeline: Extending the CB Notes Daemon

Rather than build new infrastructure, we extended the existing handle_cb_notes.py daemon (running on the JADA Internal Lambda, triggered by SMS inbound to the JADA line). Captain sends a structured SMS:

DEPOSIT RECEIVED - 2026-06-14-sarah-sunset - Zelle $500

The daemon's Haiku LLM parser recognizes the pattern, extracts:

  • Event type: deposit_received
  • Booking ID: 2026-06-14-sarah-sunset
  • Method: zelle
  • Amount: 500 (stored internally, never crew-visible)

Then it invokes a new handler function, process_payment_event(), in jada/payments/state_machine.py:

def process_payment_event(booking_id: str, event_type: str, method: str, amount: float, receipt_s3_key: str = None):
    """
    Update booking payment_state and trigger downstream actions.
    Args:
        booking_id: e.g., "2026-06-14-sarah-sunset"
        event_type: "deposit_received" or "paid_in_full"
        method: "zelle", "paypal", "venmo", "cash"
        amount: numeric, stored but never exposed to crew
        receipt_s3_key: optional S3 path to private receipt image
    """
    booking = dynamodb.get_item(Table="jada-bookings", Key={"booking_id": booking_id})
    
    # Update state machine
    booking["payment_state"] = event_type
    booking["payment_events"].append({
        "timestamp": iso_now(),
        "type": event_type,
        "method": method,
        "reported_by": "cb",
        "receipt_s3_key": receipt_s3_key
    })
    
    dynamodb.put_item(Table="jada-bookings", Item=booking)
    
    # Trigger downstream
    trigger_client_confirmation_email(booking_id, event_type)
    update_jada_internal_gcal(booking_id, event_type)
    
    return {"status": "ok", "booking_id": booking_id, "new_state": event_type}

Private Receipt Storage

Captain can optionally upload a Zelle screenshot during reporting. We provide a pre-signed S3 URL (valid 15 minutes) via a phone-friendly admin form at ops.queenofsandiego.com/payment-received. The form:

  • Lists today's pending bookings (payment_state = "tentative")
  • Shows a dropdown to select the booking
  • Radio buttons for payment method (Zelle, PayPal, cash, etc.)
  • A file upload input that gets a pre-signed URL from the Lambda
  • An "Amount received" field (for internal accounting only)

When uploaded, the screenshot lands in s3://jada-internal-private/receipts/{booking_id}/{timestamp}-{method}.png with:

  • No public ACL
  • No CloudFront distribution (crew cannot access via CDN)
  • IAM bucket policy limiting read/write to Captain + accounting Lambda only
  • Object lock on the bucket to prevent accidental deletion

The S3 key is stored in the payment event log but crew APIs never expose it.

Downstream: Confirmation Email & Calendar

When process_payment_event() completes with event_type = "deposit_received", it calls trigger_client_confirmation_email():

def trigger_client_confirmation_email(booking_id: str, event_type: str):
    """Send branded HTML email to client confirming deposit receipt."""
    booking = dynamodb.get_item(Table="jada-bookings", Key={"booking_id": booking_id})
    client_email = booking["client_email"]
    tour_date = booking["tour_date"]
    
    remaining_balance = booking["full_price"] - 500  # Example: $500 deposit
    due_date = (tour_date - timedelta(days=7)).isoformat()
    
    html_body = render_template("emails/deposit_received.html", {
        "client_name": booking["client_name"],
        "tour_date": tour_date,
        "remaining_balance": remaining_balance,
        "due_date": due_date,
        "guest_page_url": f"https://queenofsandiego.com/g/{booking_id}",
        "waiver_link": f"https://queenofsandiego.com/waiver/{booking_id}"
    })