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.comdomain—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.comserving 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.comvia 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.envfile for GAS to reference
When a payment log request arrives, the Lambda:
- Validates the token
- Parses the payment details (tenant, amount, date, method)
- Reads the current
receipts.jsonfrom S3 - Appends the new receipt
- Writes it back to S3
- 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:
- Receives the forwarded email via webhook trigger
- Parses the subject and body for Zelle payment details (tenant ID, amount)
- Calls the Lambda receipt-action endpoint with the admin token
- 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