nodebb-plugin-onekite-calendar 2.0.97 → 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', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.97",
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",