OPS RUNBOOK — Prayer Delivery Offer Engine
v1 brand: St. Peter's Basilica. One shared engine, many brand fronts. New brand = config + prompt pack + donor image + example photos + domain, zero app-code changes.
Rewritten 2026-09-14 to match DECISIONS-2026-09-14.md, then rewritten a second time the same day to the FINAL ANSWERS block in that file and the PrayerSong funnel shape (PRAYERSONG-MAPPING.md §2–§4). Every line that once carried a (PROPOSED — confirm) tag is now settled. No open tags remain in this doc.
1. REPO LAYOUT
prayer-engine/
├── apps/
│ ├── web/ # Next.js (App Router) funnel + portal + orders dashboard. Brand resolved at middleware by Host header.
│ └── worker/ # Fulfillment worker: image gen, audio gen, reveal tick, delivery email scheduling, monthly member cron. Polls job queue.
├── packages/
│ ├── engine/ # Shared business logic: survey state, offer state machine, pricing, order lifecycle, upsell chain, event emitter.
│ ├── imaging/ # Image-model clients (GPT Image / Nano Banana Pro class), two-pass donor→card-in-hand pipeline, asset upload, signed URLs.
│ ├── audio/ # TTS client (persona voice), ffmpeg ambience mixer (per-location preset), MP3 encode + upload.
│ ├── payments/ # TagadaPay adapter (tagada.ts) + mock adapter (mock.ts) behind one PspAdapter interface. See §4.
│ └── db/ # Drizzle schema, migrations, seed. Single Postgres DB, brand_slug column partitions logically.
├── brands/
│ └── <slug>/ # One folder per brand front.
│ ├── config.ts # Domain, site name, persona, location, survey options, copy, prices, pixels, delivery windows, locales.
│ ├── prompts/ # Prompt pack: pass-1 scene prompts, pass-2 card-in-hand prompts, TTS voice id, ambience preset, letter copy templates.
│ └── assets/
│ ├── donor/ # Per-location DONOR reference image (sourced from Pinterest). Pass-1 conditions on this file.
│ ├── examples/ # 7 example deliverable photos: 1 landing hero, 3 on /create/ready, 3 on /offer.
│ └── ... # Portraits, logos, favicon, card background art, og images.
└── infra/
├── docker-compose.yml
├── Caddyfile
└── env.example
The rule: new brand = new brands/<slug>/ folder + config.ts + prompt pack + donor image + example photos + domain (DNS + TagadaPay store + Resend domain + fresh pixel). Zero app-code changes. Middleware loads config by domain; if the domain isn't in any brand config, 404.
Brand config schema
This block is byte-identical to SPEC-PRAYER-ENGINE §1. Edit both or neither.
// brands/<slug>/config.ts
export type BrandConfig = {
slug: string; // "st-peters" — used as brand_slug in DB
domain: string; // "holycardsofsaintpeters.com" — middleware match key
site: {
name: string; // "Holy Cards of St. Peter's"
tagline: string; // "Your prayer, placed at the heart of the Basilica."
merchantName: string; // legal name on receipts — never contains "prayer"
supportEmail: string; // "care@holycardsofsaintpeters.com" — the only support channel
supportUrl: string; // "holycardsofsaintpeters.com/support" — goes in the descriptor
statementDescriptor: string; // Stripe suffix after the fixed NUMINA* prefix, e.g. "HOLYCARDS"; <= 15 chars, no <>\'"* characters. Never the word "prayer".
colors: { primary: string; accent: string; bg: string; text: string };
};
location: {
key: string; // "st_peters_basilica" — keys donor refs + ambience presets
displayName: string; // "St. Peter's Basilica, Vatican City"
storyBlock: string; // long-form copy for /offer
donorRef: string; // "assets/donor/st_peters_basilica.jpg" — pass-1 reference
examplePhotos: string[]; // 7 paths under assets/examples — 1 landing hero, 3 on /create/ready, 3 on /offer
};
persona: {
name: string; // "Fr. Marco"
title: string; // "Pilgrim Chaplain"
bio: string; // short bio for /offer + emails
portraitSet: string; // key into brands/<slug>/assets — consistent persona face
};
prayer: {
maxChars: number; // 220 — hard cap so the text always fits the card
};
survey: {
forWhom: string[]; // step 1 options: Myself, Mother, Father, Husband, Wife, Child, Friend, Someone who has passed, Other
intentions: string[]; // step 2 options: Healing, Strength, Grief, Family, Provision, Protection, Gratitude, Other
};
products: {
fe: { cents: number }; // 3490 — Western Wall front runs 2990
anchorCents: number; // 6900 — struck-through anchor above the FE price
bumps: {
candle: { cents: number }; // 499 — the only checkout bump, never pre-checked
};
oto1: { cents: number }; // 1400 — second prayer, saved card
oto2: { rush: { cents: number; hours: number } }; // 1990 / 24 — rush delivery
oto3: { audio: { cents: number } }; // 990 — Voice of the Sanctuary MP3
replacement: { cents: number }; // 1400 — anniversary re-placement, saved card
monthly: { monthly: { cents: number }; annual: { cents: number } }; // 990 / 7900
};
delivery: {
standardDays: number; // 3 — fixed promise; delivery email fires on day 3
rushHours: number; // 24 — OTO2 rush SLA, queue-jumping
queueHours: number; // max hours from payment to asset generated (SLA: 24)
};
guarantee: {
days: number; // 30 — satisfaction guarantee, stated on every sales surface
};
portal: {
revealMinutes: number; // 15 — reveal cron tick that flips a ready deliverable visible
};
audio: {
voiceId: string; // TTS voice id for the persona
ambiencePreset: string; // ffmpeg mix preset keyed to location.key
sampleUrl: string; // static per-brand preview clip played on /oto/3
};
gift: {
enabled: boolean; // true — optional recipient email at survey step 5
};
tracking: {
pixelId: string; // Meta pixel — a fresh pixel per brand, never shared
capiToken: string; // Meta CAPI access token (server-side mirror)
};
locales: string[]; // ["en-US"] — copy variants keyed by locale
};
2. APP ROUTES (Next.js App Router)
Brand resolved by domain at middleware (apps/web/middleware.ts): reads Host, looks up matching BrandConfig, attaches to request headers / server context. All routes render against that config.
Twelve surfaces, in funnel order. This list is identical to SPEC-PRAYER-ENGINE §2.
| Route | Job |
|---|---|
/ |
Landing: testimonial headline, hero example card-in-hand photo, "Delivered in 3 days", then the live ticker directly under the hero (COPY-CREATIVES §11 pool), how it works, what you get, pricing block, FAQ. One CTA into the survey. No aggregate customer-count claim anywhere. Fires view_landing. |
/create |
5-step survey, progress bar, Back/Next, no price on any step. 1 who is this prayer for (survey.forWhom) + their first name. 2 what is it for (survey.intentions) — names the discount on /offer and seeds the monthly theme. 3 the prayer, hard-capped at prayer.maxChars with a live counter, Next disabled over the cap, server re-validates. 4 optional note to the Father (feeds the persona note, never printed on the card). 5 your first name + email + optional gift recipient email. Fires survey_step per step with {step} meta; step 5 writes orders (status lead) + prayers, sets the ph cookie, and fires lead_submit. No payment. |
/create/ready |
Transition: 3 example photos with customer quotes (stubs), the guarantee box, one button "Continue to your order". Fires transition_view on render. First route that requires ph. |
/offer |
Sales page "Almost There! Complete Your Order": summary card (prayer for [name], placed by [paid_at + delivery.standardDays, shown as today + 3 before payment]), "Limited Time [Intention] Discount" box from the step-2 answer, offer_view on render, the only place it fires. Requires ph. CTA → /checkout. |
/checkout |
FE payment: Stripe.js Card Element tokenization, billing address collection, one bump only — Votive Candle $4.99, rendered inline, never pre-checked, retention line under the summary. Vaults the instrument for the one-tap chain. Requires ph. |
/oto/1 |
Second Prayer $14: textarea prefilled with the first prayer (same maxChars cap + live counter) + one confirm checkbox + Submit (charges the saved card) or Skip. 5-minute countdown. Guarantee box. Submit writes a second prayers row on the same order. Requires ph and orders.status='paid'. |
/oto/2 |
24-Hour Placement $19.90: one-tap saved-card confirm, 24 hours instead of 3 days. Banner at the top confirms the OTO1 decision. Guarantee box. Accept sets orders.rush = true. Requires ph + paid. |
/oto/3 |
Voice of the Sanctuary $9.90: one-tap saved-card confirm. Plays the static per-brand sample clip from audio.sampleUrl. Requires ph + paid. |
/thanks |
Order recap card per accepted item, each labelled with its §4 SKU name, "I need to correct my email" button posting to /thanks/correct-email, what happens next in 3 steps with real dates, spam-folder note, big button "Open your portal", cross-sell "place a prayer for someone else", membership pitch at the bottom. Requires ph + paid. |
/portal/<token> |
The delivery portal. Magic-link entry (portal_sessions.token, no passwords, never ph). Deliverable gallery with the reveal tick, track-order timeline, monthly intention field, membership manage (pause / resume / cancel), email preferences, and the tokenised offer surface /offer?product=<id>&s=<token>. Fires portal_open. Every email links here. |
/membership |
Monthly Prayer Membership $9.90/mo or $79/yr signup: Stripe subscription, theme preview carousel. Also the remarketing landing. No ph required — a visitor with no prior order gets a standalone subscription (own email + first_name), its own portal_sessions token keyed to subscription_id, and the Member welcome email (§6 template 17). |
/replace |
Anniversary Re-Placement $14: one tap on the saved card, re-runs the prayer at the same location with a fresh scene. Writes a new prayers row on the original order with a new variant_seed, so it produces a new photo. Reached from the day-365 email. Fires replacement_take. Requires ph + paid. |
Three routes from the previous runbook no longer exist. The old lead-form route and the old loading interstitial are replaced by the survey at /create and the transition at /create/ready. The old account page is replaced by /portal/<token>.
/offer serves two different pages. Bare /offer is the funnel sales page and needs a valid ph. /offer?product=<id>&s=<token> is the portal's tokenised one-tap offer surface and needs a valid portal_sessions.token. Different auth, different render. Neither accepts the other's credential.
Supporting routes
Eight surfaces outside the funnel order. This list is identical in SPEC-PRAYER-ENGINE §2.
| Route | Job |
|---|---|
/support |
Contact form posting to site.supportEmail. The target of site.supportUrl, which goes in the statement descriptor. |
/terms |
Terms page. Linked from every footer. |
/privacy |
Privacy page. Linked from every footer. |
/refunds |
Refund and guarantee policy page. Linked from every footer. |
/create/resume |
GET only. The target of every abandoned email (§6 templates 8 to 10). Verifies the signed t token, re-mints the ph cookie for that order, and redirects to /create/ready with the draft intact. Token spec below. |
/thanks/correct-email |
POST only. Rewrites orders.email on the ph order and re-sends the confirmation email to the new address. Rate-limited with the same per-handle counter. |
/admin |
The orders dashboard (§9). Gated by ADMIN_DASH_TOKEN; no session, no cookie. |
/portal/<token>/email-preferences |
Sets orders.marketing_opt_out / subscriptions.marketing_opt_out. Every marketing send checks it (§6); transactional sends ignore it. |
Funnel session — the ph order handle
There is exactly one funnel identity and it is a cookie.
- Minted at survey step 5. The server creates the
ordersrow (statuslead), then sets a signed, httpOnly, SameSite=Lax cookie namedphwhose value is the order id plus an HMAC over it, keyed byPORTAL_TOKEN_SALT. TTL 7 days. - Required from
/create/readyonward. Any route in the funnel order after the survey rejects a request with noph, a bad HMAC, or an expired cookie, and sends it back to/create. /oto/1,/oto/2,/oto/3,/thanksand/replaceadditionally requireorders.status = 'paid'on the handle's order. An unpaid handle never reaches a one-tap charge page.- Ownership pre-check. Before rendering any OTO, check
upsellsfor thatkindon the order. If it is already owned, do not re-sell it: skip straight to the next step in the chain. Ported from the Divine Rev Upsell Checkout v2 pattern. - Rate limit: 5 charge attempts per hour per handle, counted across every one-tap route. This is what stops a guessed or replayed handle from becoming a charge machine.
- The portal is a separate identity.
/portal/<token>and/offer?product=<id>&s=<token>authenticate onportal_sessions.tokenonly.phgrants nothing in the portal and a portal token grants nothing in the funnel.
Resume identity — the t token
ph lives 7 days; the draft lives 30 (§3). The three abandoned emails promise the draft is exactly where it was left, so they carry a signed resume link, never a bare /create.
- The link is
/create/resume?t=<token>. The token is the order id plus an HMAC over that id and an expiry, keyed by the samePORTAL_TOKEN_SALTasph. TTL 30 days fromorders.created_at— it dies on the same day the lead purge (§3) deletes the draft, so a live link never lands on a purged order. /create/resumeverifies the HMAC and the expiry, re-mintsphfor that order id with a fresh 7-day TTL, and redirects to/create/ready. A missing, tampered or expiredtgoes to/createwith a blank box.- The token is funnel identity for one order and nothing else. It is not a portal token and never grants portal access. It does not satisfy the
orders.status = 'paid'requirement on any one-tap route, and once it becomes aphit counts against the same 5-charge-attempts-per-hour limit. - A token whose order has since paid redirects to
/thanks, which the re-minted handle now satisfies. The abandoned sequence itself is already suppressed on purchase (§3), so this only catches a stale link in an old inbox.
Guarantee surfaces. The 30-day satisfaction guarantee, verbatim — "100% Money Back Guarantee. Not satisfied? Get a full refund. No questions asked, no hassle. 30-day guarantee. Risk-free purchase." — renders on /, /create/ready, /offer, /oto/1, /oto/2, /membership, /replace, and in all three abandoned emails. Always beside the price anchor.
3. DATABASE SCHEMA (Drizzle-style DDL sketch)
CREATE TYPE order_status AS ENUM ('lead','paid','fulfilled','delivered','refunded');
CREATE TYPE prayer_status AS ENUM ('received','preparing','placed','photo_ready','failed');
CREATE TYPE deliverable_kind AS ENUM ('photo','audio');
CREATE TYPE upsell_kind AS ENUM ('bump_candle','oto1_prayer','oto2_rush','oto3_audio','replacement','monthly');
CREATE TYPE intent_status AS ENUM ('in_flight','settled','failed');
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
brand_slug text NOT NULL,
status order_status NOT NULL DEFAULT 'lead',
email text NOT NULL,
first_name text NOT NULL DEFAULT '',
gift_recipient_email text, -- set = DUAL SEND. Every delivery goes to this address AND to orders.email
rush boolean NOT NULL DEFAULT false, -- set true when 24-Hour Placement is accepted: queue-jump, deliver within delivery.rushHours
marketing_opt_out boolean NOT NULL DEFAULT false, -- set from /portal/<token>/email-preferences; every marketing send checks it
billing_address jsonb, -- {line1,line2,city,state,postal,country}
total_cents integer NOT NULL DEFAULT 0, -- FE + all taken upsells
currency text NOT NULL DEFAULT 'usd',
psp text NOT NULL DEFAULT 'stripe', -- rail name; the adapter is swappable, the column is the record
psp_customer_id text,
psp_instrument_id text, -- "pi_..." vaulted card, drives every one-tap charge
psp_order_id text,
lead_sequence_stage smallint NOT NULL DEFAULT 0, -- 0/1/2/3 — which abandonment email has gone out
created_at timestamptz NOT NULL DEFAULT now(), -- LEAD creation time. Keys the abandoned sequence ONLY.
paid_at timestamptz -- payment time, written by the settle path. Keys EVERY other schedule.
);
CREATE INDEX orders_brand_created_idx ON orders (brand_slug, created_at);
CREATE INDEX orders_brand_paid_idx ON orders (brand_slug, paid_at);
CREATE INDEX orders_email_idx ON orders (email);
CREATE INDEX orders_lead_idx ON orders (status, lead_sequence_stage, created_at) WHERE status = 'lead';
CREATE TABLE prayers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders(id),
for_whom text, -- survey step 1 — one of BrandConfig.survey.forWhom
for_name text, -- survey step 1 — first name of the person prayed for
intention text, -- survey step 2 — one of BrandConfig.survey.intentions; names the discount, seeds the monthly theme
text text NOT NULL, -- survey step 3 — length enforced against BrandConfig.prayer.maxChars
note_to_father text, -- survey step 4 — optional, feeds the persona note, never printed on the card
candle boolean NOT NULL DEFAULT false, -- true when the Votive Candle bump was taken: Pass 2 adds a second lit votive beside the card
variant_seed integer NOT NULL, -- 32-bit deterministic scene-variant key (time of day / light / angle / hand / card)
status prayer_status NOT NULL DEFAULT 'received',
placed_at timestamptz, -- set when Pass 2 clears QA; the source of [PLACEMENT_DATE]
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE deliverables (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(id), -- null on a standalone member deliverable
subscription_id uuid REFERENCES subscriptions(id), -- set on member deliveries; null on order deliveries
prayer_id uuid REFERENCES prayers(id), -- one photo PER PRAYER row; null for audio-only
kind deliverable_kind NOT NULL,
asset_url text NOT NULL, -- object-storage key; emails get 7-day signed URL
reveal_at timestamptz NOT NULL, -- earliest moment this may appear in the portal
visible boolean NOT NULL DEFAULT false, -- flipped by the reveal cron once ready AND reveal_at has passed
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz, -- set when delivery email fires
CONSTRAINT deliverables_owner_ck CHECK (order_id IS NOT NULL OR subscription_id IS NOT NULL)
);
CREATE INDEX deliverables_reveal_idx ON deliverables (visible, reveal_at) WHERE visible = false;
CREATE TABLE portal_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(id), -- one of order_id / subscription_id is always set
subscription_id uuid REFERENCES subscriptions(id),
token text NOT NULL UNIQUE, -- nanoid; the whole of /portal/<token> auth, no passwords
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz,
CONSTRAINT portal_sessions_owner_ck CHECK (order_id IS NOT NULL OR subscription_id IS NOT NULL)
);
CREATE INDEX portal_sessions_order_idx ON portal_sessions (order_id);
CREATE INDEX portal_sessions_subscription_idx ON portal_sessions (subscription_id);
CREATE TABLE subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
brand_slug text NOT NULL,
member_no serial UNIQUE, -- drives the treatment rotation, see §5
psp_subscription_id text NOT NULL UNIQUE,
status text NOT NULL, -- mirror of the rail's subscription status
email text NOT NULL, -- copied from the order, or collected at /membership on a standalone signup
first_name text NOT NULL DEFAULT '', -- the name printed on every member card
marketing_opt_out boolean NOT NULL DEFAULT false,
member_since timestamptz NOT NULL DEFAULT now(),
current_intention text, -- opt-in intention for the coming month; null = generic themed intention
order_id uuid REFERENCES orders(id) -- origin order link; NULL on a standalone /membership signup
);
CREATE TABLE upsells (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders(id),
kind upsell_kind NOT NULL,
amount_cents integer NOT NULL,
taken_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE charge_intents ( -- idempotency guard: the rail has no idempotency key
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders(id),
kind text NOT NULL, -- 'fe' | 'oto1' | 'oto2' | 'oto3' | 'replacement' | 'subscription'
attempt_no integer NOT NULL DEFAULT 1, -- 1 for the first intent of this kind on this order, then 2, 3, ...
amount_cents integer NOT NULL,
status intent_status NOT NULL DEFAULT 'in_flight',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX charge_intents_open_idx ON charge_intents (order_id, kind, attempt_no) WHERE status <> 'failed';
CREATE TABLE disputes ( -- primary: charge.dispute.created/closed webhooks; manual entry is the backstop
id bigserial PRIMARY KEY,
order_id uuid REFERENCES orders(id),
opened_at date NOT NULL,
amount_cents integer NOT NULL,
outcome text, -- 'open' | 'won' | 'lost' | 'refunded_pre_dispute'
entered_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE events (
id bigserial PRIMARY KEY,
brand_slug text NOT NULL,
name text NOT NULL, -- see §8 event map
session_id text,
order_id uuid,
meta jsonb, -- survey_step carries {"step": 1..5}
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX events_brand_name_idx ON events (brand_slug, name, created_at);
CREATE TABLE dead_letter_jobs ( -- worker retry exhaustion (§5)
id bigserial PRIMARY KEY,
job_type text NOT NULL,
payload jsonb NOT NULL,
error text NOT NULL,
attempts integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
Migration order. The block above is grouped for reading, not for execution. deliverables and portal_sessions both carry a subscription_id foreign key, so the real migration creates orders → prayers → subscriptions → deliverables → portal_sessions → the rest.
Survey answers live on prayers, not orders. A second prayer bought at /oto/1 inherits for_whom / for_name / intention from the first row unless the buyer changes them; note_to_father is captured once, at survey step 4.
Leads. An orders row with status lead is an unpaid lead. lead_sequence_stage tracks how far the abandonment sequence (§6) has run. The moment the row flips to paid, the sequence is suppressed — the query filters on status = 'lead' every time, never on a cached list.
Draft retention — one rule, three surfaces. There is exactly one retention answer and every surface states it identically:
A lead draft is kept for 30 days, then purged.
- A daily cron deletes
ordersrows still atstatus = 'lead'whosecreated_atis older than 30 days, and theirprayersrows with them. Nothing else purges leads. - The checkout page state lives in the
phcookie, TTL 7 days (§2). A returning buyer inside 7 days lands back on their own order; after that they re-enter through/createand the saved draft is still there for 30 days from creation. - All three copy surfaces — the checkout retention line, abandoned email 2 and abandoned email 3 — say "saved for 30 days". No surface says 30 minutes, and no surface says "clears soon".
paid_at is the clock for everything after payment. The settle path (§4) writes it. reveal_at, the day-3 delivery send, the 24-hour rush SLA, the day-5 re-send, the day-7 membership invite, the day-30 win-back and the day-365 re-placement all count from orders.paid_at. created_at is lead-creation time and keys the abandoned sequence and the resume token (§2), and nothing else. A lead created Monday and paid Thursday gets Thursday's dates.
Member deliveries are the one exception: they count from subscriptions.member_since, not from any order, because a standalone /membership member has no order row. The monthly delivery date is member_since + N months and is independent of the billing interval (§5).
Prayer lifecycle. prayer_status runs received → preparing → placed → photo_ready, with failed as the terminal error state. The portal timeline (§2, COPY-CREATIVES §8) renders exactly these four live states:
| Status | Written when | Portal timeline state |
|---|---|---|
received |
survey step 5 writes the row | Received |
preparing |
the job is enqueued and Pass 1 starts | Being prepared |
placed |
Pass 2 clears card-text QA; placed_at is written here |
Placed |
photo_ready |
the asset is uploaded and the deliverables row exists |
Photo ready |
failed |
the job dead-letters after 3 Pass-2 attempts (§5) | not rendered; the order shows on the stuck-orders query |
[PLACEMENT_DATE] = paid_at + 2 days on a standard order, paid_at + 12 hours on a rush order. prayers.placed_at is the actual value; the formula is what the copy promises before it exists.
Charge intents and repeat charges. attempt_no makes the idempotency guard per-attempt rather than per-kind-forever. The first replacement intent on an order is attempt_no = 1; next year's re-placement on the same order opens attempt_no = 2 and is not blocked by the settled first one. A retry of an in-flight charge re-uses the same attempt_no and is still refused a second charge, which is the whole point of the guard.
Portal deliverable semantics. A deliverable is written the moment generation finishes, with visible = false and a reveal_at timestamp. The reveal cron runs every portal.revealMinutes (15) and flips rows to visible = true once the asset is ready and reveal_at has passed. The portal only ever renders visible = true rows. Standard orders get reveal_at = paid_at + delivery.standardDays; rush orders get reveal_at = ready_at, so a rush buyer sees the photo the moment it exists. A goodwill grant is a hand-inserted deliverable row with a reveal_at a few hours out — the same cron delivers it, with no special-case code. Ported from the Divine Rev client-portal-worker reveal tick.
4. PAYMENTS — STRIPE WIRING
Rail: Stripe, account Numina House (acct_1UGPHEKGASr0FGk4), entity 9566-0866 Québec inc., country CA, default currency CAD, charges and payouts enabled. Shaw's entity is merchant of record and carries full chargeback liability. We own every checkout page; Stripe tokenizes and charges. No products or prices are created on the rail — every amount comes from BrandConfig in cents, and subscriptions carry inline price_data instead of a pre-created Price object.
packages/payments/stripe.tsimplementsPspAdapter(createCustomer,vaultCard,charge,confirmAction,createSubscription,pauseSubscription,resumeSubscription,cancelSubscription,refund,lookupCharge,parseWebhook) over plainfetchviastripe-http.ts— nostripe-nodeSDK.packages/payments/mock.tsimplements the same interface for tests. Nothing rail-specific lives inapps/web.- Adapter selection:
PSP_ADAPTER=stripein prod and staging,mockin tests. - Keys:
pass companies/9566-0866/stripe-api-test(test) andpass companies/9566-0866/stripe-api-live(live) — secret key on the first line, publishable key on thepublishable:line. Per brand:<SLUG>_STRIPE_SECRET_KEY,<SLUG>_STRIPE_WEBHOOK_SECRET,<SLUG>_STRIPE_PUBLISHABLE_KEY. - Charges run in USD, the currency every SKU price below is written in. The account settles to CAD, so Stripe converts at payout and takes a currency-conversion fee on top of its processing fee.
Amount table (per brand, from config, in cents)
SKU names are the cross-doc invariant set in SPEC-PRAYER-ENGINE §3. The receipt line, the /thanks recap card, the delivery email and the dashboard take-rate label all use the name in the left column, exactly as written.
| SKU name | Amount | How |
|---|---|---|
| The Witnessed Carry | $34.90 | customer-initiated payments.process (Western Wall front: $29.90) |
| Votive Candle | +$4.99 | added to the FE amount, same charge |
| Second Prayer | $14.00 | merchant-initiated one-tap on the vaulted instrument |
| 24-Hour Placement | $19.90 | merchant-initiated one-tap |
| Voice of the Sanctuary | $9.90 | merchant-initiated one-tap |
| Anniversary Re-Placement | $14.00 | merchant-initiated one-tap from /replace |
| Monthly Prayer Membership (monthly) | $9.90/mo | rail subscription engine |
| Monthly Prayer Membership (annual) | $79/yr | rail subscription engine |
Max stacked order: $83.69 across up to five separate charges.
Tokenization + first charge
- Lead (survey step 5 at
/create): no payment call at all.ordersrow, statuslead,phcookie set. - Tokenize (
/checkout): Stripe.js Card Element in the browser creates aPaymentMethod(pm_...). Card only. The card number never reaches our server. Wallets (Apple/Google Pay) are off for v1; no alternative rail is offered. - Vault: the server creates (or reuses) the Stripe
customerfor the buyer and attaches thePaymentMethodto it, storing the pair asorders.psp_customer_idandorders.psp_instrument_id. - Guard: write a
charge_intentsrow (kind='fe',attempt_no=1,status='in_flight') before any charge call. A second request that finds anin_flightrow for the same(order_id, kind, attempt_no)never fires a second charge. - Charge: create a PaymentIntent for
FE + Votive Candle if taken, in USD, withIdempotency-Key = charge_intent_id. - Settle — this path is authoritative. On success, inside one transaction: set
charge_intents.status='settled',orders.status='paid',orders.paid_at = now(), write theupsellsrow for the Votive Candle if taken, mint theportal_sessionstoken, firepurchaseserver-side withevent_id = order_id(Pixel + CAPI mirror dedupe on that id), and enqueue fulfilment (§5). On failure setfailedand run the decline ladder.
The webhook never marks an order paid for the first time. Settle owns the transition; the webhook reconciles. Every OTO settle follows the same shape: guard row first, then on success write the upsells row, bump orders.total_cents, fire the take event server-side, and enqueue whatever the SKU produces. A dropped webhook therefore never loses a conversion from Meta.
Idempotency
Two guards, not one. The charge_intents row is ours and is written before the call. On top of it, every PaymentIntent carries Idempotency-Key = charge_intent_id, so Stripe itself returns the same PaymentIntent for a retried or duplicated request instead of creating a second charge. TagadaPay had no idempotency key at all — this is new protection, and it is on top of the guard row, never instead of it.
One-tap on the saved card (OTO1, OTO2, OTO3, /replace)
Same PaymentIntent shape against the vaulted pm_..., with off_session: true on the stored customer — a merchant-initiated transaction, no card re-entry. Every attempt writes its charge_intents row first (next free attempt_no for that kind) and an upsells row on success, and bumps orders.total_cents. The 24-Hour Placement also sets orders.rush = true and re-prioritises every open job on the order. /replace additionally writes a new prayers row with a fresh variant_seed and enqueues a job for it.
Every one of these routes is gated by the ph handle plus orders.status = 'paid', runs the ownership pre-check against upsells before it renders, and counts against the 5-charge-attempts-per-hour limit (§2).
Page pattern ported from Divine Rev Upsell Checkout v2: the button is a confirmation ("uses card ending •1234"), not a checkout form, and a failure never navigates away from the page. The same pattern serves the tokenised offer surface /offer?product=<id>&s=<token> reached from the portal.
Decline ladder:
requires_action→ run 3DS (below), then complete.- Soft decline (insufficient funds) → stay on page, offer the downsell or "try again later", then exactly one follow-up email at T+2–4 h with the same link.
- Hard decline → friendly message + inline new-card entry on the same page.
Never re-route a declined charge for the same product to a second processor. That is the load-balancing pattern the card brands prohibit; any second rail must be split by product line, never used as a retry path.
3DS
A PaymentIntent that needs authentication comes back requires_action. The browser goes to /checkout/authenticate (AuthenticateClient.tsx) and runs stripe.handleNextAction(client_secret). Once the challenge clears, the client calls POST /api/checkout/confirm, and the server re-reads the PaymentIntent from Stripe before settling — it never trusts the client's word that the challenge passed. The server never re-confirms the intent itself.
Subscriptions
Stripe Subscriptions, with inline price_data and no pre-created Stripe Price object: one Product per brand and interval, amounts from BrandConfig — monthly $9.90 / annual $79.
- Pause =
pause_collection.behavior = 'void'— billing stops and the subscription stays active. Resume clearspause_collection. - Cancel at period end or immediately — both self-serve and one click in the portal.
- Mirror every subscription event into our own
subscriptionsrow so the monthly cron never depends on Stripe's state.
A standalone /membership signup has no order. Write the subscriptions row with its own email and first_name, mint a portal_sessions token against subscription_id, and send the Member welcome email (§6 template 17).
Dunning
Stripe retries a failed invoice on its own adaptive Smart Retries schedule, set in the Stripe dashboard under Billing settings. That is not a published hour ladder, so no fixed hour numbers are written anywhere in these docs.
Dunning emails ride Stripe's real webhook events, never a calendar of our own:
| Stripe event | Our email |
|---|---|
first invoice.payment_failed |
13 — Dunning #1, "we could not renew, we will try again" |
a later invoice.payment_failed, before the final scheduled retry |
14 — Dunning #2 |
invoice.payment_failed on the last scheduled retry |
15 — Dunning #3, "one more attempt, update your card in the portal to keep it" — no day count named |
customer.subscription.deleted |
16 — Membership ended, the only email that says billing has stopped |
The rule behind the table: no email says charges have stopped while the rail can still charge. A "no further charges" line sent before Stripe cancels is the recurring-charge dispute pattern the 0.6% kill-switch (§9) exists to prevent.
Webhook endpoint /api/psp/webhook (one per brand, one per env)
Route: apps/web/src/app/api/psp/webhook/route.ts. Brand resolved from the Host header.
- Verify the
Stripe-Signatureheader: HMAC-SHA256 over{timestamp}.{raw body}against that endpoint's signing secret, with thet=timestamp checked against a 5-minute replay window. The signing secret is shown once, at registration — capture it then, or re-run the registration to create a second endpoint (Stripe never re-shows an existing secret). - Return 2xx instantly and enqueue. No business logic runs inline.
- Twelve event types handled:
payment_intent.succeeded,payment_intent.payment_failed,invoice.paid,invoice.payment_failed,charge.refunded,customer.subscription.updated,customer.subscription.deleted,customer.subscription.paused,customer.subscription.resumed,charge.dispute.created,charge.dispute.closed,radar.early_fraud_warning.created. - Registration:
pnpm --filter payments exec tsx scripts/stripe-register-webhook.ts <public-url> <brandSlug>— run once the public URL from the Cloudflare deploy exists. The signing secret is printed once; capture it into<SLUG>_STRIPE_WEBHOOK_SECRETimmediately. - Handler map. Every order-side handler is reconciliation only and idempotent on the order id.
payment_intent.succeeded→ if the order is alreadypaid, do nothing; if it is not, the settle path was lost, so apply the same settle transaction now (setpaid,paid_at, mint the portal token, enqueue fulfilment) and firepurchasewithevent_id = order_id, which the pixel then dedupes against.payment_intent.payment_failed→ mark the mirrored intent failed; the dunning table above owns the subscription case.invoice.paid→ record the renewal on the mirrored row and enqueue nothing. Deliveries are the cron's job, not the rail's: a monthly rebill would double-deliver and an annual rebill fires once a year against twelve promised photographs (§5).invoice.payment_failed→ the dunning table above.customer.subscription.updated/paused/resumed→ mirror the state onto thesubscriptionsrow.customer.subscription.deleted→ mirror the status and send the Membership ended email (template 16).charge.refunded→ setorders.status = 'refunded'and stop any pending delivery.charge.dispute.created/charge.dispute.closed→ write or update thedisputesrow, which feeds the/adminmerchant-health number.radar.early_fraud_warning.created→ refund the charge in full immediately (reasonfraudulent) once Stripe marks the warning actionable, and write aneventsrowpsp_fraud_warning_refunded— this pre-empts the chargeback before it can be filed. - The first member job comes off the create call, not off a rebill. Write the mirrored
subscriptionsrow, assignmember_no, and enqueue the first member job immediately, on the same path the monthly delivery cron uses (§5). The initial payment is not a rebill, so nothing else would ever produce that first card, and the Member welcome email (template 17) names the date it lands.
Reconciliation
lookupCharge searches Stripe PaymentIntents by metadata.charge_intent_id, so a charge our own tables lost track of is always findable on the rail. The worker's reconciliation sweep resolves every needs_reconcile intent against lookupCharge and auto-fails any intent still unconfirmed 24 hours after the charge. An admin can also resolve one by hand at POST /api/admin/intents/{id}/resolve.
Statement descriptor
The prefix is fixed at the Stripe account level: NUMINA. site.statementDescriptor sets only the suffix, so the buyer sees NUMINA* <suffix> (for example NUMINA* HOLYCARDS), and prefix + * + suffix must stay inside Stripe's 22-character total limit. No phone number. Never the word "prayer."
The suffix is confirmed as HOLYCARDS — the old HOLYCARDS (PENDING LEON) placeholder is resolved, not carried forward.
Required fields
Billing address (line1, city, state, postal, country) is required on every payment — FE, one-tap fallback, membership. This is the dispute-evidence baseline. No phone number is collected anywhere in the funnel, because support is email only.
Refunds and disputes
The 30-day satisfaction guarantee is honoured on request, no questions asked, through Stripe's refund API, mirrored by the charge.refunded webhook. A refund sets orders.status = 'refunded' and stops any pending delivery.
Disputes now arrive as webhooks. charge.dispute.created and charge.dispute.closed land in the disputes table directly and feed the /admin merchant-health number — TagadaPay emitted no dispute event at all, so this is new signal. It is on top of, not instead of, the existing daily manual entry from the payment dashboard (§9), which stays as the backstop that catches anything a webhook misses or reclassifies.
The Divine Rev lesson drives both ends of this: every rebill email names the new photograph in the subject line and the first body line, and cancelling a membership from the portal is always one click, so no customer ever needs the bank to cancel for them.
Verification gates
Before any ad spend, in order: mock parity → real E2E with a real browser card submit → webhook chaos check (drop, duplicate, replay) → one live purchase and one live refund on production → payout watch.
Test-mode smoke — DONE 2026-09-16: 17/17. customer, vault, charge, idempotent re-charge returning the same PaymentIntent, insufficient-funds decline, 3DS requires_action, off-session charge, refund, subscription create / pause / resume / cancel, lookupCharge.
Still open: live-mode E2E, the webhook chaos check, the live purchase and live refund, and the payout watch — which is also what confirms the USD→CAD conversion fee against a real payout.
5. FULFILLMENT WORKER
Job flow on payment (photo path)
Enqueue one job PER PRAYER row —
(order_id, prayer_id, brand_slug, location_key, variant_seed, rush, candle)into the job queue (Postgres-backedFOR UPDATE SKIP LOCKEDtable — same DB, no extra infra). An order with two prayers produces two jobs and two photos. Never one job per order.Every job carries the two strings the card renders, and where they come from depends on the job type:
Job type First name Card text Order job orders.first_namethe prayersrow'stextMember job subscriptions.first_namesubscriptions.current_intention, or the generic themed intention for that month (PROMPT-PACKS §9.1 theme list) when it is nullA member job carries
(subscription_id, brand_slug, location_key, member_no, month_index, year_offset, candle=false)and never dereferences an order — a standalone/membershipmember has no order row (§3, §4). The enqueue path refuses any job whose first name resolves empty, because the card render requires it; an empty name is a funnel bug and fails loudly rather than rendering a nameless card.Rush queue.
rush = truejobs are pulled first (ORDER BY rush DESC, created_at ASC) and must complete insidedelivery.rushHours. A 24-Hour Placement acceptance re-prioritises every open job already queued for that order and rewrites theirreveal_atto the ready time.Worker pulls job → sets
prayers.status = 'preparing', loads the brand prompt pack frombrands/<slug>/prompts/and the donor reference image frombrands/<slug>/assets/donor/<location_key>.jpg.Pass 1 — scene generation: image-model call (GPT Image / Nano Banana Pro class) conditions on the donor reference and regenerates the site, randomizing time of day (morning / afternoon / evening / night), light and camera angle from
variant_seedso no two buyers get the same moment.Pass 2 — card in hand: an edit pass on the Pass-1 output renders a hand holding the prayer card, with the job's first name and the full card text on it. The
maxCharscap is what guarantees it fits. Pass 2 always places one lit votive candle beside the card, because a candle lit beside the card is promised on every order (COPY-CREATIVES §1, §4, §7, §8, §9). Whencandleis true it places two (PROMPT-PACKS §4.1a) — that second flame is the entire deliverable of the $4.99 bump, and without it a paying buyer receives a photograph identical to one who did not pay. Output: 1 hero image per prayer.QA gate — one vision call over two images, not an eyeball. Send both the Pass-1 frame and the Pass-2 output to the vision model through OpenRouter (
OPENROUTER_API_KEY, defaultgoogle/gemini-3.5-flash) in a single call. It returns the card-face transcription, the count of lit votives in the foreground beside the card, three frame-relative scene booleans, and the whole-frame flame counts for logging. The worker gates on all seven:# Check Verified by Pass condition 1 Name card_textexact match with the job's first name (step 1 table), case-insensitive 2 Prayer card_text≥ 95% of the source words present, in source order 3 Votive beside the card card_votiveslit votives in glass holders in the FOREGROUND beside or below the card = 1 when candleis false, 2 when true. Background racks and lamps are never counted;pass1_flames/pass2_flamesare logged only (rule changed 2026-09-16) — five of the six locations put many flames in the Pass-1 frame by design.4 Same place same_placerecognisably the same location and surfaces as the Pass-1 frame from a similar viewpoint; the hand, card, votive and a modest reframe are allowed 5 No new text new_textnothing readable except the card face and text already visible in the Pass-1 frame (inscriptions, friezes) 6 Card not occluded card_unoccludedno finger, thumb, flame, candle or architectural edge crosses the printed area 7 Hand plausible hand_plausibleone hand with five fingers, one card, no face, no second person All seven must pass. Any failure re-runs Pass 2 only — the Pass-1 scene is kept — using the name-spelling recovery prompt from PROMPT-PACKS §4.2 rule 9. Maximum 3 Pass-2 attempts. After the third, write a
dead_letter_jobsrow, setprayers.status = 'failed', and alert the operator. Cost is unchanged at +$0.02 per attempt: seven checks ride one call, and the second image does not add a second call. That is the line carried in the SPEC §3 and ECONOMICS §2 COGS tables. The full prompt-side contract, including the JSON response shape, is PROMPT-PACKS §4.5.Mark placed. On a QA pass, set
prayers.status = 'placed'and writeprayers.placed_at = now(). This timestamp is what the portal timeline and every[PLACEMENT_DATE]token render.Upload the asset to object storage (S3-compatible bucket per droplet). Store the original. Generate a signed URL, 7-day expiry for email embedding; the portal always serves fresh signed URLs.
Insert
deliverablesrow (kindphoto,prayer_idset,order_idset,visible = false,reveal_at=orders.paid_at+delivery.standardDayson standard orders, = ready time on rush), then setprayers.status = 'photo_ready'.Reveal tick. A cron every
portal.revealMinutes(15) flips ready deliverables whosereveal_athas passed tovisible = true. The portal renders only visible rows, so the gallery and the delivery email land together instead of the portal leaking the photo early.Schedule the delivery email. Standard orders:
paid_at+ 3 days, fixed (delivery.standardDays), not a random window — the confirmation email already promised that date. Rush orders: as soon as every job on the order is ready, and always insidedelivery.rushHoursofpaid_at. The same 15-minute cron checks due sends.In-progress email fires at
paid_at+ 24 h on standard orders that have not yet delivered. Rush orders skip it.
An order's delivery email waits for all its photo jobs (and the audio job, if Voice of the Sanctuary was taken) so the buyer gets one complete email, never a trickle.
Gift orders are dual-send. When orders.gift_recipient_email is set, every delivery-class email — the delivery email, the day-5 re-send, and any re-issue from support — is sent twice: once to orders.email and once to gift_recipient_email, each with its own signed URL and its own portal link. The recipient gets the photograph in their own inbox, in the buyer's name; the buyer keeps their copy. The copy promises both (COPY-CREATIVES §1 FAQ, §2 step 5) and the schema comment says the same. Nothing is ever sent instead of the buyer.
OTO3 audio path (on oto3_audio upsell taken)
- Prayer text → TTS with the persona voice for the brand (
audio.voiceId). - Ambience mix: ffmpeg preset from
audio.ambiencePreset(e.g. low basilica reverb bed, distant bells at −26 LUFS under voice at −16 LUFS). - Encode MP3 (128 kbps), upload, insert
deliverables(kindaudio, samereveal_atrules as the photo). - The audio rides inside the photo delivery email and appears in the portal gallery. There is no separate audio email. The preview the buyer heard on
/oto/3was the staticaudio.sampleUrlclip, not their own prayer.
Monthly membership cron (daily)
- Select members whose monthly delivery date falls today. The delivery date is
member_since+ N months — the same day of the month asmember_since, every month, clamped back to the last day of a shorter month. It is independent of the billing interval. A $79 annual member rebills once a year and still receives twelve photographs a year, on exactly the schedule a $9.90 monthly member gets. Billing cadence belongs to the rail; delivery cadence belongs to this cron, and this cron is the only thing that enqueues a member job on an existing subscription (§4). The first delivery is not the cron's: it is enqueued at thecreateSubscriptioncall (app-side, not a webhook), so month 1 lands on the date the Member welcome email names, and the cron takes over atmember_since+ 1 month. - Theme = the calendar month (12 themes, e.g. "January — Renewal", "March — St. Joseph", "November — All Souls").
- Treatment =
(member_no + year_offset) mod 4, whereyear_offsetis whole years sincemember_since. 12 × 4 = 48 combinations; no member repeats a photo inside four years. There is no stored cursor to advance — the formula is the state. - Name and card text come off the
subscriptionsrow, never off an order. The name printed on the card issubscriptions.first_name. The card text issubscriptions.current_intentionif the member opted in this month; if it is null, place the generic themed intention for that month (the theme named in PROMPT-PACKS §9.1), seeded at signup from the member's survey step-2 answer where one exists. A member never misses a delivery for not filling in a form. Clearcurrent_intentionafter the delivery. - Generate via the same two-pass pipeline (donor → card in hand), including the same seven-check QA gate at step 6 above — with the name checked against
subscriptions.first_nameand the candle delta checked at 1, sincecandleis always false on a member job. A member job takes an explicit branch on the scene axis: the per-order angle formulaangle = (variant_seed >> 4) mod n_variantsdoes not apply. Member jobs usescene_variant = (member_no + month_index) mod n_variants(PROMPT-PACKS §9.3) and take treatment from(member_no + year_offset) mod 4. The worker picks the branch off the job type, not off the seed. - Deliver: write the
deliverablesrow withsubscription_idset andorder_idnull for a standalone member, or both set for a member who came from an order. Send the member-delivery email and show it in the portal gallery. - Reminder: 5 days before each monthly delivery date, send the monthly-intention reminder email with a direct link to the portal intention field. Monthly and annual members both get it, twelve times a year.
- Renewal is rail-side;
invoice.payment_failedandcustomer.subscription.updated(with statuspast_due) drive dunning (§4 ladder table, §6 templates).
Retry rule
3 attempts per job, exponential backoff (5 min / 30 min / 2 h). After the 3rd failure → write a dead_letter_jobs row, set prayers.status = 'failed', and alert the operator (Telegram digest, §8 pattern). The same is true of the three-attempt card-text QA cap: a job that cannot clear QA dead-letters and its prayer row goes to failed. Nothing is ever left sitting at preparing forever — failed is what makes stuck orders findable and what the delivery-SLA tile (§9) counts against. Manual replay endpoint: POST /admin/replay/:dead_letter_id (auth-gated). Rush jobs alert on the first failure, not the third.
6. EMAIL — Resend
Per-brand sending domain (mail.<brand-domain>), verified with DKIM + SPF + DMARC records in DNS before first send. One Resend account, one domain key per brand.
Template list — 17 templates (mirrors SPEC §7; COPY-CREATIVES §9 must carry finished copy for all seventeen)
Every template links into /portal/<token>. Every schedule below counts from orders.paid_at, except templates 8 to 10, which count from orders.created_at.
| # | Template | Trigger | Class |
|---|---|---|---|
| 1 | Confirmation | within 60 s of payment — order id, promise date, prayer echoed back, portal link | transactional |
| 2 | In-progress | paid_at + 24 h, standard orders not yet delivered; rush orders skip it |
transactional |
| 3 | Delivery — photo + audio | paid_at + 3 days standard; ≤delivery.rushHours on rush. Audio rides inside this email if Voice of the Sanctuary was taken. |
transactional |
| 4 | Day-5 re-send | paid_at + 5 days — "here is your photo again", for buyers who never opened #3 |
transactional |
| 5 | Day-7 membership invite | paid_at + 7 days, non-members only |
marketing |
| 6 | Day-30 win-back (buyer) | paid_at + 30 days, no second purchase |
marketing |
| 7 | Day-365 re-placement | paid_at + 365 days → /replace |
marketing |
| 8 | Abandoned #1 | night 1 after a status='lead' row that never paid — social proof, 3 example photos, guarantee |
marketing |
| 9 | Abandoned #2 | night 2 — "you started a prayer" + what you get, guarantee | marketing |
| 10 | Abandoned #3 | night 3 — one example + the guarantee as urgency | marketing |
| 11 | Monthly intention reminder | 5 days before the member's monthly delivery date (member_since + N months) → the portal intention field |
transactional |
| 12 | Member delivery | each monthly delivery date, themed, the photograph named in the subject line. Twelve a year for monthly and annual members alike. | transactional |
| 13 | Dunning #1 | first invoice.payment_failed (the rail's 24 h attempt) |
transactional |
| 14 | Dunning #2 | the rail's 96 h attempt fails | transactional |
| 15 | Dunning #3 | the rail's first 168 h attempt fails — "one more attempt in 7 days" | transactional |
| 16 | Membership ended | customer.subscription.deleted only — the one email that says billing has stopped, plus the win-back ask |
transactional |
| 17 | Member welcome | standalone /membership signup with no prior order — portal link, what arrives and when |
transactional |
Abandoned emails go out in a fixed nightly batch, one per night for three nights.
Rules
- Delivery email: one clickable CTA only, and it is the Monthly Prayer Membership. No audio pitch, no second-prayer pitch, no seasonal block. One line, one link. The review ask is a reply request ("reply with 1–5 stars"), not a link — reviews are collected by reply only.
- Lead abandonment suppresses on purchase. The scheduler re-checks
orders.status = 'lead'at send time, not at schedule time. A lead who paid between stages gets nothing further. - Every abandoned email links to
/create/resume?t=<token>, never to a bare/create. The signed resume token re-mintsphfor that order and lands the reader on/create/readywith the draft intact, which is what templates 8, 9 and 10 promise in words. The token's TTL is 30 days fromorders.created_at, so it expires with the draft (§2, §3). - Every rebill and member-delivery email names the deliverable in the subject line and the first body line. This is the direct fix for the chargeback pattern where a recurring charge delivered nothing the customer recognised.
- Every email carries the portal link. Email still carries the photo itself, so a portal outage never blocks a delivery.
- Gift orders are dual-send. With
orders.gift_recipient_emailset, templates 3 and 4 and any support re-issue go to both addresses, each with its own signed URL and portal link. See §5. - No dunning or cancellation email states that charges have stopped unless the rail has cancelled. Template 16 is the only place that sentence exists, and it fires on
customer.subscription.deletedonly. - Suppression: hard bounces + spam complaints auto-added to the Resend suppression list;
/portal/<token>/email-preferencessetsorders.marketing_opt_out/subscriptions.marketing_opt_out; every marketing send checks that flag before it sends. The Class column above is the rule: transactional always sends, marketing respects the opt-out. - Support replies come back to
site.supportEmail— a real, answered mailbox. The Divine Rev reply-engine v2 pattern (deterministic pre/post gates, policy-as-data, shadow mode before live sends) gets ported once volume justifies it; until then a human answers.
7. DROPLET DEPLOY
docker-compose outline
services:
web: # Next.js, built from apps/web
worker: # apps/worker, same image, different entrypoint
postgres: # postgres:16, volume-mounted, not exposed publicly
caddy: # reverse proxy, auto-TLS per brand domain, ports 80/443
Caddy routes all brand domains → web. Cron sidecars (or host crontab) run: reveal tick + delivery-email scheduler (every 15 min), monthly member cron (daily), lead-abandonment nightly batch, lead purge (daily — deletes lead orders older than 30 days and their prayers, §3), reconciliation sweep (every 48 h), nightly pg_dump.
Env var inventory
# Brand secrets (per brand, prefixed by slug)
ST_PETERS_PIXEL_ID / ST_PETERS_CAPI_TOKEN
ST_PETERS_STRIPE_SECRET_KEY / ST_PETERS_STRIPE_WEBHOOK_SECRET
ST_PETERS_STRIPE_PUBLISHABLE_KEY
ST_PETERS_RESEND_API_KEY / ST_PETERS_RESEND_DOMAIN
# Payments rail
PSP_ADAPTER # 'stripe' in prod and staging; 'mock' in tests
# Stripe keys: pass companies/9566-0866/stripe-api-test (test) / stripe-api-live (live)
# Model API keys
OPENROUTER_API_KEY # one key for image, vision and TTS (pass apis/openrouter); decided 2026-09-16
OPENROUTER_PASS1_MODEL # default openai/gpt-5.4-image-2 (scene from the donor; GPT only, Shaw 2026-09-16)
OPENROUTER_PASS2_MODEL # default openai/gpt-5.4-image-2 (card in hand, handwriting)
OPENROUTER_VISION_MODEL # default google/gemini-3.5-flash (QA read-back)
OPENROUTER_TTS_MODEL # default openai/gpt-audio-mini (Voice of the Sanctuary; brand audio.voiceId is the OpenAI voice name)
# Storage
S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY
# Core
DATABASE_URL
PORTAL_TOKEN_SALT # nanoid salt for portal_sessions tokens AND the HMAC key for the `ph` funnel cookie
ADMIN_DASH_TOKEN # gates /admin, the orders dashboard
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID # escalation digest
No support telephone number exists in any env var, template, page or descriptor.
Deploy steps from zero
- Provision fresh Ubuntu droplet (isolated from all other brand infra).
- Lock to SSH keys: disable password auth in sshd_config, add operator key, root login prohibited.
ufw allow 22,80,443 && ufw enable.- Install Docker + compose plugin.
- Copy
infra/, set.env,docker compose up -d. - DNS: A record per brand domain → droplet IP. (Cloudflare in front optional — proxy on, but keep Caddy issuing via DNS-01 if proxying.)
- Verify TLS green on each brand domain, then run the webhook test against the registered endpoint.
Backups
Nightly pg_dump → object storage, 7-day retention (cron prunes). Restore drill: documented one-liner in infra/README.
8. ANALYTICS EVENTS MAP
| Event | Fires at | Client/Server |
|---|---|---|
view_landing |
/ first render |
client (PostHog + Pixel) |
survey_step |
each completed survey step, meta { step: 1..5 } |
client |
lead_submit |
survey step 5 success | server + client |
transition_view |
/create/ready render |
client |
offer_view |
/offer render — this is the only firing point |
client |
initiate_checkout |
/checkout tokenization form mount |
client |
purchase |
the synchronous settle path (§4 step 6), never the webhook | server (Pixel + CAPI mirror, event_id = order_id for dedupe) |
bump_take |
candle bump checkbox checked→confirmed | client |
oto1_take |
OTO1 submit confirmed | server |
oto2_take |
OTO2 rush accept confirmed | server |
oto3_take |
OTO3 audio accept confirmed | server |
monthly_signup |
subscription created | server |
deliverable_sent |
delivery email fired | server |
portal_open |
successful /portal/<token> entry |
server |
replacement_take |
/replace charge confirmed |
server |
email_open |
Resend open webhook | server |
email_click |
Resend click webhook | server |
Meta will classify this domain as Religion. Divine Rev's religion-flagged pixel earned an EU block and a slow appeal. Build for it from day one:
- Attribution must not rely on URL parameters. Assume click ids get stripped.
- Every CAPI event carries
event_source_urlplus the full match-key set, withevent_iddedupe against the browser pixel. - Fresh pixel per brand, never shared across brand fronts.
- New-ads-per-day cap ≤ 4, to keep a restricted account out of review pileup.
PostHog owns the funnel views (view_landing → survey_step ×5 → lead_submit → transition_view → offer_view → initiate_checkout → purchase). The five survey_step events are the survey drop-off map — the single most important new number in this funnel. Operator alerts and escalations go to a Telegram digest, following the Divine Rev check-escalations pattern: investigate every case, present findings, collect approvals, then execute in one pass.
9. ORDERS DASHBOARD (one screen)
Single admin screen at /admin, gated by ADMIN_DASH_TOKEN, filtered by brand, date-ranged (today / 7d / 30d toggles):
- Revenue (today / 7d / 30d) — sum of
orders.total_cents+upsellsbytaken_at. - Orders — count by status, per period.
- CPA — ad-spend input field (manual daily entry per brand) ÷ new paid orders. Contribution = revenue − ad spend − rail fees − COGS (model costs logged per job).
- Survey drop-off — completion rate per step, from
survey_stepmeta. Step 3 (the prayer) and step 5 (the email) are the two expected leak points. - Take rates, labelled with the §4 SKU names — Votive Candle, Second Prayer, 24-Hour Placement, Voice of the Sanctuary, Anniversary Re-Placement, Monthly Prayer Membership: taken ÷ paid orders.
- Stuck orders — count of prayers at
failed, plus any prayer sitting atpreparingfor longer thandelivery.queueHours. - Portal open rate — distinct orders with at least one
portal_open÷ paid orders, rolling 30d. A low number means the emails are not pulling people in and the reveal tick is doing nothing. - Delivery SLA compliance — % of standard orders delivered on day 3, % of rush orders delivered inside
delivery.rushHours. - Open disputes — count + dispute rate (disputes ÷ paid orders, rolling 30d), read from the
disputestable.
Disputes arrive by webhook, hand-entry is the backstop
charge.dispute.created opens the disputes row the moment Stripe reports a new case. charge.dispute.closed records the verdict (won or lost) on that same row. This is the primary path (§4). A daily operator task reads the payment dashboard and inserts or corrects any disputes row a webhook missed or reclassified — the backstop for anything that arrives outside the webhook. The dashboard shows the last entry timestamp; if it is older than 36 h, the tile goes amber — a stale number is worse than no number.
Dispute kill-switch rule
0.6% monthly dispute rate = pause ads immediately, fix billing clarity (descriptor, receipt email, obvious price display, recurring deliverable named in every rebill email) before resume. Dashboard shows the number in red at 0.45% as early warning.
10. DAY 0–13 LAUNCH CHECKLIST
Day 0 — infra & accounts
- Stripe account live under Numina House (
acct_1UGPHEKGASr0FGk4) — DONE 2026-09-16: entity 9566-0866 Québec inc., country CA, charges and payouts enabled -
PSP_ADAPTER=stripeset per brand env, with<SLUG>_STRIPE_SECRET_KEY/<SLUG>_STRIPE_WEBHOOK_SECRET/<SLUG>_STRIPE_PUBLISHABLE_KEYpopulated from the live keys (pass companies/9566-0866/stripe-api-live) - Statement descriptor set:
NUMINA* HOLYCARDS— DONE 2026-09-16, fits Stripe's 22-character cap, no phone, never "prayer" - Webhook endpoint registered with
scripts/stripe-register-webhook.ts <public-url> <brandSlug>once the Cloudflare deploy has a public URL; signing secret captured into<SLUG>_STRIPE_WEBHOOK_SECRETimmediately (Stripe shows it once) - Domain purchased + DNS + TLS green
- Funnel deployed with brand config v1 (St. Peter's)
- Donor reference image approved for the launch location and committed to
brands/st-peters/assets/donor/st_peters_basilica.jpg - Seven example photos generated, approved and committed to
brands/st-peters/assets/examples/— 1 landing hero, 3 on/create/ready, 3 on/offer - 5-step survey live end-to-end: 220-char cap enforced client and server, live counter,
survey_stepfiring with{step}meta, lead row written at step 5 withfor_whom/for_name/intention/note_to_father - Image pipeline end-to-end on a test order (donor → Pass 1 → Pass 2 card-in-hand → the seven-check QA gate over both frames → signed URL → deliverable row, one photo per prayer row), with a standard test order showing one votive beside the card and a Votive Candle test order showing two
- Audio pipeline on a test MP3 (TTS → ambience mix → MP3 → deliverable row) + the static OTO3 sample clip live
- Portal live at
/portal/<token>: magic-link entry, deliverable gallery, 15-minute reveal tick flipping a ready deliverable visible, track-order timeline, intention field, membership pause/resume/cancel, email preferences, tokenised offer surface - Supporting routes live:
/support,/terms,/privacy,/refunds,/create/resume,/thanks/correct-email,/admin -
phhandle verified: cookie minted at survey step 5, every post-survey route rejects a missing or tampered handle, OTO routes reject an unpaid order, ownership pre-check skips an owned SKU, 5-charge-attempts-per-hour limit enforced - Resume token verified: an abandoned-email link re-mints
phon a 20-day-old lead and lands on/create/readywith the draft intact; a tampered or expiredtfalls back to/create - Member delivery path verified on a standalone
/membershipsignup: thecreateSubscriptioncall (app-side, not a webhook) enqueues the first job, the card renderssubscriptions.first_name, and the cron schedulesmember_since+ 1 month regardless of whether the signup was monthly or annual - Webhooks verified: all 12 subscribed Stripe event types reaching the endpoint,
Stripe-Signatureverified inside the 5-minute replay window, handler returning 2xx - 48 h reconciliation sweep scheduled and tested against the orders/payments list API
- Resend domain verified (DKIM/SPF/DMARC) + all 17 templates loaded (§6), every one linking into the portal
- Support mailbox live and answered — email only
- Meta BM + page + fresh pixel + CAPI verified — fire test pixel, confirm Events Manager check post-pixel shows server events deduped and carrying
event_source_url - 3 creatives uploaded to ad account
- Orders dashboard live with ad-spend input, survey drop-off, portal open rate and the manual
disputesentry form - Test order end-to-end on sandbox: survey → pay → OTO1 (typed second prayer) → OTO2 (rush) → OTO3 (audio) → thank-you → portal → emails → deliverables (two photos + MP3)
- One live purchase and one live refund completed on production — this gate blocks ads
- Pre-mortem: confirm zero shared infra with any existing brand — no shared payment store, no shared domain or sending domain, no shared Meta BM, no shared droplet. Written sign-off.
Day 3 — launch
- Ads live: $50/day, broad, US + CA, women 40+, ≤4 new ads/day
- Monitor: first 10 orders watched manually end-to-end (email deliverability, fulfillment SLA, reveal tick, CAPI dedupe)
Day 6 — creative rotation
- Rotate in fresh creative if frequency climbing or CTR decaying; keep winners
Day 13 — CPA read
- CPA read at observed spend, then survey step drop-off
- Apply the scale-and-kill rule. It is defined once, in SPEC-PRAYER-ENGINE §9, and quoted here verbatim. No other threshold exists in any doc:
Contribution per buyer before ads ≈ $40.13. Day-10 read: CPA ≤ $31.40 → scale +20–30% every 3 days. CPA $31.40–$33.00 → hold, rotate creative. CPA > $33.00 → kill the ad set.