kasy-cli 1.31.13 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/lib/commands/new.js +15 -1
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/api/patch/README.md +87 -2
  4. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  5. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  6. package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
  7. package/lib/scaffold/backends/supabase/deploy.js +5 -0
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  10. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  11. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  12. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +69 -17
  13. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +6 -0
  15. package/lib/scaffold/generate.js +1 -1
  16. package/lib/scaffold/shared/generator-utils.js +22 -3
  17. package/lib/utils/i18n/messages-en.js +2 -0
  18. package/lib/utils/i18n/messages-es.js +2 -0
  19. package/lib/utils/i18n/messages-pt.js +2 -0
  20. package/package.json +2 -2
  21. package/templates/firebase/docs/auth-setup.en.md +7 -1
  22. package/templates/firebase/docs/auth-setup.es.md +7 -1
  23. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  24. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  25. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  26. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  27. package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
  28. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  29. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  30. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  32. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +62 -11
  34. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  35. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  36. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  37. package/templates/firebase/lib/components/kasy_toast.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  39. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +6 -0
  40. package/templates/firebase/lib/core/bottom_menu/notification_bottom_item.dart +16 -37
  41. package/templates/firebase/lib/core/config/features.dart +13 -0
  42. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  43. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  44. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
  45. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  46. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  47. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  48. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  49. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  50. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  51. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
  52. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  53. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  54. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  55. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  56. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
  57. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
  58. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  59. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  60. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  61. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
  62. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
  63. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  64. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
  65. package/templates/firebase/lib/features/notifications/providers/unread_notifications_count_provider.dart +17 -0
  66. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  67. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
  68. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  69. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  70. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
  72. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  73. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  74. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -6
  75. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  76. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  77. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  78. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  79. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  80. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  81. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  82. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  83. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  84. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  85. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  86. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  87. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  88. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  89. package/templates/firebase/lib/i18n/en.i18n.json +10 -1
  90. package/templates/firebase/lib/i18n/es.i18n.json +10 -1
  91. package/templates/firebase/lib/i18n/pt.i18n.json +10 -1
  92. package/templates/firebase/pubspec.yaml +0 -1
  93. package/templates/firebase/web/stripe_success.html +64 -26
  94. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  95. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  96. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  97. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  98. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  99. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  100. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  101. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -1,5 +1,6 @@
1
1
  import 'dart:convert';
2
2
  import 'package:crypto/crypto.dart';
3
+ import 'package:firebase_auth/firebase_auth.dart' as fb_auth;
3
4
  import 'package:flutter/foundation.dart' show kIsWeb;
4
5
  import 'package:flutter/services.dart' show PlatformException;
5
6
  import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
@@ -149,6 +150,13 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
149
150
 
150
151
  @override
