@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 +594 -13
- package/index_ms.mjs +28 -0
- package/index_msa.mjs +28 -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
|
@@ -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)
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
760
|
-
|
|
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
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
@@ -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);
|