Ctrl K
ComunidadGuías…

lib/billing-cycle.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Commonly used window (MIN-72, annual plans). Billing can be
 * ANNUAL, but usage is reset EVERY MONTH: we divide the period
 * Stripe into monthly sub-cycles anchored on the day of the period (the
 * `current_period_start`). An annual subscriber pays once a year but finds
 * his budget every month, on the same date. Taken from the tested logic of AutoKap.
 *
 * The annual is detected by the DURATION of the Stripe period (> 45 d), not by a
 * config of env: the window remains correct even if the annual price IDs do not
 * are not yet entered. Pure module (UTC dates), without server dependency.
 */

export interface UsageWindow {
  /** Start of current window (ISO). */
  start: string;
  /** End of window = date of next reset (ISO). */
  end: string;
}

/** Beyond this duration, the Stripe period is annual (monthly ≤ 31 days). */
const YEARLY_MIN_PERIOD_MS = 45 * 24 * 60 * 60 * 1000;

function isValidDate(value: Date): boolean {
  return Number.isFinite(value.getTime());
}

function daysInUtcMonth(year: number, monthIndex: number): number {
  return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}

/** Date on the same day/time as the anchor, projected on (year, monthIndex), day
 * clamped to the last day of the target month (e.g. anchor on 31 → 28/29 in February). */
function anchoredUtcDate(anchor: Date, year: number, monthIndex: number): Date {
  const day = Math.min(anchor.getUTCDate(), daysInUtcMonth(year, monthIndex));
  return new Date(
    Date.UTC(
      year,
      monthIndex,
      day,
      anchor.getUTCHours(),
      anchor.getUTCMinutes(),
      anchor.getUTCSeconds(),
      anchor.getUTCMilliseconds()
    )
  );
}

/** Start of the current monthly sub-cycle for an annual subscription. */
export function yearlyCycleStart(anchor: Date, now: Date): Date {
  let cycleStart = anchoredUtcDate(anchor, now.getUTCFullYear(), now.getUTCMonth());
  if (cycleStart.getTime() > now.getTime()) {
    cycleStart = anchoredUtcDate(anchor, now.getUTCFullYear(), now.getUTCMonth() - 1);
  }
  // Never before the actual start of the subscription.
  return cycleStart.getTime() < anchor.getTime() ? new Date(anchor) : cycleStart;
}

/** Next monthly reset (end of the current sub-cycle) for an annual subscription. */
export function yearlyNextReset(anchor: Date, now: Date): Date {
  let nextReset = anchoredUtcDate(anchor, now.getUTCFullYear(), now.getUTCMonth());
  if (nextReset.getTime() <= now.getTime()) {
    nextReset = anchoredUtcDate(anchor, now.getUTCFullYear(), now.getUTCMonth() + 1);
  }
  return nextReset;
}

/**
 * Usage window from a stored Stripe period. Monthly → period
 * as is. Annual → the current monthly sub-cycle, `end` clamped at the end
 * of the annual period (renewal will restart the sub-cycles).
 */
export function resolveUsageWindow(params: {
  periodStart: string;
  periodEnd: string;
  now?: Date;
}): UsageWindow | null {
  const start = new Date(params.periodStart);
  const end = new Date(params.periodEnd);
  const now = params.now ?? new Date();

  if (!isValidDate(start) || !isValidDate(end) || end.getTime() < start.getTime()) {
    return null;
  }

  const isYearly = end.getTime() - start.getTime() > YEARLY_MIN_PERIOD_MS;
  if (!isYearly) {
    return { start: params.periodStart, end: params.periodEnd };
  }

  const cycleStart = yearlyCycleStart(start, now);
  const nextReset = yearlyNextReset(start, now);
  const clampedEnd = nextReset.getTime() > end.getTime() ? end : nextReset;
  return { start: cycleStart.toISOString(), end: clampedEnd.toISOString() };
}