151
152
  Future<Credentials> signinWithApple() async {
153
+ // Apple on web needs a paid Apple Service ID + secret (not configured here);
154
+ // getAppleIDCredential force-unwraps webAuthenticationOptions and crashes on
155
+ // web. The UI hides the Apple button on web; guard here too so a programmatic
156
+ // call fails with a clear error instead of a null-check crash.
157
+ if (kIsWeb) {
158
+ throw ApiError(code: 501, message: 'Apple sign-in on web is not supported.');
159
+ }
152
160
  final rawNonce = client.auth.generateRawNonce();
153
161
  final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
154
162
 
@@ -212,16 +220,16 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
212
220
  @override
213
221
  Future<Credentials> signinWithGoogle() async {
214
222
  if (kIsWeb) {
215
- // google_sign_in's imperative authenticate() is UNSUPPORTED on web (the v7
216
- // web plugin throws UnimplementedError). Use Supabase's OAuth redirect flow:
217
- // it sends the user to Google and back; the session arrives on return and is
218
- // picked up by the auth state listener. Requires your web origin in Supabase
219
- // Auth -> URL Configuration (Site URL / Redirect URLs).
220
- await client.auth.signInWithOAuth(
221
- OAuthProvider.google,
222
- redirectTo: Uri.base.origin,
223
+ // google_sign_in v7 can't do imperative auth on web. Get the Google ID token
224
+ // via Firebase's popup (zero manual config reuses the Firebase web OAuth
225
+ // client + authorized domains, which the kit already sets up) and sign into
226
+ // Supabase with it. Supabase remains the auth backend.
227
+ final idToken = await _googleIdTokenFromWebPopup();
228
+ final res = await client.auth.signInWithIdToken(
229
+ provider: OAuthProvider.google,
230
+ idToken: idToken,
223
231
  );
224
- return Credentials(id: '', token: '');
232
+ return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
225
233
  }
226
234
  final googleSignIn = GoogleSignIn.instance;
227
235
  // Web: clientId = Web Client ID (no serverClientId needed)
@@ -264,6 +272,33 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
264
272
 
265
273
  }
266
274
 
275
+ /// Web only: obtains a Google ID token via Firebase's popup. google_sign_in v7
276
+ /// can't do imperative auth on the web, but the kit already initializes Firebase
277
+ /// (for FCM), so we reuse Firebase's working web OAuth client to get the token,
278
+ /// then sign into Supabase with it — no Supabase callback / Google console step
279
+ /// needed. The token's audience is the Firebase web client ID, which is the same
280
+ /// one configured as Supabase's Google provider, so signInWithIdToken accepts it.
281
+ Future<String> _googleIdTokenFromWebPopup() async {
282
+ try {
283
+ final cred = await fb_auth.FirebaseAuth.instance.signInWithPopup(
284
+ fb_auth.GoogleAuthProvider(),
285
+ );
286
+ final idToken = (cred.credential as fb_auth.OAuthCredential?)?.idToken;
287
+ if (idToken == null) {
288
+ throw ApiError(code: 401, message: 'No Google ID token from Firebase popup.');
289
+ }
290
+ return idToken;
291
+ } on fb_auth.FirebaseAuthException catch (e) {
292
+ if (e.code == 'popup-closed-by-user' ||
293
+ e.code == 'cancelled-popup-request' ||
294
+ e.code == 'web-context-cancelled' ||
295
+ e.code == 'user-cancelled') {
296
+ throw const UserCancelledSignInException();
297
+ }
298
+ rethrow;
299
+ }
300
+ }
301
+
267
302
  @override
268
303
  Future<Credentials> signinWithGooglePlay() {
269
304
  // Google Play Games sign-in is not supported for Supabase backend.
@@ -281,6 +316,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
281
316
 
282
317
  @override
283
318
  Future<Credentials> signupFromAnonymousWithApple() async {
319
+ // See signinWithApple: Apple on web is unsupported here; the UI hides the button
320
+ // on web, and this guard prevents a null-check crash on a programmatic call.
321
+ if (kIsWeb) {
322
+ throw ApiError(code: 501, message: 'Apple sign-in on web is not supported.');
323
+ }
284
324
  final rawNonce = client.auth.generateRawNonce();
285
325
  final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
286
326
 
@@ -323,14 +363,26 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
323
363
  @override
324
364
  Future<Credentials> signupFromAnonymousWithGoogle() async {
325
365
  if (kIsWeb) {
326
- // Imperative GoogleSignIn.authenticate() is UNSUPPORTED on web. Use Supabase's
327
- // OAuth redirect; the session arrives on return via the auth listener. (On web
328
- // this signs in with Google instead of linking the anonymous user.)
329
- await client.auth.signInWithOAuth(
330
- OAuthProvider.google,
331
- redirectTo: Uri.base.origin,
332
- );
333
- return Credentials(id: '', token: '');
366
+ // Web: get the Google ID token via Firebase's popup (see signinWithGoogle) and
367
+ // link it to the current anonymous Supabase user.
368
+ final idToken = await _googleIdTokenFromWebPopup();
369
+ try {
370
+ final response = await client.auth.linkIdentityWithIdToken(
371
+ provider: OAuthProvider.google,
372
+ idToken: idToken,
373
+ );
374
+ return Credentials(id: response.user!.id, token: response.session?.accessToken ?? '');
375
+ } on AuthException catch (e) {
376
+ if (e.code == 'identity_already_exists') {
377
+ await _deleteCurrentAnonymousUser();
378
+ final res = await client.auth.signInWithIdToken(
379
+ provider: OAuthProvider.google,
380
+ idToken: idToken,
381
+ );
382
+ return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
383
+ }
384
+ rethrow;
385
+ }
334
386
  }
335
387
  final scopes = ['email'];
336
388
  final googleSignIn = GoogleSignIn.instance;
@@ -30,6 +30,7 @@ class StripeBackendApi {
30
30
  String? successUrl,
31
31
  String? cancelUrl,
32
32
  String? locale,
33
+ bool? allowPromoCodes,
33
34
  }) async {
34
35
  final res = await _client.functions.invoke(
35
36
  'stripe-create-checkout-session',
@@ -38,16 +39,25 @@ class StripeBackendApi {
38
39
  if (successUrl != null) 'successUrl': successUrl,
39
40
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
40
41
  if (locale != null) 'locale': locale,
42
+ if (allowPromoCodes != null) 'allowPromoCodes': allowPromoCodes,
41
43
  },
42
44
  );
43
45
  return (res.data as Map)['url'] as String;
44
46
  }
45
47
 
46
48
  /// Create a Customer Portal session (manage / cancel) and return its URL.
47
- Future<String> createPortalSession({String? returnUrl}) async {
49
+ /// Pass [planSwitching] = true to auto-configure the portal with
50
+ /// upgrade/downgrade support (no manual Stripe dashboard setup needed).
51
+ Future<String> createPortalSession({
52
+ String? returnUrl,
53
+ bool? planSwitching,
54
+ }) async {
48
55
  final res = await _client.functions.invoke(
49
56
  'stripe-create-portal-session',
50
- body: {if (returnUrl != null) 'returnUrl': returnUrl},
57
+ body: {
58
+ if (returnUrl != null) 'returnUrl': returnUrl,
59
+ if (planSwitching != null) 'planSwitching': planSwitching,
60
+ },
51
61
  );
52
62
  return (res.data as Map)['url'] as String;
53
63
  }
@@ -25,6 +25,12 @@ dependencies:
25
25
  dio: ^5.9.2
26
26
  facebook_app_events: ^0.24.0
27
27
  firebase_app_installations: ^0.4.0+7
28
+ # Web-only Google sign-in: on web, google_sign_in v7 can't do imperative auth, so we
29
+ # get the Google ID token via Firebase's popup (zero manual config, reuses the
30
+ # Firebase web client + authorized domains) and hand it to Supabase signInWithIdToken.
31
+ # Supabase stays the auth backend; mobile keeps the native google_sign_in flow.
32
+ # Kept before firebase_core so the deps stay alphabetical (sort_pub_dependencies).
33
+ firebase_auth: ^6.1.4
28
34
  firebase_core: ^4.5.0
29
35
  firebase_messaging: ^16.1.2
30
36
  firebase_remote_config: ^6.2.0
@@ -219,7 +219,7 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
219
219
  await writeVsCodeLaunch(targetDir, appName, backend, modules, answers, language);
220
220
  await writeEnvExample(targetDir, modules, answers, language);
221
221
  await writeEnvFileIfMissing(targetDir);
222
- await writeFeaturesConfig(targetDir, modules, answers, language);
222
+ await writeFeaturesConfig(targetDir, modules, answers, language, backend);
223
223
  await writeRouter(targetDir, modules, packageName, moduleAnswers.defaultPaywall || 'basic');
224
224
  await writeMakefile(targetDir, language, backend, modules, answers);
225
225
  await writeKitSetup(targetDir, {
@@ -616,7 +616,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
616
616
  * @param {object} [answers={}] - moduleAnswers (rcTestKey, stripe keys, etc.)
617
617
  * @param {string} [language='en'] - User's CLI language (en, pt, es)
618
618
  */
619
- async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en') {
619
+ async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en', backend = 'firebase') {
620
620
  const withOnboarding = modules.includes('onboarding');
621
621
  const withAiChat = modules.includes('ai_chat');
622
622
  const withFeedback = modules.includes('feedback');
@@ -624,6 +624,10 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
624
624
  const withStripe = modules.includes('stripe');
625
625
  const withLocalReminders = modules.includes('local_reminders');
626
626
  const withWeb = modules.includes('web');
627
+ // Apple sign-in on web only works on Firebase (signInWithPopup). Supabase/API
628
+ // throw on web (no Service ID + token exchange), so they must ship this false
629
+ // and hide the Apple button on web. Native always shows it.
630
+ const withAppleWebSignin = backend === 'firebase';
627
631
 
628
632
  const f = getStrings(language).features;
629
633
  const content = `${f.comment1}
@@ -635,7 +639,14 @@ const bool withFeedback = ${withFeedback};
635
639
  const bool withRevenuecat = ${withRevenuecat};
636
640
  // Stripe web subscriptions module (independent from RevenueCat mobile IAP).
637
641
  const bool withStripe = ${withStripe};
642
+ // When true, Stripe Checkout shows a promo-code / coupon field.
643
+ const bool withStripePromoCodes = true;
644
+ // When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
645
+ const bool withStripePlanSwitching = true;
638
646
  const bool withLocalReminders = ${withLocalReminders};
647
+ // Apple sign-in on web: Firebase supports it (signInWithPopup); Supabase/API throw
648
+ // on web, so they ship false. Native always shows the Apple button.
649
+ const bool withAppleWebSignin = ${withAppleWebSignin};
639
650
  ${f.comment3}
640
651
  ${f.comment4}
641
652
  ${f.comment5}
@@ -1347,10 +1358,18 @@ async function removeFacebookSigninFromAuthPages(projectDir) {
1347
1358
  for (const p of pages) {
1348
1359
  if (!(await fs.pathExists(p))) continue;
1349
1360
  let content = await fs.readFile(p, 'utf8');
1350
- // Remove import line
1361
+ // Legacy: remove the standalone FacebookSigninComponent + its import, if present.
1351
1362
  content = content.replace(/^import 'package:[^']+\/features\/authentication\/ui\/components\/facebook_signin\.dart';\n/m, '');
1352
- // Remove component line (any leading whitespace)
1353
1363
  content = content.replace(/[ \t]*const FacebookSigninComponent\(\),\n/g, '');
1364
+ // Current UI: the Facebook button is an inline _SocialSigninTile/_SocialSignupTile
1365
+ // in the social row (label: t.auth.signin.facebook ... signinWithFacebook()). Strip
1366
+ // the whole tile plus the SizedBox spacer that precedes it, anchored on the facebook
1367
+ // label so the Google/Apple tiles are never touched. Runs before dartFix/format, so
1368
+ // it matches the kit's raw formatting verbatim.
1369
+ content = content.replace(
1370
+ /\n[ \t]*const SizedBox\(width: KasySpacing\.sm\),\n[ \t]*Expanded\(\n[ \t]*child: _Social(?:Signin|Signup)Tile\(\n[ \t]*label: t\.auth\.signin\.facebook,[\s\S]*?\.signinWithFacebook\(\),\n[ \t]*\),\n[ \t]*\),/,
1371
+ '',
1372
+ );
1354
1373
  await fs.writeFile(p, content, 'utf8');
1355
1374
  }
1356
1375
  }
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Features enabled:',
741
741
  'new.success.bundleId': 'App identifier (bundle ID)',
742
742
  'new.success.bundleId.hint': "Your app's unique identifier on Android, iOS and Firebase (push).",
743
+ 'new.success.api.serverContracts': 'API backend: you must implement the server contracts (delete account, AI chat and push). See patch/README.md',
743
744
  'new.success.nextSteps': 'Next steps:',
744
745
  'new.success.step.cd': 'Go to your project folder:',
745
746
  'new.success.step.deploy': 'Push the server to Firebase (DB + functions):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Google Sign-In: enable manually in the Console (Google provider):',
755
756
  'new.google.manualHint.noEmail': 'Google Sign-In: could not detect a support email (gcloud has no account). Enable manually in the Console:',
756
757
  'new.google.supabaseManual': 'Google Sign-In: client created, but the secret was not available yet. Enable it later in the Supabase dashboard (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Google sign-in on web: could not authorize localhost in the Firebase authorized domains automatically. If the Google popup fails with "unauthorized-domain", add localhost in Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'generated automatically',
758
760
  'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
759
761
  'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Recursos activados:',
741
741
  'new.success.bundleId': 'Identificador de la app (bundle ID)',
742
742
  'new.success.bundleId.hint': 'Identificador único de tu app en Android, iOS y Firebase (push).',
743
+ 'new.success.api.serverContracts': 'Backend API: debes implementar los contratos del servidor (eliminar cuenta, IA chat y push). Consulta patch/README.md',
743
744
  'new.success.nextSteps': 'Próximos pasos:',
744
745
  'new.success.step.cd': 'Ve a la carpeta del proyecto:',
745
746
  'new.success.step.deploy': 'Sube el servidor a Firebase (DB + funciones):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Inicio de sesión con Google: actívalo manualmente en la consola (proveedor Google):',
755
756
  'new.google.manualHint.noEmail': 'Inicio de sesión con Google: no detecté un email de soporte (gcloud sin cuenta). Actívalo manualmente en la consola:',
756
757
  'new.google.supabaseManual': 'Inicio de sesión con Google: cliente creado, pero el secret aún no estaba disponible. Actívalo luego en el panel de Supabase (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Inicio de sesión con Google en web: no se pudo autorizar localhost en los dominios de Firebase automáticamente. Si el popup de Google falla con "unauthorized-domain", agrega localhost en Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'generada automáticamente',
758
760
  'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
759
761
  'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Recursos ativados:',
741
741
  'new.success.bundleId': 'Identificador do app (bundle ID)',
742
742
  'new.success.bundleId.hint': 'Identificador único do seu app no Android, iOS e Firebase (push).',
743
+ 'new.success.api.serverContracts': 'Backend API: você precisa implementar os contratos do servidor (excluir conta, IA chat e push). Veja patch/README.md',
743
744
  'new.success.nextSteps': 'Próximos passos:',
744
745
  'new.success.step.cd': 'Entre na pasta do projeto:',
745
746
  'new.success.step.deploy': 'Suba o servidor pro Firebase (banco + funções):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Login com Google: ative manualmente no Console (provedor Google):',
755
756
  'new.google.manualHint.noEmail': 'Login com Google: não consegui detectar um e-mail de suporte (gcloud sem conta). Ative manualmente no Console:',
756
757
  'new.google.supabaseManual': 'Login com Google: client criado, mas o secret ainda não estava disponível. Ative depois no painel do Supabase (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Login com Google na web: não consegui autorizar o localhost nos domínios do Firebase automaticamente. Se o popup do Google falhar com "unauthorized-domain", adicione localhost em Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'gerada automaticamente',
758
760
  'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
759
761
  'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.13",
3
+ "version": "1.32.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js",
34
+ "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js && node test/stripe-webhook-orphan-guard.test.js && node test/facebook-strip.test.js && node test/path-provider-pin.test.js && node test/features-flags-parity.test.js",
35
35
  "start": "node ./bin/kasy.js",
36
36
  "setup": "node ./bin/kasy.js setup",
37
37
  "doctor": "node ./bin/kasy.js doctor",
@@ -69,7 +69,13 @@ Requires an [Apple Developer](https://developer.apple.com) account (paid).
69
69
  1. Open `ios/Runner.xcworkspace` in Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → add **Sign In with Apple**
71
71
 
72
- > **Android and Web**: no additional setup needed. The Firebase SDK handles the flow on Android. For Web, the Services ID (Step 3) already covers the redirect flow via `firebaseapp.com`.
72
+ > **iOS / macOS**: the Apple button shows automatically once the steps above are done.
73
+ >
74
+ > **Android**: the Apple button is hidden by design (it needs the paid Services ID web flow and adds little on Android for a SaaS). Leave it hidden.
75
+ >
76
+ > **Web (Firebase)**: works after Steps 1-4 above (`withAppleWebSignin` already ships `true`). The Services ID Return URL (`firebaseapp.com/__/auth/handler`) covers the popup flow.
77
+ >
78
+ > **Web (Supabase)**: the CLI ships `withAppleWebSignin = false` (native iOS works; web needs more). To enable it: in Supabase → Authentication → Providers → Apple, add the **client secret** (a JWT signed with your `.p8` key + Services ID), then set `withAppleWebSignin = true` in `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -69,7 +69,13 @@ Requiere cuenta de [Apple Developer](https://developer.apple.com) (de pago).
69
69
  1. Abre `ios/Runner.xcworkspace` en Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → agrega **Sign In with Apple**
71
71
 
72
- > **Android y Web**: no se necesita configuración adicional. El SDK de Firebase gestiona el flujo en Android. Para Web, el Services ID (Paso 3) ya cubre el flujo de redirect vía `firebaseapp.com`.
72
+ > **iOS / macOS**: el botón Apple aparece automáticamente tras los pasos anteriores.
73
+ >
74
+ > **Android**: el botón Apple queda oculto por defecto (necesita el flujo del Services ID de pago y aporta poco en Android para un SaaS). Déjalo oculto.
75
+ >
76
+ > **Web (Firebase)**: funciona tras los Pasos 1 a 4 anteriores (`withAppleWebSignin` ya viene `true`). La Return URL del Services ID (`firebaseapp.com/__/auth/handler`) cubre el flujo de popup.
77
+ >
78
+ > **Web (Supabase)**: la CLI genera `withAppleWebSignin = false` (iOS nativo funciona; la web necesita más). Para habilitar: en Supabase → Authentication → Providers → Apple, agrega el **client secret** (un JWT firmado con la clave `.p8` + Services ID) y luego define `withAppleWebSignin = true` en `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -69,7 +69,13 @@ Requer conta [Apple Developer](https://developer.apple.com) (paga).
69
69
  1. Abra `ios/Runner.xcworkspace` no Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → adicione **Sign In with Apple**
71
71
 
72
- > **Android e Web**: não precisam de configuração adicional. O Firebase SDK gerencia o fluxo no Android. Para Web, o Services ID (Passo 3) já cobre o fluxo de redirect via `firebaseapp.com`.
72
+ > **iOS / macOS**: o botão Apple aparece automaticamente depois dos passos acima.
73
+ >
74
+ > **Android**: o botão Apple fica escondido por padrão (exige o fluxo do Services ID pago e agrega pouco no Android para um SaaS). Deixe escondido.
75
+ >
76
+ > **Web (Firebase)**: funciona depois dos Passos 1 a 4 acima (`withAppleWebSignin` já vem `true`). A Return URL do Services ID (`firebaseapp.com/__/auth/handler`) cobre o fluxo de popup.
77
+ >
78
+ > **Web (Supabase)**: a CLI gera `withAppleWebSignin = false` (iOS nativo funciona; web precisa de mais). Para habilitar: em Supabase → Authentication → Providers → Apple, adicione o **client secret** (um JWT assinado com a chave `.p8` + Services ID) e depois defina `withAppleWebSignin = true` em `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -125,6 +125,8 @@ export const createCheckoutSession = onCall(
125
125
  const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
126
126
  const trialDays = trialDaysFor(price, price.product as Stripe.Product);
127
127
 
128
+ const allowPromoCodes = request.data?.allowPromoCodes as boolean | undefined;
129
+
128
130
  const session = await stripe.checkout.sessions.create({
129
131
  mode: "subscription",
130
132
  customer: customerId,
@@ -136,13 +138,70 @@ export const createCheckoutSession = onCall(
136
138
  metadata: {firebaseUID: uid},
137
139
  ...(trialDays ? {trial_period_days: trialDays} : {}),
138
140
  },
141
+ ...(allowPromoCodes ? {allow_promotion_codes: true} : {}),
139
142
  });
140
143
  return {url: session.url};
141
144
  },
142
145
  );
143
146
 
144
147
  // ---------------------------------------------------------------------------
145
- // createPortalSessionStripe Customer Portal (manage / cancel).
148
+ // getOrCreatePortalConfigcreates (once) a Customer Portal configuration
149
+ // with subscription_update (plan switching) enabled and caches its ID in
150
+ // Firestore so we don't recreate it on every portal open. Falls back to
151
+ // undefined (default portal) if there are no prices to switch between.
152
+ // ---------------------------------------------------------------------------
153
+ async function getOrCreatePortalConfig(stripe: Stripe): Promise<string | undefined> {
154
+ const db = admin.firestore();
155
+ const configRef = db.doc("_config/stripe_portal");
156
+ const snap = await configRef.get();
157
+ const cachedId = snap.data()?.configId as string | undefined;
158
+ if (cachedId) {
159
+ try {
160
+ const cfg = await stripe.billingPortal.configurations.retrieve(cachedId);
161
+ if (cfg.active) return cachedId;
162
+ } catch {
163
+ // Cached config was deleted on Stripe — recreate below
164
+ }
165
+ }
166
+
167
+ // Build allowed-prices list grouped by product. STRIPE_PRODUCT_ID narrows the
168
+ // query to a single product; if it's not set we use all active recurring prices.
169
+ const productId = stripeProductId.value();
170
+ const priceParams: Stripe.PriceListParams = {active: true, type: "recurring", limit: 100};
171
+ if (productId) priceParams.product = productId;
172
+ const {data: prices} = await stripe.prices.list(priceParams);
173
+
174
+ if (prices.length === 0) return undefined;
175
+
176
+ const byProduct: Record<string, string[]> = {};
177
+ for (const p of prices) {
178
+ const pid = typeof p.product === "string" ? p.product : p.product.id;
179
+ if (!byProduct[pid]) byProduct[pid] = [];
180
+ byProduct[pid].push(p.id);
181
+ }
182
+ const products = Object.entries(byProduct).map(([prod, priceIds]) => ({
183
+ product: prod,
184
+ prices: priceIds,
185
+ }));
186
+
187
+ const config = await stripe.billingPortal.configurations.create({
188
+ features: {
189
+ subscription_update: {
190
+ enabled: true,
191
+ default_allowed_updates: ["price"],
192
+ products,
193
+ },
194
+ subscription_cancel: {enabled: true, mode: "at_period_end"},
195
+ payment_method_update: {enabled: true},
196
+ },
197
+ });
198
+
199
+ await configRef.set({configId: config.id}, {merge: true});
200
+ return config.id;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // createPortalSession — Stripe Customer Portal (manage / cancel / switch plan).
146
205
  // ---------------------------------------------------------------------------
147
206
  export const createPortalSession = onCall(
148
207
  {secrets: [stripeSecretKey]},
@@ -150,6 +209,7 @@ export const createPortalSession = onCall(
150
209
  const uid = request.auth?.uid;
151
210
  if (!uid) throw new HttpsError("unauthenticated", "Sign in required");
152
211
  const returnUrl = (request.data?.returnUrl as string | undefined) ?? "";
212
+ const planSwitching = request.data?.planSwitching as boolean | undefined;
153
213
 
154
214
  const stripe = stripeClient();
155
215
  const snap = await admin
@@ -161,9 +221,16 @@ export const createPortalSession = onCall(
161
221
  if (!customerId) {
162
222
  throw new HttpsError("failed-precondition", "No Stripe customer for user");
163
223
  }
224
+
225
+ // When plan switching is requested, resolve (or create) a portal configuration
226
+ // that has subscription_update enabled. This removes the need for any manual
227
+ // setup in the Stripe dashboard.
228
+ const configId = planSwitching ? await getOrCreatePortalConfig(stripe) : undefined;
229
+
164
230
  const session = await stripe.billingPortal.sessions.create({
165
231
  customer: customerId,
166
232
  return_url: returnUrl,
233
+ ...(configId ? {configuration: configId} : {}),
167
234
  });
168
235
  return {url: session.url};
169
236
  },
@@ -189,11 +256,18 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
189
256
  console.log("[stripe-webhook] subscription without firebaseUID metadata, skipping");
190
257
  return;
191
258
  }
259
+ // Skip if the user no longer exists. Deleting an account cancels the Stripe
260
+ // customer, which fires customer.subscription.deleted AFTER deleteUserAccount
261
+ // already removed subscriptions/{uid} — without this guard the webhook would
262
+ // re-create an orphan doc for a user that is gone. (The Supabase webhook does
263
+ // the same check.) The lookup also gives us the email to denormalize below.
264
+ const user = await usersRepository.getFromId(uid);
265
+ if (!user) {
266
+ console.log(`[stripe-webhook] user ${uid} not found (likely deleted), skipping`);
267
+ return;
268
+ }
192
269
  const now = Timestamp.now();
193
270
  const existing = await subscriptionsRepository.getFromUserId(uid);
194
- // Denormalize the subscriber's email onto the Firestore subscription doc (see
195
- // SubscriptionData.email) so a subscribers list reads it without a second hop.
196
- const user = await usersRepository.getFromId(uid);
197
271
  // In Stripe API v18 the billing period lives on each subscription item.
198
272
  const item = sub.items.data[0];
199
273
  const priceId = item?.price?.id ?? "";
@@ -211,7 +285,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
211
285
  expirationDate: expiration,
212
286
  store: Stores.STRIPE,
213
287
  productId: priceId,
214
- email: user?.email,
288
+ email: user.email,
215
289
  },
216
290
  subscriptionsRepository,
217
291
  );
@@ -366,7 +366,7 @@ class _TrailingChevron extends StatelessWidget {
366
366
  duration: const Duration(milliseconds: 200),
367
367
  child: Icon(
368
368
  KasyIcons.chevronDown,
369
- size: 18.5,
369
+ size: KasyIconSize.md,
370
370
  color: context.colors.muted,
371
371
  ),
372
372
  );
@@ -376,7 +376,7 @@ class _TrailingChevron extends StatelessWidget {
376
376
  duration: const Duration(milliseconds: 200),
377
377
  child: Icon(
378
378
  KasyIcons.chevronRight,
379
- size: 18.5,
379
+ size: KasyIconSize.md,
380
380
  color: context.colors.muted,
381
381
  ),
382
382
  );
@@ -232,7 +232,7 @@ class KasyAlertCircleButton extends StatelessWidget {
232
232
  height: 36,
233
233
  child: Icon(
234
234
  icon,
235
- size: 19,
235
+ size: KasyIconSize.md,
236
236
  color: context.colors.onSurface.withValues(alpha: 0.58),
237
237
  ),
238
238
  ),
@@ -292,7 +292,7 @@ class KasyAppBar extends StatelessWidget {
292
292
  ),
293
293
  _ => KasyChromeOrbIconButton(
294
294
  icon: KasyIcons.arrowBackIos,
295
- iconSize: 18,
295
+ iconSize: KasyIconSize.md,
296
296
  foregroundColor: orbFg,
297
297
  fillColor: orbFill,
298
298
  onPressed: handleBack,
@@ -404,7 +404,7 @@ class KasyAppBar extends StatelessWidget {
404
404
  case KasyAppBarStyle.subpage:
405
405
  return KasyChromeOrbIconButton(
406
406
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
407
- iconSize: 20,
407
+ iconSize: KasyIconSize.lg,
408
408
  foregroundColor: iconFg,
409
409
  fillColor: orbFill,
410
410
  onPressed: () {
@@ -421,7 +421,7 @@ class KasyAppBar extends StatelessWidget {
421
421
  if (trailing != null) return trailing!;
422
422
  return KasyChromeOrbIconButton(
423
423
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
424
- iconSize: 20,
424
+ iconSize: KasyIconSize.lg,
425
425
  foregroundColor: iconFg,
426
426
  fillColor: orbFill,
427
427
  onPressed: () {
@@ -298,7 +298,7 @@ class _IconBubble extends StatelessWidget {
298
298
  width: 72,
299
299
  height: 72,
300
300
  decoration: BoxDecoration(color: p.background, shape: BoxShape.circle),
301
- child: Icon(icon, size: 30, color: p.foreground),
301
+ child: Icon(icon, size: KasyIconSize.xxl, color: p.foreground),
302
302
  );
303
303
  }
304
304
 
@@ -417,17 +417,17 @@ class KasyButton extends StatelessWidget {
417
417
  iconOnlyExtent: 40,
418
418
  horizontalPadding: EdgeInsets.symmetric(horizontal: 16),
419
419
  labelFontSize: 13,
420
- iconSize: 16,
421
- iconOnlyGlyphSize: 12,
420
+ iconSize: KasyIconSize.sm,
421
+ iconOnlyGlyphSize: KasyIconSize.xxs,
422
422
  loadingSpinnerExtent: 13,
423
423
  ),
424
424
  KasyButtonSize.medium => const _KasyButtonMetrics(
425
- height: 47,
426
- iconOnlyExtent: 47,
425
+ height: 45,
426
+ iconOnlyExtent: 45,
427
427
  horizontalPadding: EdgeInsets.symmetric(horizontal: 18),
428
428
  labelFontSize: 14,
429
- iconSize: 18,
430
- iconOnlyGlyphSize: 14,
429
+ iconSize: KasyIconSize.md,
430
+ iconOnlyGlyphSize: KasyIconSize.xs,
431
431
  loadingSpinnerExtent: 14,
432
432
  ),
433
433
  KasyButtonSize.large => const _KasyButtonMetrics(
@@ -435,8 +435,8 @@ class KasyButton extends StatelessWidget {
435
435
  iconOnlyExtent: 54,
436
436
  horizontalPadding: EdgeInsets.symmetric(horizontal: 22),
437
437
  labelFontSize: 15,
438
- iconSize: 19,
439
- iconOnlyGlyphSize: 18,
438
+ iconSize: KasyIconSize.md,
439
+ iconOnlyGlyphSize: KasyIconSize.md,
440
440
  loadingSpinnerExtent: 15,
441
441
  ),
442
442
  };
@@ -36,7 +36,7 @@ class KasyChip extends StatelessWidget {
36
36
  final KasyColors c = context.colors;
37
37
  final Widget? avatar = icon == null
38
38
  ? null
39
- : Icon(icon, size: 18, color: enabled ? c.primary : c.muted);
39
+ : Icon(icon, size: KasyIconSize.md, color: enabled ? c.primary : c.muted);
40
40
  final OutlinedBorder chipShape = RoundedRectangleBorder(
41
41
  borderRadius: BorderRadius.circular(KasyRadius.full),
42
42
  side: BorderSide(color: c.outline.withValues(alpha: 0.45)),