Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ea3e7c7
Add commerce: Stripe Connect, price mirroring, checkout, webhooks, pa…
roncodes Oct 23, 2026
2540ee2
Add the Studio Payouts and account Billing pages, website checkout an…
roncodes Oct 23, 2026
648dda8
Leave refunds out of sales totals, show a generic message for platfor…
roncodes Oct 23, 2026
cae865f
Apply the commerce decisions: repeat purchases add seats, disputes re…
roncodes Oct 23, 2026
f3d9a2f
Add staff finance: fee revenue, refunds, disputes and per-publisher t…
roncodes Oct 23, 2026
171872c
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
18b1176
Merge remote-tracking branch 'origin/feat/website-inertia' into feat/…
roncodes Oct 23, 2026
b0b60eb
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
3d8b5e8
Merge remote-tracking branch 'origin/feat/website-inertia' into feat/…
roncodes Oct 23, 2026
807ae00
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
bab8f7f
Merge remote-tracking branch 'origin/feat/website-inertia' into feat/…
roncodes Oct 23, 2026
12e1b2d
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
8352c93
Merge remote-tracking branch 'origin/feat/website-inertia' into feat/…
roncodes Oct 23, 2026
4b6fa59
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
01904f0
Instance licences carry the purchase receipt, so a console keeps it a…
roncodes Oct 23, 2026
9fdd3d0
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
4c148fa
Serve a release's prebuilt dist to installers: GET /npm/{engine}/-/di…
roncodes Oct 23, 2026
8b14b68
Merge remote-tracking branch 'origin/feat/commerce' into HEAD
roncodes Oct 23, 2026
c9ff0cb
Dist download test: the encoded-name request before If-None-Match, wh…
roncodes Oct 23, 2026
9d04893
Merge remote-tracking branch 'origin/feat/commerce' into feat/staff-f…
roncodes Oct 23, 2026
fed8693
Merge feat/website-inertia into feat/commerce
roncodes Oct 23, 2026
b4a4e19
Merge feat/commerce into feat/staff-finance
roncodes Oct 23, 2026
8f630b4
Merge feat/website-inertia into feat/commerce
roncodes Oct 24, 2026
d16471a
Merge feat/commerce into feat/staff-finance
roncodes Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=

# Stripe (Connect, Checkout, webhooks). Commerce routes answer 503 until STRIPE_SECRET is set.
STRIPE_KEY=
STRIPE_SECRET=
# Comma-separated signing secrets: the platform endpoint's and the Connect endpoint's.
STRIPE_WEBHOOK_SECRET=
REGISTRY_FACILITATOR_FEE_BPS=1000
REGISTRY_LICENCE_MAX_ACTIVATIONS=5
REGISTRY_STRIPE_TAX=true
REGISTRY_STRIPE_TAX_CODE=
REGISTRY_COMMERCE_NOTIFY=

# Package store. Local disk by default; S3 / MinIO in docker and production.
FILESYSTEM_DISK=local
REGISTRY_STORAGE_DISK=local
Expand Down
193 changes: 188 additions & 5 deletions README.md

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions app/Commerce/CheckoutSessions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

namespace App\Commerce;

use App\Models\Account;
use App\Models\Checkout;
use App\Models\Extension;
use App\Models\FacilitatorFee;
use App\Models\Instance;
use App\Models\Organization;
use Illuminate\Support\Arr;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

