```html

Integrating Payment Logging into AWS Lambda + CloudFront: A Case Study in Serverless SPA Architecture

What Was Done

We extended a serverless event-management application (Ship Captain Crew) to support payment logging for patrons. This required:

  • Adding Gmail credential injection into a Lambda environment to enable transactional email
  • Implementing a new handle_payment_logged Lambda handler to persist payment records to DynamoDB
  • Building a "Log Payment" modal UI in the dispatch HTML (S3-hosted SPA)
  • Wiring a new CloudFront behavior to route payment endpoints to the Lambda Function URL
  • Fixing a discovered routing bug where waiver requests were falling through to S3

Technical Details

Lambda Payment Handler Implementation

The Lambda function at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py needed a new payment logging endpoint. We added:

def handle_payment_logged(event, context):
    """
    POST /api/payment-logged
    Receives: { event_id, patron_name, amount, payment_method, notes }
    Returns: { success: bool, message: str, payment_id: str }
    """
    # Extract auth token from headers
    # Validate against ADMIN_PASS_HASH env var
    # Insert record into shipcaptaincrew DynamoDB table
    # Send confirmation email via SES + Gmail credentials
    # Return payment ID + success status

Why this pattern? The existing Lambda already handled event management, authentication, and DynamoDB writes. Co-locating payment logging here avoided introducing a second function (reducing cold-start latency and operational overhead) and reused established auth middleware.

Gmail Credential Injection

The application uses AWS SES for transactional emails but needed Gmail SMTP credentials for enhanced deliverability. We:

  1. Generated Gmail App Password (stored securely, not in repo)
  2. Added to Lambda environment variables via aws lambda update-function-configuration:
    • GMAIL_USER
    • GMAIL_PASSWORD
    • GMAIL_SMTP_HOST=smtp.gmail.com
    • GMAIL_SMTP_PORT=587
  3. Created helper function in Lambda to abstract SMTP logic

Why not just SES? SES alone works, but Gmail credentials provide a fallback and are required if the application later needs to send from a Gmail-authenticated address for compliance reasons.

CloudFront Routing Fix

During reconnaissance, we discovered a critical routing bug: requests to /g/2026-05-23/waiver were being routed to S3 instead of the Lambda Function URL. The dispatch SPA would then try to parse 2026-05-23/waiver as an event ID, fetch /api/g/2026-05-23/waiver, receive HTML from the correct Lambda handler, and fail when trying to parse HTML as JSON.

The fix involved adding a new CloudFront behavior in the shipcaptaincrew distribution:

Path Pattern: /g/*/waiver
Origin: Lambda Function URL (not S3)
Compress: Yes
Cache TTL: 0 (bypass caching for dynamic waiver forms)

This ensures waiver requests hit handle_waiver_get (line 1697 in lambda_function.py) directly, which returns properly-formatted HTML.

Infrastructure Changes

DynamoDB Schema

The shipcaptaincrew DynamoDB table already had the structure to support payment records. We added a new record type with:

  • pk: EVENT#{event_id}
  • sk: PAYMENT#{uuid}
  • timestamp: ISO 8601 datetime
  • patron_name: String
  • amount_cents: Integer (stored as cents to avoid float precision issues)
  • payment_method: String (e.g., "cash", "venmo", "check")
  • notes: String (optional memo)
  • logged_by: Email of admin who recorded payment

S3 Bucket

The dispatch HTML is stored at s3://shipcaptaincrew-[account-id]/dispatch.html. We pulled the current production version (2463 lines) before making edits to ensure we didn't lose existing functionality.

Lambda Environment & Deployment

Updated Lambda configuration via:

aws lambda update-function-configuration \
  --function-name shipcaptaincrew \
  --environment Variables={GMAIL_USER=...,GMAIL_PASSWORD=...,GMAIL_SMTP_HOST=...}

aws lambda update-function-code \
  --function-name shipcaptaincrew \
  --zip-file fileb://lambda_deployment.zip

Both updates were staged and waited for CloudWatch to confirm deployment completion before smoke testing.

Key Decisions

Why Not a Separate Lambda for Payments?

We could have created shipcaptaincrew-payments as a separate function. However:

  • Shared Auth: Payment endpoints reuse the existing admin token validation
  • DynamoDB Access: Same table, same IAM role
  • Email Infrastructure: Same SES/Gmail credentials
  • Cold Starts: One warm function is faster than two cold ones

A separate function would only be justified if payment logging had different scaling characteristics or required different permissions.

Why Store Amounts in Cents?

JavaScript numbers lose precision with floating-point currency (e.g., 0.1 + 0.2 !== 0.3). Storing cents as integers eliminates this problem and is industry standard for financial systems.

Why CloudFront Behavior Before Fix?

We added the waiver behavior before testing payment logging because CloudFront routing errors cascade into the SPA's error handling, making the payment feature appear broken when the real issue was upstream. Fixing infrastructure first meant cleaner test results.

What's Next

Once this deploys to staging and is validated:

  • Email notification to CB confirming payment logging is ready for admin use
  • Update admin dashboard kanban board to mark this task complete
  • Monitor CloudWatch Logs for Lambda errors in the new handle_payment_logged handler
  • Consider adding a payment audit trail (read-only view showing all logged payments for an event)
  • Fix the slug naming convention: event IDs should follow YYYY-MM-DD-firstname-[period],