kasy-cli 1.31.11 → 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.
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/ai-chat/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/delete-user-account/index.ts +24 -1
- package/lib/scaffold/backends/supabase/edge-functions/meta-track-event/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/revenuecat-webhook/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +9 -2
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/package.json +2 -2
- package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +12 -0
- package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +10 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +56 -1
- package/templates/firebase/functions/src/subscriptions/triggers.ts +35 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +16 -43
- package/templates/firebase/lib/components/kasy_dialog.dart +48 -30
- package/templates/firebase/lib/components/kasy_toast.dart +6 -4
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +33 -14
- package/templates/firebase/lib/features/onboarding/repositories/user_infos_repository.dart +9 -2
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -1
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +13 -3
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +51 -22
- package/templates/firebase/lib/i18n/en.i18n.json +5 -2
- package/templates/firebase/lib/i18n/es.i18n.json +6 -3
- package/templates/firebase/lib/i18n/pt.i18n.json +7 -4
- 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": {
|
package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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;
|
|
@@ -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": "
|
|
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": "
|
|
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": "
|
|
22
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
Deno.serve(async (req: Request) => {
|
|
@@ -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
|
|
|
@@ -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": "
|
|
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": "
|
|
264
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
265
265
|
},
|
|
266
266
|
});
|
|
267
267
|
}
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -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": "
|
|
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.
|
|
@@ -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"] });
|
|
@@ -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": "
|
|
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": "
|
|
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/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -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
|
|
package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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.
|
|
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/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",
|
|
@@ -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
|
|
|
@@ -1,11 +1,12 @@
|
|
|
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";
|
|
6
7
|
import Stripe from "stripe";
|
|
7
8
|
import {Subscription} from "./models/subscriptions";
|
|
8
|
-
import {subscriptionsRepository} from "../core/data/repositories/repositories";
|
|
9
|
+
import {subscriptionsRepository, usersRepository} from "../core/data/repositories/repositories";
|
|
9
10
|
import {Stores, SubscriptionStatus} from "./models/subscription_status";
|
|
10
11
|
|
|
11
12
|
// Server-side only. Never exposed to the client.
|
|
@@ -103,6 +104,20 @@ export const createCheckoutSession = onCall(
|
|
|
103
104
|
// Pre-fill Checkout with the signed-in user's email (UX only; the user is
|
|
104
105
|
// identified by uid, so paying with a different email still updates them).
|
|
105
106
|
const email = request.auth?.token?.email as string | undefined;
|
|
107
|
+
// Persist the app language on the user so server-side notifications (e.g.
|
|
108
|
+
// the "subscription saved" message) are sent in the right language. On web
|
|
109
|
+
// there is no registered device to read the locale from, so we capture it
|
|
110
|
+
// here at purchase time.
|
|
111
|
+
const locale = (request.data?.locale as string | undefined)
|
|
112
|
+
?.substring(0, 2)
|
|
113
|
+
.toLowerCase();
|
|
114
|
+
if (locale) {
|
|
115
|
+
await admin
|
|
116
|
+
.firestore()
|
|
117
|
+
.collection("users")
|
|
118
|
+
.doc(uid)
|
|
119
|
+
.set({locale}, {merge: true});
|
|
120
|
+
}
|
|
106
121
|
|
|
107
122
|
const stripe = stripeClient();
|
|
108
123
|
const customerId = await getOrCreateCustomer(stripe, uid, email);
|
|
@@ -176,6 +191,9 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
176
191
|
}
|
|
177
192
|
const now = Timestamp.now();
|
|
178
193
|
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);
|
|
179
197
|
// In Stripe API v18 the billing period lives on each subscription item.
|
|
180
198
|
const item = sub.items.data[0];
|
|
181
199
|
const priceId = item?.price?.id ?? "";
|
|
@@ -193,6 +211,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
193
211
|
expirationDate: expiration,
|
|
194
212
|
store: Stores.STRIPE,
|
|
195
213
|
productId: priceId,
|
|
214
|
+
email: user?.email,
|
|
196
215
|
},
|
|
197
216
|
subscriptionsRepository,
|
|
198
217
|
);
|
|
@@ -237,3 +256,39 @@ export const stripeWebhook = onRequest(
|
|
|
237
256
|
}
|
|
238
257
|
},
|
|
239
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
|
+
);
|
|
@@ -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
|
|
27
|
-
body
|
|
57
|
+
title,
|
|
58
|
+
body,
|
|
28
59
|
},
|
|
29
60
|
);
|
|
30
61
|
} catch (error) {
|
|
@@ -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,26 +109,19 @@ Color kasyChromeOrbFillColor(BuildContext context) {
|
|
|
111
109
|
class KasyFrostedChromeBackground extends StatelessWidget {
|
|
112
110
|
final Widget child;
|
|
113
111
|
|
|
114
|
-
///
|
|
115
|
-
|
|
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
|
|
|
128
|
-
///
|
|
129
|
-
/// Derived from the global `surface` token so the bar lifts off the canvas
|
|
130
|
-
/// and follows light/dark automatically.
|
|
122
|
+
/// Solid bar fill from the global `surface` token; follows light/dark.
|
|
131
123
|
Color _tint(BuildContext context) {
|
|
132
|
-
|
|
133
|
-
return context.colors.surface.withValues(alpha: dark ? 0.88 : 0.82);
|
|
124
|
+
return context.colors.surface;
|
|
134
125
|
}
|
|
135
126
|
|
|
136
127
|
@override
|
|
@@ -168,16 +159,8 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
168
159
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
169
160
|
value: overlayStyle,
|
|
170
161
|
child: DecoratedBox(
|
|
171
|
-
decoration: BoxDecoration(boxShadow: chromeShadow),
|
|
172
|
-
child:
|
|
173
|
-
child: BackdropFilter(
|
|
174
|
-
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
|
175
|
-
child: DecoratedBox(
|
|
176
|
-
decoration: BoxDecoration(color: tint),
|
|
177
|
-
child: content,
|
|
178
|
-
),
|
|
179
|
-
),
|
|
180
|
-
),
|
|
162
|
+
decoration: BoxDecoration(color: tint, boxShadow: chromeShadow),
|
|
163
|
+
child: content,
|
|
181
164
|
),
|
|
182
165
|
);
|
|
183
166
|
}
|
|
@@ -194,24 +177,19 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
194
177
|
class KasyTopScrollFade extends StatelessWidget {
|
|
195
178
|
const KasyTopScrollFade({super.key});
|
|
196
179
|
|
|
197
|
-
///
|
|
180
|
+
/// Total wash height: status-bar strip + a fade zone below it.
|
|
198
181
|
static const double _contentFade = 40.0;
|
|
199
182
|
|
|
200
183
|
@override
|
|
201
184
|
Widget build(BuildContext context) {
|
|
202
185
|
final double topInset = MediaQuery.paddingOf(context).top;
|
|
203
|
-
// On web topInset = 0; enforce a 6 px minimum so the
|
|
204
|
-
// 100 % opaque even without a status bar.
|
|
186
|
+
// On web topInset = 0; enforce a 6 px minimum so the strip still exists.
|
|
205
187
|
final double solidHeight = topInset < 6 ? 6.0 : topInset;
|
|
206
188
|
final double totalHeight = solidHeight + _contentFade;
|
|
207
|
-
final
|
|
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;
|
|
189
|
+
final Color base = context.colors.surface;
|
|
214
190
|
|
|
191
|
+
// Same gradient the grid cards use for their caption scrim — strong at the
|
|
192
|
+
// top, melting gradually to fully transparent at the bottom.
|
|
215
193
|
return IgnorePointer(
|
|
216
194
|
child: SizedBox(
|
|
217
195
|
height: totalHeight,
|
|
@@ -221,14 +199,12 @@ class KasyTopScrollFade extends StatelessWidget {
|
|
|
221
199
|
begin: Alignment.topCenter,
|
|
222
200
|
end: Alignment.bottomCenter,
|
|
223
201
|
colors: <Color>[
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
bg.withValues(alpha: 0.01),
|
|
229
|
-
bg.withValues(alpha: 0.0),
|
|
202
|
+
base.withValues(alpha: 0.96),
|
|
203
|
+
base.withValues(alpha: 0.96),
|
|
204
|
+
base.withValues(alpha: 0.68),
|
|
205
|
+
base.withValues(alpha: 0.0),
|
|
230
206
|
],
|
|
231
|
-
stops: <double>[0.0,
|
|
207
|
+
stops: const <double>[0.0, 0.20, 0.55, 1.0],
|
|
232
208
|
),
|
|
233
209
|
),
|
|
234
210
|
),
|
|
@@ -263,7 +239,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
263
239
|
/// same tap target semantics as the built-in orbs.
|
|
264
240
|
final Widget? trailing;
|
|
265
241
|
final bool useSafeArea;
|
|
266
|
-
final double frostBlurSigma;
|
|
267
242
|
|
|
268
243
|
/// When set, called instead of [ThemeProvider.toggle] for the theme orb
|
|
269
244
|
/// ([KasyAppBarStyle.subpage], [KasyAppBarStyle.rootTab]).
|
|
@@ -292,7 +267,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
292
267
|
this.onBack,
|
|
293
268
|
this.trailing,
|
|
294
269
|
this.useSafeArea = true,
|
|
295
|
-
this.frostBlurSigma = 14,
|
|
296
270
|
this.onThemeToggle,
|
|
297
271
|
this.toolbarHeight,
|
|
298
272
|
this.topInset,
|
|
@@ -376,7 +350,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
376
350
|
)
|
|
377
351
|
: bar;
|
|
378
352
|
final Widget chrome = KasyFrostedChromeBackground(
|
|
379
|
-
blurSigma: frostBlurSigma,
|
|
380
353
|
padForStatusBar: useSafeArea,
|
|
381
354
|
child: barContent,
|
|
382
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)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
547
|
-
final
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
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
|
-
///
|
|
566
|
-
///
|
|
567
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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:
|
|
40
|
-
cancelUrl:
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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",
|
|
@@ -500,9 +502,10 @@
|
|
|
500
502
|
"delete_account": {
|
|
501
503
|
"button": "I want to delete my account",
|
|
502
504
|
"title": "Delete your account?",
|
|
503
|
-
"content": "Warning: this
|
|
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.",
|
|
504
506
|
"cancel": "Cancel",
|
|
505
|
-
"confirm": "Yes, delete"
|
|
507
|
+
"confirm": "Yes, delete",
|
|
508
|
+
"error": "Something went wrong. Please try again."
|
|
506
509
|
},
|
|
507
510
|
"admin": {
|
|
508
511
|
"update_bottom_sheet": "Update bottom sheet",
|
|
@@ -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
|
|
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",
|
|
@@ -500,9 +502,10 @@
|
|
|
500
502
|
"delete_account": {
|
|
501
503
|
"button": "Quiero eliminar mi cuenta",
|
|
502
504
|
"title": "¿Quieres eliminar tu cuenta?",
|
|
503
|
-
"content": "Advertencia: esta acción es
|
|
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á.",
|
|
504
506
|
"cancel": "Cancelar",
|
|
505
|
-
"confirm": "Sí, eliminar"
|
|
507
|
+
"confirm": "Sí, eliminar",
|
|
508
|
+
"error": "Algo salió mal. Por favor, inténtalo de nuevo."
|
|
506
509
|
},
|
|
507
510
|
"admin": {
|
|
508
511
|
"update_bottom_sheet": "Actualizar bottom sheet",
|
|
@@ -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
|
|
210
|
-
"web_checkout_timeout_text": "
|
|
209
|
+
"web_checkout_timeout_title": "Pagamento não confirmado",
|
|
210
|
+
"web_checkout_timeout_text": "Não recebemos a confirmação do pagamento. Se você já 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",
|
|
@@ -500,9 +502,10 @@
|
|
|
500
502
|
"delete_account": {
|
|
501
503
|
"button": "Quero excluir minha conta",
|
|
502
504
|
"title": "Quer excluir sua conta?",
|
|
503
|
-
"content": "Atenção: esta ação é
|
|
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.",
|
|
504
506
|
"cancel": "Cancelar",
|
|
505
|
-
"confirm": "Sim, excluir"
|
|
507
|
+
"confirm": "Sim, excluir",
|
|
508
|
+
"error": "Algo deu errado. Por favor, tente novamente."
|
|
506
509
|
},
|
|
507
510
|
"admin": {
|
|
508
511
|
"update_bottom_sheet": "Atualizar bottom sheet",
|
|
@@ -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>
|