@xuda.io/account_module 1.2.2308 → 1.2.2309

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 +143 -22
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -542,11 +542,18 @@ export const save_admin_presets = async function (req) {
542
542
  };
543
543
 
544
544
  // Effective entitlements = the membership plan's base quota PLUS the AI-workspace
545
- // add-on's credits + storage stacked on top. The Free membership tier does NOT
546
- // stack (it keeps its base only); every paid tier adds whatever ai_workspace plan
547
- // the account carries. String quotas ("unlimited", e.g. Enterprise T3) short-circuit
548
- // to Infinity so downstream numeric comparisons never cap. Single source of truth —
549
- // use for quota checks and any effective-quota display/API.
545
+ // add-on's credits + storage stacked on top, PLUS the Drive plan's own storage.
546
+ // The Free membership tier does NOT stack the workspace add-on (it keeps its base
547
+ // only); every paid tier adds whatever ai_workspace plan the account carries.
548
+ // String quotas ("unlimited", e.g. Enterprise T3) short-circuit to Infinity so
549
+ // downstream numeric comparisons never cap. Single source of truth, use it for
550
+ // quota checks and any effective-quota display or API.
551
+ //
552
+ // UI-113: the Drive plan (drive_* category) is a storage add-on the customer buys
553
+ // on its own, so unlike the workspace it stacks on EVERY membership tier including
554
+ // free. Withholding space that has its own line on the invoice would be selling
555
+ // nothing. The breakdown comes back with it, because a customer looking at a full
556
+ // bar needs to see which of the three plans to move to get more room.
550
557
  export const get_effective_entitlements = function (account_doc) {
551
558
  const membership = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
552
559
  const mf = membership?.features || {};
@@ -554,15 +561,34 @@ export const get_effective_entitlements = function (account_doc) {
554
561
  const numOrInf = (v) => (typeof v === 'string' ? Infinity : Number(v) || 0);
555
562
  let ai_credits = numOrInf(mf.ai_credits);
556
563
  let drive_gb = numOrInf(mf.drive);
564
+ const membership_drive_gb = drive_gb;
565
+ let workspace_drive_gb = 0;
566
+ let plan_drive_gb = 0;
557
567
 
558
568
  if (account_doc?.membership_plan && account_doc.membership_plan !== 'free') {
559
569
  const ws = _conf.PLAN_OBJ?.[account_doc?.ai_workspace_plan];
560
570
  if (ws && ws.category === 'ai_workspace') {
561
571
  if (Number.isFinite(ai_credits)) ai_credits += Number(ws.ai_credits) || 0;
562
- if (Number.isFinite(drive_gb)) drive_gb += (Number(ws.size) || 0) / 1073741824;
572
+ workspace_drive_gb = (Number(ws.size) || 0) / 1073741824;
573
+ if (Number.isFinite(drive_gb)) drive_gb += workspace_drive_gb;
563
574
  }
564
575
  }
565
- return { ai_credits, drive_gb };
576
+
577
+ const drive_plan = _conf.PLAN_OBJ?.[account_doc?.drive_plan];
578
+ if (drive_plan && drive_plan.category === 'drive') {
579
+ plan_drive_gb = Number(drive_plan.flags?.extra_gb) || 0;
580
+ if (Number.isFinite(drive_gb)) drive_gb += plan_drive_gb;
581
+ }
582
+
583
+ return {
584
+ ai_credits,
585
+ drive_gb,
586
+ drive_breakdown: {
587
+ membership_gb: membership_drive_gb,
588
+ workspace_gb: workspace_drive_gb,
589
+ drive_plan_gb: plan_drive_gb,
590
+ },
591
+ };
566
592
  };
567
593
 
568
594
  // ===================================================================
@@ -588,19 +614,58 @@ const _MODULE_SUBSCRIPTIONS = [
588
614
  // switched on there as well as here. Both call tickets_set_plan, so the plan
589
615
  // is one line item on the consolidated subscription either way.
590
616
  { key: 'tickets', scope: 'account', field: 'tickets_plan', default_plan: 'tickets_free' },
617
+ // UI-104: email is its own product, billed per mailbox tier. Its plan lives on the
618
+ // account as an OBJECT (`email_plan.tier`), which is why it resolves rather than
619
+ // reading a field, and `set_email_plan` is what moves it.
620
+ {
621
+ key: 'email',
622
+ scope: 'account',
623
+ field: 'email_plan',
624
+ default_plan: 'email_free',
625
+ resolve: (a) => (a && a.email_plan && a.email_plan.tier ? `email_${a.email_plan.tier}` : ''),
626
+ },
591
627
  // Auto response is the single gate for whether the AI answers, on every channel
592
628
  // (chat, the public chat widget, email and the phone). It is account scoped and
593
629
  // billed once however many scenarios are written, so it is a plain line item
594
630
  // 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' },
631
+ //
632
+ // UI-110: "nothing auto-responds before a plan is picked" is the module's own rule
633
+ // (auto_response_module assert_active), so PICKED is what active means here, Free
634
+ // included. Judging it on price alone would report an account that deliberately runs
635
+ // the free tier as switched off, and the menu gate would then hide the screen it uses.
636
+ {
637
+ key: 'auto_response',
638
+ scope: 'account',
639
+ field: 'auto_response_plan',
640
+ default_plan: 'auto_response_free',
641
+ is_active: (a) => !!a.auto_response_plan,
642
+ },
596
643
  // Four-tier ladder since 2026-08-06 (free / external / api / custom rules),
597
644
  // chosen on the Bot Protection Manager's Plans tab, which calls
598
645
  // bot_protection_set_plan. Free holds no line item at all.
599
646
  // No default_plan on purpose: Free is a tier the customer activates, so an
600
647
  // account that never chose one reads as not active rather than as Free.
601
- { key: 'bot_protection', scope: 'account', field: 'bot_protection_plan', default_plan: '' },
648
+ { key: 'bot_protection', scope: 'account', field: 'bot_protection_plan', default_plan: '', is_active: (a) => !!a.bot_protection_plan },
602
649
  { key: 'static_website', scope: 'resource' },
603
- { key: 'profile_phone', scope: 'resource' },
650
+ // Phone is the one module that is BOTH: an account-wide tier (set_voice_plan, held in
651
+ // communications.voice_plan) and a per-number charge, because the plan fee rides the
652
+ // oldest live number's own Stripe subscription rather than the consolidated one. So the
653
+ // row carries the tier for the switch AND one item per number for the billing card.
654
+ //
655
+ // billable is forced true for the same reason: the generic test looks for a Stripe
656
+ // price_id on a paid tier, and the phone ladder has none by design, its money is on the
657
+ // number. Left to the generic test the module would read "coming soon" while customers
658
+ // are being billed for numbers.
659
+ {
660
+ key: 'profile_phone',
661
+ scope: 'resource',
662
+ billable: true,
663
+ plan_of: (a) => `profile_phone_${a?.communications?.voice_plan?.tier || 'free'}`,
664
+ // get_voice_plans' own definition, kept identical on purpose: a number bought before
665
+ // UI-84 counts as active, so an existing customer is not asked to activate a plan
666
+ // they already pay for.
667
+ is_active: (a, items) => !!a?.communications?.voice_plan?.activated_ts || items.length > 0,
668
+ },
604
669
  // UI-96: the hosted resources. Not PLAN_OBJ categories, they are billed per resource
605
670
  // from `app_cost` on the app doc, but they are money on the same invoice, so the card
606
671
  // that answers "what am I paying for" has to show them.
@@ -614,9 +679,26 @@ const _MODULE_SUBSCRIPTIONS = [
614
679
  // so both carry their own `cycle` on each item rather than being assumed monthly.
615
680
  { key: 'domain', scope: 'resource' },
616
681
  { key: 'ipv4', scope: 'resource' },
617
- // Xuda Verify (verify_module) has no PLAN_OBJ category: every check is metered
618
- // per call into verify_meter and nothing is charged to the account today.
619
- { key: 'trust_center', scope: 'metered' },
682
+ // Trust Center (verify_module) is sold on the trust_center_* ladder: the tier
683
+ // buys a capability ceiling and an included volume of live verifications for
684
+ // the customer's OWN users, and traffic past the allowance still meters per
685
+ // check into verify_meter. Account scoped since 2026-08-06; it was 'metered'
686
+ // while the service had no ladder behind it. Xuda's own verification is never
687
+ // priced by this plan, deliberately: see the note in verify_module.
688
+ { key: 'trust_center', scope: 'account', field: 'trust_center_plan', default_plan: 'trust_center_free' },
689
+ // The Marketplace seller ladder: the tier buys live listings, the listing types the
690
+ // account may sell, and above all the cut Xuda keeps on each sale (20% down to 5%).
691
+ // is_active on the FIELD rather than on price, like auto response: Free is a tier a
692
+ // seller picks, and it is the tier that decides their commission, so an account that
693
+ // deliberately runs Free is switched on and must not read as off.
694
+ { key: 'marketplace', scope: 'account', field: 'marketplace_plan', default_plan: 'marketplace_free', is_active: (a) => !!a.marketplace_plan },
695
+ // UI-113: Drive is sold on its own drive_* ladder, storage only. The tier buys space
696
+ // STACKED on top of what the membership and the AI workspace plan already include
697
+ // (get_effective_entitlements), which is why the free tier is 0 extra GB rather than
698
+ // a number: an account that never touched this ladder keeps exactly the space it has
699
+ // today and owes nothing new. Judged on price, so Free reads as not active, because
700
+ // here that is the truth: Free adds nothing and holds no line on the invoice.
701
+ { key: 'drive', scope: 'account', field: 'drive_plan', default_plan: 'drive_free' },
620
702
  ];
621
703
 
622
704
  // app_type -> the row it belongs under. An app type that is not here is not a
@@ -785,30 +867,42 @@ export const get_module_subscriptions = async function (req = {}) {
785
867
  const account = ar.data;
786
868
  const plans = _conf.PLAN_OBJ || {};
787
869
  // A category is only offerable once its paid tiers carry a real Stripe
788
- // price, otherwise "activate" would set a plan nobody is billed for.
789
- const billable = (key) =>
790
- Object.values(plans).some((p) => p.category === key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
870
+ // price, otherwise "activate" would set a plan nobody is billed for. A module
871
+ // whose money is NOT a plan line item says so itself (phone), because for those
872
+ // an absent price_id is the design, not a gap.
873
+ const billable = (m) =>
874
+ m.billable === true ||
875
+ Object.values(plans).some((p) => p.category === m.key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
791
876
 
792
877
  const resource_items = await _module_resource_items(uid);
793
878
 
794
879
  const modules = _MODULE_SUBSCRIPTIONS.map((m) => {
795
- const base = { key: m.key, scope: m.scope, billable: billable(m.key), items: resource_items[m.key] || [] };
880
+ const base = { key: m.key, scope: m.scope, billable: billable(m), items: resource_items[m.key] || [] };
796
881
  if (m.scope === 'resource' || m.scope === 'metered') {
797
882
  const price = base.items.reduce((sum, i) => sum + Number(i.price || 0), 0);
798
883
  // UI-97: a row is only yearly when everything in it is (domains). Anything
799
884
  // else stays monthly, so a total is never a sum of two different cycles.
800
885
  const cycle = base.items.length && base.items.every((i) => i.cycle === 'yr') ? 'yr' : 'mo';
886
+ // UI-110: a resource row may still sit under an account-wide tier (phone), and
887
+ // the switch needs that tier, not the sum of the numbers. Everything without a
888
+ // `plan_of` keeps reporting no plan, exactly as before.
889
+ const plan_id = m.plan_of ? m.plan_of(account) : '';
890
+ const plan = plans[plan_id] || {};
801
891
  return {
802
892
  ...base,
803
- active: m.scope === 'metered' || base.items.length > 0,
893
+ active: m.is_active ? !!m.is_active(account, base.items) : m.scope === 'metered' || base.items.length > 0,
804
894
  included: m.scope === 'metered',
805
- plan_id: '',
806
- plan_name: '',
895
+ plan_id,
896
+ plan_name: plan.name || '',
897
+ plan_price: Number(plan.price) || 0,
807
898
  price,
808
899
  cycle,
809
900
  };
810
901
  }
811
- const plan_id = account[m.field] || m.default_plan;
902
+ // UI-104: most modules keep their plan id directly on the account, but email
903
+ // keeps `{ tier, activated_ts }`, so a module may bring its own resolver rather
904
+ // than forcing every field into the same shape.
905
+ const plan_id = (m.resolve ? m.resolve(account) : account[m.field]) || m.default_plan;
812
906
  const plan = plans[plan_id] || {};
813
907
  // UI-103: a switch-off is a DOWNGRADE, so Stripe schedules it for the end of the
814
908
  // period the customer already paid for and the plan field does not move until the
@@ -824,7 +918,10 @@ export const get_module_subscriptions = async function (req = {}) {
824
918
  plan_id,
825
919
  plan_name: plan.name || plan_id,
826
920
  price,
827
- active: price > 0,
921
+ // UI-110: paying is the default proof that a module is on, but a module that
922
+ // records the customer CHOOSING a tier says so itself, because a deliberate
923
+ // free tier is switched on and a price of zero cannot tell the two apart.
924
+ active: m.is_active ? !!m.is_active(account, base.items) : price > 0,
828
925
  included: false,
829
926
  pending,
830
927
  changed_ts: account[`${m.field}_changed`] || null,
@@ -1783,7 +1880,31 @@ export const get_account_data = async function (req) {
1783
1880
  builds_drive_size: acc_obj.builds_drive_size || 0,
1784
1881
  project_data_size: acc_obj.project_data_size || 0,
1785
1882
  total_drive_size: acc_obj.total_drive_size || 0,
1883
+ // UI-113: the QUOTA travels with the usage now. Every surface that drew a
1884
+ // storage bar (the sidenav popover, UsageBar, the Drive plans screen) was
1885
+ // deriving the ceiling from the ai_workspace plan's `size` alone, which
1886
+ // ignored the membership base and would have ignored the Drive plan too, so
1887
+ // a bar could read full while the account had room. This is the same
1888
+ // get_effective_entitlements the overage metering charges on, in bytes.
1889
+ ...(() => {
1890
+ const ent = get_effective_entitlements(acc_obj);
1891
+ const gb = 1073741824;
1892
+ const b = ent.drive_breakdown || {};
1893
+ return {
1894
+ quota_bytes: Number.isFinite(ent.drive_gb) ? Math.round(ent.drive_gb * gb) : 0,
1895
+ quota_unlimited: !Number.isFinite(ent.drive_gb),
1896
+ quota_from: {
1897
+ membership_bytes: Math.round((b.membership_gb === Infinity ? 0 : b.membership_gb || 0) * gb),
1898
+ workspace_bytes: Math.round((b.workspace_gb || 0) * gb),
1899
+ drive_plan_bytes: Math.round((b.drive_plan_gb || 0) * gb),
1900
+ },
1901
+ };
1902
+ })(),
1786
1903
  },
1904
+ // UI-113: the Drive storage ladder the account sits on, for the plans screen
1905
+ // and the Modules card. Absent on an account that never picked one, which the
1906
+ // registry reads as drive_free.
1907
+ drive_plan: acc_obj.drive_plan || 'drive_free',
1787
1908
 
1788
1909
  stripe_connect_account_id: acc_obj?.stripe_connect_account_obj?.id,
1789
1910
  stripe_connect_account_status: acc_obj?.stripe_connect_account_status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2308",
3
+ "version": "1.2.2309",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {