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 +7 -116
- package/lib/api.js +18 -0
- package/lib/helloassoWebhook.js +99 -17
- package/lib/syncPayment.js +141 -0
- package/library.js +1 -0
- package/package.json +1 -1
- package/public/client.js +51 -2
- 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) {
|
|
@@ -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
|
// --------------------
|
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, {
|
|
@@ -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 || '');
|
|
@@ -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
|
|
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 -->
|