The Trinity Beast — Subscription Lifecycle

Complete guide to subscription management — Stripe integration, webhook processing, tier management, LRS add-on lifecycle, payment failure handling, refund processing, and cache invalidation.

Lambda: trinity-beast-receipt + trinity-beast-email-sender Webhook Endpoint: https://receipt.cpmp-site.org/webhook Webhooks: Stripe → API Gateway → Lambda Updated: July 26, 2026 Version: v19

Table of Contents

  1. Overview
  2. Subscription Tiers
  3. New Subscription Flow
  4. LRS Add-On Activation
  5. Donation Processing
  6. Stripe Webhook Events
  7. Tier Upgrades & Downgrades — Multi-product orchestration, checkout guard, auto-refund
  8. Cancellation Flow
  9. Admin-Initiated Tier Change
  10. Payment Failure & Grace Period
  11. Refund Processing
  12. Cache Invalidation
  13. Email Pipeline
  14. PostgreSQL Functions
  15. Database Schema
  16. Stripe Customer Portal
  17. Account Dashboard

List of Diagrams

  1. Diagram 3.1 — New Subscription Flow
  2. Diagram 6.1 — Webhook Event Routing
  3. Diagram 9.1 — Payment Failure & Recovery

1. Overview

The Trinity Beast subscription lifecycle is managed by two Lambda functions working in tandem:

Architecture (July 2026)

Zero Direct Database Access. The receipt Lambda does NOT connect to Aurora directly. It is NOT in the VPC. All database operations go through the LPO server's admin API over HTTPS (api.cpmp-site.org/admin/sql). Six PostgreSQL functions handle all writes: record_subscription, record_donation, record_lrs_addon, record_webhook_subscription, record_refund, and record_donation_refund. This eliminates the $32/month NAT gateway cost and the 10-second cold-start timeout that plagued direct DB connections from a non-VPC Lambda.

Dedicated Secret. The receipt Lambda uses its own secret: trinity-beast-receipt-secrets (contains only Stripe keys). It has zero access to database credentials, Bedrock keys, or any other infrastructure secret. Blast radius is limited to Stripe test-mode operations.

Async Email Pipeline. Emails are never sent inline. The receipt Lambda builds the complete localized email from pre-translated frames in Valkey, then enqueues the finished message to SQS (trinity-beast-email-queue). The trinity-beast-email-sender Lambda picks it up within 3–6 seconds and delivers via SES. If SQS itself is unavailable (extremely rare), the receipt Lambda falls back to direct SES delivery. Emails are durable — SQS retains messages for 4 days with automatic retry.

Zero Bedrock for Emails. All email content (subject, heading, labels, footer, impact messages) is assembled from pre-translated frames stored in Valkey (email:frames — 12 languages × 45 keys). No AI translation at send time. A Japanese donation receipt assembles entirely from Japanese frame labels — instant, deterministic, zero cost.

Idempotency: Both checkout and webhook paths are idempotent. Checkout sessions are deduplicated via ElastiCache (1-hour TTL). Webhook events are deduplicated by Stripe event ID in ElastiCache (24-hour TTL). Duplicate calls return cached responses without re-processing.

Lambda Specifications

Propertytrinity-beast-receipttrinity-beast-email-sender
RuntimeGo (provided.al2023)Go (provided.al2023)
Memory1770 MB1770 MB
Timeout180 seconds60 seconds
VPCNot in VPCNot in VPC
Secrettrinity-beast-receipt-secretsNone (SES via IAM role)
TriggerAPI Gateway (checkout + webhook)SQS (trinity-beast-email-queue)
SQS ConfigBatch 6, 3s window, ReportBatchItemFailures
Cold Start~650 ms~104 ms
DependenciesStripe, Valkey, SQS, Admin APISES only

2. Subscription Tiers

LPO — Listener Price Oracle (Pull API)

Tier Monthly Queries Rate Limit (QPS) Burst Limit Min Wait (sec) LRS Included
Free 1,000 1 5 1.0 10 reports/month
Pro 50,000 10 20 0.1 10 reports/month (unlimited with add-on)
Enterprise 500,000 50 100 0.02 10 reports/month (unlimited with add-on)
Unlimited Unlimited 100 200 0.01 Unlimited (included)
Lifetime Unlimited 100 200 0.01 Unlimited (included)
AWS Partner Unlimited No limit No limit 0 Unlimited (included)

Webhook Push (Real-Time Delivery)

Webhook Associates receive prices pushed directly to their endpoints — no polling required. Every tier includes both UDP and HTTPS delivery and LRS reporting, and selects from the full prewarmed catalog across all 6 exchanges up to the tier's asset ceiling.

Tier Assets Push Interval Delivery Price
Starter 9 60 seconds UDP + HTTPS $30/month
Standard 30 15 seconds UDP + HTTPS $90/month
Professional 75 6 seconds UDP + HTTPS $210/month
Enterprise 150 (all) 3 seconds UDP + HTTPS $420/month

Token Bucket Rate Limiting: Each API key has a QPS limit enforced by a token bucket algorithm. The bucket refills at rate_limit_qps tokens per second, with a maximum burst of burst_limit tokens. The minimum_wait_seconds is the minimum time between requests when the bucket is empty.

AWS Partner Tier: The exchanges we depend on — Coinbase, Bitstamp, Kraken, Gate.io, Crypto.com, and OKX — share their price feeds with The Trinity Beast at no cost. We pass that generosity forward to the AWS community. If your AWS application needs live crypto prices, partner keys provide unlimited access with no rate limiting, no monthly caps, and no billing. Partners connect via AWS PrivateLink directly to containers — bypassing the ALB and public internet entirely. We receive freely, we give freely.

3. New Subscription Flow

When a customer completes a Stripe Checkout session on the subscription page, the thank-you page renders instantly with translated defaults (Phase 1), then calls the Lambda for personalization (Phase 2). The Lambda processes the session, records everything via PostgreSQL functions, and enqueues a localized receipt email.

Diagram 3.1 — New Subscription Flow
sequenceDiagram
    participant C as Customer
    participant S as Stripe Checkout
    participant TY as Thank-You Page
    participant L as Lambda (receipt)
    participant API as LPO Admin API
    participant DB as Aurora (PG functions)
    participant EC as ElastiCache (Valkey)
    participant SQS as SQS Email Queue
    participant ES as Lambda (email-sender)
    participant SES as SES

    C->>S: Select tier & pay
    S->>TY: Redirect with session_id
    Note over TY: Phase 1: Instant translated defaults
    TY->>L: POST {session_id, type: "subscription"}
    L->>EC: Check session dedup
    EC-->>L: Not found (first call)
    L->>S: Get checkout session (expand: payment_link)
    S-->>L: Session details + Payment Link metadata
    L->>API: SELECT * FROM record_subscription(...)
    API->>DB: Execute PG function
    DB-->>API: {user_id, api_key, txn_id}
    API-->>L: Result rows
    L->>EC: Read email:frames (cached 5min)
    L->>L: Assemble localized email from frames
    L->>SQS: Enqueue pre-built email
    L->>API: /admin/invalidate-key
    L->>EC: Cache response (1hr dedup)
    L-->>TY: {success, api_key, transaction_id}
    Note over TY: Phase 2: Personalize with name/key
    SQS-->>ES: Batch pickup (3-6s)
    ES->>SES: Send email
    SES-->>C: Receipt email arrives
        

Processing Steps

  1. Session dedup checkElastiCache key receipt:session:{id} with 1-hour TTL prevents double-processing if the thank-you page calls twice (page refresh, back button).
  2. Read Stripe session — Retrieves session with AddExpand("payment_link") to access Payment Link metadata. Reads: customer email, name, amount, payment status, customer ID, and metadata (tier from Payment Link metadata, locale from client_reference_id).
  3. Language resolution — Priority: metadata.localeclient_reference_id (passed from cpmp-lang localStorage via the checkout URL) → Stripe session locale → fallback "en".
  4. Execute PostgreSQL function — Calls record_subscription(email, name, tier, amount, stripe_customer_id, stripe_subscription_id, lang, charge_id, metadata) via the admin API. The PG function handles user upsert, API key generation with tier-specific limits, Stripe ID linking, transaction recording, and LRS auto-enable for Unlimited/Lifetime tiers — all in a single atomic operation.
  5. Assemble email — Reads pre-translated frame labels from Valkey (email:frames, 5-minute local cache). Builds the complete HTML email (subject, heading, labels, values, footer) in the customer's language. Zero Bedrock calls.
  6. Enqueue email — Drops the fully-assembled email JSON onto SQS (trinity-beast-email-queue). Takes ~20 ms. Falls back to direct SES if SQS fails.
  7. Invalidate cache — Calls /admin/invalidate-key on both api.cpmp-site.org and lrs.cpmp-site.org so the new key is immediately active.
  8. Cache response — Stores the full response in ElastiCache for 1-hour session dedup.

Thank-You Page — Two-Phase Architecture

The thank-you page does not wait for the Lambda to render content. It uses a two-phase approach:

4. LRS Add-On Activation

The LRS (Listener Reporting Service) add-on upgrades a subscriber from 10 reports/month to unlimited reports. It's a separate Stripe subscription linked to the same customer.

Activation Steps

  1. Execute PostgreSQL function — Calls record_lrs_addon(email, name, amount, stripe_customer_id, stripe_subscription_id, lang, charge_id) via the admin API. The PG function finds the active API key by email, validates eligibility, sets lrs_enabled = true, links the LRS subscription ID, and records the transaction — all atomically.
  2. Assemble and enqueue email — Builds the LRS confirmation email from pre-translated Valkey frames in the subscriber's language, then enqueues to SQS for async delivery.
  3. Invalidate cache — Calls /admin/invalidate-key on both api.cpmp-site.org and lrs.cpmp-site.org so the LRS upgrade takes effect immediately.

Tier routing: The Stripe Payment Link metadata carries tier: lrs_addon. The Lambda reads this from the expanded Payment Link metadata on the checkout session and routes to the LRS handler. No special metadata key detection needed.

5. Donation Processing

Donations follow a focused flow — no API key generation, no tier assignment. The Lambda records the donation via a PostgreSQL function and sends a personalized, localized receipt email with impact-specific messaging.

Processing Steps

  1. Read Stripe session — Expands Payment Link to read impact_type from Payment Link metadata (e.g., freedom, water, wheelchair, medical, bible, audio_bible, provisions, education, sewing, general).
  2. Execute PostgreSQL function — Calls record_donation(email, name, amount, impact_type, lang, charge_id, stripe_customer_id) via the admin API. The PG function upserts the user, records the transaction with impact_type, and returns the transaction ID.
  3. Load impact message — Reads the localized impact message from Valkey (impact:messages key, 5-minute local cache). Each of the 10 impact types has a unique, pre-translated message in all 12 languages describing what the donation specifically funds.
  4. Assemble and enqueue email — Builds the donation receipt from Valkey email frames + the impact-specific message. The email tells the donor exactly what their gift provides (e.g., "Your $510 gift provides clean water for 3 families through a well installation in rural Punjab").

