@subvalue/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,7 @@ npm run demo
42
42
  - **Receipt:** local canvas preview and PNG export of the exact same pixels. No projects, paths, requests or sessions in exports.
43
43
  - **Settings:** Sources, Billing, USD, Privacy / data. Inactive providers stay here, with optional manual inclusion.
44
44
 
45
- First run shows detected history, then offers subscription plans directly for each provider with imported usage. Choose a plan, a custom USD amount, or **No subscription** independently for Codex and Claude Code. No subscription requires no amount and never implies API billing. **Set up later** leaves billing unknown.
45
+ First run offers subscription plans for each provider with imported usage. Choose a plan, a custom USD amount, or **No subscription · API** independently for Codex and Claude Code. Subscription + API is available as a checkbox. Unconfigured or legacy no-subscription settings default to API as an editable preference; this does not prove how any historical request was billed. Actual API payments remain unknown until entered for a specific period.
46
46
 
47
47
  Month opens a floating calendar-month picker (including historical months). Previous/next arrows move by one calendar month, seven or thirty days for rolling presets, or the selected custom duration. Comparisons use the full declared monthly price for each complete calendar month selected. The current month is marked In progress. 7D, 30D and partial custom ranges show API-equivalent usage without subscription comparisons. Custom ranges covering whole calendar months compare the corresponding number of monthly charges. Amounts and API prices are USD; no invented exchange rates. Billing declarations do not overwrite historical records or prove their original billing mode. Settings offers bundled, verified ChatGPT/Claude plan presets and a custom amount. Annual presets retain the exact annual price divided by 12, with per-seat plans labeled.
48
48
 
@@ -68,7 +68,11 @@ Codex excludes inherited ordinal prefixes, uses cumulative deltas, suppresses re
68
68
 
69
69
  The versioned catalog supports mappings with confidence and primary-source provenance, half-open effective intervals, cache reads/writes, Claude cache durations, and long-context bands. Rates and release evidence were checked against [OpenAI pricing](https://developers.openai.com/api/docs/pricing) and [Claude pricing](https://platform.claude.com/docs/en/about-claude/pricing) on **2026-10-05**. Historical versions include the July 30 Luna/Terra reductions and August 21 Sol promotion. Exact public GPT-5.6/6 and Claude identifiers carry mapping provenance; long-context rates use the verified 272k request threshold. New-model launch days use their documented initial API-equivalent rate. Calendar-dated changes between rates remain unknown because their exact UTC switch time is not documented. Sol pricing after the confirmed promotion remains unavailable. See [the pricing evidence ledger](dist/PRICING.md). Internal/unknown model identifiers are never mapped by resemblance.
70
70
 
71
- Overview and receipt display the sum of priceable events; the compact priced percentage identifies partial pricing, and unpriced model identities remain visible in Details. No guessed price or zero is assigned to unknown events. Both views share a display comparison of the known priced amount with the declared full-month subscription: Observed Value / You Saved is their difference, and Value Multiple is their ratio. The normalized summary still marks the full-usage cost and comparison unknown when pricing is incomplete. Partial coverage remains visible; these observed values do not infer a certain outcome or an exact break-even date. No subscription, unknown billing, and legacy API/mixed settings have no subscription comparison. Combined subscription comparisons require a declared subscription for every active provider.
71
+ Overview and receipt display the sum of priceable events; partial pricing stays visible and unknown model prices are never invented. Value is the displayed API equivalent minus total paid; Value Multiple is API equivalent divided by total paid (subscription plus declared API payments). A missing payment stays unknown; an explicit zero is accepted but cannot be a multiplier denominator. Comparisons remain limited to complete calendar months. Partial pricing uses observed values without a certain outcome or break-even date.
72
+
73
+ Expense controls appear inside a provider card only for mixed billing, or when that provider has no subscription while the other active provider has a subscription. Select a plan using the same menu as onboarding, or enter actual API spend; mixed mode starts its editable API field at $0. **Save** applies only to the displayed period and provider. A complete single month is stored as a monthly override, shared by Month and an equivalent Custom selection. Other ranges use an exact civil-date range key; amounts are not copied to other selections. API spend over several whole months is a total for that range and is counted once. Settings also provides per-period editing and reset to the default. Saved overrides survive changes to the usual plan, receipt theme changes and relaunch. The scanner and usage records are unchanged.
74
+
75
+ When an active provider has not supplied its expenses, a labeled comparison uses only providers with known expenses; the overall API-equivalent figure still includes all observed usage. Empty periods show imported history bounds.
72
76
 
73
77
  ## Architecture
74
78
 
@@ -9,6 +9,8 @@ import { demoData } from '../../core/src/demo.js';
9
9
  import {
10
10
  defaultSettings,
11
11
 
12
+
13
+
12
14
 
13
15
 
14
16
  } from '../../core/src/types.js';
@@ -17,6 +19,7 @@ import {
17
19
  import { isRecord } from '../../core/src/metadata.js';
18
20
  import { safeDirectory } from '../../core/src/security.js';
19
21
  import { subscriptionPlan } from '../../core/src/subscriptions.js';
22
+ import { shiftCalendarDays } from '../../core/src/calendar.js';
20
23
  const mime = {
21
24
  '.html': 'text/html; charset=utf-8',
22
25
  '.js': 'text/javascript; charset=utf-8',
@@ -29,6 +32,34 @@ const billingModes = ['UNKNOWN', 'SUBSCRIPTION', 'NO_SUBSCRIPTION', 'API', 'MIXE
29
32
  const isBillingMode = (value ) =>
30
33
  billingModes.some((mode) => mode === value);
31
34
 
35
+ function validateBilling(provider , value , monthly = false) {
36
+ if (!isRecord(value) || !isBillingMode(value.mode)) throw new Error('Invalid billing settings');
37
+ const amount = (n ) =>
38
+ n === null || (typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1000000);
39
+ if (!amount(value.monthly)) throw new Error('Invalid subscription amount');
40
+ if (value.apiSpend !== undefined && !amount(value.apiSpend)) throw new Error('Invalid API spend');
41
+ if (!monthly && value.apiSpend != null) throw new Error('API spend belongs to a specific month');
42
+ const planId = value.planId;
43
+ if (planId != null && typeof planId !== 'string') throw new Error('Invalid subscription plan');
44
+ const plan = planId == null ? undefined : subscriptionPlan(provider, planId);
45
+ if (planId != null && !plan) throw new Error('Invalid subscription plan');
46
+ const subscribed = value.mode === 'SUBSCRIPTION' || value.mode === 'MIXED';
47
+ if (
48
+ subscribed &&
49
+ plan &&
50
+ (value.monthly === null || Math.abs(Number(value.monthly) - plan.monthly) > 0.005)
51
+ )
52
+ throw new Error('Subscription amount does not match the selected plan');
53
+ return {
54
+ mode: value.mode,
55
+ monthly: subscribed ? (plan?.monthly ?? (value.monthly )) : null,
56
+ ...(subscribed && planId !== undefined ? { planId: plan?.id ?? null } : {}),
57
+ ...(monthly &&
58
+ (value.mode === 'API' || value.mode === 'MIXED' || value.mode === 'NO_SUBSCRIPTION')
59
+ ? { apiSpend: (value.apiSpend ) ?? null }
60
+ : {}),
61
+ };
62
+ }
32
63
  export function validateSettings(value ) {
33
64
  if (!isRecord(value) || value.currency !== 'USD' || typeof value.onboarded !== 'boolean')
34
65
  throw new Error('Invalid settings');
@@ -42,33 +73,49 @@ export function validateSettings(value ) {
42
73
  settings.receiptTheme = value.receiptTheme;
43
74
  }
44
75
  for (const provider of ['codex', 'claude'] ) {
45
- const billing = value.billing[provider];
46
- const included = value.include[provider];
47
- if (!isRecord(billing) || !isBillingMode(billing.mode) || typeof included !== 'boolean')
48
- throw new Error('Invalid billing settings');
49
- const monthly = billing.monthly;
50
- if (
51
- monthly !== null &&
52
- (typeof monthly !== 'number' || !Number.isFinite(monthly) || monthly < 0 || monthly > 1000000)
53
- )
54
- throw new Error('Invalid subscription amount');
55
- const planId = billing.planId;
56
- if (planId != null && typeof planId !== 'string') throw new Error('Invalid subscription plan');
57
- const plan = planId == null ? undefined : subscriptionPlan(provider, planId);
58
- if (planId != null && !plan) throw new Error('Invalid subscription plan');
59
- if (
60
- billing.mode === 'SUBSCRIPTION' &&
61
- plan &&
62
- (monthly === null || Math.abs(monthly - plan.monthly) > 0.005)
63
- )
64
- throw new Error('Subscription amount does not match the selected plan');
65
- settings.billing[provider] = {
66
- mode: billing.mode,
67
- monthly: billing.mode === 'SUBSCRIPTION' ? (plan?.monthly ?? monthly) : null,
68
- };
69
- if (billing.mode === 'SUBSCRIPTION' && planId !== undefined)
70
- settings.billing[provider].planId = plan?.id ?? null;
71
- settings.include[provider] = included;
76
+ if (typeof value.include[provider] !== 'boolean') throw new Error('Invalid billing settings');
77
+ settings.billing[provider] = validateBilling(provider, value.billing[provider]);
78
+ settings.include[provider] = value.include[provider];
79
+ }
80
+ if (value.monthlyBilling !== undefined) {
81
+ if (!isRecord(value.monthlyBilling) || Object.keys(value.monthlyBilling).length > 240)
82
+ throw new Error('Invalid monthly billing');
83
+ settings.monthlyBilling = {};
84
+ for (const [month, providers] of Object.entries(value.monthlyBilling)) {
85
+ if (
86
+ !/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month) ||
87
+ !isRecord(providers) ||
88
+ Object.keys(providers).some((provider) => provider !== 'codex' && provider !== 'claude')
89
+ )
90
+ throw new Error('Invalid monthly billing');
91
+ const entry = {};
92
+ for (const provider of ['codex', 'claude'] )
93
+ if (providers[provider] !== undefined)
94
+ entry[provider] = validateBilling(provider, providers[provider], true);
95
+ settings.monthlyBilling[month] = entry;
96
+ }
97
+ }
98
+ if (value.periodBilling !== undefined) {
99
+ if (!isRecord(value.periodBilling) || Object.keys(value.periodBilling).length > 240)
100
+ throw new Error('Invalid period billing');
101
+ settings.periodBilling = {};
102
+ for (const [key, providers] of Object.entries(value.periodBilling)) {
103
+ const [from, to] = key.split(':');
104
+ if (
105
+ !/^\d{4}-\d{2}-\d{2}:\d{4}-\d{2}-\d{2}$/.test(key) ||
106
+ shiftCalendarDays(from, 0) === null ||
107
+ shiftCalendarDays(to, 0) === null ||
108
+ from > to ||
109
+ !isRecord(providers) ||
110
+ Object.keys(providers).some((p) => p !== 'codex' && p !== 'claude')
111
+ )
112
+ throw new Error('Invalid period billing');
113
+ const entry = {};
114
+ for (const provider of ['codex', 'claude'] )
115
+ if (providers[provider] !== undefined)
116
+ entry[provider] = validateBilling(provider, providers[provider], true);
117
+ settings.periodBilling[key] = entry;
118
+ }
72
119
  }
73
120
  return settings;
74
121
  }
@@ -0,0 +1,112 @@
1
+
2
+
3
+ import { fullCalendarMonths, shiftCalendarMonth } from './calendar.js';
4
+
5
+ function civilDate(date ) {
6
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
7
+ }
8
+ export function billingPeriodKey(range ) {
9
+ return (
10
+ (range.calendarFrom ?? civilDate(new Date(range.from))) +
11
+ ':' +
12
+ (range.calendarTo ?? civilDate(new Date(Date.parse(range.until) - 1)))
13
+ );
14
+ }
15
+ export function withPeriodBilling(
16
+ settings ,
17
+ provider ,
18
+ range ,
19
+ billing ,
20
+ ) {
21
+ const next = structuredClone(settings),
22
+ exactKey = billingPeriodKey(range),
23
+ monthly = fullCalendarMonths(range) === 1,
24
+ key = monthly ? exactKey.slice(0, 7) : exactKey,
25
+ declarations = monthly ? (next.monthlyBilling ??= {}) : (next.periodBilling ??= {});
26
+ // A rolling range can share dates with a calendar month. Editing that month
27
+ // supersedes an earlier exact-date declaration instead of leaving a stale override.
28
+ if (monthly && next.periodBilling?.[exactKey]?.[provider]) {
29
+ delete next.periodBilling[exactKey][provider];
30
+ if (!Object.keys(next.periodBilling[exactKey]).length) delete next.periodBilling[exactKey];
31
+ }
32
+ declarations[key] ??= {};
33
+ if (billing) declarations[key][provider] = billing;
34
+ else delete declarations[key][provider];
35
+ if (!Object.keys(declarations[key]).length) delete declarations[key];
36
+ return next;
37
+ }
38
+ export function billingForSelection(settings , provider , range ) {
39
+ const exact = settings.periodBilling?.[billingPeriodKey(range)]?.[provider];
40
+ if (exact) return exact;
41
+ if (fullCalendarMonths(range) === 1)
42
+ return billingForMonth(settings, provider, billingPeriodKey(range).slice(0, 7));
43
+ return billingForMonth({ ...settings, monthlyBilling: undefined }, provider, '');
44
+ }
45
+
46
+ /** Keep the dashboard action limited to mixed billing or a second provider without a plan. */
47
+ export function showExpenseAction(
48
+ provider ,
49
+ providers ,
50
+ ) {
51
+ if (!provider.records) return false;
52
+ if (provider.mode === 'MIXED') return true;
53
+ return (
54
+ (provider.mode === 'API' ||
55
+ provider.mode === 'NO_SUBSCRIPTION' ||
56
+ provider.mode === 'UNKNOWN') &&
57
+ providers.some(
58
+ (other) =>
59
+ other.provider !== provider.provider &&
60
+ other.records > 0 &&
61
+ (other.mode === 'SUBSCRIPTION' || other.mode === 'MIXED') &&
62
+ (other.monthly != null || (other.comparison.subscription ?? 0) > 0),
63
+ )
64
+ );
65
+ }
66
+
67
+ /** A default is a user preference, not evidence of how a historical request was billed. */
68
+ export function billingForMonth(settings , provider , month ) {
69
+ const saved = settings.monthlyBilling?.[month]?.[provider] ?? settings.billing[provider];
70
+ return {
71
+ ...saved,
72
+ mode: saved.mode === 'UNKNOWN' || saved.mode === 'NO_SUBSCRIPTION' ? 'API' : saved.mode,
73
+ };
74
+ }
75
+
76
+ export function billingForRange(settings , provider , range ) {
77
+ const count = fullCalendarMonths(range);
78
+ const exact = settings.periodBilling?.[billingPeriodKey(range)]?.[provider];
79
+ const start = new Date(range.from);
80
+ const first =
81
+ range.calendarFrom?.slice(0, 7) ??
82
+ `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`;
83
+ const months = Array.from(
84
+ { length: count ?? 1 },
85
+ (_, index) => exact ?? billingForMonth(settings, provider, shiftCalendarMonth(first, index) ),
86
+ );
87
+ const mode = months.every((b) => b.mode === months[0].mode)
88
+ ? months[0].mode
89
+ : 'MIXED';
90
+ const subscriptionKnown = months.every((b) => b.mode === 'API' || b.monthly !== null);
91
+ const apiKnown = months.every((b) => b.mode === 'SUBSCRIPTION' || b.apiSpend != null);
92
+ const subscription =
93
+ count !== null && subscriptionKnown
94
+ ? months.reduce((total, b) => total + (b.mode === 'API' ? 0 : b.monthly ), 0)
95
+ : null;
96
+ const apiSpend =
97
+ count !== null && apiKnown
98
+ ? exact
99
+ ? exact.mode === 'SUBSCRIPTION'
100
+ ? 0
101
+ : exact.apiSpend
102
+ : months.reduce((total, b) => total + (b.mode === 'SUBSCRIPTION' ? 0 : b.apiSpend ), 0)
103
+ : null;
104
+ return {
105
+ mode,
106
+ monthly: months.length === 1 ? months[0].monthly : null,
107
+ subscription,
108
+ apiSpend,
109
+ paid: subscription !== null && apiSpend !== null ? subscription + apiSpend : null,
110
+ needsBilling: !subscriptionKnown || !apiKnown,
111
+ };
112
+ }
@@ -1,3 +1,4 @@
1
+ import { billingForRange } from './billing.js';
1
2
 
2
3
  import { priceRecord, catalog, } from './pricing/index.js';
3
4
  import { fullCalendarMonths, shiftCalendarDays } from './calendar.js';
@@ -88,6 +89,8 @@ export function dateRange(
88
89
  return { ...range, calendarMonths: fullCalendarMonths(range) };
89
90
  }
90
91
 
92
+
93
+
91
94
 
92
95
 
93
96
 
@@ -118,6 +121,7 @@ export function subscriptionForRange(monthly , range )
118
121
 
119
122
 
120
123
 
124
+
121
125
 
122
126
 
123
127
 
@@ -199,52 +203,46 @@ export function summarize(
199
203
  const historyPartial = !demo; // Local transcripts cannot prove that all historical requests were retained.
200
204
  const completePricing = rows.length > 0 && priced.length === rows.length;
201
205
  const apiEquivalent = completePricing ? subtotal : null;
202
- const billing = settings.billing[provider];
203
- const subscription =
204
- billing.mode === 'SUBSCRIPTION' && billing.monthly !== null
205
- ? subscriptionForRange(billing.monthly, range)
206
- : null;
207
- let comparison = neutral(
208
- billing.mode === 'NO_SUBSCRIPTION'
209
- ? 'No subscription'
210
- : billing.mode === 'API'
211
- ? 'API usage'
212
- : billing.mode === 'MIXED'
213
- ? 'Mixed billing'
214
- : billing.mode === 'UNKNOWN'
215
- ? 'Billing not set'
216
- : fullCalendarMonths(range) === null
217
- ? 'Choose a full month'
218
- : subscription === null
219
- ? 'Subscription price not set'
220
- : !completePricing
221
- ? 'Pricing unavailable'
222
- : 'Partial history',
223
- subscription,
224
- );
225
- if (completePricing && subscription !== null) {
226
- const value = apiEquivalent - subscription;
206
+ const billing = billingForRange(settings, provider, range);
207
+ const { subscription, apiSpend, paid } = billing;
208
+ let comparison = {
209
+ ...neutral(
210
+ fullCalendarMonths(range) === null
211
+ ? 'Choose a full month'
212
+ : paid === null
213
+ ? 'Expenses not set'
214
+ : 'Partial history',
215
+ subscription,
216
+ ),
217
+ apiSpend,
218
+ paid,
219
+ };
220
+ if (completePricing && paid !== null) {
221
+ const value = apiEquivalent - paid;
227
222
  let accumulated = 0;
228
223
  let breakEven = null;
229
224
  for (const d of daily) {
230
225
  accumulated += d[provider] ?? 0;
231
- if (accumulated >= subscription) {
226
+ if (accumulated >= paid) {
232
227
  breakEven = d.date;
233
228
  break;
234
229
  }
235
230
  }
236
231
  comparison = {
237
232
  subscription,
233
+ apiSpend,
234
+ paid,
238
235
  value,
239
- roi: subscription > 0 ? apiEquivalent / subscription : null,
236
+ roi: paid > 0 ? apiEquivalent / paid : null,
240
237
  breakEven: historyPartial ? null : breakEven,
241
- outcome: historyPartial
242
- ? 'neutral'
243
- : value > 0
244
- ? 'positive'
245
- : value < 0
246
- ? 'negative'
247
- : 'neutral',
238
+ outcome:
239
+ historyPartial || subscription === 0
240
+ ? 'neutral'
241
+ : value > 0
242
+ ? 'positive'
243
+ : value < 0
244
+ ? 'negative'
245
+ : 'neutral',
248
246
  reason: historyPartial ? 'Observed usage · Partial history' : null,
249
247
  };
250
248
  }
@@ -299,6 +297,7 @@ export function summarize(
299
297
  comparison,
300
298
  mode: billing.mode,
301
299
  monthly: billing.monthly,
300
+ needsBilling: billing.needsBilling,
302
301
  first: timestamps[0] ?? null,
303
302
  last: timestamps.at(-1) ?? null,
304
303
  days: new Set(timestamps.map((t) => t.slice(0, 10))).size,
@@ -311,43 +310,43 @@ export function summarize(
311
310
  const known = active.filter((p) => p.knownSubtotal !== null);
312
311
  const knownSubtotal = known.length ? known.reduce((n, p) => n + p.knownSubtotal , 0) : null;
313
312
  const apiEquivalent = count > 0 && pricedRecords === count ? knownSubtotal : null;
314
- const subs = active.filter((p) => p.mode === 'SUBSCRIPTION');
315
- let comparison = neutral(
316
- active.some((p) => p.mode !== 'SUBSCRIPTION')
317
- ? 'Subscription comparison unavailable'
318
- : 'Partial history',
319
- );
320
- if (
321
- subs.length &&
322
- subs.length === active.length &&
323
- subs.every((p) => p.comparison.value !== null)
324
- ) {
325
- const subscription = subs.reduce((n, p) => n + p.comparison.subscription , 0);
326
- const value = apiEquivalent - subscription;
327
- let accumulated = 0,
328
- breakEven = null;
329
- if (demo)
330
- for (const d of daily) {
331
- accumulated += (d.codex ?? 0) + (d.claude ?? 0);
332
- if (accumulated >= subscription) {
333
- breakEven = d.date;
334
- break;
313
+ let comparison = neutral('Expenses not set');
314
+ if (active.length && active.every((p) => p.comparison.paid != null)) {
315
+ const subscription = active.reduce((n, p) => n + p.comparison.subscription , 0),
316
+ apiSpend = active.reduce((n, p) => n + p.comparison.apiSpend , 0),
317
+ paid = subscription + apiSpend;
318
+ comparison = { ...neutral('Partial history', subscription), apiSpend, paid };
319
+ if (apiEquivalent !== null) {
320
+ const value = apiEquivalent - paid;
321
+ let accumulated = 0,
322
+ breakEven = null;
323
+ if (demo)
324
+ for (const d of daily) {
325
+ accumulated += (d.codex ?? 0) + (d.claude ?? 0);
326
+ if (accumulated >= paid) {
327
+ breakEven = d.date;
328
+ break;
329
+ }
335
330
  }
336
- }
337
- comparison = {
338
- subscription,
339
- value,
340
- roi: subscription > 0 ? apiEquivalent / subscription : null,
341
- breakEven,
342
- outcome: demo ? (value > 0 ? 'positive' : value < 0 ? 'negative' : 'neutral') : 'neutral',
343
- reason: demo ? null : 'Observed usage · Partial history',
344
- };
345
- } else if (
346
- subs.length &&
347
- subs.length === active.length &&
348
- subs.every((p) => p.comparison.subscription !== null)
349
- )
350
- comparison.subscription = subs.reduce((n, p) => n + p.comparison.subscription , 0);
331
+ comparison = {
332
+ subscription,
333
+ apiSpend,
334
+ paid,
335
+ value,
336
+ roi: paid > 0 ? apiEquivalent / paid : null,
337
+ breakEven,
338
+ outcome:
339
+ demo && subscription > 0
340
+ ? value > 0
341
+ ? 'positive'
342
+ : value < 0
343
+ ? 'negative'
344
+ : 'neutral'
345
+ : 'neutral',
346
+ reason: demo ? null : 'Observed usage · Partial history',
347
+ };
348
+ }
349
+ }
351
350
  const confidence = active.length
352
351
  ? ['INCOMPLETE', 'LOW', 'MEDIUM', 'HIGH'].find((q) => active.some((p) => p.confidence === q))
353
352
  : 'NO DATA';
@@ -1,7 +1,13 @@
1
1
 
2
2
 
3
- // API and MIXED remain readable for existing local settings.
4
3
 
4
+
5
+
6
+
7
+
8
+
9
+
10
+
5
11
 
6
12
 
7
13
 
@@ -60,7 +66,9 @@
60
66
 
61
67
 
62
68
 
63
-
69
+
70
+
71
+
64
72
 
65
73
  export const defaultSettings = () => ({
66
74
  onboarded: false,