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 intvg-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:
| Topic | Where it lives |
|---|---|
| The storefront, and the request form's validation | tvg-storefront.md |
| Ops page layout, the catalogue editor, makers and payout statements | tvg-ops-and-payouts.md (not written yet) |
| Chat and voice intake | tvg-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
| Surface | URL | Who | Auth |
|---|---|---|---|
| Confirmation screen | shown after submit on the PDP or /bulk | Customer | none; it shows only what they just sent |
| Request page | /request/{token} | Customer | request token |
| Proof page | /proof/{token} | Customer | the same token |
| Pay page | /pay/{token} | Customer | the same token |
| Thanks page | /order/thanks?o=TVG-###### | Customer | shows the order number and state only, no personal data |
| Founder inbox and request detail | churchwiseai.com/founder/{token}/tvg, /tvg/requests/{id} | Founder | founder token (layout owned by tvg-ops-and-payouts.md) |
| Stripe Checkout | Stripe-hosted | Customer | Stripe |
| Webhook | existing /api/stripe/webhook → stripe_webhook_inbox → /api/cron/process-stripe-webhooks (every minute) → handleTvgStripeEvent() | Stripe / system | Stripe 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: noindexplus 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.txton the brand host disallows/request,/proofand/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 fromtvg_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, thenPOST /api/tvg/uploads/verify. - On a verified upload, status moves
needs_info→newand 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 state | DB status | Customer sees (label on the request page) | Founder sees (inbox column / badge) | Customer can |
|---|---|---|---|---|
| request | new | "Received — we're preparing your proof" | New (count; oldest first) | Cancel |
| request | needs_info | "We need something from you" + what is missing | Needs info (what's missing) | Add photo; Cancel |
| request | proof_in_progress | "We're preparing your proof" | Proofing | Cancel |
| proof_sent | proof_sent | "Your proof is ready — please review" + expiry date | Awaiting customer + days waiting; "Send reminder" button after 3 business days | Review, Approve, Request changes, Cancel |
| changes_requested | changes_requested | "We're updating your proof" + their change text | Changes requested (their text) | Cancel |
| approved | approved_awaiting_payment | "Approved — payment needed to start" + pay-by date | Awaiting payment + days since approval; "Record e-transfer" when allowed | Pay; Cancel |
| paid | paid | "Paid — in the queue. Ready by {due}" | Paid — to make + due date, sorted by due date | — |
| in_production | in_production | "Being made. Ready by {due}" | In production + due date; red when past due | — |
| ready | ready_for_pickup | "Ready for pickup — book a time" | Ready for pickup + days waiting | Book pickup |
| shipped | shipped | "Shipped — track your package" + tracking link | Shipped | Track |
| complete | completed | "Complete" | Completed (archived view) | Report a problem (within the §8 window) |
| expired | expired | "This proof has expired" + "Ask us to renew it" | Expired | Contact |
| cancelled | cancelled | "Cancelled — nothing was charged" or "Cancelled — refunded ${x}" | Cancelled + reason | — |
| (declined) | declined | "We can't make this one" + founder's reason | Declined | — |
| (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.
| From | To | Trigger (actor) | |
|---|---|---|---|
| (none) | new / needs_info | valid request POST (customer / ai_chat / operator) | E1 → customer, E2 → founder |
needs_info | new | verified photo upload (customer) or founder "Info received" | E2 "[TVG] Photo added" |
new, needs_info | proof_in_progress | founder opens "Start proof" | — |
new, needs_info, proof_in_progress, changes_requested | proof_sent | founder "Send proof" (§4.4) | E3 |
proof_sent | changes_requested | customer "Request changes" POST | E5 → customer, E2-style note → founder |
proof_sent | approved_awaiting_payment | customer "Approve" POST with C-A1 ticked, proof not expired, proof is the latest version | E6 |
approved_awaiting_payment | paid | checkout.session.completed for this request with amount = approved total (system), or founder "Record payment" (e-transfer/cheque) | E7 → customer, E8 → maker |
paid | in_production | founder "Start making" | — |
paid, in_production | ready_for_pickup | founder "Mark ready for pickup" (only if fulfilment=pickup) | E9 |
paid, in_production | shipped | founder "Mark shipped" with carrier + tracking + ship date (only if fulfilment=ship) | E10 |
ready_for_pickup | completed | founder "Picked up" with name of person + time (§7 evidence) | — |
shipped | completed | founder "Mark complete" | — |
proof_sent | expired | founder "Expire" (only after the proof's expires_at) | E12 (optional; founder ticks "notify customer") |
approved_awaiting_payment | expired | founder "Expire" (only after the pay-by date, §6.5) | E12 (optional) |
expired | proof_in_progress | founder "Renew" (re-quote allowed) | — (a new proof → E3) |
any pre-paid state | cancelled | customer "Cancel" POST or founder "Cancel" | E12 |
paid (not started) | cancelled | founder "Cancel + refund" (§8.4) | E12 + E11 |
new, needs_info, proof_in_progress | declined | founder "Decline" with reason (required) | E12 variant |
paid … completed | refunded | charge.refunded with refunded = amount paid (system; founder initiated) | E11 |
Hard rules:
paidis reachable only through a matching Stripe event or a founder-recorded manual payment. No customer-side request can set it.- No production state (
in_productionand later) exists beforepaid. - An
exampleorcoming_soonproduct can never have a request pastnew. The server gate is the storefront spec's §5. kind='waitlist'rows never enter this machine. They staynewuntil the founder archives them.- Every transition writes one
tvg_order_eventsrow, withactorand 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_atand 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_versesby 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_atand 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)
| Part | Content | Rule |
|---|---|---|
| Version | v1, 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-back | rendered_text_snapshot: every produced field, character for character, with the field label | Pre-filled from the request. Any difference from the request must be listed in "What we changed" (below). |
| What we changed | An 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. |
| Translation | Full name and abbreviation: "King James Version (KJV)" or "World English Bible (WEB)", plus reference and full verse text | It must equal the stored tvg_verses text for that id. Any mismatch blocks Send. |
| Options | Font, colour, size, material | — |
| Quantity | The number, and for bulk the count of names in the list | A 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 lines | Per 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 line | Jurisdiction 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. |
| Total | CAD, tax included | It 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}" | — |
| Message | An optional founder note to the customer, up to 1,000 characters | Plain 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"
- The founder clicks "Preview what the customer sees". This opens the exact proof page in a read-only frame.
- The founder clicks "Send proof", then a confirmation dialog: "Send proof v2 to {email}?"
- The server then:
- freezes the proof (sha256 of each image, the snapshot, the quote);
- sets
tvg_proofs.status='sent',sent_at, andexpires_at; - sets the request to
proof_sent; - writes an event;
- sends E3.
- 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
- "Proof v{n} for TVG-000123", the product, the maker, and "Sent {date}".
- The rendered proof image(s), full width, zoomable with the keyboard, with alt text restating the content.
- "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". - "What we changed" (only if not empty): each change as "You wrote: … / Proof shows: … — why: …".
- The options, quantity, and for bulk the full names list (collapsible when more than 10).
- 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.
- Timing (with the "if you approve today" date), the expiry date, and the colour note.
- 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.
- 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
POSTapproving 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 approvePOSTreturns 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
paidor 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'andresponded_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, andfinal_sale_ack_text_version. -
One
tvg_order_eventsrow withactor='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_paymentsrows. - Any total that differs from the approved proof.
- A promo-code field.
Other states:
proof_sent: "Please approve your proof first", with a link.paidor 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:
- 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. - Expire every earlier
opensession for this request (the Stripe sessions-expire call, test-mode verified), so that at most one session is payable at any time. Mark thosetvg_paymentsrowsexpired. [UNVERIFIED] The expire endpoint's behaviour on already-completed sessions; handle the error without failing the click. - 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_dataon the single Product "Thames Valley Gifts personalized order" and carriestax_rates: [txr_<jurisdiction>](a manual, exclusive Tax Rate, display name "HST" or "GST", jurisdiction "ON" etc.). automatic_taxoff. Noallow_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 onpayment_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}.
- Insert a
tvg_paymentsrow:method='stripe_checkout',status='pending',amount_cents, andstripe_checkout_session_id. - 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_centson 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_centsandcurrency == '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 thattvg_paymentsrowexpiredand 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).
- When a session expires, Stripe sends
- 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,
/payshows (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).
- DECIDED 2026-09-28 (default adopted by orchestrator):
- 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) pluslead_time_max_bdbusiness 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
paidtransition), 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
paidunless the founder ticks "Accept as full payment".
On save:
- A
tvg_paymentsrow (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 forcharge.*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.
| Event | Worker does | Customer 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 mismatch | Payment 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.expired | The matching tvg_payments row → expired; an event | None |
charge.refunded | Update 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.created | A founder action item with the assembled evidence bundle (§7); an event | None |
Timing:
- The inbox row for each TVG event is
processedwithin 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 field | TVG source |
|---|---|
product_description | Product snapshot (title, description, materials) + the approved proof image(s) + rendered_text_snapshot + "What we changed" |
customer_communication | Every 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_policy | The 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_documentation | Entered at "Mark shipped" (§8.2), plus an optional photo of the label |
shipping_address | Request ship_address |
customer_signature | Pickup 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_file | The 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:
| Case | How it's decided (from evidence) | Customer gets | Who bears the cost |
|---|---|---|---|
| 1. Maker error / defect / damage | The item differs from the approved proof image or text, is defective, or arrived broken | The 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 error | The 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 text | The 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 approved | Final 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.refundeddrives the status (§6.8). E11 is sent.
8.4 Cancellation
| When | Customer can | Result |
|---|---|---|
Before approval (new, needs_info, proof_in_progress, proof_sent, changes_requested) | Cancel from the request page | cancelled; "Nothing was charged"; E12 |
| Approved, not paid | Cancel from the request page | cancelled; 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:
- "Thames Valley Gifts is a division of ChurchWiseAI LTD." (C1)
- 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.
- 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. - The reason line: "You're receiving this because you requested a proof for order TVG-000123. It's about that order only."
- 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
| Id | Name | Trigger | To | Subject (exact) | Must contain | Must NEVER contain |
|---|---|---|---|---|---|---|
| E1 | Request received | Request created | Customer | We 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 |
| E2 | New request (internal) | Request created / photo added / changes requested | Founder | [TVG] New request TVG-000123 — {product} × {qty} | Ops link; source (web/chat/voice); needed-by date; photo quality flags | The customer photo as an attachment (link to ops only) |
| E3 | Proof ready | Founder "Send proof" | Customer | Your proof for TVG-000123 is ready to review | The 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 |
| E4 | Proof reminder | Founder "Send reminder" (button appears after 3 business days in proof_sent) | Customer | Reminder: your proof for TVG-000123 is waiting | Link; expiry date | Urgency language ("last chance", "act now") |
| E4b | Payment reminder | Founder button (after 3 business days in approved_awaiting_payment) | Customer | Reminder: payment for TVG-000123 | Pay-page link; pay-by date; e-transfer block if enabled | Urgency language |
| E5 | Changes received | Customer "Request changes" | Customer | We got your changes for TVG-000123 | Their change text echoed back; "new proof within 1 business day" | — |
| E6 | Approved — pay to start | Approve POST | Customer | Proof 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" |
| E7 | Receipt | paid (card via webhook, or founder-recorded payment) | Customer | Receipt for TVG-000123 — Thames Valley Gifts | Legal 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 + link | Promotions; full card number |
| E8 | Maker job sheet (internal) | paid | Maker (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 |
| E9 | Ready for pickup | Founder "Mark ready for pickup" | Customer | TVG-000123 is ready for pickup | How to book (per §8.1); pickup address; what to bring (the order number); who may collect | Promotions |
| E10 | Shipped | Founder "Mark shipped" | Customer | TVG-000123 has shipped | Carrier; tracking number; tracking link; what to do if it arrives damaged (the §8.3 window) | Promotions |
| E11 | Refund issued | charge.refunded (founder-initiated), or a founder-recorded e-transfer refund | Customer | Refund for TVG-000123 | Amount; method; "may take several business days to appear" ([UNVERIFIED] the typical card-refund timing; don't state a number until checked) | Blame language |
| E12 | Cancelled / declined / expired | Customer cancel; founder cancel, decline or expire (optional notify tick) | Customer | TVG-000123 has been cancelled · We can't make TVG-000123 · Your proof for TVG-000123 has expired | What happened; "nothing was charged" or the refund amount; how to start again; the reason for a decline | — |
| E13 | Now available (waitlist) | Founder "Notify waitlist" for one product; sent once per consenting signup | Waitlist signup | {Product} is ready to order | That product's link only; a reminder of the consent they gave; a mandatory unsubscribe link | Other products; discounts |
| E14 | Delay notice | Founder button when a due date will be missed | Customer | Update on TVG-000123: new ready date | New 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" |
| E15 | Your request link | Storefront "Find my request" match | Customer | Your link for TVG-000123 | The request-page link | Status 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)
| Step | Customer surface | Founder surface | |
|---|---|---|---|
| Submit | Confirmation screen (§2.1) | Inbox: New | E1, E2 |
| Proofing | Request page: "preparing your proof" | Request detail: build proof (§4) | — |
| Proof sent | Proof page (§5) | Awaiting customer + reminder button | E3 (E4) |
| Changes | Request page: "updating your proof" | Changes requested | E5 |
| Approved | Pay page (§6.1) | Awaiting payment + record e-transfer | E6 (E4b) |
| Paid | Thanks page → request page with the due date | Paid — to make, sorted by due date | E7, E8 |
| Making | Request page: "Being made. Ready by …" | In production (red when late) | (E14) |
| Ready / shipped | Request page with booking info or tracking | Ready for pickup / Shipped | E9 / E10 |
| Complete | "Complete" + report-a-problem window | Archived | — |
Thanks page /order/thanks?o=TVG-000123:
- Should see:
- If the database shows
paidor 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.
- If the database shows
- 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.
| # | Decision | Recommended default | Status |
|---|---|---|---|
| P1 | Token reissue button | Yes | DECIDED 2026-09-28 |
| P2 | Proof validity period | 14 days from send | DECIDED 2026-09-28 |
| P3 | Free revision rounds | Unlimited text/spelling; adjustment line allowed after 3 layout rounds | DECIDED 2026-09-28 |
| P4 | Pay-by window after approval | 7 days | DECIDED 2026-09-28 |
| P5 | Due date when payment is late (>2 business days after approval) | Count from payment date; tell the customer | DECIDED 2026-09-28 |
| P6 | Tax rounding method (with the accountant) | Match Stripe's per-line rounding | DECIDED 2026-09-28, subject to the accountant's routine sign-off |
| P7 | E-transfer for retail | Bulk only | DECIDED 2026-09-28 |
| P8 | E-transfer receiving address; auto-deposit | Company account; confirm auto-deposit | OPEN — founder (the actual account/address has no default) |
| P9 | Accept cheques | Bulk only | DECIDED 2026-09-28 |
| P10 | Pickup signature capture | Typed name + time at launch | DECIDED 2026-09-28 |
| P11 | Evidence retention | 180 days after completion | DECIDED 2026-09-28 |
| P12 | Pickup booking method | Reply to E9 with two times | DECIDED 2026-09-28 |
| P13 | Problem-report window | 14 days (7 for transit damage) | DECIDED 2026-09-28 |
| P14 | Who bears maker-error remake cost | Maker, per agreement; transit damage the hub | DECIDED 2026-09-28 |
| P15 | Remake discount for customer error | None published; case by case | DECIDED 2026-09-28 |
| P16 | Cancellation after payment, before production | Full refund on request | DECIDED 2026-09-28 |
| P17 | Sending address | orders@thamesvalleygifts.ca | DECIDED 2026-09-28 |
| P18 | Mailing address in the footer | ChurchWiseAI LTD registered office (confirm) | DECIDED 2026-09-28: 125 Concession Street, Ingersoll, ON ([UNVERIFIED] as the registered office) |
| P19 | Unsubscribe link on transactional mail | Include it (fail-safe) | OPEN — [LAWYER], CASL s.6(6) reading unconfirmed; interim default is to include it |
| P20 | Who ships; what E8 reveals to makers | Address only when the maker ships | DECIDED 2026-09-28 |
| P21 | Delay notice offers a refund | Yes, if more than 5 business days late | DECIDED 2026-09-28 |
| P22 | Bless the email templates (architecture §5) | Required before launch | OPEN — 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
TVGIFTSsuffix 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-webhookswith 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. - (a) a Vercel preview deployment with test-mode keys, a test webhook endpoint pointing at it, and the inbox worker triggered by hand (
-
Use only the QA product and QA maker (
tvg-storefront.mdD15), with requests flaggedis_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.
- the confirmation screen text per §2.1 (includes "Nothing has been charged.";
- Load
/request/{token}and assert the status label "Received — we're preparing your proof". A wrong token returns 404. Response headers includenoindex,Referrer-Policy: no-referrerandno-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_proofsv1 hasproof_image_sha256set.
- 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 approvewithproof_version:1returns 409. - Approve v2 without the checkbox: 400,
approval_evidencestill null. - Approve v2 with the checkbox. The status is
approved_awaiting_payment.approval_evidencehas 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. Thetvg_paymentscount 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 toquote_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 showsopen; tvg_paymentshas S1expiredand S2pending.
- 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_inboxrow for that event isprocessed; - the request is
paidwithpaid_atset; production_due_dateequals an independently computed value (approval date +lead_time_max_bdOntario business days);tvg_paymentsS2 ispaid, andcommission_bps_snapshotis 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
- 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 paidtvg_paymentsrow, 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.expiredevent is processed, the payment row isexpired, and no customer email is sent; - revisiting
/pay/{token}and clicking Pay mints a new session (new id,open), and payment succeeds.
- the
- 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):
/payshows "This approval expired on …";- the POST returns 409, and 0 new sessions are created.
- Proof expiry: a proof past
expires_atshows 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)
-
processStripeEventfixtures 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. - a CWA church checkout (
-
Positive control: the TVG fixture with
metadata.productremoved 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-checkoutcritical-path Playwright artifact is saved, or the override label is used with a reason. Independent QA sign-off follows. The paying-customer smoke ofPAYING-CUSTOMER-SURFACES.mdruns 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 isrefundedand 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 containingproduct_description,customer_communication,refund_policyandrefund_policy_disclosurevalues. 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
paidwith 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
completedand 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_eventsemail row (template id, version, message id). - No email was sent by a cron. Every email event has an actor of
customer,operatororstripe, neversystem_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.