Skip to content

Commerce: Stripe Connect, checkout, webhooks, payouts and billing (Phase 4 K, L, M) - #10

Merged
roncodes merged 14 commits into
release/v2.0.0from
feat/commerce
Oct 24, 2026
Merged

roncodes merged 14 commits into
release/v2.0.0from
feat/commerce

Conversation

@roncodes

@roncodes roncodes commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Stacked on #8, which is stacked on #6. Neither has merged into release/v2.0.0 yet, so this PR targets feat/website-inertia and shows only the commerce commits. When #6 and #8 merge, retarget this PR to release/v2.0.0. Do not merge it into main.

Phase 4 Workstreams K, L and M: paid extensions are sold through Stripe. Publishers get Stripe Connect Express accounts, prices are mirrored to Stripe, buyers pay on hosted Checkout from a console or the website, and webhooks create purchases and paid licences. Publishers see payouts; organizations see billing.

What's in it

  • Stripe Connect. Each publisher organization gets one Express account, created with the same controller settings the console uses today: the platform pays Stripe's fees and carries losses. Account Sessions cover embedded onboarding, account management, the notification banner, payouts, tax settings and tax registrations. payout_status (pending, restricted, enabled, rejected) and meta.stripe come from account.updated, or from a refresh when onboarding exits.

  • Prices mirrored to Stripe. Saving a price is PUT …/price, which now also takes sale_amount and on_sale. It creates one product per listing, a regular price and the sale price as a second Stripe price (prices.stripe_sale_price_id). on_sale decides which price Checkout charges. A new amount or currency creates a new Stripe price and archives the old one. If Stripe refuses a price, it is not saved (502).

  • Checkout. There are two ways to start one:

    • POST /v1/instances/me/purchases/checkout (instance token);
    • POST /v1/purchases/checkout (website; owners, admins and billing members of the buyer).

    Both create a hosted session in payment mode with:

    • a destination charge to the publisher;
    • application_fee_amount from the facilitator fee in effect, which has history (facilitator_fees, GET/POST /v1/admin/facilitator-fees);
    • Stripe Tax, with the charge made on_behalf_of the publisher, who is liable for the tax;
    • metadata: extension, organization, hand-off id, source.

    The hand-off is a checkouts row. It is polled at GET /v1/instances/me/purchases/{handoff} or GET /v1/purchases/{handoff}.

  • Webhooks. POST /v1/webhooks/stripe checks the signature against several secrets (the platform endpoint's and the Connect endpoint's). Each event id is stored once in events.external_id, in the same transaction as its effects. A failure answers 500 and nothing is written.

    • checkout.session.completed and async_payment_succeeded create the purchase and a paid licence (5 seats), complete the hand-off and email the receipt. The purchase records amount, fee, net, tax and the receipt URL.
    • checkout.session.expired expires the hand-off.
    • charge.refunded (full refund) marks the purchase refunded and revokes the licence. Running instances keep their seat.
    • charge.dispute.created flags the purchase, revokes its licence and emails staff.
    • account.updated updates the publisher's payout status.
  • Payouts and billing APIs. Under /v1/organizations/{slug}:

    • payouts: balance (available, pending, in transit), payout schedule, recent payouts;
    • payouts/transactions and .csv: gross, fee, net, status, customer, with totals;
    • billing/purchases: purchases with who bought them and the receipt;
    • billing/licences: licences with the instances holding a seat (any member).
  • Staff licence management. GET /v1/admin/licences, POST …/{id}/revoke, POST …/{id}/reinstate, PATCH …/{id} (seats).

  • Website pages.

    • Studio → Payouts (/studio/payouts/{slug?}): Connect.js onboarding and management, balance and sales table, CSV.
    • Account → Billing (/account/billing/{slug?}): purchases, receipts, licences; shows Finalizing while it polls after a website checkout.
    • Buy panel on paid extension pages.
    • The pricing editor gains a sale price and shows the live fee.
  • registry:stripe-sync reads the state of imported Connect accounts and mirrors imported paid prices. Checkout also does this on demand.

  • README. Covers Stripe setup, settings, routes, webhook effects and the instance checkout contract for registry-bridge (README → Commerce → Instance checkout).

Migrations:

  • prices.stripe_sale_price_id;
  • facilitator_fees;
  • checkouts;
  • unique events.external_id.

New env (all empty or default in .env.example): STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, REGISTRY_FACILITATOR_FEE_BPS, REGISTRY_LICENCE_MAX_ACTIVATIONS, REGISTRY_STRIPE_TAX*, REGISTRY_CHECKOUT_EXPIRES_MINUTES, REGISTRY_CONNECT_COUNTRY, REGISTRY_COMMERCE_NOTIFY.

Dependencies: stripe/stripe-php ^22 and @stripe/connect-js 3.4.6. Connect.js only loads in the browser, never during SSR.

Tests

  • Pest: 435 pass (358 before). 77 new tests in tests/Feature/Commerce run against real stripe-php with a fake HTTP client that answers from JSON fixtures (tests/Fixtures/stripe) and records every request. They cover Connect, price mirroring, both checkouts and refusals, fee history, every webhook (idempotency, rollback and retry, bad signatures), payouts, CSV, billing, role checks and the two pages. Pint passes.
  • Vitest: 125 pass (11 new: Payouts, Billing with the Finalizing poll, the Buy panel). ESLint passes; the client and SSR builds pass. Run on Node 22, as CI does; Vitest does not start on Node 20.
  • Manual check: a local SQLite instance in the browser, with the Payouts, Billing and extension pages and the Buy flow up to Stripe. That check rejected the fake local key, as expected. Not run against Stripe test mode.

Decisions (answered by Ron)

  1. Tax liability: Fleetbase is not liable. The charge is made on_behalf_of the publisher, and the publisher's account is liable for the tax. No change needed.
  2. Prices exclude tax (exclusive) for now. No change needed.
  3. Fee: 10%, taken on the price before tax. No change needed.
  4. Buying again: allowed. Each purchase is another paid licence with 5 seats, and installs use the seats of all the organization's licences. A seat is one Fleetbase instance; one install covers every company on that instance. A different registry organization buys its own licence.
  5. Staff refunds and licence management: this PR adds licence revoke, reinstate and seat changes. The staff refund API (reverse transfer, refund the application fee) and the finance view are in a separate session, stacked on this branch.
  6. Disputes: a dispute revokes the licence at once. Staff reinstate it if the dispute is won.
  7. Console return and cancel URLs must be on the instance's registered host or its domain (api.acme.com allows acme.com and *.acme.com). localhost and IP addresses match only themselves.
  8. Licences are visible to every member. Purchases, receipts and payouts stay with owners, admins and billing members.
  9. Receipt email: the registry's email is kept; a second email from Stripe is fine.
  10. Stripe API version: create both webhook endpoints on API version 2026-09-30.endive.

…d sale pricing; document the instance checkout contract
…m-side Stripe failures, highlight the selected organization on Billing
…voke the licence, staff licence management, return URLs on the instance host, members see licences
@roncodes
roncodes changed the base branch from feat/website-inertia to release/v2.0.0 October 24, 2026 09:21
@roncodes
roncodes merged commit e16af51 into release/v2.0.0 Oct 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant