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 CHANGED
@@ -124,23 +124,10 @@ admin.approveReservation = async function (req, res) {
124
124
  }
125
125
 
126
126
  let paymentUrl = null;
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 (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 env2 = settings2.helloassoEnv || 'prod';
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
- 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
- if (settings2.helloassoFormType === 'Shop' && settings2.helloassoOrganizationSlug && settings2.helloassoFormSlug) {
402
- const frontBase = (env2 === 'sandbox') ? 'https://www.helloasso-sandbox.com' : 'https://www.helloasso.com';
403
- 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))}`;
404
- } else {
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
- } else {
428
- console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
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
@@ -220,111 +220,11 @@ function formatFR(tsOrIso) {
220
220
 
221
221
  function getReservationIdFromPayload(payload) {
222
222
  try {
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) {
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
- // 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)) {
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(processedKey);
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(processedKey);
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
- router.get(`${base}/webhook-test`, ...adminMws, admin.webhookTest);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version":"11.1.61",
3
+ "version":"11.1.63",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
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 testWebhook() {
363
- try {
364
- return await fetchJson('/api/v3/admin/plugins/calendar-onekite/webhook-test');
365
- } catch (e) {
366
- return await fetchJson('/api/admin/plugins/calendar-onekite/webhook-test');
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
- return { init };
516
- });
517
- const webhookBtn = document.getElementById('onekite-webhook-test');
518
- if (webhookBtn) {
519
- webhookBtn.addEventListener('click', async () => {
520
- const out = document.getElementById('onekite-debug-output');
521
- if (out) out.textContent = 'Test webhook...';
522
- try {
523
- const result = await testWebhook();
524
- if (out) out.textContent = JSON.stringify(result, null, 2);
525
- if (result && result.allowed) {
526
- showAlert('success', `Webhook: IP autorisée (${result.seenIp})`);
527
- } else {
528
- showAlert('warning', `Webhook: IP non autorisée (${result && result.seenIp ? result.seenIp : 'inconnue'})`);
529
- }
530
- } catch (e) {
531
- if (out) out.textContent = String(e && e.message ? e.message : e);
532
- showAlert('error', 'Test webhook impossible.');
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>