/**
* Starts a hosted Stripe Checkout Session for a buyer organization: `payment` mode, a destination
* charge to the publisher's connected account with Fleetbase's facilitator fee as
* `application_fee_amount`, and Stripe Tax with the publisher liable (the charge is made on its
* behalf). The checkout row's public id is the hand-off id on the success URL.
*/
class CheckoutSessions
{
/**
* @param array{account?: ?Account, instance?: ?Instance, email?: ?string} $buyer
*/
public function start(Extension $extension, Organization $organization, string $source, string $successUrl, string $cancelUrl, array $buyer = []): Checkout
{
$extension->loadMissing(['price', 'organization']);
$this->assertPurchasable($extension, $organization, $buyer['instance'] ?? null);

$price = $extension->price;
$publisher = $extension->organization;
$amount = $price->effective_amount;
$rate = FacilitatorFee::rateAt(now());
$fee = FacilitatorFee::feeFor($amount, $rate);
$expiresAt = now()->addMinutes(max(30, min(1440, (int) config('registry.commerce.checkout_expires_minutes', 60))));

$checkout = Checkout::create([
'extension_uuid' => $extension->uuid,
'organization_uuid' => $organization->uuid,
'account_uuid' => ($buyer['account'] ?? null)?->uuid,
'instance_uuid' => ($buyer['instance'] ?? null)?->uuid,
'source' => $source,
'status' => Checkout::STATUS_OPEN,
'amount' => $amount,
'currency' => $price->currency,
'fee_amount' => $fee,
'fee_rate_bps' => $rate,
'price_snapshot' => [
'model' => $price->model,
'amount' => (int) $price->amount,
'sale_amount' => $price->hasSaleAmount() ? (int) $price->sale_amount : null,
'on_sale' => $price->isOnSale(),
'currency' => $price->currency,
'stripe_price_id' => $price->checkoutStripePriceId(),
],
'expires_at' => $expiresAt,
]);

$metadata = array_filter([
'handoff' => $checkout->public_id,
'source' => $source,
'extension' => $extension->public_id,
'extension_uuid' => $extension->uuid,
'organization' => $organization->public_id,
'organization_uuid' => $organization->uuid,
'account' => ($buyer['account'] ?? null)?->public_id,
'instance' => ($buyer['instance'] ?? null)?->public_id,
'fee_rate_bps' => (string) $rate,
]);

$tax = (bool) config('registry.commerce.tax.enabled', true);
$params = [
'mode' => 'payment',
'line_items' => [['price' => $price->checkoutStripePriceId(), 'quantity' => 1]],
'success_url' => static::withQuery($successUrl, ['handoff' => $checkout->public_id]),
'cancel_url' => static::withQuery($cancelUrl, ['handoff' => $checkout->public_id, 'cancelled' => 1]),
'client_reference_id' => $checkout->public_id,
'expires_at' => $expiresAt->getTimestamp(),
'metadata' => $metadata,
'payment_intent_data' => array_filter([
'application_fee_amount' => $fee,
'transfer_data' => ['destination' => $publisher->stripe_connect_id],
'on_behalf_of' => $tax ? $publisher->stripe_connect_id : null,
'description' => "{$extension->name} licence",
'metadata' => $metadata,
]),
];

if (filled($buyer['email'] ?? null)) {
$params['customer_email'] = $buyer['email'];
}

if ($tax) {
$params['automatic_tax'] = ['enabled' => true, 'liability' => ['type' => 'account', 'account' => $publisher->stripe_connect_id]];
$params['tax_id_collection'] = ['enabled' => true];
}

try {
$session = Stripe::client()->checkout->sessions->create($params, ['idempotency_key' => 'checkout:'.$checkout->uuid]);
} catch (\Throwable $e) {
$checkout->delete();

throw $e;
}

$checkout->forceFill([
'stripe_checkout_session_id' => $session->id,
'checkout_url' => $session->url,
])->save();

return $checkout;
}

/**
* Refuse a checkout that cannot or need not happen, with the reason the buyer sees.
*/
protected function assertPurchasable(Extension $extension, Organization $organization, ?Instance $instance): void
{
if ($extension->status !== Extension::STATUS_PUBLISHED) {
throw new UnprocessableEntityHttpException('This extension is not published.');
}

if (! $extension->isPaid()) {
throw new UnprocessableEntityHttpException("{$extension->name} is free: install it without buying.");
}

if ($instance && $extension->hosting !== Extension::HOSTING_BOTH && $extension->hosting !== $instance->kind) {
throw new UnprocessableEntityHttpException("This extension is only available for {$extension->hosting} instances.");
}

if ($extension->organization_uuid === $organization->uuid) {
throw new ConflictHttpException("{$organization->name} publishes {$extension->name} and already has access to it.");
}

// Imported listings and accounts (registry:import) have no Stripe state yet: read it now.
$publisher = $extension->organization;
if ($publisher?->hasStripeAccount() && $publisher->stripeState() === []) {
app(ConnectAccounts::class)->refresh($publisher);
}
if ($publisher?->canReceivePayments() && ! $extension->price->checkoutStripePriceId()) {
app(PriceCatalog::class)->sync($extension->price, $extension)->save();
}

if (! $publisher?->canReceivePayments() || ! $extension->price->checkoutStripePriceId()) {
throw new UnprocessableEntityHttpException("{$extension->name} cannot be bought right now: its publisher has not finished setting up payouts.");
}
}

/**
* Add query parameters to a URL, keeping its own.
*/
public static function withQuery(string $url, array $query): string
{
$fragment = '';
if (($hash = strpos($url, '#')) !== false) {
$fragment = substr($url, $hash);
$url = substr($url, 0, $hash);
}

return $url.(str_contains($url, '?') ? '&' : '?').Arr::query($query).$fragment;
}
}
156 changes: 156 additions & 0 deletions app/Commerce/ConnectAccounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace App\Commerce;

use App\Models\Account;
use App\Models\Event;
use App\Models\Organization;
use Stripe\Account as StripeAccount;
use Stripe\AccountSession;

