diff --git a/server/credits.js b/server/credits.js index 994fab6..be02f4f 100644 --- a/server/credits.js +++ b/server/credits.js @@ -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") { diff --git a/server/package.json b/server/package.json index 304ec80..13f06ce 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "recap-relay-server", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "private": true, "dependencies": { diff --git a/startos/actions/adjustTierQuotas.ts b/startos/actions/adjustTierQuotas.ts index 71ee76e..c074786 100644 --- a/startos/actions/adjustTierQuotas.ts +++ b/startos/actions/adjustTierQuotas.ts @@ -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, diff --git a/startos/versions/index.ts b/startos/versions/index.ts index 568e025..c823859 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -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], }) diff --git a/startos/versions/v0.2.6.ts b/startos/versions/v0.2.6.ts new file mode 100644 index 0000000..e039a3b --- /dev/null +++ b/startos/versions/v0.2.6.ts @@ -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 }) => {}, + }, +})