@xuda.io/account_module 1.2.2258 → 1.2.2259

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
@@ -580,42 +580,9 @@ export const increment_account_usage = async function (req) {
580
580
  // app type?".
581
581
  const BILLABLE_APP_TYPES = ['vps', 'datacenter', 'instance', 'balancer'];
582
582
 
583
- // Stamp `app_cost.terminated_ts` on an app doc IMMEDIATELY, without
584
- // waiting for the destroy job to pick up. Called from the dashboard
585
- // the instant the user confirms a destroy so the billing UI reacts
586
- // without delay.
587
- //
588
- // Why we need this: destroy_app runs with { job: true } — i.e. the
589
- // HTTP call queues a background job and returns right away. The job
590
- // worker eventually executes destroy_app server-side, which marks
591
- // terminated_ts at the START of its own execution. But during the
592
- // window between "request returns" and "job worker starts", the
593
- // app doc still looks billable. The dashboard refresh in that
594
- // window sees the old state — exactly the "GUI doesn't react /
595
- // second refresh fixes it" symptom.
596
- //
597
- // This endpoint sidesteps the job queue entirely. It's a synchronous
598
- // 2-line write — no DO API calls, no destroy chain. The dashboard
599
- // calls it BEFORE the destroy_app job, returns can render the new
600
- // billing state without waiting on infra teardown.
601
- //
602
- // Idempotent: if terminated_ts is already set, returns immediately.
603
- export const mark_app_terminated = async function (req) {
604
- const { app_id } = req || {};
605
- if (!app_id) return { code: -1, data: 'app_id required' };
606
- try {
607
- const ret = await db_module.get_couch_doc('xuda_master', app_id);
608
- if (ret.code < 0) return { code: -1, data: 'app not found' };
609
- const app_doc = ret.data;
610
- if (!app_doc.app_cost) return { code: 1, data: 'no app_cost — nothing to mark' };
611
- if (app_doc.app_cost.terminated_ts) return { code: 1, data: 'already terminated' };
612
- app_doc.app_cost.terminated_ts = Date.now();
613
- await db_module.save_couch_doc('xuda_master', app_doc);
614
- return { code: 1, data: { terminated_ts: app_doc.app_cost.terminated_ts } };
615
- } catch (err) {
616
- return { code: -1, data: err.message };
617
- }
618
- };
583
+ // `mark_app_terminated` was removed its job is now done by
584
+ // `pre_destroy_app` in app_module, wired up via http_module's
585
+ // pre_dispatch hook on `destroy_app`. See cpi/app_module/index.mjs.
619
586
 
620
587
  // -------------------------------------------------------------------
621
588
  // Abuse detection — see the "How can we detect abuse?" plan.
@@ -1023,8 +990,16 @@ export const get_account_data = async function (req) {
1023
990
  support_plan_changed: acc_obj.support_plan_changed,
1024
991
  ai_workspace_plan: acc_obj.ai_workspace_plan,
1025
992
  storage_plan_changed: acc_obj.storage_plan_changed,
993
+ account_hosted_email: acc_obj.account_hosted_email || null,
1026
994
  account_project_id: acc_obj.account_project_id,
1027
- is_boarded: acc_obj.isBoarded,
995
+ // Coerce to explicit boolean. New accounts have `isBoarded`
996
+ // undefined; without this, JSON serialization drops the field
997
+ // entirely. The dashboard's SAVE_user uses spread-merge which
998
+ // doesn't overwrite missing-from-response fields, so a stale
999
+ // `is_boarded: true` from a previous user's localStorage would
1000
+ // never get cleared. Always sending an explicit false keeps the
1001
+ // gate honest.
1002
+ is_boarded: !!acc_obj.isBoarded,
1028
1003
 
1029
1004
  prices_info: acc_obj.prices_info,
1030
1005
  activity_usage: acc_obj.activity_usage,
@@ -1093,6 +1068,52 @@ export const get_account_instances = async function (req) {
1093
1068
  key: req.uid,
1094
1069
  });
1095
1070
  };
1071
+ // Standalone VPS list for the sidenav — lets the dashboard refresh DATA.vps
1072
+ // after a deploy without a full account reload (SideNavData calls this). Same
1073
+ // user_vps view that the initial account load uses.
1074
+ export const get_account_vps = async function (req) {
1075
+ return await db_module.get_couch_view('xuda_master', 'user_vps', {
1076
+ key: req.uid,
1077
+ });
1078
+ };
1079
+
1080
+ // Advertising campaigns are apps (app_type:'advertising'). A Mango query by
1081
+ // owner avoids needing a dedicated CouchDB view; shaped like get_couch_view
1082
+ // ({code, data:{rows:[{id,value}]}}) so SideNavData buckets them into
1083
+ // DATA.advertising exactly like deployments/vps.
1084
+ export const get_account_advertising = async function (req) {
1085
+ try {
1086
+ // Superuser sees EVERY campaign across all accounts; everyone else only their own.
1087
+ const isSuper = _conf.superuser_account_ids?.includes(req.uid);
1088
+ const selector = isSuper ? { app_type: 'advertising', docType: 'app' } : { app_uId: req.uid, app_type: 'advertising', docType: 'app' };
1089
+ const ret = await db_module.find_couch_query('xuda_master', {
1090
+ selector,
1091
+ limit: 1000,
1092
+ });
1093
+ const rows = (ret?.docs || []).map((d) => ({ id: d._id, value: d }));
1094
+ return { code: 1, data: { rows } };
1095
+ } catch (err) {
1096
+ return { code: -1, data: err.message };
1097
+ }
1098
+ };
1099
+
1100
+ // Static websites are apps (app_type:'static_website') created via create_app, but
1101
+ // they aren't emitted by any user_* CouchDB view, so they never surface in a sidenav
1102
+ // bucket. Same Mango-query approach as get_account_advertising → DATA.static_website.
1103
+ export const get_account_static_websites = async function (req) {
1104
+ try {
1105
+ const isSuper = _conf.superuser_account_ids?.includes(req.uid);
1106
+ const selector = isSuper ? { app_type: 'static_website', docType: 'app' } : { app_uId: req.uid, app_type: 'static_website', docType: 'app' };
1107
+ const ret = await db_module.find_couch_query('xuda_master', {
1108
+ selector,
1109
+ limit: 1000,
1110
+ });
1111
+ const rows = (ret?.docs || []).map((d) => ({ id: d._id, value: d }));
1112
+ return { code: 1, data: { rows } };
1113
+ } catch (err) {
1114
+ return { code: -1, data: err.message };
1115
+ }
1116
+ };
1096
1117
 
