Private Payment Intake & Crew Visibility Isolation: A Case Study in Event-Driven Architecture
When a client deposit arrives via Zelle, we face a classic separation-of-concerns problem: the payment receipt is sensitive accounting data (crew has no business seeing deposit amounts), but the deposit event itself must trigger a cascade of updates across three separate systems—client email confirmation, internal calendar state, and crew dispatch visibility. This post documents the architectural decision and implementation approach we chose to handle this cleanly.
The Problem Statement
A deposit landing in a Zelle account is a real-world event that currently has no structured entry point into our system. The current manual workaround requires:
- A screenshot of the Zelle confirmation
- Manual upload to some location (currently undefined)
- Manual email to clients
- Manual calendar update
- Manual crew page state flip
This is error-prone and creates timing gaps. Additionally, storing that screenshot anywhere accessible to crew violates the principle of least privilege—crew members don't need to see deposit amounts, only booking confirmation status.
Why We Separate the Artifact from the Signal
The Zelle screenshot and the "deposit received" event are two distinct things. The screenshot is an accounting artifact with audit/tax implications. The event is a system state transition. By treating them separately, we gain:
- Privacy enforcement: The screenshot lives in a private S3 prefix with no public ACL and no CloudFront distribution. Crew cannot access it even by accident.
- Decoupled automation: The system doesn't need the screenshot to trigger workflows. A simple structured signal (text, form submission, or email) is sufficient.
- Cleaner audit trail: Payment logs are separate from operational event logs.
Technical Architecture: Three Implementation Options (Ranked by Fit)
Option 1: Extend the Existing CB Notes Daemon (Recommended)
We already have a daemon at services/cb_notes/handle_cb_notes.py that parses structured notes sent by crew to a dedicated Twilio-forwarded phone number. This daemon has:
- Real-time SQS ingestion from Twilio webhooks
- Haiku LLM parsing for intent extraction
- Event emission into our state machine
Intake flow: CB sends a text to the JADA line in the format DEPOSIT RECEIVED - 2026-06-14-sarah-sunset - Zelle $500. The daemon parses this, extracts the booking ID and amount, and emits a PaymentReceived event into DynamoDB's booking table.
Code change (minimal): In handle_cb_notes.py, add a pattern matcher for payment intents:
if "deposit received" in parsed_intent.lower():
booking_id = extract_booking_id_from_context(parsed_text)
amount = extract_currency(parsed_text)
emit_payment_event(
booking_id=booking_id,
amount=amount,
method="zelle",
timestamp=datetime.utcnow()
)
Why this works: CB already has the phone habit. No new infrastructure. The daemon is battle-tested. Latency is ~2–3 seconds end-to-end.
Option 2: New Private Admin Form (Best Long-Term UX)
A dedicated ops dashboard at ops.queenofsandiego.com/payment-received (subdomain routed via Route53 to the same Lambda backend) with:
- Dropdown to select booking from recent clients
- Radio button or checkbox: "Zelle deposit received"
- Optional file upload field (screenshot/receipt)
- Submit button
File storage: Upload goes via pre-signed POST URL to s3://jada-private/receipts/{booking_id}/{timestamp}.png. The bucket policy denies any GET or LIST requests from CloudFront distributions or cross-origin callers. Only the Lambda function (with an IAM role) can read/write.
S3 bucket setup (pseudocode):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:role/jada-lambda-exec"
},
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::jada-private/receipts/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::jada-private", "arn:aws:s3:::jada-private/*"],
"Condition": {
"StringNotLike": {
"aws:userid": "*:jada-lambda-exec"
}
}
}
]
}
Why this is better long-term: Mobile-friendly UI, CB can submit from phone without switching apps, visual confirmation on form submit that it worked.
Option 3: Email Forwarding (Fragile, Not Recommended)
Forward Zelle notification emails to payment@queenofsandiego.com, parse them with SES rules and Lambda. The problem: Zelle emails are sparse on structured data, sender addresses vary, and parsing is brittle. We'd be reverse-engineering the email format. Avoid this unless you have zero other options.
The Canonical Event Chain
Once a PaymentReceived event is emitted (from any intake option), these things happen in order:
- Payment log entry: Written to
DynamoDB/jada-payments(private table, no crew access). Fields:booking_id,amount,method,received_timestamp,receipt_s3_key(nullable). - Booking state flip: Update
DynamoDB/jada-bookingsrecord:deposit_status = "received",balance_due = original_balance - deposit_amount. - Client confirmation email: Render and send branded HTML email from
templates/emails/deposit_received.html. Include deposit amount (confirm what was received), remaining balance, due date, boarding instructions, waiver link, and "arrive 30 min early" language. Use SES withFrom: bookings@queenofsandiego.com. - Crew dispatch page update: Query
DynamoDB/jada-bookingsfrom the crew-facing Lambda athandlers/crew/get_booking_status.py. Returnstatus: "confirmed"with zero financial fields. - Internal calendar: Update the Google Calendar event for this booking (via service account with
calendar:events:update