@xuda.io/account_module 1.2.2255 → 1.2.2257

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
@@ -197,6 +197,9 @@ export const update_account_info = async function (req, job_id, headers) {
197
197
  return { code: -1310, data: error };
198
198
  }
199
199
  if (!change) {
200
+ if (account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
201
+ set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
202
+ }
200
203
  return { code: 1300, data: 'no change' };
201
204
  }
202
205
 
@@ -219,6 +222,59 @@ export const update_account_info = async function (req, job_id, headers) {
219
222
  }
220
223
  }
221
224
 
225
+ // Opportunistic Stripe consolidation. Any time the user updates
226
+ // their account info, also check if they're still on the legacy
227
+ // 3-subscription model and migrate them to the consolidated
228
+ // 1-subscription-with-3-items model in the background. The
229
+ // migration helper is idempotent — already-consolidated accounts
230
+ // and accounts without a stripe_customer_id return immediately
231
+ // with no side effects — so this is cheap to run on every save.
232
+ //
233
+ // Fire-and-forget: we don't await it so the user's update_account_info
234
+ // response isn't blocked on Stripe API calls (creating a new sub +
235
+ // scheduling 3 cancels can take several seconds). The migration
236
+ // logs its own errors; if it fails for a particular account the
237
+ // user can still operate normally on the legacy model and we'll
238
+ // retry on the next update_account_info call.
239
+ if (account_obj.stripe_customer_id && !account_obj.stripe_subscription_id) {
240
+ stripe_ms
241
+ .migrate_account_to_consolidated({ uid })
242
+ .then((r) => {
243
+ if (r?.code === 1) {
244
+ console.log(`[update_account_info] migration result for ${uid}:`, typeof r.data === 'string' ? r.data : `migrated → ${r.data?.new_sub_id}`);
245
+ } else if (r?.code < 0) {
246
+ console.warn(`[update_account_info] migration failed for ${uid}:`, r?.data);
247
+ }
248
+ })
249
+ .catch((err) => {
250
+ console.warn(`[update_account_info] migration errored for ${uid}:`, err?.message || err);
251
+ });
252
+ } else if (account_obj.stripe_subscription_id) {
253
+ // Already on the consolidated model — but if the original
254
+ // migration used `trial_end` (older code path) the Stripe
255
+ // dashboard still shows the sub as "trialing". Idempotently
256
+ // swap it for a no-trial equivalent (cancel + recreate with
257
+ // `billing_cycle_anchor`). First invoice date stays exactly
258
+ // the same so the customer sees no billing change.
259
+ stripe_ms
260
+ .end_consolidated_trial({ uid })
261
+ .then((r) => {
262
+ if (r?.code === 1) {
263
+ const data = r.data;
264
+ if (typeof data === 'object' && data?.new_sub_id) {
265
+ console.log(`[update_account_info] trial removed for ${uid}: new sub ${data.new_sub_id}`);
266
+ }
267
+ // Quiet otherwise — "no consolidated sub" / "no active
268
+ // trial" are the common no-op cases and would spam logs.
269
+ } else if (r?.code < 0) {
270
+ console.warn(`[update_account_info] end_consolidated_trial failed for ${uid}:`, r?.data);
271
+ }
272
+ })
273
+ .catch((err) => {
274
+ console.warn(`[update_account_info] end_consolidated_trial errored for ${uid}:`, err?.message || err);
275
+ });
276
+ }
277
+
222
278
  if (save_ret.code > 0) {
223
279
  return { code: 1300, data: 'ok' };
224
280
  }
@@ -494,6 +550,455 @@ export const increment_account_usage = async function (req) {
494
550
  }
495
551
  };
496
552
 
