@xuda.io/account_module 1.2.2256 → 1.2.2258

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
@@ -34,6 +34,7 @@ const account_info_properties = [
34
34
  'network_lang',
35
35
  'network_country_code',
36
36
  'network_city_slug',
37
+ 'public_profile_disabled',
37
38
  ];
38
39
 
39
40
  global._conf = (
@@ -197,6 +198,9 @@ export const update_account_info = async function (req, job_id, headers) {
197
198
  return { code: -1310, data: error };
198
199
  }
199
200
  if (!change) {
201
+ if (account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
202
+ set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
203
+ }
200
204
  return { code: 1300, data: 'no change' };
201
205
  }
202
206
 
@@ -219,6 +223,59 @@ export const update_account_info = async function (req, job_id, headers) {
219
223
  }
220
224
  }
221
225
 
226
+ // Opportunistic Stripe consolidation. Any time the user updates
227
+ // their account info, also check if they're still on the legacy
228
+ // 3-subscription model and migrate them to the consolidated
229
+ // 1-subscription-with-3-items model in the background. The
230
+ // migration helper is idempotent — already-consolidated accounts
231
+ // and accounts without a stripe_customer_id return immediately
232
+ // with no side effects — so this is cheap to run on every save.
233
+ //
234
+ // Fire-and-forget: we don't await it so the user's update_account_info
235
+ // response isn't blocked on Stripe API calls (creating a new sub +
236
+ // scheduling 3 cancels can take several seconds). The migration
237
+ // logs its own errors; if it fails for a particular account the
238
+ // user can still operate normally on the legacy model and we'll
239
+ // retry on the next update_account_info call.
240
+ if (account_obj.stripe_customer_id && !account_obj.stripe_subscription_id) {
241
+ stripe_ms
242
+ .migrate_account_to_consolidated({ uid })
243
+ .then((r) => {
244
+ if (r?.code === 1) {
245
+ console.log(`[update_account_info] migration result for ${uid}:`, typeof r.data === 'string' ? r.data : `migrated → ${r.data?.new_sub_id}`);
246
+ } else if (r?.code < 0) {
247
+ console.warn(`[update_account_info] migration failed for ${uid}:`, r?.data);
248
+ }
249
+ })
250
+ .catch((err) => {
251
+ console.warn(`[update_account_info] migration errored for ${uid}:`, err?.message || err);
252
+ });
253
+ } else if (account_obj.stripe_subscription_id) {
254
+ // Already on the consolidated model — but if the original
255
+ // migration used `trial_end` (older code path) the Stripe
256
+ // dashboard still shows the sub as "trialing". Idempotently
257
+ // swap it for a no-trial equivalent (cancel + recreate with
258
+ // `billing_cycle_anchor`). First invoice date stays exactly
259
+ // the same so the customer sees no billing change.
260
+ stripe_ms
261
+ .end_consolidated_trial({ uid })
262
+ .then((r) => {
263
+ if (r?.code === 1) {
264
+ const data = r.data;
265
+ if (typeof data === 'object' && data?.new_sub_id) {
266
+ console.log(`[update_account_info] trial removed for ${uid}: new sub ${data.new_sub_id}`);
267
+ }
268
+ // Quiet otherwise — "no consolidated sub" / "no active
269
+ // trial" are the common no-op cases and would spam logs.
270
+ } else if (r?.code < 0) {
271
+ console.warn(`[update_account_info] end_consolidated_trial failed for ${uid}:`, r?.data);
272
+ }
273
+ })
274
+ .catch((err) => {
275
+ console.warn(`[update_account_info] end_consolidated_trial errored for ${uid}:`, err?.message || err);
276
+ });
277
+ }
278
+
222
279
  if (save_ret.code > 0) {
223
280
  return { code: 1300, data: 'ok' };
224
281
  }
@@ -494,6 +551,455 @@ export const increment_account_usage = async function (req) {
494
551
  }
495
552
  };
496
553
 
554
+ // ──────────────────────────────────────────────────────────────────
555
+ // Per-deployment billing helpers.
556
+ //
557
+ // HISTORY: an earlier iteration of unified billing had an hourly
558
+ // cron (`accrue_deployment_costs`) that walked every active
559
+ // deployment and pushed accrual into a `deployment_usage` doc per
560
+ // VPS, then flushed those docs to Stripe at end-of-cycle. That
561
+ // approach had cron-coupling, off-by-one bugs at hour boundaries,
562
+ // drift, and a parallel state machine to keep in sync.
563
+ //
564
+ // CURRENT: on-demand. Both the dashboard's `accrued_so_far` /
565
+ // `projected_total` numbers AND the Stripe invoice items at cycle
566
+ // close are computed from `app_cost.created_ts` /
567
+ // `app_cost.terminated_ts` directly. No cron, no accumulator
568
+ // docs. See compute_cycle_billable_amount in deploy_module/cost.mjs
569
+ // for the math. Result: per-second accuracy automatically, idempotent
570
+ // flush, zero between-event state.
571
+ //
572
+ // Only the `backfill_app_costs` one-shot remains here — it stamps
573
+ // `app_cost.*` onto legacy deployments that pre-date the per-VPS
574
+ // ledger, anchoring `created_ts = now` so past hours stay unbilled.
575
+ // ──────────────────────────────────────────────────────────────────
576
+
577
+ // Doc types that own a billable droplet / add-on bundle. Used by the
578
+ // backfill query to scope its selector — and reusable as a constant
579
+ // elsewhere should anything need to filter by "is this a billable
580
+ // app type?".
581
+ const BILLABLE_APP_TYPES = ['vps', 'datacenter', 'instance', 'balancer'];
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
+ };
619
+
620
+ // -------------------------------------------------------------------
621
+ // Abuse detection — see the "How can we detect abuse?" plan.
622
+ // -------------------------------------------------------------------
623
+
624
+ // Admin emails get access to the abuse triage endpoints. Keep this in
625
+ // sync with the dashboard's admin-route gate. Lives here (not in
626
+ // config) so a compromised config_dev.json can't grant admin access.
627
+ const ADMIN_EMAILS = new Set(['info@ioshka.com']);
628
+
629
+ const is_admin_email = (email) => {
630
+ if (!email) return false;
631
+ return ADMIN_EMAILS.has(String(email).toLowerCase());
632
+ };
633
+
634
+ // Get the active billing cycle bounds for an account, on demand from
635
+ // Stripe. Used by recompute_abuse_signals to scope its "this cycle"
636
+ // counters correctly. Returns { cycle_start_ts, cycle_end_ts } in ms
637
+ // or null if Stripe isn't reachable / customer not found.
638
+ const get_account_cycle_bounds = async (account) => {
639
+ if (!account?.stripe_customer_id) return null;
640
+ try {
641
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
642
+ // get_upcoming_invoice returns the upcoming invoice for the customer.
643
+ // Its period_start/period_end define the active cycle window. If
644
+ // that endpoint isn't available, we approximate from docDate.
645
+ const upcoming = await stripe_ms
646
+ .get_upcoming_invoice({
647
+ customer_id: account.stripe_customer_id,
648
+ })
649
+ .catch(() => null);
650
+ if (upcoming?.data?.period_start && upcoming?.data?.period_end) {
651
+ return {
652
+ cycle_start_ts: upcoming.data.period_start * 1000,
653
+ cycle_end_ts: upcoming.data.period_end * 1000,
654
+ };
655
+ }
656
+ } catch (_) {}
657
+ // Fallback: rolling 30-day window from now. Approximation good enough
658
+ // for abuse signals — exact cycle bounds only matter for invoicing.
659
+ const now = Date.now();
660
+ return { cycle_start_ts: now - 30 * 24 * 60 * 60 * 1000, cycle_end_ts: now };
661
+ };
662
+
663
+ // Walk an account's deployments and update its abuse_signals object.
664
+ // Idempotent — safe to call on every cycle close, on resize, or on
665
+ // admin demand. Returns the computed signals shape + any flags raised.
666
+ //
667
+ // Rules implemented (from the abuse plan):
668
+ // Rule 1 (negative_margin) — margin_ratio < 0 AND provider > $5
669
+ // Rule 2 (resize_churn) — >3 short segments OR >10 resizes this cycle
670
+ // Rule 3 (new_account_high_spec) — account age < 7d AND any deploy > $150/mo
671
+ //
672
+ // Rules 4/5/6 need additional data sources (project history view, Stripe
673
+ // payment_failed webhook, fingerprint capture) and ship in later phases.
674
+ export const recompute_abuse_signals = async function (req) {
675
+ const { uid } = req || {};
676
+ if (!uid) return { code: -1, data: 'uid required' };
677
+
678
+ try {
679
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
680
+ if (acct_ret.code < 0) return { code: -1, data: 'account not found' };
681
+ const account = acct_ret.data;
682
+
683
+ const cost_mod = await import(`${module_path}/deploy_module/cost.mjs`);
684
+ const { compute_cycle_billable_amount, is_billable } = cost_mod;
685
+
686
+ const bounds = await get_account_cycle_bounds(account);
687
+ const cycle_start_ts = bounds?.cycle_start_ts || Date.now() - 30 * 24 * 60 * 60 * 1000;
688
+ const cycle_end_ts = bounds?.cycle_end_ts || Date.now();
689
+
690
+ // Walk apps via the user_apps view.
691
+ const apps_ret = await db_module.get_couch_view('xuda_master', 'user_apps', {
692
+ startkey: [uid, ''],
693
+ endkey: [uid, 'ZZZZZ'],
694
+ include_docs: true,
695
+ });
696
+
697
+ let resizes_this_cycle = 0;
698
+ let short_segments_this_cycle = 0;
699
+ let total_billed = 0;
700
+ let total_provider = 0;
701
+ let max_monthly_spec = 0;
702
+
703
+ for (const row of apps_ret?.data?.rows || []) {
704
+ const doc = row.doc;
705
+ if (!is_billable(doc)) continue;
706
+
707
+ // Count cycle-relevant resizes by examining history[] entries
708
+ // whose closing timestamp falls inside the cycle window.
709
+ for (const seg of doc.app_cost.history || []) {
710
+ if (seg.to_ts > cycle_start_ts && seg.to_ts <= cycle_end_ts) {
711
+ resizes_this_cycle++;
712
+ if (seg.to_ts - seg.from_ts < 60 * 60 * 1000) short_segments_this_cycle++;
713
+ }
714
+ }
715
+
716
+ const billed = compute_cycle_billable_amount(doc, cycle_start_ts, cycle_end_ts);
717
+ total_billed += billed;
718
+
719
+ // Provider cost: use provider_cost_monthly if populated (Phase B
720
+ // will fill this from DO billing API). Until then approximate as
721
+ // monthly_hosting (assume 0% markup baseline — conservative for
722
+ // detecting negative margin).
723
+ const provider_monthly = doc.app_cost.provider_cost_monthly ?? doc.app_cost.monthly_hosting ?? 0;
724
+ const cycle_ms = cycle_end_ts - cycle_start_ts;
725
+ const window_start = Math.max(doc.app_cost.created_ts || cycle_start_ts, cycle_start_ts);
726
+ const window_end = Math.min(doc.app_cost.terminated_ts || cycle_end_ts, cycle_end_ts);
727
+ if (window_end > window_start && cycle_ms > 0) {
728
+ total_provider += provider_monthly * ((window_end - window_start) / cycle_ms);
729
+ }
730
+
731
+ if (doc.app_cost.monthly_total > max_monthly_spec) {
732
+ max_monthly_spec = doc.app_cost.monthly_total;
733
+ }
734
+ }
735
+
736
+ const account_age_ms = Date.now() - (account.docDate || account.created_ts || Date.now());
737
+ const account_age_days = account_age_ms / (24 * 60 * 60 * 1000);
738
+ const margin_ratio = total_provider > 0 ? (total_billed - total_provider) / total_provider : 0;
739
+
740
+ // Apply rules — first flag wins.
741
+ let flag = null;
742
+ let flag_detail = null;
743
+ if (total_provider > 5 && margin_ratio < 0) {
744
+ flag = 'negative_margin';
745
+ flag_detail = `provider $${total_provider.toFixed(2)} > billed $${total_billed.toFixed(2)} (margin ${(margin_ratio * 100).toFixed(0)}%)`;
746
+ } else if (short_segments_this_cycle > 3 || resizes_this_cycle > 10) {
747
+ flag = 'resize_churn';
748
+ flag_detail = `${resizes_this_cycle} resizes, ${short_segments_this_cycle} sub-1h segments`;
749
+ } else if (account_age_days < 7 && max_monthly_spec > 150) {
750
+ flag = 'new_account_high_spec';
751
+ flag_detail = `${account_age_days.toFixed(1)}d old, biggest VPS $${max_monthly_spec}/mo`;
752
+ }
753
+
754
+ const prev = account.abuse_signals || {};
755
+ const signals = {
756
+ resizes_this_cycle,
757
+ short_segments_this_cycle,
758
+ destroy_recreate_pairs: prev.destroy_recreate_pairs || 0, // populated by Rule 4 (later)
759
+ account_age_days,
760
+ total_provider_cost: Number(total_provider.toFixed(2)),
761
+ total_billed_amount: Number(total_billed.toFixed(2)),
762
+ margin_ratio: Number(margin_ratio.toFixed(4)),
763
+ max_monthly_spec,
764
+ cycle_start_ts,
765
+ cycle_end_ts,
766
+ computed_at: Date.now(),
767
+ flagged: !!flag,
768
+ flag_reason: flag,
769
+ flag_detail,
770
+ flag_ts: flag ? prev.flag_ts || Date.now() : null, // sticky until reviewed
771
+ reviewed_by: flag ? prev.reviewed_by || null : null,
772
+ reviewed_at: flag ? prev.reviewed_at || null : null,
773
+ };
774
+
775
+ account.abuse_signals = signals;
776
+ await db_module.save_couch_doc('xuda_accounts', account);
777
+
778
+ return { code: 1, data: signals };
779
+ } catch (err) {
780
+ console.error('[recompute_abuse_signals]', err.message);
781
+ return { code: -1, data: err.message };
782
+ }
783
+ };
784
+
785
+ // Admin-only: list every account currently flagged. The dashboard's
786
+ // /admin/abuse page reads from here.
787
+ export const get_flagged_accounts = async function (req) {
788
+ const { uid: caller_uid } = req || {};
789
+ if (!caller_uid) return { code: -1, data: 'unauthorized' };
790
+ try {
791
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
792
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
793
+ return { code: -1, data: 'unauthorized' };
794
+ }
795
+
796
+ // No view yet — scan via find_couch_query on abuse_signals.flagged.
797
+ // Acceptable at small scale; index this if the flagged list grows.
798
+ const find_ret = await db_module.find_couch_query('xuda_accounts', {
799
+ selector: { 'abuse_signals.flagged': true },
800
+ limit: 500,
801
+ });
802
+
803
+ const rows = (find_ret?.docs || []).map((a) => ({
804
+ uid: a._id,
805
+ email: a.email,
806
+ first_name: a.first_name,
807
+ last_name: a.last_name,
808
+ username: a.username,
809
+ stripe_customer_id: a.stripe_customer_id,
810
+ abuse_signals: a.abuse_signals,
811
+ stat: a.stat,
812
+ docDate: a.docDate,
813
+ }));
814
+
815
+ // Sort: unreviewed first, then most recent flag.
816
+ rows.sort((a, b) => {
817
+ const ar = a.abuse_signals?.reviewed_by ? 1 : 0;
818
+ const br = b.abuse_signals?.reviewed_by ? 1 : 0;
819
+ if (ar !== br) return ar - br;
820
+ return (b.abuse_signals?.flag_ts || 0) - (a.abuse_signals?.flag_ts || 0);
821
+ });
822
+
823
+ return { code: 1, data: rows };
824
+ } catch (err) {
825
+ return { code: -1, data: err.message };
826
+ }
827
+ };
828
+
829
+ // Admin-only: sweep every account that has a stripe_customer_id and
830
+ // recompute its abuse_signals. Used by the admin page's "Run sweep"
831
+ // button so signals get refreshed without waiting for the next
832
+ // invoice.upcoming webhook. Returns counts (flagged / clean / errors).
833
+ export const sweep_abuse_signals = async function (req) {
834
+ const { uid: caller_uid } = req || {};
835
+ if (!caller_uid) return { code: -1, data: 'unauthorized' };
836
+ try {
837
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
838
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
839
+ return { code: -1, data: 'unauthorized' };
840
+ }
841
+
842
+ const accounts_ret = await db_module.find_couch_query('xuda_accounts', {
843
+ selector: { stripe_customer_id: { $exists: true } },
844
+ limit: 1000,
845
+ });
846
+ const accounts = accounts_ret?.docs || [];
847
+
848
+ const summary = { scanned: 0, flagged: 0, clean: 0, errors: [] };
849
+ for (const acct of accounts) {
850
+ summary.scanned++;
851
+ try {
852
+ const r = await recompute_abuse_signals({ uid: acct._id });
853
+ if (r?.code < 0) {
854
+ summary.errors.push({ uid: acct._id, message: r.data });
855
+ } else if (r?.data?.flagged) {
856
+ summary.flagged++;
857
+ } else {
858
+ summary.clean++;
859
+ }
860
+ } catch (err) {
861
+ summary.errors.push({ uid: acct._id, message: err.message });
862
+ }
863
+ }
864
+ return { code: 1, data: summary };
865
+ } catch (err) {
866
+ return { code: -1, data: err.message };
867
+ }
868
+ };
869
+
870
+ // Admin-only: mark an account's current flag as reviewed (cleared until
871
+ // next recompute raises it again). Records who reviewed + when.
872
+ export const mark_account_reviewed = async function (req) {
873
+ const { uid: caller_uid, target_uid, note } = req || {};
874
+ if (!caller_uid || !target_uid) return { code: -1, data: 'uid + target_uid required' };
875
+ try {
876
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
877
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
878
+ return { code: -1, data: 'unauthorized' };
879
+ }
880
+
881
+ const target_ret = await db_module.get_couch_doc('xuda_accounts', target_uid);
882
+ if (target_ret.code < 0) return { code: -1, data: 'target account not found' };
883
+ const target = target_ret.data;
884
+
885
+ if (!target.abuse_signals) target.abuse_signals = {};
886
+ target.abuse_signals.reviewed_by = caller_ret.data.email;
887
+ target.abuse_signals.reviewed_at = Date.now();
888
+ target.abuse_signals.review_note = note || null;
889
+ target.abuse_signals.flagged = false; // cleared
890
+
891
+ await db_module.save_couch_doc('xuda_accounts', target);
892
+ return { code: 1, data: target.abuse_signals };
893
+ } catch (err) {
894
+ return { code: -1, data: err.message };
895
+ }
896
+ };
897
+
898
+ // One-shot backfill: stamp `app_cost.*` onto legacy deployments,
899
+ // anchored at `last_billed_ts = now` so the cron starts forward-only.
900
+ // Idempotent — re-running on a doc that already has app_cost.last_billed_ts
901
+ // is a no-op.
902
+ //
903
+ // opts:
904
+ // dry_run: boolean — compute, don't save.
905
+ // limit: number — cap on docs processed (canary backfill).
906
+ // app_id: string — backfill only this specific app.
907
+ export const backfill_app_costs = async function (opts = {}) {
908
+ const { dry_run = false, limit = null, app_id = null } = opts || {};
909
+
910
+ // compute_app_cost lives in deploy_module/cost.mjs. Imported via
911
+ // the same cross-module path convention used elsewhere in this
912
+ // file (see fs_module / db_module imports at the top). Falls back
913
+ // to inline noop if the import fails so the backfill can at least
914
+ // log what it WOULD have done.
915
+ let compute_app_cost;
916
+ try {
917
+ const cost_mod = await import(`${module_path}/deploy_module/cost.mjs`);
918
+ compute_app_cost = cost_mod.compute_app_cost;
919
+ } catch (err) {
920
+ return { code: -1, data: `cost.mjs not importable from account_module: ${err.message}` };
921
+ }
922
+
923
+ const start = Date.now();
924
+ const summary = {
925
+ started_at: new Date().toISOString(),
926
+ dry_run,
927
+ scanned: 0,
928
+ stamped: 0,
929
+ terminated: 0,
930
+ skipped: 0,
931
+ nothing_to_bill: 0,
932
+ errors: [],
933
+ };
934
+
935
+ const APP_STATUS_DELETED = 4;
936
+
937
+ let selector;
938
+ if (app_id) {
939
+ selector = { _id: app_id };
940
+ } else {
941
+ selector = {
942
+ docType: 'app',
943
+ $or: [{ app_type: { $in: BILLABLE_APP_TYPES } }, { is_deployment: true }],
944
+ };
945
+ }
946
+
947
+ const find_ret = await db_module.find_couch_query('xuda_master', { selector, limit: limit || 99999 });
948
+ const docs = find_ret?.docs || [];
949
+
950
+ for (const doc of docs) {
951
+ summary.scanned++;
952
+ try {
953
+ let action = 'skipped';
954
+ let reason = '';
955
+
956
+ if (doc.app_cost?.last_billed_ts) {
957
+ reason = 'already has app_cost.last_billed_ts';
958
+ } else if (!doc.deploy_data) {
959
+ reason = 'no deploy_data';
960
+ } else if (!BILLABLE_APP_TYPES.includes(doc.app_type) && !doc.is_deployment) {
961
+ reason = `app_type ${doc.app_type} not billable`;
962
+ } else {
963
+ 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;
964
+ if (doc.is_deployment && !has_addons && !doc.deploy_data?.app_server_type) {
965
+ reason = 'deployment without addons → datacenter bears cost';
966
+ } else {
967
+ const cost = compute_app_cost(doc.deploy_data);
968
+ if (!cost) {
969
+ action = 'nothing-to-bill';
970
+ reason = 'monthly_total = 0';
971
+ } else {
972
+ if (doc.app_status_code === APP_STATUS_DELETED) {
973
+ cost.terminated_ts = doc.app_status_data ? new Date(doc.app_status_data).getTime() : Date.now();
974
+ action = 'terminated';
975
+ } else {
976
+ action = 'stamped';
977
+ }
978
+ reason = `monthly=$${cost.monthly_total}`;
979
+ doc.app_cost = cost;
980
+ if (!dry_run) {
981
+ await db_module.save_couch_doc('xuda_master', doc);
982
+ }
983
+ }
984
+ }
985
+ }
986
+
987
+ if (action === 'stamped') summary.stamped++;
988
+ else if (action === 'terminated') summary.terminated++;
989
+ else if (action === 'nothing-to-bill') summary.nothing_to_bill++;
990
+ else summary.skipped++;
991
+
992
+ if (!summary.per_doc) summary.per_doc = [];
993
+ summary.per_doc.push({ _id: doc._id, app_type: doc.app_type, app_name: doc.app_name, action, reason });
994
+ } catch (err) {
995
+ summary.errors.push({ _id: doc._id, message: err.message });
996
+ }
997
+ }
998
+
999
+ summary.duration_ms = Date.now() - start;
1000
+ return summary;
1001
+ };
1002
+
497
1003
  export const get_account_data = async function (req) {
498
1004
  var { uid, enforce_usage } = req;
499
1005
 
@@ -705,15 +1211,31 @@ export const did_you_know_tips = async function (req, job_id, headers) {
705
1211
  }
706
1212
  };
707
1213
 
1214
+ const _warned_no_account_project_id = new Set();
1215
+
708
1216
  export const get_active_account_profile_info = async function (uid, profile_id) {
709
1217
  try {
710
1218
  const acc_obj = await db_module.get_couch_doc_native('xuda_accounts', uid);
711
1219
 
712
- if (!acc_obj.account_project_id) throw new Error(`acc ${acc_obj._id} has no account_project_id`);
1220
+ if (!acc_obj.account_project_id) {
1221
+ if (!_warned_no_account_project_id.has(acc_obj._id)) {
1222
+ _warned_no_account_project_id.add(acc_obj._id);
1223
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id} has no account_project_id; returning null`);
1224
+ }
1225
+ return { uid, account_profile_id: null, app_id: null, is_main: false, account_profile_obj: null };
1226
+ }
713
1227
 
714
- let active_account_profile_id = profile_id || acc_obj.account_info.active_account_profile_id;
1228
+ // Fallback chain: explicit profile_id > active_account_profile_id > top-level account_profile_id.
1229
+ // Recovers gracefully from accounts where active_account_profile_id was never set during boarding
1230
+ // (e.g. interrupted Google signup flow), instead of throwing on every chat-widget poll.
1231
+ let active_account_profile_id = profile_id || acc_obj.account_info?.active_account_profile_id || acc_obj.account_profile_id;
715
1232
 
716
- if (!active_account_profile_id) throw new Error(`acc ${acc_obj._id} has no active_account_profile_id`);
1233
+ if (!active_account_profile_id) {
1234
+ // Soft-fail: log once and return a degraded response. Callers that need a real profile
1235
+ // should null-check `account_profile_obj`. The chat widget treats this as anonymous visitor.
1236
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id} has neither active_account_profile_id nor account_profile_id; returning null`);
1237
+ return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
1238
+ }
717
1239
 
718
1240
  const account_profile_obj = await db_module.get_app_couch_doc_native(acc_obj.account_project_id, active_account_profile_id);
719
1241
  if (account_profile_obj.share_item_id) {
@@ -742,7 +1264,7 @@ export const get_account_name = async function (req) {
742
1264
  if (data.code < 0) {
743
1265
  return data;
744
1266
  }
745
- const { membership_plan = '', support_plan = '', ai_workspace_plan = '', date_created_ts = '' } = data.data;
1267
+ const { membership_plan = '', support_plan = '', ai_workspace_plan = '', date_created_ts = '', stat = '' } = data.data;
746
1268
  var obj = {
747
1269
  first_name: '',
748
1270
  last_name: '',
@@ -754,13 +1276,41 @@ export const get_account_name = async function (req) {
754
1276
  support_plan,
755
1277
  ai_workspace_plan,
756
1278
  date_created_ts,
1279
+ stat,
757
1280
  };
758
1281
  if (data.data.account_info) {
759
- const { first_name, last_name, email, phone_number, profile_picture, profile_avatar, username, website, country, bio, industry, account_type, address, city, state, zip, business_name, auto_respond, auto_respond_mode, auto_respond_agents, avatar_source, active_account_profile_id } =
760
- data.data.account_info;
1282
+ const {
1283
+ first_name,
1284
+ last_name,
1285
+ email,
1286
+ phone_number,
1287
+ profile_picture,
1288
+ profile_avatar,
1289
+ username,
1290
+ website,
1291
+ country,
1292
+ bio,
1293
+ industry,
1294
+ account_type,
1295
+ address,
1296
+ city,
1297
+ state,
1298
+ zip,
1299
+ business_name,
1300
+ auto_respond,
1301
+ auto_respond_mode,
1302
+ auto_respond_agents,
1303
+ avatar_source,
1304
+ active_account_profile_id,
1305
+ network_country_code,
1306
+ public_profile_disabled,
1307
+ } = data.data.account_info;
761
1308
 
762
1309
  obj = {
763
1310
  _id: data.data._id,
1311
+ stat,
1312
+ network_country_code,
1313
+ public_profile_disabled,
764
1314
  first_name,
765
1315
  last_name,
766
1316
  email,
@@ -1955,13 +2505,13 @@ export const onboarding_completed = async function (req, job_id, headers) {
1955
2505
 
1956
2506
  let account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
1957
2507
 
1958
- if (!account_doc.account_info?.profile_picture) {
1959
- throw new Error(`missing profile_picture`);
1960
- }
1961
-
1962
- if (!account_doc.account_info?.profile_avatar) {
1963
- throw new Error(`missing profile_avatar`);
1964
- }
2508
+ // NOTE: neither profile_picture nor profile_avatar are required
2509
+ // to complete onboarding. The client UI labels step 4 "(optional)"
2510
+ // and lets the user skip — matching that here means a successful
2511
+ // client flow always succeeds server-side too. Anything they skip
2512
+ // can be added later from Settings → Profile. The avatar service
2513
+ // can also time out or fail transiently, and we don't want a
2514
+ // generation hiccup to block someone from finishing signup.
1965
2515
 
1966
2516
  account_doc.account_info.full_name = `${account_doc.account_info.first_name} ${account_doc.account_info.last_name}`;
1967
2517
 
@@ -2088,6 +2638,35 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
2088
2638
  }
2089
2639
  };
2090
2640
 
2641
+ // Internal trigger used by the widget Google-signup flow: when a freshly-created
2642
+ // visitor account already has a profile_picture (their Google photo) but no
2643
+ // generated avatar yet, kick off avatar generation. set_account_profile_picture
2644
+ // maintains profile_avatar_stat (1 → 2 → 3); the caller polls that. Mirrors the
2645
+ // opportunistic trigger in update_account_info (profile_avatar_stat !== 2 guards
2646
+ // against re-triggering while a generation is mid-flight).
2647
+ export const ensure_profile_avatar = async function (req, job_id, headers) {
2648
+ try {
2649
+ const { uid } = req || {};
2650
+ if (!uid) return { code: -1, data: 'missing uid' };
2651
+ const { code, data: account_obj } = await db_module.get_couch_doc('xuda_accounts', uid);
2652
+ if (code < 0 || !account_obj) return { code: -1, data: 'account not found' };
2653
+ const info = account_obj.account_info || {};
2654
+ if (info.profile_picture && !info.profile_avatar && info.profile_avatar_stat !== 2) {
2655
+ // Best-effort profile context for AI-usage attribution. A freshly-created
2656
+ // widget visitor may have no profile/project yet — tolerate that.
2657
+ let account_profile_info;
2658
+ try {
2659
+ account_profile_info = await get_active_account_profile_info(uid);
2660
+ } catch (e) {}
2661
+ // fire-and-forget inside this worker; generation is heavy (vision + image ops)
2662
+ set_account_profile_picture(uid, uid, info, job_id, headers, account_profile_info);
2663
+ }
2664
+ return { code: 1, data: { profile_avatar_stat: info.profile_avatar_stat || 0 } };
2665
+ } catch (err) {
2666
+ return { code: -1, data: err.message };
2667
+ }
2668
+ };
2669
+
2091
2670
  setTimeout(async () => {
2092
2671
  const app_id = 'prj712ffdf5aa8adce6cedef988f9c12392'; //'prj3937cb6f9a31c8c7dea25055bba845b1'; //
2093
2672
  const uid = 'd39126e0e2c51ffbd1aad10709fc8335';
@@ -3494,6 +4073,7 @@ export const update_entity_account_profiles = async function (req, job_id, heade
3494
4073
  };
3495
4074
 
3496
4075
  export const get_contact = async function (uid, contact_id) {
4076
+ if (!contact_id) return null;
3497
4077
  const account_profile_info = await get_active_account_profile_info(uid);
3498
4078
 
3499
4079
  const contact_ret = await db_module.get_app_couch_doc_native(account_profile_info.app_id, contact_id);
@@ -3880,6 +4460,7 @@ export const read_accounts_emails = async function () {
3880
4460
  const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account' }, limit: 99999 });
3881
4461
  for await (let account_doc of active_accounts.docs) {
3882
4462
  if (!account_doc?.account_info?.active_account_profile_id) continue;
4463
+ if (account_doc.membership_plan == 'free' && account_doc.ai_workspace_plan == 'free_ai_workspace') continue;
3883
4464
  email_msa.refresh_mailboxes({ uid: account_doc._id });
3884
4465
  }
3885
4466
  };
package/index_ms.mjs CHANGED
@@ -33,6 +33,30 @@ 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
+ };
39
+
40
+ export const recompute_abuse_signals = async function (...args) {
41
+ return await broker.send_to_queue("recompute_abuse_signals", ...args);
42
+ };
43
+
44
+ export const get_flagged_accounts = async function (...args) {
45
+ return await broker.send_to_queue("get_flagged_accounts", ...args);
46
+ };
47
+
48
+ export const sweep_abuse_signals = async function (...args) {
49
+ return await broker.send_to_queue("sweep_abuse_signals", ...args);
50
+ };
51
+
52
+ export const mark_account_reviewed = async function (...args) {
53
+ return await broker.send_to_queue("mark_account_reviewed", ...args);
54
+ };
55
+
56
+ export const backfill_app_costs = async function (...args) {
57
+ return await broker.send_to_queue("backfill_app_costs", ...args);
58
+ };
59
+
36
60
  export const get_account_data = async function (...args) {
37
61
  return await broker.send_to_queue("get_account_data", ...args);
38
62
  };
@@ -177,6 +201,10 @@ export const ts_contact = async function (...args) {
177
201
  return await broker.send_to_queue("ts_contact", ...args);
178
202
  };
179
203
 
204
+ export const ensure_profile_avatar = async function (...args) {
205
+ return await broker.send_to_queue("ensure_profile_avatar", ...args);
206
+ };
207
+
180
208
  export const add_contact = async function (...args) {
181
209
  return await broker.send_to_queue("add_contact", ...args);
182
210
  };
package/index_msa.mjs CHANGED
@@ -33,6 +33,30 @@ 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
+ };
39
+
40
+ export const recompute_abuse_signals = function (...args) {
41
+ broker.send_to_queue_async("recompute_abuse_signals", ...args);
42
+ };
43
+
44
+ export const get_flagged_accounts = function (...args) {
45
+ broker.send_to_queue_async("get_flagged_accounts", ...args);
46
+ };
47
+
48
+ export const sweep_abuse_signals = function (...args) {
49
+ broker.send_to_queue_async("sweep_abuse_signals", ...args);
50
+ };
51
+
52
+ export const mark_account_reviewed = function (...args) {
53
+ broker.send_to_queue_async("mark_account_reviewed", ...args);
54
+ };
55
+
56
+ export const backfill_app_costs = function (...args) {
57
+ broker.send_to_queue_async("backfill_app_costs", ...args);
58
+ };
59
+
36
60
  export const get_account_data = function (...args) {
37
61
  broker.send_to_queue_async("get_account_data", ...args);
38
62
  };
@@ -177,6 +201,10 @@ export const ts_contact = function (...args) {
177
201
  broker.send_to_queue_async("ts_contact", ...args);
178
202
  };
179
203
 
204
+ export const ensure_profile_avatar = function (...args) {
205
+ broker.send_to_queue_async("ensure_profile_avatar", ...args);
206
+ };
207
+
180
208
  export const add_contact = function (...args) {
181
209
  broker.send_to_queue_async("add_contact", ...args);
182
210
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2256",
3
+ "version": "1.2.2258",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {
@@ -0,0 +1,51 @@
1
+ // One-shot backfill runner.
2
+ //
3
+ // Usage (from /var/xuda on dev/prod, with XUDA_HOME and XUDA_CONFIG set
4
+ // the same way the cpi services have them):
5
+ //
6
+ // node cpi/account_module/scripts/run_backfill.mjs # full backfill
7
+ // node cpi/account_module/scripts/run_backfill.mjs --dry-run # compute, don't save
8
+ // node cpi/account_module/scripts/run_backfill.mjs --limit=5 # first 5 docs only
9
+ // node cpi/account_module/scripts/run_backfill.mjs --app-id=vps-abc123 # one doc
10
+ //
11
+ // Idempotent — safe to re-run if the first pass errored partway.
12
+ // Each doc with a populated app_cost.last_billed_ts is silently
13
+ // skipped on subsequent runs.
14
+
15
+ import path from 'path';
16
+
17
+ if (!global._conf) {
18
+ global._conf = (
19
+ await import(path.join(process.env.XUDA_HOME, process.env.XUDA_CONFIG), {
20
+ with: { type: 'json' },
21
+ })
22
+ ).default;
23
+ }
24
+
25
+ const argv = process.argv.slice(2);
26
+ const opts = {};
27
+ for (const a of argv) {
28
+ if (a === '--dry-run') opts.dry_run = true;
29
+ else if (a.startsWith('--limit=')) opts.limit = parseInt(a.split('=')[1], 10);
30
+ else if (a.startsWith('--app-id=')) opts.app_id = a.split('=')[1];
31
+ else console.warn(`Unknown arg: ${a}`);
32
+ }
33
+
34
+ const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
35
+ const { backfill_app_costs } = await import(`${module_path}/account_module/index.mjs`);
36
+
37
+ console.log('Running backfill with opts:', opts);
38
+ const summary = await backfill_app_costs(opts);
39
+
40
+ const { per_doc, ...headline } = summary;
41
+ console.log('\n=== Backfill summary ===');
42
+ console.log(JSON.stringify(headline, null, 2));
43
+
44
+ if (per_doc?.length) {
45
+ const fs = await import('fs');
46
+ const out = path.join(process.env.XUDA_HOME || process.cwd(), `backfill_app_cost_log_${Date.now()}.json`);
47
+ fs.writeFileSync(out, JSON.stringify(per_doc, null, 2));
48
+ console.log(`\nPer-doc log written to: ${out}`);
49
+ }
50
+
51
+ process.exit(summary.errors?.length ? 1 : 0);
@@ -0,0 +1,54 @@
1
+ // Migrate a single account from legacy 3-subscription model to the
2
+ // consolidated 1-subscription-with-3-items model.
3
+ //
4
+ // Usage (from /var/xuda on dev/prod, with XUDA_HOME and XUDA_CONFIG set):
5
+ //
6
+ // node cpi/account_module/scripts/run_migrate_account.mjs --uid=<UID>
7
+ // node cpi/account_module/scripts/run_migrate_account.mjs --uid=<UID> --dry-run
8
+ //
9
+ // Idempotent — running on an already-consolidated account is a no-op.
10
+ // Use --dry-run first to preview which subs would be cancelled and
11
+ // when the new sub would start billing.
12
+
13
+ import path from 'path';
14
+
15
+ if (!global._conf) {
16
+ global._conf = (
17
+ await import(path.join(process.env.XUDA_HOME, process.env.XUDA_CONFIG), {
18
+ with: { type: 'json' },
19
+ })
20
+ ).default;
21
+ }
22
+
23
+ const argv = process.argv.slice(2);
24
+ const opts = {};
25
+ for (const a of argv) {
26
+ if (a === '--dry-run') opts.dry_run = true;
27
+ else if (a.startsWith('--uid=')) opts.uid = a.split('=')[1];
28
+ else console.warn(`Unknown arg: ${a}`);
29
+ }
30
+
31
+ if (!opts.uid) {
32
+ console.error('--uid=<UID> is required');
33
+ process.exit(1);
34
+ }
35
+
36
+ const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
37
+ const { migrate_account_to_consolidated } = await import(`${module_path}/stripe_module/index.mjs`);
38
+
39
+ console.log(`Migrating account ${opts.uid}${opts.dry_run ? ' (DRY RUN)' : ''}...`);
40
+ const result = await migrate_account_to_consolidated(opts);
41
+ console.log('\n=== Result ===');
42
+ console.log(JSON.stringify(result, null, 2));
43
+
44
+ if (result.code === 1) {
45
+ if (opts.dry_run) {
46
+ console.log('\nDry run complete. Re-run without --dry-run to actually migrate.');
47
+ } else {
48
+ console.log(`\n✓ Account ${opts.uid} migrated. New sub will start billing at unix ts ${result.data?.starts_billing_at_unix_ts}.`);
49
+ }
50
+ process.exit(0);
51
+ } else {
52
+ console.error(`\n✗ Migration failed: ${result.data}`);
53
+ process.exit(1);
54
+ }
@@ -0,0 +1,109 @@
1
+ // Bulk migrate EVERY account from the legacy 3-subscription model to
2
+ // the consolidated 1-subscription-with-3-items model.
3
+ //
4
+ // Idempotent — accounts already on the consolidated model return
5
+ // "already consolidated" and are skipped silently. Per-account errors
6
+ // don't stop the loop; the summary at the end lists what failed.
7
+ //
8
+ // Usage (from /var/xuda on dev/prod, with XUDA_HOME and XUDA_CONFIG set):
9
+ //
10
+ // node cpi/account_module/scripts/run_migrate_all.mjs --dry-run
11
+ // node cpi/account_module/scripts/run_migrate_all.mjs
12
+ // node cpi/account_module/scripts/run_migrate_all.mjs --limit=5 # canary first N
13
+ //
14
+ // On dev this is fine to run at any time. On prod, prefer the
15
+ // per-account script (run_migrate_account.mjs) or the webhook-driven
16
+ // gradual migration so each customer migrates at their natural
17
+ // cycle boundary.
18
+
19
+ import path from 'path';
20
+
21
+ if (!global._conf) {
22
+ global._conf = (
23
+ await import(path.join(process.env.XUDA_HOME, process.env.XUDA_CONFIG), {
24
+ with: { type: 'json' },
25
+ })
26
+ ).default;
27
+ }
28
+
29
+ const argv = process.argv.slice(2);
30
+ const opts = { dry_run: false, limit: null };
31
+ for (const a of argv) {
32
+ if (a === '--dry-run') opts.dry_run = true;
33
+ else if (a.startsWith('--limit=')) opts.limit = parseInt(a.split('=')[1], 10);
34
+ else console.warn(`Unknown arg: ${a}`);
35
+ }
36
+
37
+ const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
38
+ const db_module = await import(`${module_path}/db_module/index.mjs`);
39
+ const { migrate_account_to_consolidated } = await import(`${module_path}/stripe_module/index.mjs`);
40
+
41
+ console.log(`Bulk migration ${opts.dry_run ? '(DRY RUN)' : ''}${opts.limit ? ` limit=${opts.limit}` : ''}...`);
42
+
43
+ const accounts_ret = await db_module.get_couch_view_raw('xuda_accounts', 'all_accounts', {});
44
+ const accounts = (accounts_ret?.rows || []).map((r) => r.value).filter((a) => a?._id);
45
+
46
+ console.log(`Found ${accounts.length} accounts.\n`);
47
+
48
+ const summary = {
49
+ scanned: 0,
50
+ migrated: 0,
51
+ already: 0,
52
+ skipped_no_stripe: 0,
53
+ failed: 0,
54
+ errors: [],
55
+ per_account: [],
56
+ };
57
+
58
+ const todo = opts.limit ? accounts.slice(0, opts.limit) : accounts;
59
+
60
+ for (const acct of todo) {
61
+ summary.scanned++;
62
+
63
+ // Skip accounts that don't have any Stripe presence yet (e.g.
64
+ // half-created test accounts) — there's nothing to migrate.
65
+ if (!acct.stripe_customer_id) {
66
+ summary.skipped_no_stripe++;
67
+ summary.per_account.push({ uid: acct._id, action: 'skipped', reason: 'no stripe_customer_id' });
68
+ continue;
69
+ }
70
+
71
+ try {
72
+ const result = await migrate_account_to_consolidated({ uid: acct._id, dry_run: opts.dry_run });
73
+ if (result.code === 1) {
74
+ const data = result.data || '';
75
+ const isAlready = typeof data === 'string' && (data.includes('already') || data.includes('reconciled'));
76
+ if (isAlready) {
77
+ summary.already++;
78
+ summary.per_account.push({ uid: acct._id, action: 'already', reason: data });
79
+ } else {
80
+ summary.migrated++;
81
+ summary.per_account.push({ uid: acct._id, action: opts.dry_run ? 'dry-run-ok' : 'migrated', reason: typeof data === 'object' ? `new_sub=${data.new_sub_id} starts=${data.starts_billing_at_unix_ts}` : data });
82
+ }
83
+ } else {
84
+ summary.failed++;
85
+ summary.errors.push({ uid: acct._id, reason: result.data });
86
+ summary.per_account.push({ uid: acct._id, action: 'failed', reason: result.data });
87
+ }
88
+ } catch (err) {
89
+ summary.failed++;
90
+ summary.errors.push({ uid: acct._id, reason: err.message });
91
+ summary.per_account.push({ uid: acct._id, action: 'errored', reason: err.message });
92
+ }
93
+
94
+ // Tiny gap between accounts so we don't hammer Stripe with bursts.
95
+ await new Promise((r) => setTimeout(r, 250));
96
+ }
97
+
98
+ const { per_account, ...headline } = summary;
99
+ console.log('\n=== Bulk migration summary ===');
100
+ console.log(JSON.stringify(headline, null, 2));
101
+
102
+ if (per_account.length) {
103
+ const fs = await import('fs');
104
+ const out = path.join(process.env.XUDA_HOME || process.cwd(), `bulk_migrate_log_${Date.now()}.json`);
105
+ fs.writeFileSync(out, JSON.stringify(per_account, null, 2));
106
+ console.log(`\nPer-account log written to: ${out}`);
107
+ }
108
+
109
+ process.exit(summary.failed ? 1 : 0);