/**
* Stripe Connect Express accounts for publisher organizations: one account per organization,
* onboarding and management through Account Sessions (embedded components on the website), and
* `payout_status` kept from `account.updated`.
*/
class ConnectAccounts
{
/** Embedded components an Account Session enables. */
public const COMPONENTS = ['account_onboarding', 'account_management', 'notification_banner', 'payouts', 'tax_registrations', 'tax_settings'];

/**
* Create the organization's Express account, or return the organization unchanged when it
* has one. The platform pays Stripe's fees and carries losses, as the console's accounts do.
*/
public function create(Organization $organization, Account $by): Organization
{
if ($organization->hasStripeAccount()) {
return $organization;
}

$params = array_filter([
'controller' => [
'stripe_dashboard' => ['type' => 'express'],
'fees' => ['payer' => 'application'],
'losses' => ['payments' => 'application'],
],
'country' => config('registry.commerce.connect_country'),
'email' => $by->email,
'business_profile' => array_filter(['name' => $organization->name, 'url' => $organization->website]),
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
'metadata' => ['organization' => $organization->public_id, 'organization_uuid' => $organization->uuid],
], fn ($value) => $value !== null && $value !== []);

$account = Stripe::client()->accounts->create($params, ['idempotency_key' => 'connect-account:'.$organization->uuid]);

$organization->forceFill(['stripe_connect_id' => $account->id])->save();
Event::record('organization.stripe_account_created', $organization, $by, ['account' => $account->id]);

return $this->sync($organization, $account);
}

/**
* A short-lived client secret for Connect.js on the website.
*/
public function session(Organization $organization): AccountSession
{
$components = [];
foreach (self::COMPONENTS as $component) {
$components[$component] = ['enabled' => true];
}
foreach (['account_onboarding', 'account_management', 'notification_banner'] as $component) {
$components[$component]['features'] = ['external_account_collection' => true];
}

return Stripe::client()->accountSessions->create([
'account' => $organization->stripe_connect_id,
'components' => $components,
]);
}

/**
* Read the account from Stripe and store its state (the website calls this when onboarding
* exits, so the page does not wait for the webhook).
*/
public function refresh(Organization $organization): Organization
{
return $this->sync($organization, Stripe::client()->accounts->retrieve($organization->stripe_connect_id));
}

/**
* Store what the account says: `payout_status` and the details behind it in `meta.stripe`.
*/
public function sync(Organization $organization, StripeAccount $account): Organization
{
$requirements = $account['requirements'] ?? null;
$from = $organization->payout_status;
$status = static::payoutStatus($account);

$organization->forceFill([
'payout_status' => $status,
'meta' => array_merge((array) $organization->meta, [
'stripe' => [
'charges_enabled' => (bool) ($account['charges_enabled'] ?? false),
'payouts_enabled' => (bool) ($account['payouts_enabled'] ?? false),
'details_submitted' => (bool) ($account['details_submitted'] ?? false),
'transfers' => $account['capabilities']['transfers'] ?? null,
'card_payments' => $account['capabilities']['card_payments'] ?? null,
'disabled_reason' => $requirements['disabled_reason'] ?? null,
'currently_due' => array_values((array) ($requirements['currently_due'] ?? [])),
'past_due' => array_values((array) ($requirements['past_due'] ?? [])),
'current_deadline' => $requirements['current_deadline'] ?? null,
'country' => $account['country'] ?? null,
'default_currency' => $account['default_currency'] ?? null,
'synced_at' => now()->toIso8601String(),
],
]),
])->save();

if ($from !== $status) {
Event::record('organization.payout_status_changed', $organization, null, ['from' => $from, 'to' => $status, 'account' => $account->id]);
}

return $organization;
}

/**
* `enabled` once charges and payouts work, `rejected` when Stripe rejected the account,
* `pending` until the publisher submits their details, else `restricted` (Stripe needs more).
*/
public static function payoutStatus(StripeAccount $account): string
{
$disabled = (string) ($account['requirements']['disabled_reason'] ?? '');

return match (true) {
str_starts_with($disabled, 'rejected') => Organization::PAYOUT_REJECTED,
($account['charges_enabled'] ?? false) && ($account['payouts_enabled'] ?? false) => Organization::PAYOUT_ENABLED,
! ($account['details_submitted'] ?? false) => Organization::PAYOUT_PENDING,
default => Organization::PAYOUT_RESTRICTED,
};
}

/**
* What the website and API show about an organization's connected account.
*/
public static function summary(Organization $organization): array
{
$state = $organization->stripeState();

return [
'connected' => $organization->hasStripeAccount(),
'payout_status' => $organization->payout_status,
'can_receive_payments' => $organization->canReceivePayments(),
'charges_enabled' => (bool) ($state['charges_enabled'] ?? false),
'payouts_enabled' => (bool) ($state['payouts_enabled'] ?? false),
'details_submitted' => (bool) ($state['details_submitted'] ?? false),
'requirements_due' => array_values(array_unique(array_merge($state['past_due'] ?? [], $state['currently_due'] ?? []))),
'current_deadline' => $state['current_deadline'] ?? null,
'country' => $state['country'] ?? null,
'default_currency' => isset($state['default_currency']) ? strtoupper($state['default_currency']) : null,
'synced_at' => $state['synced_at'] ?? null,
];
}
}
Loading
Loading