@xuda.io/account_module 1.2.2311 → 1.2.2312

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/index.mjs CHANGED
@@ -7839,9 +7839,11 @@ const _list_owner_members = async function (owner_uid, profiles) {
7839
7839
  const acc = await db_module.get_couch_doc_native('xuda_accounts', u);
7840
7840
  const ai = acc?.account_info || {};
7841
7841
  const name = ai.account_type === 'business' ? ai.business_name || 'Member' : `${ai.first_name || ''} ${ai.last_name || ''}`.trim() || ai.email || 'Member';
7842
- out.push({ uid: u, name });
7842
+ // owner is reported (UI-163) so the member card can say so without the
7843
+ // dashboard having to infer it from the order of this list.
7844
+ out.push({ uid: u, name, owner: u === owner_uid });
7843
7845
  } catch (e) {
7844
- out.push({ uid: u, name: 'Member' });
7846
+ out.push({ uid: u, name: 'Member', owner: u === owner_uid });
7845
7847
  }
7846
7848
  }
7847
7849
  return out;
@@ -7882,7 +7884,139 @@ export const get_credit_management = async function (req) {
7882
7884
  const profiles = await _list_owner_profiles(uid, app_id, account_doc?.account_profile_id);
7883
7885
  const members = await _list_owner_members(uid, profiles);
7884
7886
 
7885
- return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members } };
7887
+ // main_profile_id is reported (UI-163) so the spend chart can name the
7888
+ // owner's own profile: it is deliberately absent from `profiles` (no member
7889
+ // to scope a rule to), and without the id its spend can only be labelled
7890
+ // "some other profile".
7891
+ return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members, main_profile_id: account_doc?.account_profile_id || '' } };
7892
+ } catch (err) {
7893
+ return { code: -1, data: err.message };
7894
+ }
7895
+ };
7896
+
7897
+ /////////////////////////////////////////////////////////////////////////
7898
+ // USAGE HISTORY (UI-163)
7899
+ //
7900
+ // The drill-down behind the "N credits used" line on a profile or member card:
7901
+ // day by day spend, which models it went on, and the calls themselves. It reads
7902
+ // the same raw ai_usage docs _get_scoped_usage aggregates, and calibrates them
7903
+ // the same way, so a card's total and its history cannot tell two stories.
7904
+ //
7905
+ // Calibration: raw token cost is not the credit unit the ledger reports, so the
7906
+ // scan also covers the CURRENT PERIOD and the two totals give the conversion
7907
+ // factor (authoritative period spend / raw period cost). That is a unit
7908
+ // conversion, so it holds for any window inside the scan.
7909
+ /////////////////////////////////////////////////////////////////////////
7910
+ const _CREDIT_HISTORY_MAX_DOCS = 20000;
7911
+ const _CREDIT_HISTORY_MAX_DAYS = 180;
7912
+ const _CREDIT_HISTORY_SCAN_DAYS = 120;
7913
+ const _DAY_MS = 86400000;
7914
+ const _utc_day = function (ts) {
7915
+ return new Date(ts).toISOString().slice(0, 10);
7916
+ };
7917
+ const _raw_usage_cost = function (doc) {
7918
+ const c = doc?.cost || { input: 1, output: 1 };
7919
+ return ((Number(doc?.input_tokens) || 0) * (Number(c.input) || 0) + (Number(doc?.output_tokens) || 0) * (Number(c.output) || 0)) / 1e6;
7920
+ };
7921
+
7922
+ export const get_credit_usage_history = async function (req) {
7923
+ try {
7924
+ const uid = req?.uid || req?.token_ret?.data?.uid;
7925
+ if (!uid) return { code: -1, data: 'not authorized' };
7926
+
7927
+ const scope = ['profile', 'user'].includes(req?.scope) ? req.scope : 'account';
7928
+ const id = typeof req?.id === 'string' ? req.id : '';
7929
+ if (scope !== 'account' && !id) return { code: -1, data: 'id is required for a profile or member history' };
7930
+
7931
+ let days = Math.round(Number(req?.days) || 30);
7932
+ if (!(days >= 1)) days = 30;
7933
+ days = Math.min(_CREDIT_HISTORY_MAX_DAYS, days);
7934
+
7935
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
7936
+ const period = _current_credit_period(account_doc);
7937
+
7938
+ // UTC day buckets, so the series is the same series wherever it is read.
7939
+ const today_start = Math.floor(Date.now() / _DAY_MS) * _DAY_MS;
7940
+ const window_from = today_start - (days - 1) * _DAY_MS;
7941
+ // Reach back to the period start even when the window is shorter (the factor
7942
+ // needs it), but never further than the scan bound: a yearly period must not
7943
+ // turn one drill-down into a full-history scan.
7944
+ const scan_from = Math.max(Math.min(window_from, period.start), today_start - _CREDIT_HISTORY_SCAN_DAYS * _DAY_MS);
7945
+
7946
+ // ignore_warning=true: same nested selector as _get_scoped_usage, and the
7947
+ // same index note applies (an index on account_profile_info.uid before any
7948
+ // prod rollout).
7949
+ const raw = await db_module.find_couch_query(
7950
+ 'xuda_usage',
7951
+ {
7952
+ selector: { docType: 'ai_usage', 'account_profile_info.uid': uid, date_created_ts: { $gte: scan_from } },
7953
+ fields: ['date_created_ts', 'uid', 'model', 'source', 'prompt', 'input_tokens', 'output_tokens', 'cost', 'account_profile_info.account_profile_id'],
7954
+ limit: _CREDIT_HISTORY_MAX_DOCS,
7955
+ },
7956
+ true,
7957
+ );
7958
+ const docs = raw?.docs || [];
7959
+
7960
+ let raw_period_total = 0;
7961
+ for (const d of docs) if ((Number(d.date_created_ts) || 0) >= period.start) raw_period_total += _raw_usage_cost(d);
7962
+ const su = await _get_scoped_usage(uid, false);
7963
+ const factor = raw_period_total > 0 && su.total > 0 ? su.total / raw_period_total : 1;
7964
+
7965
+ const in_scope = function (d) {
7966
+ if (scope === 'profile') return d?.account_profile_info?.account_profile_id === id;
7967
+ if (scope === 'user') return d?.uid === id;
7968
+ return true;
7969
+ };
7970
+
7971
+ const series = {};
7972
+ for (let t = window_from; t <= today_start; t += _DAY_MS) series[_utc_day(t)] = 0;
7973
+
7974
+ const by_model = {};
7975
+ const events = [];
7976
+ let total = 0;
7977
+ for (const d of docs) {
7978
+ const ts = Number(d.date_created_ts) || 0;
7979
+ if (ts < window_from || !in_scope(d)) continue;
7980
+ const credits = _raw_usage_cost(d) * factor;
7981
+ total += credits;
7982
+ const day = _utc_day(ts);
7983
+ if (day in series) series[day] += credits;
7984
+ const code = _conf.ai_model_aliases?.[d.model] || d.model || 'unknown';
7985
+ by_model[code] = (by_model[code] || 0) + credits;
7986
+ events.push({
7987
+ ts,
7988
+ credits,
7989
+ model: code,
7990
+ model_name: _conf.ai_models?.[code]?.name || d.model || code,
7991
+ source: d.source || 'other',
7992
+ uid: d.uid || '',
7993
+ account_profile_id: d?.account_profile_info?.account_profile_id || '',
7994
+ prompt: typeof d.prompt === 'string' ? d.prompt : '',
7995
+ });
7996
+ }
7997
+ events.sort((a, b) => b.ts - a.ts);
7998
+
7999
+ const r2 = (n) => Math.round(n * 100) / 100;
8000
+ return {
8001
+ code: 1,
8002
+ data: {
8003
+ scope,
8004
+ id,
8005
+ days,
8006
+ total: r2(total),
8007
+ calls: events.length,
8008
+ // The scan is capped, so say so rather than quietly reporting a partial
8009
+ // history as the whole of it.
8010
+ truncated: docs.length >= _CREDIT_HISTORY_MAX_DOCS,
8011
+ series: Object.keys(series)
8012
+ .sort()
8013
+ .map((day) => ({ day, credits: r2(series[day]) })),
8014
+ by_model: Object.entries(by_model)
8015
+ .map(([code, credits]) => ({ code, name: _conf.ai_models?.[code]?.name || code, credits: r2(credits) }))
8016
+ .sort((a, b) => b.credits - a.credits),
8017
+ events: events.slice(0, 60).map((e) => ({ ...e, credits: Math.round(e.credits * 10000) / 10000 })),
8018
+ },
8019
+ };
7886
8020
  } catch (err) {
7887
8021
  return { code: -1, data: err.message };
7888
8022
  }
@@ -9156,7 +9290,10 @@ const _newsletter_blast = async (issue, by_uid) => {
9156
9290
  } catch (e) {
9157
9291
  console.error('[newsletter] finalize failed:', e.message);
9158
9292
  }
9159
- _ops_notify_ops(`Newsletter #${issue.issue_number} published`, [['Issue', '#' + issue.issue_number], ['Sent', sent], ['Failed', failed], ['Recipients', total]], 'NL-DONE-' + issue.issue_number);
9293
+ // No ops notification on completion: the "Publishing newsletter #N" alert
9294
+ // already announces the blast, and the result lands on the issue doc
9295
+ // (sent.count / sent.failed) plus the log line below.
9296
+ console.log(`[newsletter] #${issue.issue_number} published: sent=${sent} failed=${failed} recipients=${total}`);
9160
9297
  };
9161
9298
 
9162
9299
  export const newsletter_publish = async function (req = {}) {
package/index_ms.mjs CHANGED
@@ -541,6 +541,10 @@ export const get_credit_management = async function (...args) {
541
541
  return await broker.send_to_queue("get_credit_management", ...args);
542
542
  };
543
543
 
544
+ export const get_credit_usage_history = async function (...args) {
545
+ return await broker.send_to_queue("get_credit_usage_history", ...args);
546
+ };
547
+
544
548
  export const set_credit_rules = async function (...args) {
545
549
  return await broker.send_to_queue("set_credit_rules", ...args);
546
550
  };
package/index_msa.mjs CHANGED
@@ -541,6 +541,10 @@ export const get_credit_management = function (...args) {
541
541
  broker.send_to_queue_async("get_credit_management", ...args);
542
542
  };
543
543
 
544
+ export const get_credit_usage_history = function (...args) {
545
+ broker.send_to_queue_async("get_credit_usage_history", ...args);
546
+ };
547
+
544
548
  export const set_credit_rules = function (...args) {
545
549
  broker.send_to_queue_async("set_credit_rules", ...args);
546
550
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2311",
3
+ "version": "1.2.2312",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {