nodebb-plugin-onekite-calendar 2.0.82 → 2.0.84

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
@@ -8,7 +8,6 @@ const nconf = require.main.require('nconf');
8
8
 
9
9
  const shared = require('./shared');
10
10
  const {
11
- forumBaseUrl,
12
11
  formatFR,
13
12
  sendEmail,
14
13
  buildCalendarLinks,
@@ -111,39 +110,67 @@ admin.approveReservation = async function (req, res) {
111
110
  let paymentUrl = null;
112
111
  if (token) {
113
112
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
114
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
115
- const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
116
- const base = forumBaseUrl();
117
- const returnUrl = base ? `${base}/calendar` : '';
118
- const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
119
113
  const year = new Date(Number(r.start)).getFullYear();
120
- const intent = await helloasso.createCheckoutIntent({
121
- env,
122
- token,
123
- organizationSlug: settings.helloassoOrganizationSlug,
124
- formType: settings.helloassoFormType,
125
- // Form slug is derived from the year
126
- formSlug: `locations-materiel-${year}`,
127
- totalAmount,
128
- payerEmail: requester && requester.email,
129
- // User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
130
- callbackUrl: returnUrl,
131
- webhookUrl: webhookUrl,
132
- itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
133
- containsDonation: false,
134
- metadata: {
135
- reservationId: String(rid),
136
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
137
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
138
- },
139
- });
140
- paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
141
- ? (intent.paymentUrl || intent.redirectUrl)
142
- : (typeof intent === 'string' ? intent : null);
143
- if (intent && intent.checkoutIntentId) {
144
- r.checkoutIntentId = intent.checkoutIntentId;
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
145
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 });
145
+ } 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
+ }
146
172
  }
173
+ }
147
174
 
148
175
  if (paymentUrl) {
149
176
  r.paymentUrl = paymentUrl;
@@ -441,6 +468,44 @@ admin.debugHelloAsso = async function (req, res) {
441
468
  out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
442
469
  }
443
470
 
471
+ // Test checkout intent creation with a 1-cent amount to validate URL format.
472
+ // This creates a real (but minimal) intent — it will expire quickly and can be ignored.
473
+ out.checkoutIntentTest = { ok: false };
474
+ try {
475
+ const callbackUrl = normalizeReturnUrl();
476
+ const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
477
+ if (!callbackUrl) {
478
+ out.checkoutIntentTest = { ok: false, error: 'callbackUrl empty — forum base URL not configured' };
479
+ } else {
480
+ const intent = await helloasso.createCheckoutIntent({
481
+ env,
482
+ token,
483
+ organizationSlug: settings.helloassoOrganizationSlug,
484
+ formType: settings.helloassoFormType,
485
+ formSlug: `locations-materiel-${new Date().getFullYear()}`,
486
+ totalAmount: 100,
487
+ payerEmail: '',
488
+ callbackUrl,
489
+ webhookUrl,
490
+ itemName: '[TEST] Vérification lien paiement',
491
+ containsDonation: false,
492
+ metadata: { debug: 'true' },
493
+ });
494
+ if (intent && intent.paymentUrl) {
495
+ out.checkoutIntentTest = {
496
+ ok: true,
497
+ checkoutIntentId: intent.checkoutIntentId,
498
+ paymentUrl: intent.paymentUrl,
499
+ note: 'Cet intent de test (1 cent) peut être ignoré — il expirera automatiquement',
500
+ };
501
+ } else {
502
+ out.checkoutIntentTest = { ok: false, error: 'no paymentUrl in response', raw: intent };
503
+ }
504
+ }
505
+ } catch (e) {
506
+ out.checkoutIntentTest = { ok: false, error: String(e && e.message ? e.message : e) };
507
+ }
508
+
444
509
  return res.json(out);
445
510
  } catch (e) {
446
511
  out.ok = false;
package/lib/api.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  signCalendarLink,
32
32
  ymdToCompact,
33
33
  dtToGCalUtc,
34
+ getSetting,
34
35
  } = shared;
