nodebb-plugin-onekite-calendar 2.0.89 → 2.0.91

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,16 +11,15 @@ 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,
17
+ forumBaseUrl,
20
18
  } = shared;
21
19
 
22
20
  const dbLayer = require('./db');
23
21
  const helloasso = require('./helloasso');
22
+ const { buildCheckoutIntent } = require('./checkout');
24
23
 
25
24
  const ADMIN_PRIV = 'admin:settings';
26
25
 
@@ -44,10 +43,16 @@ admin.saveSettings = async function (req, res) {
44
43
  };
45
44
 
46
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
+
47
52
  const ids = await dbLayer.listAllReservationIds(5000);
48
53
  // Batch fetch to avoid N DB round-trips.
49
54
  const rows = await dbLayer.getReservations(ids);
50
- const pending = (rows || []).filter(r => r && r.status === 'pending');
55
+ const pending = (rows || []).filter(r => r && statusFilter.includes(r.status));
51
56
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
52
57
 
53
58
  // Enrich with user info for ACP display (username + userslug)
@@ -98,84 +103,21 @@ admin.approveReservation = async function (req, res) {
98
103
  r.approvedByUsername = '';
99
104
  }
100
105
 
101
- // Create HelloAsso payment link if configured
106
+ // Create HelloAsso payment link
102
107
  const settings = await getSettings();
103
- const env = settings.helloassoEnv || 'prod';
104
- const token = await helloasso.getAccessToken({
105
- env,
106
- clientId: settings.helloassoClientId,
107
- clientSecret: settings.helloassoClientSecret,
108
- });
109
-
110
108
  let paymentUrl = null;
111
- if (token) {
112
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
113
- const year = new Date(Number(r.start)).getFullYear();
114
- const formSlug = autoFormSlugForYear(year);
115
-
116
- // Recompute total from HelloAsso catalog (same logic as api.js approve path)
117
- // to ensure the checkout amount is always up-to-date with actual item prices.
118
- let recomputedTotalCents = null;
119
- try {
120
- const { items: catalog } = await helloasso.listCatalogItems({
121
- env,
122
- token,
123
- organizationSlug: settings.helloassoOrganizationSlug,
124
- formType: settings.helloassoFormType,
125
- formSlug,
126
- });
127
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
128
- const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
129
- const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
130
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
131
- if (sumCentsPerDay > 0) {
132
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
133
- r.total = recomputedTotalCents / 100;
134
- }
135
- } catch (e) {
136
- // ignore recompute failures; fallback to stored total
137
- }
138
-
139
- const totalAmount = (typeof recomputedTotalCents === 'number')
140
- ? recomputedTotalCents
141
- : Math.max(0, Math.round((Number(r.total) || 0) * 100));
142
-
143
- if (!totalAmount) {
144
- 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;
145
116
  } else {
146
- const returnUrl = normalizeReturnUrl();
147
- const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
148
- const intent = await helloasso.createCheckoutIntent({
149
- env,
150
- token,
151
- organizationSlug: settings.helloassoOrganizationSlug,
152
- formType: settings.helloassoFormType,
153
- formSlug,
154
- totalAmount,
155
- payerEmail: requester && requester.email,
156
- callbackUrl: returnUrl,
157
- webhookUrl: webhookUrl,
158
- itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
159
- containsDonation: false,
160
- metadata: {
161
- reservationId: String(rid),
162
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
163
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
164
- },
165
- });
166
- paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
167
- ? (intent.paymentUrl || intent.redirectUrl)
168
- : (typeof intent === 'string' ? intent : null);
169
- if (intent && intent.checkoutIntentId) {
170
- r.checkoutIntentId = intent.checkoutIntentId;
171
- }
117
+ console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
172
118
  }
173
- }
174
-
175
- if (paymentUrl) {
176
- r.paymentUrl = paymentUrl;
177
- } else {
178
- 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);
179
121
  }
180
122
 
181
123
  await dbLayer.saveReservation(r);
@@ -213,6 +155,7 @@ admin.approveReservation = async function (req, res) {
213
155
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
214
156
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
215
157
  paymentUrl: paymentUrl || '',
158
+ calendarUrl: `${forumBaseUrl()}/calendar`,
216
159
  pickupAddress: r.pickupAddress || '',
217
160
  notes: r.notes || '',
218
161
  pickupTime: r.pickupTime || '',
@@ -506,44 +449,6 @@ admin.debugHelloAsso = async function (req, res) {
506
449
  out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
507
450
  }
508
451
 
509
- // Test checkout intent creation with a 1-cent amount to validate URL format.
510
- // This creates a real (but minimal) intent — it will expire quickly and can be ignored.
511
- out.checkoutIntentTest = { ok: false };
512
- try {
513
- const callbackUrl = normalizeReturnUrl();
514
- const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
515
- if (!callbackUrl) {
516
- out.checkoutIntentTest = { ok: false, error: 'callbackUrl empty — forum base URL not configured' };
517
- } else {
518
- const intent = await helloasso.createCheckoutIntent({
519
- env,
520
- token,
521
- organizationSlug: settings.helloassoOrganizationSlug,
522
- formType: settings.helloassoFormType,
523
- formSlug: `locations-materiel-${new Date().getFullYear()}`,
524
- totalAmount: 100,
525
- payerEmail: '',
526
- callbackUrl,
527
- webhookUrl,
528
- itemName: '[TEST] Vérification lien paiement',
529
- containsDonation: false,
530
- metadata: { debug: 'true' },
531
- });
532
- if (intent && intent.paymentUrl) {
533
- out.checkoutIntentTest = {
534
- ok: true,
535
- checkoutIntentId: intent.checkoutIntentId,
536
- paymentUrl: intent.paymentUrl,
537
- note: 'Cet intent de test (1 cent) peut être ignoré — il expirera automatiquement',
538
- };
539
- } else {
540
- out.checkoutIntentTest = { ok: false, error: 'no paymentUrl in response', raw: intent };
541
- }
542
- }
543
- } catch (e) {
544
- out.checkoutIntentTest = { ok: false, error: String(e && e.message ? e.message : e) };
545
- }
546
-
547
452
  return res.json(out);
548
453
  } catch (e) {
549
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
 
@@ -1653,6 +1535,7 @@ api.createReservation = async function (req, res) {
1653
1535
  pickupLon: '',
1654
1536
  mapUrl: '',
1655
1537
  paymentUrl: resv.paymentUrl || '',
1538
+ calendarUrl: `${forumBaseUrl()}/calendar`,
1656
1539
  validatedBy: creatorUsername || '',
1657
1540
  validatedByUrl: creatorUsername ? `${forumBaseUrl()}/user/${encodeURIComponent(String(creatorUsername))}` : '',
1658
1541
  ...buildCalendarLinks({
@@ -1773,74 +1656,11 @@ api.approveReservation = async function (req, res) {
1773
1656
  }
1774
1657
  // Create HelloAsso payment link on validation
1775
1658
  try {
1776
- const settings2 = await getSettings();
1777
- const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
1778
- const payer = await user.getUserFields(r.uid, ['email']);
1779
- const year = yearFromTs(r.start);
1780
-
1781
- // Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
1782
- const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
1783
-
1784
- // Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
1785
- // and to ensure checkout amount is always consistent.
1786
- let recomputedTotalCents = null;
1787
- try {
1788
- const { items: catalog } = await helloasso.listCatalogItems({
1789
- env: settings2.helloassoEnv,
1790
- token,
1791
- organizationSlug: settings2.helloassoOrganizationSlug,
1792
- formType: settings2.helloassoFormType,
1793
- formSlug: autoFormSlugForYear(year),
1794
- });
1795
- const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
1796
- const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
1797
- const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
1798
- if (sumCentsPerDay > 0) {
1799
- recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
1800
- // Keep stored total in sync (euros) for emails/UX.
1801
- r.total = recomputedTotalCents / 100;
1802
- }
1803
- } catch (e) {
1804
- // ignore recompute failures; fallback to stored total
1805
- }
1806
-
1807
- const intent = await helloasso.createCheckoutIntent({
1808
- env: settings2.helloassoEnv,
1809
- token,
1810
- organizationSlug: settings2.helloassoOrganizationSlug,
1811
- formType: settings2.helloassoFormType,
1812
- // Form slug is derived from the year of the reservation start date
1813
- formSlug: autoFormSlugForYear(year),
1814
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
1815
- totalAmount: (() => {
1816
- const cents = (typeof recomputedTotalCents === 'number')
1817
- ? recomputedTotalCents
1818
- : Math.max(0, Math.round((Number(r.total) || 0) * 100));
1819
- if (!cents) {
1820
- console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
1821
- }
1822
- return cents;
1823
- })(),
1824
- payerEmail: payer && payer.email ? payer.email : '',
1825
- // By default, point to the forum base url so the webhook hits this NodeBB instance.
1826
- // Can be overridden via ACP setting `helloassoCallbackUrl`.
1827
- callbackUrl: normalizeReturnUrl(),
1828
- webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl),
1829
- itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
1830
- containsDonation: false,
1831
- metadata: {
1832
- reservationId: String(rid),
1833
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
1834
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1835
- },
1836
- });
1837
- const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
1838
- const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
1839
- if (paymentUrl) {
1840
- r.paymentUrl = paymentUrl;
1841
- if (checkoutIntentId) {
1842
- r.checkoutIntentId = checkoutIntentId;
1843
- }
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;
1844
1664
  } else {
1845
1665
  console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
1846
1666
  }
@@ -1878,8 +1698,8 @@ api.approveReservation = async function (req, res) {
1878
1698
  uid: requesterUid,
1879
1699
  username: requester && requester.username ? requester.username : '',
1880
1700
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1881
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1882
- 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)}`,
1883
1703
  start: formatFR(r.start),
1884
1704
  end: formatFR(r.end),
1885
1705
  pickupAddress: r.pickupAddress || '',
@@ -1889,6 +1709,7 @@ api.approveReservation = async function (req, res) {
1889
1709
  pickupLon: r.pickupLon || '',
1890
1710
  mapUrl,
1891
1711
  paymentUrl: r.paymentUrl || '',
1712
+ calendarUrl: `${forumBaseUrl()}/calendar`,
1892
1713
  ...buildCalendarLinks({
1893
1714
  type: 'reservation',
1894
1715
  id: String(r.rid),
@@ -1953,8 +1774,8 @@ api.refuseReservation = async function (req, res) {
1953
1774
  uid: requesterUid2,
1954
1775
  username: requester && requester.username ? requester.username : '',
1955
1776
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1956
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1957
- 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)}`,
1958
1779
  start: formatFR(r.start),
1959
1780
  end: formatFR(r.end),
1960
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.)
package/lib/scheduler.js CHANGED
@@ -253,6 +253,7 @@ async function processAwaitingPayment(preIds, preReservations) {
253
253
  itemNames: arrayifyNames(r),
254
254
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
255
255
  paymentUrl: r.paymentUrl || '',
256
+ calendarUrl: `${forumBaseUrl()}/calendar`,
256
257
  delayMinutes: holdMins,
257
258
  pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
258
259
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.89",
3
+ "version": "2.0.91",
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">
@@ -40,11 +40,16 @@
40
40
  <p><strong>Notes :</strong><br>{notes}</p>
41
41
  <!-- ENDIF notes -->
42
42
 
43
- <!-- IF paymentUrl -->
43
+ <!-- IF calendarUrl -->
44
44
  <p>
45
- <a href="{paymentUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
46
- Payer maintenant
45
+ <a href="{calendarUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
46
+ Accéder au calendrier pour payer
47
47
  </a>
48
48
  </p>
49
- <p style="font-size: 12px; color: #666;">Si le bouton ne fonctionne pas, utilisez ce lien : <a href="{paymentUrl}">{paymentUrl}</a></p>
50
- <!-- ENDIF paymentUrl -->
49
+ <p style="font-size: 12px; color: #666;">
50
+ Sur le calendrier, cliquez sur votre réservation puis sur le bouton <strong>« Payer maintenant »</strong>.
51
+ <!-- IF paymentUrl -->
52
+ <br>Vous pouvez aussi payer directement via ce lien (valable quelques heures) : <a href="{paymentUrl}">{paymentUrl}</a>
53
+ <!-- ENDIF paymentUrl -->
54
+ </p>
55
+ <!-- ENDIF calendarUrl -->
@@ -10,11 +10,16 @@
10
10
 
11
11
  <p>{dateRange}</p>
12
12
 
13
- <!-- IF paymentUrl -->
13
+ <!-- IF calendarUrl -->
14
14
  <p>
15
- <a href="{paymentUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
16
- Payer maintenant
15
+ <a href="{calendarUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
16
+ Accéder au calendrier pour payer
17
17
  </a>
18
18
  </p>
19
- <p style="font-size: 12px; color: #666;">Si le bouton ne fonctionne pas, utilisez ce lien : <a href="{paymentUrl}">{paymentUrl}</a></p>
20
- <!-- ENDIF paymentUrl -->
19
+ <p style="font-size: 12px; color: #666;">
20
+ Sur le calendrier, cliquez sur votre réservation puis sur le bouton <strong>« Payer maintenant »</strong>.
21
+ <!-- IF paymentUrl -->
22
+ <br>Vous pouvez aussi payer directement via ce lien (valable quelques heures) : <a href="{paymentUrl}">{paymentUrl}</a>
23
+ <!-- ENDIF paymentUrl -->
24
+ </p>
25
+ <!-- ENDIF calendarUrl -->