nodebb-plugin-equipment-calendar 0.5.0 → 0.6.1

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/library.js CHANGED
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // Node 18+ has global fetch; fallback to undici if needed
4
+ let fetchFn = global.fetch;
5
+ try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
6
+
3
7
  const db = require.main.require('./src/database');
4
8
  const meta = require.main.require('./src/meta');
5
9
  const groups = require.main.require('./src/groups');
@@ -29,6 +33,8 @@ const DEFAULT_SETTINGS = {
29
33
  ha_itemsFormType: '',
30
34
  ha_itemsFormSlug: '',
31
35
  ha_locationMapJson: '{}',
36
+ ha_calendarItemNamePrefix: 'Location matériel',
37
+ paymentTimeoutMinutes: 10,
32
38
  // HelloAsso
33
39
  ha_clientId: '',
34
40
  ha_clientSecret: '',
@@ -80,7 +86,7 @@ async function getHelloAssoAccessToken(settings) {
80
86
  formBody.set('client_secret', String(settings.ha_clientSecret || ''));
81
87
  }
82
88
 
83
- const resp = await fetch('https://api.helloasso.com/oauth2/token', {
89
+ const resp = await fetchFn('https://api.helloasso.com/oauth2/token', {
84
90
  method: 'POST',
85
91
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
86
92
  body: formBody.toString(),
@@ -111,6 +117,93 @@ async function getHelloAssoAccessToken(settings) {
111
117
  return accessToken;
112
118
  }
113
119
 
120
+
121
+ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
122
+ const org = String(settings.ha_organizationSlug || '').trim();
123
+ if (!org) throw new Error('HelloAsso organization slug missing');
124
+
125
+ const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
126
+ const backUrl = baseUrl + '/equipment/calendar';
127
+ const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
128
+ const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
129
+
130
+ const items = await getActiveItems(settings);
131
+ const byId = {};
132
+ items.forEach(i => { byId[i.id] = i; });
133
+
134
+ const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
135
+ const totalAmount = reservations.reduce((sum, r) => {
136
+ const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
137
+ return sum + (Number.isFinite(price) ? price : 0);
138
+ }, 0);
139
+
140
+ if (!totalAmount || totalAmount <= 0) {
141
+ throw new Error('Montant total invalide (prix manquant sur les items).');
142
+ }
143
+
144
+ const body = {
145
+ totalAmount,
146
+ initialAmount: totalAmount,
147
+ itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
148
+ backUrl,
149
+ errorUrl,
150
+ returnUrl,
151
+ containsDonation: false,
152
+ metadata: {
153
+ bookingId,
154
+ rids: reservations.map(r => r.rid || r.id),
155
+ uid: reservations[0] && reservations[0].uid,
156
+ startMs: reservations[0] && reservations[0].startMs,
157
+ endMs: reservations[0] && reservations[0].endMs,
158
+ },
159
+ };
160
+
161
+ const token = await getHelloAssoAccessToken(settings);
162
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
163
+ const resp = await fetchFn(url, {
164
+ method: 'POST',
165
+ headers: {
166
+ accept: 'application/json',
167
+ 'content-type': 'application/json',
168
+ authorization: `Bearer ${token}`,
169
+ },
170
+ body: JSON.stringify(body),
171
+ });
172
+
173
+ if (!resp.ok) {
174
+ const t = await resp.text();
175
+ throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
176
+ }
177
+ return await resp.json();
178
+ }
179
+
180
+ async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
181
+ const org = String(settings.ha_organizationSlug || '').trim();
182
+ const token = await getHelloAssoAccessToken(settings);
183
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
184
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
185
+ if (!resp.ok) {
186
+ const t = await resp.text();
187
+ throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
188
+ }
189
+ return await resp.json();
190
+ }
191
+
192
+ function isCheckoutPaid(checkout) {
193
+ const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
194
+ const list = Array.isArray(payments) ? payments : [];
195
+ const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
196
+ if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
197
+
198
+ const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
199
+ if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
200
+
201
+ if (checkout && (checkout.order || checkout.orderId)) {
202
+ if (list.length) return true;
203
+ }
204
+ return false;
205
+ }
206
+
114
207
  async function fetchHelloAssoItems(settings) {
115
208
  const org = String(settings.ha_organizationSlug || '').trim();
116
209
  const formType = String(settings.ha_itemsFormType || '').trim();
@@ -128,7 +221,7 @@ async function fetchHelloAssoItems(settings) {
128
221
 
129
222
  const token = await getHelloAssoAccessToken(settings);
130
223
  const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
131
- const resp = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
224
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}` } });
132
225
  if (!resp.ok) {
133
226
  const t = await resp.text();
134
227
  throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
@@ -207,11 +300,30 @@ function resKey(id) { return `equipmentCalendar:res:${id}`; }
207
300
  function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
208
301
 
209
302
  function statusBlocksItem(status) {
210
- // statuses that block availability
211
- return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
303
+ const s = String(status || '');
304
+ if (s === 'rejected' || s === 'cancelled') return false;
305
+ return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
306
+ }
307
+
308
+
309
+
310
+
311
+ async function saveBooking(booking) {
312
+ const bid = booking.bookingId;
313
+ await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
314
+ }
315
+
316
+ async function getBooking(bookingId) {
317
+ return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
212
318
  }
213
319
 
320
+ async function addReservationToBooking(bookingId, rid) {
321
+ await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
322
+ }
214
323
 
324
+ async function getBookingRids(bookingId) {
325
+ return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
326
+ }
215
327
 
216
328
  async function saveReservation(res) {
217
329
  await db.setObject(resKey(res.id), res);
@@ -684,6 +796,65 @@ async function renderAdminPage(req, res) {
684
796
  }
685
797
 
686
798
  // --- Calendar page ---
799
+
800
+ async function handleHelloAssoCallback(req, res) {
801
+ const settings = await getSettings();
802
+
803
+ const bookingId = String(req.query.bookingId || '').trim();
804
+ const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
805
+ const code = String(req.query.code || '').trim();
806
+ const type = String(req.query.type || '').trim();
807
+
808
+ let status = 'pending';
809
+ let message = 'Retour paiement reçu. Vérification en cours…';
810
+
811
+ try {
812
+ if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
813
+ const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
814
+
815
+ const paid = isCheckoutPaid(checkout);
816
+ if (paid) {
817
+ status = 'paid';
818
+ message = 'Paiement confirmé. Merci !';
819
+
820
+ if (bookingId) {
821
+ const rids = await getBookingRids(bookingId);
822
+ for (const rid of rids) {
823
+ // eslint-disable-next-line no-await-in-loop
824
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
825
+ if (!r || !r.rid) continue;
826
+ r.status = 'paid';
827
+ r.paidAt = Date.now();
828
+ r.checkoutIntentId = checkoutIntentId;
829
+ // eslint-disable-next-line no-await-in-loop
830
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
831
+ }
832
+ try {
833
+ const b = (await getBooking(bookingId)) || { bookingId };
834
+ b.status = 'paid';
835
+ b.checkoutIntentId = checkoutIntentId;
836
+ b.paidAt = Date.now();
837
+ await saveBooking(b);
838
+ } catch (e) {}
839
+ }
840
+ } else {
841
+ status = (type === 'error' || code) ? 'error' : 'pending';
842
+ message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
843
+ }
844
+ } catch (e) {
845
+ status = 'error';
846
+ message = e.message || 'Erreur de vérification paiement';
847
+ }
848
+
849
+ res.render('equipment-calendar/payment-return', {
850
+ title: 'Retour paiement',
851
+ status,
852
+ statusError: status === 'error',
853
+ statusPaid: status === 'paid',
854
+ message,
855
+ });
856
+ }
857
+
687
858
  async function renderCalendarPage(req, res) {
688
859
  const settings = await getSettings();
689
860
  const items = await getActiveItems(settings);
@@ -889,7 +1060,7 @@ async function renderApprovalsPage(req, res) {
889
1060
 
890
1061
  // --- Actions ---
891
1062
 
892
- async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser) {
1063
+ async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
893
1064
  const items = await getActiveItems(settings);
894
1065
  const item = items.find(i => i.id === itemId);
895
1066
  if (!item) {
@@ -912,9 +1083,13 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
912
1083
  endMs,
913
1084
  status: 'pending',
914
1085
  notesUser: notesUser || '',
1086
+ bookingId: bookingId || '',
915
1087
  createdAt: Date.now(),
916
1088
  };
917
1089
  await saveReservation(data);
1090
+ if (bookingId) {
1091
+ await addReservationToBooking(bookingId, rid);
1092
+ }
918
1093
  return data;
919
1094
  }
920
1095
 
@@ -1165,6 +1340,7 @@ async function handleAdminSave(req, res) {
1165
1340
  ha_webhookSecret: String(req.body.ha_webhookSecret || ''),
1166
1341
  defaultView: String(req.body.defaultView || DEFAULT_SETTINGS.defaultView),
1167
1342
  timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
1343
+ paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
1168
1344
  showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
1169
1345
  };
1170
1346
 
@@ -1175,4 +1351,54 @@ async function handleAdminSave(req, res) {
1175
1351
  }
1176
1352
  }
1177
1353
 
1354
+
1355
+ let paymentTimeoutInterval = null;
1356
+
1357
+ function startPaymentTimeoutScheduler() {
1358
+ if (paymentTimeoutInterval) return;
1359
+ // Run every minute
1360
+ paymentTimeoutInterval = setInterval(async () => {
1361
+ try {
1362
+ const settings = await getSettings();
1363
+ const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
1364
+ const cutoff = Date.now() - timeoutMin * 60 * 1000;
1365
+
1366
+ // Scan bookings keys (we keep an index set)
1367
+ const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
1368
+ for (const bid of bookingIds) {
1369
+ // eslint-disable-next-line no-await-in-loop
1370
+ const b = await getBooking(bid);
1371
+ if (!b || !b.bookingId) continue;
1372
+ const status = String(b.status || '');
1373
+ if (status !== 'payment_pending') continue;
1374
+
1375
+ const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
1376
+ if (!pendingAt || pendingAt > cutoff) continue;
1377
+
1378
+ // Timeout reached -> cancel booking + all reservations
1379
+ // eslint-disable-next-line no-await-in-loop
1380
+ const rids = await getBookingRids(bid);
1381
+ for (const rid of rids) {
1382
+ // eslint-disable-next-line no-await-in-loop
1383
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1384
+ if (!r || !r.rid) continue;
1385
+ if (String(r.status) === 'paid') continue; // safety
1386
+ r.status = 'cancelled';
1387
+ r.cancelledAt = Date.now();
1388
+ r.paymentUrl = '';
1389
+ // eslint-disable-next-line no-await-in-loop
1390
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
1391
+ }
1392
+
1393
+ b.status = 'cancelled';
1394
+ b.cancelledAt = Date.now();
1395
+ b.paymentUrl = '';
1396
+ await saveBooking(b);
1397
+ }
1398
+ } catch (e) {
1399
+ // ignore to keep scheduler alive
1400
+ }
1401
+ }, 60 * 1000);
1402
+ }
1403
+
1178
1404
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "scripts": [
27
27
  "public/js/client.js"
28
28
  ],
29
- "version": "0.3.1",
29
+ "version": "0.4.1",
30
30
  "minver": "4.7.1"
31
31
  }
@@ -95,6 +95,11 @@
95
95
 
96
96
  <div class="card card-body mb-3">
97
97
  <h5>Calendrier</h5>
98
+ <div class="mb-3">
99
+ <label class="form-label">Timeout paiement (minutes)</label>
100
+ <input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
101
+ <div class="form-text">Après validation, si le paiement n’est pas confirmé dans ce délai, la réservation est annulée et le matériel est débloqué.</div>
102
+ </div>
98
103
  <div class="mb-3">
99
104
  <label class="form-label">Vue par défaut</label>
100
105
  <select name="defaultView" class="form-select">
@@ -1,9 +1,15 @@
1
- <div class="equipment-payment-return">
2
- <h1>Paiement</h1>
1
+ <div class="card card-body">
2
+ <h4>Retour paiement</h4>
3
+
4
+ {{{ if statusPaid }}}
5
+ <div class="alert alert-success">✅ Paiement confirmé.</div>
6
+ {{{ end }}}
7
+
3
8
  {{{ if statusError }}}
4
- <div class="alert alert-danger">Le paiement semble avoir échoué. Référence réservation: <code>{rid}</code></div>
5
- {{{ else }}}
6
- <div class="alert alert-info">Merci. Si le paiement est confirmé, la réservation passera en "validée". Référence: <code>{rid}</code></div>
9
+ <div class="alert alert-danger">❌ Paiement non confirmé.</div>
7
10
  {{{ end }}}
11
+
12
+ <p>{message}</p>
13
+
8
14
  <a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
9
15
  </div>