Skip to main content

Thames Valley Gifts Proof-to-Pay — Expected Output Spec

DRAFT — not approved. Written by an agent from research_notes/Thames Valley Gifts build/ (architecture §2.2–§2.5, §2.7, §2.10, §3, §4 WP3; payment_processors.md; ux_research.md §3.3–§4) and the fixed founder facts in tvg-storefront.md §1. Founder Stage-2 review pending. Until approval, Rule #17 blocks WP3 code.

Markers used in this spec:

  • FOUNDER DECISION: the founder has to decide. A recommended default is given.
  • DECIDED 2026-09-28 (default adopted by orchestrator): a FOUNDER DECISION whose recommended default was accepted on the founder's behalf so WP3 planning can proceed; the founder can still override it. See the Decision log at the end of this file.
  • [UNVERIFIED]: nobody has checked this yet.
  • [PLACEHOLDER]: a stand-in value that must be replaced before launch.

0. Scope​

Covered. Everything from the moment a request is created (web form, or later chat/voice once tvg-ai-front-desk.md exists) until the order is complete, refunded or cancelled:

  • the customer's confirmation screen, request page, proof page, pay page and thanks page;
  • founder-side proof creation, quoting, payment recording and fulfilment steps, at the level of what the founder must see and what each button does;
  • every email in the flow;
  • the Stripe webhook branch;
  • dispute evidence;
  • refunds and remakes.

Not covered here:

TopicWhere it lives
The storefront, and the request form's validationtvg-storefront.md
Ops page layout, the catalogue editor, makers and payout statementstvg-ops-and-payouts.md (not written yet)
Chat and voice intaketvg-ai-front-desk.md (not written yet)

Fixed facts (from tvg-storefront.md §1, repeated where they bind this flow):

  • F2: existing Stripe account.
  • F4: division line.
  • F6: final sale unless defective or different from the approved proof.
  • F7: the delivery window runs from proof approval.
  • F8: free pickup by appointment in Ingersoll.
  • F9: Canada-only flat shipping, $14.50 [PLACEHOLDER].
  • F10: KJV/WEB.
  • F11: status gate.

House rules that bind this flow:

  • Crons never send email. The only emails that go out are:
    • triggered by the customer's own action;
    • sent when the founder presses a button;
    • triggered by a Stripe event about this customer's own payment or refund.
  • GET requests never change data. Email link-scanners (for example Outlook Safe Links) open every link in an email. If loading a page could approve a proof or create a Checkout Session, a scanner would do it. So every state change is a POST behind a button.
  • Refunds and dispute submissions are founder clicks only.
  • Live Stripe writes need founder confirmation:
    • the one Product "Thames Valley Gifts personalized order";
    • the manual Tax Rates;
    • the descriptor suffix.
  • The shared webhook file is critical-path (architecture §0.1, R2). The TVG branch keys only on metadata.product === 'tvg_order'.

1. Actors and surfaces​

SurfaceURLWhoAuth
Confirmation screenshown after submit on the PDP or /bulkCustomernone; it shows only what they just sent
Request page/request/{token}Customerrequest token
Proof page/proof/{token}Customerthe same token
Pay page/pay/{token}Customerthe same token
Thanks page/order/thanks?o=TVG-######Customershows the order number and state only, no personal data
Founder inbox and request detailchurchwiseai.com/founder/{token}/tvg, /tvg/requests/{id}Founderfounder token (layout owned by tvg-ops-and-payouts.md)
Stripe CheckoutStripe-hostedCustomerStripe
Webhookexisting /api/stripe/webhook → stripe_webhook_inbox → /api/cron/process-stripe-webhooks (every minute) → handleTvgStripeEvent()Stripe / systemStripe signature

Token rules (all testable):

  • One random token per request, at least 128 bits, base64url. Only its sha256 is stored (access_token_hash).
  • The token opens the request, proof and pay pages for that request only.
  • A wrong or unknown token returns 404, exactly as for a nonexistent page.
  • Token pages send:
    • X-Robots-Tag: noindex plus a robots meta tag;
    • Referrer-Policy: no-referrer, so the token never leaks to Stripe or other sites through the Referer header;
    • Cache-Control: private, no-store.
  • robots.txt on the brand host disallows /request, /proof and /pay.
  • Analytics, if any is added to TVG, must never record the token. Page paths are sent as /proof/[token]. [UNVERIFIED] It is not yet decided whether TVG pages load PostHog at all.
  • Rate limit: 30 token-page requests per IP per minute. More returns 429.
  • DECIDED 2026-09-28 (default adopted by orchestrator): the founder can reissue a token when a customer says their email was forwarded or leaked. It is an ops button, and the old token returns 404 at once.

2. Request received​

2.1 Confirmation screen (canonical; tvg-storefront.md §4.11 mirrors this)​

Should see:

  • "Request TVG-000123 received". The order number matches ^TVG-\d{6}$, zero-padded from tvg_order_number_seq.
  • "Nothing has been charged."
  • For retail: "We'll email your proof to {email} within 1 business day. Nothing is made until you approve it."
  • For bulk: "We'll email your quote and proof to {email} within 1 business day."
  • The read-back of every produced value, quantity and fulfilment choice.
  • IF status needs_info (photo still needed): "We still need your photo." with "Add your photo", linking to /request/{token}#photo.
  • "View your request", linking to /request/{token}.
  • "Didn't get the email in 10 minutes? Check spam, or contact us with your order number."

Should NOT see:

  • "Order placed", "Order confirmed", "Thank you for your purchase", "Paid", "Receipt", a total, a tax amount, or a pay button.
  • Cross-sell, sharing prompts, account creation or a marketing opt-in.

2.2 E1: "We received your request" email​

Sent automatically on successful submit. This is transactional and customer-triggered. The full template is in §9.

2.3 Request page /request/{token}​

This is the customer's persistent status page. It shows a timeline and one clear next step. It works whether or not any email was opened (ux_research failure #3).

Should see:

  • Order number, product(s), maker, quantity, fulfilment, and the read-back.
  • The status label for the current state, from §3.1.
  • One primary next action, when there is one:
    • "Add your photo";
    • "Review your proof";
    • "Pay ${total}";
    • "Book your pickup" (shows the booking instructions).
  • A timeline of dated events: received · proof sent (vN) · changes requested · approved · paid · being made · ready / shipped (with tracking link) · complete.
  • The due date, once paid (§6.6):
    • "Ready for pickup by {date}", or
    • "Ships by {date}".
  • "Cancel this request", available in the states listed in §8.4. It opens a confirmation dialog, then POSTs.
  • Contact line: "Questions? Reply to any of our emails or contact us with TVG-000123."

Photo add (needs_info):

  • The same upload control and DPI rules as tvg-storefront.md §4.5 (i).
  • The upload goes through POST /api/tvg/uploads/sign, then direct-to-Storage, then POST /api/tvg/uploads/verify.
  • On a verified upload, status moves needs_info → new and E2 is re-sent to the founder with the subject "[TVG] Photo added".

Should NOT see:

  • Internal notes, maker commission, maker contact details, other requests, the customer's full card or payment details, or Stripe ids.

3. State machine​

3.1 States​

