```html

Building a Private Payment Intake Pipeline for Charter Operations: Extending the CB Notes Daemon

When a Zelle deposit lands in the operator's account, a chain of automations needs to fire: client confirmation email, calendar updates, crew authorization, guest page state transitions. But the receipt artifact itself — a screenshot of the Zelle app — is sensitive financial data that crew should never see. This post covers the architecture we built to separate the privacy concern from the automation trigger, using the existing CB Notes daemon as the integration point.

The Problem: Two Separate Concerns

A common mistake is treating the payment receipt (the screenshot) as the source of truth for automation. It's not. The screenshot is an accounting artifact, useful for bookkeeping and dispute resolution, but the signal that a deposit was received is what triggers the state machine. Conflating them means either:

  • Crew gets visibility into payment artifacts (security/privacy violation)
  • The automation is fragile (parsing image metadata, OCR, email notifications)
  • Operational overhead explodes (manual intervention, duplicate systems)

The solution: decouple receipt storage from event reporting. Store the screenshot in a private S3 bucket with zero public path, and trigger automations via a structured intake mechanism using existing infrastructure.

Architecture: Extending the Existing CB Notes Daemon

JADA already runs a Lightsail daemon that polls CB notes and parses them with Claude Haiku. It lives at 34.239.233.28 in handle_cb_notes.py. The daemon:

  • Polls every 60 seconds for new notes in DynamoDB
  • Sends each note to the Haiku API for intent classification
  • Routes to appropriate handlers (booking creation, crew dispatch, etc.)
  • Replies to CB with confirmation or error

Rather than building new infrastructure, we extend this daemon to recognize a new note pattern: DEPOSIT RECEIVED - {booking-slug} - Zelle ${amount}. This fits how CB already interacts with the system and requires minimal new code.

Technical Implementation

Step 1: Update the Haiku Prompt and Intent Classification

In handle_cb_notes.py, the prompt that guides Haiku classification needs to recognize payment intents. Current intents might include booking_create, crew_dispatch, etc. Add:

INTENTS = {
    "booking_create": "Create a new charter booking",
    "crew_dispatch": "Assign crew to an existing booking",
    "payment_received": "Log receipt of deposit or full payment",
    ...
}

Haiku should extract:

  • intent: "payment_received"
  • booking_slug: e.g., "2026-06-14-sarah-sunset"
  • amount: e.g., 500
  • payment_method: "zelle" | "venmo" | "check"
  • payment_type: "deposit" | "full_payment"

Step 2: Add Payment Handler Function

Create handlers/payment_received.py:

def handle_payment_received(parsed_intent, cb_note_id):
    """
    Process a logged payment and trigger downstream automations.
    Args:
        parsed_intent: dict with booking_slug, amount, payment_method, payment_type
        cb_note_id: DynamoDB note ID (for idempotency)
    """
    booking_slug = parsed_intent["booking_slug"]
    amount = parsed_intent["amount"]
    payment_type = parsed_intent["payment_type"]  # "deposit" or "full_payment"
    
    # Query booking from DynamoDB
    booking = get_booking_by_slug(booking_slug)
    if not booking:
        return {"error": f"Booking {booking_slug} not found"}
    
    # Log payment to private table (never crew-visible)
    log_payment(
        booking_id=booking["id"],
        amount=amount,
        payment_type=payment_type,
        received_at=datetime.utcnow(),
        note_id=cb_note_id  # For idempotency
    )
    
    # Update booking state
    if payment_type == "deposit":
        update_booking_state(booking["id"], state="deposit_received")
    elif payment_type == "full_payment":
        update_booking_state(booking["id"], state="paid_in_full")
    
    # Trigger automations
    send_client_confirmation_email(booking, payment_type, amount)
    update_jada_internal_calendar(booking, payment_type)
    update_crew_page(booking)
    update_guest_page(booking)
    
    return {"status": "success", "booking_slug": booking_slug}

Step 3: Private Payment Log in DynamoDB

Create a new table: jada-payments-private with schema:

  • PK (Partition Key): booking_id
  • SK (Sort Key): received_at (timestamp)
  • Attributes: amount, payment_type, payment_method, note_id, timestamp

This table is only accessed by Lambda functions and the operator dashboard — never exposed to crew view or guest pages. Queries are admin-only.

Step 4: State Machine for Crew Authorization

The crew page queries the booking state, not the payment log. Update the booking record in DynamoDB with a payment_state field:

  • no_deposit: Red banner: "Booking not confirmed. No departure authorized."
  • deposit_received: Yellow/amber banner: "Deposit received. Do not depart until full payment is logged."
  • paid_in_full: Green: "Fully paid. Clear to depart."

The crew page renders this banner at the top of the booking detail, but never displays amounts or payment artifacts.

Step 5: Optional Screenshot Storage (Private S3)

If CB wants to upload a Zelle screenshot for the record, provide an admin form at ops.queenofsandiego.com/payment-received that:

  • Requires CB login
  • Lists pending bookings in a dropdown
  • Allows optional screenshot upload
  • Generates a pre-signed POST URL to s3://jada-private/receipts/{booking_id}/{timestamp}.png
  • Submits the structured payment note to the daemon after upload completes

The bucket policy for jada-private:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::jada-private/*",
      "Condition": {