From 129451f10c0af59925b2578d9a028b9ab0ff1820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 21 Sep 2026 20:23:26 +0300 Subject: [PATCH 1/2] chore(deps): re-pin the fluttersdk stack to this batch's releases Every in-house constraint names the newest release of the 2026-09-21 batch: 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, magic_devtools ^0.0.5, fluttersdk_dusk ^0.0.15, fluttersdk_telescope ^0.0.6 and fluttersdk_artisan ^0.0.16. The backend moves fluttersdk/magic-starter-laravel ^0.0.5 -> ^0.0.9. Composer's caret on 0.0.x pins the patch, so this pin had held the backend four releases behind; only that package is updated in the lock, plus the four packages 0.0.9 requires (laravel/cashier and its stripe, moneyphp and intl polyfill dependencies), not a --with-dependencies sweep. --- backend/composer.json | 2 +- backend/composer.lock | 348 +++++++++++++++++++++++++++++++++++++++++- pubspec.lock | 44 +++--- pubspec.yaml | 18 +-- 4 files changed, 374 insertions(+), 38 deletions(-) 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/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 From 0a21d8c2c93e9e8518aff8b80771c41fc4356b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 21 Sep 2026 20:31:09 +0300 Subject: [PATCH 2/2] chore(backend): republish the magic-starter config from 0.0.9 The published copy dated from 0.0.5. Config merging is shallow, so its onesignal array shadowed the package's whole block and the three keys 0.0.6 to 0.0.9 added under it (external_id_prefix, web_origin, self_test_enabled) were unreachable through config for this repo and every fork taken from it. route_middleware and billing were only present through mergeConfigFrom's top-level fallback, which is also where a fork would never look for them. Taken verbatim from vendor/fluttersdk/magic-starter-laravel 0.0.9, with this app's six lines reapplied: the Features import, use_uuids false, and the four enabled features (two-factor, teams, sessions, email verification). --- backend/config/magic-starter.php | 487 +++++++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) 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), ], ];