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}"
})