1097
1118
  export const get_account_info = async function (req) {
1098
1119
  return await get_account_data({
@@ -1393,6 +1414,11 @@ export const verify_account = async function (req) {
1393
1414
  export const validate_user_plan = async function (req) {
1394
1415
  const { account_id, app_obj } = req;
1395
1416
 
1417
+ // Advertising campaigns are charged per-campaign (Stripe), NOT plan-capped — they
1418
+ // (and their team sharing) are available on EVERY plan, free included. Exempt them
1419
+ // from the plan gate so free accounts can invite collaborators to a campaign.
1420
+ if (app_obj?.app_type === 'advertising') return { code: 1, data: { membership_plan: 'advertising_exempt' } };
1421
+
1396
1422
  const apps_ret = await db_module.get_couch_view('xuda_master', 'user_apps_count', {
1397
1423
  startkey: [account_id, ''],
1398
1424
  endkey: [account_id, 'ZZZZZ'],
@@ -1420,8 +1446,13 @@ export const validate_user_plan = async function (req) {
1420
1446
  return { code: -7, data: 'error - no plan defined' };
1421
1447
  }
1422
1448
 
1423
- // validate number of projects
1424
- if (app_obj.app_type === 'master' && ret_obj.master >= plan.features.projects) {
1449
+ // validate number of projects.
1450
+ // The system account-project (is_account_project) backs login + the user
1451
+ // drive and is created automatically by login_maintenance_fix — it must
1452
+ // NEVER be blocked by the plan's project cap, or an account at the limit can
1453
+ // never be provisioned and gets permanently locked out (get_gtp_token_info
1454
+ // throws "fix: account_project_id" -> 401). Exempt it from the count.
1455
+ if (app_obj.app_type === 'master' && !app_obj.is_account_project && ret_obj.master >= plan.features.projects) {
1425
1456
  return {
1426
1457
  code: -8,
1427
1458
  data: `Number of projects (${ret.data.membership_plan}) exceeds to the ${plan.features.projects} plan limits`,
@@ -2289,52 +2320,34 @@ export const validate_account_topup = async function (uid, items = []) {
2289
2320
  if (code < 0) {
2290
2321
  throw new Error(data);
2291
2322
  }
2292
- let topup = 0;
2293
- for (const item of items) {
2294
- topup += get_prices(uid, item);
2295
- }
2323
+ // POSTPAID billing: paid features (VPS, custom domain, xuda subdomain, add-ons)
2324
+ // now bill on the Stripe invoice at cycle close — we NO LONGER require a prepaid
2325
+ // credit balance up front, only a working payment method. `items` is kept for
2326
+ // call-site compatibility but is no longer used to gate on a prepaid amount.
2296
2327
 
2328
+ // Gate 1: there must be a card on file to bill against.
2297
2329
  if (!data?.customer_obj?.default_source) {
2298
2330
  let err = new Error('no card on file');
2299
2331
  err.code = -90;
2300
2332
  err.billing_problem = true;
2301
2333
  throw err;
2302
- // throw new Error('no card on file');
2303
2334
  }
2304
2335
 
2336
+ // Gate 2: a prior invoice that failed to charge is a real billing problem —
2337
+ // block new paid usage until the card is fixed (otherwise unpaid usage runs
2338
+ // unbounded). This is "pay your overdue invoice", not a prepaid top-up.
2305
2339
  if (data?.balance?.past_due) {
2306
2340
  let err = new Error(`past due ${data.balance.past_due}`);
2307
2341
  err.code = -91;
2308
2342
  err.billing_problem = true;
2309
- err.topup = topup + data.balance.past_due;
2343
+ err.topup = data.balance.past_due;
2310
2344
  err.open_invoices = data?.open_invoices;
2311
2345
  throw err;
2312
2346
  }
2313
- // if (data?.balance?.past_due) {
2314
- // let err = new Error(`past_due ${data?.balance?.past_due}`);
2315
- // err.code = -91;
2316
- // err.billing_problem = true;
2317
- // err.topup = topup;
2318
- // throw err;
2319
- // }
2320
-
2321
- const balance = data?.balance?.account || 0; // minus balance represents positive balance
2322
-
2323
- if (balance > 0) {
2324
- let err = new Error(`negative balance ${-balance}`);
2325
- err.code = -92;
2326
- err.billing_problem = true;
2327
- err.topup = topup - balance; // ask for whole balance
2328
- throw err;
2329
- }
2330
2347
 
2331
- if (topup + balance > 0) {
2332
- let err = new Error(`not enough balance ${topup + balance}`);
2333
- err.code = -93;
2334
- err.billing_problem = true;
2335
- err.topup = topup;
2336
- throw err;
2337
- }
2348
+ // No prepaid-credit / "not enough balance" gate anymore — usage accrues on the
2349
+ // app_cost ledger and is charged on the next invoice (Stripe applies any credit
2350
+ // balance first, then the card).
2338
2351
 
2339
2352
  await release_account_billing_hold_if_current(uid);
2340
2353
 
@@ -2519,19 +2532,11 @@ export const onboarding_completed = async function (req, job_id, headers) {
2519
2532
  account_doc.isBoarded = true;
2520
2533
  const save_ret = await db_module.save_couch_doc('xuda_accounts', account_doc);
2521
2534
 
2522
- if (account_doc.account_info?.is_xuda_network_ambassador === true) {
2523
- console.log('[onboarding_completed] suppressing welcome_aboard for xuda.network ambassador', uid, account_doc.account_info.email);
2524
- } else {
2525
- notification_msa.submit_notification({
2526
- type: 'account',
2527
- app_id: null,
2528
- uid_arr: [uid],
2529
- topic: 'welcome_aboard',
2530
- params: { name: account_doc.account_info.first_name, email: account_doc.account_info.email, dashboard_url: 'https://xuda.ai/dashboard' },
2531
- ref: null,
2532
- email: null,
2533
- });
2534
- }
2535
+ // Welcome email is sent once per account by maybe_send_welcome_email, which
2536
+ // is also triggered when avatar generation completes (so it reaches every
2537
+ // signup surface, not only the dashboard onboarding form). It is idempotent
2538
+ // (welcome_email_sent_ts) and suppresses mentors/ambassadors.
2539
+ maybe_send_welcome_email(uid);
2535
2540
 
2536
2541
  return save_ret;
2537
2542
  } catch (err) {
@@ -2539,6 +2544,55 @@ export const onboarding_completed = async function (req, job_id, headers) {
2539
2544
  }
2540
2545
  };
2541
2546
 
2547
+ // Sends the welcome_aboard email exactly once per account. Safe to call from
2548
+ // multiple triggers (avatar-completion, onboarding_completed) and across every
2549
+ // signup surface (xuda.ai, Google OAuth, xuda.fashion, xuda.network, chat
2550
+ // widget, public profile). Skips mentors/ambassadors (is_xuda_network_ambassador).
2551
+ export const maybe_send_welcome_email = async function (uid) {
2552
+ try {
2553
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
2554
+ if (!account_doc || !account_doc.account_info) return { code: -1, data: 'no account' };
2555
+ if (account_doc.account_info.is_xuda_network_ambassador === true) {
2556
+ console.log('[maybe_send_welcome_email] suppressing for xuda.network ambassador/mentor', uid);
2557
+ return { code: 0, data: 'ambassador' };
2558
+ }
2559
+ if (account_doc.welcome_email_sent_ts) return { code: 0, data: 'already sent' };
2560
+
2561
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2562
+ const username = account_doc.account_info.username || uid;
2563
+ const base = `https://${host}`;
2564
+ const public_url = `${base}/public_profiles/${encodeURIComponent(username)}`;
2565
+ const params = {
2566
+ name: account_doc.account_info.first_name || 'there',
2567
+ dashboard_url: `${base}/dashboard`,
2568
+ public_url,
2569
+ public_page_label: `${host}/public_profiles/${username}`,
2570
+ qr: `${public_url}/qr.png`,
2571
+ gif_base: `${base}/dist/images/email`,
2572
+ };
2573
+ await notification_msa.submit_notification({ type: 'account', app_id: null, uid_arr: [uid], topic: 'welcome_aboard', params, ref: null, email: null });
2574
+
2575
+ account_doc.welcome_email_sent_ts = Date.now();
2576
+ await db_module.save_couch_doc('xuda_accounts', account_doc);
2577
+ return { code: 1, data: 'sent' };
2578
+ } catch (err) {
2579
+ console.error('[maybe_send_welcome_email]', err.message);
2580
+ return { code: -1, data: err.message };
2581
+ }
2582
+ };
2583
+
2584
+ // Resolve an account uid from a public username (account_info.username).
2585
+ // Used by the public-profile route so /public_profiles/<username> resolves.
2586
+ export const get_uid_by_username = async function (username) {
2587
+ if (!username) return null;
2588
+ const ret = await db_module.find_couch_query('xuda_accounts', {
2589
+ selector: { 'account_info.username': username },
2590
+ fields: ['_id'],
2591
+ limit: 1,
2592
+ });
2593
+ return ret?.docs?.[0]?._id || null;
2594
+ };
2595
+
2542
2596
  export const ts_contact = async function (uid, contact_id) {
2543
2597
  try {
2544
2598
  const contact_doc = await get_contact(uid, contact_id);
@@ -2632,6 +2686,9 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
2632
2686
 
2633
2687
  const account_save_ret = await db_module.save_couch_doc('xuda_accounts', account_obj);
2634
2688
  await update_account_profile_picture_status(account_uid, 3);
2689
+ // Avatar finished generating -> the account is fully set up; send the
2690
+ // one-time welcome email (idempotent, skips mentors/ambassadors).
2691
+ maybe_send_welcome_email(account_uid);
2635
2692
  }
2636
2693
  } catch (err) {
2637
2694
  await update_account_profile_picture_status(account_uid, 1, err.message);
@@ -4350,6 +4407,34 @@ export const get_account_ai_usage_old = async function (req, job_id, headers) {
4350
4407
  }
4351
4408
  };
4352
4409
 
4410
+ // Push the account's live AI-credit balance to its dashboard WS room, so every
4411
+ // meter (the AiPrompt meter, the sidenav ring, the usage popup) updates the
4412
+ // instant credits are spent or topped up — no refresh. Debounced per-uid so a
4413
+ // burst of usage records (a streaming chat writing many chunks) collapses into a
4414
+ // single balance recompute + emit. Reuses get_account_ai_usage (the ledger read)
4415
+ // and emit_message_to_dashboard (the same uid-room push used for account updates).
4416
+ const _credits_broadcast_timers = {};
4417
+ export const broadcast_credits = function (uid) {
4418
+ if (!uid || uid === 'system' || uid === 'webhook') return;
4419
+ clearTimeout(_credits_broadcast_timers[uid]);
4420
+ _credits_broadcast_timers[uid] = setTimeout(async () => {
4421
+ delete _credits_broadcast_timers[uid];
4422
+ try {
4423
+ const ret = await get_account_ai_usage({ uid });
4424
+ if (!ret || ret.code !== 55) return;
4425
+ const max = Math.round((ret.data?.credits?.total || 0) * 100) / 100;
4426
+ const used = Math.round((ret.data?.usage?.total || 0) * 100) / 100;
4427
+ ws_dashboard_msa.emit_message_to_dashboard({
4428
+ service: 'credits_update',
4429
+ to: [uid],
4430
+ data: { used, max, remaining: Math.round((max - used) * 100) / 100, by_source: ret.data?.credits || {} },
4431
+ });
4432
+ } catch (e) {
4433
+ console.error('[broadcast_credits]', e?.message || e);
4434
+ }
4435
+ }, 800);
4436
+ };
4437
+
4353
4438
  export const record_ai_usage = async function (uid, input_tokens, output_tokens, source, prompt, model, metadata = {}, account_profile_info, tools) {
4354
4439
  try {
4355
4440
  if (!account_profile_info) throw new Error('missing account_profile_info');
@@ -4378,6 +4463,7 @@ export const record_ai_usage = async function (uid, input_tokens, output_tokens,
4378
4463
  };
4379
4464
  const save_ret = await db_module.save_couch_doc('xuda_usage', usage_doc);
4380
4465
  // console.log(save_ret);
4466
+ broadcast_credits(uid); // live meter: this account just spent credits
4381
4467
  } catch (err) {
4382
4468
  console.error(err);
4383
4469
  }
@@ -4413,6 +4499,7 @@ export const record_ai_credit = async function (uid, credits = 0, source, detail
4413
4499
  };
4414
4500
  const save_ret = await db_module.save_couch_doc('xuda_billing', credit_doc);
4415
4501
  // console.log(save_ret);
4502
+ broadcast_credits(credited_uid); // live meter: this account just gained credits
4416
4503
  return save_ret;
4417
4504
  } catch (err) {
4418
4505
  if (!err?.message?.includes('already credited!')) {
package/index_ms.mjs CHANGED
@@ -33,9 +33,8 @@ export const increment_account_usage = async function (...args) {
33
33
  return await broker.send_to_queue("increment_account_usage", ...args);
34
34
  };
35
35
 
36
- export const mark_app_terminated = async function (...args) {
37
- return await broker.send_to_queue("mark_app_terminated", ...args);
38
- };
36
+ // mark_app_terminated removed replaced by app_module.pre_destroy_app
37
+ // invoked via http_module's pre_dispatch hook on destroy_app.
39
38
 
40
39
  export const recompute_abuse_signals = async function (...args) {
41
40
  return await broker.send_to_queue("recompute_abuse_signals", ...args);
@@ -97,6 +96,10 @@ export const get_account_name = async function (...args) {
97
96
  return await broker.send_to_queue("get_account_name", ...args);
98
97
  };
99
98
 
99
+ export const get_uid_by_username = async function (...args) {
100
+ return await broker.send_to_queue("get_uid_by_username", ...args);
101
+ };
102
+
100
103
  export const account_validate_username = async function (...args) {
101
104
  return await broker.send_to_queue("account_validate_username", ...args);
102
105
  };
@@ -352,3 +355,15 @@ export const read_accounts_emails = async function (...args) {
352
355
  export const process_accounts_emails = async function (...args) {
353
356
  return await broker.send_to_queue("process_accounts_emails", ...args);
354
357
  };
358
+
359
+ export const get_account_vps = async function (...args) {
360
+ return await broker.send_to_queue("get_account_vps", ...args);
361
+ };
362
+
363
+ export const get_account_advertising = async function (...args) {
364
+ return await broker.send_to_queue("get_account_advertising", ...args);
365
+ };
366
+
367
+ export const get_account_static_websites = async function (...args) {
368
+ return await broker.send_to_queue("get_account_static_websites", ...args);
369
+ };
package/index_msa.mjs CHANGED
@@ -33,9 +33,8 @@ export const increment_account_usage = function (...args) {
33
33
  broker.send_to_queue_async("increment_account_usage", ...args);
34
34
  };
35
35
 
36
- export const mark_app_terminated = function (...args) {
37
- broker.send_to_queue_async("mark_app_terminated", ...args);
38
- };
36
+ // mark_app_terminated removed replaced by app_module.pre_destroy_app
37
+ // invoked via http_module's pre_dispatch hook on destroy_app.
39
38
 
40
39
  export const recompute_abuse_signals = function (...args) {
41
40
  broker.send_to_queue_async("recompute_abuse_signals", ...args);
@@ -73,6 +72,14 @@ export const get_account_deployments = function (...args) {
73
72
  broker.send_to_queue_async("get_account_deployments", ...args);
74
73
  };
75
74
 
75
+ export const get_account_advertising = function (...args) {
76
+ broker.send_to_queue_async("get_account_advertising", ...args);
77
+ };
78
+
79
+ export const get_account_static_websites = function (...args) {
80
+ broker.send_to_queue_async("get_account_static_websites", ...args);
81
+ };
82
+
76
83
  export const get_account_instances = function (...args) {
77
84
  broker.send_to_queue_async("get_account_instances", ...args);
78
85
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2258",
3
+ "version": "1.2.2259",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {