nodebb-plugin-calendar-onekite 11.1.60 → 11.1.62

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 CHANGED
@@ -124,10 +124,23 @@ admin.approveReservation = async function (req, res) {
124
124
  }
125
125
 
126
126
  let paymentUrl = null;
127
- if (token) {
128
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
129
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
130
- const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
127
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
128
+ // Persist payer email so we can reconcile shop payments when HelloAsso does not provide metadata.
129
+ if (requester && requester.email) {
130
+ r.payerEmail = requester.email;
131
+ }
132
+ // r.total is stored as an estimated total in euros; store expected total in cents as well.
133
+ const expectedTotalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
134
+ r.expectedTotalAmount = expectedTotalAmount;
135
+
136
+ // If admin configured a Shop form, we can't create a cart programmatically with the standard Checkout API.
137
+ // We therefore redirect the user to the shop page so the transaction is recorded in the Shop form.
138
+ if (String(settings.helloassoFormType || '').toLowerCase() === 'shop' && settings.helloassoOrganizationSlug && settings.helloassoFormSlug) {
139
+ const frontBase = (env === 'sandbox') ? 'https://www.helloasso-sandbox.com' : 'https://www.helloasso.com';
140
+ // Add rid as UTM for easier manual reconciliation on HelloAsso side (may not be returned in webhooks).
141
+ paymentUrl = `${frontBase}/associations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/boutiques/${encodeURIComponent(settings.helloassoFormSlug)}?utm_source=nodebb&utm_medium=calendar&utm_campaign=location&utm_content=${encodeURIComponent(String(rid))}`;
142
+ } else if (token) {
143
+ const totalAmount = expectedTotalAmount;
131
144
  const base = forumBaseUrl();
132
145
  const returnUrl = base ? `${base}/calendar` : '';
133
146
  const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
package/lib/api.js CHANGED
@@ -389,40 +389,45 @@ api.approveReservation = async function (req, res) {
389
389
  // Create HelloAsso payment link on validation
390
390
  try {
391
391
  const settings2 = await meta.settings.get('calendar-onekite');
392
- const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
392
+ const env2 = settings2.helloassoEnv || 'prod';
393
393
  const payer = await user.getUserFields(r.uid, ['email']);
394
- const intent = await helloasso.createCheckoutIntent({
395
- env: settings2.helloassoEnv,
396
- token,
397
- organizationSlug: settings2.helloassoOrganizationSlug,
398
- formType: settings2.helloassoFormType,
399
- formSlug: settings2.helloassoFormSlug,
400
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
401
- totalAmount: (() => {
402
- const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
403
- if (!cents) {
404
- console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
394
+ if (payer && payer.email) {
395
+ r.payerEmail = payer.email;
396
+ }
397
+ const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
398
+ r.expectedTotalAmount = cents;
399
+
400
+ // If a Shop form is configured, redirect to the Shop page so the payment is recorded in the shop.
401
+ // The stored value can vary in casing depending on the ACP select.
402
+ if (String(settings2.helloassoFormType || '').toLowerCase() === 'shop' && settings2.helloassoOrganizationSlug && settings2.helloassoFormSlug) {
403
+ const frontBase = (env2 === 'sandbox') ? 'https://www.helloasso-sandbox.com' : 'https://www.helloasso.com';
404
+ r.paymentUrl = `${frontBase}/associations/${encodeURIComponent(settings2.helloassoOrganizationSlug)}/boutiques/${encodeURIComponent(settings2.helloassoFormSlug)}?utm_source=nodebb&utm_medium=calendar&utm_campaign=location&utm_content=${encodeURIComponent(String(rid))}`;
405
+ } else {
406
+ const token = await helloasso.getAccessToken({ env: env2, clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
407
+ const intent = await helloasso.createCheckoutIntent({
408
+ env: env2,
409
+ token,
410
+ organizationSlug: settings2.helloassoOrganizationSlug,
411
+ formType: settings2.helloassoFormType,
412
+ formSlug: settings2.helloassoFormSlug,
413
+ totalAmount: cents,
414
+ payerEmail: payer && payer.email ? payer.email : '',
415
+ callbackUrl: normalizeReturnUrl(meta),
416
+ webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
417
+ itemName: 'Réservation matériel OneKite',
418
+ containsDonation: false,
419
+ metadata: { reservationId: String(rid) },
420
+ });
421
+ const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
422
+ const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
423
+ if (paymentUrl) {
424
+ r.paymentUrl = paymentUrl;
425
+ if (checkoutIntentId) {
426
+ r.checkoutIntentId = checkoutIntentId;
405
427
  }
406
- return cents;
407
- })(),
408
- payerEmail: payer && payer.email ? payer.email : '',
409
- // By default, point to the forum base url so the webhook hits this NodeBB instance.
410
- // Can be overridden via ACP setting `helloassoCallbackUrl`.
411
- callbackUrl: normalizeReturnUrl(meta),
412
- webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
413
- itemName: 'Réservation matériel OneKite',
414
- containsDonation: false,
415
- metadata: { reservationId: String(rid) },
416
- });
417
- const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
418
- const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
419
- if (paymentUrl) {
420
- r.paymentUrl = paymentUrl;
421
- if (checkoutIntentId) {
422
- r.checkoutIntentId = checkoutIntentId;
428
+ } else {
429
+ console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
423
430
  }
424
- } else {
425
- console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
426
431
  }
427
432
  } catch (e) {
428
433
  // ignore payment link errors, admin can retry
@@ -220,11 +220,111 @@ function formatFR(tsOrIso) {
220
220
 
221
221
  function getReservationIdFromPayload(payload) {
222
222
  try {
223
- const m = payload && payload.data ? payload.data.meta : null;
224
- if (!m) return null;
225
- if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
226
- if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
223
+ const data = payload && payload.data ? payload.data : null;
224
+ if (!data) return null;
225
+
226
+ // Prefer meta/metadata when present (Checkout-intents).
227
+ const candidates = [
228
+ data.meta,
229
+ data.metadata,
230
+ data.order && (data.order.meta || data.order.metadata),
231
+ data.checkoutIntent && (data.checkoutIntent.meta || data.checkoutIntent.metadata),
232
+ ].filter(Boolean);
233
+
234
+ for (const m of candidates) {
235
+ if (m && typeof m === 'object' && !Array.isArray(m)) {
236
+ if (m.reservationId) return String(m.reservationId);
237
+ if (m.reservationID) return String(m.reservationID);
238
+ }
239
+ if (Array.isArray(m)) {
240
+ const found = m.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
241
+ if (found && (found.value || found.val)) return String(found.value || found.val);
242
+ }
243
+ }
244
+
245
+ return null;
246
+ } catch (e) {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function getFormTypeFromPayload(payload) {
252
+ try {
253
+ const data = payload && payload.data ? payload.data : null;
254
+ const order = data && data.order ? data.order : null;
255
+ return String((order && order.formType) || data.formType || '').trim();
256
+ } catch (e) {
257
+ return '';
258
+ }
259
+ }
260
+
261
+ function getFormSlugFromPayload(payload) {
262
+ try {
263
+ const data = payload && payload.data ? payload.data : null;
264
+ const order = data && data.order ? data.order : null;
265
+ return String((order && order.formSlug) || data.formSlug || '').trim();
266
+ } catch (e) {
267
+ return '';
268
+ }
269
+ }
270
+
271
+ function getPayerEmailFromPayload(payload) {
272
+ try {
273
+ const data = payload && payload.data ? payload.data : null;
274
+ if (!data) return '';
275
+ const payer = data.payer || data.user || null;
276
+ return String((payer && payer.email) || data.payerEmail || '').trim().toLowerCase();
277
+ } catch (e) {
278
+ return '';
279
+ }
280
+ }
281
+
282
+ function getTotalAmountCentsFromPayload(payload) {
283
+ try {
284
+ const data = payload && payload.data ? payload.data : null;
285
+ if (!data) return null;
286
+ const amountObj = data.amount || data.totalAmount || data.amountTotal || null;
287
+ if (typeof amountObj === 'number') return Math.round(amountObj);
288
+ if (amountObj && typeof amountObj === 'object') {
289
+ const v = amountObj.total ?? amountObj.value ?? amountObj.amount;
290
+ if (typeof v === 'number') return Math.round(v);
291
+ }
292
+ // Fallback: sum items amounts when provided.
293
+ const items = Array.isArray(data.items) ? data.items : null;
294
+ if (items && items.length) {
295
+ const sum = items.reduce((acc, it) => acc + (Number(it.amount || it.totalAmount || 0) || 0), 0);
296
+ if (sum) return Math.round(sum);
297
+ }
298
+ return null;
299
+ } catch (e) {
227
300
  return null;
301
+ }
302
+ }
303
+
304
+ async function tryMatchShopOrderToReservation(payload) {
305
+ try {
306
+ const payerEmail = getPayerEmailFromPayload(payload);
307
+ if (!payerEmail) return null;
308
+ const total = getTotalAmountCentsFromPayload(payload);
309
+
310
+ const latest = await dbLayer.getLatestReservations(200);
311
+ const candidates = (latest || []).filter((r) => {
312
+ if (!r || r.status !== 'awaiting_payment') return false;
313
+ if (!r.payerEmail) return false;
314
+ if (String(r.payerEmail).trim().toLowerCase() !== payerEmail) return false;
315
+ // When we know the expected total, require match.
316
+ if (typeof total === 'number' && Number.isFinite(total)) {
317
+ const exp = Number(r.expectedTotalAmount);
318
+ if (Number.isFinite(exp) && exp > 0) {
319
+ return exp === total;
320
+ }
321
+ }
322
+ return true;
323
+ });
324
+
325
+ if (!candidates.length) return null;
326
+ candidates.sort((a, b) => (Number(b.approvedAt || 0) - Number(a.approvedAt || 0)));
327
+ return String(candidates[0].rid);
228
328
  } catch (e) {
229
329
  return null;
230
330
  }
@@ -295,8 +395,11 @@ async function handler(req, res, next) {
295
395
  return res.json({ ok: true, ignored: true });
296
396
  }
297
397
 
298
- const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
299
- if (await alreadyProcessed(paymentId)) {
398
+ // PaymentId may be absent on Order events; fall back to order id for replay protection.
399
+ const orderId = payload && payload.data && payload.data.order ? payload.data.order.id : null;
400
+ const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId || (payload.data.payment && payload.data.payment.id)) : null;
401
+ const processedKey = paymentId || orderId;
402
+ if (await alreadyProcessed(processedKey)) {
300
403
  return res.json({ ok: true, duplicate: true });
301
404
  }
302
405
 
@@ -311,6 +414,19 @@ async function handler(req, res, next) {
311
414
  // Some webhook payloads omit metadata; try to fetch the payment details.
312
415
  resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
313
416
  }
417
+ // For Shop forms, HelloAsso often does not include metadata/checkoutIntentId.
418
+ // As a last resort, reconcile by payer email (+ expected amount when available).
419
+ if (!resolvedRid) {
420
+ const formType = getFormTypeFromPayload(payload).toLowerCase();
421
+ const formSlug = getFormSlugFromPayload(payload);
422
+ if (formType === 'shop' && String(settings.helloassoFormType || '').toLowerCase() === 'shop') {
423
+ // If a specific shop slug is configured, require it when the payload provides it.
424
+ // Some webhook payloads (notably Payment) may omit the formSlug.
425
+ if (!settings.helloassoFormSlug || !formSlug || String(settings.helloassoFormSlug) === String(formSlug)) {
426
+ resolvedRid = await tryMatchShopOrderToReservation(payload);
427
+ }
428
+ }
429
+ }
314
430
  if (!resolvedRid) {
315
431
  // eslint-disable-next-line no-console
316
432
  console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
@@ -320,7 +436,7 @@ async function handler(req, res, next) {
320
436
 
321
437
  const r = await dbLayer.getReservation(resolvedRid);
322
438
  if (!r) {
323
- await markProcessed(paymentId);
439
+ await markProcessed(processedKey);
324
440
  return res.json({ ok: true, processed: true, reservationNotFound: true });
325
441
  }
326
442
 
@@ -352,7 +468,7 @@ async function handler(req, res, next) {
352
468
  });
353
469
  }
354
470
 
355
- await markProcessed(paymentId);
471
+ await markProcessed(processedKey);
356
472
  return res.json({ ok: true, processed: true });
357
473
  } catch (err) {
358
474
  return next(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version":"11.1.60",
3
+ "version":"11.1.62",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",