nodebb-plugin-onekite-calendar 2.0.72 → 2.0.74

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
@@ -196,6 +196,67 @@ admin.approveReservation = async function (req, res) {
196
196
  res.json({ ok: true, paymentUrl: paymentUrl || null });
197
197
  };
198
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
+
199
260
  admin.refuseReservation = async function (req, res) {
200
261
  const rid = req.params.rid;
201
262
  const r = await dbLayer.getReservation(rid);
package/lib/api.js CHANGED
@@ -775,6 +775,7 @@ api.getCapabilities = async function (req, res) {
775
775
  ]);
776
776
  res.json({
777
777
  canModerate: canMod,
778
+ isValidator: canMod,
778
779
  canCreateSpecial: canSpecialC,
779
780
  canDeleteSpecial: canSpecialD,
780
781
  canCreateOuting: canReq,
@@ -1224,37 +1225,20 @@ api.createReservation = async function (req, res) {
1224
1225
  const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
1225
1226
  const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
1226
1227
 
1227
- // Validators can create "free" reservations that skip the payment workflow.
1228
- // However, long rentals should follow the normal paid workflow.
1229
- // Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
1230
- let validatorFreeMaxDays = 0;
1231
- try {
1232
- const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
1233
- validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
1234
- } catch (e) {
1235
- validatorFreeMaxDays = 0;
1236
- }
1237
-
1238
1228
  // Reliable calendar-day count (endDate is EXCLUSIVE)
1239
1229
  const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
1240
1230
 
1241
- // A validator is "free" only if the rental duration is within the configured threshold.
1242
- 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();
1243
1235
 
1244
- // Business rule: a reservation cannot start in the past.
1245
- // We compare against server-local midnight. (Front-end also prevents it.)
1246
- try {
1247
- const today0 = new Date();
1248
- today0.setHours(0, 0, 0, 0);
1249
- const today0ts = today0.getTime();
1250
- if (start < today0ts) {
1251
- return res.status(400).json({
1252
- error: 'date-too-soon',
1253
- message: "Impossible de réserver pour une date passée.",
1254
- });
1255
- }
1256
- } catch (e) {
1257
- // 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
+ });
1258
1242
  }
1259
1243
 
1260
1244
  // Support both legacy single itemId and new itemIds[] payload
@@ -1311,16 +1295,53 @@ api.createReservation = async function (req, res) {
1311
1295
  const now = Date.now();
1312
1296
  const rid = crypto.randomUUID();
1313
1297
 
1314
- // Snapshot username for display in calendar popups
1315
- let username = null;
1298
+ // Snapshot creator (validator) username for audit trail
1299
+ let creatorUsername = null;
1316
1300
  try {
1317
- username = await user.getUserField(uid, 'username');
1301
+ creatorUsername = await user.getUserField(uid, 'username');
1318
1302
  } catch (e) {}
1319
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
+
1320
1339
  const resv = {
1321
1340
  rid,
1322
- uid,
1323
- username: username || null,
1341
+ uid: targetUid,
1342
+ username: targetUsername || null,
1343
+ createdBy: uid,
1344
+ createdByUsername: creatorUsername || null,
1324
1345
  itemIds,
1325
1346
  itemNames: itemNames.length ? itemNames : itemIds,
1326
1347
  // keep legacy fields for backward compatibility
@@ -1330,19 +1351,17 @@ api.createReservation = async function (req, res) {
1330
1351
  end,
1331
1352
  startDate,
1332
1353
  endDate,
1333
- status: isValidatorFree ? 'paid' : 'pending',
1354
+ status: (isValidatorFree || isRegularization) ? 'paid' : 'pending',
1334
1355
  createdAt: now,
1335
- paidAt: isValidatorFree ? now : 0,
1336
- approvedBy: isValidatorFree ? uid : 0,
1337
- // total is used for accounting (paid reservations).
1338
- // Validator self-reservations are FREE (no payment required) and must not be
1339
- // counted as revenue.
1356
+ paidAt: (isValidatorFree || isRegularization) ? now : 0,
1357
+ approvedBy: (isValidatorFree || isRegularization) ? uid : 0,
1358
+ approvedByUsername: (isValidatorFree || isRegularization) ? (creatorUsername || '') : '',
1340
1359
  isFree: !!isValidatorFree,
1341
1360
  total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
1361
+ manuallyPaid: isRegularization ? true : undefined,
1342
1362
  };
1343
1363
 
1344
- // NOTE: We intentionally do NOT compute a monetary total for validator self-reservations.
1345
- // 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.
1346
1365
 
1347
1366
  // Save
1348
1367
  await dbLayer.saveReservation(resv);
@@ -1351,12 +1370,17 @@ api.createReservation = async function (req, res) {
1351
1370
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'created', rid: resv.rid, status: resv.status });
1352
1371
 
1353
1372
  // Audit
1354
- 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, {
1355
1377
  targetType: 'reservation',
1356
1378
  targetId: String(resv.rid),
1357
- uid: Number(uid) || 0,
1358
- requesterUid: Number(uid) || 0,
1359
- requesterUsername: username || '',
1379
+ uid: Number(targetUid) || 0,
1380
+ requesterUid: Number(targetUid) || 0,
1381
+ requesterUsername: targetUsername || '',
1382
+ createdBy: Number(uid) || 0,
1383
+ createdByUsername: creatorUsername || '',
1360
1384
  itemIds: resv.itemIds || [],
1361
1385
  itemNames: resv.itemNames || [],
1362
1386
  startDate: resv.startDate || '',
@@ -1364,7 +1388,7 @@ api.createReservation = async function (req, res) {
1364
1388
  status: resv.status,
1365
1389
  });
1366
1390
 
1367
- if (!isValidatorFree) {
1391
+ if (!isValidatorFree && !isRegularization) {
1368
1392
 
1369
1393
 
1370
1394
  // Notify groups by email (NodeBB emailer config)
@@ -1438,7 +1462,23 @@ api.createReservation = async function (req, res) {
1438
1462
 
1439
1463
  }
1440
1464
 
1441
- res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
1465
+ res.json({ ok: true, rid, status: resv.status, autoPaid: !!(isValidatorFree || isRegularization) });
1466
+ };
1467
+
1468
+ api.searchUsers = async function (req, res) {
1469
+ const uid = req.uid;
1470
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
1471
+ const settings = await getSettings();
1472
+ if (!(await canValidate(uid, settings))) return res.status(403).json({ error: 'not-allowed' });
1473
+ const q = String(req.query.q || '').trim();
1474
+ if (q.length < 2) return res.json([]);
1475
+ try {
1476
+ const result = await user.search({ query: q, searchBy: 'username', resultsPerPage: 8, uid });
1477
+ const list = (result && result.users) ? result.users : [];
1478
+ return res.json(list.map(u => ({ uid: u.uid, username: u.username })));
1479
+ } catch (e) {
1480
+ return res.json([]);
1481
+ }
1442
1482
  };
1443
1483
 
1444
1484
  // Validator actions (from calendar popup)
@@ -1,18 +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;
13
+
14
+ const SETTINGS_KEY = 'calendar-onekite';
15
+ const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
16
20
 
17
21
  function buildReservationCalendarLinks(r) {
18
22
  const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
@@ -50,19 +54,21 @@ async function auditLog(action, actorUid, payload) {
50
54
  } catch (e) {}
51
55
  }
52
56
 
53
- const SETTINGS_KEY = 'calendar-onekite';
54
-
55
- // Replay protection: store processed payment ids.
56
- const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
57
-
58
- // 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
+ }
59
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.
60
67
  function getReservationIdFromPayload(payload) {
61
68
  try {
62
69
  const data = payload && payload.data ? payload.data : null;
63
70
  if (!data) return null;
64
71
 
65
- // HelloAsso commonly uses "metadata" for checkout-intents/payments.
66
72
  const metaCandidates = [
67
73
  data.meta,
68
74
  data.metadata,
@@ -74,56 +80,81 @@ function getReservationIdFromPayload(payload) {
74
80
  if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
75
81
  return String(metaObj.reservationId);
76
82
  }
77
- // Some systems send metadata as array of key/value pairs
83
+ // Some implementations send metadata as an array of { key, value } pairs
78
84
  if (Array.isArray(metaObj)) {
79
85
  const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
80
86
  if (found && (found.value || found.val)) return String(found.value || found.val);
81
87
  }
82
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;
83
121
  } catch (e) {
84
- // ignore
122
+ return false;
85
123
  }
86
- return null;
87
124
  }
88
125
 
89
- async function tryRecoverReservationIdFromPayment(settings, paymentId) {
90
- if (!paymentId) return null;
126
+ async function alreadyProcessed(paymentId) {
127
+ if (!paymentId) return false;
91
128
  try {
92
- const token = await helloasso.getAccessToken({
93
- env: settings.helloassoEnv || 'live',
94
- clientId: settings.helloassoClientId,
95
- clientSecret: settings.helloassoClientSecret,
96
- });
97
- if (!token) return null;
98
- const details = await helloasso.getPaymentDetails({
99
- env: settings.helloassoEnv || 'live',
100
- token,
101
- paymentId,
102
- });
103
- if (!details) return null;
104
- // Reuse the same extraction logic on the payment details object.
105
- return getReservationIdFromPayload({ data: details });
129
+ return await db.isSetMember(PROCESSED_KEY, String(paymentId));
106
130
  } catch (e) {
107
- return null;
131
+ return false;
108
132
  }
109
133
  }
110
134
 
111
- 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) {
112
145
  if (!checkoutIntentId) return null;
113
146
  try {
114
- // First, try a direct mapping stored when the checkout intent was created.
115
147
  const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
116
148
  if (mapped) return String(mapped);
117
149
 
118
- // If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
119
150
  const token = await helloasso.getAccessToken({
120
- env: settings.helloassoEnv || 'live',
151
+ env: settings.helloassoEnv || 'prod',
121
152
  clientId: settings.helloassoClientId,
122
153
  clientSecret: settings.helloassoClientSecret,
123
154
  });
124
155
  if (!token) return null;
125
156
  const details = await helloasso.getCheckoutIntentDetails({
126
- env: settings.helloassoEnv || 'live',
157
+ env: settings.helloassoEnv || 'prod',
127
158
  token,
128
159
  organizationSlug: settings.helloassoOrganizationSlug,
129
160
  checkoutIntentId,
@@ -131,171 +162,119 @@ async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutInten
131
162
  if (!details) return null;
132
163
  const rid = getReservationIdFromPayload({ data: details });
133
164
  if (rid) {
134
- // persist mapping for next webhooks
135
- try {
136
- await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
137
- } catch (e) {}
165
+ try { await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid)); } catch (e) {}
138
166
  return String(rid);
139
167
  }
140
- } catch (e) {
141
- return null;
142
- }
168
+ } catch (e) {}
143
169
  return null;
144
170
  }
145
171
 
146
- async function alreadyProcessed(paymentId) {
147
- if (!paymentId) return false;
148
- try {
149
- return await db.isSetMember(PROCESSED_KEY, String(paymentId));
150
- } catch (e) {
151
- return false;
152
- }
153
- }
154
-
155
- async function markProcessed(paymentId) {
156
- if (!paymentId) return;
157
- try {
158
- await db.setAdd(PROCESSED_KEY, String(paymentId));
159
- } catch (e) {
160
- // ignore
161
- }
162
- }
163
-
164
- 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;
165
175
  try {
166
- if (!payload || !payload.data) return false;
167
- const eventType = String(payload.eventType || '').toLowerCase();
168
- const stateRaw = payload.data.state || payload.data.status || payload.data.paymentState || '';
169
- const state = String(stateRaw).toLowerCase();
170
-
171
- // We accept the most common "paid" states seen in HelloAsso webhooks.
172
- const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
173
-
174
- // HelloAsso may send eventType "Payment" and/or "Order".
175
- if (eventType === 'payment') {
176
- return okState;
177
- }
178
- if (eventType === 'order') {
179
- // Order payloads may not carry a payment-like "state" field; accept if missing,
180
- // but still require a recognizable "paid" state when provided.
181
- return !state || okState;
182
- }
183
- 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 });
184
189
  } catch (e) {
185
- return false;
190
+ return null;
186
191
  }
