Initial commit (unpacked platform)
This commit is contained in:
@@ -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 §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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user