nodebb-plugin-onekite-calendar 2.0.71 → 2.0.73

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
@@ -20,10 +20,6 @@ const {
20
20
  autoFormSlugForYear,
21
21
  } = shared;
22
22
 
23
- // Kept for local compatibility in accounting helper
24
- function baseUrl() { return forumBaseUrl(); }
25
-
26
-
27
23
  const dbLayer = require('./db');
28
24
  const helloasso = require('./helloasso');
29
25
 
@@ -200,6 +196,67 @@ admin.approveReservation = async function (req, res) {
200
196
  res.json({ ok: true, paymentUrl: paymentUrl || null });
201
197
  };
202
198
 
199
+ admin.markReservationPaid = async function (req, res) {
200
+ const rid = req.params.rid;
201
+ const r = await dbLayer.getReservation(rid);
202
+ if (!r) return res.status(404).json({ error: 'not-found' });
203
+
204
+ const note = String((req.body && req.body.note) || '').trim();
205
+ const manualPaymentId = String((req.body && req.body.paymentId) || '').trim();
206
+
207
+ r.status = 'paid';
208
+ r.paidAt = Date.now();
209
+ r.manuallyPaid = true;
210
+ r.manuallyPaidBy = req.uid;
211
+ r.manuallyPaidNote = note;
212
+ if (manualPaymentId) r.paymentId = manualPaymentId;
213
+ await dbLayer.saveReservation(r);
214
+
215
+ // Audit
216
+ try {
217
+ const year = new Date().getFullYear();
218
+ await dbLayer.addAuditEntry({
219
+ ts: Date.now(), year, action: 'reservation_manually_paid',
220
+ targetType: 'reservation', targetId: String(r.rid),
221
+ reservationUid: Number(r.uid) || 0,
222
+ reservationUsername: String(r.username || ''),
223
+ actorUid: req.uid || 0,
224
+ note,
225
+ });
226
+ } catch (e) {}
227
+
228
+ realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(rid), status: 'paid' });
229
+
230
+ // Email requester
231
+ try {
232
+ const requesterUid = parseInt(r.uid, 10);
233
+ const requester = await user.getUserFields(requesterUid, ['username']);
234
+ if (requesterUid) {
235
+ const { sendEmail, formatFR, buildCalendarLinks } = shared;
236
+ await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
237
+ uid: requesterUid,
238
+ username: requester && requester.username ? requester.username : '',
239
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
240
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
241
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
242
+ paymentReceiptUrl: '',
243
+ pickupTime: r.pickupTime || '',
244
+ pickupAddress: r.pickupAddress || '',
245
+ mapUrl: '',
246
+ ...(buildCalendarLinks({
247
+ type: 'reservation', id: String(r.rid || ''), uid: Number(r.uid) || 0,
248
+ itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
249
+ pickupAddress: r.pickupAddress || '', allDay: true,
250
+ startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
251
+ endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
252
+ })),
253
+ });
254
+ }
255
+ } catch (e) {}
256
+
257
+ res.json({ ok: true });
258
+ };
259
+
203
260
  admin.refuseReservation = async function (req, res) {
204
261
  const rid = req.params.rid;
205
262
  const r = await dbLayer.getReservation(rid);
package/lib/api.js CHANGED
@@ -361,12 +361,6 @@ function eventsForOuting(o) {
361
361
 
362
362
  const api = {};
363
363
 
364
- // baseUrl, hmacSecret, signCalendarLink, ymdToCompact, dtToGCalUtc,
365
- // buildCalendarLinks are imported from shared.js
366
- function baseUrl() {
367
- return forumBaseUrl();
368
- }
369
-
370
364
  function computeEtag(payload) {
371
365
  // Weak ETag is fine here: it is only used to skip identical JSON payloads.
372
366
  const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
@@ -773,19 +767,19 @@ api.getCapabilities = async function (req, res) {
773
767
  specialEventCategoryCid: 0,
774
768
  });
775
769
  }
776
- const [canMod, canSpecialC, canSpecialD, canOuting, canRes] = await Promise.all([
770
+ const [canMod, canSpecialC, canSpecialD, canReq] = await Promise.all([
777
771
  canValidate(uid, settings),
778
772
  canCreateSpecial(uid, settings),
779
773
  canDeleteSpecial(uid, settings),
780
774
  canRequest(uid, settings, Date.now()),
781
- canRequest(uid, settings, Date.now()),
782
775
  ]);
783
776
  res.json({
784
777
  canModerate: canMod,
778
+ isValidator: canMod,
785
779
  canCreateSpecial: canSpecialC,
786
780
  canDeleteSpecial: canSpecialD,
787
- canCreateOuting: canOuting,
788
- canCreateReservation: canRes,
781
+ canCreateOuting: canReq,
782
+ canCreateReservation: canReq,
789
783
  specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
790
784
  });
791
785
  };
@@ -1231,37 +1225,20 @@ api.createReservation = async function (req, res) {
1231
1225
  const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
1232
1226
  const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
1233
1227
 
1234
- // Validators can create "free" reservations that skip the payment workflow.
1235
- // However, long rentals should follow the normal paid workflow.
1236
- // Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
1237
- let validatorFreeMaxDays = 0;
1238
- try {
1239
- const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
1240
- validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
1241
- } catch (e) {
1242
- validatorFreeMaxDays = 0;
1243
- }
1244
-
1245
1228
  // Reliable calendar-day count (endDate is EXCLUSIVE)
1246
1229
  const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
1247
1230
 
1248
- // A validator is "free" only if the rental duration is within the configured threshold.
1249
- const isValidatorFree = !!isValidator && (validatorFreeMaxDays <= 0 || nbDays <= validatorFreeMaxDays);
1231
+ // Detect past dates once (server-local midnight)
1232
+ const _today0 = new Date();
1233
+ _today0.setHours(0, 0, 0, 0);
1234
+ const isPastDate = start < _today0.getTime();
1250
1235
 
1251
- // Business rule: a reservation cannot start in the past.
1252
- // We compare against server-local midnight. (Front-end also prevents it.)
1253
- try {
1254
- const today0 = new Date();
1255
- today0.setHours(0, 0, 0, 0);
1256
- const today0ts = today0.getTime();
1257
- if (start < today0ts) {
1258
- return res.status(400).json({
1259
- error: 'date-too-soon',
1260
- message: "Impossible de réserver pour une date passée.",
1261
- });
1262
- }
1263
- } catch (e) {
1264
- // If anything goes wrong, fail safe by allowing the normal flow.
1236
+ // Block past dates for non-validators
1237
+ if (!isValidator && isPastDate) {
1238
+ return res.status(400).json({
1239
+ error: 'date-too-soon',
1240
+ message: "Impossible de réserver pour une date passée.",
1241
+ });
1265
1242
  }
1266
1243
 
1267
1244
  // Support both legacy single itemId and new itemIds[] payload
@@ -1318,16 +1295,53 @@ api.createReservation = async function (req, res) {
1318
1295
  const now = Date.now();
1319
1296
  const rid = crypto.randomUUID();
1320
1297
 
1321
- // Snapshot username for display in calendar popups
1322
- let username = null;
1298
+ // Snapshot creator (validator) username for audit trail
1299
+ let creatorUsername = null;
1323
1300
  try {
1324
- username = await user.getUserField(uid, 'username');
1301
+ creatorUsername = await user.getUserField(uid, 'username');
1325
1302
  } catch (e) {}
1326
1303
 
1304
+ // Validators can create on behalf of another user
1305
+ let targetUid = uid;
1306
+ let targetUsername = creatorUsername;
1307
+ if (isValidator && req.body.targetUsername) {
1308
+ const tName = String(req.body.targetUsername).trim();
1309
+ if (tName) {
1310
+ try {
1311
+ const tUid = await user.getUidByUsername(tName);
1312
+ if (tUid) {
1313
+ targetUid = parseInt(tUid, 10);
1314
+ const tu = await user.getUserFields(targetUid, ['username']);
1315
+ targetUsername = (tu && tu.username) ? tu.username : tName;
1316
+ } else {
1317
+ return res.status(400).json({ error: 'user-not-found', message: `Utilisateur "${tName}" introuvable.` });
1318
+ }
1319
+ } catch (e) {
1320
+ return res.status(400).json({ error: 'user-not-found', message: "Impossible de trouver l'utilisateur." });
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ const isForSelf = (targetUid === uid);
1326
+
1327
+ // Free only when validator creates for themselves with a future date (within configured maxDays)
1328
+ const validatorFreeMaxDays = (() => {
1329
+ try {
1330
+ const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
1331
+ return Number.isFinite(v) ? v : 0;
1332
+ } catch (e) { return 0; }
1333
+ })();
1334
+ const isValidatorFree = !!isValidator && isForSelf && !isPastDate && (validatorFreeMaxDays <= 0 || nbDays <= validatorFreeMaxDays);
1335
+
1336
+ // Regularization: past-dated reservation created by a validator → paid immediately with amount
1337
+ const isRegularization = !!isValidator && isPastDate;
1338
+
1327
1339
  const resv = {
1328
1340
  rid,
1329
- uid,
1330
- username: username || null,
1341
+ uid: targetUid,
1342
+ username: targetUsername || null,
1343
+ createdBy: uid,
1344
+ createdByUsername: creatorUsername || null,
1331
1345
  itemIds,
1332
1346
  itemNames: itemNames.length ? itemNames : itemIds,
1333
1347
  // keep legacy fields for backward compatibility
@@ -1337,19 +1351,17 @@ api.createReservation = async function (req, res) {
1337
1351
  end,
1338
1352
  startDate,
1339
1353
  endDate,
1340
- status: isValidatorFree ? 'paid' : 'pending',
1354
+ status: (isValidatorFree || isRegularization) ? 'paid' : 'pending',
1341
1355
  createdAt: now,
1342
- paidAt: isValidatorFree ? now : 0,
1343
- approvedBy: isValidatorFree ? uid : 0,
1344
- // total is used for accounting (paid reservations).
1345
- // Validator self-reservations are FREE (no payment required) and must not be
1346
- // counted as revenue.
1356
+ paidAt: (isValidatorFree || isRegularization) ? now : 0,
1357
+ approvedBy: (isValidatorFree || isRegularization) ? uid : 0,
1358
+ approvedByUsername: (isValidatorFree || isRegularization) ? (creatorUsername || '') : '',
1347
1359
  isFree: !!isValidatorFree,
1348
1360
  total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
1361
+ manuallyPaid: isRegularization ? true : undefined,
1349
1362
  };
1350
1363
 
1351
- // NOTE: We intentionally do NOT compute a monetary total for validator self-reservations.
1352
- // Those are free "sorties" and are tracked separately in the accounting view.
1364
+ // Validator self-reservations are FREE (no payment required) and tracked separately in accounting.
1353
1365
 
1354
1366
  // Save
1355
1367
  await dbLayer.saveReservation(resv);
@@ -1358,12 +1370,17 @@ api.createReservation = async function (req, res) {
1358
1370
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'created', rid: resv.rid, status: resv.status });
1359
1371
 
1360
1372
  // Audit
1361
- await auditLog(isValidatorFree ? 'reservation_self_checked' : 'reservation_requested', uid, {
1373
+ const auditAction = isValidatorFree ? 'reservation_self_checked'
1374
+ : isRegularization ? 'reservation_manually_paid'
1375
+ : 'reservation_requested';
1376
+ await auditLog(auditAction, uid, {
1362
1377
  targetType: 'reservation',
1363
1378
  targetId: String(resv.rid),
1364
- uid: Number(uid) || 0,
1365
- requesterUid: Number(uid) || 0,
1366
- requesterUsername: username || '',
1379
+ uid: Number(targetUid) || 0,
1380
+ requesterUid: Number(targetUid) || 0,
1381
+ requesterUsername: targetUsername || '',
1382
+ createdBy: Number(uid) || 0,
1383
+ createdByUsername: creatorUsername || '',
1367
1384
  itemIds: resv.itemIds || [],
1368
1385
  itemNames: resv.itemNames || [],
1369
1386
  startDate: resv.startDate || '',
@@ -1371,12 +1388,12 @@ api.createReservation = async function (req, res) {
1371
1388
  status: resv.status,
1372
1389
  });
1373
1390
 
1374
- if (!isValidatorFree) {
1391
+ if (!isValidatorFree && !isRegularization) {
1375
1392
 
1376
1393
 
1377
1394
  // Notify groups by email (NodeBB emailer config)
1378
1395
  try {
1379
- const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
1396
+ const notifyGroups = normalizeAllowedGroups(settings.notifyGroups);
1380
1397
  if (notifyGroups.length) {
1381
1398
  const requester = await user.getUserFields(uid, ['username', 'email']);
1382
1399
  const itemsLabel = (resv.itemNames || []).join(', ');
@@ -1445,7 +1462,7 @@ api.createReservation = async function (req, res) {
1445
1462
 
1446
1463
  }
1447
1464
 
1448
- res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
1465
+ res.json({ ok: true, rid, status: resv.status, autoPaid: !!(isValidatorFree || isRegularization) });
1449
1466
  };
1450
1467
 
1451
1468
  // Validator actions (from calendar popup)
@@ -1601,18 +1618,7 @@ api.approveReservation = async function (req, res) {
1601
1618
  pickupLon: r.pickupLon || '',
1602
1619
  mapUrl,
1603
1620
  paymentUrl: r.paymentUrl || '',
1604
- icsUrl: (buildCalendarLinks({
1605
- type: 'reservation',
1606
- id: String(r.rid),
1607
- uid: requesterUid,
1608
- title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
1609
- details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
1610
- location: r.pickupAddress || '',
1611
- allDay: true,
1612
- startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
1613
- endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
1614
- })).icsUrl,
1615
- googleCalUrl: (buildCalendarLinks({
1621
+ ...buildCalendarLinks({
1616
1622
  type: 'reservation',
1617
1623
  id: String(r.rid),
1618
1624
  uid: requesterUid,
@@ -1622,7 +1628,7 @@ api.approveReservation = async function (req, res) {
1622
1628
  allDay: true,
1623
1629
  startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
1624
1630
  endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
1625
- })).googleCalUrl,
1631
+ }),
1626
1632
  validatedBy: r.approvedByUsername || '',
1627
1633
  validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
1628
1634
  });
@@ -1972,7 +1978,7 @@ api.getIcs = async function (req, res) {
1972
1978
  allDay: true,
1973
1979
  startYmd,
1974
1980
  endYmd,
1975
- url: `${baseUrl()}/calendar`,
1981
+ url: `${forumBaseUrl()}/calendar`,
1976
1982
  });
1977
1983
  } else if (type === 'special') {
1978
1984
  const ev = await dbLayer.getSpecialEvent(id);
@@ -1987,7 +1993,7 @@ api.getIcs = async function (req, res) {
1987
1993
  allDay: false,
1988
1994
  start,
1989
1995
  end,
1990
- url: `${baseUrl()}/calendar`,
1996
+ url: `${forumBaseUrl()}/calendar`,
1991
1997
  });
1992
1998
  } else if (type === 'outing') {
1993
1999
  const o = await dbLayer.getOuting(id);
@@ -2002,7 +2008,7 @@ api.getIcs = async function (req, res) {
2002
2008
  allDay: false,
2003
2009
  start,
2004
2010
  end,
2005
- url: `${baseUrl()}/calendar`,
2011
+ url: `${forumBaseUrl()}/calendar`,
2006
2012
  });
2007
2013
  } else {
2008
2014
  return res.status(400).send('unknown-type');
package/lib/db.js CHANGED
@@ -21,19 +21,6 @@ const KEY_MAINTENANCE_ZSET = 'calendar-onekite:maintenance:itemIds';
21
21
  const KEY_AUDIT_ZSET = (year) => `calendar-onekite:audit:${year}`;
22
22
  const KEY_AUDIT_OBJ = (id) => `calendar-onekite:audit:entry:${id}`;
23
23
 
24
- // Helpers
25
- function reservationKey(rid) {
26
- return KEY_OBJ(rid);
27
- }
28
-
29
- function specialKey(eid) {
30
- return KEY_SPECIAL_OBJ(eid);
31
- }
32
-
33
- function outingKey(oid) {
34
- return KEY_OUTING_OBJ(oid);
35
- }
36
-
37
24
  async function getReservation(rid) {
38
25
  return await db.getObject(KEY_OBJ(rid));
39
26
  }
@@ -45,7 +32,7 @@ async function getReservation(rid) {
45
32
  async function getReservations(rids) {
46
33
  const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
47
34
  if (!ids.length) return [];
48
- const keys = ids.map(reservationKey);
35
+ const keys = ids.map(KEY_OBJ);
49
36
  const rows = await db.getObjects(keys);
50
37
  // Ensure rid is present even if older objects were missing it.
51
38
  return (rows || []).map((row, idx) => {
@@ -198,7 +185,7 @@ module.exports = {
198
185
  getSpecialEvents: async (eids) => {
199
186
  const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
200
187
  if (!ids.length) return [];
201
- const keys = ids.map(specialKey);
188
+ const keys = ids.map(KEY_SPECIAL_OBJ);
202
189
  const rows = await db.getObjects(keys);
203
190
  return (rows || []).map((row, idx) => {
204
191
  if (!row) return null;
@@ -225,7 +212,7 @@ module.exports = {
225
212
  getOutings: async (oids) => {
226
213
  const ids = Array.isArray(oids) ? oids.filter(Boolean) : [];
227
214
  if (!ids.length) return [];
228
- const keys = ids.map(outingKey);
215
+ const keys = ids.map(KEY_OUTING_OBJ);
229
216
  const rows = await db.getObjects(keys);
230
217
  return (rows || []).map((row, idx) => {
231
218
  if (!row) return null;
@@ -1,21 +1,22 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('crypto');
4
-
5
3
  const db = require.main.require('./src/database');
6
4
  const meta = require.main.require('./src/meta');
7
5
  const user = require.main.require('./src/user');
8
- const nconf = require.main.require('nconf');
9
6
 
10
7
  const dbLayer = require('./db');
11
8
  const helloasso = require('./helloasso');
12
9
  const discord = require('./discord');
13
10
  const realtime = require('./realtime');
14
11
  const shared = require('./shared');
15
- const { formatFR, forumBaseUrl, sendEmail, buildCalendarLinks, signCalendarLink, ymdToCompact } = shared;
12
+ const { formatFR, sendEmail, buildCalendarLinks } = shared;
16
13
 
17
- function baseUrl() { return forumBaseUrl(); }
14
+ const SETTINGS_KEY = 'calendar-onekite';
15
+ const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
18
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
19
20
 
20
21
  function buildReservationCalendarLinks(r) {
21
22
  const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
@@ -53,19 +54,21 @@ async function auditLog(action, actorUid, payload) {
53
54
  } catch (e) {}
54
55
  }
55
56
 
56
- const SETTINGS_KEY = 'calendar-onekite';
57
-
58
- // Replay protection: store processed payment ids.
59
- const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
60
-
61
- // sendEmail imported from shared.js
57
+ function getClientIp(req) {
58
+ const cf = req.headers['cf-connecting-ip'];
59
+ if (cf) return String(cf).trim();
60
+ const xff = req.headers['x-forwarded-for'];
61
+ if (xff) return String(xff).split(',')[0].trim();
62
+ return String(req.ip || '').trim();
63
+ }
62
64
 
65
+ // Extract reservationId from the metadata field of a webhook payload.
66
+ // HelloAsso uses several payload shapes depending on event type and API version.
63
67
  function getReservationIdFromPayload(payload) {
64
68
  try {
65
69
  const data = payload && payload.data ? payload.data : null;
66
70
  if (!data) return null;
67
71
 
68
- // HelloAsso commonly uses "metadata" for checkout-intents/payments.
69
72
  const metaCandidates = [
70
73
  data.meta,
71
74
  data.metadata,
@@ -77,56 +80,81 @@ function getReservationIdFromPayload(payload) {
77
80
  if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
78
81
  return String(metaObj.reservationId);
79
82
  }
80
- // Some systems send metadata as array of key/value pairs
83
+ // Some implementations send metadata as an array of { key, value } pairs
81
84
  if (Array.isArray(metaObj)) {
82
85
  const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
83
86
  if (found && (found.value || found.val)) return String(found.value || found.val);
84
87
  }
85
88
  }
89
+ } catch (e) {}
90
+ return null;
91
+ }
92
+
93
+ function getCheckoutIntentIdFromPayload(payload) {
94
+ try {
95
+ const data = payload && payload.data ? payload.data : null;
96
+ if (!data) return null;
97
+ const candidates = [
98
+ data.checkoutIntentId,
99
+ data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
100
+ data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
101
+ ].filter(Boolean);
102
+ if (candidates.length) return String(candidates[0]);
103
+ } catch (e) {}
104
+ return null;
105
+ }
106
+
107
+ // Returns true for event types and states that represent a completed payment.
108
+ function isConfirmedPayment(payload) {
109
+ try {
110
+ if (!payload || !payload.data) return false;
111
+ const eventType = String(payload.eventType || '').toLowerCase();
112
+ const state = String(payload.data.state || payload.data.status || payload.data.paymentState || '').toLowerCase();
113
+ const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
114
+
115
+ if (eventType === 'payment') return okState;
116
+ if (eventType === 'order') {
117
+ // Order payloads sometimes omit the state field; accept when missing.
118
+ return !state || okState;
119
+ }
120
+ return false;
86
121
  } catch (e) {
87
- // ignore
122
+ return false;
88
123
  }
89
- return null;
90
124
  }
91
125
 
92
- async function tryRecoverReservationIdFromPayment(settings, paymentId) {
93
- if (!paymentId) return null;
126
+ async function alreadyProcessed(paymentId) {
127
+ if (!paymentId) return false;
94
128
  try {
95
- const token = await helloasso.getAccessToken({
96
- env: settings.helloassoEnv || 'live',
97
- clientId: settings.helloassoClientId,
98
- clientSecret: settings.helloassoClientSecret,
99
- });
100
- if (!token) return null;
101
- const details = await helloasso.getPaymentDetails({
102
- env: settings.helloassoEnv || 'live',
103
- token,
104
- paymentId,
105
- });
106
- if (!details) return null;
107
- // Reuse the same extraction logic on the payment details object.
108
- return getReservationIdFromPayload({ data: details });
129
+ return await db.isSetMember(PROCESSED_KEY, String(paymentId));
109
130
  } catch (e) {
110
- return null;
131
+ return false;
111
132
  }
112
133
  }
113
134
 
114
- async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId) {
135
+ async function markProcessed(paymentId) {
136
+ if (!paymentId) return;
137
+ try {
138
+ await db.setAdd(PROCESSED_KEY, String(paymentId));
139
+ } catch (e) {}
140
+ }
141
+
142
+ // Try to find the reservationId via the checkoutIntentId → rid mapping stored at approval time.
143
+ // Falls back to querying HelloAsso's API when the mapping is missing (older reservations).
144
+ async function tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId) {
115
145
  if (!checkoutIntentId) return null;
116
146
  try {
117
- // First, try a direct mapping stored when the checkout intent was created.
118
147
  const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
119
148
  if (mapped) return String(mapped);
120
149
 
121
- // If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
122
150
  const token = await helloasso.getAccessToken({
123
- env: settings.helloassoEnv || 'live',
151
+ env: settings.helloassoEnv || 'prod',
124
152
  clientId: settings.helloassoClientId,
125
153
  clientSecret: settings.helloassoClientSecret,
126
154
  });
127
155
  if (!token) return null;
128
156
  const details = await helloasso.getCheckoutIntentDetails({
129
- env: settings.helloassoEnv || 'live',
157
+ env: settings.helloassoEnv || 'prod',
130
158
  token,
131
159
  organizationSlug: settings.helloassoOrganizationSlug,
132
160
  checkoutIntentId,
@@ -134,171 +162,119 @@ async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutInten
134
162
  if (!details) return null;
135
163
  const rid = getReservationIdFromPayload({ data: details });
136
164
  if (rid) {
137
- // persist mapping for next webhooks
138
- try {
139
- await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
140
- } catch (e) {}
165
+ try { await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid)); } catch (e) {}
141
166
  return String(rid);
142
167
  }
143
- } catch (e) {
144
- return null;
145
- }
168
+ } catch (e) {}
146
169
  return null;
147
170
  }
148
171
 
149
- async function alreadyProcessed(paymentId) {
150
- if (!paymentId) return false;
151
- try {
152
- return await db.isSetMember(PROCESSED_KEY, String(paymentId));
153
- } catch (e) {
154
- return false;
155
- }
156
- }
157
-
158
- async function markProcessed(paymentId) {
159
- if (!paymentId) return;
160
- try {
161
- await db.setAdd(PROCESSED_KEY, String(paymentId));
162
- } catch (e) {
163
- // ignore
164
- }
165
- }
166
-
167
- function isConfirmedPayment(payload) {
172
+ // Last-resort: fetch the payment from HelloAsso's API and read metadata from there.
173
+ async function tryRecoverRidFromPayment(settings, paymentId) {
174
+ if (!paymentId) return null;
168
175
  try {
169
- if (!payload || !payload.data) return false;
170
- const eventType = String(payload.eventType || '').toLowerCase();
171
- const stateRaw = payload.data.state || payload.data.status || payload.data.paymentState || '';
172
- const state = String(stateRaw).toLowerCase();
173
-
174
- // We accept the most common "paid" states seen in HelloAsso webhooks.
175
- const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
176
-
177
- // HelloAsso may send eventType "Payment" and/or "Order".
178
- if (eventType === 'payment') {
179
- return okState;
180
- }
181
- if (eventType === 'order') {
182
- // Order payloads may not carry a payment-like "state" field; accept if missing,
183
- // but still require a recognizable "paid" state when provided.
184
- return !state || okState;
185
- }
186
- return false;
176
+ const token = await helloasso.getAccessToken({
177
+ env: settings.helloassoEnv || 'prod',
178
+ clientId: settings.helloassoClientId,
179
+ clientSecret: settings.helloassoClientSecret,
180
+ });
181
+ if (!token) return null;
182
+ const details = await helloasso.getPaymentDetails({
183
+ env: settings.helloassoEnv || 'prod',
184
+ token,
185
+ paymentId,
186
+ });
187
+ if (!details) return null;
188
+ return getReservationIdFromPayload({ data: details });
187
189
  } catch (e) {
188
- return false;
190
+ return null;
189
191
  }
190
192
  }
191
193
 
192
- function getCheckoutIntentIdFromPayload(payload) {
193
- try {
194
- const data = payload && payload.data ? payload.data : null;
195
- if (!data) return null;
196
- // Common locations based on HelloAsso docs and community reports:
197
- // - Order event often contains checkoutIntentId at root of data
198
- // - Return/back redirects contain checkoutIntentId in query params (not here)
199
- const candidates = [
200
- data.checkoutIntentId,
201
- data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
202
- data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
203
- ].filter(Boolean);
204
- if (candidates.length) return String(candidates[0]);
205
- } catch (e) {}
206
- return null;
207
- }
194
+ // ---------------------------------------------------------------------------
195
+ // Webhook handler
196
+ // ---------------------------------------------------------------------------
208
197
 
209
- /**
210
- * Hardened HelloAsso webhook handler.
211
- * - Requires x-ha-signature (HMAC SHA-256) verification.
212
- * - Only accepts eventType=Payment with state=Confirmed.
213
- * - Provides basic replay protection using the payment id.
214
- *
215
- * This handler is "safe" by default: if we cannot verify the signature,
216
- * we refuse the request.
217
- */
218
198
  async function handler(req, res, next) {
219
199
  try {
220
- if (req.method === 'GET') {
221
- return res.json({ ok: true });
222
- }
223
- if (req.method !== 'POST') {
224
- return res.status(405).json({ ok: false, error: 'method-not-allowed' });
225
- }
200
+ if (req.method === 'GET') return res.json({ ok: true });
201
+ if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'method-not-allowed' });
226
202
 
227
203
  const settings = await meta.settings.get(SETTINGS_KEY);
228
204
 
229
- // Restrict webhook calls by IP (HelloAsso partner mode signature is not used).
230
- const defaultAllowed = ['51.138.206.200', '4.233.135.234'];
231
- const raw = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
232
- const allowedIps = (raw ? raw.split(/[\s,;]+/g) : defaultAllowed).map(s => String(s || '').trim()).filter(Boolean);
233
-
234
- const clientIp = (() => {
235
- const cf = req.headers['cf-connecting-ip'];
236
- if (cf) return String(cf).trim();
237
- const xff = req.headers['x-forwarded-for'];
238
- if (xff) return String(xff).split(',')[0].trim();
239
- return String(req.ip || '').trim();
240
- })();
241
-
242
- if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
243
- // eslint-disable-next-line no-console
244
- console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
245
- return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
205
+ // Optional IP allowlist (configure helloassoWebhookAllowedIps in ACP).
206
+ // If empty, all IPs are accepted.
207
+ const rawIps = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
208
+ const allowedIps = rawIps ? rawIps.split(/[\s,;]+/g).map((s) => s.trim()).filter(Boolean) : [];
209
+ if (allowedIps.length) {
210
+ const clientIp = getClientIp(req);
211
+ if (!allowedIps.includes(clientIp)) {
212
+ // eslint-disable-next-line no-console
213
+ console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
214
+ return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
215
+ }
246
216
  }
247
217
 
248
- // At this point, the payload is trusted.
249
218
  const payload = req.body;
250
219
 
251
220
  if (!isConfirmedPayment(payload)) {
252
- // Acknowledge but ignore other event types/states.
253
221
  return res.json({ ok: true, ignored: true });
254
222
  }
255
223
 
256
- // Ignore incomplete Payment events (HelloAsso sometimes omits metadata and checkoutIntentId on Payment webhooks).
257
- // We rely on Order events (or checkoutIntent mappings) for reliable reconciliation.
258
- const _eventType = String((payload && payload.eventType) || '').toLowerCase();
259
- const _rid = getReservationIdFromPayload(payload);
260
- const _checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
261
- if (_eventType === 'payment' && !_rid && !_checkoutIntentId) {
224
+ const eventType = String((payload && payload.eventType) || '').toLowerCase();
225
+ const rid = getReservationIdFromPayload(payload);
226
+ const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
227
+
228
+ // Incomplete Payment events (no metadata, no checkoutIntentId) cannot be reconciled.
229
+ if (eventType === 'payment' && !rid && !checkoutIntentId) {
230
+ // eslint-disable-next-line no-console
231
+ console.warn('[calendar-onekite] HelloAsso webhook: Payment event has no metadata.reservationId and no checkoutIntentId', {
232
+ paymentId: payload && payload.data && payload.data.id,
233
+ dataKeys: payload && payload.data ? Object.keys(payload.data) : [],
234
+ });
262
235
  return res.json({ ok: true, ignored: true, incompletePayment: true });
263
236
  }
264
237
 
265
238
  const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
266
- // If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
267
239
  if (!paymentId) {
240
+ // eslint-disable-next-line no-console
241
+ console.warn('[calendar-onekite] HelloAsso webhook: missing payment id', { eventType, dataKeys: payload && payload.data ? Object.keys(payload.data) : [] });
268
242
  return res.json({ ok: true, ignored: true, missingPaymentId: true });
269
243
  }
244
+
270
245
  if (await alreadyProcessed(paymentId)) {
271
246
  return res.json({ ok: true, duplicate: true });
272
247
  }
273
248
 
274
- const rid = getReservationIdFromPayload(payload);
249
+ // Resolve the reservation ID: try metadata first, then checkoutIntentId mapping, then API call.
275
250
  let resolvedRid = rid;
276
- const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
277
251
  if (!resolvedRid && checkoutIntentId) {
278
- // Some webhook payloads omit metadata but provide checkoutIntentId (common on Order events).
279
- resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
252
+ resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
280
253
  }
281
- if (!resolvedRid && paymentId) {
282
- // Some webhook payloads omit metadata; try to fetch the payment details.
283
- resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
254
+ if (!resolvedRid) {
255
+ resolvedRid = await tryRecoverRidFromPayment(settings, paymentId);
284
256
  }
285
257
  if (!resolvedRid) {
286
258
  // eslint-disable-next-line no-console
287
- console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
288
- // Do NOT mark as processed: if metadata/config is fixed later, a manual replay may be possible.
259
+ console.warn('[calendar-onekite] HelloAsso webhook: could not resolve reservationId payment NOT recorded', { eventType, paymentId, checkoutIntentId });
260
+ // Do not mark as processed: a manual replay may be possible after fixing config.
289
261
  return res.json({ ok: true, processed: false, missingReservationId: true });
290
262
  }
291
263
 
292
264
  const r = await dbLayer.getReservation(resolvedRid);
293
265
  if (!r) {
266
+ // eslint-disable-next-line no-console
267
+ console.warn('[calendar-onekite] HelloAsso webhook: reservation not found (expired/deleted?)', { rid: resolvedRid, paymentId });
294
268
  await markProcessed(paymentId);
295
269
  return res.json({ ok: true, processed: true, reservationNotFound: true });
296
270
  }
297
271
 
298
- // Mark as paid and persist payment metadata.
272
+ // eslint-disable-next-line no-console
273
+ console.info('[calendar-onekite] HelloAsso webhook: marking reservation paid', { rid: resolvedRid, prevStatus: r.status, paymentId });
274
+
299
275
  r.status = 'paid';
300
276
  r.paidAt = Date.now();
301
- r.paymentId = paymentId ? String(paymentId) : '';
277
+ r.paymentId = String(paymentId);
302
278
  if (payload.data && payload.data.paymentReceiptUrl) {
303
279
  r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
304
280
  }
@@ -313,13 +289,11 @@ async function handler(req, res, next) {
313
289
  itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
314
290
  startDate: r.startDate || '',
315
291
  endDate: r.endDate || '',
316
- paymentId: r.paymentId || '',
292
+ paymentId: r.paymentId,
317
293
  });
318
294
 
319
- // Real-time notify: refresh calendars for all viewers
320
295
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: r.status });
321
296
 
322
- // Notify requester
323
297
  const requesterUid = parseInt(r.uid, 10);
324
298
  const requester = await user.getUserFields(requesterUid, ['username']);
325
299
  if (requesterUid) {
@@ -343,7 +317,6 @@ async function handler(req, res, next) {
343
317
  });
344
318
  }
345
319
 
346
- // Discord webhook (optional)
347
320
  try {
348
321
  await discord.notifyPaymentReceived(settings, {
349
322
  rid: r.rid,
@@ -364,6 +337,4 @@ async function handler(req, res, next) {
364
337
  }
365
338
  }
366
339
 
367
- module.exports = {
368
- handler,
369
- };
340
+ module.exports = { handler };
package/lib/scheduler.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const nconf = require.main.require('nconf');
4
4
  const db = require.main.require('./src/database');
5
+ const user = require.main.require('./src/user');
5
6
  const dbLayer = require('./db');
6
7
  const discord = require('./discord');
7
8
  const realtime = require('./realtime');
@@ -109,8 +110,6 @@ async function expirePending(preIds, preReservations) {
109
110
  const validatorReminderMins = parseInt(getSetting(settings, 'validatorReminderMinutesPending', '0'), 10) || 0;
110
111
  const now = Date.now();
111
112
 
112
- const user = require.main.require('./src/user');
113
-
114
113
  const adminUrl = (() => {
115
114
  const base = forumBaseUrl();
116
115
  return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
@@ -214,8 +213,6 @@ async function processAwaitingPayment(preIds, preReservations) {
214
213
  const ids = preIds || await dbLayer.listAllReservationIds(5000);
215
214
  if (!ids || !ids.length) return;
216
215
 
217
- const user = require.main.require('./src/user');
218
-
219
216
  const adminUrl = (() => {
220
217
  const base = forumBaseUrl();
221
218
  return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
package/lib/shared.js CHANGED
@@ -23,8 +23,8 @@ function forumBaseUrl() {
23
23
  // Try meta.config first (runtime-accurate), then nconf fallback.
24
24
  let base = '';
25
25
  try {
26
- base = (meta && meta.config && (meta.config.url || meta.config['url']))
27
- ? String(meta.config.url || meta.config['url'])
26
+ base = (meta && meta.config && meta.config.url)
27
+ ? String(meta.config.url)
28
28
  : '';
29
29
  } catch (_) { /* ignore */ }
30
30
  if (!base) {
package/library.js CHANGED
@@ -101,28 +101,27 @@ Plugin.init = async function (params) {
101
101
  router.get('/plugins/calendar-onekite/ics/:type/:id', api.getIcs);
102
102
 
103
103
  // Admin API (JSON)
104
- const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
105
-
106
- adminBases.forEach((base) => {
107
- router.get(`${base}/settings`, ...adminMws, admin.getSettings);
108
- router.put(`${base}/settings`, ...adminMws, admin.saveSettings);
109
-
110
- router.get(`${base}/pending`, ...adminMws, admin.listPending);
111
- router.put(`${base}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
112
- router.put(`${base}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
113
-
114
- router.post(`${base}/purge`, ...adminMws, admin.purgeByYear);
115
- router.get(`${base}/debug`, ...adminMws, admin.debugHelloAsso);
116
- // Accounting / exports
117
- router.get(`${base}/accounting`, ...adminMws, admin.getAccounting);
118
- router.get(`${base}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
119
- router.post(`${base}/accounting/purge`, ...adminMws, admin.purgeAccounting);
120
-
121
- // Purge special events by year
122
- router.post(`${base}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
123
- // Purge outings by year
124
- router.post(`${base}/outings/purge`, ...adminMws, admin.purgeOutingsByYear);
125
- });
104
+ const adminBase = '/api/v3/admin/plugins/calendar-onekite';
105
+
106
+ router.get(`${adminBase}/settings`, ...adminMws, admin.getSettings);
107
+ router.put(`${adminBase}/settings`, ...adminMws, admin.saveSettings);
108
+
109
+ router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
110
+ router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
111
+ router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
112
+ router.put(`${adminBase}/reservations/:rid/mark-paid`, ...adminMws, admin.markReservationPaid);
113
+
114
+ router.post(`${adminBase}/purge`, ...adminMws, admin.purgeByYear);
115
+ router.get(`${adminBase}/debug`, ...adminMws, admin.debugHelloAsso);
116
+ // Accounting / exports
117
+ router.get(`${adminBase}/accounting`, ...adminMws, admin.getAccounting);
118
+ router.get(`${adminBase}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
119
+ router.post(`${adminBase}/accounting/purge`, ...adminMws, admin.purgeAccounting);
120
+
121
+ // Purge special events by year
122
+ router.post(`${adminBase}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
123
+ // Purge outings by year
124
+ router.post(`${adminBase}/outings/purge`, ...adminMws, admin.purgeOutingsByYear);
126
125
 
127
126
  // HelloAsso callback endpoint (hardened)
128
127
  // - Only accepts POST
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.71",
3
+ "version": "2.0.73",
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
@@ -573,6 +573,13 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
573
573
  }
574
574
  }
575
575
 
576
+ async function markPaid(rid, payload) {
577
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/mark-paid`, {
578
+ method: 'PUT',
579
+ body: JSON.stringify(payload || {}),
580
+ });
581
+ }
582
+
576
583
  async function debugHelloAsso() {
577
584
  try {
578
585
  return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
@@ -1214,6 +1221,34 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1214
1221
  });
1215
1222
  }
1216
1223
 
1224
+ // Manual mark-as-paid (recovery when HelloAsso webhook failed)
1225
+ const markPaidBtn = document.getElementById('onekite-mark-paid-btn');
1226
+ if (markPaidBtn) {
1227
+ markPaidBtn.addEventListener('click', async () => {
1228
+ const ridInput = document.getElementById('onekite-mark-paid-rid');
1229
+ const rid = (ridInput ? ridInput.value : '').trim();
1230
+ if (!rid) {
1231
+ showAlert('error', 'ID de réservation requis.');
1232
+ return;
1233
+ }
1234
+ bootbox.confirm(
1235
+ `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>`,
1236
+ async (ok) => {
1237
+ if (!ok) return;
1238
+ try {
1239
+ await withLock(`mark-paid:${rid}`, [markPaidBtn], async () => {
1240
+ await markPaid(rid, { note: 'Paiement validé manuellement depuis ACP' });
1241
+ showAlert('success', `Réservation ${rid} marquée comme payée.`);
1242
+ if (ridInput) ridInput.value = '';
1243
+ });
1244
+ } catch (e) {
1245
+ showAlert('error', `Impossible de marquer comme payée : ${e && e.message ? e.message : e}`);
1246
+ }
1247
+ }
1248
+ );
1249
+ });
1250
+ }
1251
+
1217
1252
  // Accounting (paid reservations)
1218
1253
  const accFrom = document.getElementById('onekite-acc-from');
1219
1254
  const accTo = document.getElementById('onekite-acc-to');
package/public/client.js CHANGED
@@ -1022,7 +1022,8 @@ function toDatetimeLocalValue(date) {
1022
1022
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
1023
1023
  }
1024
1024
 
1025
- async function openReservationDialog(selectionInfo, items) {
1025
+ async function openReservationDialog(selectionInfo, items, opts) {
1026
+ const isValidatorMode = !!(opts && opts.isValidator);
1026
1027
  const start = selectionInfo.start;
1027
1028
  let end = selectionInfo.end;
1028
1029
 
@@ -1122,9 +1123,17 @@ function toDatetimeLocalValue(date) {
1122
1123
 
1123
1124
  const endDisplay = endInclusiveForDisplay(start, end);
1124
1125
 
1126
+ const forUserHtml = isValidatorMode ? `
1127
+ <div class="mb-2 mt-1">
1128
+ <label style="font-size:13px; font-weight:600; margin-bottom:3px; display:block;">Pour (pseudo, vide = moi-même)</label>
1129
+ <input type="text" class="form-control form-control-sm" id="onekite-target-username" placeholder="Pseudo de l'adhérent" autocomplete="off">
1130
+ </div>
1131
+ ` : '';
1132
+
1125
1133
  const messageHtml = `
1126
1134
  <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>
1127
1135
  ${shortcutsHtml}
1136
+ ${forUserHtml}
1128
1137
  <div class="mb-2"><strong>Matériel</strong></div>
1129
1138
  <div id="onekite-items" class="mb-2" style="max-height: 320px; overflow: auto; border: 1px solid var(--bs-border-color, #ddd); border-radius: 6px; padding: 6px;">
1130
1139
  ${rows}
@@ -1169,7 +1178,8 @@ function toDatetimeLocalValue(date) {
1169
1178
  const total = (sum / 100) * days;
1170
1179
  // Return the effective end date (exclusive) because duration shortcuts can
1171
1180
  // change the range without updating the original FullCalendar selection.
1172
- resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
1181
+ const targetUsername = isValidatorMode ? ((document.getElementById('onekite-target-username') || {}).value || '').trim() : '';
1182
+ resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end), targetUsername });
1173
1183
  },
1174
1184
  },
1175
1185
  },
@@ -1290,6 +1300,7 @@ function toDatetimeLocalValue(date) {
1290
1300
  const canDeleteSpecial = !!caps.canDeleteSpecial;
1291
1301
  const canCreateOuting = !!caps.canCreateOuting;
1292
1302
  const canCreateReservation = !!caps.canCreateReservation;
1303
+ const isValidator = !!caps.isValidator;
1293
1304
  const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
1294
1305
 
1295
1306
  // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
@@ -1380,17 +1391,19 @@ function toDatetimeLocalValue(date) {
1380
1391
  }
1381
1392
  } catch (e) {}
1382
1393
 
1383
- // Business rule: nothing can be created in the past.
1384
- try {
1385
- const startDateCheck = toLocalYmd(info.start);
1386
- const todayCheck = toLocalYmd(new Date());
1387
- if (startDateCheck < todayCheck) {
1388
- lastDateRuleToastAt = Date.now();
1389
- showAlert('error', "Impossible de créer pour une date passée.");
1390
- try { calendar.unselect(); } catch (e) {}
1391
- return;
1392
- }
1393
- } catch (e) {}
1394
+ // Business rule: nothing can be created in the past (validators are exempt for regularization).
1395
+ if (!isValidator) {
1396
+ try {
1397
+ const startDateCheck = toLocalYmd(info.start);
1398
+ const todayCheck = toLocalYmd(new Date());
1399
+ if (startDateCheck < todayCheck) {
1400
+ lastDateRuleToastAt = Date.now();
1401
+ showAlert('error', "Impossible de créer pour une date passée.");
1402
+ try { calendar.unselect(); } catch (e) {}
1403
+ return;
1404
+ }
1405
+ } catch (e) {}
1406
+ }
1394
1407
 
1395
1408
  function handleCreateError(e) {
1396
1409
  const code = String((e && (e.status || e.message)) || '');
@@ -1437,22 +1450,24 @@ function toDatetimeLocalValue(date) {
1437
1450
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1438
1451
  return;
1439
1452
  }
1440
- const chosen = await openReservationDialog(sel, items);
1453
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1441
1454
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1442
1455
  const startDate = toLocalYmd(sel.start);
1443
1456
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
1444
- const resp = await requestReservation({
1457
+ const reqPayload = {
1445
1458
  title: (chosen && chosen.title) ? String(chosen.title) : '',
1446
1459
  start: startDate,
1447
1460
  end: endDate,
1448
1461
  itemIds: chosen.itemIds,
1449
1462
  itemNames: chosen.itemNames,
1450
1463
  total: chosen.total,
1451
- });
1464
+ };
1465
+ if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
1466
+ const resp = await requestReservation(reqPayload);
1452
1467
  if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1453
- showAlert('success', 'Réservation confirmée.');
1468
+ showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
1454
1469
  } else {
1455
- showAlert('success', 'Demande envoyée (en attente de validation).');
1470
+ showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
1456
1471
  }
1457
1472
  invalidateEventsCache();
1458
1473
  scheduleRefetch(calendar);
@@ -1526,22 +1541,24 @@ function toDatetimeLocalValue(date) {
1526
1541
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1527
1542
  return;
1528
1543
  }
1529
- const chosen = await openReservationDialog(sel, items);
1544
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1530
1545
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1531
1546
  const startDate = toLocalYmd(sel.start);
1532
1547
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
1533
- const resp = await requestReservation({
1548
+ const reqPayload = {
1534
1549
  title: (chosen && chosen.title) ? String(chosen.title) : '',
1535
1550
  start: startDate,
1536
1551
  end: endDate,
1537
1552
  itemIds: chosen.itemIds,
1538
1553
  itemNames: chosen.itemNames,
1539
1554
  total: chosen.total,
1540
- });
1555
+ };
1556
+ if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
1557
+ const resp = await requestReservation(reqPayload);
1541
1558
  if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1542
- showAlert('success', 'Réservation confirmée.');
1559
+ showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
1543
1560
  } else {
1544
- showAlert('success', 'Demande envoyée (en attente de validation).');
1561
+ showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
1545
1562
  }
1546
1563
  invalidateEventsCache();
1547
1564
  scheduleRefetch(calendar);
@@ -1614,8 +1631,9 @@ function toDatetimeLocalValue(date) {
1614
1631
  longPressDelay: 300,
1615
1632
  selectLongPressDelay: 300,
1616
1633
  dayCellDidMount: function (arg) {
1617
- // Visually disable past days for reservation creation rules,
1618
- // without breaking event clicks.
1634
+ // Visually disable past days for reservation creation rules.
1635
+ // Validators are exempt (they can create past-dated reservations for regularization).
1636
+ if (isValidator) return;
1619
1637
  try {
1620
1638
  const cellDate = arg && arg.date ? new Date(arg.date) : null;
1621
1639
  if (!cellDate) return;
@@ -130,8 +130,8 @@
130
130
 
131
131
  <div class="mb-3">
132
132
  <label class="form-label">IPs autorisées pour le webhook HelloAsso (csv)</label>
133
- <input class="form-control" name="helloassoWebhookAllowedIps" placeholder="51.138.206.200,4.233.135.234">
134
- <div class="form-text">Liste des IPs autorisées à appeler le webhook. Laisse vide pour utiliser la liste par défaut.</div>
133
+ <input class="form-control" name="helloassoWebhookAllowedIps" placeholder="ex: 1.2.3.4, 5.6.7.8">
134
+ <div class="form-text">IPs autorisées à appeler le webhook (séparées par virgule). <strong>Si vide : toutes les IPs sont acceptées</strong> (recommandé — HelloAsso change ses IPs sans préavis). Ne remplis ce champ que si tu connais les IPs exactes de ton serveur HelloAsso.</div>
135
135
  </div>
136
136
 
137
137
  <div class="mb-3">
@@ -227,6 +227,17 @@
227
227
  <p class="text-muted">Teste la récupération du token et la liste du matériel (catalogue).</p>
228
228
  <button type="button" class="btn btn-secondary me-2" id="onekite-debug-run">Tester le chargement du matériel</button>
229
229
  <pre id="onekite-debug-output" class="mt-3 p-3 border rounded onekite-debug-output" style="max-height: 360px; overflow: auto;"></pre>
230
+
231
+ <hr class="my-4" />
232
+ <h5>Récupération paiement manuelle</h5>
233
+ <p class="text-muted" style="max-width: 700px;">
234
+ Si un adhérent a payé via HelloAsso mais que le webhook n'a pas été reçu (IP bloquée, timeout, etc.), entre l'ID de réservation pour la marquer manuellement comme payée. Un email de confirmation est envoyé à l'adhérent.
235
+ </p>
236
+ <div class="d-flex gap-2 align-items-center">
237
+ <input type="text" class="form-control" id="onekite-mark-paid-rid" placeholder="ID de réservation (ex: 1714234567890)" style="max-width: 400px;">
238
+ <button type="button" class="btn btn-warning" id="onekite-mark-paid-btn">Marquer comme payée</button>
239
+ </div>
240
+ <div class="form-text mt-2">L'ID de réservation est visible dans les logs NodeBB ou dans l'URL d'approbation.</div>
230
241
  </div>
231
242
 
232
243
  <div class="tab-pane fade" id="onekite-tab-accounting" role="tabpanel">