35
36
 
36
37
  const helloasso = require('./helloasso');
@@ -757,6 +758,8 @@ api.leaveSpecialEvent = async function (req, res) {
757
758
  api.getCapabilities = async function (req, res) {
758
759
  const settings = await getSettings();
759
760
  const uid = req.uid || 0;
761
+ const pendingHoldMinutes = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
762
+ const paymentHoldMinutes = parseInt(getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')), 10) || 60;
760
763
  if (!uid) {
761
764
  return res.json({
762
765
  canModerate: false,
@@ -765,6 +768,8 @@ api.getCapabilities = async function (req, res) {
765
768
  canCreateOuting: false,
766
769
  canCreateReservation: false,
767
770
  specialEventCategoryCid: 0,
771
+ pendingHoldMinutes,
772
+ paymentHoldMinutes,
768
773
  });
769
774
  }
770
775
  const [canMod, canSpecialC, canSpecialD, canReq] = await Promise.all([
@@ -781,6 +786,8 @@ api.getCapabilities = async function (req, res) {
781
786
  canCreateOuting: canMod || canReq,
782
787
  canCreateReservation: canReq,
783
788
  specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
789
+ pendingHoldMinutes,
790
+ paymentHoldMinutes,
784
791
  });
785
792
  };
786
793
 
@@ -1369,7 +1376,69 @@ api.createReservation = async function (req, res) {
1369
1376
  manuallyPaid: (isRegularizationSelf || isRegularization) ? true : undefined,
1370
1377
  };
1371
1378
 
1372
- // Validator self-reservations are FREE (no payment required) and tracked separately in accounting.
1379
+ // For past-date reservations created for another user (isRegularizationOther):
1380
+ // create the HelloAsso checkout intent immediately so the user has a payment link right away.
1381
+ if (isRegularizationOther) {
1382
+ try {
1383
+ const token = await helloasso.getAccessToken({ env: settings.helloassoEnv || 'prod', clientId: settings.helloassoClientId, clientSecret: settings.helloassoClientSecret });
1384
+ const payer = await user.getUserFields(targetUid, ['email']);
1385
+ const year = yearFromTs(resv.start);
1386
+ const days = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : nbDays;
1387
+
1388
+ let recomputedTotalCents = null;
1389
+ try {
1390
+ const { items: catalog } = await helloasso.listCatalogItems({
1391
+ env: settings.helloassoEnv,
1392
+ token,
1393
+ organizationSlug: settings.helloassoOrganizationSlug,
1394
+ formType: settings.helloassoFormType,
1395
+ formSlug: autoFormSlugForYear(year),
1396
+ });
1397
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
1398
+ const ids = itemIds.map(String);
1399
+ const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
1400
+ if (sumCentsPerDay > 0) {
1401
+ recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
1402
+ resv.total = recomputedTotalCents / 100;
1403
+ }
1404
+ } catch (e) {}
1405
+
1406
+ const intent = await helloasso.createCheckoutIntent({
1407
+ env: settings.helloassoEnv,
1408
+ token,
1409
+ organizationSlug: settings.helloassoOrganizationSlug,
1410
+ formType: settings.helloassoFormType,
1411
+ formSlug: autoFormSlugForYear(year),
1412
+ totalAmount: (() => {
1413
+ const cents = (typeof recomputedTotalCents === 'number')
1414
+ ? recomputedTotalCents
1415
+ : Math.max(0, Math.round((Number(resv.total) || 0) * 100));
1416
+ if (!cents) console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (regularizationOther)', { rid });
1417
+ return cents;
1418
+ })(),
1419
+ payerEmail: payer && payer.email ? payer.email : '',
1420
+ callbackUrl: normalizeReturnUrl(),
1421
+ webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
1422
+ itemName: buildHelloAssoItemName('', resv.itemNames || [], resv.start, resv.end),
1423
+ containsDonation: false,
1424
+ metadata: {
1425
+ reservationId: String(rid),
1426
+ items: resv.itemNames || [],
1427
+ dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
1428
+ },
1429
+ });
1430
+ const intentPaymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
1431
+ const intentCheckoutId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
1432
+ if (intentPaymentUrl) {
1433
+ resv.paymentUrl = intentPaymentUrl;
1434
+ if (intentCheckoutId) resv.checkoutIntentId = intentCheckoutId;
1435
+ } else {
1436
+ console.warn('[calendar-onekite] HelloAsso payment link not created (regularizationOther)', { rid });
1437
+ }
1438
+ } catch (e) {
1439
+ console.warn('[calendar-onekite] Failed to create HelloAsso checkout for regularizationOther', e && e.message ? e.message : e);
1440
+ }
1441
+ }
1373
1442
 
1374
1443
  // Save
1375
1444
  await dbLayer.saveReservation(resv);
@@ -1456,15 +1525,48 @@ api.createReservation = async function (req, res) {
1456
1525
  // Notify target user when a validator created the reservation on their behalf
1457
1526
  if (!isForSelf) {
1458
1527
  try {
1459
- await sendEmail('calendar-onekite_pending_for_you', targetUid, 'Location matériel - Demande créée en votre nom', {
1460
- uid: targetUid,
1461
- username: targetUsername,
1462
- createdBy: creatorUsername,
1463
- itemName: (resv.itemNames || []).join(', '),
1464
- itemNames: resv.itemNames || [],
1465
- dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
1466
- total: resv.total || 0,
1467
- });
1528
+ if (isRegularizationOther) {
1529
+ // Reservation is already awaiting_payment: send the approved email with payment link
1530
+ await sendEmail('calendar-onekite_approved', targetUid, 'Location matériel - Réservation validée', {
1531
+ uid: targetUid,
1532
+ username: targetUsername,
1533
+ itemName: (resv.itemNames || []).join(', '),
1534
+ itemNames: resv.itemNames || [],
1535
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
1536
+ start: formatFR(start),
1537
+ end: formatFR(end),
1538
+ pickupAddress: '',
1539
+ notes: '',
1540
+ pickupTime: '',
1541
+ pickupLat: '',
1542
+ pickupLon: '',
1543
+ mapUrl: '',
1544
+ paymentUrl: resv.paymentUrl || '',
1545
+ validatedBy: creatorUsername || '',
1546
+ validatedByUrl: creatorUsername ? `${forumBaseUrl()}/user/${encodeURIComponent(String(creatorUsername))}` : '',
1547
+ ...buildCalendarLinks({
1548
+ type: 'reservation',
1549
+ id: String(resv.rid),
1550
+ uid: targetUid,
1551
+ title: (resv.itemNames && resv.itemNames.length) ? `Location - ${resv.itemNames.join(', ')}` : 'Location',
1552
+ details: (resv.itemNames && resv.itemNames.length) ? `Matériel: ${resv.itemNames.join(', ')}` : '',
1553
+ location: '',
1554
+ allDay: true,
1555
+ startYmd: startDate || new Date(start).toISOString().slice(0, 10),
1556
+ endYmd: endDate || new Date(end).toISOString().slice(0, 10),
1557
+ }),
1558
+ });
1559
+ } else {
1560
+ await sendEmail('calendar-onekite_pending_for_you', targetUid, 'Location matériel - Demande créée en votre nom', {
1561
+ uid: targetUid,
1562
+ username: targetUsername,
1563
+ createdBy: creatorUsername,
1564
+ itemName: (resv.itemNames || []).join(', '),
1565
+ itemNames: resv.itemNames || [],
1566
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
1567
+ total: resv.total || 0,
1568
+ });
1569
+ }
1468
1570
  } catch (e) {
1469
1571
  console.warn('[calendar-onekite] Failed to send pending_for_you email', e && e.message ? e.message : e);
1470
1572
  }
package/lib/helloasso.js CHANGED
@@ -306,7 +306,17 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
306
306
  };
307
307
  const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
308
308
  if (status >= 200 && status < 300 && json) {
309
- return { paymentUrl: (json.redirectUrl || json.checkoutUrl || json.url || null), checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
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
+ return { paymentUrl: redirectUrl, checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
310
320
  }
311
321
  // Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
312
322
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.82",
3
+ "version": "2.0.84",
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/client.js CHANGED
@@ -1123,6 +1123,13 @@ function toDatetimeLocalValue(date) {
1123
1123
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
1124
1124
  }
1125
1125
 
1126
+ function fmtDuration(minutes) {
1127
+ if (minutes < 60) return `${minutes} min`;
1128
+ const h = Math.floor(minutes / 60);
1129
+ const rem = minutes % 60;
1130
+ return rem ? `${h} h ${rem} min` : `${h} h`;
1131
+ }
1132
+
1126
1133
  async function openReservationDialog(selectionInfo, items, opts) {
1127
1134
  const isValidatorMode = !!(opts && opts.isValidator);
1128
1135
  const start = selectionInfo.start;
@@ -1237,6 +1244,15 @@ function toDatetimeLocalValue(date) {
1237
1244
  </div>
1238
1245
  ` : '';
1239
1246
 
1247
+ const holdPending = parseInt((opts && opts.pendingHoldMinutes), 10) || 5;
1248
+ const holdPayment = parseInt((opts && opts.paymentHoldMinutes), 10) || 60;
1249
+ const delayBannerHtml = `
1250
+ <div class="mt-2 p-2 rounded" style="font-size: 12px; background: var(--bs-info-bg-subtle, #cff4fc); border: 1px solid var(--bs-info-border-subtle, #9eeaf9); color: var(--bs-info-text-emphasis, #055160);">
1251
+ <strong>Délais :</strong>
1252
+ validation sous <strong>${fmtDuration(holdPending)}</strong> · paiement sous <strong>${fmtDuration(holdPayment)}</strong> après validation
1253
+ </div>
1254
+ `;
1255
+
1240
1256
  const messageHtml = `
1241
1257
  <div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(endDisplay)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
1242
1258
  ${shortcutsHtml}
@@ -1253,6 +1269,7 @@ function toDatetimeLocalValue(date) {
1253
1269
  <div id="onekite-total" style="font-size: 18px;"><strong>0,00 €</strong></div>
1254
1270
  </div>
1255
1271
  <div class="text-muted" style="font-size: 12px;">Les matériels grisés sont déjà réservés ou en attente.</div>
1272
+ ${delayBannerHtml}
1256
1273
  `;
1257
1274
 
1258
1275
  return new Promise((resolve) => {
@@ -1429,6 +1446,8 @@ function toDatetimeLocalValue(date) {
1429
1446
  const canCreateReservation = !!caps.canCreateReservation;
1430
1447
  const isValidator = !!caps.isValidator;
1431
1448
  const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
1449
+ const pendingHoldMinutes = parseInt(caps.pendingHoldMinutes, 10) || 5;
1450
+ const paymentHoldMinutes = parseInt(caps.paymentHoldMinutes, 10) || 60;
1432
1451
 
1433
1452
  // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1434
1453
 
@@ -1577,7 +1596,7 @@ function toDatetimeLocalValue(date) {
1577
1596
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1578
1597
  return;
1579
1598
  }
1580
- const chosen = await openReservationDialog(sel, items, { isValidator });
1599
+ const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1581
1600
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1582
1601
  const startDate = toLocalYmd(sel.start);
1583
1602
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
@@ -1669,7 +1688,7 @@ function toDatetimeLocalValue(date) {
1669
1688
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1670
1689
  return;
1671
1690
  }
1672
- const chosen = await openReservationDialog(sel, items, { isValidator });
1691
+ const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1673
1692
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1674
1693
  const startDate = toLocalYmd(sel.start);
1675
1694
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);