@xuda.io/drive_module 1.1.1499 → 1.1.1500

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -29,6 +29,10 @@ const { get_account_default_project_id } = await import(path.join(process.env.XU
29
29
  const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
30
30
  const fs_module = await import(`${module_path}/fs_module/index.mjs`);
31
31
  const db_module = await import(`${module_path}/db_module/index.mjs`);
32
+ // UI-113: the Drive storage ladder is one line item on the consolidated
33
+ // subscription, so drive_set_plan drives Stripe the same way every other module
34
+ // tier does (add_subscription_item to buy or reprice, remove on the free tier).
35
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
32
36
 
33
37
  // const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
34
38
 
@@ -4470,3 +4474,172 @@ export const file_upload_validator = function (file_name) {
4470
4474
  return { valid: false, error: `Unsupported file extension: .${ext}` };
4471
4475
  }
4472
4476
  };
4477
+
4478
+ ///////////////////////////////////////////////////////////////////////////////
4479
+ // DRIVE PLANS (UI-113)
4480
+ //
4481
+ // Drive is sold on its own drive_* ladder and what the ladder sells is SPACE.
4482
+ // A tier's `flags.extra_gb` is added on top of the storage the account already
4483
+ // has from its membership plan and its AI workspace plan, it does not replace
4484
+ // either, so an account that never picks a tier is on drive_free and nothing
4485
+ // about its Drive changes.
4486
+ //
4487
+ // The money is one line item on the consolidated subscription, the same shape
4488
+ // every other module tier uses: a paid tier creates or reprices the line, free
4489
+ // removes it. Billing moves FIRST and the plan is only written once Stripe
4490
+ // agreed, so a failed charge can never leave an account holding space it is
4491
+ // not paying for.
4492
+ ///////////////////////////////////////////////////////////////////////////////
4493
+
4494
+ const DRIVE_CATEGORY = 'drive';
4495
+ const GB = 1073741824;
4496
+
4497
+ const _drive_tiers = () =>
4498
+ Object.values(_conf.PLAN_OBJ || {})
4499
+ .filter((p) => p.category === DRIVE_CATEGORY)
4500
+ .sort((a, b) => (Number(a.rank) || 0) - (Number(b.rank) || 0));
4501
+
4502
+ const _drive_plan = (plan_id) => {
4503
+ const plan = _conf.PLAN_OBJ?.[plan_id];
4504
+ return plan && plan.category === DRIVE_CATEGORY ? plan : null;
4505
+ };
4506
+
4507
+ const _drive_account_doc = async (uid) => {
4508
+ const ret = await db_module.get_couch_doc('xuda_accounts', uid);
4509
+ return ret.code < 0 || !ret.data ? {} : ret.data;
4510
+ };
4511
+
4512
+ // An account that never touched the ladder is on free. Same default the billing
4513
+ // Modules card applies, kept identical on purpose so the two never disagree.
4514
+ const _drive_account_plan = (account) => (_drive_plan(account?.drive_plan) ? account.drive_plan : 'drive_free');
4515
+
4516
+ // What the account is storing, using the SAME sum the overage metering charges
4517
+ // on (account_module increment_account_usage). project_data_size is deliberately
4518
+ // out of it there, so it is out of it here: a guard that counted bytes the
4519
+ // metering ignores would refuse a downgrade the customer is not billed for.
4520
+ const _drive_used_bytes = (account) =>
4521
+ (account?.user_drive_size || 0) +
4522
+ (account?.studio_drive_size || 0) +
4523
+ (account?.workspace_drive_size || 0) +
4524
+ (account?.builds_drive_size || 0) +
4525
+ (account?.plugins_drive_size || 0);
4526
+
4527
+ // The quota the account WOULD have on a given tier, in bytes. Delegates to
4528
+ // account_module's get_effective_entitlements rather than re-deriving the stack,
4529
+ // because that function is what the metering and every storage bar already use.
4530
+ // Imported lazily: this is the only place in the drive module that needs it and
4531
+ // account_module pulls in a large tree of its own.
4532
+ const _drive_quota_bytes = async (account, plan_id) => {
4533
+ const { get_effective_entitlements } = await import(`${module_path}/account_module/index.mjs`);
4534
+ const ent = get_effective_entitlements({ ...account, drive_plan: plan_id ?? account?.drive_plan });
4535
+ return {
4536
+ unlimited: !Number.isFinite(ent.drive_gb),
4537
+ bytes: Number.isFinite(ent.drive_gb) ? Math.round(ent.drive_gb * GB) : 0,
4538
+ breakdown: ent.drive_breakdown || {},
4539
+ };
4540
+ };
4541
+
4542
+ const _drive_pretty = (bytes) => {
4543
+ if (bytes >= 1099511627776) return `${Math.round((bytes / 1099511627776) * 10) / 10} TB`;
4544
+ if (bytes >= GB) return `${Math.round((bytes / GB) * 10) / 10} GB`;
4545
+ return `${Math.round(bytes / 1048576)} MB`;
4546
+ };
4547
+
4548
+ export const get_drive_plans = async function (req) {
4549
+ try {
4550
+ const uid = req?.uid;
4551
+ if (!uid) return { code: -401, data: 'not authenticated' };
4552
+ const account = await _drive_account_doc(uid);
4553
+ if (!account._id) return { code: -404, data: 'account not found' };
4554
+
4555
+ const plan_id = _drive_account_plan(account);
4556
+ const used = _drive_used_bytes(account);
4557
+ const quota = await _drive_quota_bytes(account, plan_id);
4558
+
4559
+ // Every tier carries the quota it WOULD give and whether the account already
4560
+ // stores more than that, so the screen can mark a tier as out of reach
4561
+ // without doing the entitlement arithmetic itself.
4562
+ const plans = [];
4563
+ for (const p of _drive_tiers()) {
4564
+ const q = await _drive_quota_bytes(account, p.id);
4565
+ plans.push({
4566
+ ...p,
4567
+ quota_bytes: q.bytes,
4568
+ quota_unlimited: q.unlimited,
4569
+ fits: q.unlimited || used <= q.bytes,
4570
+ });
4571
+ }
4572
+
4573
+ return {
4574
+ code: 1,
4575
+ data: {
4576
+ plan_id,
4577
+ plan_name: _drive_plan(plan_id)?.name || '',
4578
+ changed_ts: account.drive_plan_changed || null,
4579
+ // The line on the invoice. Free holds no item at all.
4580
+ subscribed: !!account?.stripe_subscription_items?.[DRIVE_CATEGORY],
4581
+ used_bytes: used,
4582
+ quota_bytes: quota.bytes,
4583
+ quota_unlimited: quota.unlimited,
4584
+ // Where the space comes from, so a full bar can point at the plan that
4585
+ // would actually fix it rather than at this one by default.
4586
+ quota_from: {
4587
+ membership_bytes: Math.round((quota.breakdown.membership_gb === Infinity ? 0 : quota.breakdown.membership_gb || 0) * GB),
4588
+ workspace_bytes: Math.round((quota.breakdown.workspace_gb || 0) * GB),
4589
+ drive_plan_bytes: Math.round((quota.breakdown.drive_plan_gb || 0) * GB),
4590
+ },
4591
+ membership_plan: account.membership_plan || 'free',
4592
+ ai_workspace_plan: account.ai_workspace_plan || '',
4593
+ plans,
4594
+ },
4595
+ };
4596
+ } catch (err) {
4597
+ return { code: -1, data: err.message || String(err) };
4598
+ }
4599
+ };
4600
+
4601
+ export const drive_set_plan = async function (req) {
4602
+ try {
4603
+ const uid = req?.uid;
4604
+ if (!uid) return { code: -401, data: 'not authenticated' };
4605
+ const plan_id = req?.plan_id || '';
4606
+ const plan = _drive_plan(plan_id);
4607
+ if (!plan) return { code: -1, data: 'unknown drive plan' };
4608
+
4609
+ const account = await _drive_account_doc(uid);
4610
+ if (!account._id) return { code: -404, data: 'account not found' };
4611
+ const current = _drive_account_plan(account);
4612
+ if (current === plan_id) return await get_drive_plans({ uid });
4613
+
4614
+ // Downgrade guard. Taking space away from an account that is already using it
4615
+ // would drop it straight into metered overage on files it cannot see a way to
4616
+ // remove, so name the gap instead and let the customer clear it first.
4617
+ const target = await _drive_quota_bytes(account, plan_id);
4618
+ const used = _drive_used_bytes(account);
4619
+ if (!target.unlimited && used > target.bytes) {
4620
+ return {
4621
+ code: -1,
4622
+ data: `You are storing ${_drive_pretty(used)} and the ${plan.name} plan would leave you with ${_drive_pretty(target.bytes)}. Free up ${_drive_pretty(used - target.bytes)} in Drive first.`,
4623
+ };
4624
+ }
4625
+
4626
+ const paid = Number(plan.price) > 0;
4627
+ if (paid) {
4628
+ const ret = await stripe_ms.add_subscription_item({ uid, plan_id });
4629
+ if (!ret || ret.code < 0) return { code: ret?.code === -402 || ret?.code === -3 ? -402 : -1, data: ret?.data || 'could not change the plan' };
4630
+ } else if (account.stripe_subscription_items?.[DRIVE_CATEGORY]) {
4631
+ const ret = await stripe_ms.remove_subscription_item({ uid, category: DRIVE_CATEGORY });
4632
+ if (!ret || ret.code < 0) return { code: -1, data: ret?.data || 'could not remove the subscription line' };
4633
+ }
4634
+
4635
+ // Re-read: the stripe call just wrote stripe_subscription_items on this doc.
4636
+ const fresh = await _drive_account_doc(uid);
4637
+ fresh.drive_plan = plan_id;
4638
+ fresh.drive_plan_changed = Date.now();
4639
+ await db_module.save_couch_doc('xuda_accounts', fresh);
4640
+
4641
+ return await get_drive_plans({ uid });
4642
+ } catch (err) {
4643
+ return { code: -1, data: err.message || String(err) };
4644
+ }
4645
+ };
package/index_ms.mjs CHANGED
@@ -421,6 +421,14 @@ export const file_upload_validator = async function (...args) {
421
421
  return await broker.send_to_queue("file_upload_validator", ...args);
422
422
  };
423
423
 
424
+ export const get_drive_plans = async function (...args) {
425
+ return await broker.send_to_queue("get_drive_plans", ...args);
426
+ };
427
+
428
+ export const drive_set_plan = async function (...args) {
429
+ return await broker.send_to_queue("drive_set_plan", ...args);
430
+ };
431
+
424
432
  export const get_drive_files_studio = async function (...args) {
425
433
  return await broker.send_to_queue("get_drive_files_studio", ...args);
426
434
  };
package/index_msa.mjs CHANGED
@@ -421,6 +421,14 @@ export const file_upload_validator = function (...args) {
421
421
  broker.send_to_queue_async("file_upload_validator", ...args);
422
422
  };
423
423
 
424
+ export const get_drive_plans = function (...args) {
425
+ broker.send_to_queue_async("get_drive_plans", ...args);
426
+ };
427
+
428
+ export const drive_set_plan = function (...args) {
429
+ broker.send_to_queue_async("drive_set_plan", ...args);
430
+ };
431
+
424
432
  export const get_drive_files_studio = function (...args) {
425
433
  broker.send_to_queue_async("get_drive_files_studio", ...args);
426
434
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/drive_module",
3
- "version": "1.1.1499",
3
+ "version": "1.1.1500",
4
4
  "description": "Xuda Drive Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {