product

Story-as-Merch — print-on-demand posters + apparel

Status: Phase 1 BUILT (poster + apparel storefront; inert until Gelato is configured); Phase 2 pending. Product task 54663ae8 (Emberkiln, P2). Memory: project_story_as_merch. See the Build log sections at the bottom for what shipped + env.

Locked decisions (2026-06-27): fan markup split 50% creator / 50% platform (of the margin, not the gross); Gelato-only for apparel (no Printful — reuse the one client); guests allowed to buy (nullable buyer_user_id); Phase 1 shipped staged (PR A: poster storefront + payout + moderation + cron; PR B: apparel SKUs).

Provider note (reconciled 2026-06-27): the repo already ships a Lulu-based POD pipeline — but Lulu prints books only (print_orders, lulu.ts, print-storefront.ts: 6×9 paperbacks). Lulu has no poster or apparel product, so merch uses Gelato (posters + apparel) as a second provider that coexists with Lulu (books). Merch deliberately mirrors Lulu's proven shapes (the print_orders table, quoteRetail margin math, the Stripe kind-dispatch webhook, status-sync) rather than extending them.

"A story as all forms." The Emberkiln render-kit already turns one Work into print / audiobook / reel / screenplay. This adds the next allotrope: the story as a physical object — a poster, a shirt, a mug — printed from cover art the pipeline already generates, fulfilled by a print-on-demand partner with zero inventory.


