nodebb-plugin-onekite-calendar 2.0.18 → 2.0.19

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/lib/admin.js CHANGED
@@ -432,6 +432,72 @@ admin.getAccounting = async function (req, res) {
432
432
  let paidCount = 0;
433
433
  let grandTotal = 0;
434
434
 
435
+ // Helper: calendar-day difference (end exclusive) from YYYY-MM-DD strings (UTC midnights).
436
+ const calendarDaysExclusiveYmd = (startYmd, endYmd) => {
437
+ try {
438
+ const s = String(startYmd || '').trim();
439
+ const e = String(endYmd || '').trim();
440
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !/^\d{4}-\d{2}-\d{2}$/.test(e)) return null;
441
+ const [sy, sm, sd] = s.split('-').map((x) => parseInt(x, 10));
442
+ const [ey, em, ed] = e.split('-').map((x) => parseInt(x, 10));
443
+ const sUtc = Date.UTC(sy, sm - 1, sd);
444
+ const eUtc = Date.UTC(ey, em - 1, ed);
445
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
446
+ return Math.max(1, diff);
447
+ } catch (e) {
448
+ return null;
449
+ }
450
+ };
451
+
452
+ const yearFromTs = (ts) => {
453
+ const d = new Date(Number(ts));
454
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
455
+ };
456
+ const formSlugForYear = (y) => `locations-materiel-${y}`;
457
+
458
+ // Cache HelloAsso catalog price lookups by year to compute *per-item* totals.
459
+ // This prevents a bug where the full reservation total was previously counted on every item line.
460
+ let settingsCache = null;
461
+ let tokenCache = null;
462
+ const catalogByYear = new Map(); // year -> Map(itemId -> priceCents)
463
+ const getCatalogPriceMapForYear = async (year) => {
464
+ const y = Number(year);
465
+ if (!Number.isFinite(y)) return null;
466
+ if (catalogByYear.has(y)) return catalogByYear.get(y);
467
+ try {
468
+ if (!settingsCache) settingsCache = await meta.settings.get('calendar-onekite');
469
+ if (!settingsCache || !settingsCache.helloassoClientId || !settingsCache.helloassoClientSecret || !settingsCache.helloassoOrganizationSlug || !settingsCache.helloassoFormType) {
470
+ catalogByYear.set(y, null);
471
+ return null;
472
+ }
473
+ if (!tokenCache) {
474
+ tokenCache = await helloasso.getAccessToken({
475
+ env: settingsCache.helloassoEnv || 'prod',
476
+ clientId: settingsCache.helloassoClientId,
477
+ clientSecret: settingsCache.helloassoClientSecret,
478
+ });
479
+ }
480
+ if (!tokenCache) {
481
+ catalogByYear.set(y, null);
482
+ return null;
483
+ }
484
+
485
+ const { items: catalog } = await helloasso.listCatalogItems({
486
+ env: settingsCache.helloassoEnv,
487
+ token: tokenCache,
488
+ organizationSlug: settingsCache.helloassoOrganizationSlug,
489
+ formType: settingsCache.helloassoFormType,
490
+ formSlug: formSlugForYear(y),
491
+ });
492
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
493
+ catalogByYear.set(y, byId);
494
+ return byId;
495
+ } catch (e) {
496
+ catalogByYear.set(y, null);
497
+ return null;
498
+ }
499
+ };
500
+
435
501
  for (const rid of ids) {
436
502
  const r = await dbLayer.getReservation(rid);
437
503
  if (!r) continue;
@@ -473,14 +539,55 @@ admin.getAccounting = async function (req, res) {
473
539
 
474
540
  // Only count paid (non-free) reservations into revenue by item.
475
541
  if (!isFree) {
476
- for (const name of itemNames) {
542
+ // Compute per-item totals (price_per_day * calendar_days) when possible.
543
+ // Fallback: split reservation total equally across items.
544
+ const idsForRes = (Array.isArray(r.itemIds) && r.itemIds.length) ? r.itemIds.map(String) : (r.itemId ? [String(r.itemId)] : []);
545
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
546
+ const year = yearFromTs(start);
547
+
548
+ let perItemTotals = null; // Map(itemId -> euros)
549
+ const priceMap = await getCatalogPriceMapForYear(year);
550
+ if (priceMap && idsForRes.length) {
551
+ const m = new Map();
552
+ for (const id of idsForRes) {
553
+ const cents = priceMap.get(String(id)) || 0;
554
+ if (cents > 0) {
555
+ m.set(String(id), (cents * days) / 100);
556
+ }
557
+ }
558
+ // Use catalog-based totals only if we got at least one valid price.
559
+ if (m.size) perItemTotals = m;
560
+ }
561
+
562
+ const nItems = Math.max(1, itemNames.length || idsForRes.length || 1);
563
+ const fallbackEach = (Number(total) || 0) / nItems;
564
+
565
+ // Aggregate by *display name* (stable for admins), using the computed per-item totals.
566
+ // If we have ids and names, we try to align by index; otherwise we apply fallback.
567
+ for (let i = 0; i < itemNames.length; i++) {
568
+ const name = itemNames[i];
477
569
  const key = String(name || '').trim();
478
570
  if (!key) continue;
571
+ const id = idsForRes[i] != null ? String(idsForRes[i]) : null;
572
+ const per = (id && perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
479
573
  const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
480
574
  cur.count += 1;
481
- cur.total += total;
575
+ cur.total += (Number(per) || 0);
482
576
  byItem.set(key, cur);
483
577
  }
578
+
579
+ // If we have ids but no names (edge cases), keep a reasonable summary by id.
580
+ if (!itemNames.length && idsForRes.length) {
581
+ for (const id of idsForRes) {
582
+ const key = String(id || '').trim();
583
+ if (!key) continue;
584
+ const per = (perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
585
+ const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
586
+ cur.count += 1;
587
+ cur.total += (Number(per) || 0);
588
+ byItem.set(key, cur);
589
+ }
590
+ }
484
591
  }
485
592
  }
486
593
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -110,6 +110,7 @@ admin.listPending = async function (req, res) {
110
110
  .filter(v => Number.isInteger(v) && v > 0)));
111
111
 
112
112
  if (uids.length) {
113
+ // user.getUsersFields exists in NodeBB 4.x
113
114
  const users = await user.getUsersFields(uids, ['uid', 'username', 'userslug']);
114
115
  const byUid = new Map((users || []).filter(Boolean).map(u => [String(u.uid), u]));
115
116
  for (const r of pending) {
@@ -121,7 +122,7 @@ admin.listPending = async function (req, res) {
121
122
  }
122
123
  }
123
124
  } catch (e) {
124
- // Best-effort only
125
+ // Best-effort only; keep API working even if user lookups fail
125
126
  }
126
127
 
127
128
  res.json(pending);
@@ -431,6 +432,72 @@ admin.getAccounting = async function (req, res) {
431
432
  let paidCount = 0;
432
433
  let grandTotal = 0;
433
434
 
435
+ // Helper: calendar-day difference (end exclusive) from YYYY-MM-DD strings (UTC midnights).
436
+ const calendarDaysExclusiveYmd = (startYmd, endYmd) => {
437
+ try {
438
+ const s = String(startYmd || '').trim();
439
+ const e = String(endYmd || '').trim();
440
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !/^\d{4}-\d{2}-\d{2}$/.test(e)) return null;
441
+ const [sy, sm, sd] = s.split('-').map((x) => parseInt(x, 10));
442
+ const [ey, em, ed] = e.split('-').map((x) => parseInt(x, 10));
443
+ const sUtc = Date.UTC(sy, sm - 1, sd);
444
+ const eUtc = Date.UTC(ey, em - 1, ed);
445
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
446
+ return Math.max(1, diff);
447
+ } catch (e) {
448
+ return null;
449
+ }
450
+ };
451
+
452
+ const yearFromTs = (ts) => {
453
+ const d = new Date(Number(ts));
454
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
455
+ };
456
+ const formSlugForYear = (y) => `locations-materiel-${y}`;
457
+
458
+ // Cache HelloAsso catalog price lookups by year to compute *per-item* totals.
459
+ // This prevents a bug where the full reservation total was previously counted on every item line.
460
+ let settingsCache = null;
461
+ let tokenCache = null;
462
+ const catalogByYear = new Map(); // year -> Map(itemId -> priceCents)
463
+ const getCatalogPriceMapForYear = async (year) => {
464
+ const y = Number(year);
465
+ if (!Number.isFinite(y)) return null;
466
+ if (catalogByYear.has(y)) return catalogByYear.get(y);
467
+ try {
468
+ if (!settingsCache) settingsCache = await meta.settings.get('calendar-onekite');
469
+ if (!settingsCache || !settingsCache.helloassoClientId || !settingsCache.helloassoClientSecret || !settingsCache.helloassoOrganizationSlug || !settingsCache.helloassoFormType) {
470
+ catalogByYear.set(y, null);
471
+ return null;
472
+ }
473
+ if (!tokenCache) {
474
+ tokenCache = await helloasso.getAccessToken({
475
+ env: settingsCache.helloassoEnv || 'prod',
476
+ clientId: settingsCache.helloassoClientId,
477
+ clientSecret: settingsCache.helloassoClientSecret,
478
+ });
479
+ }
480
+ if (!tokenCache) {
481
+ catalogByYear.set(y, null);
482
+ return null;
483
+ }
484
+
485
+ const { items: catalog } = await helloasso.listCatalogItems({
486
+ env: settingsCache.helloassoEnv,
487
+ token: tokenCache,
488
+ organizationSlug: settingsCache.helloassoOrganizationSlug,
489
+ formType: settingsCache.helloassoFormType,
490
+ formSlug: formSlugForYear(y),
491
+ });
492
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
493
+ catalogByYear.set(y, byId);
494
+ return byId;
495
+ } catch (e) {
496
+ catalogByYear.set(y, null);
497
+ return null;
498
+ }
499
+ };
500
+
434
501
  for (const rid of ids) {
435
502
  const r = await dbLayer.getReservation(rid);
436
503
  if (!r) continue;
@@ -472,14 +539,55 @@ admin.getAccounting = async function (req, res) {
472
539
 
473
540
  // Only count paid (non-free) reservations into revenue by item.
474
541
  if (!isFree) {
475
- for (const name of itemNames) {
542
+ // Compute per-item totals (price_per_day * calendar_days) when possible.
543
+ // Fallback: split reservation total equally across items.
544
+ const idsForRes = (Array.isArray(r.itemIds) && r.itemIds.length) ? r.itemIds.map(String) : (r.itemId ? [String(r.itemId)] : []);
545
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
546
+ const year = yearFromTs(start);
547
+
548
+ let perItemTotals = null; // Map(itemId -> euros)
549
+ const priceMap = await getCatalogPriceMapForYear(year);
550
+ if (priceMap && idsForRes.length) {
551
+ const m = new Map();
552
+ for (const id of idsForRes) {
553
+ const cents = priceMap.get(String(id)) || 0;
554
+ if (cents > 0) {
555
+ m.set(String(id), (cents * days) / 100);
556
+ }
557
+ }
558
+ // Use catalog-based totals only if we got at least one valid price.
559
+ if (m.size) perItemTotals = m;
560
+ }
561
+
562
+ const nItems = Math.max(1, itemNames.length || idsForRes.length || 1);
563
+ const fallbackEach = (Number(total) || 0) / nItems;
564
+
565
+ // Aggregate by *display name* (stable for admins), using the computed per-item totals.
566
+ // If we have ids and names, we try to align by index; otherwise we apply fallback.
567
+ for (let i = 0; i < itemNames.length; i++) {
568
+ const name = itemNames[i];
476
569
  const key = String(name || '').trim();
477
570
  if (!key) continue;
571
+ const id = idsForRes[i] != null ? String(idsForRes[i]) : null;
572
+ const per = (id && perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
478
573
  const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
479
574
  cur.count += 1;
480
- cur.total += total;
575
+ cur.total += (Number(per) || 0);
481
576
  byItem.set(key, cur);
482
577
  }
578
+
579
+ // If we have ids but no names (edge cases), keep a reasonable summary by id.
580
+ if (!itemNames.length && idsForRes.length) {
581
+ for (const id of idsForRes) {
582
+ const key = String(id || '').trim();
583
+ if (!key) continue;
584
+ const per = (perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
585
+ const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
586
+ cur.count += 1;
587
+ cur.total += (Number(per) || 0);
588
+ byItem.set(key, cur);
589
+ }
590
+ }
483
591
  }
484
592
  }
485
593
 
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.18"
42
+ "version": "2.0.19"
43
43
  }