Building a Serverless Payment Logging System for Event Crew Management
During this development session, we implemented a payment logging feature for the ShipCaptainCrew tool—a Lambda-backed event management system used by our crew to track patron payments. This post walks through the architectural decisions, infrastructure wiring, and code patterns we used to extend the existing system with payment capture capabilities.
What We Built
The ShipCaptainCrew tool needed a way for crew members to log when a patron has paid for an event. Previously, the system tracked events and crew assignments but had no payment state. We added:
- A modal UI component in the dispatch SPA to capture and log payments
- Lambda handlers to persist payment records to DynamoDB
- Backend support for Gmail token retention across deployments
- Admin authentication endpoints for secure payment operations
Technical Architecture
Frontend: Modal-Driven Payment Capture
The dispatch HTML lives at /tools/shipcaptaincrew/index.html in the queenofsandiego.com S3 bucket. It's a single-page application served through CloudFront with Lambda Function URL routing for dynamic endpoints.
We added a payment logging modal following the existing modal pattern in the codebase. The pattern uses an active CSS class to show/hide modals rather than inline styles, keeping separation of concerns:
<div id="log-payment-modal" class="modal">
<div class="modal-content">
<h3>Log Payment</h3>
<input type="text" id="amount-input" placeholder="Amount">
<button onclick="submitPayment()">Confirm</button>
</div>
</div>
The modal integrates with the existing apiFetch helper, which handles auth token injection and error handling. When a crew member clicks "Log Payment" on an event card, the JavaScript:
- Captures the event ID from the card's data attributes
- Opens the modal and waits for user input
- Calls
apiFetch('/api/payment/log', { event_id, amount, ... }) - Updates the event card UI on success or shows an error toast
Backend: Lambda Handlers and DynamoDB Schema
The Lambda function at /tools/shipcaptaincrew/lambda_function.py handles all business logic. We extended the routing to include payment endpoints:
POST /api/payment/log → handle_payment_log()
The handler validates:
- Admin authentication: Compares the request's auth token against the
SCC_ADMIN_PASS_HASHenvironment variable - Event existence: Queries DynamoDB Events table by event_id
- Amount validity: Ensures the payment amount is positive and doesn't exceed the event total
On success, the handler writes a payment record to the DynamoDB Events table with a new attribute:
{
"event_id": "2026-05-23-captain-cory",
"payment_cleared": true,
"payment_amount": 350.00,
"payment_timestamp": "2024-01-15T14:23:45Z",
"payment_logged_by": "crew_member_id"
}
We chose to store payment metadata on the event record itself rather than a separate table because:
- Payment is a single state transition per event (not a time-series)
- Keeps the read path simple—crew sees payment status in the list query
- Reduces DynamoDB round-trips and complexity
- Maintains referential integrity (no orphaned payment records)
Infrastructure & Deployment
Environment Variable Management
The Lambda function relies on several environment variables for auth and integration:
SCC_ADMIN_PASS_HASH: Bcrypt hash of the admin password (set during this session)GMAIL_TOKEN_*: OAuth credentials for SES email notificationsSCC_DISPATCH_BUCKET: S3 bucket for the dispatch HTML
During deployment, we:
- Pulled the current Lambda environment from AWS using the AWS CLI
- Merged in new payment-related variables
- Ensured Gmail tokens were preserved across updates (they're sensitive and don't live in source control)
- Deployed the merged config to Lambda
aws lambda update-function-configuration \
--function-name shipcaptaincrew \
--environment Variables={...merged env object...}
CloudFront & S3 Routing
The distribution (ID: EXXXXXXXX) has two main behaviors:
/api/*→ Lambda Function URL (API Gateway equivalent, but cheaper and lower latency)/*→ S3 origin, serving the dispatch SPA with index.html as fallback
During testing, we discovered a routing bug: /g/*/waiver paths were falling through to S3 instead of hitting the Lambda waiver handler. The fix is to add a CloudFront behavior:
/g/*/waiver → Lambda Function URL
/g/*/payment → Lambda Function URL (new, for payment endpoints)
This ensures all crew-facing routes go through Lambda where auth and business logic live.
Staging & Testing Workflow
We deployed to a staging slot before production:
- Built a new Lambda zip from the updated Python source
- Deployed to the
stagingenvironment variable (CloudFront alternate origin) - Invalidated the
/_staging/*cache distribution to force CloudFront to re-fetch - Smoke-tested the new payment endpoints with admin credentials
- Verified event cards rendered correctly with the new modal
Once staging passed, we promoted the same code to production by updating the main Lambda alias and invalidating the CloudFront /* path cache.
Key Architectural Decisions
Why Single Table for Events + Payments
Rather than create a separate Payments table, we added payment fields to the Events table. This follows DynamoDB best practices for access patterns:
- Query pattern: "Get all events and their payment status" — single query to Events table
- Write pattern: "Log a payment for an event" — single UpdateItem on that event
- No joins: Avoids the N+1 problem of fetching events, then fetching their payments
Admin Auth via Environment Variable Hash
We store an admin password hash in Lambda environment variables rather than in DynamoDB because