Building a Self-Contained Tenant Payment Portal with Automated Zelle Receipt Logging

We built a complete tenant management system for a rental property that handles credential distribution, payment tracking, and automated receipt ingestion—all while maintaining strict domain isolation from a separate business entity.

The Problem

A property manager needed to:

  • Issue secure credentials to tenants for a private portal
  • Track rental payments made via Zelle (bank transfers)
  • Avoid manual data entry for payment logging
  • Keep all infrastructure completely isolated on the dangerouscentaur.com domain (not sharing with queenofsandiego.com)

The critical constraint: domain isolation. Previous email communications from queenofsandiego.com created security and brand concerns, so we rebuilt the entire system within the dangerouscentaur.com namespace.

Architecture Overview

The system consists of three integrated components:

  • Frontend: Static HTML tenant hub with credential display and payment receipt viewing
  • Lambda APIs: Serverless functions for receipt action logging and email parsing
  • Email Ingestion Pipeline: Google Apps Script that forwards Zelle emails to Lambda for automatic payment logging

Tenant Hub Portal

The hub lives at https://3028fiftyfirststreet.92105.dangerouscentaur.com/ and is deployed to an S3 bucket at s3://3028fiftyfirststreet.92105.dangerouscentaur.com/. The file /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/index.html contains:

  • A credentials table with generated temporary passwords (refreshed for each tenant)
  • A receipts section that dynamically loads payment history from the backend
  • JavaScript handlers that call Lambda endpoints to populate receipts and execute admin actions

The portal uses environment-based configuration to reference Lambda function URLs, allowing the same codebase to work across different deployments without hardcoding sensitive URLs.

Why static S3 + CloudFront: S3 provides version control history, CloudFront handles HTTPS/caching, and there's no server overhead. We invalidated the CloudFront distribution (specific distribution ID tracked in deployment scripts) after each update to ensure fresh content.

Credential Generation and Distribution

When tenants pay the security deposit, we:

  1. Generate fresh temporary passwords for each tenant
  2. Hash passwords using standard algorithms (stored in the portal's HTML table)
  3. Update index.html with new hashes
  4. Redeploy to S3 and invalidate CloudFront cache
  5. Send credential emails via AWS SES from a verified dangerouscentaur.com sender address

All SES email sending was done via AWS CLI from the authenticated environment:

aws ses send-email \
  --from "noreply@dangerouscentaur.com" \
  --to [tenant-email] \
  --subject "Your Tenant Portal Credentials" \
  --text "Visit: https://3028fiftyfirststreet.92105.dangerouscentaur.com/"

Why verified sender domain: Previous emails from queenofsandiego.com raised concerns about domain legitimacy and brand confusion. By establishing dangerouscentaur.com as the authoritative sender (with DKIM/SPF records configured), we ensure email deliverability and clarity about which entity is communicating.

Lambda Functions for Receipt Management

Two Lambda functions handle the backend logic:

Receipt Action Logger

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

This function provides an HTTP endpoint (via Lambda function URL) that accepts admin actions, including a new log_rent_payment action:

def log_rent_payment(tenant_id, amount, payment_date, method="zelle"):
    """Log a rent payment to receipts.json in S3"""
    receipt = {
        "tenant_id": tenant_id,
        "amount": float(amount),
        "date": payment_date,
        "method": method,
        "logged_at": datetime.now().isoformat()
    }
    # Append to receipts.json in S3
    return receipt

The function validates an ADMIN_TOKEN environment variable before executing any action, preventing unauthorized payment logging. The token is set during Lambda deployment and stored in the runtime environment.

Email Parser Lambda

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

This function receives forwarded emails from the Google Apps Script pipeline and extracts payment details from Zelle email formats. It then calls the receipt-action Lambda to log the payment.

Google Apps Script Email Forwarding

File: /Users/cb/Documents/repos/sites/queenofsandiego.com/WarmLeadResponder.gs (note: this GAS project is actually bound to a dangerouscentaur.com-owned Google Workspace account, not queenofsandiego.com—naming is legacy)

The script implements an email handler that:

  1. Monitors an inbox alias on the dangerouscentaur.com domain (e.g., payments@dangerouscentaur.com)
  2. Detects forwarded Zelle confirmation emails based on sender and subject patterns
  3. Extracts tenant ID, amount, and date from email body/headers
  4. Calls the Lambda email-parser endpoint with extracted data

The key function in the execute block:

function log_rent_payment(tenantId, amount, paymentDate) {
  var payload = {
    action: "log_rent_payment",
    tenant_id: tenantId,
    amount: amount,
    payment_date: paymentDate,
    admin_token: ADMIN_TOKEN
  };
  
  var options = {
    method: "post",
    payload: JSON.stringify(payload),
    contentType: "application/json"
  };
  
  var response = UrlFetchApp.fetch(LAMBDA_URL, options);
  return JSON.parse(response.getContentText());
}

Why Google Apps Script: GAS is tightly integrated with Gmail and Google Workspace. The property manager forwards Zelle emails to a GAS-monitored inbox, and the script automatically parses and logs payments without manual intervention. This beats separate IFTTT or Zapier services and keeps everything in one ecosystem.