kasy-cli 1.31.11 → 1.31.12

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 (25) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +1 -1
  3. package/lib/scaffold/backends/supabase/edge-functions/ai-chat/index.ts +1 -1
  4. package/lib/scaffold/backends/supabase/edge-functions/delete-user-account/index.ts +1 -1
  5. package/lib/scaffold/backends/supabase/edge-functions/meta-track-event/index.ts +1 -1
  6. package/lib/scaffold/backends/supabase/edge-functions/revenuecat-webhook/index.ts +1 -1
  7. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +1 -1
  8. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +1 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +1 -1
  10. package/package.json +2 -2
  11. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +12 -0
  12. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +10 -0
  13. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +19 -1
  14. package/templates/firebase/functions/src/subscriptions/triggers.ts +35 -4
  15. package/templates/firebase/lib/components/kasy_app_bar.dart +12 -22
  16. package/templates/firebase/lib/components/kasy_toast.dart +6 -4
  17. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +33 -14
  18. package/templates/firebase/lib/features/onboarding/repositories/user_infos_repository.dart +9 -2
  19. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
  20. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +13 -3
  21. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +51 -22
  22. package/templates/firebase/lib/i18n/en.i18n.json +2 -0
  23. package/templates/firebase/lib/i18n/es.i18n.json +3 -1
  24. package/templates/firebase/lib/i18n/pt.i18n.json +4 -2
  25. package/templates/firebase/web/stripe_success.html +138 -0
