@xuda.io/account_module 1.2.2305 → 1.2.2307

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.
Files changed (2) hide show
  1. package/index.mjs +147 -24
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -588,43 +588,151 @@ const _MODULE_SUBSCRIPTIONS = [
588
588
  // switched on there as well as here. Both call tickets_set_plan, so the plan
589
589
  // is one line item on the consolidated subscription either way.
590
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' },
591
+ // Auto response is the single gate for whether the AI answers, on every channel
592
+ // (chat, the public chat widget, email and the phone). It is account scoped and
593
+ // billed once however many scenarios are written, so it is a plain line item
594
+ // here rather than a per-resource charge like phone or email.
595
+ { key: 'auto_response', scope: 'account', field: 'auto_response_plan', default_plan: 'auto_response_free' },
596
+ // Four-tier ladder since 2026-08-06 (free / external / api / custom rules),
597
+ // chosen on the Bot Protection Manager's Plans tab, which calls
598
+ // bot_protection_set_plan. Free holds no line item at all.
599
+ { key: 'bot_protection', scope: 'account', field: 'bot_protection_plan', default_plan: 'bot_protection_free' },
594
600
  { key: 'static_website', scope: 'resource' },
595
601
  { key: 'profile_phone', scope: 'resource' },
602
+ // UI-96: the hosted resources. Not PLAN_OBJ categories, they are billed per resource
603
+ // from `app_cost` on the app doc, but they are money on the same invoice, so the card
604
+ // that answers "what am I paying for" has to show them.
605
+ { key: 'vps', scope: 'resource' },
606
+ { key: 'datacenter', scope: 'resource' },
607
+ { key: 'project', scope: 'resource' },
608
+ { key: 'preview', scope: 'resource' },
609
+ { key: 'instance', scope: 'resource' },
610
+ { key: 'backup', scope: 'resource' },
611
+ // UI-97: a registered domain renews yearly and a dedicated IPv4 is a monthly line,
612
+ // so both carry their own `cycle` on each item rather than being assumed monthly.
613
+ { key: 'domain', scope: 'resource' },
614
+ { key: 'ipv4', scope: 'resource' },
596
615
  // Xuda Verify (verify_module) has no PLAN_OBJ category: every check is metered
597
616
  // per call into verify_meter and nothing is charged to the account today.
598
617
  { key: 'trust_center', scope: 'metered' },
599
618
  ];
600
619
 
620
+ // app_type -> the row it belongs under. An app type that is not here is not a
621
+ // recurring hosted charge (or is not something the customer thinks of as one), so it
622
+ // simply does not appear rather than landing in a catch-all.
623
+ const _HOSTING_GROUP = {
624
+ vps: 'vps',
625
+ datacenter: 'datacenter',
626
+ master: 'project',
627
+ preview: 'preview',
628
+ instance: 'instance',
629
+ backup: 'backup',
630
+ static_website: 'static_website',
631
+ };
632
+
601
633
  // The individual paid things inside a per-resource module, so the billing
602
634
  // screen can be the ONE place that shows everything the account pays for.
603
635
  // A site or a number on a free tier is not a subscription and is left out.
604
636
  const _module_resource_items = async (uid) => {
605
- const items = { static_website: [], profile_phone: [] };
637
+ const items = {};
638
+ for (const m of _MODULE_SUBSCRIPTIONS) if (m.scope === 'resource') items[m.key] = [];
639
+
640
+ // UI-96: every hosted resource is a monthly charge, so the card walks the account's
641
+ // apps the way BILLING itself does (the `user_apps` view plus `is_billable` from
642
+ // deploy_module/cost.mjs, which is what get_billing_metrics uses) and groups the live
643
+ // ones by kind. Reading `app_cost.monthly_total` rather than re-deriving a price from
644
+ // the plan catalog is what keeps this card and the invoice from disagreeing.
645
+ try {
646
+ const { is_billable, format_app_cost_summary } = await import(`${module_path}/deploy_module/cost.mjs`);
647
+ const apps_ret = await db_module.get_couch_view('xuda_master', 'user_apps', {
648
+ startkey: [uid, ''],
649
+ endkey: [uid, 'ZZZZZ'],
650
+ include_docs: true,
651
+ });
652
+ for (const row of apps_ret?.data?.rows || []) {
653
+ const app = row.doc;
654
+ if (!app || !is_billable(app)) continue;
655
+ // Billing's own definition of live: status 4 and up is gone, and a terminated
656
+ // deployment stops costing. A deleted website was being counted before this.
657
+ if (Number(app.app_status_code) >= 4) continue;
658
+ if (app.app_cost?.terminated_ts) continue;
659
+ const key = _HOSTING_GROUP[app.app_type];
660
+ if (!key || !items[key]) continue;
661
+ let summary = '';
662
+ try {
663
+ summary = format_app_cost_summary(app) || '';
664
+ } catch (e) {
665
+ summary = '';
666
+ }
667
+ items[key].push({
668
+ id: app._id,
669
+ label: app.app_name || app.name || app.deploy_data?.domain || app._id,
670
+ plan_id: app.app_cost?.app_server_type || app.app_type,
671
+ plan_name: summary || app.app_cost?.app_server_type || '',
672
+ price: Number(app.app_cost?.monthly_total) || 0,
673
+ });
674
+ }
675
+ } catch (e) {
676
+ console.error('[get_module_subscriptions hosting]', e.message);
677
+ }
606
678
 
679
+ // UI-97: domains registered THROUGH Xuda. A domain the customer merely connected
680
+ // (registered elsewhere, `connection_type: 'connected'`) costs nothing here, so the
681
+ // Stripe yearly price is what decides whether it is a subscription at all.
607
682
  try {
608
683
  const ret = await db_module.find_couch_query(
609
684
  'xuda_master',
610
- { selector: { docType: 'app', app_type: 'static_website', app_uId: uid }, limit: 1000 },
685
+ { selector: { docType: 'domain_registration', owner_uid: uid }, limit: 500 },
611
686
  true,
612
687
  );
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;
688
+ for (const dom of ret?.docs || []) {
689
+ const price = Number(dom.stripe_price_yr) || 0;
617
690
  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,
691
+ const renews = dom.expires_at ? new Date(dom.expires_at) : null;
692
+ items.domain.push({
693
+ id: dom._id,
694
+ label: dom.name || dom._id,
695
+ // Auto renew off means it lapses at expiry, which is the one thing worth
696
+ // saying on a billing screen about a domain.
697
+ plan_name: dom.auto_renew === false
698
+ ? `Renews ${renews ? renews.toISOString().slice(0, 10) : 'at expiry'}, auto renew off`
699
+ : `Renews ${renews ? renews.toISOString().slice(0, 10) : 'yearly'}`,
623
700
  price,
701
+ cycle: 'yr',
624
702
  });
625
703
  }
626
704
  } catch (e) {
627
- console.error('[get_module_subscriptions static_website]', e.message);
705
+ console.error('[get_module_subscriptions domains]', e.message);
706
+ }
707
+
708
+ // UI-97: dedicated IPv4. The per-address price depends on what it is doing
709
+ // (internal / external / parked), which is exactly how ipv4_module prices it, so the
710
+ // rate is read from PRICE_OBJ the same way rather than assumed.
711
+ try {
712
+ const prices = {
713
+ internal: Number(_conf.PRICE_OBJ?.ipv4?.internal ?? 2),
714
+ external: Number(_conf.PRICE_OBJ?.ipv4?.external ?? 10),
715
+ parked: Number(_conf.PRICE_OBJ?.ipv4?.parked ?? 5),
716
+ };
717
+ const ret = await db_module.find_couch_query(
718
+ 'xuda_ipv4',
719
+ { selector: { docType: 'ipv4_addr', account_id: uid }, limit: 1000 },
720
+ true,
721
+ );
722
+ for (const ip of ret?.docs || []) {
723
+ // A suspended address keeps billing at the rate it held, same as ipv4_module.
724
+ const bill = ip.state === 'suspended' ? ip.bill_as : ip.state;
725
+ const price = Number(prices[bill]) || 0;
726
+ if (!price) continue;
727
+ items.ipv4.push({
728
+ id: ip._id,
729
+ label: ip.address || ip._id,
730
+ plan_name: [bill, ip.target ? `on ${ip.target}` : ''].filter(Boolean).join(', '),
731
+ price,
732
+ });
733
+ }
734
+ } catch (e) {
735
+ console.error('[get_module_subscriptions ipv4]', e.message);
628
736
  }
629
737
 
630
738
  try {
@@ -674,7 +782,6 @@ export const get_module_subscriptions = async function (req = {}) {
674
782
  if (ar.code < 0 || !ar.data) return { code: -404, data: 'account not found' };
675
783
  const account = ar.data;
676
784
  const plans = _conf.PLAN_OBJ || {};
677
- const is_member = !!(account.membership_plan && account.membership_plan !== 'free');
678
785
  // A category is only offerable once its paid tiers carry a real Stripe
679
786
  // price, otherwise "activate" would set a plan nobody is billed for.
680
787
  const billable = (key) =>
@@ -684,12 +791,11 @@ export const get_module_subscriptions = async function (req = {}) {
684
791
 
685
792
  const modules = _MODULE_SUBSCRIPTIONS.map((m) => {
686
793
  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
794
  if (m.scope === 'resource' || m.scope === 'metered') {
692
795
  const price = base.items.reduce((sum, i) => sum + Number(i.price || 0), 0);
796
+ // UI-97: a row is only yearly when everything in it is (domains). Anything
797
+ // else stays monthly, so a total is never a sum of two different cycles.
798
+ const cycle = base.items.length && base.items.every((i) => i.cycle === 'yr') ? 'yr' : 'mo';
693
799
  return {
694
800
  ...base,
695
801
  active: m.scope === 'metered' || base.items.length > 0,
@@ -697,6 +803,7 @@ export const get_module_subscriptions = async function (req = {}) {
697
803
  plan_id: '',
698
804
  plan_name: '',
699
805
  price,
806
+ cycle,
700
807
  };
701
808
  }
702
809
  const plan_id = account[m.field] || m.default_plan;
@@ -4052,8 +4159,20 @@ const get_contact_background = function (doc) {
4052
4159
  return ret;
4053
4160
  };
4054
4161
 
4162
+ // The card's background pattern doubles as a verification signal: L0..L4 each
4163
+ // have their own tile, distinguishable by density and brightness as well as by
4164
+ // the level token, so the level reads at a glance without another badge. Level
4165
+ // comes from account_info.verify_level, the projection verify_module writes on
4166
+ // every recompute; anything missing or out of range reads as L0, which is the
4167
+ // honest default for an account we have not scored.
4168
+ const level_pattern = function (doc) {
4169
+ const raw = doc?.verify_level ?? doc?.account_info?.verify_level ?? doc?.user_contact?.verify_level;
4170
+ const level = Number.isFinite(Number(raw)) ? Math.max(0, Math.min(4, Math.trunc(Number(raw)))) : 0;
4171
+ return `level-${level}-pattern.png`;
4172
+ };
4173
+
4055
4174
  const get_contact_pattern = async function (doc) {
4056
- let ret = `default-pattern.png`;
4175
+ let ret = level_pattern(doc);
4057
4176
 
4058
4177
  if (doc?.shared_from_uid) {
4059
4178
  const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
@@ -6293,7 +6412,9 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
6293
6412
  }
6294
6413
 
6295
6414
  const get_pattern = async function (doc) {
6296
- let ret = `default-pattern.png`;
6415
+ // Same level-based default as get_contact_pattern, so a card shows the same
6416
+ // verification tile whichever path built it.
6417
+ let ret = level_pattern(doc);
6297
6418
 
6298
6419
  if (doc.shared_from_uid) {
6299
6420
  // const account_profile_doc = await db_module.get_couch_doc_native('xuda_accounts', doc.share_item_id);
@@ -8214,8 +8335,10 @@ const _nl_one = async (db, docType) => {
8214
8335
  // notification DB on EVERY get_account_data: "No matching index found" plus a full-DB
8215
8336
  // examine per dashboard load. uid leads (most selective), then topic, then read; the extra
8216
8337
  // docType / delivery_method clauses those callers add are cheap in-memory filters once
8217
- // uid + topic have narrowed the set to a handful. Unlike the content DBs above,
8218
- // xuda_notification is node-local on every box, so this runs unconditionally.
8338
+ // uid + topic have narrowed the set to a handful. Not host-gated like the content DBs
8339
+ // above: xuda_notification exists on every box, so every node should ensure the index.
8340
+ // The fleet boxes replicate it through the master hub (so the PUT no-ops once one node
8341
+ // has it) but dev is outside that hub and only gets the index from its own run.
8219
8342
  (async () => {
8220
8343
  try {
8221
8344
  const ret = await db_module.create_couch_index('xuda_notification', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2305",
3
+ "version": "1.2.2307",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {