import type { SubscriptionPlan, SubscriptionStatus } from "@app/shared-types"; import { resolvePlanFromProductId } from "./entitlements.js"; /** * Butiksverifiering (spec §47, Del 13). Backend verifierar ALLTID köpet mot * butiken – klientens kvitto är bara ett påstående (spec §61.14). * * PRODUKTIONSINTEGRATION (implementeras i fas 7, se docs/utvecklingsfaser.md): * - Apple: App Store Server API v2 – verifiera JWS-signaturen i signedTransaction * mot Apples publika nycklar, kontrollera bundleId + environment. * - Google: Play Developer API purchases.subscriptionsv2.get med service account. * * Dev/CI: APP_STORE_MODE=sandbox accepterar särskilt formaterade * sandbox-payloads så att hela flödet kan testas end-to-end utan butikskonton. */ export interface VerifiedPurchase { provider: "apple" | "google"; productId: string; plan: SubscriptionPlan; originalTransactionId: string; status: SubscriptionStatus; purchasedAt: Date; expiresAt: Date; } export type StoreVerificationResult = { ok: true; purchase: VerifiedPurchase } | { ok: false; error: string }; export interface StoreVerifierConfig { mode: "production" | "sandbox"; appleBundleId?: string | undefined; googlePackageName?: string | undefined; } export class StoreVerifier { constructor(private readonly cfg: StoreVerifierConfig) {} async verifyApple(signedTransaction: string): Promise { if (this.cfg.mode === "sandbox") { return parseSandboxPayload("apple", signedTransaction); } // TODO(fas 7): App Store Server API v2 JWS-verifiering. return { ok: false, error: "Apple-verifiering i produktionsläge är inte konfigurerad ännu. " + "Kräver APPLE_ISSUER_ID/KEY_ID/PRIVATE_KEY (se docs/subscriptions.md).", }; } async verifyGoogle( packageName: string, productId: string, purchaseToken: string, ): Promise { if (this.cfg.mode === "sandbox") { return parseSandboxPayload("google", purchaseToken, productId); } if (this.cfg.googlePackageName && packageName !== this.cfg.googlePackageName) { return { ok: false, error: `Fel paketnamn: ${packageName}` }; } // TODO(fas 7): Play Developer API purchases.subscriptionsv2. return { ok: false, error: "Google-verifiering i produktionsläge är inte konfigurerad ännu. " + "Kräver GOOGLE_SERVICE_ACCOUNT_JSON (se docs/subscriptions.md).", }; } } /** * Sandbox-payload: JSON i klartext: * {"productId":".family_monthly","originalTransactionId":"sandbox-1","expiresInDays":30} */ function parseSandboxPayload( provider: "apple" | "google", payload: string, fallbackProductId?: string, ): StoreVerificationResult { try { const data = JSON.parse(payload) as { productId?: string; originalTransactionId?: string; expiresInDays?: number; }; const productId = data.productId ?? fallbackProductId; if (!productId) return { ok: false, error: "sandbox-payload saknar productId" }; const plan = resolvePlanFromProductId(productId); if (!plan || plan === "free") { return { ok: false, error: `Okänt productId: ${productId}` }; } const now = new Date(); return { ok: true, purchase: { provider, productId, plan, originalTransactionId: data.originalTransactionId ?? `sandbox-${Date.now()}`, status: "active", purchasedAt: now, expiresAt: new Date(now.getTime() + (data.expiresInDays ?? 30) * 86_400_000), }, }; } catch { return { ok: false, error: "Ogiltig sandbox-payload (förväntar JSON)" }; } }