147 lines
5.0 KiB
TypeScript
147 lines
5.0 KiB
TypeScript
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<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 });
|
||
},
|
||
);
|
||
}
|