@xuda.io/account_module 1.2.2302 → 1.2.2304

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
@@ -139,6 +139,15 @@ export const _sync_account_project_name = async function (account_obj, ref_info)
139
139
  }
140
140
  };
141
141
 
142
+ // The profile PICTURE is open to everyone, including accounts that have not
143
+ // confirmed their email; the generated AVATAR is what waits for verification.
144
+ // stat 1 is the unverified state (stat 2 is the password-reset temp state,
145
+ // stat 3 verified), matching the write-gate in http_module — which now lets
146
+ // update_account_info and upload_drive_file_user through at stat 1 precisely so
147
+ // the picture can be saved. verify_account starts any deferred generation the
148
+ // moment the account flips to verified, so nothing is lost by waiting here.
149
+ const can_generate_avatar = (account_obj) => account_obj?.stat !== 1;
150
+
142
151
  export const update_account_info = async function (req, job_id, headers) {
143
152
  const { uid } = req;
144
153
  const data = req;
@@ -278,7 +287,7 @@ export const update_account_info = async function (req, job_id, headers) {
278
287
  return { code: -1310, data: error };
279
288
  }
280
289
  if (!change) {
281
- if (account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
290
+ if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
282
291
  set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
283
292
  }
284
293
  return { code: 1300, data: 'no change' };
@@ -317,7 +326,7 @@ export const update_account_info = async function (req, job_id, headers) {
317
326
  await _sync_account_project_name(account_obj, _old_name_info);
318
327
  }
319
328
 
320
- if (account_obj.account_info?.profile_picture) {
329
+ if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture) {
321
330
  if (!account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
322
331
  set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
323
332
  }
@@ -556,6 +565,159 @@ export const get_effective_entitlements = function (account_doc) {
556
565
  return { ai_credits, drive_gb };
557
566
  };
558
567
 
568
+ // ===================================================================
569
+ // Module subscriptions. Membership, support and AI workspace are the three
570
+ // core tiers the billing screen already renders off the account doc; every
571
+ // other suite is sold as its own PLAN_OBJ category billed as a line item on
572
+ // the consolidated subscription. This registry is what the billing screen
573
+ // lists under "Modules", so the customer can see which suites need a
574
+ // subscription and switch one on.
575
+ //
576
+ // scope 'account' → one plan for the whole account, held in `field`, changed
577
+ // with the module's own *_set_plan method.
578
+ // scope 'resource' → priced on the thing itself (a website, a phone number),
579
+ // so there is no account-wide plan to show, only a pointer
580
+ // to where it is set.
581
+ // scope 'metered' → no plan ladder at all, usage is metered per call. Listed
582
+ // so the customer can see it costs no subscription.
583
+ const _MODULE_SUBSCRIPTIONS = [
584
+ { key: 'commerce', scope: 'account', field: 'commerce_plan', default_plan: 'commerce_free' },
585
+ { key: 'shipping', scope: 'account', field: 'shipping_plan', default_plan: 'shipping_free' },
586
+ { key: 'finance', scope: 'account', field: 'finance_plan', default_plan: 'finance_free' },
587
+ // The desk screen has its own copy of this ladder (UI-82), because Tickets is
588
+ // switched on there as well as here. Both call tickets_set_plan, so the plan
589
+ // is one line item on the consolidated subscription either way.
590
+ { key: 'tickets', scope: 'account', field: 'tickets_plan', default_plan: 'tickets_free' },
591
+ // No free tier and no account field: the entitlement IS the subscription item
592
+ // (or a paid membership, which includes the standard tier at no charge).
593
+ { key: 'bot_protection', scope: 'account' },
594
+ { key: 'static_website', scope: 'resource' },
595
+ { key: 'profile_phone', scope: 'resource' },
596
+ // Xuda Verify (verify_module) has no PLAN_OBJ category: every check is metered
597
+ // per call into verify_meter and nothing is charged to the account today.
598
+ { key: 'trust_center', scope: 'metered' },
599
+ ];
600
+
601
+ // The individual paid things inside a per-resource module, so the billing
602
+ // screen can be the ONE place that shows everything the account pays for.
603
+ // A site or a number on a free tier is not a subscription and is left out.
604
+ const _module_resource_items = async (uid) => {
605
+ const items = { static_website: [], profile_phone: [] };
606
+
607
+ try {
608
+ const ret = await db_module.find_couch_query(
609
+ 'xuda_master',
610
+ { selector: { docType: 'app', app_type: 'static_website', app_uId: uid }, limit: 1000 },
611
+ true,
612
+ );
613
+ for (const app of ret?.docs || []) {
614
+ const tier = app.static_website?.plan_tier || app.deploy_data?.static_website_hosting_plan || 'free';
615
+ const plan = _conf.PLAN_OBJ?.[`static_website_${tier}`];
616
+ const price = Number(plan?.price) || 0;
617
+ if (!price) continue;
618
+ items.static_website.push({
619
+ id: app._id,
620
+ label: app.app_name || app.name || app.deploy_data?.domain || app._id,
621
+ plan_id: plan.id || `static_website_${tier}`,
622
+ plan_name: plan.name || tier,
623
+ price,
624
+ });
625
+ }
626
+ } catch (e) {
627
+ console.error('[get_module_subscriptions static_website]', e.message);
628
+ }
629
+
630
+ try {
631
+ const ret = await db_module.find_couch_query(
632
+ 'xuda_master',
633
+ { selector: { docType: 'voice_number', owner_uid: uid, status: { $ne: 'released' } }, limit: 500 },
634
+ true,
635
+ );
636
+ const profiles = new Map();
637
+ for (const number of ret?.docs || []) {
638
+ let profile = profiles.get(number.profile_id);
639
+ if (profile === undefined) {
640
+ const pr = await db_module.get_couch_doc('xuda_master', number.profile_id);
641
+ profile = pr.code > -1 ? pr.data : null;
642
+ profiles.set(number.profile_id, profile);
643
+ }
644
+ const tier = profile?.voice_plan?.tier || 'free';
645
+ const plan = _conf.PLAN_OBJ?.[`profile_phone_${tier}`];
646
+ // billing.monthly_total is the number's FULL monthly (rental plus the
647
+ // tier's service fee), so it wins when set. A number on the free tier
648
+ // still costs its rental, which is exactly what this row must show.
649
+ const rental = Number(number.billing?.monthly_total) || 0;
650
+ const price = rental > 0 ? rental : Number(plan?.price) || 0;
651
+ if (!price) continue;
652
+ items.profile_phone.push({
653
+ id: number._id,
654
+ label: number.friendly_name || number.phone_number || number._id,
655
+ plan_id: plan?.id || `profile_phone_${tier}`,
656
+ plan_name: plan?.name || 'Number',
657
+ price,
658
+ });
659
+ }
660
+ } catch (e) {
661
+ console.error('[get_module_subscriptions profile_phone]', e.message);
662
+ }
663
+
664
+ return items;
665
+ };
666
+
667
+ // Per-module subscription state for the billing screen: what the account is on
668
+ // now, and whether the module is switched on at all.
669
+ export const get_module_subscriptions = async function (req = {}) {
670
+ try {
671
+ const uid = req?.uid;
672
+ if (!uid) return { code: -401, data: 'not authenticated' };
673
+ const ar = await db_module.get_couch_doc('xuda_accounts', uid);
674
+ if (ar.code < 0 || !ar.data) return { code: -404, data: 'account not found' };
675
+ const account = ar.data;
676
+ const plans = _conf.PLAN_OBJ || {};
677
+ const is_member = !!(account.membership_plan && account.membership_plan !== 'free');
678
+ // A category is only offerable once its paid tiers carry a real Stripe
679
+ // price, otherwise "activate" would set a plan nobody is billed for.
680
+ const billable = (key) =>
681
+ Object.values(plans).some((p) => p.category === key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
682
+
683
+ const resource_items = await _module_resource_items(uid);
684
+
685
+ const modules = _MODULE_SUBSCRIPTIONS.map((m) => {
686
+ const base = { key: m.key, scope: m.scope, billable: billable(m.key), items: resource_items[m.key] || [] };
687
+ if (m.key === 'bot_protection') {
688
+ const has_item = !!account.stripe_subscription_items?.bot_protection;
689
+ return { ...base, active: has_item || is_member, included: is_member && !has_item, plan_id: '', plan_name: '', price: 0 };
690
+ }
691
+ if (m.scope === 'resource' || m.scope === 'metered') {
692
+ const price = base.items.reduce((sum, i) => sum + Number(i.price || 0), 0);
693
+ return {
694
+ ...base,
695
+ active: m.scope === 'metered' || base.items.length > 0,
696
+ included: m.scope === 'metered',
697
+ plan_id: '',
698
+ plan_name: '',
699
+ price,
700
+ };
701
+ }
702
+ const plan_id = account[m.field] || m.default_plan;
703
+ const plan = plans[plan_id] || {};
704
+ const price = Number(plan.price) || 0;
705
+ return {
706
+ ...base,
707
+ plan_id,
708
+ plan_name: plan.name || plan_id,
709
+ price,
710
+ active: price > 0,
711
+ included: false,
712
+ changed_ts: account[`${m.field}_changed`] || null,
713
+ };
714
+ });
715
+ return { code: 1, data: { modules, membership_plan: account.membership_plan || 'free' } };
716
+ } catch (err) {
717
+ return { code: -1, data: err.message };
718
+ }
719
+ };
720
+
559
721
  // Which website-builder image modes an account may use. SVG and stock are always
560
722
  // available; the two gpt-image modes (gen_low = img-1, gen_high = img-2) require a
561
723
  // paid membership AND a non-free workspace credit plan. Single source of truth for
@@ -1036,6 +1198,21 @@ export const recompute_abuse_signals = async function (req) {
1036
1198
  }
1037
1199
  };
1038
1200
 
1201
+ // xuda.network personas (ambassadors + mentors) are seed accounts on
1202
+ // @ambassadors.xuda.network / @mentors.xuda.network, not customers, so every
1203
+ // ops list and search hides them. Three independent markers, any one is
1204
+ // enough: the signup flag, the `xuda_network_*` source stamped at creation,
1205
+ // and the seed mail domains. They agree on all of them today, and belt-and-
1206
+ // braces means a persona created through a future surface still stays out.
1207
+ const _NETWORK_PERSONA_DOMAINS = ['ambassadors.xuda.network', 'mentors.xuda.network'];
1208
+ const _is_network_persona = (a = {}) => {
1209
+ const info = a.account_info || {};
1210
+ if (info.is_xuda_network_ambassador === true) return true;
1211
+ if (String(a.source || '').startsWith('xuda_network_')) return true;
1212
+ const email = String(info.email || a.email || '').toLowerCase();
1213
+ return _NETWORK_PERSONA_DOMAINS.some((d) => email.endsWith(`@${d}`));
1214
+ };
1215
+
1039
1216
  // Admin-only: list every account currently flagged. The dashboard's
1040
1217
  // /admin/abuse page reads from here.
1041
1218
  export const get_flagged_accounts = async function (req) {
@@ -1054,7 +1231,7 @@ export const get_flagged_accounts = async function (req) {
1054
1231
  limit: 500,
1055
1232
  });
1056
1233
 
1057
- const rows = (find_ret?.docs || []).map((a) => ({
1234
+ const rows = (find_ret?.docs || []).filter((a) => !_is_network_persona(a)).map((a) => ({
1058
1235
  uid: a._id,
1059
1236
  email: a.email,
1060
1237
  first_name: a.first_name,
@@ -1501,6 +1678,10 @@ export const get_account_data = async function (req) {
1501
1678
  // one-shot notice at suspension time can be missed or predate this). Best-
1502
1679
  // effort + fire-and-forget; only touches the DB for the rare suspended account.
1503
1680
  if (acc_obj.account_email_bounce_suspended) _ensure_bounce_suspension_banner(acc_obj);
1681
+ // UI-82: same treatment for the billing hold. Called on every load, not
1682
+ // only when the hold is on, because this is also what retires the banner
1683
+ // once the hold is lifted.
1684
+ _ensure_billing_hold_banner(acc_obj);
1504
1685
 
1505
1686
  return ret;
1506
1687
  } catch (err) {
@@ -1973,7 +2154,10 @@ export const ops_list_terminations = async function (req = {}) {
1973
2154
  db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_termination_status: 1 }, limit: 9999 }, true),
1974
2155
  ]);
1975
2156
  const by_id = new Map();
1976
- for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) by_id.set(a._id, a);
2157
+ for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) {
2158
+ if (_is_network_persona(a)) continue;
2159
+ by_id.set(a._id, a);
2160
+ }
1977
2161
  const rows = [...by_id.values()].map((a) => ({
1978
2162
  account_uid: a._id,
1979
2163
  email: a.account_info?.email,
@@ -2015,6 +2199,8 @@ const _ops_account_summary = (a) => ({
2015
2199
  });
2016
2200
 
2017
2201
  // Find accounts by email / name / uid (substring, case-insensitive).
2202
+ // xuda.network personas are dropped AFTER the query, so the fetch limit is
2203
+ // raised to keep a full page of real customers when personas match the term.
2018
2204
  export const ops_find_account = async function (req = {}) {
2019
2205
  try {
2020
2206
  if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
@@ -2030,9 +2216,39 @@ export const ops_find_account = async function (req = {}) {
2030
2216
  { _id: { $regex: rx } },
2031
2217
  ],
2032
2218
  },
2033
- limit: 25,
2219
+ limit: 200,
2034
2220
  }, true);
2035
- return { code: 1, data: { rows: (ret.docs || []).map(_ops_account_summary) } };
2221
+ const rows = (ret.docs || []).filter((a) => !_is_network_persona(a)).slice(0, 25).map(_ops_account_summary);
2222
+ return { code: 1, data: { rows } };
2223
+ } catch (err) { return { code: -1, data: err.message }; }
2224
+ };
2225
+
2226
+ // Every customer account, newest first, paged. Backs the "All customers" tab.
2227
+ // Deleted accounts (stat 5) and xuda.network personas are left out, so the
2228
+ // total is the real customer base rather than a raw doc count. A few hundred
2229
+ // docs, so the page is cut in memory (Mango has no stable sort here without a
2230
+ // dedicated index, and skip/limit paging over an unsorted view is not stable).
2231
+ export const ops_list_accounts = async function (req = {}) {
2232
+ try {
2233
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2234
+ const limit = Math.min(Math.max(parseInt(req.limit, 10) || 50, 1), 200);
2235
+ const skip = Math.max(parseInt(req.skip, 10) || 0, 0);
2236
+ const ret = await db_module.find_couch_query('xuda_accounts', {
2237
+ selector: { docType: 'account' },
2238
+ limit: 100000,
2239
+ }, true);
2240
+ const all = (ret.docs || [])
2241
+ .filter((a) => a.stat !== 5 && !_is_network_persona(a))
2242
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0));
2243
+ return {
2244
+ code: 1,
2245
+ data: {
2246
+ rows: all.slice(skip, skip + limit).map(_ops_account_summary),
2247
+ total: all.length,
2248
+ skip,
2249
+ limit,
2250
+ },
2251
+ };
2036
2252
  } catch (err) { return { code: -1, data: err.message }; }
2037
2253
  };
2038
2254
 
@@ -2143,7 +2359,9 @@ export const ops_add_ai_credits = async function (req = {}) {
2143
2359
  if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
2144
2360
  const account = ar.data;
2145
2361
  const details = `admin grant ${credits} by ${req.uid} @ ${Date.now()}`;
2146
- const cr = await record_ai_credit(req.uid, credits, 'admin_grant', details, account_uid);
2362
+ // An ops grant is a deliberate gift on top of the plan, so it must survive
2363
+ // the period roll rather than evaporating at the next renewal.
2364
+ const cr = await record_ai_credit(req.uid, credits, 'admin_grant', details, account_uid, { kind: CREDIT_KIND.TOPUP });
2147
2365
  if (cr.code < 0) return { code: -1, data: cr.data };
2148
2366
  if (req.notify) {
2149
2367
  notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], subject: `${credits} AI credits added to your account`, body: `<h2>${credits} AI credits added</h2><p>Our team has added ${credits} AI credits to your account. Enjoy!</p>`, delivery_method: ['banner', 'email'], display_type: 'success' });
@@ -3182,6 +3400,19 @@ export const verify_account = async function (req) {
3182
3400
  console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
3183
3401
  }
3184
3402
 
3403
+ // A picture uploaded before verification deliberately did NOT generate an
3404
+ // avatar (see can_generate_avatar). The account is verified as of the save
3405
+ // above, so start the generation that was deferred. Fire-and-forget:
3406
+ // generation is heavy (vision + image ops) and must not hold up the verify
3407
+ // response, and ensure_profile_avatar re-reads the doc and does its own
3408
+ // guarding — picture present, no avatar yet, not already generating — so a
3409
+ // user who never uploaded one is a no-op.
3410
+ try {
3411
+ ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
3412
+ } catch (e) {
3413
+ console.warn('[verify_account] deferred avatar start threw:', e.message);
3414
+ }
3415
+
3185
3416
  // is_boarded rides along so the verify route can tell the client whether
3186
3417
  // onboarding is still pending (it is, for a fresh email signup).
3187
3418
  return { code: 1, data: { ...ret_acc.data, is_boarded: !!obj.isBoarded } };
@@ -4660,6 +4891,61 @@ const _notify_bounce_suspended = async (account) => {
4660
4891
  // can be missed or predate this; get_account_data calls this on every load.
4661
4892
  // Idempotent + banner-only: bails if a persistent (app_id '') banner already
4662
4893
  // exists, so it never duplicates the doc or re-sends the SMS.
4894
+ // The billing-hold band, as a notification instead of a hardcoded strip in the
4895
+ // dashboard shell (UI-82). Driven by account state on every load rather than
4896
+ // hooked onto the four places that flip the flag (ops apply, ops release, and
4897
+ // both Stripe webhook paths), because a state check cannot drift out of sync
4898
+ // with the flag the way four call sites can. Raises the banner while the hold
4899
+ // is on, retires it when the hold lifts.
4900
+ const _ensure_billing_hold_banner = async (account) => {
4901
+ try {
4902
+ const uid = account._id;
4903
+ const on = account.account_billing_hold_status === 1;
4904
+ const ret = await db_module.find_couch_query('xuda_notification', {
4905
+ selector: { docType: 'notification', uid, topic: 'account_billing_hold', delivery_method: 'banner', read: false },
4906
+ });
4907
+ const docs = ret?.docs || [];
4908
+
4909
+ if (!on) {
4910
+ // Hold lifted: retire whatever is on screen. Same read + stat 4 the
4911
+ // bounce banner uses for its duplicates, so the notification centre
4912
+ // treats it as handled rather than leaving a stale warning up.
4913
+ for (const doc of docs) {
4914
+ doc.read = true;
4915
+ doc.stat = 4;
4916
+ doc.stat_ts = Date.now();
4917
+ await db_module.save_couch_doc('xuda_notification', doc).catch(() => {});
4918
+ }
4919
+ return;
4920
+ }
4921
+
4922
+ // Same de-duplication as the bounce banner: find-then-create is not atomic
4923
+ // and the index lags a fresh doc, so concurrent loads can both create one.
4924
+ if (docs.length) {
4925
+ docs.sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0));
4926
+ for (let i = 1; i < docs.length; i++) {
4927
+ docs[i].read = true;
4928
+ docs[i].stat = 4;
4929
+ docs[i].stat_ts = Date.now();
4930
+ await db_module.save_couch_doc('xuda_notification', docs[i]).catch(() => {});
4931
+ }
4932
+ return;
4933
+ }
4934
+
4935
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
4936
+ await notification_msa.submit_notification({
4937
+ type: 'account',
4938
+ app_id: null,
4939
+ to_app_id: '',
4940
+ uid_arr: [uid],
4941
+ topic: 'account_billing_hold',
4942
+ params: { action_label: 'Review billing', action_href: `https://${host}/dashboard/settings/billing` },
4943
+ });
4944
+ } catch (e) {
4945
+ console.error('ensure billing hold banner failed:', e.message);
4946
+ }
4947
+ };
4948
+
4663
4949
  const _ensure_bounce_suspension_banner = async (account) => {
4664
4950
  try {
4665
4951
  const uid = account._id;
@@ -6587,24 +6873,38 @@ export const save_cache_hit = async function (_id) {
6587
6873
  ///////////////////////////////
6588
6874
 
6589
6875
  export const get_account_ai_usage = async function (req, job_id, headers) {
6590
- // Default to the LIFETIME window (all-time spend) the credit model is a
6591
- // remaining-pool ledger (see _LIFETIME_WINDOW + the Credit Management page).
6592
- // Callers that want a specific range (the usage chart in WorkspaceUsage.vue)
6593
- // pass explicit year/month/day. The nav meter + ring call with no window, so
6594
- // this makes them read lifetime "used" and match the Credit Management page
6595
- // instead of a current-month slice against a lifetime pool.
6876
+ // Called with NO window at all (the nav meter, the avatar ring, account boot,
6877
+ // the low-credit alert), this reports the CURRENT BILLING PERIOD: usage.total
6878
+ // is the period's spend and credits.total is the period allowance plus the
6879
+ // topup still available. That keeps `credits.total - usage.total` equal to the
6880
+ // real remaining balance, which matters because the dashboard reads those two
6881
+ // numbers directly and computes the difference itself so the meter stays
6882
+ // correct with no frontend change.
6883
+ //
6884
+ // Callers that pass an explicit range (the WorkspaceUsage chart) still get raw
6885
+ // windowed spend and the full ledger total, unchanged.
6596
6886
  const { uid, year_from = 2000, month_from = 1, day_from = 1, year_to = 2999, month_to = 12, day_to = 31 } = req;
6597
6887
  try {
6598
6888
  const app_id = await get_account_default_project_id(uid);
6599
6889
 
6890
+ // ts_from / ts_to override the Y/M/D window. Billing periods start at an
6891
+ // arbitrary time of day (Isaac's renews 04:00 on the 2nd), and the Y/M/D
6892
+ // form can only express midnight boundaries, so a day-granular window would
6893
+ // count a few hours of the neighbouring period's spend against this one.
6894
+ const has_ts = Number(req?.ts_from) > 0 || Number(req?.ts_to) > 0;
6895
+ const has_ymd = req?.year_from != null || req?.year_to != null || req?.month_from != null || req?.month_to != null || req?.day_from != null || req?.day_to != null;
6896
+ // No window requested at all -> scope to the account's current credit period.
6897
+ const acct_doc = !has_ts && !has_ymd ? await db_module.get_couch_doc_native('xuda_accounts', uid) : null;
6898
+ const period = acct_doc ? _current_credit_period(acct_doc) : null;
6899
+
6600
6900
  // const timestamp_from = new Date(Number(year_from) || year, (Number(month_from) || month) - 1, Number(day_from) || day).getTime();
6601
6901
  // const timestamp_to = new Date(Number(year_to) || year, (Number(month_to) || month) - 1, Number(day_to) || day).getTime();
6602
6902
 
6603
6903
  // BEGINNING OF DAY (00:00:00.000)
6604
- const timestamp_from = new Date(Number(year_from), Number(month_from) - 1, Number(day_from)).setHours(0, 0, 0, 0);
6904
+ const timestamp_from = period ? period.start : has_ts ? Number(req.ts_from) || 0 : new Date(Number(year_from), Number(month_from) - 1, Number(day_from)).setHours(0, 0, 0, 0);
6605
6905
 
6606
6906
  // END OF DAY (23:59:59.999)
6607
- const timestamp_to = new Date(Number(year_to), Number(month_to) - 1, Number(day_to)).setHours(23, 59, 59, 999);
6907
+ const timestamp_to = period ? Date.now() : has_ts ? Number(req.ts_to) || Date.now() : new Date(Number(year_to), Number(month_to) - 1, Number(day_to)).setHours(23, 59, 59, 999);
6608
6908
 
6609
6909
  const query = {
6610
6910
  // include_docs: true,
@@ -6648,7 +6948,24 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
6648
6948
  credits_obj[source] += credits;
6649
6949
  }
6650
6950
 
6651
- let data = { credits: { ...credits_obj, total: total_credits }, usage: { profile, projects: [], total: total_usage }, packs: _conf.ai_credit_packs || [] };
6951
+ // Period-scoped read: the ledger total above counts every active grant, but
6952
+ // topup already spent in EARLIER periods is invisible to this period's usage
6953
+ // figure, so it has to be deducted here. Without that, rolling the period
6954
+ // would hand the customer back topup credits they had already spent.
6955
+ let period_meta = null;
6956
+ if (period) {
6957
+ let period_grant = 0;
6958
+ let topup_total = 0;
6959
+ for (const [src, amount] of Object.entries(credits_obj)) {
6960
+ if (_credit_kind({ source: src }) === CREDIT_KIND.TOPUP) topup_total += amount;
6961
+ else period_grant += amount;
6962
+ }
6963
+ const topup_available = Math.max(0, topup_total - Math.max(0, Number(acct_doc?.credits_topup_consumed) || 0));
6964
+ total_credits = period_grant + topup_available;
6965
+ period_meta = { start_ts: period.start, end_ts: period.end, source: period.source, period_grant, topup_total, topup_available };
6966
+ }
6967
+
6968
+ let data = { credits: { ...credits_obj, total: total_credits }, usage: { profile, projects: [], total: total_usage }, packs: _conf.ai_credit_packs || [], period: period_meta };
6652
6969
 
6653
6970
  if (job_id) {
6654
6971
  data.profile_info = {};
@@ -6750,9 +7067,143 @@ const _usage_total = function (usage_data) {
6750
7067
  return Object.values(by_profile).reduce((a, b) => a + (Number(b) || 0), 0);
6751
7068
  };
6752
7069
 
6753
- // Wide window so "used" reads as all-time spend (the lifetime / remaining-pool model).
7070
+ // Wide window so "used" reads as all-time spend. Kept for the few places that
7071
+ // genuinely want all-time (ops snapshots, reporting). It is NOT the basis of the
7072
+ // balance any more: see get_credit_balance below for why that was broken.
6754
7073
  const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
6755
7074
 
7075
+ /////////////////////////////////////////////////////////////////////////
7076
+ // CREDIT BALANCE
7077
+ //
7078
+ // The balance used to be computed two different ways, and they disagreed:
7079
+ // evaluate_credit_gate did (ledger grants - LIFETIME usage) while the Credit
7080
+ // Management page did (plan entitlement - LIFETIME usage). Both were unstable,
7081
+ // because grants expire after 30 days while usage was counted forever, so the
7082
+ // balance could only ever drift negative. A paying Pro customer was hard-blocked
7083
+ // from AI while his own dashboard told him he had 539 credits left.
7084
+ //
7085
+ // The model now matches how the plans are actually sold:
7086
+ //
7087
+ // period pool — the plan's allowance for the CURRENT billing period. Granted
7088
+ // when the invoice is paid, and it does not roll over. The next
7089
+ // period starts from the plan's number again.
7090
+ // topup pool — separately acquired credits (bought packs, ops grants,
7091
+ // bonuses). Never reset; they persist until spent.
7092
+ //
7093
+ // Spend comes out of the period pool first and only overflows into topup once
7094
+ // the period allowance is exhausted. Overflow consumed in PAST periods is
7095
+ // remembered on the account as credits_topup_consumed, because usage is only
7096
+ // ever queried for the current period — without that counter, rolling the
7097
+ // period would silently hand back topup credits the customer already spent.
7098
+ /////////////////////////////////////////////////////////////////////////
7099
+
7100
+ // The window the current period pool is measured over. A paid account gets its
7101
+ // real Stripe period (stamped by the invoice.paid webhook). Anything else falls
7102
+ // back to the calendar month, so a free account's daily drip still resets on a
7103
+ // predictable boundary instead of being compared against all-time spend. A
7104
+ // stored period that has already elapsed is treated as absent, so a missed or
7105
+ // late renewal webhook degrades to the fallback instead of freezing the window.
7106
+ const _current_credit_period = function (account_doc) {
7107
+ const now = Date.now();
7108
+ const start = Number(account_doc?.credits_period_start_ts) || 0;
7109
+ const end = Number(account_doc?.credits_period_end_ts) || 0;
7110
+ if (start && end && now >= start && now < end) return { start, end, source: 'invoice' };
7111
+ const d = new Date(now);
7112
+ return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1), end: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), source: 'calendar' };
7113
+ };
7114
+
7115
+ // Single source of truth for "how many credits does this account have left".
7116
+ // Everything that gates, meters or displays credits must go through here.
7117
+ export const get_credit_balance = async function (uid, account_doc) {
7118
+ const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
7119
+ const period = _current_credit_period(doc);
7120
+
7121
+ // Active (stat 3) grants, summed by source, then split into the two pools.
7122
+ // Expired period grants drop out of this view on their own, which is what
7123
+ // makes the period pool reset. Legacy docs carry no credit_kind, so the
7124
+ // classification falls back to the source name (see _credit_kind).
7125
+ const view = await db_module.get_couch_view('xuda_billing', 'ai_credits', { startkey: [uid, ''], endkey: [uid, 'zzz'], reduce: true, group_level: 999 });
7126
+ const by_source = {};
7127
+ let period_grant = 0;
7128
+ let topup_total = 0;
7129
+ for (const row of view?.data?.rows || []) {
7130
+ const source = row.key[1];
7131
+ const credits = Number(row.value) || 0;
7132
+ by_source[source] = (by_source[source] || 0) + credits;
7133
+ if (_credit_kind({ source }) === CREDIT_KIND.TOPUP) topup_total += credits;
7134
+ else period_grant += credits;
7135
+ }
7136
+
7137
+ const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
7138
+ const used = _usage_total(usage_ret?.data?.usage);
7139
+
7140
+ const topup_consumed = Math.max(0, Number(doc?.credits_topup_consumed) || 0);
7141
+ const topup_available = Math.max(0, topup_total - topup_consumed);
7142
+
7143
+ // An "unlimited" tier (Enterprise T3) resolves to Infinity and must never gate.
7144
+ const unlimited = !Number.isFinite(get_effective_entitlements(doc).ai_credits);
7145
+ const granted = period_grant + topup_available;
7146
+
7147
+ return {
7148
+ period_start_ts: period.start,
7149
+ period_end_ts: period.end,
7150
+ period_source: period.source,
7151
+ period_grant,
7152
+ topup_total,
7153
+ topup_consumed,
7154
+ topup_available,
7155
+ granted,
7156
+ used,
7157
+ remaining: unlimited ? Infinity : Math.round((granted - used) * 100) / 100,
7158
+ unlimited,
7159
+ by_source,
7160
+ usage_profile: usage_ret?.data?.usage?.profile,
7161
+ };
7162
+ };
7163
+
7164
+ // Close out the period that just ended and open the next one. Called from the
7165
+ // invoice.paid webhook right before the new grant lands.
7166
+ //
7167
+ // The overflow bookkeeping is the important half: any spend beyond the old
7168
+ // period's allowance came out of topup, and because the next balance read only
7169
+ // looks at the NEW period's usage, that spend would otherwise be forgotten and
7170
+ // the customer would get those topup credits back every month.
7171
+ export const roll_credit_period = async function (uid, next_start_ts, next_end_ts) {
7172
+ try {
7173
+ const doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
7174
+ if (!doc) return { code: -1, data: 'account not found' };
7175
+
7176
+ const prev_start = Number(doc.credits_period_start_ts) || 0;
7177
+ const prev_end = Number(doc.credits_period_end_ts) || 0;
7178
+ if (prev_start && prev_end && Number(next_start_ts) > prev_start) {
7179
+ const prev = await get_credit_balance(uid, doc);
7180
+ const prev_overflow = Math.max(0, prev.used - prev.period_grant);
7181
+ if (prev_overflow > 0) doc.credits_topup_consumed = (Number(doc.credits_topup_consumed) || 0) + prev_overflow;
7182
+
7183
+ // Retire the old period's grants so they cannot be spent in the new one.
7184
+ // Scoped to period grants whose window has closed; topup is never touched.
7185
+ const stale = await db_module.find_couch_query('xuda_billing', { selector: { docType: 'ai_credit', credited_uid: uid, stat: 3 }, limit: 9999 });
7186
+ for (const c of stale?.docs || []) {
7187
+ if (_credit_kind(c) !== CREDIT_KIND.PERIOD) continue;
7188
+ const ended = Number(c.period_end_ts) || Number(c.date_created_ts) || 0;
7189
+ if (ended && ended > Number(next_start_ts)) continue; // belongs to the period we are opening
7190
+ c.stat = 5;
7191
+ c.stat_ts = Date.now();
7192
+ c.stat_reason = 'period closed';
7193
+ await db_module.save_couch_doc('xuda_billing', c);
7194
+ }
7195
+ }
7196
+
7197
+ doc.credits_period_start_ts = Number(next_start_ts) || Date.now();
7198
+ doc.credits_period_end_ts = Number(next_end_ts) || 0;
7199
+ const save = await db_module.save_couch_doc('xuda_accounts', doc);
7200
+ return { code: save.code > 0 ? 1 : -1, data: { period_start_ts: doc.credits_period_start_ts, period_end_ts: doc.credits_period_end_ts, topup_consumed: doc.credits_topup_consumed || 0 } };
7201
+ } catch (err) {
7202
+ console.error('[roll_credit_period]', err?.message || err);
7203
+ return { code: -1, data: err?.message };
7204
+ }
7205
+ };
7206
+
6756
7207
  // Per-model / per-source spend is not emitted by the usage view, so re-derive it
6757
7208
  // from the raw ai_usage docs and CALIBRATE to the view's authoritative total, so the
6758
7209
  // numbers stay in the same credit unit (factor = view_total / raw_token_cost_total).
@@ -6764,7 +7215,9 @@ const _get_scoped_usage = async function (owner_uid, need_raw) {
6764
7215
  const cached = _scoped_usage_cache[owner_uid];
6765
7216
  if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
6766
7217
 
6767
- const usage_ret = await get_account_ai_usage({ uid: owner_uid, ..._LIFETIME_WINDOW });
7218
+ // No window: per-profile and per-member caps are an allocation of the CURRENT
7219
+ // period's pool, so they have to be measured over the same period the pool is.
7220
+ const usage_ret = await get_account_ai_usage({ uid: owner_uid });
6768
7221
  const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
6769
7222
  const total = _usage_total(usage_ret?.data?.usage);
6770
7223
 
@@ -6895,10 +7348,15 @@ export const evaluate_credit_gate = async function (req) {
6895
7348
  const account_doc = await db_module.get_couch_doc_native('xuda_accounts', owner);
6896
7349
  const rules = account_doc?.credit_rules;
6897
7350
 
6898
- // Back-compat: no rules or disabled => legacy single ledger cap, byte-for-byte.
7351
+ // No rules or disabled => the single account-wide pool cap, which is the
7352
+ // default for almost every account. This is what hard-blocks AI, so it MUST
7353
+ // read the same balance the dashboard shows the customer. It used to compute
7354
+ // its own (ledger grants - lifetime usage) while the Credit Management page
7355
+ // computed (plan entitlement - lifetime usage); the two drifted apart and a
7356
+ // paying Pro customer was blocked while his page reported 539 credits left.
6899
7357
  if (!rules || rules.enabled === false) {
6900
- const usage = await get_account_ai_usage({ uid: owner, ..._LIFETIME_WINDOW });
6901
- const over = (Number(usage?.data?.credits?.total) || 0) - _usage_total(usage?.data?.usage) < -0.5;
7358
+ const bal = await get_credit_balance(owner, account_doc);
7359
+ const over = bal.remaining < -0.5;
6902
7360
  return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
6903
7361
  }
6904
7362
 
@@ -7009,15 +7467,17 @@ export const get_credit_management = async function (req) {
7009
7467
  const plan = account_doc?.membership_plan || 'free';
7010
7468
  const can_edit = _is_team_tier(plan);
7011
7469
  const rules = account_doc?.credit_rules || _default_credit_rules();
7012
- const ent = get_effective_entitlements(account_doc);
7013
- const unlimited = !Number.isFinite(ent.ai_credits);
7014
- const entitlements = { ai_credits: unlimited ? null : ent.ai_credits, unlimited };
7470
+ // Report exactly what the gate enforces. These were two different formulas
7471
+ // and the page cheerfully showed credits to a customer the gate was blocking.
7472
+ const bal = await get_credit_balance(uid, account_doc);
7473
+ const unlimited = bal.unlimited;
7474
+ const entitlements = { ai_credits: unlimited ? null : bal.granted, unlimited };
7015
7475
 
7016
7476
  const su = await _get_scoped_usage(uid, true);
7017
- const remaining = unlimited ? null : Math.round((ent.ai_credits - su.total) * 100) / 100;
7477
+ const remaining = unlimited ? null : bal.remaining;
7018
7478
  const usage_breakdown = {
7019
- window: { mode: rules.window || 'lifetime' },
7020
- pool: { used: Math.round(su.total * 100) / 100, remaining },
7479
+ window: { mode: 'period', period_start_ts: bal.period_start_ts, period_end_ts: bal.period_end_ts, period_grant: bal.period_grant, topup_available: bal.topup_available },
7480
+ pool: { used: Math.round(bal.used * 100) / 100, remaining },
7021
7481
  by_profile: su.by_profile,
7022
7482
  by_user: su.by_user,
7023
7483
  by_model: su.by_model,
@@ -7307,7 +7767,26 @@ export const record_ai_usage = async function (uid, input_tokens, output_tokens,
7307
7767
  }
7308
7768
  };
7309
7769
 
7310
- export const record_ai_credit = async function (uid, credits = 0, source, details, credited_uid) {
7770
+ // A credit belongs to exactly one of two pools, and they behave differently:
7771
+ //
7772
+ // 'period' — the allowance that comes WITH a plan (the monthly membership
7773
+ // grant, and the free tier's daily drip). It is spent inside one
7774
+ // billing period and does NOT roll over: the next period starts
7775
+ // from the plan's number again.
7776
+ // 'topup' — credits the customer separately acquired (bought packs, ops
7777
+ // grants, the boarding bonus, referral bonuses). These never
7778
+ // reset; they sit there until they are used.
7779
+ //
7780
+ // Legacy docs predate the field, so _credit_kind() infers it from `source`
7781
+ // rather than requiring a backfill.
7782
+ const CREDIT_KIND = { PERIOD: 'period', TOPUP: 'topup' };
7783
+ const _TOPUP_SOURCES = new Set(['purchase', 'admin_grant', 'boarding', 'contact connection']);
7784
+ const _credit_kind = function (doc) {
7785
+ if (doc?.credit_kind === CREDIT_KIND.TOPUP || doc?.credit_kind === CREDIT_KIND.PERIOD) return doc.credit_kind;
7786
+ return _TOPUP_SOURCES.has(doc?.source) ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD;
7787
+ };
7788
+
7789
+ export const record_ai_credit = async function (uid, credits = 0, source, details, credited_uid, opts = {}) {
7311
7790
  try {
7312
7791
  var dup = await db_module.find_couch_query('xuda_billing', {
7313
7792
  selector: {
@@ -7334,7 +7813,11 @@ export const record_ai_credit = async function (uid, credits = 0, source, detail
7334
7813
  stat: 3,
7335
7814
  credited_uid,
7336
7815
  details,
7816
+ credit_kind: opts.kind === CREDIT_KIND.TOPUP ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD,
7337
7817
  };
7818
+ if (opts.period_start_ts) credit_doc.period_start_ts = opts.period_start_ts;
7819
+ if (opts.period_end_ts) credit_doc.period_end_ts = opts.period_end_ts;
7820
+ if (opts.stripe_invoice_id) credit_doc.stripe_invoice_id = opts.stripe_invoice_id;
7338
7821
  const save_ret = await db_module.save_couch_doc('xuda_billing', credit_doc);
7339
7822
  // console.log(save_ret);
7340
7823
  broadcast_credits(credited_uid); // live meter: this account just gained credits
@@ -7359,7 +7842,9 @@ export const add_ai_credits_to_active_accounts = async function () {
7359
7842
  const _24_hr_ms = 1000 * 60 * 60 * 24;
7360
7843
  const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account', last_free_daily_ai_credit_ts: { $lt: Date.now() - _24_hr_ms }, ai_workspace_plan: 'free_ai_workspace' }, limit: 99999 });
7361
7844
  for await (let account_doc of active_accounts.docs) {
7362
- const ret = await record_ai_credit('system', 1, 'daily credit', 'free daily credit ' + date_str, account_doc._id);
7845
+ // The free tier's drip IS its plan allowance, so it is a period credit: it
7846
+ // resets with the period instead of accumulating forever.
7847
+ const ret = await record_ai_credit('system', 1, 'daily credit', 'free daily credit ' + date_str, account_doc._id, { kind: CREDIT_KIND.PERIOD });
7363
7848
  if (ret.code > -1) {
7364
7849
  account_doc.last_free_daily_ai_credit_ts = Date.now();
7365
7850
  account_doc.last_free_daily_ai_credit_id = ret.data.id;
@@ -7371,11 +7856,23 @@ export const add_ai_credits_to_active_accounts = async function () {
7371
7856
  export const archive_expire_ai_credits = async function () {
7372
7857
  const _24_hr_ms = 1000 * 60 * 60 * 24;
7373
7858
  const _mo_ms = _24_hr_ms * 30;
7859
+ const now = Date.now();
7374
7860
 
7375
- const active_ai_credits = await db_module.find_couch_query('xuda_billing', { selector: { stat: 3, docType: 'ai_credit', date_created_ts: { $lt: Date.now() - _mo_ms } }, limit: 99999 });
7861
+ const active_ai_credits = await db_module.find_couch_query('xuda_billing', { selector: { stat: 3, docType: 'ai_credit', date_created_ts: { $lt: now - _mo_ms } }, limit: 99999 });
7376
7862
  for await (let ai_credit_doc of active_ai_credits.docs) {
7863
+ // TOPUP credits are the customer's property until they spend them: bought
7864
+ // packs, ops grants, the welcome bonus. This cron used to expire those too,
7865
+ // silently deleting credits people had paid real money for.
7866
+ if (_credit_kind(ai_credit_doc) === CREDIT_KIND.TOPUP) continue;
7867
+
7868
+ // A period grant expires when its PERIOD ends, not 30 days after it was
7869
+ // written. Those differ whenever a month is 31 days long, and the old rule
7870
+ // would have retired the current allowance a day early.
7871
+ const period_end = Number(ai_credit_doc.period_end_ts) || 0;
7872
+ if (period_end && period_end > now) continue;
7873
+
7377
7874
  ai_credit_doc.stat = 5;
7378
- ai_credit_doc.stat_ts = Date.now();
7875
+ ai_credit_doc.stat_ts = now;
7379
7876
  ai_credit_doc.stat_reason = 'expired';
7380
7877
  const save_ret = await db_module.save_couch_doc('xuda_billing', ai_credit_doc);
7381
7878
  }
package/index_ms.mjs CHANGED
@@ -57,6 +57,10 @@ export const get_effective_entitlements = async function (...args) {
57
57
  return await broker.send_to_queue("get_effective_entitlements", ...args);
58
58
  };
59
59
 
60
+ export const get_module_subscriptions = async function (...args) {
61
+ return await broker.send_to_queue("get_module_subscriptions", ...args);
62
+ };
63
+
60
64
  export const _site_build_image_modes = async function (...args) {
61
65
  return await broker.send_to_queue("_site_build_image_modes", ...args);
62
66
  };
@@ -181,6 +185,10 @@ export const ops_find_account = async function (...args) {
181
185
  return await broker.send_to_queue("ops_find_account", ...args);
182
186
  };
183
187
 
188
+ export const ops_list_accounts = async function (...args) {
189
+ return await broker.send_to_queue("ops_list_accounts", ...args);
190
+ };
191
+
184
192
  export const ops_account_snapshot = async function (...args) {
185
193
  return await broker.send_to_queue("ops_account_snapshot", ...args);
186
194
  };
@@ -509,6 +517,14 @@ export const get_account_ai_usage = async function (...args) {
509
517
  return await broker.send_to_queue("get_account_ai_usage", ...args);
510
518
  };
511
519
 
520
+ export const get_credit_balance = async function (...args) {
521
+ return await broker.send_to_queue("get_credit_balance", ...args);
522
+ };
523
+
524
+ export const roll_credit_period = async function (...args) {
525
+ return await broker.send_to_queue("roll_credit_period", ...args);
526
+ };
527
+
512
528
  export const emit_credit_soft_limit = async function (...args) {
513
529
  return await broker.send_to_queue("emit_credit_soft_limit", ...args);
514
530
  };
package/index_msa.mjs CHANGED
@@ -57,6 +57,10 @@ export const get_effective_entitlements = function (...args) {
57
57
  broker.send_to_queue_async("get_effective_entitlements", ...args);
58
58
  };
59
59
 
60
+ export const get_module_subscriptions = function (...args) {
61
+ broker.send_to_queue_async("get_module_subscriptions", ...args);
62
+ };
63
+
60
64
  export const _site_build_image_modes = function (...args) {
61
65
  broker.send_to_queue_async("_site_build_image_modes", ...args);
62
66
  };
@@ -181,6 +185,10 @@ export const ops_find_account = function (...args) {
181
185
  broker.send_to_queue_async("ops_find_account", ...args);
182
186
  };
183
187
 
188
+ export const ops_list_accounts = function (...args) {
189
+ broker.send_to_queue_async("ops_list_accounts", ...args);
190
+ };
191
+
184
192
  export const ops_account_snapshot = function (...args) {
185
193
  broker.send_to_queue_async("ops_account_snapshot", ...args);
186
194
  };
@@ -509,6 +517,14 @@ export const get_account_ai_usage = function (...args) {
509
517
  broker.send_to_queue_async("get_account_ai_usage", ...args);
510
518
  };
511
519
 
520
+ export const get_credit_balance = function (...args) {
521
+ broker.send_to_queue_async("get_credit_balance", ...args);
522
+ };
523
+
524
+ export const roll_credit_period = function (...args) {
525
+ broker.send_to_queue_async("roll_credit_period", ...args);
526
+ };
527
+
512
528
  export const emit_credit_soft_limit = function (...args) {
513
529
  broker.send_to_queue_async("emit_credit_soft_limit", ...args);
514
530
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2302",
3
+ "version": "1.2.2304",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {