```html

Building a Self-Contained Payment Logging System for Multi-Tenant Property Management

We recently built a complete payment ingestion and logging system for a property management platform that needed to operate entirely within its own domain namespace, completely divorced from the parent organization's email infrastructure. This post walks through the architecture, implementation details, and key decisions that made this possible.

The Problem

The scenario: tenants made security deposit payments via Zelle, and we needed to:

  • Send them login credentials for their tenant portal via email from the property management domain (not the parent company)
  • Create a way to log those Zelle payments that didn't require manual data entry
  • Ideally, auto-ingest payment notifications from the landlord's bank into the system
  • Keep everything within the dangerouscentaur.com domain—no cross-domain email leakage

The constraint was strict: complete domain isolation. No emails from queenofsandiego.com, no shared infrastructure. This became the architectural north star.

Architecture Overview

The solution uses three main components:

  • Tenant Portal Hub: A static HTML/JS site hosted at 3028fiftyfirststreet.92105.dangerouscentaur.com serving as the credential store and dashboard
  • Lambda-Based Payment API: AWS Lambda functions with Function URLs handling payment ingestion and validation
  • Google Apps Script (GAS) Email Command Handler: A webhook receiver that parses forwarded bank emails and triggers payment logging via Lambda

The data flow: Bank email → Forward to GAS handler → Parse Zelle details → Call Lambda admin endpoint → Update receipts.json in S3 → CloudFront invalidation.

Technical Details: Domain Separation

The first decision: DNS and email routing must be entirely separate.

We set up ImprovMX aliases for dangerouscentaur.com to forward incoming mail to the landlord's actual inbox. This means:

  • Emails can be sent from addresses like noreply@dangerouscentaur.com via Amazon SES
  • Replies/forwards go back to the same domain for ingestion
  • No cross-domain email headers or authentication issues

In AWS SES, we verified the dangerouscentaur.com domain and obtained DKIM tokens to prevent delivery to spam. The verification happened at the DNS provider level—critical for domain reputation.

Credential Distribution: Dynamic Portal Generation

File modified: /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/index.html

Rather than hard-coding credentials in the HTML (a security anti-pattern), we generated fresh temporary passwords using bcrypt hashing and embedded them in a credentials table in the portal HTML. The table structure:

<table id="credentialsTable">
  <tr>
    <td>Tenant Name</td>
    <td>Username</td>
    <td>Temporary Password</td>
  </tr>
  ...
</table>

Each password was hashed server-side before being baked into the HTML. The portal was then uploaded to the S3 bucket backing the CloudFront distribution for 3028fiftyfirststreet.92105.dangerouscentaur.com, and we invalidated the entire CloudFront cache to force immediate edge node refresh.

AWS CLI example (no credentials shown):

aws s3 cp index.html s3://tenant-hub-bucket/index.html
aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/*"

Payment Ingestion: Lambda Receipt Action

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

This Lambda function has two responsibilities:

  • Public endpoint: Serves the receipts list to the portal UI
  • Admin endpoint: Accepts payment logs when called with a valid admin token

The key pattern: token-based authentication. All requests to the admin endpoint require a header like Authorization: Bearer ADMIN_TOKEN. This token is:

  • Generated once during setup
  • Stored as a Lambda environment variable (encrypted at rest by AWS)
  • Also stored in the deployment repository's repos.env file for GAS to reference

When a payment log request arrives, the Lambda:

  1. Validates the token
  2. Parses the payment details (tenant, amount, date, method)
  3. Reads the current receipts.json from S3
  4. Appends the new receipt
  5. Writes it back to S3
  6. Returns success or error

The Lambda function is deployed with a public Function URL (no API Gateway), keeping infrastructure minimal:

aws lambda get-function-url-config --function-name receipt-action

Email-to-Payment: Google Apps Script Handler

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

This is the clever part. The landlord forwards Zelle emails from their bank to a GAS-monitored inbox. The script:

  1. Receives the forwarded email via webhook trigger
  2. Parses the subject and body for Zelle payment details (tenant ID, amount)
  3. Calls the Lambda receipt-action endpoint with the admin token
  4. Logs success/failure back to a sheet for audit

The execute block in the GAS command handler now includes:

case 'log_rent_payment':
  var tenantId = params.tenant_id;
  var amount = params.amount;
  var date = params.date;
  var adminToken = PropertiesService.getScriptProperties().getProperty('ADMIN_TOKEN');
  
  var payload = {
    tenant_id: tenantId,
    amount: amount,
    date: date,
    method: 'zelle'
  };
  
  var response = UrlFetchApp.fetch(RECEIPT_ACTION_URL, {
    method: 'post',
    payload: JSON.stringify(payload),
    headers: {
      'Authorization': 'Bearer ' + adminToken,
      'Content-Type': 'application/json'
    }
  });
  
  return response.getContentText();
  break;

The GAS script lives in the queenofsandiego.com project (it's shared infrastructure), but all outbound calls go to the dangerouscentaur.com domain