Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
165 changes: 161 additions & 4 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