import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { schema } from "@app/database"; import { verifyPurchaseInputSchema } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js"; import { loadEntitlementsWithToken } from "../lib/entitlements.js"; /** * Prenumerationer (spec §45–47, Del 13). * Backend verifierar ALLTID mot butiken och är source of truth (spec §61.14). * Webhooks tas emot råa och processas asynkront av workern. */ export async function subscriptionRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.post("/v1/subscriptions/verify", auth, async (req) => { const input = parse(verifyPurchaseInputSchema, req.body); const result = input.provider === "apple" ? await app.storeVerifier.verifyApple(input.signedTransaction) : await app.storeVerifier.verifyGoogle( input.packageName, input.productId, input.purchaseToken, ); if (!result.ok) throw errors.badRequest(`Kunde inte verifiera köpet: ${result.error}`); const purchase = result.purchase; const householdId = await getActiveHouseholdId(app.db, req.userId); // Idempotent på originalTransactionId const [existing] = await app.db .select() .from(schema.subscriptions) .where(eq(schema.subscriptions.originalTransactionId, purchase.originalTransactionId)) .limit(1); let subscriptionId: string; if (existing) { if (existing.userId !== req.userId) { throw errors.conflict( "Det här köpet är kopplat till ett annat konto. Använd Återställ köp på rätt konto.", ); } const [updated] = await app.db .update(schema.subscriptions) .set({ status: purchase.status, expiresAt: purchase.expiresAt, plan: purchase.plan, productId: purchase.productId, lastVerifiedAt: new Date(), updatedAt: new Date(), }) .where(eq(schema.subscriptions.id, existing.id)) .returning(); subscriptionId = updated!.id; } else { const [created] = await app.db .insert(schema.subscriptions) .values({ userId: req.userId, householdId, provider: purchase.provider, productId: purchase.productId, plan: purchase.plan, originalTransactionId: purchase.originalTransactionId, status: purchase.status, purchasedAt: purchase.purchasedAt, expiresAt: purchase.expiresAt, lastVerifiedAt: new Date(), }) .returning(); subscriptionId = created!.id; await emitEvent(app.db, { type: "SUBSCRIPTION_STARTED", payload: { subscriptionId, plan: purchase.plan, provider: purchase.provider }, userId: req.userId, householdId: householdId ?? undefined, correlationId: req.correlationId, }); } await app.db.insert(schema.subscriptionEvents).values({ subscriptionId, userId: req.userId, eventType: existing ? "verified" : "purchased", payload: { productId: purchase.productId }, }); await audit(app.db, { actorUserId: req.userId, action: "subscription.verified", targetType: "subscription", targetId: subscriptionId, }); return { ok: true, entitlements: await loadEntitlementsWithToken(app, req.userId) }; }); app.post("/v1/subscriptions/restore", auth, async (req) => { // Restore = samma flöde som verify; klienten skickar aktuellt kvitto/token. return { ok: true, message: "Skicka aktuellt kvitto till /v1/subscriptions/verify så återställs köpet.", }; }); /** * App Store Server Notifications V2 (spec §47). * Signaturverifiering av JWS sker i workern (PROCESS_STORE_NOTIFICATION). */ app.post( "/v1/subscriptions/webhooks/apple", { config: { rateLimit: false } }, async (req, reply) => { const [row] = await app.db .insert(schema.storeNotifications) .values({ provider: "apple", rawPayload: (req.body ?? {}) as Record }) .returning(); await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", { jobType: "PROCESS_STORE_NOTIFICATION", notificationId: row!.id, }); return reply.status(200).send({ ok: true }); }, ); /** Google Play Real-time Developer Notifications (via Pub/Sub push). */ app.post( "/v1/subscriptions/webhooks/google", { config: { rateLimit: false } }, async (req, reply) => { const [row] = await app.db .insert(schema.storeNotifications) .values({ provider: "google", rawPayload: (req.body ?? {}) as Record }) .returning(); await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", { jobType: "PROCESS_STORE_NOTIFICATION", notificationId: row!.id, }); return reply.status(200).send({ ok: true }); }, ); }