nodebb-plugin-onekite-calendar 2.0.95 → 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) {
@@ -1873,6 +1874,23 @@ api.cancelReservation = async function (req, res) {
1873
1874
  return res.json({ ok: true, status: 'cancelled' });
1874
1875
  };
1875
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
+
1876
1894
  // --------------------
1877
1895
  // Maintenance (simple ON/OFF)
1878
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, {
@@ -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.95",
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 || '');
@@ -2411,6 +2412,37 @@ function toDatetimeLocalValue(date) {
2411
2412
  },
2412
2413
  };
2413
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
+ }
2414
2446
  if (showModeration) {
2415
2447
  buttons.refuse = {
2416
2448
  label: 'Refuser',
@@ -3142,6 +3174,23 @@ function autoInit(data) {
3142
3174
 
3143
3175
  // Live refresh when the calendar changes (reservation status, new reservation,
3144
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
+
3145
3194
  try {
3146
3195
  if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
3147
3196
  window.__oneKiteSocketBound = true;
@@ -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 -->