import { InternalError } from "@api/errors"; import { getCycleEnd } from "@utils/billingUtils/cycleUtils/getCycleEnd"; import { ms } from "@utils/common"; import { isAllocatedPrice, isAllocatedV2Price, isConsumablePrice, isOneOffPrice, isPayPerUsePrice, isPrepaidPrice, isVolumePrice, } from "@utils/productUtils/priceUtils/classifyPriceUtils"; import type { EntityBalance, FullCustomerEntitlement, } from "../../models/cusProductModels/cusEntModels/cusEntModels"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { FeatureType, FeatureUsageType, } from "../../models/featureModels/featureEnums"; import { AllowanceType } from "../../models/productModels/entModels/entModels"; import { notNullish, nullish } from "../utils"; import { cusEntToCusPrice } from "./convertCusEntUtils/cusEntToCusPrice"; export const isBooleanCusEnt = ({ cusEnt, }: { cusEnt: FullCustomerEntitlement; }) => { return cusEnt.entitlement.feature.type === FeatureType.Boolean; }; export const isUnlimitedCusEnt = (cusEnt: FullCustomerEntitlement) => { return cusEnt.entitlement.allowance_type === AllowanceType.Unlimited; }; /** * Type guard that narrows cusEnt to have non-null entities. * Use directly with cusEnt (not wrapped in object) for type narrowing to work. */ export const isEntityScopedCusEnt = ( cusEnt: T, ): cusEnt is T & { entities: Record } => { return notNullish(cusEnt.entitlement.entity_feature_id); }; export const cusEntsHavePrice = ({ cusEnts, }: { cusEnts: FullCusEntWithFullCusProduct[]; }) => { return cusEnts.some((cusEnt) => { const cusPrice = cusEntToCusPrice({ cusEnt }); return notNullish(cusPrice); }); }; export const isFreeCustomerEntitlement = ( customerEntitlement: FullCusEntWithFullCusProduct, ) => { const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); return nullish(cusPrice); }; export const isPaidCustomerEntitlement = ( customerEntitlement: FullCusEntWithFullCusProduct, ) => { const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); return notNullish(cusPrice); }; export const isAllocatedCustomerEntitlement = ( customerEntitlement: FullCusEntWithFullCusProduct, ) => { const feature = customerEntitlement.entitlement.feature; const isContinuous = feature.config?.usage_type === FeatureUsageType.Continuous; if (!isContinuous) return false; return true; }; export const customerEntitlementAllowsRollovers = ( customerEntitlement: FullCustomerEntitlement, ) => { return notNullish(customerEntitlement.entitlement.rollover); }; /** * * Only applicable for paid customer entitlements */ export const customerEntitlementShouldBeBilled = ({ cusEnt, invoicePeriodEndMs, billingCycleAnchorMs, }: { cusEnt: FullCusEntWithFullCusProduct; invoicePeriodEndMs: number; billingCycleAnchorMs?: number; }) => { if (!isPaidCustomerEntitlement(cusEnt)) { throw new InternalError({ message: `[customerEntitlementShouldReset] this function is only applicable to paid customer entitlements`, }); } const TOLERANCE_MS = ms.days(1); const nextResetAt = cusEnt.next_reset_at; if (!nextResetAt) { // Allocated v2 (continuous-use) cusEnts never reset so they carry no // next_reset_at — bill them whenever their price's billing cycle ends at // this invoice's period end (multi-interval safe: a yearly item on a // monthly subscription only bills at the year boundary). if (!billingCycleAnchorMs) return false; if (!isAllocatedV2CustomerEntitlement(cusEnt)) return false; const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice || !isConsumablePrice(cusPrice.price)) return false; const priceConfig = cusPrice.price.config; const cycleEnd = getCycleEnd({ anchor: billingCycleAnchorMs, interval: priceConfig.interval, intervalCount: priceConfig.interval_count ?? 1, now: invoicePeriodEndMs - TOLERANCE_MS, }); return Math.abs(cycleEnd - invoicePeriodEndMs) <= TOLERANCE_MS; } return nextResetAt <= invoicePeriodEndMs + TOLERANCE_MS; }; export const isVolumeBasedCusEnt = (cusEnt: FullCusEntWithFullCusProduct) => { const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isVolumePrice(cusPrice.price); }; export const isUsageBasedAllocatedCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => { const isAllocated = isAllocatedCustomerEntitlement(cusEnt); const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isAllocated && isAllocatedPrice(cusPrice.price); }; /** * Continuous-use feature billed like a consumable (allocated v2): holdings are * billed in arrears at each cycle end and the balance is never reset. */ export const isAllocatedV2CustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => { const isAllocated = isAllocatedCustomerEntitlement(cusEnt); const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isAllocated && isAllocatedV2Price(cusPrice.price); }; /** Whether the customer entitlement has a prepaid price (usage billed in advance). */ export const isPrepaidCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => { const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isPrepaidPrice(cusPrice.price); }; /** Whether the customer entitlement has a pay-per-use price (usage billed in arrears). */ export const isPayPerUseCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => { const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isPayPerUsePrice({ price: cusPrice.price }); }; export const isOneOffCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => { const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice) return false; return isOneOffPrice(cusPrice.price); }; export const isConsumableCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => !isAllocatedCustomerEntitlement(cusEnt); /** * One-off prepaid consumable cusEnts are auto-preserved as a lifetime cusEnt * on product transitions (cusProductToOneOffPrepaidCarryOvers). Any other * carry-over / existing-usage path that iterates cusEnts must skip these to * avoid double-counting the same balance. */ export const isOneOffPrepaidConsumableCustomerEntitlement = ( cusEnt: FullCusEntWithFullCusProduct, ) => isPrepaidCustomerEntitlement(cusEnt) && isConsumableCustomerEntitlement(cusEnt) && isOneOffCustomerEntitlement(cusEnt);