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 +17 -4
- package/lib/api.js +36 -31
- package/lib/helloassoWebhook.js +124 -8
- package/package.json +1 -1
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
392
|
+
const env2 = settings2.helloassoEnv || 'prod';
|
|
393
393
|
const payer = await user.getUserFields(r.uid, ['email']);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -220,11 +220,111 @@ function formatFR(tsOrIso) {
|
|
|
220
220
|
|
|
221
221
|
function getReservationIdFromPayload(payload) {
|
|
222
222
|
try {
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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(
|
|
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(
|
|
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