diff --git a/backend/composer.json b/backend/composer.json index 0beba7a..957c2af 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -7,7 +7,7 @@ "license": "MIT", "require": { "php": "^8.3", - "fluttersdk/magic-starter-laravel": "^0.0.5", + "fluttersdk/magic-starter-laravel": "^0.0.9", "laravel/framework": "^13.8", "laravel/tinker": "^3.0" }, diff --git a/backend/composer.lock b/backend/composer.lock index d682cab..8acdc33 100644 --- a/backend/composer.lock +++ b/backend/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": "4f41380a5cccc4dd11cc0f9cf8326637", + "content-hash": "8ea890d082e4ce1f52f90ae064addf1f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -680,23 +680,30 @@ }, { "name": "fluttersdk/magic-starter-laravel", - "version": "0.0.5", + "version": "0.0.9", "source": { "type": "git", "url": "https://github.com/fluttersdk/magic-starter-laravel.git", - "reference": "305232dcfc50b3336078c85f2cf1fc7891619482" + "reference": "b4644a5e642cc37fb6ba1e5110280e56db6d3509" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fluttersdk/magic-starter-laravel/zipball/305232dcfc50b3336078c85f2cf1fc7891619482", - "reference": "305232dcfc50b3336078c85f2cf1fc7891619482", + "url": "https://api.github.com/repos/fluttersdk/magic-starter-laravel/zipball/b4644a5e642cc37fb6ba1e5110280e56db6d3509", + "reference": "b4644a5e642cc37fb6ba1e5110280e56db6d3509", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", + "illuminate/auth": "^12.0|^13.0", + "illuminate/bus": "^12.0|^13.0", + "illuminate/console": "^12.0|^13.0", "illuminate/database": "^12.0|^13.0", + "illuminate/http": "^12.0|^13.0", + "illuminate/queue": "^12.0|^13.0", "illuminate/routing": "^12.0|^13.0", "illuminate/support": "^12.0|^13.0", + "illuminate/validation": "^12.0|^13.0", + "laravel/cashier": "^16.6", "laravel/sanctum": "^4.0", "laravel/socialite": "^5.0", "onesignal/onesignal-php-api": "^5.3", @@ -751,7 +758,7 @@ "issues": "https://github.com/fluttersdk/magic-starter-laravel/issues", "source": "https://github.com/fluttersdk/magic-starter-laravel" }, - "time": "2026-07-25T23:17:38+00:00" + "time": "2026-09-21T16:36:49+00:00" }, { "name": "fruitcake/php-cors", @@ -1303,6 +1310,95 @@ ], "time": "2026-08-24T17:13:02+00:00" }, + { + "name": "laravel/cashier", + "version": "v16.8.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "3741d81d0e2b7ca8de52c2f9a272d30edfa741b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/3741d81d0e2b7ca8de52c2f9a272d30edfa741b9", + "reference": "3741d81d0e2b7ca8de52c2f9a272d30edfa741b9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^17.4|^18.0|^19.0|^20.0|^21.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.0|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.22.1", + "symfony/polyfill-php84": "^1.32" + }, + "require-dev": { + "dompdf/dompdf": "^2.0|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "spatie/laravel-ray": "^1.40" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", + "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2026-09-01T13:29:00+00:00" + }, { "name": "laravel/framework", "version": "v13.29.0", @@ -2489,6 +2585,96 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" + }, + "time": "2026-05-04T20:23:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4098,6 +4284,68 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v21.3.2", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "0d8b075e1a97d15c5324353a5277d0ea686ea525" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/0d8b075e1a97d15c5324353a5277d0ea686ea525", + "reference": "0d8b075e1a97d15c5324353a5277d0ea686ea525", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.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/v21.3.2" + }, + "time": "2026-09-09T20:40:43+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", @@ -5268,6 +5516,94 @@ ], "time": "2026-07-28T08:25:59+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:53+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.42.0", diff --git a/backend/config/magic-starter.php b/backend/config/magic-starter.php index 4ec7cf7..71d7c6f 100644 --- a/backend/config/magic-starter.php +++ b/backend/config/magic-starter.php @@ -4,6 +4,7 @@ use FlutterSdk\MagicStarter\Models\Team; use FlutterSdk\MagicStarter\Models\TeamInvitation; use FlutterSdk\MagicStarter\Models\TeamUser; +use FlutterSdk\MagicStarter\Support\RevenueCatClient; return [ /* @@ -48,6 +49,7 @@ // \FlutterSdk\MagicStarter\Features::phoneOtp(), Features::emailVerification(), // \FlutterSdk\MagicStarter\Features::timezones(), + // \FlutterSdk\MagicStarter\Features::billing(), ], /* @@ -128,6 +130,14 @@ 'team_photo_disk' => env('MAGIC_STARTER_TEAM_PHOTO_DISK', env('MAGIC_STARTER_PROFILE_PHOTO_DISK', 'public')), 'profile_photo_path' => env('MAGIC_STARTER_PROFILE_PHOTO_PATH', 'profile-photos'), 'team_photo_path' => env('MAGIC_STARTER_TEAM_PHOTO_PATH', 'team-photos'), + /* + | Where a generated avatar comes from for an account that has uploaded no + | photo. Set it to an EMPTY string to send `profile_photo_url: null` + | instead, which is usually what a JSON client wants: it draws its own + | initials already, and the generated image otherwise costs a third-party + | round trip per avatar, sends the person's name to that third party, fails + | offline, and arrives in colours the client's design system did not pick. + */ 'ui_avatars_url' => env('MAGIC_STARTER_UI_AVATARS_URL', 'https://ui-avatars.com/api/'), /* @@ -141,6 +151,27 @@ 'route_prefix' => env('MAGIC_STARTER_ROUTE_PREFIX', 'api/v1'), + /* + | Middleware applied to every route in `src/routes/api.php`. + | + | NOT the vendor webhook routes. Those load from `src/routes/webhooks.php` + | under their own gate and deliberately inherit nothing from here: a webhook + | URL is registered in a vendor dashboard and is called by that vendor, not + | by your users, so a tenant scope or a locale resolver has nobody to resolve + | and an auth middleware would reject the call. + | + | These routes are loaded by the service provider rather than from the + | host's `routes/api.php`, so they join NO middleware group on their own. + | Anything the host relies on in its `api` group (a locale resolver, a + | request id, a tenant scope) does not run here unless it is named below. + | + | Empty by default rather than `['api']`: every route here already declares + | the throttle it wants by name, and defaulting into a group that carries + | `throttle:api` as well would silently halve a rate limit a prior release + | granted. + */ + 'route_middleware' => [], + /* |-------------------------------------------------------------------------- | Team Invitation Expiry @@ -249,6 +280,359 @@ 'challenge_token_ttl' => 5, ], + /* + |-------------------------------------------------------------------------- + | Billing + |-------------------------------------------------------------------------- + | + | The package ships the entitlement CONTRACT, not a payment rail: the two + | neutral enums, the provenance columns, and the one action that arbitrates + | between rails writing the same tier. Your own rail (Cashier, a store SDK, + | an operator command) feeds it. + | + | 'billable' is WHICH KIND of thing you bill, and it accepts exactly two + | values: 'user' or 'team'. It is a closed token rather than a model class + | name deliberately, because the package has to know what kind of subject it + | is writing to and a class name cannot say (an App\Models\Account could be + | either). The class itself still comes from the 'models' block above, so a + | published App\Models\Team or your own user model is picked up unchanged. + | + | The default is 'user' because the teams feature ships OFF: on a fresh + | install there is no team to bill, not even a personal one. Selecting 'team' + | therefore REQUIRES the teams feature, and the provider refuses to boot + | rather than letting an entitlement be written to a subject that does not + | exist. Any other PRESENT value, including an explicit null, is refused at + | boot for the same reason. Leaving the key out entirely is not: an older + | published config has no key at all, and 'user' is the answer for it. + | + | 'tier_order' is your plan catalogue, CHEAPEST FIRST. The tier vocabulary + | belongs to your application, so this package never guesses it; list your + | own plan ids in the order a customer upgrades through them. + | + | WritesEntitlement uses this list for exactly one decision: whether an + | incoming write from a DIFFERENT billing rail than the one on record would + | leave the billable holding LESS than it holds now. Such a write is + | dropped, because a rail may only revoke what it granted. + | + | Leaving the list empty makes that comparison undecidable, and an + | undecidable cross-rail write against a tier the billable HOLDS is REFUSED, + | with a warning naming this key. A billable holding nothing is a separate + | case and is unaffected: there is no tier to take away, so such a write + | applies whether or not this list is published. So the empty default is + | safe for a fresh install and is not safe once you sell something on more + | than one rail: publish the order then. + | + | 'plans' is the CATALOGUE the billing screen renders: one entry per tier, + | cheapest first, served verbatim under a 'data' envelope by + | GET billing/plans. It is display data and gating data, never Stripe price + | ids (those are 'prices' below). + | + | 'prices' maps a Stripe price id onto the tier AND the cycle it sells. A + | tier is not a price: sold monthly and annually it is two, and the checkout + | asks for a (tier, cycle) pair so the customer is charged the figure the + | screen showed them. A bare value ('price_x' => 'pro') names the tier and is + | read as MONTHLY, which is a guess this package cannot verify, so declare + | ['tier' => ..., 'cycle' => ...] on anything that is not monthly or every + | screen will report the wrong interval over a real charge. + | + | THE FIRST ENTRY MATCHING A (tier, cycle) PAIR WINS, in the order written + | here. The old shape had one entry per tier by construction and this one + | invites several: the realistic case is a grandfathered price kept mapped so + | its webhooks still grant the tier, and new checkouts then go to whichever + | of the two is listed higher, silently. List the price you want SOLD first + | and keep retired ones below it. + | + | The package names only the fields every billing screen needs: 'id', 'name', + | 'tagline', 'monthly', 'annual', 'currency', 'features', 'recommended', plus + | 'cycles', which is RESERVED and derived: the endpoint computes it from the + | price map below and overwrites whatever an entry carries under that key, so + | do not write one. Every other key you put on an entry travels to the client + | UNTOUCHED, which is where anything product-specific belongs: what a tier + | caps, what it unlocks, the copy for a capability only your product has. + | This package cannot know those and does not try, exactly as it delegates + | counting to ReportsUsage and the tier vocabulary to the list below. A null + | price means "contact us"; what a null LIMIT means is your application's + | business, not this package's. + | + | A bullet in 'features' is a promise made to somebody holding a credit card, + | so it may only name something that works today. + | + | ORDER AND RANKING. When 'tier_order' below is published it is the ranking, + | full stop, and this catalogue is only display. When it is NOT published the + | order is taken from these entries' ids instead, so an adopter who publishes + | one list gets both behaviours rather than a working screen beside an + | undecidable cross-rail write. Publishing both and having them disagree + | means the explicit list wins, because it is the more specific declaration; + | there is no reason to write two orders, so write one. + | + | 'prices' is which Stripe PRICE sells which tier ON WHICH CYCLE, in either + | of the two forms described above: a bare 'price_id' => 'tier_id' string, + | read as MONTHLY, or the explicit ['tier' => ..., 'cycle' => ...] entry. The + | Stripe rail reads it in both directions: a webhook asks which tier and + | cycle the price on a subscription sells, and a checkout asks which price + | sells the (tier, cycle) pair the customer picked. + | + | It lives here rather than under cashier.plans, which is where an earlier + | application kept it. That key is NOT part of Cashier: Cashier's own config + | has no 'plans' key at all, so an adopter following Cashier's documentation + | would never create one, every price would resolve to no tier, and no + | webhook would ever grant anything, with no error anywhere to say why. + | + | 'store_products' is the same question for the STORE rail: which App Store + | or Play product sells which tier, as a ['product_id' => 'tier_id'] map. The + | store rail cannot grant anything until it is filled, and an unmapped + | product is logged and written nowhere, which is the direction that cannot + | hand out a tier nobody bought. + | + | KEY IT ON THE WHOLE PRODUCT ID GOOGLE SENDS. Play reports a subscription as + | ':', so a map keyed on the bare subscription + | id misses on every Android renewal and warns instead of granting. Apple + | sends the plain product id and needs no such care. + | + | An unmapped price is a CONFIG GAP and never a downgrade: a rail that cannot + | name the tier a paying subscription sells leaves the entitlement alone and + | warns, because the alternative is taking a tier away from somebody whose + | card just cleared. + | + | Assembling this from the environment is the normal case + | (env('CASHIER_PRICE_PRO') and friends), and it is also how the dangerous + | entry appears: an unset variable writes an EMPTY KEY, and an empty key that + | reached a reverse lookup would name the empty string as the price that + | sells a paid tier. Empty keys and empty tiers are therefore stripped when + | this map is read, so leaving a variable unset costs you one tier that + | cannot be sold rather than one that is given away. + | + | WHY laravel/cashier IS A HARD REQUIRE. It is the one dependency in this + | package that needed an argument. The four SDKs already in the require + | block (Sanctum, Socialite, google2fa, OneSignal) are libraries: they cost + | an adopter nothing until something resolves them. Cashier is a package + | with a service provider, and CashierServiceProvider::boot() runs for every + | adopter whether or not they bill, registering the stripe/webhook route + | under config('cashier.path'), merging a cashier config, and adding a + | vendor:publish group. + | + | Cashier::ignoreRoutes() is what makes that acceptable. The provider calls + | it from register() under the billing feature gate, which is the only phase + | early enough: every provider's register() runs before any provider's + | boot(), so by the time Cashier boots the refusal is already in place. With + | billing on, the package serves the webhook under a key of its own and + | Cashier's route never appears; with billing off, nothing here touches + | Cashier and an adopter driving it directly keeps their own routes. + | + | The publish group is the one residual cost and it cannot be vetoed in + | code: addPublishGroup() is additive and Laravel exposes no removal, so + | Cashier's groups stay listed under vendor:publish. + | + | THE STRIPE WEBHOOK KEEPS CASHIER'S PATH AND CASHIER'S SECRET, and both are + | deliberate exceptions to the package-owned-key rule 'prices' follows above. + | + | The package serves the webhook itself (src/routes/webhooks.php, loaded from + | its own loadRoutesFrom under the billing feature), and it registers the + | route under config('cashier.path'), which defaults to 'stripe'. So the + | served path is `stripe/webhook`: exactly what Cashier serves, exactly what + | an adopter arriving from Cashier already has in their Stripe dashboard. + | That file is separate from src/routes/api.php because nothing here may + | inherit 'route_prefix' above: an API prefix moves with a deploy, and a + | webhook URL registered in a vendor dashboard cannot, so inheriting it would + | 404 every delivery until somebody edited the dashboard by hand. + | + | config('cashier.webhook.secret') stays Cashier's key for a stricter reason + | than convention: Cashier's own WebhookController::__construct() is what + | reads it, and it attaches the signature middleware only when it is set. + | Moving it under a package key would leave that constructor reading an empty + | value and would silently UNSIGN the endpoint. 'prices' moved precisely + | because the opposite is true of it: 'cashier.plans' was never a Cashier key + | at all, so nothing in Cashier reads it. + | + | DO NOT RUN `vendor:publish --tag=cashier-migrations`. That is the residual + | cost turning into a broken schema, and it is the one instruction here that + | can only be written down. Cashier's five migrations hardcode + | Schema::table('users'), $table->id() and foreignId('user_id'): on an + | application billing a team they put the Stripe customer columns on the + | wrong table, and on any application using UUID keys they create a bigint + | `subscriptions` whose child `subscription_items` cannot reference it. The + | package ships its own three in place of those five + | (add_cashier_customer_columns_to_billable_table, create_subscriptions_table + | and create_subscription_items_table), resolving the table from 'billable' + | and the key type from 'use_uuids', with Cashier's two later meter columns + | folded into the items create. `magic-starter:install` publishes them in + | dependency order. + | + | USAGE REPORTING has no key here, because it has no default to hold: the + | package ships FlutterSdk\MagicStarter\Contracts\ReportsUsage with + | deliberately NO default implementation and NO binding, and the usage + | endpoint it would feed is registered only once a consumer binds one + | (typically `$this->app->bind(ReportsUsage::class, ...)` in the + | consumer's own AppServiceProvider). Binding a default here would answer + | an empty usage map for every billable until the consumer wires real + | counting, and an empty map is not "unknown", it is "used nothing": every + | cap a consumer gates on that answer would silently open. See the + | contract's own docblock for the shipped defect this refusal exists to + | prevent. + | + | UPGRADING FROM A RELEASE BEFORE 'billable' EXISTED. Set this key + | EXPLICITLY before re-running the installer, even to the value you believe + | is already in effect. mergeConfigFrom is a shallow merge, so a config + | published before the key existed carries no 'billable' at all and the + | 'user' default answers for it. That is correct for the arbitration + | contract, and it is wrong for the SCHEMA of an application billing a team: + | the four migrations above resolve their target through + | MagicStarter::billableModel(), so a team-billing application that upgrades + | without setting the key gets a whole Cashier schema plus the entitlement + | provenance on `users` instead of `teams`. Nothing refuses it, and nothing + | can: the boot guard only rejects a token it does not RECOGNISE, and 'user' + | is a perfectly valid one. Before the Cashier tables shipped this cost one + | mis-targeted ALTER; it now costs the whole billing schema. + | + | 'revenuecat' configures the STORE rail: Apple App Store and Google Play + | subscriptions, reaching the application as webhook deliveries that are + | only ever a SIGNAL. What a subscriber is actually entitled to is read + | back from RevenueCat's API by + | \FlutterSdk\MagicStarter\Support\RevenueCatClient, so both halves of the + | rail need configuration here: the secret that authenticates an inbound + | delivery, and the key that authenticates the outbound read. + | + | It lives under a PACKAGE-OWNED key rather than under 'cashier', because + | RevenueCat has no Laravel vendor package at all here: there is no default + | to defer to and no adopter dashboard that already points at some other + | path, unlike the Stripe webhook, which keeps reading Cashier's own + | 'cashier.path'. + | + | The five SECRET AND TUNING ENV VAR NAMES below are NOT this package's to + | rename, even though the config key that reads them is. An adopter migrating + | from a hand-rolled RevenueCat integration already has these set on their + | server, and keeping the names means adopting this package is a config-file + | change rather than a server .env edit with a window where deliveries fail. + | + | 'path' is the WHOLE served path of the inbound webhook, and its default is + | constrained by the same fact: `webhooks/revenuecat` is the path the + | application this rail was extracted from already serves and already has + | registered in the RevenueCat dashboard. A webhook URL cannot move with a + | deploy, so any other default would make adopting this package a manual + | dashboard edit with a window in which every delivery 404s. Change it only + | when you are changing the dashboard in the same breath. + | + | THE ROUTE IS WITHHELD ON A HALF-CONFIGURED RAIL. When the store rail is + | configured (an API key or a store product map) and 'webhook_secret' is not, + | the endpoint could not authenticate anybody, so it is not registered at all: + | the provider logs the reason once at boot and `magic-starter:install` + | refuses to complete. The application keeps serving; only the store rail is + | held back, because an endpoint that refuses every delivery would spend + | RevenueCat's five retries on a configuration no retry can fix. + | + | BOTH SECRETS ARE EMPTY BY DEFAULT AND THE RAIL FAILS CLOSED ON EITHER. + | With no webhook secret the endpoint refuses every delivery, because it + | cannot tell RevenueCat apart from anybody who found the URL and an + | endpoint that queues a tier change must not accept an unauthenticated + | one. With no API key the authoritative read raises rather than answering + | "nothing is owed", which would revoke every paying team. Neither has a + | fallback: a default would be either a secret in a public repository or a + | value that authenticates as nobody. + | + | 'operation_budget_seconds' bounds the WHOLE retried read, not one call: a + | per-call timeout sized against a wall breaks the moment anything retries. + | + | 'accept_sandbox' is whether this deployment may act on sandbox purchases + | at all. FALSE in production, always: a sandbox purchase granting a real + | paid tier is money out of the door, and a store's sandbox is trivially + | reachable by anybody with a developer account. It only WIDENS what an + | inbound event is allowed to say; it is never read instead of it. + | + | 'reconcile' configures the SWEEP that heals a dropped webhook. Both rails + | abandon a delivery (RevenueCat after five retries inside about three + | hours, Stripe after roughly three days) and after that the drift is + | permanent and silent, so `billing:reconcile` re-reads each rail and + | corrects what moved. The package registers the schedule itself, under the + | billing feature, because a rail that only heals when the adopter + | remembered to schedule something is a rail that does not heal. + | + | THE DEFAULT IS DAILY, and that is a deliberate softening of the cadence + | the application this rail came from runs. The sweep makes one + | authoritative RevenueCat read per store subject per run, so hourly against + | a large store fleet is an API bill an adopter never agreed to, and this + | package ships the schedule whether or not they thought about it. Daily + | still heals inside Stripe's three-day window; it can be a day behind a + | store expiry. An adopter selling mostly through the stores should set this + | to 'hourly', which is what heals inside the window the damage arrives in. + | + | The value is a frequency WORD from the list the reconciler's registration + | recognises, or any cron expression, which is the escape hatch for a + | cadence no word names (a staggered sweep, or four times a day at hours you + | choose). A word that is not on the list and is not a valid cron expression + | raises from `schedule:run` rather than silently never running. + | + | The registration carries `withoutOverlapping()` and `onOneServer()`. + | onOneServer() is NOT fleet-wide protection on every cache store: it takes + | a lock through the default cache, and the `file` and `array` stores both + | implement locking LOCALLY (a file on that server's own disk, an array in + | that process's own memory), so every server acquires its own lock and runs + | its own sweep. Nothing raises and nothing warns. Point the default cache + | at a shared store (redis, memcached, database, dynamodb) if one sweep per + | fleet is what you need; otherwise expect one per server. + | + */ + + 'billing' => [ + 'billable' => 'user', + + 'plans' => [ + // [ + // 'id' => 'free', + // 'name' => 'Free', + // 'tagline' => 'Kick the tires.', + // 'monthly' => 0, + // 'annual' => 0, + // 'currency' => 'usd', + // 'features' => [ + // 'Everything you need to try it', + // ], + // 'recommended' => false, + // // Anything below this line is yours and travels untouched. + // 'limits' => [ + // 'seats' => 1, + // ], + // ], + ], + + 'tier_order' => [ + // 'free', + // 'pro', + // 'business', + ], + + 'prices' => [ + // A bare value names the tier and is read as MONTHLY. Terse, and + // correct only when the price really is a monthly one. + // env('CASHIER_PRICE_PRO') => 'pro', + // + // Declare the cycle when you sell a tier both ways, which is the + // form that lets a customer buy the annual figure your billing + // screen is showing them. A checkout names a tier AND a cycle and + // gets the price behind that exact pair; a cycle you have not mapped + // is refused with a 422 rather than charged at the other price. + // env('CASHIER_PRICE_PRO_MONTHLY') => ['tier' => 'pro', 'cycle' => 'monthly'], + // env('CASHIER_PRICE_PRO_ANNUAL') => ['tier' => 'pro', 'cycle' => 'annual'], + ], + + 'store_products' => [ + // 'com.example.app.pro.monthly' => 'pro', + // 'business_monthly:business-base' => 'business', + ], + + 'reconcile' => [ + 'cadence' => env('MAGIC_STARTER_BILLING_RECONCILE_CADENCE', 'daily'), + ], + + 'revenuecat' => [ + 'path' => env('REVENUECAT_WEBHOOK_PATH', 'webhooks/revenuecat'), + 'webhook_secret' => env('REVENUECAT_WEBHOOK_SECRET'), + 'secret_api_key' => env('REVENUECAT_SECRET_API_KEY'), + 'base_url' => env('REVENUECAT_BASE_URL', RevenueCatClient::DEFAULT_BASE_URL), + 'operation_budget_seconds' => env('REVENUECAT_OPERATION_BUDGET_SECONDS', 10), + 'accept_sandbox' => (bool) env('REVENUECAT_ACCEPT_SANDBOX', false), + ], + ], + /* |-------------------------------------------------------------------------- | OneSignal Push Notifications @@ -295,5 +679,108 @@ */ 'target_channel' => 'push', + + /* + |-------------------------------------------------------------------------- + | External ID Prefix + |-------------------------------------------------------------------------- + | + | What a device's OneSignal external id carries before the user's own + | key. Both sides have to compose the same string or the server + | addresses an id no device registered, and nothing anywhere says so: + | OneSignal accepts the notification and delivers it to nobody, leaving + | a zero-recipient response as the only trace. + | + | The Flutter client reads the same value from + | `magic_starter.notifications.external_id_prefix`, which defaults to + | the same `user_`. Change one and change the other. + | + | It is read in two places here: {@see HasNotifications::routeNotificationForOneSignal}, + | and {@see OneSignalChannel::send}'s fallback for a notifiable that + | does not use the trait. That fallback used to send the key BARE, + | which is the one shape that can never work: it matches no device this + | stack registers, and OneSignal rejects a bare numeric external id + | outright. A published `App\Models\User` without the trait is the + | ordinary way to reach it, since `MagicStarter::userModel()` detects + | one automatically. + | + */ + + 'external_id_prefix' => env('MAGIC_STARTER_EXTERNAL_ID_PREFIX', 'user_'), + + /* + |-------------------------------------------------------------------------- + | Web Origin + |-------------------------------------------------------------------------- + | + | Where the Flutter web client is served, e.g. https://app.example.com. + | Set this and a push carrying a deep link opens the right SCREEN in a + | browser instead of the home page. + | + | It is needed because a browser reads `web_url` and nothing else. The + | mobile clients navigate from the notification's custom data, and the + | web one cannot: a click is handled by the service worker, which opens + | the launch url as an ordinary page load, so no Dart is running yet to + | read that data. OneSignal supplies the dashboard's Site URL when the + | payload names no url, which is why the symptom is the home page in a + | new tab rather than an error. Measured against a live deployment on + | 2026-09-10; nothing reported a failure. + | + | {@see OneSignalChannel::applyWebUrl} joins this to the deep link the + | notification already carries, so an application sets one value here + | rather than composing an absolute url in every notification class. A + | builder that sets `web_url` itself is always left alone. + | + | Absent means off, and off is the old behaviour rather than a broken + | one: mobile keeps working exactly as before and web keeps landing on + | the home page. Guessing an origin would be worse than not having one, + | since `APP_URL` on an API-only deployment is the API host and would + | send every web recipient somewhere the client is not served. + | + */ + + 'web_origin' => env('MAGIC_STARTER_WEB_ORIGIN'), + + /* + |-------------------------------------------------------------------------- + | Self-Addressed Push Test + |-------------------------------------------------------------------------- + | + | Whether POST {route_prefix}/notifications/push-test is switched on. + | The endpoint lets a signed-in person page their OWN devices, so they + | can find out whether push reaches the phone in their hand before an + | incident rather than during one. It takes no recipient: the target is + | derived from the Sanctum session. + | + | OFF, and an absent key is off too. Everything the endpoint does is + | make the platform emit a real push because a client asked it to, and + | that is a capability to switch on deliberately once an application + | actually offers the button, not one to have live and reachable by + | anything holding a token from the day the package is installed. + | Absence has to mean off for the same reason: `mergeConfigFrom` is a + | shallow merge, so a config published before this key existed carries + | an `onesignal` block with no switch in it, and an upgrade must not + | turn an outbound send on for an adopter who never asked for one. + | + | While it is off the route is still registered (under the + | `notifications` feature, as before) and answers 501: the server does + | not offer this functionality. That is deliberately none of the other + | three answers this endpoint gives. 403 means the caller may not, 409 + | means push is not provisioned on this deployment, and 404 means the + | notifications feature is off entirely; a switched-off endpoint is + | none of those, and reusing one of them would send whoever hits it + | looking for the wrong thing. + | + | TURNING IT ON TAKES BOTH HALVES. The Flutter client carries the same + | switch, `notifications.push.self_test_enabled` in the magic + | notifications config, also off by default, and its push channel + | reports itself unavailable and posts nothing while that one is off. + | Setting only this key leaves a live endpoint no client calls; setting + | only the client's leaves a client posting requests that always 501. + | Set both, and only once something needs the button. + | + */ + + 'self_test_enabled' => (bool) env('MAGIC_STARTER_PUSH_SELF_TEST_ENABLED', false), ], ]; diff --git a/pubspec.lock b/pubspec.lock index 62f4b2b..d751afc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -449,34 +449,34 @@ packages: dependency: "direct main" description: name: fluttersdk_artisan - sha256: "12edf7ed81708340b52e854dd670e89db3f7bdc1996b307a9e7ae51793e77625" + sha256: "1b905fa27f746120829fee8970412af8f21b8fb7005f4886da5f2e84960db0bc" url: "https://pub.dev" source: hosted - version: "0.0.15" + version: "0.0.16" fluttersdk_dusk: dependency: "direct main" description: name: fluttersdk_dusk - sha256: cb67f106452e2e655a9f5e3345c6c04b3c39fa81f1247eaa37cf2fa0b45c878c + sha256: "0b2418275f9349f128e12544104c599d165c61a522600e86e042749bb00f7463" url: "https://pub.dev" source: hosted - version: "0.0.13" + version: "0.0.15" fluttersdk_telescope: dependency: "direct main" description: name: fluttersdk_telescope - sha256: "2496ce77e12fc0d4053c3e204d9cc2ec6872ec7be7acf3de8c18b5a114e272a7" + sha256: "9f6d0f11c8d9c7f2eddb733f3248f5ce61f2778d50b0cc257d60875f50a24056" url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" fluttersdk_wind: dependency: transitive description: name: fluttersdk_wind - sha256: b436a41ab042be1cc578a69a6634e3f39cae2801505e6e2514d4e2ad9dd7f4fd + sha256: "21bb766b4361c7cdd64973cc389093f540d81bb0d98dda8ebd004458f1920d97" url: "https://pub.dev" source: hosted - version: "1.5.3" + version: "1.6.2" fluttersdk_wind_diagnostics_contracts: dependency: transitive description: @@ -761,58 +761,58 @@ packages: dependency: "direct main" description: name: magic - sha256: e018522fdadbe8b30d7fa1f9f061ae2082933a34f9915ccc78257c6a05711d84 + sha256: d3659e0bbf246094483ca92af5eabe14443fcc9a8c484b339601465aaa8bff0c url: "https://pub.dev" source: hosted - version: "0.0.11" + version: "0.0.15" magic_deeplink: dependency: "direct main" description: name: magic_deeplink - sha256: "4993fa9db2030a0e85a1f9381d81b179d3f1e0acd82d3c2c1f27cb5005b96549" + sha256: "4aeaf5fbb18d629a0b2f809cb0fac48b374a7ccc751a386a95db765b680e748d" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.2" magic_devtools: dependency: "direct main" description: name: magic_devtools - sha256: aa7337320708cc5864a3514299a4a6049c59c977526128f14d0fd7653b8e8519 + sha256: "26a920f33301a83689ef010096e8bac23bcaa737625ea3506a39942a0248d707" url: "https://pub.dev" source: hosted - version: "0.0.4" + version: "0.0.5" magic_notifications: dependency: "direct main" description: name: magic_notifications - sha256: aee5e0bf5ecc6c64b35c90916c117c1151714a386995bcfc08dc2adf29c14ecb + sha256: add62ae150d2e91e8fdd96ae23120b0f00dbecac34ef6175f4b02e6c2e76aa0d url: "https://pub.dev" source: hosted - version: "0.3.2" + version: "0.3.3" magic_payments: dependency: transitive description: name: magic_payments - sha256: a3ec0cfd8b6e7421e629d003d4da5f9b8ece0510f96e0e000024e3f43b9ee5de + sha256: "65473127ebcad27a25a3b283d37b8aa82d1c6cb872aae17540faf32070273b47" url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.3" magic_social_auth: dependency: "direct main" description: name: magic_social_auth - sha256: "37ade352950ce84923a20a6549fd861a0928d3f8efc00c818ba38eef74b3772e" + sha256: "11189045e12f220f7afc5817b039e7fb016e2d740d712a7f96eae3653ff7df60" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" magic_starter: dependency: "direct main" description: name: magic_starter - sha256: b16c8b0308d46281934c9d9bf1c52e34132a9bb0ac8e6312cc3d340f62806f94 + sha256: "86afdb01e13fe3528ed3184d5054a361195c08c4db7d4aebe2c52aebdceb5c9c" url: "https://pub.dev" source: hosted - version: "0.0.27" + version: "0.0.31" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6dddb8d..aa557fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,21 +35,21 @@ dependencies: # copied outside this workspace resolves without any sibling checkout. # In-workspace development overrides these to local path checkouts via the # gitignored pubspec_overrides.yaml; that file never ships in the fork. - magic: ^0.0.11 - magic_deeplink: ^0.1.0 - magic_notifications: ^0.3.2 - magic_social_auth: ^0.0.3 - magic_starter: ^0.0.27 + magic: ^0.0.15 + magic_deeplink: ^0.1.2 + magic_notifications: ^0.3.3 + magic_social_auth: ^0.0.4 + magic_starter: ^0.0.31 # Dev-tooling, imported by lib/main.dart under kDebugMode (release tree-shakes). - magic_devtools: ^0.0.4 - fluttersdk_dusk: ^0.0.13 - fluttersdk_telescope: ^0.0.5 + magic_devtools: ^0.0.5 + fluttersdk_dusk: ^0.0.15 + fluttersdk_telescope: ^0.0.6 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - fluttersdk_artisan: ^0.0.15 + fluttersdk_artisan: ^0.0.16 # No file_picker pin here on purpose. Nothing in this app imports it; it # arrives through magic's Pick facade, so magic's own constraint is the only