From 067c70b960bca501c09b46caa3eed46baf0afb8e Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 28 Apr 2026 01:51:04 +0200 Subject: [PATCH 1/2] tweaked apple IAP UX --- .../account/SubscriptionSettings.module.css | 10 ++++ .../account/SubscriptionSettings.tsx | 33 +++++++++-- components/projects/CreateProjectPage.tsx | 4 +- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/ja.json | 6 +- messages/ko.json | 6 +- messages/pl.json | 6 +- messages/zh.json | 6 +- src/app/api/apple/subscription-owner/route.ts | 41 +++++++++++++ src/app/api/apple/verify/route.ts | 57 +++++++++++++++++++ src/app/api/webhooks/stripe/route.ts | 10 +--- src/lib/import/import-project.ts | 2 +- src/lib/utils/requests.ts | 10 +++- 16 files changed, 183 insertions(+), 32 deletions(-) create mode 100644 src/app/api/apple/subscription-owner/route.ts create mode 100644 src/app/api/apple/verify/route.ts diff --git a/components/dashboard/account/SubscriptionSettings.module.css b/components/dashboard/account/SubscriptionSettings.module.css index 05722580..058d4329 100644 --- a/components/dashboard/account/SubscriptionSettings.module.css +++ b/components/dashboard/account/SubscriptionSettings.module.css @@ -170,6 +170,16 @@ margin: 0; } +.errorMessage { + font-size: 0.82rem; + color: var(--error); + padding: 8px 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--error) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--error) 20%, transparent); + margin: 0; +} + .upgradeBtn { display: flex; align-items: center; diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index 31dfc42a..5e5c0a86 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { ArrowRight, Check, Lock } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; -import { cancelStripeSubscription, createStripeCheckout, submitApplePurchase } from "@src/lib/utils/requests"; +import { cancelStripeSubscription, createStripeCheckout, getAppleSubscriptionOwner, submitApplePurchase } from "@src/lib/utils/requests"; import { useUser } from "@src/lib/utils/hooks"; import styles from "./SubscriptionSettings.module.css"; @@ -56,13 +56,22 @@ const SubscriptionSettings = () => { return () => { cancelled = true; }; }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + const showOwnerError = async (jwsRepresentation: string) => { + const ownerEmail = await getAppleSubscriptionOwner(jwsRepresentation); + if (ownerEmail) { + setError(t("subscription.alreadyBoundTo", { email: ownerEmail })); + } else { + setError(t("subscription.alreadyBoundUnknown")); + } + }; + const handleUpgrade = async () => { setError(null); setUpgradeLoading(true); if (isTauri()) { try { - const { purchase, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); + const { purchase, restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); const result = await purchase(APPLE_PRODUCT_ID, "subs", { appAccountToken: user?.id, }); @@ -76,9 +85,25 @@ const SubscriptionSettings = () => { if (ok) { await mutate(); } else { - setError(t("subscription.purchaseError")); + await showOwnerError(result.jwsRepresentation); } } catch (err) { + // StoreKit may throw when the Apple ID already has an active subscription. + // Restore purchases to find the existing JWS and identify the linked account. + try { + const { restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); + const { purchases } = await restorePurchases("subs"); + const existing = purchases.find( + (p) => p.productId === APPLE_PRODUCT_ID + && p.purchaseState === PurchaseState.PURCHASED + && p.jwsRepresentation, + ); + if (existing?.jwsRepresentation) { + await showOwnerError(existing.jwsRepresentation); + return; + } + } catch { /* ignore */ } + console.error("[SubscriptionSettings] Apple IAP failed:", err); setError(t("subscription.purchaseError")); } finally { @@ -194,7 +219,7 @@ const SubscriptionSettings = () => { )} - {error &&

{error}

} + {error &&

{error}

} ); }; diff --git a/components/projects/CreateProjectPage.tsx b/components/projects/CreateProjectPage.tsx index edab4e63..90946933 100644 --- a/components/projects/CreateProjectPage.tsx +++ b/components/projects/CreateProjectPage.tsx @@ -62,7 +62,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { if (selectedFile) { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (res.ok && json.data) { projectId = json.data.id; @@ -104,7 +104,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { setFormInfo({ content: json.message!, isError: true }); diff --git a/messages/de.json b/messages/de.json index dce8e7f5..a0c0217d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -148,7 +148,9 @@ "purchasing": "Kauf wird durchgeführt...", "purchaseError": "Kauf fehlgeschlagen. Bitte erneut versuchen.", "cancelApple": "Apple-Abonnements werden über den App Store verwaltet.", - "manageApple": "App Store öffnen" + "manageApple": "App Store öffnen", + "alreadyBoundTo": "Dieses Abonnement ist bereits mit {email} verknüpft.", + "alreadyBoundUnknown": "Dieses Abonnement ist bereits mit einem anderen Konto verknüpft." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 39d3bb9e..4bcca531 100644 --- a/messages/en.json +++ b/messages/en.json @@ -147,7 +147,9 @@ "purchasing": "Purchasing...", "purchaseError": "Purchase failed. Please try again.", "cancelApple": "Apple subscriptions are managed through the App Store.", - "manageApple": "Open App Store" + "manageApple": "Open App Store", + "alreadyBoundTo": "This subscription is already linked to {email}.", + "alreadyBoundUnknown": "This subscription is already linked to another account." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# month ago} other {# months ago}}", "moreThanYearAgo": "More than 1 year ago" } -} +} \ No newline at end of file diff --git a/messages/es.json b/messages/es.json index ab41adb9..f62a42d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -147,7 +147,9 @@ "purchasing": "Comprando...", "purchaseError": "La compra falló. Inténtalo de nuevo.", "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", - "manageApple": "Abrir App Store" + "manageApple": "Abrir App Store", + "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", + "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", "moreThanYearAgo": "Hace más de 1 año" } -} +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index dc30105a..f2b1eb62 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -148,7 +148,9 @@ "purchasing": "Achat en cours...", "purchaseError": "L'achat a échoué. Veuillez réessayer.", "cancelApple": "Les abonnements Apple sont gérés via l'App Store.", - "manageApple": "Ouvrir l'App Store" + "manageApple": "Ouvrir l'App Store", + "alreadyBoundTo": "Cet abonnement est déjà associé à {email}.", + "alreadyBoundUnknown": "Cet abonnement est déjà associé à un autre compte." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} +} \ No newline at end of file diff --git a/messages/ja.json b/messages/ja.json index 84f66eee..f6428a42 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -147,7 +147,9 @@ "purchasing": "購入中...", "purchaseError": "購入に失敗しました。もう一度お試しください。", "cancelApple": "Appleのサブスクリプションはアプリストアで管理されます。", - "manageApple": "App Storeを開く" + "manageApple": "App Storeを開く", + "alreadyBoundTo": "このサブスクリプションは既に {email} に関連付けられています。", + "alreadyBoundUnknown": "このサブスクリプションは既に別のアカウントに関連付けられています。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} +} \ No newline at end of file diff --git a/messages/ko.json b/messages/ko.json index c9eb3baf..d031da89 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -147,7 +147,9 @@ "purchasing": "구매 중...", "purchaseError": "구매에 실패했습니다. 다시 시도해 주세요.", "cancelApple": "Apple 구독은 App Store에서 관리됩니다.", - "manageApple": "App Store 열기" + "manageApple": "App Store 열기", + "alreadyBoundTo": "이 구독은 이미 {email}에 연결되어 있습니다.", + "alreadyBoundUnknown": "이 구독은 이미 다른 계정에 연결되어 있습니다." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} +} \ No newline at end of file diff --git a/messages/pl.json b/messages/pl.json index 0b3aeb35..b982fd08 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -147,7 +147,9 @@ "purchasing": "Zakup w toku...", "purchaseError": "Zakup nie powiódł się. Spróbuj ponownie.", "cancelApple": "Subskrypcje Apple są zarządzane przez App Store.", - "manageApple": "Otwórz App Store" + "manageApple": "Otwórz App Store", + "alreadyBoundTo": "Ta subskrypcja jest już powiązana z {email}.", + "alreadyBoundUnknown": "Ta subskrypcja jest już powiązana z innym kontem." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json index fc85ce77..14655f9c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -147,7 +147,9 @@ "purchasing": "购买中...", "purchaseError": "购买失败,请重试。", "cancelApple": "Apple 订阅通过 App Store 管理。", - "manageApple": "打开 App Store" + "manageApple": "打开 App Store", + "alreadyBoundTo": "此订阅已与 {email} 关联。", + "alreadyBoundUnknown": "此订阅已与其他账户关联。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} +} \ No newline at end of file diff --git a/src/app/api/apple/subscription-owner/route.ts b/src/app/api/apple/subscription-owner/route.ts new file mode 100644 index 00000000..46e43cef --- /dev/null +++ b/src/app/api/apple/subscription-owner/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { decodeJwt } from "jose"; +import * as UserService from "@src/server/service/user-service"; + +const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; +const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; + +interface AppleTransactionPayload { + bundleId: string; + productId: string; + appAccountToken?: string; +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + return `${local[0]}***@${domain}`; +} + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + + if (typeof jwsTransaction !== "string") { + return NextResponse.json({ email: null }); + } + + const payload = decodeJwt(jwsTransaction) as unknown as AppleTransactionPayload; + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId) || payload.productId !== APPLE_PRODUCT_ID) { + return NextResponse.json({ email: null }); + } + + if (!payload.appAccountToken) { + return NextResponse.json({ email: null }); + } + + const user = await UserService.getUserFromId(payload.appAccountToken); + const email = user?.email ? maskEmail(user.email) : null; + + return NextResponse.json({ email }); +} diff --git a/src/app/api/apple/verify/route.ts b/src/app/api/apple/verify/route.ts new file mode 100644 index 00000000..30d53ea6 --- /dev/null +++ b/src/app/api/apple/verify/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from "next/server"; +import { decodeJwt } from "jose"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils"; +import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; + +const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; +const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; + +interface AppleTransactionPayload { + transactionId: string; + originalTransactionId: string; + bundleId: string; + productId: string; + purchaseDate: number; + expiresDate: number; + type: string; + appAccountToken?: string; +} + +async function verifyApplePurchase(req: NextRequest, { user }: AuthApiContext) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + if (typeof jwsTransaction !== "string") { + throw new BodyFieldError("Missing jwsTransaction"); + } + + const payload = decodeJwt(jwsTransaction) as unknown as AppleTransactionPayload; + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) { + throw new ForbiddenError("Invalid bundle ID"); + } + if (payload.productId !== APPLE_PRODUCT_ID) { + throw new ForbiddenError("Invalid product ID"); + } + if (payload.appAccountToken && payload.appAccountToken !== user.id) { + throw new ForbiddenError("This purchase belongs to a different account"); + } + + const expiresDate = new Date(payload.expiresDate); + if (expiresDate <= new Date()) { + throw new ForbiddenError("Transaction already expired"); + } + + await UserService.updateUserFromId(user.id, { + isProUntil: expiresDate, + subscriptionProvider: "APPLE", + isSubscriptionCancelled: false, + }); + + await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.transactionId); + + return Success(null); +} + +export const POST = apiHandler(verifyApplePurchase); diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 09a5f208..61d86a6d 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import prisma from "@src/server/db"; import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; export async function POST(req: NextRequest) { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); @@ -30,13 +30,7 @@ export async function POST(req: NextRequest) { isProUntil: periodEnd ? new Date(periodEnd * 1000) : null, subscriptionProvider: "STRIPE", }); - await prisma.transaction.create({ - data: { - userId, - provider: "STRIPE", - transactionId: subscriptionId, - }, - }); + await TransactionService.createTransactionIfNotExists(userId, "STRIPE", subscriptionId); } } diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts index ea134c6b..276e9817 100644 --- a/src/lib/import/import-project.ts +++ b/src/lib/import/import-project.ts @@ -144,7 +144,7 @@ async function createRemoteProject(userId: string, title: string, description?: description, }; - const res = await createProject(userId, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index ab9606c1..550b1f28 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -29,7 +29,7 @@ export const getCloudToken = async (projectId: string): Promise<{ token: string return { token: null, status: res.status }; }; -export const createProject = async (userId: string, body: CreateProjectBody) => { +export const createProject = async (body: CreateProjectBody) => { return request(`/api/projects`, "POST", body); }; @@ -142,3 +142,11 @@ export const submitApplePurchase = async (jwsTransaction: string): Promise => { + const res = await request("/api/apple/subscription-owner", "POST", { jwsTransaction }); + if (!res.ok) return null; + const { email } = (await res.json()) as { email: string | null }; + return email ?? null; +}; + From 9fd57e0cfe5942bd9f784b2027a3b587749a0614 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 28 Apr 2026 01:54:45 +0200 Subject: [PATCH 2/2] added missing prisma generate in ci --- .github/workflows/checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 88e9fbaa..80c489c1 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -24,7 +24,7 @@ jobs: continue-on-error: true - name: Typecheck - run: npx tsc --noEmit + run: npx prisma generate && npx tsc --noEmit build-macos: runs-on: macos-latest