187
192
  }
188
193
 
189
- function getCheckoutIntentIdFromPayload(payload) {
190
- try {
191
- const data = payload && payload.data ? payload.data : null;
192
- if (!data) return null;
193
- // Common locations based on HelloAsso docs and community reports:
194
- // - Order event often contains checkoutIntentId at root of data
195
- // - Return/back redirects contain checkoutIntentId in query params (not here)
196
- const candidates = [
197
- data.checkoutIntentId,
198
- data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
199
- data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
200
- ].filter(Boolean);
201
- if (candidates.length) return String(candidates[0]);
202
- } catch (e) {}
203
- return null;
204
- }
194
+ // ---------------------------------------------------------------------------
195
+ // Webhook handler
196
+ // ---------------------------------------------------------------------------
205
197
 
206
- /**
207
- * Hardened HelloAsso webhook handler.
208
- * - Requires x-ha-signature (HMAC SHA-256) verification.
209
- * - Only accepts eventType=Payment with state=Confirmed.
210
- * - Provides basic replay protection using the payment id.
211
- *
212
- * This handler is "safe" by default: if we cannot verify the signature,
213
- * we refuse the request.
214
- */
215
198
  async function handler(req, res, next) {
216
199
  try {
217
- if (req.method === 'GET') {
218
- return res.json({ ok: true });
219
- }
220
- if (req.method !== 'POST') {
221
- return res.status(405).json({ ok: false, error: 'method-not-allowed' });
222
- }
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' });
223
202
 
224
203
  const settings = await meta.settings.get(SETTINGS_KEY);
225
204
 
226
- // Restrict webhook calls by IP (HelloAsso partner mode signature is not used).
227
- const defaultAllowed = ['51.138.206.200', '4.233.135.234'];
228
- const raw = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
229
- const allowedIps = (raw ? raw.split(/[\s,;]+/g) : defaultAllowed).map(s => String(s || '').trim()).filter(Boolean);
230
-
231
- const clientIp = (() => {
232
- const cf = req.headers['cf-connecting-ip'];
233
- if (cf) return String(cf).trim();
234
- const xff = req.headers['x-forwarded-for'];
235
- if (xff) return String(xff).split(',')[0].trim();
236
- return String(req.ip || '').trim();
237
- })();
238
-
239
- if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
240
- // eslint-disable-next-line no-console
241
- console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
242
- 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
+ }
243
216
  }
244
217
 
245
- // At this point, the payload is trusted.
246
218
  const payload = req.body;
247
219
 
248
220
  if (!isConfirmedPayment(payload)) {
249
- // Acknowledge but ignore other event types/states.
250
221
  return res.json({ ok: true, ignored: true });
251
222
  }
252
223
 
253
- // Ignore incomplete Payment events (HelloAsso sometimes omits metadata and checkoutIntentId on Payment webhooks).
254
- // We rely on Order events (or checkoutIntent mappings) for reliable reconciliation.
255
- const _eventType = String((payload && payload.eventType) || '').toLowerCase();
256
- const _rid = getReservationIdFromPayload(payload);
257
- const _checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
258
- 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
+ });
259
235
  return res.json({ ok: true, ignored: true, incompletePayment: true });
260
236
  }
261
237
 
262
238
  const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
263
- // If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
264
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) : [] });
265
242
  return res.json({ ok: true, ignored: true, missingPaymentId: true });
266
243
  }
244
+
267
245
  if (await alreadyProcessed(paymentId)) {
268
246
  return res.json({ ok: true, duplicate: true });
269
247
  }
270
248
 
271
- const rid = getReservationIdFromPayload(payload);
249
+ // Resolve the reservation ID: try metadata first, then checkoutIntentId mapping, then API call.
272
250
  let resolvedRid = rid;
273
- const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
274
251
  if (!resolvedRid && checkoutIntentId) {
275
- // Some webhook payloads omit metadata but provide checkoutIntentId (common on Order events).
276
- resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
252
+ resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
277
253
  }
278
- if (!resolvedRid && paymentId) {
279
- // Some webhook payloads omit metadata; try to fetch the payment details.
280
- resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
254
+ if (!resolvedRid) {
255
+ resolvedRid = await tryRecoverRidFromPayment(settings, paymentId);
281
256
  }
282
257
  if (!resolvedRid) {
283
258
  // eslint-disable-next-line no-console
284
- console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
285
- // 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.
286
261
  return res.json({ ok: true, processed: false, missingReservationId: true });
287
262
  }
288
263
 
289
264
  const r = await dbLayer.getReservation(resolvedRid);
290
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 });
291
268
  await markProcessed(paymentId);
292
269
  return res.json({ ok: true, processed: true, reservationNotFound: true });
293
270
  }
294
271
 
295
- // 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
+
296
275
  r.status = 'paid';
297
276
  r.paidAt = Date.now();
298
- r.paymentId = paymentId ? String(paymentId) : '';
277
+ r.paymentId = String(paymentId);
299
278
  if (payload.data && payload.data.paymentReceiptUrl) {
300
279
  r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
301
280
  }
