nodebb-plugin-onekite-calendar 2.0.94 → 2.0.96

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
@@ -23,6 +23,7 @@ const dbLayer = require('./db');
23
23
  const helloasso = require('./helloasso');
24
24
  const discord = require('./discord');
25
25
  const { buildCheckoutIntent } = require('./checkout');
26
+ const { syncPayment: syncPaymentHelper } = require('./syncPayment');
26
27
 
27
28
  const ADMIN_PRIV = 'admin:settings';
28
29
 
@@ -166,10 +167,9 @@ admin.approveReservation = async function (req, res) {
166
167
  pickupLon: r.pickupLon || '',
167
168
  mapUrl,
168
169
  ...(buildCalendarLinks({
169
- rid: String(r.rid),
170
- uid: requesterUid,
170
+ type: 'reservation', id: String(r.rid || ''), uid: requesterUid,
171
171
  itemNames: arrayifyNames(r),
172
- pickupAddress: r.pickupAddress || '',
172
+ pickupAddress: r.pickupAddress || '', allDay: true,
173
173
  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),
174
174
  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),
175
175
  })),
@@ -690,126 +690,17 @@ admin.exportAccountingCsv = async function (req, res) {
690
690
  // Sync payment status from HelloAsso for a reservation stuck in awaiting_payment.
691
691
  // Uses the stored checkoutIntentId to query HelloAsso's API directly.
692
692
  admin.syncPaymentFromHelloAsso = async function (req, res) {
693
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
694
+
693
695
  const rid = String(req.params.rid || '').trim();
694
696
  if (!rid) return res.status(400).json({ error: 'missing-rid' });
695
697
 
696
698
  const r = await dbLayer.getReservation(rid);
697
699
  if (!r) return res.status(404).json({ error: 'not-found' });
698
700
 
699
- if (r.status === 'paid') {
700
- return res.json({ ok: true, alreadyPaid: true, status: r.status });
701
- }
702
-
703
- const checkoutIntentId = r.checkoutIntentId ? String(r.checkoutIntentId) : null;
704
- if (!checkoutIntentId) {
705
- return res.json({ ok: false, reason: 'no-checkout-intent-id', status: r.status });
706
- }
707
-
708
701
  const settings = await getSettings();
709
- const token = await helloasso.getAccessToken({
710
- env: settings.helloassoEnv || 'prod',
711
- clientId: settings.helloassoClientId,
712
- clientSecret: settings.helloassoClientSecret,
713
- });
714
- if (!token) {
715
- return res.json({ ok: false, reason: 'helloasso-auth-failed' });
716
- }
717
-
718
- const details = await helloasso.getCheckoutIntentDetails({
719
- env: settings.helloassoEnv || 'prod',
720
- token,
721
- organizationSlug: settings.helloassoOrganizationSlug,
722
- checkoutIntentId,
723
- });
724
-
725
- if (!details) {
726
- return res.json({ ok: false, reason: 'checkout-intent-not-found', checkoutIntentId });
727
- }
728
-
729
- // Determine payment state from checkout intent details
730
- const order = (details && details.order) ? details.order : details;
731
- const orderState = String((order && (order.state || order.status)) || '').toLowerCase();
732
- const paymentsArr = (order && Array.isArray(order.payments)) ? order.payments : [];
733
- const paymentState = paymentsArr.length
734
- ? String((paymentsArr[0].state || paymentsArr[0].status) || '').toLowerCase()
735
- : '';
736
- const okStates = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'];
737
- const isPaid = okStates.includes(orderState) || okStates.includes(paymentState);
738
-
739
- if (!isPaid) {
740
- return res.json({ ok: false, reason: 'not-paid-on-helloasso', orderState, paymentState });
741
- }
742
-
743
- // Mark as paid
744
- const prevStatus = r.status;
745
- r.status = 'paid';
746
- r.paidAt = Date.now();
747
- r.syncedFromHelloAsso = true;
748
- r.syncedBy = req.uid;
749
- const paymentId = paymentsArr.length ? String(paymentsArr[0].id || '') : '';
750
- const paymentReceiptUrl = paymentsArr.length
751
- ? String(paymentsArr[0].paymentReceiptUrl || paymentsArr[0].receiptUrl || '')
752
- : '';
753
- if (paymentId) r.paymentId = paymentId;
754
- if (paymentReceiptUrl) r.paymentReceiptUrl = paymentReceiptUrl;
755
- await dbLayer.saveReservation(r);
756
-
757
- try {
758
- await dbLayer.addAuditEntry({
759
- ts: Date.now(), year: new Date().getFullYear(),
760
- action: 'reservation_sync_paid',
761
- targetType: 'reservation', targetId: String(r.rid),
762
- reservationUid: Number(r.uid) || 0,
763
- reservationUsername: String(r.username || ''),
764
- actorUid: req.uid || 0,
765
- prevStatus,
766
- });
767
- } catch (e) {}
768
-
769
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: 'paid' });
770
-
771
- try {
772
- const requesterUid = parseInt(r.uid, 10);
773
- if (requesterUid) {
774
- const requester = await user.getUserFields(requesterUid, ['username']);
775
- const latNum = Number(r.pickupLat);
776
- const lonNum = Number(r.pickupLon);
777
- const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
778
- ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
779
- : '';
780
- await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
781
- uid: requesterUid,
782
- username: requester && requester.username ? requester.username : '',
783
- itemName: arrayifyNames(r).join(', '),
784
- itemNames: arrayifyNames(r),
785
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
786
- paymentReceiptUrl: r.paymentReceiptUrl || '',
787
- pickupTime: r.pickupTime || '',
788
- pickupAddress: r.pickupAddress || '',
789
- mapUrl,
790
- ...(buildCalendarLinks({
791
- type: 'reservation', id: String(r.rid || ''), uid: requesterUid,
792
- itemNames: arrayifyNames(r),
793
- pickupAddress: r.pickupAddress || '', allDay: true,
794
- 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),
795
- 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),
796
- })),
797
- });
798
- }
799
- } catch (e) {}
800
-
801
- try {
802
- const requester = await user.getUserFields(parseInt(r.uid, 10), ['username']);
803
- await discord.notifyPaymentReceived(settings, {
804
- rid: r.rid, uid: r.uid,
805
- username: (requester && requester.username) ? requester.username : (r.username || ''),
806
- itemIds: arrayifyIds(r),
807
- itemNames: arrayifyNames(r),
808
- start: r.start, end: r.end, status: r.status,
809
- });
810
- } catch (e) {}
811
-
812
- return res.json({ ok: true, synced: true, prevStatus, paymentId: r.paymentId || '' });
702
+ const result = await syncPaymentHelper({ r, settings, actorUid: req.uid });
703
+ return res.json(result);
813
704
  };
814
705
 
815
706
  admin.purgeAccounting = async function (req, res) {
package/lib/api.js CHANGED
@@ -36,6 +36,7 @@ const helloasso = require('./helloasso');
36
36
  const discord = require('./discord');
37
37
  const realtime = require('./realtime');
38
38
  const { buildCheckoutIntent } = require('./checkout');
39
+ const { syncPayment: syncPaymentHelper } = require('./syncPayment');
39
40
 
40
41
 
41
42
  function overlap(aStart, aEnd, bStart, bEnd) {
@@ -793,8 +794,6 @@ api.leaveSpecialEvent = async function (req, res) {
793
794
  api.getCapabilities = async function (req, res) {
794
795
  const settings = await getSettings();
795
796
  const uid = req.uid || 0;
796
- const pendingHoldMinutes = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
797
- const paymentHoldMinutes = parseInt(getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')), 10) || 60;
798
797
  if (!uid) {
799
798
  return res.json({
800
799
  canModerate: false,
@@ -803,8 +802,6 @@ api.getCapabilities = async function (req, res) {
803
802
  canCreateOuting: false,
804
803
  canCreateReservation: false,
805
804
  specialEventCategoryCid: 0,
806
- pendingHoldMinutes,
807
- paymentHoldMinutes,
808
805
  });
809
806
  }
810
807
  const [canMod, canSpecialC, canSpecialD, canReq] = await Promise.all([
@@ -821,8 +818,6 @@ api.getCapabilities = async function (req, res) {
821
818
  canCreateOuting: canMod || canReq,
822
819
  canCreateReservation: canReq,
823
820
  specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
824
- pendingHoldMinutes,
825
- paymentHoldMinutes,
826
821
  });
827
822
  };
828
823
 
@@ -1879,6 +1874,23 @@ api.cancelReservation = async function (req, res) {
1879
1874
  return res.json({ ok: true, status: 'cancelled' });
1880
1875
  };
1881
1876
 
1877
+ // Validator: sync payment status from HelloAsso (fallback when webhook fails).
1878
+ api.syncPaymentFromHelloAsso = async function (req, res) {
1879
+ const uid = req.uid;
1880
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
1881
+ const settings = await getSettings();
1882
+ if (!(await canValidate(uid, settings))) return res.status(403).json({ error: 'not-allowed' });
1883
+
1884
+ const rid = String(req.params.rid || '').trim();
1885
+ if (!rid) return res.status(400).json({ error: 'missing-rid' });
1886
+
1887
+ const r = await dbLayer.getReservation(rid);
1888
+ if (!r) return res.status(404).json({ error: 'not-found' });
1889
+
1890
+ const result = await syncPaymentHelper({ r, settings, actorUid: uid });
1891
+ return res.json(result);
1892
+ };
1893
+
1882
1894
  // --------------------
1883
1895
  // Maintenance (simple ON/OFF)
1884
1896
  // --------------------
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
3
5
  const db = require.main.require('./src/database');
4
6
  const meta = require.main.require('./src/meta');
5
7
  const user = require.main.require('./src/user');
@@ -62,11 +64,32 @@ function getClientIp(req) {
62
64
  return String(req.ip || '').trim();
63
65
  }
64
66
 
67
+ // HelloAsso sends two distinct webhook formats:
68
+ // Format A – global webhook: { eventType: "Payment", data: { id, state, checkoutIntentId, ... } }
69
+ // Format B – notificationUrl: checkout intent object directly: { id, state, metadata, order: { payments } }
70
+ //
71
+ // All extraction helpers normalise both by using `payload.data` when present,
72
+ // falling back to `payload` itself (Format B).
73
+
74
+ function _payloadData(payload) {
75
+ if (!payload) return null;
76
+ // Format A: wrapped under .data
77
+ if (payload.data && typeof payload.data === 'object') return payload.data;
78
+ // Format B: direct checkout intent object from notificationUrl.
79
+ // Require BOTH a numeric/string id AND (state OR order) to avoid misidentifying
80
+ // arbitrary JSON (e.g. health-check responses, error objects) as valid intents.
81
+ if (!payload.eventType && payload.id !== undefined &&
82
+ (payload.state !== undefined || (payload.order && typeof payload.order === 'object'))) {
83
+ return payload;
84
+ }
85
+ return null;
86
+ }
87
+
65
88
  // Extract reservationId from the metadata field of a webhook payload.
66
89
  // HelloAsso uses several payload shapes depending on event type and API version.
67
90
  function getReservationIdFromPayload(payload) {
68
91
  try {
69
- const data = payload && payload.data ? payload.data : null;
92
+ const data = _payloadData(payload);
70
93
  if (!data) return null;
71
94
 
72
95
  const metaCandidates = [
@@ -96,10 +119,12 @@ function getReservationIdFromPayload(payload) {
96
119
 
97
120
  function getCheckoutIntentIdFromPayload(payload) {
98
121
  try {
99
- const data = payload && payload.data ? payload.data : null;
122
+ const data = _payloadData(payload);
100
123
  if (!data) return null;
101
124
  const candidates = [
102
125
  data.checkoutIntentId,
126
+ // Format B: the top-level .id is the checkout intent ID
127
+ (!payload.data && data.id) ? data.id : undefined,
103
128
  data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
104
129
  data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
105
130
  ].filter(Boolean);
@@ -111,17 +136,30 @@ function getCheckoutIntentIdFromPayload(payload) {
111
136
  // Returns true for event types and states that represent a completed payment.
112
137
  function isConfirmedPayment(payload) {
113
138
  try {
114
- if (!payload || !payload.data) return false;
139
+ if (!payload) return false;
140
+ const data = _payloadData(payload);
141
+ if (!data) return false;
142
+
115
143
  const eventType = String(payload.eventType || '').toLowerCase();
116
- const state = String(payload.data.state || payload.data.status || payload.data.paymentState || '').toLowerCase();
144
+ // For Format B (direct checkout intent): state is at data.state or nested in order/payments.
145
+ const orderPayments = (data.order && Array.isArray(data.order.payments)) ? data.order.payments : [];
146
+ const state = String(
147
+ data.state || data.status || data.paymentState ||
148
+ (orderPayments.length ? (orderPayments[0].state || orderPayments[0].status) : '') ||
149
+ (data.order && (data.order.state || data.order.status)) ||
150
+ ''
151
+ ).toLowerCase();
117
152
  const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
118
153
 
154
+ // Format A – wrapped event types
119
155
  if (eventType === 'payment') return okState;
120
156
  if (eventType === 'order' || eventType === 'checkout') {
121
157
  // Order/Checkout payloads sometimes omit the state field; accept when missing.
122
158
  return !state || okState;
123
159
  }
124
- // For any other event type, accept if state is clearly positive.
160
+ // Format B no eventType: direct checkout intent object from notificationUrl
161
+ if (!eventType) return okState;
162
+ // Any other event type: accept if state is clearly positive.
125
163
  if (okState) return true;
126
164
  return false;
127
165
  } catch (e) {
@@ -233,12 +271,39 @@ async function handler(req, res, next) {
233
271
  }
234
272
  }
235
273
 
274
+ // Verify HelloAsso HMAC-SHA256 signature when the header is present.
275
+ // HelloAsso signs with the client secret; header format: x-ha-signature: sha256=<hex>
276
+ const sigHeader = String(req.headers && req.headers['x-ha-signature'] || '').trim();
277
+ if (sigHeader) {
278
+ const clientSecret = String((settings && settings.helloassoClientSecret) || '').trim();
279
+ if (!clientSecret) {
280
+ // eslint-disable-next-line no-console
281
+ console.warn('[calendar-onekite] HelloAsso webhook: x-ha-signature present but helloassoClientSecret not configured — rejecting');
282
+ return res.status(401).json({ ok: false, error: 'signature-config-missing' });
283
+ }
284
+ const rawBody = req.rawBody ? req.rawBody : Buffer.from(JSON.stringify(payload || {}));
285
+ const expected = `sha256=${crypto.createHmac('sha256', clientSecret).update(rawBody).digest('hex')}`;
286
+ const isValid = crypto.timingSafeEqual(
287
+ Buffer.from(expected, 'utf8'),
288
+ Buffer.from(sigHeader, 'utf8')
289
+ );
290
+ if (!isValid) {
291
+ // eslint-disable-next-line no-console
292
+ console.warn('[calendar-onekite] HelloAsso webhook: invalid x-ha-signature — rejected');
293
+ return res.status(401).json({ ok: false, error: 'invalid-signature' });
294
+ }
295
+ }
296
+
297
+ const _d = _payloadData(payload);
298
+ const _isFormatB = !!payload && !payload.eventType && !!_d;
236
299
  // eslint-disable-next-line no-console
237
300
  console.info('[calendar-onekite] HelloAsso webhook received', {
301
+ format: _isFormatB ? 'direct-checkoutIntent' : 'wrapped-event',
238
302
  eventType: payload && payload.eventType,
239
- state: payload && payload.data && (payload.data.state || payload.data.status || payload.data.paymentState),
240
- paymentId: payload && payload.data && (payload.data.id || payload.data.paymentId),
241
- checkoutIntentId: payload && payload.data && (payload.data.checkoutIntentId || (payload.data.order && payload.data.order.checkoutIntentId)),
303
+ state: _d && (_d.state || _d.status || _d.paymentState || (_d.order && (_d.order.state || _d.order.status))),
304
+ paymentId: _d && (_d.id || _d.paymentId || (_d.order && Array.isArray(_d.order.payments) && _d.order.payments[0] && _d.order.payments[0].id)),
305
+ checkoutIntentId: _d && (_d.checkoutIntentId || (_isFormatB ? _d.id : undefined) || (_d.order && _d.order.checkoutIntentId)),
306
+ hasMetadata: !!(_d && (_d.metadata || _d.meta)),
242
307
  hasBody: !!payload,
243
308
  contentType: req.headers && req.headers['content-type'],
244
309
  });
@@ -246,9 +311,10 @@ async function handler(req, res, next) {
246
311
  if (!isConfirmedPayment(payload)) {
247
312
  // eslint-disable-next-line no-console
248
313
  console.warn('[calendar-onekite] HelloAsso webhook: payment not confirmed — ignored', {
314
+ format: _isFormatB ? 'direct-checkoutIntent' : 'wrapped-event',
249
315
  eventType: payload && payload.eventType,
250
- state: payload && payload.data && (payload.data.state || payload.data.status || payload.data.paymentState),
251
- hasData: !!(payload && payload.data),
316
+ state: _d && (_d.state || _d.status || _d.paymentState || (_d.order && (_d.order.state || _d.order.status))),
317
+ hasBody: !!payload,
252
318
  });
253
319
  return res.json({ ok: true, ignored: true });
254
320
  }
@@ -261,16 +327,22 @@ async function handler(req, res, next) {
261
327
  if (eventType === 'payment' && !rid && !checkoutIntentId) {
262
328
  // eslint-disable-next-line no-console
263
329
  console.warn('[calendar-onekite] HelloAsso webhook: Payment event has no metadata.reservationId and no checkoutIntentId', {
264
- paymentId: payload && payload.data && payload.data.id,
265
- dataKeys: payload && payload.data ? Object.keys(payload.data) : [],
330
+ paymentId: _d && _d.id,
331
+ dataKeys: _d ? Object.keys(_d) : [],
266
332
  });
267
333
  return res.json({ ok: true, ignored: true, incompletePayment: true });
268
334
  }
269
335
 
270
- const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
336
+ // Extract paymentId.
337
+ // Format A: data.id is the payment ID.
338
+ // Format B: order.payments[0].id is the payment ID (data.id is the checkout intent ID).
339
+ const _payments = (_d && _d.order && Array.isArray(_d.order.payments)) ? _d.order.payments : [];
340
+ const paymentId = payload.data
341
+ ? (_d.id || _d.paymentId)
342
+ : (_payments.length ? _payments[0].id : null);
271
343
  if (!paymentId) {
272
344
  // eslint-disable-next-line no-console
273
- console.warn('[calendar-onekite] HelloAsso webhook: missing payment id', { eventType, dataKeys: payload && payload.data ? Object.keys(payload.data) : [] });
345
+ console.warn('[calendar-onekite] HelloAsso webhook: missing payment id', { eventType, dataKeys: _d ? Object.keys(_d) : [] });
274
346
  return res.json({ ok: true, ignored: true, missingPaymentId: true });
275
347
  }
276
348
 
@@ -301,15 +373,25 @@ async function handler(req, res, next) {
301
373
  return res.json({ ok: true, processed: true, reservationNotFound: true });
302
374
  }
303
375
 
376
+ // Idempotency: already paid → just mark as processed and return.
377
+ if (r.status === 'paid') {
378
+ await markProcessed(paymentId);
379
+ return res.json({ ok: true, processed: true, alreadyPaid: true });
380
+ }
381
+
304
382
  // eslint-disable-next-line no-console
305
383
  console.info('[calendar-onekite] HelloAsso webhook: marking reservation paid', { rid: resolvedRid, prevStatus: r.status, paymentId });
306
384
 
307
385
  r.status = 'paid';
308
386
  r.paidAt = Date.now();
309
387
  r.paymentId = String(paymentId);
310
- if (payload.data && payload.data.paymentReceiptUrl) {
311
- r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
312
- }
388
+ // paymentReceiptUrl: Format A = data.paymentReceiptUrl, Format B = order.payments[0].paymentReceiptUrl
389
+ const _receiptUrl = (payload.data && payload.data.paymentReceiptUrl)
390
+ ? String(payload.data.paymentReceiptUrl)
391
+ : (_payments.length && (_payments[0].paymentReceiptUrl || _payments[0].receiptUrl))
392
+ ? String(_payments[0].paymentReceiptUrl || _payments[0].receiptUrl)
393
+ : '';
394
+ if (_receiptUrl) r.paymentReceiptUrl = _receiptUrl;
313
395
  await dbLayer.saveReservation(r);
314
396
 
315
397
  await auditLog('reservation_paid', 0, {
package/lib/scheduler.js CHANGED
@@ -1,338 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const nconf = require.main.require('nconf');
4
- const db = require.main.require('./src/database');
5
- const user = require.main.require('./src/user');
6
- const dbLayer = require('./db');
7
- const discord = require('./discord');
8
- const realtime = require('./realtime');
9
- const { getSettings } = require('./settings');
10
4
 
11
- const shared = require('./shared');
12
- const {
13
- getSetting,
14
- formatFR,
15
- arrayifyNames,
16
- forumBaseUrl,
17
- normalizeAllowedGroups,
18
- normalizeUids,
19
- getMembersByGroupIdentifier,
20
- sendEmail,
21
- } = shared;
22
5
  let timer = null;
23
6
 
24
- // Some NodeBB database adapters don't expose setAdd/setRemove helpers.
25
- // Use a safe "add once" guard to avoid crashing the scheduler.
26
- async function addOnce(key, value) {
27
- const v = String(value);
28
- if (!v) return false;
29
-
30
- // Best-effort atomic guard across multi-process / multi-instance.
31
- // incrObjectField is implemented by most NodeBB DB adapters (Redis/Mongo).
32
- // If it exists, it gives us a reliable "first winner" (value === 1).
33
- if (typeof db.incrObjectField === 'function') {
34
- try {
35
- const n = await db.incrObjectField(key, v);
36
- return Number(n) === 1;
37
- } catch (e) {
38
- // fall through
39
- }
40
- }
41
-
42
- // Preferred atomic helper (Redis + some adapters)
43
- if (typeof db.setAdd === 'function') {
44
- try {
45
- return !!(await db.setAdd(key, v));
46
- } catch (e) {
47
- // fall through
48
- }
49
- }
50
-
51
- // Fallback: store a marker in an object map.
52
- // Not perfectly atomic across clustered processes, but avoids a hard failure.
53
- try {
54
- const existing = await db.getObjectField(key, v);
55
- if (existing) return false;
56
- await db.setObjectField(key, v, 1);
57
- return true;
58
- } catch (e) {
59
- // Last resort: allow sending rather than silently doing nothing.
60
- // This may duplicate emails in edge cases, but avoids "expires with no email".
61
- return true;
62
- }
63
- }
64
-
65
- // Helpers imported from shared.js above
66
- async function getValidatorUids(settings) {
67
- const out = new Set();
68
- // Always include administrators
69
- try {
70
- const admins = await getMembersByGroupIdentifier('administrators');
71
- normalizeUids(admins).forEach((u) => out.add(u));
72
- } catch (e) {}
73
-
74
- const groupsCsv = normalizeAllowedGroups(settings && settings.validatorGroups);
75
- for (const g of groupsCsv) {
76
- try {
77
- const members = await getMembersByGroupIdentifier(g);
78
- normalizeUids(members).forEach((u) => out.add(u));
79
- } catch (e) {}
80
- }
81
- return Array.from(out);
82
- }
83
-
84
-
85
- async function getNotifyUids(settings) {
86
- const out = new Set();
87
- const groupsCsv = normalizeAllowedGroups(settings && settings.notifyGroups);
88
- for (const g of groupsCsv) {
89
- try {
90
- const members = await getMembersByGroupIdentifier(g);
91
- normalizeUids(members).forEach((u) => out.add(u));
92
- } catch (e) {}
93
- }
94
- return Array.from(out);
95
- }
96
-
97
- async function getNotifiedValidatorUids(settings) {
98
- const [validators, notified] = await Promise.all([
99
- getValidatorUids(settings),
100
- getNotifyUids(settings),
101
- ]);
102
- const notifiedSet = new Set((notified || []).map((u) => parseInt(u, 10)).filter(Number.isFinite));
103
- return (validators || []).filter((u) => notifiedSet.has(parseInt(u, 10)));
104
- }
105
-
106
- // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
107
- async function expirePending(preIds, preReservations) {
108
- const settings = await getSettings();
109
- const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
110
- const validatorReminderMins = parseInt(getSetting(settings, 'validatorReminderMinutesPending', '0'), 10) || 0;
111
- const now = Date.now();
112
-
113
- const adminUrl = (() => {
114
- const base = forumBaseUrl();
115
- return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
116
- })();
117
-
118
- const validatorUids = validatorReminderMins > 0 ? await getNotifiedValidatorUids(settings) : [];
119
-
120
- const ids = preIds || await dbLayer.listAllReservationIds(5000);
121
- if (!ids || !ids.length) return;
122
-
123
- const reservations = preReservations || await dbLayer.getReservations(ids);
124
-
125
- for (let i = 0; i < ids.length; i += 1) {
126
- const rid = ids[i];
127
- const resv = reservations[i];
128
- if (!resv || resv.status !== 'pending') {
129
- continue;
130
- }
131
- const createdAt = parseInt(resv.createdAt, 10) || 0;
132
- const expiresAt = createdAt + holdMins * 60 * 1000;
133
-
134
- // Reminder to validators while still pending
135
- if (validatorReminderMins > 0 && createdAt && now >= (createdAt + validatorReminderMins * 60 * 1000) && now < expiresAt) {
136
- const reminderKey = 'calendar-onekite:email:validatorReminder:pending';
137
- const first = await addOnce(reminderKey, rid);
138
- if (first && validatorUids.length) {
139
- const requesterUid = parseInt(resv.uid, 10) || 0;
140
- const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
141
- const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
142
- for (const vuid of validatorUids) {
143
- await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
144
- rid,
145
- requesterUsername,
146
- itemNames: arrayifyNames(resv),
147
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
148
- adminUrl,
149
- kind: 'pending',
150
- delayMinutes: validatorReminderMins,
151
- });
152
- }
153
- }
154
- }
155
-
156
- if (now > expiresAt) {
157
- // Expire (remove from calendar) + notify requester + validators
158
- const expiredKey = 'calendar-onekite:email:expiredSent:pending';
159
- const firstExpired = await addOnce(expiredKey, rid);
160
-
161
- const requesterUid = parseInt(resv.uid, 10) || 0;
162
- const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
163
- const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
164
-
165
- const reason = 'Demande non prise en charge dans le temps imparti.';
166
-
167
- if (firstExpired && requesterUid) {
168
- await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
169
- uid: requesterUid,
170
- username: requesterUsername,
171
- itemNames: arrayifyNames(resv),
172
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
173
- delayMinutes: holdMins,
174
- reason,
175
- });
176
- }
177
-
178
- // Validators info email (best-effort)
179
- if (firstExpired) {
180
- const validators = await getNotifiedValidatorUids(settings);
181
- for (const vuid of validators) {
182
- await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
183
- rid,
184
- requesterUsername,
185
- itemNames: arrayifyNames(resv),
186
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
187
- adminUrl,
188
- reason,
189
- });
190
- }
191
- }
192
-
193
- await dbLayer.removeReservation(rid);
194
-
195
- // Real-time refresh for all viewers
196
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'expired', rid: String(rid), status: 'expired' });
197
- }
198
- }
199
- }
200
-
201
- // Payment window logic:
202
- // - When a reservation is validated it becomes awaiting_payment
203
- // - We send a reminder after `paymentHoldMinutes` (default 60)
204
- // - We expire (and remove) after `2 * paymentHoldMinutes`
205
- async function processAwaitingPayment(preIds, preReservations) {
206
- const settings = await getSettings();
207
- const holdMins = parseInt(
208
- getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
209
- 10
210
- ) || 60;
211
- const now = Date.now();
212
-
213
- const ids = preIds || await dbLayer.listAllReservationIds(5000);
214
- if (!ids || !ids.length) return;
215
-
216
- const adminUrl = (() => {
217
- const base = forumBaseUrl();
218
- return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
219
- })();
220
-
221
- const reservations = preReservations || await dbLayer.getReservations(ids);
222
-
223
- for (let i = 0; i < ids.length; i += 1) {
224
- const rid = ids[i];
225
- const r = reservations[i];
226
- if (!r || r.status !== 'awaiting_payment') continue;
227
-
228
- const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
229
- if (!approvedAt) continue;
230
-
231
- const reminderAt = approvedAt + holdMins * 60 * 1000;
232
- const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
233
-
234
- if (!r.reminderSent && now >= reminderAt && now < expireAt) {
235
- // Send reminder once (guarded across clustered NodeBB processes)
236
- const reminderKey = 'calendar-onekite:email:reminderSent';
237
- const first = await addOnce(reminderKey, rid);
238
- if (!first) {
239
- // another process already sent it
240
- r.reminderSent = true;
241
- r.reminderAt = now;
242
- await dbLayer.saveReservation(r);
243
- continue;
244
- }
245
-
246
- const toUid = parseInt(r.uid, 10);
247
- const u = await user.getUserFields(toUid, ['username']);
248
- if (toUid) {
249
- await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
250
- uid: toUid,
251
- username: (u && u.username) ? u.username : '',
252
- itemName: arrayifyNames(r).join(', '),
253
- itemNames: arrayifyNames(r),
254
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
255
- paymentUrl: r.paymentUrl || '',
256
- calendarUrl: `${forumBaseUrl()}/calendar`,
257
- delayMinutes: holdMins,
258
- pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
259
- });
260
- }
261
-
262
- r.reminderSent = true;
263
- r.reminderAt = now;
264
- await dbLayer.saveReservation(r);
265
- continue;
266
- }
267
-
268
- if (now >= expireAt) {
269
- // Expire: remove reservation so it disappears from calendar and frees items
270
- // Guard email send across clustered NodeBB processes
271
- const expiredKey = 'calendar-onekite:email:expiredSent';
272
- const firstExpired = await addOnce(expiredKey, rid);
273
- const shouldEmail = !!firstExpired;
274
-
275
- // Guard Discord notification across clustered NodeBB processes
276
- const discordKey = 'calendar-onekite:discord:cancelledSent';
277
- const firstDiscord = await addOnce(discordKey, rid);
278
- const shouldDiscord = !!firstDiscord;
279
-
280
- const toUid = parseInt(r.uid, 10);
281
- const u = await user.getUserFields(toUid, ['username']);
282
-
283
- if (shouldEmail && toUid) {
284
- await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
285
- uid: toUid,
286
- username: (u && u.username) ? u.username : '',
287
- itemName: arrayifyNames(r).join(', '),
288
- itemNames: arrayifyNames(r),
289
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
290
- delayMinutes: holdMins,
291
- reason: 'Paiement non reçu dans le temps imparti.',
292
- });
293
- }
294
-
295
- // Validators info email (best-effort)
296
- if (shouldEmail) {
297
- const validators = await getNotifiedValidatorUids(settings);
298
- for (const vuid of validators) {
299
- await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
300
- rid: r.rid || rid,
301
- requesterUsername: (u && u.username) ? u.username : (r.username || ''),
302
- itemNames: arrayifyNames(r),
303
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
304
- adminUrl,
305
- reason: 'Paiement non reçu dans le temps imparti.',
306
- paymentUrl: r.paymentUrl || '',
307
- });
308
- }
309
- }
310
-
311
- if (shouldDiscord) {
312
- try {
313
- await discord.notifyReservationCancelled(settings, {
314
- rid: r.rid || rid,
315
- uid: r.uid,
316
- username: (u && u.username) ? u.username : (r.username || ''),
317
- itemIds: r.itemIds || [],
318
- itemNames: r.itemNames || [],
319
- start: r.start,
320
- end: r.end,
321
- status: 'cancelled',
322
- cancelledAt: now,
323
- cancelledBy: 'system',
324
- });
325
- } catch (e) {}
326
- }
327
-
328
- await dbLayer.removeReservation(rid);
329
-
330
- // Real-time refresh for all viewers
331
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'expired', rid: String(rid), status: 'expired' });
332
- }
333
- }
334
- }
335
-
336
7
  function start() {
337
8
  const runJobs = nconf.get('runJobs');
338
9
  if (runJobs === false || runJobs === 'false' || runJobs === 0 || runJobs === '0') {
@@ -340,24 +11,8 @@ function start() {
340
11
  console.info('[calendar-onekite] Scheduler disabled (runJobs=false)');
341
12
  return;
342
13
  }
343
- if (timer) return;
344
14
  // eslint-disable-next-line no-console
345
- console.info('[calendar-onekite] Scheduler enabled');
346
- timer = setInterval(async () => {
347
- try {
348
- // Single DB fetch shared between both jobs (avoids duplicate listAll + getReservations)
349
- const ids = await dbLayer.listAllReservationIds(5000);
350
- const reservations = ids && ids.length ? await dbLayer.getReservations(ids) : [];
351
- await expirePending(ids, reservations).catch((err) => {
352
- console.warn('[calendar-onekite] Scheduler error in expirePending', err && err.message ? err.message : err);
353
- });
354
- await processAwaitingPayment(ids, reservations).catch((err) => {
355
- console.warn('[calendar-onekite] Scheduler error in processAwaitingPayment', err && err.message ? err.message : err);
356
- });
357
- } catch (err) {
358
- console.warn('[calendar-onekite] Scheduler tick error', err && err.message ? err.message : err);
359
- }
360
- }, 60 * 1000);
15
+ console.info('[calendar-onekite] Scheduler started (no expiry timers configured)');
361
16
  }
362
17
 
363
18
  function stop() {
@@ -367,9 +22,4 @@ function stop() {
367
22
  }
368
23
  }
369
24
 
370
- module.exports = {
371
- start,
372
- stop,
373
- expirePending,
374
- processAwaitingPayment,
375
- };
25
+ module.exports = { start, stop };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared helper: verify payment status from HelloAsso for a reservation
5
+ * stuck in awaiting_payment, and mark it as paid if confirmed.
6
+ *
7
+ * Used by both api.syncPaymentFromHelloAsso (validator-level) and
8
+ * admin.syncPaymentFromHelloAsso (admin-level). Auth checks are the
9
+ * caller's responsibility.
10
+ *
11
+ * @param {object} opts
12
+ * @param {object} opts.r Reservation object (mutated and saved on success)
13
+ * @param {object} opts.settings Plugin settings
14
+ * @param {number} opts.actorUid UID of the user triggering the sync (for audit)
15
+ * @returns {Promise<{ok: boolean, synced?: boolean, alreadyPaid?: boolean,
16
+ * reason?: string, prevStatus?: string, paymentId?: string,
17
+ * orderState?: string, paymentState?: string}>}
18
+ */
19
+
20
+ const user = require.main.require('./src/user');
21
+
22
+ const dbLayer = require('./db');
23
+ const helloasso = require('./helloasso');
24
+ const discord = require('./discord');
25
+ const realtime = require('./realtime');
26
+ const shared = require('./shared');
27
+ const { formatFR, sendEmail, buildCalendarLinks, arrayifyIds, arrayifyNames } = shared;
28
+
29
+ const OK_STATES = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'];
30
+
31
+ async function syncPayment({ r, settings, actorUid }) {
32
+ if (r.status === 'paid') return { ok: true, alreadyPaid: true, status: r.status };
33
+ if (r.status !== 'awaiting_payment') return { ok: false, reason: 'wrong-status', status: r.status };
34
+
35
+ const checkoutIntentId = r.checkoutIntentId ? String(r.checkoutIntentId) : null;
36
+ if (!checkoutIntentId) return { ok: false, reason: 'no-checkout-intent-id' };
37
+
38
+ const token = await helloasso.getAccessToken({
39
+ env: settings.helloassoEnv || 'prod',
40
+ clientId: settings.helloassoClientId,
41
+ clientSecret: settings.helloassoClientSecret,
42
+ });
43
+ if (!token) return { ok: false, reason: 'helloasso-auth-failed' };
44
+
45
+ const details = await helloasso.getCheckoutIntentDetails({
46
+ env: settings.helloassoEnv || 'prod',
47
+ token,
48
+ organizationSlug: settings.helloassoOrganizationSlug,
49
+ checkoutIntentId,
50
+ });
51
+ if (!details) return { ok: false, reason: 'checkout-intent-not-found' };
52
+
53
+ const order = details.order ? details.order : details;
54
+ const orderState = String((order.state || order.status) || '').toLowerCase();
55
+ const paymentsArr = Array.isArray(order.payments) ? order.payments : [];
56
+ const paymentState = paymentsArr.length
57
+ ? String((paymentsArr[0].state || paymentsArr[0].status) || '').toLowerCase()
58
+ : '';
59
+
60
+ if (!OK_STATES.includes(orderState) && !OK_STATES.includes(paymentState)) {
61
+ return { ok: false, reason: 'not-paid-on-helloasso', orderState, paymentState };
62
+ }
63
+
64
+ const prevStatus = r.status;
65
+ const paymentId = paymentsArr.length ? String(paymentsArr[0].id || '') : '';
66
+ const paymentReceiptUrl = paymentsArr.length
67
+ ? String(paymentsArr[0].paymentReceiptUrl || paymentsArr[0].receiptUrl || '')
68
+ : '';
69
+
70
+ r.status = 'paid';
71
+ r.paidAt = Date.now();
72
+ r.syncedFromHelloAsso = true;
73
+ r.syncedBy = actorUid || 0;
74
+ if (paymentId) r.paymentId = paymentId;
75
+ if (paymentReceiptUrl) r.paymentReceiptUrl = paymentReceiptUrl;
76
+ await dbLayer.saveReservation(r);
77
+
78
+ try {
79
+ await dbLayer.addAuditEntry({
80
+ ts: Date.now(), year: new Date().getFullYear(),
81
+ action: 'reservation_sync_paid',
82
+ targetType: 'reservation', targetId: String(r.rid),
83
+ reservationUid: Number(r.uid) || 0,
84
+ reservationUsername: String(r.username || ''),
85
+ actorUid: actorUid || 0,
86
+ itemIds: arrayifyIds(r), itemNames: arrayifyNames(r),
87
+ startDate: r.startDate || '', endDate: r.endDate || '',
88
+ prevStatus,
89
+ });
90
+ } catch (e) {}
91
+
92
+ realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: 'paid' });
93
+
94
+ try {
95
+ const requesterUid = parseInt(r.uid, 10);
96
+ if (requesterUid) {
97
+ const requester = await user.getUserFields(requesterUid, ['username']);
98
+ const latNum = Number(r.pickupLat);
99
+ const lonNum = Number(r.pickupLon);
100
+ const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
101
+ ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
102
+ : '';
103
+ await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
104
+ uid: requesterUid,
105
+ username: requester && requester.username ? requester.username : '',
106
+ itemName: arrayifyNames(r).join(', '),
107
+ itemNames: arrayifyNames(r),
108
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
109
+ paymentReceiptUrl: r.paymentReceiptUrl || '',
110
+ pickupTime: r.pickupTime || '',
111
+ pickupAddress: r.pickupAddress || '',
112
+ mapUrl,
113
+ ...(buildCalendarLinks({
114
+ type: 'reservation', id: String(r.rid || ''), uid: requesterUid,
115
+ itemNames: arrayifyNames(r),
116
+ pickupAddress: r.pickupAddress || '', allDay: true,
117
+ startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
118
+ ? String(r.startDate)
119
+ : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
120
+ endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate)))
121
+ ? String(r.endDate)
122
+ : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
123
+ })),
124
+ });
125
+ }
126
+ } catch (e) {}
127
+
128
+ try {
129
+ const requester2 = await user.getUserFields(parseInt(r.uid, 10), ['username']);
130
+ await discord.notifyPaymentReceived(settings, {
131
+ rid: r.rid, uid: r.uid,
132
+ username: (requester2 && requester2.username) ? requester2.username : (r.username || ''),
133
+ itemIds: arrayifyIds(r), itemNames: arrayifyNames(r),
134
+ start: r.start, end: r.end, status: r.status,
135
+ });
136
+ } catch (e) {}
137
+
138
+ return { ok: true, synced: true, prevStatus, paymentId: r.paymentId || '' };
139
+ }
140
+
141
+ module.exports = { syncPayment };
package/library.js CHANGED
@@ -80,6 +80,7 @@ Plugin.init = async function (params) {
80
80
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
81
81
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
82
82
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
83
+ router.post('/api/v3/plugins/calendar-onekite/reservations/:rid/sync-payment', ...publicExpose, api.syncPaymentFromHelloAsso);
83
84
 
84
85
  router.post('/api/v3/plugins/calendar-onekite/special-events', ...publicExpose, api.createSpecialEvent);
85
86
  router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.94",
3
+ "version": "2.0.96",
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/client.js CHANGED
@@ -608,10 +608,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
608
608
 
609
609
  function statusLabel(s) {
610
610
  const map = {
611
- pending: 'En attente de validation de validation',
611
+ pending: 'En attente de validation',
612
612
  awaiting_payment: 'Validée – paiement en attente',
613
613
  paid: 'Payée',
614
- rejected: 'Rejetée',
614
+ refused: 'Rejetée',
615
+ cancelled: 'Annulée',
615
616
  expired: 'Expirée',
616
617
  };
617
618
  return map[String(s || '')] || String(s || '');
@@ -1244,14 +1245,6 @@ function toDatetimeLocalValue(date) {
1244
1245
  </div>
1245
1246
  ` : '';
1246
1247
 
1247
- const holdPending = parseInt((opts && opts.pendingHoldMinutes), 10) || 5;
1248
- const holdPayment = parseInt((opts && opts.paymentHoldMinutes), 10) || 60;
1249
- const delayBannerHtml = `
1250
- <div class="mt-2 p-2 rounded" style="font-size: 12px; background: var(--bs-info-bg-subtle, #cff4fc); border: 1px solid var(--bs-info-border-subtle, #9eeaf9); color: var(--bs-info-text-emphasis, #055160);">
1251
- <strong>Délais :</strong>
1252
- validation sous <strong>${fmtDuration(holdPending)}</strong> · paiement sous <strong>${fmtDuration(holdPayment)}</strong> après validation
1253
- </div>
1254
- `;
1255
1248
 
1256
1249
  const messageHtml = `
1257
1250
  <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>
@@ -1269,7 +1262,6 @@ function toDatetimeLocalValue(date) {
1269
1262
  <div id="onekite-total" style="font-size: 18px;"><strong>0,00 €</strong></div>
1270
1263
  </div>
1271
1264
  <div class="text-muted" style="font-size: 12px;">Les matériels grisés sont déjà réservés ou en attente.</div>
1272
- ${delayBannerHtml}
1273
1265
  `;
1274
1266
 
1275
1267
  return new Promise((resolve) => {
@@ -1446,9 +1438,6 @@ function toDatetimeLocalValue(date) {
1446
1438
  const canCreateReservation = !!caps.canCreateReservation;
1447
1439
  const isValidator = !!caps.isValidator;
1448
1440
  const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
1449
- const pendingHoldMinutes = parseInt(caps.pendingHoldMinutes, 10) || 5;
1450
- const paymentHoldMinutes = parseInt(caps.paymentHoldMinutes, 10) || 60;
1451
-
1452
1441
  // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1453
1442
 
1454
1443
  // Inject lightweight responsive CSS once.
@@ -1596,7 +1585,7 @@ function toDatetimeLocalValue(date) {
1596
1585
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1597
1586
  return;
1598
1587
  }
1599
- const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1588
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1600
1589
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1601
1590
  const startDate = toLocalYmd(sel.start);
1602
1591
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
@@ -1688,7 +1677,7 @@ function toDatetimeLocalValue(date) {
1688
1677
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1689
1678
  return;
1690
1679
  }
1691
- const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1680
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1692
1681
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1693
1682
  const startDate = toLocalYmd(sel.start);
1694
1683
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
@@ -2423,6 +2412,37 @@ function toDatetimeLocalValue(date) {
2423
2412
  },
2424
2413
  };
2425
2414
  }
2415
+ if (canModerate && status === 'awaiting_payment') {
2416
+ buttons.syncPayment = {
2417
+ label: 'Vérifier paiement',
2418
+ className: 'btn-outline-info',
2419
+ callback: () => {
2420
+ (async () => {
2421
+ try {
2422
+ const resp = await fetchJson(
2423
+ `/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(rid))}/sync-payment`,
2424
+ { method: 'POST' }
2425
+ );
2426
+ if (resp && (resp.alreadyPaid || resp.synced)) {
2427
+ const msg = resp.alreadyPaid
2428
+ ? 'Paiement déjà confirmé.'
2429
+ : 'Paiement confirmé chez HelloAsso — statut mis à jour.';
2430
+ showAlert('success', msg);
2431
+ invalidateEventsCache();
2432
+ scheduleRefetch(calendar);
2433
+ try { bootbox.hideAll(); } catch (e) {}
2434
+ } else {
2435
+ const reason = (resp && resp.reason) || 'inconnu';
2436
+ showAlert('warning', `Paiement non confirmé chez HelloAsso (${reason}).`);
2437
+ }
2438
+ } catch (e) {
2439
+ showAlert('error', 'Erreur lors de la vérification du paiement.');
2440
+ }
2441
+ })();
2442
+ return false;
2443
+ },
2444
+ };
2445
+ }
2426
2446
  if (showModeration) {
2427
2447
  buttons.refuse = {
2428
2448
  label: 'Refuser',
@@ -3154,6 +3174,23 @@ function autoInit(data) {
3154
3174
 
3155
3175
  // Live refresh when the calendar changes (reservation status, new reservation,
3156
3176
  // new special event, maintenance toggles, webhook payment, etc.)
3177
+ // Refresh when returning to the tab after paying on HelloAsso — registered
3178
+ // independently of socket so it works even when socket is unavailable.
3179
+ try {
3180
+ if (!window.__oneKiteVisibilityBound) {
3181
+ window.__oneKiteVisibilityBound = true;
3182
+ document.addEventListener('visibilitychange', function () {
3183
+ if (document.visibilityState !== 'visible') return;
3184
+ try {
3185
+ const cal = window.oneKiteCalendar;
3186
+ if (!cal) return;
3187
+ invalidateEventsCache();
3188
+ scheduleRefetch(cal);
3189
+ } catch (e) {}
3190
+ });
3191
+ }
3192
+ } catch (e) {}
3193
+
3157
3194
  try {
3158
3195
  if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
3159
3196
  window.__oneKiteSocketBound = true;
@@ -51,23 +51,6 @@
51
51
  <input class="form-control" name="notifyGroups" placeholder="ex: administrators">
52
52
  </div>
53
53
 
54
- <div class="mb-3">
55
- <label class="form-label">Durée de blocage en attente (minutes)</label>
56
- <input class="form-control" name="pendingHoldMinutes" placeholder="5">
57
- </div>
58
-
59
- <div class="mb-3">
60
- <label class="form-label">Rappel validateurs (demande en attente) après (minutes)</label>
61
- <input class="form-control" name="validatorReminderMinutesPending" placeholder="0">
62
- <div class="form-text">Si &gt; 0, un email de rappel est envoyé aux validateurs après ce délai tant que la demande est toujours en attente.</div>
63
- </div>
64
-
65
- <div class="mb-3">
66
- <label class="form-label">Délai rappel paiement (minutes)</label>
67
- <input class="form-control" name="paymentHoldMinutes" placeholder="60">
68
- <div class="form-text">Après validation (statut <code>paiement en attente</code>), un rappel est envoyé après ce délai. La réservation est ensuite expirée après <strong>2×</strong> ce délai.</div>
69
- </div>
70
-
71
54
  <div class="mb-3">
72
55
  <label class="form-label">Location longue durée (jours) pour validateurs</label>
73
56
  <input class="form-control" name="validatorFreeMaxDays" placeholder="0">
@@ -43,13 +43,10 @@
43
43
  <!-- IF calendarUrl -->
44
44
  <p>
45
45
  <a href="{calendarUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
46
- Accéder au calendrier pour payer
46
+ Accéder au calendrier
47
47
  </a>
48
48
  </p>
49
49
  <p style="font-size: 12px; color: #666;">
50
50
  Sur le calendrier, cliquez sur votre réservation puis sur le bouton <strong>« Payer maintenant »</strong>.
51
- <!-- IF paymentUrl -->
52
- <br>Vous pouvez aussi payer directement via ce lien (valable quelques heures) : <a href="{paymentUrl}">{paymentUrl}</a>
53
- <!-- ENDIF paymentUrl -->
54
51
  </p>
55
52
  <!-- ENDIF calendarUrl -->
@@ -13,13 +13,10 @@
13
13
  <!-- IF calendarUrl -->
14
14
  <p>
15
15
  <a href="{calendarUrl}" style="display:inline-block;padding:12px 18px;border-radius:6px;text-decoration:none;border:1px solid #333;">
16
- Accéder au calendrier pour payer
16
+ Accéder au calendrier
17
17
  </a>
18
18
  </p>
19
19
  <p style="font-size: 12px; color: #666;">
20
20
  Sur le calendrier, cliquez sur votre réservation puis sur le bouton <strong>« Payer maintenant »</strong>.
21
- <!-- IF paymentUrl -->
22
- <br>Vous pouvez aussi payer directement via ce lien (valable quelques heures) : <a href="{paymentUrl}">{paymentUrl}</a>
23
- <!-- ENDIF paymentUrl -->
24
21
  </p>
25
22
  <!-- ENDIF calendarUrl -->