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

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;
}