@@ -310,13 +289,11 @@ async function handler(req, res, next) {
310
289
  itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
311
290
  startDate: r.startDate || '',
312
291
  endDate: r.endDate || '',
313
- paymentId: r.paymentId || '',
292
+ paymentId: r.paymentId,
314
293
  });
315
294
 
316
- // Real-time notify: refresh calendars for all viewers
317
295
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: r.status });
318
296
 
319
- // Notify requester
320
297
  const requesterUid = parseInt(r.uid, 10);
321
298
  const requester = await user.getUserFields(requesterUid, ['username']);
322
299
  if (requesterUid) {
@@ -340,7 +317,6 @@ async function handler(req, res, next) {
340
317
  });
341
318
  }
342
319
 
343
- // Discord webhook (optional)
344
320
  try {
345
321
  await discord.notifyPaymentReceived(settings, {
346
322
  rid: r.rid,
@@ -361,6 +337,4 @@ async function handler(req, res, next) {
361
337
  }
362
338
  }
363
339
 
364
- module.exports = {
365
- handler,
366
- };
340
+ module.exports = { handler };
package/library.js CHANGED
@@ -65,6 +65,7 @@ Plugin.init = async function (params) {
65
65
  router.get('/api/v3/plugins/calendar-onekite/events', ...publicExpose, api.getEvents);
66
66
  router.get('/api/v3/plugins/calendar-onekite/items', ...publicExpose, api.getItems);
67
67
  router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
68
+ router.get('/api/v3/plugins/calendar-onekite/users/search', ...publicExpose, api.searchUsers);
68
69
 
69
70
  // Maintenance / audit (restricted to validator groups)
70
71
  router.get('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.getMaintenance);
@@ -109,6 +110,7 @@ Plugin.init = async function (params) {
109
110
  router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
110
111
  router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
111
112
  router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
113
+ router.put(`${adminBase}/reservations/:rid/mark-paid`, ...adminMws, admin.markReservationPaid);
112
114
 
113
115
  router.post(`${adminBase}/purge`, ...adminMws, admin.purgeByYear);
114
116
  router.get(`${adminBase}/debug`, ...adminMws, admin.debugHelloAsso);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.72",
3
+ "version": "2.0.74",
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
@@ -821,6 +821,107 @@ async function searchAddresses(query, limit) {
821
821
  }).filter(h => h && h.displayName);
822
822
  }
823
823
 
824
+ async function searchUsers(q, limit) {
825
+ try {
826
+ const qs = new URLSearchParams({ q: String(q || ''), limit: String(limit || 8) });
827
+ const arr = await fetchJson(`/api/v3/plugins/calendar-onekite/users/search?${qs}`);
828
+ if (!Array.isArray(arr)) return [];
829
+ return arr;
830
+ } catch (e) {
831
+ return [];
832
+ }
833
+ }
834
+
835
+ function attachUserAutocomplete(inputEl) {
836
+ if (!inputEl) return;
837
+ if (inputEl.getAttribute('data-onekite-user-ac') === '1') return;
838
+ inputEl.setAttribute('data-onekite-user-ac', '1');
839
+
840
+ const host = inputEl.parentNode || document.body;
841
+ try {
842
+ const cs = window.getComputedStyle(host);
843
+ if (!cs || cs.position === 'static') host.style.position = 'relative';
844
+ } catch (e) {
845
+ host.style.position = 'relative';
846
+ }
847
+
848
+ const menu = document.createElement('div');
849
+ menu.className = 'onekite-autocomplete-menu';
850
+ menu.style.position = 'absolute';
851
+ menu.style.left = '0';
852
+ menu.style.right = '0';
853
+ menu.style.top = '100%';
854
+ menu.style.zIndex = '2000';
855
+ menu.style.background = '#fff';
856
+ menu.style.border = '1px solid rgba(0,0,0,.15)';
857
+ menu.style.borderTop = '0';
858
+ menu.style.maxHeight = '220px';
859
+ menu.style.overflowY = 'auto';
860
+ menu.style.display = 'none';
861
+ menu.style.borderRadius = '0 0 .375rem .375rem';
862
+ host.appendChild(menu);
863
+
864
+ let timer = null;
865
+ let busy = false;
866
+
867
+ function hide() { menu.style.display = 'none'; menu.innerHTML = ''; }
868
+
869
+ function show(hits) {
870
+ if (!hits || !hits.length) { hide(); return; }
871
+ menu.innerHTML = '';
872
+ hits.forEach((h) => {
873
+ const btn = document.createElement('button');
874
+ btn.type = 'button';
875
+ btn.className = 'onekite-autocomplete-item';
876
+ btn.textContent = h.username;
877
+ btn.style.display = 'block';
878
+ btn.style.width = '100%';
879
+ btn.style.textAlign = 'left';
880
+ btn.style.padding = '.35rem .5rem';
881
+ btn.style.border = '0';
882
+ btn.style.background = 'transparent';
883
+ btn.style.cursor = 'pointer';
884
+ btn.addEventListener('click', () => { inputEl.value = h.username; hide(); });
885
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.05)'; });
886
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
887
+ menu.appendChild(btn);
888
+ });
889
+ menu.style.display = 'block';
890
+ }
891
+
892
+ async function run(q) {
893
+ if (busy) return;
894
+ busy = true;
895
+ try {
896
+ const hits = await searchUsers(q, 8);
897
+ if (String(inputEl.value || '').trim() !== q) return;
898
+ show(hits);
899
+ } catch (e) {
900
+ hide();
901
+ } finally {
902
+ busy = false;
903
+ }
904
+ }
905
+
906
+ inputEl.addEventListener('input', () => {
907
+ const q = String(inputEl.value || '').trim();
908
+ if (timer) clearTimeout(timer);
909
+ if (q.length < 2) { hide(); return; }
910
+ timer = setTimeout(() => run(q), 250);
911
+ });
912
+
913
+ inputEl.addEventListener('focus', () => {
914
+ const q = String(inputEl.value || '').trim();
915
+ if (q.length >= 2) { if (timer) clearTimeout(timer); timer = setTimeout(() => run(q), 150); }
916
+ });
917
+
918
+ document.addEventListener('click', (e) => {
919
+ try { if (!host.contains(e.target)) hide(); } catch (err) {}
920
+ });
921
+
922
+ inputEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
923
+ }
924
+
824
925
  function attachAddressAutocomplete(inputEl, onPick) {
825
926
  if (!inputEl) return;
826
927
  // Avoid double attach
@@ -1022,7 +1123,8 @@ function toDatetimeLocalValue(date) {
1022
1123
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
1023
1124
  }
1024
1125
 
1025
- async function openReservationDialog(selectionInfo, items) {
1126
+ async function openReservationDialog(selectionInfo, items, opts) {
1127
+ const isValidatorMode = !!(opts && opts.isValidator);
1026
1128
  const start = selectionInfo.start;
1027
1129
  let end = selectionInfo.end;
1028
1130
 
@@ -1122,9 +1224,17 @@ function toDatetimeLocalValue(date) {
1122
1224
 
1123
1225
  const endDisplay = endInclusiveForDisplay(start, end);
1124
1226
 
1227
+ const forUserHtml = isValidatorMode ? `
1228
+ <div class="mb-2 mt-1">
1229
+ <label style="font-size:13px; font-weight:600; margin-bottom:3px; display:block;">Pour (pseudo, vide = moi-même)</label>
1230
+ <input type="text" class="form-control form-control-sm" id="onekite-target-username" placeholder="Pseudo de l'adhérent" autocomplete="off">
1231
+ </div>
1232
+ ` : '';
1233
+
1125
1234
  const messageHtml = `
1126
1235
  <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
1236
  ${shortcutsHtml}
1237
+ ${forUserHtml}
1128
1238
  <div class="mb-2"><strong>Matériel</strong></div>
1129
1239
  <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
1240
  ${rows}
@@ -1169,7 +1279,8 @@ function toDatetimeLocalValue(date) {
1169
1279
  const total = (sum / 100) * days;
1170
1280
  // Return the effective end date (exclusive) because duration shortcuts can
1171
1281
  // change the range without updating the original FullCalendar selection.
1172
- resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
1282
+ const targetUsername = isValidatorMode ? ((document.getElementById('onekite-target-username') || {}).value || '').trim() : '';
1283
+ resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end), targetUsername });
1173
1284
  },
1174
1285
  },
1175
1286
  },
@@ -1177,6 +1288,10 @@ function toDatetimeLocalValue(date) {
1177
1288
 
1178
1289
  // live total update
1179
1290
  setTimeout(() => {
1291
+ if (isValidatorMode) {
1292
+ attachUserAutocomplete(document.getElementById('onekite-target-username'));
1293
+ }
1294
+
1180
1295
  const totalEl = document.getElementById('onekite-total');
1181
1296
  const periodEl = document.getElementById('onekite-period');
1182
1297
  const daysEl = document.getElementById('onekite-days');
@@ -1290,6 +1405,7 @@ function toDatetimeLocalValue(date) {
1290
1405
  const canDeleteSpecial = !!caps.canDeleteSpecial;
1291
1406
  const canCreateOuting = !!caps.canCreateOuting;
1292
1407
  const canCreateReservation = !!caps.canCreateReservation;
1408
+ const isValidator = !!caps.isValidator;
1293
1409
  const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
1294
1410
 
1295
1411
  // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
@@ -1380,17 +1496,19 @@ function toDatetimeLocalValue(date) {
1380
1496
  }
1381
1497
  } catch (e) {}
1382
1498
 
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) {}
1499
+ // Business rule: nothing can be created in the past (validators are exempt for regularization).
1500
+ if (!isValidator) {
1501
+ try {
1502
+ const startDateCheck = toLocalYmd(info.start);
1503
+ const todayCheck = toLocalYmd(new Date());
1504
+ if (startDateCheck < todayCheck) {
1505
+ lastDateRuleToastAt = Date.now();
1506
+ showAlert('error', "Impossible de créer pour une date passée.");
1507
+ try { calendar.unselect(); } catch (e) {}
1508
+ return;
1509
+ }
1510
+ } catch (e) {}
1511
+ }
1394
1512
 
1395
1513
  function handleCreateError(e) {
1396
1514
  const code = String((e && (e.status || e.message)) || '');
@@ -1437,22 +1555,24 @@ function toDatetimeLocalValue(date) {
1437
1555
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1438
1556
  return;
1439
1557
  }
1440
- const chosen = await openReservationDialog(sel, items);
1558
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1441
1559
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1442
1560
  const startDate = toLocalYmd(sel.start);
1443
1561
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
1444
- const resp = await requestReservation({
1562
+ const reqPayload = {
1445
1563
  title: (chosen && chosen.title) ? String(chosen.title) : '',
1446
1564
  start: startDate,
1447
1565
  end: endDate,
1448
1566
  itemIds: chosen.itemIds,
1449
1567
  itemNames: chosen.itemNames,
1450
1568
  total: chosen.total,
1451
- });
1569
+ };
1570
+ if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
1571
+ const resp = await requestReservation(reqPayload);
1452
1572
  if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1453
- showAlert('success', 'Réservation confirmée.');
1573
+ showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
1454
1574
  } else {
1455
- showAlert('success', 'Demande envoyée (en attente de validation).');
1575
+ showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
1456
1576
  }
1457
1577
  invalidateEventsCache();
1458
1578
  scheduleRefetch(calendar);
@@ -1526,22 +1646,24 @@ function toDatetimeLocalValue(date) {
1526
1646
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1527
1647
  return;
1528
1648
  }
1529
- const chosen = await openReservationDialog(sel, items);
1649
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1530
1650
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1531
1651
  const startDate = toLocalYmd(sel.start);
1532
1652
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
1533
- const resp = await requestReservation({
1653
+ const reqPayload = {
1534
1654
  title: (chosen && chosen.title) ? String(chosen.title) : '',
1535
1655
  start: startDate,
1536
1656
  end: endDate,
1537
1657
  itemIds: chosen.itemIds,
1538
1658
  itemNames: chosen.itemNames,
1539
1659
  total: chosen.total,
1540
- });
1660
+ };
1661
+ if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
1662
+ const resp = await requestReservation(reqPayload);
1541
1663
  if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1542
- showAlert('success', 'Réservation confirmée.');
1664
+ showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
1543
1665
  } else {
1544
- showAlert('success', 'Demande envoyée (en attente de validation).');
1666
+ showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
1545
1667
  }
1546
1668
  invalidateEventsCache();
1547
1669
  scheduleRefetch(calendar);
@@ -1614,8 +1736,9 @@ function toDatetimeLocalValue(date) {
1614
1736
  longPressDelay: 300,
1615
1737
  selectLongPressDelay: 300,
1616
1738
  dayCellDidMount: function (arg) {
1617
- // Visually disable past days for reservation creation rules,
1618
- // without breaking event clicks.
1739
+ // Visually disable past days for reservation creation rules.
1740
+ // Validators are exempt (they can create past-dated reservations for regularization).
1741
+ if (isValidator) return;
1619
1742
  try {
1620
1743
  const cellDate = arg && arg.date ? new Date(arg.date) : null;
1621
1744
  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">