Files
Cibello-app/packages/subscriptions/src/entitlements.ts
T
Claude 88876a24b7 fix(subscriptions): trial 14 dagar konsekvent i UI/docs/legal/butik
TRIAL_DAYS var redan 14 i koden men auth.trialNote (12 sprak), butiksmetadata, juridik-utkast, API-ref, exec-summary och UX-doc sa 7. Nu 14 overallt. Rorde EJ icke-trial 7-dagar (inbjudningslank, backup, S3-lifecycle, grace, retention). Beslut: Johan (battre onboarding).
2026-08-17 17:04:00 +00:00

109 lines
3.4 KiB
TypeScript

import {
PLAN_DEFINITIONS,
TRIAL_DAYS,
TRIAL_SCANS,
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: 14 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: TRIAL_SCANS,
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;
}