108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
import {
|
|
PLAN_DEFINITIONS,
|
|
TRIAL_DAYS,
|
|
type Entitlements,
|
|
type SubscriptionPlan,
|
|
type SubscriptionStatus,
|
|
} from "@app/shared-types";
|
|
|
|
export interface SubscriptionSnapshot {
|
|
plan: SubscriptionPlan;
|
|
status: SubscriptionStatus;
|
|
expiresAt?: Date | null;
|
|
gracePeriodExpiresAt?: Date | null;
|
|
}
|
|
|
|
export interface TrialSnapshot {
|
|
startedAt: Date;
|
|
endsAt: Date;
|
|
}
|
|
|
|
export interface UsageSnapshot {
|
|
aiScansUsedThisMonth: number;
|
|
}
|
|
|
|
/**
|
|
* Backend är source of truth (spec §47, §61.14). Denna funktion är den enda
|
|
* platsen som avgör vad en användare får göra.
|
|
*
|
|
* Prioritet: aktiv betald prenumeration (inkl. grace) > pågående trial > free.
|
|
*/
|
|
export function computeEntitlements(
|
|
subscription: SubscriptionSnapshot | null,
|
|
trial: TrialSnapshot | null,
|
|
usage: UsageSnapshot,
|
|
now: Date = new Date(),
|
|
): Entitlements {
|
|
// 1. Betald prenumeration
|
|
if (subscription) {
|
|
const withinPeriod = subscription.expiresAt == null || subscription.expiresAt > now;
|
|
const withinGrace =
|
|
subscription.gracePeriodExpiresAt != null && subscription.gracePeriodExpiresAt > now;
|
|
const active =
|
|
(subscription.status === "active" || subscription.status === "trial") && withinPeriod;
|
|
const inGrace = subscription.status === "in_grace" && withinGrace;
|
|
|
|
if (active || inGrace) {
|
|
const def = PLAN_DEFINITIONS[subscription.plan];
|
|
return {
|
|
plan: subscription.plan,
|
|
status: inGrace ? "in_grace" : subscription.status,
|
|
maxHouseholdMembers: def.maxHouseholdMembers,
|
|
aiScansPerMonth: def.aiScansPerMonth,
|
|
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
|
weekPlanning: def.weekPlanning,
|
|
advancedNutrition: def.advancedNutrition,
|
|
communityPublish: def.communityPublish,
|
|
expiresAt: subscription.expiresAt?.toISOString(),
|
|
graceUntil: subscription.gracePeriodExpiresAt?.toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// 2. Trial: 7 dagar full tillgång utan kort (spec §46). Family-nivå under trial.
|
|
if (trial && trial.endsAt > now) {
|
|
const def = PLAN_DEFINITIONS.family;
|
|
return {
|
|
plan: "family",
|
|
status: "trial",
|
|
maxHouseholdMembers: def.maxHouseholdMembers,
|
|
aiScansPerMonth: def.aiScansPerMonth,
|
|
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
|
weekPlanning: true,
|
|
advancedNutrition: true,
|
|
communityPublish: true,
|
|
expiresAt: trial.endsAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
// 3. Gratisnivå: användbar men begränsad (spec §46).
|
|
const free = PLAN_DEFINITIONS.free;
|
|
return {
|
|
plan: "free",
|
|
status: "free",
|
|
maxHouseholdMembers: free.maxHouseholdMembers,
|
|
aiScansPerMonth: free.aiScansPerMonth,
|
|
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
|
weekPlanning: free.weekPlanning,
|
|
advancedNutrition: free.advancedNutrition,
|
|
communityPublish: free.communityPublish,
|
|
};
|
|
}
|
|
|
|
export function trialEndsAt(startedAt: Date): Date {
|
|
return new Date(startedAt.getTime() + TRIAL_DAYS * 86_400_000);
|
|
}
|
|
|
|
/** Har användaren AI-skanningar kvar denna månad? (fair use, spec §45) */
|
|
export function canUseAiScan(entitlements: Entitlements): boolean {
|
|
return entitlements.aiScansUsedThisMonth < entitlements.aiScansPerMonth;
|
|
}
|
|
|
|
export function resolvePlanFromProductId(productId: string): SubscriptionPlan | null {
|
|
for (const def of Object.values(PLAN_DEFINITIONS)) {
|
|
if (def.appleProductId === productId || def.googleProductId === productId) return def.plan;
|
|
}
|
|
return null;
|
|
}
|