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.
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 |
|---|---|
checkout.session.completed | Order → paid, Stripe customer saved, subscription mirrored, entitlements + delivery jobs created, receipt emailed, order.paid automation trigger |
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) |
charge.dispute.created | Order disputed, entitlements revoked, owners/admins notified |
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.