Store & payments
The store sells products (one-time or recurring) whose deliverables become entitlements the moment Stripe confirms payment. Entitlements are pushed to Discord, FiveM or applied internally through delivery jobs.
The only path that grants anything
Customer → Stripe Checkout → Stripe → POST /webhooks/stripe (signature verified)
→ webhook_events (unique event id) → job stripe.process_event
→ order PAID → entitlements ACTIVE → delivery_jobs → Discord / FiveM / internal
The browser landing on /store/success never grants anything. It only shows the order state.
Products
Dashboard → Store → New product.
| Field | Notes |
|---|---|
| Kind | one_time or recurring (monthly / yearly via Stripe Billing) |
| Price / currency | Stored in cents. Changing price is audited (product.price_changed). |
| Minimum age | Buyers need age_verified or identity_verified status |
| Stock | Blank = unlimited (informational; Stripe does not enforce it) |
| Community | Optional: attach the product to one community page |
| Deliverables | One or more perks granted on payment (below) |
Deliverables
| Type | Fields | Delivered by |
|---|---|---|
discord_role | guild ID, role ID | Discord bot (DISCORD_BOT_TOKEN); needs the buyer's Discord linked (users.discord_id) |
fivem_group | group / ACE name, optional server ID | The FiveM resource polls /api/v1/servers/{id}/entitlements/pending and acks each grant |
credits | amount | Internal, applied immediately |
permission | key (e.g. priority_queue) | Internal, applied immediately |
Deliverables are stored as JSON on the product and copied onto each order item at checkout, so later product edits do not change what a past order owes.
Standard pricing packages
Dashboard → Store → Packages (/dashboard/store/packages). A curated catalogue of products priced at what
streamers typically charge, so a new workspace can have a working store in one click instead of eleven blank forms.
| Group | Packages (default price) |
|---|---|
| Memberships | Supporter $4.99/mo · VIP $9.99/mo (featured) · Elite $24.99/mo |
| One-time perks | On-stream shoutout $10 · Play a game with me $25 · 1:1 coaching $50 · Custom emote/art $30 · Priority queue $7.99/mo |
| Credit packs | 500 ($4.99) · 1,200 ($9.99) · 3,000 ($19.99) |
The catalogue lives in App\Modules\Store\Services\Packages::CATALOG (key = product slug). Packages matching the
workspace's creator_type are pre-ticked (streamers get memberships + perks; communities and FiveM servers also
get credit packs and priority queue); any package can be deployed by anyone. The dashboard launch checklist points
a streamer without products here.
Deploying:
- Creates ordinary
productsrows (active, catalogue sort order, the price typed on the page or the default, one
currency for the batch). A package whose slug already exists in the store is skipped, never duplicated — the
page shows it as "in store" with a link to edit it. The catalogue is never consulted at checkout.
- Deliverables are
permissionflags (tier.supporter,tier.vip,tier.elite,perk.shoutout, …) or
credits, which need nothing configured first. Attach a Discord role or FiveM group on the product afterwards.
- AI product image (ticked by default): one
store.product_imagejob per product on themediaqueue. The
worker calls AIService::renderProductImage(), which renders the catalogue's art brief at the store_product
preset (1200×675, no text, brand accent colour), stores it as a media_assets row (ai_generated, meta
product_id), sets products.image_url and meters it as ai_usage.feature = 'store_image'. The same job backs
the ✨ Generate with AI button on any product's edit form, which refuses up front when the month's AI credits
are used up. Under the sync queue driver (tests, unconfigured installs) images render inline.
- Audited as
product.created(withpackage),store.packages_deployed,product.image_requested,
product.image_generated.
Only admins/owners can deploy or generate images; staff can browse the page.
Merch (print on demand)
Products with fulfillment = pod are printed and shipped by the workspace's own Printful, Printify or Gooten
account: variant picker on the product page, flat shipping fee, Stripe collects the address, the paid order is
submitted to the provider and tracked to delivery. See Merch.
Stripe setup
- Create a Stripe account, copy the secret key to
STRIPE_SECRET_KEYand the publishable key toSTRIPE_PUBLISHABLE_KEY. - Add a webhook endpoint in Stripe → Developers → Webhooks:
- URL:
https://krunc.com/webhooks/stripe - Events:
checkout.session.completed,invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deleted,charge.refunded,charge.dispute.created - Copy the signing secret to
STRIPE_WEBHOOK_SECRET.
- Enable the Customer portal in Stripe (Settings → Billing → Customer portal) so
/store/portal/{tenantId}works.
Without STRIPE_SECRET_KEY the app runs a mock provider: checkout redirects straight to /store/success?mock=1, nothing is ever marked paid, and the dashboard shows a warning banner.
Local webhook testing
stripe listen --forward-to https://krunc.com/webhooks/stripe
stripe trigger checkout.session.completed
Test cards: 4242 4242 4242 4242 (success), 4000 0000 0000 9995 (declined), 4000 0000 0000 0341 (attaches, then fails on renewal).
Webhook handling
- Signature:
Stripe-Signatureheader, HMAC-SHA256 over"{t}.{payload}", 5-minute tolerance. - Idempotency:
webhook_events (provider, event_id)is unique. A replayed event returns200 {"duplicate":true}and does nothing. - Every handler re-checks state before writing (paid orders stay paid, revoked entitlements are not re-revoked), so re-processing is safe.
- Processing is queued (
stripe.process_event). WithQUEUE_DRIVER=syncit runs inline; withdatabaserunphp bin/worker.
| Event | Effect |
|---|---|
invoice.paid (metadata kind = sponsor_invoice) | Sponsor invoice → paid once; workspace ledger credited (revenue − fee − creator share) — docs/sponsors.md |
invoice.voided, invoice.marked_uncollectible | Sponsor invoice released so the value is billable again |
v2.core.account[…] thin events (event destination) | Owning creator profile / workspace re-syncs its Connect payout status; v2.core.event_destination.ping acknowledged |
account.updated (Connect endpoint) | Creator payout account status cached on creator_profiles (stripe_payouts_enabled, requirements); creator notified when payouts pause |
transfer.reversed | Creator payout marked reversed, amount re-credited to the creator ledger once |
checkout.session.completed (metadata kind = sponsor_topup) | Advertiser wallet credited once (sponsor_ledger.stripe_session_id unique), sponsor_topups → paid, receipt emailed — see docs/sponsors.md |
checkout.session.completed | Order → paid, Stripe customer saved, subscription mirrored, entitlements + delivery jobs created, receipt emailed, order.paid automation trigger; merch orders: shipping address stored, print order queued (docs/merch.md) |
invoice.paid | Subscription active, period extended, lapsed entitlements re-granted |
invoice.payment_failed | Subscription past_due, "payment failed" email (perks stay until Stripe gives up) |
customer.subscription.updated | Mirror status / cancel-at-period-end; revoke on canceled/unpaid |
customer.subscription.deleted | Subscription canceled, entitlements revoked, subscription.canceled trigger |
charge.refunded | Order refunded (full refunds revoke entitlements and cancel a not-yet-produced print order) |
charge.dispute.created | Order disputed, entitlements revoked, owners/admins notified |
Tips (creator page tip jar)
A creator switches the jar on in the profile editor ("Tip jar": accept tips, suggested amounts, thank-you line,
whether supporters are listed). The public page then shows a ♥ Support button in the header and a Support card
in the sidebar; /@handle/support takes an amount ($1–$500, presets or typed), an optional name and a message,
and works for guests.
A tip is an orders row with meta.kind = tip and no order_items, paid through Stripe Checkout with
metadata.kind = tip. It follows the one rule above: nothing is credited until checkout.session.completed
arrives. Then TipService::paid (called from the webhook processor after the order is marked paid, idempotent)
marks the tips row paid, posts the amount minus TIPS_FEE_PERCENT (default 0) to the creator's earnings ledger
(creator_earnings, type tip, so it is paid out from Admin → Payouts like sponsor earnings), notifies the
workspace owners (tip.received), fires the tip.received automation trigger (name, message, amount) and the
outbound tip.received event. A full charge.refunded reverses the ledger entry (tip_refund) and hides the
tip from the page. The thank-you page only reports the state it finds; it never confirms a payment itself.
Goal and board. The jar's settings also hold a goal (goal_cents, goal_label, goal_started_at): the page
and the tip form show a progress bar of paid tips since the goal started (TipService::goal); changing the amount
or ticking "start fresh" on the editor moves the start to now, editing the label does not. The top-supporters
board (TipService::top, switchable) sums paid, listed tips per person — by account when the tipper was signed
in, else by the typed name, case-insensitively; anonymous tips count toward the goal but never appear.
Platform buttons on the creator page come from creator_profiles.platforms (a handle or URL per platform,
entered on the editor; PlatformLinks builds the channel URL) merged with connected accounts, so a creator who
relays through Krunc ingest without connecting OAuth accounts still links out to Twitch, Kick, YouTube and the rest.
Refunds
Dashboard → Orders → open the order → Refund & revoke (admin/owner only). This calls Stripe's refund API, cancels any subscription, marks the order refunded and revokes the entitlements. Stripe's own charge.refunded event arrives later and is a no-op.
Delivery jobs
| Status | Meaning |
|---|---|
pending | Created; for FiveM: waiting for the game server to poll |
waiting | Retrying later (e.g. Discord not linked, Discord API error) — scheduler store.retry_deliveries runs every 10 min |
delivered | Applied |
acked | FiveM server confirmed it applied the grant |
failed | Gave up after 10 attempts — use Re-deliver on the Entitlements page |
canceled | Grant was revoked before it was applied |
Manual grants and revokes (Entitlements page) go through the same pipeline and are written to the audit log.
Coupons
Dashboard → Coupons. Percent or fixed amount, optional max uses / expiry. Applied at checkout as a one-time Stripe coupon; orders.discount_cents and coupon_code record what was applied.