nodebb-plugin-onekite-calendar 1.0.12 → 1.0.13

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.
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const controllers = {};
4
+
5
+ controllers.renderCalendar = async function (req, res) {
6
+ res.render('calendar-onekite', {
7
+ title: 'Calendar',
8
+ });
9
+ };
10
+
11
+ module.exports = controllers;
package/lib/db.js ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+
5
+ const KEY_ZSET = 'calendar-onekite:reservations';
6
+ const KEY_OBJ = (rid) => `calendar-onekite:reservation:${rid}`;
7
+ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToRid';
8
+
9
+ // Special events (non-reservation events shown in a different colour)
10
+ const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
+ const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
+
13
+ // Helpers
14
+ function reservationKey(rid) {
15
+ return KEY_OBJ(rid);
16
+ }
17
+
18
+ function specialKey(eid) {
19
+ return KEY_SPECIAL_OBJ(eid);
20
+ }
21
+
22
+ async function getReservation(rid) {
23
+ return await db.getObject(KEY_OBJ(rid));
24
+ }
25
+
26
+ /**
27
+ * Batch fetch reservations in one DB roundtrip.
28
+ * Returns an array aligned with rids (missing objects => null).
29
+ */
30
+ async function getReservations(rids) {
31
+ const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
32
+ if (!ids.length) return [];
33
+ const keys = ids.map(reservationKey);
34
+ const rows = await db.getObjects(keys);
35
+ // Ensure rid is present even if older objects were missing it.
36
+ return (rows || []).map((row, idx) => {
37
+ if (!row) return null;
38
+ if (!row.rid) row.rid = String(ids[idx]);
39
+ return row;
40
+ });
41
+ }
42
+
43
+ async function saveReservation(resv) {
44
+ await db.setObject(KEY_OBJ(resv.rid), resv);
45
+ // score = start timestamp
46
+ await db.sortedSetAdd(KEY_ZSET, resv.start, resv.rid);
47
+ // Optional mapping to help reconcile HelloAsso webhooks that don't include metadata
48
+ if (resv.checkoutIntentId) {
49
+ try {
50
+ await db.setObjectField(KEY_CHECKOUT_INTENT_TO_RID, String(resv.checkoutIntentId), String(resv.rid));
51
+ } catch (e) {
52
+ // ignore
53
+ }
54
+ }
55
+ }
56
+
57
+ async function removeReservation(rid) {
58
+ await db.sortedSetRemove(KEY_ZSET, rid);
59
+ await db.delete(KEY_OBJ(rid));
60
+ }
61
+
62
+ async function listReservationIdsByStartRange(startTs, endTs, limit = 1000) {
63
+ // NodeBB db method name is getSortedSetRangeByScore(set, start, stop, min, max)
64
+ // (start/stop are index offsets, min/max are score range)
65
+ const start = 0;
66
+ const stop = Math.max(0, (parseInt(limit, 10) || 1000) - 1);
67
+ return await db.getSortedSetRangeByScore(KEY_ZSET, start, stop, startTs, endTs);
68
+ }
69
+
70
+ async function listAllReservationIds(limit = 5000) {
71
+ return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
72
+ }
73
+
74
+ module.exports = {
75
+ KEY_ZSET,
76
+ KEY_SPECIAL_ZSET,
77
+ KEY_CHECKOUT_INTENT_TO_RID,
78
+ getReservation,
79
+ getReservations,
80
+ saveReservation,
81
+ removeReservation,
82
+ // Special events
83
+ getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
84
+ getSpecialEvents: async (eids) => {
85
+ const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
86
+ if (!ids.length) return [];
87
+ const keys = ids.map(specialKey);
88
+ const rows = await db.getObjects(keys);
89
+ return (rows || []).map((row, idx) => {
90
+ if (!row) return null;
91
+ if (!row.eid) row.eid = String(ids[idx]);
92
+ return row;
93
+ });
94
+ },
95
+ saveSpecialEvent: async (ev) => {
96
+ await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
97
+ await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
98
+ },
99
+ removeSpecialEvent: async (eid) => {
100
+ await db.sortedSetRemove(KEY_SPECIAL_ZSET, eid);
101
+ await db.delete(KEY_SPECIAL_OBJ(eid));
102
+ },
103
+ listSpecialIdsByStartRange: async (startTs, endTs, limit = 2000) => {
104
+ const start = 0;
105
+ const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
106
+ return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
107
+ },
108
+ listReservationIdsByStartRange,
109
+ listAllReservationIds,
110
+ };
package/lib/discord.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const { URL } = require('url');
5
+
6
+ function isEnabled(v, defaultValue) {
7
+ if (v === undefined || v === null || v === '') return defaultValue !== false;
8
+ const s = String(v).trim().toLowerCase();
9
+ if (['1', 'true', 'yes', 'on'].includes(s)) return true;
10
+ if (['0', 'false', 'no', 'off'].includes(s)) return false;
11
+ return defaultValue !== false;
12
+ }
13
+
14
+ function formatFRShort(ts) {
15
+ const d = new Date(ts);
16
+ const dd = String(d.getDate()).padStart(2, '0');
17
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
18
+ const yy = String(d.getFullYear()).slice(-2);
19
+ return `${dd}/${mm}/${yy}`;
20
+ }
21
+
22
+ function postWebhook(webhookUrl, payload) {
23
+ return new Promise((resolve, reject) => {
24
+ try {
25
+ const u = new URL(String(webhookUrl));
26
+ if (u.protocol !== 'https:') {
27
+ return reject(new Error('discord-webhook-must-be-https'));
28
+ }
29
+
30
+ const body = Buffer.from(JSON.stringify(payload || {}), 'utf8');
31
+ const req = https.request({
32
+ method: 'POST',
33
+ hostname: u.hostname,
34
+ port: u.port || 443,
35
+ path: u.pathname + (u.search || ''),
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'Content-Length': body.length,
39
+ 'User-Agent': 'nodebb-plugin-calendar-onekite',
40
+ },
41
+ }, (res) => {
42
+ const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 300;
43
+ const chunks = [];
44
+ res.on('data', (c) => chunks.push(c));
45
+ res.on('end', () => {
46
+ if (ok) return resolve(true);
47
+ const msg = Buffer.concat(chunks).toString('utf8');
48
+ return reject(new Error(`discord-webhook-http-${res.statusCode}: ${msg}`));
49
+ });
50
+ });
51
+ req.on('error', reject);
52
+ req.write(body);
53
+ req.end();
54
+ } catch (e) {
55
+ reject(e);
56
+ }
57
+ });
58
+ }
59
+
60
+ function buildReservationMessage(kind, reservation) {
61
+ const calUrl = 'https://www.onekite.com/calendar';
62
+ const username = reservation && reservation.username ? String(reservation.username) : '';
63
+ const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
64
+ ? reservation.itemNames.map(String)
65
+ : (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
66
+ const start = reservation && reservation.start ? Number(reservation.start) : NaN;
67
+ const end = reservation && reservation.end ? Number(reservation.end) : NaN;
68
+
69
+ const title = kind === 'paid'
70
+ ? `**[Paiement reçu](${calUrl})** — ${username}`
71
+ : `**[Demande réservation](${calUrl})** — ${username}`;
72
+
73
+ const lines = [
74
+ title,
75
+ '',
76
+ kind === 'paid' ? '**Matériel :**' : '**Matériel demandé :**',
77
+ ];
78
+ for (const it of items) {
79
+ lines.push(`- ${it}`);
80
+ }
81
+ if (Number.isFinite(start) && Number.isFinite(end)) {
82
+ lines.push('');
83
+ lines.push(`Du ${formatFRShort(start)} au ${formatFRShort(end)}`);
84
+ }
85
+ return lines.join('\n');
86
+ }
87
+
88
+ function buildWebhookPayload(kind, reservation) {
89
+ // Discord "regroupe" visuellement les messages consécutifs d'un même auteur.
90
+ // En utilisant un username différent par action, on obtient un message bien distinct.
91
+ const webhookUsername = kind === 'paid' ? 'Onekite • Paiement' : 'Onekite • Réservation';
92
+
93
+ const calUrl = 'https://www.onekite.com/calendar';
94
+ const username = reservation && reservation.username ? String(reservation.username) : '';
95
+ const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
96
+ ? reservation.itemNames.map(String)
97
+ : (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
98
+ const start = reservation && reservation.start ? Number(reservation.start) : NaN;
99
+ const end = reservation && reservation.end ? Number(reservation.end) : NaN;
100
+
101
+ const title = kind === 'paid' ? '💳 Paiement reçu' : '⏳ Demande de réservation';
102
+
103
+ const fields = [];
104
+ if (username) {
105
+ fields.push({ name: 'Membre', value: username, inline: true });
106
+ }
107
+ if (items.length) {
108
+ const label = kind === 'paid' ? 'Matériel' : 'Matériel demandé';
109
+ fields.push({ name: label, value: items.map((it) => `• ${it}`).join('\n'), inline: false });
110
+ }
111
+ if (Number.isFinite(start) && Number.isFinite(end)) {
112
+ fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
113
+ }
114
+
115
+ return {
116
+ username: webhookUsername,
117
+ // On laisse "content" vide pour privilégier l'embed (plus lisible sur Discord)
118
+ content: '',
119
+ embeds: [
120
+ {
121
+ title,
122
+ url: calUrl,
123
+ description: kind === 'paid'
124
+ ? 'Un paiement a été reçu pour une réservation.'
125
+ : 'Une nouvelle demande de réservation a été créée.',
126
+ fields,
127
+ footer: { text: 'Onekite • Calendrier' },
128
+ timestamp: new Date().toISOString(),
129
+ },
130
+ ],
131
+ };
132
+ }
133
+
134
+ async function notifyReservationRequested(settings, reservation) {
135
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
136
+ if (!url) return;
137
+ if (!isEnabled(settings.discordNotifyOnRequest, true)) return;
138
+
139
+ try {
140
+ await postWebhook(url, buildWebhookPayload('request', reservation));
141
+ } catch (e) {
142
+ // eslint-disable-next-line no-console
143
+ console.warn('[calendar-onekite] Discord webhook failed (request)', e && e.message ? e.message : String(e));
144
+ }
145
+ }
146
+
147
+ async function notifyPaymentReceived(settings, reservation) {
148
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
149
+ if (!url) return;
150
+ if (!isEnabled(settings.discordNotifyOnPaid, true)) return;
151
+
152
+ try {
153
+ await postWebhook(url, buildWebhookPayload('paid', reservation));
154
+ } catch (e) {
155
+ // eslint-disable-next-line no-console
156
+ console.warn('[calendar-onekite] Discord webhook failed (paid)', e && e.message ? e.message : String(e));
157
+ }
158
+ }
159
+
160
+ module.exports = {
161
+ notifyReservationRequested,
162
+ notifyPaymentReceived,
163
+ };
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ function requestJson(method, url, headers = {}, bodyObj = null) {
6
+ return new Promise((resolve) => {
7
+ const u = new URL(url);
8
+ const body = bodyObj ? JSON.stringify(bodyObj) : null;
9
+
10
+ const req = https.request(
11
+ {
12
+ method,
13
+ hostname: u.hostname,
14
+ port: u.port || 443,
15
+ path: u.pathname + u.search,
16
+ headers: {
17
+ Accept: 'application/json',
18
+ 'Content-Type': 'application/json',
19
+ ...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
20
+ ...headers,
21
+ },
22
+ },
23
+ (resp) => {
24
+ let data = '';
25
+ resp.setEncoding('utf8');
26
+ resp.on('data', (chunk) => { data += chunk; });
27
+ resp.on('end', () => {
28
+ const status = resp.statusCode || 0;
29
+
30
+ // Try JSON first, but never throw from here (avoid crashing NodeBB).
31
+ try {
32
+ const json = data ? JSON.parse(data) : null;
33
+ return resolve({ status, json });
34
+ } catch (e) {
35
+ // Non-JSON response (HTML/proxy error/etc.)
36
+ const snippet = String(data || '').slice(0, 500);
37
+ return resolve({ status, json: null, raw: snippet });
38
+ }
39
+ });
40
+ }
41
+ );
42
+
43
+ req.on('error', (err) => {
44
+ resolve({ status: 0, json: null, error: err && err.message ? err.message : String(err) });
45
+ });
46
+ if (body) req.write(body);
47
+ req.end();
48
+ });
49
+ }
50
+
51
+ function baseUrl(env) {
52
+ return env === 'sandbox'
53
+ ? 'https://api.helloasso-sandbox.com'
54
+ : 'https://api.helloasso.com';
55
+ }
56
+
57
+ // In-memory access token cache (per NodeBB process)
58
+ let _tokenCache = { token: null, expiresAt: 0 };
59
+ let _tokenInFlight = null;
60
+
61
+ function _sleep(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+
65
+ function _tokenLooksValid() {
66
+ // Refresh 60s before expiration to avoid edge cases
67
+ return !!(_tokenCache.token && Date.now() < (_tokenCache.expiresAt - 60_000));
68
+ }
69
+
70
+ function _isRateLimited(status, parsedJson, bodySnippet) {
71
+ if (status === 429) return true;
72
+ const snip = String(bodySnippet || '').toLowerCase();
73
+ if (snip.includes('1015')) return true; // Cloudflare rate limited
74
+ if (parsedJson && typeof parsedJson === 'object') {
75
+ const j = JSON.stringify(parsedJson).toLowerCase();
76
+ if (j.includes('1015')) return true;
77
+ if (j.includes('rate') && j.includes('limit')) return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ function _retryAfterMs(headers) {
83
+ if (!headers) return 0;
84
+ const ra = headers['retry-after'] || headers['Retry-After'];
85
+ if (!ra) return 0;
86
+ const n = parseInt(Array.isArray(ra) ? ra[0] : String(ra), 10);
87
+ if (!Number.isFinite(n) || n <= 0) return 0;
88
+ return Math.min(120_000, n * 1000);
89
+ }
90
+
91
+ function _requestAccessTokenRaw({ env, clientId, clientSecret }) {
92
+ const url = `${baseUrl(env)}/oauth2/token`;
93
+ const body = new URLSearchParams({
94
+ grant_type: 'client_credentials',
95
+ client_id: clientId,
96
+ client_secret: clientSecret,
97
+ }).toString();
98
+
99
+ return new Promise((resolve) => {
100
+ try {
101
+ const u = new URL(url);
102
+ const req = https.request(
103
+ {
104
+ method: 'POST',
105
+ hostname: u.hostname,
106
+ port: u.port || 443,
107
+ path: u.pathname + u.search,
108
+ headers: {
109
+ 'Accept': 'application/json',
110
+ 'Content-Type': 'application/x-www-form-urlencoded',
111
+ 'Content-Length': Buffer.byteLength(body),
112
+ },
113
+ },
114
+ (res) => {
115
+ let data = '';
116
+ res.setEncoding('utf8');
117
+ res.on('data', (chunk) => (data += chunk));
118
+ res.on('end', () => {
119
+ const status = res.statusCode || 0;
120
+ const headers = res.headers || {};
121
+ const snippet = String(data || '').slice(0, 1000);
122
+ resolve({ status, headers, bodyText: data || '', snippet });
123
+ });
124
+ }
125
+ );
126
+ req.on('error', (err) => {
127
+ resolve({ status: 0, headers: {}, bodyText: '', snippet: err && err.message ? String(err.message) : String(err) });
128
+ });
129
+ req.write(body);
130
+ req.end();
131
+ } catch (e) {
132
+ resolve({ status: 0, headers: {}, bodyText: '', snippet: e && e.message ? String(e.message) : String(e) });
133
+ }
134
+ });
135
+ }
136
+
137
+ async function _fetchAccessTokenWithRetry(params) {
138
+ // 3 attempts max with exponential backoff on rate-limit/network
139
+ for (let attempt = 0; attempt < 3; attempt++) {
140
+ const { status, headers, bodyText, snippet } = await _requestAccessTokenRaw(params);
141
+
142
+ // 2xx: parse and return token (+ cache expiry)
143
+ if (status >= 200 && status < 300) {
144
+ try {
145
+ const json = JSON.parse(bodyText || '{}');
146
+ const token = json && json.access_token ? String(json.access_token) : null;
147
+ const expiresIn = parseInt(json && json.expires_in ? String(json.expires_in) : '3600', 10) || 3600;
148
+ if (token) {
149
+ const expiresAt = Date.now() + Math.max(60, expiresIn) * 1000;
150
+ return { token, expiresAt };
151
+ }
152
+ return { token: null, expiresAt: 0 };
153
+ } catch (e) {
154
+ return { token: null, expiresAt: 0 };
155
+ }
156
+ }
157
+
158
+ // Non-2xx: decide whether to retry
159
+ let parsed = null;
160
+ try { parsed = bodyText ? JSON.parse(bodyText) : null; } catch (e) { /* ignore */ }
161
+
162
+ const rateLimited = _isRateLimited(status, parsed, snippet);
163
+ const networkish = status === 0;
164
+
165
+ if ((rateLimited || networkish) && attempt < 2) {
166
+ const base = 1500 * (2 ** attempt);
167
+ const ra = _retryAfterMs(headers);
168
+ const jitter = Math.floor(Math.random() * 250);
169
+ const waitMs = Math.min(60_000, Math.max(base, ra) + jitter);
170
+ await _sleep(waitMs);
171
+ continue;
172
+ }
173
+
174
+ return { token: null, expiresAt: 0 };
175
+ }
176
+
177
+ return { token: null, expiresAt: 0 };
178
+ }
179
+
180
+ async function getAccessToken({ env, clientId, clientSecret }) {
181
+ if (!clientId || !clientSecret) return null;
182
+
183
+ if (_tokenLooksValid()) return _tokenCache.token;
184
+
185
+ // De-duplicate concurrent token requests (prevents bursts -> 429/1015)
186
+ if (_tokenInFlight) return _tokenInFlight;
187
+
188
+ _tokenInFlight = (async () => {
189
+ const { token, expiresAt } = await _fetchAccessTokenWithRetry({ env, clientId, clientSecret });
190
+ if (token) {
191
+ _tokenCache = { token, expiresAt };
192
+ }
193
+ return token || null;
194
+ })();
195
+
196
+ try {
197
+ return await _tokenInFlight;
198
+ } finally {
199
+ _tokenInFlight = null;
200
+ }
201
+ }
202
+
203
+ async function listItems({ env, token, organizationSlug, formType, formSlug }) {
204
+ if (!token || !organizationSlug || !formType || !formSlug) return [];
205
+ // This endpoint returns *sold items* (i.e., items present in orders). If your shop has
206
+ // no sales yet, it will legitimately return an empty list.
207
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items?pageIndex=1&pageSize=200`;
208
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
209
+ if (status >= 200 && status < 300 && json) {
210
+ return json.data || json.items || [];
211
+ }
212
+ return [];
213
+ }
214
+
215
+ async function getFormPublic({ env, token, organizationSlug, formType, formSlug }) {
216
+ if (!token || !organizationSlug || !formType || !formSlug) return null;
217
+ // Public form details contains extraOptions/customFields and (for Shop) usually the catalog structure.
218
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
219
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
220
+ if (status >= 200 && status < 300) {
221
+ return json || null;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function extractCatalogItems(publicFormJson) {
227
+ if (!publicFormJson || typeof publicFormJson !== 'object') return [];
228
+
229
+ // Try a few common shapes used in HelloAsso "public" form responses.
230
+ const candidates = [];
231
+ const pushArr = (arr) => {
232
+ if (Array.isArray(arr)) candidates.push(...arr);
233
+ };
234
+
235
+ pushArr(publicFormJson.items);
236
+ pushArr(publicFormJson.tiers);
237
+ pushArr(publicFormJson.products);
238
+ pushArr(publicFormJson.data);
239
+ if (publicFormJson.form) {
240
+ pushArr(publicFormJson.form.items);
241
+ pushArr(publicFormJson.form.tiers);
242
+ pushArr(publicFormJson.form.products);
243
+ }
244
+
245
+ // Some responses nest in "campaign" or "publicForm".
246
+ if (publicFormJson.publicForm) {
247
+ pushArr(publicFormJson.publicForm.items);
248
+ pushArr(publicFormJson.publicForm.tiers);
249
+ pushArr(publicFormJson.publicForm.products);
250
+ }
251
+ if (publicFormJson.campaign) {
252
+ pushArr(publicFormJson.campaign.items);
253
+ pushArr(publicFormJson.campaign.tiers);
254
+ pushArr(publicFormJson.campaign.products);
255
+ }
256
+
257
+ // Normalize to { id, name, price }
258
+ return candidates
259
+ .map((it) => {
260
+ if (!it || typeof it !== 'object') return null;
261
+ const id = it.id ?? it.itemId ?? it.tierId;
262
+ const name = it.name ?? it.label ?? it.title;
263
+ const price =
264
+ (it.amount && (it.amount.total ?? it.amount.value)) ??
265
+ it.price ??
266
+ it.unitPrice ??
267
+ it.totalAmount ??
268
+ it.initialAmount;
269
+ if (!id || !name) return null;
270
+ return { id, name, price: typeof price === 'number' ? price : 0, raw: it };
271
+ })
272
+ .filter(Boolean);
273
+ }
274
+
275
+ async function listCatalogItems({ env, token, organizationSlug, formType, formSlug }) {
276
+ const publicForm = await getFormPublic({ env, token, organizationSlug, formType, formSlug });
277
+ const extracted = extractCatalogItems(publicForm);
278
+ return {
279
+ publicForm,
280
+ items: extracted.map(({ id, name, price, raw }) => ({ id, name, price, raw })),
281
+ };
282
+ }
283
+
284
+ async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail, callbackUrl, webhookUrl, itemName, containsDonation, metadata }) {
285
+ if (!token || !organizationSlug) return null;
286
+ if (!callbackUrl || !/^https?:\/\//i.test(String(callbackUrl))) {
287
+ console.warn('[calendar-onekite] HelloAsso invalid return/back/error URL', { callbackUrl });
288
+ return null;
289
+ }
290
+ if (webhookUrl && !/^https?:\/\//i.test(String(webhookUrl))) {
291
+ console.warn('[calendar-onekite] HelloAsso invalid webhook URL', { webhookUrl });
292
+ }
293
+ // Checkout intents are created at organization level.
294
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/checkout-intents`;
295
+ const payload = {
296
+ totalAmount: totalAmount,
297
+ initialAmount: totalAmount,
298
+ itemName: itemName || 'Réservation matériel',
299
+ containsDonation: (typeof containsDonation === 'boolean' ? containsDonation : false),
300
+ payer: payerEmail ? { email: payerEmail } : undefined,
301
+ metadata: metadata || undefined,
302
+ backUrl: callbackUrl || '',
303
+ errorUrl: callbackUrl || '',
304
+ returnUrl: callbackUrl || '',
305
+ notificationUrl: webhookUrl || callbackUrl || '',
306
+ };
307
+ const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
308
+ if (status >= 200 && status < 300 && json) {
309
+ return { paymentUrl: (json.redirectUrl || json.checkoutUrl || json.url || null), checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
310
+ }
311
+ // Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
312
+ try {
313
+ // eslint-disable-next-line no-console
314
+ console.warn('[calendar-onekite] HelloAsso checkout-intent failed', { status, json });
315
+ } catch (e) { /* ignore */ }
316
+ return null;
317
+ }
318
+
319
+ // Fetch detailed payment information (used to recover metadata when webhook payload is incomplete)
320
+ // HelloAsso exposes GET /payments/{paymentId} in its v5 API.
321
+ async function getPaymentDetails({ env, token, paymentId }) {
322
+ if (!token || !paymentId) return null;
323
+ const url = `${baseUrl(env)}/v5/payments/${encodeURIComponent(String(paymentId))}`;
324
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
325
+ if (status >= 200 && status < 300) {
326
+ return json || null;
327
+ }
328
+ return null;
329
+ }
330
+
331
+
332
+
333
+ async function getCheckoutIntentDetails({ env, token, organizationSlug, checkoutIntentId }) {
334
+ if (!token || !organizationSlug || !checkoutIntentId) return null;
335
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(String(organizationSlug))}/checkout-intents/${encodeURIComponent(String(checkoutIntentId))}`;
336
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
337
+ if (status >= 200 && status < 300) {
338
+ return json || null;
339
+ }
340
+ return null;
341
+ }
342
+
343
+ module.exports = {
344
+ getAccessToken,
345
+ listItems,
346
+ getFormPublic,
347
+ extractCatalogItems,
348
+ listCatalogItems,
349
+ createCheckoutIntent,
350
+ getPaymentDetails,
351
+ getCheckoutIntentDetails,
352
+ };