553
+ // ──────────────────────────────────────────────────────────────────
554
+ // Per-deployment billing helpers.
555
+ //
556
+ // HISTORY: an earlier iteration of unified billing had an hourly
557
+ // cron (`accrue_deployment_costs`) that walked every active
558
+ // deployment and pushed accrual into a `deployment_usage` doc per
559
+ // VPS, then flushed those docs to Stripe at end-of-cycle. That
560
+ // approach had cron-coupling, off-by-one bugs at hour boundaries,
561
+ // drift, and a parallel state machine to keep in sync.
562
+ //
563
+ // CURRENT: on-demand. Both the dashboard's `accrued_so_far` /
564
+ // `projected_total` numbers AND the Stripe invoice items at cycle
565
+ // close are computed from `app_cost.created_ts` /
566
+ // `app_cost.terminated_ts` directly. No cron, no accumulator
567
+ // docs. See compute_cycle_billable_amount in deploy_module/cost.mjs
568
+ // for the math. Result: per-second accuracy automatically, idempotent
569
+ // flush, zero between-event state.
570
+ //
571
+ // Only the `backfill_app_costs` one-shot remains here — it stamps
572
+ // `app_cost.*` onto legacy deployments that pre-date the per-VPS
573
+ // ledger, anchoring `created_ts = now` so past hours stay unbilled.
574
+ // ──────────────────────────────────────────────────────────────────
575
+
576
+ // Doc types that own a billable droplet / add-on bundle. Used by the
577
+ // backfill query to scope its selector — and reusable as a constant
578
+ // elsewhere should anything need to filter by "is this a billable
579
+ // app type?".
580
+ const BILLABLE_APP_TYPES = ['vps', 'datacenter', 'instance', 'balancer'];
581
+
582
+ // Stamp `app_cost.terminated_ts` on an app doc IMMEDIATELY, without
583
+ // waiting for the destroy job to pick up. Called from the dashboard
584
+ // the instant the user confirms a destroy so the billing UI reacts
585
+ // without delay.
586
+ //
587
+ // Why we need this: destroy_app runs with { job: true } — i.e. the
588
+ // HTTP call queues a background job and returns right away. The job
589
+ // worker eventually executes destroy_app server-side, which marks
590
+ // terminated_ts at the START of its own execution. But during the
591
+ // window between "request returns" and "job worker starts", the
592
+ // app doc still looks billable. The dashboard refresh in that
593
+ // window sees the old state — exactly the "GUI doesn't react /
594
+ // second refresh fixes it" symptom.
595
+ //
596
+ // This endpoint sidesteps the job queue entirely. It's a synchronous
597
+ // 2-line write — no DO API calls, no destroy chain. The dashboard
598
+ // calls it BEFORE the destroy_app job, returns can render the new
599
+ // billing state without waiting on infra teardown.
600
+ //
601
+ // Idempotent: if terminated_ts is already set, returns immediately.
602
+ export const mark_app_terminated = async function (req) {
603
+ const { app_id } = req || {};
604
+ if (!app_id) return { code: -1, data: 'app_id required' };
605
+ try {
606
+ const ret = await db_module.get_couch_doc('xuda_master', app_id);
607
+ if (ret.code < 0) return { code: -1, data: 'app not found' };
608
+ const app_doc = ret.data;
609
+ if (!app_doc.app_cost) return { code: 1, data: 'no app_cost — nothing to mark' };
610
+ if (app_doc.app_cost.terminated_ts) return { code: 1, data: 'already terminated' };
611
+ app_doc.app_cost.terminated_ts = Date.now();
612
+ await db_module.save_couch_doc('xuda_master', app_doc);
613
+ return { code: 1, data: { terminated_ts: app_doc.app_cost.terminated_ts } };
614
+ } catch (err) {
615
+ return { code: -1, data: err.message };
616
+ }
617
+ };
618
+
619
+ // -------------------------------------------------------------------
620
+ // Abuse detection — see the "How can we detect abuse?" plan.
621
+ // -------------------------------------------------------------------
622
+
623
+ // Admin emails get access to the abuse triage endpoints. Keep this in
624
+ // sync with the dashboard's admin-route gate. Lives here (not in
625
+ // config) so a compromised config_dev.json can't grant admin access.
626
+ const ADMIN_EMAILS = new Set(['info@ioshka.com']);
627
+
628
+ const is_admin_email = (email) => {
629
+ if (!email) return false;
630
+ return ADMIN_EMAILS.has(String(email).toLowerCase());
631
+ };
632
+
633
+ // Get the active billing cycle bounds for an account, on demand from
634
+ // Stripe. Used by recompute_abuse_signals to scope its "this cycle"
635
+ // counters correctly. Returns { cycle_start_ts, cycle_end_ts } in ms
636
+ // or null if Stripe isn't reachable / customer not found.
637
+ const get_account_cycle_bounds = async (account) => {
638
+ if (!account?.stripe_customer_id) return null;
639
+ try {
640
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
641
+ // get_upcoming_invoice returns the upcoming invoice for the customer.
642
+ // Its period_start/period_end define the active cycle window. If
643
+ // that endpoint isn't available, we approximate from docDate.
644
+ const upcoming = await stripe_ms
645
+ .get_upcoming_invoice({
646
+ customer_id: account.stripe_customer_id,
647
+ })
648
+ .catch(() => null);
649
+ if (upcoming?.data?.period_start && upcoming?.data?.period_end) {
650
+ return {
651
+ cycle_start_ts: upcoming.data.period_start * 1000,
652
+ cycle_end_ts: upcoming.data.period_end * 1000,
653
+ };
654
+ }
655
+ } catch (_) {}
656
+ // Fallback: rolling 30-day window from now. Approximation good enough
657
+ // for abuse signals — exact cycle bounds only matter for invoicing.
658
+ const now = Date.now();
659
+ return { cycle_start_ts: now - 30 * 24 * 60 * 60 * 1000, cycle_end_ts: now };
660
+ };
661
+
662
+ // Walk an account's deployments and update its abuse_signals object.
663
+ // Idempotent — safe to call on every cycle close, on resize, or on
664
+ // admin demand. Returns the computed signals shape + any flags raised.
665
+ //
666
+ // Rules implemented (from the abuse plan):
667
+ // Rule 1 (negative_margin) — margin_ratio < 0 AND provider > $5
668
+ // Rule 2 (resize_churn) — >3 short segments OR >10 resizes this cycle
669
+ // Rule 3 (new_account_high_spec) — account age < 7d AND any deploy > $150/mo
670
+ //
671
+ // Rules 4/5/6 need additional data sources (project history view, Stripe
672
+ // payment_failed webhook, fingerprint capture) and ship in later phases.
673
+ export const recompute_abuse_signals = async function (req) {
674
+ const { uid } = req || {};
675
+ if (!uid) return { code: -1, data: 'uid required' };
676
+
677
+ try {
678
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
679
+ if (acct_ret.code < 0) return { code: -1, data: 'account not found' };
680
+ const account = acct_ret.data;
681
+
682
+ const cost_mod = await import(`${module_path}/deploy_module/cost.mjs`);
683
+ const { compute_cycle_billable_amount, is_billable } = cost_mod;
684
+
685
+ const bounds = await get_account_cycle_bounds(account);
686
+ const cycle_start_ts = bounds?.cycle_start_ts || Date.now() - 30 * 24 * 60 * 60 * 1000;
687
+ const cycle_end_ts = bounds?.cycle_end_ts || Date.now();
688
+
689
+ // Walk apps via the user_apps view.
690
+ const apps_ret = await db_module.get_couch_view('xuda_master', 'user_apps', {
691
+ startkey: [uid, ''],
692
+ endkey: [uid, 'ZZZZZ'],
693
+ include_docs: true,
694
+ });
695
+
696
+ let resizes_this_cycle = 0;
697
+ let short_segments_this_cycle = 0;
698
+ let total_billed = 0;
699
+ let total_provider = 0;
700
+ let max_monthly_spec = 0;
701
+
702
+ for (const row of apps_ret?.data?.rows || []) {
703
+ const doc = row.doc;
704
+ if (!is_billable(doc)) continue;
705
+
706
+ // Count cycle-relevant resizes by examining history[] entries
707
+ // whose closing timestamp falls inside the cycle window.
708
+ for (const seg of doc.app_cost.history || []) {
709
+ if (seg.to_ts > cycle_start_ts && seg.to_ts <= cycle_end_ts) {
710
+ resizes_this_cycle++;
711
+ if (seg.to_ts - seg.from_ts < 60 * 60 * 1000) short_segments_this_cycle++;
712
+ }
713
+ }
714
+
715
+ const billed = compute_cycle_billable_amount(doc, cycle_start_ts, cycle_end_ts);
716
+ total_billed += billed;
717
+
718
+ // Provider cost: use provider_cost_monthly if populated (Phase B
719
+ // will fill this from DO billing API). Until then approximate as
720
+ // monthly_hosting (assume 0% markup baseline — conservative for
721
+ // detecting negative margin).
722
+ const provider_monthly = doc.app_cost.provider_cost_monthly ?? doc.app_cost.monthly_hosting ?? 0;
723
+ const cycle_ms = cycle_end_ts - cycle_start_ts;
724
+ const window_start = Math.max(doc.app_cost.created_ts || cycle_start_ts, cycle_start_ts);
725
+ const window_end = Math.min(doc.app_cost.terminated_ts || cycle_end_ts, cycle_end_ts);
726
+ if (window_end > window_start && cycle_ms > 0) {
727
+ total_provider += provider_monthly * ((window_end - window_start) / cycle_ms);
728
+ }
729
+
730
+ if (doc.app_cost.monthly_total > max_monthly_spec) {
731
+ max_monthly_spec = doc.app_cost.monthly_total;
732
+ }
733
+ }
734
+
735
+ const account_age_ms = Date.now() - (account.docDate || account.created_ts || Date.now());
736
+ const account_age_days = account_age_ms / (24 * 60 * 60 * 1000);
737
+ const margin_ratio = total_provider > 0 ? (total_billed - total_provider) / total_provider : 0;
738
+
739
+ // Apply rules — first flag wins.
740
+ let flag = null;
741
+ let flag_detail = null;
742
+ if (total_provider > 5 && margin_ratio < 0) {
743
+ flag = 'negative_margin';
744
+ flag_detail = `provider $${total_provider.toFixed(2)} > billed $${total_billed.toFixed(2)} (margin ${(margin_ratio * 100).toFixed(0)}%)`;
745
+ } else if (short_segments_this_cycle > 3 || resizes_this_cycle > 10) {
746
+ flag = 'resize_churn';
747
+ flag_detail = `${resizes_this_cycle} resizes, ${short_segments_this_cycle} sub-1h segments`;
748
+ } else if (account_age_days < 7 && max_monthly_spec > 150) {
749
+ flag = 'new_account_high_spec';
750
+ flag_detail = `${account_age_days.toFixed(1)}d old, biggest VPS $${max_monthly_spec}/mo`;
751
+ }
752
+
753
+ const prev = account.abuse_signals || {};
754
+ const signals = {
755
+ resizes_this_cycle,
756
+ short_segments_this_cycle,
757
+ destroy_recreate_pairs: prev.destroy_recreate_pairs || 0, // populated by Rule 4 (later)
758
+ account_age_days,
759
+ total_provider_cost: Number(total_provider.toFixed(2)),
760
+ total_billed_amount: Number(total_billed.toFixed(2)),
761
+ margin_ratio: Number(margin_ratio.toFixed(4)),
762
+ max_monthly_spec,
763
+ cycle_start_ts,
764
+ cycle_end_ts,
765
+ computed_at: Date.now(),
766
+ flagged: !!flag,
767
+ flag_reason: flag,
768
+ flag_detail,
769
+ flag_ts: flag ? prev.flag_ts || Date.now() : null, // sticky until reviewed
770
+ reviewed_by: flag ? prev.reviewed_by || null : null,
771
+ reviewed_at: flag ? prev.reviewed_at || null : null,
772
+ };
773
+
774
+ account.abuse_signals = signals;
775
+ await db_module.save_couch_doc('xuda_accounts', account);
776
+
777
+ return { code: 1, data: signals };
778
+ } catch (err) {
779
+ console.error('[recompute_abuse_signals]', err.message);
780
+ return { code: -1, data: err.message };
781
+ }
782
+ };
783
+
784
+ // Admin-only: list every account currently flagged. The dashboard's
785
+ // /admin/abuse page reads from here.
786
+ export const get_flagged_accounts = async function (req) {
787
+ const { uid: caller_uid } = req || {};
788
+ if (!caller_uid) return { code: -1, data: 'unauthorized' };
789
+ try {
790
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
791
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
792
+ return { code: -1, data: 'unauthorized' };
793
+ }
794
+
795
+ // No view yet — scan via find_couch_query on abuse_signals.flagged.
796
+ // Acceptable at small scale; index this if the flagged list grows.
797
+ const find_ret = await db_module.find_couch_query('xuda_accounts', {
798
+ selector: { 'abuse_signals.flagged': true },
799
+ limit: 500,
800
+ });
801
+
802
+ const rows = (find_ret?.docs || []).map((a) => ({
803
+ uid: a._id,
804
+ email: a.email,
805
+ first_name: a.first_name,
806
+ last_name: a.last_name,
807
+ username: a.username,
808
+ stripe_customer_id: a.stripe_customer_id,
809
+ abuse_signals: a.abuse_signals,
810
+ stat: a.stat,
811
+ docDate: a.docDate,
812
+ }));
813
+
814
+ // Sort: unreviewed first, then most recent flag.
815
+ rows.sort((a, b) => {
816
+ const ar = a.abuse_signals?.reviewed_by ? 1 : 0;
817
+ const br = b.abuse_signals?.reviewed_by ? 1 : 0;
818
+ if (ar !== br) return ar - br;
819
+ return (b.abuse_signals?.flag_ts || 0) - (a.abuse_signals?.flag_ts || 0);
820
+ });
821
+
822
+ return { code: 1, data: rows };
823
+ } catch (err) {
824
+ return { code: -1, data: err.message };
825
+ }
826
+ };
827
+
828
+ // Admin-only: sweep every account that has a stripe_customer_id and
829
+ // recompute its abuse_signals. Used by the admin page's "Run sweep"
830
+ // button so signals get refreshed without waiting for the next
831
+ // invoice.upcoming webhook. Returns counts (flagged / clean / errors).
832
+ export const sweep_abuse_signals = async function (req) {
833
+ const { uid: caller_uid } = req || {};
834
+ if (!caller_uid) return { code: -1, data: 'unauthorized' };
835
+ try {
836
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
837
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
838
+ return { code: -1, data: 'unauthorized' };
839
+ }
840
+
841
+ const accounts_ret = await db_module.find_couch_query('xuda_accounts', {
842
+ selector: { stripe_customer_id: { $exists: true } },
843
+ limit: 1000,
844
+ });
845
+ const accounts = accounts_ret?.docs || [];
846
+
847
+ const summary = { scanned: 0, flagged: 0, clean: 0, errors: [] };
848
+ for (const acct of accounts) {
849
+ summary.scanned++;
850
+ try {
851
+ const r = await recompute_abuse_signals({ uid: acct._id });
852
+ if (r?.code < 0) {
853
+ summary.errors.push({ uid: acct._id, message: r.data });
854
+ } else if (r?.data?.flagged) {
855
+ summary.flagged++;
856
+ } else {
857
+ summary.clean++;
858
+ }
859
+ } catch (err) {
860
+ summary.errors.push({ uid: acct._id, message: err.message });
861
+ }
862
+ }
863
+ return { code: 1, data: summary };
864
+ } catch (err) {
865
+ return { code: -1, data: err.message };
866
+ }
867
+ };
868
+
869
+ // Admin-only: mark an account's current flag as reviewed (cleared until
870
+ // next recompute raises it again). Records who reviewed + when.
871
+ export const mark_account_reviewed = async function (req) {
872
+ const { uid: caller_uid, target_uid, note } = req || {};
873
+ if (!caller_uid || !target_uid) return { code: -1, data: 'uid + target_uid required' };
874
+ try {
875
+ const caller_ret = await db_module.get_couch_doc('xuda_accounts', caller_uid);
876
+ if (caller_ret.code < 0 || !is_admin_email(caller_ret.data.email)) {
877
+ return { code: -1, data: 'unauthorized' };
878
+ }
879
+
880
+ const target_ret = await db_module.get_couch_doc('xuda_accounts', target_uid);
881
+ if (target_ret.code < 0) return { code: -1, data: 'target account not found' };
882
+ const target = target_ret.data;
883
+
884
+ if (!target.abuse_signals) target.abuse_signals = {};
885
+ target.abuse_signals.reviewed_by = caller_ret.data.email;
886
+ target.abuse_signals.reviewed_at = Date.now();
887
+ target.abuse_signals.review_note = note || null;
888
+ target.abuse_signals.flagged = false; // cleared
889
+
890
+ await db_module.save_couch_doc('xuda_accounts', target);
891
+ return { code: 1, data: target.abuse_signals };
892
+ } catch (err) {
893
+ return { code: -1, data: err.message };
894
+ }
895
+ };
896
+
897
+ // One-shot backfill: stamp `app_cost.*` onto legacy deployments,
898
+ // anchored at `last_billed_ts = now` so the cron starts forward-only.
899
+ // Idempotent — re-running on a doc that already has app_cost.last_billed_ts
900
+ // is a no-op.
901
+ //
902
+ // opts:
903
+ // dry_run: boolean — compute, don't save.
904
+ // limit: number — cap on docs processed (canary backfill).
905
+ // app_id: string — backfill only this specific app.
906
+ export const backfill_app_costs = async function (opts = {}) {
907
+ const { dry_run = false, limit = null, app_id = null } = opts || {};
908
+
909
+ // compute_app_cost lives in deploy_module/cost.mjs. Imported via
910
+ // the same cross-module path convention used elsewhere in this
911
+ // file (see fs_module / db_module imports at the top). Falls back
912
+ // to inline noop if the import fails so the backfill can at least
913
+ // log what it WOULD have done.
914
+ let compute_app_cost;
915
+ try {
916
+ const cost_mod = await import(`${module_path}/deploy_module/cost.mjs`);
917
+ compute_app_cost = cost_mod.compute_app_cost;
918
+ } catch (err) {
919
+ return { code: -1, data: `cost.mjs not importable from account_module: ${err.message}` };
920
+ }
921
+
922
+ const start = Date.now();
923
+ const summary = {
924
+ started_at: new Date().toISOString(),
925
+ dry_run,
926
+ scanned: 0,
927
+ stamped: 0,
928
+ terminated: 0,
929
+ skipped: 0,
930
+ nothing_to_bill: 0,
931
+ errors: [],
932
+ };
933
+
934
+ const APP_STATUS_DELETED = 4;
935
+
936
+ let selector;
937
+ if (app_id) {
938
+ selector = { _id: app_id };
939
+ } else {
940
+ selector = {
941
+ docType: 'app',
942
+ $or: [{ app_type: { $in: BILLABLE_APP_TYPES } }, { is_deployment: true }],
943
+ };
944
+ }
945
+
946
+ const find_ret = await db_module.find_couch_query('xuda_master', { selector, limit: limit || 99999 });
947
+ const docs = find_ret?.docs || [];
948
+
949
+ for (const doc of docs) {
950
+ summary.scanned++;
951
+ try {
952
+ let action = 'skipped';
953
+ let reason = '';
954
+
955
+ if (doc.app_cost?.last_billed_ts) {
956
+ reason = 'already has app_cost.last_billed_ts';
957
+ } else if (!doc.deploy_data) {
958
+ reason = 'no deploy_data';
959
+ } else if (!BILLABLE_APP_TYPES.includes(doc.app_type) && !doc.is_deployment) {
960
+ reason = `app_type ${doc.app_type} not billable`;
961
+ } else {
962
+ 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;
963
+ if (doc.is_deployment && !has_addons && !doc.deploy_data?.app_server_type) {
964
+ reason = 'deployment without addons → datacenter bears cost';
965
+ } else {
966
+ const cost = compute_app_cost(doc.deploy_data);
967
+ if (!cost) {
968
+ action = 'nothing-to-bill';
969
+ reason = 'monthly_total = 0';
970
+ } else {
971
+ if (doc.app_status_code === APP_STATUS_DELETED) {
972
+ cost.terminated_ts = doc.app_status_data ? new Date(doc.app_status_data).getTime() : Date.now();
973
+ action = 'terminated';
974
+ } else {
975
+ action = 'stamped';
976
+ }
977
+ reason = `monthly=$${cost.monthly_total}`;
978
+ doc.app_cost = cost;
979
+ if (!dry_run) {
980
+ await db_module.save_couch_doc('xuda_master', doc);
981
+ }
982
+ }
983
+ }
984
+ }
985
+
986
+ if (action === 'stamped') summary.stamped++;
987
+ else if (action === 'terminated') summary.terminated++;
988
+ else if (action === 'nothing-to-bill') summary.nothing_to_bill++;
989
+ else summary.skipped++;
990
+
991
+ if (!summary.per_doc) summary.per_doc = [];
992
+ summary.per_doc.push({ _id: doc._id, app_type: doc.app_type, app_name: doc.app_name, action, reason });
993
+ } catch (err) {
994
+ summary.errors.push({ _id: doc._id, message: err.message });
995
+ }
996
+ }
997
+
998
+ summary.duration_ms = Date.now() - start;
999
+ return summary;
1000
+ };
1001
+
497
1002
  export const get_account_data = async function (req) {
498
1003
  var { uid, enforce_usage } = req;
499
1004
 
@@ -705,15 +1210,31 @@ export const did_you_know_tips = async function (req, job_id, headers) {
705
1210
  }
706
1211
  };
707
1212
 
1213
+ const _warned_no_account_project_id = new Set();
1214
+
708
1215
  export const get_active_account_profile_info = async function (uid, profile_id) {
709
1216
  try {
710
1217
  const acc_obj = await db_module.get_couch_doc_native('xuda_accounts', uid);
711
1218
 
712
- if (!acc_obj.account_project_id) throw new Error(`acc ${acc_obj._id} has no account_project_id`);
1219
+ if (!acc_obj.account_project_id) {
1220
+ if (!_warned_no_account_project_id.has(acc_obj._id)) {
1221
+ _warned_no_account_project_id.add(acc_obj._id);
1222
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id} has no account_project_id; returning null`);
1223
+ }
1224
+ return { uid, account_profile_id: null, app_id: null, is_main: false, account_profile_obj: null };
1225
+ }
713
1226
 
714
- let active_account_profile_id = profile_id || acc_obj.account_info.active_account_profile_id;
1227
+ // Fallback chain: explicit profile_id > active_account_profile_id > top-level account_profile_id.
1228
+ // Recovers gracefully from accounts where active_account_profile_id was never set during boarding
1229
+ // (e.g. interrupted Google signup flow), instead of throwing on every chat-widget poll.
1230
+ let active_account_profile_id = profile_id || acc_obj.account_info?.active_account_profile_id || acc_obj.account_profile_id;
715
1231
 
716
- if (!active_account_profile_id) throw new Error(`acc ${acc_obj._id} has no active_account_profile_id`);
1232
+ if (!active_account_profile_id) {
1233
+ // Soft-fail: log once and return a degraded response. Callers that need a real profile
1234
+ // should null-check `account_profile_obj`. The chat widget treats this as anonymous visitor.
1235
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id} has neither active_account_profile_id nor account_profile_id; returning null`);
1236
+ return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
1237
+ }
717
1238
 
718
1239
  const account_profile_obj = await db_module.get_app_couch_doc_native(acc_obj.account_project_id, active_account_profile_id);
719
1240
  if (account_profile_obj.share_item_id) {
@@ -1955,13 +2476,13 @@ export const onboarding_completed = async function (req, job_id, headers) {
1955
2476
 
1956
2477
  let account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
1957
2478
 
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
- }
2479
+ // NOTE: neither profile_picture nor profile_avatar are required
2480
+ // to complete onboarding. The client UI labels step 4 "(optional)"
2481
+ // and lets the user skip — matching that here means a successful
2482
+ // client flow always succeeds server-side too. Anything they skip
2483
+ // can be added later from Settings → Profile. The avatar service
2484
+ // can also time out or fail transiently, and we don't want a
2485
+ // generation hiccup to block someone from finishing signup.
1965
2486
 
1966
2487
  account_doc.account_info.full_name = `${account_doc.account_info.first_name} ${account_doc.account_info.last_name}`;
1967
2488
 
@@ -3238,11 +3759,11 @@ export const get_account_profiles = async function (req) {
3238
3759
  let profile_docs = { docs: [], total_docs: profiles.total_docs };
3239
3760
  for await (let doc of profiles.docs) {
3240
3761
  doc = await get_account_profile_info(uid, doc);
3241
- // if (doc.main) {
3242
- // profile_docs.docs.unshift(doc);
3243
- // } else {
3244
- profile_docs.docs.push(doc);
3245
- // }
3762
+ if (doc.main) {
3763
+ profile_docs.docs.unshift(doc);
3764
+ } else {
3765
+ profile_docs.docs.push(doc);
3766
+ }
3246
3767
  }
3247
3768
 
3248
3769
  if (!profile_id) {
@@ -3253,17 +3774,16 @@ export const get_account_profiles = async function (req) {
3253
3774
  }
3254
3775
  }
3255
3776
 
3256
- if (filter_type === 'all' && !profile_id && !search && active_tab && profile_docs.docs[0].main) {
3257
- //profile_docs.docs.length === 1 &&
3258
- // return empty state if no custom profile defined by user
3259
- return {
3260
- code: 1,
3261
- data: {
3262
- docs: [],
3263
- total_docs: 0,
3264
- },
3265
- };
3266
- }
3777
+ // if (filter_type === 'all' && !profile_id && !search && active_tab && profile_docs.docs.length === 1 && profile_docs.docs[0].main) {
3778
+ // // return empty state if no custom profile defined by user
3779
+ // return {
3780
+ // code: 1,
3781
+ // data: {
3782
+ // docs: [],
3783
+ // total_docs: 0,
3784
+ // },
3785
+ // };
3786
+ // }
3267
3787
 
3268
3788
  return {
3269
3789
  code: 1,
@@ -3881,6 +4401,7 @@ export const read_accounts_emails = async function () {
3881
4401
  const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account' }, limit: 99999 });
3882
4402
  for await (let account_doc of active_accounts.docs) {
3883
4403
  if (!account_doc?.account_info?.active_account_profile_id) continue;
4404
+ if (account_doc.membership_plan == 'free' && account_doc.ai_workspace_plan == 'free_ai_workspace') continue;
3884
4405
  email_msa.refresh_mailboxes({ uid: account_doc._id });
3885
4406
  }
3886
4407
  };
package/index_ms.mjs CHANGED
@@ -33,6 +33,35 @@ 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
+ // accrue_deployment_costs removed — on-demand billing computes
37
+ // from app_cost timestamps. See compute_cycle_billable_amount in
38
+ // deploy_module/cost.mjs and the get_billing_metrics +
39
+ // flush_deployment_usage_to_stripe entry points in stripe_module.
40
+
41
+ export const backfill_app_costs = async function (...args) {
42
+ return await broker.send_to_queue("backfill_app_costs", ...args);
43
+ };
44
+
45
+ export const mark_app_terminated = async function (...args) {
46
+ return await broker.send_to_queue("mark_app_terminated", ...args);
47
+ };
48
+
49
+ export const recompute_abuse_signals = async function (...args) {
50
+ return await broker.send_to_queue("recompute_abuse_signals", ...args);
51
+ };
52
+
53
+ export const get_flagged_accounts = async function (...args) {
54
+ return await broker.send_to_queue("get_flagged_accounts", ...args);
55
+ };
56
+
57
+ export const mark_account_reviewed = async function (...args) {
58
+ return await broker.send_to_queue("mark_account_reviewed", ...args);
59
+ };
60
+
61
+ export const sweep_abuse_signals = async function (...args) {
62
+ return await broker.send_to_queue("sweep_abuse_signals", ...args);
63
+ };
64
+
36
65
  export const get_account_data = async function (...args) {
37
66
  return await broker.send_to_queue("get_account_data", ...args);
38
67
  };
package/index_msa.mjs CHANGED
@@ -33,6 +33,33 @@ export const increment_account_usage = function (...args) {
33
33
  broker.send_to_queue_async("increment_account_usage", ...args);
34
34
  };
35
35
 
36
+ // accrue_deployment_costs removed — on-demand billing. See
37
+ // compute_cycle_billable_amount in deploy_module/cost.mjs.
38
+
39
+ export const backfill_app_costs = function (...args) {
40
+ broker.send_to_queue_async("backfill_app_costs", ...args);
41
+ };
42
+
43
+ export const mark_app_terminated = function (...args) {
44
+ broker.send_to_queue_async("mark_app_terminated", ...args);
45
+ };
46
+
47
+ export const recompute_abuse_signals = function (...args) {
48
+ broker.send_to_queue_async("recompute_abuse_signals", ...args);
49
+ };
50
+
51
+ export const get_flagged_accounts = function (...args) {
52
+ broker.send_to_queue_async("get_flagged_accounts", ...args);
53
+ };
54
+
55
+ export const mark_account_reviewed = function (...args) {
56
+ broker.send_to_queue_async("mark_account_reviewed", ...args);
57
+ };
58
+
59
+ export const sweep_abuse_signals = function (...args) {
60
+ broker.send_to_queue_async("sweep_abuse_signals", ...args);
61
+ };
62
+
36
63
  export const get_account_data = function (...args) {
37
64
  broker.send_to_queue_async("get_account_data", ...args);
38
65
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2255",
3
+ "version": "1.2.2257",
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);