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 +7 -116
- package/lib/api.js +18 -6
- package/lib/helloassoWebhook.js +99 -17
- package/lib/scheduler.js +2 -352
- package/lib/syncPayment.js +141 -0
- package/library.js +1 -0
- package/package.json +1 -1
- package/public/client.js +53 -16
- package/templates/admin/plugins/calendar-onekite.tpl +0 -17
- package/templates/emails/calendar-onekite_approved.tpl +1 -4
- package/templates/emails/calendar-onekite_reminder.tpl +1 -4
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
|
-
|
|
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
|
|
710
|
-
|
|
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
|
// --------------------
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
240
|
-
paymentId:
|
|
241
|
-
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:
|
|
251
|
-
|
|
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:
|
|
265
|
-
dataKeys:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
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
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
|
|
611
|
+
pending: 'En attente de validation',
|
|
612
612
|
awaiting_payment: 'Validée – paiement en attente',
|
|
613
613
|
paid: 'Payée',
|
|
614
|
-
|
|
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
|
|
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
|
|
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 > 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
|
|
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
|
|
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 -->
|