```html

Building a Multi-Lambda Payment Receipt Pipeline for Tenant Management

We built a complete payment logging system for a property management tenant hub, enabling landlords to forward bank notifications directly into an automated receipt pipeline. This post covers the architecture, Lambda functions, authentication patterns, and email integration that makes it work.

The Challenge

A tenant portal at 3028fiftyfirststreet.92105.dangerouscentaur.com needed a way for property managers to log rent payments received via Zelle without manually typing them into a system. The solution had to:

  • Accept forwarded bank emails and SMS messages
  • Parse payment details from unstructured text
  • Validate requests with admin-only authentication
  • Persist receipt data to S3
  • Display results in the tenant hub UI without page refresh
  • Run entirely within the dangerouscentaur.com domain (not queenofsandiego.com)

Architecture Overview

We deployed three Lambda functions working in concert:

  • receipt-action Lambda — Handles admin commands including log_rent_payment
  • lambda-email-parser Lambda — Extracts structured data from forwarded emails
  • Google Apps Script — Routes incoming emails to the correct Lambda via HTTP

A Google Workspace inbox alias forwards all payment notifications to a GAS trigger, which parses the content and calls the appropriate Lambda function. Admin tokens authenticate each request.

Lambda Function Details

Receipt-Action Lambda

Located at /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/scripts/lambda-receipt-action/lambda_function.py

This function handles administrative actions on the receipts dataset. The core pattern:

def lambda_handler(event, context):
    # Validate admin token from environment variable
    admin_token = os.environ.get('ADMIN_TOKEN')
    request_token = event.get('headers', {}).get('Authorization', '')
    
    if not validate_token(request_token, admin_token):
        return error_response(403, 'Unauthorized')
    
    action = event.get('action')
    
    if action == 'log_rent_payment':
        return log_rent_payment(event)
    elif action == 'list_receipts':
        return list_receipts()
    else:
        return error_response(400, 'Unknown action')

The log_rent_payment handler creates a new receipt entry in the S3-backed receipts JSON file, adding timestamps and payment metadata. It accepts:

  • tenant_id — Identifier matching the user table in the hub
  • amount — Payment amount in dollars
  • payment_method — "zelle", "check", "transfer", etc.
  • reference — Bank transaction ID or confirmation code
  • notes — Optional additional context

We store all receipts in a single JSON file in the tenant portal S3 bucket under receipts.json. This is read by the hub UI to populate a receipts section without requiring database infrastructure.

Email Parser Lambda

Created at /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/scripts/lambda-email-parser/lambda_function.py

This function extracts structured payment data from unstructured email text:

def parse_zelle_email(email_body):
    """Extract Zelle payment details from bank notification"""
    patterns = {
        'amount': r'\$(\d+(?:,\d{3})*(?:\.\d{2})?)',
        'confirmation': r'(?:confirmation|reference|confirmation ID)[:\s]+([A-Z0-9]+)',
        'sender': r'(?:From|received from)[:\s]+([^<\n]+)',
        'timestamp': r'(?:Date|Time)[:\s]+(.+?)(?:\n|$)'
    }
    
    result = {}
    for key, pattern in patterns.items():
        match = re.search(pattern, email_body, re.IGNORECASE)
        if match:
            result[key] = match.group(1)
    
    return result

The parser returns a normalized dictionary that the GAS script can use to call the receipt-action Lambda.

Google Apps Script Integration

File: /Users/cb/Documents/repos/sites/queenofsandiego.com/WarmLeadResponder.gs

This GAS project handles inbound emails and routes them to the appropriate handlers. The key addition for payment processing:

function doPost(e) {
    const action = e.parameter.action;
    const adminToken = PropertiesService.getScriptProperties()
        .getProperty('ADMIN_TOKEN');
    
    if (action === 'log_rent_payment') {
        return callLambda('receipt-action', {
            action: 'log_rent_payment',
            tenant_id: e.parameter.tenant_id,
            amount: e.parameter.amount,
            payment_method: e.parameter.payment_method,
            reference: e.parameter.reference,
            notes: e.parameter.notes
        }, adminToken);
    }
}

function callLambda(functionName, payload, token) {
    const url = 'https://' + LAMBDA_URL_ID + '.lambda-url.' + 
                AWS_REGION + '.on.aws/';
    
    const options = {
        method: 'post',
        headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json'
        },
        payload: JSON.stringify(payload),
        muteHttpExceptions: true
    };
    
    const response = UrlFetchApp.fetch(url, options);
    return ContentService.createTextOutput(response.getContentText())
        .setMimeType(ContentService.MimeType.JSON);
}

Infrastructure & Deployment

S3 & CloudFront

The tenant hub is deployed to an S3 bucket with CloudFront distribution caching. Key resources:

  • S3 Bucket — Stores index.html, receipts.json, and Lambda scripts
  • CloudFront Distribution — Serves the tenant portal with cache invalidation for rapid updates
  • Origin Configuration — S3 origin with OAC (Origin Access Control) for security

After updating Lambda code or credentials, we invalidate CloudFront caches with:

aws cloudfront create-invalidation \
  --distribution-id DIST_ID \
  --paths "/*"

Lambda Function URLs

Both Lambda functions are exposed via function URLs with resource-based policies. The receipt-action Lambda URL is configured to:

  • Accept POST requests with JSON payloads
  • Validate Bearer token in Authorization header