The brief's short names map to the database status values (architecture §2.2 CHECK list). One database status can cover several brief states. declined and refunded are terminal states the brief did not list; they are included because the schema has them and the customer must see something honest in each.

Brief stateDB statusCustomer sees (label on the request page)Founder sees (inbox column / badge)Customer can
requestnew"Received — we're preparing your proof"New (count; oldest first)Cancel
requestneeds_info"We need something from you" + what is missingNeeds info (what's missing)Add photo; Cancel
requestproof_in_progress"We're preparing your proof"ProofingCancel
proof_sentproof_sent"Your proof is ready — please review" + expiry dateAwaiting customer + days waiting; "Send reminder" button after 3 business daysReview, Approve, Request changes, Cancel
changes_requestedchanges_requested"We're updating your proof" + their change textChanges requested (their text)Cancel
approvedapproved_awaiting_payment"Approved — payment needed to start" + pay-by dateAwaiting payment + days since approval; "Record e-transfer" when allowedPay; Cancel
paidpaid"Paid — in the queue. Ready by {due}"Paid — to make + due date, sorted by due date—
in_productionin_production"Being made. Ready by {due}"In production + due date; red when past due—
readyready_for_pickup"Ready for pickup — book a time"Ready for pickup + days waitingBook pickup
shippedshipped"Shipped — track your package" + tracking linkShippedTrack
completecompleted"Complete"Completed (archived view)Report a problem (within the §8 window)
expiredexpired"This proof has expired" + "Ask us to renew it"ExpiredContact
cancelledcancelled"Cancelled — nothing was charged" or "Cancelled — refunded ${x}"Cancelled + reason—
(declined)declined"We can't make this one" + founder's reasonDeclined—
(refunded)refunded"Refunded ${x} on {date}"Refunded (full)—

A partial refund does not change status. It shows on the request page timeline as "Partial refund ${x} on {date}".

3.2 Allowed transitions​

Any transition not listed is rejected with 409, and nothing is written.

FromToTrigger (actor)Email
(none)new / needs_infovalid request POST (customer / ai_chat / operator)E1 → customer, E2 → founder
needs_infonewverified photo upload (customer) or founder "Info received"E2 "[TVG] Photo added"
new, needs_infoproof_in_progressfounder opens "Start proof"—
new, needs_info, proof_in_progress, changes_requestedproof_sentfounder "Send proof" (§4.4)E3
proof_sentchanges_requestedcustomer "Request changes" POSTE5 → customer, E2-style note → founder
proof_sentapproved_awaiting_paymentcustomer "Approve" POST with C-A1 ticked, proof not expired, proof is the latest versionE6
approved_awaiting_paymentpaidcheckout.session.completed for this request with amount = approved total (system), or founder "Record payment" (e-transfer/cheque)E7 → customer, E8 → maker
paidin_productionfounder "Start making"—
paid, in_productionready_for_pickupfounder "Mark ready for pickup" (only if fulfilment=pickup)E9
paid, in_productionshippedfounder "Mark shipped" with carrier + tracking + ship date (only if fulfilment=ship)E10
ready_for_pickupcompletedfounder "Picked up" with name of person + time (§7 evidence)—
shippedcompletedfounder "Mark complete"—
proof_sentexpiredfounder "Expire" (only after the proof's expires_at)E12 (optional; founder ticks "notify customer")
approved_awaiting_paymentexpiredfounder "Expire" (only after the pay-by date, §6.5)E12 (optional)
expiredproof_in_progressfounder "Renew" (re-quote allowed)— (a new proof → E3)
any pre-paid statecancelledcustomer "Cancel" POST or founder "Cancel"E12
paid (not started)cancelledfounder "Cancel + refund" (§8.4)E12 + E11
new, needs_info, proof_in_progressdeclinedfounder "Decline" with reason (required)E12 variant
paid … completedrefundedcharge.refunded with refunded = amount paid (system; founder initiated)E11

Hard rules:

  • paid is reachable only through a matching Stripe event or a founder-recorded manual payment. No customer-side request can set it.
  • No production state (in_production and later) exists before paid.
  • An example or coming_soon product can never have a request past new. The server gate is the storefront spec's §5.
  • kind='waitlist' rows never enter this machine. They stay new until the founder archives them.
  • Every transition writes one tvg_order_events row, with actor and a payload that includes the from/to states.
  • No transition ever happens on a timer. Expiry is a founder click. The customer pages compute "expired" at read time from expires_at and show it, without writing anything.

4. Founder-side proof creation​

The ops layout is in tvg-ops-and-payouts.md. This section fixes what a proof contains and what "Send proof" checks.

4.1 What the founder sees on the request detail before proofing​

  • Every personalization value exactly as submitted, in a monospaced view that makes hidden problems visible:
    • leading, trailing and double spaces;
    • look-alike characters (Latin vs Cyrillic letters, straight vs curly apostrophes);
    • non-NFC characters. Each is flagged in words ("double space after 'Anna'").
  • The verse reference, translation and the stored verse text, from tvg_verses by id.
  • The monogram letters, arrangement and rendered string.
  • Uploaded photos (signed URLs), each with pixel size, effective DPI, the good/ok/low rating, and whether the customer acknowledged low resolution.
  • The server-computed price lines, fulfilment, the address and province, and the needed-by date, which is highlighted if it is earlier than the lead-time estimate.
  • spelling_confirmed_at and the final-sale text version the customer saw on the PDP.

4.2 What a proof contains (stored in tvg_proofs, shown on the proof page)​

PartContentRule
Versionv1, v2, …Unique per request. Sending a new version marks the old one superseded.
Rendered image(s)One or more PNG/JPG images of exactly what will be made. For photo engraving, this is the converted engraving render, not the original photo.At least 1 is required. The sha256 is recorded at send. The image is immutable after send; any change means a new version.
Exact text read-backrendered_text_snapshot: every produced field, character for character, with the field labelPre-filled from the request. Any difference from the request must be listed in "What we changed" (below).
What we changedAn auto-computed list of every difference between the request values and the snapshot, each with the founder's one-line reason ("Moved 'Est. 2019' to a second line so it fits")Send is blocked while any difference has no reason. The customer sees this box whenever it isn't empty.
TranslationFull name and abbreviation: "King James Version (KJV)" or "World English Bible (WEB)", plus reference and full verse textIt must equal the stored tvg_verses text for that id. Any mismatch blocks Send.
OptionsFont, colour, size, material—
QuantityThe number, and for bulk the count of names in the listA count that doesn't match the list is shown as a warning to the founder and as a line on the proof ("24 names, quantity 25 — we'll make one blank").
Price linesPer item: unit price, tier applied, option deltas, line subtotal. Plus named adjustment lines if the founder adds one ("Custom layout +$10.00"), each with a customer-visible label.The server computes the base lines. Adjustments are explicit lines, never a silent unit-price edit.
Shipping / pickup line"Shipping, anywhere in Canada — $14.50 [PLACEHOLDER]", or "Pickup in Ingersoll, by appointment — free"Taken from the settings value. There is no free-text shipping amount.
Tax lineJurisdiction label + rate + amount, for example "HST (Ontario) 13% — $10.92"The jurisdiction comes from the tax table (tvg_tax_rates) by fulfilment and province (architecture §2.5); the founder cannot type a rate. [UNVERIFIED] The non-Ontario rates, pending the accountant.
TotalCAD, tax includedIt must equal what Stripe will charge (§6.3, rounding note).
Timing"Ready {min}–{max} business days after you approve. If you approve today: ready by {date}."The date is computed live on the proof page at read time.
Expiry"This proof and price are good until {date}."DECIDED 2026-09-28 (default adopted by orchestrator): sent_at + 14 days.
Colour note"Colours on screen can differ slightly from the finished piece."Always present for sublimation and printed items.
Maker"Made by {Maker}, {Town}"—
MessageAn optional founder note to the customer, up to 1,000 charactersPlain text.

4.3 What the founder cannot do​

  • Send a proof for a product that is not available.
  • Send without an image, without a total, or with an unexplained difference.
  • Edit a sent proof.
  • Change the tax rate by hand.
  • Mark a request paid without a payment record: a Stripe event or a founder-entered manual payment with a reference.

4.4 "Send proof"​

  1. The founder clicks "Preview what the customer sees". This opens the exact proof page in a read-only frame.
  2. The founder clicks "Send proof", then a confirmation dialog: "Send proof v2 to {email}?"
  3. The server then:
    1. freezes the proof (sha256 of each image, the snapshot, the quote);
    2. sets tvg_proofs.status='sent', sent_at, and expires_at;
    3. sets the request to proof_sent;
    4. writes an event;
    5. sends E3.
  4. A double click creates one proof version and one email, using an idempotency key per click session.

5. Customer proof page /proof/{token}​

Screenshots: proof-desktop.png, proof-375.png, proof-expired.png, proof-superseded.png.

5.1 Should see, top to bottom​

  1. "Proof v{n} for TVG-000123", the product, the maker, and "Sent {date}".
  2. The rendered proof image(s), full width, zoomable with the keyboard, with alt text restating the content.
  3. "Check every letter": the exact text read-back from rendered_text_snapshot, one row per field. Each row has the field label and the value in a large monospaced style, with double spaces made visible. The verse shows reference, translation name and full text. The monogram shows "M J A — J larger in the centre".
  4. "What we changed" (only if not empty): each change as "You wrote: … / Proof shows: … — why: …".
  5. The options, quantity, and for bulk the full names list (collapsible when more than 10).
  6. The price table: item lines, adjustment lines, the shipping or pickup line, the tax line with jurisdiction and rate, and the total in CAD. No price appears only after this point.
  7. Timing (with the "if you approve today" date), the expiry date, and the colour note.
  8. The approval block:
    • C-A1 checkbox, unticked: "I have checked the spelling of every name, date and word on this proof. I understand personalized items are final sale unless they arrive defective or different from this proof."
    • The primary button: "Approve and continue to payment".
    • The secondary button: "Request changes". This opens a textarea labelled "What should we change? (required, up to 1,000 characters)", an optional replacement photo, and a "Send my changes" button.
  9. The contact line and footer, with the C1 division line.

5.2 Should NOT see​

  • A pay button before approval.
  • A pre-ticked checkbox.
  • "Approve" on a superseded or expired proof.
  • The internal quote breakdown: commission, maker share.
  • Stripe fields.
  • Any text saying the order is placed or paid.
  • A countdown timer. The expiry is a date, not a live clock (WAI: no time limits on forms).

5.3 Conditional​

  • IF a newer version exists: the page shows "A newer proof (v{n+1}) replaced this one", with a link to it. There is no approve or change action. A direct POST approving the old version returns 409.
  • IF the date is past expires_at (computed at read time; nothing is written): "This proof expired on {date}. Prices and timing may have changed." plus "Ask us to renew it" (to contact, prefilled with the order number). Approve and change are hidden. The approve POST returns 409.
  • IF status is approved_awaiting_payment: the page shows "You approved this proof on {date}" and a primary "Pay ${total}" button (to /pay/{token}).
  • IF status is paid or later: "Approved and paid — see your request status", with no actions.

5.4 Approve POST: what is recorded (the dispute evidence)​

POST /api/tvg/proofs/{token}/approve with body {proof_version, checkbox:true, checkbox_text_version}.

It rejects when:

  • the checkbox is false or missing (400);
  • the version is not the latest (409);
  • the proof is expired (409);
  • the status is not proof_sent (409).

On success it writes, in one transaction:

  • tvg_proofs.status='approved' and responded_at.

  • tvg_proofs.approval_evidence, with all of these keys present:

    {
    "proof_id": "…", "proof_version": 2,
    "proof_image_sha256": ["…"],
    "rendered_text_snapshot": { "…": "…" },
    "quote_total_cents": 9492, "currency": "cad", "tax_label": "HST (Ontario) 13%",
    "checkbox_text": "<exact C-A1 text shown>", "checkbox_text_version": "ca1-v1",
    "final_sale_policy_version": "returns-v1",
    "pre_request_spelling_confirmed_at": "<from the item>",
    "approved_at": "<UTC ISO-8601>", "ip": "<client IP>", "user_agent": "<UA>",
    "email_bound": "<the address the token was emailed to>",
    "page_url_path": "/proof/[token]"
    }
  • tvg_order_requests.status='approved_awaiting_payment', approved_at, final_sale_ack_at, and final_sale_ack_text_version.

  • One tvg_order_events row with actor='customer'.

Then it redirects (303) to /pay/{token} and sends E6.

Privacy: the privacy page says approvals record time, IP address and browser for this purpose (tvg-storefront.md §4.9).

5.5 Request-changes POST​

This requires non-empty text of 1–1,000 characters. It sets the proof to changes_requested and stores change_request_text, sets the request to changes_requested, writes an event, and sends E5 to the customer plus a note to the founder.

  • DECIDED 2026-09-28 (default adopted by orchestrator): unlimited free rounds for spelling and text; after 3 rounds of layout changes the founder may add an adjustment line, shown on the next proof.

6. Paying​

6.1 Pay page /pay/{token} (GET, no side effects)​

Should see (status approved_awaiting_payment, within the pay-by date):

  • "Pay for TVG-000123".
  • A price table identical to the approved proof: lines, shipping/pickup, tax line with jurisdiction and rate, and total.
  • "Pay by {pay-by date}".
  • The primary button "Pay ${total} securely" (a POST form).
  • The note: "You'll pay on Stripe's secure page. Card, Apple Pay or Google Pay."
  • IF e-transfer is enabled for this request (§6.7): a secondary section "Or pay by Interac e-Transfer".
  • The final-sale reminder: "Personalized items are final sale unless they arrive defective or different from your approved proof."

Should NOT see:

  • A Stripe session created just by loading the page. Loading the page N times creates 0 tvg_payments rows.
  • Any total that differs from the approved proof.
  • A promo-code field.

Other states:

  • proof_sent: "Please approve your proof first", with a link.
  • paid or later: "Already paid — thank you", with a link to the request page.
  • Past the pay-by date, or expired: §6.5.

6.2 The "Pay" POST: a fresh Checkout Session per click​

POST /api/tvg/pay/{token}. Every click creates a new Stripe Checkout Session:

  1. Re-check: status is approved_awaiting_payment, within the pay-by date, and the approved proof is the latest. Otherwise return 409 and show the matching message.
  2. Expire every earlier open session for this request (the Stripe sessions-expire call, test-mode verified), so that at most one session is payable at any time. Mark those tvg_payments rows expired. [UNVERIFIED] The expire endpoint's behaviour on already-completed sessions; handle the error without failing the click.
  3. Create the session with these parameters. Each is listed so QA can read it back from Stripe:
    • mode: 'payment', currency: 'cad', customer_email = the request email.
    • Line items: one per order item (name "{product title} — TVG-000123"; a description giving a short personalization summary, truncated with "…" if long), plus one shipping line when fulfilment is ship. Each line uses price_data on the single Product "Thames Valley Gifts personalized order" and carries tax_rates: [txr_<jurisdiction>] (a manual, exclusive Tax Rate, display name "HST" or "GST", jurisdiction "ON" etc.).
    • automatic_tax off. No allow_promotion_codes.
    • expires_at: now + 24 h. [UNVERIFIED] 24 h is Stripe's maximum (payment_processors.md Gap 1). Confirm in the Checkout Sessions API reference before building.
    • metadata: {product:'tvg_order', order_request_id, order_number, proof_id, proof_version}, and the same on payment_intent_data.metadata.
    • payment_intent_data.statement_descriptor_suffix: 'TVGIFTS'. [UNVERIFIED] It must fit the 22-character limit together with the account's current prefix; check the prefix first.
    • custom_text.submit.message: "Personalized items are final sale unless they arrive defective or different from your approved proof."
    • success_url: https://thamesvalleygifts.ca/order/thanks?o=TVG-000123. cancel_url: https://thamesvalleygifts.ca/proof/{token}.
  4. Insert a tvg_payments row: method='stripe_checkout', status='pending', amount_cents, and stripe_checkout_session_id.
  5. Return 303 to the session URL.

6.3 Amount equality (hard rule)​

  • Before creating the session, the server checks that the sum Stripe will charge equals quote_total_cents on the approved proof. If it doesn't, no session is created, the founder gets an action item, and the customer sees "We found a problem with this total — we'll email you shortly."
  • Rounding. [UNVERIFIED], and it blocks the build. Stripe calculates tax on each line item that carries tax_rates. Architecture §2.5 rounds tax once per order. The two can differ by a cent. Recommended: compute the quote's tax exactly the way Stripe does (per line, then sum), and prove it with a fixture whose per-line and whole-order rounding differ.
    • DECIDED 2026-09-28 (default adopted by orchestrator): compute the quote's tax exactly the way Stripe does (per line, then sum) — per-line rounding is the accepted method, subject to the accountant's routine sign-off on the overall tax table.
  • The webhook worker asserts amount_total == quote_total_cents and currency == 'cad' again (§6.8).

6.4 In Stripe Checkout (Stripe-hosted)​

The customer sees:

  • the item line(s) and the shipping line;
  • a tax line ("HST 13%" or similar — [UNVERIFIED] Stripe's exact display of a manual tax rate);
  • the total in CAD;
  • the email pre-filled;
  • card, Apple Pay and Google Pay (Stripe's automatic wallet display, per device);
  • the final-sale message above the pay button.

The customer does NOT see: a promo-code box, a quantity editor, or Link / other methods beyond the account's defaults. [UNVERIFIED] Which payment methods are on by default for this account. They must not include delayed methods whose checkout.session.completed arrives unpaid; if any are on, pass payment_method_types: ['card'] so Apple Pay and Google Pay still show through card.

6.5 Expiry: the 24 h session vs the pay-by window​

There are two separate clocks, and customers should never be stranded by either.

  • The Stripe session (24 h).
    • When a session expires, Stripe sends checkout.session.expired. The worker marks that tvg_payments row expired and writes an event. No email is sent.
    • The customer can return to /pay/{token} at any time within the pay-by window. The next click mints a new session (§6.2).
  • The pay-by window (approval validity).
    • DECIDED 2026-09-28 (default adopted by orchestrator): approved_at + 7 calendar days (architecture §2.4).
    • Shown as a date on the pay page and in E6.
    • After it passes, /pay shows (computed at read time) "This approval expired on {date}. Contact us and we'll reopen it — prices and timing may need updating." The pay POST returns 409.
    • The founder sees it in an "Expired approvals" list and chooses Expire (with optional E12) or Reopen (sets a new pay-by date; re-quoting requires a new proof version).
  • Reminders: there is no automatic reminder. After 3 business days unpaid, the founder inbox shows a "Send payment reminder" button (E4b).

6.6 Production due date​

The founder fact: the delivery window runs from proof approval (F7).

The rule:

  • production_due_date = the approval date (America/Toronto) plus lead_time_max_bd business days.
  • Business days are Monday to Friday, excluding Ontario public holidays from a config list. [UNVERIFIED] The holiday list must be written into config and reviewed.
  • For multi-item requests, the longest item's lead time applies.
  • The date is set when payment is recorded (the paid transition), because nothing is made before payment. It is shown on the receipt (E7), the request page, the founder inbox and the maker job sheet (E8).
  • Late payment. When payment arrives more than 2 business days after approval (a slow e-transfer, a returning card payer), counting from approval would promise a date the maker can't meet. DECIDED 2026-09-28 (default adopted by orchestrator): count from the payment date in that case, and show the customer the date on the receipt ("Ready by {date}"). The pay page states: "If you pay after {approval + 2 business days}, your ready date moves to {lead time} business days after payment."
  • A missed due date is never silent:
    • The founder inbox marks the request red on the due date.
    • The founder sends E14 (a delay notice with the new date) by button.
    • There is no automatic email.

6.7 Manual Interac e-Transfer path (B2B)​

When it applies:

  • Enabled automatically for kind='bulk_quote'.
  • Enabled for any other request only if the founder ticks "Allow e-transfer" on it.
  • DECIDED 2026-09-28 (default adopted by orchestrator): e-transfer is bulk only; not offered for retail.

The customer sees (on /pay/{token} and in E6), under "Or pay by Interac e-Transfer":

  • "Send ${total} to {e-transfer address}. FOUNDER DECISION: which address; it must be the company account."
  • "Put TVG-000123 in the message."
  • "Auto-deposit is on, so no security question is needed." [UNVERIFIED] Only show this sentence once auto-deposit is confirmed on the receiving account. Otherwise say "We'll email you the security answer separately", and never put it in the same email.
  • "We start your order when the transfer arrives. We'll email your receipt."
  • The ready-date rule from §6.6.

The founder sees:

  • "Record payment", with these fields:
    • method: e-transfer or cheque (DECIDED 2026-09-28 (default adopted by orchestrator): cheques accepted, bulk only);
    • amount (pre-filled with the total);
    • reference / confirmation number (required);
    • date received (required).
  • An amount different from the total requires a reason, and does not move the request to paid unless the founder ticks "Accept as full payment".

On save:

  • A tvg_payments row (method='etransfer', status='paid', manual_reference, recorded_by, paid_at).
  • The request moves to paid, with the due date per §6.6.
  • E7 and E8 are sent, exactly as for card.

Should NOT see: e-transfer instructions on retail requests unless enabled; the words "Interac Online"; an e-transfer option inside Stripe Checkout.

6.8 Webhook branch and worker behaviour​

The one early branch in the shared processStripeEvent():

  • Condition: the event object's metadata.product === 'tvg_order', or for charge.* events, the charge's PaymentIntent metadata.
  • Action: call handleTvgStripeEvent(event) and return.
  • Everything else falls through unchanged.

The TVG handler lives in src/lib/tvg/stripe-events.ts.

EventWorker doesCustomer email
checkout.session.completed, payment_status='paid'Idempotent on event.id and session id. Checks that the request is approved_awaiting_payment and that amount_total == quote_total_cents and currency is CAD. Then: tvg_payments → paid (with the payment-intent id); request → paid, paid_at, production_due_date (§6.6); each item's commission_bps_snapshot is set; an event is written.E7; E8 to the maker
… amount mismatchPayment recorded as paid; the request is not advanced; a founder action item "TVG amount mismatch TVG-000123"None until the founder resolves it
… the request is already paid (duplicate payment)Second payment recorded and flagged duplicate; a founder action item "Refund duplicate payment TVG-000123"None automatic; the founder refunds, which sends E11
checkout.session.expiredThe matching tvg_payments row → expired; an eventNone
charge.refundedUpdate refunded_cents. If the full amount is refunded: request → refunded, payment → refunded. Partial: payment → partially_refunded, request status unchanged. The event records the amount.E11
charge.dispute.createdA founder action item with the assembled evidence bundle (§7); an eventNone

Timing:

  • The inbox row for each TVG event is processed within 2 minutes of the event on production. The processing cron runs every minute.
  • The TVG path never raises the church-provisioning "churchId=undefined" watchtower alert.

7. Dispute evidence (mapped to Stripe's fields)​

Everything below is captured at the time it happens, so nothing is reconstructed after a dispute.

  • On charge.dispute.created, the worker assembles a bundle and files a founder action item.
  • Submitting the evidence is a founder click in Stripe. Nothing is submitted automatically.

The field names come from Stripe's dispute documentation as cited in payment_processors.md. Names marked [UNVERIFIED] were not in that research. Confirm them against the Stripe Disputes API before building.

Stripe evidence fieldTVG source
product_descriptionProduct snapshot (title, description, materials) + the approved proof image(s) + rendered_text_snapshot + "What we changed"
customer_communicationEvery email sent for this request (template id, version, timestamp, message id from tvg_order_events), the change-request texts, and the full approval_evidence JSON (§5.4)
refund_policyThe returns and final-sale policy text, at the version in force at approval (final_sale_policy_version)
refund_policy_disclosure"Shown on the product page before the request (spelling/final-sale checkbox confirmed {pre_request_spelling_confirmed_at}); shown again on the proof page and accepted by checkbox at {approved_at} from IP {ip}; repeated in Stripe Checkout above the pay button."
cancellation_policy, cancellation_policy_disclosure [UNVERIFIED names]§8.4 text + the same disclosure trail
customer_email_address, customer_name [UNVERIFIED names]Request email and name
receipt [UNVERIFIED name]The E7 receipt as a PDF or text
shipping_carrier, shipping_tracking_number, shipping_date, shipping_documentationEntered at "Mark shipped" (§8.2), plus an optional photo of the label
shipping_addressRequest ship_address
customer_signaturePickup record: name of the person collecting, time, and the founder's confirmation. DECIDED 2026-09-28 (default adopted by orchestrator): a typed name + time at launch; add a signature capture once pickups exceed 10/month.
uncategorized_fileThe evidence summary PDF (one page: timeline, proof image, approval record)

Retention: evidence is kept for at least 180 days after completion, even though customer photos are deleted at 90 days. The approval JSON and the proof image hashes stay. The proof image itself is kept until 180 days. DECIDED 2026-09-28 (default adopted by orchestrator): 180 days is confirmed (it covers typical card dispute windows [UNVERIFIED]); the privacy page states this retention period.


8. Fulfilment, refunds, remakes, cancellation​

8.1 Ready for pickup​

Founder: "Mark ready for pickup" (only when fulfilment is pickup). This sends E9.

The customer then sees on the request page: "Ready for pickup — book a time", with the booking method:

  • DECIDED 2026-09-28 (default adopted by orchestrator): reply to the email with two times that suit you.
  • The pickup address appears only here and in E9 (DECIDED 2026-09-28 (default adopted by orchestrator) in the storefront spec, D11).

At handoff: the founder records "Picked up by {name} at {time}", and the request becomes completed.

8.2 Shipped​

Founder: "Mark shipped", with these fields:

  • carrier (select; default [UNVERIFIED] Canada Post);
  • tracking number (required, non-empty, trimmed);
  • ship date (default today).

This sends E10, which links the carrier's tracking page with the number. [UNVERIFIED] The Canada Post tracking URL format. The request page shows the same link.

Should NOT happen: "shipped" without a tracking number. Untracked shipping is not offered at launch.

8.3 Refunds and remakes​

The customer reports a problem. They reply to any email or use contact, with photos:

  • within 14 days of pickup or delivery (DECIDED 2026-09-28 (default adopted by orchestrator): 14 days);
  • for transit damage, within 7 days of delivery.

The founder classifies it using the recorded evidence. There are three cases, stated plainly on the Returns page:

CaseHow it's decided (from evidence)Customer getsWho bears the cost
1. Maker error / defect / damageThe item differs from the approved proof image or text, is defective, or arrived brokenThe customer's choice: a free remake (including shipping), or a full refund (merchandise + tax + shipping). Returning the item is not required unless the founder asks.The maker, per the consignment agreement (the payout clawback in architecture §2.9); transit damage is the hub's. DECIDED 2026-09-28 (default adopted by orchestrator): confirmed as the maker agreement's default cost-allocation rule.
2. Proof errorThe approved proof differed from what the customer typed in the request, and that difference was not listed in "What we changed"Free remake (we changed it without telling them)The hub
3. Customer's own approved textThe item matches the approved proof, and the mistake was in the customer's own text (a typo, wrong date, wrong verse or wrong translation), or a difference that was listed in "What we changed" and approvedFinal sale. No refund. The founder may offer a remake as a new request. DECIDED 2026-09-28 (default adopted by orchestrator): no standard remake discount is published; handled case by case.The customer

Colour: a small screen-vs-product colour difference is not case 1. The proof's colour note (§4.2) discloses it. A clearly wrong colour choice versus the approved proof is case 1.

How refunds are executed:

  • Always a founder click: in the Stripe Dashboard, or through a founder-token ops button that calls Stripe.
  • Card refunds go back to the original payment method. E-transfer payments are refunded by e-transfer, and the founder records the refund in the ops page.
  • charge.refunded drives the status (§6.8). E11 is sent.

8.4 Cancellation​

WhenCustomer canResult
Before approval (new, needs_info, proof_in_progress, proof_sent, changes_requested)Cancel from the request pagecancelled; "Nothing was charged"; E12
Approved, not paidCancel from the request pagecancelled; nothing charged; any open session expired; E12
Paid, not started (paid)Ask to cancel (contact)DECIDED 2026-09-28 (default adopted by orchestrator): full refund on request. The founder clicks "Cancel + refund".
Started (in_production or later)—Final sale; the §8.3 rules apply after delivery

The founder may decline a request before proofing, with a required reason ("We can't engrave that material"). This sends the E12 variant; nothing is charged.


9. Emails​

9.1 Rules for every TVG email​

Transactional only. Every email in this flow exists solely to confirm, update, or deliver information about the customer's own request, payment, refund or pickup. They must contain no promotion:

  • no other products, images of other products, or "you might also like";
  • no discount codes, sales, "new arrivals", seasonal promotions or gift guides;
  • no newsletter signup, social "follow us", review requests, referral asks, or ChurchWiseAI / AI product mentions.

Allowed links:

  • this request's request, proof and pay pages;
  • the ordered product's own page;
  • policy pages;
  • contact;
  • carrier tracking.

Why: CASL s.6(6) exempts only messages that are solely transactional from the consent requirement. [UNVERIFIED] Confirm this reading with counsel. Adding promotion would make the email a commercial message that needs consent the customer never gave.

Sending triggers. An email is sent only on one of:

  • the customer's own action;
  • a founder button click;
  • a Stripe event about this request's payment or refund.

No cron job sends any TVG email. Reminders are founder buttons.

Every send is logged as a tvg_order_events row: template id, template version, recipient, provider message id.

From / Reply-To:

  • DECIDED 2026-09-28 (default adopted by orchestrator): the sending address is Thames Valley Gifts <orders@thamesvalleygifts.ca>, Reply-To a monitored mailbox.
  • [UNVERIFIED] The domain is verified with the email provider (SPF/DKIM) before launch.

Subjects always contain the order number, never contain promotional words, and never contain emoji.

Plain language. No "order confirmed" wording before payment.

Required CASL footer. Every customer email ends with these parts, in this order:

  1. "Thames Valley Gifts is a division of ChurchWiseAI LTD." (C1)
  2. A mailing address. DECIDED 2026-09-28 (default adopted by orchestrator): 125 Concession Street, Ingersoll, ON — the address other acceptance specs already cite. [UNVERIFIED] that this is ChurchWiseAI LTD's registered office and the address the founder wants in customer mail; confirm before launch.
  3. Contact: email, the website thamesvalleygifts.ca, and a phone number once the front-desk line is live. These must stay valid for at least 60 days after sending.
  4. The reason line: "You're receiving this because you requested a proof for order TVG-000123. It's about that order only."
  5. Unsubscribe mechanism.
    • [UNVERIFIED] [LAWYER] Whether CASL still requires an unsubscribe mechanism on s.6(6) messages — open until counsel confirms. Interim default in force (unsubscribe link included anyway): "Stop emails about this request".
    • The link opens a one-click page that suppresses further TVG emails to that address. It takes effect immediately, well inside the 10-business-day limit.
    • The request page then shows the banner "You've turned off emails; check this page for updates."
    • It must work for at least 60 days after sending.

The internal emails E2 and E8 go to the founder and the maker, and carry C1 only.

9.2 Email catalogue​

IdNameTriggerToSubject (exact)Must containMust NEVER contain
E1Request receivedRequest createdCustomerWe received your request TVG-000123"Nothing has been charged"; what happens next and when (proof within 1 business day); the read-back of their personalization; the request-page link; the "we still need your photo" block if needs_info; a one-line final-sale reminder"Order confirmed", "purchase", a total, a tax amount, a pay link
E2New request (internal)Request created / photo added / changes requestedFounder[TVG] New request TVG-000123 — {product} × {qty}Ops link; source (web/chat/voice); needed-by date; photo quality flagsThe customer photo as an attachment (link to ops only)
E3Proof readyFounder "Send proof"CustomerYour proof for TVG-000123 is ready to reviewThe proof image inline and the "Review your proof" link (the image is never the only way to see it); total incl. tax and shipping; the expiry date; "nothing is charged until you approve and pay""Reply 'yes' to approve" (approval happens only on the proof page); a pay link
E4Proof reminderFounder "Send reminder" (button appears after 3 business days in proof_sent)CustomerReminder: your proof for TVG-000123 is waitingLink; expiry dateUrgency language ("last chance", "act now")
E4bPayment reminderFounder button (after 3 business days in approved_awaiting_payment)CustomerReminder: payment for TVG-000123Pay-page link; pay-by date; e-transfer block if enabledUrgency language
E5Changes receivedCustomer "Request changes"CustomerWe got your changes for TVG-000123Their change text echoed back; "new proof within 1 business day"—
E6Approved — pay to startApprove POSTCustomerProof approved — pay to start TVG-000123"You approved proof v{n} on {date} at {time}"; total; the pay-page link; the pay-by date; the §6.6 ready-date rule; e-transfer block if enabled"Paid", "order confirmed"
E7Receiptpaid (card via webhook, or founder-recorded payment)CustomerReceipt for TVG-000123 — Thames Valley GiftsLegal name line C1; ChurchWiseAI LTD GST/HST registration number (DECIDED 2026-09-28 (default adopted by orchestrator): supplied at runtime via an environment variable, never committed to git; [UNVERIFIED] the 9-digit BN + RT0001 itself — never the BIN 1001760694); receipt date; order number; items with the personalization read-back; subtotal; shipping/pickup line; tax line with jurisdiction and rate; total CAD; payment method (card brand + last 4 from Stripe, or "Interac e-Transfer, ref …"); "Ready by {due date}" or "Ships by {due date}"; the final-sale policy summary + linkPromotions; full card number
E8Maker job sheet (internal)paidMaker (the founder for ChurchWise Gifts)[TVG] Job TVG-000123 — due {date}Exact personalization snapshot; proof image link (signed, valid 30 days); quantity; due date; fulfilment. The customer's shipping address only if the maker ships (DECIDED 2026-09-28 (default adopted by orchestrator): the maker ships for pickup-free makers, the hub otherwise).Customer email/phone (unless the founder decides otherwise); payment details; commission
E9Ready for pickupFounder "Mark ready for pickup"CustomerTVG-000123 is ready for pickupHow to book (per §8.1); pickup address; what to bring (the order number); who may collectPromotions
E10ShippedFounder "Mark shipped"CustomerTVG-000123 has shippedCarrier; tracking number; tracking link; what to do if it arrives damaged (the §8.3 window)Promotions
E11Refund issuedcharge.refunded (founder-initiated), or a founder-recorded e-transfer refundCustomerRefund for TVG-000123Amount; method; "may take several business days to appear" ([UNVERIFIED] the typical card-refund timing; don't state a number until checked)Blame language
E12Cancelled / declined / expiredCustomer cancel; founder cancel, decline or expire (optional notify tick)CustomerTVG-000123 has been cancelled · We can't make TVG-000123 · Your proof for TVG-000123 has expiredWhat happened; "nothing was charged" or the refund amount; how to start again; the reason for a decline—
E13Now available (waitlist)Founder "Notify waitlist" for one product; sent once per consenting signupWaitlist signup{Product} is ready to orderThat product's link only; a reminder of the consent they gave; a mandatory unsubscribe linkOther products; discounts
E14Delay noticeFounder button when a due date will be missedCustomerUpdate on TVG-000123: new ready dateNew date; a one-line honest reason; the option to cancel for a full refund if the new date is more than 5 business days late (DECIDED 2026-09-28 (default adopted by orchestrator))Vague "soon"
E15Your request linkStorefront "Find my request" matchCustomerYour link for TVG-000123The request-page linkStatus details (the link carries those)

E13 is the only email here that is not transactional. It relies on the express consent given on the waitlist form (tvg-storefront.md §4.6), and it is never sent by a cron.


10. What each state shows (the journey at a glance)​

StepCustomer surfaceFounder surfaceEmail
SubmitConfirmation screen (§2.1)Inbox: NewE1, E2
ProofingRequest page: "preparing your proof"Request detail: build proof (§4)—
Proof sentProof page (§5)Awaiting customer + reminder buttonE3 (E4)
ChangesRequest page: "updating your proof"Changes requestedE5
ApprovedPay page (§6.1)Awaiting payment + record e-transferE6 (E4b)
PaidThanks page → request page with the due datePaid — to make, sorted by due dateE7, E8
MakingRequest page: "Being made. Ready by …"In production (red when late)(E14)
Ready / shippedRequest page with booking info or trackingReady for pickup / ShippedE9 / E10
Complete"Complete" + report-a-problem windowArchived—

Thanks page /order/thanks?o=TVG-000123:

  • Should see:
    • If the database shows paid or later: "Payment received for TVG-000123. Your receipt is on its way to your email. Ready by {date}."
    • If the webhook has not been processed yet: "Your payment went through on Stripe. We're recording it now — your receipt email usually arrives within a few minutes." The page re-checks every 10 seconds, up to 2 minutes, and never shows an error for the delay.
    • A link to the request page.
  • Should NOT see: customer name, email, address, the session id or any Stripe id, "Order placed" before payment is recorded, or cross-sell. The page shows the same content to anyone holding the order number: state, and nothing personal.

11. FOUNDER DECISIONS in this spec​

Status: DECIDED = the orchestrator adopted the recommended default on 2026-09-28 (the founder can still override); OPEN = reserved for the founder, the accountant or counsel, or no default exists.

#DecisionRecommended defaultStatus
P1Token reissue buttonYesDECIDED 2026-09-28
P2Proof validity period14 days from sendDECIDED 2026-09-28
P3Free revision roundsUnlimited text/spelling; adjustment line allowed after 3 layout roundsDECIDED 2026-09-28
P4Pay-by window after approval7 daysDECIDED 2026-09-28
P5Due date when payment is late (>2 business days after approval)Count from payment date; tell the customerDECIDED 2026-09-28
P6Tax rounding method (with the accountant)Match Stripe's per-line roundingDECIDED 2026-09-28, subject to the accountant's routine sign-off
P7E-transfer for retailBulk onlyDECIDED 2026-09-28
P8E-transfer receiving address; auto-depositCompany account; confirm auto-depositOPEN — founder (the actual account/address has no default)
P9Accept chequesBulk onlyDECIDED 2026-09-28
P10Pickup signature captureTyped name + time at launchDECIDED 2026-09-28
P11Evidence retention180 days after completionDECIDED 2026-09-28
P12Pickup booking methodReply to E9 with two timesDECIDED 2026-09-28
P13Problem-report window14 days (7 for transit damage)DECIDED 2026-09-28
P14Who bears maker-error remake costMaker, per agreement; transit damage the hubDECIDED 2026-09-28
P15Remake discount for customer errorNone published; case by caseDECIDED 2026-09-28
P16Cancellation after payment, before productionFull refund on requestDECIDED 2026-09-28
P17Sending addressorders@thamesvalleygifts.caDECIDED 2026-09-28
P18Mailing address in the footerChurchWiseAI LTD registered office (confirm)DECIDED 2026-09-28: 125 Concession Street, Ingersoll, ON ([UNVERIFIED] as the registered office)
P19Unsubscribe link on transactional mailInclude it (fail-safe)OPEN — [LAWYER], CASL s.6(6) reading unconfirmed; interim default is to include it
P20Who ships; what E8 reveals to makersAddress only when the maker shipsDECIDED 2026-09-28
P21Delay notice offers a refundYes, if more than 5 business days lateDECIDED 2026-09-28
P22Bless the email templates (architecture §5)Required before launchOPEN — templates must be drafted and blessed before first send

12. [UNVERIFIED] items in this spec​

  • The 24 h maximum for Checkout Session expires_at (§6.2).
  • The sessions-expire behaviour on completed sessions (§6.2).
  • The TVGIFTS suffix fitting within 22 characters with the current prefix (§6.2).
  • Stripe's per-line tax rounding vs round-once (§6.3). This blocks the build.
  • Stripe Checkout's display of manual tax rates (§6.4).
  • Default payment methods on the account, and whether any are delayed (§6.4).
  • Stripe dispute field names beyond those cited in payment_processors.md (§7).
  • Stripe account-level "email customers on successful payments". This setting is account-wide and also affects ChurchWiseAI SaaS customers. Do not change it for TVG. E7 is the TVG receipt whether or not Stripe also sends one. Check the current setting so customers aren't confused by two receipts.
  • The ChurchWiseAI LTD 9-digit BN for the GST/HST line on E7.
  • Non-Ontario tax jurisdictions (accountant).
  • The CASL s.6(6) reading, and the unsubscribe requirement (counsel).
  • The mailing address; the sending domain SPF/DKIM.
  • The Ontario holiday list (§6.6).
  • The Canada Post tracking URL (§8.2).
  • Card-dispute window length (§7).
  • Card-refund appearance time (E11).
  • Vercel crons run only on production deployments. A test-mode run on a preview host will not process the webhook inbox automatically. See §13.0.

13. QA checklist: Stripe test mode, card 4242​

13.0 Environment​

  • DECIDED 2026-09-28 (default adopted by orchestrator), and [UNVERIFIED] on mechanics. Production uses live Stripe keys, so a test-mode run needs one of these:

    • (a) a Vercel preview deployment with test-mode keys, a test webhook endpoint pointing at it, and the inbox worker triggered by hand (/api/cron/process-stripe-webhooks with the cron secret), because preview deployments don't run crons; or
    • (b) a guarded test-mode switch on production scoped to the QA product.

    Option (a) is adopted for §13.1–13.4. Then a single founder-run live smoke on https://thamesvalleygifts.ca: one real small payment on the QA product, then refunded. That is a live Stripe write, so the founder presses it.

  • Use only the QA product and QA maker (tvg-storefront.md D15), with requests flagged is_test.

  • Customer email: john+tvgqa@churchwiseai.com. The Gmail MCP reads that mailbox.

  • Every "none happened" check has a positive control that shows the same query can find a match.

13.1 Request → proof → approval​

  • Submit a valid request. Assert:
    • the confirmation screen text per §2.1 (includes "Nothing has been charged."; ^TVG-\d{6}$; no /paid|order placed|purchase/i);
    • E1 arrives with the subject "We received your request TVG-…";
    • E2 arrives at the founder address.
  • Load /request/{token} and assert the status label "Received — we're preparing your proof". A wrong token returns 404. Response headers include noindex, Referrer-Policy: no-referrer and no-store.
  • Founder sends proof v1.
    • Try Send with a request/snapshot difference and no reason: blocked (positive control).
    • Add the reason and send. The status is proof_sent; E3 has the inline image and the link.
    • tvg_proofs v1 has proof_image_sha256 set.
  • Customer requests changes. Status changes_requested, E5 echoes the text. The founder sends v2. The v1 page shows "A newer proof (v2) replaced this one". POST approve with proof_version:1 returns 409.
  • Approve v2 without the checkbox: 400, approval_evidence still null.
  • Approve v2 with the checkbox. The status is approved_awaiting_payment. approval_evidence has every key listed in §5.4, each non-null. The response is a 303 to /pay/{token}. E6 arrives.
  • GET never mutates. Load /proof/{token} and /pay/{token} 5 times each. The tvg_payments count is unchanged. (Positive control: one POST to pay increases it by 1.)

13.2 Pay → paid​

  • /pay/{token} shows the tax line with jurisdiction and rate, and a total equal to quote_total_cents.
  • Click Pay and record the session id S1. Go back and click Pay again: session S2. Assert:
    • S1 ≠ S2;
    • retrieving S1 from Stripe shows status='expired', and S2 shows open;
    • tvg_payments has S1 expired and S2 pending.
  • Read back S2 from Stripe:
    • currency='cad', amount_total == quote_total_cents;
    • line items include a tax amount on each taxed line;
    • metadata.product='tvg_order', automatic_tax.enabled=false, no promotion codes;
    • expires_at − created ≈ 86,400 s (±60).
  • Pay S2 with 4242 4242 4242 4242. Within 2 minutes (trigger the worker by hand on preview per §13.0):
    • the stripe_webhook_inbox row for that event is processed;
    • the request is paid with paid_at set;
    • production_due_date equals an independently computed value (approval date + lead_time_max_bd Ontario business days);
    • tvg_payments S2 is paid, and commission_bps_snapshot is set on each item;
    • E7 arrives, containing C1, a GST/HST number line, the tax line and "Ready by {date}";
    • E8 arrives at the maker address.
  • The thanks page shows "Payment received for TVG-…" and no email address or name.
  • Idempotency: resend the same event with stripe events resend <id>. There is still exactly 1 paid tvg_payments row, and the E7 count in the mailbox is unchanged.
  • Declined card 4000 0000 0000 0002 on a second QA request: Checkout shows the decline, the request stays approved_awaiting_payment, and no E7 is sent.

13.3 Expiry​

  • Stripe session expiry: create a session, then expire it through the Stripe test API (simulating 24 h). Assert:
    • the checkout.session.expired event is processed, the payment row is expired, and no customer email is sent;
    • revisiting /pay/{token} and clicking Pay mints a new session (new id, open), and payment succeeds.
  • Pay-by window: on a QA request whose pay-by date is in the past (set through the founder "Reopen" date control, not by a DB edit):
    • /pay shows "This approval expired on …";
    • the POST returns 409, and 0 new sessions are created.
  • Proof expiry: a proof past expires_at shows the expired message, the approve POST returns 409, and the DB status is unchanged by the page load (GET never mutates).

13.4 Webhook isolation (fixture tests, CI)​

  • processStripeEvent fixtures for:

    • a CWA church checkout (metadata.tier='cwa_complete');
    • a legacy church tier;
    • a PewSearch Premium checkout;
    • an ITW and a SermonWise (B2C) checkout;
    • a ChurchWise tracker event (isTrackerEvent);
    • a subscription invoice.paid;
    • a TVG checkout.session.completed.

    Assert each non-TVG fixture reaches the same handler as before the change (snapshot of the dispatch target and its arguments), and only the TVG fixture calls handleTvgStripeEvent.

  • Positive control: the TVG fixture with metadata.product removed reaches the church path. This proves the test can observe routing.

  • The TVG fixture raises no "churchId=undefined" watchtower alert (alert spy call count 0; positive control: the product-less fixture calls it once).

  • Amount-mismatch fixture: the request is not advanced, a founder action item is created, and no E7.

  • Duplicate-payment fixture: a second payment is flagged, and an action item is created.

  • Rounding fixture (§6.3): a quote whose per-line and whole-order rounding differ produces a Stripe-equal total.

  • The stripe-live-checkout critical-path Playwright artifact is saved, or the override label is used with a reason. Independent QA sign-off follows. The paying-customer smoke of PAYING-CUSTOMER-SURFACES.md runs within 15 minutes of the production deploy.

13.5 Refund, dispute, fulfilment, e-transfer​

  • Refund: a founder refund in test mode sends charge.refunded. The request is refunded and E11 arrives. A partial refund leaves the status unchanged and puts the timeline entry on the request page.
  • Dispute: test card 4000 0000 0000 0259 raises charge.dispute.created. A founder action item is created, with the evidence bundle containing product_description, customer_communication, refund_policy and refund_policy_disclosure values. No customer email.
  • E-transfer: a QA bulk request, approved. The pay page shows the e-transfer section. The founder records a payment with a reference, and the request becomes paid with the due date set. E7 shows "Interac e-Transfer, ref …". No Stripe session exists for the request. A retail QA request shows no e-transfer section (positive control: the bulk one does).
  • Fulfilment:
    • "Mark shipped" with an empty tracking number is blocked. With a tracking number, E10 contains the number and a link.
    • For pickup: "Mark ready" sends E9. "Picked up by {name}" makes the request completed and stores the name and time.

13.6 Email compliance sweep (every email received in 13.1–13.5)​

  • Each customer email contains C1, a mailing address, a contact email, the reason line with the order number, and the stop-emails link. The link works: after clicking it, the next founder-triggered email to that address is suppressed and logged as suppressed.
  • Transactional emails (all except E13) show 0 matches for /shop now|sale|discount|% off|new arrivals|you might also like|follow us|review|churchwiseai.com|AI-powered/i and have no links other than the allowed set (§9.1).
    • Positive control: the same regex finds "shop now" in a deliberately bad fixture template.
  • Every subject contains the order number (except E13) and no emoji.
  • Each send has a matching tvg_order_events email row (template id, version, message id).
  • No email was sent by a cron. Every email event has an actor of customer, operator or stripe, never system_cron.

14. Decision log (2026-09-28 editorial pass)​

Defaulted (DECIDED 2026-09-28, default adopted by orchestrator; the founder can still override): P1, P2, P3, P4, P5, P6 (subject to the accountant's routine sign-off), P7, P9, P10, P11, P12, P13, P14, P15, P16, P17, P18 (§11); the GST/HST-number-via-env-var default on the E7 receipt.

Left open:

  • P8 — the e-transfer receiving address and auto-deposit confirmation: no default exists; the founder must supply the real account.
  • P19 — the unsubscribe link on transactional mail: CASL s.6(6) reading needs counsel [LAWYER]; the interim operating default (include the link) stays in force meanwhile.
  • P22 — blessing the email templates: left open per policy until templates are drafted and reviewed.
  • The "within 1 business day" proof promise (mirrors storefront C14, D2): reserved for the founder.