billing-logic.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Deduction order: monthly included first, then add-on rollover (Netlify-style). |
| 3 | */ |
| 4 | import { |
| 5 | MONTHLY_INCLUDED_CENTS_BY_TIER, |
| 6 | MONTHLY_INDEXING_TOKENS_INCLUDED_BY_TIER, |
| 7 | MONTHLY_SEARCHES_INCLUDED_BY_TIER, |
| 8 | MONTHLY_INDEX_JOBS_INCLUDED_BY_TIER, |
| 9 | CONSOLIDATION_PASSES_BY_TIER, |
| 10 | PACK_TOKENS, |
| 11 | PACK_CONSOLIDATIONS, |
| 12 | } from './billing-constants.mjs'; |
| 13 | import { |
| 14 | clampConsolidationInt, |
| 15 | HOSTED_CONSOL_DEFAULT_LOOKBACK_HOURS, |
| 16 | HOSTED_CONSOL_DEFAULT_MAX_EVENTS, |
| 17 | HOSTED_CONSOL_DEFAULT_MAX_TOPICS, |
| 18 | HOSTED_CONSOL_DEFAULT_LLM_MAX_TOKENS, |
| 19 | HOSTED_CONSOL_LOOKBACK_MIN, |
| 20 | HOSTED_CONSOL_LOOKBACK_MAX, |
| 21 | HOSTED_CONSOL_MAX_EVENTS_MIN, |
| 22 | HOSTED_CONSOL_MAX_EVENTS_MAX, |
| 23 | HOSTED_CONSOL_MAX_TOPICS_MIN, |
| 24 | HOSTED_CONSOL_MAX_TOPICS_MAX, |
| 25 | HOSTED_CONSOL_LLM_TOKENS_MIN, |
| 26 | HOSTED_CONSOL_LLM_TOKENS_MAX, |
| 27 | } from '../../lib/hosted-consolidation-advanced.mjs'; |
| 28 | |
| 29 | /** |
| 30 | * @param {object} u - Billing user record |
| 31 | * @returns {number|null} null = unlimited (beta or pro) |
| 32 | */ |
| 33 | export function effectiveMonthlyIndexingTokensIncluded(u) { |
| 34 | const tier = String(u?.tier || 'beta'); |
| 35 | if (tier === 'beta') return null; |
| 36 | const val = MONTHLY_INDEXING_TOKENS_INCLUDED_BY_TIER[tier]; |
| 37 | if (val === null || val === undefined) return null; |
| 38 | if (tier === 'free') return val; |
| 39 | return val; |
| 40 | } |
| 41 | |
| 42 | /** Ensure all billing fields exist on loaded JSON records. */ |
| 43 | export function normalizeBillingUser(u) { |
| 44 | if (!u || typeof u !== 'object') return u; |
| 45 | if (typeof u.monthly_indexing_tokens_used !== 'number' || !Number.isFinite(u.monthly_indexing_tokens_used)) { |
| 46 | u.monthly_indexing_tokens_used = 0; |
| 47 | } |
| 48 | if (typeof u.pack_indexing_tokens_balance !== 'number' || !Number.isFinite(u.pack_indexing_tokens_balance)) { |
| 49 | u.pack_indexing_tokens_balance = 0; |
| 50 | } |
| 51 | if (typeof u.pack_consolidation_passes_balance !== 'number' || !Number.isFinite(u.pack_consolidation_passes_balance)) { |
| 52 | u.pack_consolidation_passes_balance = 0; |
| 53 | } |
| 54 | if (u.pack_consolidation_legacy_inferred !== true) { |
| 55 | u.pack_consolidation_legacy_inferred = false; |
| 56 | } |
| 57 | if (typeof u.monthly_searches_used !== 'number' || !Number.isFinite(u.monthly_searches_used)) { |
| 58 | u.monthly_searches_used = 0; |
| 59 | } |
| 60 | if (typeof u.monthly_index_jobs_used !== 'number' || !Number.isFinite(u.monthly_index_jobs_used)) { |
| 61 | u.monthly_index_jobs_used = 0; |
| 62 | } |
| 63 | if (typeof u.monthly_consolidation_jobs_used !== 'number' || !Number.isFinite(u.monthly_consolidation_jobs_used)) { |
| 64 | u.monthly_consolidation_jobs_used = 0; |
| 65 | } |
| 66 | if (u.consolidation_enabled === undefined) { |
| 67 | u.consolidation_enabled = false; |
| 68 | } |
| 69 | if (u.consolidation_last_pass_at === undefined) { |
| 70 | u.consolidation_last_pass_at = null; |
| 71 | } |
| 72 | if (u.consolidation_interval_minutes === undefined) { |
| 73 | u.consolidation_interval_minutes = null; |
| 74 | } |
| 75 | if (!u.consolidation_passes || typeof u.consolidation_passes !== 'object') { |
| 76 | u.consolidation_passes = { consolidate: true, verify: true, discover: false }; |
| 77 | } |
| 78 | u.consolidation_lookback_hours = clampConsolidationInt( |
| 79 | u.consolidation_lookback_hours, |
| 80 | HOSTED_CONSOL_LOOKBACK_MIN, |
| 81 | HOSTED_CONSOL_LOOKBACK_MAX, |
| 82 | HOSTED_CONSOL_DEFAULT_LOOKBACK_HOURS, |
| 83 | ); |
| 84 | u.consolidation_max_events_per_pass = clampConsolidationInt( |
| 85 | u.consolidation_max_events_per_pass, |
| 86 | HOSTED_CONSOL_MAX_EVENTS_MIN, |
| 87 | HOSTED_CONSOL_MAX_EVENTS_MAX, |
| 88 | HOSTED_CONSOL_DEFAULT_MAX_EVENTS, |
| 89 | ); |
| 90 | u.consolidation_max_topics_per_pass = clampConsolidationInt( |
| 91 | u.consolidation_max_topics_per_pass, |
| 92 | HOSTED_CONSOL_MAX_TOPICS_MIN, |
| 93 | HOSTED_CONSOL_MAX_TOPICS_MAX, |
| 94 | HOSTED_CONSOL_DEFAULT_MAX_TOPICS, |
| 95 | ); |
| 96 | u.consolidation_llm_max_tokens = clampConsolidationInt( |
| 97 | u.consolidation_llm_max_tokens, |
| 98 | HOSTED_CONSOL_LLM_TOKENS_MIN, |
| 99 | HOSTED_CONSOL_LLM_TOKENS_MAX, |
| 100 | HOSTED_CONSOL_DEFAULT_LLM_MAX_TOKENS, |
| 101 | ); |
| 102 | return u; |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * @param {object} u - Billing user record |
| 107 | * @returns {number|null} null = unlimited |
| 108 | */ |
| 109 | export function effectiveMonthlySearchesIncluded(u) { |
| 110 | const tier = String(u?.tier || 'beta'); |
| 111 | const val = MONTHLY_SEARCHES_INCLUDED_BY_TIER[tier]; |
| 112 | return val === undefined ? null : val; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * @param {object} u - Billing user record |
| 117 | * @returns {number|null} null = unlimited |
| 118 | */ |
| 119 | export function effectiveMonthlyIndexJobsIncluded(u) { |
| 120 | const tier = String(u?.tier || 'beta'); |
| 121 | const val = MONTHLY_INDEX_JOBS_INCLUDED_BY_TIER[tier]; |
| 122 | return val === undefined ? null : val; |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * @param {object} u - Billing user record |
| 127 | * @returns {number|null} null = unlimited; 0 = no hosted consolidation on this tier |
| 128 | */ |
| 129 | export function effectiveMonthlyConsolidationPassesIncluded(u) { |
| 130 | const tier = String(u?.tier || 'beta'); |
| 131 | const val = CONSOLIDATION_PASSES_BY_TIER[tier]; |
| 132 | return val === undefined ? null : val; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Infer how many pack consolidation passes correspond to a remaining indexing-token pack balance. |
| 137 | * Uses greedy decomposition into known pack sizes (large → medium → small), then the small-pack |
| 138 | * ratio (20M tokens / 50 passes) for any remainder. Used once per account to backfill purchases |
| 139 | * made before `pack_consolidation_passes_balance` was credited in Stripe webhooks. |
| 140 | * |
| 141 | * @param {number} tokenBalance |
| 142 | * @returns {number} |
| 143 | */ |
| 144 | export function inferPackConsolidationPassesFromIndexingTokenBalance(tokenBalance) { |
| 145 | const n = Math.max(0, Math.floor(Number(tokenBalance) || 0)); |
| 146 | if (n <= 0) return 0; |
| 147 | let t = n; |
| 148 | let passes = 0; |
| 149 | const packs = [ |
| 150 | { tok: PACK_TOKENS.large, pass: PACK_CONSOLIDATIONS.large }, |
| 151 | { tok: PACK_TOKENS.medium, pass: PACK_CONSOLIDATIONS.medium }, |
| 152 | { tok: PACK_TOKENS.small, pass: PACK_CONSOLIDATIONS.small }, |
| 153 | ]; |
| 154 | for (const p of packs) { |
| 155 | while (t >= p.tok) { |
| 156 | passes += p.pass; |
| 157 | t -= p.tok; |
| 158 | } |
| 159 | } |
| 160 | if (t > 0) { |
| 161 | const tokensPerPass = PACK_TOKENS.small / PACK_CONSOLIDATIONS.small; |
| 162 | passes += Math.floor(t / tokensPerPass); |
| 163 | } |
| 164 | return passes; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * @param {object} user - Billing user record from store |
| 169 | * @param {number} costCents |
| 170 | * @returns {{ ok: boolean, code?: string }} |
| 171 | */ |
| 172 | export function tryDeduct(user, costCents) { |
| 173 | const cost = Math.max(0, Math.floor(Number(costCents) || 0)); |
| 174 | if (cost === 0) return { ok: true }; |
| 175 | |
| 176 | if (user.tier === 'beta') return { ok: true }; |
| 177 | |
| 178 | if (user.tier === 'free') { |
| 179 | user.monthly_included_cents = MONTHLY_INCLUDED_CENTS_BY_TIER.free ?? 0; |
| 180 | } |
| 181 | |
| 182 | const included = Math.max(0, Math.floor(Number(user.monthly_included_cents) || 0)); |
| 183 | const used = Math.max(0, Math.floor(Number(user.monthly_used_cents) || 0)); |
| 184 | const addon = Math.max(0, Math.floor(Number(user.addon_cents) || 0)); |
| 185 | |
| 186 | const remainingMonthly = Math.max(0, included - used); |
| 187 | |
| 188 | if (cost <= remainingMonthly) { |
| 189 | user.monthly_used_cents = used + cost; |
| 190 | return { ok: true }; |
| 191 | } |
| 192 | |
| 193 | const needFromAddon = cost - remainingMonthly; |
| 194 | if (needFromAddon <= addon) { |
| 195 | user.monthly_used_cents = included; |
| 196 | user.addon_cents = addon - needFromAddon; |
| 197 | return { ok: true }; |
| 198 | } |
| 199 | |
| 200 | return { ok: false, code: 'QUOTA_EXHAUSTED' }; |
| 201 | } |
| 202 | |
| 203 | export function defaultUserRecord(userId) { |
| 204 | return { |
| 205 | user_id: userId, |
| 206 | tier: 'beta', |
| 207 | stripe_customer_id: null, |
| 208 | stripe_subscription_id: null, |
| 209 | period_start: null, |
| 210 | period_end: null, |
| 211 | monthly_included_cents: 0, |
| 212 | monthly_used_cents: 0, |
| 213 | addon_cents: 0, |
| 214 | monthly_indexing_tokens_used: 0, |
| 215 | pack_indexing_tokens_balance: 0, |
| 216 | pack_consolidation_passes_balance: 0, |
| 217 | pack_consolidation_legacy_inferred: false, |
| 218 | monthly_searches_used: 0, |
| 219 | monthly_index_jobs_used: 0, |
| 220 | monthly_consolidation_jobs_used: 0, |
| 221 | consolidation_enabled: false, |
| 222 | consolidation_last_pass_at: null, |
| 223 | consolidation_interval_minutes: null, |
| 224 | consolidation_passes: { consolidate: true, verify: true, discover: false }, |
| 225 | consolidation_lookback_hours: HOSTED_CONSOL_DEFAULT_LOOKBACK_HOURS, |
| 226 | consolidation_max_events_per_pass: HOSTED_CONSOL_DEFAULT_MAX_EVENTS, |
| 227 | consolidation_max_topics_per_pass: HOSTED_CONSOL_DEFAULT_MAX_TOPICS, |
| 228 | consolidation_llm_max_tokens: HOSTED_CONSOL_DEFAULT_LLM_MAX_TOKENS, |
| 229 | }; |
| 230 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago