@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 +547 -26
- package/index_ms.mjs +29 -0
- package/index_msa.mjs +27 -0
- package/package.json +1 -1
- package/scripts/run_backfill.mjs +51 -0
- package/scripts/run_migrate_account.mjs +54 -0
- package/scripts/run_migrate_all.mjs +109 -0
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
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
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
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
|
@@ -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);
|