kasy-cli 1.31.12 → 1.31.13

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.
@@ -37,6 +37,7 @@ class StripeBackendApi {
37
37
  required String priceId,
38
38
  String? successUrl,
39
39
  String? cancelUrl,
40
+ String? locale,
40
41
  }) async {
41
42
  final Response res = await _client.post(
42
43
  '/stripe/checkout-session',
@@ -44,6 +45,7 @@ class StripeBackendApi {
44
45
  'priceId': priceId,
45
46
  if (successUrl != null) 'successUrl': successUrl,
46
47
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
48
+ if (locale != null) 'locale': locale,
47
49
  },
48
50
  );
49
51
  return (res.data as Map)['url'] as String;
@@ -71,6 +71,29 @@ Deno.serve(async (req: Request) => {
71
71
 
72
72
  const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey);
73
73
 
74
+ // Best-effort Stripe teardown BEFORE deletion: deleting the Stripe customer
75
+ // cancels all of its subscriptions, so billing stops for the deleted account.
76
+ // The DB rows (stripe_customers, subscriptions, ...) are removed by ON DELETE
77
+ // CASCADE, but that does not reach Stripe. Guarded by the secret so a
78
+ // non-Stripe app simply skips it.
79
+ const stripeKey = Deno.env.get("STRIPE_SECRET_KEY");
80
+ if (stripeKey) {
81
+ try {
82
+ const { data: cust } = await supabaseAdmin
83
+ .from("stripe_customers")
84
+ .select("customer_id")
85
+ .eq("user_id", user.id)
86
+ .maybeSingle();
87
+ const customerId = cust?.customer_id as string | undefined;
88
+ if (customerId) {
89
+ const { default: Stripe } = await import("npm:stripe@18");
90
+ await new Stripe(stripeKey).customers.del(customerId);
91
+ }
92
+ } catch (e) {
93
+ console.error("[delete-user-account] Stripe cleanup failed:", e);
94
+ }
95
+ }
96
+
74
97
  try {
75
98
  const { error: deleteError } = await supabaseAdmin.auth.admin.deleteUser(user.id);
76
99
 
@@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
97
97
  }
98
98
  const uid = user.id;
99
99
 
100
- let body: { priceId?: string; successUrl?: string; cancelUrl?: string };
100
+ let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string };
101
101
  try {
102
102
  body = await req.json();
103
103
  } catch {
@@ -109,10 +109,17 @@ Deno.serve(async (req: Request) => {
109
109
  }
110
110
  const successUrl = body.successUrl ?? "";
111
111
  const cancelUrl = body.cancelUrl ?? successUrl;
112
+ const locale = body.locale?.substring(0, 2).toLowerCase();
112
113
 
113
114
  try {
114
115
  const stripe = new Stripe(secretKey);
115
116
  const admin = createClient(supabaseUrl, serviceRoleKey);
117
+ // Persist the app language on the user so server-side notifications are sent
118
+ // in the right language (on web there is no registered device to read it
119
+ // from). Mirrors the Firebase checkout behavior.
120
+ if (locale) {
121
+ await admin.from("users").update({ locale }).eq("id", uid);
122
+ }
116
123
  const customerId = await getOrCreateCustomer(stripe, admin, uid, user.email);
117
124
 
118
125
  const price = await stripe.prices.retrieve(priceId, { expand: ["product"] });
@@ -211,6 +211,18 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
211
211
 
212
212
  @override
213
213
  Future<Credentials> signinWithGoogle() async {
214
+ 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
+ );
224
+ return Credentials(id: '', token: '');
225
+ }
214
226
  final googleSignIn = GoogleSignIn.instance;
215
227
  // Web: clientId = Web Client ID (no serverClientId needed)
216
228
  // Native iOS: clientId = iOS Client ID, serverClientId = Web Client ID
@@ -310,6 +322,16 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
310
322
 
311
323
  @override
312
324
  Future<Credentials> signupFromAnonymousWithGoogle() async {
325
+ 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: '');
334
+ }
313
335
  final scopes = ['email'];
314
336
  final googleSignIn = GoogleSignIn.instance;
315
337
 
@@ -29,6 +29,7 @@ class StripeBackendApi {
29
29
  required String priceId,
30
30
  String? successUrl,
31
31
  String? cancelUrl,
32
+ String? locale,
32
33
  }) async {
33
34
  final res = await _client.functions.invoke(
34
35
  'stripe-create-checkout-session',
@@ -36,6 +37,7 @@ class StripeBackendApi {
36
37
  'priceId': priceId,
37
38
  if (successUrl != null) 'successUrl': successUrl,
38
39
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
40
+ if (locale != null) 'locale': locale,
39
41
  },
40
42
  );
41
43
  return (res.data as Map)['url'] as String;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.12",
3
+ "version": "1.31.13",
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/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",
35
35
  "start": "node ./bin/kasy.js",
36
36
  "setup": "node ./bin/kasy.js setup",
37
37
  "doctor": "node ./bin/kasy.js doctor",
@@ -1,5 +1,6 @@
1
1
  import {error} from "firebase-functions/logger";
2
2
  import {onCall, onRequest, HttpsError} from "firebase-functions/v2/https";
3
+ import {onDocumentDeleted} from "firebase-functions/v2/firestore";
3
4
  import {defineSecret, defineString} from "firebase-functions/params";
4
5
  import * as admin from "firebase-admin";
5
6
  import {Timestamp} from "firebase-admin/firestore";
@@ -255,3 +256,39 @@ export const stripeWebhook = onRequest(
255
256
  }
256
257
  },
257
258
  );
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // onUserDeletedCleanupStripe — tears down Stripe state when an account is
262
+ // deleted. deleteUserAccount removes the users/{uid} doc; this trigger fires on
263
+ // that deletion and (a) deletes the Stripe customer, which immediately cancels
264
+ // all of its subscriptions so billing stops, and (b) removes the local
265
+ // uid -> customer mapping so no orphan stripe_customers doc is left behind.
266
+ // Lives in the Stripe module so a non-Stripe app never deploys it.
267
+ // ---------------------------------------------------------------------------
268
+ export const onUserDeletedCleanupStripe = onDocumentDeleted(
269
+ {document: "users/{userId}", secrets: [stripeSecretKey]},
270
+ async (event) => {
271
+ const uid = event.params.userId;
272
+ const custRef = admin
273
+ .firestore()
274
+ .collection(CUSTOMERS_COLLECTION)
275
+ .doc(uid);
276
+ try {
277
+ const snap = await custRef.get();
278
+ const customerId = snap.data()?.customerId as string | undefined;
279
+ if (customerId) {
280
+ // Deleting the Stripe customer cancels all of its subscriptions, so
281
+ // billing stops for the deleted account in a single call.
282
+ await stripeClient().customers.del(customerId);
283
+ }
284
+ } catch (e) {
285
+ console.log(`[stripe-cleanup] could not delete Stripe customer for ${uid}: ${e}`);
286
+ }
287
+ // Always drop the local mapping so no orphan remains.
288
+ try {
289
+ await custRef.delete();
290
+ } catch (e) {
291
+ console.log(`[stripe-cleanup] could not delete stripe_customers/${uid}: ${e}`);
292
+ }
293
+ },
294
+ );
@@ -23,8 +23,6 @@
23
23
 