@@ -1,4 +1,13 @@
1
1
  {
2
+ "1.31.12": {
3
+ "modules": {
4
+ "core": {
5
+ "pt": "Preview de dispositivo na web não lança mais erros no console (\"Not initialized\" / provider não encontrado) durante a inicialização — agora espera o DevicePreview montar antes de sincronizar a orientação.",
6
+ "en": "Web device preview no longer throws console errors (\"Not initialized\" / provider not found) during startup — it now waits for DevicePreview to mount before syncing orientation.",
7
+ "es": "La vista previa de dispositivo en web ya no lanza errores en consola (\"Not initialized\" / provider no encontrado) durante el arranque — ahora espera a que DevicePreview se monte antes de sincronizar la orientación."
8
+ }
9
+ }
10
+ },
2
11
  "1.31.10": {
3
12
  "modules": {
4
13
  "core": {
@@ -24,7 +24,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
24
24
  const corsHeaders = {
25
25
  "Access-Control-Allow-Origin": "*",
26
26
  "Access-Control-Allow-Methods": "POST, OPTIONS",
27
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
27
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
28
28
  };
29
29
 
30
30
  // In-memory cap: load up to this many of the most recent users in one call.
@@ -30,7 +30,7 @@ const SSE_HEADERS = {
30
30
  const CORS_HEADERS = {
31
31
  "Access-Control-Allow-Origin": "*",
32
32
  "Access-Control-Allow-Methods": "POST, OPTIONS",
33
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
33
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
34
34
  };
35
35
 
36
36
  interface ChatMessage {
@@ -19,7 +19,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
19
19
  const corsHeaders = {
20
20
  "Access-Control-Allow-Origin": "*",
21
21
  "Access-Control-Allow-Methods": "POST, OPTIONS",
22
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
22
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
23
23
  };
24
24
 
25
25
  Deno.serve(async (req: Request) => {
@@ -172,7 +172,7 @@ async function sendMetaEvent(
172
172
  const corsHeaders = {
173
173
  "Access-Control-Allow-Origin": "*",
174
174
  "Access-Control-Allow-Methods": "POST, OPTIONS",
175
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
175
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
176
176
  };
177
177
 
178
178
  // ── Main handler ─────────────────────────────────────────────────────────────
@@ -261,7 +261,7 @@ Deno.serve(async (req: Request) => {
261
261
  headers: {
262
262
  "Access-Control-Allow-Origin": "*",
263
263
  "Access-Control-Allow-Methods": "POST, OPTIONS",
264
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
264
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
265
265
  },
266
266
  });
267
267
  }
@@ -23,7 +23,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
23
23
  const corsHeaders = {
24
24
  "Access-Control-Allow-Origin": "*",
25
25
  "Access-Control-Allow-Methods": "POST, OPTIONS",
26
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
26
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
27
27
  };
28
28
 
29
29
  // Maps a Supabase auth user -> its Stripe customer id.
@@ -22,7 +22,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
22
22
  const corsHeaders = {
23
23
  "Access-Control-Allow-Origin": "*",
24
24
  "Access-Control-Allow-Methods": "POST, OPTIONS",
25
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
25
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
26
26
  };
27
27
 
28
28
  const CUSTOMERS_TABLE = "stripe_customers";
@@ -19,7 +19,7 @@ import Stripe from "npm:stripe@18";
19
19
  const corsHeaders = {
20
20
  "Access-Control-Allow-Origin": "*",
21
21
  "Access-Control-Allow-Methods": "POST, OPTIONS",
22
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
22
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
23
23
  };
24
24
 
25
25
  function trialDaysFor(price: Stripe.Price, product: Stripe.Product): number | null {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.11",
3
+ "version": "1.31.12",
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/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/backend-pubspec-local-reminders.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",
@@ -5,6 +5,11 @@ import {Subscription} from "../../../subscriptions/models/subscriptions";
5
5
 
6
6
  export interface SubscriptionEntityData {
7
7
  id?: string,
8
+ // Denormalized reference to the subscriber, written as explicit fields on the
9
+ // Firestore doc (besides being the doc id) so a subscribers list / admin view
10
+ // can read who owns it and their email without a second lookup.
11
+ user_id?: string;
12
+ email?: string;
8
13
  creation_date: Timestamp;
9
14
  last_activity: Timestamp;
10
15
  expiration_date?: Timestamp;
@@ -20,6 +25,8 @@ export interface SubscriptionEntity extends SubscriptionEntityData {}
20
25
  export class SubscriptionEntity {
21
26
  constructor({
22
27
  id,
28
+ user_id,
29
+ email,
23
30
  creation_date,
24
31
  last_activity,
25
32
  expiration_date,
@@ -29,6 +36,8 @@ export class SubscriptionEntity {
29
36
  }: SubscriptionEntityData
30
37
  ) {
31
38
  this.id = id;
39
+ this.user_id = user_id;
40
+ this.email = email;
32
41
  this.creation_date = creation_date;
33
42
  this.last_activity = last_activity;
34
43
  this.expiration_date = expiration_date;
@@ -52,6 +61,8 @@ export class SubscriptionEntity {
52
61
  static from(subscription: Subscription): SubscriptionEntity {
53
62
  return new SubscriptionEntity({
54
63
  id: subscription.userId,
64
+ user_id: subscription.userId,
65
+ email: subscription.email,
55
66
  creation_date: subscription.creationDate,
56
67
  last_activity: subscription.lastUpdate,
57
68
  expiration_date: subscription.expirationDate,
@@ -70,6 +81,7 @@ export class SubscriptionEntity {
70
81
  status: this.status,
71
82
  store: this.store,
72
83
  productId: this.product_id,
84
+ email: this.email,
73
85
  }, subscriptionRepository);
74
86
  }
75
87
  }
@@ -24,6 +24,11 @@ export interface SubscriptionData {
24
24
  expirationDate?: Timestamp;
25
25
  store: Stores;
26
26
  productId: string;
27
+ // Denormalized copy of the subscriber's email. Firestore is a non-relational
28
+ // store, so we duplicate it onto the subscription doc to list/show subscribers
29
+ // without a second read. (On the relational backends the user is referenced by
30
+ // a user_id foreign key and the email is joined from the users table instead.)
31
+ email?: string;
27
32
  }
28
33
 
29
34
  export interface Subscription extends SubscriptionData {}
@@ -38,6 +43,7 @@ export class Subscription {
38
43
  expirationDate,
39
44
  store,
40
45
  productId,
46
+ email,
41
47
  }: SubscriptionData,
42
48
  private subscriptionRepository: SubscriptionsRepository,
43
49
  ) {
@@ -48,6 +54,7 @@ export class Subscription {
48
54
  this.expirationDate = expirationDate;
49
55
  this.store = store;
50
56
  this.productId = productId;
57
+ this.email = email;
51
58
  }
52
59
 
53
60
  static async fromRevenueCat({
@@ -76,6 +83,7 @@ export class Subscription {
76
83
  ? Stores.APPLE_STORE
77
84
  : Stores.PLAY_STORE,
78
85
  productId: event.product_id,
86
+ email: user.email,
79
87
  }, subscriptionRepository);
80
88
  }
81
89
  return new Subscription({
@@ -88,6 +96,7 @@ export class Subscription {
88
96
  ? Stores.APPLE_STORE
89
97
  : Stores.PLAY_STORE,
90
98
  productId: event.product_id,
99
+ email: user.email,
91
100
  }, subscriptionRepository);
92
101
  }
93
102
 
@@ -106,6 +115,7 @@ export class Subscription {
106
115
  expirationDate: entity.expiration_date,
107
116
  store: entity.store,
108
117
  productId: entity.product_id,
118
+ email: entity.email,
109
119
  }, subscriptionRepository);
110
120
  }
111
121
 
@@ -5,7 +5,7 @@ import * as admin from "firebase-admin";
5
5
  import {Timestamp} from "firebase-admin/firestore";
6
6
  import Stripe from "stripe";
7
7
  import {Subscription} from "./models/subscriptions";
8
- import {subscriptionsRepository} from "../core/data/repositories/repositories";
8
+ import {subscriptionsRepository, usersRepository} from "../core/data/repositories/repositories";
9
9
  import {Stores, SubscriptionStatus} from "./models/subscription_status";
10
10
 
11
11
  // Server-side only. Never exposed to the client.
@@ -103,6 +103,20 @@ export const createCheckoutSession = onCall(
103
103
  // Pre-fill Checkout with the signed-in user's email (UX only; the user is
104
104
  // identified by uid, so paying with a different email still updates them).
105
105
  const email = request.auth?.token?.email as string | undefined;
106
+ // Persist the app language on the user so server-side notifications (e.g.
107
+ // the "subscription saved" message) are sent in the right language. On web
108
+ // there is no registered device to read the locale from, so we capture it
109
+ // here at purchase time.
110
+ const locale = (request.data?.locale as string | undefined)
111
+ ?.substring(0, 2)
112
+ .toLowerCase();
113
+ if (locale) {
114
+ await admin
115
+ .firestore()
116
+ .collection("users")
117
+ .doc(uid)
118
+ .set({locale}, {merge: true});
119
+ }
106
120
 
107
121
  const stripe = stripeClient();
108
122
  const customerId = await getOrCreateCustomer(stripe, uid, email);
@@ -176,6 +190,9 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
176
190
  }
177
191
  const now = Timestamp.now();
178
192
  const existing = await subscriptionsRepository.getFromUserId(uid);
193
+ // Denormalize the subscriber's email onto the Firestore subscription doc (see
194
+ // SubscriptionData.email) so a subscribers list reads it without a second hop.
195
+ const user = await usersRepository.getFromId(uid);
179
196
  // In Stripe API v18 the billing period lives on each subscription item.
180
197
  const item = sub.items.data[0];
181
198
  const priceId = item?.price?.id ?? "";
@@ -193,6 +210,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
193
210
  expirationDate: expiration,
194
211
  store: Stores.STRIPE,
195
212
  productId: priceId,
213
+ email: user?.email,
196
214
  },
197
215
  subscriptionsRepository,
198
216
  );
@@ -1,9 +1,38 @@
1
1
  import { Logger } from "../core/logger/logger";
2
- import {usersRepository} from "../core/data/repositories/repositories";
2
+ import {usersRepository, userDevicesRepository} from "../core/data/repositories/repositories";
3
3
  import {notificationsApi} from "../notifications/notifications_api";
4
4
  import {SystemNotificationParams} from "../notifications/models/notification";
5
5
  import {onDocumentCreated} from "firebase-functions/v2/firestore";
6
6
 
7
+ /// Localized copy for the "subscription saved" notification.
8
+ function subscriptionSavedText(locale: string): {title: string; body: string} {
9
+ switch (locale) {
10
+ case "pt":
11
+ return {title: "Assinatura confirmada", body: "Obrigado pela sua confiança!"};
12
+ case "es":
13
+ return {title: "Suscripción confirmada", body: "¡Gracias por tu confianza!"};
14
+ default:
15
+ return {title: "Subscription confirmed", body: "Thank you for your trust!"};
16
+ }
17
+ }
18
+
19
+ /// Resolves the user's language: the locale persisted on the user (set on web at
20
+ /// checkout), then the device locale (native), then English.
21
+ async function resolveUserLocale(
22
+ userId: string,
23
+ userLocale: string | undefined,
24
+ ): Promise<string> {
25
+ if (userLocale) return userLocale.substring(0, 2).toLowerCase();
26
+ try {
27
+ const devices = await userDevicesRepository.getDevices([userId]);
28
+ const raw = devices[0]?.extra_data?.["deviceLocale"] as string | undefined;
29
+ if (raw) return raw.substring(0, 2).toLowerCase();
30
+ } catch {
31
+ // Fall through to the default below.
32
+ }
33
+ return "en";
34
+ }
35
+
7
36
  export const onNewSubscription = onDocumentCreated(
8
37
  "subscriptions/{userId}",
9
38
  async (event) => {
@@ -12,7 +41,7 @@ export const onNewSubscription = onDocumentCreated(
12
41
  }
13
42
  const userId = event.params.userId;
14
43
  const logger = new Logger("onNewSubscription");
15
-
44
+
16
45
  try {
17
46
  logger.info(`New subscription for user ${userId}`);
18
47
  const user = await usersRepository.getFromId(userId);
@@ -20,11 +49,13 @@ export const onNewSubscription = onDocumentCreated(
20
49
  logger.error(`User ${userId} not found`);
21
50
  return;
22
51
  }
52
+ const locale = await resolveUserLocale(userId, user.locale);
53
+ const {title, body} = subscriptionSavedText(locale);
23
54
  await notificationsApi.notify(
24
55
  [userId],
25
56
  <SystemNotificationParams> {
26
- title: "Subscription saved",
27
- body: "Thank you",
57
+ title,
58
+ body,
28
59
  },
29
60
  );
30
61
  } catch (error) {
@@ -125,12 +125,9 @@ class KasyFrostedChromeBackground extends StatelessWidget {
125
125
  this.padForStatusBar = true,
126
126
  });
127
127
 
128
- /// Translucent tint so blurred content shows through; tuned per brightness.
129
- /// Derived from the global `surface` token so the bar lifts off the canvas
130
- /// and follows light/dark automatically.
128
+ /// Solid bar fill from the global `surface` token; follows light/dark.
131
129
  Color _tint(BuildContext context) {
132
- final bool dark = Theme.of(context).brightness == Brightness.dark;
133
- return context.colors.surface.withValues(alpha: dark ? 0.88 : 0.82);
130
+ return context.colors.surface;
134
131
  }
135
132
 
136
133
  @override
@@ -194,24 +191,19 @@ class KasyFrostedChromeBackground extends StatelessWidget {
194
191
  class KasyTopScrollFade extends StatelessWidget {
195
192
  const KasyTopScrollFade({super.key});
196
193
 
197
- /// Fade zone below the status-bar strip (mobile only).
194
+ /// Total wash height: status-bar strip + a fade zone below it.
198
195
  static const double _contentFade = 40.0;
199
196
 
200
197
  @override
201
198
  Widget build(BuildContext context) {
202
199
  final double topInset = MediaQuery.paddingOf(context).top;
203
- // On web topInset = 0; enforce a 6 px minimum so the topmost pixels are
204
- // 100 % opaque even without a status bar.
200
+ // On web topInset = 0; enforce a 6 px minimum so the strip still exists.
205
201
  final double solidHeight = topInset < 6 ? 6.0 : topInset;
206
202
  final double totalHeight = solidHeight + _contentFade;
207
- final double ss = solidHeight / totalHeight; // solidStop
208
- final Color bg = context.colors.background;
209
-
210
- // Whole wash is intentionally faint — even the very top peaks at ~0.32, then
211
- // melts to nothing across the fade zone. "Quase transparente, bem suave."
212
- // Read from bottom → top: invisible → ghost → a soft hint at the very top.
213
- double p(double t) => ss + (1 - ss) * t;
203
+ final Color base = context.colors.surface;
214
204
 
205
+ // Same gradient the grid cards use for their caption scrim — strong at the
206
+ // top, melting gradually to fully transparent at the bottom.
215
207
  return IgnorePointer(
216
208
  child: SizedBox(
217
209
  height: totalHeight,
@@ -221,14 +213,12 @@ class KasyTopScrollFade extends StatelessWidget {
221
213
  begin: Alignment.topCenter,
222
214
  end: Alignment.bottomCenter,
223
215
  colors: <Color>[
224
- bg.withValues(alpha: 0.32),
225
- bg.withValues(alpha: 0.32),
226
- bg.withValues(alpha: 0.16),
227
- bg.withValues(alpha: 0.06),
228
- bg.withValues(alpha: 0.01),
229
- bg.withValues(alpha: 0.0),
216
+ base.withValues(alpha: 0.96),
217
+ base.withValues(alpha: 0.96),
218
+ base.withValues(alpha: 0.68),
219
+ base.withValues(alpha: 0.0),
230
220
  ],
231
- stops: <double>[0.0, ss, p(0.10), p(0.26), p(0.44), 1.0],
221
+ stops: const <double>[0.0, 0.20, 0.55, 1.0],
232
222
  ),
233
223
  ),
234
224
  ),
@@ -733,7 +733,9 @@ _Palette _palette(KasyColors c, KasyToastTone tone) {
733
733
  accent: c.primary,
734
734
  icon: KasyIcons.info,
735
735
  buttonBg: c.primary,
736
- buttonFg: c.onPrimary,
736
+ // The Close button sits on a solid, vivid tone color — keep its label
737
+ // white for reliable contrast regardless of the brand "on" token.
738
+ buttonFg: Colors.white,
737
739
  );
738
740
  case KasyToastTone.success:
739
741
  final Color successDark = HSLColor.fromColor(c.success)
@@ -745,21 +747,21 @@ _Palette _palette(KasyColors c, KasyToastTone tone) {
745
747
  accent: successDark,
746
748
  icon: KasyIcons.checkCircle,
747
749
  buttonBg: successDark,
748
- buttonFg: c.onSuccess,
750
+ buttonFg: Colors.white,
749
751
  );
750
752
  case KasyToastTone.warning:
751
753
  return _Palette(
752
754
  accent: c.warning,
753
755
  icon: KasyIcons.privacy,
754
756
  buttonBg: c.warning,
755
- buttonFg: c.onWarning,
757
+ buttonFg: Colors.white,
756
758
  );
757
759
  case KasyToastTone.danger:
758
760
  return _Palette(
759
761
  accent: c.error,
760
762
  icon: KasyIcons.error,
761
763
  buttonBg: c.error,
762
- buttonFg: c.onError,
764
+ buttonFg: Colors.white,
763
765
  );
764
766
  }
765
767
  }
@@ -508,6 +508,11 @@ class _DeviceSwitchBridge extends StatefulWidget {
508
508
  }
509
509
 
510
510
  class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
511
+ // _syncOrientation retries on the next frame while the DevicePreview store is
512
+ // still mounting/initializing; cap the retries so it can never spin forever.
513
+ int _syncRetries = 0;
514
+ static const int _maxSyncRetries = 120;
515
+
511
516
  @override
512
517
  void initState() {
513
518
  super.initState();
@@ -531,8 +536,8 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
531
536
  }
532
537
 
533
538
  void _onFrameVisibleChanged() {
534
- if (!mounted) return;
535
- final store = Provider.of<DevicePreviewStore>(context, listen: false);
539
+ final store = _store();
540
+ if (store == null) return;
536
541
  final data = _readData(store);
537
542
  if (data == null) return;
538
543
  if (data.isFrameVisible != widget.frameVisibleNotifier.value) {
@@ -543,17 +548,21 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
543
548
  void _onOrientationChanged() => _syncOrientation();
544
549
 
545
550
  void _syncOrientation() {
546
- if (!mounted) return;
547
- final store = Provider.of<DevicePreviewStore>(context, listen: false);
548
- final data = _readData(store);
549
- if (data == null) {
550
- // DevicePreview initializes asynchronously (it loads saved preferences), so
551
- // on the first web frame the store can still be uninitialized and reading
552
- // store.data throws "Not initialized". Retry next frame instead of surfacing
553
- // a scary (and harmless) exception in the console.
554
- WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
551
+ final store = _store();
552
+ final data = store == null ? null : _readData(store);
553
+ if (store == null || data == null) {
554
+ // DevicePreview mounts its store asynchronously: on the first web frames the
555
+ // provider may not be in the tree yet (ProviderNotFound) or the store may be
556
+ // uninitialized (reading .data throws "Not initialized"). Retry on the next
557
+ // frame instead of surfacing a scary (and harmless) exception. Capped so it
558
+ // can never spin forever.
559
+ if (mounted && _syncRetries < _maxSyncRetries) {
560
+ _syncRetries++;
561
+ WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
562
+ }
555
563
  return;
556
564
  }
565
+ _syncRetries = 0;
557
566
  final target = widget.landscapeNotifier.value
558
567
  ? Orientation.landscape
559
568
  : Orientation.portrait;
@@ -562,9 +571,19 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
562
571
  }
563
572
  }
564
573
 
565
- /// [DevicePreviewStore.data] throws while the store is still finishing its async
566
- /// initialization. Returns null instead of throwing so callers can skip or retry
567
- /// cleanly (see _syncOrientation).
574
+ /// The DevicePreview store, or null if it isn't in the tree yet — `Provider.of`
575
+ /// throws ProviderNotFound on the first web frames before DevicePreview mounts it.
576
+ DevicePreviewStore? _store() {
577
+ if (!mounted) return null;
578
+ try {
579
+ return Provider.of<DevicePreviewStore>(context, listen: false);
580
+ } catch (_) {
581
+ return null;
582
+ }
583
+ }
584
+
585
+ /// [DevicePreviewStore.data] throws "Not initialized" while the store finishes its
586
+ /// async init. Returns null instead so callers can skip or retry cleanly.
568
587
  DevicePreviewData? _readData(DevicePreviewStore store) {
569
588
  try {
570
589
  return store.data;
@@ -17,11 +17,18 @@ class UserInfosRepository {
17
17
 
18
18
  Future<void> save(String userId, UserInfoDetail info) async {
19
19
  final entity = info.toEntity();
20
-
20
+
21
21
  final alreadyExistingInfo =
22
22
  await _userInfosApi.getByKey(userId, entity.key);
23
23
  if (alreadyExistingInfo != null) {
24
- return _userInfosApi.update(userId, entity);
24
+ // Reuse the existing document id so re-answering the same onboarding
25
+ // question overwrites its value instead of appending a duplicate. The
26
+ // fresh entity has a null id, which would otherwise make update() write
27
+ // to a new auto-id document every time (the duplication seen in testing).
28
+ return _userInfosApi.update(
29
+ userId,
30
+ entity.copyWith(id: alreadyExistingInfo.id),
31
+ );
25
32
  }
26
33
 
27
34
  return _userInfosApi.create(userId, entity);
@@ -34,6 +34,7 @@ class StripeBackendApi {
34
34
  required String priceId,
35
35
  String? successUrl,
36
36
  String? cancelUrl,
37
+ String? locale,
37
38
  }) async {
38
39
  final res = await _functions
39
40
  .httpsCallable('stripeFunctions-createCheckoutSession')
@@ -41,6 +42,7 @@ class StripeBackendApi {
41
42
  'priceId': priceId,
42
43
  if (successUrl != null) 'successUrl': successUrl,
43
44
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
45
+ if (locale != null) 'locale': locale,
44
46
  });
45
47
  return (res.data as Map)['url'] as String;
46
48
  }
@@ -3,6 +3,7 @@ import 'package:kasy_kit/core/data/models/subscription.dart';
3
3
  import 'package:kasy_kit/features/subscriptions/api/entities/subscription_entity.dart';
4
4
  import 'package:kasy_kit/features/subscriptions/api/stripe_backend_api.dart';
5
5
  import 'package:kasy_kit/features/subscriptions/api/subscription_payment_api.dart';
6
+ import 'package:kasy_kit/i18n/translations.g.dart';
6
7
  import 'package:url_launcher/url_launcher.dart';
7
8
 
8
9
  /// Stripe web subscription provider.
@@ -33,11 +34,20 @@ class StripePaymentApi implements SubscriptionPaymentApi {
33
34
 
34
35
  @override
35
36
  Future<void> purchaseProduct(SubscriptionProduct product) async {
36
- final returnUrl = Uri.base.toString();
37
+ // On success, Stripe redirects this new tab to a tiny standalone page
38
+ // (web/stripe_success.html) instead of reloading the whole app in a second
39
+ // tab. The original app tab keeps polling and flips to premium via the
40
+ // webhook. On cancel we send the user back to the app where they were.
41
+ final appUrl = Uri.base.toString();
42
+ final successUrl = '${Uri.base.origin}/stripe_success.html';
37
43
  final url = await _backend.createCheckoutSession(
38
44
  priceId: product.skuId,
39
- successUrl: returnUrl,
40
- cancelUrl: returnUrl,
45
+ successUrl: successUrl,
46
+ cancelUrl: appUrl,
47
+ // Send the app language so the backend can persist it on the user and
48
+ // deliver subscription notifications in the right language (on web there
49
+ // is no registered device to read the locale from).
50
+ locale: LocaleSettings.instance.currentLocale.languageCode,
41
51
  );
42
52
  await _open(url);
43
53
  // Checkout is now open in a new tab. Payment is NOT confirmed yet — the
@@ -32,6 +32,17 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
32
32
 
33
33
  @override
34
34
  Future<PremiumState> build() async {
35
+ // A user who already has an active subscription must ALWAYS land on the
36
+ // "subscribed" state, even if the offer list fails to load. On web the
37
+ // offers come from Stripe and the fetch can transiently fail; without this
38
+ // early return a paying user would wrongly see the empty-products paywall
39
+ // and "lose" the confirmation that they paid. The active view
40
+ // ([ActivePremiumContent]) does not need the offer list.
41
+ final currentSubscription = _userState.subscription;
42
+ if (currentSubscription is SubscriptionStateData) {
43
+ return PremiumState.active(activeOffer: currentSubscription.activeOffer);
44
+ }
45
+
35
46
  try {
36
47
  // If you have installed the remote config brick
37
48
  // you can use it like this
@@ -42,25 +53,13 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
42
53
  final offers = await _subscriptionRepository.getOffers();
43
54
  if (offers.isEmpty) {
44
55
  _logger.w(
45
- 'RevenueCat returned no subscription offers. The paywall will show '
56
+ 'The store returned no subscription offers. The paywall will show '
46
57
  'an empty-products state instead of a blank screen.',
47
58
  );
48
59
  return const PremiumState(offers: []);
49
60
  }
50
61
 
51
- return switch (_userState.subscription) {
52
- SubscriptionStateData(:final activeOffer) => PremiumState.active(
53
- activeOffer: offers.firstWhere(
54
- (element) => element.skuId == activeOffer?.skuId,
55
- orElse: () => offers.first,
56
- ),
57
- ),
58
- SubscriptionInactiveStateData() => PremiumState(
59
- offers: offers,
60
- selectedOffer: offers.first,
61
- ),
62
- _ => PremiumState(offers: offers, selectedOffer: offers.first),
63
- };
62
+ return PremiumState(offers: offers, selectedOffer: offers.first);
64
63
  } catch (err, stack) {
65
64
  // RevenueCat CONFIGURATION_ERROR (code 23) means the products exist in the
66
65
  // dashboard but aren't live on the store yet (e.g. "Ready to Submit").
@@ -271,15 +270,45 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
271
270
 
272
271
  try {
273
272
  await _subscriptionRepository.restorePurchase();
273
+
274
+ // restorePurchase is a no-op on web (Stripe status lives server-side), so
275
+ // we re-read the backend to learn the real state: the webhook may have
276
+ // already written the subscription. Only flip to active when the backend
277
+ // actually confirms it — otherwise we'd show a false "restored" success.
278
+ final userId = _userState.user.idOrNull;
279
+ final restored = userId == null
280
+ ? null
281
+ : await _subscriptionRepository.get(userId);
274
282
  final t = ref.read(translationsProvider);
275
- ref
276
- .read(toastProvider)
277
- .success(
278
- title: t.premium.restore_success_title,
279
- text: t.premium.restore_success_text,
280
- );
281
- await Future.delayed(const Duration(seconds: 2));
282
- if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
283
+
284
+ if (restored is SubscriptionStateData && restored.isActive) {
285
+ await ref
286
+ .read(userStateNotifierProvider.notifier)
287
+ .refreshSubscription(
288
+ product: restored.activeOffer,
289
+ entitlements: restored.entitlements,
290
+ );
291
+ state = AsyncData(
292
+ PremiumState.active(activeOffer: restored.activeOffer),
293
+ );
294
+ ref.read(toastProvider).success(
295
+ title: t.premium.restore_success_title,
296
+ text: t.premium.restore_success_text,
297
+ );
298
+ await Future.delayed(const Duration(seconds: 2));
299
+ if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
300
+ return;
301
+ }
302
+
303
+ // Nothing to restore: revert to the paywall and tell the user, instead of
304
+ // a misleading success toast.
305
+ state = AsyncData(
306
+ PremiumStateData(offers: data.offers, selectedOffer: data.selectedOffer),
307
+ );
308
+ ref.read(toastProvider).alert(
309
+ title: t.premium.restore_none_title,
310
+ text: t.premium.restore_none_text,
311
+ );
283
312
  } catch (err, trace) {
284
313
  _logger.e("Error while restoring purchase: $err : $trace");
285
314
  state = AsyncData(
@@ -208,6 +208,8 @@
208
208
  "error_text": "An error occurred. Please try again",
209
209
  "web_checkout_timeout_title": "Payment not confirmed",
210
210
  "web_checkout_timeout_text": "We did not receive payment confirmation. If you already paid, tap Restore.",
211
+ "restore_none_title": "No subscription found",
212
+ "restore_none_text": "We could not find an active subscription to restore.",
211
213
  "comparison": {
212
214
  "title": "Premium plan comparison",
213
215
  "features_label": "Features",
@@ -207,7 +207,9 @@
207
207
  "error_title": "Error",
208
208
  "error_text": "Ocurrió un error. Inténtalo de nuevo",
209
209
  "web_checkout_timeout_title": "Pago no confirmado",
210
- "web_checkout_timeout_text": "No recibimos confirmacion del pago. Si ya pagaste, toca Restaurar.",
210
+ "web_checkout_timeout_text": "No recibimos confirmación del pago. Si ya pagaste, toca Restaurar.",
211
+ "restore_none_title": "No se encontró ninguna suscripción",
212
+ "restore_none_text": "No encontramos una suscripción activa para restaurar.",
211
213
  "comparison": {
212
214
  "title": "Comparación de planes Premium",
213
215
  "features_label": "Características",
@@ -206,8 +206,10 @@
206
206
  "purchase_success_text": "Obrigado pela sua confiança",
207
207
  "error_title": "Erro",
208
208
  "error_text": "Ocorreu um erro. Tente novamente",
209
- "web_checkout_timeout_title": "Pagamento nao confirmado",
210
- "web_checkout_timeout_text": "Nao recebemos a confirmacao do pagamento. Se voce ja pagou, toque em Restaurar.",
209
+ "web_checkout_timeout_title": "Pagamento não confirmado",
210
+ "web_checkout_timeout_text": "Não recebemos a confirmação do pagamento. Se você pagou, toque em Restaurar.",
211
+ "restore_none_title": "Nenhuma assinatura encontrada",
212
+ "restore_none_text": "Não encontramos uma assinatura ativa para restaurar.",
211
213
  "comparison": {
212
214
  "title": "Comparação de planos Premium",
213
215
  "features_label": "Recursos",
@@ -0,0 +1,138 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ Stripe Checkout success page.
4
+
5
+ Stripe redirects the checkout tab here (success_url) after a successful
6
+ payment. It is intentionally a tiny standalone page — NOT the full Flutter
7
+ app — so the user does not end up with two heavy app tabs open. The original
8
+ app tab keeps polling and flips to premium on its own (via the webhook), so
9
+ here we just congratulate the user and let them close this tab / return.
10
+
11
+ Localized client-side from navigator.language (pt / es / en).
12
+ -->
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="UTF-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
17
+ <title>Payment complete</title>
18
+ <style>
19
+ :root {
20
+ --accent: #0485F7;
21
+ --success: #16A34A;
22
+ --bg: #F6F8FB;
23
+ --card: #FFFFFF;
24
+ --text: #0B1524;
25
+ --muted: #5B6776;
26
+ --border: rgba(11, 21, 36, 0.08);
27
+ }
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --bg: #0B1220;
31
+ --card: #131C2B;
32
+ --text: #F3F6FB;
33
+ --muted: #9AA7B8;
34
+ --border: rgba(255, 255, 255, 0.10);
35
+ }
36
+ }
37
+ * { box-sizing: border-box; }
38
+ html, body { height: 100%; margin: 0; }
39
+ body {
40
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
41
+ Helvetica, Arial, sans-serif;
42
+ background: var(--bg);
43
+ color: var(--text);
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ padding: 24px;
48
+ }
49
+ .card {
50
+ width: 100%;
51
+ max-width: 420px;
52
+ background: var(--card);
53
+ border: 1px solid var(--border);
54
+ border-radius: 20px;
55
+ padding: 40px 32px;
56
+ text-align: center;
57
+ box-shadow: 0 12px 40px rgba(11, 21, 36, 0.08);
58
+ }
59
+ .check {
60
+ width: 72px;
61
+ height: 72px;
62
+ margin: 0 auto 24px;
63
+ border-radius: 50%;
64
+ background: rgba(22, 163, 74, 0.12);
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ }
69
+ .check svg { width: 38px; height: 38px; stroke: var(--success); }
70
+ h1 { font-size: 22px; font-weight: 700; margin: 0 0 10px; letter-spacing: -0.4px; }
71
+ p { font-size: 15px; line-height: 1.5; color: var(--muted); margin: 0 0 28px; }
72
+ button {
73
+ width: 100%;
74
+ border: none;
75
+ border-radius: 12px;
76
+ padding: 14px 20px;
77
+ font-size: 15px;
78
+ font-weight: 600;
79
+ color: #FFFFFF;
80
+ background: var(--accent);
81
+ cursor: pointer;
82
+ transition: opacity 0.15s ease;
83
+ }
84
+ button:hover { opacity: 0.92; }
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <div class="card">
89
+ <div class="check">
90
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2.5"
91
+ stroke-linecap="round" stroke-linejoin="round">
92
+ <path d="M20 6L9 17l-5-5" />
93
+ </svg>
94
+ </div>
95
+ <h1 id="title">Payment complete</h1>
96
+ <p id="subtitle">Your subscription is confirmed. You can return to the app.</p>
97
+ <button id="close">Return to the app</button>
98
+ </div>
99
+
100
+ <script>
101
+ var I18N = {
102
+ en: {
103
+ doc: "Payment complete",
104
+ title: "Payment complete",
105
+ subtitle: "Your subscription is confirmed. You can return to the app.",
106
+ button: "Return to the app",
107
+ },
108
+ pt: {
109
+ doc: "Pagamento concluído",
110
+ title: "Pagamento concluído!",
111
+ subtitle: "Sua assinatura foi confirmada. Você já pode voltar ao aplicativo.",
112
+ button: "Voltar ao aplicativo",
113
+ },
114
+ es: {
115
+ doc: "Pago completado",
116
+ title: "¡Pago completado!",
117
+ subtitle: "Tu suscripción está confirmada. Ya puedes volver a la aplicación.",
118
+ button: "Volver a la aplicación",
119
+ },
120
+ };
121
+
122
+ var lang = (navigator.language || "en").slice(0, 2).toLowerCase();
123
+ var t = I18N[lang] || I18N.en;
124
+ document.documentElement.lang = lang in I18N ? lang : "en";
125
+ document.title = t.doc;
126
+ document.getElementById("title").textContent = t.title;
127
+ document.getElementById("subtitle").textContent = t.subtitle;
128
+ document.getElementById("close").textContent = t.button;
129
+
130
+ // This tab was opened by the app via window.open, so window.close() is
131
+ // allowed. A button click is a user gesture, so it closes reliably and
132
+ // returns focus to the original app tab (already flipped to premium).
133
+ document.getElementById("close").addEventListener("click", function () {
134
+ window.close();
135
+ });
136
+ </script>
137
+ </body>
138
+ </html>