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/README.md b/README.md index 89e7681..2250f4a 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, staff finance and refunds | done | +| CLI v2 (G), staff finance view | later workstreams | ## Stack @@ -133,8 +134,10 @@ 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 | -| `/review`, `/review/publish`, `/review/releases/{id}` | Staff review console | +| `/studio/payouts/{slug?}` | Payouts: Stripe onboarding and account management (Connect.js), balance, schedule, sales and CSV | +| `/review`, `/review/publish`, `/review/releases/{id}`, `/review/finance` | Staff review console (finance: fee revenue, refunds, disputes, per-publisher totals, refunds) | ## API overview @@ -210,6 +213,12 @@ default here is 12) because Laravel's `hashed` cast refuses hashes above the con | `GET /v1/catalog/extensions?q=&category=&pricing=&hosting=&console=&compatible=&sort=&page=&limit=` | Paged listings with publisher, category, latest release, install count, compat, price, package names. `console=` adds `compatibility: {console, compatible, newest_compatible}` to each item (does the latest release work, and the newest published release that does); with `compatible=1` only listings that have such a release | | `GET /v1/catalog/extensions/{slug}` | Full listing, screenshots, publisher, versions summary | | `GET /v1/catalog/extensions/{slug}/releases?channel=` | Published (and yanked) releases: versions, channels, dates, sizes, notes, compat | + +Catalog releases (`latest_release`, each `versions` entry, `/releases`) and the instance +`updates` releases carry `dist`: `null`, or `{size, sha256, integrity, built_against}` for +a release with a prebuilt engine dist, where `built_against` holds the +`@fleetbase/ember-ui` and `@fleetbase/ember-core` versions it was built with. The archive +is `GET /npm/{engine}/-/dist/{version}`, behind the licence gate. | `GET /v1/catalog/publishers/{slug}` | Publisher profile and their published extensions | | `GET /v1/catalog/categories` | Categories with published extension counts | @@ -227,6 +236,7 @@ carry npm's `error` string beside the usual `errors` array. | --- | --- | | `GET /npm/{name}` | Packument from published, non-yanked releases: `versions` (stored `package.json` + `dist.tarball`, `dist.shasum`, `dist.integrity`), `dist-tags` (`latest` = highest stable, `beta` = highest beta; `latest` falls back to the highest beta when nothing is stable), `time`. Scoped names work encoded (`@scope%2fname`) or not. ETag, 304 | | `GET /npm/{name}/-/{basename}-{version}.tgz` | The engine tarball. Yanked versions stay downloadable for lockfiles | +| `GET /npm/{name}/-/dist/{version}` | The release's prebuilt engine dist as uploaded (`fleetbase-build-engine-release` output, tar or tar.gz), which a console installs as a runtime engine without a rebuild. Not an npm protocol route. `X-Checksum-Sha256`, ETag, 304; 404 when the release has no dist. Yanked versions stay downloadable. Catalog releases carry its `dist: {size, sha256, integrity, built_against}` | | `PUT`/`POST`/`DELETE`/`PATCH /npm/…` | 405: publish with `flb publish` | | `GET /npm/-/ping`, `GET /npm/-/whoami` | `npm ping`, `npm whoami` | | `GET /composer/packages.json` | `metadata-url`, `available-packages` (every package with a published release) | @@ -249,8 +259,8 @@ Client setup: with `COMPOSER_AUTH='{"bearer": {"registry.fleetbase.io": "flbp_…"}}'` for paid packages. -**Licence gate.** Free packages are open. For a paid package the packument, tarball, p2 -and dist routes need `Authorization: Bearer` with a personal token carrying the `install` +**Licence gate.** Free packages are open. For a paid package the packument, tarball, prebuilt dist, +p2 and Composer dist routes need `Authorization: Bearer` with a personal token carrying the `install` scope or an instance token (`flbi_…`). The token's organizations are its pinned organization, or every organization of the account when it is not pinned; access is granted when one of them owns the extension or holds an active, unexpired licence. 401 @@ -283,7 +293,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 +386,13 @@ 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 | +| `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 | +| `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) @@ -434,6 +451,172 @@ 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. + +**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 | +| --- | --- | --- | +| `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, 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` | 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 | + +### 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 +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 `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) + +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. 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 + { + "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; 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). + +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/Commerce/CheckoutSessions.php b/app/Commerce/CheckoutSessions.php new file mode 100644 index 0000000..d186641 --- /dev/null +++ b/app/Commerce/CheckoutSessions.php @@ -0,0 +1,163 @@ +loadMissing(['price', 'organization']); + $this->assertPurchasable($extension, $organization, $buyer['instance'] ?? null); + + $price = $extension->price; + $publisher = $extension->organization; + $amount = $price->effective_amount; + $rate = FacilitatorFee::rateAt(now()); + $fee = FacilitatorFee::feeFor($amount, $rate); + $expiresAt = now()->addMinutes(max(30, min(1440, (int) config('registry.commerce.checkout_expires_minutes', 60)))); + + $checkout = Checkout::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $organization->uuid, + 'account_uuid' => ($buyer['account'] ?? null)?->uuid, + 'instance_uuid' => ($buyer['instance'] ?? null)?->uuid, + 'source' => $source, + 'status' => Checkout::STATUS_OPEN, + 'amount' => $amount, + 'currency' => $price->currency, + 'fee_amount' => $fee, + 'fee_rate_bps' => $rate, + 'price_snapshot' => [ + 'model' => $price->model, + 'amount' => (int) $price->amount, + 'sale_amount' => $price->hasSaleAmount() ? (int) $price->sale_amount : null, + 'on_sale' => $price->isOnSale(), + 'currency' => $price->currency, + 'stripe_price_id' => $price->checkoutStripePriceId(), + ], + 'expires_at' => $expiresAt, + ]); + + $metadata = array_filter([ + 'handoff' => $checkout->public_id, + 'source' => $source, + 'extension' => $extension->public_id, + 'extension_uuid' => $extension->uuid, + 'organization' => $organization->public_id, + 'organization_uuid' => $organization->uuid, + 'account' => ($buyer['account'] ?? null)?->public_id, + 'instance' => ($buyer['instance'] ?? null)?->public_id, + 'fee_rate_bps' => (string) $rate, + ]); + + $tax = (bool) config('registry.commerce.tax.enabled', true); + $params = [ + 'mode' => 'payment', + 'line_items' => [['price' => $price->checkoutStripePriceId(), 'quantity' => 1]], + 'success_url' => static::withQuery($successUrl, ['handoff' => $checkout->public_id]), + 'cancel_url' => static::withQuery($cancelUrl, ['handoff' => $checkout->public_id, 'cancelled' => 1]), + 'client_reference_id' => $checkout->public_id, + 'expires_at' => $expiresAt->getTimestamp(), + 'metadata' => $metadata, + 'payment_intent_data' => array_filter([ + 'application_fee_amount' => $fee, + 'transfer_data' => ['destination' => $publisher->stripe_connect_id], + 'on_behalf_of' => $tax ? $publisher->stripe_connect_id : null, + 'description' => "{$extension->name} licence", + 'metadata' => $metadata, + ]), + ]; + + if (filled($buyer['email'] ?? null)) { + $params['customer_email'] = $buyer['email']; + } + + if ($tax) { + $params['automatic_tax'] = ['enabled' => true, 'liability' => ['type' => 'account', 'account' => $publisher->stripe_connect_id]]; + $params['tax_id_collection'] = ['enabled' => true]; + } + + try { + $session = Stripe::client()->checkout->sessions->create($params, ['idempotency_key' => 'checkout:'.$checkout->uuid]); + } catch (\Throwable $e) { + $checkout->delete(); + + throw $e; + } + + $checkout->forceFill([ + 'stripe_checkout_session_id' => $session->id, + 'checkout_url' => $session->url, + ])->save(); + + return $checkout; + } + + /** + * Refuse a checkout that cannot or need not happen, with the reason the buyer sees. + */ + protected function assertPurchasable(Extension $extension, Organization $organization, ?Instance $instance): void + { + if ($extension->status !== Extension::STATUS_PUBLISHED) { + throw new UnprocessableEntityHttpException('This extension is not published.'); + } + + if (! $extension->isPaid()) { + throw new UnprocessableEntityHttpException("{$extension->name} is free: install it without buying."); + } + + if ($instance && $extension->hosting !== Extension::HOSTING_BOTH && $extension->hosting !== $instance->kind) { + throw new UnprocessableEntityHttpException("This extension is only available for {$extension->hosting} instances."); + } + + if ($extension->organization_uuid === $organization->uuid) { + throw new ConflictHttpException("{$organization->name} publishes {$extension->name} and already has access to it."); + } + + // Imported listings and accounts (registry:import) have no Stripe state yet: read it now. + $publisher = $extension->organization; + if ($publisher?->hasStripeAccount() && $publisher->stripeState() === []) { + app(ConnectAccounts::class)->refresh($publisher); + } + if ($publisher?->canReceivePayments() && ! $extension->price->checkoutStripePriceId()) { + app(PriceCatalog::class)->sync($extension->price, $extension)->save(); + } + + if (! $publisher?->canReceivePayments() || ! $extension->price->checkoutStripePriceId()) { + throw new UnprocessableEntityHttpException("{$extension->name} cannot be bought right now: its publisher has not finished setting up payouts."); + } + } + + /** + * Add query parameters to a URL, keeping its own. + */ + public static function withQuery(string $url, array $query): string + { + $fragment = ''; + if (($hash = strpos($url, '#')) !== false) { + $fragment = substr($url, $hash); + $url = substr($url, 0, $hash); + } + + return $url.(str_contains($url, '?') ? '&' : '?').Arr::query($query).$fragment; + } +} 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/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/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/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/Commerce/Stripe.php b/app/Commerce/Stripe.php new file mode 100644 index 0000000..1459e55 --- /dev/null +++ b/app/Commerce/Stripe.php @@ -0,0 +1,49 @@ +getMessage() + : 'Payments are unavailable right now. Try again in a few minutes.'; + } + + /** + * Request options that act on a connected account. + */ + public static function onAccount(string $account): array + { + return ['stripe_account' => $account]; + } +} diff --git a/app/Commerce/StripeWebhooks.php b/app/Commerce/StripeWebhooks.php new file mode 100644 index 0000000..916b382 --- /dev/null +++ b/app/Commerce/StripeWebhooks.php @@ -0,0 +1,281 @@ +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(); + + $this->revoke($purchase, 'refunded'); + + Event::record('purchase.refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); + } + + /** + * 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 + { + $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(); + + $this->revoke($purchase, 'disputed'); + 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)]; + } + } + + /** + * 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(); + + 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/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/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/Catalog/CatalogController.php b/app/Http/Controllers/Catalog/CatalogController.php index fd164c4..b6b213e 100644 --- a/app/Http/Controllers/Catalog/CatalogController.php +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -72,7 +72,7 @@ public function extension(Request $request, string $slug): JsonResponse { return $this->cached($request, function () use ($request, $slug) { $extension = $this->listingQuery() - ->with(['screenshots', 'publishedReleases']) + ->with(['screenshots', 'publishedReleases.engineDist']) ->withCount('publishedReleases') ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) ->firstOrFail(); @@ -96,6 +96,7 @@ public function releases(Request $request, string $slug): JsonResponse ->when($request->filled('channel'), fn ($q) => $q->where('channel', $request->input('channel'))) ->orderByDesc('published_at') ->orderByDesc('id') + ->with('engineDist') ->get(); return [ @@ -154,7 +155,7 @@ protected function listingQuery(): Builder protected function listingScopes(Builder|Relation $query) { return $query - ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease']) + ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease.engineDist']) ->withCount(['installs' => fn ($q) => $q->where('status', Install::STATUS_INSTALLED)]); } 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/InstanceController.php b/app/Http/Controllers/Instance/InstanceController.php index 7949e5c..b0fd6ad 100644 --- a/app/Http/Controllers/Instance/InstanceController.php +++ b/app/Http/Controllers/Instance/InstanceController.php @@ -106,7 +106,8 @@ public function register(RegisterInstanceRequest $request): JsonResponse /** * GET /v1/instances/me — the instance, its organization and the organization's - * licences with seat usage and whether this instance holds a seat. + * licences with seat usage, whether this instance holds a seat, and the receipt of a + * bought licence (so a console can link it after a re-registration or a cache clear). */ public function me(Request $request): JsonResponse { @@ -115,7 +116,7 @@ public function me(Request $request): JsonResponse $licences = Licence::query() ->where('organization_uuid', $instance->organization_uuid) - ->with('extension') + ->with(['extension', 'purchase']) ->withCount('activeActivations') ->addSelect(['activated_here' => LicenceActivation::query() ->selectRaw('count(*)') @@ -148,7 +149,7 @@ public function updates(Request $request): JsonResponse $installs = Install::query() ->where('instance_uuid', $instance->uuid) ->where('status', Install::STATUS_INSTALLED) - ->with(['extension', 'release']) + ->with(['extension', 'release.engineDist']) ->orderBy('created_at') ->get() ->filter(fn (Install $install) => $install->extension !== null); @@ -157,6 +158,7 @@ public function updates(Request $request): JsonResponse ->published() ->whereIn('extension_uuid', $installs->pluck('extension_uuid')->unique()) ->whereIn('channel', $channels) + ->with('engineDist') ->get() ->groupBy('extension_uuid'); diff --git a/app/Http/Controllers/Instance/PurchaseController.php b/app/Http/Controllers/Instance/PurchaseController.php new file mode 100644 index 0000000..c8dde59 --- /dev/null +++ b/app/Http/Controllers/Instance/PurchaseController.php @@ -0,0 +1,82 @@ +user(); + + $extension = Extension::findByIdentifier($request->input('extension')); + if (! $extension) { + 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, + 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..6fdc20e --- /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. Any member. + */ + public function licences(Request $request, string $slug): JsonResponse + { + $organization = $this->findOrganization($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..d42e277 --- /dev/null +++ b/app/Http/Controllers/Organization/PayoutController.php @@ -0,0 +1,191 @@ +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 (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() + ->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/Packages/NpmController.php b/app/Http/Controllers/Packages/NpmController.php index 2625085..e6dc3ab 100644 --- a/app/Http/Controllers/Packages/NpmController.php +++ b/app/Http/Controllers/Packages/NpmController.php @@ -4,6 +4,7 @@ use App\Models\Account; use App\Models\ExtensionPackage; +use App\Models\Release; use App\Services\Packages\NpmPackument; use App\Services\ReleaseStorage; use Illuminate\Http\JsonResponse; @@ -56,6 +57,28 @@ public function tarball(Request $request, string $package, string $file): Respon )); } + /** + * GET /npm/{name}/-/dist/{version} + * + * The release's prebuilt engine dist (`fleetbase-build-engine-release` output, as uploaded + * with `flb publish`), which the console installs as a runtime engine without a rebuild. + * Not part of the npm protocol: npm clients never ask for it. Gated like the tarball; + * catalog releases carry its size, sha256 and builtAgainst under `dist`. + */ + public function dist(Request $request, string $package, string $version): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + + $release = $resolved->extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->where('version', $version) + ->with('engineDist') + ->first(); + abort_if($release?->engineDist === null, 404, 'Dist not found.'); + + return $this->storage->download($release->engineDist, $request, $this->archiveCacheHeaders(! $resolved->extension->isPaid())); + } + /** * PUT/POST/DELETE/PATCH under /npm: publish, unpublish, dist-tag, deprecate, adduser. */ 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/Web/AccountPageController.php b/app/Http/Controllers/Web/AccountPageController.php index 34152e3..f626103 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,31 @@ 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, null); + $billing = $organization && in_array($organization['role'] ?? null, OrganizationController::BILLING_ROLES, true); + $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 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, + ]); + } + public function tokens(Request $request): Response { return Inertia::render('Account/Tokens', [ 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/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..f291cfc 100644 --- a/app/Http/Controllers/Web/WebController.php +++ b/app/Http/Controllers/Web/WebController.php @@ -2,10 +2,14 @@ namespace App\Http\Controllers\Web; +use App\Commerce\Stripe; 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 +56,48 @@ protected function api(Request $request, string $uri, array $query, string $cont } } + /** + * 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 $roles = OrganizationController::BILLING_ROLES): array + { + $organizations = collect($this->api($request, '/v1/organizations', [], OrganizationController::class, 'index')) + ->filter(fn (array $organization) => $roles === null || in_array($organization['role'] ?? null, $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::failure($e)]; + } + } + /** * Only the query parameters a listing page passes through to the API. * 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/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/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/ExtensionResource.php b/app/Http/Resources/ExtensionResource.php index 5f0e75e..2ddf846 100644 --- a/app/Http/Resources/ExtensionResource.php +++ b/app/Http/Resources/ExtensionResource.php @@ -27,6 +27,7 @@ public function toArray($request): array 'version' => $release->version, 'channel' => $release->channel, 'published_at' => $release->published_at, + 'dist' => $release->relationLoaded('engineDist') ? ReleaseResource::dist($release) : null, ])->all()), 'versions_count' => $this->whenCounted('publishedReleases'), 'publisher' => $this->whenLoaded('organization', fn () => (new PublisherResource($this->organization))->resolve($request)), 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/app/Http/Resources/LicenceResource.php b/app/Http/Resources/LicenceResource.php index da05a19..aac88a8 100644 --- a/app/Http/Resources/LicenceResource.php +++ b/app/Http/Resources/LicenceResource.php @@ -16,11 +16,26 @@ 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), + 'receipt_url' => $this->whenLoaded('purchase', fn () => $this->purchase?->receipt_url), '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/Http/Resources/ReleaseResource.php b/app/Http/Resources/ReleaseResource.php index 82d6aed..debbc3e 100644 --- a/app/Http/Resources/ReleaseResource.php +++ b/app/Http/Resources/ReleaseResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Models\Release; use App\Support\Http; class ReleaseResource extends FleetbaseResource @@ -18,9 +19,32 @@ public function toArray($request): array 'size_bytes' => (int) $this->size_bytes, 'checksum' => $this->checksum, 'notes' => $this->notes, + 'dist' => $this->whenLoaded('engineDist', fn () => static::dist($this->resource)), 'published_at' => $this->published_at, 'yanked_at' => $this->yanked_at, 'created_at' => $this->created_at, ]; } + + /** + * The prebuilt engine dist of a release (its `engineDist` loaded), or null: what an + * installer needs to fetch `GET /npm/{engine}/-/dist/{version}` and verify it, and the + * `@fleetbase/ember-ui` / `@fleetbase/ember-core` versions it was built against. + * + * @return array{size: int, sha256: ?string, integrity: ?string, built_against: array}|null + */ + public static function dist(Release $release): ?array + { + $file = $release->engineDist; + if ($file === null) { + return null; + } + + return [ + 'size' => (int) $file->size, + 'sha256' => $file->checksum, + 'integrity' => $file->integrity, + 'built_against' => (array) $release->metaValue('dist.built_against', []), + ]; + } } diff --git a/app/Mail/DisputeOpenedMail.php b/app/Mail/DisputeOpenedMail.php new file mode 100644 index 0000000..48d8bf8 --- /dev/null +++ b/app/Mail/DisputeOpenedMail.php @@ -0,0 +1,48 @@ +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', 'licence']); + $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, + 'licenceId' => $this->purchase->licence?->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/Instance.php b/app/Models/Instance.php index 97d54eb..1574517 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); + } + /** * The active instance of an organization registered from the same host (scheme, case and a * trailing slash ignored), most recently seen first. Null when the host is empty. 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..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::failure($e), 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/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/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/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..6e506dc --- /dev/null +++ b/resources/js/components/commerce/CommercePages.test.jsx @@ -0,0 +1,219 @@ +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 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' })); + 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 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' } }; + 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/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 ( -
+ {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/Review/Finance.jsx b/resources/js/pages/Review/Finance.jsx new file mode 100644 index 0000000..53761e9 --- /dev/null +++ b/resources/js/pages/Review/Finance.jsx @@ -0,0 +1,413 @@ +import { useState } from 'react'; +import { router } from '@inertiajs/react'; +import SiteLayout from '../../layouts/SiteLayout'; +import Dialog from '../../components/Dialog'; +import Icon from '../../components/Icon'; +import { StatusChip } from '../../components/Status'; +import { EmptyState, Field, Note } from '../../components/Ui'; +import { ReviewTabs, useAction } from '../../components/review/Parts'; +import { monthLabel, refundError, refundSplit, toMajor, toMinor } from '../../components/review/finance'; +import { api } from '../../lib/api'; +import { money, plural, shortDate } from '../../lib/format'; +import { purchaseStatus } from '../../lib/statuses'; + +const RIGHT = { textAlign: 'right' }; +const REASONS = [ + ['requested_by_customer', 'Requested by the customer'], + ['duplicate', 'Duplicate purchase'], + ['fraudulent', 'Fraudulent'], +]; + +/** Drop empty values from a query. */ +function query(values) { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== '' && value !== null && value !== undefined)); +} + +/** + * Finance: Fleetbase's fee revenue, refunds and disputes over a date range, per month and per + * publisher, with a monthly CSV, and every purchase in the range with a refund action. + */ +export default function Finance({ summary = {}, purchases, filters = {}, stripe = {}, counts = {} }) { + const [from, setFrom] = useState(filters.from ?? ''); + const [to, setTo] = useState(filters.to ?? ''); + const [refunding, setRefunding] = useState(null); + const range = query({ from, to }); + const csv = new URLSearchParams(query({ from: summary.from, to: summary.to })).toString(); + + function apply(event) { + event.preventDefault(); + router.get('/review/finance', query({ ...range, status: filters.status, publisher: filters.publisher }), { preserveScroll: true }); + } + + return ( + +
+
+
+
+

Finance

+

+ Fleetbase’s fee revenue, refunds and disputes from {shortDate(summary.from)} to {shortDate(summary.to)}, by the date of each sale. +

+
+ +
+ +
+ + + + +
+ + + monthLabel(row.month)} + heading="Month" + empty="No sales in this range." + rowKey={(row) => `${row.month}-${row.currency}`} + /> + row.organization?.name ?? 'Unknown publisher'} + heading="Publisher" + empty="No publisher sold anything in this range." + rowKey={(row) => `${row.organization?.id ?? 'none'}-${row.currency}`} + /> + +
+
+ setRefunding(null)} /> +
+ ); +} + +function Stat({ label, value, hint, ...rest }) { + return ( +
+ {label} + {value} + {hint && {hint}} +
+ ); +} + +/** Per currency: fee revenue, gross sales, refunds and disputes. */ +function Totals({ totals }) { + if (!totals.length) { + return ( + + Purchases appear here as soon as Stripe confirms the payment. + + ); + } + return totals.map((total) => ( +
+ + + + +
+ )); +} + +/** A table of report rows (months or publishers). */ +function Breakdown({ id, title, rows, label, heading, empty, rowKey }) { + return ( +
+
+

+ {title} +

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

{empty}

+
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
{heading} + Sales + + Gross + + Refunded + + Fee revenue + + To publishers + + Disputed +
+ {label(row)} {row.currency} + + {row.sales} + + {money(row.gross, row.currency)} + + {row.refunded ? `−${money(row.refunded, row.currency)}` : '—'} + + {money(row.fee_revenue, row.currency)} + + {money(row.publisher_net, row.currency)} + + {row.disputes ? `${money(row.disputed, row.currency)} (${row.disputes})` : '—'} +
+
+ )} +
+ ); +} + +/** Purchases in the range, newest first, filtered by status and publisher, with Refund. */ +function Purchases({ purchases, filters, range, publishers, stripe, onRefund }) { + const rows = purchases?.data ?? []; + const meta = purchases?.meta; + const options = [...new Map(publishers.filter((row) => row.organization).map((row) => [row.organization.slug, row.organization.name])).entries()]; + + function visit(changes) { + router.get('/review/finance', query({ ...range, status: filters.status, publisher: filters.publisher, ...changes }), { preserveScroll: true, only: ['purchases', 'filters'] }); + } + + return ( +
+
+

+ Purchases +

+
+ + +
+
+ {!stripe.configured && ( +
+ Payments are not set up on this registry, so refunds are off. +
+ )} + {rows.length === 0 ? ( +
+

No purchases{filters.status || filters.publisher ? ' match these filters' : ' in this range'}.

+
+ ) : ( +
+ + + + + + + + + + + + + + + {rows.map((row) => { + const s = purchaseStatus(row.status); + return ( + + + + + + + + + + + ); + })} + +
DateExtensionCustomerStatus + Gross + + Fee + + Refunded + + Actions +
{shortDate(row.created_at)} +
+ {row.extension?.name ?? '—'} + {row.publisher?.name} +
+
{row.customer?.name ?? '—'} + + {s.label} + + {row.dispute?.reason &&
{row.dispute.reason.replace(/_/g, ' ')}
} +
+ {money(row.gross, row.currency)} + + {money(row.fee, row.currency)} + + {row.refunded ? `−${money(row.refunded, row.currency)}` : '—'} + + {stripe.configured && row.refundable > 0 && ( + + )} +
+
+ )} + {meta && meta.last_page > 1 && ( +
+ + + Page {meta.current_page} of {meta.last_page} + + +
+ )} +
+ ); +} + +/** Refund all or part of what is left, through Stripe. */ +function RefundDialog({ open, purchase, onClose }) { + return open && purchase ? : null; +} + +function RefundForm({ purchase, onClose }) { + const [amount, setAmount] = useState(toMajor(purchase.refundable)); + const [reason, setReason] = useState(REASONS[0][0]); + const [note, setNote] = useState(''); + const [error, setError] = useState(null); + const { run, busy } = useAction(); + const currency = purchase.currency; + const minor = toMinor(amount) ?? 0; + const split = refundSplit(purchase, minor); + const full = purchase.refunded + minor >= purchase.gross; + + function submit() { + const problem = refundError(amount, purchase.refundable, currency); + if (problem || busy) { + setError(problem); + return; + } + run(() => api.post(`/admin/purchases/${purchase.id}/refund`, query({ amount: minor, reason, note: note.trim() })), { + success: (result) => `Refund of ${money(result?.refund?.amount ?? minor, currency)} sent to Stripe. The purchase updates when Stripe confirms it.`, + onDone: onClose, + onError: (e) => setError(e.field('amount') ?? e.message), + }); + } + + const name = purchase.extension?.name ?? 'this purchase'; + return ( + + + + + } + > +

+ {purchase.customer?.name ?? 'The customer'} paid {money(purchase.gross, currency)} on {shortDate(purchase.created_at)} + {purchase.refunded ? `; ${money(purchase.refunded, currency)} is already refunded` : ''}. +

+ + {(id, aria) => setAmount(e.target.value)} data-refund-amount />} + + + {(id, aria) => ( + + )} + + + {(id, aria) =>