Files
Cibello-app/apps/api/src/routes/subscriptions.ts
T
2026-08-05 19:21:11 +07:00

147 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 §4547, 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<string, unknown> })
.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<string, unknown> })
.returning();
await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", {
jobType: "PROCESS_STORE_NOTIFICATION",
notificationId: row!.id,
});
return reply.status(200).send({ ok: true });
},
);
}