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.

FieldNotes
Kindone_time or recurring (monthly / yearly via Stripe Billing)
Price / currencyStored in cents. Changing price is audited (product.price_changed).
Minimum ageBuyers need age_verified or identity_verified status
StockBlank = unlimited (informational; Stripe does not enforce it)
CommunityOptional: attach the product to one community page
DeliverablesOne or more perks granted on payment (below)

Deliverables

TypeFieldsDelivered by
discord_roleguild ID, role IDDiscord bot (DISCORD_BOT_TOKEN); needs the buyer's Discord linked (users.discord_id)
fivem_groupgroup / ACE name, optional server IDThe FiveM resource polls /api/v1/servers/{id}/entitlements/pending and acks each grant
creditsamountInternal, applied immediately
permissionkey (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.

GroupPackages (default price)
MembershipsSupporter $4.99/mo · VIP $9.99/mo (featured) · Elite $24.99/mo
One-time perksOn-stream shoutout $10 · Play a game with me $25 · 1:1 coaching $50 · Custom emote/art $30 · Priority queue $7.99/mo
Credit packs500 ($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 products rows (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 permission flags (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_image job per product on the media queue. 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 (with package), 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

  1. Create a Stripe account, copy the secret key to STRIPE_SECRET_KEY and the publishable key to STRIPE_PUBLISHABLE_KEY.
  2. 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.
  1. 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-Signature header, HMAC-SHA256 over "{t}.{payload}", 5-minute tolerance.
  • Idempotency: webhook_events (provider, event_id) is unique. A replayed event returns 200 {"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). With QUEUE_DRIVER=sync it runs inline; with database run php bin/worker.
EventEffect
invoice.paid (metadata kind = sponsor_invoice)Sponsor invoice → paid once; workspace ledger credited (revenue − fee − creator share) — docs/sponsors.md
invoice.voided, invoice.marked_uncollectibleSponsor 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.reversedCreator 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.completedOrder → 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.paidSubscription active, period extended, lapsed entitlements re-granted
invoice.payment_failedSubscription past_due, "payment failed" email (perks stay until Stripe gives up)
customer.subscription.updatedMirror status / cancel-at-period-end; revoke on canceled/unpaid
customer.subscription.deletedSubscription canceled, entitlements revoked, subscription.canceled trigger
charge.refundedOrder refunded (full refunds revoke entitlements and cancel a not-yet-produced print order)
charge.dispute.createdOrder 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

StatusMeaning
pendingCreated; for FiveM: waiting for the game server to poll
waitingRetrying later (e.g. Discord not linked, Discord API error) — scheduler store.retry_deliveries runs every 10 min
deliveredApplied
ackedFiveM server confirmed it applied the grant
failedGave up after 10 attempts — use Re-deliver on the Entitlements page
canceledGrant 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.