v0.2.6 calendar-anniversary billing
This commit is contained in:
+129
-18
@@ -10,11 +10,20 @@
|
||||
// tier_snapshot: "core" | "pro" | "max", // last-seen tier
|
||||
// lifetime_consumed: number, // total Core credits ever used
|
||||
// lifetime_gemini_consumed: number, // Core credits served by Gemini
|
||||
// month: "YYYY-MM", // calendar-month key
|
||||
// monthly_consumed: number, // total this month (paid tiers)
|
||||
// monthly_gemini_consumed: number, // Gemini-only this month
|
||||
// last_renewal_at: ISO-8601 string, // start of current billing period
|
||||
// monthly_consumed: number, // total this period (paid tiers)
|
||||
// monthly_gemini_consumed: number, // Gemini-only this period
|
||||
// last_active_at: ISO-8601 string,
|
||||
// }
|
||||
//
|
||||
// Billing periods are CALENDAR-ANNIVERSARY, not calendar-month. A user
|
||||
// whose first paid request lands on the 17th of October renews on the
|
||||
// 17th of every subsequent month — not the 1st. This matches how typical
|
||||
// subscription billing works (Stripe et al.) so the relay's monthly-cap
|
||||
// resets line up with the actual renewal date the user is being charged
|
||||
// on. Edge cases (Jan 31 → Feb 28/29) clamp to the last day of the
|
||||
// target month, then resume the original day-of-month the next time
|
||||
// it's available, same as standard subscription convention.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
@@ -38,37 +47,120 @@ export async function initCredits({ dataDir: dd }) {
|
||||
}
|
||||
ledger = { rows: {} };
|
||||
}
|
||||
// Migrate any rows that still carry the old { month: "YYYY-MM" }
|
||||
// shape from pre-anniversary ledger versions. Conservative: anchor
|
||||
// their last_renewal_at at the first of that month so they don't
|
||||
// get a surprise re-issue.
|
||||
let migrated = 0;
|
||||
for (const row of Object.values(ledger.rows)) {
|
||||
if (row.last_renewal_at) continue;
|
||||
if (typeof row.month === "string" && /^\d{4}-\d{2}$/.test(row.month)) {
|
||||
const [y, m] = row.month.split("-").map(Number);
|
||||
row.last_renewal_at = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString();
|
||||
delete row.month;
|
||||
migrated += 1;
|
||||
} else {
|
||||
// No prior period info — anchor to now so the next renewal
|
||||
// happens one calendar month from this point.
|
||||
row.last_renewal_at = new Date().toISOString();
|
||||
if ("month" in row) delete row.month;
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
if (migrated > 0) {
|
||||
console.log(`[credits] migrated ${migrated} row(s) from calendar-month to anniversary-renewal shape`);
|
||||
await persist();
|
||||
}
|
||||
console.log(`[credits] loaded ${Object.keys(ledger.rows).length} install rows from ${ledgerPath}`);
|
||||
}
|
||||
|
||||
function currentMonthKey() {
|
||||
const d = new Date();
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
// Add one calendar month to the given Date, clamping the day to the
|
||||
// last valid day of the target month. Examples (all UTC):
|
||||
// Jan 31 → Feb 28 (or Feb 29 in leap years)
|
||||
// Feb 28 → Mar 28 (note: NOT Mar 31 — we use the original day)
|
||||
// Mar 31 → Apr 30
|
||||
// May 31 → Jun 30 → Jul 31 (clamping is per-step, not absorbed)
|
||||
//
|
||||
// Note the third example. We anchor to the ORIGINAL anniversary day
|
||||
// each step, so a user who started on the 31st keeps getting renewals
|
||||
// on the 31st in months that have one, and on the last day for months
|
||||
// that don't. This is the standard subscription-billing rule.
|
||||
export function nextRenewalAfter(d, originalDay) {
|
||||
// originalDay is the anniversary day-of-month. If not supplied, use
|
||||
// d's own date — useful for the first hop from a freshly-set
|
||||
// last_renewal_at.
|
||||
const anchor = originalDay ?? d.getUTCDate();
|
||||
const targetMonth = d.getUTCMonth() + 1;
|
||||
const overflow = targetMonth > 11 ? 1 : 0;
|
||||
const targetYear = d.getUTCFullYear() + overflow;
|
||||
const actualMonth = targetMonth % 12;
|
||||
// Last day of the target month (e.g. for Feb non-leap, this is 28).
|
||||
const lastDayOfTarget = new Date(Date.UTC(targetYear, actualMonth + 1, 0)).getUTCDate();
|
||||
const day = Math.min(anchor, lastDayOfTarget);
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
targetYear,
|
||||
actualMonth,
|
||||
day,
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Lazily rolls over the per-install monthly counters when the calendar
|
||||
// month changes. Lifetime counter is left untouched (Core lifetime
|
||||
// credits never reset).
|
||||
function ensureCurrentMonth(row) {
|
||||
const m = currentMonthKey();
|
||||
if (row.month !== m) {
|
||||
row.month = m;
|
||||
// Determine the install's anniversary day-of-month, which stays stable
|
||||
// even if a clamp pushed last_renewal_at to a shorter month. Stored
|
||||
// explicitly as `anniversary_day` once the row is created at a non-
|
||||
// month-end date; for legacy rows without it we infer from
|
||||
// last_renewal_at as a one-time best-effort.
|
||||
function anniversaryDay(row) {
|
||||
if (typeof row.anniversary_day === "number" && row.anniversary_day >= 1 && row.anniversary_day <= 31) {
|
||||
return row.anniversary_day;
|
||||
}
|
||||
const d = new Date(row.last_renewal_at);
|
||||
return Number.isFinite(d.getTime()) ? d.getUTCDate() : 1;
|
||||
}
|
||||
|
||||
// Step the renewal forward until next_renewal > now. Handles rows
|
||||
// that have been dormant for several months and need multiple rollovers
|
||||
// in one shot. Mutates `row` in place; caller is responsible for
|
||||
// persisting when something changed.
|
||||
function ensureRenewalRollover(row) {
|
||||
if (!row.last_renewal_at) {
|
||||
row.last_renewal_at = new Date().toISOString();
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const anchorDay = anniversaryDay(row);
|
||||
let last = new Date(row.last_renewal_at);
|
||||
let next = nextRenewalAfter(last, anchorDay);
|
||||
let rolled = false;
|
||||
while (next.getTime() <= now) {
|
||||
last = next;
|
||||
next = nextRenewalAfter(last, anchorDay);
|
||||
rolled = true;
|
||||
}
|
||||
if (rolled) {
|
||||
row.last_renewal_at = last.toISOString();
|
||||
row.monthly_consumed = 0;
|
||||
row.monthly_gemini_consumed = 0;
|
||||
}
|
||||
return row;
|
||||
return rolled;
|
||||
}
|
||||
|
||||
function blankRow(installId) {
|
||||
const now = new Date();
|
||||
return {
|
||||
install_id: installId,
|
||||
tier_snapshot: "core",
|
||||
lifetime_consumed: 0,
|
||||
lifetime_gemini_consumed: 0,
|
||||
month: currentMonthKey(),
|
||||
last_renewal_at: now.toISOString(),
|
||||
anniversary_day: now.getUTCDate(),
|
||||
monthly_consumed: 0,
|
||||
monthly_gemini_consumed: 0,
|
||||
last_active_at: new Date().toISOString(),
|
||||
last_active_at: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,12 +185,15 @@ async function persist() {
|
||||
export async function getOrCreateRow(installId) {
|
||||
if (!installId) throw new Error("getOrCreateRow: installId required");
|
||||
let row = ledger.rows[installId];
|
||||
let dirty = false;
|
||||
if (!row) {
|
||||
row = blankRow(installId);
|
||||
ledger.rows[installId] = row;
|
||||
await persist();
|
||||
dirty = true;
|
||||
}
|
||||
return ensureCurrentMonth(row);
|
||||
if (ensureRenewalRollover(row)) dirty = true;
|
||||
if (dirty) await persist();
|
||||
return row;
|
||||
}
|
||||
|
||||
// Compute the remaining balance for a row against its tier's quota.
|
||||
@@ -178,9 +273,25 @@ export function planBackend(row, quota, { hasHardware }) {
|
||||
// Tracks Gemini-vs-hardware separately for Core (lifetime_gemini_consumed)
|
||||
// and paid tiers (monthly_gemini_consumed) so the planner can enforce
|
||||
// the per-tier Gemini cap.
|
||||
//
|
||||
// When a previously-Core install presents a paid license for the first
|
||||
// time, we treat THIS moment as the start of their billing period and
|
||||
// anchor last_renewal_at + anniversary_day to now. That way a user who
|
||||
// upgrades on the 17th gets renewals on the 17th going forward, not
|
||||
// at some earlier date that happens to be when their install_id was
|
||||
// first seen.
|
||||
export async function commitCredit(installId, { backend, tier }) {
|
||||
const row = await getOrCreateRow(installId);
|
||||
const wasCorePromotion =
|
||||
tier !== "core" && row.tier_snapshot === "core";
|
||||
row.tier_snapshot = tier;
|
||||
if (wasCorePromotion) {
|
||||
const now = new Date();
|
||||
row.last_renewal_at = now.toISOString();
|
||||
row.anniversary_day = now.getUTCDate();
|
||||
row.monthly_consumed = 0;
|
||||
row.monthly_gemini_consumed = 0;
|
||||
}
|
||||
if (tier === "core") {
|
||||
row.lifetime_consumed = (row.lifetime_consumed || 0) + 1;
|
||||
if (backend === "gemini") {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "recap-relay-server",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -70,7 +70,7 @@ const inputSpec = InputSpec.of({
|
||||
pro_monthly: Value.number({
|
||||
name: 'Pro — Monthly Credits',
|
||||
description:
|
||||
'Total credits a Pro user gets each calendar month. Resets on the 1st. Default 50.',
|
||||
'Total credits a Pro user gets each billing period. Renewals are calendar-anniversary based: a user who first activates on the 17th renews on the 17th of every following month (clamps to the last day for shorter months — e.g. Jan 31 → Feb 28/29 → Mar 31). Default 50.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
|
||||
@@ -6,8 +6,9 @@ import { v_0_2_2 } from './v0.2.2'
|
||||
import { v_0_2_3 } from './v0.2.3'
|
||||
import { v_0_2_4 } from './v0.2.4'
|
||||
import { v_0_2_5 } from './v0.2.5'
|
||||
import { v_0_2_6 } from './v0.2.6'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_2_5,
|
||||
other: [v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
||||
current: v_0_2_6,
|
||||
other: [v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
export const v_0_2_6 = VersionInfo.of({
|
||||
version: '0.2.6:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Calendar-anniversary billing replaces calendar-month resets. A user who first activates on the 17th now renews on the 17th of every subsequent month (clamps to last day for shorter months — Jan 31 → Feb 28/29 → Mar 31, standard Stripe-style edge-case handling). Pre-existing credit rows migrate transparently on startup. The admin dashboard JSON now surfaces last_renewal_at + anniversary_day so you can audit when each install renews.',
|
||||
},
|
||||
migrations: {
|
||||
// No config-side migration needed — the ledger migrates itself
|
||||
// when initCredits() runs on first boot of this version. Rows
|
||||
// with the legacy { month: "YYYY-MM" } shape get a
|
||||
// last_renewal_at anchored at the first of that month so they
|
||||
// don't unexpectedly re-issue credits.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user