1. The thesis (why this isn't a swag store)

The sharp reframe is merch-as-creative-ritual, not vanity swag. Two distinct buyers, two motivations:

  1. Author-for-self — the inspiration poster. Indie writers print a poster of the book or movie they're trying to finish and pin it up to write beneath. It's a commitment / manifestation object, not promo. The author is the buyer; the motivation is warm and non-cringe; intent is high. This is the wedge — lead with it.
  2. Author-for-fans — the support storefront. A shareable link the author sends to friends, family, and fans who buy a shirt/hat/mug to support them and their stories.

Build the plumbing once; surface both. The self-poster seeds the fan store (an author who pinned up their own poster will share the shop link).

The unlock: the art asset already exists. Every season has poster_url, every chapter chapter_cover_image_url, every journal song a cover_url — all gpt-image-1. A poster is that cover at print resolution with title + byline. Zero new creative work is asked of the author.


2. The one real technical gate: print resolution

Everything else is plumbing we already own. This is the only genuinely new piece.

Covers are generated at 1024×1024 PNG in season-assets/story-covers/{id}.png (story-cover-images.ts, chapter-cover-images.ts). For a 13×19" poster at 300 DPI you need ~3900×5700 px — the screen cover is ~7× short on each axis. Two paths:

PathHowVerdict
A — re-generate at print sizegpt-image-1 supports 1024×1536 / 1536×1024, not arbitrary print dimensions. Still far short of 300 DPI and produces a different image than the cover fans/author already know.✗ off-brand + still too small
B — upscale the existing coverFeed the exact poster_url through a Replicate super-resolution model (Real-ESRGAN / clarity-upscaler) 4× → ~4096 px, enough for an 11×17 at ~240 DPI or 13×19 at ~210 DPI (acceptable for art posters at viewing distance). Uses the already-wired getReplicateClient() in render-kit/providers.ts.Recommended — preserves the canonical image, reuses an existing provider

Cache the upscaled asset to season-assets/print/{id}@4x.png so we upscale once per cover, not per order. A poster-quality gate (reject covers that upscale poorly / contain artifacts) runs before the storefront offers a print.


3. What we reuse (almost everything)

NeedExisting infra to mirrorFile
One-time checkoutepisode-tips flow — Stripe mode:'payment' + metadata.kind, recorded on checkout.session.completedroutes/payment.ts
Creator payoutcreator_earnings append-only ledger (source_kind, gross_cents, creator_share_cents, platform_share_cents, stripe_*) + creator_payout_accounts (Stripe Connect Express)migration 160
Split overridecreator_revenue_splits (per-creator %) — add a merch_creator_pct columnmigration 160
Provider clientgetOpenAiClient() / getReplicateClient() consolidation patternrender-kit/providers.ts
Cost loggingtts_call_log + logTtsCall() (per-call cost rows)tts-spend.ts
Fulfillment polling cronrunStoryCoverTick() + recordHeartbeat() + CRON_REGISTRY + isCronEnabled()system-heartbeats.ts, cron-toggles.ts
Affiliate disclosure patternAmazon hivejournal-20 + FTC disclosure componentAmazonAffiliateDisclosure.tsx

Only genuinely new external dependency: the print-on-demand provider.


4. POD provider

  • Gelato for prints (posters, framed prints) — strong global print network + clean REST API + mockup generator. Start here; it's tightest to the inspiration-poster wedge.
  • Printful for apparel (tees, hoodies, hats, mugs) — the apparel default; add in Phase 1.

Wrap each behind a provider client mirroring providers.ts: apps/backend/src/services/gelato-client.tsgetGelatoClient(apiKey?), throwing if GELATO_API_KEY is unset. New env vars (add to ARCHITECTURE.md "Key environment variables"): GELATO_API_KEY, GELATO_WEBHOOK_SECRET (+ PRINTFUL_API_KEY in Phase 1).

For v1 the product catalog is a code constant (poster sizes → Gelato product UIDs + base costs), not a table. Promote to a table only when authors customize SKUs.


5. Data model (one new table + one column)

-- migration NNN: story-as-merch
CREATE TABLE merch_orders (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  buyer_user_id      UUID REFERENCES auth.users(id),   -- nullable: guest fan checkout
  creator_user_id    UUID NOT NULL,                    -- who earns
  source_kind        TEXT NOT NULL,                    -- 'season' | 'episode' | 'journal_song'
  source_id          UUID NOT NULL,                    -- season_id / episode_id / journal_song_id
  item_type          TEXT NOT NULL,                    -- 'poster_13x19' | 'tee' | 'mug' | ...
  print_asset_url    TEXT NOT NULL,                    -- the @4x cached upscale used
  gelato_order_id    TEXT,                             -- provider order ref
  status             TEXT NOT NULL DEFAULT 'pending',  -- pending|submitted|in_production|shipped|delivered|canceled|failed
  gross_cents        INT  NOT NULL,
  pod_cost_cents     INT  NOT NULL,                    -- base + shipping quoted at checkout
  stripe_session_id  TEXT,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE creator_revenue_splits
  ADD COLUMN merch_creator_pct NUMERIC;  -- nullable; falls back to a platform default

Money: gross_cents = pod_cost_cents + margin. The author's share of margin flows to a creator_earnings row with source_kind='merch_revenue' on fulfillment, using merch_creator_pct (default split TBD — suggest 50/50 of margin, since unlike a tip the platform carries POD ops + provider risk).

Author-for-self at cost: when buyer_user_id == creator_user_id, sell at pod_cost_cents with zero margin — remove all friction from the author printing their own inspiration poster. (This is also the cheapest way to dogfood print quality.)


6. Architecture decision: standalone, not an IR render target (yet)

The render-kit IR (Work → Section → Segment, ir/types.ts) is built for time-based, segmented outputs (audiobook, reel, scene, screenplay, print). A poster is a single still + title block — it doesn't need segments. Forcing it through the IR adds ~2 weeks of infra for no v1 benefit.

v1 = standalone apps/backend/src/services/merch.ts: read story_seasons / story_episodes / journal_songs directly, ensure the print-res asset, call Gelato. Revisit folding poster layout into the IR (as WorkMeta.poster_url consumers) only once a designed poster — multi-element layout pulling cast/quote/credits from the IR — is on the table. Noted in EMBERKILN_PIPELINE_ARCHITECTURE.md as a deferred render target.


7. Surfaces (frontend)

  • Season page — a "Make it real" CTA near the poster in seasons/[id]/SeasonClient.tsx.
  • Songs row — reuse the card in GraphenePublicSongsRow.tsx (a "Print this" affordance alongside the new "Hide from homepage" one).
  • Storefront/seasons/[id]/shop (or /s/<slug>/shop): the cover applied to Gelato mockups, a size/product picker, Stripe checkout. This is the shareable "support me" link.
  • Author earnings — merch rows surface in the existing creator earnings view (they're just creator_earnings with a new source_kind).

8. Fulfillment cron

runMerchStatusTick() polls Gelato for pending/submitted orders, advances merch_orders.status, and on shipped flips the matching creator_earnings row to transferred. Must recordHeartbeat({ name:'merch_status_tick', expectedIntervalSeconds:3600 }) on both paths, register a CRON_LABELS entry + a CRON_REGISTRY key with an isCronEnabled('merch_status_tick') guard. (Gelato also posts fulfillment webhooks — use the webhook as the primary signal and the cron as the backstop sweep.)


9. Gates / landmines

  • Print rightsgpt-image-1 output is generally OK for commercial physical goods under OpenAI terms, but confirm before selling. Add the equivalent of the Amazon affiliate disclosure for POD.
  • Content moderation — Gelato/Printful reject recognizable real faces, logos, and trademarks. AI covers sometimes render face-like figures (cf. the E42 card). A pre-flight moderation pass must gate the storefront, with a clear rejection message.
  • Margins + shipping — POD margins are thin and shipping can exceed product cost. The self-inspiration framing tolerates this; the fan store needs honest, all-in pricing shown before checkout (quote pod_cost_cents live from Gelato, don't estimate).
  • Returns / quality — POD handles fulfillment + returns, but a bad upscale ships a bad poster. The §2 quality gate is load-bearing.

10. Phasing

  • Phase 0 — prove the pipe. Print-res upscale path (§2) + getGelatoClient() + one product (13×19 poster) + author-at-cost self-order from the season page. No public store, no payout split. Validates print quality on real covers cheaply.
  • Phase 1 — the storefront. /seasons/[id]/shop + Stripe checkout (episode-tips pattern)
    • merch_orders + creator_earnings(source_kind='merch_revenue') + apparel via Printful + the fulfillment cron + content moderation gate.
  • Phase 2 — scale + integrate. Designed (multi-element) posters off the IR; curated collections; physical inventory at conventions (ties into EMBERKILN_AUTHOR_NATION_BOOTH.md); per-author SKU customization (promote the code-constant catalog to a table).

11. Open decisions (need sign-off before Phase 1)

  1. Margin split — default merch_creator_pct (suggest 50% of margin; tips are 80% but carry no COGS/ops).
  2. Upscaler model — which Replicate model (Real-ESRGAN vs clarity-upscaler) clears the §2 quality bar on real covers — decided empirically in Phase 0.
  3. Provider — confirm Gelato (prints) + Printful (apparel), or consolidate on one (Gelato does apparel too, fewer integrations vs. Printful's better apparel catalog).
  4. Guest checkout — do fans need an account to buy, or is buyer_user_id nullable from day one? (Lower friction = nullable; recommend allowing guests.)

Build log — Phase 0 (shipped 2026-06-27)

Prove the pipe: author-at-cost self-order of a single poster from the season page. The whole path is gated/inert until GELATO_API_KEY is set — without it an order still takes payment and parks at awaiting_fulfillment rather than failing (the Lulu/coaching "shipped but inert" convention).

What shipped:

  • migration 426merch_orders (image-shaped, modeled on print_orders) + a nullable merch_creator_pct on creator_revenue_splits (Phase-1 payout split, added now to avoid a second migration).
  • print-asset.tsensurePrintAsset(): the one real new step. Upscales a cover 4× via Replicate Real-ESRGAN and caches it to season-assets/print/<kind>-<id>-4x.png (upscale once per cover, not per order). Gated on REPLICATE_API_TOKEN.
  • gelato-client.ts — Gelato Order API v4 client (isGelatoConfigured, createGelatoOrder, getGelatoOrder), inert without a key.
  • merch.ts — Phase-0 product catalog (code constant), quoteSelfMerch (at-cost: base + shipping, margin 0), createSelfMerchCheckout (Stripe Checkout collects the shipping address), fulfillMerchOrder (webhook → upscale → Gelato or park).
  • routes/merch.tsGET /api/merch/products, POST /api/merch/:seasonId/self-checkout (owner-gated). Mounted in index.ts; the Stripe webhook dispatches kind:'merch_order' in routes/payment.ts.
  • MakeItRealCTA.tsx — owner-only "Make it real → Order a poster" card on the season page (next to Make another), framed as the inspiration poster, sold at cost.

Env config to activate (add to ARCHITECTURE.md "Key environment variables" when set): GELATO_API_KEY (required to actually fulfill), GELATO_POSTER_13X19_UID (catalog product UID — the default is a placeholder, confirm against Gelato's catalog), GELATO_POSTER_13X19_BASE_CENTS, GELATO_POSTER_SHIPPING_CENTS. Reuses the already-configured REPLICATE_API_TOKEN + STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET. See setup-guides/GELATO_MERCH.md.

Deliberately deferred to Phase 1 (not in Phase 0): the public/fan storefront, the payout split (creator_earnings rows via merch_creator_pct), live Gelato price/shipping quoting, apparel, the content-moderation gate, and the fulfillment-status cron. Not yet tested: no golden test — the Phase-0 surface is I/O plumbing gated on external services and the only pure function (quoteSelfMerch) is a trivial sum; the deterministic logic worth a test (live quoting + margin splits) arrives in Phase 1.

Activation checklist: apply migration 426 to prod → create a Gelato account + API key → confirm the 13×19 poster product UID + set the cost env vars → place one real author-at-cost order and verify print quality on a real upscaled cover (the §2 quality gate).


Build log — Phase 1 PR A: fan storefront + payout + moderation + cron (shipped 2026-06-27)

Opens the public "support the author" storefront. Anyone (guests included) can buy a poster of a public story; the markup is split 50/50 with the creator; a pre-sale moderation pass gates covers the POD partner would reject. Still inert without GELATO_API_KEY (paid orders park at awaiting_fulfillment).

  • migration 427 — adds 'merch_revenue' to the creator_earnings.source_kind CHECK + moderation-cache columns on story_seasons (merch_moderation_status / _reason / _checked_at).
  • pricingmerch-pricing.ts (pure, import-free): computeFanQuote (base + shipping + max(base×MERCH_MARGIN_PERCENT, MERCH_MARGIN_MIN_CENTS), rounded up to 50¢), computeSelfQuote, computeMerchSplit (creator share of the margin). Golden-testedmerch-pricing.test.ts (9 cases: markup/floor/round-up, never-below-cost, split-sums-to-margin).
  • moderationmerch-moderation.ts ensureMerchSellable(): gpt-4o-mini vision check (recognizable real faces / brand logos / trademarked characters / illegal content → reject), verdict cached on the season; fails open on infra error, never caches a non-verdict. Gates fan checkout only — not author self-orders.
  • storefront + payoutmerch.ts adds assertPublicSeason (gate on is_publicly_viewable), getSeasonStorefront, createFanMerchCheckout (guests allowed via soft auth; moderation-gated), and bookMerchEarning inside fulfillMerchOrder → writes a creator_earnings row (source_kind='merch_revenue', status='pending', split via merch_creator_pct ?? MERCH_DEFAULT_CREATOR_PCT). At-cost self-orders (margin 0) no-op.
  • routesGET /api/merch/:seasonId/storefront (public; 404 → UI hides) + POST /api/merch/:seasonId/checkout (soft auth; 422 merch_cover_rejected when moderation blocks).
  • fulfillment cronmerch-order-sync.ts syncInFlightMerchOrders() polls open Gelato orders (backstop for missed webhooks); wired as the merch_orders_sync cron (15min) in index.ts with heartbeat + CRON_REGISTRY + CRON_LABELS + toggle guard. No-op when Gelato is unconfigured.
  • UIFanPosterStorefront.tsx: public "🖼️ Support this story — order a poster ($X)" card shown to non-owner visitors on the season page; self-hides when the season isn't sellable.

New env knobs: MERCH_MARGIN_PERCENT (default 40), MERCH_MARGIN_MIN_CENTS (default 500), MERCH_DEFAULT_CREATOR_PCT (default 50). Also fixed: registered the merch_checkout_started / merch_order_paid analytics events in ServerEvent (Phase 0 left them unregistered — a latent backend type error that didn't block the Railway build).


Build log — Phase 1 PR B: apparel + drinkware SKUs (shipped 2026-06-27)

Extends the catalog from poster-only to poster · tee · hoodie · mug · cap, fulfilled by the same Gelato client (no Printful, no new integration). Pure catalog + UI work — the checkout, moderation gate, payout split, and fulfillment cron are all product-agnostic and unchanged.

  • merch.tsMerchProduct gains a kind (poster | apparel | drinkware); MERCH_PRODUCTS adds tee/hoodie/mug/hat with env-overridable Gelato UIDs + base/shipping costs. The cover is reused as the print file as-is (Gelato fits it to each product's print area; a per-garment "centered graphic" treatment is a later refinement). Both product-list endpoints now return kind.
  • UI — FanPosterStorefront.tsx
    • MakeItRealCTA.tsx become product pickers (chips with per-item icon + price; one order button for the selection).
  • New env (all optional, placeholder defaults): GELATO_{TEE,HOODIE,MUG,HAT}_UID + _BASE_CENTS + _SHIPPING_CENTS.

Phase 1 complete. Phase 2 — designed (multi-element) posters off the IR, curated collections, convention inventory (EMBERKILN_AUTHOR_NATION_BOOTH.md), per-author SKU customization (promote the code-constant catalog to a table). Not yet built: Stripe Connect transfer automation (earnings book as pending; moving money to the creator's Connect account is the existing manual/period reconciliation path); live Gelato price/shipping quoting (catalog is still a code constant); per-garment print-file treatment.


Build log — hardening + marketing (2026-06-27)

Sell-gate fix (important). "Inert until configured" originally meant fulfillment parked at awaiting_fulfillment — but checkout still took the buyer's money via Stripe first, so the never-configured state would charge a fan and ship nothing. Fixed in routes/merch.ts: every sell path now gates on isGelatoConfigured() (mirroring the Lulu storefront's 503) — GET /products returns empty, GET /:seasonId/storefront 404s (→ CTAs self-hide), and both checkout endpoints 503. So without a Gelato key we don't sell at all (no money taken); the awaiting_fulfillment parking now only covers a transient Gelato outage after it's configured.

Marketing surfaces. Merch is now positioned as the next output form of the "every form" thesis: a Merch chip in EmberkilnFlow on /emberkiln, a Story-as-merch — posters & apparel entry in the /features catalog, and a FAQ entry on the /studio landing. Safe to ship ahead of activation because the buy paths self-gate (above) — the copy describes the capability; the buttons only light up once Gelato is wired.


Build log — money-path audit (2026-06-27)

Adversarial pass over the merch money paths for the same bug class as the sell-gate miss. Two real findings fixed; two limitations noted (matching the existing Lulu storefront, not regressions).

Fixed:

  • Double-fulfillment racefulfillMerchOrder read status, then wrote much later, so two concurrent/duplicate Stripe checkout.session.completed deliveries could both pass the status === 'pending_payment' guard → double Gelato order + double earnings row + double upscale. Now an atomic compare-and-swap claims the row (pending_payment → processing); only the winning delivery proceeds, the rest no-op. This also makes bookMerchEarning idempotent (it relied entirely on that guard).
  • Stale moderation verdict on cover change — a regenerated cover kept the old merch_moderation_status, so a newly-rejectable cover could sell under a prior approved (or a fixed cover stay rejected). Both poster write-sites in season-media.ts now null the moderation cache so the new cover is re-checked before sale.

Known limitations (consistent with the Lulu storefront, not fixed here):

  • A transient failure during fulfillment marks the order paid_unfulfilled … with no auto-retryfixed 2026-07-05, see build log below.
  • Refunds don't reverse a pending earnings rowfixed 2026-07-05, see build log below. (An already-transferred earning still needs a manual Connect reversal — surfaced, not silently voided.)

Build log — stranded-order auto-retry (2026-07-05)

Closes the "took the money, shipped nothing, no auto-retry" gap the audit flagged. A PAID order could get stuck before reaching Gelato — paid_unfulfilled (fulfillment threw), a stranded processing (crash mid-fulfill), or awaiting_fulfillment (paid while Gelato was unconfigured; now it is) — and the merch_orders_sync cron only polled orders that already had a gelato_order_id, so these sat until a human noticed.

  • migration 453merch_orders.fulfillment_attempts (int, default 0) + last_attempt_at. The attempt counter doubles as an optimistic-lock version.
  • idempotent bookMerchEarning (merch.ts) — guards on the Stripe payment intent so retrying an already-booked order (e.g. an awaiting_fulfillment order that booked its earning before Gelato was configured) can't write a second merch_revenue row. Removes the audit-noted "relied solely on the CAS guard" coupling.
  • performMerchFulfillment(order) — the fulfillment body, extracted from fulfillMerchOrder so both the webhook path (claims pending_payment→processing) and the retry sweep (optimistic-lock claim) run the exact same code.
  • retryStrandedMerchOrders() (merch-order-sync.ts) — selects due orders via the pure, golden-tested selectRetryableMerchOrders (retryable status, no gelato_order_id, under the 6-attempt cap, past its exponential backoff (10min ×2ⁿ, capped 6h), and for processing only if stale >20min so an in-flight fulfill isn't grabbed), claims each with an optimistic lock on fulfillment_attempts (concurrent sweeps can't both re-fulfill), then re-runs performMerchFulfillment. No-op when Gelato is unconfigured. Golden test: merch-order-retry.test.ts (9 cases: which states retry, Gelato-id exclusion, processing staleness, attempt cap, backoff).
  • cron — folded into the existing merch_orders_sync tick (status sync first, then the retry sweep) so no new heartbeat/registry entry; retry counts surface in the heartbeat metadata (retry_candidates/retry_recovered/retry_failed).

After the cap (6 attempts) an order stays paid_unfulfilled for manual ops (a true dead-letter) — each failure is still logged via recordServiceError.


Build log — refund reverses pending earnings (2026-07-05)

Closes the second audit limitation. A Stripe refund used to leave the creator_earnings row untouched, so a refunded sale still counted toward a payout the creator never earned. No migrationcreator_earnings.status already allows 'reversed' and the row carries stripe_payment_intent_id.

  • charge.refunded webhook case (routes/payment.ts) — on a refunded charge, matches earnings by payment intent and reverses them.
  • refund-earnings.ts — pure, golden-tested selectEarningsToReverse (only a full refund voids earnings; reverse pending, and surface transferred ones for a manual Connect reversal rather than silently voiding money already paid out; partial refunds punt to manual). reverseEarningsForRefund writes status='reversed' with a .eq('status','pending') race guard, idempotent on redelivery. Test: refund-earnings.test.ts (6 cases). Source-kind agnostic — a refunded tip reverses the same way, so this fixes the gap for all creator_earnings, not just merch.
  • Activation: enable the charge.refunded event on the Stripe webhook endpoint (dashboard) — the handler is inert until Stripe delivers the event.

Remaining Phase-2 money work: Stripe Connect transfer automation (still moves pendingtransferred out of band; an already-transferred refund needs a manual reversal, now surfaced).

EMBERKILN STORY AS MERCH — Docs | HiveJournal