kasy-cli 1.31.10 → 1.31.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/add.js +31 -0
- package/lib/commands/new.js +0 -14
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +4 -2
- 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 +1 -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 +28 -10
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +4 -2
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +4 -3
- package/lib/scaffold/shared/generator-utils.js +11 -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 +40 -5
- package/templates/firebase/functions/src/subscriptions/triggers.ts +35 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +22 -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/subscriptions/api/stripe_backend_api.dart +2 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +18 -6
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +140 -69
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
- package/templates/firebase/lib/i18n/en.i18n.json +4 -0
- package/templates/firebase/lib/i18n/es.i18n.json +4 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -0
- package/templates/firebase/web/stripe_success.html +138 -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
|
/**
|
|
@@ -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": {
|
|
@@ -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";
|
|
@@ -22,7 +24,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
|
|
|
22
24
|
const corsHeaders = {
|
|
23
25
|
"Access-Control-Allow-Origin": "*",
|
|
24
26
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
25
|
-
"Access-Control-Allow-Headers": "
|
|
27
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
26
28
|
};
|
|
27
29
|
|
|
28
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) => {
|
|
@@ -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
|
@@ -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_...
|
|
@@ -21,7 +23,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
|
|
|
21
23
|
const corsHeaders = {
|
|
22
24
|
"Access-Control-Allow-Origin": "*",
|
|
23
25
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
24
|
-
"Access-Control-Allow-Headers": "
|
|
26
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
// Maps a Supabase auth user -> its Stripe customer id.
|
|
@@ -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
|
|
@@ -20,7 +22,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
|
|
|
20
22
|
const corsHeaders = {
|
|
21
23
|
"Access-Control-Allow-Origin": "*",
|
|
22
24
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
23
|
-
"Access-Control-Allow-Headers": "
|
|
25
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
const CUSTOMERS_TABLE = "stripe_customers";
|
|
@@ -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_...
|
|
@@ -18,7 +19,7 @@ import Stripe from "npm:stripe@18";
|
|
|
18
19
|
const corsHeaders = {
|
|
19
20
|
"Access-Control-Allow-Origin": "*",
|
|
20
21
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
21
|
-
"Access-Control-Allow-Headers": "
|
|
22
|
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
function trialDaysFor(price: Stripe.Price, product: Stripe.Product): number | null {
|
|
@@ -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.12",
|
|
4
4
|
"description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kasy": "./bin/kasy.js"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js",
|
|
34
|
+
"prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/backend-pubspec-local-reminders.test.js",
|
|
35
35
|
"start": "node ./bin/kasy.js",
|
|
36
36
|
"setup": "node ./bin/kasy.js setup",
|
|
37
37
|
"doctor": "node ./bin/kasy.js doctor",
|
|
@@ -5,6 +5,11 @@ import {Subscription} from "../../../subscriptions/models/subscriptions";
|
|
|
5
5
|
|
|
6
6
|
export interface SubscriptionEntityData {
|
|
7
7
|
id?: string,
|
|
8
|
+
// Denormalized reference to the subscriber, written as explicit fields on the
|
|
9
|
+
// Firestore doc (besides being the doc id) so a subscribers list / admin view
|
|
10
|
+
// can read who owns it and their email without a second lookup.
|
|
11
|
+
user_id?: string;
|
|
12
|
+
email?: string;
|
|
8
13
|
creation_date: Timestamp;
|
|
9
14
|
last_activity: Timestamp;
|
|
10
15
|
expiration_date?: Timestamp;
|
|
@@ -20,6 +25,8 @@ export interface SubscriptionEntity extends SubscriptionEntityData {}
|
|
|
20
25
|
export class SubscriptionEntity {
|
|
21
26
|
constructor({
|
|
22
27
|
id,
|
|
28
|
+
user_id,
|
|
29
|
+
email,
|
|
23
30
|
creation_date,
|
|
24
31
|
last_activity,
|
|
25
32
|
expiration_date,
|
|
@@ -29,6 +36,8 @@ export class SubscriptionEntity {
|
|
|
29
36
|
}: SubscriptionEntityData
|
|
30
37
|
) {
|
|
31
38
|
this.id = id;
|
|
39
|
+
this.user_id = user_id;
|
|
40
|
+
this.email = email;
|
|
32
41
|
this.creation_date = creation_date;
|
|
33
42
|
this.last_activity = last_activity;
|
|
34
43
|
this.expiration_date = expiration_date;
|
|
@@ -52,6 +61,8 @@ export class SubscriptionEntity {
|
|
|
52
61
|
static from(subscription: Subscription): SubscriptionEntity {
|
|
53
62
|
return new SubscriptionEntity({
|
|
54
63
|
id: subscription.userId,
|
|
64
|
+
user_id: subscription.userId,
|
|
65
|
+
email: subscription.email,
|
|
55
66
|
creation_date: subscription.creationDate,
|
|
56
67
|
last_activity: subscription.lastUpdate,
|
|
57
68
|
expiration_date: subscription.expirationDate,
|
|
@@ -70,6 +81,7 @@ export class SubscriptionEntity {
|
|
|
70
81
|
status: this.status,
|
|
71
82
|
store: this.store,
|
|
72
83
|
productId: this.product_id,
|
|
84
|
+
email: this.email,
|
|
73
85
|
}, subscriptionRepository);
|
|
74
86
|
}
|
|
75
87
|
}
|
|
@@ -24,6 +24,11 @@ export interface SubscriptionData {
|
|
|
24
24
|
expirationDate?: Timestamp;
|
|
25
25
|
store: Stores;
|
|
26
26
|
productId: string;
|
|
27
|
+
// Denormalized copy of the subscriber's email. Firestore is a non-relational
|
|
28
|
+
// store, so we duplicate it onto the subscription doc to list/show subscribers
|
|
29
|
+
// without a second read. (On the relational backends the user is referenced by
|
|
30
|
+
// a user_id foreign key and the email is joined from the users table instead.)
|
|
31
|
+
email?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export interface Subscription extends SubscriptionData {}
|
|
@@ -38,6 +43,7 @@ export class Subscription {
|
|
|
38
43
|
expirationDate,
|
|
39
44
|
store,
|
|
40
45
|
productId,
|
|
46
|
+
email,
|
|
41
47
|
}: SubscriptionData,
|
|
42
48
|
private subscriptionRepository: SubscriptionsRepository,
|
|
43
49
|
) {
|
|
@@ -48,6 +54,7 @@ export class Subscription {
|
|
|
48
54
|
this.expirationDate = expirationDate;
|
|
49
55
|
this.store = store;
|
|
50
56
|
this.productId = productId;
|
|
57
|
+
this.email = email;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
static async fromRevenueCat({
|
|
@@ -76,6 +83,7 @@ export class Subscription {
|
|
|
76
83
|
? Stores.APPLE_STORE
|
|
77
84
|
: Stores.PLAY_STORE,
|
|
78
85
|
productId: event.product_id,
|
|
86
|
+
email: user.email,
|
|
79
87
|
}, subscriptionRepository);
|
|
80
88
|
}
|
|
81
89
|
return new Subscription({
|
|
@@ -88,6 +96,7 @@ export class Subscription {
|
|
|
88
96
|
? Stores.APPLE_STORE
|
|
89
97
|
: Stores.PLAY_STORE,
|
|
90
98
|
productId: event.product_id,
|
|
99
|
+
email: user.email,
|
|
91
100
|
}, subscriptionRepository);
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -106,6 +115,7 @@ export class Subscription {
|
|
|
106
115
|
expirationDate: entity.expiration_date,
|
|
107
116
|
store: entity.store,
|
|
108
117
|
productId: entity.product_id,
|
|
118
|
+
email: entity.email,
|
|
109
119
|
}, subscriptionRepository);
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -5,7 +5,7 @@ import * as admin from "firebase-admin";
|
|
|
5
5
|
import {Timestamp} from "firebase-admin/firestore";
|
|
6
6
|
import Stripe from "stripe";
|
|
7
7
|
import {Subscription} from "./models/subscriptions";
|
|
8
|
-
import {subscriptionsRepository} from "../core/data/repositories/repositories";
|
|
8
|
+
import {subscriptionsRepository, usersRepository} from "../core/data/repositories/repositories";
|
|
9
9
|
import {Stores, SubscriptionStatus} from "./models/subscription_status";
|
|
10
10
|
|
|
11
11
|
// Server-side only. Never exposed to the client.
|
|
@@ -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,26 @@ 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;
|
|
106
|
+
// Persist the app language on the user so server-side notifications (e.g.
|
|
107
|
+
// the "subscription saved" message) are sent in the right language. On web
|
|
108
|
+
// there is no registered device to read the locale from, so we capture it
|
|
109
|
+
// here at purchase time.
|
|
110
|
+
const locale = (request.data?.locale as string | undefined)
|
|
111
|
+
?.substring(0, 2)
|
|
112
|
+
.toLowerCase();
|
|
113
|
+
if (locale) {
|
|
114
|
+
await admin
|
|
115
|
+
.firestore()
|
|
116
|
+
.collection("users")
|
|
117
|
+
.doc(uid)
|
|
118
|
+
.set({locale}, {merge: true});
|
|
119
|
+
}
|
|
89
120
|
|
|
90
121
|
const stripe = stripeClient();
|
|
91
|
-
const customerId = await getOrCreateCustomer(stripe, uid);
|
|
122
|
+
const customerId = await getOrCreateCustomer(stripe, uid, email);
|
|
92
123
|
|
|
93
124
|
const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
|
|
94
125
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
@@ -159,6 +190,9 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
159
190
|
}
|
|
160
191
|
const now = Timestamp.now();
|
|
161
192
|
const existing = await subscriptionsRepository.getFromUserId(uid);
|
|
193
|
+
// Denormalize the subscriber's email onto the Firestore subscription doc (see
|
|
194
|
+
// SubscriptionData.email) so a subscribers list reads it without a second hop.
|
|
195
|
+
const user = await usersRepository.getFromId(uid);
|
|
162
196
|
// In Stripe API v18 the billing period lives on each subscription item.
|
|
163
197
|
const item = sub.items.data[0];
|
|
164
198
|
const priceId = item?.price?.id ?? "";
|
|
@@ -176,6 +210,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
176
210
|
expirationDate: expiration,
|
|
177
211
|
store: Stores.STRIPE,
|
|
178
212
|
productId: priceId,
|
|
213
|
+
email: user?.email,
|
|
179
214
|
},
|
|
180
215
|
subscriptionsRepository,
|
|
181
216
|
);
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { Logger } from "../core/logger/logger";
|
|
2
|
-
import {usersRepository} from "../core/data/repositories/repositories";
|
|
2
|
+
import {usersRepository, userDevicesRepository} from "../core/data/repositories/repositories";
|
|
3
3
|
import {notificationsApi} from "../notifications/notifications_api";
|
|
4
4
|
import {SystemNotificationParams} from "../notifications/models/notification";
|
|
5
5
|
import {onDocumentCreated} from "firebase-functions/v2/firestore";
|
|
6
6
|
|
|
7
|
+
/// Localized copy for the "subscription saved" notification.
|
|
8
|
+
function subscriptionSavedText(locale: string): {title: string; body: string} {
|
|
9
|
+
switch (locale) {
|
|
10
|
+
case "pt":
|
|
11
|
+
return {title: "Assinatura confirmada", body: "Obrigado pela sua confiança!"};
|
|
12
|
+
case "es":
|
|
13
|
+
return {title: "Suscripción confirmada", body: "¡Gracias por tu confianza!"};
|
|
14
|
+
default:
|
|
15
|
+
return {title: "Subscription confirmed", body: "Thank you for your trust!"};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Resolves the user's language: the locale persisted on the user (set on web at
|
|
20
|
+
/// checkout), then the device locale (native), then English.
|
|
21
|
+
async function resolveUserLocale(
|
|
22
|
+
userId: string,
|
|
23
|
+
userLocale: string | undefined,
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
if (userLocale) return userLocale.substring(0, 2).toLowerCase();
|
|
26
|
+
try {
|
|
27
|
+
const devices = await userDevicesRepository.getDevices([userId]);
|
|
28
|
+
const raw = devices[0]?.extra_data?.["deviceLocale"] as string | undefined;
|
|
29
|
+
if (raw) return raw.substring(0, 2).toLowerCase();
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall through to the default below.
|
|
32
|
+
}
|
|
33
|
+
return "en";
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
export const onNewSubscription = onDocumentCreated(
|
|
8
37
|
"subscriptions/{userId}",
|
|
9
38
|
async (event) => {
|
|
@@ -12,7 +41,7 @@ export const onNewSubscription = onDocumentCreated(
|
|
|
12
41
|
}
|
|
13
42
|
const userId = event.params.userId;
|
|
14
43
|
const logger = new Logger("onNewSubscription");
|
|
15
|
-
|
|
44
|
+
|
|
16
45
|
try {
|
|
17
46
|
logger.info(`New subscription for user ${userId}`);
|
|
18
47
|
const user = await usersRepository.getFromId(userId);
|
|
@@ -20,11 +49,13 @@ export const onNewSubscription = onDocumentCreated(
|
|
|
20
49
|
logger.error(`User ${userId} not found`);
|
|
21
50
|
return;
|
|
22
51
|
}
|
|
52
|
+
const locale = await resolveUserLocale(userId, user.locale);
|
|
53
|
+
const {title, body} = subscriptionSavedText(locale);
|
|
23
54
|
await notificationsApi.notify(
|
|
24
55
|
[userId],
|
|
25
56
|
<SystemNotificationParams> {
|
|
26
|
-
title
|
|
27
|
-
body
|
|
57
|
+
title,
|
|
58
|
+
body,
|
|
28
59
|
},
|
|
29
60
|
);
|
|
30
61
|
} catch (error) {
|