@xuda.io/account_module 1.2.2301 → 1.2.2303
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 +473 -40
- package/index_ms.mjs +16 -0
- package/index_msa.mjs +16 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -139,6 +139,15 @@ export const _sync_account_project_name = async function (account_obj, ref_info)
|
|
|
139
139
|
}
|
|
140
140
|
};
|
|
141
141
|
|
|
142
|
+
// The profile PICTURE is open to everyone, including accounts that have not
|
|
143
|
+
// confirmed their email; the generated AVATAR is what waits for verification.
|
|
144
|
+
// stat 1 is the unverified state (stat 2 is the password-reset temp state,
|
|
145
|
+
// stat 3 verified), matching the write-gate in http_module — which now lets
|
|
146
|
+
// update_account_info and upload_drive_file_user through at stat 1 precisely so
|
|
147
|
+
// the picture can be saved. verify_account starts any deferred generation the
|
|
148
|
+
// moment the account flips to verified, so nothing is lost by waiting here.
|
|
149
|
+
const can_generate_avatar = (account_obj) => account_obj?.stat !== 1;
|
|
150
|
+
|
|
142
151
|
export const update_account_info = async function (req, job_id, headers) {
|
|
143
152
|
const { uid } = req;
|
|
144
153
|
const data = req;
|
|
@@ -278,7 +287,7 @@ export const update_account_info = async function (req, job_id, headers) {
|
|
|
278
287
|
return { code: -1310, data: error };
|
|
279
288
|
}
|
|
280
289
|
if (!change) {
|
|
281
|
-
if (account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
|
|
290
|
+
if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
|
|
282
291
|
set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
|
|
283
292
|
}
|
|
284
293
|
return { code: 1300, data: 'no change' };
|
|
@@ -317,7 +326,7 @@ export const update_account_info = async function (req, job_id, headers) {
|
|
|
317
326
|
await _sync_account_project_name(account_obj, _old_name_info);
|
|
318
327
|
}
|
|
319
328
|
|
|
320
|
-
if (account_obj.account_info?.profile_picture) {
|
|
329
|
+
if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture) {
|
|
321
330
|
if (!account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
|
|
322
331
|
set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
|
|
323
332
|
}
|
|
@@ -556,6 +565,159 @@ export const get_effective_entitlements = function (account_doc) {
|
|
|
556
565
|
return { ai_credits, drive_gb };
|
|
557
566
|
};
|
|
558
567
|
|
|
568
|
+
// ===================================================================
|
|
569
|
+
// Module subscriptions. Membership, support and AI workspace are the three
|
|
570
|
+
// core tiers the billing screen already renders off the account doc; every
|
|
571
|
+
// other suite is sold as its own PLAN_OBJ category billed as a line item on
|
|
572
|
+
// the consolidated subscription. This registry is what the billing screen
|
|
573
|
+
// lists under "Modules", so the customer can see which suites need a
|
|
574
|
+
// subscription and switch one on.
|
|
575
|
+
//
|
|
576
|
+
// scope 'account' → one plan for the whole account, held in `field`, changed
|
|
577
|
+
// with the module's own *_set_plan method.
|
|
578
|
+
// scope 'resource' → priced on the thing itself (a website, a phone number),
|
|
579
|
+
// so there is no account-wide plan to show, only a pointer
|
|
580
|
+
// to where it is set.
|
|
581
|
+
// scope 'metered' → no plan ladder at all, usage is metered per call. Listed
|
|
582
|
+
// so the customer can see it costs no subscription.
|
|
583
|
+
const _MODULE_SUBSCRIPTIONS = [
|
|
584
|
+
{ key: 'commerce', scope: 'account', field: 'commerce_plan', default_plan: 'commerce_free' },
|
|
585
|
+
{ key: 'shipping', scope: 'account', field: 'shipping_plan', default_plan: 'shipping_free' },
|
|
586
|
+
{ key: 'finance', scope: 'account', field: 'finance_plan', default_plan: 'finance_free' },
|
|
587
|
+
// The desk screen has its own copy of this ladder (UI-82), because Tickets is
|
|
588
|
+
// switched on there as well as here. Both call tickets_set_plan, so the plan
|
|
589
|
+
// is one line item on the consolidated subscription either way.
|
|
590
|
+
{ key: 'tickets', scope: 'account', field: 'tickets_plan', default_plan: 'tickets_free' },
|
|
591
|
+
// No free tier and no account field: the entitlement IS the subscription item
|
|
592
|
+
// (or a paid membership, which includes the standard tier at no charge).
|
|
593
|
+
{ key: 'bot_protection', scope: 'account' },
|
|
594
|
+
{ key: 'static_website', scope: 'resource' },
|
|
595
|
+
{ key: 'profile_phone', scope: 'resource' },
|
|
596
|
+
// Xuda Verify (verify_module) has no PLAN_OBJ category: every check is metered
|
|
597
|
+
// per call into verify_meter and nothing is charged to the account today.
|
|
598
|
+
{ key: 'trust_center', scope: 'metered' },
|
|
599
|
+
];
|
|
600
|
+
|
|
601
|
+
// The individual paid things inside a per-resource module, so the billing
|
|
602
|
+
// screen can be the ONE place that shows everything the account pays for.
|
|
603
|
+
// A site or a number on a free tier is not a subscription and is left out.
|
|
604
|
+
const _module_resource_items = async (uid) => {
|
|
605
|
+
const items = { static_website: [], profile_phone: [] };
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const ret = await db_module.find_couch_query(
|
|
609
|
+
'xuda_master',
|
|
610
|
+
{ selector: { docType: 'app', app_type: 'static_website', app_uId: uid }, limit: 1000 },
|
|
611
|
+
true,
|
|
612
|
+
);
|
|
613
|
+
for (const app of ret?.docs || []) {
|
|
614
|
+
const tier = app.static_website?.plan_tier || app.deploy_data?.static_website_hosting_plan || 'free';
|
|
615
|
+
const plan = _conf.PLAN_OBJ?.[`static_website_${tier}`];
|
|
616
|
+
const price = Number(plan?.price) || 0;
|
|
617
|
+
if (!price) continue;
|
|
618
|
+
items.static_website.push({
|
|
619
|
+
id: app._id,
|
|
620
|
+
label: app.app_name || app.name || app.deploy_data?.domain || app._id,
|
|
621
|
+
plan_id: plan.id || `static_website_${tier}`,
|
|
622
|
+
plan_name: plan.name || tier,
|
|
623
|
+
price,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
} catch (e) {
|
|
627
|
+
console.error('[get_module_subscriptions static_website]', e.message);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const ret = await db_module.find_couch_query(
|
|
632
|
+
'xuda_master',
|
|
633
|
+
{ selector: { docType: 'voice_number', owner_uid: uid, status: { $ne: 'released' } }, limit: 500 },
|
|
634
|
+
true,
|
|
635
|
+
);
|
|
636
|
+
const profiles = new Map();
|
|
637
|
+
for (const number of ret?.docs || []) {
|
|
638
|
+
let profile = profiles.get(number.profile_id);
|
|
639
|
+
if (profile === undefined) {
|
|
640
|
+
const pr = await db_module.get_couch_doc('xuda_master', number.profile_id);
|
|
641
|
+
profile = pr.code > -1 ? pr.data : null;
|
|
642
|
+
profiles.set(number.profile_id, profile);
|
|
643
|
+
}
|
|
644
|
+
const tier = profile?.voice_plan?.tier || 'free';
|
|
645
|
+
const plan = _conf.PLAN_OBJ?.[`profile_phone_${tier}`];
|
|
646
|
+
// billing.monthly_total is the number's FULL monthly (rental plus the
|
|
647
|
+
// tier's service fee), so it wins when set. A number on the free tier
|
|
648
|
+
// still costs its rental, which is exactly what this row must show.
|
|
649
|
+
const rental = Number(number.billing?.monthly_total) || 0;
|
|
650
|
+
const price = rental > 0 ? rental : Number(plan?.price) || 0;
|
|
651
|
+
if (!price) continue;
|
|
652
|
+
items.profile_phone.push({
|
|
653
|
+
id: number._id,
|
|
654
|
+
label: number.friendly_name || number.phone_number || number._id,
|
|
655
|
+
plan_id: plan?.id || `profile_phone_${tier}`,
|
|
656
|
+
plan_name: plan?.name || 'Number',
|
|
657
|
+
price,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
} catch (e) {
|
|
661
|
+
console.error('[get_module_subscriptions profile_phone]', e.message);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return items;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Per-module subscription state for the billing screen: what the account is on
|
|
668
|
+
// now, and whether the module is switched on at all.
|
|
669
|
+
export const get_module_subscriptions = async function (req = {}) {
|
|
670
|
+
try {
|
|
671
|
+
const uid = req?.uid;
|
|
672
|
+
if (!uid) return { code: -401, data: 'not authenticated' };
|
|
673
|
+
const ar = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
674
|
+
if (ar.code < 0 || !ar.data) return { code: -404, data: 'account not found' };
|
|
675
|
+
const account = ar.data;
|
|
676
|
+
const plans = _conf.PLAN_OBJ || {};
|
|
677
|
+
const is_member = !!(account.membership_plan && account.membership_plan !== 'free');
|
|
678
|
+
// A category is only offerable once its paid tiers carry a real Stripe
|
|
679
|
+
// price, otherwise "activate" would set a plan nobody is billed for.
|
|
680
|
+
const billable = (key) =>
|
|
681
|
+
Object.values(plans).some((p) => p.category === key && Number(p.price) > 0 && p.price_id && !/TODO/.test(p.price_id));
|
|
682
|
+
|
|
683
|
+
const resource_items = await _module_resource_items(uid);
|
|
684
|
+
|
|
685
|
+
const modules = _MODULE_SUBSCRIPTIONS.map((m) => {
|
|
686
|
+
const base = { key: m.key, scope: m.scope, billable: billable(m.key), items: resource_items[m.key] || [] };
|
|
687
|
+
if (m.key === 'bot_protection') {
|
|
688
|
+
const has_item = !!account.stripe_subscription_items?.bot_protection;
|
|
689
|
+
return { ...base, active: has_item || is_member, included: is_member && !has_item, plan_id: '', plan_name: '', price: 0 };
|
|
690
|
+
}
|
|
691
|
+
if (m.scope === 'resource' || m.scope === 'metered') {
|
|
692
|
+
const price = base.items.reduce((sum, i) => sum + Number(i.price || 0), 0);
|
|
693
|
+
return {
|
|
694
|
+
...base,
|
|
695
|
+
active: m.scope === 'metered' || base.items.length > 0,
|
|
696
|
+
included: m.scope === 'metered',
|
|
697
|
+
plan_id: '',
|
|
698
|
+
plan_name: '',
|
|
699
|
+
price,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const plan_id = account[m.field] || m.default_plan;
|
|
703
|
+
const plan = plans[plan_id] || {};
|
|
704
|
+
const price = Number(plan.price) || 0;
|
|
705
|
+
return {
|
|
706
|
+
...base,
|
|
707
|
+
plan_id,
|
|
708
|
+
plan_name: plan.name || plan_id,
|
|
709
|
+
price,
|
|
710
|
+
active: price > 0,
|
|
711
|
+
included: false,
|
|
712
|
+
changed_ts: account[`${m.field}_changed`] || null,
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
return { code: 1, data: { modules, membership_plan: account.membership_plan || 'free' } };
|
|
716
|
+
} catch (err) {
|
|
717
|
+
return { code: -1, data: err.message };
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
559
721
|
// Which website-builder image modes an account may use. SVG and stock are always
|
|
560
722
|
// available; the two gpt-image modes (gen_low = img-1, gen_high = img-2) require a
|
|
561
723
|
// paid membership AND a non-free workspace credit plan. Single source of truth for
|
|
@@ -1036,6 +1198,21 @@ export const recompute_abuse_signals = async function (req) {
|
|
|
1036
1198
|
}
|
|
1037
1199
|
};
|
|
1038
1200
|
|
|
1201
|
+
// xuda.network personas (ambassadors + mentors) are seed accounts on
|
|
1202
|
+
// @ambassadors.xuda.network / @mentors.xuda.network, not customers, so every
|
|
1203
|
+
// ops list and search hides them. Three independent markers, any one is
|
|
1204
|
+
// enough: the signup flag, the `xuda_network_*` source stamped at creation,
|
|
1205
|
+
// and the seed mail domains. They agree on all of them today, and belt-and-
|
|
1206
|
+
// braces means a persona created through a future surface still stays out.
|
|
1207
|
+
const _NETWORK_PERSONA_DOMAINS = ['ambassadors.xuda.network', 'mentors.xuda.network'];
|
|
1208
|
+
const _is_network_persona = (a = {}) => {
|
|
1209
|
+
const info = a.account_info || {};
|
|
1210
|
+
if (info.is_xuda_network_ambassador === true) return true;
|
|
1211
|
+
if (String(a.source || '').startsWith('xuda_network_')) return true;
|
|
1212
|
+
const email = String(info.email || a.email || '').toLowerCase();
|
|
1213
|
+
return _NETWORK_PERSONA_DOMAINS.some((d) => email.endsWith(`@${d}`));
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1039
1216
|
// Admin-only: list every account currently flagged. The dashboard's
|
|
1040
1217
|
// /admin/abuse page reads from here.
|
|
1041
1218
|
export const get_flagged_accounts = async function (req) {
|
|
@@ -1054,7 +1231,7 @@ export const get_flagged_accounts = async function (req) {
|
|
|
1054
1231
|
limit: 500,
|
|
1055
1232
|
});
|
|
1056
1233
|
|
|
1057
|
-
const rows = (find_ret?.docs || []).map((a) => ({
|
|
1234
|
+
const rows = (find_ret?.docs || []).filter((a) => !_is_network_persona(a)).map((a) => ({
|
|
1058
1235
|
uid: a._id,
|
|
1059
1236
|
email: a.email,
|
|
1060
1237
|
first_name: a.first_name,
|
|
@@ -1214,7 +1391,7 @@ export const backfill_app_costs = async function (opts = {}) {
|
|
|
1214
1391
|
} else if (!BILLABLE_APP_TYPES.includes(doc.app_type) && !doc.is_deployment) {
|
|
1215
1392
|
reason = `app_type ${doc.app_type} not billable`;
|
|
1216
1393
|
} else {
|
|
1217
|
-
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?.
|
|
1394
|
+
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_user_assist;
|
|
1218
1395
|
if (doc.is_deployment && !has_addons && !doc.deploy_data?.app_server_type) {
|
|
1219
1396
|
reason = 'deployment without addons → datacenter bears cost';
|
|
1220
1397
|
} else {
|
|
@@ -1973,7 +2150,10 @@ export const ops_list_terminations = async function (req = {}) {
|
|
|
1973
2150
|
db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_termination_status: 1 }, limit: 9999 }, true),
|
|
1974
2151
|
]);
|
|
1975
2152
|
const by_id = new Map();
|
|
1976
|
-
for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])])
|
|
2153
|
+
for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) {
|
|
2154
|
+
if (_is_network_persona(a)) continue;
|
|
2155
|
+
by_id.set(a._id, a);
|
|
2156
|
+
}
|
|
1977
2157
|
const rows = [...by_id.values()].map((a) => ({
|
|
1978
2158
|
account_uid: a._id,
|
|
1979
2159
|
email: a.account_info?.email,
|
|
@@ -2015,6 +2195,8 @@ const _ops_account_summary = (a) => ({
|
|
|
2015
2195
|
});
|
|
2016
2196
|
|
|
2017
2197
|
// Find accounts by email / name / uid (substring, case-insensitive).
|
|
2198
|
+
// xuda.network personas are dropped AFTER the query, so the fetch limit is
|
|
2199
|
+
// raised to keep a full page of real customers when personas match the term.
|
|
2018
2200
|
export const ops_find_account = async function (req = {}) {
|
|
2019
2201
|
try {
|
|
2020
2202
|
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
@@ -2030,9 +2212,39 @@ export const ops_find_account = async function (req = {}) {
|
|
|
2030
2212
|
{ _id: { $regex: rx } },
|
|
2031
2213
|
],
|
|
2032
2214
|
},
|
|
2033
|
-
limit:
|
|
2215
|
+
limit: 200,
|
|
2034
2216
|
}, true);
|
|
2035
|
-
|
|
2217
|
+
const rows = (ret.docs || []).filter((a) => !_is_network_persona(a)).slice(0, 25).map(_ops_account_summary);
|
|
2218
|
+
return { code: 1, data: { rows } };
|
|
2219
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
// Every customer account, newest first, paged. Backs the "All customers" tab.
|
|
2223
|
+
// Deleted accounts (stat 5) and xuda.network personas are left out, so the
|
|
2224
|
+
// total is the real customer base rather than a raw doc count. A few hundred
|
|
2225
|
+
// docs, so the page is cut in memory (Mango has no stable sort here without a
|
|
2226
|
+
// dedicated index, and skip/limit paging over an unsorted view is not stable).
|
|
2227
|
+
export const ops_list_accounts = async function (req = {}) {
|
|
2228
|
+
try {
|
|
2229
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
2230
|
+
const limit = Math.min(Math.max(parseInt(req.limit, 10) || 50, 1), 200);
|
|
2231
|
+
const skip = Math.max(parseInt(req.skip, 10) || 0, 0);
|
|
2232
|
+
const ret = await db_module.find_couch_query('xuda_accounts', {
|
|
2233
|
+
selector: { docType: 'account' },
|
|
2234
|
+
limit: 100000,
|
|
2235
|
+
}, true);
|
|
2236
|
+
const all = (ret.docs || [])
|
|
2237
|
+
.filter((a) => a.stat !== 5 && !_is_network_persona(a))
|
|
2238
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
2239
|
+
return {
|
|
2240
|
+
code: 1,
|
|
2241
|
+
data: {
|
|
2242
|
+
rows: all.slice(skip, skip + limit).map(_ops_account_summary),
|
|
2243
|
+
total: all.length,
|
|
2244
|
+
skip,
|
|
2245
|
+
limit,
|
|
2246
|
+
},
|
|
2247
|
+
};
|
|
2036
2248
|
} catch (err) { return { code: -1, data: err.message }; }
|
|
2037
2249
|
};
|
|
2038
2250
|
|
|
@@ -2143,7 +2355,9 @@ export const ops_add_ai_credits = async function (req = {}) {
|
|
|
2143
2355
|
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
2144
2356
|
const account = ar.data;
|
|
2145
2357
|
const details = `admin grant ${credits} by ${req.uid} @ ${Date.now()}`;
|
|
2146
|
-
|
|
2358
|
+
// An ops grant is a deliberate gift on top of the plan, so it must survive
|
|
2359
|
+
// the period roll rather than evaporating at the next renewal.
|
|
2360
|
+
const cr = await record_ai_credit(req.uid, credits, 'admin_grant', details, account_uid, { kind: CREDIT_KIND.TOPUP });
|
|
2147
2361
|
if (cr.code < 0) return { code: -1, data: cr.data };
|
|
2148
2362
|
if (req.notify) {
|
|
2149
2363
|
notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], subject: `${credits} AI credits added to your account`, body: `<h2>${credits} AI credits added</h2><p>Our team has added ${credits} AI credits to your account. Enjoy!</p>`, delivery_method: ['banner', 'email'], display_type: 'success' });
|
|
@@ -3182,6 +3396,19 @@ export const verify_account = async function (req) {
|
|
|
3182
3396
|
console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
|
|
3183
3397
|
}
|
|
3184
3398
|
|
|
3399
|
+
// A picture uploaded before verification deliberately did NOT generate an
|
|
3400
|
+
// avatar (see can_generate_avatar). The account is verified as of the save
|
|
3401
|
+
// above, so start the generation that was deferred. Fire-and-forget:
|
|
3402
|
+
// generation is heavy (vision + image ops) and must not hold up the verify
|
|
3403
|
+
// response, and ensure_profile_avatar re-reads the doc and does its own
|
|
3404
|
+
// guarding — picture present, no avatar yet, not already generating — so a
|
|
3405
|
+
// user who never uploaded one is a no-op.
|
|
3406
|
+
try {
|
|
3407
|
+
ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
|
|
3408
|
+
} catch (e) {
|
|
3409
|
+
console.warn('[verify_account] deferred avatar start threw:', e.message);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3185
3412
|
// is_boarded rides along so the verify route can tell the client whether
|
|
3186
3413
|
// onboarding is still pending (it is, for a fresh email signup).
|
|
3187
3414
|
return { code: 1, data: { ...ret_acc.data, is_boarded: !!obj.isBoarded } };
|
|
@@ -5999,14 +6226,9 @@ export const get_account_profile_info = async function (uid, contact_profile_doc
|
|
|
5999
6226
|
doc.profile_picture = account_info_ret.data.profile_picture;
|
|
6000
6227
|
}
|
|
6001
6228
|
|
|
6002
|
-
// The main profile
|
|
6003
|
-
//
|
|
6004
|
-
//
|
|
6005
|
-
// runtime (ai_module.auto_response) also hard-skips the main profile.
|
|
6006
|
-
if (doc.main) {
|
|
6007
|
-
doc.auto_respond = false;
|
|
6008
|
-
}
|
|
6009
|
-
|
|
6229
|
+
// The main profile's stored auto_respond is returned as-is (Boaz, 2026-07-31, reversing
|
|
6230
|
+
// UI-44 part 2). This used to be forced to false so the card would hide the control; with
|
|
6231
|
+
// the control back, forcing it would make the toggle read as off however it was saved.
|
|
6010
6232
|
if (!doc.account_type) {
|
|
6011
6233
|
doc.account_type = account_info_ret.data.account_type;
|
|
6012
6234
|
}
|
|
@@ -6592,24 +6814,38 @@ export const save_cache_hit = async function (_id) {
|
|
|
6592
6814
|
///////////////////////////////
|
|
6593
6815
|
|
|
6594
6816
|
export const get_account_ai_usage = async function (req, job_id, headers) {
|
|
6595
|
-
//
|
|
6596
|
-
//
|
|
6597
|
-
//
|
|
6598
|
-
//
|
|
6599
|
-
//
|
|
6600
|
-
//
|
|
6817
|
+
// Called with NO window at all (the nav meter, the avatar ring, account boot,
|
|
6818
|
+
// the low-credit alert), this reports the CURRENT BILLING PERIOD: usage.total
|
|
6819
|
+
// is the period's spend and credits.total is the period allowance plus the
|
|
6820
|
+
// topup still available. That keeps `credits.total - usage.total` equal to the
|
|
6821
|
+
// real remaining balance, which matters because the dashboard reads those two
|
|
6822
|
+
// numbers directly and computes the difference itself — so the meter stays
|
|
6823
|
+
// correct with no frontend change.
|
|
6824
|
+
//
|
|
6825
|
+
// Callers that pass an explicit range (the WorkspaceUsage chart) still get raw
|
|
6826
|
+
// windowed spend and the full ledger total, unchanged.
|
|
6601
6827
|
const { uid, year_from = 2000, month_from = 1, day_from = 1, year_to = 2999, month_to = 12, day_to = 31 } = req;
|
|
6602
6828
|
try {
|
|
6603
6829
|
const app_id = await get_account_default_project_id(uid);
|
|
6604
6830
|
|
|
6831
|
+
// ts_from / ts_to override the Y/M/D window. Billing periods start at an
|
|
6832
|
+
// arbitrary time of day (Isaac's renews 04:00 on the 2nd), and the Y/M/D
|
|
6833
|
+
// form can only express midnight boundaries, so a day-granular window would
|
|
6834
|
+
// count a few hours of the neighbouring period's spend against this one.
|
|
6835
|
+
const has_ts = Number(req?.ts_from) > 0 || Number(req?.ts_to) > 0;
|
|
6836
|
+
const has_ymd = req?.year_from != null || req?.year_to != null || req?.month_from != null || req?.month_to != null || req?.day_from != null || req?.day_to != null;
|
|
6837
|
+
// No window requested at all -> scope to the account's current credit period.
|
|
6838
|
+
const acct_doc = !has_ts && !has_ymd ? await db_module.get_couch_doc_native('xuda_accounts', uid) : null;
|
|
6839
|
+
const period = acct_doc ? _current_credit_period(acct_doc) : null;
|
|
6840
|
+
|
|
6605
6841
|
// const timestamp_from = new Date(Number(year_from) || year, (Number(month_from) || month) - 1, Number(day_from) || day).getTime();
|
|
6606
6842
|
// const timestamp_to = new Date(Number(year_to) || year, (Number(month_to) || month) - 1, Number(day_to) || day).getTime();
|
|
6607
6843
|
|
|
6608
6844
|
// BEGINNING OF DAY (00:00:00.000)
|
|
6609
|
-
const timestamp_from = new Date(Number(year_from), Number(month_from) - 1, Number(day_from)).setHours(0, 0, 0, 0);
|
|
6845
|
+
const timestamp_from = period ? period.start : has_ts ? Number(req.ts_from) || 0 : new Date(Number(year_from), Number(month_from) - 1, Number(day_from)).setHours(0, 0, 0, 0);
|
|
6610
6846
|
|
|
6611
6847
|
// END OF DAY (23:59:59.999)
|
|
6612
|
-
const timestamp_to = new Date(Number(year_to), Number(month_to) - 1, Number(day_to)).setHours(23, 59, 59, 999);
|
|
6848
|
+
const timestamp_to = period ? Date.now() : has_ts ? Number(req.ts_to) || Date.now() : new Date(Number(year_to), Number(month_to) - 1, Number(day_to)).setHours(23, 59, 59, 999);
|
|
6613
6849
|
|
|
6614
6850
|
const query = {
|
|
6615
6851
|
// include_docs: true,
|
|
@@ -6653,7 +6889,24 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
|
|
|
6653
6889
|
credits_obj[source] += credits;
|
|
6654
6890
|
}
|
|
6655
6891
|
|
|
6656
|
-
|
|
6892
|
+
// Period-scoped read: the ledger total above counts every active grant, but
|
|
6893
|
+
// topup already spent in EARLIER periods is invisible to this period's usage
|
|
6894
|
+
// figure, so it has to be deducted here. Without that, rolling the period
|
|
6895
|
+
// would hand the customer back topup credits they had already spent.
|
|
6896
|
+
let period_meta = null;
|
|
6897
|
+
if (period) {
|
|
6898
|
+
let period_grant = 0;
|
|
6899
|
+
let topup_total = 0;
|
|
6900
|
+
for (const [src, amount] of Object.entries(credits_obj)) {
|
|
6901
|
+
if (_credit_kind({ source: src }) === CREDIT_KIND.TOPUP) topup_total += amount;
|
|
6902
|
+
else period_grant += amount;
|
|
6903
|
+
}
|
|
6904
|
+
const topup_available = Math.max(0, topup_total - Math.max(0, Number(acct_doc?.credits_topup_consumed) || 0));
|
|
6905
|
+
total_credits = period_grant + topup_available;
|
|
6906
|
+
period_meta = { start_ts: period.start, end_ts: period.end, source: period.source, period_grant, topup_total, topup_available };
|
|
6907
|
+
}
|
|
6908
|
+
|
|
6909
|
+
let data = { credits: { ...credits_obj, total: total_credits }, usage: { profile, projects: [], total: total_usage }, packs: _conf.ai_credit_packs || [], period: period_meta };
|
|
6657
6910
|
|
|
6658
6911
|
if (job_id) {
|
|
6659
6912
|
data.profile_info = {};
|
|
@@ -6755,9 +7008,143 @@ const _usage_total = function (usage_data) {
|
|
|
6755
7008
|
return Object.values(by_profile).reduce((a, b) => a + (Number(b) || 0), 0);
|
|
6756
7009
|
};
|
|
6757
7010
|
|
|
6758
|
-
// Wide window so "used" reads as all-time spend
|
|
7011
|
+
// Wide window so "used" reads as all-time spend. Kept for the few places that
|
|
7012
|
+
// genuinely want all-time (ops snapshots, reporting). It is NOT the basis of the
|
|
7013
|
+
// balance any more: see get_credit_balance below for why that was broken.
|
|
6759
7014
|
const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
|
|
6760
7015
|
|
|
7016
|
+
/////////////////////////////////////////////////////////////////////////
|
|
7017
|
+
// CREDIT BALANCE
|
|
7018
|
+
//
|
|
7019
|
+
// The balance used to be computed two different ways, and they disagreed:
|
|
7020
|
+
// evaluate_credit_gate did (ledger grants - LIFETIME usage) while the Credit
|
|
7021
|
+
// Management page did (plan entitlement - LIFETIME usage). Both were unstable,
|
|
7022
|
+
// because grants expire after 30 days while usage was counted forever, so the
|
|
7023
|
+
// balance could only ever drift negative. A paying Pro customer was hard-blocked
|
|
7024
|
+
// from AI while his own dashboard told him he had 539 credits left.
|
|
7025
|
+
//
|
|
7026
|
+
// The model now matches how the plans are actually sold:
|
|
7027
|
+
//
|
|
7028
|
+
// period pool — the plan's allowance for the CURRENT billing period. Granted
|
|
7029
|
+
// when the invoice is paid, and it does not roll over. The next
|
|
7030
|
+
// period starts from the plan's number again.
|
|
7031
|
+
// topup pool — separately acquired credits (bought packs, ops grants,
|
|
7032
|
+
// bonuses). Never reset; they persist until spent.
|
|
7033
|
+
//
|
|
7034
|
+
// Spend comes out of the period pool first and only overflows into topup once
|
|
7035
|
+
// the period allowance is exhausted. Overflow consumed in PAST periods is
|
|
7036
|
+
// remembered on the account as credits_topup_consumed, because usage is only
|
|
7037
|
+
// ever queried for the current period — without that counter, rolling the
|
|
7038
|
+
// period would silently hand back topup credits the customer already spent.
|
|
7039
|
+
/////////////////////////////////////////////////////////////////////////
|
|
7040
|
+
|
|
7041
|
+
// The window the current period pool is measured over. A paid account gets its
|
|
7042
|
+
// real Stripe period (stamped by the invoice.paid webhook). Anything else falls
|
|
7043
|
+
// back to the calendar month, so a free account's daily drip still resets on a
|
|
7044
|
+
// predictable boundary instead of being compared against all-time spend. A
|
|
7045
|
+
// stored period that has already elapsed is treated as absent, so a missed or
|
|
7046
|
+
// late renewal webhook degrades to the fallback instead of freezing the window.
|
|
7047
|
+
const _current_credit_period = function (account_doc) {
|
|
7048
|
+
const now = Date.now();
|
|
7049
|
+
const start = Number(account_doc?.credits_period_start_ts) || 0;
|
|
7050
|
+
const end = Number(account_doc?.credits_period_end_ts) || 0;
|
|
7051
|
+
if (start && end && now >= start && now < end) return { start, end, source: 'invoice' };
|
|
7052
|
+
const d = new Date(now);
|
|
7053
|
+
return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1), end: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), source: 'calendar' };
|
|
7054
|
+
};
|
|
7055
|
+
|
|
7056
|
+
// Single source of truth for "how many credits does this account have left".
|
|
7057
|
+
// Everything that gates, meters or displays credits must go through here.
|
|
7058
|
+
export const get_credit_balance = async function (uid, account_doc) {
|
|
7059
|
+
const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
|
|
7060
|
+
const period = _current_credit_period(doc);
|
|
7061
|
+
|
|
7062
|
+
// Active (stat 3) grants, summed by source, then split into the two pools.
|
|
7063
|
+
// Expired period grants drop out of this view on their own, which is what
|
|
7064
|
+
// makes the period pool reset. Legacy docs carry no credit_kind, so the
|
|
7065
|
+
// classification falls back to the source name (see _credit_kind).
|
|
7066
|
+
const view = await db_module.get_couch_view('xuda_billing', 'ai_credits', { startkey: [uid, ''], endkey: [uid, 'zzz'], reduce: true, group_level: 999 });
|
|
7067
|
+
const by_source = {};
|
|
7068
|
+
let period_grant = 0;
|
|
7069
|
+
let topup_total = 0;
|
|
7070
|
+
for (const row of view?.data?.rows || []) {
|
|
7071
|
+
const source = row.key[1];
|
|
7072
|
+
const credits = Number(row.value) || 0;
|
|
7073
|
+
by_source[source] = (by_source[source] || 0) + credits;
|
|
7074
|
+
if (_credit_kind({ source }) === CREDIT_KIND.TOPUP) topup_total += credits;
|
|
7075
|
+
else period_grant += credits;
|
|
7076
|
+
}
|
|
7077
|
+
|
|
7078
|
+
const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
|
|
7079
|
+
const used = _usage_total(usage_ret?.data?.usage);
|
|
7080
|
+
|
|
7081
|
+
const topup_consumed = Math.max(0, Number(doc?.credits_topup_consumed) || 0);
|
|
7082
|
+
const topup_available = Math.max(0, topup_total - topup_consumed);
|
|
7083
|
+
|
|
7084
|
+
// An "unlimited" tier (Enterprise T3) resolves to Infinity and must never gate.
|
|
7085
|
+
const unlimited = !Number.isFinite(get_effective_entitlements(doc).ai_credits);
|
|
7086
|
+
const granted = period_grant + topup_available;
|
|
7087
|
+
|
|
7088
|
+
return {
|
|
7089
|
+
period_start_ts: period.start,
|
|
7090
|
+
period_end_ts: period.end,
|
|
7091
|
+
period_source: period.source,
|
|
7092
|
+
period_grant,
|
|
7093
|
+
topup_total,
|
|
7094
|
+
topup_consumed,
|
|
7095
|
+
topup_available,
|
|
7096
|
+
granted,
|
|
7097
|
+
used,
|
|
7098
|
+
remaining: unlimited ? Infinity : Math.round((granted - used) * 100) / 100,
|
|
7099
|
+
unlimited,
|
|
7100
|
+
by_source,
|
|
7101
|
+
usage_profile: usage_ret?.data?.usage?.profile,
|
|
7102
|
+
};
|
|
7103
|
+
};
|
|
7104
|
+
|
|
7105
|
+
// Close out the period that just ended and open the next one. Called from the
|
|
7106
|
+
// invoice.paid webhook right before the new grant lands.
|
|
7107
|
+
//
|
|
7108
|
+
// The overflow bookkeeping is the important half: any spend beyond the old
|
|
7109
|
+
// period's allowance came out of topup, and because the next balance read only
|
|
7110
|
+
// looks at the NEW period's usage, that spend would otherwise be forgotten and
|
|
7111
|
+
// the customer would get those topup credits back every month.
|
|
7112
|
+
export const roll_credit_period = async function (uid, next_start_ts, next_end_ts) {
|
|
7113
|
+
try {
|
|
7114
|
+
const doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
|
|
7115
|
+
if (!doc) return { code: -1, data: 'account not found' };
|
|
7116
|
+
|
|
7117
|
+
const prev_start = Number(doc.credits_period_start_ts) || 0;
|
|
7118
|
+
const prev_end = Number(doc.credits_period_end_ts) || 0;
|
|
7119
|
+
if (prev_start && prev_end && Number(next_start_ts) > prev_start) {
|
|
7120
|
+
const prev = await get_credit_balance(uid, doc);
|
|
7121
|
+
const prev_overflow = Math.max(0, prev.used - prev.period_grant);
|
|
7122
|
+
if (prev_overflow > 0) doc.credits_topup_consumed = (Number(doc.credits_topup_consumed) || 0) + prev_overflow;
|
|
7123
|
+
|
|
7124
|
+
// Retire the old period's grants so they cannot be spent in the new one.
|
|
7125
|
+
// Scoped to period grants whose window has closed; topup is never touched.
|
|
7126
|
+
const stale = await db_module.find_couch_query('xuda_billing', { selector: { docType: 'ai_credit', credited_uid: uid, stat: 3 }, limit: 9999 });
|
|
7127
|
+
for (const c of stale?.docs || []) {
|
|
7128
|
+
if (_credit_kind(c) !== CREDIT_KIND.PERIOD) continue;
|
|
7129
|
+
const ended = Number(c.period_end_ts) || Number(c.date_created_ts) || 0;
|
|
7130
|
+
if (ended && ended > Number(next_start_ts)) continue; // belongs to the period we are opening
|
|
7131
|
+
c.stat = 5;
|
|
7132
|
+
c.stat_ts = Date.now();
|
|
7133
|
+
c.stat_reason = 'period closed';
|
|
7134
|
+
await db_module.save_couch_doc('xuda_billing', c);
|
|
7135
|
+
}
|
|
7136
|
+
}
|
|
7137
|
+
|
|
7138
|
+
doc.credits_period_start_ts = Number(next_start_ts) || Date.now();
|
|
7139
|
+
doc.credits_period_end_ts = Number(next_end_ts) || 0;
|
|
7140
|
+
const save = await db_module.save_couch_doc('xuda_accounts', doc);
|
|
7141
|
+
return { code: save.code > 0 ? 1 : -1, data: { period_start_ts: doc.credits_period_start_ts, period_end_ts: doc.credits_period_end_ts, topup_consumed: doc.credits_topup_consumed || 0 } };
|
|
7142
|
+
} catch (err) {
|
|
7143
|
+
console.error('[roll_credit_period]', err?.message || err);
|
|
7144
|
+
return { code: -1, data: err?.message };
|
|
7145
|
+
}
|
|
7146
|
+
};
|
|
7147
|
+
|
|
6761
7148
|
// Per-model / per-source spend is not emitted by the usage view, so re-derive it
|
|
6762
7149
|
// from the raw ai_usage docs and CALIBRATE to the view's authoritative total, so the
|
|
6763
7150
|
// numbers stay in the same credit unit (factor = view_total / raw_token_cost_total).
|
|
@@ -6769,7 +7156,9 @@ const _get_scoped_usage = async function (owner_uid, need_raw) {
|
|
|
6769
7156
|
const cached = _scoped_usage_cache[owner_uid];
|
|
6770
7157
|
if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
|
|
6771
7158
|
|
|
6772
|
-
|
|
7159
|
+
// No window: per-profile and per-member caps are an allocation of the CURRENT
|
|
7160
|
+
// period's pool, so they have to be measured over the same period the pool is.
|
|
7161
|
+
const usage_ret = await get_account_ai_usage({ uid: owner_uid });
|
|
6773
7162
|
const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
|
|
6774
7163
|
const total = _usage_total(usage_ret?.data?.usage);
|
|
6775
7164
|
|
|
@@ -6900,10 +7289,15 @@ export const evaluate_credit_gate = async function (req) {
|
|
|
6900
7289
|
const account_doc = await db_module.get_couch_doc_native('xuda_accounts', owner);
|
|
6901
7290
|
const rules = account_doc?.credit_rules;
|
|
6902
7291
|
|
|
6903
|
-
//
|
|
7292
|
+
// No rules or disabled => the single account-wide pool cap, which is the
|
|
7293
|
+
// default for almost every account. This is what hard-blocks AI, so it MUST
|
|
7294
|
+
// read the same balance the dashboard shows the customer. It used to compute
|
|
7295
|
+
// its own (ledger grants - lifetime usage) while the Credit Management page
|
|
7296
|
+
// computed (plan entitlement - lifetime usage); the two drifted apart and a
|
|
7297
|
+
// paying Pro customer was blocked while his page reported 539 credits left.
|
|
6904
7298
|
if (!rules || rules.enabled === false) {
|
|
6905
|
-
const
|
|
6906
|
-
const over =
|
|
7299
|
+
const bal = await get_credit_balance(owner, account_doc);
|
|
7300
|
+
const over = bal.remaining < -0.5;
|
|
6907
7301
|
return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
|
|
6908
7302
|
}
|
|
6909
7303
|
|
|
@@ -7014,15 +7408,17 @@ export const get_credit_management = async function (req) {
|
|
|
7014
7408
|
const plan = account_doc?.membership_plan || 'free';
|
|
7015
7409
|
const can_edit = _is_team_tier(plan);
|
|
7016
7410
|
const rules = account_doc?.credit_rules || _default_credit_rules();
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
const
|
|
7411
|
+
// Report exactly what the gate enforces. These were two different formulas
|
|
7412
|
+
// and the page cheerfully showed credits to a customer the gate was blocking.
|
|
7413
|
+
const bal = await get_credit_balance(uid, account_doc);
|
|
7414
|
+
const unlimited = bal.unlimited;
|
|
7415
|
+
const entitlements = { ai_credits: unlimited ? null : bal.granted, unlimited };
|
|
7020
7416
|
|
|
7021
7417
|
const su = await _get_scoped_usage(uid, true);
|
|
7022
|
-
const remaining = unlimited ? null :
|
|
7418
|
+
const remaining = unlimited ? null : bal.remaining;
|
|
7023
7419
|
const usage_breakdown = {
|
|
7024
|
-
window: { mode:
|
|
7025
|
-
pool: { used: Math.round(
|
|
7420
|
+
window: { mode: 'period', period_start_ts: bal.period_start_ts, period_end_ts: bal.period_end_ts, period_grant: bal.period_grant, topup_available: bal.topup_available },
|
|
7421
|
+
pool: { used: Math.round(bal.used * 100) / 100, remaining },
|
|
7026
7422
|
by_profile: su.by_profile,
|
|
7027
7423
|
by_user: su.by_user,
|
|
7028
7424
|
by_model: su.by_model,
|
|
@@ -7312,7 +7708,26 @@ export const record_ai_usage = async function (uid, input_tokens, output_tokens,
|
|
|
7312
7708
|
}
|
|
7313
7709
|
};
|
|
7314
7710
|
|
|
7315
|
-
|
|
7711
|
+
// A credit belongs to exactly one of two pools, and they behave differently:
|
|
7712
|
+
//
|
|
7713
|
+
// 'period' — the allowance that comes WITH a plan (the monthly membership
|
|
7714
|
+
// grant, and the free tier's daily drip). It is spent inside one
|
|
7715
|
+
// billing period and does NOT roll over: the next period starts
|
|
7716
|
+
// from the plan's number again.
|
|
7717
|
+
// 'topup' — credits the customer separately acquired (bought packs, ops
|
|
7718
|
+
// grants, the boarding bonus, referral bonuses). These never
|
|
7719
|
+
// reset; they sit there until they are used.
|
|
7720
|
+
//
|
|
7721
|
+
// Legacy docs predate the field, so _credit_kind() infers it from `source`
|
|
7722
|
+
// rather than requiring a backfill.
|
|
7723
|
+
const CREDIT_KIND = { PERIOD: 'period', TOPUP: 'topup' };
|
|
7724
|
+
const _TOPUP_SOURCES = new Set(['purchase', 'admin_grant', 'boarding', 'contact connection']);
|
|
7725
|
+
const _credit_kind = function (doc) {
|
|
7726
|
+
if (doc?.credit_kind === CREDIT_KIND.TOPUP || doc?.credit_kind === CREDIT_KIND.PERIOD) return doc.credit_kind;
|
|
7727
|
+
return _TOPUP_SOURCES.has(doc?.source) ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD;
|
|
7728
|
+
};
|
|
7729
|
+
|
|
7730
|
+
export const record_ai_credit = async function (uid, credits = 0, source, details, credited_uid, opts = {}) {
|
|
7316
7731
|
try {
|
|
7317
7732
|
var dup = await db_module.find_couch_query('xuda_billing', {
|
|
7318
7733
|
selector: {
|
|
@@ -7339,7 +7754,11 @@ export const record_ai_credit = async function (uid, credits = 0, source, detail
|
|
|
7339
7754
|
stat: 3,
|
|
7340
7755
|
credited_uid,
|
|
7341
7756
|
details,
|
|
7757
|
+
credit_kind: opts.kind === CREDIT_KIND.TOPUP ? CREDIT_KIND.TOPUP : CREDIT_KIND.PERIOD,
|
|
7342
7758
|
};
|
|
7759
|
+
if (opts.period_start_ts) credit_doc.period_start_ts = opts.period_start_ts;
|
|
7760
|
+
if (opts.period_end_ts) credit_doc.period_end_ts = opts.period_end_ts;
|
|
7761
|
+
if (opts.stripe_invoice_id) credit_doc.stripe_invoice_id = opts.stripe_invoice_id;
|
|
7343
7762
|
const save_ret = await db_module.save_couch_doc('xuda_billing', credit_doc);
|
|
7344
7763
|
// console.log(save_ret);
|
|
7345
7764
|
broadcast_credits(credited_uid); // live meter: this account just gained credits
|
|
@@ -7364,7 +7783,9 @@ export const add_ai_credits_to_active_accounts = async function () {
|
|
|
7364
7783
|
const _24_hr_ms = 1000 * 60 * 60 * 24;
|
|
7365
7784
|
const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account', last_free_daily_ai_credit_ts: { $lt: Date.now() - _24_hr_ms }, ai_workspace_plan: 'free_ai_workspace' }, limit: 99999 });
|
|
7366
7785
|
for await (let account_doc of active_accounts.docs) {
|
|
7367
|
-
|
|
7786
|
+
// The free tier's drip IS its plan allowance, so it is a period credit: it
|
|
7787
|
+
// resets with the period instead of accumulating forever.
|
|
7788
|
+
const ret = await record_ai_credit('system', 1, 'daily credit', 'free daily credit ' + date_str, account_doc._id, { kind: CREDIT_KIND.PERIOD });
|
|
7368
7789
|
if (ret.code > -1) {
|
|
7369
7790
|
account_doc.last_free_daily_ai_credit_ts = Date.now();
|
|
7370
7791
|
account_doc.last_free_daily_ai_credit_id = ret.data.id;
|
|
@@ -7376,11 +7797,23 @@ export const add_ai_credits_to_active_accounts = async function () {
|
|
|
7376
7797
|
export const archive_expire_ai_credits = async function () {
|
|
7377
7798
|
const _24_hr_ms = 1000 * 60 * 60 * 24;
|
|
7378
7799
|
const _mo_ms = _24_hr_ms * 30;
|
|
7800
|
+
const now = Date.now();
|
|
7379
7801
|
|
|
7380
|
-
const active_ai_credits = await db_module.find_couch_query('xuda_billing', { selector: { stat: 3, docType: 'ai_credit', date_created_ts: { $lt:
|
|
7802
|
+
const active_ai_credits = await db_module.find_couch_query('xuda_billing', { selector: { stat: 3, docType: 'ai_credit', date_created_ts: { $lt: now - _mo_ms } }, limit: 99999 });
|
|
7381
7803
|
for await (let ai_credit_doc of active_ai_credits.docs) {
|
|
7804
|
+
// TOPUP credits are the customer's property until they spend them: bought
|
|
7805
|
+
// packs, ops grants, the welcome bonus. This cron used to expire those too,
|
|
7806
|
+
// silently deleting credits people had paid real money for.
|
|
7807
|
+
if (_credit_kind(ai_credit_doc) === CREDIT_KIND.TOPUP) continue;
|
|
7808
|
+
|
|
7809
|
+
// A period grant expires when its PERIOD ends, not 30 days after it was
|
|
7810
|
+
// written. Those differ whenever a month is 31 days long, and the old rule
|
|
7811
|
+
// would have retired the current allowance a day early.
|
|
7812
|
+
const period_end = Number(ai_credit_doc.period_end_ts) || 0;
|
|
7813
|
+
if (period_end && period_end > now) continue;
|
|
7814
|
+
|
|
7382
7815
|
ai_credit_doc.stat = 5;
|
|
7383
|
-
ai_credit_doc.stat_ts =
|
|
7816
|
+
ai_credit_doc.stat_ts = now;
|
|
7384
7817
|
ai_credit_doc.stat_reason = 'expired';
|
|
7385
7818
|
const save_ret = await db_module.save_couch_doc('xuda_billing', ai_credit_doc);
|
|
7386
7819
|
}
|
package/index_ms.mjs
CHANGED
|
@@ -57,6 +57,10 @@ export const get_effective_entitlements = async function (...args) {
|
|
|
57
57
|
return await broker.send_to_queue("get_effective_entitlements", ...args);
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
export const get_module_subscriptions = async function (...args) {
|
|
61
|
+
return await broker.send_to_queue("get_module_subscriptions", ...args);
|
|
62
|
+
};
|
|
63
|
+
|
|
60
64
|
export const _site_build_image_modes = async function (...args) {
|
|
61
65
|
return await broker.send_to_queue("_site_build_image_modes", ...args);
|
|
62
66
|
};
|
|
@@ -181,6 +185,10 @@ export const ops_find_account = async function (...args) {
|
|
|
181
185
|
return await broker.send_to_queue("ops_find_account", ...args);
|
|
182
186
|
};
|
|
183
187
|
|
|
188
|
+
export const ops_list_accounts = async function (...args) {
|
|
189
|
+
return await broker.send_to_queue("ops_list_accounts", ...args);
|
|
190
|
+
};
|
|
191
|
+
|
|
184
192
|
export const ops_account_snapshot = async function (...args) {
|
|
185
193
|
return await broker.send_to_queue("ops_account_snapshot", ...args);
|
|
186
194
|
};
|
|
@@ -509,6 +517,14 @@ export const get_account_ai_usage = async function (...args) {
|
|
|
509
517
|
return await broker.send_to_queue("get_account_ai_usage", ...args);
|
|
510
518
|
};
|
|
511
519
|
|
|
520
|
+
export const get_credit_balance = async function (...args) {
|
|
521
|
+
return await broker.send_to_queue("get_credit_balance", ...args);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
export const roll_credit_period = async function (...args) {
|
|
525
|
+
return await broker.send_to_queue("roll_credit_period", ...args);
|
|
526
|
+
};
|
|
527
|
+
|
|
512
528
|
export const emit_credit_soft_limit = async function (...args) {
|
|
513
529
|
return await broker.send_to_queue("emit_credit_soft_limit", ...args);
|
|
514
530
|
};
|
package/index_msa.mjs
CHANGED
|
@@ -57,6 +57,10 @@ export const get_effective_entitlements = function (...args) {
|
|
|
57
57
|
broker.send_to_queue_async("get_effective_entitlements", ...args);
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
export const get_module_subscriptions = function (...args) {
|
|
61
|
+
broker.send_to_queue_async("get_module_subscriptions", ...args);
|
|
62
|
+
};
|
|
63
|
+
|
|
60
64
|
export const _site_build_image_modes = function (...args) {
|
|
61
65
|
broker.send_to_queue_async("_site_build_image_modes", ...args);
|
|
62
66
|
};
|
|
@@ -181,6 +185,10 @@ export const ops_find_account = function (...args) {
|
|
|
181
185
|
broker.send_to_queue_async("ops_find_account", ...args);
|
|
182
186
|
};
|
|
183
187
|
|
|
188
|
+
export const ops_list_accounts = function (...args) {
|
|
189
|
+
broker.send_to_queue_async("ops_list_accounts", ...args);
|
|
190
|
+
};
|
|
191
|
+
|
|
184
192
|
export const ops_account_snapshot = function (...args) {
|
|
185
193
|
broker.send_to_queue_async("ops_account_snapshot", ...args);
|
|
186
194
|
};
|
|
@@ -509,6 +517,14 @@ export const get_account_ai_usage = function (...args) {
|
|
|
509
517
|
broker.send_to_queue_async("get_account_ai_usage", ...args);
|
|
510
518
|
};
|
|
511
519
|
|
|
520
|
+
export const get_credit_balance = function (...args) {
|
|
521
|
+
broker.send_to_queue_async("get_credit_balance", ...args);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
export const roll_credit_period = function (...args) {
|
|
525
|
+
broker.send_to_queue_async("roll_credit_period", ...args);
|
|
526
|
+
};
|
|
527
|
+
|
|
512
528
|
export const emit_credit_soft_limit = function (...args) {
|
|
513
529
|
broker.send_to_queue_async("emit_credit_soft_limit", ...args);
|
|
514
530
|
};
|