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

111 lines
3.6 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 { 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)" };
}
}