Impact Types

TypeCategoryPayment Link
freedomBrick kiln liberationGive → Save a Soul
waterClean water wellsGive → Clean Water
wheelchairWheelchair provisionGive → Wheelchairs
medicalMedical campsGive → Medical
bibleBible distributionGive → Word of Life
audio_bibleAudio Bible tabletsGive → Audio Bible
provisionsFood & essential suppliesGive → Provisions
educationSchool sponsorshipGive → Education
sewingSewing machine trainingGive → Sewing
generalGreatest needGive → General Support

100% of donation revenue funds freedom from brick kiln debt bondage and community development in Pakistan through Cross Power Ministries of Pakistan (CPMP).

Donation Refunds

When a donation charge is refunded, the Lambda identifies it as a donation (no API key found for the customer) and calls record_donation_refund(charge_id, refund_amount, refund_id). A localized refund confirmation email is sent to the donor. No API key revocation occurs (donors don't have API keys).

6. Stripe Webhook Events

Stripe sends webhook events to https://receipt.cpmp-site.org/webhook (Route 53 → API Gateway → Lambda). Events are verified using the Stripe webhook signing secret and deduplicated by event ID in ElastiCache.

Diagram 6.1 — Webhook Event Routing
flowchart TD
    S[Stripe Event] --> V{Verify Signature}
    V -->|Invalid| R400[400 Invalid]
    V -->|Valid| D{Duplicate Check}
    D -->|Already processed| R200D[200 Already processed]
    D -->|New event| Route{Event Type}
    Route -->|customer.subscription.updated| SU[handleSubscriptionUpdated]
    Route -->|customer.subscription.deleted| SD[handleSubscriptionDeleted]
    Route -->|invoice.payment_failed| PF[handlePaymentFailed]
    Route -->|invoice.paid| PR[handlePaymentRecovered]
    Route -->|charge.refunded| RF[handleChargeRefunded]
    Route -->|Other| Skip[Log & skip]
    SU --> Mark[Mark processed in ElastiCache]
    SD --> Mark
    PF --> Mark
    PR --> Mark
    RF --> Mark
    Mark --> R200[200 OK]

    style S fill:#1e3a5f,stroke:#0f172a,color:#ffffff,font-weight:bold
    style V fill:#7c3aed,stroke:#0f172a,color:#ffffff,font-weight:bold
    style D fill:#7c3aed,stroke:#0f172a,color:#ffffff,font-weight:bold
    style Route fill:#b45309,stroke:#0f172a,color:#ffffff,font-weight:bold
    style SU fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
    style SD fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
    style PF fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
    style PR fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
    style RF fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
    style Skip fill:#475569,stroke:#0f172a,color:#ffffff
    style Mark fill:#1e3a5f,stroke:#0f172a,color:#ffffff,font-weight:bold
    style R200 fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
    style R200D fill:#475569,stroke:#0f172a,color:#ffffff
    style R400 fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
        
Stripe Event Handler Action
customer.subscription.updated handleSubscriptionUpdated Tier change (upgrade/downgrade) or status change (past_due → active)
customer.subscription.deleted handleSubscriptionDeleted Cancellation — downgrade to free, disable LRS, clear Stripe IDs
invoice.payment_failed handlePaymentFailed Set status to past_due, record payment_failed_at timestamp
invoice.paid handlePaymentRecovered Restore status to active, clear payment_failed_at
charge.refunded handleChargeRefunded Revoke API key, set status to refunded, record refund transaction, invalidate cache

Error Handling: All webhook handlers return HTTP 200 to Stripe even on processing errors. This prevents Stripe from retrying and creating duplicate events. Errors are logged for manual review.

7. Tier Upgrades & Downgrades

Why We Built Our Own Orchestration

The Multi-Product Constraint. The Trinity Beast offers two distinct subscription product lines: LPO (pull API — Free, Pro, Enterprise, Unlimited, Lifetime) and Webhook Push (real-time delivery — Starter, Standard, Professional, Enterprise). Stripe's native subscription management assumes a single product hierarchy — its built-in plan switching, proration engine, and Customer Portal work within one product's price list. It cannot enforce cross-product boundaries, cannot prevent an LPO subscriber from "switching" to a Webhook tier, and cannot handle the two-product model where a single customer may have both an LPO subscription and a Webhook subscription simultaneously. We had to build the orchestration layer ourselves.

What Stripe handles vs. what we handle:

  • Stripe handles: Payment collection, proration math (prorating the old price and charging the difference for upgrades or crediting for downgrades), invoice generation, recurring billing, payment method management, and webhook delivery.
  • We handle: Product family validation (REST ↔ REST only, Webhook ↔ Webhook only), tier limit enforcement in Aurora, LRS flag transitions, API key cache invalidation across all containers, the free-tier upgrade path (no existing subscription to prorate), the Lifetime one-time purchase with credit calculation, the checkout guard preventing duplicate subscriptions, and the auto-refund safety net for customers who bypass the guard.

The Three Upgrade Paths

All plan changes are initiated from the Account Dashboard (/dashboard → Change Plan panel). The system determines which path to take based on the customer's current state:

ScenarioPathMechanismHandler
Free → Paid Stripe Checkout (subscription mode) No existing Stripe subscription exists — cannot prorate nothing. A new Checkout Session is created with the target tier's stripe_price_id. On completion, the receipt Lambda creates a new subscription in Aurora. createUpgradeCheckout()
Paid → Paid
(Pro ↔ Enterprise ↔ Unlimited)
Stripe Subscription Update API Existing subscription is PATCHed with the new price ID and proration_behavior: create_prorations. Stripe calculates the proration automatically. Fires customer.subscription.updated webhook → receipt Lambda applies new limits. stripeUpdateSubscription()
Any → Lifetime Stripe Checkout (payment mode, one-time) A one-time Checkout Session is created for the net amount ($3,000 minus credit from the current tier's monthly price). On completion, the receipt Lambda creates a Lifetime key, cancels the existing subscription, and revokes the old key. GoLifetimeHandler

Path 1: Free → Paid (Checkout Session)

Free-tier users have no Stripe subscription to update. The dashboard detects stripe_subscription_id = NULL and creates a new Stripe Checkout Session in subscription mode:

  1. Customer clicks "Upgrade" on the Change Plan panel.
  2. Dashboard calls POST /dashboard/api/change-plan with {"target_tier": "pro"}.
  3. Handler detects no existing subscription → calls createUpgradeCheckout().
  4. Returns a checkout_url — the frontend redirects the browser to Stripe.
  5. Customer pays → Stripe redirects to /dashboard?upgrade=success.
  6. Concurrently, the thank-you page calls the receipt Lambda (same as a new subscription).
  7. Receipt Lambda: creates key with new tier limits, records transaction, sends receipt email, invalidates cache.

Existing account detection: The receipt Lambda checks if the email already has an active LPO key. If it does (which happens on this path — they have a free key), the Lambda detects the tier mismatch and handles it as an upgrade. The existing free key is superseded by the new paid key.

Path 2: Paid → Paid (Subscription Update)

When a subscriber already has a Stripe subscription (any paid tier), the system uses the Stripe Subscription Update API for seamless proration:

  1. Customer selects a new tier on the Change Plan panel.
  2. Dashboard calls POST /dashboard/api/change-plan with the target tier.
  3. Handler validates:
    • Target tier exists in tier_catalog with a valid stripe_price_id
    • Not the same tier they're already on
    • Product family matches (cannot switch between REST and Webhook)
  4. Handler retrieves the subscription from Stripe, extracts the subscription item ID.
  5. Calls PATCH /v1/subscriptions/{id} with the new price, tier metadata, and proration_behavior: create_prorations.
  6. Stripe fires customer.subscription.updated → receipt Lambda applies the new tier limits in Aurora.

Proration is automatic. Stripe calculates the prorated credit for unused time on the old plan and charges the prorated amount for the new plan on the same billing cycle. Upgrades result in an immediate additional charge (prorated difference). Downgrades result in a credit applied to the next invoice. The subscriber never needs to take action — the billing adjusts seamlessly.

Path 3: Any → Lifetime (One-Time Purchase with Credit)

Lifetime is a $3,000 one-time purchase that permanently unlocks unlimited access. The system credits the customer's current tier monthly price as a thank-you for their existing commitment:

  1. Customer clicks "Go Lifetime" on the Change Plan panel (gold card).
  2. Dashboard calls GET /dashboard/api/lifetime-quote to display the credit and net amount.
  3. Credit = current tier's price_cents from tier_catalog (e.g., Enterprise = $100 credit).
  4. Net = $3,000 − credit (e.g., $3,000 − $100 = $2,900).
  5. Customer confirms → dashboard calls POST /dashboard/api/go-lifetime.
  6. Handler creates a Stripe Checkout Session in payment mode (one-time) with unit_amount = net_cents.
  7. Metadata carries: tier=lifetime, upgrade_from=enterprise, credit_cents=10000, source=dashboard-go-lifetime.
  8. Customer pays → receipt Lambda handles the rest: creates Lifetime key (unlimited, no rate limiting), cancels existing subscription in Stripe, revokes old key, sends receipt email.
  9. If the account also holds a separately-billed LRS add-on subscription (stripe_lrs_subscription_id), that subscription is cancelled too — Lifetime includes LRS free, so the addon becomes redundant the moment Lifetime is active.

Why credit from tier_catalog and not from the last transaction? Transaction amounts can be stale — a customer who switched tiers mid-month may have prorated charges that don't represent their plan's actual cost. The tier_catalog price is the canonical, current monthly cost for their plan. Simple and predictable: Enterprise = $100 credit, Unlimited = $300 credit, Pro = $30 credit.

Cleaning Up a Redundant LRS Add-On on Upgrade

Setting lrs_enabled = true by tier is not the same as stopping the bill for a separate addon subscription that the tier just made unnecessary. Both this Go Lifetime path and the ordinary Change Plan path (upgrading to Unlimited) call cancelSubscriptionWithProratedRefund() (trinity-beast-receipt-lambda/cmd/handler/main.go) against StripeLRSSubscriptionID whenever the destination tier already includes LRS. This does two things, not one:

  1. Cancels the Stripe subscription immediately — not at period end, since the new tier already covers LRS from the moment it takes effect.
  2. Refunds the unused portion of the customer's most recent LRS-addon charge, computed from that subscription item's own current_period_start/current_period_end — a clean cancellation still leaves the customer having pre-paid for time on the addon they will never use, and that time is owed back.

The same function is applied to the main plan's own subscription in the Go Lifetime path — a bare cancel with no refund credit only ever accounted for the current period's discount against the $3,000 price, not for unused time already paid for within that period.

Found live, not in review. This gap surfaced on 2026-08-04 when Cory upgraded his own account to Lifetime and found his separately-billed LRS addon subscription still active and billing afterward. The standing rule this establishes: any handler that flips an entitlement flag to true by tier, where a separately-billed subscription for that same capability might already exist, must also resolve that subscription — cancel it, and refund unused time. Setting the flag alone leaves a redundant charge running silently.

Product Family Enforcement

A customer cannot switch between product lines through the plan change flow:

The handler detects webhook tiers by the webhook_ prefix on tier names. A customer who wants both products creates a separate subscription for each — they appear as separate product cards on the dashboard, billed independently.

The Checkout Guard (Duplicate Prevention)

Existing subscribers who visit the public subscription page (subscribe-listener.html or webhook.html) must not create duplicate subscriptions. A three-layer defense prevents this:

LayerMechanismWhen It Fires
1. Server-side interstitial The /checkout endpoint (served on api.cpmp-site.org) renders a lightweight HTML page that checks localStorage for an active dashboard session token (cpmp_user.token). If found, redirects to /dashboard?panel=change-plan. If not, proceeds to Stripe. Every paid subscription/webhook CTA click
2. Receipt Lambda detection If a checkout completion arrives for an email that already has an active key in the same product family, the Lambda detects the duplication. It sends an explanatory email with options (use dashboard to change plan, or use a different email for a separate account). Post-payment, if layer 1 was bypassed
3. Auto-refund If the duplicate checkout had a payment (paid tier), the Lambda automatically refunds the payment_intent via the Stripe API. The customer is never charged for a subscription they cannot use. Immediately after layer 2 detects duplication

Why server-side? The subscription page lives on cpmp-site.org but the dashboard session is stored in localStorage on api.cpmp-site.org. Cross-origin localStorage access is impossible. The interstitial page is served on api.cpmp-site.org (same origin as the dashboard) so it CAN read the session token. This is why the guard must be server-side — a client-side check on cpmp-site.org would always see an empty localStorage.

Webhook Processing (customer.subscription.updated)

When Stripe fires a customer.subscription.updated event (from a dashboard-initiated plan change or from the Stripe Customer Portal), the receipt Lambda processes it:

  1. Reads metadata.tier from the subscription — this is the new tier (set by the dashboard handler when it updated the subscription).
  2. Looks up the customer in Aurora by stripe_customer_id.
  3. If the new tier differs from the current tier in Aurora:
    • LPO tiers: Applies new rate limits (query_limit, rate_limit_qps, burst_limit, minimum_wait_seconds) from rate_limit_template.
    • Webhook tiers: Updates webhook_subscriptions with new interval_seconds and max_assets.
  4. Handles LRS transitions:
    • Upgrading TO Unlimited/Lifetime → sets lrs_enabled = true
    • Downgrading FROM Unlimited/Lifetime → sets lrs_enabled = false (unless they have a separate LRS add-on: stripe_lrs_subscription_id != '')
  5. Invalidates the API key cache across all containers.
  6. Sends a plan switch confirmation email.

If metadata.tier is unchanged but the subscription status changed (e.g., past_dueactive), the handler updates only subscription_status.

Plan Switch Confirmation Email

Every tier change (upgrade or downgrade) sends a branded dark-theme confirmation email to the subscriber. The email includes:

The comparison data is read from tier_catalog in Aurora (via admin API) at send time. Improvements in the new tier are highlighted in green. The email is fully assembled from pre-translated frame labels in the subscriber's preferred_lang — zero Bedrock cost, instant assembly, deterministic output. Enqueued to SQS for async delivery.

Non-Switchable Tiers

TierCan Switch?Reason
LifetimeNoPermanent unlimited access. They paid forever — they get forever. No downgrade path exists.
PartnerNoManaged separately via AWS PrivateLink agreement. Not a billing relationship.
FreeUpgrade onlyRequires Checkout (no existing subscription to prorate). Cannot "downgrade" to free — that's a cancellation (Section 8).

Translation Key Isolation

Every query that resolves the customer's "current" LPO or Webhook subscription explicitly excludes translation keys:

AND COALESCE(k.service_type, 'prices') != 'translation'
AND COALESCE(k.is_translation, false) = false

Translation is a completely independent product line with its own Stripe customer, its own API key, and its own billing lifecycle. It never participates in LPO/Webhook plan switching, credit calculations, or proration. This separation is enforced in every handler: ChangePlanHandler, AvailablePlansHandler, LifetimeQuoteHandler, GoLifetimeHandler, and resolveStripeCustomerID.

8. Cancellation Flow

When a subscription is cancelled (via Customer Portal or Stripe dashboard), Stripe sends a customer.subscription.deleted event.

LPO Subscription Cancelled

  1. Tier is set to free with free-tier limits.
  2. subscription_status set to canceled.
  3. lrs_enabled set to false.
  4. stripe_subscription_id and stripe_lrs_subscription_id cleared.
  5. If an LRS add-on subscription exists, it is automatically cancelled in Stripe via the API.
  6. API key cache invalidated on all containers.

LRS Add-On Cancelled (separately)

  1. Identified by matching subscription.id to stripe_lrs_subscription_id.
  2. lrs_enabled set to false.
  3. stripe_lrs_subscription_id cleared.
  4. The main LPO subscription remains active and unaffected.
  5. API key cache invalidated.

8.1 Admin-Initiated Tier Change (Support Operations)

Not all tier transitions are self-service. Some require admin intervention — typically when a customer contacts support to cancel, request a refund, or when the admin needs to correct a tier that was set incorrectly.

When This Path Is Used

The Process

# Full command
bash scripts/kcc.sh admin-tier-reset <email> <target_tier> [--refund] [--refund-amount N.NN] [--key-id uuid]

# Example: Lifetime cancellation with full refund
bash scripts/kcc.sh admin-tier-reset customer@email.com free --refund --key-id bb1fdafb-...

The command performs these steps in order:

  1. Looks up all non-revoked, non-translation keys by email.
  2. Displays current state vs target state for confirmation.
  3. If --refund: Issues Stripe refund of the most recent charge (via POST /admin/refund). The charge.refunded webhook event fires, and the receipt Lambda sends the customer a branded refund confirmation email automatically.
  4. Records the tier transition in tier_change_history (source: admin-tier-reset).
  5. Updates the key with rate_limit_template values for the target tier — query_limit, rate_limit_qps, burst_limit, burst_tokens, minimum_wait_seconds.
  6. Sets appropriate flags: is_rate_limited, is_billing_exempt, subscription_status.
  7. Resets current_usage to 0 (fresh start on new tier).
  8. Invalidates the API key cache on all ECS nodes (immediate effect).

What Is Preserved

Refund vs Subscription Cancellation

These are intentionally decoupled. A refund returns money. A subscription cancellation stops future billing. They can happen independently:

  • Recurring subscription (Pro, Enterprise, Unlimited): Cancel the Stripe subscription first (via Customer Portal or Stripe Dashboard), then run admin-tier-reset with --refund for the most recent charge.
  • One-time purchase (Lifetime): There is no subscription to cancel. Just refund + tier reset.
  • Goodwill credit: Use --refund-amount for a partial refund without any tier change (set target tier = current tier).

Audit Trail

Every admin-initiated tier change is fully auditable:

-- Query the admin tier change history for a customer
SELECT previous_tier, new_tier, source, changed_at, notes
FROM tier_change_history
WHERE api_key_id = '<uuid>'
ORDER BY changed_at DESC;

9. Payment Failure & Grace Period

Diagram 9.1 — Payment Failure & Recovery
stateDiagram-v2
    [*] --> Active: Subscription created
    Active --> PastDue: invoice.payment_failed
    PastDue --> Active: invoice.paid (recovered)
    PastDue --> Blocked: Grace period expired
    Blocked --> Active: invoice.paid (recovered)
    Active --> Canceled: customer.subscription.deleted
    PastDue --> Canceled: customer.subscription.deleted
    Canceled --> [*]: Downgraded to free

    classDef active fill:#065f46,stroke:#0f172a,color:#ffffff,font-weight:bold
    classDef pastdue fill:#b45309,stroke:#0f172a,color:#ffffff,font-weight:bold
    classDef blocked fill:#991b1b,stroke:#0f172a,color:#ffffff,font-weight:bold
    classDef canceled fill:#475569,stroke:#0f172a,color:#ffffff,font-weight:bold

    class Active active
    class PastDue pastdue
    class Blocked blocked
    class Canceled canceled
        

Payment Failure (invoice.payment_failed)

Grace Period

The LPO server checks the grace period on every price request for past_due subscribers:

Payment Recovery (invoice.paid)

10. Refund Processing

When a charge is refunded via the Stripe Dashboard or mobile app, Stripe sends a charge.refunded webhook event. The Lambda handles two scenarios: subscription refunds (API key revocation) and donation refunds (record-only).

Subscription Refund Flow

  1. Stripe fires charge.refunded — triggered when you process a refund in the Stripe Dashboard or mobile app.
  2. Look up customer — Uses the charge's customer ID to find the API key via lookupByStripeCustomer (admin API query).
  3. Execute PostgreSQL function — Calls record_refund(api_key_id, charge_id, refund_amount, refund_id, customer_id, tier) via the admin API. The PG function revokes the API key, sets status to 'refunded', and records the refund transaction atomically.
  4. Send refund email — Assembles a localized refund confirmation from Valkey frames (includes tier, refund amount, refund ID, API key) and enqueues to SQS.
  5. Invalidate cache — Calls invalidateAPIKeyCache on all LPO/LRS servers so the revoked key stops working immediately.

Donation Refund Flow

  1. No API key found — When lookupByStripeCustomer returns no result (the customer is a donor, not a subscriber).
  2. Execute PostgreSQL function — Calls record_donation_refund(charge_id, refund_amount, refund_id) via the admin API. Records the refund against the original donation transaction.
  3. Send refund email — Assembles a localized donation refund confirmation from Valkey frames and enqueues to SQS.

Policy: All giving is non-refundable as stated on the site. Refunds are processed only in extenuating circumstances at the discretion of the administrator. The automated handler ensures that when a refund does occur, the system responds immediately — no manual cleanup required.

Partial vs. Full Refunds: The handler fires on any refund event regardless of amount. Both partial and full refunds result in API key revocation for subscription refunds. If a partial refund should not revoke the key, the administrator should manually re-enable it in Aurora after the refund is processed.

11. Cache Invalidation

Every lifecycle event that changes API key data triggers cache invalidation to ensure changes take effect immediately across all containers.

Invalidation Path

The Lambda calls GET /admin/invalidate-key?key={api_key} on both endpoints:

  • https://api.cpmp-site.org/admin/invalidate-key — LPO containers (BeastMain, BeastMirror, BeastLRS)
  • https://lrs.cpmp-site.org/admin/invalidate-key — LRS container

Each endpoint removes the API key from:

  1. Local sync.Map cache — per-container in-memory cache
  2. ElastiCache (Valkey) — shared apikey:{key} hash

The next request for that API key triggers a fresh read from Aurora, which now has the updated tier, limits, LRS status, and subscription status.

Why public endpoints? The Lambda is not in the VPC. Using the public ALB endpoints avoids the $32/month NAT gateway cost. The admin key header (X-Admin-Key) authenticates the request. This same pattern applies to ALL admin API calls from the Lambda (SQL queries, cache invalidation).

12. Email Pipeline

Every email in the subscription lifecycle is assembled from pre-translated frames and delivered asynchronously via SQS. Zero Bedrock calls, zero SES waits in the receipt Lambda's response path.

Architecture

Pipeline Flow

Receipt Lambda → builds email from Valkey frames → SQS (trinity-beast-email-queue)Email-Sender LambdaSES → Customer inbox

Pre-Translated Frames (Valkey email:frames)

A single JSON blob in Valkey containing 12 languages × 45 keys. Every email label (subject, heading, subheading, field labels, footer, link text) is pre-translated and stored. The receipt Lambda reads this on startup (5-minute local cache) and assembles emails by combining frame labels with dynamic values (name, amount, API key, date).

Frame KeyExample (English)Used In
subscription_subjectSubscription ConfirmedSubscription receipt
donation_headingThank You for Your GiftDonation receipt
label_api_keyAPI KeySubscription/webhook receipts
refund_subjectRefund ProcessedRefund confirmation
plan_switch_headingPlan Change ConfirmedTier upgrade/downgrade
footer_mission100% of revenue funds freedom...All emails

Impact Messages (Valkey impact:messages)

A separate Valkey key containing 12 languages × 10 impact types. Each impact type has a unique, descriptive message explaining what the donor's gift specifically provides. Source of truth: s3://trinity-beast-website-east2/data/impact-messages.json.

SQS Message Format

The receipt Lambda enqueues a complete, ready-to-send message. The email-sender Lambda does zero processing — it just delivers what it receives:

{
  "to": "subscriber@example.com",
  "subject": "サブスクリプション確認",
  "html_body": "<html>...complete email...</html>",
  "lang": "ja",
  "timestamp": "2026-07-24T09:15:00Z"
}

Data Sources — Nightly Refresh

Both Valkey keys are refreshed nightly by the BeastReconciler sync job:

Edit the JSON on S3 → push to Valkey manually (or wait for nightly sync) → live without redeploy.

Fallback

If SQS itself is unavailable, the receipt Lambda falls back to direct SES delivery (sendEmailDirect). The customer always receives their receipt — the pipeline is belt and suspenders.

Server-Originated Mail — Valkey email:tpl:*

Not every lifecycle email originates from the receipt Lambda. Refund confirmations issued through /admin/refund, reactivation notices, and dashboard magic links are built by the LPO server, which uses a separate template registry: six per-template Valkey hashes under email:tpl:*, read through a shared EmailLoader with a 5-minute in-memory cache and English fallback.

Lifecycle EmailOriginTemplate Source
Subscription / donation / LRS / webhook receiptReceipt Lambdaemail:frames
Plan switch confirmationReceipt Lambdaemail:frames
Stripe-webhook refund confirmationReceipt Lambdaemail:frames
Admin-issued refund confirmationLPO serveremail:tpl:refund (22 keys × 12 langs)
Reactivation confirmationLPO serveremail:tpl:reactivation (8 keys × 12 langs)
Dashboard magic linkLPO serveremail:tpl:magic-link (7 keys × 12 langs)
TBTS welcomeLPO serveremail:tpl:tbts-welcome (20 keys × 12 langs)

Both stores are refreshed nightly from S3 by the BeastReconciler and can be pushed on demand with bash scripts/kcc.sh push-email-templates. Copy edits go live within five minutes without a redeploy. Full detail lives in the Multi-Lingual Communications guide.

Why two stores. The receipt Lambda is short-lived and must enqueue an email in milliseconds, so it loads one 45-key blob and caches it for the container's lifetime. The LPO server is long-running with many distinct email types, so per-template hashes let each send fetch only the keys it needs. Both keep their original hardcoded Go strings compiled into the binary as a last-resort fallback — a Valkey outage degrades localization, never delivery.

13. PostgreSQL Functions

All database writes from the receipt Lambda go through dedicated PostgreSQL functions. Each function encapsulates a complete business operation (user upsert + key creation + transaction recording) in a single atomic call. The Lambda calls these via adminQuery(ctx, "SELECT * FROM function_name($1,$2,...)") over the LPO admin API.

FunctionPurposeReturns
record_subscription New LPO subscription: upsert user, generate API key with tier limits, link Stripe IDs, record transaction. Auto-enables LRS for Unlimited/Lifetime. user_id, api_key, api_key_id, txn_id
record_donation Donation: upsert user, record transaction with impact_type, store preferred_lang. user_id, txn_id
record_lrs_addon LRS add-on: find API key by email, validate eligibility, set lrs_enabled=true, link LRS subscription ID, record transaction. api_key_id, txn_id
record_webhook_subscription New webhook subscription: upsert user, generate API key with webhook tier limits, create webhook config, link Stripe IDs, record transaction. user_id, api_key, api_key_id, txn_id
record_refund Subscription refund: revoke API key, set subscription_status='refunded', record refund transaction. txn_id
record_donation_refund Donation refund: record refund against original donation transaction by charge_id. txn_id

Why functions over raw SQL? Each function is a single atomic operation — no partial states, no race conditions, no multi-statement transactions over HTTP. The Lambda makes one admin API call, gets back a clean result set. If the function fails, nothing is committed. If the network drops after the function succeeds, idempotency protection prevents re-processing.

Idempotency within functions: record_subscription uses NULLIF(charge_id, '') to handle Stripe subscription sessions where payment_intent is null (subscriptions charge via invoices, not payment intents). This prevents unique constraint violations on stripe_charge_id.

14. Database Schema

api_keys — Subscription Lifecycle Columns

ColumnTypePurpose
stripe_customer_idTEXTStripe customer ID for webhook lookups
stripe_subscription_idTEXTMain LPO subscription ID
stripe_lrs_subscription_idTEXTSeparate LRS add-on subscription ID
subscription_statusTEXTactive, past_due, canceled (default: active)
tier_effective_dateTIMESTAMPTZWhen the current tier took effect
payment_failed_atTIMESTAMPTZFirst payment failure timestamp (NULL when healthy)
lrs_enabledBOOLEANWhether unlimited LRS reports are enabled
tierTEXTfree, pro, enterprise, unlimited, lifetime, partner
query_limitINTEGERMonthly query limit for the tier
rate_limit_qpsINTEGERQueries per second limit
burst_limitINTEGERToken bucket burst capacity
burst_tokensNUMERICCurrent token bucket balance
minimum_wait_secondsNUMERICMinimum time between requests when throttled

Indexes for Webhook Lookups

IndexPurpose
idx_api_keys_stripe_customer_idFast lookup by Stripe customer ID (partial: WHERE stripe_customer_id IS NOT NULL)
idx_api_keys_stripe_subscription_idFast lookup by Stripe subscription ID (partial: WHERE stripe_subscription_id IS NOT NULL)

14.1 Nullable Columns — Scan Safety

Several api_keys columns are legitimately NULL for a freshly created key: name when the caller supplied no label, and last_used and last_success until the key makes its first request. Go's lib/pq driver cannot scan a SQL NULL into a non-pointer string or time.Time. The scan returns an error, and because API key validation treats any lookup error as a validation failure, a perfectly valid key returns 401 Invalid API key.

This failure mode is deceptive. The key exists in Aurora. It is not revoked. Its tier, quota, and subscription status are all correct. Querying the row directly returns exactly what you expect. But every request with it is rejected, and the log line says nothing about NULL — only that the key was invalid. The symptom points at authentication; the cause is type marshalling three layers down.

The Rule

Every nullable column read on the API key validation path must be made scan-safe at one of two levels:

Column TypeTechniqueApplied As
Nullable textCOALESCE at the SQL levelCOALESCE(name, ''), COALESCE(response_format, 'tbc'), COALESCE(api_lang, 'en'), COALESCE(service_type, 'prices')
Nullable timestampsql.NullTime scan target, hydrated after scanlast_used, last_success, tier_effective_date, payment_failed_at

Timestamps use sql.NullTime rather than COALESCE because the distinction matters downstream: a key that has never been used is semantically different from one used at the Unix epoch. The Valid flag preserves that difference; a COALESCE default would erase it.

// Nullable timestamps scanned safely, then hydrated
var lastUsed, lastSuccess sql.NullTime

err := row.Scan(
    &data.ID, &data.UserID, &data.Name,        // Name is COALESCE'd in SQL
    &data.Tier, &data.QueryLimit, &data.CurrentUsage,
    &lastUsed, &data.CreatedAt, &data.Revoked,
    // ...
    &lastSuccess,
)

if lastUsed.Valid {
    data.LastUsed = lastUsed.Time
}
if lastSuccess.Valid {
    data.LastSuccess = lastSuccess.Time
}

Applies to both query paths. API key lookup runs through an inline query and a prepared statement in internal/database/dbx/queries.go. Both must carry identical COALESCE wrapping and identical scan targets. A fix applied to only one path produces an intermittent bug that depends on whether the prepared-statement registry was warm — far harder to diagnose than a consistent failure.

The same discipline applies when adding any new nullable column to api_keys. If the validation query selects it, wrap it or scan it as a null type before deploying — otherwise every key created after the migration authenticates correctly until the column is populated, and fails afterward.

15. Stripe Customer Portal

Each subscription receipt email includes a link to the Stripe Customer Portal, generated dynamically by the Lambda using billingportal.Session. The portal allows subscribers to:

Portal URL: Generated per-customer at receipt time. Each URL is a one-time session link that expires. The portal is hosted entirely by Stripe — no custom UI needed.

16. Account Dashboard

Every customer has access to a unified Account Dashboard at https://api.cpmp-site.org/dashboard. The dashboard is a single-page application with a flat sidebar — every customer sees the same panels regardless of what products they subscribe to. Empty states with CTAs enable product discovery through visibility.

Access & Authentication

Sidebar Panels (Flat — No Tabs)

Empty States as Invitations

Panels for inactive products show friendly descriptions with secondary CTA buttons linking to the relevant product page. This replaces the old "No API key" error messages. Every product is always visible — the sidebar is a discovery surface, not a gated fortress.

Full Documentation: See Account Dashboards for complete panel specifications, API endpoints, localStorage schema, and admin features.