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.
- package/lib/helloassoWebhook.js +161 -7
- package/lib/scheduler.js +48 -1
- package/package.json +1 -1
package/lib/helloassoWebhook.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
62
|
+
console.info('[calendar-onekite] Scheduler started');
|
|
16
63
|
}
|
|
17
64
|
|
|
18
65
|
function stop() {
|
package/package.json
CHANGED