Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions components/dashboard/account/SubscriptionSettings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 29 additions & 4 deletions components/dashboard/account/SubscriptionSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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";
Expand Down Expand Up @@ -56,13 +56,22 @@
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");

Check warning on line 74 in components/dashboard/account/SubscriptionSettings.tsx

View workflow job for this annotation

GitHub Actions / lint

'restorePurchases' is assigned a value but never used
const result = await purchase(APPLE_PRODUCT_ID, "subs", {
appAccountToken: user?.id,
});
Expand All @@ -76,9 +85,25 @@
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 {
Expand Down Expand Up @@ -194,7 +219,7 @@
</button>
)}

{error && <p className={styles.cancelSuccess} style={{ color: "var(--error)" }}>{error}</p>}
{error && <p className={styles.errorMessage}>{error}</p>}
</div>
);
};
Expand Down
4 changes: 2 additions & 2 deletions components/projects/CreateProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -465,4 +467,4 @@
"monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}",
"moreThanYearAgo": "Vor über einem Jahr"
}
}
}
6 changes: 4 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "{months, plural, one {# month ago} other {# months ago}}",
"moreThanYearAgo": "More than 1 year ago"
}
}
}
6 changes: 4 additions & 2 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}",
"moreThanYearAgo": "Hace más de 1 año"
}
}
}
6 changes: 4 additions & 2 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -465,4 +467,4 @@
"monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}",
"moreThanYearAgo": "Il y a plus d'un an"
}
}
}
6 changes: 4 additions & 2 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@
"purchasing": "購入中...",
"purchaseError": "購入に失敗しました。もう一度お試しください。",
"cancelApple": "Appleのサブスクリプションはアプリストアで管理されます。",
"manageApple": "App Storeを開く"
"manageApple": "App Storeを開く",
"alreadyBoundTo": "このサブスクリプションは既に {email} に関連付けられています。",
"alreadyBoundUnknown": "このサブスクリプションは既に別のアカウントに関連付けられています。"
}
},
"projects": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "{months}ヶ月前",
"moreThanYearAgo": "1年以上前"
}
}
}
6 changes: 4 additions & 2 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@
"purchasing": "구매 중...",
"purchaseError": "구매에 실패했습니다. 다시 시도해 주세요.",
"cancelApple": "Apple 구독은 App Store에서 관리됩니다.",
"manageApple": "App Store 열기"
"manageApple": "App Store 열기",
"alreadyBoundTo": "이 구독은 이미 {email}에 연결되어 있습니다.",
"alreadyBoundUnknown": "이 구독은 이미 다른 계정에 연결되어 있습니다."
}
},
"projects": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "{months}달 전",
"moreThanYearAgo": "1년 이상 전"
}
}
}
6 changes: 4 additions & 2 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu",
"moreThanYearAgo": "Ponad rok temu"
}
}
}
6 changes: 4 additions & 2 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@
"purchasing": "购买中...",
"purchaseError": "购买失败,请重试。",
"cancelApple": "Apple 订阅通过 App Store 管理。",
"manageApple": "打开 App Store"
"manageApple": "打开 App Store",
"alreadyBoundTo": "此订阅已与 {email} 关联。",
"alreadyBoundUnknown": "此订阅已与其他账户关联。"
}
},
"projects": {
Expand Down Expand Up @@ -464,4 +466,4 @@
"monthsAgo": "{months} 个月前",
"moreThanYearAgo": "1 年前"
}
}
}
41 changes: 41 additions & 0 deletions src/app/api/apple/subscription-owner/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 });
}
57 changes: 57 additions & 0 deletions src/app/api/apple/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
10 changes: 2 additions & 8 deletions src/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -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!);
Expand Down Expand Up @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/import/import-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading