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