111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import type { SubscriptionPlan, SubscriptionStatus } from "@app/shared-types";
|
||
import { resolvePlanFromProductId } from "./entitlements.js";
|
||
|
||
/**
|
||
* Butiksverifiering (spec §47, Del 13). Backend verifierar ALLTID köpet mot
|
||
* butiken – klientens kvitto är bara ett påstående (spec §61.14).
|
||
*
|
||
* PRODUKTIONSINTEGRATION (implementeras i fas 7, se docs/utvecklingsfaser.md):
|
||
* - Apple: App Store Server API v2 – verifiera JWS-signaturen i signedTransaction
|
||
* mot Apples publika nycklar, kontrollera bundleId + environment.
|
||
* - Google: Play Developer API purchases.subscriptionsv2.get med service account.
|
||
*
|
||
* Dev/CI: APP_STORE_MODE=sandbox accepterar särskilt formaterade
|
||
* sandbox-payloads så att hela flödet kan testas end-to-end utan butikskonton.
|
||
*/
|
||
|
||
export interface VerifiedPurchase {
|
||
provider: "apple" | "google";
|
||
productId: string;
|
||
plan: SubscriptionPlan;
|
||
originalTransactionId: string;
|
||
status: SubscriptionStatus;
|
||
purchasedAt: Date;
|
||
expiresAt: Date;
|
||
}
|
||
|
||
export type StoreVerificationResult =
|
||
{ ok: true; purchase: VerifiedPurchase } | { ok: false; error: string };
|
||
|
||
export interface StoreVerifierConfig {
|
||
mode: "production" | "sandbox";
|
||
appleBundleId?: string | undefined;
|
||
googlePackageName?: string | undefined;
|
||
}
|
||
|
||
export class StoreVerifier {
|
||
constructor(private readonly cfg: StoreVerifierConfig) {}
|
||
|
||
async verifyApple(signedTransaction: string): Promise<StoreVerificationResult> {
|
||
if (this.cfg.mode === "sandbox") {
|
||
return parseSandboxPayload("apple", signedTransaction);
|
||
}
|
||
// TODO(fas 7): App Store Server API v2 JWS-verifiering.
|
||
return {
|
||
ok: false,
|
||
error:
|
||
"Apple-verifiering i produktionsläge är inte konfigurerad ännu. " +
|
||
"Kräver APPLE_ISSUER_ID/KEY_ID/PRIVATE_KEY (se docs/subscriptions.md).",
|
||
};
|
||
}
|
||
|
||
async verifyGoogle(
|
||
packageName: string,
|
||
productId: string,
|
||
purchaseToken: string,
|
||
): Promise<StoreVerificationResult> {
|
||
if (this.cfg.mode === "sandbox") {
|
||
return parseSandboxPayload("google", purchaseToken, productId);
|
||
}
|
||
if (this.cfg.googlePackageName && packageName !== this.cfg.googlePackageName) {
|
||
return { ok: false, error: `Fel paketnamn: ${packageName}` };
|
||
}
|
||
// TODO(fas 7): Play Developer API purchases.subscriptionsv2.
|
||
return {
|
||
ok: false,
|
||
error:
|
||
"Google-verifiering i produktionsläge är inte konfigurerad ännu. " +
|
||
"Kräver GOOGLE_SERVICE_ACCOUNT_JSON (se docs/subscriptions.md).",
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sandbox-payload: JSON i klartext:
|
||
* {"productId":"<iosBundleId>.family_monthly","originalTransactionId":"sandbox-1","expiresInDays":30}
|
||
*/
|
||
function parseSandboxPayload(
|
||
provider: "apple" | "google",
|
||
payload: string,
|
||
fallbackProductId?: string,
|
||
): StoreVerificationResult {
|
||
try {
|
||
const data = JSON.parse(payload) as {
|
||
productId?: string;
|
||
originalTransactionId?: string;
|
||
expiresInDays?: number;
|
||
};
|
||
const productId = data.productId ?? fallbackProductId;
|
||
if (!productId) return { ok: false, error: "sandbox-payload saknar productId" };
|
||
const plan = resolvePlanFromProductId(productId);
|
||
if (!plan || plan === "free") {
|
||
return { ok: false, error: `Okänt productId: ${productId}` };
|
||
}
|
||
const now = new Date();
|
||
return {
|
||
ok: true,
|
||
purchase: {
|
||
provider,
|
||
productId,
|
||
plan,
|
||
originalTransactionId: data.originalTransactionId ?? `sandbox-${Date.now()}`,
|
||
status: "active",
|
||
purchasedAt: now,
|
||
expiresAt: new Date(now.getTime() + (data.expiresInDays ?? 30) * 86_400_000),
|
||
},
|
||
};
|
||
} catch {
|
||
return { ok: false, error: "Ogiltig sandbox-payload (förväntar JSON)" };
|
||
}
|
||
}
|