From ea3e7c788c88c8dd4dc55e405ae7145e36108ce4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 14:40:28 +0800 Subject: [PATCH 01/10] Add commerce: Stripe Connect, price mirroring, checkout, webhooks, payouts and billing APIs (Phase 4 K, L, M) --- .env.example | 11 + app/Commerce/CheckoutSessions.php | 173 +++++++++++ app/Commerce/ConnectAccounts.php | 156 ++++++++++ app/Commerce/PriceCatalog.php | 93 ++++++ app/Commerce/Stripe.php | 34 +++ app/Commerce/StripeWebhooks.php | 269 ++++++++++++++++++ app/Console/Commands/SyncStripe.php | 68 +++++ .../Admin/FacilitatorFeeController.php | 65 +++++ .../Instance/InstallController.php | 28 +- .../Instance/PurchaseController.php | 73 +++++ .../Organization/ActsForOrganization.php | 64 +++++ .../Organization/BillingController.php | 66 +++++ .../Organization/OrganizationController.php | 34 +-- .../Organization/PayoutController.php | 188 ++++++++++++ .../Organization/StripeConnectController.php | 75 +++++ .../Publisher/ExtensionController.php | 26 +- .../Purchase/CheckoutController.php | 78 +++++ .../Webhook/StripeWebhookController.php | 57 ++++ .../Instance/StartCheckoutRequest.php | 28 ++ .../Requests/Publisher/UpdatePriceRequest.php | 5 +- .../Purchase/StartCheckoutRequest.php | 25 ++ app/Http/Resources/CheckoutResource.php | 32 +++ app/Http/Resources/LicenceResource.php | 13 + .../Resources/PayoutTransactionResource.php | 30 ++ app/Http/Resources/PriceResource.php | 4 +- app/Http/Resources/PurchaseResource.php | 32 +++ app/Mail/DisputeOpenedMail.php | 47 +++ app/Mail/PurchaseReceiptMail.php | 47 +++ app/Models/Checkout.php | 83 ++++++ app/Models/Event.php | 2 +- app/Models/Extension.php | 23 ++ app/Models/FacilitatorFee.php | 52 ++++ app/Models/Organization.php | 34 +++ app/Models/Price.php | 33 ++- app/Models/Purchase.php | 5 + app/Providers/AppServiceProvider.php | 9 +- bootstrap/app.php | 10 + composer.json | 3 +- composer.lock | 64 ++++- config/registry.php | 28 ++ config/services.php | 10 + ...d_stripe_sale_price_id_to_prices_table.php | 27 ++ ...4_000202_create_facilitator_fees_table.php | 31 ++ ...26_10_24_000203_create_checkouts_table.php | 44 +++ ...000204_add_external_id_to_events_table.php | 27 ++ resources/views/mail/dispute-opened.blade.php | 9 + .../views/mail/purchase-receipt.blade.php | 20 ++ routes/api.php | 30 +- tests/Feature/Commerce/CheckoutTest.php | 250 ++++++++++++++++ tests/Feature/Commerce/ConnectTest.php | 142 +++++++++ .../Commerce/PayoutsAndBillingTest.php | 212 ++++++++++++++ tests/Feature/Commerce/PriceSyncTest.php | 112 ++++++++ .../Commerce/StripeSyncCommandTest.php | 31 ++ tests/Feature/Commerce/WebhookTest.php | 229 +++++++++++++++ tests/Feature/Releases/ListingPriceTest.php | 6 +- tests/Fixtures/stripe/account.json | 31 ++ tests/Fixtures/stripe/account_pending.json | 27 ++ tests/Fixtures/stripe/account_session.json | 15 + tests/Fixtures/stripe/balance.json | 6 + tests/Fixtures/stripe/charge.json | 19 ++ tests/Fixtures/stripe/checkout_session.json | 26 ++ tests/Fixtures/stripe/dispute.json | 16 ++ .../stripe/error_invalid_request.json | 8 + tests/Fixtures/stripe/event.json | 11 + tests/Fixtures/stripe/payment_intent.json | 33 +++ tests/Fixtures/stripe/payouts.json | 9 + tests/Fixtures/stripe/price.json | 18 ++ tests/Fixtures/stripe/product.json | 16 ++ tests/Helpers/commerce.php | 50 ++++ tests/Helpers/stripe.php | 176 ++++++++++++ tests/Pest.php | 7 + tests/TestCase.php | 9 + 72 files changed, 3745 insertions(+), 79 deletions(-) create mode 100644 app/Commerce/CheckoutSessions.php create mode 100644 app/Commerce/ConnectAccounts.php create mode 100644 app/Commerce/PriceCatalog.php create mode 100644 app/Commerce/Stripe.php create mode 100644 app/Commerce/StripeWebhooks.php create mode 100644 app/Console/Commands/SyncStripe.php create mode 100644 app/Http/Controllers/Admin/FacilitatorFeeController.php create mode 100644 app/Http/Controllers/Instance/PurchaseController.php create mode 100644 app/Http/Controllers/Organization/ActsForOrganization.php create mode 100644 app/Http/Controllers/Organization/BillingController.php create mode 100644 app/Http/Controllers/Organization/PayoutController.php create mode 100644 app/Http/Controllers/Organization/StripeConnectController.php create mode 100644 app/Http/Controllers/Purchase/CheckoutController.php create mode 100644 app/Http/Controllers/Webhook/StripeWebhookController.php create mode 100644 app/Http/Requests/Instance/StartCheckoutRequest.php create mode 100644 app/Http/Requests/Purchase/StartCheckoutRequest.php create mode 100644 app/Http/Resources/CheckoutResource.php create mode 100644 app/Http/Resources/PayoutTransactionResource.php create mode 100644 app/Http/Resources/PurchaseResource.php create mode 100644 app/Mail/DisputeOpenedMail.php create mode 100644 app/Mail/PurchaseReceiptMail.php create mode 100644 app/Models/Checkout.php create mode 100644 app/Models/FacilitatorFee.php create mode 100644 database/migrations/2026_10_24_000201_add_stripe_sale_price_id_to_prices_table.php create mode 100644 database/migrations/2026_10_24_000202_create_facilitator_fees_table.php create mode 100644 database/migrations/2026_10_24_000203_create_checkouts_table.php create mode 100644 database/migrations/2026_10_24_000204_add_external_id_to_events_table.php create mode 100644 resources/views/mail/dispute-opened.blade.php create mode 100644 resources/views/mail/purchase-receipt.blade.php create mode 100644 tests/Feature/Commerce/CheckoutTest.php create mode 100644 tests/Feature/Commerce/ConnectTest.php create mode 100644 tests/Feature/Commerce/PayoutsAndBillingTest.php create mode 100644 tests/Feature/Commerce/PriceSyncTest.php create mode 100644 tests/Feature/Commerce/StripeSyncCommandTest.php create mode 100644 tests/Feature/Commerce/WebhookTest.php create mode 100644 tests/Fixtures/stripe/account.json create mode 100644 tests/Fixtures/stripe/account_pending.json create mode 100644 tests/Fixtures/stripe/account_session.json create mode 100644 tests/Fixtures/stripe/balance.json create mode 100644 tests/Fixtures/stripe/charge.json create mode 100644 tests/Fixtures/stripe/checkout_session.json create mode 100644 tests/Fixtures/stripe/dispute.json create mode 100644 tests/Fixtures/stripe/error_invalid_request.json create mode 100644 tests/Fixtures/stripe/event.json create mode 100644 tests/Fixtures/stripe/payment_intent.json create mode 100644 tests/Fixtures/stripe/payouts.json create mode 100644 tests/Fixtures/stripe/price.json create mode 100644 tests/Fixtures/stripe/product.json create mode 100644 tests/Helpers/commerce.php create mode 100644 tests/Helpers/stripe.php diff --git a/.env.example b/.env.example index d435b99..3a13646 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Commerce/CheckoutSessions.php b/app/Commerce/CheckoutSessions.php new file mode 100644 index 0000000..6901d56 --- /dev/null +++ b/app/Commerce/CheckoutSessions.php @@ -0,0 +1,173 @@ +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."); + } + + $licensed = Licence::query()->active() + ->where('extension_uuid', $extension->uuid) + ->where('organization_uuid', $organization->uuid) + ->where('kind', Licence::KIND_PAID) + ->exists(); + if ($licensed) { + throw new ConflictHttpException("{$organization->name} already holds a licence for {$extension->name}. Ask Fleetbase staff for more seats."); + } + + // 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; + } +} diff --git a/app/Commerce/ConnectAccounts.php b/app/Commerce/ConnectAccounts.php new file mode 100644 index 0000000..691d41b --- /dev/null +++ b/app/Commerce/ConnectAccounts.php @@ -0,0 +1,156 @@ +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, + ]; + } +} diff --git a/app/Commerce/PriceCatalog.php b/app/Commerce/PriceCatalog.php new file mode 100644 index 0000000..56f8c96 --- /dev/null +++ b/app/Commerce/PriceCatalog.php @@ -0,0 +1,93 @@ +isFree()) { + $this->archive($price->stripe_price_id); + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_price_id' => null, 'stripe_sale_price_id' => null]); + + return $price; + } + + $product = $this->product($price, $extension); + $currencyChanged = ! $price->exists || $price->isDirty('currency'); + + if (! $price->stripe_price_id || $currencyChanged || $price->isDirty('amount')) { + $created = $stripe->prices->create($this->priceParams($price, $extension, $product, (int) $price->amount, 'regular')); + $this->archive($price->stripe_price_id); + $price->forceFill(['stripe_price_id' => $created->id]); + } + + if ($price->hasSaleAmount()) { + if (! $price->stripe_sale_price_id || $currencyChanged || $price->isDirty('sale_amount')) { + $created = $stripe->prices->create($this->priceParams($price, $extension, $product, (int) $price->sale_amount, 'sale')); + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_sale_price_id' => $created->id]); + } + } elseif ($price->stripe_sale_price_id) { + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_sale_price_id' => null]); + } + + return $price; + } + + /** + * The listing's product, created on first sync and renamed to follow the listing. + */ + protected function product(Price $price, Extension $extension): string + { + $stripe = Stripe::client(); + $params = [ + 'name' => $extension->name, + 'metadata' => ['extension' => $extension->public_id, 'extension_uuid' => $extension->uuid], + ]; + + if ($price->stripe_product_id) { + $stripe->products->update($price->stripe_product_id, $params + ['active' => true]); + + return $price->stripe_product_id; + } + + $product = $stripe->products->create(array_filter($params + [ + 'tax_code' => config('registry.commerce.tax.code'), + ])); + $price->forceFill(['stripe_product_id' => $product->id]); + + return $product->id; + } + + protected function priceParams(Price $price, Extension $extension, string $product, int $amount, string $kind): array + { + return [ + 'product' => $product, + 'unit_amount' => $amount, + 'currency' => strtolower($price->currency), + 'tax_behavior' => config('registry.commerce.tax.behavior', 'exclusive'), + 'metadata' => ['extension' => $extension->public_id, 'kind' => $kind], + ]; + } + + protected function archive(?string $id): void + { + if ($id) { + Stripe::client()->prices->update($id, ['active' => false]); + } + } +} diff --git a/app/Commerce/Stripe.php b/app/Commerce/Stripe.php new file mode 100644 index 0000000..2b6563a --- /dev/null +++ b/app/Commerce/Stripe.php @@ -0,0 +1,34 @@ + $account]; + } +} diff --git a/app/Commerce/StripeWebhooks.php b/app/Commerce/StripeWebhooks.php new file mode 100644 index 0000000..784df56 --- /dev/null +++ b/app/Commerce/StripeWebhooks.php @@ -0,0 +1,269 @@ +type, self::HANDLED, true)) { + return self::IGNORED; + } + + if (Event::query()->where('external_id', $event->id)->exists()) { + return self::DUPLICATE; + } + + $this->mail = []; + + try { + DB::transaction(function () use ($event) { + Event::create([ + 'kind' => 'stripe.'.$event->type, + 'external_id' => $event->id, + 'payload' => array_filter([ + 'object' => $event->data->object->id ?? null, + 'account' => $event->account ?? null, + 'livemode' => (bool) $event->livemode, + ], fn ($value) => $value !== null), + ]); + + $object = $event->data->object; + match ($event->type) { + 'checkout.session.completed', 'checkout.session.async_payment_succeeded' => $this->completed($object), + 'checkout.session.expired' => $this->expired($object), + 'charge.refunded' => $this->refunded($object), + 'charge.dispute.created' => $this->disputed($object), + 'account.updated' => $this->accountUpdated($object), + }; + }); + } catch (UniqueConstraintViolationException) { + // Delivered twice at once: the other delivery stored the event. + return self::DUPLICATE; + } + + foreach ($this->mail as [$to, $mailable]) { + rescue(fn () => Mail::to($to)->send($mailable)); + } + + return self::PROCESSED; + } + + /** + * A paid session creates the purchase and a paid licence, and completes the hand-off. + * Sessions paid later (bank debits) wait for `async_payment_succeeded`. + */ + protected function completed(Session $session): void + { + if (! in_array($session->payment_status, ['paid', 'no_payment_required'], true)) { + return; + } + + if (Purchase::query()->where('stripe_checkout_session_id', $session->id)->exists()) { + return; + } + + $checkout = Checkout::query()->where('stripe_checkout_session_id', $session->id)->lockForUpdate()->first() + ?? Checkout::findById($session->metadata['handoff'] ?? null); + $extension = Extension::find($checkout?->extension_uuid ?? ($session->metadata['extension_uuid'] ?? null)); + $organization = Organization::find($checkout?->organization_uuid ?? ($session->metadata['organization_uuid'] ?? null)); + + if (! $extension || ! $organization) { + Log::warning('Stripe checkout session without a registry checkout', ['session' => $session->id]); + + return; + } + + $intent = $session->payment_intent + ? Stripe::client()->paymentIntents->retrieve(is_string($session->payment_intent) ? $session->payment_intent : $session->payment_intent->id, ['expand' => ['latest_charge']]) + : null; + $charge = $intent?->latest_charge instanceof Charge ? $intent->latest_charge : null; + + $total = (int) $session->amount_total; + $fee = (int) ($intent?->application_fee_amount ?? $checkout?->fee_amount ?? 0); + $email = $session->customer_details['email'] ?? $session->customer_email ?? null; + + $purchase = Purchase::create([ + 'extension_uuid' => $extension->uuid, + 'buyer_organization_uuid' => $organization->uuid, + 'buyer_account_uuid' => $checkout?->account_uuid, + 'price_snapshot' => $checkout?->price_snapshot, + 'amount_total' => $total, + 'fee_amount' => $fee, + 'net_amount' => $total - $fee, + 'currency' => strtoupper((string) $session->currency), + 'stripe_checkout_session_id' => $session->id, + 'stripe_payment_intent_id' => $intent?->id, + 'status' => Purchase::STATUS_PAID, + 'receipt_url' => $charge?->receipt_url, + 'meta' => array_filter([ + 'handoff' => $checkout?->public_id, + 'source' => $checkout?->source, + 'instance_uuid' => $checkout?->instance_uuid, + 'amount_subtotal' => (int) $session->amount_subtotal, + 'amount_tax' => (int) ($session->total_details['amount_tax'] ?? 0), + 'fee_rate_bps' => $checkout?->fee_rate_bps ?? (int) ($session->metadata['fee_rate_bps'] ?? 0), + 'customer_email' => $email, + 'stripe_charge_id' => $charge?->id, + 'stripe_account' => $intent?->transfer_data['destination'] ?? null, + ], fn ($value) => $value !== null), + ]); + + $licence = Licence::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $organization->uuid, + 'purchase_uuid' => $purchase->uuid, + 'kind' => Licence::KIND_PAID, + 'max_activations' => (int) config('registry.commerce.max_activations', 5), + 'status' => Licence::STATUS_ACTIVE, + ]); + + $checkout?->forceFill([ + 'status' => Checkout::STATUS_COMPLETE, + 'purchase_uuid' => $purchase->uuid, + 'completed_at' => now(), + ])->save(); + + Event::record('purchase.completed', $purchase, $checkout?->account, [ + 'extension' => $extension->public_id, + 'organization' => $organization->public_id, + 'licence' => $licence->public_id, + 'amount_total' => $total, + 'fee_amount' => $fee, + ]); + + if ($email) { + $this->mail[] = [$email, new PurchaseReceiptMail($purchase)]; + } + } + + protected function expired(Session $session): void + { + Checkout::query() + ->where('stripe_checkout_session_id', $session->id) + ->where('status', Checkout::STATUS_OPEN) + ->update(['status' => Checkout::STATUS_EXPIRED, 'updated_at' => now()]); + } + + /** + * A full refund marks the purchase refunded and revokes its licence (new installs and updates + * stop; running instances keep working). A partial refund is recorded on the purchase only. + */ + protected function refunded(Charge $charge): void + { + $purchase = $this->purchaseFor($charge->payment_intent); + if (! $purchase) { + return; + } + + $meta = array_merge((array) $purchase->meta, ['amount_refunded' => (int) $charge->amount_refunded]); + + if (! $charge->refunded) { + $purchase->forceFill(['meta' => $meta])->save(); + Event::record('purchase.partially_refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); + + return; + } + + $purchase->forceFill([ + 'status' => Purchase::STATUS_REFUNDED, + 'meta' => $meta + ['refunded_at' => now()->toIso8601String()], + ])->save(); + + $purchase->licence()->where('status', Licence::STATUS_ACTIVE)->get()->each(function (Licence $licence) { + $licence->forceFill([ + 'status' => Licence::STATUS_REVOKED, + 'meta' => array_merge((array) $licence->meta, ['revoked_reason' => 'refunded', 'revoked_at' => now()->toIso8601String()]), + ])->save(); + }); + + Event::record('purchase.refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); + } + + /** + * A dispute flags the purchase (the licence stays active while it is open) and tells staff. + */ + protected function disputed(Dispute $dispute): void + { + $purchase = $this->purchaseFor($dispute->payment_intent); + if (! $purchase) { + return; + } + + $purchase->forceFill([ + 'status' => $purchase->status === Purchase::STATUS_REFUNDED ? $purchase->status : Purchase::STATUS_DISPUTED, + 'meta' => array_merge((array) $purchase->meta, [ + 'dispute' => [ + 'id' => $dispute->id, + 'reason' => $dispute->reason, + 'status' => $dispute->status, + 'amount' => (int) $dispute->amount, + 'opened_at' => now()->toIso8601String(), + ], + ]), + ])->save(); + + Event::record('purchase.disputed', $purchase, null, ['dispute' => $dispute->id, 'reason' => $dispute->reason]); + + foreach ((array) config('registry.commerce.notify', []) as $to) { + $this->mail[] = [$to, new DisputeOpenedMail($purchase)]; + } + } + + protected function accountUpdated(StripeAccount $account): void + { + $organization = Organization::query()->where('stripe_connect_id', $account->id)->first(); + + if ($organization) { + app(ConnectAccounts::class)->sync($organization, $account); + } + } + + protected function purchaseFor(mixed $paymentIntent): ?Purchase + { + $id = is_string($paymentIntent) ? $paymentIntent : ($paymentIntent->id ?? null); + + return $id ? Purchase::query()->where('stripe_payment_intent_id', $id)->lockForUpdate()->first() : null; + } +} diff --git a/app/Console/Commands/SyncStripe.php b/app/Console/Commands/SyncStripe.php new file mode 100644 index 0000000..27c5060 --- /dev/null +++ b/app/Console/Commands/SyncStripe.php @@ -0,0 +1,68 @@ +error('Set STRIPE_SECRET first.'); + + return self::FAILURE; + } + + $failed = 0; + + Organization::query()->whereNotNull('stripe_connect_id')->each(function (Organization $organization) use ($accounts, &$failed) { + if ($this->option('dry-run')) { + $this->line("account {$organization->slug} {$organization->stripe_connect_id}"); + + return; + } + try { + $accounts->refresh($organization); + $this->line("account {$organization->slug} {$organization->payout_status}"); + } catch (\Throwable $e) { + $failed++; + $this->warn("account {$organization->slug} failed: {$e->getMessage()}"); + } + }); + + Price::query()->with('extension')->where('model', '!=', Price::MODEL_FREE)->where('amount', '>', 0)->whereNull('stripe_price_id')->each(function (Price $price) use ($catalog, &$failed) { + if (! $price->extension) { + return; + } + if ($this->option('dry-run')) { + $this->line("price {$price->extension->slug} {$price->amount} {$price->currency}"); + + return; + } + try { + $catalog->sync($price, $price->extension)->save(); + $this->line("price {$price->extension->slug} {$price->stripe_price_id}"); + } catch (\Throwable $e) { + $failed++; + $this->warn("price {$price->extension->slug} failed: {$e->getMessage()}"); + } + }); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Http/Controllers/Admin/FacilitatorFeeController.php b/app/Http/Controllers/Admin/FacilitatorFeeController.php new file mode 100644 index 0000000..ee4c580 --- /dev/null +++ b/app/Http/Controllers/Admin/FacilitatorFeeController.php @@ -0,0 +1,65 @@ +with('createdBy')->orderByDesc('effective_from')->orderByDesc('id')->get(); + + return response()->json([ + 'current_rate_bps' => FacilitatorFee::rateAt(now()), + 'default_rate_bps' => (int) config('registry.commerce.facilitator_fee_bps', 1000), + 'data' => $fees->map(fn (FacilitatorFee $fee) => $this->present($fee))->values(), + ]); + } + + /** + * POST /v1/admin/facilitator-fees — `{rate_bps, effective_from?, note?}`; 1000 = 10%. + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'rate_bps' => ['required', 'integer', 'min:0', 'max:5000'], + 'effective_from' => ['nullable', 'date', 'after_or_equal:'.now()->subMinute()->toIso8601String()], + 'note' => ['nullable', 'string', 'max:255'], + ], ['effective_from.after_or_equal' => 'A fee change cannot take effect in the past.']); + + $fee = FacilitatorFee::create([ + 'rate_bps' => (int) $request->input('rate_bps'), + 'effective_from' => $request->input('effective_from') ?? now(), + 'note' => $request->input('note'), + 'created_by_account_uuid' => $request->user()->uuid, + ]); + + Event::record('facilitator_fee.scheduled', $fee, $request->user(), ['rate_bps' => $fee->rate_bps, 'effective_from' => $fee->effective_from->toIso8601String()], $request->ip()); + + return response()->json($this->present($fee->load('createdBy')), 201); + } + + protected function present(FacilitatorFee $fee): array + { + return [ + 'id' => $fee->uuid, + 'rate_bps' => $fee->rate_bps, + 'effective_from' => $fee->effective_from, + 'note' => $fee->note, + 'created_by' => $fee->createdBy ? ['id' => $fee->createdBy->public_id, 'name' => $fee->createdBy->name] : null, + 'created_at' => $fee->created_at, + ]; + } +} diff --git a/app/Http/Controllers/Instance/InstallController.php b/app/Http/Controllers/Instance/InstallController.php index ffea8ed..fe64c85 100644 --- a/app/Http/Controllers/Instance/InstallController.php +++ b/app/Http/Controllers/Instance/InstallController.php @@ -10,14 +10,12 @@ use App\Http\Resources\LicenceActivationResource; use App\Models\Event; use App\Models\Extension; -use App\Models\ExtensionPackage; use App\Models\Install; use App\Models\Instance; use App\Models\Release; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; /** * Install telemetry from instances. One `installs` row per install attempt: @@ -39,7 +37,7 @@ public function store(ReportInstallRequest $request, ActivateLicence $activate, $instance = $request->user(); $status = $request->input('status'); - $extension = $this->resolveExtension($request->input('extension')); + $extension = Extension::findByIdentifier($request->input('extension')); if (! $extension) { return response()->error('Extension not found.', 404); } @@ -125,7 +123,7 @@ public function destroy(Request $request, string $extension, DeactivateLicence $ /** @var Instance $instance */ $instance = $request->user(); - $model = $this->resolveExtension($extension); + $model = Extension::findByIdentifier($extension); if (! $model) { return response()->error('Extension not found.', 404); } @@ -157,28 +155,6 @@ public function destroy(Request $request, string $extension, DeactivateLicence $ ]); } - /** - * Extensions are addressed by slug, public id, uuid, or one of their npm or - * Composer package names (what an instance knows from its installed packages). - */ - protected function resolveExtension(string $identifier): ?Extension - { - $identifier = urldecode($identifier); - - $extension = Extension::query() - ->where(fn ($q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)) - ->when(Str::isUuid($identifier), fn ($q) => $q->orWhere('uuid', $identifier)) - ->first(); - - if ($extension) { - return $extension; - } - - $package = ExtensionPackage::query()->where('name', $identifier)->first(); - - return $package ? Extension::find($package->extension_uuid) : null; - } - /** * Why a new install may not start, or null when it may. */ diff --git a/app/Http/Controllers/Instance/PurchaseController.php b/app/Http/Controllers/Instance/PurchaseController.php new file mode 100644 index 0000000..170a7a9 --- /dev/null +++ b/app/Http/Controllers/Instance/PurchaseController.php @@ -0,0 +1,73 @@ +user(); + + $extension = Extension::findByIdentifier($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + $checkout = $sessions->start( + $extension, + $instance->organization, + Checkout::SOURCE_INSTANCE, + $request->input('return_url'), + $request->input('cancel_url') ?? $request->input('return_url'), + ['instance' => $instance, 'email' => $request->input('email')], + ); + + Event::record('checkout.started', $checkout, $instance, ['extension' => $extension->public_id, 'source' => Checkout::SOURCE_INSTANCE], $request->ip()); + + return (new CheckoutResource($checkout->load(['extension', 'organization'])))->response()->setStatusCode(201); + } + + /** + * GET /v1/instances/me/purchases/{handoff} — the hand-off's status; `complete` carries the + * purchase and its licence. Any checkout of the instance's organization. + */ + public function show(Request $request, string $handoff): CheckoutResource + { + /** @var Instance $instance */ + $instance = $request->user(); + + $checkout = Checkout::query() + ->where('public_id', $handoff) + ->where('organization_uuid', $instance->organization_uuid) + ->with(['extension', 'organization', 'purchase.extension', 'purchase.licence']) + ->first(); + + if (! $checkout) { + throw new NotFoundHttpException('Checkout not found.'); + } + + return new CheckoutResource($checkout); + } +} diff --git a/app/Http/Controllers/Organization/ActsForOrganization.php b/app/Http/Controllers/Organization/ActsForOrganization.php new file mode 100644 index 0000000..cc1758a --- /dev/null +++ b/app/Http/Controllers/Organization/ActsForOrganization.php @@ -0,0 +1,64 @@ +user(); + } + + /** + * One of the caller's organizations by slug, public id or uuid, with the caller's + * membership as `pivot`. Organizations the caller is not in answer 404. + */ + protected function findOrganization(Request $request, string $identifier, array $with = []): Organization + { + $account = $this->account($request); + + $organization = $account->organizations() + ->with($with) + ->where(fn (Builder $q) => $q->where('organizations.slug', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.uuid', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + throw new NotFoundHttpException('Organization not found.'); + } + + $pinned = $account->currentToken()?->organization_uuid; + if ($pinned && $pinned !== $organization->uuid) { + throw new AccessDeniedHttpException('This token is limited to another organization.'); + } + + return $organization; + } + + /** + * @param list $roles + */ + protected function assertRole(Organization $organization, array $roles, string $message): void + { + if (! in_array($organization->pivot?->role, $roles, true)) { + throw new AccessDeniedHttpException($message); + } + } +} diff --git a/app/Http/Controllers/Organization/BillingController.php b/app/Http/Controllers/Organization/BillingController.php new file mode 100644 index 0000000..8ca9233 --- /dev/null +++ b/app/Http/Controllers/Organization/BillingController.php @@ -0,0 +1,66 @@ +billingOrganization($request, $slug); + + $purchases = Purchase::query() + ->where('buyer_organization_uuid', $organization->uuid) + ->with(['extension', 'buyer', 'buyerAccount', 'licence']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(500) + ->get(); + + return response()->json(PurchaseResource::collection($purchases)->resolve($request)); + } + + /** + * GET /v1/organizations/{slug}/billing/licences — paid and free licences with seat use and + * the instances activated on each. + */ + public function licences(Request $request, string $slug): JsonResponse + { + $organization = $this->billingOrganization($request, $slug); + + $licences = Licence::query() + ->where('organization_uuid', $organization->uuid) + ->with(['extension', 'purchase', 'activeActivations' => fn ($q) => $q->with(['instance', 'release'])->orderBy('activated_at')]) + ->withCount('activeActivations') + ->orderByRaw('case when kind = ? then 0 else 1 end', [Licence::KIND_PAID]) + ->orderByDesc('created_at') + ->get(); + + return response()->json(LicenceResource::collection($licences)->resolve($request)); + } + + protected function billingOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can see billing.'); + + return $organization; + } +} diff --git a/app/Http/Controllers/Organization/OrganizationController.php b/app/Http/Controllers/Organization/OrganizationController.php index 4b1ce2c..d7d8b07 100644 --- a/app/Http/Controllers/Organization/OrganizationController.php +++ b/app/Http/Controllers/Organization/OrganizationController.php @@ -32,6 +32,8 @@ */ class OrganizationController extends Controller { + use ActsForOrganization; + /** Roles that may edit the organization and manage its members. */ public const MANAGING_ROLES = [Membership::ROLE_OWNER, Membership::ROLE_ADMIN]; @@ -206,38 +208,6 @@ public function removeMember(Request $request, string $slug, string $member): Js return response()->json(['removed' => true, 'id' => $membership->account->public_id]); } - protected function account(Request $request): Account - { - return $request->user(); - } - - /** - * One of the caller's organizations by slug, public id or uuid, with the caller's - * membership as `pivot`. Organizations the caller is not in answer 404. - */ - protected function findOrganization(Request $request, string $identifier, array $with = []): Organization - { - $account = $this->account($request); - - $organization = $account->organizations() - ->with($with) - ->where(fn (Builder $q) => $q->where('organizations.slug', $identifier) - ->orWhere('organizations.public_id', $identifier) - ->orWhere('organizations.uuid', $identifier)) - ->first(); - - if (! $organization instanceof Organization) { - throw new NotFoundHttpException('Organization not found.'); - } - - $pinned = $account->currentToken()?->organization_uuid; - if ($pinned && $pinned !== $organization->uuid) { - throw new AccessDeniedHttpException('This token is limited to another organization.'); - } - - return $organization; - } - protected function detail(Request $request, string $identifier): OrganizationResource { $organization = $this->findOrganization($request, $identifier, ['memberships' => fn ($q) => $q->with('account')->orderBy('created_at')]); diff --git a/app/Http/Controllers/Organization/PayoutController.php b/app/Http/Controllers/Organization/PayoutController.php new file mode 100644 index 0000000..0878d77 --- /dev/null +++ b/app/Http/Controllers/Organization/PayoutController.php @@ -0,0 +1,188 @@ +billingOrganization($request, $slug); + $connect = ConnectAccounts::summary($organization); + + if (! $organization->hasStripeAccount()) { + return response()->json(['connect' => $connect, 'balance' => null, 'schedule' => null, 'payouts' => []]); + } + + $stripe = Stripe::client(); + $on = Stripe::onAccount($organization->stripe_connect_id); + $balance = $stripe->balance->retrieve(null, $on); + $payouts = $stripe->payouts->all(['limit' => 100], $on); + $account = $stripe->accounts->retrieve($organization->stripe_connect_id); + $schedule = $account['settings']['payouts']['schedule'] ?? null; + + $inTransit = []; + foreach ($payouts->data as $payout) { + if ($payout->status === 'in_transit') { + $currency = strtoupper($payout->currency); + $inTransit[$currency] = ($inTransit[$currency] ?? 0) + (int) $payout->amount; + } + } + + return response()->json([ + 'connect' => $connect, + 'balance' => [ + 'available' => $this->amounts($balance->available ?? []), + 'pending' => $this->amounts($balance->pending ?? []), + 'in_transit' => collect($inTransit)->map(fn ($amount, $currency) => ['amount' => $amount, 'currency' => $currency])->values()->all(), + ], + 'schedule' => $schedule ? [ + 'interval' => $schedule['interval'] ?? null, + 'delay_days' => $schedule['delay_days'] ?? null, + 'weekly_anchor' => $schedule['weekly_anchor'] ?? null, + 'monthly_anchor' => $schedule['monthly_anchor'] ?? null, + ] : null, + 'payouts' => collect($payouts->data)->take(10)->map(fn ($payout) => [ + 'id' => $payout->id, + 'amount' => (int) $payout->amount, + 'currency' => strtoupper($payout->currency), + 'status' => $payout->status, + 'arrival_date' => $payout->arrival_date ? Carbon::createFromTimestamp($payout->arrival_date)->toIso8601String() : null, + 'created_at' => Carbon::createFromTimestamp($payout->created)->toIso8601String(), + ])->values()->all(), + ]); + } + + /** + * GET /v1/organizations/{slug}/payouts/transactions[?from=&to=&extension=&status=&page=] — + * sales of the organization's extensions, newest first, with totals per currency. + */ + public function transactions(Request $request, string $slug): JsonResponse + { + $organization = $this->billingOrganization($request, $slug); + $query = $this->sales($request, $organization); + + $totals = (clone $query)->reorder() + ->selectRaw('currency, count(*) as count, sum(amount_total) as gross, sum(fee_amount) as fee, sum(net_amount) as net') + ->groupBy('currency') + ->get() + ->map(fn ($row) => ['currency' => $row->currency, 'count' => (int) $row->count, 'gross' => (int) $row->gross, 'fee' => (int) $row->fee, 'net' => (int) $row->net]) + ->values(); + + $page = $query->with(['extension', 'buyer'])->paginate(min(100, max(1, (int) $request->input('per_page', self::PER_PAGE)))); + + return response()->json([ + 'data' => PayoutTransactionResource::collection($page->getCollection())->resolve($request), + 'totals' => $totals, + 'meta' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]); + } + + /** + * GET /v1/organizations/{slug}/payouts/transactions.csv — the same sales (same filters) as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $organization = $this->billingOrganization($request, $slug); + $query = $this->sales($request, $organization)->with(['extension', 'buyer']); + $name = "{$organization->slug}-transactions-".now()->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($query) { + $out = fopen('php://output', 'w'); + fputcsv($out, ['date', 'id', 'extension', 'customer', 'status', 'currency', 'gross', 'fee', 'net', 'tax', 'refunded']); + $query->chunk(500, function ($purchases) use ($out) { + foreach ($purchases as $purchase) { + fputcsv($out, [ + $purchase->created_at?->toIso8601String(), + $purchase->public_id, + $purchase->extension?->name, + $purchase->buyer?->name, + $purchase->status, + $purchase->currency, + static::decimal($purchase->amount_total), + static::decimal($purchase->fee_amount), + static::decimal($purchase->net_amount), + static::decimal($purchase->meta['amount_tax'] ?? 0), + static::decimal($purchase->meta['amount_refunded'] ?? 0), + ]); + } + }); + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + protected function billingOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can see payouts.'); + + return $organization; + } + + protected function sales(Request $request, Organization $organization): Builder + { + $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'status' => ['nullable', 'string', 'in:'.implode(',', [Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED])], + 'extension' => ['nullable', 'string', 'max:191'], + ]); + + return Purchase::query() + ->whereHas('extension', fn (Builder $q) => $q->withTrashed()->where('organization_uuid', $organization->uuid)) + ->when($request->input('from'), fn (Builder $q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay())) + ->when($request->input('to'), fn (Builder $q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay())) + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('extension'), fn (Builder $q, $extension) => $q->whereHas('extension', fn (Builder $e) => $e->where('slug', $extension)->orWhere('public_id', $extension))) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * @param iterable $amounts + */ + protected function amounts(iterable $amounts): array + { + $out = []; + foreach ($amounts as $amount) { + $out[] = ['amount' => (int) $amount->amount, 'currency' => strtoupper($amount->currency)]; + } + + return $out; + } + + protected static function decimal(int|string|null $minor): string + { + return number_format(((int) $minor) / 100, 2, '.', ''); + } +} diff --git a/app/Http/Controllers/Organization/StripeConnectController.php b/app/Http/Controllers/Organization/StripeConnectController.php new file mode 100644 index 0000000..fad3085 --- /dev/null +++ b/app/Http/Controllers/Organization/StripeConnectController.php @@ -0,0 +1,75 @@ +findOrganization($request, $slug); + + if ($request->boolean('refresh') && $organization->hasStripeAccount() && in_array($organization->pivot?->role, self::MANAGING_ROLES, true)) { + $accounts->refresh($organization); + } + + return response()->json(ConnectAccounts::summary($organization)); + } + + /** + * POST /v1/organizations/{slug}/stripe/account — create the Express account (once). + */ + public function store(Request $request, ConnectAccounts $accounts, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::MANAGING_ROLES, 'Only organization owners and admins can set up payouts.'); + $created = ! $organization->hasStripeAccount(); + + $accounts->create($organization, $this->account($request)); + + return response()->json(ConnectAccounts::summary($organization), $created ? 201 : 200); + } + + /** + * POST /v1/organizations/{slug}/stripe/account-session — a client secret for Connect.js + * (onboarding, account management, payouts, tax settings and the notification banner). + */ + public function session(Request $request, ConnectAccounts $accounts, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::MANAGING_ROLES, 'Only organization owners and admins can set up payouts.'); + + if (! $organization->hasStripeAccount()) { + throw new UnprocessableEntityHttpException('Create the Stripe account first.'); + } + + $session = $accounts->session($organization); + + return response()->json([ + 'client_secret' => $session->client_secret, + 'expires_at' => $session->expires_at, + 'publishable_key' => config('services.stripe.key'), + 'components' => ConnectAccounts::COMPONENTS, + ]); + } +} diff --git a/app/Http/Controllers/Publisher/ExtensionController.php b/app/Http/Controllers/Publisher/ExtensionController.php index 61dc2bb..86f36bd 100644 --- a/app/Http/Controllers/Publisher/ExtensionController.php +++ b/app/Http/Controllers/Publisher/ExtensionController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Publisher; +use App\Commerce\PriceCatalog; use App\Http\Requests\Publisher\CreateExtensionRequest; use App\Http\Requests\Publisher\UpdateExtensionRequest; use App\Http\Requests\Publisher\UpdatePriceRequest; @@ -183,22 +184,37 @@ public function update(UpdateExtensionRequest $request, string $slug): Publisher * D6 a price that differs from the last release's snapshot sends the next release to * human review. */ - public function price(UpdatePriceRequest $request, string $slug): PublisherExtensionResource + public function price(UpdatePriceRequest $request, PriceCatalog $catalog, string $slug): PublisherExtensionResource { $extension = $this->findExtension($request, $slug, ['organization', 'price']); $model = $request->input('model'); - if ($model !== Price::MODEL_FREE && blank($extension->organization?->stripe_connect_id)) { - throw new UnprocessableEntityHttpException('Paid pricing opens when Stripe Connect payouts ship (Phase 4).'); + if ($model !== Price::MODEL_FREE && ! $extension->organization?->hasStripeAccount()) { + throw new UnprocessableEntityHttpException("Set up payouts for {$extension->organization?->name} before charging for an extension."); } $attributes = $model === Price::MODEL_FREE ? ['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => $request->input('currency') ?? $extension->price?->currency ?? 'USD', 'on_sale' => false, 'sale_amount' => null] - : ['model' => $model, 'amount' => (int) $request->input('amount'), 'currency' => $request->input('currency')]; + : [ + 'model' => $model, + 'amount' => (int) $request->input('amount'), + 'currency' => $request->input('currency'), + 'sale_amount' => $request->filled('sale_amount') ? (int) $request->input('sale_amount') : null, + 'on_sale' => $request->filled('sale_amount') && $request->boolean('on_sale'), + ]; $before = $extension->price ? (new PriceResource($extension->price))->resolve($request) : null; $price = $extension->price ?? $extension->price()->make(); - $price->fill($attributes)->save(); + $price->fill($attributes); + + // Mirror to Stripe first: a price Stripe refused is not saved. + $sync = $model === Price::MODEL_FREE + ? $price->stripe_price_id || $price->stripe_sale_price_id + : $price->isDirty() || ! $price->stripe_price_id; + if ($sync) { + $catalog->sync($price, $extension); + } + $price->save(); Event::record('extension.price_changed', $extension, $this->account($request), ['from' => $before, 'to' => $attributes]); diff --git a/app/Http/Controllers/Purchase/CheckoutController.php b/app/Http/Controllers/Purchase/CheckoutController.php new file mode 100644 index 0000000..87e0568 --- /dev/null +++ b/app/Http/Controllers/Purchase/CheckoutController.php @@ -0,0 +1,78 @@ +account($request); + $identifier = $request->input('organization') ?? $account->currentToken()?->organization_uuid ?? $account->personalOrganization()?->slug; + if ($identifier === null) { + throw new UnprocessableEntityHttpException('Choose the organization that is buying.'); + } + + $organization = $this->findOrganization($request, $identifier); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can buy extensions.'); + + $extension = Extension::findByIdentifier($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + $site = rtrim((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), '/'); + $checkout = $sessions->start( + $extension, + $organization, + Checkout::SOURCE_WEBSITE, + "{$site}/account/billing/{$organization->slug}", + "{$site}/extensions/{$extension->slug}", + ['account' => $account, 'email' => $account->email], + ); + + Event::record('checkout.started', $checkout, $account, ['extension' => $extension->public_id, 'source' => Checkout::SOURCE_WEBSITE], $request->ip()); + + return (new CheckoutResource($checkout->load(['extension', 'organization'])))->response()->setStatusCode(201); + } + + /** + * GET /v1/purchases/{handoff} — a checkout of one of the caller's organizations. + */ + public function show(Request $request, string $handoff): CheckoutResource + { + $checkout = Checkout::query() + ->where('public_id', $handoff) + ->with(['extension', 'organization', 'purchase.extension', 'purchase.licence']) + ->first(); + + if (! $checkout || ! $checkout->organization) { + throw new NotFoundHttpException('Checkout not found.'); + } + $this->findOrganization($request, $checkout->organization->uuid); + + return new CheckoutResource($checkout); + } +} diff --git a/app/Http/Controllers/Webhook/StripeWebhookController.php b/app/Http/Controllers/Webhook/StripeWebhookController.php new file mode 100644 index 0000000..73f83e9 --- /dev/null +++ b/app/Http/Controllers/Webhook/StripeWebhookController.php @@ -0,0 +1,57 @@ +error('Stripe webhooks are not set up on this registry.', 503); + } + + $payload = $request->getContent(); + $signature = (string) $request->header('Stripe-Signature'); + $event = null; + + foreach ($secrets as $secret) { + try { + $event = Webhook::constructEvent($payload, $signature, $secret, (int) config('services.stripe.webhook_tolerance', 300)); + break; + } catch (SignatureVerificationException) { + continue; + } catch (\UnexpectedValueException) { + return response()->error('The payload is not a Stripe event.', 400); + } + } + + if ($event === null) { + return response()->error('The Stripe signature is invalid.', 400); + } + + try { + $status = $webhooks->handle($event); + } catch (\Throwable $e) { + Log::error('Stripe webhook failed', ['event' => $event->id, 'type' => $event->type, 'error' => $e->getMessage()]); + report($e); + + return response()->error('The event could not be handled; Stripe will retry it.', 500); + } + + return response()->json(['received' => true, 'event' => $event->id, 'status' => $status]); + } +} diff --git a/app/Http/Requests/Instance/StartCheckoutRequest.php b/app/Http/Requests/Instance/StartCheckoutRequest.php new file mode 100644 index 0000000..e59107e --- /dev/null +++ b/app/Http/Requests/Instance/StartCheckoutRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:191'], + 'return_url' => ['required', 'string', 'max:2000', 'url:http,https'], + 'cancel_url' => ['nullable', 'string', 'max:2000', 'url:http,https'], + 'email' => ['nullable', 'string', 'email', 'max:191'], + ]; + } +} diff --git a/app/Http/Requests/Publisher/UpdatePriceRequest.php b/app/Http/Requests/Publisher/UpdatePriceRequest.php index 3d8edb5..f27893e 100644 --- a/app/Http/Requests/Publisher/UpdatePriceRequest.php +++ b/app/Http/Requests/Publisher/UpdatePriceRequest.php @@ -7,7 +7,8 @@ use Illuminate\Validation\Rule; /** - * `{model: free|one_time, amount, currency}`. Amounts are minor units (cents). + * `{model: free|one_time, amount, currency, sale_amount?, on_sale?}`. Amounts are minor units + * (cents). A sale amount is below the amount; `on_sale` turns it on. */ class UpdatePriceRequest extends FormRequest { @@ -32,6 +33,8 @@ public function rules(): array 'model' => ['required', 'string', Rule::in(Price::MODELS)], 'amount' => ['nullable', 'required_if:model,'.Price::MODEL_ONE_TIME, 'integer', 'min:'.($this->input('model') === Price::MODEL_ONE_TIME ? 1 : 0), 'max:'.self::MAX_AMOUNT], 'currency' => ['nullable', 'required_if:model,'.Price::MODEL_ONE_TIME, 'string', 'size:3', 'regex:/^[A-Z]{3}$/'], + 'sale_amount' => ['nullable', 'integer', 'min:1', 'lt:amount'], + 'on_sale' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/Purchase/StartCheckoutRequest.php b/app/Http/Requests/Purchase/StartCheckoutRequest.php new file mode 100644 index 0000000..841c0e0 --- /dev/null +++ b/app/Http/Requests/Purchase/StartCheckoutRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:191'], + 'organization' => ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Resources/CheckoutResource.php b/app/Http/Resources/CheckoutResource.php new file mode 100644 index 0000000..fb5f940 --- /dev/null +++ b/app/Http/Resources/CheckoutResource.php @@ -0,0 +1,32 @@ +resource->hasLapsed() ? Checkout::STATUS_EXPIRED : $this->status; + + return [ + 'handoff' => $this->public_id, + 'status' => $status, + 'source' => $this->source, + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'organization' => $this->whenLoaded('organization', fn () => $this->organization ? ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name] : null), + 'amount' => (int) $this->amount, + 'currency' => $this->currency, + 'url' => $status === Checkout::STATUS_OPEN ? $this->checkout_url : null, + 'expires_at' => $this->expires_at, + 'completed_at' => $this->completed_at, + 'purchase' => $this->whenLoaded('purchase', fn () => $this->purchase ? (new PurchaseResource($this->purchase))->resolve($request) : null), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/LicenceResource.php b/app/Http/Resources/LicenceResource.php index da05a19..357c252 100644 --- a/app/Http/Resources/LicenceResource.php +++ b/app/Http/Resources/LicenceResource.php @@ -16,11 +16,24 @@ public function toArray($request): array 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'purchase' => $this->whenLoaded('purchase', fn () => $this->purchase?->public_id), 'kind' => $this->kind, 'status' => $this->status, 'max_activations' => $this->max_activations, 'seats_used' => $this->whenCounted('activeActivations', fn () => (int) $this->active_activations_count), 'activated_here' => $this->when(array_key_exists('activated_here', $this->resource->getAttributes()), fn () => (bool) $this->resource->getAttribute('activated_here')), + 'activations' => $this->whenLoaded('activeActivations', fn () => $this->activeActivations->map(fn ($activation) => [ + 'id' => $activation->uuid, + 'instance' => $activation->relationLoaded('instance') && $activation->instance ? [ + 'id' => $activation->instance->public_id, + 'name' => $activation->instance->name, + 'host' => $activation->instance->host, + 'kind' => $activation->instance->kind, + ] : null, + 'release' => $activation->relationLoaded('release') ? $activation->release?->version : null, + 'activated_at' => $activation->activated_at, + ])->values()->all()), + 'revoked_reason' => $this->when($this->status === 'revoked', fn () => $this->meta['revoked_reason'] ?? null), 'expires_at' => $this->expires_at, 'created_at' => $this->created_at, ]; diff --git a/app/Http/Resources/PayoutTransactionResource.php b/app/Http/Resources/PayoutTransactionResource.php new file mode 100644 index 0000000..1bf1c67 --- /dev/null +++ b/app/Http/Resources/PayoutTransactionResource.php @@ -0,0 +1,30 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'customer' => $this->whenLoaded('buyer', fn () => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null), + 'gross' => (int) $this->amount_total, + 'fee' => (int) $this->fee_amount, + 'net' => (int) $this->net_amount, + 'tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'refunded' => (int) ($this->meta['amount_refunded'] ?? 0), + 'fee_rate_bps' => isset($this->meta['fee_rate_bps']) ? (int) $this->meta['fee_rate_bps'] : null, + 'currency' => $this->currency, + 'status' => $this->status, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/PriceResource.php b/app/Http/Resources/PriceResource.php index 59652c8..ffdecf3 100644 --- a/app/Http/Resources/PriceResource.php +++ b/app/Http/Resources/PriceResource.php @@ -14,8 +14,8 @@ public function toArray($request): array 'model' => $this->model, 'amount' => (int) $this->amount, 'currency' => $this->currency, - 'on_sale' => (bool) $this->on_sale, - 'sale_amount' => $this->sale_amount !== null ? (int) $this->sale_amount : null, + 'on_sale' => $this->resource->isOnSale(), + 'sale_amount' => $this->resource->hasSaleAmount() ? (int) $this->sale_amount : null, 'effective_amount' => $this->effective_amount, ]; } diff --git a/app/Http/Resources/PurchaseResource.php b/app/Http/Resources/PurchaseResource.php new file mode 100644 index 0000000..1f31901 --- /dev/null +++ b/app/Http/Resources/PurchaseResource.php @@ -0,0 +1,32 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'organization' => $this->whenLoaded('buyer', fn () => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null), + 'bought_by' => $this->whenLoaded('buyerAccount', fn () => $this->buyerAccount ? ['id' => $this->buyerAccount->public_id, 'name' => $this->buyerAccount->name, 'email' => $this->buyerAccount->email] : null), + 'source' => $this->meta['source'] ?? null, + 'amount_total' => (int) $this->amount_total, + 'amount_subtotal' => (int) ($this->meta['amount_subtotal'] ?? $this->amount_total), + 'amount_tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'amount_refunded' => (int) ($this->meta['amount_refunded'] ?? 0), + 'currency' => $this->currency, + 'status' => $this->status, + 'receipt_url' => $this->receipt_url, + 'licence' => $this->whenLoaded('licence', fn () => $this->licence ? (new LicenceResource($this->licence))->resolve($request) : null), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Mail/DisputeOpenedMail.php b/app/Mail/DisputeOpenedMail.php new file mode 100644 index 0000000..49c9804 --- /dev/null +++ b/app/Mail/DisputeOpenedMail.php @@ -0,0 +1,47 @@ +purchase->loadMissing('extension'); + + return new Envelope(subject: 'Dispute opened: '.$this->purchase->extension->name.' ('.$this->purchase->public_id.')'); + } + + public function content(): Content + { + $this->purchase->loadMissing(['extension.organization', 'buyer']); + $dispute = (array) ($this->purchase->meta['dispute'] ?? []); + + return new Content( + markdown: 'mail.dispute-opened', + with: [ + 'extension' => $this->purchase->extension->name, + 'publisher' => $this->purchase->extension->organization?->name, + 'buyer' => $this->purchase->buyer?->name, + 'amount' => number_format(((int) ($dispute['amount'] ?? $this->purchase->amount_total)) / 100, 2).' '.$this->purchase->currency, + 'reason' => $dispute['reason'] ?? null, + 'disputeId' => $dispute['id'] ?? null, + 'purchaseId' => $this->purchase->public_id, + ], + ); + } +} diff --git a/app/Mail/PurchaseReceiptMail.php b/app/Mail/PurchaseReceiptMail.php new file mode 100644 index 0000000..e28389b --- /dev/null +++ b/app/Mail/PurchaseReceiptMail.php @@ -0,0 +1,47 @@ +purchase->loadMissing('extension'); + + return new Envelope(subject: 'Your receipt for '.$this->purchase->extension->name); + } + + public function content(): Content + { + $this->purchase->loadMissing(['extension', 'buyer', 'licence']); + + return new Content( + markdown: 'mail.purchase-receipt', + with: [ + 'extension' => $this->purchase->extension->name, + 'organization' => $this->purchase->buyer?->name, + 'amount' => number_format($this->purchase->amount_total / 100, 2).' '.$this->purchase->currency, + 'seats' => $this->purchase->licence?->max_activations, + 'receiptUrl' => $this->purchase->receipt_url, + 'billingUrl' => rtrim((string) (config('registry.public_url') ?: config('app.url')), '/').'/account/billing/'.$this->purchase->buyer?->slug, + 'purchaseId' => $this->purchase->public_id, + ], + ); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 0000000..19df38c --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,83 @@ + Money::class, + 'fee_amount' => Money::class, + 'fee_rate_bps' => 'integer', + 'price_snapshot' => 'array', + 'expires_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class, 'purchase_uuid', 'uuid'); + } + + /** + * Open, but past the session's expiry (the `checkout.session.expired` webhook may lag). + */ + public function hasLapsed(): bool + { + return $this->status === self::STATUS_OPEN && $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 0d8671a..eae0fda 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -23,7 +23,7 @@ class Event extends EloquentModel public $incrementing = false; - protected $fillable = ['subject_type', 'subject_uuid', 'actor_type', 'actor_uuid', 'kind', 'payload', 'ip_address']; + protected $fillable = ['subject_type', 'subject_uuid', 'actor_type', 'actor_uuid', 'kind', 'external_id', 'payload', 'ip_address']; protected $hidden = ['id']; diff --git a/app/Models/Extension.php b/app/Models/Extension.php index c4623ab..61747f5 100644 --- a/app/Models/Extension.php +++ b/app/Models/Extension.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; /** * A catalog listing. Listing fields are independent of releases; only releases go through review. @@ -173,6 +174,28 @@ public function scopePublished(Builder $query): Builder return $query->where('status', self::STATUS_PUBLISHED); } + /** + * An extension by slug, public id, uuid, or one of its npm or Composer package names (what + * an instance knows from its installed packages). + */ + public static function findByIdentifier(string $identifier): ?static + { + $identifier = urldecode($identifier); + + $extension = static::query() + ->where(fn ($q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)) + ->when(Str::isUuid($identifier), fn ($q) => $q->orWhere('uuid', $identifier)) + ->first(); + + if ($extension) { + return $extension; + } + + $package = ExtensionPackage::query()->where('name', $identifier)->first(); + + return $package ? static::find($package->extension_uuid) : null; + } + public function isPaid(): bool { return $this->price !== null && $this->price->model !== Price::MODEL_FREE && (int) $this->price->amount > 0; diff --git a/app/Models/FacilitatorFee.php b/app/Models/FacilitatorFee.php new file mode 100644 index 0000000..3d5c535 --- /dev/null +++ b/app/Models/FacilitatorFee.php @@ -0,0 +1,52 @@ + 'integer', 'effective_from' => 'datetime']; + + public function createdBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'created_by_account_uuid', 'uuid'); + } + + /** + * The rate in effect at a moment, falling back to `registry.commerce.facilitator_fee_bps` + * before the first row takes effect. + */ + public static function rateAt(?\DateTimeInterface $at = null): int + { + $rate = static::query() + ->where('effective_from', '<=', $at ?? now()) + ->orderByDesc('effective_from') + ->orderByDesc('id') + ->value('rate_bps'); + + return $rate ?? (int) config('registry.commerce.facilitator_fee_bps', 1000); + } + + /** + * The fee on an amount in minor units, rounded half up. + */ + public static function feeFor(int $amount, int $rateBps): int + { + return (int) round($amount * $rateBps / 10000, 0, PHP_ROUND_HALF_UP); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 5f0b80b..0afb0a5 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -24,6 +24,15 @@ class Organization extends Model protected string $publicIdType = 'org'; + /** `payout_status` values, set from Stripe's `account.updated`. */ + public const PAYOUT_PENDING = 'pending'; + + public const PAYOUT_RESTRICTED = 'restricted'; + + public const PAYOUT_ENABLED = 'enabled'; + + public const PAYOUT_REJECTED = 'rejected'; + protected $fillable = [ 'slug', 'name', 'website', 'bio', 'avatar_url', 'is_personal', 'verified_at', 'stripe_connect_id', 'payout_status', 'meta', @@ -81,4 +90,29 @@ public function isVerified(): bool { return $this->verified_at !== null; } + + public function hasStripeAccount(): bool + { + return filled($this->stripe_connect_id); + } + + /** + * What `account.updated` last said about the connected account (charges, payouts, + * transfers capability, requirements), or an empty array. + */ + public function stripeState(): array + { + return (array) ($this->meta['stripe'] ?? []); + } + + /** + * Destination charges can be paid out to this organization: its connected account's + * `transfers` capability is active. + */ + public function canReceivePayments(): bool + { + return $this->hasStripeAccount() + && ($this->stripeState()['transfers'] ?? null) === 'active' + && $this->payout_status !== self::PAYOUT_REJECTED; + } } diff --git a/app/Models/Price.php b/app/Models/Price.php index a8299da..29444ce 100644 --- a/app/Models/Price.php +++ b/app/Models/Price.php @@ -26,10 +26,10 @@ class Price extends Model protected $fillable = [ 'extension_uuid', 'model', 'amount', 'currency', 'sale_amount', 'on_sale', - 'stripe_product_id', 'stripe_price_id', 'interval', 'interval_count', 'trial_days', + 'stripe_product_id', 'stripe_price_id', 'stripe_sale_price_id', 'interval', 'interval_count', 'trial_days', ]; - protected $hidden = ['id', 'stripe_product_id', 'stripe_price_id']; + protected $hidden = ['id', 'stripe_product_id', 'stripe_price_id', 'stripe_sale_price_id']; protected $casts = [ 'amount' => Money::class, @@ -52,15 +52,36 @@ public function isFree(): bool return $this->model === self::MODEL_FREE || (int) $this->amount === 0; } + /** + * A sale amount is set: positive and below the regular amount. (Money stores a cleared + * sale amount as 0.) + */ + public function hasSaleAmount(): bool + { + return (int) $this->sale_amount > 0 && (int) $this->sale_amount < (int) $this->amount; + } + + /** + * The sale is on: `on_sale` with a sale amount. + */ + public function isOnSale(): bool + { + return (bool) $this->on_sale && $this->hasSaleAmount(); + } + /** * The amount a buyer pays right now, honouring an active sale. */ public function getEffectiveAmountAttribute(): int { - if ($this->on_sale && $this->sale_amount !== null) { - return (int) $this->sale_amount; - } + return $this->isOnSale() ? (int) $this->sale_amount : (int) $this->amount; + } - return (int) $this->amount; + /** + * The Stripe price Checkout charges: the sale price while the sale is on. + */ + public function checkoutStripePriceId(): ?string + { + return $this->isOnSale() ? $this->stripe_sale_price_id : $this->stripe_price_id; } } diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php index 5ba04d9..7d9c26e 100644 --- a/app/Models/Purchase.php +++ b/app/Models/Purchase.php @@ -54,6 +54,11 @@ public function buyer(): BelongsTo return $this->belongsTo(Organization::class, 'buyer_organization_uuid', 'uuid'); } + public function buyerAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'buyer_account_uuid', 'uuid'); + } + public function licence(): HasOne { return $this->hasOne(Licence::class, 'purchase_uuid', 'uuid'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e575ca7..59fa7c0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,12 +15,16 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\MessageBag; use Illuminate\Support\ServiceProvider; +use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { public function register(): void { - // + $this->app->bind(StripeClient::class, fn () => new StripeClient([ + 'api_key' => (string) config('services.stripe.secret'), + 'max_network_retries' => 2, + ])); } public function boot(): void @@ -90,6 +94,9 @@ protected function registerRateLimiters(): void // Release uploads carry up to two 50 MB archives and a dist. RateLimiter::for('upload', fn (Request $request) => Limit::perHour(60)->by($request->user()?->uuid ?: $request->ip())); + // Stripe retries and bursts (a refund sends several events at once). + RateLimiter::for('webhooks', fn (Request $request) => Limit::perMinute(600)->by($request->ip())); + RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); // npm and Composer fetch metadata and archives in bursts during an install. diff --git a/bootstrap/app.php b/bootstrap/app.php index 25beb6a..4464d36 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,7 @@ use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Inertia\Inertia; +use Stripe\Exception\ApiErrorException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -71,6 +72,15 @@ } }); + // Stripe refused or could not be reached: a bad gateway, with Stripe's message. + $exceptions->render(function (ApiErrorException $e, Request $request) use ($isApi) { + if ($isApi($request)) { + report($e); + + return response()->error('Stripe could not complete the request: '.$e->getMessage(), 502); + } + }); + $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isApi, $isPackage) { if ($isPackage($request)) { $message = $e->getMessage() ?: (Response::$statusTexts[$e->getStatusCode()] ?? 'Request failed.'); diff --git a/composer.json b/composer.json index 156ff51..71d225d 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "laravel/socialite": "^5.31", "laravel/tinker": "^2.10.1", "league/flysystem-aws-s3-v3": "^3.0", - "opis/json-schema": "^2.3" + "opis/json-schema": "^2.3", + "stripe/stripe-php": "^22.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 813b9f1..1108a63 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6cacbdf406048c2e9bceb10089b4d443", + "content-hash": "fff2b619f3119adb227e78b741451ed8", "packages": [ { "name": "aws/aws-crt-php", @@ -4327,6 +4327,68 @@ }, "time": "2026-09-16T11:39:30+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v22.0.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "c4c1f658483e717519935222e47af224ef4a508b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/c4c1f658483e717519935222e47af224ef4a508b", + "reference": "c4c1f658483e717519935222e47af224ef4a508b", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php" + ], + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v22.0.0" + }, + "time": "2026-10-01T00:43:03+00:00" + }, { "name": "symfony/clock", "version": "v7.4.21", diff --git a/config/registry.php b/config/registry.php index af80df2..54ca6b4 100644 --- a/config/registry.php +++ b/config/registry.php @@ -83,4 +83,32 @@ ], ], + 'commerce' => [ + // Facilitator fee in basis points (1000 = 10%) when no row in `facilitator_fees` is in + // effect yet. Staff change the fee with POST /v1/admin/facilitator-fees, which keeps history. + 'facilitator_fee_bps' => (int) env('REGISTRY_FACILITATOR_FEE_BPS', 1000), + + // Seats (instances) on a paid licence (D4). Staff raise it per licence on request. + 'max_activations' => (int) env('REGISTRY_LICENCE_MAX_ACTIVATIONS', 5), + + // Stripe Tax on Checkout. The publisher's connected account is liable (the charge is + // made on its behalf), so the tax collected reaches the account that files it. + 'tax' => [ + 'enabled' => (bool) env('REGISTRY_STRIPE_TAX', true), + // Product tax code for extensions; null uses the Stripe account's default. + 'code' => env('REGISTRY_STRIPE_TAX_CODE'), + // Whether listing prices exclude tax (added at checkout) or include it. + 'behavior' => env('REGISTRY_STRIPE_TAX_BEHAVIOR', 'exclusive'), + ], + + // Minutes a Checkout Session stays open (Stripe allows 30 to 1440). + 'checkout_expires_minutes' => (int) env('REGISTRY_CHECKOUT_EXPIRES_MINUTES', 60), + + // Country for new Connect Express accounts; null lets the publisher choose in onboarding. + 'connect_country' => env('REGISTRY_CONNECT_COUNTRY'), + + // Who hears about new disputes (a comma-separated list of emails); empty sends nothing. + 'notify' => array_values(array_filter(array_map('trim', explode(',', (string) env('REGISTRY_COMMERCE_NOTIFY', ''))))), + ], + ]; diff --git a/config/services.php b/config/services.php index 664a5ce..e48faab 100644 --- a/config/services.php +++ b/config/services.php @@ -42,4 +42,14 @@ 'redirect' => env('GITHUB_REDIRECT_URI') ?: rtrim((string) env('APP_URL', 'http://localhost'), '/').'/auth/github/callback', ], + // Stripe: Connect Express accounts for publishers, Checkout for buyers, webhooks. Commerce + // routes answer 503 until `secret` is set. `webhook_secrets` is comma-separated: the platform + // endpoint's signing secret and the Connect endpoint's (account.updated) when they differ. + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secrets' => array_values(array_filter(array_map('trim', explode(',', (string) env('STRIPE_WEBHOOK_SECRET', ''))))), + 'webhook_tolerance' => (int) env('STRIPE_WEBHOOK_TOLERANCE', 300), + ], + ]; diff --git a/database/migrations/2026_10_24_000201_add_stripe_sale_price_id_to_prices_table.php b/database/migrations/2026_10_24_000201_add_stripe_sale_price_id_to_prices_table.php new file mode 100644 index 0000000..79a2941 --- /dev/null +++ b/database/migrations/2026_10_24_000201_add_stripe_sale_price_id_to_prices_table.php @@ -0,0 +1,27 @@ +string('stripe_sale_price_id')->nullable()->after('stripe_price_id')->index(); + }); + } + + public function down(): void + { + Schema::table('prices', function (Blueprint $table) { + $table->dropIndex(['stripe_sale_price_id']); + $table->dropColumn('stripe_sale_price_id'); + }); + } +}; diff --git a/database/migrations/2026_10_24_000202_create_facilitator_fees_table.php b/database/migrations/2026_10_24_000202_create_facilitator_fees_table.php new file mode 100644 index 0000000..e65cedd --- /dev/null +++ b/database/migrations/2026_10_24_000202_create_facilitator_fees_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->unsignedInteger('rate_bps'); + $table->timestamp('effective_from')->index(); + $table->string('note')->nullable(); + $table->uuid('created_by_account_uuid')->nullable()->index(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('facilitator_fees'); + } +}; diff --git a/database/migrations/2026_10_24_000203_create_checkouts_table.php b/database/migrations/2026_10_24_000203_create_checkouts_table.php new file mode 100644 index 0000000..9e4e960 --- /dev/null +++ b/database/migrations/2026_10_24_000203_create_checkouts_table.php @@ -0,0 +1,44 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('public_id', 191)->unique(); + $table->uuid('extension_uuid')->index(); + $table->uuid('organization_uuid')->index(); + $table->uuid('account_uuid')->nullable()->index(); + $table->uuid('instance_uuid')->nullable()->index(); + $table->string('source', 16); + $table->string('status', 16)->default('open')->index(); + $table->bigInteger('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->bigInteger('fee_amount')->default(0); + $table->unsignedInteger('fee_rate_bps')->default(0); + $table->json('price_snapshot')->nullable(); + $table->string('stripe_checkout_session_id')->nullable()->unique(); + $table->text('checkout_url')->nullable(); + $table->uuid('purchase_uuid')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_10_24_000204_add_external_id_to_events_table.php b/database/migrations/2026_10_24_000204_add_external_id_to_events_table.php new file mode 100644 index 0000000..8dee51c --- /dev/null +++ b/database/migrations/2026_10_24_000204_add_external_id_to_events_table.php @@ -0,0 +1,27 @@ +string('external_id', 191)->nullable()->after('kind')->unique(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropUnique(['external_id']); + $table->dropColumn('external_id'); + }); + } +}; diff --git a/resources/views/mail/dispute-opened.blade.php b/resources/views/mail/dispute-opened.blade.php new file mode 100644 index 0000000..fca0664 --- /dev/null +++ b/resources/views/mail/dispute-opened.blade.php @@ -0,0 +1,9 @@ + +# A purchase of {{ $extension }} is disputed + +{{ $buyer ?? 'A buyer' }} disputed a payment of {{ $amount }} to {{ $publisher ?? 'the publisher' }}{{ $reason ? ' (reason: '.$reason.')' : '' }}. The purchase is flagged and its licence stays active while the dispute is open. + +Respond in the Stripe Dashboard{{ $disputeId ? ' (dispute `'.$disputeId.'`)' : '' }}. + +Purchase id: `{{ $purchaseId }}` + diff --git a/resources/views/mail/purchase-receipt.blade.php b/resources/views/mail/purchase-receipt.blade.php new file mode 100644 index 0000000..d0a3a7b --- /dev/null +++ b/resources/views/mail/purchase-receipt.blade.php @@ -0,0 +1,20 @@ + +# Thanks for buying {{ $extension }} + +{{ $organization ? $organization.' now holds' : 'Your organization now holds' }} a licence for {{ $extension }}{{ $seats ? ' on up to '.$seats.' instances' : '' }}. Install it from your console's Extensions page. + +**Paid:** {{ $amount }} + +@if ($receiptUrl) + +View the receipt + +@endif + +Your organization's purchases and licences are on the [billing page]({{ $billingUrl }}). + +Purchase id: `{{ $purchaseId }}` + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/api.php b/routes/api.php index 4c05ab8..d24af1a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Account\GithubController as AccountGithubController; use App\Http\Controllers\Account\InstanceCodeController; use App\Http\Controllers\Account\TokenController; +use App\Http\Controllers\Admin\FacilitatorFeeController; use App\Http\Controllers\Admin\ReviewController; use App\Http\Controllers\Auth\AuthController; use App\Http\Controllers\Auth\PasswordResetController; @@ -11,12 +12,18 @@ use App\Http\Controllers\Catalog\CatalogController; use App\Http\Controllers\Instance\InstallController; use App\Http\Controllers\Instance\InstanceController; +use App\Http\Controllers\Instance\PurchaseController; +use App\Http\Controllers\Organization\BillingController; use App\Http\Controllers\Organization\OrganizationController; +use App\Http\Controllers\Organization\PayoutController; +use App\Http\Controllers\Organization\StripeConnectController; use App\Http\Controllers\Packages\ComposerController; use App\Http\Controllers\Packages\NpmController; use App\Http\Controllers\Publisher\ExtensionController as PublisherExtensionController; use App\Http\Controllers\Publisher\ListingMediaController; use App\Http\Controllers\Publisher\ReleaseController as PublisherReleaseController; +use App\Http\Controllers\Purchase\CheckoutController; +use App\Http\Controllers\Webhook\StripeWebhookController; use Illuminate\Support\Facades\Route; /* @@ -35,7 +42,9 @@ | owner, admin or publisher role in the listing's organization. | Admin routes: personal token (or website session) with the `review` scope, member of an | `is_staff` organization. -| Stripe routes are added in a later workstream. +| Commerce: organization Stripe Connect, payouts and billing take a personal token or the +| website session; checkout runs from an instance (flbi_) or the website; Stripe posts signed +| webhooks to `webhooks/stripe` (no session, no CSRF, no token). | */ @@ -88,6 +97,18 @@ Route::post('organizations/{slug}/members', [OrganizationController::class, 'addMember'])->name('organizations.members.store'); Route::patch('organizations/{slug}/members/{member}', [OrganizationController::class, 'updateMember'])->name('organizations.members.update'); Route::delete('organizations/{slug}/members/{member}', [OrganizationController::class, 'removeMember'])->name('organizations.members.destroy'); + + Route::get('organizations/{slug}/stripe', [StripeConnectController::class, 'show'])->name('organizations.stripe.show'); + Route::post('organizations/{slug}/stripe/account', [StripeConnectController::class, 'store'])->name('organizations.stripe.account'); + Route::post('organizations/{slug}/stripe/account-session', [StripeConnectController::class, 'session'])->name('organizations.stripe.session'); + Route::get('organizations/{slug}/payouts', [PayoutController::class, 'summary'])->name('organizations.payouts'); + Route::get('organizations/{slug}/payouts/transactions', [PayoutController::class, 'transactions'])->name('organizations.payouts.transactions'); + Route::get('organizations/{slug}/payouts/transactions.csv', [PayoutController::class, 'export'])->name('organizations.payouts.export'); + Route::get('organizations/{slug}/billing/purchases', [BillingController::class, 'purchases'])->name('organizations.billing.purchases'); + Route::get('organizations/{slug}/billing/licences', [BillingController::class, 'licences'])->name('organizations.billing.licences'); + + Route::post('purchases/checkout', [CheckoutController::class, 'checkout'])->name('purchases.checkout'); + Route::get('purchases/{handoff}', [CheckoutController::class, 'show'])->name('purchases.show'); }); Route::prefix('publisher')->name('publisher.')->middleware(['auth:token,web', 'throttle:publish', 'scope:publish'])->group(function () { @@ -122,6 +143,8 @@ Route::post('releases/{release}/publish', [ReviewController::class, 'publish'])->name('releases.publish'); Route::post('extensions/{slug}/suspend', [ReviewController::class, 'suspend'])->name('extensions.suspend'); Route::post('extensions/{slug}/unsuspend', [ReviewController::class, 'unsuspend'])->name('extensions.unsuspend'); + Route::get('facilitator-fees', [FacilitatorFeeController::class, 'index'])->name('facilitator-fees.index'); + Route::post('facilitator-fees', [FacilitatorFeeController::class, 'store'])->name('facilitator-fees.store'); }); // Instances authenticate with flbi_ tokens only: never a session or CSRF. @@ -133,8 +156,13 @@ Route::get('me/updates', [InstanceController::class, 'updates'])->name('me.updates'); Route::post('me/installs', [InstallController::class, 'store'])->name('me.installs.store'); Route::delete('me/installs/{extension}', [InstallController::class, 'destroy'])->where('extension', '.+')->name('me.installs.destroy'); + Route::post('me/purchases/checkout', [PurchaseController::class, 'checkout'])->name('me.purchases.checkout'); + Route::get('me/purchases/{handoff}', [PurchaseController::class, 'show'])->name('me.purchases.show'); }); }); + + // Signed by Stripe; never a session, CSRF or token. + Route::post('webhooks/stripe', StripeWebhookController::class)->withoutMiddleware('stateful')->middleware('throttle:webhooks')->name('webhooks.stripe'); }); Route::prefix('npm')->name('npm.')->middleware('throttle:packages')->group(function () { diff --git a/tests/Feature/Commerce/CheckoutTest.php b/tests/Feature/Commerce/CheckoutTest.php new file mode 100644 index 0000000..d506afc --- /dev/null +++ b/tests/Feature/Commerce/CheckoutTest.php @@ -0,0 +1,250 @@ +postJson('/v1/instances/me/purchases/checkout', [ + 'extension' => packageName($listing, 'npm'), + 'return_url' => 'https://console.globex.example/extensions/purchased?x=1', + 'email' => 'ops@globex.example', + ])->assertCreated(); + + $checkout = Checkout::sole(); + $response->assertJsonPath('handoff', $checkout->public_id) + ->assertJsonPath('status', 'open') + ->assertJsonPath('amount', 4900) + ->assertJsonPath('currency', 'USD') + ->assertJsonPath('extension.slug', $listing->slug) + ->assertJsonPath('organization.id', $organization->public_id) + ->assertJsonPath('url', 'https://checkout.stripe.com/c/pay/'.$checkout->stripe_checkout_session_id); + + $request = stripe()->sent('post', '/v1/checkout/sessions')[0]; + $params = $request['params']; + $metadata = [ + 'handoff' => $checkout->public_id, + 'source' => 'instance', + 'extension' => $listing->public_id, + 'extension_uuid' => $listing->uuid, + 'organization' => $organization->public_id, + 'organization_uuid' => $organization->uuid, + 'instance' => $instance->public_id, + 'fee_rate_bps' => '1000', + ]; + + expect($params['mode'])->toBe('payment') + ->and($params['line_items'])->toBe([['price' => 'price_1QfLmTRegular0001', 'quantity' => 1]]) + ->and($params['success_url'])->toBe("https://console.globex.example/extensions/purchased?x=1&handoff={$checkout->public_id}") + ->and($params['cancel_url'])->toBe("https://console.globex.example/extensions/purchased?x=1&handoff={$checkout->public_id}&cancelled=1") + ->and($params['client_reference_id'])->toBe($checkout->public_id) + ->and($params['customer_email'])->toBe('ops@globex.example') + ->and($params['metadata'])->toBe($metadata) + ->and($params['payment_intent_data'])->toBe([ + 'application_fee_amount' => 490, + 'transfer_data' => ['destination' => 'acct_1QfLmTPublisher01'], + 'on_behalf_of' => 'acct_1QfLmTPublisher01', + 'description' => "{$listing->name} licence", + 'metadata' => $metadata, + ]) + ->and($params['automatic_tax'])->toBe(['enabled' => 'true', 'liability' => ['type' => 'account', 'account' => 'acct_1QfLmTPublisher01']]) + ->and($params['tax_id_collection'])->toBe(['enabled' => 'true']) + ->and((int) $params['expires_at'])->toBe($checkout->expires_at->getTimestamp()) + ->and(FakeStripeHttpClient::header($request, 'Idempotency-Key'))->toBe('checkout:'.$checkout->uuid); + + expect($checkout->fee_amount)->toBe(490) + ->and($checkout->fee_rate_bps)->toBe(1000) + ->and($checkout->instance_uuid)->toBe($instance->uuid) + ->and($checkout->price_snapshot['stripe_price_id'])->toBe('price_1QfLmTRegular0001'); +}); + +it('charges the sale price while the sale is on', function () { + [, , $listing] = sellableListing(4900, [], ['sale_amount' => 2900, 'on_sale' => true, 'stripe_sale_price_id' => 'price_1QfLmTSale0000001']); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x']) + ->assertCreated() + ->assertJsonPath('amount', 2900); + + $params = stripe()->sent('post', '/v1/checkout/sessions')[0]['params']; + expect($params['line_items'][0]['price'])->toBe('price_1QfLmTSale0000001') + ->and($params['payment_intent_data']['application_fee_amount'])->toBe(290); +}); + +it('uses the facilitator fee in effect when the checkout starts', function () { + [, , $listing] = sellableListing(4900); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + FacilitatorFee::create(['rate_bps' => 1500, 'effective_from' => now()->subDay()]); + FacilitatorFee::create(['rate_bps' => 2000, 'effective_from' => now()->addWeek()]); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->assertCreated(); + + expect(stripe()->sent('post', '/v1/checkout/sessions')[0]['params']['payment_intent_data']['application_fee_amount'])->toBe(735) + ->and(Checkout::sole()->fee_rate_bps)->toBe(1500); +}); + +it('refuses checkouts that cannot or need not happen', function (Closure $arrange, int $status, string $message) { + [, $publisher, $listing] = sellableListing(4900); + [, $organization] = buyerOrganization(); + $instance = buyerInstance($organization); + $message = $arrange($listing, $organization, $instance, $publisher) ?? $message; + actingAsInstance($instance->refresh()); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x']) + ->assertStatus($status) + ->assertExactJson(['errors' => [$message]]); + + expect(Checkout::count())->toBe(0) + ->and(stripe()->sent('post', '/v1/checkout/sessions'))->toBe([]); +})->with([ + 'not published' => [function ($listing) { + $listing->forceFill(['status' => Extension::STATUS_SUSPENDED])->save(); + }, 422, 'This extension is not published.'], + 'free' => [function ($listing) { + $listing->price->forceFill(['model' => 'free', 'amount' => 0])->save(); + + return "{$listing->name} is free: install it without buying."; + }, 422, ''], + 'wrong hosting' => [function ($listing) { + $listing->forceFill(['hosting' => Extension::HOSTING_CLOUD])->save(); + }, 422, 'This extension is only available for cloud instances.'], + 'publisher buys its own' => [function ($listing, $organization, $instance, $publisher) { + $instance->forceFill(['organization_uuid' => $publisher->uuid])->save(); + + return "{$publisher->name} publishes {$listing->name} and already has access to it."; + }, 409, ''], + 'already licensed' => [function ($listing, $organization) { + Licence::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $organization->uuid, 'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE]); + + return "{$organization->name} already holds a licence for {$listing->name}. Ask Fleetbase staff for more seats."; + }, 409, ''], + 'payouts not ready' => [function ($listing, $organization, $instance, $publisher) { + $publisher->forceFill(['meta' => ['stripe' => ['transfers' => 'inactive']], 'payout_status' => Organization::PAYOUT_RESTRICTED])->save(); + + return "{$listing->name} cannot be bought right now: its publisher has not finished setting up payouts."; + }, 422, ''], +]); + +it('reads imported accounts and mirrors imported prices before the first checkout', function () { + [, $publisher, $listing] = sellableListing(4900, [], ['stripe_price_id' => null]); + $publisher->forceFill(['meta' => null, 'payout_status' => null])->save(); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->assertCreated(); + + $price = $listing->price->fresh(); + expect(stripe()->sent('get', '/v1/accounts/acct_1QfLmTPublisher01'))->toHaveCount(1) + ->and($publisher->refresh()->payout_status)->toBe(Organization::PAYOUT_ENABLED) + ->and($price->stripe_price_id)->toStartWith('price_test') + ->and(stripe()->sent('post', '/v1/checkout/sessions')[0]['params']['line_items'][0]['price'])->toBe($price->stripe_price_id); +}); + +it('validates the instance checkout request', function () { + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => 'x', 'return_url' => 'javascript:alert(1)']) + ->assertStatus(422) + ->assertJsonStructure(['fields' => ['return_url']]); + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => 'missing', 'return_url' => 'https://console.example/x']) + ->assertNotFound(); + $this->withHeader('Authorization', 'Bearer flbi_nope') + ->postJson('/v1/instances/me/purchases/checkout', ['extension' => 'x', 'return_url' => 'https://console.example/x']) + ->assertUnauthorized(); +}); + +it('polls a hand-off from the instance until the purchase lands', function () { + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + $instance = buyerInstance($organization); + actingAsInstance($instance); + + $handoff = $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->json('handoff'); + + $this->getJson("/v1/instances/me/purchases/{$handoff}") + ->assertOk() + ->assertJsonPath('status', 'open') + ->assertJsonPath('purchase', null); + + $session = Checkout::sole()->stripe_checkout_session_id; + stripeWebhook('checkout.session.completed', stripeFixture('checkout_session', ['id' => $session]))->assertOk(); + + $this->getJson("/v1/instances/me/purchases/{$handoff}") + ->assertOk() + ->assertJsonPath('status', 'complete') + ->assertJsonPath('url', null) + ->assertJsonPath('purchase.status', 'paid') + ->assertJsonPath('purchase.amount_total', 5292) + ->assertJsonPath('purchase.receipt_url', 'https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUWZMbVQ') + ->assertJsonPath('purchase.licence.kind', 'paid') + ->assertJsonPath('purchase.licence.max_activations', 5); + + // Another organization's instance cannot see it. + [, $other] = buyerOrganization(); + actingAsInstance(buyerInstance($other)); + $this->getJson("/v1/instances/me/purchases/{$handoff}")->assertNotFound(); +}); + +it('reports an open hand-off past its expiry as expired', function () { + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + $handoff = $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->json('handoff'); + + $this->travel(2)->hours(); + + $this->getJson("/v1/instances/me/purchases/{$handoff}")->assertOk()->assertJsonPath('status', 'expired')->assertJsonPath('url', null); +}); + +it('starts a website checkout for a signed-in buyer organization', function () { + config(['registry.public_url' => 'https://registry.example']); + [, , $listing] = sellableListing(4900); + [$account, $organization] = buyerOrganization(Membership::ROLE_BILLING); + actingAsAccount($account); + + $response = $this->postJson('/v1/purchases/checkout', ['extension' => $listing->slug, 'organization' => $organization->slug]) + ->assertCreated() + ->assertJsonPath('status', 'open'); + + $handoff = $response->json('handoff'); + $params = stripe()->sent('post', '/v1/checkout/sessions')[0]['params']; + expect($params['success_url'])->toBe("https://registry.example/account/billing/{$organization->slug}?handoff={$handoff}") + ->and($params['cancel_url'])->toBe("https://registry.example/extensions/{$listing->slug}?handoff={$handoff}&cancelled=1") + ->and($params['customer_email'])->toBe($account->email) + ->and($params['metadata']['source'])->toBe('website') + ->and($params['metadata']['account'])->toBe($account->public_id) + ->and($params['metadata'])->not->toHaveKey('instance'); + + $this->getJson("/v1/purchases/{$handoff}")->assertOk()->assertJsonPath('handoff', $handoff); + expect(Checkout::sole()->account_uuid)->toBe($account->uuid); +}); + +it('keeps website checkout to owners, admins and billing members of the buyer', function () { + [, , $listing] = sellableListing(); + [$account, $organization] = buyerOrganization(Membership::ROLE_PUBLISHER); + actingAsAccount($account); + + $this->postJson('/v1/purchases/checkout', ['extension' => $listing->slug, 'organization' => $organization->slug]) + ->assertForbidden() + ->assertExactJson(['errors' => ['Only organization owners, admins and billing members can buy extensions.']]); + + $stranger = Organization::factory()->create(); + $this->postJson('/v1/purchases/checkout', ['extension' => $listing->slug, 'organization' => $stranger->slug])->assertNotFound(); + + // A hand-off of another organization is not found. + [, $other] = buyerOrganization(); + $checkout = Checkout::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $other->uuid, 'source' => 'website']); + $this->getJson("/v1/purchases/{$checkout->public_id}")->assertNotFound(); +}); diff --git a/tests/Feature/Commerce/ConnectTest.php b/tests/Feature/Commerce/ConnectTest.php new file mode 100644 index 0000000..5210712 --- /dev/null +++ b/tests/Feature/Commerce/ConnectTest.php @@ -0,0 +1,142 @@ +postJson("/v1/organizations/{$organization->slug}/stripe/account") + ->assertCreated() + ->assertJsonPath('connected', true) + ->assertJsonPath('payout_status', Organization::PAYOUT_PENDING) + ->assertJsonPath('can_receive_payments', false) + ->assertJsonPath('requirements_due', ['external_account', 'tos_acceptance.date', 'tos_acceptance.ip', 'business_profile.mcc']); + + $sent = stripe()->sent('post', '/v1/accounts'); + expect($sent)->toHaveCount(1) + ->and($sent[0]['params']['controller'])->toBe([ + 'stripe_dashboard' => ['type' => 'express'], + 'fees' => ['payer' => 'application'], + 'losses' => ['payments' => 'application'], + ]) + ->and($sent[0]['params']['capabilities'])->toBe(['card_payments' => ['requested' => 'true'], 'transfers' => ['requested' => 'true']]) + ->and($sent[0]['params']['email'])->toBe($account->email) + ->and($sent[0]['params']['metadata'])->toBe(['organization' => $organization->public_id, 'organization_uuid' => $organization->uuid]) + ->and(FakeStripeHttpClient::header($sent[0], 'Idempotency-Key'))->toBe('connect-account:'.$organization->uuid); + + $organization->refresh(); + expect($organization->stripe_connect_id)->toStartWith('acct_') + ->and(Event::where('kind', 'organization.stripe_account_created')->count())->toBe(1); + + // A second call keeps the account. + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account")->assertOk(); + expect(stripe()->sent('post', '/v1/accounts'))->toHaveCount(1); +}); + +it('opens an Account Session with the embedded components for owners and admins', function () { + [$account, $organization] = buyerOrganization(Membership::ROLE_ADMIN); + $organization->forceFill(['stripe_connect_id' => 'acct_1QfLmTPublisher01'])->save(); + actingAsAccount($account); + + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account-session") + ->assertOk() + ->assertJsonPath('client_secret', 'accs_secret__QfLmTdYxGvZ1kRk6S5bdlFJxXyZ') + ->assertJsonPath('publishable_key', 'pk_test_registry') + ->assertJsonPath('components', ['account_onboarding', 'account_management', 'notification_banner', 'payouts', 'tax_registrations', 'tax_settings']); + + $params = stripe()->sent('post', '/v1/account_sessions')[0]['params']; + expect($params['account'])->toBe('acct_1QfLmTPublisher01') + ->and($params['components']['account_onboarding'])->toBe(['enabled' => 'true', 'features' => ['external_account_collection' => 'true']]) + ->and($params['components']['payouts'])->toBe(['enabled' => 'true']); +}); + +it('keeps payouts setup to owners and admins and to members of the organization', function () { + [$account, $organization] = buyerOrganization(Membership::ROLE_PUBLISHER); + actingAsAccount($account); + + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account") + ->assertForbidden() + ->assertExactJson(['errors' => ['Only organization owners and admins can set up payouts.']]); + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account-session")->assertForbidden(); + + // Members still see the status. + $this->getJson("/v1/organizations/{$organization->slug}/stripe")->assertOk()->assertJsonPath('connected', false); + + $other = Organization::factory()->create(); + $this->getJson("/v1/organizations/{$other->slug}/stripe")->assertNotFound(); + + expect(stripe()->requests)->toBe([]); +}); + +it('asks for the account before opening a session', function () { + [$account, $organization] = buyerOrganization(); + actingAsAccount($account); + + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account-session") + ->assertStatus(422) + ->assertExactJson(['errors' => ['Create the Stripe account first.']]); +}); + +it('refreshes the account from Stripe on request', function () { + [$account, $organization] = buyerOrganization(); + $organization->forceFill(['stripe_connect_id' => 'acct_1QfLmTPublisher01', 'payout_status' => Organization::PAYOUT_PENDING])->save(); + actingAsAccount($account); + + $this->getJson("/v1/organizations/{$organization->slug}/stripe?refresh=1") + ->assertOk() + ->assertJsonPath('payout_status', Organization::PAYOUT_ENABLED) + ->assertJsonPath('can_receive_payments', true) + ->assertJsonPath('country', 'US') + ->assertJsonPath('default_currency', 'USD'); + + expect(Event::where('kind', 'organization.payout_status_changed')->sole()->payload)->toBe(['from' => 'pending', 'to' => 'enabled', 'account' => 'acct_1QfLmTPublisher01']); +}); + +it('answers 503 for commerce routes until Stripe is configured', function () { + config(['services.stripe.secret' => null]); + [$account, $organization] = buyerOrganization(); + actingAsAccount($account); + + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account") + ->assertStatus(503) + ->assertExactJson(['errors' => ['Payments are not set up on this registry.']]); +}); + +it('reports Stripe errors as a bad gateway', function () { + [$account, $organization] = buyerOrganization(); + actingAsAccount($account); + stripe()->on('post', '/v1/accounts', stripeFixture('error_invalid_request'), 400); + + $this->postJson("/v1/organizations/{$organization->slug}/stripe/account") + ->assertStatus(502) + ->assertJsonPath('errors.0', fn ($message) => str_starts_with($message, 'Stripe could not complete the request: You passed an empty string')); + + expect($organization->refresh()->stripe_connect_id)->toBeNull(); +}); + +it('derives payout status from the account', function (array $overrides, string $status) { + $account = Stripe\Account::constructFrom(stripeFixture('account', $overrides)); + + expect(ConnectAccounts::payoutStatus($account))->toBe($status); +})->with([ + 'enabled' => [[], Organization::PAYOUT_ENABLED], + 'details missing' => [['details_submitted' => false, 'charges_enabled' => false, 'payouts_enabled' => false], Organization::PAYOUT_PENDING], + 'payouts paused' => [['payouts_enabled' => false, 'requirements' => ['disabled_reason' => 'requirements.past_due']], Organization::PAYOUT_RESTRICTED], + 'rejected' => [['charges_enabled' => false, 'requirements' => ['disabled_reason' => 'rejected.fraud']], Organization::PAYOUT_REJECTED], +]); + +it('lets a website session set up payouts', function () { + $account = Account::factory()->create(); + $organization = Organization::factory()->create(); + $account->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_OWNER]); + config(['session.driver' => 'database']); + $cookies = websiteSignIn($account); + + websiteRequest('POST', "/v1/organizations/{$organization->slug}/stripe/account", $cookies)->assertCreated(); + websiteRequest('POST', "/v1/organizations/{$organization->slug}/stripe/account-session", $cookies, [], csrf: false)->assertStatus(419); +}); diff --git a/tests/Feature/Commerce/PayoutsAndBillingTest.php b/tests/Feature/Commerce/PayoutsAndBillingTest.php new file mode 100644 index 0000000..e614c7c --- /dev/null +++ b/tests/Feature/Commerce/PayoutsAndBillingTest.php @@ -0,0 +1,212 @@ + $listing->uuid, + 'buyer_organization_uuid' => $buyer->uuid, + 'amount_total' => $total, + 'fee_amount' => $fee, + 'net_amount' => $total - $fee, + 'currency' => 'USD', + 'status' => Purchase::STATUS_PAID, + 'stripe_checkout_session_id' => 'cs_test_'.uniqid(), + 'stripe_payment_intent_id' => 'pi_test_'.uniqid(), + 'receipt_url' => 'https://pay.stripe.com/receipts/payment/x', + 'meta' => ['amount_subtotal' => 4900, 'amount_tax' => 392, 'fee_rate_bps' => 1000, 'source' => 'website'], + ], $attributes)); + Licence::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $buyer->uuid, 'purchase_uuid' => $purchase->uuid, 'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE]); + + return $purchase; +} + +it('shows the publisher balance, schedule and payouts from the connected account', function () { + [$owner, $publisher] = sellableListing(); + actingAsAccount($owner); + + $this->getJson("/v1/organizations/{$publisher->slug}/payouts") + ->assertOk() + ->assertJsonPath('connect.payout_status', 'enabled') + ->assertJsonPath('balance', [ + 'available' => [['amount' => 12880, 'currency' => 'USD']], + 'pending' => [['amount' => 4410, 'currency' => 'USD']], + 'in_transit' => [['amount' => 8820, 'currency' => 'USD']], + ]) + ->assertJsonPath('schedule', ['interval' => 'weekly', 'delay_days' => 2, 'weekly_anchor' => 'friday', 'monthly_anchor' => null]) + ->assertJsonPath('payouts.0.id', 'po_1QfLmTPayout00002') + ->assertJsonPath('payouts.0.status', 'in_transit') + ->assertJsonPath('payouts.1.status', 'paid'); + + foreach (['/v1/balance', '/v1/payouts'] as $path) { + expect(FakeStripeHttpClient::header(stripe()->sent('get', $path)[0], 'Stripe-Account'))->toBe('acct_1QfLmTPublisher01'); + } +}); + +it('answers without Stripe for an organization that has no account', function () { + [$account, $organization] = buyerOrganization(); + actingAsAccount($account); + + $this->getJson("/v1/organizations/{$organization->slug}/payouts") + ->assertOk() + ->assertJsonPath('connect.connected', false) + ->assertJsonPath('balance', null); + expect(stripe()->requests)->toBe([]); +}); + +it('lists sales with gross, fee, net, status and customer, filtered and totalled', function () { + [$owner, $publisher, $listing] = sellableListing(); + [, $globex] = buyerOrganization(); + [, $initech] = buyerOrganization(); + $this->travelTo(Carbon::parse('2026-10-01 12:00')); + $old = recordedSale($listing, $globex); + $this->travelTo(Carbon::parse('2026-10-20 12:00')); + $refunded = recordedSale($listing, $initech, 5292, 490, ['status' => Purchase::STATUS_REFUNDED, 'meta' => ['amount_refunded' => 5292]]); + // Someone else's sale never shows. + [, , $otherListing] = sellableListing(); + recordedSale($otherListing, $globex); + actingAsAccount($owner); + + $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $refunded->public_id) + ->assertJsonPath('data.0.customer.name', $initech->name) + ->assertJsonPath('data.0.status', 'refunded') + ->assertJsonPath('data.0.refunded', 5292) + ->assertJsonPath('data.1', [ + 'id' => $old->public_id, + 'extension' => ['id' => $listing->public_id, 'slug' => $listing->slug, 'name' => $listing->name], + 'customer' => ['id' => $globex->public_id, 'slug' => $globex->slug, 'name' => $globex->name], + 'gross' => 5292, + 'fee' => 490, + 'net' => 4802, + 'tax' => 392, + 'refunded' => 0, + 'fee_rate_bps' => 1000, + 'currency' => 'USD', + 'status' => 'paid', + 'created_at' => $old->created_at->toJSON(), + ]) + ->assertJsonPath('totals', [['currency' => 'USD', 'count' => 2, 'gross' => 10584, 'fee' => 980, 'net' => 9604]]) + ->assertJsonPath('meta.total', 2); + + $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions?from=2026-10-10")->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $refunded->public_id); + $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions?status=paid")->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $old->public_id); + $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions?status=bogus")->assertStatus(422); +}); + +it('exports sales as CSV', function () { + [$owner, $publisher, $listing] = sellableListing(); + [, $globex] = buyerOrganization(); + $this->travelTo(Carbon::parse('2026-10-20 12:00:00', 'UTC')); + $purchase = recordedSale($listing, $globex); + actingAsAccount($owner); + + $response = $this->get("/v1/organizations/{$publisher->slug}/payouts/transactions.csv")->assertOk(); + + expect($response->headers->get('Content-Type'))->toStartWith('text/csv') + ->and($response->headers->get('Content-Disposition'))->toContain("{$publisher->slug}-transactions-2026-10-20.csv") + ->and(array_map('str_getcsv', explode("\n", trim($response->streamedContent()))))->toBe([ + ['date', 'id', 'extension', 'customer', 'status', 'currency', 'gross', 'fee', 'net', 'tax', 'refunded'], + ['2026-10-20T12:00:00+00:00', $purchase->public_id, $listing->name, $globex->name, 'paid', 'USD', '52.92', '4.90', '48.02', '3.92', '0.00'], + ]); +}); + +it('keeps payouts and billing to owners, admins and billing members', function (string $role, int $status) { + [, $publisher] = sellableListing(); + $member = Account::factory()->create(); + $member->organizations()->attach($publisher->uuid, ['role' => $role]); + actingAsAccount($member); + + $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions")->assertStatus($status); + $this->getJson("/v1/organizations/{$publisher->slug}/billing/purchases")->assertStatus($status); + $this->getJson("/v1/organizations/{$publisher->slug}/billing/licences")->assertStatus($status); +})->with([ + 'billing' => [Membership::ROLE_BILLING, 200], + 'admin' => [Membership::ROLE_ADMIN, 200], + 'publisher' => [Membership::ROLE_PUBLISHER, 403], + 'member' => [Membership::ROLE_MEMBER, 403], +]); + +it('lists what an organization bought, who bought it and the receipts', function () { + [, , $listing] = sellableListing(); + [$account, $organization] = buyerOrganization(Membership::ROLE_BILLING); + $purchase = recordedSale($listing, $organization, attributes: ['buyer_account_uuid' => $account->uuid]); + actingAsAccount($account); + + $this->getJson("/v1/organizations/{$organization->slug}/billing/purchases") + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $purchase->public_id) + ->assertJsonPath('0.extension.slug', $listing->slug) + ->assertJsonPath('0.bought_by', ['id' => $account->public_id, 'name' => $account->name, 'email' => $account->email]) + ->assertJsonPath('0.amount_total', 5292) + ->assertJsonPath('0.amount_subtotal', 4900) + ->assertJsonPath('0.amount_tax', 392) + ->assertJsonPath('0.receipt_url', 'https://pay.stripe.com/receipts/payment/x') + ->assertJsonPath('0.licence.kind', 'paid') + ->assertJsonMissingPath('0.fee_amount'); +}); + +it('lists licences with the instances holding a seat', function () { + [, , $listing] = sellableListing(); + [$account, $organization] = buyerOrganization(); + $purchase = recordedSale($listing, $organization); + $instance = buyerInstance($organization); + $release = makeRelease($listing->packages()->where('kind', 'npm')->sole(), '1.2.0'); + $licence = Licence::sole(); + $licence->activations()->create(['instance_uuid' => $instance->uuid, 'release_uuid' => $release->uuid, 'activated_at' => now()]); + $licence->activations()->create(['instance_uuid' => buyerInstance($organization)->uuid, 'activated_at' => now(), 'deactivated_at' => now()]); + $free = Licence::create(['extension_uuid' => Extension::factory()->create()->uuid, 'organization_uuid' => $organization->uuid, 'kind' => Licence::KIND_FREE, 'max_activations' => null]); + actingAsAccount($account); + + $this->getJson("/v1/organizations/{$organization->slug}/billing/licences") + ->assertOk() + ->assertJsonCount(2) + ->assertJsonPath('0.id', $licence->public_id) + ->assertJsonPath('0.purchase', $purchase->public_id) + ->assertJsonPath('0.seats_used', 1) + ->assertJsonPath('0.max_activations', 5) + ->assertJsonPath('0.activations.0.instance', ['id' => $instance->public_id, 'name' => $instance->name, 'host' => $instance->host, 'kind' => $instance->kind]) + ->assertJsonPath('0.activations.0.release', '1.2.0') + ->assertJsonCount(1, '0.activations') + ->assertJsonPath('1.id', $free->public_id) + ->assertJsonPath('1.kind', 'free'); +}); + +it('keeps the facilitator fee as staff-managed history', function () { + $staff = staffAccount(); + actingAsAccount($staff); + + $this->getJson('/v1/admin/facilitator-fees')->assertOk()->assertJsonPath('current_rate_bps', 1000)->assertJsonPath('data', []); + + $this->postJson('/v1/admin/facilitator-fees', ['rate_bps' => 1200, 'note' => 'Q1 change']) + ->assertCreated() + ->assertJsonPath('rate_bps', 1200) + ->assertJsonPath('created_by.id', $staff->public_id); + $this->postJson('/v1/admin/facilitator-fees', ['rate_bps' => 1500, 'effective_from' => now()->addMonth()->toIso8601String()])->assertCreated(); + $this->postJson('/v1/admin/facilitator-fees', ['rate_bps' => 500, 'effective_from' => now()->subDay()->toIso8601String()]) + ->assertStatus(422) + ->assertJsonPath('fields.effective_from.0', 'A fee change cannot take effect in the past.'); + $this->postJson('/v1/admin/facilitator-fees', ['rate_bps' => 9000])->assertStatus(422); + + $this->getJson('/v1/admin/facilitator-fees')->assertOk()->assertJsonPath('current_rate_bps', 1200)->assertJsonCount(2, 'data'); + expect(FacilitatorFee::rateAt(now()->addMonths(2)))->toBe(1500) + ->and(FacilitatorFee::rateAt(now()->subYear()))->toBe(1000); + + [$account] = buyerOrganization(); + actingAsAccount($account); + $this->getJson('/v1/admin/facilitator-fees')->assertForbidden(); +}); diff --git a/tests/Feature/Commerce/PriceSyncTest.php b/tests/Feature/Commerce/PriceSyncTest.php new file mode 100644 index 0000000..c8060ed --- /dev/null +++ b/tests/Feature/Commerce/PriceSyncTest.php @@ -0,0 +1,112 @@ +forceFill(['stripe_connect_id' => 'acct_1QfLmTPublisher01', 'payout_status' => Organization::PAYOUT_PENDING])->save(); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + + return [$account, $organization, $listing, '/v1/publisher/extensions/'.$listing->slug.'/price']; +} + +it('mirrors a new paid price to a Stripe product and price', function () { + [, , $listing, $path] = connectedPublisher(); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD'])->assertOk(); + + $product = stripe()->sent('post', '/v1/products')[0]['params']; + $created = stripe()->sent('post', '/v1/prices')[0]['params']; + expect($product['name'])->toBe($listing->name) + ->and($product['metadata'])->toBe(['extension' => $listing->public_id, 'extension_uuid' => $listing->uuid]) + ->and($created)->toMatchArray(['unit_amount' => 4900, 'currency' => 'usd', 'tax_behavior' => 'exclusive']) + ->and($created['metadata'])->toBe(['extension' => $listing->public_id, 'kind' => 'regular']); + + $price = Price::where('extension_uuid', $listing->uuid)->sole(); + expect($price->stripe_product_id)->toStartWith('prod_') + ->and($price->stripe_price_id)->toStartWith('price_') + ->and($price->stripe_sale_price_id)->toBeNull() + ->and($created['product'])->toBe($price->stripe_product_id); +}); + +it('adds the sale price as a second Stripe price and on_sale chooses the one checkout uses', function () { + [, , $listing, $path] = connectedPublisher(); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 2900, 'on_sale' => true]) + ->assertOk() + ->assertJsonPath('price', ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'on_sale' => true, 'sale_amount' => 2900, 'effective_amount' => 2900]); + + $price = Price::where('extension_uuid', $listing->uuid)->sole(); + $prices = stripe()->sent('post', '/v1/prices'); + expect($prices)->toHaveCount(2) + ->and($prices[1]['params'])->toMatchArray(['unit_amount' => 2900, 'product' => $price->stripe_product_id]) + ->and($prices[1]['params']['metadata']['kind'])->toBe('sale') + ->and($price->stripe_sale_price_id)->not->toBe($price->stripe_price_id) + ->and($price->checkoutStripePriceId())->toBe($price->stripe_sale_price_id); + + // Ending the sale creates nothing: the regular price is already there. + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 2900, 'on_sale' => false]) + ->assertOk() + ->assertJsonPath('price.on_sale', false) + ->assertJsonPath('price.effective_amount', 4900); + expect(stripe()->sent('post', '/v1/prices'))->toHaveCount(2) + ->and($price->refresh()->checkoutStripePriceId())->toBe($price->stripe_price_id); +}); + +it('replaces and archives the Stripe price when the amount or currency changes', function () { + [, , $listing, $path] = connectedPublisher(); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 2900])->assertOk(); + $first = Price::where('extension_uuid', $listing->uuid)->sole(); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 5900, 'currency' => 'EUR', 'sale_amount' => 3900])->assertOk(); + $second = $first->fresh(); + + expect($second->stripe_product_id)->toBe($first->stripe_product_id) + ->and($second->stripe_price_id)->not->toBe($first->stripe_price_id) + ->and($second->stripe_sale_price_id)->not->toBe($first->stripe_sale_price_id) + ->and(collect(stripe()->sent('post', '/v1/prices/price_\w+'))->map(fn ($r) => [basename($r['path']), $r['params']])->all())->toBe([ + [$first->stripe_price_id, ['active' => 'false']], + [$first->stripe_sale_price_id, ['active' => 'false']], + ]) + ->and(stripe()->sent('post', '/v1/prices')[2]['params']['currency'])->toBe('eur'); + + // The product follows the listing's name. + expect(stripe()->sent('post', '/v1/products/prod_\w+')[0]['params'])->toMatchArray(['name' => $listing->name, 'active' => 'true']); +}); + +it('archives the Stripe prices when a listing goes free', function () { + [, , $listing, $path] = connectedPublisher(); + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 2900])->assertOk(); + $paid = Price::where('extension_uuid', $listing->uuid)->sole(); + + $this->putJson($path, ['model' => 'free'])->assertOk(); + + $price = $paid->fresh(); + expect($price->stripe_price_id)->toBeNull() + ->and($price->stripe_sale_price_id)->toBeNull() + ->and($price->stripe_product_id)->toBe($paid->stripe_product_id) + ->and(stripe()->sent('post', '/v1/prices/price_\w+'))->toHaveCount(2); +}); + +it('does not save a price Stripe refused', function () { + [, , $listing, $path] = connectedPublisher(); + stripe()->on('post', '/v1/prices', stripeFixture('error_invalid_request'), 400); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD'])->assertStatus(502); + + expect($listing->price()->sole()->model)->toBe(Price::MODEL_FREE); +}); + +it('validates the sale amount', function () { + [, , , $path] = connectedPublisher(); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'USD', 'sale_amount' => 4900]) + ->assertStatus(422) + ->assertJsonStructure(['fields' => ['sale_amount']]); + expect(stripe()->requests)->toBe([]); +}); diff --git a/tests/Feature/Commerce/StripeSyncCommandTest.php b/tests/Feature/Commerce/StripeSyncCommandTest.php new file mode 100644 index 0000000..bcadc6f --- /dev/null +++ b/tests/Feature/Commerce/StripeSyncCommandTest.php @@ -0,0 +1,31 @@ +create(['stripe_connect_id' => 'acct_1QfLmTPublisher01']); + $listing = Extension::factory()->published()->paid(2500)->for($organization)->create(); + $listing->price->forceFill(['stripe_product_id' => 'prod_imported'])->save(); + Extension::factory()->published()->free()->create(); + + $this->artisan('registry:stripe-sync')->assertSuccessful(); + + $price = $listing->price->fresh(); + expect($organization->refresh()->payout_status)->toBe(Organization::PAYOUT_ENABLED) + ->and($price->stripe_product_id)->toBe('prod_imported') + ->and($price->stripe_price_id)->toStartWith('price_test') + ->and(stripe()->sent('post', '/v1/prices'))->toHaveCount(1) + ->and(Price::whereNotNull('stripe_price_id')->count())->toBe(1); + + // Nothing left to mirror on a second run. + $this->artisan('registry:stripe-sync')->assertSuccessful(); + expect(stripe()->sent('post', '/v1/prices'))->toHaveCount(1); +}); + +it('needs Stripe configured', function () { + config(['services.stripe.secret' => null]); + + $this->artisan('registry:stripe-sync')->expectsOutput('Set STRIPE_SECRET first.')->assertFailed(); +}); diff --git a/tests/Feature/Commerce/WebhookTest.php b/tests/Feature/Commerce/WebhookTest.php new file mode 100644 index 0000000..1d71936 --- /dev/null +++ b/tests/Feature/Commerce/WebhookTest.php @@ -0,0 +1,229 @@ +postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->assertCreated(); + + return [Checkout::sole(), $organization, $listing, $instance]; +} + +function completedSession(Checkout $checkout, array $overrides = []): array +{ + return stripeFixture('checkout_session', array_replace_recursive([ + 'id' => $checkout->stripe_checkout_session_id, + 'client_reference_id' => $checkout->public_id, + 'metadata' => ['handoff' => $checkout->public_id], + ], $overrides)); +} + +beforeEach(fn () => Mail::fake()); + +it('creates the purchase and a paid licence from checkout.session.completed', function () { + [$checkout, $organization, $listing, $instance] = startedCheckout(); + + stripeWebhook('checkout.session.completed', completedSession($checkout), ['id' => 'evt_completed_1']) + ->assertOk() + ->assertExactJson(['received' => true, 'event' => 'evt_completed_1', 'status' => 'processed']); + + $purchase = Purchase::sole(); + expect($purchase->extension_uuid)->toBe($listing->uuid) + ->and($purchase->buyer_organization_uuid)->toBe($organization->uuid) + ->and($purchase->amount_total)->toBe(5292) + ->and($purchase->fee_amount)->toBe(490) + ->and($purchase->net_amount)->toBe(4802) + ->and($purchase->currency)->toBe('USD') + ->and($purchase->status)->toBe(Purchase::STATUS_PAID) + ->and($purchase->receipt_url)->toBe('https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUWZMbVQ') + ->and($purchase->stripe_payment_intent_id)->toBe('pi_3QfLmTPayment0001') + ->and($purchase->meta)->toMatchArray([ + 'handoff' => $checkout->public_id, + 'source' => 'instance', + 'instance_uuid' => $instance->uuid, + 'amount_subtotal' => 4900, + 'amount_tax' => 392, + 'fee_rate_bps' => 1000, + 'customer_email' => 'buyer@globex.example', + 'stripe_charge_id' => 'ch_3QfLmTCharge00001', + 'stripe_account' => 'acct_1QfLmTPublisher01', + ]); + + $licence = Licence::sole(); + expect($licence->organization_uuid)->toBe($organization->uuid) + ->and($licence->kind)->toBe(Licence::KIND_PAID) + ->and($licence->max_activations)->toBe(5) + ->and($licence->purchase_uuid)->toBe($purchase->uuid) + ->and($licence->isActive())->toBeTrue(); + + expect($checkout->refresh()->status)->toBe(Checkout::STATUS_COMPLETE) + ->and($checkout->purchase_uuid)->toBe($purchase->uuid) + ->and(Event::where('external_id', 'evt_completed_1')->sole()->kind)->toBe('stripe.checkout.session.completed') + ->and(stripe()->sent('get', '/v1/payment_intents/pi_3QfLmTPayment0001')[0]['params'])->toBe(['expand' => ['latest_charge']]); + + Mail::assertSent(PurchaseReceiptMail::class, fn ($mail) => $mail->hasTo('buyer@globex.example') && $mail->purchase->is($purchase)); +}); + +it('lets the instance install the extension with the new licence', function () { + [$checkout, , $listing, $instance] = startedCheckout(); + makeRelease($listing->packages()->where('kind', 'npm')->sole(), '1.0.0'); + + $this->postJson('/v1/instances/me/installs', ['extension' => $listing->slug, 'version' => '1.0.0', 'status' => 'installing']) + ->assertForbidden(); + + stripeWebhook('checkout.session.completed', completedSession($checkout))->assertOk(); + + $this->postJson('/v1/instances/me/installs', ['extension' => $listing->slug, 'version' => '1.0.0', 'status' => 'installing']) + ->assertCreated() + ->assertJsonPath('activation.licence.kind', 'paid'); +}); + +it('handles each event id once', function () { + [$checkout] = startedCheckout(); + $session = completedSession($checkout); + + stripeWebhook('checkout.session.completed', $session, ['id' => 'evt_dup'])->assertOk()->assertJsonPath('status', 'processed'); + stripeWebhook('checkout.session.completed', $session, ['id' => 'evt_dup'])->assertOk()->assertJsonPath('status', 'duplicate'); + // A different event for the same session (Stripe can send both) creates nothing new either. + stripeWebhook('checkout.session.async_payment_succeeded', $session, ['id' => 'evt_async'])->assertOk(); + + expect(Purchase::count())->toBe(1) + ->and(Licence::count())->toBe(1) + ->and(Event::whereNotNull('external_id')->count())->toBe(2); + Mail::assertSentCount(1); +}); + +it('waits for async_payment_succeeded when the session is not paid yet', function () { + [$checkout] = startedCheckout(); + + stripeWebhook('checkout.session.completed', completedSession($checkout, ['payment_status' => 'unpaid']))->assertOk(); + expect(Purchase::count())->toBe(0)->and($checkout->refresh()->status)->toBe(Checkout::STATUS_OPEN); + + stripeWebhook('checkout.session.async_payment_succeeded', completedSession($checkout))->assertOk(); + expect(Purchase::count())->toBe(1)->and($checkout->refresh()->status)->toBe(Checkout::STATUS_COMPLETE); +}); + +it('rolls back and answers 500 when handling fails, so Stripe retries', function () { + [$checkout] = startedCheckout(); + stripe()->on('get', '/v1/payment_intents/(pi_\w+)', ['error' => ['type' => 'invalid_request_error', 'message' => 'No such payment_intent.']], 404, times: 1); + + stripeWebhook('checkout.session.completed', completedSession($checkout), ['id' => 'evt_retry'])->assertStatus(500); + expect(Purchase::count())->toBe(0)->and(Event::where('external_id', 'evt_retry')->exists())->toBeFalse(); + + stripeWebhook('checkout.session.completed', completedSession($checkout), ['id' => 'evt_retry'])->assertOk()->assertJsonPath('status', 'processed'); + expect(Purchase::count())->toBe(1); +}); + +it('refuses a bad or stale signature', function () { + [$checkout] = startedCheckout(); + + stripeWebhook('checkout.session.completed', completedSession($checkout), [], 'whsec_wrong') + ->assertStatus(400) + ->assertExactJson(['errors' => ['The Stripe signature is invalid.']]); + stripeWebhook('checkout.session.completed', completedSession($checkout), [], STRIPE_TEST_WEBHOOK_SECRET, time() - 3600)->assertStatus(400); + + $this->call('POST', '/v1/webhooks/stripe', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], '{}')->assertStatus(400); + + expect(Purchase::count())->toBe(0)->and(Event::whereNotNull('external_id')->count())->toBe(0); +}); + +it('accepts the Connect endpoint secret and ignores events it does not handle', function () { + stripeWebhook('customer.created', ['id' => 'cus_1', 'object' => 'customer'], [], 'whsec_test_connect') + ->assertOk() + ->assertJsonPath('status', 'ignored'); +}); + +it('answers 503 until a webhook secret is set', function () { + config(['services.stripe.webhook_secrets' => []]); + + stripeWebhook('customer.created', ['id' => 'cus_1', 'object' => 'customer'])->assertStatus(503); +}); + +it('marks the checkout expired on checkout.session.expired', function () { + [$checkout] = startedCheckout(); + + stripeWebhook('checkout.session.expired', completedSession($checkout, ['status' => 'expired', 'payment_status' => 'unpaid']))->assertOk(); + + expect($checkout->refresh()->status)->toBe(Checkout::STATUS_EXPIRED); + $this->getJson("/v1/instances/me/purchases/{$checkout->public_id}")->assertJsonPath('status', 'expired'); +}); + +it('marks the purchase refunded and revokes the licence on a full refund', function () { + [$checkout, , $listing] = startedCheckout(); + makeRelease($listing->packages()->where('kind', 'npm')->sole(), '1.0.0'); + stripeWebhook('checkout.session.completed', completedSession($checkout))->assertOk(); + $activation = Licence::sole()->activations()->create(['instance_uuid' => $checkout->instance_uuid, 'activated_at' => now()]); + + // A partial refund is recorded and changes nothing else. + stripeWebhook('charge.refunded', stripeFixture('charge', ['amount_refunded' => 1000, 'refunded' => false]))->assertOk(); + expect(Purchase::sole()->status)->toBe(Purchase::STATUS_PAID) + ->and(Purchase::sole()->meta['amount_refunded'])->toBe(1000) + ->and(Licence::sole()->isActive())->toBeTrue(); + + stripeWebhook('charge.refunded', stripeFixture('charge'))->assertOk(); + + $purchase = Purchase::sole(); + $licence = Licence::sole(); + expect($purchase->status)->toBe(Purchase::STATUS_REFUNDED) + ->and($purchase->meta['amount_refunded'])->toBe(5292) + ->and($licence->status)->toBe(Licence::STATUS_REVOKED) + ->and($licence->meta['revoked_reason'])->toBe('refunded') + // Grace: the running instance keeps its seat. + ->and($activation->refresh()->deactivated_at)->toBeNull() + ->and(Event::where('kind', 'purchase.refunded')->count())->toBe(1); + + // New installs and package downloads stop. + $this->postJson('/v1/instances/me/installs', ['extension' => $listing->slug, 'version' => '1.0.0', 'status' => 'installing'])->assertForbidden(); + expect(app(LicenceGate::class)->hasActiveLicence($listing, [$purchase->buyer_organization_uuid]))->toBeFalse(); +}); + +it('ignores refunds and disputes of charges the registry did not sell', function () { + stripeWebhook('charge.refunded', stripeFixture('charge', ['payment_intent' => 'pi_other']))->assertOk()->assertJsonPath('status', 'processed'); + stripeWebhook('charge.dispute.created', stripeFixture('dispute', ['payment_intent' => 'pi_other']))->assertOk(); + + expect(Purchase::count())->toBe(0); +}); + +it('flags a disputed purchase and tells staff', function () { + config(['registry.commerce.notify' => ['finance@fleetbase.example']]); + [$checkout] = startedCheckout(); + stripeWebhook('checkout.session.completed', completedSession($checkout))->assertOk(); + + stripeWebhook('charge.dispute.created', stripeFixture('dispute'))->assertOk(); + + $purchase = Purchase::sole(); + expect($purchase->status)->toBe(Purchase::STATUS_DISPUTED) + ->and($purchase->meta['dispute'])->toMatchArray(['id' => 'dp_1QfLmTDispute0001', 'reason' => 'fraudulent', 'status' => 'needs_response', 'amount' => 5292]) + ->and(Licence::sole()->isActive())->toBeTrue(); + Mail::assertSent(DisputeOpenedMail::class, fn ($mail) => $mail->hasTo('finance@fleetbase.example')); +}); + +it('keeps payout_status from account.updated', function () { + $organization = Organization::factory()->create(['stripe_connect_id' => 'acct_1QfLmTPublisher01', 'payout_status' => Organization::PAYOUT_ENABLED]); + + stripeWebhook('account.updated', stripeFixture('account_pending', ['details_submitted' => true]), ['account' => 'acct_1QfLmTPublisher01'], 'whsec_test_connect')->assertOk(); + + $organization->refresh(); + expect($organization->payout_status)->toBe(Organization::PAYOUT_RESTRICTED) + ->and($organization->canReceivePayments())->toBeFalse() + ->and($organization->stripeState()['past_due'])->toBe(['external_account', 'tos_acceptance.date', 'tos_acceptance.ip']); + + stripeWebhook('account.updated', stripeFixture('account'))->assertOk(); + expect($organization->refresh()->payout_status)->toBe(Organization::PAYOUT_ENABLED) + ->and($organization->canReceivePayments())->toBeTrue(); +}); diff --git a/tests/Feature/Releases/ListingPriceTest.php b/tests/Feature/Releases/ListingPriceTest.php index c866d09..c6ffcf4 100644 --- a/tests/Feature/Releases/ListingPriceTest.php +++ b/tests/Feature/Releases/ListingPriceTest.php @@ -8,13 +8,13 @@ use Illuminate\Support\Facades\Mail; it('keeps paid pricing closed until the organization has Stripe Connect', function () { - [$account, , $listing] = publisher(); + [$account, $organization, $listing] = publisher(); actingAsAccount($account, [Token::SCOPE_PUBLISH]); $path = '/v1/publisher/extensions/'.$listing->slug.'/price'; $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'usd']) ->assertStatus(422) - ->assertExactJson(['errors' => ['Paid pricing opens when Stripe Connect payouts ship (Phase 4).']]); + ->assertExactJson(['errors' => ["Set up payouts for {$organization->name} before charging for an extension."]]); $this->putJson($path, ['model' => 'free']) ->assertOk() @@ -25,6 +25,7 @@ }); it('sets a one-time price for an organization with Stripe Connect', function () { + fakeStripe(); [$account, $organization, $listing] = publisher(); $organization->forceFill(['stripe_connect_id' => 'acct_123'])->save(); actingAsAccount($account, [Token::SCOPE_PUBLISH]); @@ -47,6 +48,7 @@ it('sends the next release to review after a price change', function () { Mail::fake(); + fakeStripe(); [$account, $organization, $listing] = publisher(); $organization->forceFill(['stripe_connect_id' => 'acct_123'])->save(); publishedRelease($account, $listing, '1.0.0'); diff --git a/tests/Fixtures/stripe/account.json b/tests/Fixtures/stripe/account.json new file mode 100644 index 0000000..d161461 --- /dev/null +++ b/tests/Fixtures/stripe/account.json @@ -0,0 +1,31 @@ +{ + "id": "acct_1QfLmTPublisher01", + "object": "account", + "business_profile": { "name": "Acme Fleet Tools", "url": "https://acme.example", "mcc": null, "support_email": null }, + "business_type": "company", + "capabilities": { "card_payments": "active", "transfers": "active" }, + "charges_enabled": true, + "controller": { + "fees": { "payer": "application" }, + "is_controller": true, + "losses": { "payments": "application" }, + "requirement_collection": "stripe", + "stripe_dashboard": { "type": "express" }, + "type": "application" + }, + "country": "US", + "created": 1792700000, + "default_currency": "usd", + "details_submitted": true, + "email": "owner@acme.example", + "external_accounts": { "object": "list", "data": [], "has_more": false, "total_count": 0, "url": "/v1/accounts/acct_1QfLmTPublisher01/external_accounts" }, + "future_requirements": { "alternatives": [], "current_deadline": null, "currently_due": [], "disabled_reason": null, "errors": [], "eventually_due": [], "past_due": [], "pending_verification": [] }, + "metadata": {}, + "payouts_enabled": true, + "requirements": { "alternatives": [], "current_deadline": null, "currently_due": [], "disabled_reason": null, "errors": [], "eventually_due": [], "past_due": [], "pending_verification": [] }, + "settings": { + "payouts": { "debit_negative_balances": true, "schedule": { "delay_days": 2, "interval": "weekly", "weekly_anchor": "friday" }, "statement_descriptor": null } + }, + "tos_acceptance": { "date": 1792700500 }, + "type": "express" +} diff --git a/tests/Fixtures/stripe/account_pending.json b/tests/Fixtures/stripe/account_pending.json new file mode 100644 index 0000000..b03a986 --- /dev/null +++ b/tests/Fixtures/stripe/account_pending.json @@ -0,0 +1,27 @@ +{ + "id": "acct_1QfLmTPublisher01", + "object": "account", + "business_profile": { "name": "Acme Fleet Tools", "url": "https://acme.example" }, + "capabilities": { "card_payments": "inactive", "transfers": "inactive" }, + "charges_enabled": false, + "controller": { "fees": { "payer": "application" }, "is_controller": true, "losses": { "payments": "application" }, "stripe_dashboard": { "type": "express" }, "type": "application" }, + "country": "US", + "created": 1792700000, + "default_currency": "usd", + "details_submitted": false, + "email": "owner@acme.example", + "metadata": {}, + "payouts_enabled": false, + "requirements": { + "alternatives": [], + "current_deadline": null, + "currently_due": ["business_profile.mcc", "external_account", "tos_acceptance.date", "tos_acceptance.ip"], + "disabled_reason": "requirements.past_due", + "errors": [], + "eventually_due": ["business_profile.mcc", "external_account", "tos_acceptance.date", "tos_acceptance.ip"], + "past_due": ["external_account", "tos_acceptance.date", "tos_acceptance.ip"], + "pending_verification": [] + }, + "settings": { "payouts": { "schedule": { "delay_days": 2, "interval": "daily" } } }, + "type": "express" +} diff --git a/tests/Fixtures/stripe/account_session.json b/tests/Fixtures/stripe/account_session.json new file mode 100644 index 0000000..397d660 --- /dev/null +++ b/tests/Fixtures/stripe/account_session.json @@ -0,0 +1,15 @@ +{ + "object": "account_session", + "account": "acct_1QfLmTPublisher01", + "client_secret": "accs_secret__QfLmTdYxGvZ1kRk6S5bdlFJxXyZ", + "components": { + "account_management": { "enabled": true, "features": { "external_account_collection": true } }, + "account_onboarding": { "enabled": true, "features": { "external_account_collection": true } }, + "notification_banner": { "enabled": true, "features": { "external_account_collection": true } }, + "payouts": { "enabled": true, "features": { "edit_payout_schedule": false, "instant_payouts": false, "standard_payouts": false } }, + "tax_registrations": { "enabled": true, "features": {} }, + "tax_settings": { "enabled": true, "features": {} } + }, + "expires_at": 1792801800, + "livemode": false +} diff --git a/tests/Fixtures/stripe/balance.json b/tests/Fixtures/stripe/balance.json new file mode 100644 index 0000000..c40c1cd --- /dev/null +++ b/tests/Fixtures/stripe/balance.json @@ -0,0 +1,6 @@ +{ + "object": "balance", + "available": [{ "amount": 12880, "currency": "usd", "source_types": { "card": 12880 } }], + "livemode": false, + "pending": [{ "amount": 4410, "currency": "usd", "source_types": { "card": 4410 } }] +} diff --git a/tests/Fixtures/stripe/charge.json b/tests/Fixtures/stripe/charge.json new file mode 100644 index 0000000..1e0ae29 --- /dev/null +++ b/tests/Fixtures/stripe/charge.json @@ -0,0 +1,19 @@ +{ + "id": "ch_3QfLmTCharge00001", + "object": "charge", + "amount": 5292, + "amount_captured": 5292, + "amount_refunded": 5292, + "application_fee_amount": 490, + "captured": true, + "created": 1792800100, + "currency": "usd", + "disputed": false, + "livemode": false, + "paid": true, + "payment_intent": "pi_3QfLmTPayment0001", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUWZMbVQ", + "refunded": true, + "status": "succeeded", + "transfer_data": { "amount": null, "destination": "acct_1QfLmTPublisher01" } +} diff --git a/tests/Fixtures/stripe/checkout_session.json b/tests/Fixtures/stripe/checkout_session.json new file mode 100644 index 0000000..8fc8c0e --- /dev/null +++ b/tests/Fixtures/stripe/checkout_session.json @@ -0,0 +1,26 @@ +{ + "id": "cs_test_a1QfLmTCheckout0001", + "object": "checkout.session", + "amount_subtotal": 4900, + "amount_total": 5292, + "automatic_tax": { "enabled": true, "liability": { "type": "account", "account": "acct_1QfLmTPublisher01" }, "status": "complete" }, + "billing_address_collection": null, + "cancel_url": "https://console.example/extensions", + "client_reference_id": null, + "created": 1792800000, + "currency": "usd", + "customer": null, + "customer_details": { "address": { "country": "US", "postal_code": "94103" }, "email": "buyer@globex.example", "name": "Hank Scorpio", "tax_exempt": "none", "tax_ids": [] }, + "customer_email": null, + "expires_at": 1792803600, + "livemode": false, + "metadata": {}, + "mode": "payment", + "payment_intent": "pi_3QfLmTPayment0001", + "payment_status": "paid", + "status": "complete", + "success_url": "https://console.example/extensions", + "total_details": { "amount_discount": 0, "amount_shipping": 0, "amount_tax": 392 }, + "ui_mode": "hosted", + "url": "https://checkout.stripe.com/c/pay/cs_test_a1QfLmTCheckout0001" +} diff --git a/tests/Fixtures/stripe/dispute.json b/tests/Fixtures/stripe/dispute.json new file mode 100644 index 0000000..b38fa49 --- /dev/null +++ b/tests/Fixtures/stripe/dispute.json @@ -0,0 +1,16 @@ +{ + "id": "dp_1QfLmTDispute0001", + "object": "dispute", + "amount": 5292, + "balance_transactions": [], + "charge": "ch_3QfLmTCharge00001", + "created": 1792900000, + "currency": "usd", + "evidence_details": { "due_by": 1793700000, "has_evidence": false, "past_due": false, "submission_count": 0 }, + "is_charge_refundable": false, + "livemode": false, + "metadata": {}, + "payment_intent": "pi_3QfLmTPayment0001", + "reason": "fraudulent", + "status": "needs_response" +} diff --git a/tests/Fixtures/stripe/error_invalid_request.json b/tests/Fixtures/stripe/error_invalid_request.json new file mode 100644 index 0000000..9e83c36 --- /dev/null +++ b/tests/Fixtures/stripe/error_invalid_request.json @@ -0,0 +1,8 @@ +{ + "error": { + "code": "parameter_invalid_empty", + "message": "You passed an empty string for 'currency'. We assume empty values are an attempt to unset a parameter; however 'currency' cannot be unset.", + "param": "currency", + "type": "invalid_request_error" + } +} diff --git a/tests/Fixtures/stripe/event.json b/tests/Fixtures/stripe/event.json new file mode 100644 index 0000000..b12e07a --- /dev/null +++ b/tests/Fixtures/stripe/event.json @@ -0,0 +1,11 @@ +{ + "id": "evt_1QfLmT2eZvKYlo2C0aBcDeFg", + "object": "event", + "api_version": "2026-09-30.endive", + "created": 1792800000, + "data": { "object": {} }, + "livemode": false, + "pending_webhooks": 1, + "request": { "id": null, "idempotency_key": null }, + "type": "checkout.session.completed" +} diff --git a/tests/Fixtures/stripe/payment_intent.json b/tests/Fixtures/stripe/payment_intent.json new file mode 100644 index 0000000..ad64ab0 --- /dev/null +++ b/tests/Fixtures/stripe/payment_intent.json @@ -0,0 +1,33 @@ +{ + "id": "pi_3QfLmTPayment0001", + "object": "payment_intent", + "amount": 5292, + "amount_received": 5292, + "application_fee_amount": 490, + "created": 1792800100, + "currency": "usd", + "description": "Route Optimizer licence", + "latest_charge": { + "id": "ch_3QfLmTCharge00001", + "object": "charge", + "amount": 5292, + "amount_captured": 5292, + "amount_refunded": 0, + "application_fee_amount": 490, + "captured": true, + "created": 1792800100, + "currency": "usd", + "livemode": false, + "paid": true, + "payment_intent": "pi_3QfLmTPayment0001", + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUWZMbVQ", + "refunded": false, + "status": "succeeded", + "transfer_data": { "amount": null, "destination": "acct_1QfLmTPublisher01" } + }, + "livemode": false, + "metadata": {}, + "on_behalf_of": "acct_1QfLmTPublisher01", + "status": "succeeded", + "transfer_data": { "destination": "acct_1QfLmTPublisher01" } +} diff --git a/tests/Fixtures/stripe/payouts.json b/tests/Fixtures/stripe/payouts.json new file mode 100644 index 0000000..6d734b2 --- /dev/null +++ b/tests/Fixtures/stripe/payouts.json @@ -0,0 +1,9 @@ +{ + "object": "list", + "data": [ + { "id": "po_1QfLmTPayout00002", "object": "payout", "amount": 8820, "arrival_date": 1792972800, "created": 1792800000, "currency": "usd", "method": "standard", "status": "in_transit", "type": "bank_account" }, + { "id": "po_1QfLmTPayout00001", "object": "payout", "amount": 4410, "arrival_date": 1792368000, "created": 1792195200, "currency": "usd", "method": "standard", "status": "paid", "type": "bank_account" } + ], + "has_more": false, + "url": "/v1/payouts" +} diff --git a/tests/Fixtures/stripe/price.json b/tests/Fixtures/stripe/price.json new file mode 100644 index 0000000..f7aa497 --- /dev/null +++ b/tests/Fixtures/stripe/price.json @@ -0,0 +1,18 @@ +{ + "id": "price_1QfLmTRegular0001", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1792750000, + "currency": "usd", + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_QfLmTRouteOptimizer", + "recurring": null, + "tax_behavior": "exclusive", + "type": "one_time", + "unit_amount": 4900, + "unit_amount_decimal": "4900" +} diff --git a/tests/Fixtures/stripe/product.json b/tests/Fixtures/stripe/product.json new file mode 100644 index 0000000..b6747ea --- /dev/null +++ b/tests/Fixtures/stripe/product.json @@ -0,0 +1,16 @@ +{ + "id": "prod_QfLmTRouteOptimizer", + "object": "product", + "active": true, + "created": 1792750000, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "metadata": {}, + "name": "Route Optimizer", + "tax_code": null, + "type": "service", + "updated": 1792750000, + "url": null +} diff --git a/tests/Helpers/commerce.php b/tests/Helpers/commerce.php new file mode 100644 index 0000000..7299051 --- /dev/null +++ b/tests/Helpers/commerce.php @@ -0,0 +1,50 @@ +create(); + $publisher = Organization::factory()->create([ + 'stripe_connect_id' => 'acct_1QfLmTPublisher01', + 'payout_status' => Organization::PAYOUT_ENABLED, + 'meta' => ['stripe' => ['charges_enabled' => true, 'payouts_enabled' => true, 'details_submitted' => true, 'transfers' => 'active', 'card_payments' => 'active']], + ]); + $owner->organizations()->attach($publisher->uuid, ['role' => Membership::ROLE_OWNER]); + + $listing = Extension::factory()->published()->paid($amount)->withPackages()->for($publisher)->state($extension)->create(); + $listing->price->forceFill(array_merge([ + 'stripe_product_id' => 'prod_QfLmTRouteOptimizer', + 'stripe_price_id' => 'price_1QfLmTRegular0001', + ], $price))->save(); + + return [$owner, $publisher, $listing->fresh(['price', 'organization'])]; +} + +/** + * A buyer account and organization with the given role. Returns [account, organization]. + */ +function buyerOrganization(string $role = Membership::ROLE_OWNER): array +{ + $account = Account::factory()->create(); + $organization = Organization::factory()->create(); + $account->organizations()->attach($organization->uuid, ['role' => $role]); + + return [$account, $organization]; +} + +/** + * A registered instance of the organization. + */ +function buyerInstance(Organization $organization, string $kind = Instance::KIND_SELF_HOSTED): Instance +{ + return Instance::factory()->create(['organization_uuid' => $organization->uuid, 'kind' => $kind]); +} diff --git a/tests/Helpers/stripe.php b/tests/Helpers/stripe.php new file mode 100644 index 0000000..f64defd --- /dev/null +++ b/tests/Helpers/stripe.php @@ -0,0 +1,176 @@ + */ + protected array $routes = []; + + /** @var list */ + public array $requests = []; + + /** + * Answer `METHOD /v1/path` (a regex on the path, anchored) with an object or a callable that + * receives the params and returns one. Later routes win, so a test can override a default. + */ + public function on(string $method, string $path, array|callable $response, int $status = 200, ?int $times = null): static + { + array_unshift($this->routes, ['method' => strtolower($method), 'pattern' => '#^'.$path.'$#', 'response' => $response, 'status' => $status, 'times' => $times]); + + return $this; + } + + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + $path = (string) parse_url($absUrl, PHP_URL_PATH); + $this->requests[] = ['method' => $method, 'path' => $path, 'params' => (array) $params, 'headers' => $headers]; + + foreach ($this->routes as $index => $route) { + if ($route['method'] !== $method || ! preg_match($route['pattern'], $path)) { + continue; + } + if ($route['times'] !== null) { + if ($route['times'] === 0) { + continue; + } + $this->routes[$index]['times']--; + } + + $body = is_callable($route['response']) ? ($route['response'])((array) $params, $path) : $route['response']; + + return [json_encode($body), $route['status'], ['request-id' => 'req_test']]; + } + + throw new RuntimeException("Unexpected Stripe request: {$method} {$path}"); + } + + /** + * Recorded requests, optionally for one method and path regex. + * + * @return list + */ + public function sent(?string $method = null, ?string $path = null): array + { + return array_values(array_filter($this->requests, fn ($request) => ($method === null || $request['method'] === strtolower($method)) + && ($path === null || preg_match('#^'.$path.'$#', $request['path'])))); + } + + /** + * The value of a header on a recorded request (`Stripe-Account`, `Idempotency-Key`). + */ + public static function header(array $request, string $name): ?string + { + foreach ($request['headers'] as $line) { + [$key, $value] = array_map('trim', explode(':', $line, 2)) + [1 => null]; + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } +} + +const STRIPE_TEST_WEBHOOK_SECRET = 'whsec_test_platform'; + +/** + * Configure Stripe for a test and route stripe-php to a fresh fake. Reset in Pest.php. + */ +function fakeStripe(): FakeStripeHttpClient +{ + config([ + 'services.stripe.key' => 'pk_test_registry', + 'services.stripe.secret' => 'sk_test_registry', + 'services.stripe.webhook_secrets' => [STRIPE_TEST_WEBHOOK_SECRET, 'whsec_test_connect'], + ]); + + $fake = new FakeStripeHttpClient; + $id = fn (string $prefix) => $prefix.'_test'.bin2hex(random_bytes(6)); + + // Defaults; a test overrides any of them with a later `on()`. + $fake->on('post', '/v1/accounts', fn ($params) => stripeFixture('account_pending', ['id' => $id('acct'), 'email' => $params['email'] ?? null])) + ->on('get', '/v1/accounts/(acct_\w+)', fn ($params, $path) => stripeFixture('account', ['id' => basename($path)])) + ->on('post', '/v1/account_sessions', fn ($params) => stripeFixture('account_session', ['account' => $params['account']])) + ->on('post', '/v1/products', fn ($params) => stripeFixture('product', ['id' => $id('prod'), 'name' => $params['name'] ?? 'Extension', 'tax_code' => $params['tax_code'] ?? null])) + ->on('post', '/v1/products/(prod_\w+)', fn ($params, $path) => stripeFixture('product', ['id' => basename($path)] + $params)) + ->on('post', '/v1/prices', fn ($params) => stripeFixture('price', [ + 'id' => $id('price'), + 'product' => $params['product'], + 'unit_amount' => (int) $params['unit_amount'], + 'unit_amount_decimal' => (string) $params['unit_amount'], + 'currency' => $params['currency'], + 'metadata' => $params['metadata'] ?? [], + ])) + ->on('post', '/v1/prices/(price_\w+)', fn ($params, $path) => stripeFixture('price', ['id' => basename($path), 'active' => (bool) ($params['active'] ?? true)])) + ->on('post', '/v1/checkout/sessions', function ($params) use ($id) { + $session = $id('cs'); + + return stripeFixture('checkout_session', [ + 'id' => $session, + 'status' => 'open', + 'payment_status' => 'unpaid', + 'payment_intent' => null, + 'amount_total' => null, + 'customer_details' => null, + 'customer_email' => $params['customer_email'] ?? null, + 'metadata' => $params['metadata'] ?? [], + 'client_reference_id' => $params['client_reference_id'] ?? null, + 'success_url' => $params['success_url'], + 'cancel_url' => $params['cancel_url'], + 'expires_at' => $params['expires_at'] ?? null, + 'url' => 'https://checkout.stripe.com/c/pay/'.$session, + ]); + }) + ->on('get', '/v1/payment_intents/(pi_\w+)', fn ($params, $path) => stripeFixture('payment_intent', ['id' => basename($path)])) + ->on('get', '/v1/balance', stripeFixture('balance')) + ->on('get', '/v1/payouts', stripeFixture('payouts')); + + ApiRequestor::setHttpClient($fake); + app()->instance(FakeStripeHttpClient::class, $fake); + + return $fake; +} + +function stripe(): FakeStripeHttpClient +{ + return app(FakeStripeHttpClient::class); +} + +/** + * A Stripe API object from tests/Fixtures/stripe/{name}.json with overrides merged in. + */ +function stripeFixture(string $name, array $overrides = []): array +{ + $fixture = json_decode(file_get_contents(base_path("tests/Fixtures/stripe/{$name}.json")), true, flags: JSON_THROW_ON_ERROR); + + return array_replace_recursive($fixture, $overrides); +} + +/** + * Post a signed event to the webhook endpoint, as Stripe does. + */ +function stripeWebhook(string $type, array $object, array $event = [], ?string $secret = STRIPE_TEST_WEBHOOK_SECRET, ?int $timestamp = null): TestResponse +{ + $payload = json_encode(array_replace(stripeFixture('event', [ + 'id' => 'evt_'.bin2hex(random_bytes(8)), + 'type' => $type, + 'created' => time(), + ]), ['data' => ['object' => $object]], $event)); + + $timestamp ??= time(); + $signature = 't='.$timestamp.',v1='.hash_hmac('sha256', $timestamp.'.'.$payload, (string) $secret); + + return test()->call('POST', '/v1/webhooks/stripe', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_STRIPE_SIGNATURE' => $signature, + ], $payload); +} diff --git a/tests/Pest.php b/tests/Pest.php index 76a82bf..2fa619a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -15,6 +15,8 @@ require_once __DIR__.'/Helpers/releases.php'; require_once __DIR__.'/Helpers/website.php'; require_once __DIR__.'/Helpers/session.php'; +require_once __DIR__.'/Helpers/stripe.php'; +require_once __DIR__.'/Helpers/commerce.php'; pest()->extend(TestCase::class) ->use(RefreshDatabase::class) @@ -27,6 +29,11 @@ config(['session.driver' => 'database']); })->in('Feature/Auth', 'Feature/Organizations'); +// Commerce tests answer Stripe from fixtures; nothing reaches the network. +pest()->beforeEach(function () { + fakeStripe(); +})->in('Feature/Commerce'); + // Package and release tests run against a fake registry disk. pest()->beforeEach(function () { config(['registry.storage.disk' => 'packages']); diff --git a/tests/TestCase.php b/tests/TestCase.php index b7a314a..a7e5836 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,6 +3,7 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Stripe\ApiRequestor; abstract class TestCase extends BaseTestCase { @@ -14,6 +15,14 @@ protected function setUp(): void $this->withoutVite(); } + protected function tearDown(): void + { + // A test that faked Stripe (tests/Helpers/stripe.php) must not leak it into the next. + ApiRequestor::setHttpClient(null); + + parent::tearDown(); + } + /** * Each simulated request resolves auth and its session from scratch, as separate HTTP * requests would (the session comes back only through its cookie and the store). From 2540ee2ac08a5911ed8c9e160b6b10bfcfd395d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 14:49:21 +0800 Subject: [PATCH 02/10] Add the Studio Payouts and account Billing pages, website checkout and sale pricing; document the instance checkout contract --- README.md | 140 +++++++- .../Controllers/Web/AccountPageController.php | 27 +- .../Controllers/Web/StudioPageController.php | 32 +- app/Http/Controllers/Web/WebController.php | 45 +++ app/Http/Middleware/HandleInertiaRequests.php | 4 + package-lock.json | 7 + package.json | 1 + .../js/components/account/AccountHeader.jsx | 3 +- .../commerce/CommercePages.test.jsx | 210 +++++++++++ .../js/components/commerce/ConnectEmbed.jsx | 74 ++++ .../js/components/studio/PricingSection.jsx | 46 ++- .../js/components/studio/StudioChrome.jsx | 3 +- resources/js/lib/statuses.js | 23 ++ resources/js/pages/Account/Billing.jsx | 235 ++++++++++++ resources/js/pages/Extension/Show.jsx | 69 +++- resources/js/pages/Studio/Insights.jsx | 19 +- resources/js/pages/Studio/Payouts.jsx | 338 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/Commerce/CommercePagesTest.php | 78 ++++ 19 files changed, 1327 insertions(+), 29 deletions(-) create mode 100644 resources/js/components/commerce/CommercePages.test.jsx create mode 100644 resources/js/components/commerce/ConnectEmbed.jsx create mode 100644 resources/js/pages/Account/Billing.jsx create mode 100644 resources/js/pages/Studio/Payouts.jsx create mode 100644 tests/Feature/Commerce/CommercePagesTest.php diff --git a/README.md b/README.md index d1a0493..0be4bc0 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Phase 1, Workstreams A to C, and Phase 2 Workstream E: | Releases pipeline: publisher API, upload, queued checks, state machine, D6 review policy, staff review API, publishing | done | | Website backend (Workstream F): cookie sessions, organizations and members, listing media and pricing, staff downloads and review items | done | | Website (Workstream F): Inertia (React) catalog, account, Studio and review console, light and dark | done | -| CLI v2 (G), Stripe (Phase 4) | later workstreams | +| Commerce (Phase 4, K to M): Stripe Connect Express, prices mirrored to Stripe, Checkout from instances and the website, webhooks, payouts and billing APIs and pages | done | +| CLI v2 (G), staff finance view | later workstreams | ## Stack @@ -133,7 +134,9 @@ a website page (unknown paths render the website's 404). | `/sign-in`, `/register`, `/verify`, `/forgot-password` | Account access | | `/auth/github`, `/auth/github/callback` | Sign in with GitHub (redirects; see below) | | `/account/profile`, `/account/organizations/{slug?}`, `/account/tokens` | Account | +| `/account/billing/{slug?}[?handoff=]` | Billing: purchases, receipts, licences and their instances; Finalizing after a website checkout | | `/studio`, `/studio/new`, `/studio/{slug}` (listing), `/studio/{slug}/releases[/{id}]`, `/studio/{slug}/review`, `/studio/{slug}/history`, `/studio/insights` | Publisher Studio | +| `/studio/payouts/{slug?}` | Payouts: Stripe onboarding and account management (Connect.js), balance, schedule, sales and CSV | | `/review`, `/review/publish`, `/review/releases/{id}` | Staff review console | ## API overview @@ -283,7 +286,7 @@ releases answer 404. | `POST`/`DELETE /v1/publisher/extensions/{slug}/icon` | Multipart `icon` (PNG, JPG or WebP, up to 1 MB) replaces the icon; DELETE removes it | | `POST /v1/publisher/extensions/{slug}/screenshots` | Multipart `screenshot` (PNG, JPG or WebP, up to 5 MB), optional `caption`; at most 8 | | `PATCH /v1/publisher/extensions/{slug}/screenshots` | `order`: every screenshot id once, cover first. `DELETE …/screenshots/{id}` removes one | -| `PUT /v1/publisher/extensions/{slug}/price` | `model` (`free`, `one_time`), `amount` (minor units), `currency`. Paid prices need the organization's Stripe Connect account (Phase 4); a price change sends the next release to review | +| `PUT /v1/publisher/extensions/{slug}/price` | `model` (`free`, `one_time`), `amount` (minor units), `currency`, optional `sale_amount` (below `amount`) and `on_sale`. Paid prices need the organization's Stripe account (see Commerce). Saving mirrors the price to Stripe first (a refused price is not saved, 502); a price change sends the next release to review | | `GET /v1/publisher/extensions/{slug}/releases` | Every release with its checks | | `POST /v1/publisher/extensions/{slug}/releases` | Multipart: `manifest` (extension.json), `engine` (npm tarball, gzipped) and/or `api` (Composer tar or tar.gz), optional `dist` (the `/` folder from ember-core's `fleetbase-build-engine-release`, tarred), optional `channel` (`stable`, `beta`; default from the manifest), `notes`, `submit` (submit as soon as the checks pass). Returns the release (201) | | `GET /v1/publisher/releases/{id}` | Files, checks, submission state, review policy outcome, the review timeline with reasons and items | @@ -376,6 +379,8 @@ checks are emailed too. | `POST /v1/admin/extensions/{slug}/unsuspend` | Optional `reason`. Restores the status saved at suspension | | `GET /v1/admin/releases/{id}/files/{engine,api,dist}` | Downloads an archive for review; recorded as `release.downloaded` | | `GET /v1/admin/review-items` | The review requirements R1 to R6 (`registry.review.items`); approve and reject accept only these codes | +| `GET /v1/admin/facilitator-fees` | The fee in effect (`current_rate_bps`), the configured default, and every scheduled fee, newest first | +| `POST /v1/admin/facilitator-fees` | `rate_bps` (0 to 5000; 1000 = 10%), optional `effective_from` (now or later, never earlier) and `note`. Adds a row; rows are never edited | ### Instance API (instance token) @@ -414,6 +419,137 @@ Install rules: version; an instance that does not report a version, or reports one that is not semver, is not constrained on that side; a range that does not parse never matches. +## Commerce (Stripe) + +Paid extensions are sold through Stripe with Connect Express accounts for publishers and hosted +Checkout for buyers. Every commerce route answers 503 until `STRIPE_SECRET` is set. + +**Setup.** Set `STRIPE_KEY` (publishable, for Connect.js on the website) and `STRIPE_SECRET`. +Create two webhook endpoints in the Stripe Dashboard, both pointing at +`https:///v1/webhooks/stripe`: + +- *Your account*: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.expired`, `charge.refunded`, `charge.dispute.created`. +- *Connected accounts*: `account.updated`. + +Put both signing secrets in `STRIPE_WEBHOOK_SECRET`, comma-separated. After `registry:import`, +run `php artisan registry:stripe-sync` once: it reads every imported Connect account's state and +mirrors every paid price that has no Stripe price yet (checkout also does this lazily). + +| Setting | Default | Meaning | +| --- | --- | --- | +| `REGISTRY_FACILITATOR_FEE_BPS` | `1000` | Fee before the first `facilitator_fees` row takes effect | +| `REGISTRY_LICENCE_MAX_ACTIVATIONS` | `5` | Seats (instances) on a paid licence (D4) | +| `REGISTRY_STRIPE_TAX` | `true` | Stripe Tax on Checkout, with the publisher's account liable | +| `REGISTRY_STRIPE_TAX_CODE`, `REGISTRY_STRIPE_TAX_BEHAVIOR` | none, `exclusive` | Product tax code; whether listing prices exclude tax | +| `REGISTRY_CHECKOUT_EXPIRES_MINUTES` | `60` | How long a Checkout Session stays open (30 to 1440) | +| `REGISTRY_CONNECT_COUNTRY` | none | Country for new Connect accounts; empty lets the publisher choose | +| `REGISTRY_COMMERCE_NOTIFY` | none | Comma-separated emails told about new disputes | + +**How money moves.** A Checkout Session is a destination charge: the buyer pays the platform, +`application_fee_amount` (the facilitator fee in effect when the session starts, on the price +before tax) stays with Fleetbase, and the rest is transferred to the publisher's account. With +Stripe Tax on, the charge is made `on_behalf_of` the publisher and the publisher's account is +liable for tax, so the tax collected reaches the account that files it. Platform-side Stripe +fees and losses fall on Fleetbase (`controller.fees.payer` and `losses.payments` are +`application`, as the console's accounts are today). + +**Prices.** One Stripe product per listing on the platform account, the regular price, and the +sale price as a second Stripe price; `on_sale` decides which one Checkout charges. Stripe prices +cannot change, so a new amount or currency creates a new price and archives the old one. + +### Organization routes (personal token or website session) + +| Method and path | Who | Purpose | +| --- | --- | --- | +| `GET /v1/organizations/{slug}/stripe[?refresh=1]` | members (refresh: owners, admins) | `connected`, `payout_status` (`pending`, `restricted`, `enabled`, `rejected`), `can_receive_payments`, `requirements_due`, country, default currency | +| `POST /v1/organizations/{slug}/stripe/account` | owners, admins | Create the Express account (once; 201, then 200) | +| `POST /v1/organizations/{slug}/stripe/account-session` | owners, admins | `client_secret` and `publishable_key` for Connect.js: onboarding, account management, notification banner, payouts, tax settings and registrations | +| `GET /v1/organizations/{slug}/payouts` | owners, admins, billing | Connect status, `balance` (`available`, `pending`, `in_transit` per currency), payout `schedule`, the last 10 payouts | +| `GET /v1/organizations/{slug}/payouts/transactions?from=&to=&status=&extension=&page=` | owners, admins, billing | Sales of the organization's extensions: `gross`, `fee`, `net`, `tax`, `refunded`, `status`, `customer`; `totals` per currency | +| `GET /v1/organizations/{slug}/payouts/transactions.csv` | owners, admins, billing | The same sales (same filters) as CSV | +| `GET /v1/organizations/{slug}/billing/purchases` | owners, admins, billing | What the organization bought: amount, tax, who bought it, status, Stripe receipt URL, licence | +| `GET /v1/organizations/{slug}/billing/licences` | owners, admins, billing | Licences with seat use and the instances activated on each | +| `POST /v1/purchases/checkout` | owners, admins, billing of the buyer | Website checkout: `extension`, `organization`. Returns a hand-off (below); Stripe returns the buyer to `/account/billing/{slug}?handoff=` | +| `GET /v1/purchases/{handoff}` | members of the buyer | The hand-off | + +### Webhooks + +`POST /v1/webhooks/stripe` checks the `Stripe-Signature` header against each secret (400 when +none matches or the timestamp is more than 5 minutes off) and stores each event id once in +`events.external_id`, in the same transaction as its effects: a redelivery answers +`{"status": "duplicate"}` and a failure answers 500 with nothing written, so Stripe's retry is +safe. + +| Event | Effect | +| --- | --- | +| `checkout.session.completed` (paid), `checkout.session.async_payment_succeeded` | Purchase (`amount_total`, `fee_amount` from the PaymentIntent's application fee, `net_amount` = total − fee, tax and subtotal in `meta`, receipt URL from the charge), a `paid` licence with 5 seats, the hand-off `complete`, a receipt email to the buyer | +| `checkout.session.expired` | The hand-off `expired` | +| `charge.refunded` | Full refund: purchase `refunded`, licence `revoked` (new installs and updates stop; running instances keep their seat). Partial refund: `meta.amount_refunded` only | +| `charge.dispute.created` | Purchase `disputed` with the dispute in `meta` (licence stays active), email to `REGISTRY_COMMERCE_NOTIFY` | +| `account.updated` | The organization's `payout_status` and `meta.stripe` | + +### Instance checkout (contract for registry-bridge) + +A console buys a paid extension for its instance's organization in four steps. All calls use the +instance token (`Authorization: Bearer flbi_…`). + +1. **Start.** `POST /v1/instances/me/purchases/checkout` + + ```json + { + "extension": "route-optimizer", + "return_url": "https://console.example.com/extensions/purchased", + "cancel_url": "https://console.example.com/extensions", + "email": "ops@example.com" + } + ``` + + `extension` is a slug, `ext_` id or package name. `return_url` (http or https) is where Stripe + sends the operator after paying; `cancel_url` defaults to it. `email` prefills Checkout and is + optional. Answers `201`: + + ```json + { + "handoff": "chk_x1y2z3a4b5", + "status": "open", + "source": "instance", + "extension": { "id": "ext_…", "slug": "route-optimizer", "name": "Route Optimizer" }, + "organization": { "id": "org_…", "slug": "globex", "name": "Globex" }, + "amount": 4900, + "currency": "USD", + "url": "https://checkout.stripe.com/c/pay/cs_…", + "expires_at": "2026-10-24T13:00:00.000000Z", + "completed_at": null, + "purchase": null, + "created_at": "2026-10-24T12:00:00.000000Z" + } + ``` + + `amount` is the price before tax (the sale price while a sale is on). Refusals use the usual + `{"errors": [...]}` envelope: 404 unknown extension; 409 when the organization publishes the + extension or already holds a paid licence for it; 422 when it is not published, is free, does + not match the instance's hosting kind, or the publisher cannot take payments yet; 503 when the + registry has no Stripe configuration; 502 when Stripe refuses. + +2. **Redirect** the operator's browser to `url` (a full-page redirect; Checkout cannot be framed). + +3. **Return.** Stripe sends the operator to `return_url` with `handoff=` appended + (`…/purchased?handoff=chk_x1y2z3a4b5`), or to `cancel_url` with `handoff` and `cancelled=1`. + The return proves nothing about payment. + +4. **Poll** `GET /v1/instances/me/purchases/{handoff}` (any checkout of the instance's + organization) every 2 seconds, showing *Finalizing*, until `status` is no longer `open`: + + - `complete`: `purchase` is set, with `status`, `amount_total` (with tax), `amount_tax`, + `receipt_url` and `licence` (`kind: paid`, `max_activations`). Install as usual; the install + claims a seat. + - `expired`: the session ran out (or `expires_at` passed) without payment. Offer to start again. + + The webhook usually lands within seconds. If it has not after about a minute, tell the + operator the purchase will appear once Stripe confirms it; the licence arrives by webhook even + if the console tab is closed, and `GET /v1/instances/me` lists it. + ## Importing from Fleetbase Cloud `registry:import` copies the cloud registry-bridge tables into the service, following the diff --git a/app/Http/Controllers/Web/AccountPageController.php b/app/Http/Controllers/Web/AccountPageController.php index 34152e3..db5b3dd 100644 --- a/app/Http/Controllers/Web/AccountPageController.php +++ b/app/Http/Controllers/Web/AccountPageController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Account\TokenController; +use App\Http\Controllers\Organization\BillingController; use App\Http\Controllers\Organization\OrganizationController; +use App\Http\Controllers\Purchase\CheckoutController; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -12,7 +14,7 @@ /** * Sign in, create account, verify, forgot password; then profile, organizations and tokens for the signed-in - * account. Sign-in itself is POST /v1/auth/session from the page. + * account, and its organizations' billing. Sign-in itself is POST /v1/auth/session from the page. */ class AccountPageController extends WebController { @@ -83,6 +85,29 @@ public function organizations(Request $request, ?string $slug = null): Response ]); } + /** + * GET /account/billing[/{slug}][?handoff=] — what the organization bought, its licences with + * their instances, and receipts. Stripe returns website buyers here with the hand-off, which + * the page polls until the purchase lands. + */ + public function billing(Request $request, ?string $slug = null): Response + { + [$organizations, $organization] = $this->billingOrganizations($request, $slug); + $checkout = null; + + if ($organization && $request->filled('handoff')) { + [$checkout] = $this->tryApi($request, '/v1/purchases/'.$request->query('handoff'), [], CheckoutController::class, 'show', ['handoff' => (string) $request->query('handoff')]); + } + + return Inertia::render('Account/Billing', [ + 'organizations' => $organizations, + 'organization' => $organization, + 'purchases' => $organization ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/purchases", [], BillingController::class, 'purchases', ['slug' => $organization['slug']]) : [], + 'licences' => $organization ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/licences", [], BillingController::class, 'licences', ['slug' => $organization['slug']]) : [], + 'checkout' => $checkout, + ]); + } + public function tokens(Request $request): Response { return Inertia::render('Account/Tokens', [ diff --git a/app/Http/Controllers/Web/StudioPageController.php b/app/Http/Controllers/Web/StudioPageController.php index 53fdd86..40e155b 100644 --- a/app/Http/Controllers/Web/StudioPageController.php +++ b/app/Http/Controllers/Web/StudioPageController.php @@ -2,7 +2,10 @@ namespace App\Http\Controllers\Web; +use App\Commerce\Stripe; use App\Http\Controllers\Catalog\CatalogController; +use App\Http\Controllers\Organization\PayoutController; +use App\Http\Controllers\Organization\StripeConnectController; use App\Http\Controllers\Publisher\ExtensionController; use App\Http\Controllers\Publisher\ReleaseController; use App\Models\Release; @@ -12,7 +15,7 @@ /** * Publisher Studio: my extensions, create, the listing editor, releases with live checks, - * review status, history and the coming-soon insights. + * review status, history, payouts and the coming-soon insights. */ class StudioPageController extends WebController { @@ -115,6 +118,33 @@ public function insights(): Response return Inertia::render('Studio/Insights'); } + /** + * GET /studio/payouts[/{slug}] — an organization's Stripe Connect account (onboarding and + * management through Connect.js), balance, payout schedule and sales. + */ + public function payouts(Request $request, ?string $slug = null): Response + { + [$organizations, $organization] = $this->billingOrganizations($request, $slug); + $payouts = $error = $transactions = null; + + if ($organization) { + $slug = $organization['slug']; + [$payouts, $error] = $this->tryApi($request, "/v1/organizations/{$slug}/payouts", [], PayoutController::class, 'summary', ['slug' => $slug]); + $payouts ??= ['connect' => $this->api($request, "/v1/organizations/{$slug}/stripe", [], StripeConnectController::class, 'show', ['slug' => $slug])]; + $transactions = $this->api($request, "/v1/organizations/{$slug}/payouts/transactions", $this->only($request, ['page', 'from', 'to', 'status']), PayoutController::class, 'transactions', ['slug' => $slug]); + } + + return Inertia::render('Studio/Payouts', [ + 'organizations' => $organizations, + 'organization' => $organization, + 'payouts' => $payouts, + 'transactions' => $transactions, + 'filters' => $this->only($request, ['from', 'to', 'status']), + 'error' => $error, + 'stripe' => ['configured' => Stripe::configured(), 'publishable_key' => config('services.stripe.key')], + ]); + } + /** * The listing (as its publisher sees it) and every release with its checks, newest first. * diff --git a/app/Http/Controllers/Web/WebController.php b/app/Http/Controllers/Web/WebController.php index 364ad13..68abcec 100644 --- a/app/Http/Controllers/Web/WebController.php +++ b/app/Http/Controllers/Web/WebController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Http\Controllers\Organization\OrganizationController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Stripe\Exception\ApiErrorException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; /** * Base for the website's Inertia page controllers. @@ -52,6 +55,48 @@ protected function api(Request $request, string $uri, array $query, string $cont } } + /** + * The caller's organizations where they see money (owners, admins, billing members), and the + * selected one: `$slug`, else the first that is not personal, else the first. + * + * @return array{0: list, 1: ?array} + */ + protected function billingOrganizations(Request $request, ?string $slug): array + { + $organizations = collect($this->api($request, '/v1/organizations', [], OrganizationController::class, 'index')) + ->filter(fn (array $organization) => in_array($organization['role'] ?? null, OrganizationController::BILLING_ROLES, true)) + ->values(); + + $selected = $slug !== null + ? $organizations->firstWhere('slug', $slug) + : ($organizations->firstWhere('is_personal', false) ?? $organizations->first()); + + if ($slug !== null && $selected === null) { + abort(404); + } + + return [$organizations->all(), $selected]; + } + + /** + * Like api(), but a failure (Stripe not configured or unreachable) answers null and the + * reason instead of an error page. + * + * @return array{0: ?array, 1: ?string} + */ + protected function tryApi(Request $request, string $uri, array $query, string $controller, string $method, array $parameters = []): array + { + try { + return [$this->api($request, $uri, $query, $controller, $method, $parameters), null]; + } catch (HttpExceptionInterface $e) { + return [null, $e->getMessage()]; + } catch (ApiErrorException $e) { + report($e); + + return [null, 'Stripe could not be reached: '.$e->getMessage()]; + } + } + /** * Only the query parameters a listing page passes through to the API. * diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e7fa37b..0088450 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,9 +2,11 @@ namespace App\Http\Middleware; +use App\Commerce\Stripe; use App\Http\Controllers\Auth\GithubSignInController; use App\Http\Resources\AccountResource; use App\Models\Account; +use App\Models\FacilitatorFee; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Inertia\Middleware; @@ -45,6 +47,8 @@ public function share(Request $request): array 'host' => parse_url((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), PHP_URL_HOST) ?: $request->getHost(), 'url' => rtrim((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), '/'), ], + // Fleetbase's facilitator fee now (the Studio price editor shows the publisher's share). + 'commerce' => fn () => ['fee_bps' => FacilitatorFee::rateAt(now()), 'enabled' => Stripe::configured()], 'console' => $this->consoleVersion($request), 'flash' => fn () => $request->session()->get('flash'), ]; diff --git a/package-lock.json b/package-lock.json index dacb2b0..90cfe0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@inertiajs/react": "^3.9.1", + "@stripe/connect-js": "3.4.6", "axios": "^1.20.0", "dompurify": "^3.4.17", "react": "^19.3.1", @@ -1063,6 +1064,12 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true }, + "node_modules/@stripe/connect-js": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@stripe/connect-js/-/connect-js-3.4.6.tgz", + "integrity": "sha512-fuC/U/ONDBMKwTBxKAnEBQH+m0x+Tze62/bV1ZDLT7oRnsJgddEnRi7FzdwIxz9pmFZhOJpSaikLIBw9g/1o1w==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", diff --git a/package.json b/package.json index f30f52f..4be37ba 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@inertiajs/react": "^3.9.1", + "@stripe/connect-js": "3.4.6", "axios": "^1.20.0", "dompurify": "^3.4.17", "react": "^19.3.1", diff --git a/resources/js/components/account/AccountHeader.jsx b/resources/js/components/account/AccountHeader.jsx index 8085169..e3d30aa 100644 --- a/resources/js/components/account/AccountHeader.jsx +++ b/resources/js/components/account/AccountHeader.jsx @@ -4,11 +4,12 @@ import './account.css'; const SECTIONS = [ { key: 'organizations', label: 'Organizations', href: '/account/organizations' }, + { key: 'billing', label: 'Billing', href: '/account/billing' }, { key: 'profile', label: 'Profile', href: '/account/profile' }, { key: 'tokens', label: 'Tokens', href: '/account/tokens' }, ]; -/** The account pages' heading with the Organizations · Profile · Tokens switcher. */ +/** The account pages' heading with the Organizations · Billing · Profile · Tokens switcher. */ export default function AccountHeader({ title = 'Account', current, description, actions }) { const account = usePage().props.auth?.account; const subtitle = diff --git a/resources/js/components/commerce/CommercePages.test.jsx b/resources/js/components/commerce/CommercePages.test.jsx new file mode 100644 index 0000000..2b74a83 --- /dev/null +++ b/resources/js/components/commerce/CommercePages.test.jsx @@ -0,0 +1,210 @@ +import { act, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { router } from '@inertiajs/react'; +import Payouts, { scheduleLabel } from '../../pages/Studio/Payouts'; +import Billing from '../../pages/Account/Billing'; +import { BuyPanel } from '../../pages/Extension/Show'; +import { api, ApiError } from '../../lib/api'; + +let page; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => page, + router: { reload: vi.fn(), get: vi.fn() }, + Link: ({ href, children, ...rest }) => ( + + {children} + + ), + Head: () => null, +})); + +vi.mock('../../layouts/SiteLayout', async () => { + const { ToastProvider } = await import('../Toasts'); + return { default: ({ children }) => {children} }; +}); + +vi.mock('../../lib/api', async () => { + const actual = await vi.importActual('../../lib/api'); + return { ApiError: actual.ApiError, api: { get: vi.fn(), post: vi.fn() } }; +}); + +// Connect.js loads from Stripe in the browser; the pages only need to know which component mounts. +vi.mock('./ConnectEmbed', () => ({ default: ({ component }) =>
})); + +const acme = { id: 'org_acme', slug: 'acme', name: 'Acme Fleet Tools', role: 'owner', is_personal: false }; + +beforeEach(() => { + vi.clearAllMocks(); + page = { url: '/', props: { auth: { account: null }, commerce: { fee_bps: 1000, enabled: true } } }; +}); + +afterEach(() => vi.useRealTimers()); + +describe('Payouts', () => { + const stripe = { configured: true, publishable_key: 'pk_test' }; + const transactions = { + data: [ + { id: 'pur_1', extension: { name: 'Route Optimizer' }, customer: { name: 'Globex' }, gross: 5292, fee: 490, net: 4802, currency: 'USD', status: 'paid', created_at: '2026-10-20T12:00:00Z' }, + { id: 'pur_2', extension: { name: 'Route Optimizer' }, customer: { name: 'Initech' }, gross: 4900, fee: 490, net: 4410, currency: 'USD', status: 'refunded', created_at: '2026-10-19T12:00:00Z' }, + ], + totals: [{ currency: 'USD', count: 2, gross: 10192, fee: 980, net: 9212 }], + meta: { current_page: 1, last_page: 1, total: 2 }, + }; + + it('describes payout schedules', () => { + expect(scheduleLabel({ interval: 'weekly', weekly_anchor: 'friday', delay_days: 2 })).toBe('Weekly on Friday, 2 days after payment'); + expect(scheduleLabel({ interval: 'manual' })).toBe('Manual'); + expect(scheduleLabel(null)).toBeNull(); + }); + + it('lets an owner set up payouts and reloads the account', async () => { + const user = userEvent.setup(); + api.post.mockResolvedValue({ connected: true }); + render(); + + expect(screen.getByText('Not set up')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Set up payouts' })); + + expect(api.post).toHaveBeenCalledWith('organizations/acme/stripe/account'); + expect(router.reload).toHaveBeenCalledWith({ only: ['payouts'] }); + expect(screen.getByText(/No sales yet/)).toBeInTheDocument(); + }); + + it('shows onboarding until Stripe enables payouts, and only to owners and admins', () => { + const connect = { connected: true, payout_status: 'restricted', requirements_due: ['external_account'] }; + const { rerender } = render(); + expect(screen.getByTestId('connect-account-onboarding')).toBeInTheDocument(); + expect(screen.getByText('Stripe still needs 1 item.')).toBeInTheDocument(); + + const billing = { ...acme, role: 'billing' }; + rerender(); + expect(screen.queryByTestId('connect-account-onboarding')).not.toBeInTheDocument(); + expect(screen.getByText('An owner or admin of Acme Fleet Tools finishes this.')).toBeInTheDocument(); + }); + + it('shows the balance, schedule and sales with fee, net and totals', async () => { + const user = userEvent.setup(); + const payouts = { + connect: { connected: true, payout_status: 'enabled', default_currency: 'USD' }, + balance: { available: [{ amount: 12880, currency: 'USD' }], pending: [{ amount: 4410, currency: 'USD' }], in_transit: [] }, + schedule: { interval: 'daily', delay_days: 2 }, + }; + render(); + + expect(screen.getByText('Payouts on')).toBeInTheDocument(); + const balance = screen.getByRole('region', { name: 'Balance' }); + expect(within(balance).getByText('$128.80')).toBeInTheDocument(); + expect(within(balance).getByText('$44.10')).toBeInTheDocument(); + expect(within(balance).getByText('Daily, 2 days after payment')).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(within(rows[1]).getByText('Globex')).toBeInTheDocument(); + expect(within(rows[1]).getByText('−$4.90')).toBeInTheDocument(); + expect(within(rows[1]).getByText('$48.02')).toBeInTheDocument(); + expect(within(rows[2]).getByText('Refunded')).toBeInTheDocument(); + expect(screen.getByText('Total, 2 sales (USD)')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'CSV' })).toHaveAttribute('href', '/v1/organizations/acme/payouts/transactions.csv?status=paid'); + + await user.click(screen.getByRole('button', { name: 'Bank account, tax and payout schedule' })); + expect(screen.getByTestId('connect-account-management')).toBeInTheDocument(); + expect(screen.getByTestId('connect-tax-registrations')).toBeInTheDocument(); + }); + + it('says when payments are not set up on the registry', () => { + render(); + expect(screen.getByText(/Payments are not set up on this registry yet/)).toBeInTheDocument(); + }); +}); + +describe('Billing', () => { + const purchase = { + id: 'pur_1', + extension: { slug: 'route-optimizer', name: 'Route Optimizer' }, + bought_by: { name: 'Hank Scorpio' }, + amount_total: 5292, + amount_tax: 392, + currency: 'USD', + status: 'paid', + receipt_url: 'https://pay.stripe.com/receipts/x', + created_at: '2026-10-20T12:00:00Z', + }; + const licence = { + id: 'lic_1', + extension: { name: 'Route Optimizer' }, + kind: 'paid', + status: 'active', + max_activations: 5, + seats_used: 1, + activations: [{ id: 'a1', instance: { name: 'Depot', host: 'fleet.globex.example' }, release: '1.2.0', activated_at: '2026-10-21T08:00:00Z' }], + }; + + it('lists purchases with receipts and licences with their instances', () => { + render(); + + expect(screen.getByRole('link', { name: /Receipt/ })).toHaveAttribute('href', 'https://pay.stripe.com/receipts/x'); + expect(screen.getByText('Hank Scorpio')).toBeInTheDocument(); + expect(screen.getByText(/incl\. \$3\.92 tax/)).toBeInTheDocument(); + expect(screen.getByText('1 of 5 instances')).toBeInTheDocument(); + expect(screen.getByText(/Depot/)).toBeInTheDocument(); + expect(screen.getByText(/v1\.2\.0/)).toBeInTheDocument(); + }); + + it('shows Finalizing and polls the hand-off until the purchase lands', async () => { + vi.useFakeTimers(); + const checkout = { handoff: 'chk_1', status: 'open', extension: { slug: 'route-optimizer', name: 'Route Optimizer' } }; + api.get.mockResolvedValueOnce({ ...checkout }).mockResolvedValueOnce({ ...checkout, status: 'complete' }); + render(); + + expect(screen.getByText('Finalizing your purchase of Route Optimizer…')).toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect(api.get).toHaveBeenCalledWith('purchases/chk_1'); + expect(router.reload).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect(screen.getByText(/Acme Fleet Tools now holds a licence for Route Optimizer/)).toBeInTheDocument(); + expect(router.reload).toHaveBeenCalledWith({ only: ['purchases', 'licences'] }); + }); +}); + +describe('Buy on the website', () => { + const extension = { slug: 'route-optimizer', publisher: { id: 'org_pub' }, price: { model: 'one_time', amount: 4900, effective_amount: 2900, currency: 'USD', on_sale: true } }; + + it('asks visitors to sign in', () => { + render(); + expect(screen.getByRole('link', { name: 'Sign in to buy' })).toHaveAttribute('href', '/sign-in'); + }); + + it('buys for one of the organizations the account may buy for and goes to Stripe', async () => { + const user = userEvent.setup(); + const assign = vi.fn(); + vi.stubGlobal('location', { ...window.location, assign }); + api.post.mockResolvedValue({ url: 'https://checkout.stripe.com/c/pay/cs_test_1' }); + const account = { + organizations: [ + { id: 'org_me', slug: 'me', name: 'Me', role: 'owner', is_personal: true }, + { id: 'org_globex', slug: 'globex', name: 'Globex', role: 'billing', is_personal: false }, + { id: 'org_other', slug: 'other', name: 'Other', role: 'publisher', is_personal: false }, + ], + }; + render(); + + const select = screen.getByLabelText('Buy for'); + expect(within(select).getAllByRole('option').map((option) => option.textContent)).toEqual(['Me', 'Globex']); + expect(select).toHaveValue('globex'); + await user.click(screen.getByRole('button', { name: 'Buy for $29' })); + + expect(api.post).toHaveBeenCalledWith('purchases/checkout', { extension: 'route-optimizer', organization: 'globex' }); + expect(assign).toHaveBeenCalledWith('https://checkout.stripe.com/c/pay/cs_test_1'); + vi.unstubAllGlobals(); + }); + + it('shows why a checkout was refused', async () => { + const user = userEvent.setup(); + api.post.mockRejectedValue(new ApiError(409, { errors: ['Globex already holds a licence for Route Optimizer. Ask Fleetbase staff for more seats.'] })); + render(); + + await user.click(screen.getByRole('button', { name: 'Buy for $29' })); + expect(screen.getByRole('alert')).toHaveTextContent('Globex already holds a licence'); + }); +}); diff --git a/resources/js/components/commerce/ConnectEmbed.jsx b/resources/js/components/commerce/ConnectEmbed.jsx new file mode 100644 index 0000000..0f7810e --- /dev/null +++ b/resources/js/components/commerce/ConnectEmbed.jsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../../lib/api'; +import { Skeleton } from '../Ui'; + +/** Connect.js appearance from the site's theme tokens, read when the component mounts. */ +function appearance() { + const style = getComputedStyle(document.documentElement); + const token = (name, fallback) => style.getPropertyValue(name).trim() || fallback; + return { + overlays: 'dialog', + variables: { + fontFamily: token('--font', 'system-ui'), + colorPrimary: token('--accent', '#1c6cc7'), + colorText: token('--ink', '#111'), + colorBackground: token('--surface', '#fff'), + colorBorder: token('--line', '#ddd'), + borderRadius: '10px', + }, + }; +} + +/** + * One Stripe Connect embedded component (`account-onboarding`, `account-management`, `payouts`, + * `notification-banner`, …) for an organization's connected account. Connect.js loads in the browser + * only; each mount asks the service for an Account Session (`POST …/stripe/account-session`). + */ +export default function ConnectEmbed({ organization, publishableKey, component, onExit, label }) { + const host = useRef(null); + const exit = useRef(onExit); + const [state, setState] = useState('loading'); + exit.current = onExit; + + useEffect(() => { + let cancelled = false; + let element = null; + + (async () => { + try { + const { loadConnectAndInitialize } = await import('@stripe/connect-js'); + if (cancelled) { + return; + } + const instance = loadConnectAndInitialize({ + publishableKey, + fetchClientSecret: async () => (await api.post(`organizations/${organization}/stripe/account-session`)).client_secret, + appearance: appearance(), + }); + element = instance.create(component); + if (component === 'account-onboarding') { + element.setOnExit?.(() => exit.current?.()); + } + host.current?.replaceChildren(element); + setState('ready'); + } catch { + if (!cancelled) { + setState('failed'); + } + } + })(); + + return () => { + cancelled = true; + element?.remove(); + }; + }, [organization, publishableKey, component]); + + return ( +
+ {state === 'loading' && } + {state === 'failed' &&

Stripe could not be loaded. Check your connection and reload the page.

} +
+
+ ); +} diff --git a/resources/js/components/studio/PricingSection.jsx b/resources/js/components/studio/PricingSection.jsx index 12d80fd..2bd9bc9 100644 --- a/resources/js/components/studio/PricingSection.jsx +++ b/resources/js/components/studio/PricingSection.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { router } from '@inertiajs/react'; +import { Link, router, usePage } from '@inertiajs/react'; import { Field, Note, Segmented } from '../Ui'; import { useToast } from '../Toasts'; import { api } from '../../lib/api'; @@ -7,7 +7,7 @@ import { money } from '../../lib/format'; const CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'SGD']; -/** Fleetbase's facilitator fee on a sale, as a fraction. */ +/** Fleetbase's facilitator fee on a sale, as a fraction, when the page does not say. */ export const FEE = 0.1; /** "29.50" → 2950 minor units, or null when it is not a positive amount. */ @@ -21,27 +21,39 @@ export function toMinor(value) { } /** - * Pricing: Free or a one-time licence. Saved on its own (`PUT …/price`). Paid pricing needs the - * organization's Stripe Connect account; the service answers 422 until then. + * Pricing: Free or a one-time licence, with an optional sale price. Saved on its own + * (`PUT …/price`), which mirrors it to Stripe. Paid pricing needs the organization's Stripe + * account (Studio → Payouts); the service answers 422 until then. */ export default function PricingSection({ extension }) { const toast = useToast(); + const fee = (usePage().props.commerce?.fee_bps ?? FEE * 10000) / 10000; const price = extension.price ?? { model: 'free', amount: 0, currency: 'USD' }; const [model, setModel] = useState(price.model === 'one_time' ? 'one_time' : 'free'); const [amount, setAmount] = useState(price.model === 'one_time' && price.amount ? (price.amount / 100).toFixed(2) : ''); const [currency, setCurrency] = useState(price.currency || 'USD'); + const [sale, setSale] = useState(price.sale_amount ? (price.sale_amount / 100).toFixed(2) : ''); + const [onSale, setOnSale] = useState(Boolean(price.on_sale)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const minor = toMinor(amount); - const changed = model !== (price.model === 'one_time' ? 'one_time' : 'free') || (model === 'one_time' && (minor !== price.amount || currency !== price.currency)); - const valid = model === 'free' || minor !== null; + const saleMinor = toMinor(sale); + const changed = + model !== (price.model === 'one_time' ? 'one_time' : 'free') || + (model === 'one_time' && (minor !== price.amount || currency !== price.currency || (saleMinor ?? null) !== (price.sale_amount ?? null) || (onSale && saleMinor !== null) !== Boolean(price.on_sale))); + const valid = model === 'free' || (minor !== null && (sale.trim() === '' || (saleMinor !== null && saleMinor < minor))); + const net = (value) => money(Math.round(value * (1 - fee)), currency); + const percent = `${Math.round(fee * 1000) / 10}%`; async function save() { setBusy(true); setError(null); try { - await api.put(`/publisher/extensions/${extension.slug}/price`, model === 'free' ? { model: 'free' } : { model, amount: minor, currency }); + await api.put( + `/publisher/extensions/${extension.slug}/price`, + model === 'free' ? { model: 'free' } : { model, amount: minor, currency, sale_amount: saleMinor, on_sale: saleMinor !== null && onSale }, + ); router.reload({ only: ['extension'] }); toast('Price saved.'); } catch (e) { @@ -78,7 +90,7 @@ export default function PricingSection({ extension }) { /> {model === 'one_time' && (
- + {(id, aria) => setAmount(e.target.value)} placeholder="29.00" autoComplete="off" />} @@ -92,6 +104,13 @@ export default function PricingSection({ extension }) { )} + + {(id, aria) => setSale(e.target.value)} placeholder="19.00" autoComplete="off" />} + +
)} {general ? ( @@ -100,7 +119,16 @@ export default function PricingSection({ extension }) { ) : ( - Charging for an extension needs a payout account for {extension.organization?.name ?? 'your organization'}. Payouts open with Stripe Connect soon; until then listings are free. Changing the price of a published extension sends its next release to review. + + Charging for an extension needs payouts set up for {extension.organization?.name ?? 'your organization'} + {extension.organization?.slug && ( + <> + {' '} + (Payouts) + + )} + . Changing the price of a published extension sends its next release to review. + )}
diff --git a/resources/js/components/studio/StudioChrome.jsx b/resources/js/components/studio/StudioChrome.jsx index ac2ee4c..8f186c1 100644 --- a/resources/js/components/studio/StudioChrome.jsx +++ b/resources/js/components/studio/StudioChrome.jsx @@ -20,10 +20,11 @@ export function StudioHeader({ extension, title, sub, back = '/studio', backLabe ); } -/** Studio sections: Extensions · Insights. */ +/** Studio sections: Extensions · Payouts · Insights. */ export function StudioNav({ current }) { const items = [ { href: '/studio', label: 'Extensions', key: 'extensions' }, + { href: '/studio/payouts', label: 'Payouts', key: 'payouts' }, { href: '/studio/insights', label: 'Insights', key: 'insights' }, ]; return ( diff --git a/resources/js/lib/statuses.js b/resources/js/lib/statuses.js index fd1dc37..f8d7d47 100644 --- a/resources/js/lib/statuses.js +++ b/resources/js/lib/statuses.js @@ -76,3 +76,26 @@ export function openRelease(releases = []) { export function liveRelease(releases = []) { return [...releases].filter((release) => release.status === 'published').sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0] ?? null; } + +/** A publisher organization's Stripe Connect payout status (`account.updated`). */ +export const PAYOUT_STATUSES = { + enabled: { tone: 'ok', icon: 'check', label: 'Payouts on', hint: 'Sales are paid out on your schedule.' }, + pending: { tone: 'you', icon: 'arrowRight', label: 'Finish setup', hint: 'Stripe needs your business and bank details.' }, + restricted: { tone: 'you', icon: 'alert', label: 'Action needed', hint: 'Stripe needs more information before it pays out.' }, + rejected: { tone: 'fail', icon: 'x', label: 'Rejected', hint: 'Stripe closed this account. Contact Fleetbase.' }, +}; + +/** A purchase's status. */ +export const PURCHASE_STATUSES = { + paid: { tone: 'ok', icon: 'check', label: 'Paid' }, + refunded: { tone: 'idle', icon: 'minus', label: 'Refunded' }, + disputed: { tone: 'fail', icon: 'alert', label: 'Disputed' }, +}; + +export function payoutStatus(status) { + return PAYOUT_STATUSES[status] ?? { tone: 'idle', icon: 'minus', label: 'Not set up', hint: 'Connect a Stripe account to sell extensions.' }; +} + +export function purchaseStatus(status) { + return PURCHASE_STATUSES[status] ?? { tone: 'idle', icon: 'minus', label: status }; +} diff --git a/resources/js/pages/Account/Billing.jsx b/resources/js/pages/Account/Billing.jsx new file mode 100644 index 0000000..4dc21b0 --- /dev/null +++ b/resources/js/pages/Account/Billing.jsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from 'react'; +import { Link, router } from '@inertiajs/react'; +import SiteLayout from '../../layouts/SiteLayout'; +import AccountHeader from '../../components/account/AccountHeader'; +import Icon from '../../components/Icon'; +import { StatusChip } from '../../components/Status'; +import { EmptyState, Note } from '../../components/Ui'; +import { api } from '../../lib/api'; +import { money, shortDate } from '../../lib/format'; +import { purchaseStatus } from '../../lib/statuses'; + +/** How long the page waits for Stripe's webhook after the buyer returns, polling every 2 s. */ +export const FINALIZE_TIMEOUT_MS = 60_000; + +/** + * An organization's billing: what it bought (who bought it, the amount, Stripe's receipt) and its + * licences with the instances holding a seat. After a website checkout Stripe returns here with + * `?handoff=`, and the page shows Finalizing until the webhook has created the purchase. + */ +export default function Billing({ organizations = [], organization, purchases = [], licences = [], checkout }) { + return ( + +
+
+ + {organizations.length > 1 && ( + + )} + {!organization ? ( + + Billing is for organization owners, admins and billing members. + + ) : ( + <> + {checkout && } + + + + )} +
+
+
+ ); +} + +/** The checkout the buyer just returned from: Finalizing until the purchase lands. */ +function Handoff({ organization, checkout: initial }) { + const [checkout, setCheckout] = useState(initial); + const [timedOut, setTimedOut] = useState(false); + const cancelled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('cancelled'); + + useEffect(() => { + setCheckout(initial); + }, [initial]); + + useEffect(() => { + if (checkout.status !== 'open' || cancelled) { + return undefined; + } + const started = Date.now(); + const timer = window.setInterval(async () => { + if (Date.now() - started > FINALIZE_TIMEOUT_MS) { + window.clearInterval(timer); + setTimedOut(true); + return; + } + try { + const next = await api.get(`purchases/${checkout.handoff}`); + if (next.status !== 'open') { + window.clearInterval(timer); + setCheckout(next); + if (next.status === 'complete') { + router.reload({ only: ['purchases', 'licences'] }); + } + } + } catch { + // Keep polling; a blip should not end the wait. + } + }, 2000); + return () => window.clearInterval(timer); + }, [checkout.status, checkout.handoff, cancelled]); + + const name = checkout.extension?.name ?? 'the extension'; + if (checkout.status === 'complete') { + return ( + + + {organization.name} now holds a licence for {name}. Install it from your console’s Extensions page. + + + ); + } + if (checkout.status === 'expired' || cancelled) { + return ( + + + The checkout for {name} was not completed. {checkout.extension && Try again} + + + ); + } + return ( + + + {timedOut ? `Stripe has not confirmed the payment for ${name} yet. It appears here once it does; you can leave this page.` : `Finalizing your purchase of ${name}…`} + + + ); +} + +function Purchases({ purchases }) { + return ( +
+
+

+ Purchases +

+
+ {purchases.length === 0 ? ( +
+

+ Nothing bought yet. Paid extensions are bought from their page or in your console. Browse extensions +

+
+ ) : ( +
+ + + + + + + + + + + + + {purchases.map((purchase) => { + const s = purchaseStatus(purchase.status); + return ( + + + + + + + + + ); + })} + +
DateExtensionBought byStatus + Amount + Receipt
{shortDate(purchase.created_at)}{purchase.extension ? {purchase.extension.name} : '—'}{purchase.bought_by?.name ?? (purchase.source === 'instance' ? 'From a console' : '—')} + + {s.label} + + + {money(purchase.amount_total, purchase.currency)} + {purchase.amount_tax > 0 && incl. {money(purchase.amount_tax, purchase.currency)} tax} + + {purchase.receipt_url ? ( + + Receipt + + ) : ( + — + )} +
+
+ )} +
+ ); +} + +function Licences({ licences }) { + return ( +
+
+

+ Licences +

+
+ {licences.length === 0 ? ( +
+

No licences yet. Installing a free extension or buying a paid one adds one here.

+
+ ) : ( +
    + {licences.map((licence) => ( +
  • +
    + {licence.extension?.name ?? 'Removed extension'} + + {licence.kind === 'paid' ? 'Paid' : 'Free'} + {licence.status === 'revoked' ? ( + + Revoked{licence.revoked_reason === 'refunded' ? ' (refunded)' : ''} + + ) : ( + + Active + + )} + +
    + + {licence.max_activations == null ? `${licence.seats_used ?? 0} instances, no limit` : `${licence.seats_used ?? 0} of ${licence.max_activations} instances`} + + {licence.activations?.length > 0 && ( +
      + {licence.activations.map((activation) => ( +
    • + {activation.instance?.name ?? 'Instance'} + {activation.instance?.host && · {activation.instance.host}} + {activation.release && · v{activation.release}} + · since {shortDate(activation.activated_at)} +
    • + ))} +
    + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/resources/js/pages/Extension/Show.jsx b/resources/js/pages/Extension/Show.jsx index 79f9833..38039ce 100644 --- a/resources/js/pages/Extension/Show.jsx +++ b/resources/js/pages/Extension/Show.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { api } from '../../lib/api'; import { Link, router, usePage } from '@inertiajs/react'; import SiteLayout from '../../layouts/SiteLayout'; import ExtensionIcon from '../../components/ExtensionIcon'; @@ -16,7 +17,7 @@ import { HOSTINGS } from '../../lib/statuses'; * version (a partial reload of `compatibility`). */ export default function ExtensionShow({ extension, releases, compatibility }) { - const { registry, auth } = usePage().props; + const { registry, auth, commerce } = usePage().props; const selfHosted = extension.hosting === 'self_hosted'; const paid = isPaid(extension.price); const [method, setMethod] = useState(selfHosted ? 'cli' : 'console'); @@ -200,6 +201,7 @@ export default function ExtensionShow({ extension, releases, compatibility }) {
{extension.price?.on_sale && On sale}
+ {paid && !owns && commerce?.enabled && } The console installs the engine and API package and reloads when done. - {paid && Paid extensions are bought in your console; checkout here opens with payouts.} + {paid && Or buy it on this page: the licence covers your organization’s instances either way.}
))} {method === 'cli' && ( @@ -441,3 +443,66 @@ function Lightbox({ extension, screenshots, index: start, onClose }) { ); } + +const BUYING_ROLES = ['owner', 'admin', 'billing']; + +/** + * Buy on the website: pick one of your organizations (owners, admins, billing members) and go to + * Stripe's hosted checkout. Stripe returns to the organization's billing page. + */ +export function BuyPanel({ extension, account }) { + const organizations = (account?.organizations ?? []).filter((organization) => BUYING_ROLES.includes(organization.role) && organization.id !== extension.publisher?.id); + const [organization, setOrganization] = useState(organizations.find((item) => !item.is_personal)?.slug ?? organizations[0]?.slug ?? ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + if (!account) { + return ( + + Sign in to buy + + ); + } + + if (organizations.length === 0) { + return To buy, ask an owner of your organization for the billing role.; + } + + async function buy() { + setBusy(true); + setError(null); + try { + const checkout = await api.post('purchases/checkout', { extension: extension.slug, organization }); + window.location.assign(checkout.url); + } catch (e) { + setError(e.message); + setBusy(false); + } + } + + return ( +
+ {organizations.length > 1 && ( + + )} + + {error && ( + + {error} + + )} + Tax is added at checkout where it applies. Paid securely with Stripe. +
+ ); +} diff --git a/resources/js/pages/Studio/Insights.jsx b/resources/js/pages/Studio/Insights.jsx index f8c6436..1c08208 100644 --- a/resources/js/pages/Studio/Insights.jsx +++ b/resources/js/pages/Studio/Insights.jsx @@ -1,8 +1,9 @@ +import { Link } from '@inertiajs/react'; import SiteLayout from '../../layouts/SiteLayout'; import { StatusChip } from '../../components/Status'; import { StudioNav } from '../../components/studio/StudioChrome'; -/** Analytics and payouts, both coming soon. No numbers until there are real ones. */ +/** Analytics (coming soon; no numbers until there are real ones) and the way to payouts. */ export default function StudioInsights() { return ( @@ -29,15 +30,12 @@ export default function StudioInsights() {

Today: each extension’s install count is on its public page.

- - Coming soon -

Payouts

-

Sell extensions with a one-time licence. Payments go to your organization’s Stripe Connect account; Fleetbase keeps a facilitator fee and shows every sale, refund and payout here.

+

Sell extensions with a one-time licence. Payments go to your organization’s Stripe account; Fleetbase keeps a facilitator fee and shows every sale, refund and payout.

    - {['Connect a Stripe account for your organization', 'Set a price on any listing', 'Operators buy in their console; you’re paid on Stripe’s schedule'].map((step, index) => ( + {['Set up payouts for your organization', 'Set a price on any listing', 'Operators buy in their console or here; you’re paid on Stripe’s schedule'].map((step, index) => (
- - - Available when payouts open - + + Open payouts +
diff --git a/resources/js/pages/Studio/Payouts.jsx b/resources/js/pages/Studio/Payouts.jsx new file mode 100644 index 0000000..bf028ec --- /dev/null +++ b/resources/js/pages/Studio/Payouts.jsx @@ -0,0 +1,338 @@ +import { useState } from 'react'; +import { Link, router } from '@inertiajs/react'; +import SiteLayout from '../../layouts/SiteLayout'; +import ConnectEmbed from '../../components/commerce/ConnectEmbed'; +import Icon from '../../components/Icon'; +import { StatusChip } from '../../components/Status'; +import { StudioNav } from '../../components/studio/StudioChrome'; +import { useToast } from '../../components/Toasts'; +import { EmptyState, Note } from '../../components/Ui'; +import { api } from '../../lib/api'; +import { money, shortDate } from '../../lib/format'; +import { payoutStatus, purchaseStatus } from '../../lib/statuses'; + +const MANAGING = ['owner', 'admin']; + +/** `weekly on Friday, 2 days after payment` from a Stripe payout schedule. */ +export function scheduleLabel(schedule) { + if (!schedule?.interval) { + return null; + } + const when = { + manual: 'Manual', + daily: 'Daily', + weekly: `Weekly${schedule.weekly_anchor ? ` on ${schedule.weekly_anchor[0].toUpperCase()}${schedule.weekly_anchor.slice(1)}` : ''}`, + monthly: `Monthly${schedule.monthly_anchor ? ` on day ${schedule.monthly_anchor}` : ''}`, + }[schedule.interval] ?? schedule.interval; + return schedule.delay_days != null && schedule.interval !== 'manual' ? `${when}, ${schedule.delay_days} days after payment` : when; +} + +/** Amounts per currency as one line: `$128.80 · €40`. */ +function amounts(list = []) { + return list.length ? list.map((item) => money(item.amount, item.currency)).join(' · ') : money(0); +} + +/** + * Payouts for a publisher organization: the Stripe Connect account (embedded onboarding and + * management), balance and schedule, and every sale with Fleetbase's fee and the net, with a CSV + * export. Owners, admins and billing members. + */ +export default function Payouts({ organizations = [], organization, payouts, transactions, filters = {}, error, stripe }) { + return ( + +
+
+
+
+

Payouts

+ {organization &&

{organization.name}: what your extensions earned and when Stripe pays it out.

} +
+ +
+ {organizations.length > 1 && ( + + )} + {!organization ? ( + + Payouts are for organization owners, admins and billing members. Ask an owner to give you the billing role. + + ) : !stripe?.configured ? ( + Payments are not set up on this registry yet, so extensions here are free. + ) : ( + + )} +
+
+
+ ); +} + +function PayoutsBody({ organization, payouts, transactions, filters, error, publishableKey }) { + const connect = payouts?.connect ?? { connected: false }; + const manages = MANAGING.includes(organization.role); + + return ( + <> + + {error && ( + + {error} + + )} + {payouts?.balance && ( +
+ + + +
+ )} + + + ); +} + +function Stat({ label, value, hint }) { + return ( +
+ {label} + {value} + {hint && {hint}} +
+ ); +} + +/** The Connect account: set it up, finish onboarding, or manage it, through Stripe's components. */ +function ConnectCard({ organization, connect, manages, publishableKey }) { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const [managing, setManaging] = useState(false); + const status = payoutStatus(connect.connected ? connect.payout_status : null); + + async function create() { + setBusy(true); + try { + await api.post(`organizations/${organization.slug}/stripe/account`); + router.reload({ only: ['payouts'] }); + } catch (e) { + toast(e.message, { type: 'error' }); + } finally { + setBusy(false); + } + } + + async function onboardingExited() { + try { + await api.get(`organizations/${organization.slug}/stripe`, { refresh: 1 }); + } finally { + router.reload({ only: ['payouts'] }); + } + } + + return ( +
+
+

+ Stripe account +

+ + {status.label} + +
+
+ {!connect.connected ? ( + <> +

Sales go to a Stripe account in {organization.name}’s name. Fleetbase keeps its facilitator fee and Stripe pays the rest to your bank.

+ {manages ? ( +
+ +
+ ) : ( +

An owner or admin of {organization.name} sets this up.

+ )} + + ) : connect.payout_status !== 'enabled' ? ( + <> +

{status.hint}

+ {connect.requirements_due?.length > 0 &&

Stripe still needs {connect.requirements_due.length} item{connect.requirements_due.length === 1 ? '' : 's'}.

} + {manages ? ( + + ) : ( +

An owner or admin of {organization.name} finishes this.

+ )} + + ) : ( + <> + {manages && } +

+ Charges and payouts are on{connect.default_currency ? `, in ${connect.default_currency} by default` : ''}. +

+ {manages && ( +
+ + {managing && ( + <> + + + + + + )} +
+ )} + + )} +
+
+ ); +} + +/** Sales of the organization's extensions: gross, Fleetbase's fee, net, with filters and CSV. */ +function Sales({ organization, transactions, filters }) { + const [from, setFrom] = useState(filters.from ?? ''); + const [to, setTo] = useState(filters.to ?? ''); + const [status, setStatus] = useState(filters.status ?? ''); + const rows = transactions?.data ?? []; + const meta = transactions?.meta; + const query = new URLSearchParams(Object.entries({ from, to, status }).filter(([, value]) => value)).toString(); + + function apply(event) { + event.preventDefault(); + router.get(`/studio/payouts/${organization.slug}`, Object.fromEntries(new URLSearchParams(query)), { preserveScroll: true, only: ['transactions', 'filters'] }); + } + + function page(number) { + router.get(`/studio/payouts/${organization.slug}`, { ...Object.fromEntries(new URLSearchParams(query)), page: number }, { preserveScroll: true, only: ['transactions'] }); + } + + return ( +
+
+

+ Sales +

+ + + CSV + +
+
+ + + + +
+ {rows.length === 0 ? ( +
+

No sales{query ? ' match these filters' : ' yet'}. Sales show here as soon as Stripe confirms the payment.

+
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((row) => { + const s = purchaseStatus(row.status); + return ( + + + + + + + + + + ); + })} + + + {(transactions?.totals ?? []).map((total) => ( + + + + + + + ))} + +
DateExtensionCustomerStatus + Gross + + Fee + + Net +
{shortDate(row.created_at)}{row.extension?.name ?? '—'}{row.customer?.name ?? '—'} + + {s.label} + + + {money(row.gross, row.currency)} + + −{money(row.fee, row.currency)} + + {money(row.net, row.currency)} +
+ Total, {total.count} sale{total.count === 1 ? '' : 's'} ({total.currency}) + + {money(total.gross, total.currency)} + + −{money(total.fee, total.currency)} + + {money(total.net, total.currency)} +
+
+ )} + {meta && meta.last_page > 1 && ( +
+ + + Page {meta.current_page} of {meta.last_page} + + +
+ )} +
+ ); +} diff --git a/routes/web.php b/routes/web.php index 073cac9..d62bffc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -40,10 +40,12 @@ Route::get('account/profile', [AccountPageController::class, 'profile'])->name('account.profile'); Route::get('account/organizations/{slug?}', [AccountPageController::class, 'organizations'])->name('account.organizations'); Route::get('account/tokens', [AccountPageController::class, 'tokens'])->name('account.tokens'); + Route::get('account/billing/{slug?}', [AccountPageController::class, 'billing'])->name('account.billing'); Route::get('studio', [StudioPageController::class, 'index'])->name('studio'); Route::get('studio/new', [StudioPageController::class, 'create'])->name('studio.create'); Route::get('studio/insights', [StudioPageController::class, 'insights'])->name('studio.insights'); + Route::get('studio/payouts/{slug?}', [StudioPageController::class, 'payouts'])->name('studio.payouts'); Route::get('studio/{slug}', [StudioPageController::class, 'editor'])->name('studio.editor'); Route::get('studio/{slug}/releases', [StudioPageController::class, 'releases'])->name('studio.releases'); Route::get('studio/{slug}/releases/{release}', [StudioPageController::class, 'release'])->name('studio.release'); diff --git a/tests/Feature/Commerce/CommercePagesTest.php b/tests/Feature/Commerce/CommercePagesTest.php new file mode 100644 index 0000000..6b22ac0 --- /dev/null +++ b/tests/Feature/Commerce/CommercePagesTest.php @@ -0,0 +1,78 @@ + $listing->uuid, 'buyer_organization_uuid' => $buyer->uuid, 'amount_total' => 4900, 'fee_amount' => 490, 'net_amount' => 4410, 'currency' => 'USD', 'status' => 'paid']); + actingOnWebsite($owner); + + $this->get("/studio/payouts/{$publisher->slug}") + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Studio/Payouts') + ->where('organization.slug', $publisher->slug) + ->where('payouts.connect.payout_status', 'enabled') + ->where('payouts.balance.available.0.amount', 12880) + ->where('transactions.data.0.net', 4410) + ->where('transactions.totals.0.net', 4410) + ->where('error', null) + ->where('stripe', ['configured' => true, 'publishable_key' => 'pk_test_registry']) + ->where('commerce.fee_bps', 1000)); +}); + +it('shows the Connect status when Stripe cannot be reached', function () { + [$owner, $publisher] = sellableListing(); + stripe()->on('get', '/v1/balance', ['error' => ['type' => 'invalid_request_error', 'message' => 'Account not found.']], 400); + actingOnWebsite($owner); + + $this->get('/studio/payouts') + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Studio/Payouts') + ->where('organization.slug', $publisher->slug) + ->where('payouts.connect.connected', true) + ->where('error', 'Stripe could not be reached: Account not found.')); +}); + +it('lists only organizations where the account sees money', function () { + $account = Account::factory()->create(); + [, $organization] = buyerOrganization(); + $account->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_PUBLISHER]); + actingOnWebsite($account); + + $this->get('/studio/payouts')->assertOk()->assertInertia(fn (Assert $page) => $page->where('organizations', [])->where('organization', null)); + $this->get("/studio/payouts/{$organization->slug}")->assertNotFound(); + $this->get('/account/billing')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Account/Billing')->where('organization', null)); +}); + +it('renders account billing with purchases, licences and the returning hand-off', function () { + [, , $listing] = sellableListing(); + [$account, $organization] = buyerOrganization(Membership::ROLE_BILLING); + $purchase = Purchase::create(['extension_uuid' => $listing->uuid, 'buyer_organization_uuid' => $organization->uuid, 'buyer_account_uuid' => $account->uuid, 'amount_total' => 4900, 'fee_amount' => 490, 'net_amount' => 4410, 'currency' => 'USD', 'status' => 'paid', 'receipt_url' => 'https://pay.stripe.com/receipts/x']); + Licence::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $organization->uuid, 'purchase_uuid' => $purchase->uuid, 'kind' => 'paid', 'max_activations' => 5]); + $checkout = Checkout::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $organization->uuid, 'source' => 'website', 'status' => 'open', 'expires_at' => now()->addHour()]); + actingOnWebsite($account); + + $this->get("/account/billing/{$organization->slug}?handoff={$checkout->public_id}") + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Account/Billing') + ->where('organization.slug', $organization->slug) + ->where('purchases.0.id', $purchase->public_id) + ->where('purchases.0.receipt_url', 'https://pay.stripe.com/receipts/x') + ->where('licences.0.max_activations', 5) + ->where('checkout.handoff', $checkout->public_id) + ->where('checkout.status', 'open')); + + // Someone else's hand-off is not shown. + [, $other] = buyerOrganization(); + $foreign = Checkout::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $other->uuid, 'source' => 'website']); + $this->get("/account/billing/{$organization->slug}?handoff={$foreign->public_id}")->assertOk()->assertInertia(fn (Assert $page) => $page->where('checkout', null)); +}); From 648dda8aadbf2817d444b02ac17f1dabee274bd0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 14:56:37 +0800 Subject: [PATCH 03/10] Leave refunds out of sales totals, show a generic message for platform-side Stripe failures, highlight the selected organization on Billing --- README.md | 2 +- app/Commerce/Stripe.php | 15 +++++++++++++++ .../Controllers/Organization/PayoutController.php | 5 ++++- app/Http/Controllers/Web/WebController.php | 3 ++- bootstrap/app.php | 5 +++-- resources/css/registry.css | 2 +- .../js/components/commerce/CommercePages.test.jsx | 2 +- resources/js/components/studio/studio.css | 1 - resources/js/pages/Studio/Payouts.jsx | 2 +- tests/Feature/Commerce/CommercePagesTest.php | 12 ++++++++++-- tests/Feature/Commerce/PayoutsAndBillingTest.php | 2 +- 11 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0be4bc0..bbf4f24 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ cannot change, so a new amount or currency creates a new price and archives the | `POST /v1/organizations/{slug}/stripe/account` | owners, admins | Create the Express account (once; 201, then 200) | | `POST /v1/organizations/{slug}/stripe/account-session` | owners, admins | `client_secret` and `publishable_key` for Connect.js: onboarding, account management, notification banner, payouts, tax settings and registrations | | `GET /v1/organizations/{slug}/payouts` | owners, admins, billing | Connect status, `balance` (`available`, `pending`, `in_transit` per currency), payout `schedule`, the last 10 payouts | -| `GET /v1/organizations/{slug}/payouts/transactions?from=&to=&status=&extension=&page=` | owners, admins, billing | Sales of the organization's extensions: `gross`, `fee`, `net`, `tax`, `refunded`, `status`, `customer`; `totals` per currency | +| `GET /v1/organizations/{slug}/payouts/transactions?from=&to=&status=&extension=&page=` | owners, admins, billing | Sales of the organization's extensions: `gross`, `fee`, `net`, `tax`, `refunded`, `status`, `customer`; `totals` per currency, leaving out refunded sales | | `GET /v1/organizations/{slug}/payouts/transactions.csv` | owners, admins, billing | The same sales (same filters) as CSV | | `GET /v1/organizations/{slug}/billing/purchases` | owners, admins, billing | What the organization bought: amount, tax, who bought it, status, Stripe receipt URL, licence | | `GET /v1/organizations/{slug}/billing/licences` | owners, admins, billing | Licences with seat use and the instances activated on each | diff --git a/app/Commerce/Stripe.php b/app/Commerce/Stripe.php index 2b6563a..1459e55 100644 --- a/app/Commerce/Stripe.php +++ b/app/Commerce/Stripe.php @@ -2,6 +2,9 @@ namespace App\Commerce; +use Stripe\Exception\ApiErrorException; +use Stripe\Exception\CardException; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -24,6 +27,18 @@ public static function client(): StripeClient return app(StripeClient::class); } + /** + * What to tell the caller about a Stripe failure: Stripe's own words for a refused request + * (a bad price, an incomplete account), a generic line when the platform's side failed + * (credentials, network, rate limits), which callers cannot fix. + */ + public static function failure(ApiErrorException $e): string + { + return $e instanceof InvalidRequestException || $e instanceof CardException + ? 'Stripe could not complete the request: '.$e->getMessage() + : 'Payments are unavailable right now. Try again in a few minutes.'; + } + /** * Request options that act on a connected account. */ diff --git a/app/Http/Controllers/Organization/PayoutController.php b/app/Http/Controllers/Organization/PayoutController.php index 0878d77..d42e277 100644 --- a/app/Http/Controllers/Organization/PayoutController.php +++ b/app/Http/Controllers/Organization/PayoutController.php @@ -80,14 +80,17 @@ public function summary(Request $request, string $slug): JsonResponse /** * GET /v1/organizations/{slug}/payouts/transactions[?from=&to=&extension=&status=&page=] — - * sales of the organization's extensions, newest first, with totals per currency. + * sales of the organization's extensions, newest first, with totals per currency (refunded + * sales left out of the totals). */ public function transactions(Request $request, string $slug): JsonResponse { $organization = $this->billingOrganization($request, $slug); $query = $this->sales($request, $organization); + // Refunded sales are listed but not counted: their money went back to the buyer. $totals = (clone $query)->reorder() + ->where('status', '!=', Purchase::STATUS_REFUNDED) ->selectRaw('currency, count(*) as count, sum(amount_total) as gross, sum(fee_amount) as fee, sum(net_amount) as net') ->groupBy('currency') ->get() diff --git a/app/Http/Controllers/Web/WebController.php b/app/Http/Controllers/Web/WebController.php index 68abcec..a8d56a6 100644 --- a/app/Http/Controllers/Web/WebController.php +++ b/app/Http/Controllers/Web/WebController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Web; +use App\Commerce\Stripe; use App\Http\Controllers\Controller; use App\Http\Controllers\Organization\OrganizationController; use Illuminate\Http\JsonResponse; @@ -93,7 +94,7 @@ protected function tryApi(Request $request, string $uri, array $query, string $c } catch (ApiErrorException $e) { report($e); - return [null, 'Stripe could not be reached: '.$e->getMessage()]; + return [null, Stripe::failure($e)]; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 4464d36..ff0015e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ render(function (ApiErrorException $e, Request $request) use ($isApi) { if ($isApi($request)) { report($e); - return response()->error('Stripe could not complete the request: '.$e->getMessage(), 502); + return response()->error(Stripe::failure($e), 502); } }); diff --git a/resources/css/registry.css b/resources/css/registry.css index c599c81..3214399 100644 --- a/resources/css/registry.css +++ b/resources/css/registry.css @@ -153,7 +153,7 @@ code, kbd, .mono { font-family: var(--mono); font-size: 0.88em; } .chip[aria-pressed='true'] { border-color: var(--accent); background: var(--accent-soft); color: var(--ink); } .seg { display: inline-flex; padding: 4px; gap: 4px; border-radius: 12px; background: var(--bg-tint); border: 1px solid var(--line); } .seg button, .seg a { min-height: 36px; padding: 0 14px; border: 0; border-radius: 8px; background: transparent; color: var(--ink-2); font: 500 14px/1 var(--font); cursor: pointer; display: inline-flex; align-items: center; gap: 8px; } -.seg [aria-selected='true'] { background: var(--surface); color: var(--ink); box-shadow: var(--e1), 0 0 0 1px var(--line); } +.seg [aria-selected='true'], .seg a[aria-current='page'] { background: var(--surface); color: var(--ink); box-shadow: var(--e1), 0 0 0 1px var(--line); } /* Status: icon + word, never colour alone */ .st { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: var(--r-full); font-size: 12.5px; font-weight: 600; border: 1px solid; white-space: nowrap; } diff --git a/resources/js/components/commerce/CommercePages.test.jsx b/resources/js/components/commerce/CommercePages.test.jsx index 2b74a83..54c3555 100644 --- a/resources/js/components/commerce/CommercePages.test.jsx +++ b/resources/js/components/commerce/CommercePages.test.jsx @@ -103,7 +103,7 @@ describe('Payouts', () => { expect(within(rows[1]).getByText('−$4.90')).toBeInTheDocument(); expect(within(rows[1]).getByText('$48.02')).toBeInTheDocument(); expect(within(rows[2]).getByText('Refunded')).toBeInTheDocument(); - expect(screen.getByText('Total, 2 sales (USD)')).toBeInTheDocument(); + expect(screen.getByText('Total, 2 sales not refunded (USD)')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'CSV' })).toHaveAttribute('href', '/v1/organizations/acme/payouts/transactions.csv?status=paid'); await user.click(screen.getByRole('button', { name: 'Bank account, tax and payout schedule' })); diff --git a/resources/js/components/studio/studio.css b/resources/js/components/studio/studio.css index cd2b8af..f1b8371 100644 --- a/resources/js/components/studio/studio.css +++ b/resources/js/components/studio/studio.css @@ -2,7 +2,6 @@ Upload, Rejected, History, Soon). Tokens only, so both themes follow. */ /* Section nav (Extensions · Insights): links styled like the selected segment. */ -.seg a[aria-current='page'] { background: var(--surface); color: var(--ink); box-shadow: var(--e1), 0 0 0 1px var(--line); } .seg a { text-decoration: none; } /* Studio home */ diff --git a/resources/js/pages/Studio/Payouts.jsx b/resources/js/pages/Studio/Payouts.jsx index bf028ec..765b84e 100644 --- a/resources/js/pages/Studio/Payouts.jsx +++ b/resources/js/pages/Studio/Payouts.jsx @@ -303,7 +303,7 @@ function Sales({ organization, transactions, filters }) { {(transactions?.totals ?? []).map((total) => ( - Total, {total.count} sale{total.count === 1 ? '' : 's'} ({total.currency}) + Total, {total.count} sale{total.count === 1 ? '' : 's'} not refunded ({total.currency}) {money(total.gross, total.currency)} diff --git a/tests/Feature/Commerce/CommercePagesTest.php b/tests/Feature/Commerce/CommercePagesTest.php index 6b22ac0..fb58783 100644 --- a/tests/Feature/Commerce/CommercePagesTest.php +++ b/tests/Feature/Commerce/CommercePagesTest.php @@ -27,7 +27,7 @@ ->where('commerce.fee_bps', 1000)); }); -it('shows the Connect status when Stripe cannot be reached', function () { +it('shows the Connect status when Stripe refuses the balance', function () { [$owner, $publisher] = sellableListing(); stripe()->on('get', '/v1/balance', ['error' => ['type' => 'invalid_request_error', 'message' => 'Account not found.']], 400); actingOnWebsite($owner); @@ -38,7 +38,7 @@ ->component('Studio/Payouts') ->where('organization.slug', $publisher->slug) ->where('payouts.connect.connected', true) - ->where('error', 'Stripe could not be reached: Account not found.')); + ->where('error', 'Stripe could not complete the request: Account not found.')); }); it('lists only organizations where the account sees money', function () { @@ -76,3 +76,11 @@ $foreign = Checkout::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $other->uuid, 'source' => 'website']); $this->get("/account/billing/{$organization->slug}?handoff={$foreign->public_id}")->assertOk()->assertInertia(fn (Assert $page) => $page->where('checkout', null)); }); + +it('hides platform-side Stripe failures behind a generic message', function () { + [$owner, $publisher] = sellableListing(); + stripe()->on('get', '/v1/balance', ['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided: sk_test_****']], 401); + actingOnWebsite($owner); + + $this->get('/studio/payouts')->assertInertia(fn (Assert $page) => $page->where('error', 'Payments are unavailable right now. Try again in a few minutes.')); +}); diff --git a/tests/Feature/Commerce/PayoutsAndBillingTest.php b/tests/Feature/Commerce/PayoutsAndBillingTest.php index e614c7c..1af2140 100644 --- a/tests/Feature/Commerce/PayoutsAndBillingTest.php +++ b/tests/Feature/Commerce/PayoutsAndBillingTest.php @@ -99,7 +99,7 @@ function recordedSale(Extension $listing, Organization $buyer, int $total = 5292 'status' => 'paid', 'created_at' => $old->created_at->toJSON(), ]) - ->assertJsonPath('totals', [['currency' => 'USD', 'count' => 2, 'gross' => 10584, 'fee' => 980, 'net' => 9604]]) + ->assertJsonPath('totals', [['currency' => 'USD', 'count' => 1, 'gross' => 5292, 'fee' => 490, 'net' => 4802]]) ->assertJsonPath('meta.total', 2); $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions?from=2026-10-10")->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $refunded->public_id); From cae865fbca034644c2c37dbc053869cbc945a61d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 16:10:32 +0800 Subject: [PATCH 04/10] Apply the commerce decisions: repeat purchases add seats, disputes revoke the licence, staff licence management, return URLs on the instance host, members see licences --- README.md | 28 +++- app/Commerce/CheckoutSessions.php | 10 -- app/Commerce/StripeWebhooks.php | 26 +++- .../Controllers/Admin/LicenceController.php | 128 ++++++++++++++++++ .../Instance/PurchaseController.php | 11 +- .../Organization/BillingController.php | 8 +- .../Controllers/Web/AccountPageController.php | 6 +- app/Http/Controllers/Web/WebController.php | 8 +- app/Http/Resources/LicenceResource.php | 1 + app/Mail/DisputeOpenedMail.php | 3 +- app/Models/Instance.php | 44 ++++++ .../commerce/CommercePages.test.jsx | 9 ++ resources/js/pages/Account/Billing.jsx | 12 +- resources/views/mail/dispute-opened.blade.php | 2 +- routes/api.php | 5 + tests/Feature/Commerce/AdminLicenceTest.php | 79 +++++++++++ tests/Feature/Commerce/CheckoutTest.php | 50 +++++-- tests/Feature/Commerce/CommercePagesTest.php | 9 +- .../Commerce/PayoutsAndBillingTest.php | 4 +- tests/Feature/Commerce/WebhookTest.php | 5 +- tests/Helpers/commerce.php | 6 +- 21 files changed, 397 insertions(+), 57 deletions(-) create mode 100644 app/Http/Controllers/Admin/LicenceController.php create mode 100644 tests/Feature/Commerce/AdminLicenceTest.php diff --git a/README.md b/README.md index bbf4f24..e6f8a0f 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,10 @@ checks are emailed too. | `GET /v1/admin/releases/{id}/files/{engine,api,dist}` | Downloads an archive for review; recorded as `release.downloaded` | | `GET /v1/admin/review-items` | The review requirements R1 to R6 (`registry.review.items`); approve and reject accept only these codes | | `GET /v1/admin/facilitator-fees` | The fee in effect (`current_rate_bps`), the configured default, and every scheduled fee, newest first | +| `GET /v1/admin/licences?organization=&extension=&status=&kind=` | Licences (100 newest) with organization, purchase and seats in use | +| `POST /v1/admin/licences/{id}/revoke` | Required `reason`. New installs and updates stop; running instances keep their seat | +| `POST /v1/admin/licences/{id}/reinstate` | Optional `reason`. For a won dispute or a mistaken revoke | +| `PATCH /v1/admin/licences/{id}` | `max_activations`: seats, at least the seats in use | | `POST /v1/admin/facilitator-fees` | `rate_bps` (0 to 5000; 1000 = 10%), optional `effective_from` (now or later, never earlier) and `note`. Adds a row; rows are never edited | ### Instance API (instance token) @@ -458,6 +462,13 @@ fees and losses fall on Fleetbase (`controller.fees.payer` and `losses.payments` sale price as a second Stripe price; `on_sale` decides which one Checkout charges. Stripe prices cannot change, so a new amount or currency creates a new price and archives the old one. +**Licences and seats.** A purchase gives the buyer organization one paid licence with 5 seats. +A seat is one Fleetbase instance registered to that organization: installing on an instance +takes a seat, uninstalling frees it, and every company (tenant) on that instance is covered by +the one install. Buying again adds another licence with 5 more seats; installs use the seats +of all the organization's active licences. Another registry organization buys its own licence. +Staff can change seats, revoke and reinstate licences (Admin API). + ### Organization routes (personal token or website session) | Method and path | Who | Purpose | @@ -469,7 +480,7 @@ cannot change, so a new amount or currency creates a new price and archives the | `GET /v1/organizations/{slug}/payouts/transactions?from=&to=&status=&extension=&page=` | owners, admins, billing | Sales of the organization's extensions: `gross`, `fee`, `net`, `tax`, `refunded`, `status`, `customer`; `totals` per currency, leaving out refunded sales | | `GET /v1/organizations/{slug}/payouts/transactions.csv` | owners, admins, billing | The same sales (same filters) as CSV | | `GET /v1/organizations/{slug}/billing/purchases` | owners, admins, billing | What the organization bought: amount, tax, who bought it, status, Stripe receipt URL, licence | -| `GET /v1/organizations/{slug}/billing/licences` | owners, admins, billing | Licences with seat use and the instances activated on each | +| `GET /v1/organizations/{slug}/billing/licences` | members | Licences with seat use and the instances activated on each | | `POST /v1/purchases/checkout` | owners, admins, billing of the buyer | Website checkout: `extension`, `organization`. Returns a hand-off (below); Stripe returns the buyer to `/account/billing/{slug}?handoff=` | | `GET /v1/purchases/{handoff}` | members of the buyer | The hand-off | @@ -486,7 +497,7 @@ safe. | `checkout.session.completed` (paid), `checkout.session.async_payment_succeeded` | Purchase (`amount_total`, `fee_amount` from the PaymentIntent's application fee, `net_amount` = total − fee, tax and subtotal in `meta`, receipt URL from the charge), a `paid` licence with 5 seats, the hand-off `complete`, a receipt email to the buyer | | `checkout.session.expired` | The hand-off `expired` | | `charge.refunded` | Full refund: purchase `refunded`, licence `revoked` (new installs and updates stop; running instances keep their seat). Partial refund: `meta.amount_refunded` only | -| `charge.dispute.created` | Purchase `disputed` with the dispute in `meta` (licence stays active), email to `REGISTRY_COMMERCE_NOTIFY` | +| `charge.dispute.created` | Purchase `disputed` with the dispute in `meta`, licence `revoked` (`revoked_reason: disputed`; staff reinstate it if the dispute is won), email to `REGISTRY_COMMERCE_NOTIFY` | | `account.updated` | The organization's `payout_status` and `meta.stripe` | ### Instance checkout (contract for registry-bridge) @@ -506,8 +517,10 @@ instance token (`Authorization: Bearer flbi_…`). ``` `extension` is a slug, `ext_` id or package name. `return_url` (http or https) is where Stripe - sends the operator after paying; `cancel_url` defaults to it. `email` prefills Checkout and is - optional. Answers `201`: + sends the operator after paying; `cancel_url` defaults to it. Both must be on the host the + instance registered (`instances.host`) or its domain: an instance registered as `api.acme.com` + may use `api.acme.com`, `acme.com` or `console.acme.com`; `localhost` and IP addresses match + only themselves (any port). `email` prefills Checkout and is optional. Answers `201`: ```json { @@ -528,9 +541,10 @@ instance token (`Authorization: Bearer flbi_…`). `amount` is the price before tax (the sale price while a sale is on). Refusals use the usual `{"errors": [...]}` envelope: 404 unknown extension; 409 when the organization publishes the - extension or already holds a paid licence for it; 422 when it is not published, is free, does - not match the instance's hosting kind, or the publisher cannot take payments yet; 503 when the - registry has no Stripe configuration; 502 when Stripe refuses. + extension; 422 when a return URL is off the instance's host, the extension is not published, + is free, does not match the instance's hosting kind, or the publisher cannot take payments yet; + 503 when the registry has no Stripe configuration; 502 when Stripe refuses. An organization + that already holds a licence may buy again: each purchase is another licence with 5 more seats. 2. **Redirect** the operator's browser to `url` (a full-page redirect; Checkout cannot be framed). diff --git a/app/Commerce/CheckoutSessions.php b/app/Commerce/CheckoutSessions.php index 6901d56..d186641 100644 --- a/app/Commerce/CheckoutSessions.php +++ b/app/Commerce/CheckoutSessions.php @@ -7,7 +7,6 @@ use App\Models\Extension; use App\Models\FacilitatorFee; use App\Models\Instance; -use App\Models\Licence; use App\Models\Organization; use Illuminate\Support\Arr; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; @@ -134,15 +133,6 @@ protected function assertPurchasable(Extension $extension, Organization $organiz throw new ConflictHttpException("{$organization->name} publishes {$extension->name} and already has access to it."); } - $licensed = Licence::query()->active() - ->where('extension_uuid', $extension->uuid) - ->where('organization_uuid', $organization->uuid) - ->where('kind', Licence::KIND_PAID) - ->exists(); - if ($licensed) { - throw new ConflictHttpException("{$organization->name} already holds a licence for {$extension->name}. Ask Fleetbase staff for more seats."); - } - // Imported listings and accounts (registry:import) have no Stripe state yet: read it now. $publisher = $extension->organization; if ($publisher?->hasStripeAccount() && $publisher->stripeState() === []) { diff --git a/app/Commerce/StripeWebhooks.php b/app/Commerce/StripeWebhooks.php index 784df56..916b382 100644 --- a/app/Commerce/StripeWebhooks.php +++ b/app/Commerce/StripeWebhooks.php @@ -211,18 +211,15 @@ protected function refunded(Charge $charge): void 'meta' => $meta + ['refunded_at' => now()->toIso8601String()], ])->save(); - $purchase->licence()->where('status', Licence::STATUS_ACTIVE)->get()->each(function (Licence $licence) { - $licence->forceFill([ - 'status' => Licence::STATUS_REVOKED, - 'meta' => array_merge((array) $licence->meta, ['revoked_reason' => 'refunded', 'revoked_at' => now()->toIso8601String()]), - ])->save(); - }); + $this->revoke($purchase, 'refunded'); Event::record('purchase.refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); } /** - * A dispute flags the purchase (the licence stays active while it is open) and tells staff. + * A dispute flags the purchase and revokes its licence at once (disputes are a common way to + * try to keep an extension for free), and tells staff. Staff reinstate the licence if the + * dispute is won (POST /v1/admin/licences/{id}/reinstate). */ protected function disputed(Dispute $dispute): void { @@ -244,6 +241,7 @@ protected function disputed(Dispute $dispute): void ]), ])->save(); + $this->revoke($purchase, 'disputed'); Event::record('purchase.disputed', $purchase, null, ['dispute' => $dispute->id, 'reason' => $dispute->reason]); foreach ((array) config('registry.commerce.notify', []) as $to) { @@ -251,6 +249,20 @@ protected function disputed(Dispute $dispute): void } } + /** + * Revoke the purchase's active licence: new installs and updates stop; running instances keep + * their seat. + */ + protected function revoke(Purchase $purchase, string $reason): void + { + $purchase->licence()->where('status', Licence::STATUS_ACTIVE)->get()->each(function (Licence $licence) use ($reason) { + $licence->forceFill([ + 'status' => Licence::STATUS_REVOKED, + 'meta' => array_merge((array) $licence->meta, ['revoked_reason' => $reason, 'revoked_at' => now()->toIso8601String()]), + ])->save(); + }); + } + protected function accountUpdated(StripeAccount $account): void { $organization = Organization::query()->where('stripe_connect_id', $account->id)->first(); diff --git a/app/Http/Controllers/Admin/LicenceController.php b/app/Http/Controllers/Admin/LicenceController.php new file mode 100644 index 0000000..0233eb4 --- /dev/null +++ b/app/Http/Controllers/Admin/LicenceController.php @@ -0,0 +1,128 @@ +validate([ + 'status' => ['nullable', 'in:'.Licence::STATUS_ACTIVE.','.Licence::STATUS_REVOKED], + 'kind' => ['nullable', 'in:'.Licence::KIND_FREE.','.Licence::KIND_PAID], + ]); + + $licences = Licence::query() + ->with(['extension', 'organization', 'purchase']) + ->withCount('activeActivations') + ->when($request->input('organization'), fn (Builder $q, $id) => $q->whereIn('organization_uuid', Organization::query()->where('slug', $id)->orWhere('public_id', $id)->select('uuid'))) + ->when($request->input('extension'), fn (Builder $q, $id) => $q->whereIn('extension_uuid', Extension::query()->where('slug', $id)->orWhere('public_id', $id)->select('uuid'))) + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('kind'), fn (Builder $q, $kind) => $q->where('kind', $kind)) + ->orderByDesc('created_at') + ->limit(100) + ->get(); + + return response()->json(LicenceResource::collection($licences)->resolve($request)); + } + + /** + * POST /v1/admin/licences/{id}/revoke — `reason` required. + */ + public function revoke(Request $request, string $licence): JsonResponse + { + $request->validate(['reason' => ['required', 'string', 'max:500']]); + $model = $this->find($licence); + + if ($model->status === Licence::STATUS_REVOKED) { + throw new ConflictHttpException('This licence is already revoked.'); + } + + $model->forceFill([ + 'status' => Licence::STATUS_REVOKED, + 'meta' => array_merge((array) $model->meta, [ + 'revoked_reason' => 'staff', + 'revoked_note' => $request->input('reason'), + 'revoked_at' => now()->toIso8601String(), + ]), + ])->save(); + + Event::record('licence.revoked', $model, $request->user(), ['reason' => $request->input('reason')], $request->ip()); + + return $this->present($request, $model); + } + + /** + * POST /v1/admin/licences/{id}/reinstate — optional `reason`. + */ + public function reinstate(Request $request, string $licence): JsonResponse + { + $request->validate(['reason' => ['nullable', 'string', 'max:500']]); + $model = $this->find($licence); + + if ($model->status === Licence::STATUS_ACTIVE) { + throw new ConflictHttpException('This licence is already active.'); + } + + $from = $model->meta['revoked_reason'] ?? null; + $model->forceFill([ + 'status' => Licence::STATUS_ACTIVE, + 'meta' => array_merge((array) $model->meta, [ + 'revoked_reason' => null, + 'reinstated_at' => now()->toIso8601String(), + 'reinstated_note' => $request->input('reason'), + ]), + ])->save(); + + Event::record('licence.reinstated', $model, $request->user(), ['was' => $from, 'reason' => $request->input('reason')], $request->ip()); + + return $this->present($request, $model); + } + + /** + * PATCH /v1/admin/licences/{id} — `max_activations` (seats; at least the seats in use). + */ + public function update(Request $request, string $licence): JsonResponse + { + $model = $this->find($licence); + $request->validate(['max_activations' => ['required', 'integer', 'min:'.max(1, $model->seatsUsed()), 'max:1000']]); + + $from = $model->max_activations; + $model->forceFill(['max_activations' => (int) $request->input('max_activations')])->save(); + + Event::record('licence.seats_changed', $model, $request->user(), ['from' => $from, 'to' => $model->max_activations], $request->ip()); + + return $this->present($request, $model); + } + + protected function find(string $id): Licence + { + return Licence::findById($id) ?? throw new NotFoundHttpException('Licence not found.'); + } + + protected function present(Request $request, Licence $licence): JsonResponse + { + $licence->load(['extension', 'organization', 'purchase'])->loadCount('activeActivations'); + + return response()->json((new LicenceResource($licence))->resolve($request)); + } +} diff --git a/app/Http/Controllers/Instance/PurchaseController.php b/app/Http/Controllers/Instance/PurchaseController.php index 170a7a9..c8dde59 100644 --- a/app/Http/Controllers/Instance/PurchaseController.php +++ b/app/Http/Controllers/Instance/PurchaseController.php @@ -23,7 +23,8 @@ class PurchaseController extends Controller { /** * POST /v1/instances/me/purchases/checkout — a hosted Checkout Session for the instance's - * organization. Answers 201 with the hand-off id and Stripe's URL. + * organization. The return and cancel URLs must be on the instance's registered host or its + * domain. Answers 201 with the hand-off id and Stripe's URL. */ public function checkout(StartCheckoutRequest $request, CheckoutSessions $sessions): JsonResponse { @@ -35,6 +36,14 @@ public function checkout(StartCheckoutRequest $request, CheckoutSessions $sessio return response()->error('Extension not found.', 404); } + foreach (array_filter([$request->input('return_url'), $request->input('cancel_url')]) as $url) { + if (! $instance->ownsUrl($url)) { + return response()->error($instance->hostname() + ? "Return URLs must be on this instance's host ({$instance->hostname()}) or its domain." + : 'This instance has no registered host. Register it again with its URL to buy extensions.', 422); + } + } + $checkout = $sessions->start( $extension, $instance->organization, diff --git a/app/Http/Controllers/Organization/BillingController.php b/app/Http/Controllers/Organization/BillingController.php index 8ca9233..6fdc20e 100644 --- a/app/Http/Controllers/Organization/BillingController.php +++ b/app/Http/Controllers/Organization/BillingController.php @@ -12,8 +12,8 @@ use Illuminate\Http\Request; /** - * What an organization bought: purchases with who bought them and Stripe's receipts, and its - * licences with the instances holding a seat. Owners, admins and billing members. + * What an organization bought: purchases with who bought them and Stripe's receipts (owners, + * admins and billing members), and its licences with the instances holding a seat (any member). */ class BillingController extends Controller { @@ -39,11 +39,11 @@ public function purchases(Request $request, string $slug): JsonResponse /** * GET /v1/organizations/{slug}/billing/licences — paid and free licences with seat use and - * the instances activated on each. + * the instances activated on each. Any member. */ public function licences(Request $request, string $slug): JsonResponse { - $organization = $this->billingOrganization($request, $slug); + $organization = $this->findOrganization($request, $slug); $licences = Licence::query() ->where('organization_uuid', $organization->uuid) diff --git a/app/Http/Controllers/Web/AccountPageController.php b/app/Http/Controllers/Web/AccountPageController.php index db5b3dd..f626103 100644 --- a/app/Http/Controllers/Web/AccountPageController.php +++ b/app/Http/Controllers/Web/AccountPageController.php @@ -92,7 +92,8 @@ public function organizations(Request $request, ?string $slug = null): Response */ public function billing(Request $request, ?string $slug = null): Response { - [$organizations, $organization] = $this->billingOrganizations($request, $slug); + [$organizations, $organization] = $this->billingOrganizations($request, $slug, null); + $billing = $organization && in_array($organization['role'] ?? null, OrganizationController::BILLING_ROLES, true); $checkout = null; if ($organization && $request->filled('handoff')) { @@ -102,7 +103,8 @@ public function billing(Request $request, ?string $slug = null): Response return Inertia::render('Account/Billing', [ 'organizations' => $organizations, 'organization' => $organization, - 'purchases' => $organization ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/purchases", [], BillingController::class, 'purchases', ['slug' => $organization['slug']]) : [], + // Purchases and receipts are for owners, admins and billing members; licences for everyone. + 'purchases' => $billing ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/purchases", [], BillingController::class, 'purchases', ['slug' => $organization['slug']]) : null, 'licences' => $organization ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/licences", [], BillingController::class, 'licences', ['slug' => $organization['slug']]) : [], 'checkout' => $checkout, ]); diff --git a/app/Http/Controllers/Web/WebController.php b/app/Http/Controllers/Web/WebController.php index a8d56a6..f291cfc 100644 --- a/app/Http/Controllers/Web/WebController.php +++ b/app/Http/Controllers/Web/WebController.php @@ -57,15 +57,15 @@ protected function api(Request $request, string $uri, array $query, string $cont } /** - * The caller's organizations where they see money (owners, admins, billing members), and the - * selected one: `$slug`, else the first that is not personal, else the first. + * The caller's organizations with one of `$roles` (default: those who see money: owners, + * admins, billing members; null: every role), and the selected one: `$slug`, else the first that is not personal, else the first. * * @return array{0: list, 1: ?array} */ - protected function billingOrganizations(Request $request, ?string $slug): array + protected function billingOrganizations(Request $request, ?string $slug, ?array $roles = OrganizationController::BILLING_ROLES): array { $organizations = collect($this->api($request, '/v1/organizations', [], OrganizationController::class, 'index')) - ->filter(fn (array $organization) => in_array($organization['role'] ?? null, OrganizationController::BILLING_ROLES, true)) + ->filter(fn (array $organization) => $roles === null || in_array($organization['role'] ?? null, $roles, true)) ->values(); $selected = $slug !== null diff --git a/app/Http/Resources/LicenceResource.php b/app/Http/Resources/LicenceResource.php index 357c252..683b8de 100644 --- a/app/Http/Resources/LicenceResource.php +++ b/app/Http/Resources/LicenceResource.php @@ -16,6 +16,7 @@ public function toArray($request): array 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'organization' => $this->whenLoaded('organization', fn () => $this->organization ? ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name] : null), 'purchase' => $this->whenLoaded('purchase', fn () => $this->purchase?->public_id), 'kind' => $this->kind, 'status' => $this->status, diff --git a/app/Mail/DisputeOpenedMail.php b/app/Mail/DisputeOpenedMail.php index 49c9804..48d8bf8 100644 --- a/app/Mail/DisputeOpenedMail.php +++ b/app/Mail/DisputeOpenedMail.php @@ -28,7 +28,7 @@ public function envelope(): Envelope public function content(): Content { - $this->purchase->loadMissing(['extension.organization', 'buyer']); + $this->purchase->loadMissing(['extension.organization', 'buyer', 'licence']); $dispute = (array) ($this->purchase->meta['dispute'] ?? []); return new Content( @@ -41,6 +41,7 @@ public function content(): Content 'reason' => $dispute['reason'] ?? null, 'disputeId' => $dispute['id'] ?? null, 'purchaseId' => $this->purchase->public_id, + 'licenceId' => $this->purchase->licence?->public_id, ], ); } diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 0be5547..9ae2872 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -102,6 +102,50 @@ public static function findByToken(string $plain): ?static ->first(); } + /** + * The instance's registered host name, lowercased, whether `host` was stored as a URL or a + * bare host (with or without a port). + */ + public function hostname(): ?string + { + $host = trim((string) $this->host); + if ($host === '') { + return null; + } + + $name = parse_url(str_contains($host, '://') ? $host : 'http://'.$host, PHP_URL_HOST); + + return is_string($name) && $name !== '' ? strtolower($name) : null; + } + + /** + * A URL on this instance's registered host or its parent domain: an instance registered as + * `api.acme.com` may send buyers back to `acme.com` or `console.acme.com`. IP addresses and + * single-label hosts (`localhost`) only match themselves. + */ + public function ownsUrl(string $url): bool + { + $registered = $this->hostname(); + $host = parse_url($url, PHP_URL_HOST); + if ($registered === null || ! is_string($host) || $host === '') { + return false; + } + + $host = strtolower($host); + if ($host === $registered) { + return true; + } + + $labels = explode('.', $registered); + if (filter_var($registered, FILTER_VALIDATE_IP) || count($labels) < 2) { + return false; + } + + $base = count($labels) > 2 ? implode('.', array_slice($labels, 1)) : $registered; + + return $host === $base || str_ends_with($host, '.'.$base); + } + public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; diff --git a/resources/js/components/commerce/CommercePages.test.jsx b/resources/js/components/commerce/CommercePages.test.jsx index 54c3555..6e506dc 100644 --- a/resources/js/components/commerce/CommercePages.test.jsx +++ b/resources/js/components/commerce/CommercePages.test.jsx @@ -150,6 +150,15 @@ describe('Billing', () => { expect(screen.getByText(/v1\.2\.0/)).toBeInTheDocument(); }); + it('shows members their licences but not the purchases', () => { + const member = { ...acme, role: 'member' }; + render(); + + expect(screen.getByText('Purchases and receipts are for Acme Fleet Tools’s owners, admins and billing members.')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Receipt/ })).not.toBeInTheDocument(); + expect(screen.getByText('1 of 5 instances')).toBeInTheDocument(); + }); + it('shows Finalizing and polls the hand-off until the purchase lands', async () => { vi.useFakeTimers(); const checkout = { handoff: 'chk_1', status: 'open', extension: { slug: 'route-optimizer', name: 'Route Optimizer' } }; diff --git a/resources/js/pages/Account/Billing.jsx b/resources/js/pages/Account/Billing.jsx index 4dc21b0..1f945f5 100644 --- a/resources/js/pages/Account/Billing.jsx +++ b/resources/js/pages/Account/Billing.jsx @@ -17,7 +17,7 @@ export const FINALIZE_TIMEOUT_MS = 60_000; * licences with the instances holding a seat. After a website checkout Stripe returns here with * `?handoff=`, and the page shows Finalizing until the webhook has created the purchase. */ -export default function Billing({ organizations = [], organization, purchases = [], licences = [], checkout }) { +export default function Billing({ organizations = [], organization, purchases = null, licences = [], checkout }) { return (
@@ -34,12 +34,18 @@ export default function Billing({ organizations = [], organization, purchases = )} {!organization ? ( - Billing is for organization owners, admins and billing members. + You are not a member of an organization yet. ) : ( <> {checkout && } - + {purchases ? ( + + ) : ( + + Purchases and receipts are for {organization.name}’s owners, admins and billing members. + + )} )} diff --git a/resources/views/mail/dispute-opened.blade.php b/resources/views/mail/dispute-opened.blade.php index fca0664..55f92d0 100644 --- a/resources/views/mail/dispute-opened.blade.php +++ b/resources/views/mail/dispute-opened.blade.php @@ -1,7 +1,7 @@ # A purchase of {{ $extension }} is disputed -{{ $buyer ?? 'A buyer' }} disputed a payment of {{ $amount }} to {{ $publisher ?? 'the publisher' }}{{ $reason ? ' (reason: '.$reason.')' : '' }}. The purchase is flagged and its licence stays active while the dispute is open. +{{ $buyer ?? 'A buyer' }} disputed a payment of {{ $amount }} to {{ $publisher ?? 'the publisher' }}{{ $reason ? ' (reason: '.$reason.')' : '' }}. The purchase is flagged and its licence is revoked. If the dispute is won, reinstate the licence{{ $licenceId ? ' (`'.$licenceId.'`)' : '' }} with `POST /v1/admin/licences/{id}/reinstate`. Respond in the Stripe Dashboard{{ $disputeId ? ' (dispute `'.$disputeId.'`)' : '' }}. diff --git a/routes/api.php b/routes/api.php index d24af1a..8d819e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Account\InstanceCodeController; use App\Http\Controllers\Account\TokenController; use App\Http\Controllers\Admin\FacilitatorFeeController; +use App\Http\Controllers\Admin\LicenceController as AdminLicenceController; use App\Http\Controllers\Admin\ReviewController; use App\Http\Controllers\Auth\AuthController; use App\Http\Controllers\Auth\PasswordResetController; @@ -143,6 +144,10 @@ Route::post('releases/{release}/publish', [ReviewController::class, 'publish'])->name('releases.publish'); Route::post('extensions/{slug}/suspend', [ReviewController::class, 'suspend'])->name('extensions.suspend'); Route::post('extensions/{slug}/unsuspend', [ReviewController::class, 'unsuspend'])->name('extensions.unsuspend'); + Route::get('licences', [AdminLicenceController::class, 'index'])->name('licences.index'); + Route::post('licences/{licence}/revoke', [AdminLicenceController::class, 'revoke'])->name('licences.revoke'); + Route::post('licences/{licence}/reinstate', [AdminLicenceController::class, 'reinstate'])->name('licences.reinstate'); + Route::patch('licences/{licence}', [AdminLicenceController::class, 'update'])->name('licences.update'); Route::get('facilitator-fees', [FacilitatorFeeController::class, 'index'])->name('facilitator-fees.index'); Route::post('facilitator-fees', [FacilitatorFeeController::class, 'store'])->name('facilitator-fees.store'); }); diff --git a/tests/Feature/Commerce/AdminLicenceTest.php b/tests/Feature/Commerce/AdminLicenceTest.php new file mode 100644 index 0000000..a5e7f73 --- /dev/null +++ b/tests/Feature/Commerce/AdminLicenceTest.php @@ -0,0 +1,79 @@ + $listing->uuid, 'organization_uuid' => $organization->uuid, 'kind' => Licence::KIND_PAID, 'max_activations' => $seats, 'status' => Licence::STATUS_ACTIVE]); +} + +it('lets staff find, revoke and reinstate a licence', function () { + $licence = staffManagedLicence(); + $staff = staffAccount(); + actingAsAccount($staff); + + $this->getJson('/v1/admin/licences?organization='.$licence->organization->slug) + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $licence->public_id) + ->assertJsonPath('0.organization.slug', $licence->organization->slug); + + $this->postJson("/v1/admin/licences/{$licence->public_id}/revoke", [])->assertStatus(422); + $this->postJson("/v1/admin/licences/{$licence->public_id}/revoke", ['reason' => 'Chargeback abuse']) + ->assertOk() + ->assertJsonPath('status', 'revoked') + ->assertJsonPath('revoked_reason', 'staff'); + $this->postJson("/v1/admin/licences/{$licence->public_id}/revoke", ['reason' => 'again'])->assertStatus(409); + expect($licence->refresh()->isActive())->toBeFalse() + ->and($licence->meta['revoked_note'])->toBe('Chargeback abuse'); + + $this->postJson("/v1/admin/licences/{$licence->public_id}/reinstate", ['reason' => 'Dispute won']) + ->assertOk() + ->assertJsonPath('status', 'active'); + expect($licence->refresh()->isActive())->toBeTrue() + ->and(Event::whereIn('kind', ['licence.revoked', 'licence.reinstated'])->orderBy('id')->pluck('kind')->all())->toBe(['licence.revoked', 'licence.reinstated']); +}); + +it('reinstates a licence revoked by a dispute', function () { + Mail::fake(); + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->assertCreated(); + $session = Checkout::sole()->stripe_checkout_session_id; + stripeWebhook('checkout.session.completed', stripeFixture('checkout_session', ['id' => $session]))->assertOk(); + stripeWebhook('charge.dispute.created', stripeFixture('dispute'))->assertOk(); + $licence = Licence::sole(); + expect($licence->status)->toBe(Licence::STATUS_REVOKED); + + actingAsAccount(staffAccount()); + $this->postJson("/v1/admin/licences/{$licence->public_id}/reinstate")->assertOk()->assertJsonPath('status', 'active'); + expect(Event::where('kind', 'licence.reinstated')->sole()->payload['was'])->toBe('disputed'); +}); + +it('lets staff change seats, never below the seats in use', function () { + $licence = staffManagedLicence(); + $licence->activations()->create(['instance_uuid' => buyerInstance($licence->organization)->uuid, 'activated_at' => now()]); + $licence->activations()->create(['instance_uuid' => buyerInstance($licence->organization)->uuid, 'activated_at' => now()]); + actingAsAccount(staffAccount()); + + $this->patchJson("/v1/admin/licences/{$licence->public_id}", ['max_activations' => 1])->assertStatus(422); + $this->patchJson("/v1/admin/licences/{$licence->public_id}", ['max_activations' => 10])->assertOk()->assertJsonPath('max_activations', 10); + + expect(Event::where('kind', 'licence.seats_changed')->sole()->payload)->toBe(['from' => 5, 'to' => 10]); +}); + +it('keeps licence management to staff', function () { + $licence = staffManagedLicence(); + [$account] = buyerOrganization(); + actingAsAccount($account); + + $this->getJson('/v1/admin/licences')->assertForbidden(); + $this->postJson("/v1/admin/licences/{$licence->public_id}/revoke", ['reason' => 'x'])->assertForbidden(); +}); diff --git a/tests/Feature/Commerce/CheckoutTest.php b/tests/Feature/Commerce/CheckoutTest.php index d506afc..92580e8 100644 --- a/tests/Feature/Commerce/CheckoutTest.php +++ b/tests/Feature/Commerce/CheckoutTest.php @@ -16,7 +16,7 @@ $response = $this->postJson('/v1/instances/me/purchases/checkout', [ 'extension' => packageName($listing, 'npm'), - 'return_url' => 'https://console.globex.example/extensions/purchased?x=1', + 'return_url' => 'https://console.example/extensions/purchased?x=1', 'email' => 'ops@globex.example', ])->assertCreated(); @@ -44,8 +44,8 @@ expect($params['mode'])->toBe('payment') ->and($params['line_items'])->toBe([['price' => 'price_1QfLmTRegular0001', 'quantity' => 1]]) - ->and($params['success_url'])->toBe("https://console.globex.example/extensions/purchased?x=1&handoff={$checkout->public_id}") - ->and($params['cancel_url'])->toBe("https://console.globex.example/extensions/purchased?x=1&handoff={$checkout->public_id}&cancelled=1") + ->and($params['success_url'])->toBe("https://console.example/extensions/purchased?x=1&handoff={$checkout->public_id}") + ->and($params['cancel_url'])->toBe("https://console.example/extensions/purchased?x=1&handoff={$checkout->public_id}&cancelled=1") ->and($params['client_reference_id'])->toBe($checkout->public_id) ->and($params['customer_email'])->toBe('ops@globex.example') ->and($params['metadata'])->toBe($metadata) @@ -124,11 +124,6 @@ return "{$publisher->name} publishes {$listing->name} and already has access to it."; }, 409, ''], - 'already licensed' => [function ($listing, $organization) { - Licence::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $organization->uuid, 'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE]); - - return "{$organization->name} already holds a licence for {$listing->name}. Ask Fleetbase staff for more seats."; - }, 409, ''], 'payouts not ready' => [function ($listing, $organization, $instance, $publisher) { $publisher->forceFill(['meta' => ['stripe' => ['transfers' => 'inactive']], 'payout_status' => Organization::PAYOUT_RESTRICTED])->save(); @@ -151,6 +146,45 @@ ->and(stripe()->sent('post', '/v1/checkout/sessions')[0]['params']['line_items'][0]['price'])->toBe($price->stripe_price_id); }); +it('sells another licence to an organization that already holds one', function () { + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + Licence::create(['extension_uuid' => $listing->uuid, 'organization_uuid' => $organization->uuid, 'kind' => Licence::KIND_PAID, 'max_activations' => 5, 'status' => Licence::STATUS_ACTIVE]); + actingAsInstance(buyerInstance($organization)); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x'])->assertCreated(); +}); + +it('sends buyers back only to the instance host or its domain', function (?string $host, string $url, bool $allowed) { + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization, host: $host)); + + $response = $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => $url]); + + $allowed ? $response->assertCreated() : $response->assertStatus(422); +})->with([ + 'same host' => ['https://api.acme.com', 'https://api.acme.com/console/extensions', true], + 'sibling subdomain' => ['https://api.acme.com', 'https://console.acme.com/extensions', true], + 'parent domain' => ['api.acme.com:8000', 'https://acme.com/x', true], + 'other domain' => ['https://api.acme.com', 'https://evil.example/x', false], + 'look-alike suffix' => ['https://api.acme.com', 'https://notacme.com/x', false], + 'localhost with port' => ['http://localhost:8000', 'http://localhost:4200/extensions', true], + 'localhost elsewhere' => ['http://localhost:8000', 'http://127.0.0.1:4200/x', false], + 'no registered host' => [null, 'https://console.example/x', false], +]); + +it('checks the cancel URL as well', function () { + [, , $listing] = sellableListing(); + [, $organization] = buyerOrganization(); + actingAsInstance(buyerInstance($organization)); + + $this->postJson('/v1/instances/me/purchases/checkout', ['extension' => $listing->slug, 'return_url' => 'https://console.example/x', 'cancel_url' => 'https://evil.example/x']) + ->assertStatus(422) + ->assertExactJson(['errors' => ["Return URLs must be on this instance's host (console.example) or its domain."]]); + expect(Checkout::count())->toBe(0); +}); + it('validates the instance checkout request', function () { [, $organization] = buyerOrganization(); actingAsInstance(buyerInstance($organization)); diff --git a/tests/Feature/Commerce/CommercePagesTest.php b/tests/Feature/Commerce/CommercePagesTest.php index fb58783..a0b8f74 100644 --- a/tests/Feature/Commerce/CommercePagesTest.php +++ b/tests/Feature/Commerce/CommercePagesTest.php @@ -41,7 +41,7 @@ ->where('error', 'Stripe could not complete the request: Account not found.')); }); -it('lists only organizations where the account sees money', function () { +it('lists only organizations where the account sees money on Payouts, and every organization on Billing', function () { $account = Account::factory()->create(); [, $organization] = buyerOrganization(); $account->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_PUBLISHER]); @@ -49,7 +49,12 @@ $this->get('/studio/payouts')->assertOk()->assertInertia(fn (Assert $page) => $page->where('organizations', [])->where('organization', null)); $this->get("/studio/payouts/{$organization->slug}")->assertNotFound(); - $this->get('/account/billing')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Account/Billing')->where('organization', null)); + // Billing shows its licences, not its purchases. + $this->get("/account/billing/{$organization->slug}")->assertOk()->assertInertia(fn (Assert $page) => $page + ->component('Account/Billing') + ->where('organization.slug', $organization->slug) + ->where('purchases', null) + ->where('licences', [])); }); it('renders account billing with purchases, licences and the returning hand-off', function () { diff --git a/tests/Feature/Commerce/PayoutsAndBillingTest.php b/tests/Feature/Commerce/PayoutsAndBillingTest.php index 1af2140..d1c55b0 100644 --- a/tests/Feature/Commerce/PayoutsAndBillingTest.php +++ b/tests/Feature/Commerce/PayoutsAndBillingTest.php @@ -124,7 +124,7 @@ function recordedSale(Extension $listing, Organization $buyer, int $total = 5292 ]); }); -it('keeps payouts and billing to owners, admins and billing members', function (string $role, int $status) { +it('keeps payouts and purchases to owners, admins and billing members, and shows licences to every member', function (string $role, int $status) { [, $publisher] = sellableListing(); $member = Account::factory()->create(); $member->organizations()->attach($publisher->uuid, ['role' => $role]); @@ -132,7 +132,7 @@ function recordedSale(Extension $listing, Organization $buyer, int $total = 5292 $this->getJson("/v1/organizations/{$publisher->slug}/payouts/transactions")->assertStatus($status); $this->getJson("/v1/organizations/{$publisher->slug}/billing/purchases")->assertStatus($status); - $this->getJson("/v1/organizations/{$publisher->slug}/billing/licences")->assertStatus($status); + $this->getJson("/v1/organizations/{$publisher->slug}/billing/licences")->assertOk(); })->with([ 'billing' => [Membership::ROLE_BILLING, 200], 'admin' => [Membership::ROLE_ADMIN, 200], diff --git a/tests/Feature/Commerce/WebhookTest.php b/tests/Feature/Commerce/WebhookTest.php index 1d71936..d356e90 100644 --- a/tests/Feature/Commerce/WebhookTest.php +++ b/tests/Feature/Commerce/WebhookTest.php @@ -199,7 +199,7 @@ function completedSession(Checkout $checkout, array $overrides = []): array expect(Purchase::count())->toBe(0); }); -it('flags a disputed purchase and tells staff', function () { +it('flags a disputed purchase, revokes its licence and tells staff', function () { config(['registry.commerce.notify' => ['finance@fleetbase.example']]); [$checkout] = startedCheckout(); stripeWebhook('checkout.session.completed', completedSession($checkout))->assertOk(); @@ -209,7 +209,8 @@ function completedSession(Checkout $checkout, array $overrides = []): array $purchase = Purchase::sole(); expect($purchase->status)->toBe(Purchase::STATUS_DISPUTED) ->and($purchase->meta['dispute'])->toMatchArray(['id' => 'dp_1QfLmTDispute0001', 'reason' => 'fraudulent', 'status' => 'needs_response', 'amount' => 5292]) - ->and(Licence::sole()->isActive())->toBeTrue(); + ->and(Licence::sole()->status)->toBe(Licence::STATUS_REVOKED) + ->and(Licence::sole()->meta['revoked_reason'])->toBe('disputed'); Mail::assertSent(DisputeOpenedMail::class, fn ($mail) => $mail->hasTo('finance@fleetbase.example')); }); diff --git a/tests/Helpers/commerce.php b/tests/Helpers/commerce.php index 7299051..be55a8b 100644 --- a/tests/Helpers/commerce.php +++ b/tests/Helpers/commerce.php @@ -42,9 +42,9 @@ function buyerOrganization(string $role = Membership::ROLE_OWNER): array } /** - * A registered instance of the organization. + * A registered instance of the organization, at `console.example` unless told otherwise. */ -function buyerInstance(Organization $organization, string $kind = Instance::KIND_SELF_HOSTED): Instance +function buyerInstance(Organization $organization, string $kind = Instance::KIND_SELF_HOSTED, ?string $host = 'https://console.example'): Instance { - return Instance::factory()->create(['organization_uuid' => $organization->uuid, 'kind' => $kind]); + return Instance::factory()->create(['organization_uuid' => $organization->uuid, 'kind' => $kind, 'host' => $host]); } From f3d9a2f19000a0e06d88dc2f792ec927534338d0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 16:19:48 +0800 Subject: [PATCH 05/10] Add staff finance: fee revenue, refunds, disputes and per-publisher totals, monthly CSV, refunds through Stripe, and the Finance page (Phase 4 M) --- README.md | 30 +- app/Commerce/FinanceReport.php | 167 +++++++ app/Commerce/Refunds.php | 88 ++++ .../Controllers/Admin/FinanceController.php | 141 ++++++ .../Controllers/Web/ReviewPageController.php | 47 +- .../Resources/FinancePurchaseResource.php | 45 ++ .../js/components/review/Finance.test.jsx | 162 +++++++ resources/js/components/review/Parts.jsx | 5 +- resources/js/components/review/finance.js | 49 +++ resources/js/pages/Review/Finance.jsx | 413 ++++++++++++++++++ routes/api.php | 7 +- routes/web.php | 1 + tests/Feature/Commerce/FinanceTest.php | 277 ++++++++++++ .../Commerce/PayoutsAndBillingTest.php | 24 - tests/Fixtures/stripe/refund.json | 17 + tests/Helpers/commerce.php | 25 ++ tests/Helpers/stripe.php | 7 + 17 files changed, 1474 insertions(+), 31 deletions(-) create mode 100644 app/Commerce/FinanceReport.php create mode 100644 app/Commerce/Refunds.php create mode 100644 app/Http/Controllers/Admin/FinanceController.php create mode 100644 app/Http/Resources/FinancePurchaseResource.php create mode 100644 resources/js/components/review/Finance.test.jsx create mode 100644 resources/js/components/review/finance.js create mode 100644 resources/js/pages/Review/Finance.jsx create mode 100644 tests/Feature/Commerce/FinanceTest.php create mode 100644 tests/Fixtures/stripe/refund.json diff --git a/README.md b/README.md index bbf4f24..0abf433 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Phase 1, Workstreams A to C, and Phase 2 Workstream E: | Releases pipeline: publisher API, upload, queued checks, state machine, D6 review policy, staff review API, publishing | done | | Website backend (Workstream F): cookie sessions, organizations and members, listing media and pricing, staff downloads and review items | done | | Website (Workstream F): Inertia (React) catalog, account, Studio and review console, light and dark | done | -| Commerce (Phase 4, K to M): Stripe Connect Express, prices mirrored to Stripe, Checkout from instances and the website, webhooks, payouts and billing APIs and pages | done | +| Commerce (Phase 4, K to M): Stripe Connect Express, prices mirrored to Stripe, Checkout from instances and the website, webhooks, payouts and billing APIs and pages, staff finance and refunds | done | | CLI v2 (G), staff finance view | later workstreams | ## Stack @@ -137,7 +137,7 @@ a website page (unknown paths render the website's 404). | `/account/billing/{slug?}[?handoff=]` | Billing: purchases, receipts, licences and their instances; Finalizing after a website checkout | | `/studio`, `/studio/new`, `/studio/{slug}` (listing), `/studio/{slug}/releases[/{id}]`, `/studio/{slug}/review`, `/studio/{slug}/history`, `/studio/insights` | Publisher Studio | | `/studio/payouts/{slug?}` | Payouts: Stripe onboarding and account management (Connect.js), balance, schedule, sales and CSV | -| `/review`, `/review/publish`, `/review/releases/{id}` | Staff review console | +| `/review`, `/review/publish`, `/review/releases/{id}`, `/review/finance` | Staff review console (finance: fee revenue, refunds, disputes, per-publisher totals, refunds) | ## API overview @@ -381,6 +381,7 @@ checks are emailed too. | `GET /v1/admin/review-items` | The review requirements R1 to R6 (`registry.review.items`); approve and reject accept only these codes | | `GET /v1/admin/facilitator-fees` | The fee in effect (`current_rate_bps`), the configured default, and every scheduled fee, newest first | | `POST /v1/admin/facilitator-fees` | `rate_bps` (0 to 5000; 1000 = 10%), optional `effective_from` (now or later, never earlier) and `note`. Adds a row; rows are never edited | +| `GET /v1/admin/finance`, `/finance/purchases`, `/finance/monthly.csv`, `POST /v1/admin/purchases/{id}/refund` | Staff finance and refunds; see Commerce | ### Instance API (instance token) @@ -473,6 +474,31 @@ cannot change, so a new amount or currency creates a new price and archives the | `POST /v1/purchases/checkout` | owners, admins, billing of the buyer | Website checkout: `extension`, `organization`. Returns a hand-off (below); Stripe returns the buyer to `/account/billing/{slug}?handoff=` | | `GET /v1/purchases/{handoff}` | members of the buyer | The hand-off | +### Staff finance and refunds (admin middleware: `review` scope, `is_staff` organization) + +The finance view reads the registry's own `purchases`, not Stripe, so it works without Stripe +and matches what the webhooks recorded. A purchase counts in the range its sale date falls in, +with its refunds and dispute, so a November refund of an October sale changes October. Amounts +are minor units per currency. Every bucket carries: + +| Field | Meaning | +| --- | --- | +| `sales`, `gross` | Purchases and `amount_total` (tax included) | +| `refunds`, `refunded` | Purchases with a refund, and `meta.amount_refunded` (all of `gross` when `refunded` without it) | +| `fee`, `fee_refunded`, `fee_revenue` | `fee_amount`; the fee returned on refunds, in proportion to the amount refunded (`refund_application_fee`); fee less fee returned | +| `publisher_net` | `net_amount` less the publisher's share of refunds (the reversed transfer) | +| `disputes`, `disputed` | Purchases with `meta.dispute`, and the disputed amount. Not subtracted: the outcome is decided in Stripe | + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/admin/finance?from=&to=` | `totals` per currency, `months` per month and currency, `publishers` per organization and currency (highest fee revenue first). Default range: the last 12 months including this one | +| `GET /v1/admin/finance/purchases?from=&to=&status=&publisher=&extension=&page=` | Purchases newest first: publisher, customer, gross, fee, net, `refunded`, `fee_refunded`, `refundable` (what is left; 0 while disputed), `dispute`, Stripe ids | +| `GET /v1/admin/finance/monthly.csv?from=&to=[&by=publisher]` | One row per month and currency (and publisher), the fields above in major units | +| `POST /v1/admin/purchases/{id}/refund` | `amount` (minor units, defaults to what is left), `reason` (`requested_by_customer`, `duplicate`, `fraudulent`), `note`. Creates a Stripe refund on the payment intent with `reverse_transfer` and `refund_application_fee`, so the publisher and Fleetbase each give back their share; records `purchase.refund_requested`. Answers 202: the purchase turns `refunded` and its licence `revoked` when `charge.refunded` arrives. 409 when already refunded or disputed (answer disputes in the Stripe Dashboard), 422 above what is left, 502 when Stripe refuses | + +Refund from the Finance page (`/review/finance`) rather than the Stripe Dashboard: a Dashboard +refund does not reverse the transfer or return the fee unless both boxes are ticked. + ### Webhooks `POST /v1/webhooks/stripe` checks the `Stripe-Signature` header against each secret (400 when diff --git a/app/Commerce/FinanceReport.php b/app/Commerce/FinanceReport.php new file mode 100644 index 0000000..7b4d84f --- /dev/null +++ b/app/Commerce/FinanceReport.php @@ -0,0 +1,167 @@ +endOfDay() : now()->endOfDay(); + $start = $from ? Carbon::parse($from)->startOfDay() : $end->copy()->subMonthsNoOverflow(11)->startOfMonth(); + + return new static($start, $end); + } + + public function purchases(): Builder + { + return Purchase::query()->whereBetween('created_at', [$this->from, $this->to]); + } + + /** + * Totals per currency, per month and currency, and per publisher and currency (highest fee + * revenue first). + * + * @return array{from: string, to: string, totals: list, months: list, publishers: list} + */ + public function summary(): array + { + $buckets = $this->aggregate(); + + return [ + 'from' => $this->from->toDateString(), + 'to' => $this->to->toDateString(), + 'totals' => $buckets['totals'], + 'months' => $buckets['months'], + 'publishers' => $buckets['publishers'], + ]; + } + + /** + * Rows per month, publisher and currency: oldest month first, highest fee revenue first within it. + * + * @return list + */ + public function monthlyByPublisher(): array + { + return $this->aggregate()['month_publishers']; + } + + /** + * @return array{totals: list, months: list, publishers: list, month_publishers: list} + */ + protected function aggregate(): array + { + $totals = $months = $publishers = $monthPublishers = []; + + $this->purchases() + ->with(['extension' => fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()])]) + ->lazyById(500) + ->each(function (Purchase $purchase) use (&$totals, &$months, &$publishers, &$monthPublishers) { + $figures = static::figures($purchase); + $currency = $purchase->currency; + $month = $purchase->created_at->format('Y-m'); + $organization = $purchase->extension?->organization; + $publisher = [ + 'organization' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + 'currency' => $currency, + ]; + $key = ($organization?->uuid ?? '').'|'.$currency; + + $totals[$currency] = static::add($totals[$currency] ?? ['currency' => $currency] + static::zero(), $figures); + $months["{$month}|{$currency}"] = static::add($months["{$month}|{$currency}"] ?? ['month' => $month, 'currency' => $currency] + static::zero(), $figures); + $publishers[$key] = static::add($publishers[$key] ?? $publisher + static::zero(), $figures); + $monthPublishers[$month][$key] = static::add($monthPublishers[$month][$key] ?? ['month' => $month] + $publisher + static::zero(), $figures); + }); + + ksort($totals); + ksort($months); + ksort($monthPublishers); + + return [ + 'totals' => array_values($totals), + 'months' => array_values($months), + 'publishers' => static::ranked($publishers), + 'month_publishers' => array_merge([], ...array_map(fn (array $rows) => static::ranked($rows), array_values($monthPublishers))), + ]; + } + + /** + * One purchase's figures. A refund returns the application fee in proportion to the amount + * refunded (as `refund_application_fee` does) and takes the rest back from the publisher + * (`reverse_transfer`). A dispute is counted, not subtracted: its outcome is decided in Stripe. + * + * @return array + */ + public static function figures(Purchase $purchase): array + { + $gross = (int) $purchase->amount_total; + $fee = (int) $purchase->fee_amount; + $net = (int) $purchase->net_amount; + $refunded = min($gross, (int) ($purchase->meta['amount_refunded'] ?? 0)); + if ($purchase->status === Purchase::STATUS_REFUNDED && $refunded === 0) { + $refunded = $gross; + } + $feeRefunded = $gross > 0 ? min($fee, (int) round($fee * $refunded / $gross)) : 0; + $dispute = $purchase->meta['dispute'] ?? null; + + return [ + 'sales' => 1, + 'gross' => $gross, + 'refunds' => $refunded > 0 ? 1 : 0, + 'refunded' => $refunded, + 'fee' => $fee, + 'fee_refunded' => $feeRefunded, + 'fee_revenue' => $fee - $feeRefunded, + 'publisher_net' => $net - ($refunded - $feeRefunded), + 'disputes' => is_array($dispute) ? 1 : 0, + 'disputed' => is_array($dispute) ? (int) ($dispute['amount'] ?? $gross) : 0, + ]; + } + + /** + * @return array + */ + protected static function zero(): array + { + return array_fill_keys(self::FIELDS, 0); + } + + /** + * Highest fee revenue first, then highest gross. + */ + protected static function ranked(array $rows): array + { + usort($rows, fn (array $a, array $b) => [$b['fee_revenue'], $b['gross']] <=> [$a['fee_revenue'], $a['gross']]); + + return $rows; + } + + protected static function add(array $bucket, array $figures): array + { + foreach (self::FIELDS as $field) { + $bucket[$field] += $figures[$field]; + } + + return $bucket; + } +} diff --git a/app/Commerce/Refunds.php b/app/Commerce/Refunds.php new file mode 100644 index 0000000..61f119f --- /dev/null +++ b/app/Commerce/Refunds.php @@ -0,0 +1,88 @@ +stripe_payment_intent_id || in_array($purchase->status, [Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED], true)) { + return 0; + } + + return max(0, (int) $purchase->amount_total - (int) ($purchase->meta['amount_refunded'] ?? 0)); + } + + /** + * Refund `$amount` (minor units; null refunds what is left) and record who asked and why. + */ + public function refund(Purchase $purchase, Account $staff, ?int $amount = null, ?string $reason = null, ?string $note = null): Refund + { + if (! $purchase->stripe_payment_intent_id) { + throw new UnprocessableEntityHttpException('This purchase has no Stripe payment to refund.'); + } + if ($purchase->status === Purchase::STATUS_REFUNDED) { + throw new ConflictHttpException('This purchase is already refunded.'); + } + if ($purchase->status === Purchase::STATUS_DISPUTED) { + throw new ConflictHttpException('This purchase is disputed. Answer the dispute in the Stripe Dashboard; Stripe does not refund a disputed payment.'); + } + + $left = static::refundable($purchase); + if ($left === 0) { + throw new ConflictHttpException('Nothing is left to refund on this purchase.'); + } + $amount ??= $left; + if ($amount > $left) { + throw ValidationException::withMessages(['amount' => 'At most '.number_format($left / 100, 2).' '.$purchase->currency.' can be refunded.']); + } + + $params = array_filter([ + 'payment_intent' => $purchase->stripe_payment_intent_id, + 'amount' => $amount, + 'reason' => $reason, + 'reverse_transfer' => true, + 'refund_application_fee' => true, + 'metadata' => array_filter([ + 'purchase' => $purchase->public_id, + 'refunded_by' => $staff->public_id, + 'note' => $note, + ]), + ], fn ($value) => $value !== null); + + // A repeated click with the same purchase state and request gets the same refund back. + $key = 'refund:'.$purchase->uuid.':'.(int) ($purchase->meta['amount_refunded'] ?? 0).':'.sha1(json_encode($params)); + $refund = Stripe::client()->refunds->create($params, ['idempotency_key' => $key]); + + Event::record('purchase.refund_requested', $purchase, $staff, array_filter([ + 'refund' => $refund->id, + 'amount' => (int) $refund->amount, + 'currency' => strtoupper((string) $refund->currency), + 'status' => $refund->status, + 'reason' => $reason, + 'note' => $note, + ], fn ($value) => $value !== null)); + + return $refund; + } +} diff --git a/app/Http/Controllers/Admin/FinanceController.php b/app/Http/Controllers/Admin/FinanceController.php new file mode 100644 index 0000000..3878ec8 --- /dev/null +++ b/app/Http/Controllers/Admin/FinanceController.php @@ -0,0 +1,141 @@ +json($this->report($request)->summary()); + } + + /** + * GET /v1/admin/finance/purchases[?from=&to=&status=&publisher=&extension=&page=] — newest first. + */ + public function purchases(Request $request): JsonResponse + { + $request->validate([ + 'status' => ['nullable', 'string', Rule::in([Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED])], + 'publisher' => ['nullable', 'string', 'max:191'], + 'extension' => ['nullable', 'string', 'max:191'], + ]); + + $page = $this->report($request)->purchases() + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('publisher'), fn (Builder $q, $publisher) => $q->whereHas('extension', fn (Builder $e) => $e->withTrashed() + ->whereHas('organization', fn (Builder $o) => $o->withTrashed()->where(fn (Builder $w) => $w->where('slug', $publisher)->orWhere('public_id', $publisher))))) + ->when($request->input('extension'), fn (Builder $q, $extension) => $q->whereHas('extension', fn (Builder $e) => $e->withTrashed() + ->where(fn (Builder $w) => $w->where('slug', $extension)->orWhere('public_id', $extension)))) + ->with(['buyer' => fn ($q) => $q->withTrashed(), 'extension' => fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()])]) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(min(100, max(1, (int) $request->input('per_page', self::PER_PAGE)))); + + return response()->json([ + 'data' => FinancePurchaseResource::collection($page->getCollection())->resolve($request), + 'meta' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]); + } + + /** + * GET /v1/admin/finance/monthly.csv[?from=&to=&by=publisher] — one row per month and currency + * (and publisher with `by=publisher`), amounts in major units. + */ + public function export(Request $request): StreamedResponse + { + $request->validate(['by' => ['nullable', 'string', 'in:publisher']]); + $report = $this->report($request); + $byPublisher = $request->input('by') === 'publisher'; + $rows = $byPublisher ? $report->monthlyByPublisher() : $report->summary()['months']; + $name = 'fleetbase-finance-'.$report->from->format('Y-m').'-to-'.$report->to->format('Y-m').($byPublisher ? '-by-publisher' : '').'.csv'; + + return response()->streamDownload(function () use ($rows, $byPublisher) { + $out = fopen('php://output', 'w'); + fputcsv($out, array_merge(['month'], $byPublisher ? ['publisher', 'publisher_id'] : [], ['currency'], FinanceReport::FIELDS)); + foreach ($rows as $row) { + fputcsv($out, array_merge( + [$row['month']], + $byPublisher ? [$row['organization']['name'] ?? '', $row['organization']['id'] ?? ''] : [], + [$row['currency']], + array_map(fn (string $field) => in_array($field, ['sales', 'refunds', 'disputes'], true) ? $row[$field] : static::decimal($row[$field]), FinanceReport::FIELDS), + )); + } + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * POST /v1/admin/purchases/{purchase}/refund — `{amount?, reason?, note?}`. `amount` is in minor + * units and defaults to what is left. Answers 202 with Stripe's refund: the purchase turns + * `refunded` (and its licence revoked) when Stripe confirms with `charge.refunded`. + */ + public function refund(Request $request, Refunds $refunds, string $purchase): JsonResponse + { + $request->validate([ + 'amount' => ['nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', Rule::in(Refunds::REASONS)], + 'note' => ['nullable', 'string', 'max:500'], + ]); + + $model = Purchase::findByIdOrFail($purchase, ['buyer', 'extension.organization']); + $refund = $refunds->refund( + $model, + $request->user(), + $request->filled('amount') ? (int) $request->input('amount') : null, + $request->input('reason'), + $request->filled('note') ? trim((string) $request->input('note')) : null, + ); + + return response()->json([ + 'refund' => [ + 'id' => $refund->id, + 'amount' => (int) $refund->amount, + 'currency' => strtoupper((string) $refund->currency), + 'status' => $refund->status, + 'reason' => $refund->reason, + ], + 'purchase' => (new FinancePurchaseResource($model))->resolve($request), + ], 202); + } + + protected function report(Request $request): FinanceReport + { + $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => array_merge(['nullable', 'date'], $request->filled('from') ? ['after_or_equal:from'] : []), + ]); + + return FinanceReport::between($request->input('from'), $request->input('to')); + } + + protected static function decimal(int $minor): string + { + return number_format($minor / 100, 2, '.', ''); + } +} diff --git a/app/Http/Controllers/Web/ReviewPageController.php b/app/Http/Controllers/Web/ReviewPageController.php index be8d9f7..ea298bf 100644 --- a/app/Http/Controllers/Web/ReviewPageController.php +++ b/app/Http/Controllers/Web/ReviewPageController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers\Web; +use App\Commerce\Stripe; +use App\Http\Controllers\Admin\FinanceController; use App\Http\Controllers\Admin\ReviewController; use App\Models\Account; use App\Models\Extension; +use App\Models\Purchase; use App\Models\Release; use App\Models\Review; use Illuminate\Http\Request; @@ -12,8 +15,8 @@ use Inertia\Response; /** - * Fleetbase staff review console: the queue, one release, releases waiting to be published - * and suspended listings. Decisions are POSTs to /v1/admin from the page. + * Fleetbase staff review console: the queue, one release, releases waiting to be published, + * suspended listings and finance. Decisions and refunds are POSTs to /v1/admin from the page. */ class ReviewPageController extends WebController { @@ -96,6 +99,46 @@ public function release(Request $request, string $release): Response ]); } + /** + * GET /review/finance[?from=&to=&status=&publisher=&page=] — fee revenue, refunds, disputes + * and per-publisher totals over the range, and the purchases in it with a refund action. + */ + public function finance(Request $request): Response + { + $this->authorizeStaff($request); + $range = $this->range($request); + $filters = $range + $this->only($request, ['status', 'publisher']); + $status = in_array($filters['status'] ?? null, [Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED], true) ? $filters['status'] : null; + $filters = array_filter(['status' => $status] + $filters); + + return Inertia::render('Review/Finance', [ + 'summary' => $this->api($request, '/v1/admin/finance', $range, FinanceController::class, 'summary'), + 'purchases' => $this->api($request, '/v1/admin/finance/purchases', $filters + $this->only($request, ['page']), FinanceController::class, 'purchases'), + 'filters' => $filters, + 'stripe' => ['configured' => Stripe::configured()], + 'counts' => $this->counts(), + ]); + } + + /** + * `from` and `to` from the query when they are dates in order; anything else falls back to + * the report's default range. + * + * @return array{from?: string, to?: string} + */ + protected function range(Request $request): array + { + $dates = []; + foreach (['from', 'to'] as $key) { + $value = $request->query($key); + if (is_string($value) && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts) && checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) { + $dates[$key] = $value; + } + } + + return isset($dates['from'], $dates['to']) && $dates['to'] < $dates['from'] ? [] : $dates; + } + protected function authorizeStaff(Request $request): void { $account = $request->user(); diff --git a/app/Http/Resources/FinancePurchaseResource.php b/app/Http/Resources/FinancePurchaseResource.php new file mode 100644 index 0000000..79e6012 --- /dev/null +++ b/app/Http/Resources/FinancePurchaseResource.php @@ -0,0 +1,45 @@ +resource); + $organization = $this->extension?->organization; + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null, + 'publisher' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + 'customer' => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null, + 'gross' => $figures['gross'], + 'fee' => $figures['fee'], + 'net' => (int) $this->net_amount, + 'tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'refunded' => $figures['refunded'], + 'fee_refunded' => $figures['fee_refunded'], + 'refundable' => Refunds::refundable($this->resource), + 'currency' => $this->currency, + 'status' => $this->status, + 'dispute' => is_array($this->meta['dispute'] ?? null) ? $this->meta['dispute'] : null, + 'refunded_at' => $this->meta['refunded_at'] ?? null, + 'stripe' => [ + 'payment_intent' => $this->stripe_payment_intent_id, + 'charge' => $this->meta['stripe_charge_id'] ?? null, + 'account' => $this->meta['stripe_account'] ?? null, + ], + 'created_at' => $this->created_at, + ]; + } +} diff --git a/resources/js/components/review/Finance.test.jsx b/resources/js/components/review/Finance.test.jsx new file mode 100644 index 0000000..2969b7e --- /dev/null +++ b/resources/js/components/review/Finance.test.jsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { vi } from 'vitest'; +import Finance from '../../pages/Review/Finance'; +import { monthLabel, refundError, refundSplit, toMajor, toMinor } from './finance'; + +const { post, reload, get } = vi.hoisted(() => ({ post: vi.fn(), reload: vi.fn(), get: vi.fn() })); + +vi.mock('../../lib/api', () => ({ + api: { post }, + ApiError: class ApiError extends Error {}, +})); + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { auth: { account: { id: 'acc_me', is_staff: true } }, flash: null }, url: '/review/finance' }), + Link: ({ href, children, ...rest }) => ( + + {children} + + ), + Head: () => null, + router: { reload, get, visit: vi.fn() }, +})); + +vi.mock('../../layouts/SiteLayout', async () => { + const { ToastProvider } = await import('../Toasts'); + return { default: ({ children }) => {children} }; +}); + +const ZERO = { sales: 0, gross: 0, refunds: 0, refunded: 0, fee: 0, fee_refunded: 0, fee_revenue: 0, publisher_net: 0, disputes: 0, disputed: 0 }; +const NORTHWIND = { id: 'org_nw', slug: 'northwind', name: 'Northwind Labs' }; + +const SUMMARY = { + from: '2025-11-01', + to: '2026-10-23', + totals: [{ ...ZERO, currency: 'USD', sales: 4, gross: 25876, refunds: 2, refunded: 6292, fee: 2470, fee_refunded: 583, fee_revenue: 1887, publisher_net: 17697, disputes: 1, disputed: 10000 }], + months: [ + { ...ZERO, month: '2026-09', currency: 'USD', sales: 1, gross: 5292, fee_revenue: 397 }, + { ...ZERO, month: '2026-10', currency: 'USD', sales: 3, gross: 20584, fee_revenue: 1490 }, + ], + publishers: [{ ...ZERO, organization: NORTHWIND, currency: 'USD', sales: 3, gross: 15876, fee_revenue: 887 }], +}; + +function purchase(overrides = {}) { + return { + id: 'pur_1', + extension: { id: 'ext_1', slug: 'route-optimizer', name: 'Route Optimizer' }, + publisher: NORTHWIND, + customer: { id: 'org_gx', slug: 'globex', name: 'Globex' }, + gross: 5292, + fee: 490, + net: 4802, + tax: 392, + refunded: 0, + fee_refunded: 0, + refundable: 5292, + currency: 'USD', + status: 'paid', + dispute: null, + created_at: '2026-10-01T12:00:00Z', + ...overrides, + }; +} + +const PURCHASES = { + data: [purchase(), purchase({ id: 'pur_refunded', status: 'refunded', refunded: 5292, refundable: 0 }), purchase({ id: 'pur_disputed', status: 'disputed', refundable: 0, dispute: { reason: 'fraudulent' } })], + meta: { current_page: 1, last_page: 1, per_page: 25, total: 3 }, +}; + +function renderFinance(props = {}) { + return render(); +} + +beforeEach(() => { + post.mockReset(); + reload.mockReset(); + get.mockReset(); +}); + +describe('finance helpers', () => { + it('reads typed amounts as minor units', () => { + expect(toMinor('42.92')).toBe(4292); + expect(toMinor('$42')).toBe(4200); + expect(toMinor('42,5')).toBe(4250); + expect(toMinor('4.999')).toBeNull(); + expect(toMinor('abc')).toBeNull(); + expect(toMajor(4292)).toBe('42.92'); + expect(monthLabel('2026-10')).toBe('Oct 2026'); + expect(monthLabel('2026-09')).toBe('Sep 2026'); + expect(monthLabel('bogus')).toBe('bogus'); + }); + + it('splits a refund between the fee and the publisher in proportion', () => { + expect(refundSplit({ gross: 5292, fee: 490 }, 5292)).toEqual({ fee: 490, publisher: 4802 }); + expect(refundSplit({ gross: 5292, fee: 490 }, 1000)).toEqual({ fee: 93, publisher: 907 }); + expect(refundError('60', 5292, 'USD')).toBe('At most 52.92 USD can be refunded.'); + expect(refundError('0', 5292, 'USD')).toMatch(/Enter the amount/); + expect(refundError('52.92', 5292, 'USD')).toBeNull(); + }); +}); + +describe('Review/Finance', () => { + it('shows totals, months, publishers and CSV links for the range', () => { + renderFinance(); + const totals = document.querySelector('[data-totals="USD"]'); + expect(within(totals).getByText('$18.87')).toBeInTheDocument(); + expect(within(totals).getByText('$24.70 in fees, $5.83 returned on refunds')).toBeInTheDocument(); + expect(within(totals).getByText('$100')).toBeInTheDocument(); + expect(document.querySelector('[data-row="2026-09-USD"]')).toHaveTextContent('Sep 2026 USD'); + expect(within(document.querySelector('[data-row="org_nw-USD"]')).getByText('$8.87')).toBeInTheDocument(); + expect(document.querySelector('[data-csv="monthly"]')).toHaveAttribute('href', '/v1/admin/finance/monthly.csv?from=2025-11-01&to=2026-10-23'); + expect(document.querySelector('[data-csv="publisher"]')).toHaveAttribute('href', '/v1/admin/finance/monthly.csv?from=2025-11-01&to=2026-10-23&by=publisher'); + expect(screen.getByRole('link', { name: 'Finance' })).toHaveAttribute('aria-current', 'page'); + }); + + it('offers Refund only where something is left to refund and Stripe is set up', () => { + const { unmount } = renderFinance(); + expect(document.querySelector('[data-refund="pur_1"]')).toBeInTheDocument(); + expect(document.querySelector('[data-refund="pur_refunded"]')).toBeNull(); + expect(document.querySelector('[data-refund="pur_disputed"]')).toBeNull(); + expect(within(document.querySelector('[data-purchase="pur_disputed"]')).getByText('fraudulent')).toBeInTheDocument(); + unmount(); + + renderFinance({ stripe: { configured: false } }); + expect(document.querySelector('[data-refund]')).toBeNull(); + expect(screen.getByText(/refunds are off/)).toBeInTheDocument(); + }); + + it('filters purchases by status and publisher, keeping the range', () => { + renderFinance({ filters: { from: '2026-10-01', to: '2026-10-31' } }); + fireEvent.change(document.querySelector('[data-filter="status"]'), { target: { value: 'paid' } }); + expect(get).toHaveBeenLastCalledWith('/review/finance', { from: '2026-10-01', to: '2026-10-31', status: 'paid' }, expect.objectContaining({ only: ['purchases', 'filters'] })); + fireEvent.change(document.querySelector('[data-filter="publisher"]'), { target: { value: 'northwind' } }); + expect(get).toHaveBeenLastCalledWith('/review/finance', { from: '2026-10-01', to: '2026-10-31', publisher: 'northwind' }, expect.anything()); + }); + + it('refunds a part, explaining who gives back what, and posts minor units', async () => { + post.mockResolvedValue({ refund: { id: 're_1', amount: 1000, currency: 'USD', status: 'succeeded' } }); + renderFinance(); + fireEvent.click(document.querySelector('[data-refund="pur_1"]')); + + const amount = document.querySelector('[data-refund-amount]'); + expect(amount).toHaveValue('52.92'); + expect(document.querySelector('[data-refund-split]')).toHaveTextContent('Northwind Labs gives back $48.02 and Fleetbase returns $4.90 of its fee.'); + expect(document.querySelector('[data-refund-split]')).toHaveTextContent('the licence is revoked'); + + fireEvent.change(amount, { target: { value: '60' } }); + fireEvent.click(document.querySelector('[data-confirm-refund]')); + expect(screen.getByText('At most 52.92 USD can be refunded.')).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + + fireEvent.change(amount, { target: { value: '10' } }); + expect(document.querySelector('[data-refund-split]')).toHaveTextContent('gives back $9.07 and Fleetbase returns $0.93'); + expect(document.querySelector('[data-refund-split]')).toHaveTextContent('A partial refund leaves the licence active.'); + fireEvent.change(document.querySelector('[data-refund-reason]'), { target: { value: 'duplicate' } }); + fireEvent.change(document.querySelector('[data-refund-note]'), { target: { value: ' Bought twice ' } }); + fireEvent.click(document.querySelector('[data-confirm-refund]')); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/admin/purchases/pur_1/refund', { amount: 1000, reason: 'duplicate', note: 'Bought twice' })); + expect(await screen.findByText('Refund of $10 sent to Stripe. The purchase updates when Stripe confirms it.')).toBeInTheDocument(); + expect(reload).toHaveBeenCalled(); + }); +}); diff --git a/resources/js/components/review/Parts.jsx b/resources/js/components/review/Parts.jsx index 6e2c651..006acc8 100644 --- a/resources/js/components/review/Parts.jsx +++ b/resources/js/components/review/Parts.jsx @@ -9,16 +9,17 @@ import { api } from '../../lib/api'; const SELECTED = { background: 'var(--surface)', color: 'var(--ink)', boxShadow: 'var(--e1), 0 0 0 1px var(--line)' }; -/** The console's sections: Waiting, To publish, Decided, Failed checks. */ +/** The console's sections: Waiting, To publish, Decided, Failed checks, Finance. */ export function ReviewTabs({ current, counts = {} }) { const tabs = [ { key: 'waiting', href: '/review', label: `Waiting · ${counts.waiting ?? 0}` }, { key: 'publish', href: '/review/publish', label: `To publish · ${counts.publish ?? 0}` }, { key: 'decided', href: '/review?tab=decided', label: 'Decided' }, { key: 'failed', href: '/review?tab=failed', label: 'Failed checks' }, + { key: 'finance', href: '/review/finance', label: 'Finance' }, ]; return ( -