BUILD DOCS

OPS RUNBOOK — Prayer Delivery Offer Engine

prayer/OPS-RUNBOOK.md

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, $69 $34.90 with the subsidy line, 3 example photos with quotes, guarantee box, what you get, why choose us, persona letter as a lower section. Fires 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 orders row (status lead), then sets a signed, httpOnly, SameSite=Lax cookie named ph whose value is the order id plus an HMAC over it, keyed by PORTAL_TOKEN_SALT. TTL 7 days.
  • Required from /create/ready onward. Any route in the funnel order after the survey rejects a request with no ph, a bad HMAC, or an expired cookie, and sends it back to /create.
  • /oto/1, /oto/2, /oto/3, /thanks and /replace additionally require orders.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 upsells for that kind on 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 on portal_sessions.token only. ph grants 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 same PORTAL_TOKEN_SALT as ph. TTL 30 days from orders.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/resume verifies the HMAC and the expiry, re-mints ph for that order id with a fresh 7-day TTL, and redirects to /create/ready. A missing, tampered or expired t goes to /create with 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 a ph it 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 ordersprayerssubscriptionsdeliverablesportal_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 orders rows still at status = 'lead' whose created_at is older than 30 days, and their prayers rows with them. Nothing else purges leads.
  • The checkout page state lives in the ph cookie, TTL 7 days (§2). A returning buyer inside 7 days lands back on their own order; after that they re-enter through /create and 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.ts implements PspAdapter (createCustomer, vaultCard, charge, confirmAction, createSubscription, pauseSubscription, resumeSubscription, cancelSubscription, refund, lookupCharge, parseWebhook) over plain fetch via stripe-http.ts — no stripe-node SDK. packages/payments/mock.ts implements the same interface for tests. Nothing rail-specific lives in apps/web.
  • Adapter selection: PSP_ADAPTER=stripe in prod and staging, mock in tests.
  • Keys: pass companies/9566-0866/stripe-api-test (test) and pass companies/9566-0866/stripe-api-live (live) — secret key on the first line, publishable key on the publishable: 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

  1. Lead (survey step 5 at /create): no payment call at all. orders row, status lead, ph cookie set.
  2. Tokenize (/checkout): Stripe.js Card Element in the browser creates a PaymentMethod (pm_...). Card only. The card number never reaches our server. Wallets (Apple/Google Pay) are off for v1; no alternative rail is offered.
  3. Vault: the server creates (or reuses) the Stripe customer for the buyer and attaches the PaymentMethod to it, storing the pair as orders.psp_customer_id and orders.psp_instrument_id.
  4. Guard: write a charge_intents row (kind='fe', attempt_no=1, status='in_flight') before any charge call. A second request that finds an in_flight row for the same (order_id, kind, attempt_no) never fires a second charge.
  5. Charge: create a PaymentIntent for FE + Votive Candle if taken, in USD, with Idempotency-Key = charge_intent_id.
  6. Settle — this path is authoritative. On success, inside one transaction: set charge_intents.status='settled', orders.status='paid', orders.paid_at = now(), write the upsells row for the Votive Candle if taken, mint the portal_sessions token, fire purchase server-side with event_id = order_id (Pixel + CAPI mirror dedupe on that id), and enqueue fulfilment (§5). On failure set failed and 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 clears pause_collection.
  • Cancel at period end or immediately — both self-serve and one click in the portal.
  • Mirror every subscription event into our own subscriptions row 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-Signature header: HMAC-SHA256 over {timestamp}.{raw body} against that endpoint's signing secret, with the t= 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_SECRET immediately.
  • Handler map. Every order-side handler is reconciliation only and idempotent on the order id. payment_intent.succeeded → if the order is already paid, do nothing; if it is not, the settle path was lost, so apply the same settle transaction now (set paid, paid_at, mint the portal token, enqueue fulfilment) and fire purchase with event_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.paidrecord 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 the subscriptions row. customer.subscription.deleted → mirror the status and send the Membership ended email (template 16). charge.refunded → set orders.status = 'refunded' and stop any pending delivery. charge.dispute.created / charge.dispute.closed → write or update the disputes row, which feeds the /admin merchant-health number. radar.early_fraud_warning.created → refund the charge in full immediately (reason fraudulent) once Stripe marks the warning actionable, and write an events row psp_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 subscriptions row, assign member_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)

  1. Enqueue one job PER PRAYER row(order_id, prayer_id, brand_slug, location_key, variant_seed, rush, candle) into the job queue (Postgres-backed FOR UPDATE SKIP LOCKED table — 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_name the prayers row's text
    Member job subscriptions.first_name subscriptions.current_intention, or the generic themed intention for that month (PROMPT-PACKS §9.1 theme list) when it is null

    A member job carries (subscription_id, brand_slug, location_key, member_no, month_index, year_offset, candle=false) and never dereferences an order — a standalone /membership member 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.

  2. Rush queue. rush = true jobs are pulled first (ORDER BY rush DESC, created_at ASC) and must complete inside delivery.rushHours. A 24-Hour Placement acceptance re-prioritises every open job already queued for that order and rewrites their reveal_at to the ready time.

  3. Worker pulls job → sets prayers.status = 'preparing', loads the brand prompt pack from brands/<slug>/prompts/ and the donor reference image from brands/<slug>/assets/donor/<location_key>.jpg.

  4. 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_seed so no two buyers get the same moment.

  5. 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 maxChars cap 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). When candle is 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.

  6. 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, default google/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_text exact 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_votives lit votives in glass holders in the FOREGROUND beside or below the card = 1 when candle is false, 2 when true. Background racks and lamps are never counted; pass1_flames / pass2_flames are 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_place recognisably 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_text nothing readable except the card face and text already visible in the Pass-1 frame (inscriptions, friezes)
    6 Card not occluded card_unoccluded no finger, thumb, flame, candle or architectural edge crosses the printed area
    7 Hand plausible hand_plausible one 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_jobs row, set prayers.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.

  7. Mark placed. On a QA pass, set prayers.status = 'placed' and write prayers.placed_at = now(). This timestamp is what the portal timeline and every [PLACEMENT_DATE] token render.

  8. 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.

  9. Insert deliverables row (kind photo, prayer_id set, order_id set, visible = false, reveal_at = orders.paid_at + delivery.standardDays on standard orders, = ready time on rush), then set prayers.status = 'photo_ready'.

  10. Reveal tick. A cron every portal.revealMinutes (15) flips ready deliverables whose reveal_at has passed to visible = true. The portal renders only visible rows, so the gallery and the delivery email land together instead of the portal leaking the photo early.

  11. 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 inside delivery.rushHours of paid_at. The same 15-minute cron checks due sends.

  12. 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)

  1. Prayer text → TTS with the persona voice for the brand (audio.voiceId).
  2. Ambience mix: ffmpeg preset from audio.ambiencePreset (e.g. low basilica reverb bed, distant bells at −26 LUFS under voice at −16 LUFS).
  3. Encode MP3 (128 kbps), upload, insert deliverables (kind audio, same reveal_at rules as the photo).
  4. 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/3 was the static audio.sampleUrl clip, not their own prayer.

