Building a Payment Logging System for Crew Management: Lambda, DynamoDB, and CloudFront Integration
What Was Done
We integrated a payment logging feature into the Ship Captain Crew (SCC) tool, enabling administrators to record patron payments directly from the crew dashboard. This required coordinating changes across three layers: a Python Lambda function backend, a DynamoDB table schema, an HTML/JavaScript frontend dispatcher, and CloudFront routing rules.
The core work involved:
- Adding Gmail credential management and email notification helpers to the Lambda function
- Implementing a
handle_log_paymentPOST handler in Lambda to persist payment records to DynamoDB - Building a "Log Payment" modal UI in the dispatch HTML with form validation and API integration
- Fixing a routing bug where waiver requests (
/g/*/waiver) were falling through to S3 instead of Lambda - Staging and deploying changes with CloudFront cache invalidation
Technical Details: Lambda Backend Changes
The Lambda function at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py required two major additions.
Gmail Credentials and Email Helpers: We added helper functions to send notifications when payments are logged. The function reads Gmail API credentials stored in Lambda environment variables (GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN) and uses the Gmail API to send emails. This follows the pattern already established in the codebase for administrative notifications:
def send_email_via_gmail(to_address, subject, body_html):
"""Send email using Gmail API credentials from env vars."""
# Read credentials from Lambda environment
# Use stored refresh token to obtain access token
# Call Gmail API send method with MIME-formatted message
# Return success/failure status
The reason for using Gmail API instead of SES (Simple Email Service) is that the Queen of San Diego already maintains Gmail credentials for administrative workflows, reducing operational overhead and keeping email delivery within the same trusted channel.
Payment Handler: We implemented handle_log_payment(event) as a new POST route that accepts a JSON payload with fields: event_id, patron_name, amount_usd, and payment_method. The handler:
- Validates the admin token (using existing
verify_admin_tokenhelper) - Writes a record to the DynamoDB table (schema:
PK=event_id#patron_id,SK=payment_log) with timestamp and amount - Calls the Gmail helper to notify the payment collector that the log was recorded
- Returns HTTP 200 with confirmation, or 401/400 on auth/validation failure
This handler is wired into the main lambda_handler routing logic before the catch-all 404, following the existing pattern for handle_get_event, handle_list_events, etc.
Frontend: Dispatch HTML Modal
The dispatch HTML at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/index.html received a new modal component. We added:
- A "Log Payment" button in the crew dashboard sidebar (visible only when admin is logged in)
- A modal dialog with form fields: event selector dropdown, patron name input, amount input (USD), payment method radio buttons (cash/check/card/venmo/paypal)
- Form validation before submit (required fields, positive amount)
- JavaScript function
openLogPaymentModal()to display the modal andsubmitPaymentLog()to POST to/api/events/{event_id}/payment-log
The modal follows the existing pattern established by other forms in the dispatcher. We use the active class toggle for visibility (not inline styles) to match the current codebase convention, and we reuse the existing apiFetch helper to handle authentication headers and error responses.
Success flow: user fills form → clicks "Log Payment" → form POSTs to Lambda → Lambda writes DynamoDB record → Lambda sends email → modal closes and shows success toast notification → payment collector receives confirmation email.
Infrastructure: CloudFront and Routing Fixes
During reconnaissance, we discovered a critical routing bug: requests to `/g/{event_id}/waiver` were returning HTML parse errors in the frontend because CloudFront was routing them to S3 instead of the Lambda Function URL.
Root Cause: The CloudFront distribution (ID: E1234... [actual ID redacted] on queenofsandiego.com) had no explicit behavior for the `/g/*/waiver` path pattern, so requests fell back to the S3 origin, which returned the dispatch HTML SPA. The SPA then tried to parse `/g/2026-05-23/waiver` as an event_id, made a request to `/api/g/2026-05-23/waiver`, received HTML in response (from Lambda, which wasn't being called), and threw a JSON parse error.
Fix: We added a new CloudFront behavior:
- Path pattern:
/g/*/waiver - Origin: Lambda Function URL (not S3)
- Allowed HTTP methods: GET, HEAD, OPTIONS (waivers are read-only from crew perspective)
- Cache behavior: forward authorization headers, TTL 3600 seconds
This ensures waiver requests hit Lambda's handle_waiver_get function (line 1697 in lambda_function.py) directly, which returns valid HTML with the waiver form embedded.
We also invalidated the CloudFront cache for staging:
aws cloudfront create-invalidation --distribution-id [ID] --paths "/_staging/*"
This forced the CDN to re-fetch dispatch HTML and pick up our new payment modal code.
DynamoDB Schema Updates
The payment log records are written to the same DynamoDB table as events, using a composite key strategy:
PK(Partition Key):event_id#patron_id(e.g.,2026-05-23-charter-sunset#pat-001)SK(Sort Key):payment_log#{timestamp}(e.g.,payment_log#2025-06-15T14:32:00Z)- Additional attributes:
amount_usd(number),method(string: cash/check/card/venmo/paypal),logged_by_admin(string),email_sent(boolean)
This design allows us to query all payments for an event+patron pair by using the PK, and payments are naturally time-ordered by the timestamp in the SK. No schema migration was required—DynamoDB is schemaless—but we documented the expected shape to guide Lambda code.
Deployment and Testing
Deployment followed a standard staging-then-production flow: