nodebb-plugin-calendar-onekite 11.1.61 → 11.1.63
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 +122 -48
- package/lib/api.js +31 -35
- package/lib/helloassoWebhook.js +8 -123
- package/library.js +3 -1
- package/package.json +1 -1
- package/public/admin.js +80 -26
- package/templates/admin/plugins/calendar-onekite.tpl +41 -1
package/lib/admin.js
CHANGED
|
@@ -124,23 +124,10 @@ admin.approveReservation = async function (req, res) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
let paymentUrl = null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
r.
|
|
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 (settings.helloassoFormType === '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;
|
|
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));
|
|
144
131
|
const base = forumBaseUrl();
|
|
145
132
|
const returnUrl = base ? `${base}/calendar` : '';
|
|
146
133
|
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
@@ -241,37 +228,6 @@ admin.purgeByYear = async function (req, res) {
|
|
|
241
228
|
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
242
229
|
|
|
243
230
|
|
|
244
|
-
admin.webhookTest = async function (req, res) {
|
|
245
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
246
|
-
const defaultIps = ['51.138.206.200', '4.233.135.234'];
|
|
247
|
-
const allowedIps = String(settings.helloassoWebhookAllowedIps || '')
|
|
248
|
-
.split(/[,\s]+/)
|
|
249
|
-
.map(s => String(s || '').trim())
|
|
250
|
-
.filter(Boolean);
|
|
251
|
-
const finalAllow = allowedIps.length ? allowedIps : defaultIps;
|
|
252
|
-
|
|
253
|
-
const cf = req.headers['cf-connecting-ip'];
|
|
254
|
-
const xff = req.headers['x-forwarded-for'];
|
|
255
|
-
const seenIp = (() => {
|
|
256
|
-
if (cf) return String(cf).trim();
|
|
257
|
-
if (xff) return String(xff).split(',')[0].trim();
|
|
258
|
-
return String(req.ip || '').trim();
|
|
259
|
-
})();
|
|
260
|
-
|
|
261
|
-
return res.json({
|
|
262
|
-
ok: true,
|
|
263
|
-
seenIp,
|
|
264
|
-
allowed: !!(seenIp && finalAllow.includes(seenIp)),
|
|
265
|
-
allowlist: finalAllow,
|
|
266
|
-
headers: {
|
|
267
|
-
'cf-connecting-ip': cf || null,
|
|
268
|
-
'x-forwarded-for': xff || null,
|
|
269
|
-
'x-real-ip': req.headers['x-real-ip'] || null,
|
|
270
|
-
host: req.headers.host || null,
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
};
|
|
274
|
-
|
|
275
231
|
admin.debugHelloAsso = async function (req, res) {
|
|
276
232
|
const settings = await meta.settings.get('calendar-onekite');
|
|
277
233
|
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
@@ -360,4 +316,122 @@ admin.debugHelloAsso = async function (req, res) {
|
|
|
360
316
|
}
|
|
361
317
|
};
|
|
362
318
|
|
|
319
|
+
// Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
|
|
320
|
+
// Query params:
|
|
321
|
+
// from=YYYY-MM-DD (inclusive, based on reservation.start)
|
|
322
|
+
// to=YYYY-MM-DD (exclusive, based on reservation.start)
|
|
323
|
+
admin.getAccounting = async function (req, res) {
|
|
324
|
+
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
325
|
+
const qTo = String((req.query && req.query.to) || '').trim();
|
|
326
|
+
const parseDay = (s) => {
|
|
327
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
328
|
+
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
329
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
330
|
+
const ts = dt.getTime();
|
|
331
|
+
return Number.isFinite(ts) ? ts : null;
|
|
332
|
+
};
|
|
333
|
+
const fromTs = parseDay(qFrom);
|
|
334
|
+
const toTs = parseDay(qTo);
|
|
335
|
+
|
|
336
|
+
// Default: last 12 months (UTC)
|
|
337
|
+
const now = new Date();
|
|
338
|
+
const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
339
|
+
const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
|
|
340
|
+
const minTs = fromTs ?? defaultFrom;
|
|
341
|
+
const maxTs = toTs ?? defaultTo;
|
|
342
|
+
|
|
343
|
+
const ids = await dbLayer.listAllReservationIds(100000);
|
|
344
|
+
const rows = [];
|
|
345
|
+
const byItem = new Map();
|
|
346
|
+
|
|
347
|
+
for (const rid of ids) {
|
|
348
|
+
const r = await dbLayer.getReservation(rid);
|
|
349
|
+
if (!r) continue;
|
|
350
|
+
if (String(r.status) !== 'paid') continue;
|
|
351
|
+
const start = parseInt(r.start, 10);
|
|
352
|
+
if (!Number.isFinite(start)) continue;
|
|
353
|
+
if (start < minTs || start >= maxTs) continue;
|
|
354
|
+
|
|
355
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
356
|
+
? r.itemNames
|
|
357
|
+
: (r.itemName ? [r.itemName] : []);
|
|
358
|
+
|
|
359
|
+
const total = Number(r.total) || 0;
|
|
360
|
+
const startDate = formatFR(r.start);
|
|
361
|
+
const endDate = formatFR(r.end);
|
|
362
|
+
|
|
363
|
+
rows.push({
|
|
364
|
+
rid: r.rid,
|
|
365
|
+
uid: r.uid,
|
|
366
|
+
username: r.username || '',
|
|
367
|
+
start: r.start,
|
|
368
|
+
end: r.end,
|
|
369
|
+
startDate,
|
|
370
|
+
endDate,
|
|
371
|
+
items: itemNames,
|
|
372
|
+
total,
|
|
373
|
+
paidAt: r.paidAt || '',
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
for (const name of itemNames) {
|
|
377
|
+
const key = String(name || '').trim();
|
|
378
|
+
if (!key) continue;
|
|
379
|
+
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
380
|
+
cur.count += 1;
|
|
381
|
+
cur.total += total;
|
|
382
|
+
byItem.set(key, cur);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
387
|
+
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
388
|
+
|
|
389
|
+
return res.json({
|
|
390
|
+
ok: true,
|
|
391
|
+
from: new Date(minTs).toISOString().slice(0, 10),
|
|
392
|
+
to: new Date(maxTs).toISOString().slice(0, 10),
|
|
393
|
+
summary,
|
|
394
|
+
rows,
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
admin.exportAccountingCsv = async function (req, res) {
|
|
399
|
+
// Reuse the same logic and emit a CSV.
|
|
400
|
+
const fakeRes = { json: (x) => x };
|
|
401
|
+
const data = await admin.getAccounting(req, fakeRes);
|
|
402
|
+
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
403
|
+
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
404
|
+
// So we re-run getAccounting but capture output by monkeypatching.
|
|
405
|
+
let payload;
|
|
406
|
+
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
407
|
+
if (!payload || !payload.ok) {
|
|
408
|
+
return res.status(500).send('error');
|
|
409
|
+
}
|
|
410
|
+
const escape = (v) => {
|
|
411
|
+
const s = String(v ?? '');
|
|
412
|
+
if (/[\n\r,\"]/g.test(s)) {
|
|
413
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
414
|
+
}
|
|
415
|
+
return s;
|
|
416
|
+
};
|
|
417
|
+
const lines = [];
|
|
418
|
+
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
|
|
419
|
+
for (const r of payload.rows || []) {
|
|
420
|
+
lines.push([
|
|
421
|
+
r.rid,
|
|
422
|
+
r.username,
|
|
423
|
+
r.uid,
|
|
424
|
+
r.startDate,
|
|
425
|
+
r.endDate,
|
|
426
|
+
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
427
|
+
(Number(r.total) || 0).toFixed(2),
|
|
428
|
+
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
429
|
+
].map(escape).join(','));
|
|
430
|
+
}
|
|
431
|
+
const csv = lines.join('\n');
|
|
432
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
433
|
+
res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
|
|
434
|
+
return res.send(csv);
|
|
435
|
+
};
|
|
436
|
+
|
|
363
437
|
module.exports = admin;
|
package/lib/api.js
CHANGED
|
@@ -389,44 +389,40 @@ 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 token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
393
393
|
const payer = await user.getUserFields(r.uid, ['email']);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const token = await helloasso.getAccessToken({ env: env2, clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
406
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
407
|
-
env: env2,
|
|
408
|
-
token,
|
|
409
|
-
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
410
|
-
formType: settings2.helloassoFormType,
|
|
411
|
-
formSlug: settings2.helloassoFormSlug,
|
|
412
|
-
totalAmount: cents,
|
|
413
|
-
payerEmail: payer && payer.email ? payer.email : '',
|
|
414
|
-
callbackUrl: normalizeReturnUrl(meta),
|
|
415
|
-
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
|
|
416
|
-
itemName: 'Réservation matériel OneKite',
|
|
417
|
-
containsDonation: false,
|
|
418
|
-
metadata: { reservationId: String(rid) },
|
|
419
|
-
});
|
|
420
|
-
const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
|
|
421
|
-
const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
|
|
422
|
-
if (paymentUrl) {
|
|
423
|
-
r.paymentUrl = paymentUrl;
|
|
424
|
-
if (checkoutIntentId) {
|
|
425
|
-
r.checkoutIntentId = checkoutIntentId;
|
|
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 });
|
|
426
405
|
}
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
429
423
|
}
|
|
424
|
+
} else {
|
|
425
|
+
console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
|
|
430
426
|
}
|
|
431
427
|
} catch (e) {
|
|
432
428
|
// ignore payment link errors, admin can retry
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -220,111 +220,11 @@ function formatFR(tsOrIso) {
|
|
|
220
220
|
|
|
221
221
|
function getReservationIdFromPayload(payload) {
|
|
222
222
|
try {
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
225
|
-
|
|
226
|
-
|
|
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) {
|
|
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);
|
|
300
227
|
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);
|
|
328
228
|
} catch (e) {
|
|
329
229
|
return null;
|
|
330
230
|
}
|
|
@@ -395,11 +295,8 @@ async function handler(req, res, next) {
|
|
|
395
295
|
return res.json({ ok: true, ignored: true });
|
|
396
296
|
}
|
|
397
297
|
|
|
398
|
-
|
|
399
|
-
|
|
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)) {
|
|
298
|
+
const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
|
|
299
|
+
if (await alreadyProcessed(paymentId)) {
|
|
403
300
|
return res.json({ ok: true, duplicate: true });
|
|
404
301
|
}
|
|
405
302
|
|
|
@@ -414,18 +311,6 @@ async function handler(req, res, next) {
|
|
|
414
311
|
// Some webhook payloads omit metadata; try to fetch the payment details.
|
|
415
312
|
resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
|
|
416
313
|
}
|
|
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.
|
|
424
|
-
if (!settings.helloassoFormSlug || String(settings.helloassoFormSlug) === String(formSlug)) {
|
|
425
|
-
resolvedRid = await tryMatchShopOrderToReservation(payload);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
314
|
if (!resolvedRid) {
|
|
430
315
|
// eslint-disable-next-line no-console
|
|
431
316
|
console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
|
|
@@ -435,7 +320,7 @@ async function handler(req, res, next) {
|
|
|
435
320
|
|
|
436
321
|
const r = await dbLayer.getReservation(resolvedRid);
|
|
437
322
|
if (!r) {
|
|
438
|
-
await markProcessed(
|
|
323
|
+
await markProcessed(paymentId);
|
|
439
324
|
return res.json({ ok: true, processed: true, reservationNotFound: true });
|
|
440
325
|
}
|
|
441
326
|
|
|
@@ -467,7 +352,7 @@ async function handler(req, res, next) {
|
|
|
467
352
|
});
|
|
468
353
|
}
|
|
469
354
|
|
|
470
|
-
await markProcessed(
|
|
355
|
+
await markProcessed(paymentId);
|
|
471
356
|
return res.json({ ok: true, processed: true });
|
|
472
357
|
} catch (err) {
|
|
473
358
|
return next(err);
|
package/library.js
CHANGED
|
@@ -94,7 +94,9 @@ Plugin.init = async function (params) {
|
|
|
94
94
|
|
|
95
95
|
router.post(`${base}/purge`, ...adminMws, admin.purgeByYear);
|
|
96
96
|
router.get(`${base}/debug`, ...adminMws, admin.debugHelloAsso);
|
|
97
|
-
|
|
97
|
+
// Accounting / exports
|
|
98
|
+
router.get(`${base}/accounting`, ...adminMws, admin.getAccounting);
|
|
99
|
+
router.get(`${base}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
// HelloAsso callback endpoint (hardened)
|
package/package.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -359,12 +359,12 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
async function
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
362
|
+
async function loadAccounting(from, to) {
|
|
363
|
+
const params = new URLSearchParams();
|
|
364
|
+
if (from) params.set('from', from);
|
|
365
|
+
if (to) params.set('to', to);
|
|
366
|
+
const qs = params.toString();
|
|
367
|
+
return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/accounting${qs ? `?${qs}` : ''}`);
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
async function init() {
|
|
@@ -510,28 +510,82 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
510
510
|
}
|
|
511
511
|
});
|
|
512
512
|
}
|
|
513
|
-
}
|
|
514
513
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
514
|
+
// Accounting (paid reservations)
|
|
515
|
+
const accFrom = document.getElementById('onekite-acc-from');
|
|
516
|
+
const accTo = document.getElementById('onekite-acc-to');
|
|
517
|
+
const accRefresh = document.getElementById('onekite-acc-refresh');
|
|
518
|
+
const accExport = document.getElementById('onekite-acc-export');
|
|
519
|
+
const accSummary = document.querySelector('#onekite-acc-summary tbody');
|
|
520
|
+
const accRows = document.querySelector('#onekite-acc-rows tbody');
|
|
521
|
+
|
|
522
|
+
function ymd(d) {
|
|
523
|
+
const yyyy = d.getUTCFullYear();
|
|
524
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
525
|
+
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
526
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
527
|
+
}
|
|
528
|
+
if (accFrom && accTo) {
|
|
529
|
+
const now = new Date();
|
|
530
|
+
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
|
531
|
+
const from = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
|
|
532
|
+
if (!accFrom.value) accFrom.value = ymd(from);
|
|
533
|
+
if (!accTo.value) accTo.value = ymd(to);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderAccounting(payload) {
|
|
537
|
+
if (accSummary) accSummary.innerHTML = '';
|
|
538
|
+
if (accRows) accRows.innerHTML = '';
|
|
539
|
+
if (!payload || !payload.ok) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
(payload.summary || []).forEach((s) => {
|
|
544
|
+
const tr = document.createElement('tr');
|
|
545
|
+
tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
|
|
546
|
+
accSummary && accSummary.appendChild(tr);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
(payload.rows || []).forEach((r) => {
|
|
550
|
+
const tr = document.createElement('tr');
|
|
551
|
+
const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>` : '';
|
|
552
|
+
const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
|
|
553
|
+
tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${escapeHtml((Number(r.total) || 0).toFixed(2))}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
|
|
554
|
+
accRows && accRows.appendChild(tr);
|
|
534
555
|
});
|
|
535
556
|
}
|
|
536
557
|
|
|
558
|
+
async function refreshAccounting() {
|
|
559
|
+
if (!accRefresh) return;
|
|
560
|
+
try {
|
|
561
|
+
const from = accFrom ? accFrom.value : '';
|
|
562
|
+
const to = accTo ? accTo.value : '';
|
|
563
|
+
accRefresh.disabled = true;
|
|
564
|
+
const payload = await loadAccounting(from, to);
|
|
565
|
+
renderAccounting(payload);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
showAlert('error', 'Impossible de charger la comptabilisation.');
|
|
568
|
+
} finally {
|
|
569
|
+
accRefresh.disabled = false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (accRefresh) {
|
|
574
|
+
accRefresh.addEventListener('click', refreshAccounting);
|
|
575
|
+
// Load once on init
|
|
576
|
+
refreshAccounting();
|
|
577
|
+
}
|
|
578
|
+
if (accExport) {
|
|
579
|
+
accExport.addEventListener('click', () => {
|
|
580
|
+
const params = new URLSearchParams();
|
|
581
|
+
if (accFrom && accFrom.value) params.set('from', accFrom.value);
|
|
582
|
+
if (accTo && accTo.value) params.set('to', accTo.value);
|
|
583
|
+
const qs = params.toString();
|
|
584
|
+
const url = `/api/v3/admin/plugins/calendar-onekite/accounting.csv${qs ? `?${qs}` : ''}`;
|
|
585
|
+
window.open(url, '_blank');
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
537
589
|
|
|
590
|
+
return { init };
|
|
591
|
+
});
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
<li class="nav-item" role="presentation">
|
|
15
15
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-debug" type="button" role="tab">Debug HelloAsso</button>
|
|
16
16
|
</li>
|
|
17
|
+
<li class="nav-item" role="presentation">
|
|
18
|
+
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-accounting" type="button" role="tab">Comptabilisation</button>
|
|
19
|
+
</li>
|
|
17
20
|
</ul>
|
|
18
21
|
|
|
19
22
|
<div class="tab-content pt-3">
|
|
@@ -102,9 +105,46 @@
|
|
|
102
105
|
<div class="tab-pane fade" id="onekite-tab-debug" role="tabpanel">
|
|
103
106
|
<p class="text-muted">Teste la récupération du token et la liste du matériel (catalogue).</p>
|
|
104
107
|
<button type="button" class="btn btn-secondary me-2" id="onekite-debug-run">Tester le chargement du matériel</button>
|
|
105
|
-
<button type="button" class="btn btn-outline-primary" id="onekite-webhook-test">Tester le webhook (IP + réponse)</button>
|
|
106
108
|
<pre id="onekite-debug-output" class="mt-3 p-3 bg-light" style="max-height: 360px; overflow: auto;"></pre>
|
|
107
109
|
</div>
|
|
110
|
+
|
|
111
|
+
<div class="tab-pane fade" id="onekite-tab-accounting" role="tabpanel">
|
|
112
|
+
<h4>Comptabilisation des locations (payées)</h4>
|
|
113
|
+
<div class="d-flex flex-wrap gap-2 align-items-end mb-3">
|
|
114
|
+
<div>
|
|
115
|
+
<label class="form-label">Du</label>
|
|
116
|
+
<input class="form-control" type="date" id="onekite-acc-from" />
|
|
117
|
+
</div>
|
|
118
|
+
<div>
|
|
119
|
+
<label class="form-label">Au</label>
|
|
120
|
+
<input class="form-control" type="date" id="onekite-acc-to" />
|
|
121
|
+
</div>
|
|
122
|
+
<div>
|
|
123
|
+
<button type="button" class="btn btn-primary" id="onekite-acc-refresh">Rafraîchir</button>
|
|
124
|
+
<button type="button" class="btn btn-outline-secondary" id="onekite-acc-export">Exporter CSV</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<h5>Résumé par matériel</h5>
|
|
129
|
+
<div class="table-responsive">
|
|
130
|
+
<table class="table table-sm" id="onekite-acc-summary">
|
|
131
|
+
<thead>
|
|
132
|
+
<tr><th>Matériel</th><th>Nb locations</th><th>Total (€)</th></tr>
|
|
133
|
+
</thead>
|
|
134
|
+
<tbody></tbody>
|
|
135
|
+
</table>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<h5 class="mt-4">Détails</h5>
|
|
139
|
+
<div class="table-responsive">
|
|
140
|
+
<table class="table table-sm" id="onekite-acc-rows">
|
|
141
|
+
<thead>
|
|
142
|
+
<tr><th>Date</th><th>Utilisateur</th><th>Matériel</th><th>Total (€)</th><th>RID</th></tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody></tbody>
|
|
145
|
+
</table>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
108
148
|
</div>
|
|
109
149
|
</div>
|
|
110
150
|
</div>
|