kasy-cli 1.31.10 → 1.31.11
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/commands/add.js +31 -0
- package/lib/commands/new.js +0 -14
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +27 -9
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +3 -2
- package/lib/scaffold/shared/generator-utils.js +11 -0
- package/package.json +2 -2
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +21 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +26 -24
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +5 -3
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +89 -47
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
- package/templates/firebase/lib/i18n/en.i18n.json +2 -0
- package/templates/firebase/lib/i18n/es.i18n.json +2 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +2 -0
package/lib/commands/add.js
CHANGED
|
@@ -30,6 +30,33 @@ const { toPackageName, buildTokens } = require('../scaffold/backends/firebase/to
|
|
|
30
30
|
|
|
31
31
|
const execAsync = promisify(exec);
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Ensure the target project's supabase/config.toml has a [functions.<name>] block
|
|
35
|
+
* with verify_jwt = false. `kasy new` already writes the full config, but a project
|
|
36
|
+
* created before 1.31.10 (or any project missing the block) needs it appended here —
|
|
37
|
+
* otherwise the deployed function keeps the platform JWT gate ON and the browser CORS
|
|
38
|
+
* preflight (OPTIONS, no auth header) is rejected => "Failed to fetch" on web. The
|
|
39
|
+
* deploy --no-verify-jwt flag is deprecated/ignored by recent CLIs, so config.toml is
|
|
40
|
+
* the authoritative setting. The function authenticates itself, so this stays secure.
|
|
41
|
+
*/
|
|
42
|
+
async function ensureSupabaseConfigNoVerifyJwt(projectDir, fnName) {
|
|
43
|
+
const configPath = path.join(projectDir, 'supabase', 'config.toml');
|
|
44
|
+
if (!(await fs.pathExists(configPath))) return;
|
|
45
|
+
const toml = await fs.readFile(configPath, 'utf8');
|
|
46
|
+
const safe = fnName.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
47
|
+
const block = toml.match(new RegExp(`\\[functions\\.${safe}\\]([\\s\\S]*?)(?=\\n\\[|$)`));
|
|
48
|
+
if (block && /verify_jwt\s*=\s*false/.test(block[1])) return; // already correct
|
|
49
|
+
let next;
|
|
50
|
+
if (block && /verify_jwt\s*=\s*true/.test(block[0])) {
|
|
51
|
+
next = toml.replace(block[0], block[0].replace(/verify_jwt\s*=\s*true/, 'verify_jwt = false'));
|
|
52
|
+
} else if (block) {
|
|
53
|
+
next = toml.replace(block[0], `${block[0].replace(/\s*$/, '')}\nverify_jwt = false`);
|
|
54
|
+
} else {
|
|
55
|
+
next = `${toml.replace(/\s*$/, '')}\n\n[functions.${fnName}]\nverify_jwt = false\n`;
|
|
56
|
+
}
|
|
57
|
+
await fs.writeFile(configPath, next, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/** URL do endpoint AI no app (espelha defineUpdates de ai_chat). */
|
|
34
61
|
function resolveAiChatEndpoint(answers, kitSetup) {
|
|
35
62
|
if (kitSetup?.backendProvider === 'api') {
|
|
@@ -513,6 +540,10 @@ async function postAddAiChat(projectDir, kitSetup, answers, t) {
|
|
|
513
540
|
if (backend === 'firebase') {
|
|
514
541
|
await execAsync('firebase deploy --only functions:aiChat', { cwd: projectDir, timeout: 180_000 });
|
|
515
542
|
} else if (backend === 'supabase') {
|
|
543
|
+
// Authoritative JWT setting lives in config.toml; ensure it before deploying so
|
|
544
|
+
// ai-chat is reachable from the web (the --no-verify-jwt flag alone is ignored
|
|
545
|
+
// by recent Supabase CLIs).
|
|
546
|
+
await ensureSupabaseConfigNoVerifyJwt(projectDir, 'ai-chat');
|
|
516
547
|
await execAsync(`supabase functions deploy ai-chat --no-verify-jwt${refFlag}`, { cwd: projectDir, timeout: 180_000 });
|
|
517
548
|
}
|
|
518
549
|
deploySpinner.stop(t('add.ai_chat.deployed'));
|
package/lib/commands/new.js
CHANGED
|
@@ -575,20 +575,6 @@ async function promptSupabaseManual(tr, cancel) {
|
|
|
575
575
|
return { supabaseUrl, supabaseAnonKey };
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
// ── Module presets ────────────────────────────────────────────────────────────
|
|
579
|
-
|
|
580
|
-
function buildPresets(audience) {
|
|
581
|
-
const catalog = getVisibleFeatures({ audience });
|
|
582
|
-
const named = ['starter', 'saas', 'content', 'full'];
|
|
583
|
-
const result = { none: [], custom: null };
|
|
584
|
-
for (const name of named) {
|
|
585
|
-
result[name] = catalog.filter((f) => f.defaultInPresets.includes(name)).map((f) => f.id);
|
|
586
|
-
}
|
|
587
|
-
return result;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const MODULE_PRESETS = buildPresets(KASY_AUDIENCE);
|
|
591
|
-
|
|
592
578
|
// ── Main wizard ───────────────────────────────────────────────────────────────
|
|
593
579
|
|
|
594
580
|
/**
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* users — the users/subscriptions RLS exposes only a user's own row, so a
|
|
15
15
|
* non-admin can never reach other people's data.
|
|
16
16
|
*
|
|
17
|
-
* Deployed
|
|
17
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
18
|
+
* browser's CORS preflight passes; security is enforced by the two layers above (the
|
|
19
|
+
* handler verifies the caller's JWT and admin role itself). See config.toml / deploy.js.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* verified JWT, never trusted from the request body, so a client cannot check
|
|
7
7
|
* out on behalf of someone else.
|
|
8
8
|
*
|
|
9
|
-
* Deployed
|
|
9
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
10
|
+
* browser's CORS preflight passes; the handler still authenticates the user via the
|
|
11
|
+
* verified JWT (getUser), so it stays secure.
|
|
10
12
|
*
|
|
11
13
|
* Secrets required:
|
|
12
14
|
* - STRIPE_SECRET_KEY: sk_test_... / sk_live_...
|
|
@@ -32,7 +34,11 @@ function trialDaysFor(price: Stripe.Price, product: Stripe.Product): number | nu
|
|
|
32
34
|
return meta ? Number(meta) : null;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
async function
|
|
37
|
+
async function getAuthUser(
|
|
38
|
+
req: Request,
|
|
39
|
+
supabaseUrl: string,
|
|
40
|
+
anonKey: string,
|
|
41
|
+
): Promise<{ id: string; email?: string } | null> {
|
|
36
42
|
const authHeader = req.headers.get("Authorization");
|
|
37
43
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
38
44
|
const token = authHeader.replace("Bearer ", "");
|
|
@@ -41,19 +47,30 @@ async function getUid(req: Request, supabaseUrl: string, anonKey: string): Promi
|
|
|
41
47
|
});
|
|
42
48
|
const { data: { user }, error } = await client.auth.getUser(token);
|
|
43
49
|
if (error || !user) return null;
|
|
44
|
-
return user.id;
|
|
50
|
+
return { id: user.id, email: user.email ?? undefined };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// deno-lint-ignore no-explicit-any
|
|
48
|
-
async function getOrCreateCustomer(
|
|
54
|
+
async function getOrCreateCustomer(
|
|
55
|
+
stripe: Stripe,
|
|
56
|
+
admin: any,
|
|
57
|
+
uid: string,
|
|
58
|
+
email?: string,
|
|
59
|
+
): Promise<string> {
|
|
49
60
|
const { data } = await admin
|
|
50
61
|
.from(CUSTOMERS_TABLE)
|
|
51
62
|
.select("customer_id")
|
|
52
63
|
.eq("user_id", uid)
|
|
53
64
|
.maybeSingle();
|
|
54
65
|
const existing = data?.customer_id as string | undefined;
|
|
55
|
-
if (existing)
|
|
56
|
-
|
|
66
|
+
if (existing) {
|
|
67
|
+
// Keep the Stripe customer email in sync so Checkout pre-fills it. The
|
|
68
|
+
// payment is bound to the app user by uid (customer + metadata.supabaseUID),
|
|
69
|
+
// never by the typed email, so this is purely UX / billing hygiene.
|
|
70
|
+
if (email) await stripe.customers.update(existing, { email });
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const customer = await stripe.customers.create({ email, metadata: { supabaseUID: uid } });
|
|
57
74
|
await admin.from(CUSTOMERS_TABLE).upsert({ user_id: uid, customer_id: customer.id });
|
|
58
75
|
return customer.id;
|
|
59
76
|
}
|
|
@@ -74,10 +91,11 @@ Deno.serve(async (req: Request) => {
|
|
|
74
91
|
return Response.json({ error: "Server configuration error" }, { status: 500, headers: corsHeaders });
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
94
|
+
const user = await getAuthUser(req, supabaseUrl, anonKey);
|
|
95
|
+
if (!user) {
|
|
79
96
|
return Response.json({ error: "Sign in required" }, { status: 401, headers: corsHeaders });
|
|
80
97
|
}
|
|
98
|
+
const uid = user.id;
|
|
81
99
|
|
|
82
100
|
let body: { priceId?: string; successUrl?: string; cancelUrl?: string };
|
|
83
101
|
try {
|
|
@@ -95,7 +113,7 @@ Deno.serve(async (req: Request) => {
|
|
|
95
113
|
try {
|
|
96
114
|
const stripe = new Stripe(secretKey);
|
|
97
115
|
const admin = createClient(supabaseUrl, serviceRoleKey);
|
|
98
|
-
const customerId = await getOrCreateCustomer(stripe, admin, uid);
|
|
116
|
+
const customerId = await getOrCreateCustomer(stripe, admin, uid, user.email);
|
|
99
117
|
|
|
100
118
|
const price = await stripe.prices.retrieve(priceId, { expand: ["product"] });
|
|
101
119
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* authenticated user and returns its URL. The user is identified by the
|
|
6
6
|
* verified JWT and the Stripe customer is looked up server-side.
|
|
7
7
|
*
|
|
8
|
-
* Deployed
|
|
8
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
9
|
+
* browser's CORS preflight passes; the handler still authenticates the user via the
|
|
10
|
+
* verified JWT (getUser), so it stays secure.
|
|
9
11
|
*
|
|
10
12
|
* Secrets required:
|
|
11
13
|
* - STRIPE_SECRET_KEY
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* contract used by the Flutter client (mirrors the Firebase `listPrices`
|
|
6
6
|
* callable). The Stripe secret key never leaves the server.
|
|
7
7
|
*
|
|
8
|
-
* Deployed
|
|
9
|
-
*
|
|
8
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
9
|
+
* browser's CORS preflight passes. This endpoint is public by design — it only
|
|
10
|
+
* returns the active prices (no user data) — so it does NOT authenticate the caller.
|
|
10
11
|
*
|
|
11
12
|
* Secrets required (set via `supabase secrets set`):
|
|
12
13
|
* - STRIPE_SECRET_KEY: sk_test_... / sk_live_...
|
|
@@ -369,6 +369,9 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
369
369
|
if (withWidget) {
|
|
370
370
|
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_home_widgets.dart';`);
|
|
371
371
|
}
|
|
372
|
+
// Admin console — always shipped (settings/ui/components/admin) and always routable;
|
|
373
|
+
// access is gated inside AdminPage (admin role || debug). The settings UI links to it.
|
|
374
|
+
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_page.dart';`);
|
|
372
375
|
if (withRevenuecat) {
|
|
373
376
|
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_paywalls.dart';`);
|
|
374
377
|
}
|
|
@@ -534,6 +537,14 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
534
537
|
lines.push(` ),`);
|
|
535
538
|
lines.push(` ),`);
|
|
536
539
|
|
|
540
|
+
// Admin console (always routable so the settings 'Admin console' tile works in
|
|
541
|
+
// release too; access is gated inside AdminPage by admin role || debug).
|
|
542
|
+
lines.push(` GoRoute(`);
|
|
543
|
+
lines.push(` name: 'admin',`);
|
|
544
|
+
lines.push(` path: '/admin',`);
|
|
545
|
+
lines.push(` builder: (context, state) => const AdminPage(),`);
|
|
546
|
+
lines.push(` ),`);
|
|
547
|
+
|
|
537
548
|
// Send push notification (admin tool — always routable; the entry point in
|
|
538
549
|
// the settings admin sheet is itself debug-gated).
|
|
539
550
|
lines.push(` GoRoute(`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasy-cli",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.11",
|
|
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",
|
|
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",
|
|
35
35
|
"start": "node ./bin/kasy.js",
|
|
36
36
|
"setup": "node ./bin/kasy.js setup",
|
|
37
37
|
"doctor": "node ./bin/kasy.js doctor",
|
|
@@ -21,13 +21,27 @@ function stripeClient(): Stripe {
|
|
|
21
21
|
return new Stripe(stripeSecretKey.value());
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
async function getOrCreateCustomer(
|
|
24
|
+
async function getOrCreateCustomer(
|
|
25
|
+
stripe: Stripe,
|
|
26
|
+
uid: string,
|
|
27
|
+
email?: string,
|
|
28
|
+
): Promise<string> {
|
|
25
29
|
const db = admin.firestore();
|
|
26
30
|
const ref = db.collection(CUSTOMERS_COLLECTION).doc(uid);
|
|
27
31
|
const snap = await ref.get();
|
|
28
32
|
const existing = snap.data()?.customerId as string | undefined;
|
|
29
|
-
if (existing)
|
|
30
|
-
|
|
33
|
+
if (existing) {
|
|
34
|
+
// Keep the Stripe customer email in sync so Checkout pre-fills it and the
|
|
35
|
+
// receipts carry the right address. The payment is bound to the app user by
|
|
36
|
+
// UID (customer + metadata.firebaseUID), never by the typed email, so this
|
|
37
|
+
// is purely UX / billing hygiene.
|
|
38
|
+
if (email) await stripe.customers.update(existing, {email});
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
const customer = await stripe.customers.create({
|
|
42
|
+
email,
|
|
43
|
+
metadata: {firebaseUID: uid},
|
|
44
|
+
});
|
|
31
45
|
await ref.set({customerId: customer.id, created_at: Timestamp.now()});
|
|
32
46
|
return customer.id;
|
|
33
47
|
}
|
|
@@ -86,9 +100,12 @@ export const createCheckoutSession = onCall(
|
|
|
86
100
|
if (!priceId) throw new HttpsError("invalid-argument", "priceId is required");
|
|
87
101
|
const successUrl = (request.data?.successUrl as string | undefined) ?? "";
|
|
88
102
|
const cancelUrl = (request.data?.cancelUrl as string | undefined) ?? successUrl;
|
|
103
|
+
// Pre-fill Checkout with the signed-in user's email (UX only; the user is
|
|
104
|
+
// identified by uid, so paying with a different email still updates them).
|
|
105
|
+
const email = request.auth?.token?.email as string | undefined;
|
|
89
106
|
|
|
90
107
|
const stripe = stripeClient();
|
|
91
|
-
const customerId = await getOrCreateCustomer(stripe, uid);
|
|
108
|
+
const customerId = await getOrCreateCustomer(stripe, uid, email);
|
|
92
109
|
|
|
93
110
|
const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
|
|
94
111
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
@@ -194,40 +194,41 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
194
194
|
class KasyTopScrollFade extends StatelessWidget {
|
|
195
195
|
const KasyTopScrollFade({super.key});
|
|
196
196
|
|
|
197
|
-
///
|
|
198
|
-
|
|
199
|
-
/// where there is no status-bar inset.
|
|
200
|
-
static const double _solidBand = 20;
|
|
201
|
-
|
|
202
|
-
/// Smooth fade below the solid band before the wash is fully transparent.
|
|
203
|
-
static const double _fadeTail = 18;
|
|
197
|
+
/// Fade zone below the status-bar strip (mobile only).
|
|
198
|
+
static const double _contentFade = 40.0;
|
|
204
199
|
|
|
205
200
|
@override
|
|
206
201
|
Widget build(BuildContext context) {
|
|
207
202
|
final double topInset = MediaQuery.paddingOf(context).top;
|
|
208
|
-
|
|
209
|
-
|
|
203
|
+
// On web topInset = 0; enforce a 6 px minimum so the topmost pixels are
|
|
204
|
+
// 100 % opaque even without a status bar.
|
|
205
|
+
final double solidHeight = topInset < 6 ? 6.0 : topInset;
|
|
206
|
+
final double totalHeight = solidHeight + _contentFade;
|
|
207
|
+
final double ss = solidHeight / totalHeight; // solidStop
|
|
210
208
|
final Color bg = context.colors.background;
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
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;
|
|
214
|
+
|
|
216
215
|
return IgnorePointer(
|
|
217
216
|
child: SizedBox(
|
|
218
|
-
height:
|
|
217
|
+
height: totalHeight,
|
|
219
218
|
child: DecoratedBox(
|
|
220
219
|
decoration: BoxDecoration(
|
|
221
220
|
gradient: LinearGradient(
|
|
222
221
|
begin: Alignment.topCenter,
|
|
223
222
|
end: Alignment.bottomCenter,
|
|
224
223
|
colors: <Color>[
|
|
225
|
-
bg,
|
|
226
|
-
bg,
|
|
227
|
-
bg.withValues(alpha: 0.
|
|
228
|
-
bg.withValues(alpha: 0),
|
|
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),
|
|
229
230
|
],
|
|
230
|
-
stops: <double>[0.0,
|
|
231
|
+
stops: <double>[0.0, ss, p(0.10), p(0.26), p(0.44), 1.0],
|
|
231
232
|
),
|
|
232
233
|
),
|
|
233
234
|
),
|
|
@@ -399,12 +400,13 @@ class KasyAppBar extends StatelessWidget {
|
|
|
399
400
|
}
|
|
400
401
|
|
|
401
402
|
// A top wash sits BEHIND the bar (and stays put even when the bar hides) so
|
|
402
|
-
// content melts into the background at the top —
|
|
403
|
-
//
|
|
404
|
-
|
|
403
|
+
// content melts into the background at the top — mobile only (tablet/desktop
|
|
404
|
+
// use the sidebar/web header instead). Skipped for modal / page-sheet bars.
|
|
405
|
+
final bool isMobile =
|
|
406
|
+
MediaQuery.sizeOf(context).width < DeviceType.medium.breakpoint;
|
|
405
407
|
final bool standardBar =
|
|
406
408
|
useSafeArea && topInset == null && toolbarHeight == null;
|
|
407
|
-
if (!standardBar) return animatedChrome;
|
|
409
|
+
if (!standardBar || !isMobile) return animatedChrome;
|
|
408
410
|
return Stack(
|
|
409
411
|
clipBehavior: Clip.none,
|
|
410
412
|
children: <Widget>[
|
|
@@ -40,6 +40,10 @@ class StripePaymentApi implements SubscriptionPaymentApi {
|
|
|
40
40
|
cancelUrl: returnUrl,
|
|
41
41
|
);
|
|
42
42
|
await _open(url);
|
|
43
|
+
// Checkout is now open in a new tab. Payment is NOT confirmed yet — the
|
|
44
|
+
// webhook will write the subscription record when the user pays. Throw so
|
|
45
|
+
// the provider knows to poll for activation instead of setting active now.
|
|
46
|
+
throw PendingWebCheckoutException();
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
@override
|
|
@@ -75,8 +79,6 @@ class StripePaymentApi implements SubscriptionPaymentApi {
|
|
|
75
79
|
Future<void> presentCodeRedemptionSheet() async {}
|
|
76
80
|
|
|
77
81
|
Future<void> _open(String url) async {
|
|
78
|
-
|
|
79
|
-
// Stripe Checkout / the Customer Portal.
|
|
80
|
-
await launchUrl(Uri.parse(url), webOnlyWindowName: '_self');
|
|
82
|
+
await launchUrl(Uri.parse(url), webOnlyWindowName: '_blank');
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -51,3 +51,11 @@ abstract interface class SubscriptionPaymentApi {
|
|
|
51
51
|
class UserCancelledPurchaseException implements Exception {
|
|
52
52
|
UserCancelledPurchaseException();
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/// Thrown by [StripePaymentApi.purchaseProduct] on web after the hosted
|
|
56
|
+
/// Checkout URL is opened in a new browser tab. The purchase is NOT complete —
|
|
57
|
+
/// the caller must poll for subscription activation (via webhook) instead of
|
|
58
|
+
/// treating the returned Future as payment confirmation.
|
|
59
|
+
class PendingWebCheckoutException implements Exception {
|
|
60
|
+
PendingWebCheckoutException();
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'package:flutter/foundation.dart';
|
|
2
2
|
import 'package:flutter/services.dart' show PlatformException;
|
|
3
3
|
import 'package:kasy_kit/core/data/api/analytics_api.dart';
|
|
4
|
+
import 'package:kasy_kit/core/data/models/entitlement.dart';
|
|
4
5
|
import 'package:kasy_kit/core/data/models/subscription.dart';
|
|
5
6
|
import 'package:kasy_kit/core/states/models/user_state.dart';
|
|
6
7
|
import 'package:kasy_kit/core/states/translations.dart';
|
|
@@ -8,7 +9,7 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
|
8
9
|
import 'package:kasy_kit/core/toast/toast_service.dart';
|
|
9
10
|
import 'package:kasy_kit/features/feedbacks/repositories/feature_request_repository.dart';
|
|
10
11
|
import 'package:kasy_kit/features/subscriptions/api/subscription_payment_api.dart'
|
|
11
|
-
show UserCancelledPurchaseException;
|
|
12
|
+
show PendingWebCheckoutException, UserCancelledPurchaseException;
|
|
12
13
|
import 'package:kasy_kit/features/subscriptions/providers/models/premium_state.dart';
|
|
13
14
|
import 'package:kasy_kit/features/subscriptions/repositories/subscription_repository.dart';
|
|
14
15
|
import 'package:kasy_kit/router.dart';
|
|
@@ -123,61 +124,102 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
|
|
|
123
124
|
String? paywall,
|
|
124
125
|
String? redirectRoute,
|
|
125
126
|
}) async {
|
|
126
|
-
|
|
127
|
-
PremiumStateData(:final offers) =>
|
|
128
|
-
PremiumState.sending(
|
|
129
|
-
isPremium: false,
|
|
130
|
-
offers: offers,
|
|
131
|
-
selectedOffer: offer,
|
|
132
|
-
),
|
|
133
|
-
),
|
|
127
|
+
final currentOffers = switch (state.value) {
|
|
128
|
+
PremiumStateData(:final offers) => offers,
|
|
134
129
|
_ => throw "cannot purchase while active",
|
|
135
130
|
};
|
|
131
|
+
state = AsyncData(
|
|
132
|
+
PremiumState.sending(isPremium: false, offers: currentOffers, selectedOffer: offer),
|
|
133
|
+
);
|
|
136
134
|
try {
|
|
137
135
|
final entitlements = await _subscriptionRepository.purchase(offer);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"duration": offer.duration,
|
|
144
|
-
"paywall": paywall,
|
|
145
|
-
});
|
|
146
|
-
// let's refresh the user state
|
|
147
|
-
await ref
|
|
148
|
-
.read(userStateNotifierProvider.notifier)
|
|
149
|
-
.refreshSubscription(product: offer, entitlements: entitlements);
|
|
150
|
-
final t = ref.read(translationsProvider);
|
|
151
|
-
ref
|
|
152
|
-
.read(toastProvider)
|
|
153
|
-
.success(
|
|
154
|
-
title: t.premium.purchase_success_title,
|
|
155
|
-
text: t.premium.purchase_success_text,
|
|
156
|
-
);
|
|
157
|
-
await Future.delayed(const Duration(seconds: 2));
|
|
158
|
-
if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
|
|
136
|
+
await _activateAfterPurchase(offer, entitlements, paywall, redirectRoute);
|
|
137
|
+
} on PendingWebCheckoutException {
|
|
138
|
+
// Stripe web: checkout opened in a new tab. Poll until the webhook
|
|
139
|
+
// activates the subscription or we time out.
|
|
140
|
+
await _waitForWebPayment(offer, currentOffers, paywall: paywall, redirectRoute: redirectRoute);
|
|
159
141
|
} catch (err, stackTrace) {
|
|
160
|
-
state =
|
|
161
|
-
PremiumStateData(:final offers) => AsyncData(
|
|
162
|
-
PremiumState(offers: offers, selectedOffer: offer),
|
|
163
|
-
),
|
|
164
|
-
PremiumStateSending(:final offers) => AsyncData(
|
|
165
|
-
PremiumState(offers: offers, selectedOffer: offer),
|
|
166
|
-
),
|
|
167
|
-
PremiumStateActive() => state,
|
|
168
|
-
_ => throw "cannot purchase while active",
|
|
169
|
-
};
|
|
142
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
170
143
|
if (err is UserCancelledPurchaseException) return;
|
|
171
144
|
await Sentry.captureException(err, stackTrace: stackTrace);
|
|
172
145
|
_logger.e("...PremiumStateNotifier: purchase failed $err : $stackTrace");
|
|
173
146
|
final t = ref.read(translationsProvider);
|
|
174
|
-
ref
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
147
|
+
ref.read(toastProvider).error(
|
|
148
|
+
title: t.premium.error_title,
|
|
149
|
+
text: t.premium.error_text,
|
|
150
|
+
reason: "We were unable to process your subscription",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
Future<void> _activateAfterPurchase(
|
|
156
|
+
SubscriptionProduct offer,
|
|
157
|
+
List<Entitlement>? entitlements,
|
|
158
|
+
String? paywall,
|
|
159
|
+
String? redirectRoute,
|
|
160
|
+
) async {
|
|
161
|
+
state = AsyncData(PremiumState.active(activeOffer: offer));
|
|
162
|
+
await _analyticsApi?.logEvent("purchase", {
|
|
163
|
+
"skuId": offer.skuId,
|
|
164
|
+
"price": offer.price,
|
|
165
|
+
"duration": offer.duration,
|
|
166
|
+
"paywall": paywall,
|
|
167
|
+
});
|
|
168
|
+
await ref
|
|
169
|
+
.read(userStateNotifierProvider.notifier)
|
|
170
|
+
.refreshSubscription(product: offer, entitlements: entitlements);
|
|
171
|
+
final t = ref.read(translationsProvider);
|
|
172
|
+
ref.read(toastProvider).success(
|
|
173
|
+
title: t.premium.purchase_success_title,
|
|
174
|
+
text: t.premium.purchase_success_text,
|
|
175
|
+
);
|
|
176
|
+
await Future.delayed(const Duration(seconds: 2));
|
|
177
|
+
if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Polls the subscription backend every 5 s until the Stripe webhook delivers
|
|
181
|
+
/// the activation (up to 10 min). Runs while the state is [PremiumStateSending].
|
|
182
|
+
Future<void> _waitForWebPayment(
|
|
183
|
+
SubscriptionProduct offer,
|
|
184
|
+
List<SubscriptionProduct> currentOffers, {
|
|
185
|
+
String? paywall,
|
|
186
|
+
String? redirectRoute,
|
|
187
|
+
}) async {
|
|
188
|
+
const maxAttempts = 120; // 10 min at 5 s intervals
|
|
189
|
+
const interval = Duration(seconds: 5);
|
|
190
|
+
final userId = _userState.user.idOrNull;
|
|
191
|
+
if (userId == null) {
|
|
192
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
for (var i = 0; i < maxAttempts; i++) {
|
|
198
|
+
await Future.delayed(interval);
|
|
199
|
+
if (state.value is! PremiumStateSending) return;
|
|
200
|
+
try {
|
|
201
|
+
final sub = await _subscriptionRepository.get(userId);
|
|
202
|
+
if (sub.isActive) {
|
|
203
|
+
await _activateAfterPurchase(offer, null, paywall, redirectRoute);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
} catch (_) {
|
|
207
|
+
// network / backend error → keep polling; disposal propagates to
|
|
208
|
+
// the outer catch on the next state.value access.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Timed out — revert to paywall and ask the user to restore if they paid.
|
|
213
|
+
if (state.value is PremiumStateSending) {
|
|
214
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
215
|
+
final t = ref.read(translationsProvider);
|
|
216
|
+
ref.read(toastProvider).error(
|
|
217
|
+
title: t.premium.web_checkout_timeout_title,
|
|
218
|
+
text: t.premium.web_checkout_timeout_text,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} catch (_) {
|
|
222
|
+
// Provider was disposed while polling (user left the paywall). Ignore.
|
|
181
223
|
}
|
|
182
224
|
}
|
|
183
225
|
|
|
@@ -76,26 +76,19 @@ class BottomPremiumMenu extends StatelessWidget {
|
|
|
76
76
|
Widget _buildRestoreButton(BuildContext context) {
|
|
77
77
|
final translations = Translations.of(context).premium;
|
|
78
78
|
final color = textColor ?? context.colors.muted;
|
|
79
|
+
// While a purchase / restore is in flight, onTapRestore is null. We keep the
|
|
80
|
+
// label visible but disabled instead of swapping it for a spinner, so the
|
|
81
|
+
// only loading indicator on the paywall is the main CTA button.
|
|
79
82
|
return Padding(
|
|
80
83
|
padding: const EdgeInsets.symmetric(horizontal: KasySpacing.sm),
|
|
81
|
-
child:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
: KasyButton.iconOnly(
|
|
91
|
-
icon: KasyIcons.refresh,
|
|
92
|
-
variant: KasyButtonVariant.ghost,
|
|
93
|
-
foregroundColor: color,
|
|
94
|
-
isLoading: true,
|
|
95
|
-
onPressed: null,
|
|
96
|
-
semanticLabel: translations.restore_action,
|
|
97
|
-
size: KasyButtonSize.small,
|
|
98
|
-
),
|
|
84
|
+
child: KasyButton(
|
|
85
|
+
variant: KasyButtonVariant.ghost,
|
|
86
|
+
label: translations.restore_action,
|
|
87
|
+
size: KasyButtonSize.small,
|
|
88
|
+
foregroundColor: color,
|
|
89
|
+
fontWeight: FontWeight.w500,
|
|
90
|
+
onPressed: onTapRestore,
|
|
91
|
+
),
|
|
99
92
|
);
|
|
100
93
|
}
|
|
101
94
|
}
|
|
@@ -206,6 +206,8 @@
|
|
|
206
206
|
"purchase_success_text": "Thank you for your trust",
|
|
207
207
|
"error_title": "Error",
|
|
208
208
|
"error_text": "An error occurred. Please try again",
|
|
209
|
+
"web_checkout_timeout_title": "Payment not confirmed",
|
|
210
|
+
"web_checkout_timeout_text": "We did not receive payment confirmation. If you already paid, tap Restore.",
|
|
209
211
|
"comparison": {
|
|
210
212
|
"title": "Premium plan comparison",
|
|
211
213
|
"features_label": "Features",
|
|
@@ -206,6 +206,8 @@
|
|
|
206
206
|
"purchase_success_text": "Gracias por tu confianza",
|
|
207
207
|
"error_title": "Error",
|
|
208
208
|
"error_text": "Ocurrió un error. Inténtalo de nuevo",
|
|
209
|
+
"web_checkout_timeout_title": "Pago no confirmado",
|
|
210
|
+
"web_checkout_timeout_text": "No recibimos confirmacion del pago. Si ya pagaste, toca Restaurar.",
|
|
209
211
|
"comparison": {
|
|
210
212
|
"title": "Comparación de planes Premium",
|
|
211
213
|
"features_label": "Características",
|
|
@@ -206,6 +206,8 @@
|
|
|
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
211
|
"comparison": {
|
|
210
212
|
"title": "Comparação de planos Premium",
|
|
211
213
|
"features_label": "Recursos",
|