Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
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 });
},
);
}