24
24
  library;
25
25
 
26
- import 'dart:ui' show ImageFilter;
27
-
28
26
  import 'package:flutter/foundation.dart' show kIsWeb;
29
27
  import 'package:flutter/material.dart';
30
28
  import 'package:flutter/services.dart' show SystemUiOverlayStyle;
@@ -111,17 +109,13 @@ Color kasyChromeOrbFillColor(BuildContext context) {
111
109
  class KasyFrostedChromeBackground extends StatelessWidget {
112
110
  final Widget child;
113
111
 
114
- /// Backdrop blur strength; higher = more frosted (content still visible through tint).
115
- final double blurSigma;
116
-
117
- /// Insets content below the notch but keeps tint/blur covering the status-bar
118
- /// strip so scroll never shows cards behind clock/battery icons.
112
+ /// Insets content below the notch but keeps the solid fill covering the
113
+ /// status-bar strip so scroll never shows cards behind clock/battery icons.
119
114
  final bool padForStatusBar;
120
115
 
121
116
  const KasyFrostedChromeBackground({
122
117
  super.key,
123
118
  required this.child,
124
- this.blurSigma = 14,
125
119
  this.padForStatusBar = true,
126
120
  });
127
121
 
@@ -165,16 +159,8 @@ class KasyFrostedChromeBackground extends StatelessWidget {
165
159
  return AnnotatedRegion<SystemUiOverlayStyle>(
166
160
  value: overlayStyle,
167
161
  child: DecoratedBox(
168
- decoration: BoxDecoration(boxShadow: chromeShadow),
169
- child: ClipRect(
170
- child: BackdropFilter(
171
- filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
172
- child: DecoratedBox(
173
- decoration: BoxDecoration(color: tint),
174
- child: content,
175
- ),
176
- ),
177
- ),
162
+ decoration: BoxDecoration(color: tint, boxShadow: chromeShadow),
163
+ child: content,
178
164
  ),
179
165
  );
180
166
  }
@@ -253,7 +239,6 @@ class KasyAppBar extends StatelessWidget {
253
239
  /// same tap target semantics as the built-in orbs.
254
240
  final Widget? trailing;
255
241
  final bool useSafeArea;
256
- final double frostBlurSigma;
257
242
 
258
243
  /// When set, called instead of [ThemeProvider.toggle] for the theme orb
259
244
  /// ([KasyAppBarStyle.subpage], [KasyAppBarStyle.rootTab]).
@@ -282,7 +267,6 @@ class KasyAppBar extends StatelessWidget {
282
267
  this.onBack,
283
268
  this.trailing,
284
269
  this.useSafeArea = true,
285
- this.frostBlurSigma = 14,
286
270
  this.onThemeToggle,
287
271
  this.toolbarHeight,
288
272
  this.topInset,
@@ -366,7 +350,6 @@ class KasyAppBar extends StatelessWidget {
366
350
  )
367
351
  : bar;
368
352
  final Widget chrome = KasyFrostedChromeBackground(
369
- blurSigma: frostBlurSigma,
370
353
  padForStatusBar: useSafeArea,
371
354
  child: barContent,
372
355
  );
@@ -506,6 +506,7 @@ Future<void> showKasyConfirmDialog(
506
506
  required String confirmLabel,
507
507
  VoidCallback? onCancel,
508
508
  VoidCallback? onConfirm,
509
+ Future<void> Function()? onConfirmAsync,
509
510
  bool destructive = false,
510
511
  bool barrierDismissible = false,
511
512
  IconData? leadingIcon,
@@ -517,37 +518,54 @@ Future<void> showKasyConfirmDialog(
517
518
  return showKasyBlurDialog<void>(
518
519
  context: context,
519
520
  barrierDismissible: barrierDismissible,
520
- builder: (dialogCtx) => KasyDialog(
521
- leadingIcon: leadingIcon ?? (destructive ? KasyIcons.trash : null),
522
- iconTone: destructive ? KasyDialogIconTone.danger : iconTone,
523
- title: title,
524
- titleCentered: leadingIcon == null && !destructive,
525
- message: message,
526
- showCloseButton: false,
527
- actionsAxis: Axis.horizontal,
528
- actions: [
529
- KasyButton(
530
- label: cancelLabel,
531
- variant: KasyButtonVariant.outline,
532
- expand: true,
533
- onPressed: () {
534
- Navigator.of(dialogCtx).pop();
535
- onCancel?.call();
536
- },
537
- ),
538
- KasyButton(
539
- label: confirmLabel,
540
- variant: destructive
541
- ? KasyButtonVariant.destructive
542
- : KasyButtonVariant.primary,
543
- expand: true,
544
- onPressed: () {
545
- Navigator.of(dialogCtx).pop();
546
- onConfirm?.call();
547
- },
521
+ builder: (dialogCtx) {
522
+ var isLoading = false;
523
+ return StatefulBuilder(
524
+ builder: (ctx, setState) => KasyDialog(
525
+ leadingIcon: leadingIcon ?? (destructive ? KasyIcons.trash : null),
526
+ iconTone: destructive ? KasyDialogIconTone.danger : iconTone,
527
+ title: title,
528
+ titleCentered: leadingIcon == null && !destructive,
529
+ message: message,
530
+ showCloseButton: false,
531
+ actionsAxis: Axis.horizontal,
532
+ actions: [
533
+ KasyButton(
534
+ label: cancelLabel,
535
+ variant: KasyButtonVariant.outline,
536
+ expand: true,
537
+ onPressed: isLoading
538
+ ? null
539
+ : () {
540
+ Navigator.of(dialogCtx).pop();
541
+ onCancel?.call();
542
+ },
543
+ ),
544
+ KasyButton(
545
+ label: confirmLabel,
546
+ variant: destructive
547
+ ? KasyButtonVariant.destructive
548
+ : KasyButtonVariant.primary,
549
+ expand: true,
550
+ isLoading: isLoading,
551
+ onPressed: isLoading
552
+ ? null
553
+ : () {
554
+ if (onConfirmAsync != null) {
555
+ setState(() => isLoading = true);
556
+ onConfirmAsync().whenComplete(() {
557
+ if (dialogCtx.mounted) Navigator.of(dialogCtx).pop();
558
+ });
559
+ } else {
560
+ Navigator.of(dialogCtx).pop();
561
+ onConfirm?.call();
562
+ }
563
+ },
564
+ ),
565
+ ],
548
566
  ),
549
- ],
550
- ),
567
+ );
568
+ },
551
569
  );
552
570
  }
553
571
 
@@ -21,7 +21,19 @@ class DeleteUserButton extends ConsumerWidget {
21
21
  cancelLabel: t.settings.delete_account.cancel,
22
22
  confirmLabel: t.settings.delete_account.confirm,
23
23
  destructive: true,
24
- onConfirm: () => ref.read(userStateNotifierProvider.notifier).deleteAccount(),
24
+ onConfirmAsync: () async {
25
+ try {
26
+ await ref.read(userStateNotifierProvider.notifier).deleteAccount();
27
+ } catch (_) {
28
+ if (context.mounted) {
29
+ showKasyToast(
30
+ context,
31
+ title: t.settings.delete_account.error,
32
+ tone: KasyToastTone.danger,
33
+ );
34
+ }
35
+ }
36
+ },
25
37
  ),
26
38
  );
27
39
  }
@@ -502,9 +502,10 @@
502
502
  "delete_account": {
503
503
  "button": "I want to delete my account",
504
504
  "title": "Delete your account?",
505
- "content": "Warning: this action is irreversible.",
505
+ "content": "Warning: this is permanent. You will lose any active subscription, and creating a new account later (even with the same email) will not restore it.",
506
506
  "cancel": "Cancel",
507
- "confirm": "Yes, delete"
507
+ "confirm": "Yes, delete",
508
+ "error": "Something went wrong. Please try again."
508
509
  },
509
510
  "admin": {
510
511
  "update_bottom_sheet": "Update bottom sheet",
@@ -502,9 +502,10 @@
502
502
  "delete_account": {
503
503
  "button": "Quiero eliminar mi cuenta",
504
504
  "title": "¿Quieres eliminar tu cuenta?",
505
- "content": "Advertencia: esta acción es irreversible.",
505
+ "content": "Advertencia: esta acción es permanente. Perderás cualquier suscripción activa, y crear una cuenta nueva más tarde (incluso con el mismo correo) no la recuperará.",
506
506
  "cancel": "Cancelar",
507
- "confirm": "Sí, eliminar"
507
+ "confirm": "Sí, eliminar",
508
+ "error": "Algo salió mal. Por favor, inténtalo de nuevo."
508
509
  },
509
510
  "admin": {
510
511
  "update_bottom_sheet": "Actualizar bottom sheet",
@@ -502,9 +502,10 @@
502
502
  "delete_account": {
503
503
  "button": "Quero excluir minha conta",
504
504
  "title": "Quer excluir sua conta?",
505
- "content": "Atenção: esta ação é irreversível.",
505
+ "content": "Atenção: esta ação é permanente. Você perde qualquer assinatura ativa, e criar uma nova conta depois (mesmo com o mesmo e-mail) não recupera ela.",
506
506
  "cancel": "Cancelar",
507
- "confirm": "Sim, excluir"
507
+ "confirm": "Sim, excluir",
508
+ "error": "Algo deu errado. Por favor, tente novamente."
508
509
  },
509
510
  "admin": {
510
511
  "update_bottom_sheet": "Atualizar bottom sheet",