nodebb-plugin-onekite-calendar 2.0.96 → 2.0.98

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.
@@ -133,6 +133,27 @@ function getCheckoutIntentIdFromPayload(payload) {
133
133
  return null;
134
134
  }
135
135
 
136
+ // Returns true for event types and states that represent a HelloAsso refund.
137
+ function isRefundEvent(payload) {
138
+ try {
139
+ if (!payload) return false;
140
+ const eventType = String(payload.eventType || '').toLowerCase();
141
+ if (eventType === 'refund') return true;
142
+ const data = _payloadData(payload);
143
+ if (!data) return false;
144
+ const orderPayments = (data.order && Array.isArray(data.order.payments)) ? data.order.payments : [];
145
+ const state = String(
146
+ data.state || data.status || data.paymentState ||
147
+ (orderPayments.length ? (orderPayments[0].state || orderPayments[0].status) : '') ||
148
+ (data.order && (data.order.state || data.order.status)) ||
149
+ ''
150
+ ).toLowerCase();
151
+ return ['refunded', 'refund', 'refundpending'].includes(state);
152
+ } catch (e) {
153
+ return false;
154
+ }
155
+ }
156
+
136
157
  // Returns true for event types and states that represent a completed payment.
137
158
  function isConfirmedPayment(payload) {
138
159
  try {
@@ -235,6 +256,129 @@ async function tryRecoverRidFromPayment(settings, paymentId) {
235
256
  }
236
257
  }
237
258
 
259
+ // ---------------------------------------------------------------------------
260
+ // Refund handler (called from main handler when isRefundEvent() is true)
261
+ // ---------------------------------------------------------------------------
262
+
263
+ async function handleRefund(req, res, payload, settings, _d) {
264
+ try {
265
+ const rid = getReservationIdFromPayload(payload);
266
+ const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
267
+
268
+ // Refund idempotency key — prefix with "refund_" to avoid collision with payment IDs.
269
+ const _payments = (_d && _d.order && Array.isArray(_d.order.payments)) ? _d.order.payments : [];
270
+ const refundId = _d && _d.id != null ? String(_d.id) : null;
271
+ const idempotencyKey = refundId
272
+ ? `refund_${refundId}`
273
+ : (!payload.data && checkoutIntentId ? `refund_ci_${checkoutIntentId}` : null);
274
+
275
+ if (!idempotencyKey) {
276
+ // eslint-disable-next-line no-console
277
+ console.warn('[calendar-onekite] HelloAsso webhook refund: missing refund id', { dataKeys: _d ? Object.keys(_d) : [] });
278
+ return res.json({ ok: true, ignored: true, missingRefundId: true });
279
+ }
280
+
281
+ if (await alreadyProcessed(idempotencyKey)) {
282
+ return res.json({ ok: true, duplicate: true });
283
+ }
284
+
285
+ // Resolve reservation ID
286
+ let resolvedRid = rid;
287
+ if (!resolvedRid && checkoutIntentId) {
288
+ resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
289
+ }
290
+ const paymentIdForRecovery = payload.data
291
+ ? (_d && (_d.id || _d.paymentId))
292
+ : (_payments.length ? _payments[0].id : null);
293
+ if (!resolvedRid && paymentIdForRecovery) {
294
+ resolvedRid = await tryRecoverRidFromPayment(settings, paymentIdForRecovery);
295
+ }
296
+ if (!resolvedRid) {
297
+ // eslint-disable-next-line no-console
298
+ console.warn('[calendar-onekite] HelloAsso webhook refund: could not resolve reservationId', { refundId, checkoutIntentId });
299
+ return res.json({ ok: true, processed: false, missingReservationId: true });
300
+ }
301
+
302
+ // eslint-disable-next-line no-console
303
+ console.info('[calendar-onekite] HelloAsso webhook refund received', { rid: resolvedRid, refundId });
304
+
305
+ const r = await dbLayer.getReservation(resolvedRid);
306
+ if (!r) {
307
+ await markProcessed(idempotencyKey);
308
+ return res.json({ ok: true, processed: true, reservationNotFound: true });
309
+ }
310
+
311
+ if (r.status === 'cancelled') {
312
+ await markProcessed(idempotencyKey);
313
+ return res.json({ ok: true, processed: true, alreadyCancelled: true });
314
+ }
315
+
316
+ r.status = 'cancelled';
317
+ r.cancelledAt = Date.now();
318
+ r.cancelledBy = 0;
319
+ r.cancelledByUsername = 'HelloAsso (remboursement)';
320
+ r.refundedAt = r.cancelledAt;
321
+ if (refundId) r.refundId = refundId;
322
+
323
+ await dbLayer.saveReservation(r);
324
+
325
+ await auditLog('reservation_refunded', 0, {
326
+ targetType: 'reservation',
327
+ targetId: String(r.rid),
328
+ reservationUid: Number(r.uid) || 0,
329
+ reservationUsername: String(r.username || ''),
330
+ itemIds: arrayifyIds(r),
331
+ itemNames: arrayifyNames(r),
332
+ startDate: r.startDate || '',
333
+ endDate: r.endDate || '',
334
+ refundId: refundId || '',
335
+ });
336
+
337
+ realtime.emitCalendarUpdated({ kind: 'reservation', action: 'cancelled', rid: String(r.rid), status: r.status });
338
+
339
+ // Email requester
340
+ try {
341
+ const requesterUid = parseInt(r.uid, 10);
342
+ const requester = await user.getUserFields(requesterUid, ['username']);
343
+ if (requesterUid) {
344
+ await sendEmail('calendar-onekite_cancelled', requesterUid, 'Location matériel - Réservation annulée (remboursement)', {
345
+ uid: requesterUid,
346
+ username: requester && requester.username ? requester.username : '',
347
+ itemName: arrayifyNames(r).join(', '),
348
+ itemNames: arrayifyNames(r),
349
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
350
+ cancelledBy: 'HelloAsso (remboursement)',
351
+ cancelledByUrl: '',
352
+ });
353
+ }
354
+ } catch (e) {}
355
+
356
+ // Discord notification
357
+ try {
358
+ await discord.notifyReservationCancelled(settings, {
359
+ rid: r.rid,
360
+ uid: r.uid,
361
+ username: r.username || '',
362
+ itemIds: arrayifyIds(r),
363
+ itemNames: arrayifyNames(r),
364
+ start: r.start,
365
+ end: r.end,
366
+ status: r.status,
367
+ cancelledAt: r.cancelledAt,
368
+ cancelledBy: 0,
369
+ cancelledByUsername: r.cancelledByUsername,
370
+ });
371
+ } catch (e) {}
372
+
373
+ await markProcessed(idempotencyKey);
374
+ return res.json({ ok: true, processed: true, refunded: true });
375
+ } catch (err) {
376
+ // eslint-disable-next-line no-console
377
+ console.error('[calendar-onekite] HelloAsso webhook refund: unhandled error', err && err.message ? err.message : String(err));
378
+ return res.status(500).json({ ok: false, error: 'internal-error' });
379
+ }
380
+ }
381
+
238
382
  // ---------------------------------------------------------------------------
239
383
  // Webhook handler
240
384
  // ---------------------------------------------------------------------------
@@ -308,6 +452,11 @@ async function handler(req, res, next) {
308
452
  contentType: req.headers && req.headers['content-type'],
309
453
  });
310
454
 
455
+ // Handle refund events before the confirmed-payment check.
456
+ if (isRefundEvent(payload)) {
457
+ return await handleRefund(req, res, payload, settings, _d);
458
+ }
459
+
311
460
  if (!isConfirmedPayment(payload)) {
312
461
  // eslint-disable-next-line no-console
313
462
  console.warn('[calendar-onekite] HelloAsso webhook: payment not confirmed — ignored', {
@@ -336,17 +485,22 @@ async function handler(req, res, next) {
336
485
  // Extract paymentId.
337
486
  // Format A: data.id is the payment ID.
338
487
  // Format B: order.payments[0].id is the payment ID (data.id is the checkout intent ID).
488
+ // For Format B, HelloAsso sometimes omits order.payments — fall back to checkoutIntentId
489
+ // as the idempotency key (prefixed to avoid collision with numeric payment IDs).
339
490
  const _payments = (_d && _d.order && Array.isArray(_d.order.payments)) ? _d.order.payments : [];
340
491
  const paymentId = payload.data
341
492
  ? (_d.id || _d.paymentId)
342
493
  : (_payments.length ? _payments[0].id : null);
343
- if (!paymentId) {
494
+ const idempotencyKey = paymentId
495
+ ? String(paymentId)
496
+ : (!payload.data && checkoutIntentId ? `ci_${checkoutIntentId}` : null);
497
+ if (!idempotencyKey) {
344
498
  // eslint-disable-next-line no-console
345
- console.warn('[calendar-onekite] HelloAsso webhook: missing payment id', { eventType, dataKeys: _d ? Object.keys(_d) : [] });
499
+ console.warn('[calendar-onekite] HelloAsso webhook: missing payment id and checkout intent id', { eventType, dataKeys: _d ? Object.keys(_d) : [] });
346
500
  return res.json({ ok: true, ignored: true, missingPaymentId: true });
347
501
  }
348
502
 
349
- if (await alreadyProcessed(paymentId)) {
503
+ if (await alreadyProcessed(idempotencyKey)) {
350
504
  return res.json({ ok: true, duplicate: true });
351
505
  }
352
506
 
@@ -369,13 +523,13 @@ async function handler(req, res, next) {
369
523
  if (!r) {
370
524
  // eslint-disable-next-line no-console
371
525
  console.warn('[calendar-onekite] HelloAsso webhook: reservation not found (expired/deleted?)', { rid: resolvedRid, paymentId });
372
- await markProcessed(paymentId);
526
+ await markProcessed(idempotencyKey);
373
527
  return res.json({ ok: true, processed: true, reservationNotFound: true });
374
528
  }
375
529
 
376
530
  // Idempotency: already paid → just mark as processed and return.
377
531
  if (r.status === 'paid') {
378
- await markProcessed(paymentId);
532
+ await markProcessed(idempotencyKey);
379
533
  return res.json({ ok: true, processed: true, alreadyPaid: true });
380
534
  }
381
535
 
@@ -384,7 +538,7 @@ async function handler(req, res, next) {
384
538
 
385
539
  r.status = 'paid';
386
540
  r.paidAt = Date.now();
387
- r.paymentId = String(paymentId);
541
+ if (paymentId) r.paymentId = String(paymentId);
388
542
  // paymentReceiptUrl: Format A = data.paymentReceiptUrl, Format B = order.payments[0].paymentReceiptUrl
389
543
  const _receiptUrl = (payload.data && payload.data.paymentReceiptUrl)
390
544
  ? String(payload.data.paymentReceiptUrl)
@@ -444,7 +598,7 @@ async function handler(req, res, next) {
444
598
  });
445
599
  } catch (e) {}
446
600
 
447
- await markProcessed(paymentId);
601
+ await markProcessed(idempotencyKey);
448
602
  return res.json({ ok: true, processed: true });
449
603
  } catch (err) {
450
604
  return next(err);
package/lib/scheduler.js CHANGED
@@ -1,8 +1,54 @@
1
1
  'use strict';
2
2
 
3
3
  const nconf = require.main.require('nconf');
4
+ const meta = require.main.require('./src/meta');
5
+
6
+ const dbLayer = require('./db');
7
+ const { syncPayment } = require('./syncPayment');
8
+
9
+ const SETTINGS_KEY = 'calendar-onekite';
10
+ const SYNC_INTERVAL_MS = 60 * 1000;
11
+ // Minimum time between HelloAsso API checks for the same reservation (avoid spamming)
12
+ const MIN_CHECK_INTERVAL_MS = 2 * 60 * 1000;
4
13
 
5
14
  let timer = null;
15
+ const lastChecked = new Map(); // rid → timestamp of last HelloAsso check
16
+
17
+ async function runPaymentSync() {
18
+ try {
19
+ const settings = await meta.settings.get(SETTINGS_KEY);
20
+ if (!settings || !settings.helloassoClientId || !settings.helloassoClientSecret) return;
21
+
22
+ const ids = await dbLayer.listAllReservationIds(5000);
23
+ if (!ids || !ids.length) return;
24
+
25
+ const reservations = await dbLayer.getReservations(ids);
26
+ const now = Date.now();
27
+
28
+ const toSync = (reservations || []).filter((r) => {
29
+ if (!r || r.status !== 'awaiting_payment' || !r.checkoutIntentId) return false;
30
+ const last = lastChecked.get(String(r.rid));
31
+ return !last || (now - last) >= MIN_CHECK_INTERVAL_MS;
32
+ });
33
+
34
+ for (const r of toSync) {
35
+ lastChecked.set(String(r.rid), now);
36
+ try {
37
+ const result = await syncPayment({ r, settings, actorUid: 0 });
38
+ if (result.synced) {
39
+ // eslint-disable-next-line no-console
40
+ console.info('[calendar-onekite] Scheduler: auto-synced payment from HelloAsso', { rid: r.rid });
41
+ }
42
+ } catch (e) {
43
+ // eslint-disable-next-line no-console
44
+ console.warn('[calendar-onekite] Scheduler: syncPayment error', { rid: r.rid, err: e && e.message });
45
+ }
46
+ }
47
+ } catch (e) {
48
+ // eslint-disable-next-line no-console
49
+ console.warn('[calendar-onekite] Scheduler: payment sync error', { err: e && e.message });
50
+ }
51
+ }
6
52
 
7
53
  function start() {
8
54
  const runJobs = nconf.get('runJobs');
@@ -11,8 +57,9 @@ function start() {
11
57
  console.info('[calendar-onekite] Scheduler disabled (runJobs=false)');
12
58
  return;
13
59
  }
60
+ timer = setInterval(runPaymentSync, SYNC_INTERVAL_MS);
14
61
  // eslint-disable-next-line no-console
15
- console.info('[calendar-onekite] Scheduler started (no expiry timers configured)');
62
+ console.info('[calendar-onekite] Scheduler started');
16
63
  }
17
64
 
18
65
  function stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.96",
3
+ "version": "2.0.98",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",