Monthly membership cron (daily)

  1. Select members whose monthly delivery date falls today. The delivery date is member_since + N months — the same day of the month as member_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 the createSubscription call (app-side, not a webhook), so month 1 lands on the date the Member welcome email names, and the cron takes over at member_since + 1 month.
  2. Theme = the calendar month (12 themes, e.g. "January — Renewal", "March — St. Joseph", "November — All Souls").
  3. Treatment = (member_no + year_offset) mod 4, where year_offset is whole years since member_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.
  4. Name and card text come off the subscriptions row, never off an order. The name printed on the card is subscriptions.first_name. The card text is subscriptions.current_intention if 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. Clear current_intention after the delivery.
  5. 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_name and the candle delta checked at 1, since candle is always false on a member job. A member job takes an explicit branch on the scene axis: the per-order angle formula angle = (variant_seed >> 4) mod n_variants does not apply. Member jobs use scene_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.
  6. Deliver: write the deliverables row with subscription_id set and order_id null 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.
  7. 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.
  8. Renewal is rail-side; invoice.payment_failed and customer.subscription.updated (with status past_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-mints ph for that order and lands the reader on /create/ready with the draft intact, which is what templates 8, 9 and 10 promise in words. The token's TTL is 30 days from orders.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_email set, 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.deleted only.
  • Suppression: hard bounces + spam complaints auto-added to the Resend suppression list; /portal/<token>/email-preferences sets orders.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

  1. Provision fresh Ubuntu droplet (isolated from all other brand infra).
  2. Lock to SSH keys: disable password auth in sshd_config, add operator key, root login prohibited.
  3. ufw allow 22,80,443 && ufw enable.
  4. Install Docker + compose plugin.
  5. Copy infra/, set .env, docker compose up -d.
  6. DNS: A record per brand domain → droplet IP. (Cloudflare in front optional — proxy on, but keep Caddy issuing via DNS-01 if proxying.)
  7. 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_url plus the full match-key set, with event_id dedupe 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 + upsells by taken_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_step meta. 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 at preparing for longer than delivery.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 disputes table.

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=stripe set per brand env, with <SLUG>_STRIPE_SECRET_KEY / <SLUG>_STRIPE_WEBHOOK_SECRET / <SLUG>_STRIPE_PUBLISHABLE_KEY populated from the live keys (pass companies/9566-0866/stripe-api-live)
  • Statement descriptor set: NUMINA* HOLYCARDSDONE 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_SECRET immediately (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_step firing with {step} meta, lead row written at step 5 with for_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
  • ph handle 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 ph on a 20-day-old lead and lands on /create/ready with the draft intact; a tampered or expired t falls back to /create
  • Member delivery path verified on a standalone /membership signup: the createSubscription call (app-side, not a webhook) enqueues the first job, the card renders subscriptions.first_name, and the cron schedules member_since + 1 month regardless of whether the signup was monthly or annual
  • Webhooks verified: all 12 subscribed Stripe event types reaching the endpoint, Stripe-Signature verified 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 disputes entry 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.