nodebb-plugin-onekite-calendar 2.0.72 → 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 +61 -0
- package/lib/api.js +70 -46
- package/lib/helloassoWebhook.js +130 -156
- package/library.js +1 -0
- package/package.json +1 -1
- package/public/admin.js +35 -0
- package/public/client.js +43 -25
- package/templates/admin/plugins/calendar-onekite.tpl +13 -2
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
|
-
//
|
|
1242
|
-
const
|
|
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
|
-
//
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
|
1315
|
-
let
|
|
1298
|
+
// Snapshot creator (validator) username for audit trail
|
|
1299
|
+
let creatorUsername = null;
|
|
1316
1300
|
try {
|
|
1317
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
1358
|
-
requesterUid: Number(
|
|
1359
|
-
requesterUsername:
|
|
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,7 @@ 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) });
|
|
1442
1466
|
};
|
|
1443
1467
|
|
|
1444
1468
|
// Validator actions (from calendar popup)
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
122
|
+
return false;
|
|
85
123
|
}
|
|
86
|
-
return null;
|
|
87
124
|
}
|
|
88
125
|
|
|
89
|
-
async function
|
|
90
|
-
if (!paymentId) return
|
|
126
|
+
async function alreadyProcessed(paymentId) {
|
|
127
|
+
if (!paymentId) return false;
|
|
91
128
|
try {
|
|
92
|
-
|
|
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
|
|
131
|
+
return false;
|
|
108
132
|
}
|
|
109
133
|
}
|
|
110
134
|
|
|
111
|
-
async function
|
|
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 || '
|
|
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 || '
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
190
|
+
return null;
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const allowedIps =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
|
|
252
|
+
resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
|
|
277
253
|
}
|
|
278
|
-
if (!resolvedRid
|
|
279
|
-
|
|
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
|
|
285
|
-
// Do
|
|
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
|
-
//
|
|
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 =
|
|
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
|
@@ -109,6 +109,7 @@ Plugin.init = async function (params) {
|
|
|
109
109
|
router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
|
|
110
110
|
router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
|
|
111
111
|
router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
|
|
112
|
+
router.put(`${adminBase}/reservations/:rid/mark-paid`, ...adminMws, admin.markReservationPaid);
|
|
112
113
|
|
|
113
114
|
router.post(`${adminBase}/purge`, ...adminMws, admin.purgeByYear);
|
|
114
115
|
router.get(`${adminBase}/debug`, ...adminMws, admin.debugHelloAsso);
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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="
|
|
134
|
-
<div class="form-text">
|
|
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">
|