diff --git a/.env.docker b/.env.docker index 49c4e1e..a03fe3f 100644 --- a/.env.docker +++ b/.env.docker @@ -10,3 +10,4 @@ LOG_LEVEL=debug BCRYPT_ROUNDS=12 REGISTRY_VERSION=2.0.0-dev REGISTRY_CATALOG_CACHE_SECONDS=60 +REGISTRY_ANALYTICS_CACHE_SECONDS=60 diff --git a/.env.example b/.env.example index d435b99..67314e8 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 @@ -82,6 +93,7 @@ IMPORT_SOURCE_AWS_BUCKET= # Registry REGISTRY_VERSION=2.0.0-dev REGISTRY_CATALOG_CACHE_SECONDS=60 +REGISTRY_ANALYTICS_CACHE_SECONDS=60 # Base URL written into npm/Composer metadata; empty = the request's scheme and host. REGISTRY_PUBLIC_URL= REGISTRY_PACKAGE_METADATA_CACHE_SECONDS=60 diff --git a/README.md b/README.md index 89e7681..3fea2fa 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ 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 | +| Publisher analytics: listing and organization stats APIs with CSV, Studio Insights | done | +| CLI v2 (G), staff finance view | later workstreams | ## Stack @@ -133,8 +135,11 @@ 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 | -| `/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 | +| `/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` | Publisher Studio | +| `/studio/insights/{organization?}[?extension=&from=&to=&group=]` | Insights: installs, uninstalls, active instances, purchases and net revenue against the previous period, a trend chart, active installs by version, recent events, CSV (see Publisher analytics) | +| `/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 +215,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 +238,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 +261,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,8 +295,9 @@ 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 | +| `GET /v1/publisher/extensions/{slug}/stats?from=&to=&group=` | The listing's analytics (see Publisher analytics); `stats.csv` with the same query is the series as CSV | | `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 | | `PATCH /v1/publisher/releases/{id}` | `notes` (nullable, up to 20,000 characters, as on upload). Any status but `yanked` (422). No review; a published release's notes show in the catalog at once | @@ -376,6 +389,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) @@ -420,6 +440,9 @@ Install rules: - One `installs` row per attempt: `installing` opens it, `installed` or `failed` closes it. When a release is installed, earlier `installed` rows for that extension become `uninstalled`, so each instance counts once in the catalog's install counts. +- Each row records its lifecycle for analytics: `installed_at` when it succeeds, then + `replaced_at` when a newer install on the instance takes over (that row gets + `previous_release_uuid`: an update) or `uninstalled_at` when the instance removes it. - `installing` and `installed` claim a licence seat (seat = instance) and return the activation. Free extensions get an automatic `free` licence for the instance's organization on first install, with unlimited seats (`max_activations` null). Paid extensions need an @@ -434,6 +457,228 @@ 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. + +## Publisher analytics + +Studio Insights and the stats APIs report, for a date range against the period of the same +length just before it: installs, updates, uninstalls, active instances, purchases and revenue, +a series per day, week (Monday to Sunday) or month, active installs per version (one listing) +or a row per listing (an organization), and the latest 25 events. Days are UTC. + +| Method and path | Who | Returns | +| --- | --- | --- | +| `GET /v1/publisher/extensions/{slug}/stats?from=&to=&group=day\|week\|month` | owners, admins, publishers (`publish` scope) | `extension`, `range`, `previous`, `as_of`, `totals`, `previous_totals`, `series`, `versions`, `events: {data, total}` | +| `GET /v1/organizations/{slug}/stats?from=&to=&group=` | owners, admins, publishers, billing | `organization`, the same report across every listing (deleted listings included while they have figures in the range), `extensions` (a row per listing) instead of `versions` | +| `GET …/stats.csv` (both) | as above | The series as CSV: `period, installs, updates, uninstalls, purchases`, then `gross_`, `fee_`, `net_` per currency (major units) | + +`from` and `to` are `YYYY-MM-DD`, inclusive, at most 731 days apart; without them, the last +30 days. Without `group`: days up to 92 days, weeks up to a year, else months. Reports are +cached for `REGISTRY_ANALYTICS_CACHE_SECONDS` (60); `as_of` says when one was built. + +What counts: + +- **Installs** are installs that succeeded (`installed_at`) on an instance that did not have the + listing; **updates** replaced the instance's previous install (`previous_release_uuid`, + including a reinstall of the same version); **uninstalls** removed one that had been installed + (`uninstalled_at`). Failed attempts count nowhere. +- **Active instances** are installed rows at the end of the range, the same count as the + catalog's `installs_count` when the range reaches today; for a past range, rows installed by + then and not yet uninstalled or replaced. `installs_count` stays derived from `installs`; there + is no counter column. +- **Revenue** per currency, minor units, by the date of the sale, as staff Finance counts it: + `gross`, `refunded`, `fee` (Fleetbase's fee after refunds) and `net` (the publisher's share after + refunds), so a refund changes the range its sale fell in. Refund events list on the date Stripe + reported them. + +**Why no rollup table.** The report reads `installs` and `purchases` directly: two grouped +range scans for the series, one count for active instances, one for versions, and four +`LIMIT 25` reads for events, each on an index: `installs (extension_uuid, installed_at, +previous_release_uuid)`, `(extension_uuid, uninstalled_at, installed_at)`, `(extension_uuid, +status, release_uuid)` and `purchases (extension_uuid, created_at)`. The two lifecycle indexes +carry the other column their query reads, so the series never touches the table, and active +instances for a past range are walked back from today's count instead of scanning history. +Measured on SQLite with 230,000 installs on one listing (about 280 a day for two years, +196,000 still active), on a heavily loaded machine, so read these as upper bounds: + +| Report | Time | +| --- | --- | +| One listing, 30 days by day | 0.23 s | +| One listing, a year by week | 0.7 s | +| One listing, two years by month | 0.9 s | +| An organization of 50 listings, 30 days to two years | 0.4 to 0.8 s | + +Most of that is counting the active installs, which the catalog's `installs_count` already does +for every listing it shows. The registry expects tens of listings with installs in the hundreds, +so a daily rollup table and its scheduled command would be one more thing to keep correct +(late reports, refunds, deleted listings) with nothing to gain yet. Add one (per listing, day +and version, rebuilt nightly for the last few days) when a listing's report runs past a few +hundred milliseconds in production. + ## Importing from Fleetbase Cloud `registry:import` copies the cloud registry-bridge tables into the service, following the diff --git a/app/Analytics/ExtensionStats.php b/app/Analytics/ExtensionStats.php new file mode 100644 index 0000000..cb9cd35 --- /dev/null +++ b/app/Analytics/ExtensionStats.php @@ -0,0 +1,460 @@ +|null */ + protected ?array $activeNow = null; + + /** Recent events a report lists. */ + public const EVENTS = 25; + + public const REFUND_EVENTS = ['purchase.refunded', 'purchase.partially_refunded']; + + /** + * @param Collection $extensions + */ + public function __construct(protected Collection $extensions) {} + + /** + * The report, cached for `registry.analytics_cache_seconds` per listings, range and shape; + * `as_of` says when it was built. + */ + public function cachedReport(StatsRange $range, bool $versions = false, bool $perExtension = false): array + { + $key = 'analytics:'.sha1(json_encode([$this->uuids(), $range->from->toDateString(), $range->to->toDateString(), $range->group, $versions, $perExtension])); + + return Cache::remember($key, (int) config('registry.analytics_cache_seconds', 60), fn () => $this->report($range, $versions, $perExtension)); + } + + /** + * @return list + */ + protected function uuids(): array + { + return $this->extensions->pluck('uuid')->all(); + } + + /** + * The report: totals for the range and the period before it, the series, active installs + * per version (`$versions`), per-listing rows (`$perExtension`) and recent events. + */ + public function report(StatsRange $range, bool $versions = false, bool $perExtension = false): array + { + $previous = $range->previous(); + $current = $this->figures($range); + $before = $this->figures($previous); + + $report = [ + 'range' => $range->toArray() + ['group' => $range->group], + 'previous' => $previous->toArray(), + 'as_of' => now()->toIso8601String(), + 'totals' => $current['totals'], + 'previous_totals' => $before['totals'], + 'series' => $this->series($range, $current), + ]; + + if ($versions) { + $report['versions'] = $this->versions(); + } + + if ($perExtension) { + $report['extensions'] = $this->perExtension($current); + } + + $report['events'] = [ + 'data' => $this->events($range), + 'total' => $current['totals']['installs'] + $current['totals']['updates'] + $current['totals']['uninstalls'] + + $current['totals']['purchases'] + $this->refundEvents($range)->count(), + ]; + + return $report; + } + + /** + * Counts per extension and day, and the totals over the range. + * + * @return array{totals: array, days: array>, extensions: array} + */ + protected function figures(StatsRange $range): array + { + $uuids = $this->uuids(); + $days = []; + $extensions = []; + $blank = ['installs' => 0, 'updates' => 0, 'uninstalls' => 0, 'purchases' => 0, 'revenue' => []]; + + $installed = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('installed_at', [$range->from, $range->to]) + ->groupBy('extension_uuid', DB::raw('DATE(installed_at)')) + ->selectRaw('extension_uuid, DATE(installed_at) as day') + ->selectRaw('SUM(CASE WHEN previous_release_uuid IS NULL THEN 1 ELSE 0 END) as installs') + ->selectRaw('SUM(CASE WHEN previous_release_uuid IS NULL THEN 0 ELSE 1 END) as updates') + ->toBase() + ->get(); + + foreach ($installed as $row) { + $days[$row->day][$row->extension_uuid] ??= $blank; + $days[$row->day][$row->extension_uuid]['installs'] += (int) $row->installs; + $days[$row->day][$row->extension_uuid]['updates'] += (int) $row->updates; + } + + $uninstalled = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('uninstalled_at', [$range->from, $range->to]) + ->whereNotNull('installed_at') + ->groupBy('extension_uuid', DB::raw('DATE(uninstalled_at)')) + ->selectRaw('extension_uuid, DATE(uninstalled_at) as day, COUNT(*) as uninstalls') + ->toBase() + ->get(); + + foreach ($uninstalled as $row) { + $days[$row->day][$row->extension_uuid] ??= $blank; + $days[$row->day][$row->extension_uuid]['uninstalls'] += (int) $row->uninstalls; + } + + // Sales are few next to installs; refunds live on each purchase, so add them up here. + Purchase::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('created_at', [$range->from, $range->to]) + ->select(['id', 'uuid', 'extension_uuid', 'amount_total', 'fee_amount', 'net_amount', 'currency', 'status', 'meta', 'created_at']) + ->lazyById(500) + ->each(function (Purchase $purchase) use (&$days, $blank) { + $day = $purchase->created_at->toDateString(); + $days[$day][$purchase->extension_uuid] ??= $blank; + $days[$day][$purchase->extension_uuid]['purchases']++; + $days[$day][$purchase->extension_uuid]['revenue'] = static::addRevenue($days[$day][$purchase->extension_uuid]['revenue'], $purchase); + }); + + $active = $this->active($range); + $totals = $blank + ['active_instances' => array_sum($active)]; + + foreach ($days as $byExtension) { + foreach ($byExtension as $uuid => $counts) { + $extensions[$uuid] = static::merge($extensions[$uuid] ?? $blank, $counts); + $totals = static::merge($totals, $counts); + } + } + foreach ($this->uuids() as $uuid) { + $extensions[$uuid] = ($extensions[$uuid] ?? $blank) + ['active_instances' => $active[$uuid] ?? 0]; + } + + $totals['revenue'] = static::revenueList($totals['revenue']); + ksort($days); + + return ['totals' => $totals, 'days' => $days, 'extensions' => $extensions]; + } + + /** + * Active installs per extension at the end of the range: installed rows (the catalog's count) + * when the range reaches today. For an earlier end, the same count walked back: less what was + * installed since, plus what was uninstalled since (an update replaces one row with another, + * so it changes nothing). That reads only the rows after the end of the range. + * + * @return array + */ + protected function active(StatsRange $range): array + { + $now = $this->activeNow(); + if ($range->isCurrent()) { + return $now; + } + + $since = [$range->to->copy()->addSecond(), now()]; + $installed = $this->countBy(Install::query()->whereBetween('installed_at', $since)->whereNull('previous_release_uuid')); + $uninstalled = $this->countBy(Install::query()->whereBetween('uninstalled_at', $since)->whereNotNull('installed_at')); + + return collect($this->uuids()) + ->mapWithKeys(fn (string $uuid) => [$uuid => max(0, ($now[$uuid] ?? 0) - ($installed[$uuid] ?? 0) + ($uninstalled[$uuid] ?? 0))]) + ->all(); + } + + /** + * @return array + */ + protected function activeNow(): array + { + return $this->activeNow ??= $this->countBy(Install::query()->active()); + } + + /** + * @return array + */ + protected function countBy(Builder $query): array + { + return $query->whereIn('extension_uuid', $this->uuids()) + ->groupBy('extension_uuid') + ->selectRaw('extension_uuid, COUNT(*) as aggregate') + ->toBase() + ->pluck('aggregate', 'extension_uuid') + ->map(fn ($count) => (int) $count) + ->all(); + } + + /** + * One row per bucket, oldest first, zeros included. + * + * @return list + */ + protected function series(StatsRange $range, array $figures): array + { + $buckets = array_fill_keys($range->buckets(), ['installs' => 0, 'updates' => 0, 'uninstalls' => 0, 'purchases' => 0, 'revenue' => []]); + + foreach ($figures['days'] as $day => $byExtension) { + $bucket = $range->bucket($day); + foreach ($byExtension as $counts) { + $buckets[$bucket] = static::merge($buckets[$bucket], $counts); + } + } + + return collect($buckets) + ->map(fn (array $counts, string $date) => ['date' => $date] + array_merge($counts, ['revenue' => static::revenueList($counts['revenue'])])) + ->values() + ->all(); + } + + /** + * Active installs per release, newest version first, with each one's share. + * + * @return list + */ + protected function versions(): array + { + $counts = Install::query() + ->active() + ->whereIn('extension_uuid', $this->uuids()) + ->groupBy('release_uuid') + ->selectRaw('release_uuid, COUNT(*) as instances') + ->toBase() + ->get(); + + $total = max(1, (int) $counts->sum('instances')); + $releases = Release::withTrashed()->whereIn('uuid', $counts->pluck('release_uuid')->filter())->get()->keyBy('uuid'); + + return $counts + ->map(fn ($row) => [ + 'release' => $releases->get($row->release_uuid)?->public_id, + 'version' => $releases->get($row->release_uuid)?->version, + 'instances' => (int) $row->instances, + 'share' => round(((int) $row->instances) / $total, 4), + ]) + ->sort(fn (array $a, array $b) => match (true) { + $a['version'] === null => 1, + $b['version'] === null => -1, + default => Semver::compare($b['version'], $a['version']), + }) + ->values() + ->all(); + } + + /** + * Figures per listing, most active instances first. + * + * @return list + */ + protected function perExtension(array $figures): array + { + return $this->extensions + // A deleted listing shows only while it still has figures in the range. + ->reject(fn (Extension $extension) => $extension->trashed() && static::isEmpty($figures['extensions'][$extension->uuid])) + ->map(fn (Extension $extension) => [ + 'extension' => static::extensionReference($extension), + ] + array_merge($figures['extensions'][$extension->uuid], [ + 'revenue' => static::revenueList($figures['extensions'][$extension->uuid]['revenue']), + ])) + ->sortBy([['active_instances', 'desc'], ['installs', 'desc'], [fn ($row) => $row['extension']['name'], 'asc']]) + ->values() + ->all(); + } + + /** + * The latest installs, updates, uninstalls, purchases and refunds in the range, newest first, + * with the organization behind each. + * + * @return list + */ + protected function events(StatsRange $range): array + { + $uuids = $this->uuids(); + $between = [$range->from, $range->to]; + $instance = fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()]); + $releases = fn ($q) => $q->withTrashed(); + $events = collect(); + + $installed = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('installed_at', $between) + ->with(['instance' => $instance, 'release' => $releases, 'previousRelease' => $releases]) + ->orderByDesc('installed_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($installed as $install) { + $events->push($this->event($install->previous_release_uuid ? 'update' : 'install', $install->installed_at, $install->extension_uuid, $install->instance?->organization, [ + 'version' => $install->release?->version, + 'from_version' => $install->previousRelease?->version, + ])); + } + + $uninstalled = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('uninstalled_at', $between) + ->whereNotNull('installed_at') + ->with(['instance' => $instance, 'release' => $releases]) + ->orderByDesc('uninstalled_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($uninstalled as $install) { + $events->push($this->event('uninstall', $install->uninstalled_at, $install->extension_uuid, $install->instance?->organization, [ + 'version' => $install->release?->version, + ])); + } + + $purchases = Purchase::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('created_at', $between) + ->with(['buyer' => fn ($q) => $q->withTrashed()]) + ->orderByDesc('created_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($purchases as $purchase) { + $events->push($this->event('purchase', $purchase->created_at, $purchase->extension_uuid, $purchase->buyer, [ + 'amount' => (int) $purchase->amount_total, + 'currency' => $purchase->currency, + ])); + } + + $refunds = $this->refundEvents($range)->orderByDesc('created_at')->limit(self::EVENTS)->get(); + $refunded = Purchase::withTrashed()->whereIn('uuid', $refunds->pluck('subject_uuid'))->with(['buyer' => fn ($q) => $q->withTrashed()])->get()->keyBy('uuid'); + + foreach ($refunds as $refund) { + $purchase = $refunded->get($refund->subject_uuid); + $events->push($this->event('refund', $refund->created_at, $purchase?->extension_uuid, $purchase?->buyer, [ + 'amount' => (int) ($refund->payload['amount_refunded'] ?? 0), + 'currency' => $purchase?->currency, + 'partial' => $refund->kind === 'purchase.partially_refunded', + ])); + } + + return $events->sortByDesc('at')->take(self::EVENTS)->values()->all(); + } + + /** + * Stripe refund notices (`charge.refunded`) on the listings' purchases in the range. + */ + protected function refundEvents(StatsRange $range): Builder + { + return Event::query() + ->whereIn('kind', self::REFUND_EVENTS) + ->where('subject_type', (new Purchase)->getMorphClass()) + ->whereIn('subject_uuid', Purchase::withTrashed()->select('uuid')->whereIn('extension_uuid', $this->uuids())) + ->whereBetween('created_at', [$range->from, $range->to]); + } + + protected function event(string $type, ?Carbon $at, ?string $extensionUuid, ?Organization $organization, array $details = []): array + { + $extension = $this->extensions->firstWhere('uuid', $extensionUuid); + + return [ + 'type' => $type, + 'at' => $at?->toIso8601String(), + 'extension' => $extension ? static::extensionReference($extension) : null, + 'organization' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + ] + array_merge(['version' => null, 'from_version' => null, 'amount' => null, 'currency' => null], $details); + } + + /** + * @return array{id: string, slug: string, name: string, status: string} + */ + public static function extensionReference(Extension $extension): array + { + return ['id' => $extension->public_id, 'slug' => $extension->slug, 'name' => $extension->name, 'status' => $extension->status]; + } + + /** + * Add a purchase to revenue keyed by currency: gross, refunded, Fleetbase's fee (after + * refunds) and the publisher's net (after refunds). + * + * @param array $revenue + * @return array + */ + protected static function addRevenue(array $revenue, Purchase $purchase): array + { + $figures = FinanceReport::figures($purchase); + $line = $revenue[$purchase->currency] ?? ['currency' => $purchase->currency, 'purchases' => 0, 'gross' => 0, 'refunded' => 0, 'fee' => 0, 'net' => 0]; + + $line['purchases']++; + $line['gross'] += $figures['gross']; + $line['refunded'] += $figures['refunded']; + $line['fee'] += $figures['fee_revenue']; + $line['net'] += $figures['publisher_net']; + $revenue[$purchase->currency] = $line; + + return $revenue; + } + + /** + * Counts added together; revenue merged per currency. + */ + protected static function merge(array $into, array $counts): array + { + foreach (['installs', 'updates', 'uninstalls', 'purchases'] as $field) { + $into[$field] += $counts[$field]; + } + + foreach ($counts['revenue'] as $currency => $line) { + $mine = $into['revenue'][$currency] ?? array_map(fn ($value) => is_int($value) ? 0 : $value, $line); + foreach (['purchases', 'gross', 'refunded', 'fee', 'net'] as $field) { + $mine[$field] += $line[$field]; + } + $into['revenue'][$currency] = $mine; + } + + return $into; + } + + protected static function isEmpty(array $counts): bool + { + return $counts['installs'] + $counts['updates'] + $counts['uninstalls'] + $counts['purchases'] + $counts['active_instances'] === 0; + } + + /** + * Revenue keyed by currency as a list, highest net first. + * + * @return list + */ + protected static function revenueList(array $revenue): array + { + return collect($revenue)->sortBy([['net', 'desc'], ['currency', 'asc']])->values()->all(); + } +} diff --git a/app/Analytics/StatsCsv.php b/app/Analytics/StatsCsv.php new file mode 100644 index 0000000..752621a --- /dev/null +++ b/app/Analytics/StatsCsv.php @@ -0,0 +1,51 @@ +pluck('currency')->sort()->values()->all(); + + return response()->streamDownload(function () use ($report, $currencies) { + $out = fopen('php://output', 'w'); + $header = ['period', 'installs', 'updates', 'uninstalls', 'purchases']; + foreach ($currencies as $currency) { + array_push($header, "gross_{$currency}", "fee_{$currency}", "net_{$currency}"); + } + fputcsv($out, $header); + + foreach ($report['series'] as $row) { + $line = [$row['date'], $row['installs'], $row['updates'], $row['uninstalls'], $row['purchases']]; + $revenue = collect($row['revenue'])->keyBy('currency'); + foreach ($currencies as $currency) { + $money = $revenue->get($currency, ['gross' => 0, 'fee' => 0, 'net' => 0]); + array_push($line, static::decimal($money['gross']), static::decimal($money['fee']), static::decimal($money['net'])); + } + fputcsv($out, $line); + } + + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * `{prefix}-{from}-to-{to}-{group}.csv` + */ + public static function filename(string $prefix, StatsRange $range): string + { + return "{$prefix}-stats-{$range->from->toDateString()}-to-{$range->to->toDateString()}-{$range->group}.csv"; + } + + protected static function decimal(int $minor): string + { + return number_format($minor / 100, 2, '.', ''); + } +} diff --git a/app/Analytics/StatsRange.php b/app/Analytics/StatsRange.php new file mode 100644 index 0000000..b5b8d87 --- /dev/null +++ b/app/Analytics/StatsRange.php @@ -0,0 +1,129 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'group' => ['nullable', 'string', 'in:'.implode(',', self::GROUPS)], + ]); + + $to = $request->query('to') ? Carbon::parse($request->query('to'))->endOfDay() : now()->endOfDay(); + $from = $request->query('from') ? Carbon::parse($request->query('from'))->startOfDay() : $to->copy()->subDays(self::DEFAULT_DAYS - 1)->startOfDay(); + + if ($from->greaterThan($to)) { + throw ValidationException::withMessages(['from' => 'The start of the range must be on or before its end.']); + } + + $range = new self($from, $to, 'day'); + if ($range->days() > self::MAX_DAYS) { + throw ValidationException::withMessages(['from' => 'A report covers at most '.self::MAX_DAYS.' days.']); + } + + return new self($from, $to, $request->query('group') ?: self::defaultGroup($range->days())); + } + + public static function defaultGroup(int $days): string + { + return match (true) { + $days <= 92 => 'day', + $days <= 366 => 'week', + default => 'month', + }; + } + + public function days(): int + { + return (int) $this->from->copy()->startOfDay()->diffInDays($this->to->copy()->startOfDay()) + 1; + } + + /** + * The period of the same number of days that ends the day before this one starts. + */ + public function previous(): static + { + $to = $this->from->copy()->subDay()->endOfDay(); + + return new static($to->copy()->subDays($this->days() - 1)->startOfDay(), $to, $this->group); + } + + /** + * Whether the range reaches today, so "active now" is its closing figure. + */ + public function isCurrent(): bool + { + return $this->to->greaterThanOrEqualTo(now()); + } + + /** + * The first day of the bucket a day (`Y-m-d`) falls in. + */ + public function bucket(string $day): string + { + $date = Carbon::parse($day); + + return match ($this->group) { + 'week' => $date->startOfWeek(Carbon::MONDAY)->toDateString(), + 'month' => $date->startOfMonth()->toDateString(), + default => $date->toDateString(), + }; + } + + /** + * Every bucket in the range, oldest first. The first and last may be partial weeks or months. + * + * @return list + */ + public function buckets(): array + { + $buckets = []; + $cursor = Carbon::parse($this->bucket($this->from->toDateString())); + $last = $this->to->toDateString(); + + while ($cursor->toDateString() <= $last) { + $buckets[] = $cursor->toDateString(); + match ($this->group) { + 'week' => $cursor->addWeek(), + 'month' => $cursor->addMonthNoOverflow(), + default => $cursor->addDay(), + }; + } + + return $buckets; + } + + /** + * @return array{from: string, to: string, days: int} + */ + public function toArray(): array + { + return ['from' => $this->from->toDateString(), 'to' => $this->to->toDateString(), 'days' => $this->days()]; + } +} 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..71e861b 100644 --- a/app/Http/Controllers/Catalog/CatalogController.php +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -11,7 +11,6 @@ use App\Http\Resources\ReleaseResource; use App\Models\Category; use App\Models\Extension; -use App\Models\Install; use App\Models\Organization; use App\Models\Release; use App\Support\CatalogCache; @@ -72,7 +71,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 +95,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,8 +154,8 @@ protected function listingQuery(): Builder protected function listingScopes(Builder|Relation $query) { return $query - ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease']) - ->withCount(['installs' => fn ($q) => $q->where('status', Install::STATUS_INSTALLED)]); + ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease.engineDist']) + ->withCount(['installs' => fn ($q) => $q->active()]); } /** diff --git a/app/Http/Controllers/Instance/InstallController.php b/app/Http/Controllers/Instance/InstallController.php index ffea8ed..9878e53 100644 --- a/app/Http/Controllers/Instance/InstallController.php +++ b/app/Http/Controllers/Instance/InstallController.php @@ -10,20 +10,20 @@ 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: * `installing` opens a row, `installed` or `failed` closes it. Once a release is * installed, earlier installed rows for the same extension become `uninstalled`, so an - * instance counts once per extension in the catalog's install counts. + * instance counts once per extension in the catalog's install counts. Analytics tell the + * two apart: a replaced row gets `replaced_at` and the new row `previous_release_uuid` + * (an update); an uninstall sets `uninstalled_at`. */ class InstallController extends Controller { @@ -39,7 +39,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); } @@ -84,21 +84,33 @@ public function store(ReportInstallRequest $request, ActivateLicence $activate, ]); $created = ! $install->exists; + $replaced = $status === Install::STATUS_INSTALLED + ? Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->when($install->exists, fn ($q) => $q->whereKeyNot($install->getKey())) + ->orderByDesc('id') + ->get(['uuid', 'release_uuid']) + : collect(); + $install->fill([ 'release_uuid' => $release->uuid, 'status' => $status, 'installed_by' => $request->input('installed_by', $install->installed_by), 'error' => $status === Install::STATUS_FAILED ? $request->input('error') : null, 'finished_at' => $status === Install::STATUS_INSTALLING ? null : now(), + 'installed_at' => $status === Install::STATUS_INSTALLED ? now() : null, + 'previous_release_uuid' => $replaced->first()?->release_uuid, ])->save(); - if ($status === Install::STATUS_INSTALLED) { - Install::query() - ->where('instance_uuid', $instance->uuid) - ->where('extension_uuid', $extension->uuid) - ->where('status', Install::STATUS_INSTALLED) - ->whereKeyNot($install->getKey()) - ->update(['status' => Install::STATUS_UNINSTALLED, 'finished_at' => now(), 'updated_at' => now()]); + if ($replaced->isNotEmpty()) { + Install::query()->whereKey($replaced->modelKeys())->update([ + 'status' => Install::STATUS_UNINSTALLED, + 'finished_at' => now(), + 'replaced_at' => now(), + 'updated_at' => now(), + ]); } return [$install, $created]; @@ -125,7 +137,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); } @@ -144,6 +156,7 @@ public function destroy(Request $request, string $extension, DeactivateLicence $ Install::query()->whereKey($installs->modelKeys())->update([ 'status' => Install::STATUS_UNINSTALLED, 'finished_at' => now(), + 'uninstalled_at' => now(), 'updated_at' => now(), ]); $deactivated = $deactivate($instance, $model); @@ -157,28 +170,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/StatsController.php b/app/Http/Controllers/Organization/StatsController.php new file mode 100644 index 0000000..daa68de --- /dev/null +++ b/app/Http/Controllers/Organization/StatsController.php @@ -0,0 +1,64 @@ +statsOrganization($request, $slug); + $range = StatsRange::fromRequest($request); + + return response()->json([ + 'organization' => ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name], + ] + $this->report($organization, $range)); + } + + /** + * GET /v1/organizations/{slug}/stats.csv?from=&to=&group= — the series as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $organization = $this->statsOrganization($request, $slug); + $range = StatsRange::fromRequest($request); + + return StatsCsv::download($this->report($organization, $range), StatsCsv::filename($organization->slug, $range)); + } + + protected function statsOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::ROLES, 'Only organization owners, admins, publishers and billing members can see analytics.'); + + return $organization; + } + + protected function report(Organization $organization, StatsRange $range): array + { + $extensions = Extension::withTrashed()->where('organization_uuid', $organization->uuid)->orderBy('name')->get(); + + return (new ExtensionStats($extensions))->cachedReport($range, perExtension: true); + } +} 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/Publisher/StatsController.php b/app/Http/Controllers/Publisher/StatsController.php new file mode 100644 index 0000000..aa08ddd --- /dev/null +++ b/app/Http/Controllers/Publisher/StatsController.php @@ -0,0 +1,45 @@ +findExtension($request, $slug); + $range = StatsRange::fromRequest($request); + + return response()->json(['extension' => ExtensionStats::extensionReference($extension)] + $this->report($extension, $range)); + } + + /** + * GET /v1/publisher/extensions/{slug}/stats.csv?from=&to=&group= — the series as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $extension = $this->findExtension($request, $slug); + $range = StatsRange::fromRequest($request); + + return StatsCsv::download($this->report($extension, $range), StatsCsv::filename($extension->slug, $range)); + } + + protected function report(Extension $extension, StatsRange $range): array + { + return (new ExtensionStats(collect([$extension])))->cachedReport($range, versions: true); + } +} 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..3932777 100644 --- a/app/Http/Controllers/Web/StudioPageController.php +++ b/app/Http/Controllers/Web/StudioPageController.php @@ -2,17 +2,24 @@ 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\StatsController as OrganizationStatsController; +use App\Http\Controllers\Organization\StripeConnectController; use App\Http\Controllers\Publisher\ExtensionController; use App\Http\Controllers\Publisher\ReleaseController; +use App\Http\Controllers\Publisher\StatsController; +use App\Models\Membership; use App\Models\Release; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; /** * 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 insights. */ class StudioPageController extends WebController { @@ -110,9 +117,72 @@ public function history(Request $request, string $slug): Response ]); } - public function insights(): Response + /** + * GET /studio/insights[/{organization}][?extension=&from=&to=&group=] — analytics for one of the + * organization's listings, or all of them together, over a date range. + */ + public function insights(Request $request, ?string $slug = null): Response { - return Inertia::render('Studio/Insights'); + [$organizations, $organization] = $this->billingOrganizations($request, $slug, Membership::PUBLISHING_ROLES); + $filters = $this->only($request, ['from', 'to', 'group']); + $extensions = []; + $selected = $request->query('extension'); + $stats = $error = null; + + if ($organization) { + $extensions = collect($this->api($request, '/v1/publisher/extensions', [], ExtensionController::class, 'index')) + ->filter(fn (array $extension) => ($extension['organization']['slug'] ?? null) === $organization['slug']) + ->map(fn (array $extension) => array_intersect_key($extension, array_flip(['id', 'slug', 'name', 'status', 'icon_url', 'fa_icon']))) + ->values() + ->all(); + + abort_if($selected !== null && ! collect($extensions)->contains('slug', $selected), 404); + + try { + $stats = $selected !== null + ? $this->api($request, "/v1/publisher/extensions/{$selected}/stats", $filters, StatsController::class, 'show', ['slug' => $selected]) + : $this->api($request, "/v1/organizations/{$organization['slug']}/stats", $filters, OrganizationStatsController::class, 'show', ['slug' => $organization['slug']]); + } catch (ValidationException $e) { + $error = collect($e->errors())->flatten()->first(); + } + } + + return Inertia::render('Studio/Insights', [ + 'organizations' => $organizations, + 'organization' => $organization, + 'extensions' => $extensions, + 'extension' => $selected, + 'stats' => $stats, + 'filters' => $filters, + 'error' => $error, + ]); + } + + /** + * 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')], + ]); } /** 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/Import/RegistryImporter.php b/app/Import/RegistryImporter.php index 893502f..dbc268f 100644 --- a/app/Import/RegistryImporter.php +++ b/app/Import/RegistryImporter.php @@ -1014,6 +1014,8 @@ protected function importInstalls(): void 'installed_by' => 'cloud-import', 'started_at' => $createdAt, 'finished_at' => $installed ? $createdAt : ($this->date($row->deleted_at ?? null) ?? $this->date($row->updated_at ?? null) ?? $createdAt), + 'installed_at' => $createdAt, + 'uninstalled_at' => $installed ? null : ($this->date($row->deleted_at ?? null) ?? $this->date($row->updated_at ?? null) ?? $createdAt), 'created_at' => $createdAt, ], 'installs'); 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/Install.php b/app/Models/Install.php index d7c990d..a8daace 100644 --- a/app/Models/Install.php +++ b/app/Models/Install.php @@ -4,11 +4,15 @@ use App\Traits\HasUuid; use Database\Factories\InstallFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * Install telemetry reported by instances; the only source of install counts. + * Install telemetry reported by instances; the only source of install counts and of the + * publisher's install analytics. A row is one install attempt: `installed_at` is set when it + * succeeds, and it ends with `uninstalled_at` (removed) or `replaced_at` (a newer install on the + * same instance took over, which then carries `previous_release_uuid`: an update). */ class Install extends Model { @@ -27,11 +31,20 @@ class Install extends Model protected $table = 'installs'; - protected $fillable = ['instance_uuid', 'extension_uuid', 'release_uuid', 'status', 'installed_by', 'error', 'started_at', 'finished_at']; + protected $fillable = [ + 'instance_uuid', 'extension_uuid', 'release_uuid', 'previous_release_uuid', 'status', 'installed_by', 'error', + 'started_at', 'finished_at', 'installed_at', 'uninstalled_at', 'replaced_at', + ]; protected $hidden = ['id']; - protected $casts = ['started_at' => 'datetime', 'finished_at' => 'datetime']; + protected $casts = [ + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'installed_at' => 'datetime', + 'uninstalled_at' => 'datetime', + 'replaced_at' => 'datetime', + ]; protected static function newFactory(): InstallFactory { @@ -52,4 +65,18 @@ public function release(): BelongsTo { return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); } + + public function previousRelease(): BelongsTo + { + return $this->belongsTo(Release::class, 'previous_release_uuid', 'uuid'); + } + + /** + * Installs running now: what the catalog's `installs_count` and the active-instance figures + * count (one installed row per instance and extension). + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_INSTALLED); + } } 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..502ca5e 100644 --- a/config/registry.php +++ b/config/registry.php @@ -20,6 +20,9 @@ // Cache lifetime for public catalog responses, in seconds. 'catalog_cache_seconds' => (int) env('REGISTRY_CATALOG_CACHE_SECONDS', 60), + // Cache lifetime for publisher analytics reports (Studio Insights), in seconds. + 'analytics_cache_seconds' => (int) env('REGISTRY_ANALYTICS_CACHE_SECONDS', 60), + // Package namespaces (npm scope / Composer vendor) only an organization flagged // `is_staff` may own. Grant the flag with `php artisan registry:staff `. 'reserved_scopes' => ['fleetbase'], @@ -83,4 +86,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/database/migrations/2026_10_25_000301_add_lifecycle_timestamps_to_installs_table.php b/database/migrations/2026_10_25_000301_add_lifecycle_timestamps_to_installs_table.php new file mode 100644 index 0000000..71298be --- /dev/null +++ b/database/migrations/2026_10_25_000301_add_lifecycle_timestamps_to_installs_table.php @@ -0,0 +1,64 @@ +timestamp('installed_at')->nullable()->after('finished_at'); + $table->timestamp('uninstalled_at')->nullable()->after('installed_at'); + $table->timestamp('replaced_at')->nullable()->after('uninstalled_at'); + $table->uuid('previous_release_uuid')->nullable()->after('release_uuid'); + + // Each carries the column its query also reads, so the series scans only the index. + $table->index(['extension_uuid', 'installed_at', 'previous_release_uuid']); + $table->index(['extension_uuid', 'uninstalled_at', 'installed_at']); + // Covers what (extension_uuid, status) served, plus the per-version split. + $table->index(['extension_uuid', 'status', 'release_uuid']); + $table->dropIndex(['extension_uuid', 'status']); + }); + + Schema::table('purchases', function (Blueprint $table) { + $table->index(['extension_uuid', 'created_at']); + }); + + // Rows written before these columns: installed when the attempt finished (an installed + // row) or began (an ended one); an ended row counts as uninstalled when it ended. Updates + // made before this point read as an uninstall and an install. + DB::table('installs')->where('status', 'installed')->update(['installed_at' => DB::raw('COALESCE(finished_at, started_at, created_at)')]); + DB::table('installs')->where('status', 'uninstalled')->update([ + 'installed_at' => DB::raw('COALESCE(started_at, created_at)'), + 'uninstalled_at' => DB::raw('COALESCE(finished_at, updated_at, created_at)'), + ]); + } + + public function down(): void + { + Schema::table('purchases', function (Blueprint $table) { + $table->dropIndex(['extension_uuid', 'created_at']); + }); + + Schema::table('installs', function (Blueprint $table) { + $table->dropIndex(['extension_uuid', 'installed_at', 'previous_release_uuid']); + $table->dropIndex(['extension_uuid', 'uninstalled_at', 'installed_at']); + $table->index(['extension_uuid', 'status']); + $table->dropIndex(['extension_uuid', 'status', 'release_uuid']); + $table->dropColumn(['installed_at', 'uninstalled_at', 'replaced_at', 'previous_release_uuid']); + }); + } +}; 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..beebf56 100644 --- a/resources/css/registry.css +++ b/resources/css/registry.css @@ -84,6 +84,7 @@ code, kbd, .mono { font-family: var(--mono); font-size: 0.88em; } .ink2 { color: var(--ink-2); } .eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } .num { font-variant-numeric: tabular-nums; } +.nowrap { white-space: nowrap; } /* Layout */ .wrap { width: 100%; max-width: 1240px; margin: 0 auto; padding: 0 40px; } @@ -153,7 +154,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/insights/InsightsPage.test.jsx b/resources/js/components/insights/InsightsPage.test.jsx new file mode 100644 index 0000000..93353a6 --- /dev/null +++ b/resources/js/components/insights/InsightsPage.test.jsx @@ -0,0 +1,207 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { router } from '@inertiajs/react'; +import Insights from '../../pages/Studio/Insights'; +import TrendChart from './TrendChart'; + +let page; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => page, + router: { get: vi.fn() }, + Link: ({ href, children, ...rest }) => ( + + {children} + + ), + Head: () => null, +})); + +vi.mock('../../layouts/SiteLayout', () => ({ default: ({ children }) =>
{children}
})); + +const acme = { id: 'org_acme', slug: 'acme', name: 'Acme Fleet Tools', role: 'owner', is_personal: false }; +const extensions = [ + { id: 'ext_1', slug: 'route-optimizer', name: 'Route Optimizer', status: 'published' }, + { id: 'ext_2', slug: 'pallet-wms', name: 'Pallet WMS', status: 'draft' }, +]; + +function series(days) { + return days.map(([date, installs, uninstalls, updates = 0]) => ({ date, installs, updates, uninstalls, purchases: 0, revenue: [] })); +} + +const listingStats = { + extension: { id: 'ext_1', slug: 'route-optimizer', name: 'Route Optimizer', status: 'published' }, + range: { from: '2026-10-01', to: '2026-10-03', days: 3, group: 'day' }, + previous: { from: '2026-09-28', to: '2026-09-30', days: 3 }, + as_of: '2026-10-03T09:00:00+00:00', + totals: { + installs: 41, + updates: 5, + uninstalls: 3, + purchases: 9, + revenue: [ + { currency: 'USD', purchases: 8, gross: 134100, refunded: 0, fee: 13410, net: 120690 }, + { currency: 'EUR', purchases: 1, gross: 4000, refunded: 0, fee: 400, net: 3600 }, + ], + active_instances: 214, + }, + previous_totals: { installs: 36, updates: 0, uninstalls: 1, purchases: 7, revenue: [{ currency: 'USD', purchases: 7, gross: 100000, refunded: 0, fee: 10000, net: 90000 }], active_instances: 180 }, + series: series([ + ['2026-10-01', 10, 1], + ['2026-10-02', 25, 0, 3], + ['2026-10-03', 6, 2], + ]), + versions: [ + { release: 'rel_2', version: '0.9.1', instances: 178, share: 0.8318 }, + { release: 'rel_1', version: '0.9.0', instances: 29, share: 0.1355 }, + { release: 'rel_0', version: '0.8.4', instances: 7, share: 0.0327 }, + ], + events: { + total: 58, + data: [ + { type: 'install', at: '2026-10-03T08:41:00Z', extension: null, organization: { name: 'Northwind Logistics' }, version: '0.9.1', from_version: null, amount: null, currency: null }, + { type: 'purchase', at: '2026-10-02T17:02:00Z', extension: null, organization: { name: 'Northwind Logistics' }, version: null, from_version: null, amount: 14900, currency: 'USD' }, + { type: 'update', at: '2026-10-01T09:30:00Z', extension: null, organization: { name: 'Atlas Cold Chain' }, version: '0.9.1', from_version: '0.9.0', amount: null, currency: null }, + { type: 'refund', at: '2026-10-01T08:00:00Z', extension: null, organization: { name: 'Harbor Freight' }, version: null, from_version: null, amount: 4900, currency: 'USD', partial: true }, + ], + }, +}; + +const organizationStats = { + ...listingStats, + extension: undefined, + versions: undefined, + organization: { id: 'org_acme', slug: 'acme', name: 'Acme Fleet Tools' }, + extensions: [ + { extension: { id: 'ext_1', slug: 'route-optimizer', name: 'Route Optimizer', status: 'published' }, installs: 30, updates: 5, uninstalls: 2, purchases: 9, revenue: [{ currency: 'USD', net: 120690 }], active_instances: 200 }, + { extension: { id: 'ext_9', slug: 'retired', name: 'Retired', status: 'published' }, installs: 0, updates: 0, uninstalls: 1, purchases: 0, revenue: [], active_instances: 0 }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + page = { url: '/studio/insights', props: { auth: { account: null } } }; +}); + +describe('Insights', () => { + it('shows tiles against the previous period, the trend, versions and recent events for one listing', () => { + render(); + + const installs = screen.getByText('Installs', { selector: '[data-stat] > span' }).closest('[data-stat]'); + expect(within(installs).getByText('41')).toBeInTheDocument(); + expect(within(installs).getByText('▲ 14%')).toHaveAttribute('data-delta', 'good'); + expect(within(installs).getByText('5 updates besides')).toBeInTheDocument(); + + const uninstalls = document.querySelector('[data-stat="uninstalls"]'); + expect(within(uninstalls).getByText('▲ +2')).toHaveAttribute('data-delta', 'bad'); + expect(within(document.querySelector('[data-stat="active_instances"]')).getByText('since 1 Oct')).toBeInTheDocument(); + + const net = document.querySelector('[data-stat="net"]'); + expect(within(net).getByText('Net revenue (USD)')).toBeInTheDocument(); + expect(within(net).getByText('$1,206.90')).toBeInTheDocument(); + expect(within(net).getByText('Also €36')).toBeInTheDocument(); + + expect(screen.getByRole('heading', { name: 'Installs and uninstalls per day' })).toBeInTheDocument(); + const versions = document.querySelector('[data-versions]'); + expect(within(versions).getByText('v0.9.1')).toBeInTheDocument(); + expect(within(versions).getByText('214 instances')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'By extension' })).not.toBeInTheDocument(); + + const events = document.querySelector('[data-events]'); + expect(within(events).getByText('Latest 4 of 58 in this range')).toBeInTheDocument(); + expect(within(events).getByText('v0.9.0 → v0.9.1')).toBeInTheDocument(); + expect(within(events).getByText('Partly refunded')).toBeInTheDocument(); + expect(within(events).getByText('−$49')).toBeInTheDocument(); + expect(within(events).queryByRole('columnheader', { name: 'Extension' })).not.toBeInTheDocument(); + + expect(screen.getByRole('link', { name: /Export CSV/ })).toHaveAttribute('href', '/v1/publisher/extensions/route-optimizer/stats.csv?from=2026-10-01&to=2026-10-03&group=day'); + expect(document.querySelector('[data-as-of]')).toHaveTextContent('days in UTC · compared with 28 Sep – 30 Sep 2026'); + }); + + it('summarises every listing with a row each, and picks one from its row', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('All extensions of Acme Fleet Tools, 1 Oct – 3 Oct 2026')).toBeInTheDocument(); + const table = document.querySelector('[data-by-extension]'); + expect(within(table).getByText('(deleted)')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Export CSV/ })).toHaveAttribute('href', '/v1/organizations/acme/stats.csv?from=2026-10-01&to=2026-10-03&group=day'); + expect(within(document.querySelector('[data-events]')).getByRole('columnheader', { name: 'Extension' })).toBeInTheDocument(); + + await user.click(within(table).getByRole('button', { name: 'Route Optimizer' })); + expect(router.get).toHaveBeenCalledWith('/studio/insights/acme', { extension: 'route-optimizer', from: '2026-10-01', to: '2026-10-03' }, expect.objectContaining({ only: ['stats', 'filters', 'extension', 'error'] })); + }); + + it('changes the listing, the range and the grouping from the filter row', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-23T12:00:00Z')); + const user = userEvent.setup(); + render(); + + await user.selectOptions(screen.getByLabelText('Extension'), 'pallet-wms'); + expect(router.get).toHaveBeenLastCalledWith('/studio/insights/acme', { extension: 'pallet-wms', from: '2026-10-01', to: '2026-10-03' }, expect.anything()); + + await user.click(screen.getByRole('button', { name: '90 days' })); + expect(router.get).toHaveBeenLastCalledWith('/studio/insights/acme', { from: '2026-07-26', to: '2026-10-23' }, expect.anything()); + + await user.selectOptions(screen.getByLabelText('Group by'), 'week'); + expect(router.get).toHaveBeenLastCalledWith('/studio/insights/acme', { from: '2026-10-01', to: '2026-10-03', group: 'week' }, expect.anything()); + + const custom = screen.getByRole('form', { name: 'Custom range' }); + fireEvent.change(within(custom).getByLabelText('From'), { target: { value: '2026-09-01' } }); + fireEvent.change(within(custom).getByLabelText('To'), { target: { value: '2026-09-30' } }); + await user.click(within(custom).getByRole('button', { name: 'Show' })); + expect(router.get).toHaveBeenLastCalledWith('/studio/insights/acme', { from: '2026-09-01', to: '2026-09-30' }, expect.anything()); + vi.useRealTimers(); + }); + + it('explains an empty organization and a refused range', () => { + const { rerender } = render(); + expect(screen.getByRole('heading', { name: 'No extensions yet' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /New extension/ })).toHaveAttribute('href', '/studio/new'); + + rerender(); + expect(screen.getByRole('heading', { name: 'No organization to show' })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('alert')).toHaveTextContent('A report covers at most 731 days.'); + }); +}); + +describe('TrendChart', () => { + const rows = listingStats.series; + + it('describes itself and lists every value in a table', () => { + render(); + + expect(screen.getByRole('img')).toHaveAccessibleName(/41 installs and 3 uninstalls over 3 days/); + const table = screen.getByRole('table'); + expect(within(table).getAllByRole('row')).toHaveLength(4); + expect(within(table).getByRole('row', { name: '2 Oct 25 3 0' })).toBeInTheDocument(); + }); + + it('reads out each bucket with the arrow keys', () => { + render(); + const slider = screen.getByRole('slider', { name: 'Installs and uninstalls by day' }); + + fireEvent.focus(slider); + expect(slider).toHaveAttribute('aria-valuetext', '3 Oct: 6 installs, 2 uninstalls'); + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + expect(slider).toHaveAttribute('aria-valuetext', '2 Oct: 25 installs, 0 uninstalls'); + expect(screen.getByRole('status')).toHaveTextContent('3 updated'); + fireEvent.keyDown(slider, { key: 'Home' }); + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + expect(slider).toHaveAttribute('aria-valuetext', '1 Oct: 10 installs, 1 uninstalls'); + fireEvent.blur(slider); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('draws a lone bucket as points and an empty range as an empty frame', () => { + const { container, rerender } = render(); + expect(container.querySelectorAll('polyline')).toHaveLength(0); + expect(container.querySelectorAll('circle.ins-dot')).toHaveLength(2); + + rerender(); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/components/insights/TrendChart.jsx b/resources/js/components/insights/TrendChart.jsx new file mode 100644 index 0000000..9f2138d --- /dev/null +++ b/resources/js/components/insights/TrendChart.jsx @@ -0,0 +1,212 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { bucketLabel, compact, niceMax, ticks } from './insights'; + +const HEIGHT = 240; +const PAD = { top: 12, right: 16, bottom: 28, left: 40 }; + +/** The two lines, in plot order. Uninstalls are dashed so the pair never relies on colour alone. */ +export const SERIES = [ + { key: 'installs', label: 'Installs', className: 'ins-installs' }, + { key: 'uninstalls', label: 'Uninstalls', className: 'ins-uninstalls', dashed: true }, +]; + +/** The chart's width follows its container; SSR and jsdom get the default. */ +function useWidth(fallback = 640) { + const ref = useRef(null); + const [width, setWidth] = useState(fallback); + useEffect(() => { + if (!ref.current || typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width)))); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + return [ref, width]; +} + +/** + * Installs and uninstalls per day, week or month, as two lines on one axis, with a crosshair + * readout on hover and arrow keys, and the same numbers as a table under it. + */ +export default function TrendChart({ series = [], group = 'day', partialFirst = false, partialLast = false }) { + const [ref, width] = useWidth(); + const [active, setActive] = useState(null); + const id = useId(); + + const count = series.length; + const plotWidth = width - PAD.left - PAD.right; + const plotHeight = HEIGHT - PAD.top - PAD.bottom; + const max = niceMax(Math.max(0, ...series.flatMap((row) => SERIES.map((s) => row[s.key] ?? 0)))); + const x = (index) => PAD.left + (count <= 1 ? plotWidth / 2 : (index / (count - 1)) * plotWidth); + const y = (value) => PAD.top + plotHeight - (value / max) * plotHeight; + const labelled = count <= 1 ? [0] : [...new Set([0, Math.floor((count - 1) / 2), count - 1])]; + const point = active !== null ? series[active] : null; + // The bucket holding today is still filling up: draw its segment faint and its dot open. + // A bucket the range only partly covers (today's, or a week or month cut by the range) is drawn + // faint, with an open dot at the end, so a short bucket never reads as a drop. + const first = partialFirst && count > 2 ? 1 : 0; + const last = partialLast && count > 1 ? count - 1 : count; + const label = (index) => `${bucketLabel(series[index].date, group)}${partialLast && index === count - 1 ? ' (so far)' : partialFirst && index === 0 ? ' (part)' : ''}`; + const note = partialFirst && partialLast ? `The first ${group} is partial and the last is still in progress.` : partialLast ? `The last ${group} is still in progress.` : partialFirst ? `The first ${group} is partial.` : null; + const points = (key, from, to) => series.slice(from, to).map((row, offset) => `${x(from + offset)},${y(row[key] ?? 0)}`).join(' '); + + function pick(event) { + const box = event.currentTarget.getBoundingClientRect(); + const ratio = box.width ? (event.clientX - box.left) / box.width : 0; + const position = ((ratio * width - PAD.left) / plotWidth) * (count - 1); + setActive(Math.min(count - 1, Math.max(0, Math.round(position)))); + } + + function keys(event) { + const move = { + ArrowRight: (current) => current + 1, + ArrowLeft: (current) => current - 1, + Home: () => 0, + End: () => count - 1, + }[event.key]; + if (!move) { + return; + } + event.preventDefault(); + setActive((current) => Math.min(count - 1, Math.max(0, move(current ?? count - 1)))); + } + + return ( +
+ +
+ + + Installs and uninstalls per {group}: {series.reduce((sum, row) => sum + row.installs, 0)} installs and {series.reduce((sum, row) => sum + row.uninstalls, 0)} uninstalls over {count} {group} + {count === 1 ? '' : 's'}. The table below lists every value. + + + {ticks(max).map((tick) => ( + + + + {compact(tick)} + + + ))} + {labelled.map((index) => ( + 1 && index === 0 ? 'start' : count > 1 && index === count - 1 ? 'end' : 'middle'}> + {series[index] ? bucketLabel(series[index].date, group).replace('Week of ', '') : ''} + + ))} + + {SERIES.map((s) => ( + + {count > 1 && ( + + )} + {first > 0 && } + {last < count && } + {count > 0 && } + + ))} + {point && ( + + )} + {count > 0 && ( + setActive(null)} + onFocus={() => setActive((current) => current ?? count - 1)} + onBlur={() => setActive(null)} + onKeyDown={keys} + style={{ outline: 'none', cursor: 'crosshair' }} + /> + )} + + {point && ( +
+ {label(active)} + {SERIES.map((s) => ( + + + {point[s.key]} + {s.label.toLowerCase()} + + ))} + {point.updates > 0 && {point.updates} updated} +
+ )} +
+
+ Show as a table +
+ + + + + + + + + + + {series.map((row) => ( + + + + + + + ))} + +
{group === 'day' ? 'Day' : group === 'week' ? 'Week' : 'Month'} + Installs + + Updates + + Uninstalls +
{bucketLabel(row.date, group)} + {row.installs} + + {row.updates} + + {row.uninstalls} +
+
+
+
+ ); +} diff --git a/resources/js/components/insights/insights.css b/resources/js/components/insights/insights.css new file mode 100644 index 0000000..d1d9ff9 --- /dev/null +++ b/resources/js/components/insights/insights.css @@ -0,0 +1,63 @@ +/* Studio Insights: stat tiles, the trend chart, version bars. Series colours are validated for + colour-blind separation and 3:1 contrast against each theme's surface (dataviz validator). */ +:root { + --series-installs: #1c6cc7; + --series-uninstalls: #d4572a; +} +[data-theme='dark'] { + --series-installs: #3485e2; + --series-uninstalls: #e06a33; +} + +.ins-tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; } +@media (max-width: 1000px) { + .ins-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 600px) { + .ins-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +} +.ins-presets { max-width: 100%; overflow-x: auto; } +.ins-presets button { white-space: nowrap; padding: 0 11px; } +.seg button[aria-pressed='true'] { background: var(--surface); color: var(--ink); box-shadow: var(--e1), 0 0 0 1px var(--line); } +.ins-delta { font-weight: 600; } +.ins-good { color: var(--ok); } +.ins-bad { color: var(--fail); } +.ins-flat { color: var(--muted); } + +.ins-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; } +.ins-split { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 24px; align-items: start; } +@media (max-width: 1100px) { + .ins-split { grid-template-columns: minmax(0, 1fr); } +} + +.ins-chart { display: flex; flex-direction: column; gap: 10px; } +.ins-legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12.5px; color: var(--ink-2); } +.ins-installs { stroke: var(--series-installs); fill: var(--series-installs); } +.ins-uninstalls { stroke: var(--series-uninstalls); fill: var(--series-uninstalls); } +.ins-installs polyline, .ins-uninstalls polyline { fill: none; } +.ins-dot { stroke: var(--surface); stroke-width: 2; } +.ins-partial { opacity: 0.45; } +.ins-dot.ins-open { fill: var(--surface); stroke: currentColor; } +.ins-installs .ins-dot.ins-open { stroke: var(--series-installs); } +.ins-uninstalls .ins-dot.ins-open { stroke: var(--series-uninstalls); } +.ins-grid line { stroke: var(--line); stroke-width: 1; } +.ins-grid text { fill: var(--muted); font: 11px var(--font); font-variant-numeric: tabular-nums; } +.ins-cross line { stroke: var(--line-strong); stroke-width: 1; } +.ins-chart rect:focus-visible { outline: 2px solid var(--focus) !important; outline-offset: -2px; } + +.ins-tip { + position: absolute; top: 4px; transform: translateX(-50%); pointer-events: none; + display: flex; flex-direction: column; gap: 4px; min-width: 150px; padding: 10px 12px; + background: var(--raised); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--e2); + font-size: 13px; color: var(--ink); +} + +.ins-table summary { cursor: pointer; color: var(--ink-2); width: max-content; } +.ins-table[open] summary { margin-bottom: 8px; } +.ins-table .tbl-wrap { max-height: 320px; overflow-y: auto; } + +.ins-bar { height: 8px; border-radius: 999px; background: var(--bg-tint); overflow: hidden; } +.ins-bar span { display: block; height: 100%; border-radius: 999px; background: var(--series-installs); } + +.linkish { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; text-align: left; } +.linkish:hover { text-decoration: underline; } diff --git a/resources/js/components/insights/insights.js b/resources/js/components/insights/insights.js new file mode 100644 index 0000000..a89b4a2 --- /dev/null +++ b/resources/js/components/insights/insights.js @@ -0,0 +1,168 @@ +import { money } from '../../lib/format'; + +/** Range presets above the report, in days ending today. */ +export const PRESETS = [ + { days: 7, label: '7 days' }, + { days: 30, label: '30 days' }, + { days: 90, label: '90 days' }, + { days: 365, label: '1 year' }, +]; + +export const GROUPS = [ + { value: 'day', label: 'Day' }, + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, +]; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** `YYYY-MM-DD` of a date, in UTC (the report's days are UTC). */ +export function isoDay(date) { + return date.toISOString().slice(0, 10); +} + +/** `{from, to}` for the last `days` days, today included. */ +export function presetRange(days, today = new Date()) { + const to = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + const from = new Date(to.getTime() - (days - 1) * 86400000); + return { from: isoDay(from), to: isoDay(to) }; +} + +/** The preset a report's range matches (ending today), or null for a custom range. */ +export function activePreset(range, today = new Date()) { + if (!range) { + return null; + } + const preset = PRESETS.find((item) => { + const expected = presetRange(item.days, today); + return expected.from === range.from && expected.to === range.to; + }); + return preset?.days ?? null; +} + +function parts(day) { + const [year, month, date] = String(day).split('-').map(Number); + return { year, month, date }; +} + +/** `5 Oct`, `5 Oct 2025` when `withYear`. */ +export function dayLabel(day, withYear = false) { + const { year, month, date } = parts(day); + return `${date} ${MONTHS[month - 1]}${withYear ? ` ${year}` : ''}`; +} + +/** A bucket's label: `5 Oct`, `Week of 5 Oct`, `Oct 2026`. */ +export function bucketLabel(day, group) { + const { year, month } = parts(day); + if (group === 'month') { + return `${MONTHS[month - 1]} ${year}`; + } + return group === 'week' ? `Week of ${dayLabel(day)}` : dayLabel(day); +} + +/** `1 Oct – 23 Oct 2026` */ +export function rangeLabel(range) { + if (!range) { + return ''; + } + const sameYear = parts(range.from).year === parts(range.to).year; + return `${dayLabel(range.from, !sameYear)} – ${dayLabel(range.to, true)}`; +} + +/** + * The change from the previous period, worded and toned for a stat tile. `goodWhenUp` says whether + * a rise is good news (installs) or bad (uninstalls); `absolute` words it as a count. + */ +export function change(current, previous, { goodWhenUp = true, absolute = false, format = (value) => String(value) } = {}) { + const diff = (current ?? 0) - (previous ?? 0); + if (diff === 0) { + return { text: 'No change', tone: 'flat', diff }; + } + const up = diff > 0; + const tone = up === goodWhenUp ? 'good' : 'bad'; + const arrow = up ? '▲' : '▼'; + if (absolute || !previous) { + return { text: `${arrow} ${up ? '+' : '−'}${format(Math.abs(diff))}`, tone, diff }; + } + const pct = Math.round((Math.abs(diff) / Math.abs(previous)) * 100); + return { text: `${arrow} ${pct}%`, tone, diff }; +} + +/** Revenue in one currency from a report's list, zeros when it sold nothing there. */ +export function revenueIn(revenue = [], currency) { + return revenue.find((line) => line.currency === currency) ?? { currency, purchases: 0, gross: 0, refunded: 0, fee: 0, net: 0 }; +} + +/** `$1,207 · €36` — net per currency, highest first. */ +export function revenueLine(revenue = [], field = 'net') { + return revenue.length ? revenue.map((line) => money(line[field], line.currency)).join(' · ') : money(0); +} + +/** + * Version rows for the bars: the newest `max - 1` versions and the rest folded into one + * `X and older` row, so a long tail never crowds the panel. + */ +export function versionRows(versions = [], max = 5) { + if (versions.length <= max) { + return versions.map((row) => ({ ...row, label: row.version ? `v${row.version}` : 'Unknown version' })); + } + const head = versions.slice(0, max - 1).map((row) => ({ ...row, label: row.version ? `v${row.version}` : 'Unknown version' })); + const tail = versions.slice(max - 1); + const folded = tail.reduce((sum, row) => ({ instances: sum.instances + row.instances, share: sum.share + row.share }), { instances: 0, share: 0 }); + return [...head, { ...folded, version: null, release: null, label: `${tail[0].version ? `v${tail[0].version}` : 'Unknown'} and older` }]; +} + +/** + * The top of the y axis: `count` equal whole-number steps of 1, 2, 2.5 or 5 × 10ⁿ that reach the + * largest value (at least 1 per step), so every gridline lands on a clean integer. + */ +export function niceMax(value, count = 4) { + const raw = Math.max(1, value / count); + const magnitude = 10 ** Math.floor(Math.log10(raw)); + const factors = magnitude >= 10 ? [1, 2, 2.5, 5, 10] : [1, 2, 5, 10]; + return factors.find((factor) => factor * magnitude >= raw) * magnitude * count; +} + +/** Evenly spaced ticks from 0 to `max`. */ +export function ticks(max, count = 4) { + return Array.from({ length: count + 1 }, (_, index) => Math.round((max / count) * index)); +} + +/** `1.2K` from 1,000 up. */ +export function compact(value) { + return value >= 1000 ? `${(value / 1000).toFixed(value >= 10000 ? 0 : 1).replace(/\.0$/, '')}K` : String(value); +} + +const EVENTS = { + install: { label: 'Installed', tone: 'ok', icon: 'download' }, + update: { label: 'Updated', tone: 'wait', icon: 'arrowUp' }, + uninstall: { label: 'Uninstalled', tone: 'fail', icon: 'trash' }, + purchase: { label: 'Purchased', tone: 'wait', icon: 'card' }, + refund: { label: 'Refunded', tone: 'you', icon: 'arrowLeft' }, +}; + +export function eventStatus(event) { + const status = EVENTS[event.type] ?? { label: event.type, tone: 'idle', icon: 'minus' }; + if (event.type === 'refund' && event.partial) { + return { ...status, label: 'Partly refunded' }; + } + if (event.type === 'update' && event.from_version && event.from_version === event.version) { + return { ...status, label: 'Reinstalled' }; + } + return status; +} + +/** The version column of an event: `v1.1.0`, `v1.0.0 → v1.1.0`. */ +export function eventVersion(event) { + if (!event.version) { + return '—'; + } + return event.type === 'update' && event.from_version && event.from_version !== event.version ? `v${event.from_version} → v${event.version}` : `v${event.version}`; +} + +/** The CSV of what's on screen: the listing's, or the organization's. */ +export function csvHref({ organization, extension, range }) { + const query = new URLSearchParams(range ? { from: range.from, to: range.to, group: range.group } : {}).toString(); + const base = extension ? `/v1/publisher/extensions/${extension}/stats.csv` : `/v1/organizations/${organization}/stats.csv`; + return query ? `${base}?${query}` : base; +} diff --git a/resources/js/components/insights/insights.test.js b/resources/js/components/insights/insights.test.js new file mode 100644 index 0000000..860934c --- /dev/null +++ b/resources/js/components/insights/insights.test.js @@ -0,0 +1,100 @@ +import { activePreset, bucketLabel, change, compact, csvHref, eventStatus, eventVersion, niceMax, presetRange, rangeLabel, revenueIn, revenueLine, ticks, versionRows } from './insights'; + +const today = new Date('2026-10-23T15:00:00Z'); + +describe('ranges', () => { + it('builds presets ending today, in UTC days', () => { + expect(presetRange(30, today)).toEqual({ from: '2026-09-24', to: '2026-10-23' }); + expect(presetRange(7, today)).toEqual({ from: '2026-10-17', to: '2026-10-23' }); + }); + + it('recognises a preset range and treats anything else as custom', () => { + expect(activePreset({ from: '2026-09-24', to: '2026-10-23' }, today)).toBe(30); + expect(activePreset({ from: '2026-10-01', to: '2026-10-23' }, today)).toBeNull(); + expect(activePreset(undefined, today)).toBeNull(); + }); + + it('labels ranges and buckets', () => { + expect(rangeLabel({ from: '2026-10-01', to: '2026-10-23' })).toBe('1 Oct – 23 Oct 2026'); + expect(rangeLabel({ from: '2025-12-01', to: '2026-01-31' })).toBe('1 Dec 2025 – 31 Jan 2026'); + expect(bucketLabel('2026-10-05', 'day')).toBe('5 Oct'); + expect(bucketLabel('2026-10-05', 'week')).toBe('Week of 5 Oct'); + expect(bucketLabel('2026-10-01', 'month')).toBe('Oct 2026'); + }); +}); + +describe('change against the previous period', () => { + it('words a rise as a percentage, good or bad by the measure', () => { + expect(change(41, 36)).toEqual({ text: '▲ 14%', tone: 'good', diff: 5 }); + expect(change(30, 40)).toEqual({ text: '▼ 25%', tone: 'bad', diff: -10 }); + expect(change(3, 1, { goodWhenUp: false, absolute: true })).toEqual({ text: '▲ +2', tone: 'bad', diff: 2 }); + }); + + it('counts instead of dividing by zero, and says when nothing changed', () => { + expect(change(4, 0)).toEqual({ text: '▲ +4', tone: 'good', diff: 4 }); + expect(change(2, 2)).toEqual({ text: 'No change', tone: 'flat', diff: 0 }); + expect(change(120700, 0, { format: (value) => `$${value / 100}` }).text).toBe('▲ +$1207'); + }); +}); + +describe('revenue', () => { + const revenue = [ + { currency: 'USD', purchases: 2, gross: 10584, refunded: 5292, fee: 490, net: 4802 }, + { currency: 'EUR', purchases: 1, gross: 4000, refunded: 0, fee: 400, net: 3600 }, + ]; + + it('picks a currency, zero when it sold nothing there', () => { + expect(revenueIn(revenue, 'EUR').net).toBe(3600); + expect(revenueIn(revenue, 'GBP')).toEqual({ currency: 'GBP', purchases: 0, gross: 0, refunded: 0, fee: 0, net: 0 }); + }); + + it('lists net per currency', () => { + expect(revenueLine(revenue)).toBe('$48.02 · €36'); + expect(revenueLine([])).toBe('$0'); + }); +}); + +describe('versions', () => { + const versions = ['2.1.0', '2.0.0', '1.9.0', '1.8.0', '1.7.0', '1.6.0'].map((version, index) => ({ version, release: `rel_${index}`, instances: 10 - index, share: (10 - index) / 45 })); + + it('folds the tail into one older row', () => { + const rows = versionRows(versions); + expect(rows.map((row) => row.label)).toEqual(['v2.1.0', 'v2.0.0', 'v1.9.0', 'v1.8.0', 'v1.7.0 and older']); + expect(rows[4].instances).toBe(11); + expect(rows[4].share).toBeCloseTo(11 / 45); + }); + + it('keeps short lists whole and names a missing version', () => { + expect(versionRows([{ version: null, instances: 2, share: 1 }]).map((row) => row.label)).toEqual(['Unknown version']); + }); +}); + +describe('axis', () => { + it('rounds the top of the axis up to a clean number', () => { + expect(niceMax(0)).toBe(4); + expect(niceMax(6)).toBe(8); + expect(niceMax(23)).toBe(40); + expect(niceMax(90)).toBe(100); + expect(niceMax(1340)).toBe(2000); + expect(ticks(40)).toEqual([0, 10, 20, 30, 40]); + expect(compact(1500)).toBe('1.5K'); + expect(compact(12000)).toBe('12K'); + expect(compact(800)).toBe('800'); + }); +}); + +describe('events', () => { + it('labels updates, reinstalls and partial refunds', () => { + expect(eventStatus({ type: 'update', version: '1.1.0', from_version: '1.0.0' }).label).toBe('Updated'); + expect(eventStatus({ type: 'update', version: '1.1.0', from_version: '1.1.0' }).label).toBe('Reinstalled'); + expect(eventStatus({ type: 'refund', partial: true }).label).toBe('Partly refunded'); + expect(eventVersion({ type: 'update', version: '1.1.0', from_version: '1.0.0' })).toBe('v1.0.0 → v1.1.0'); + expect(eventVersion({ type: 'purchase', version: null })).toBe('—'); + }); +}); + +it('points the CSV at the listing or the organization, with the range', () => { + const range = { from: '2026-10-01', to: '2026-10-23', group: 'week' }; + expect(csvHref({ organization: 'acme', extension: 'route-optimizer', range })).toBe('/v1/publisher/extensions/route-optimizer/stats.csv?from=2026-10-01&to=2026-10-23&group=week'); + expect(csvHref({ organization: 'acme', extension: null, range })).toBe('/v1/organizations/acme/stats.csv?from=2026-10-01&to=2026-10-23&group=week'); +}); 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) =>