nodebb-plugin-onekite-calendar 2.0.90 → 2.0.92

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
@@ -11,9 +11,6 @@ const {
11
11
  formatFR,
12
12
  sendEmail,
13
13
  buildCalendarLinks,
14
- buildHelloAssoItemName,
15
- normalizeCallbackUrl,
16
- normalizeReturnUrl,
17
14
  calendarDaysExclusiveYmd,
18
15
  yearFromTs,
19
16
  autoFormSlugForYear,
@@ -22,6 +19,7 @@ const {
22
19
 
23
20
  const dbLayer = require('./db');
24
21
  const helloasso = require('./helloasso');
22
+ const { buildCheckoutIntent } = require('./checkout');
25
23
 
26
24
  const ADMIN_PRIV = 'admin:settings';
27
25
 
@@ -45,10 +43,16 @@ admin.saveSettings = async function (req, res) {
45
43
  };
46
44
 
47
45
  admin.listPending = async function (req, res) {
46
+ // Accept ?status=pending or ?status=pending,awaiting_payment (default: both)
47
+ const rawStatus = (req.query && req.query.status) ? String(req.query.status) : '';
48
+ const statusFilter = rawStatus
49
+ ? rawStatus.split(',').map((s) => s.trim()).filter(Boolean)
50
+ : ['pending', 'awaiting_payment'];
51
+
48
52
  const ids = await dbLayer.listAllReservationIds(5000);
49
53
  // Batch fetch to avoid N DB round-trips.
50
54
  const rows = await dbLayer.getReservations(ids);
51
- const pending = (rows || []).filter(r => r && r.status === 'pending');
55
+ const pending = (rows || []).filter(r => r && statusFilter.includes(r.status));
52
56
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
53
57
 
54
58
  // Enrich with user info for ACP display (username + userslug)
@@ -99,84 +103,21 @@ admin.approveReservation = async function (req, res) {
99
103
  r.approvedByUsername = '';
100
104
  }
101
105
 
102
- // Create HelloAsso payment link if configured
106
+ // Create HelloAsso payment link
103
107
  const settings = await getSettings();
104
- const env = settings.helloassoEnv || 'prod';
105
- const token = await helloasso.getAccessToken({
106
- env,
107
- clientId: settings.helloassoClientId,
108
- clientSecret: settings.helloassoClientSecret,
109
- });
110
-
111
108
  let paymentUrl = null;
112
- if (token) {
113
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
114
- const year = new Date(Number(r.start)).getFullYear();
115
- const formSlug = autoFormSlugForYear(year);
116
-
117
- // Recompute total from HelloAsso catalog (same logic as api.js approve path)
118
- // to ensure the checkout amount is always up-to-date with actual item prices.
119
- let recomputedTotalCents = null;
120
- try {
121
- const { items: catalog } = await helloasso.listCatalogItems({
122
- env,
123
- token,
124
- organizationSlug: settings.helloassoOrganizationSlug,
125
- formType: settings.helloassoFormType,
126
- formSlug,
127
- });
128
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
129
- const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
130
- const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
131
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
132
- if (sumCentsPerDay > 0) {
133
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
134
- r.total = recomputedTotalCents / 100;
135
- }
136
- } catch (e) {
137
- // ignore recompute failures; fallback to stored total
138
- }
139
-
140
- const totalAmount = (typeof recomputedTotalCents === 'number')
141
- ? recomputedTotalCents
142
- : Math.max(0, Math.round((Number(r.total) || 0) * 100));
143
-
144
- if (!totalAmount) {
145
- console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve ACP) — skipping checkout intent', { rid, total: r.total });
109
+ try {
110
+ const checkoutResult = await buildCheckoutIntent({ r, settings });
111
+ if (checkoutResult) {
112
+ paymentUrl = checkoutResult.paymentUrl;
113
+ r.paymentUrl = checkoutResult.paymentUrl;
114
+ if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
115
+ if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
146
116
  } else {
147
- const returnUrl = normalizeReturnUrl();
148
- const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
149
- const intent = await helloasso.createCheckoutIntent({
150
- env,
151
- token,
152
- organizationSlug: settings.helloassoOrganizationSlug,
153
- formType: settings.helloassoFormType,
154
- formSlug,
155
- totalAmount,
156
- payerEmail: requester && requester.email,
157
- callbackUrl: returnUrl,
158
- webhookUrl: webhookUrl,
159
- itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
160
- containsDonation: false,
161
- metadata: {
162
- reservationId: String(rid),
163
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
164
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
165
- },
166
- });
167
- paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
168
- ? (intent.paymentUrl || intent.redirectUrl)
169
- : (typeof intent === 'string' ? intent : null);
170
- if (intent && intent.checkoutIntentId) {
171
- r.checkoutIntentId = intent.checkoutIntentId;
172
- }
117
+ console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
173
118
  }
174
- }
175
-
176
- if (paymentUrl) {
177
- r.paymentUrl = paymentUrl;
178
- } else {
179
- console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
119
+ } catch (e) {
120
+ console.warn('[calendar-onekite] HelloAsso payment link error (approve ACP)', e && e.message ? e.message : e);
180
121
  }
181
122
 
182
123
  await dbLayer.saveReservation(r);
@@ -508,44 +449,6 @@ admin.debugHelloAsso = async function (req, res) {
508
449
  out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
509
450
  }
510
451
 
511
- // Test checkout intent creation with a 1-cent amount to validate URL format.
512
- // This creates a real (but minimal) intent — it will expire quickly and can be ignored.
513
- out.checkoutIntentTest = { ok: false };
514
- try {
515
- const callbackUrl = normalizeReturnUrl();
516
- const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
517
- if (!callbackUrl) {
518
- out.checkoutIntentTest = { ok: false, error: 'callbackUrl empty — forum base URL not configured' };
519
- } else {
520
- const intent = await helloasso.createCheckoutIntent({
521
- env,
522
- token,
523
- organizationSlug: settings.helloassoOrganizationSlug,
524
- formType: settings.helloassoFormType,
525
- formSlug: `locations-materiel-${new Date().getFullYear()}`,
526
- totalAmount: 100,
527
- payerEmail: '',
528
- callbackUrl,
529
- webhookUrl,
530
- itemName: '[TEST] Vérification lien paiement',
531
- containsDonation: false,
532
- metadata: { debug: 'true' },
533
- });
534
- if (intent && intent.paymentUrl) {
535
- out.checkoutIntentTest = {
536
- ok: true,
537
- checkoutIntentId: intent.checkoutIntentId,
538
- paymentUrl: intent.paymentUrl,
539
- note: 'Cet intent de test (1 cent) peut être ignoré — il expirera automatiquement',
540
- };
541
- } else {
542
- out.checkoutIntentTest = { ok: false, error: 'no paymentUrl in response', raw: intent };
543
- }
544
- }
545
- } catch (e) {
546
- out.checkoutIntentTest = { ok: false, error: String(e && e.message ? e.message : e) };
547
- }
548
-
549
452
  return res.json(out);
550
453
  } catch (e) {
551
454
  out.ok = false;
package/lib/api.js CHANGED
@@ -24,21 +24,16 @@ const {
24
24
  yearFromTs,
25
25
  autoCreatorGroupForYear,
26
26
  autoFormSlugForYear,
27
- normalizeCallbackUrl,
28
- normalizeReturnUrl,
29
- buildHelloAssoItemName,
30
27
  buildCalendarLinks,
31
28
  signCalendarLink,
32
29
  ymdToCompact,
33
- dtToGCalUtc,
34
30
  getSetting,
35
31
  } = shared;
36
32
 
37
33
  const helloasso = require('./helloasso');
38
34
  const discord = require('./discord');
39
35
  const realtime = require('./realtime');
40
-
41
- // normalizeCallbackUrl and normalizeReturnUrl are imported from shared.js
36
+ const { buildCheckoutIntent } = require('./checkout');
42
37
 
43
38
 
44
39
  function overlap(aStart, aEnd, bStart, bEnd) {
@@ -46,8 +41,6 @@ function overlap(aStart, aEnd, bStart, bEnd) {
46
41
  }
47
42
 
48
43
 
49
- // buildHelloAssoItemName is imported from shared.js
50
-
51
44
  function toTs(v) {
52
45
  if (v === undefined || v === null || v === '') return NaN;
53
46
  // Accept milliseconds timestamps passed as strings or numbers.
@@ -647,82 +640,18 @@ api.getFreshPaymentUrl = async function (req, res) {
647
640
  return res.status(409).json({ error: 'wrong-status', status: r.status });
648
641
  }
649
642
 
650
- // Build a fresh checkout intent
651
- const env = settings.helloassoEnv || 'prod';
652
- const token = await helloasso.getAccessToken({
653
- env,
654
- clientId: settings.helloassoClientId,
655
- clientSecret: settings.helloassoClientSecret,
656
- });
657
-
658
- if (!token) {
659
- return res.status(503).json({ error: 'helloasso-unavailable' });
660
- }
661
-
662
- const requester = await user.getUserFields(r.uid, ['email']);
663
- const year = yearFromTs(Number(r.start));
664
- const formSlug = autoFormSlugForYear(year);
665
-
666
- // Recompute total from HelloAsso catalog to always use up-to-date prices.
667
- let recomputedTotalCents = null;
668
- try {
669
- const { items: catalog } = await helloasso.listCatalogItems({
670
- env,
671
- token,
672
- organizationSlug: settings.helloassoOrganizationSlug,
673
- formType: settings.helloassoFormType,
674
- formSlug,
675
- });
676
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
677
- const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
678
- const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
679
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
680
- if (sumCentsPerDay > 0) {
681
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
682
- }
683
- } catch (e) { /* fallback to stored total */ }
684
-
685
- const totalAmount = (typeof recomputedTotalCents === 'number')
686
- ? recomputedTotalCents
687
- : Math.max(0, Math.round((Number(r.total) || 0) * 100));
688
-
689
- if (!totalAmount) {
690
- return res.status(422).json({ error: 'zero-amount' });
691
- }
692
-
693
- const intent = await helloasso.createCheckoutIntent({
694
- env,
695
- token,
696
- organizationSlug: settings.helloassoOrganizationSlug,
697
- formType: settings.helloassoFormType,
698
- formSlug,
699
- totalAmount,
700
- payerEmail: requester && requester.email ? requester.email : '',
701
- callbackUrl: normalizeReturnUrl(),
702
- webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
703
- itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
704
- containsDonation: false,
705
- metadata: {
706
- reservationId: String(rid),
707
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
708
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
709
- },
710
- });
711
-
712
- const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
713
- ? (intent.paymentUrl || intent.redirectUrl)
714
- : (typeof intent === 'string' ? intent : null);
715
-
716
- if (!paymentUrl) {
643
+ // Build a fresh checkout intent (handles token, catalog recompute, intent creation)
644
+ const checkoutResult = await buildCheckoutIntent({ r, settings });
645
+ if (!checkoutResult) {
717
646
  return res.status(502).json({ error: 'intent-creation-failed' });
718
647
  }
719
648
 
720
- // Persist the fresh URL so subsequent requests can also renew from DB.
721
- r.paymentUrl = paymentUrl;
722
- if (intent && intent.checkoutIntentId) r.checkoutIntentId = intent.checkoutIntentId;
649
+ r.paymentUrl = checkoutResult.paymentUrl;
650
+ if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
651
+ if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
723
652
  await dbLayer.saveReservation(r);
724
653
 
725
- return res.json({ ok: true, paymentUrl });
654
+ return res.json({ ok: true, paymentUrl: checkoutResult.paymentUrl });
726
655
  };
727
656
 
728
657
  /**
@@ -1398,9 +1327,9 @@ api.createReservation = async function (req, res) {
1398
1327
  if (!(exStart < end && start < exEnd)) continue;
1399
1328
  }
1400
1329
  const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
1401
- const shared = exItemIds.filter(x => itemIds.includes(String(x)));
1402
- if (shared.length) {
1403
- conflicts.push({ rid: existing.rid, itemIds: shared, status: existing.status });
1330
+ const conflictingItemIds = exItemIds.filter(x => itemIds.includes(String(x)));
1331
+ if (conflictingItemIds.length) {
1332
+ conflicts.push({ rid: existing.rid, itemIds: conflictingItemIds, status: existing.status });
1404
1333
  }
1405
1334
  }
1406
1335
  if (conflicts.length) {
@@ -1491,63 +1420,16 @@ api.createReservation = async function (req, res) {
1491
1420
  // create the HelloAsso checkout intent immediately so the user has a payment link right away.
1492
1421
  if (isRegularizationOther) {
1493
1422
  try {
1494
- const token = await helloasso.getAccessToken({ env: settings.helloassoEnv || 'prod', clientId: settings.helloassoClientId, clientSecret: settings.helloassoClientSecret });
1495
- const payer = await user.getUserFields(targetUid, ['email']);
1496
- const year = yearFromTs(resv.start);
1497
- const days = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : nbDays;
1498
-
1499
- let recomputedTotalCents = null;
1500
- try {
1501
- const { items: catalog } = await helloasso.listCatalogItems({
1502
- env: settings.helloassoEnv,
1503
- token,
1504
- organizationSlug: settings.helloassoOrganizationSlug,
1505
- formType: settings.helloassoFormType,
1506
- formSlug: autoFormSlugForYear(year),
1507
- });
1508
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
1509
- const ids = itemIds.map(String);
1510
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
1511
- if (sumCentsPerDay > 0) {
1512
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
1513
- resv.total = recomputedTotalCents / 100;
1514
- }
1515
- } catch (e) {}
1516
-
1517
- const intent = await helloasso.createCheckoutIntent({
1518
- env: settings.helloassoEnv,
1519
- token,
1520
- organizationSlug: settings.helloassoOrganizationSlug,
1521
- formType: settings.helloassoFormType,
1522
- formSlug: autoFormSlugForYear(year),
1523
- totalAmount: (() => {
1524
- const cents = (typeof recomputedTotalCents === 'number')
1525
- ? recomputedTotalCents
1526
- : Math.max(0, Math.round((Number(resv.total) || 0) * 100));
1527
- if (!cents) console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (regularizationOther)', { rid });
1528
- return cents;
1529
- })(),
1530
- payerEmail: payer && payer.email ? payer.email : '',
1531
- callbackUrl: normalizeReturnUrl(),
1532
- webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
1533
- itemName: buildHelloAssoItemName('', resv.itemNames || [], resv.start, resv.end),
1534
- containsDonation: false,
1535
- metadata: {
1536
- reservationId: String(rid),
1537
- items: resv.itemNames || [],
1538
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
1539
- },
1540
- });
1541
- const intentPaymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
1542
- const intentCheckoutId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
1543
- if (intentPaymentUrl) {
1544
- resv.paymentUrl = intentPaymentUrl;
1545
- if (intentCheckoutId) resv.checkoutIntentId = intentCheckoutId;
1423
+ const checkoutResult = await buildCheckoutIntent({ r: resv, settings });
1424
+ if (checkoutResult) {
1425
+ resv.paymentUrl = checkoutResult.paymentUrl;
1426
+ if (checkoutResult.checkoutIntentId) resv.checkoutIntentId = checkoutResult.checkoutIntentId;
1427
+ if (checkoutResult.catalogTotalCents !== null) resv.total = checkoutResult.catalogTotalCents / 100;
1546
1428
  } else {
1547
1429
  console.warn('[calendar-onekite] HelloAsso payment link not created (regularizationOther)', { rid });
1548
1430
  }
1549
1431
  } catch (e) {
1550
- console.warn('[calendar-onekite] Failed to create HelloAsso checkout for regularizationOther', e && e.message ? e.message : e);
1432
+ console.warn('[calendar-onekite] Failed to create HelloAsso checkout (regularizationOther)', e && e.message ? e.message : e);
1551
1433
  }
1552
1434
  }
1553
1435
 
@@ -1774,74 +1656,11 @@ api.approveReservation = async function (req, res) {
1774
1656
  }
1775
1657
  // Create HelloAsso payment link on validation
1776
1658
  try {
1777
- const settings2 = await getSettings();
1778
- const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
1779
- const payer = await user.getUserFields(r.uid, ['email']);
1780
- const year = yearFromTs(r.start);
1781
-
1782
- // Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
1783
- const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
1784
-
1785
- // Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
1786
- // and to ensure checkout amount is always consistent.
1787
- let recomputedTotalCents = null;
1788
- try {
1789
- const { items: catalog } = await helloasso.listCatalogItems({
1790
- env: settings2.helloassoEnv,
1791
- token,
1792
- organizationSlug: settings2.helloassoOrganizationSlug,
1793
- formType: settings2.helloassoFormType,
1794
- formSlug: autoFormSlugForYear(year),
1795
- });
1796
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
1797
- const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
1798
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
1799
- if (sumCentsPerDay > 0) {
1800
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
1801
- // Keep stored total in sync (euros) for emails/UX.
1802
- r.total = recomputedTotalCents / 100;
1803
- }
1804
- } catch (e) {
1805
- // ignore recompute failures; fallback to stored total
1806
- }
1807
-
1808
- const intent = await helloasso.createCheckoutIntent({
1809
- env: settings2.helloassoEnv,
1810
- token,
1811
- organizationSlug: settings2.helloassoOrganizationSlug,
1812
- formType: settings2.helloassoFormType,
1813
- // Form slug is derived from the year of the reservation start date
1814
- formSlug: autoFormSlugForYear(year),
1815
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
1816
- totalAmount: (() => {
1817
- const cents = (typeof recomputedTotalCents === 'number')
1818
- ? recomputedTotalCents
1819
- : Math.max(0, Math.round((Number(r.total) || 0) * 100));
1820
- if (!cents) {
1821
- console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
1822
- }
1823
- return cents;
1824
- })(),
1825
- payerEmail: payer && payer.email ? payer.email : '',
1826
- // By default, point to the forum base url so the webhook hits this NodeBB instance.
1827
- // Can be overridden via ACP setting `helloassoCallbackUrl`.
1828
- callbackUrl: normalizeReturnUrl(),
1829
- webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl),
1830
- itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
1831
- containsDonation: false,
1832
- metadata: {
1833
- reservationId: String(rid),
1834
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
1835
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1836
- },
1837
- });
1838
- const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
1839
- const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
1840
- if (paymentUrl) {
1841
- r.paymentUrl = paymentUrl;
1842
- if (checkoutIntentId) {
1843
- r.checkoutIntentId = checkoutIntentId;
1844
- }
1659
+ const checkoutResult = await buildCheckoutIntent({ r, settings });
1660
+ if (checkoutResult) {
1661
+ r.paymentUrl = checkoutResult.paymentUrl;
1662
+ if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
1663
+ if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
1845
1664
  } else {
1846
1665
  console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
1847
1666
  }
@@ -1879,8 +1698,8 @@ api.approveReservation = async function (req, res) {
1879
1698
  uid: requesterUid,
1880
1699
  username: requester && requester.username ? requester.username : '',
1881
1700
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1882
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1883
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1701
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1702
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1884
1703
  start: formatFR(r.start),
1885
1704
  end: formatFR(r.end),
1886
1705
  pickupAddress: r.pickupAddress || '',
@@ -1955,8 +1774,8 @@ api.refuseReservation = async function (req, res) {
1955
1774
  uid: requesterUid2,
1956
1775
  username: requester && requester.username ? requester.username : '',
1957
1776
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1958
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1959
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1777
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1778
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1960
1779
  start: formatFR(r.start),
1961
1780
  end: formatFR(r.end),
1962
1781
  refusedReason: r.refusedReason || '',
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared checkout intent builder for HelloAsso reservations.
5
+ *
6
+ * Extracted from api.js and admin.js to eliminate the four identical copies
7
+ * of the same pattern:
8
+ * - api.approveReservation
9
+ * - api.getFreshPaymentUrl
10
+ * - api.createReservation (regularizationOther path)
11
+ * - admin.approveReservation
12
+ *
13
+ * Does NOT persist anything — callers are responsible for saving the reservation.
14
+ */
15
+
16
+ const user = require.main.require('./src/user');
17
+ const helloasso = require('./helloasso');
18
+ const {
19
+ yearFromTs,
20
+ autoFormSlugForYear,
21
+ calendarDaysExclusiveYmd,
22
+ buildHelloAssoItemName,
23
+ normalizeCallbackUrl,
24
+ normalizeReturnUrl,
25
+ formatFR,
26
+ } = require('./shared');
27
+
28
+ /**
29
+ * Create a HelloAsso checkout intent for a reservation.
30
+ *
31
+ * @param {object} opts
32
+ * @param {object} opts.r Reservation object (not mutated here)
33
+ * @param {object} opts.settings Plugin settings (from getSettings())
34
+ * @returns {Promise<{
35
+ * paymentUrl: string,
36
+ * checkoutIntentId: string | null,
37
+ * totalCents: number,
38
+ * catalogTotalCents: number | null,
39
+ * } | null>}
40
+ * null when: token unavailable, amount is 0, or intent creation failed.
41
+ * catalogTotalCents is non-null only when the catalog lookup succeeded with
42
+ * positive prices; callers should update r.total only in that case.
43
+ */
44
+ async function buildCheckoutIntent({ r, settings }) {
45
+ const env = settings.helloassoEnv || 'prod';
46
+
47
+ const token = await helloasso.getAccessToken({
48
+ env,
49
+ clientId: settings.helloassoClientId,
50
+ clientSecret: settings.helloassoClientSecret,
51
+ });
52
+ if (!token) return null;
53
+
54
+ const payerData = await user.getUserFields(r.uid, ['email']).catch(() => null);
55
+ const payerEmail = (payerData && payerData.email) ? String(payerData.email) : '';
56
+
57
+ const year = yearFromTs(Number(r.start));
58
+ const formSlug = autoFormSlugForYear(year);
59
+ const itemIds = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
60
+ const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
61
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
62
+
63
+ // Recompute total from HelloAsso catalog to always use up-to-date prices.
64
+ let catalogTotalCents = null;
65
+ try {
66
+ const { items: catalog } = await helloasso.listCatalogItems({
67
+ env,
68
+ token,
69
+ organizationSlug: settings.helloassoOrganizationSlug,
70
+ formType: settings.helloassoFormType,
71
+ formSlug,
72
+ });
73
+ const byId = new Map(
74
+ (catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)])
75
+ );
76
+ const sumCentsPerDay = itemIds.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
77
+ if (sumCentsPerDay > 0) {
78
+ catalogTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
79
+ }
80
+ } catch (e) {
81
+ // Catalog unavailable — fall back to stored total below.
82
+ }
83
+
84
+ const totalCents = (catalogTotalCents !== null)
85
+ ? catalogTotalCents
86
+ : Math.max(0, Math.round((Number(r.total) || 0) * 100));
87
+
88
+ if (!totalCents) {
89
+ console.warn('[calendar-onekite] checkout intent skipped: totalAmount is 0', { rid: r.rid });
90
+ return null;
91
+ }
92
+
93
+ const intent = await helloasso.createCheckoutIntent({
94
+ env,
95
+ token,
96
+ organizationSlug: settings.helloassoOrganizationSlug,
97
+ formType: settings.helloassoFormType,
98
+ formSlug,
99
+ totalAmount: totalCents,
100
+ payerEmail,
101
+ callbackUrl: normalizeReturnUrl(),
102
+ webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
103
+ itemName: buildHelloAssoItemName('Réservation matériel Onekite', itemNames, r.start, r.end),
104
+ containsDonation: false,
105
+ metadata: {
106
+ reservationId: String(r.rid),
107
+ items: itemNames.filter(Boolean),
108
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
109
+ },
110
+ });
111
+
112
+ const paymentUrl = (intent && (intent.paymentUrl || intent.redirectUrl))
113
+ ? (intent.paymentUrl || intent.redirectUrl)
114
+ : (typeof intent === 'string' ? intent : null);
115
+
116
+ if (!paymentUrl) return null;
117
+
118
+ return {
119
+ paymentUrl,
120
+ checkoutIntentId: (intent && intent.checkoutIntentId) ? String(intent.checkoutIntentId) : null,
121
+ totalCents,
122
+ catalogTotalCents,
123
+ };
124
+ }
125
+
126
+ module.exports = { buildCheckoutIntent };
package/lib/helloasso.js CHANGED
@@ -307,15 +307,6 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
307
307
  const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
308
308
  if (status >= 200 && status < 300 && json) {
309
309
  const redirectUrl = json.redirectUrl || json.checkoutUrl || json.url || null;
310
- // Always log so the URL format is visible in NodeBB logs (helps diagnose 404s).
311
- // eslint-disable-next-line no-console
312
- console.log('[calendar-onekite] HelloAsso checkout-intent created', {
313
- status,
314
- checkoutIntentId: json.id || json.checkoutIntentId || null,
315
- redirectUrl,
316
- env,
317
- organizationSlug,
318
- });
319
310
  return { paymentUrl: redirectUrl, checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
320
311
  }
321
312
  // Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
@@ -76,7 +76,11 @@ function getReservationIdFromPayload(payload) {
76
76
  data.order && (data.order.meta || data.order.metadata),
77
77
  ].filter(Boolean);
78
78
 
79
- for (const metaObj of metaCandidates) {
79
+ for (let metaObj of metaCandidates) {
80
+ // HelloAsso sometimes sends metadata as a JSON-encoded string — parse it first.
81
+ if (typeof metaObj === 'string') {
82
+ try { metaObj = JSON.parse(metaObj); } catch (e) { continue; }
83
+ }
80
84
  if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
81
85
  return String(metaObj.reservationId);
82
86
  }
@@ -215,9 +219,35 @@ async function handler(req, res, next) {
215
219
  }
216
220
  }
217
221
 
218
- const payload = req.body;
222
+ // Parse body req.body is set by our bodyParser middleware, but if NodeBB's
223
+ // global body parser consumed the stream first, fall back to req.rawBody.
224
+ let payload = req.body;
225
+ if (!payload && req.rawBody) {
226
+ try {
227
+ payload = JSON.parse(req.rawBody.toString('utf8'));
228
+ } catch (e) {
229
+ // eslint-disable-next-line no-console
230
+ console.warn('[calendar-onekite] HelloAsso webhook: failed to parse rawBody', { err: e && e.message });
231
+ }
232
+ }
233
+
234
+ // eslint-disable-next-line no-console
235
+ console.info('[calendar-onekite] HelloAsso webhook received', {
236
+ eventType: payload && payload.eventType,
237
+ state: payload && payload.data && (payload.data.state || payload.data.status || payload.data.paymentState),
238
+ paymentId: payload && payload.data && (payload.data.id || payload.data.paymentId),
239
+ checkoutIntentId: payload && payload.data && (payload.data.checkoutIntentId || (payload.data.order && payload.data.order.checkoutIntentId)),
240
+ hasBody: !!payload,
241
+ contentType: req.headers && req.headers['content-type'],
242
+ });
219
243
 
220
244
  if (!isConfirmedPayment(payload)) {
245
+ // eslint-disable-next-line no-console
246
+ console.warn('[calendar-onekite] HelloAsso webhook: payment not confirmed — ignored', {
247
+ eventType: payload && payload.eventType,
248
+ state: payload && payload.data && (payload.data.state || payload.data.status || payload.data.paymentState),
249
+ hasData: !!(payload && payload.data),
250
+ });
221
251
  return res.json({ ok: true, ignored: true });
222
252
  }
223
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.90",
3
+ "version": "2.0.92",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/admin.js CHANGED
@@ -572,6 +572,22 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
572
572
  : '';
573
573
  const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
574
574
  const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
575
+
576
+ const isAwaitingPayment = r.status === 'awaiting_payment';
577
+ const statusBadge = isAwaitingPayment
578
+ ? '<span class="badge bg-warning text-dark ms-1" style="font-size:10px;">En attente de paiement</span>'
579
+ : '<span class="badge bg-secondary ms-1" style="font-size:10px;">En attente de validation</span>';
580
+
581
+ // Buttons differ per status:
582
+ // - pending → "Valider" + "Refuser"
583
+ // - awaiting_payment → "Marquer payée" + "Renouveler lien" + "Refuser"
584
+ const actionButtons = isAwaitingPayment
585
+ ? `<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
586
+ <button type="button" class="btn btn-outline-primary btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Renouveler lien</button>
587
+ <button type="button" class="btn btn-success btn-sm" data-action="mark-paid" data-rid="${escapeHtml(String(r.rid || ''))}">Marquer payée</button>`
588
+ : `<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
589
+ <button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>`;
590
+
575
591
  const div = document.createElement('div');
576
592
  div.className = 'list-group-item onekite-pending-row';
577
593
  div.innerHTML = `
@@ -581,16 +597,15 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
581
597
  <input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
582
598
  </div>
583
599
  <div style="min-width:0;">
584
- <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
585
- <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
586
- ${userLink ? `<div class="text-muted" style="font-size: 12px;">Réservée par: ${userLink}</div>` : ''}
587
- <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
600
+ <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong>${statusBadge}</div>
601
+ <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
602
+ ${userLink ? `<div class="text-muted" style="font-size: 12px;">Réservée par: ${userLink}</div>` : ''}
603
+ <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
588
604
  </div>
589
605
  </div>
590
- <div class="d-flex gap-2">
591
- <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
592
- <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
593
- <button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
606
+ <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
607
+ <div class="d-flex gap-2 flex-wrap justify-content-end">
608
+ ${actionButtons}
594
609
  </div>
595
610
  </div>
596
611
  `;
@@ -862,7 +877,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
862
877
  const rid = btn.getAttribute('data-rid');
863
878
 
864
879
  // Prevent accidental double-open of modals on mobile/trackpad double taps
865
- if (rid && (action === 'approve' || action === 'refuse')) {
880
+ if (rid && (action === 'approve' || action === 'refuse' || action === 'mark-paid')) {
866
881
  const openKey = `open:${action}:${rid}`;
867
882
  if (actionLocks.has(openKey)) return;
868
883
  actionLocks.add(openKey);
@@ -1044,9 +1059,29 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1044
1059
 
1045
1060
  // Remove the row immediately on success for a snappier UX
1046
1061
  const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
1047
- const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action="approve"],button[data-action="refuse"]')) : [btn];
1062
+ const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action]')) : [btn];
1048
1063
 
1049
1064
  try {
1065
+ if (action === 'mark-paid') {
1066
+ bootbox.confirm(
1067
+ `Marquer la réservation <strong>${escapeHtml(rid)}</strong> comme payée manuellement ?<br><small class="text-muted">Un email de confirmation sera envoyé à l'adhérent.</small>`,
1068
+ async (ok) => {
1069
+ if (!ok) return;
1070
+ await withLock(`mark-paid:${rid}`, rowBtns, async () => {
1071
+ try {
1072
+ await markPaid(rid, { note: 'Paiement validé manuellement depuis ACP' });
1073
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
1074
+ showAlert('success', `Réservation ${rid} marquée comme payée.`);
1075
+ await refreshPending();
1076
+ } catch (e) {
1077
+ showAlert('error', `Impossible de marquer comme payée : ${e && e.message ? e.message : e}`);
1078
+ }
1079
+ });
1080
+ }
1081
+ );
1082
+ return;
1083
+ }
1084
+
1050
1085
  if (action === 'refuse') {
1051
1086
  const html = `
1052
1087
  <div class="mb-3">