@xuda.io/account_module 1.2.2301 → 1.2.2303

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,
@@ -1214,7 +1391,7 @@ export const backfill_app_costs = async function (opts = {}) {
1214
1391
  } else if (!BILLABLE_APP_TYPES.includes(doc.app_type) && !doc.is_deployment) {
1215
1392
  reason = `app_type ${doc.app_type} not billable`;
1216
1393
  } else {
1217
- const has_addons = doc.deploy_data?.enable_backups || doc.deploy_data?.enable_ai_maintenance || doc.deploy_data?.enable_ai_instructions || doc.deploy_data?.enable_offline || doc.deploy_data?.enable_utility_screen || doc.deploy_data?.enable_user_assist;
1394
+ const has_addons = doc.deploy_data?.enable_backups || doc.deploy_data?.enable_ai_maintenance || doc.deploy_data?.enable_ai_instructions || doc.deploy_data?.enable_offline || doc.deploy_data?.enable_user_assist;
1218
1395
  if (doc.is_deployment && !has_addons && !doc.deploy_data?.app_server_type) {
1219
1396
  reason = 'deployment without addons → datacenter bears cost';
1220
1397
  } else {
@@ -1973,7 +2150,10 @@ export const ops_list_terminations = async function (req = {}) {
1973
2150
  db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_termination_status: 1 }, limit: 9999 }, true),
1974
2151
  ]);
1975
2152
  const by_id = new Map();
1976
- for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) by_id.set(a._id, a);
2153
+ for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) {
2154
+ if (_is_network_persona(a)) continue;
2155
+ by_id.set(a._id, a);
2156
+ }
1977
2157
  const rows = [...by_id.values()].map((a) => ({
1978
2158
  account_uid: a._id,
1979
2159
  email: a.account_info?.email,
@@ -2015,6 +2195,8 @@ const _ops_account_summary = (a) => ({
2015
2195
  });
2016
2196
 
2017
2197
  // Find accounts by email / name / uid (substring, case-insensitive).
2198
+ // xuda.network personas are dropped AFTER the query, so the fetch limit is
2199
+ // raised to keep a full page of real customers when personas match the term.
2018
2200
  export const ops_find_account = async function (req = {}) {
2019
2201
  try {
2020
2202
  if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
@@ -2030,9 +2212,39 @@ export const ops_find_account = async function (req = {}) {
2030
2212
  { _id: { $regex: rx } },
2031
2213
  ],
2032
2214
  },
2033
- limit: 25,
2215
+ limit: 200,
2034
2216
  }, true);
2035
- return { code: 1, data: { rows: (ret.docs || []).map(_ops_account_summary) } };
2217
+ const rows = (ret.docs || []).filter((a) => !_is_network_persona(a)).slice(0, 25).map(_ops_account_summary);
2218
+ return { code: 1, data: { rows } };
2219
+ } catch (err) { return { code: -1, data: err.message }; }
2220
+ };
2221
+
2222
+ // Every customer account, newest first, paged. Backs the "All customers" tab.
2223
+ // Deleted accounts (stat 5) and xuda.network personas are left out, so the
2224
+ // total is the real customer base rather than a raw doc count. A few hundred
2225
+ // docs, so the page is cut in memory (Mango has no stable sort here without a
2226
+ // dedicated index, and skip/limit paging over an unsorted view is not stable).
2227
+ export const ops_list_accounts = async function (req = {}) {
2228
+ try {
2229
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2230
+ const limit = Math.min(Math.max(parseInt(req.limit, 10) || 50, 1), 200);
2231
+ const skip = Math.max(parseInt(req.skip, 10) || 0, 0);
2232
+ const ret = await db_module.find_couch_query('xuda_accounts', {
2233
+ selector: { docType: 'account' },
2234
+ limit: 100000,
2235
+ }, true);
2236
+ const all = (ret.docs || [])
2237
+ .filter((a) => a.stat !== 5 && !_is_network_persona(a))
2238
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0));
2239
+ return {
2240
+ code: 1,
2241
+ data: {
2242
+ rows: all.slice(skip, skip + limit).map(_ops_account_summary),
2243
+ total: all.length,
2244
+ skip,
2245
+ limit,
2246
+ },
2247
+ };
2036
2248
  } catch (err) { return { code: -1, data: err.message }; }
2037
2249
  };
2038
2250
 
@@ -2143,7 +2355,9 @@ export const ops_add_ai_credits = async function (req = {}) {
2143
2355
  if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
2144
2356
  const account = ar.data;
2145
2357
  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);
2358
+ // An ops grant is a deliberate gift on top of the plan, so it must survive
2359
+ // the period roll rather than evaporating at the next renewal.
2360
+ const cr = await record_ai_credit(req.uid, credits, 'admin_grant', details, account_uid, { kind: CREDIT_KIND.TOPUP });
2147
2361
  if (cr.code < 0) return { code: -1, data: cr.data };
2148
2362
  if (req.notify) {
2149
2363
  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 +3396,19 @@ export const verify_account = async function (req) {
3182
3396
  console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
3183
3397
  }
3184
3398
 
3399
+ // A picture uploaded before verification deliberately did NOT generate an
3400
+ // avatar (see can_generate_avatar). The account is verified as of the save
3401
+ // above, so start the generation that was deferred. Fire-and-forget:
3402
+ // generation is heavy (vision + image ops) and must not hold up the verify
3403
+ // response, and ensure_profile_avatar re-reads the doc and does its own
3404
+ // guarding — picture present, no avatar yet, not already generating — so a
3405
+ // user who never uploaded one is a no-op.
3406
+ try {
3407
+ ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
3408
+ } catch (e) {
3409
+ console.warn('[verify_account] deferred avatar start threw:', e.message);
3410
+ }
3411
+
3185
3412
  // is_boarded rides along so the verify route can tell the client whether
3186
3413
  // onboarding is still pending (it is, for a fresh email signup).
3187
3414
  return { code: 1, data: { ...ret_acc.data, is_boarded: !!obj.isBoarded } };
@@ -5999,14 +6226,9 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
5999
6226
  doc.profile_picture = account_info_ret.data.profile_picture;
6000
6227
  }
6001
6228
 
6002
- // The main profile is the account's OWN identity: it must never auto-respond
6003
- // (you don't auto-reply to yourself). Force it off in the read model so the
6004
- // card hides the control and no read-based consumer treats it as on. The
6005
- // runtime (ai_module.auto_response) also hard-skips the main profile.
6006
- if (doc.main) {
6007
- doc.auto_respond = false;
6008
- }
6009
-
6229
+ // The main profile's stored auto_respond is returned as-is (Boaz, 2026-07-31, reversing
6230
+ // UI-44 part 2). This used to be forced to false so the card would hide the control; with
6231
+ // the control back, forcing it would make the toggle read as off however it was saved.
6010
6232
  if (!doc.account_type) {
6011
6233
  doc.account_type = account_info_ret.data.account_type;
6012
6234
  }
@@ -6592,24 +6814,38 @@ export const save_cache_hit = async function (_id) {
6592
6814
  ///////////////////////////////
6593
6815
 
6594
6816
  export const get_account_ai_usage = async function (req, job_id, headers) {
6595
- // Default to the LIFETIME window (all-time spend) the credit model is a
6596
- // remaining-pool ledger (see _LIFETIME_WINDOW + the Credit Management page).
6597
- // Callers that want a specific range (the usage chart in WorkspaceUsage.vue)
6598
- // pass explicit year/month/day. The nav meter + ring call with no window, so
6599
- // this makes them read lifetime "used" and match the Credit Management page
6600
- // instead of a current-month slice against a lifetime pool.
6817
+ // Called with NO window at all (the nav meter, the avatar ring, account boot,
6818
+ // the low-credit alert), this reports the CURRENT BILLING PERIOD: usage.total
6819
+ // is the period's spend and credits.total is the period allowance plus the
6820
+ // topup still available. That keeps `credits.total - usage.total` equal to the
6821
+ // real remaining balance, which matters because the dashboard reads those two
6822
+ // numbers directly and computes the difference itself so the meter stays
6823
+ // correct with no frontend change.
6824
+ //
6825
+ // Callers that pass an explicit range (the WorkspaceUsage chart) still get raw
6826
+ // windowed spend and the full ledger total, unchanged.
6601
6827
  const { uid, year_from = 2000, month_from = 1, day_from = 1, year_to = 2999, month_to = 12, day_to = 31 } = req;
6602
6828
  try {
6603
6829
  const app_id = await get_account_default_project_id(uid);
6604
6830
 
6831
+ // ts_from / ts_to override the Y/M/D window. Billing periods start at an
6832
+ // arbitrary time of day (Isaac's renews 04:00 on the 2nd), and the Y/M/D
6833
+ // form can only express midnight boundaries, so a day-granular window would
6834
+ // count a few hours of the neighbouring period's spend against this one.
6835
+ const has_ts = Number(req?.ts_from) > 0 || Number(req?.ts_to) > 0;
6836
+ 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;
6837
+ // No window requested at all -> scope to the account's current credit period.
6838
+ const acct_doc = !has_ts && !has_ymd ? await db_module.get_couch_doc_native('xuda_accounts', uid) : null;
6839
+ const period = acct_doc ? _current_credit_period(acct_doc) : null;
6840
+
6605
6841
  // const timestamp_from = new Date(Number(year_from) || year, (Number(month_from) || month) - 1, Number(day_from) || day).getTime();
6606
6842
  // const timestamp_to = new Date(Number(year_to) || year, (Number(month_to) || month) - 1, Number(day_to) || day).getTime();
6607
6843
 
6608
6844
  // BEGINNING OF DAY (00:00:00.000)
6609
- const timestamp_from = new Date(Number(year_from), Number(month_from) - 1, Number(day_from)).setHours(0, 0, 0, 0);
6845
+ 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);
6610
6846
 
6611
6847
  // END OF DAY (23:59:59.999)
6612
- const timestamp_to = new Date(Number(year_to), Number(month_to) - 1, Number(day_to)).setHours(23, 59, 59, 999);
6848
+ 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);
6613
6849
 
6614
6850
  const query = {
6615
6851
  // include_docs: true,
@@ -6653,7 +6889,24 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
6653
6889
  credits_obj[source] += credits;
6654
6890
  }
6655
6891
 
6656
- let data = { credits: { ...credits_obj, total: total_credits }, usage: { profile, projects: [], total: total_usage }, packs: _conf.ai_credit_packs || [] };
6892
+ // Period-scoped read: the ledger total above counts every active grant, but
6893
+ // topup already spent in EARLIER periods is invisible to this period's usage
6894
+ // figure, so it has to be deducted here. Without that, rolling the period
6895
+ // would hand the customer back topup credits they had already spent.
6896
+ let period_meta = null;
6897
+ if (period) {
6898
+ let period_grant = 0;
6899
+ let topup_total = 0;
6900
+ for (const [src, amount] of Object.entries(credits_obj)) {
6901
+ if (_credit_kind({ source: src }) === CREDIT_KIND.TOPUP) topup_total += amount;
6902
+ else period_grant += amount;
6903
+ }
6904
+ const topup_available = Math.max(0, topup_total - Math.max(0, Number(acct_doc?.credits_topup_consumed) || 0));
6905
+ total_credits = period_grant + topup_available;
6906
+ period_meta = { start_ts: period.start, end_ts: period.end, source: period.source, period_grant, topup_total, topup_available };
6907
+ }
6908
+
6909
+ let data = { credits: { ...credits_obj, total: total_credits }, usage: { profile, projects: [], total: total_usage }, packs: _conf.ai_credit_packs || [], period: period_meta };
6657
6910
 
6658
6911
  if (job_id) {
6659
6912
  data.profile_info = {};
@@ -6755,9 +7008,143 @@ const _usage_total = function (usage_data) {
6755
7008
  return Object.values(by_profile).reduce((a, b) => a + (Number(b) || 0), 0);
6756
7009
  };
6757
7010
 
6758
- // Wide window so "used" reads as all-time spend (the lifetime / remaining-pool model).
7011
+ // Wide window so "used" reads as all-time spend. Kept for the few places that
7012
+ // genuinely want all-time (ops snapshots, reporting). It is NOT the basis of the
7013
+ // balance any more: see get_credit_balance below for why that was broken.
6759
7014
  const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
6760
7015
 
7016
+ /////////////////////////////////////////////////////////////////////////
7017
+ // CREDIT BALANCE
7018
+ //
7019
+ // The balance used to be computed two different ways, and they disagreed:
7020
+ // evaluate_credit_gate did (ledger grants - LIFETIME usage) while the Credit
7021
+ // Management page did (plan entitlement - LIFETIME usage). Both were unstable,
7022
+ // because grants expire after 30 days while usage was counted forever, so the
7023
+ // balance could only ever drift negative. A paying Pro customer was hard-blocked
7024
+ // from AI while his own dashboard told him he had 539 credits left.
7025
+ //
7026
+ // The model now matches how the plans are actually sold:
7027
+ //
7028
+ // period pool — the plan's allowance for the CURRENT billing period. Granted
7029
+ // when the invoice is paid, and it does not roll over. The next
7030
+ // period starts from the plan's number again.
7031
+ // topup pool — separately acquired credits (bought packs, ops grants,
7032
+ // bonuses). Never reset; they persist until spent.
7033
+ //
7034
+ // Spend comes out of the period pool first and only overflows into topup once
7035
+ // the period allowance is exhausted. Overflow consumed in PAST periods is
7036
+ // remembered on the account as credits_topup_consumed, because usage is only
7037
+ // ever queried for the current period — without that counter, rolling the
7038
+ // period would silently hand back topup credits the customer already spent.
7039
+ /////////////////////////////////////////////////////////////////////////
7040
+
7041
+ // The window the current period pool is measured over. A paid account gets its
7042
+ // real Stripe period (stamped by the invoice.paid webhook). Anything else falls
7043
+ // back to the calendar month, so a free account's daily drip still resets on a
7044
+ // predictable boundary instead of being compared against all-time spend. A
7045
+ // stored period that has already elapsed is treated as absent, so a missed or
7046
+ // late renewal webhook degrades to the fallback instead of freezing the window.
7047
+ const _current_credit_period = function (account_doc) {
7048
+ const now = Date.now();
7049
+ const start = Number(account_doc?.credits_period_start_ts) || 0;
7050
+ const end = Number(account_doc?.credits_period_end_ts) || 0;
7051
+ if (start && end && now >= start && now < end) return { start, end, source: 'invoice' };
7052
+ const d = new Date(now);
7053
+ return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1), end: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), source: 'calendar' };
7054
+ };
7055
+
7056
+ // Single source of truth for "how many credits does this account have left".
7057
+ // Everything that gates, meters or displays credits must go through here.
7058
+ export const get_credit_balance = async function (uid, account_doc) {
7059
+ const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
7060
+ const period = _current_credit_period(doc);
7061
+
7062
+ // Active (stat 3) grants, summed by source, then split into the two pools.
7063
+ // Expired period grants drop out of this view on their own, which is what
7064
+ // makes the period pool reset. Legacy docs carry no credit_kind, so the
7065
+ // classification falls back to the source name (see _credit_kind).
7066
+ const view = await db_module.get_couch_view('xuda_billing', 'ai_credits', { startkey: [uid, ''], endkey: [uid, 'zzz'], reduce: true, group_level: 999 });
7067
+ const by_source = {};
7068
+ let period_grant = 0;
7069
+ let topup_total = 0;
7070
+ for (const row of view?.data?.rows || []) {
7071
+ const source = row.key[1];
7072
+ const credits = Number(row.value) || 0;
7073
+ by_source[source] = (by_source[source] || 0) + credits;
7074
+ if (_credit_kind({ source }) === CREDIT_KIND.TOPUP) topup_total += credits;
7075
+ else period_grant += credits;
7076
+ }
7077
+
7078
+ const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
7079
+ const used = _usage_total(usage_ret?.data?.usage);
7080
+
7081
+ const topup_consumed = Math.max(0, Number(doc?.credits_topup_consumed) || 0);
7082
+ const topup_available = Math.max(0, topup_total - topup_consumed);
7083
+
7084
+ // An "unlimited" tier (Enterprise T3) resolves to Infinity and must never gate.
7085
+ const unlimited = !Number.isFinite(get_effective_entitlements(doc).ai_credits);
7086
+ const granted = period_grant + topup_available;
7087
+
7088
+ return {
7089
+ period_start_ts: period.start,
7090
+ period_end_ts: period.end,
7091
+ period_source: period.source,
7092
+ period_grant,
7093
+ topup_total,
7094
+ topup_consumed,
7095
+ topup_available,
7096
+ granted,
7097
+ used,
7098
+ remaining: unlimited ? Infinity : Math.round((granted - used) * 100) / 100,
7099
+ unlimited,
7100
+ by_source,
7101
+ usage_profile: usage_ret?.data?.usage?.profile,
7102
+ };
7103
+ };
7104
+
7105
+ // Close out the period that just ended and open the next one. Called from the
7106
+ // invoice.paid webhook right before the new grant lands.
7107
+ //
7108
+ // The overflow bookkeeping is the important half: any spend beyond the old
7109
+ // period's allowance came out of topup, and because the next balance read only
7110
+ // looks at the NEW period's usage, that spend would otherwise be forgotten and
7111
+ // the customer would get those topup credits back every month.
7112
+ export const roll_credit_period = async function (uid, next_start_ts, next_end_ts) {
7113
+ try {
7114
+ const doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
7115
+ if (!doc) return { code: -1, data: 'account not found' };
7116
+
7117
+ const prev_start = Number(doc.credits_period_start_ts) || 0;
7118
+ const prev_end = Number(doc.credits_period_end_ts) || 0;
7119
+ if (prev_start && prev_end && Number(next_start_ts) > prev_start) {
7120
+ const prev = await get_credit_balance(uid, doc);
7121
+ const prev_overflow = Math.max(0, prev.used - prev.period_grant);
7122
+ if (prev_overflow > 0) doc.credits_topup_consumed = (Number(doc.credits_topup_consumed) || 0) + prev_overflow;
7123
+
7124
+ // Retire the old period's grants so they cannot be spent in the new one.
7125
+ // Scoped to period grants whose window has closed; topup is never touched.
7126
+ const stale = await db_module.find_couch_query('xuda_billing', { selector: { docType: 'ai_credit', credited_uid: uid, stat: 3 }, limit: 9999 });
7127
+ for (const c of stale?.docs || []) {
7128
+ if (_credit_kind(c) !== CREDIT_KIND.PERIOD) continue;
7129
+ const ended = Number(c.period_end_ts) || Number(c.date_created_ts) || 0;
7130
+ if (ended && ended > Number(next_start_ts)) continue; // belongs to the period we are opening
7131
+ c.stat = 5;
7132
+ c.stat_ts = Date.now();
7133
+ c.stat_reason = 'period closed';
7134
+ await db_module.save_couch_doc('xuda_billing', c);
7135
+ }
7136
+ }
7137
+
7138
+ doc.credits_period_start_ts = Number(next_start_ts) || Date.now();
7139
+ doc.credits_period_end_ts = Number(next_end_ts) || 0;
7140
+ const save = await db_module.save_couch_doc('xuda_accounts', doc);
7141
+ 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 } };
7142
+ } catch (err) {
7143
+ console.error('[roll_credit_period]', err?.message || err);
7144
+ return { code: -1, data: err?.message };
7145
+ }
7146
+ };
7147
+
6761
7148
  // Per-model / per-source spend is not emitted by the usage view, so re-derive it
6762
7149
  // from the raw ai_usage docs and CALIBRATE to the view's authoritative total, so the
6763
7150
  // numbers stay in the same credit unit (factor = view_total / raw_token_cost_total).
@@ -6769,7 +7156,9 @@ const _get_scoped_usage = async function (owner_uid, need_raw) {
6769
7156
  const cached = _scoped_usage_cache[owner_uid];
6770
7157
  if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
6771
7158
 
6772
- const usage_ret = await get_account_ai_usage({ uid: owner_uid, ..._LIFETIME_WINDOW });
7159
+ // No window: per-profile and per-member caps are an allocation of the CURRENT
7160
+ // period's pool, so they have to be measured over the same period the pool is.
7161
+ const usage_ret = await get_account_ai_usage({ uid: owner_uid });
6773
7162
  const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
6774
7163
  const total = _usage_total(usage_ret?.data?.usage);
6775
7164
 
@@ -6900,10 +7289,15 @@ export const evaluate_credit_gate = async function (req) {
6900
7289
  const account_doc = await db_module.get_couch_doc_native('xuda_accounts', owner);
6901
7290
  const rules = account_doc?.credit_rules;
6902
7291
 
6903
- // Back-compat: no rules or disabled => legacy single ledger cap, byte-for-byte.
7292
+ // No rules or disabled => the single account-wide pool cap, which is the
7293
+ // default for almost every account. This is what hard-blocks AI, so it MUST
7294
+ // read the same balance the dashboard shows the customer. It used to compute
7295
+ // its own (ledger grants - lifetime usage) while the Credit Management page
7296
+ // computed (plan entitlement - lifetime usage); the two drifted apart and a
7297
+ // paying Pro customer was blocked while his page reported 539 credits left.
6904
7298
  if (!rules || rules.enabled === false) {
6905
- const usage = await get_account_ai_usage({ uid: owner, ..._LIFETIME_WINDOW });
6906
- const over = (Number(usage?.data?.credits?.total) || 0) - _usage_total(usage?.data?.usage) < -0.5;
7299
+ const bal = await get_credit_balance(owner, account_doc);
7300
+ const over = bal.remaining < -0.5;
6907
7301
  return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
6908
7302
  }
6909
7303
 
@@ -7014,15 +7408,17 @@ export const get_credit_management = async function (req) {
7014
7408
  const plan = account_doc?.membership_plan || 'free';
7015
7409
  const can_edit = _is_team_tier(plan);
7016
7410
  const rules = account_doc?.credit_rules || _default_credit_rules();
7017
- const ent = get_effective_entitlements(account_doc);
7018
- const unlimited = !Number.isFinite(ent.ai_credits);
7019
- const entitlements = { ai_credits: unlimited ? null : ent.ai_credits, unlimited };
7411
+ // Report exactly what the gate enforces. These were two different formulas
7412
+ // and the page cheerfully showed credits to a customer the gate was blocking.
7413
+ const bal = await get_credit_balance(uid, account_doc);
7414
+ const unlimited = bal.unlimited;
7415
+ const entitlements = { ai_credits: unlimited ? null : bal.granted, unlimited };
7020
7416
 
7021
7417
  const su = await _get_scoped_usage(uid, true);
7022
- const remaining = unlimited ? null : Math.round((ent.ai_credits - su.total) * 100) / 100;
7418
+ const remaining = unlimited ? null : bal.remaining;
7023
7419
  const usage_breakdown = {
7024
- window: { mode: rules.window || 'lifetime' },
7025
- pool: { used: Math.round(su.total * 100) / 100, remaining },
7420
+ 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 },
7421
+ pool: { used: Math.round(bal.used * 100) / 100, remaining },
7026
7422
  by_profile: su.by_profile,
7027
7423
  by_user: su.by_user,
7028
7424
  by_model: su.by_model,
@@ -7312,7 +7708,26 @@ export const record_ai_usage = async function (uid, input_tokens, output_tokens,
7312
7708
  }
7313
7709
  };
7314
7710
 
7315
- export const record_ai_credit = async function (uid, credits = 0, source, details, credited_uid) {
7711
+ // A credit belongs to exactly one of two pools, and they behave differently:
7712
+ //
7713
+ // 'period' — the allowance that comes WITH a plan (the monthly membership
7714
+ // grant, and the free tier's daily drip). It is spent inside one
7715
+ // billing period and does NOT roll over: the next period starts
7716
+ // from the plan's number again.
7717
+ // 'topup' — credits the customer separately acquired (bought packs, ops
7718
+ // grants, the boarding bonus, referral bonuses). These never
7719
+ // reset; they sit there until they are used.
7720
+ //
7721
+ // Legacy docs predate the field, so _credit_kind() infers it from `source`
7722
+ // rather than requiring a backfill.
7723
+ const CREDIT_KIND = { PERIOD: 'period', TOPUP: 'topup' };
7724
+ const _TOPUP_SOURCES = new Set(['purchase', 'admin_grant', 'boarding', 'contact connection']);
7725
+ const _credit_kind = function (doc) {
7726
+ if (doc?.credit_kind === CREDIT_KIND.TOPUP || doc?.credit_kind === CREDIT_KIND.PERIOD) return doc.credit_kind;
7727
+ return _TOPUP_SOURCES.has(doc?.source) ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD;
7728
+ };
7729
+
7730
+ export const record_ai_credit = async function (uid, credits = 0, source, details, credited_uid, opts = {}) {
7316
7731
  try {
7317
7732
  var dup = await db_module.find_couch_query('xuda_billing', {
7318
7733
  selector: {
@@ -7339,7 +7754,11 @@ export const record_ai_credit = async function (uid, credits = 0, source, detail
7339
7754
  stat: 3,
7340
7755
  credited_uid,
7341
7756
  details,
7757
+ credit_kind: opts.kind === CREDIT_KIND.TOPUP ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD,
7342
7758
  };
7759
+ if (opts.period_start_ts) credit_doc.period_start_ts = opts.period_start_ts;
7760
+ if (opts.period_end_ts) credit_doc.period_end_ts = opts.period_end_ts;
7761
+ if (opts.stripe_invoice_id) credit_doc.stripe_invoice_id = opts.stripe_invoice_id;
7343
7762
  const save_ret = await db_module.save_couch_doc('xuda_billing', credit_doc);
7344
7763
  // console.log(save_ret);
7345
7764
  broadcast_credits(credited_uid); // live meter: this account just gained credits
@@ -7364,7 +7783,9 @@ export const add_ai_credits_to_active_accounts = async function () {
7364
7783
  const _24_hr_ms = 1000 * 60 * 60 * 24;
7365
7784
  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 });
7366
7785
  for await (let account_doc of active_accounts.docs) {
7367
- const ret = await record_ai_credit('system', 1, 'daily credit', 'free daily credit ' + date_str, account_doc._id);
7786
+ // The free tier's drip IS its plan allowance, so it is a period credit: it
7787
+ // resets with the period instead of accumulating forever.
7788
+ const ret = await record_ai_credit('system', 1, 'daily credit', 'free daily credit ' + date_str, account_doc._id, { kind: CREDIT_KIND.PERIOD });
7368
7789
  if (ret.code > -1) {
7369
7790
  account_doc.last_free_daily_ai_credit_ts = Date.now();
7370
7791
  account_doc.last_free_daily_ai_credit_id = ret.data.id;
@@ -7376,11 +7797,23 @@ export const add_ai_credits_to_active_accounts = async function () {
7376
7797
  export const archive_expire_ai_credits = async function () {
7377
7798
  const _24_hr_ms = 1000 * 60 * 60 * 24;
7378
7799
  const _mo_ms = _24_hr_ms * 30;
7800
+ const now = Date.now();
7379
7801
 
7380
- 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 });
7802
+ 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 });
7381
7803
  for await (let ai_credit_doc of active_ai_credits.docs) {
7804
+ // TOPUP credits are the customer's property until they spend them: bought
7805
+ // packs, ops grants, the welcome bonus. This cron used to expire those too,
7806
+ // silently deleting credits people had paid real money for.
7807
+ if (_credit_kind(ai_credit_doc) === CREDIT_KIND.TOPUP) continue;
7808
+
7809
+ // A period grant expires when its PERIOD ends, not 30 days after it was
7810
+ // written. Those differ whenever a month is 31 days long, and the old rule
7811
+ // would have retired the current allowance a day early.
7812
+ const period_end = Number(ai_credit_doc.period_end_ts) || 0;
7813
+ if (period_end && period_end > now) continue;
7814
+
7382
7815
  ai_credit_doc.stat = 5;
7383
- ai_credit_doc.stat_ts = Date.now();
7816
+ ai_credit_doc.stat_ts = now;
7384
7817
  ai_credit_doc.stat_reason = 'expired';
7385
7818
  const save_ret = await db_module.save_couch_doc('xuda_billing', ai_credit_doc);
7386
7819
  }
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.2301",
3
+ "version": "1.2.2303",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {