nodebb-plugin-onekite-calendar 2.0.90 → 2.0.91
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 +19 -116
- package/lib/api.js +26 -207
- package/lib/checkout.js +126 -0
- package/lib/helloasso.js +0 -9
- package/package.json +1 -1
- package/public/admin.js +45 -10
package/lib/admin.js
CHANGED
|
@@ -11,9 +11,6 @@ const {
|
|
|
11
11
|
formatFR,
|
|
12
12
|
sendEmail,
|
|
13
13
|
buildCalendarLinks,
|
|
14
|
-
buildHelloAssoItemName,
|
|
15
|
-
normalizeCallbackUrl,
|
|
16
|
-
normalizeReturnUrl,
|
|
17
14
|
calendarDaysExclusiveYmd,
|
|
18
15
|
yearFromTs,
|
|
19
16
|
autoFormSlugForYear,
|
|
@@ -22,6 +19,7 @@ const {
|
|
|
22
19
|
|
|
23
20
|
const dbLayer = require('./db');
|
|
24
21
|
const helloasso = require('./helloasso');
|
|
22
|
+
const { buildCheckoutIntent } = require('./checkout');
|
|
25
23
|
|
|
26
24
|
const ADMIN_PRIV = 'admin:settings';
|
|
27
25
|
|
|
@@ -45,10 +43,16 @@ admin.saveSettings = async function (req, res) {
|
|
|
45
43
|
};
|
|
46
44
|
|
|
47
45
|
admin.listPending = async function (req, res) {
|
|
46
|
+
// Accept ?status=pending or ?status=pending,awaiting_payment (default: both)
|
|
47
|
+
const rawStatus = (req.query && req.query.status) ? String(req.query.status) : '';
|
|
48
|
+
const statusFilter = rawStatus
|
|
49
|
+
? rawStatus.split(',').map((s) => s.trim()).filter(Boolean)
|
|
50
|
+
: ['pending', 'awaiting_payment'];
|
|
51
|
+
|
|
48
52
|
const ids = await dbLayer.listAllReservationIds(5000);
|
|
49
53
|
// Batch fetch to avoid N DB round-trips.
|
|
50
54
|
const rows = await dbLayer.getReservations(ids);
|
|
51
|
-
const pending = (rows || []).filter(r => r && r.status
|
|
55
|
+
const pending = (rows || []).filter(r => r && statusFilter.includes(r.status));
|
|
52
56
|
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
53
57
|
|
|
54
58
|
// Enrich with user info for ACP display (username + userslug)
|
|
@@ -99,84 +103,21 @@ admin.approveReservation = async function (req, res) {
|
|
|
99
103
|
r.approvedByUsername = '';
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
// Create HelloAsso payment link
|
|
106
|
+
// Create HelloAsso payment link
|
|
103
107
|
const settings = await getSettings();
|
|
104
|
-
const env = settings.helloassoEnv || 'prod';
|
|
105
|
-
const token = await helloasso.getAccessToken({
|
|
106
|
-
env,
|
|
107
|
-
clientId: settings.helloassoClientId,
|
|
108
|
-
clientSecret: settings.helloassoClientSecret,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
108
|
let paymentUrl = null;
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
let recomputedTotalCents = null;
|
|
120
|
-
try {
|
|
121
|
-
const { items: catalog } = await helloasso.listCatalogItems({
|
|
122
|
-
env,
|
|
123
|
-
token,
|
|
124
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
125
|
-
formType: settings.helloassoFormType,
|
|
126
|
-
formSlug,
|
|
127
|
-
});
|
|
128
|
-
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
129
|
-
const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
130
|
-
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
131
|
-
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
|
|
132
|
-
if (sumCentsPerDay > 0) {
|
|
133
|
-
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
134
|
-
r.total = recomputedTotalCents / 100;
|
|
135
|
-
}
|
|
136
|
-
} catch (e) {
|
|
137
|
-
// ignore recompute failures; fallback to stored total
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const totalAmount = (typeof recomputedTotalCents === 'number')
|
|
141
|
-
? recomputedTotalCents
|
|
142
|
-
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
143
|
-
|
|
144
|
-
if (!totalAmount) {
|
|
145
|
-
console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve ACP) — skipping checkout intent', { rid, total: r.total });
|
|
109
|
+
try {
|
|
110
|
+
const checkoutResult = await buildCheckoutIntent({ r, settings });
|
|
111
|
+
if (checkoutResult) {
|
|
112
|
+
paymentUrl = checkoutResult.paymentUrl;
|
|
113
|
+
r.paymentUrl = checkoutResult.paymentUrl;
|
|
114
|
+
if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
|
|
115
|
+
if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
|
|
146
116
|
} else {
|
|
147
|
-
|
|
148
|
-
const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
|
|
149
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
150
|
-
env,
|
|
151
|
-
token,
|
|
152
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
153
|
-
formType: settings.helloassoFormType,
|
|
154
|
-
formSlug,
|
|
155
|
-
totalAmount,
|
|
156
|
-
payerEmail: requester && requester.email,
|
|
157
|
-
callbackUrl: returnUrl,
|
|
158
|
-
webhookUrl: webhookUrl,
|
|
159
|
-
itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
160
|
-
containsDonation: false,
|
|
161
|
-
metadata: {
|
|
162
|
-
reservationId: String(rid),
|
|
163
|
-
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
164
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
|
-
paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
168
|
-
? (intent.paymentUrl || intent.redirectUrl)
|
|
169
|
-
: (typeof intent === 'string' ? intent : null);
|
|
170
|
-
if (intent && intent.checkoutIntentId) {
|
|
171
|
-
r.checkoutIntentId = intent.checkoutIntentId;
|
|
172
|
-
}
|
|
117
|
+
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
173
118
|
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (paymentUrl) {
|
|
177
|
-
r.paymentUrl = paymentUrl;
|
|
178
|
-
} else {
|
|
179
|
-
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.warn('[calendar-onekite] HelloAsso payment link error (approve ACP)', e && e.message ? e.message : e);
|
|
180
121
|
}
|
|
181
122
|
|
|
182
123
|
await dbLayer.saveReservation(r);
|
|
@@ -508,44 +449,6 @@ admin.debugHelloAsso = async function (req, res) {
|
|
|
508
449
|
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
509
450
|
}
|
|
510
451
|
|
|
511
|
-
// Test checkout intent creation with a 1-cent amount to validate URL format.
|
|
512
|
-
// This creates a real (but minimal) intent — it will expire quickly and can be ignored.
|
|
513
|
-
out.checkoutIntentTest = { ok: false };
|
|
514
|
-
try {
|
|
515
|
-
const callbackUrl = normalizeReturnUrl();
|
|
516
|
-
const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
|
|
517
|
-
if (!callbackUrl) {
|
|
518
|
-
out.checkoutIntentTest = { ok: false, error: 'callbackUrl empty — forum base URL not configured' };
|
|
519
|
-
} else {
|
|
520
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
521
|
-
env,
|
|
522
|
-
token,
|
|
523
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
524
|
-
formType: settings.helloassoFormType,
|
|
525
|
-
formSlug: `locations-materiel-${new Date().getFullYear()}`,
|
|
526
|
-
totalAmount: 100,
|
|
527
|
-
payerEmail: '',
|
|
528
|
-
callbackUrl,
|
|
529
|
-
webhookUrl,
|
|
530
|
-
itemName: '[TEST] Vérification lien paiement',
|
|
531
|
-
containsDonation: false,
|
|
532
|
-
metadata: { debug: 'true' },
|
|
533
|
-
});
|
|
534
|
-
if (intent && intent.paymentUrl) {
|
|
535
|
-
out.checkoutIntentTest = {
|
|
536
|
-
ok: true,
|
|
537
|
-
checkoutIntentId: intent.checkoutIntentId,
|
|
538
|
-
paymentUrl: intent.paymentUrl,
|
|
539
|
-
note: 'Cet intent de test (1 cent) peut être ignoré — il expirera automatiquement',
|
|
540
|
-
};
|
|
541
|
-
} else {
|
|
542
|
-
out.checkoutIntentTest = { ok: false, error: 'no paymentUrl in response', raw: intent };
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
} catch (e) {
|
|
546
|
-
out.checkoutIntentTest = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
452
|
return res.json(out);
|
|
550
453
|
} catch (e) {
|
|
551
454
|
out.ok = false;
|
package/lib/api.js
CHANGED
|
@@ -24,21 +24,16 @@ const {
|
|
|
24
24
|
yearFromTs,
|
|
25
25
|
autoCreatorGroupForYear,
|
|
26
26
|
autoFormSlugForYear,
|
|
27
|
-
normalizeCallbackUrl,
|
|
28
|
-
normalizeReturnUrl,
|
|
29
|
-
buildHelloAssoItemName,
|
|
30
27
|
buildCalendarLinks,
|
|
31
28
|
signCalendarLink,
|
|
32
29
|
ymdToCompact,
|
|
33
|
-
dtToGCalUtc,
|
|
34
30
|
getSetting,
|
|
35
31
|
} = shared;
|
|
36
32
|
|
|
37
33
|
const helloasso = require('./helloasso');
|
|
38
34
|
const discord = require('./discord');
|
|
39
35
|
const realtime = require('./realtime');
|
|
40
|
-
|
|
41
|
-
// normalizeCallbackUrl and normalizeReturnUrl are imported from shared.js
|
|
36
|
+
const { buildCheckoutIntent } = require('./checkout');
|
|
42
37
|
|
|
43
38
|
|
|
44
39
|
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
@@ -46,8 +41,6 @@ function overlap(aStart, aEnd, bStart, bEnd) {
|
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
|
|
49
|
-
// buildHelloAssoItemName is imported from shared.js
|
|
50
|
-
|
|
51
44
|
function toTs(v) {
|
|
52
45
|
if (v === undefined || v === null || v === '') return NaN;
|
|
53
46
|
// Accept milliseconds timestamps passed as strings or numbers.
|
|
@@ -647,82 +640,18 @@ api.getFreshPaymentUrl = async function (req, res) {
|
|
|
647
640
|
return res.status(409).json({ error: 'wrong-status', status: r.status });
|
|
648
641
|
}
|
|
649
642
|
|
|
650
|
-
// Build a fresh checkout intent
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
env,
|
|
654
|
-
clientId: settings.helloassoClientId,
|
|
655
|
-
clientSecret: settings.helloassoClientSecret,
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
if (!token) {
|
|
659
|
-
return res.status(503).json({ error: 'helloasso-unavailable' });
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const requester = await user.getUserFields(r.uid, ['email']);
|
|
663
|
-
const year = yearFromTs(Number(r.start));
|
|
664
|
-
const formSlug = autoFormSlugForYear(year);
|
|
665
|
-
|
|
666
|
-
// Recompute total from HelloAsso catalog to always use up-to-date prices.
|
|
667
|
-
let recomputedTotalCents = null;
|
|
668
|
-
try {
|
|
669
|
-
const { items: catalog } = await helloasso.listCatalogItems({
|
|
670
|
-
env,
|
|
671
|
-
token,
|
|
672
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
673
|
-
formType: settings.helloassoFormType,
|
|
674
|
-
formSlug,
|
|
675
|
-
});
|
|
676
|
-
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
677
|
-
const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
678
|
-
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
679
|
-
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
|
|
680
|
-
if (sumCentsPerDay > 0) {
|
|
681
|
-
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
682
|
-
}
|
|
683
|
-
} catch (e) { /* fallback to stored total */ }
|
|
684
|
-
|
|
685
|
-
const totalAmount = (typeof recomputedTotalCents === 'number')
|
|
686
|
-
? recomputedTotalCents
|
|
687
|
-
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
688
|
-
|
|
689
|
-
if (!totalAmount) {
|
|
690
|
-
return res.status(422).json({ error: 'zero-amount' });
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
694
|
-
env,
|
|
695
|
-
token,
|
|
696
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
697
|
-
formType: settings.helloassoFormType,
|
|
698
|
-
formSlug,
|
|
699
|
-
totalAmount,
|
|
700
|
-
payerEmail: requester && requester.email ? requester.email : '',
|
|
701
|
-
callbackUrl: normalizeReturnUrl(),
|
|
702
|
-
webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
|
|
703
|
-
itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
704
|
-
containsDonation: false,
|
|
705
|
-
metadata: {
|
|
706
|
-
reservationId: String(rid),
|
|
707
|
-
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
708
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
709
|
-
},
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
713
|
-
? (intent.paymentUrl || intent.redirectUrl)
|
|
714
|
-
: (typeof intent === 'string' ? intent : null);
|
|
715
|
-
|
|
716
|
-
if (!paymentUrl) {
|
|
643
|
+
// Build a fresh checkout intent (handles token, catalog recompute, intent creation)
|
|
644
|
+
const checkoutResult = await buildCheckoutIntent({ r, settings });
|
|
645
|
+
if (!checkoutResult) {
|
|
717
646
|
return res.status(502).json({ error: 'intent-creation-failed' });
|
|
718
647
|
}
|
|
719
648
|
|
|
720
|
-
|
|
721
|
-
r.
|
|
722
|
-
if (
|
|
649
|
+
r.paymentUrl = checkoutResult.paymentUrl;
|
|
650
|
+
if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
|
|
651
|
+
if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
|
|
723
652
|
await dbLayer.saveReservation(r);
|
|
724
653
|
|
|
725
|
-
return res.json({ ok: true, paymentUrl });
|
|
654
|
+
return res.json({ ok: true, paymentUrl: checkoutResult.paymentUrl });
|
|
726
655
|
};
|
|
727
656
|
|
|
728
657
|
/**
|
|
@@ -1398,9 +1327,9 @@ api.createReservation = async function (req, res) {
|
|
|
1398
1327
|
if (!(exStart < end && start < exEnd)) continue;
|
|
1399
1328
|
}
|
|
1400
1329
|
const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
|
|
1401
|
-
const
|
|
1402
|
-
if (
|
|
1403
|
-
conflicts.push({ rid: existing.rid, itemIds:
|
|
1330
|
+
const conflictingItemIds = exItemIds.filter(x => itemIds.includes(String(x)));
|
|
1331
|
+
if (conflictingItemIds.length) {
|
|
1332
|
+
conflicts.push({ rid: existing.rid, itemIds: conflictingItemIds, status: existing.status });
|
|
1404
1333
|
}
|
|
1405
1334
|
}
|
|
1406
1335
|
if (conflicts.length) {
|
|
@@ -1491,63 +1420,16 @@ api.createReservation = async function (req, res) {
|
|
|
1491
1420
|
// create the HelloAsso checkout intent immediately so the user has a payment link right away.
|
|
1492
1421
|
if (isRegularizationOther) {
|
|
1493
1422
|
try {
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
let recomputedTotalCents = null;
|
|
1500
|
-
try {
|
|
1501
|
-
const { items: catalog } = await helloasso.listCatalogItems({
|
|
1502
|
-
env: settings.helloassoEnv,
|
|
1503
|
-
token,
|
|
1504
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
1505
|
-
formType: settings.helloassoFormType,
|
|
1506
|
-
formSlug: autoFormSlugForYear(year),
|
|
1507
|
-
});
|
|
1508
|
-
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
1509
|
-
const ids = itemIds.map(String);
|
|
1510
|
-
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
|
|
1511
|
-
if (sumCentsPerDay > 0) {
|
|
1512
|
-
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
1513
|
-
resv.total = recomputedTotalCents / 100;
|
|
1514
|
-
}
|
|
1515
|
-
} catch (e) {}
|
|
1516
|
-
|
|
1517
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
1518
|
-
env: settings.helloassoEnv,
|
|
1519
|
-
token,
|
|
1520
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
1521
|
-
formType: settings.helloassoFormType,
|
|
1522
|
-
formSlug: autoFormSlugForYear(year),
|
|
1523
|
-
totalAmount: (() => {
|
|
1524
|
-
const cents = (typeof recomputedTotalCents === 'number')
|
|
1525
|
-
? recomputedTotalCents
|
|
1526
|
-
: Math.max(0, Math.round((Number(resv.total) || 0) * 100));
|
|
1527
|
-
if (!cents) console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (regularizationOther)', { rid });
|
|
1528
|
-
return cents;
|
|
1529
|
-
})(),
|
|
1530
|
-
payerEmail: payer && payer.email ? payer.email : '',
|
|
1531
|
-
callbackUrl: normalizeReturnUrl(),
|
|
1532
|
-
webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
|
|
1533
|
-
itemName: buildHelloAssoItemName('', resv.itemNames || [], resv.start, resv.end),
|
|
1534
|
-
containsDonation: false,
|
|
1535
|
-
metadata: {
|
|
1536
|
-
reservationId: String(rid),
|
|
1537
|
-
items: resv.itemNames || [],
|
|
1538
|
-
dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
|
|
1539
|
-
},
|
|
1540
|
-
});
|
|
1541
|
-
const intentPaymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
|
|
1542
|
-
const intentCheckoutId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
|
|
1543
|
-
if (intentPaymentUrl) {
|
|
1544
|
-
resv.paymentUrl = intentPaymentUrl;
|
|
1545
|
-
if (intentCheckoutId) resv.checkoutIntentId = intentCheckoutId;
|
|
1423
|
+
const checkoutResult = await buildCheckoutIntent({ r: resv, settings });
|
|
1424
|
+
if (checkoutResult) {
|
|
1425
|
+
resv.paymentUrl = checkoutResult.paymentUrl;
|
|
1426
|
+
if (checkoutResult.checkoutIntentId) resv.checkoutIntentId = checkoutResult.checkoutIntentId;
|
|
1427
|
+
if (checkoutResult.catalogTotalCents !== null) resv.total = checkoutResult.catalogTotalCents / 100;
|
|
1546
1428
|
} else {
|
|
1547
1429
|
console.warn('[calendar-onekite] HelloAsso payment link not created (regularizationOther)', { rid });
|
|
1548
1430
|
}
|
|
1549
1431
|
} catch (e) {
|
|
1550
|
-
console.warn('[calendar-onekite] Failed to create HelloAsso checkout
|
|
1432
|
+
console.warn('[calendar-onekite] Failed to create HelloAsso checkout (regularizationOther)', e && e.message ? e.message : e);
|
|
1551
1433
|
}
|
|
1552
1434
|
}
|
|
1553
1435
|
|
|
@@ -1774,74 +1656,11 @@ api.approveReservation = async function (req, res) {
|
|
|
1774
1656
|
}
|
|
1775
1657
|
// Create HelloAsso payment link on validation
|
|
1776
1658
|
try {
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
// Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
|
|
1783
|
-
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
1784
|
-
|
|
1785
|
-
// Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
|
|
1786
|
-
// and to ensure checkout amount is always consistent.
|
|
1787
|
-
let recomputedTotalCents = null;
|
|
1788
|
-
try {
|
|
1789
|
-
const { items: catalog } = await helloasso.listCatalogItems({
|
|
1790
|
-
env: settings2.helloassoEnv,
|
|
1791
|
-
token,
|
|
1792
|
-
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
1793
|
-
formType: settings2.helloassoFormType,
|
|
1794
|
-
formSlug: autoFormSlugForYear(year),
|
|
1795
|
-
});
|
|
1796
|
-
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
1797
|
-
const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
1798
|
-
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
|
|
1799
|
-
if (sumCentsPerDay > 0) {
|
|
1800
|
-
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
1801
|
-
// Keep stored total in sync (euros) for emails/UX.
|
|
1802
|
-
r.total = recomputedTotalCents / 100;
|
|
1803
|
-
}
|
|
1804
|
-
} catch (e) {
|
|
1805
|
-
// ignore recompute failures; fallback to stored total
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
1809
|
-
env: settings2.helloassoEnv,
|
|
1810
|
-
token,
|
|
1811
|
-
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
1812
|
-
formType: settings2.helloassoFormType,
|
|
1813
|
-
// Form slug is derived from the year of the reservation start date
|
|
1814
|
-
formSlug: autoFormSlugForYear(year),
|
|
1815
|
-
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
1816
|
-
totalAmount: (() => {
|
|
1817
|
-
const cents = (typeof recomputedTotalCents === 'number')
|
|
1818
|
-
? recomputedTotalCents
|
|
1819
|
-
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
1820
|
-
if (!cents) {
|
|
1821
|
-
console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
|
|
1822
|
-
}
|
|
1823
|
-
return cents;
|
|
1824
|
-
})(),
|
|
1825
|
-
payerEmail: payer && payer.email ? payer.email : '',
|
|
1826
|
-
// By default, point to the forum base url so the webhook hits this NodeBB instance.
|
|
1827
|
-
// Can be overridden via ACP setting `helloassoCallbackUrl`.
|
|
1828
|
-
callbackUrl: normalizeReturnUrl(),
|
|
1829
|
-
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl),
|
|
1830
|
-
itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
1831
|
-
containsDonation: false,
|
|
1832
|
-
metadata: {
|
|
1833
|
-
reservationId: String(rid),
|
|
1834
|
-
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
1835
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1836
|
-
},
|
|
1837
|
-
});
|
|
1838
|
-
const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
|
|
1839
|
-
const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
|
|
1840
|
-
if (paymentUrl) {
|
|
1841
|
-
r.paymentUrl = paymentUrl;
|
|
1842
|
-
if (checkoutIntentId) {
|
|
1843
|
-
r.checkoutIntentId = checkoutIntentId;
|
|
1844
|
-
}
|
|
1659
|
+
const checkoutResult = await buildCheckoutIntent({ r, settings });
|
|
1660
|
+
if (checkoutResult) {
|
|
1661
|
+
r.paymentUrl = checkoutResult.paymentUrl;
|
|
1662
|
+
if (checkoutResult.checkoutIntentId) r.checkoutIntentId = checkoutResult.checkoutIntentId;
|
|
1663
|
+
if (checkoutResult.catalogTotalCents !== null) r.total = checkoutResult.catalogTotalCents / 100;
|
|
1845
1664
|
} else {
|
|
1846
1665
|
console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
|
|
1847
1666
|
}
|
|
@@ -1879,8 +1698,8 @@ api.approveReservation = async function (req, res) {
|
|
|
1879
1698
|
uid: requesterUid,
|
|
1880
1699
|
username: requester && requester.username ? requester.username : '',
|
|
1881
1700
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
1882
|
-
|
|
1883
|
-
|
|
1701
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
1702
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1884
1703
|
start: formatFR(r.start),
|
|
1885
1704
|
end: formatFR(r.end),
|
|
1886
1705
|
pickupAddress: r.pickupAddress || '',
|
|
@@ -1955,8 +1774,8 @@ api.refuseReservation = async function (req, res) {
|
|
|
1955
1774
|
uid: requesterUid2,
|
|
1956
1775
|
username: requester && requester.username ? requester.username : '',
|
|
1957
1776
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
1958
|
-
|
|
1959
|
-
|
|
1777
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
1778
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1960
1779
|
start: formatFR(r.start),
|
|
1961
1780
|
end: formatFR(r.end),
|
|
1962
1781
|
refusedReason: r.refusedReason || '',
|
package/lib/checkout.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared checkout intent builder for HelloAsso reservations.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from api.js and admin.js to eliminate the four identical copies
|
|
7
|
+
* of the same pattern:
|
|
8
|
+
* - api.approveReservation
|
|
9
|
+
* - api.getFreshPaymentUrl
|
|
10
|
+
* - api.createReservation (regularizationOther path)
|
|
11
|
+
* - admin.approveReservation
|
|
12
|
+
*
|
|
13
|
+
* Does NOT persist anything — callers are responsible for saving the reservation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const user = require.main.require('./src/user');
|
|
17
|
+
const helloasso = require('./helloasso');
|
|
18
|
+
const {
|
|
19
|
+
yearFromTs,
|
|
20
|
+
autoFormSlugForYear,
|
|
21
|
+
calendarDaysExclusiveYmd,
|
|
22
|
+
buildHelloAssoItemName,
|
|
23
|
+
normalizeCallbackUrl,
|
|
24
|
+
normalizeReturnUrl,
|
|
25
|
+
formatFR,
|
|
26
|
+
} = require('./shared');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a HelloAsso checkout intent for a reservation.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {object} opts.r Reservation object (not mutated here)
|
|
33
|
+
* @param {object} opts.settings Plugin settings (from getSettings())
|
|
34
|
+
* @returns {Promise<{
|
|
35
|
+
* paymentUrl: string,
|
|
36
|
+
* checkoutIntentId: string | null,
|
|
37
|
+
* totalCents: number,
|
|
38
|
+
* catalogTotalCents: number | null,
|
|
39
|
+
* } | null>}
|
|
40
|
+
* null when: token unavailable, amount is 0, or intent creation failed.
|
|
41
|
+
* catalogTotalCents is non-null only when the catalog lookup succeeded with
|
|
42
|
+
* positive prices; callers should update r.total only in that case.
|
|
43
|
+
*/
|
|
44
|
+
async function buildCheckoutIntent({ r, settings }) {
|
|
45
|
+
const env = settings.helloassoEnv || 'prod';
|
|
46
|
+
|
|
47
|
+
const token = await helloasso.getAccessToken({
|
|
48
|
+
env,
|
|
49
|
+
clientId: settings.helloassoClientId,
|
|
50
|
+
clientSecret: settings.helloassoClientSecret,
|
|
51
|
+
});
|
|
52
|
+
if (!token) return null;
|
|
53
|
+
|
|
54
|
+
const payerData = await user.getUserFields(r.uid, ['email']).catch(() => null);
|
|
55
|
+
const payerEmail = (payerData && payerData.email) ? String(payerData.email) : '';
|
|
56
|
+
|
|
57
|
+
const year = yearFromTs(Number(r.start));
|
|
58
|
+
const formSlug = autoFormSlugForYear(year);
|
|
59
|
+
const itemIds = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
60
|
+
const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
|
|
61
|
+
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
62
|
+
|
|
63
|
+
// Recompute total from HelloAsso catalog to always use up-to-date prices.
|
|
64
|
+
let catalogTotalCents = null;
|
|
65
|
+
try {
|
|
66
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
67
|
+
env,
|
|
68
|
+
token,
|
|
69
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
70
|
+
formType: settings.helloassoFormType,
|
|
71
|
+
formSlug,
|
|
72
|
+
});
|
|
73
|
+
const byId = new Map(
|
|
74
|
+
(catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)])
|
|
75
|
+
);
|
|
76
|
+
const sumCentsPerDay = itemIds.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
|
|
77
|
+
if (sumCentsPerDay > 0) {
|
|
78
|
+
catalogTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Catalog unavailable — fall back to stored total below.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const totalCents = (catalogTotalCents !== null)
|
|
85
|
+
? catalogTotalCents
|
|
86
|
+
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
87
|
+
|
|
88
|
+
if (!totalCents) {
|
|
89
|
+
console.warn('[calendar-onekite] checkout intent skipped: totalAmount is 0', { rid: r.rid });
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
94
|
+
env,
|
|
95
|
+
token,
|
|
96
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
97
|
+
formType: settings.helloassoFormType,
|
|
98
|
+
formSlug,
|
|
99
|
+
totalAmount: totalCents,
|
|
100
|
+
payerEmail,
|
|
101
|
+
callbackUrl: normalizeReturnUrl(),
|
|
102
|
+
webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
|
|
103
|
+
itemName: buildHelloAssoItemName('Réservation matériel Onekite', itemNames, r.start, r.end),
|
|
104
|
+
containsDonation: false,
|
|
105
|
+
metadata: {
|
|
106
|
+
reservationId: String(r.rid),
|
|
107
|
+
items: itemNames.filter(Boolean),
|
|
108
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const paymentUrl = (intent && (intent.paymentUrl || intent.redirectUrl))
|
|
113
|
+
? (intent.paymentUrl || intent.redirectUrl)
|
|
114
|
+
: (typeof intent === 'string' ? intent : null);
|
|
115
|
+
|
|
116
|
+
if (!paymentUrl) return null;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
paymentUrl,
|
|
120
|
+
checkoutIntentId: (intent && intent.checkoutIntentId) ? String(intent.checkoutIntentId) : null,
|
|
121
|
+
totalCents,
|
|
122
|
+
catalogTotalCents,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { buildCheckoutIntent };
|
package/lib/helloasso.js
CHANGED
|
@@ -307,15 +307,6 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
|
|
|
307
307
|
const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
|
|
308
308
|
if (status >= 200 && status < 300 && json) {
|
|
309
309
|
const redirectUrl = json.redirectUrl || json.checkoutUrl || json.url || null;
|
|
310
|
-
// Always log so the URL format is visible in NodeBB logs (helps diagnose 404s).
|
|
311
|
-
// eslint-disable-next-line no-console
|
|
312
|
-
console.log('[calendar-onekite] HelloAsso checkout-intent created', {
|
|
313
|
-
status,
|
|
314
|
-
checkoutIntentId: json.id || json.checkoutIntentId || null,
|
|
315
|
-
redirectUrl,
|
|
316
|
-
env,
|
|
317
|
-
organizationSlug,
|
|
318
|
-
});
|
|
319
310
|
return { paymentUrl: redirectUrl, checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
|
|
320
311
|
}
|
|
321
312
|
// Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
|
package/package.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -572,6 +572,22 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
572
572
|
: '';
|
|
573
573
|
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
|
|
574
574
|
const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
|
|
575
|
+
|
|
576
|
+
const isAwaitingPayment = r.status === 'awaiting_payment';
|
|
577
|
+
const statusBadge = isAwaitingPayment
|
|
578
|
+
? '<span class="badge bg-warning text-dark ms-1" style="font-size:10px;">En attente de paiement</span>'
|
|
579
|
+
: '<span class="badge bg-secondary ms-1" style="font-size:10px;">En attente de validation</span>';
|
|
580
|
+
|
|
581
|
+
// Buttons differ per status:
|
|
582
|
+
// - pending → "Valider" + "Refuser"
|
|
583
|
+
// - awaiting_payment → "Marquer payée" + "Renouveler lien" + "Refuser"
|
|
584
|
+
const actionButtons = isAwaitingPayment
|
|
585
|
+
? `<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
|
|
586
|
+
<button type="button" class="btn btn-outline-primary btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Renouveler lien</button>
|
|
587
|
+
<button type="button" class="btn btn-success btn-sm" data-action="mark-paid" data-rid="${escapeHtml(String(r.rid || ''))}">Marquer payée</button>`
|
|
588
|
+
: `<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
|
|
589
|
+
<button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>`;
|
|
590
|
+
|
|
575
591
|
const div = document.createElement('div');
|
|
576
592
|
div.className = 'list-group-item onekite-pending-row';
|
|
577
593
|
div.innerHTML = `
|
|
@@ -581,16 +597,15 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
581
597
|
<input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
|
|
582
598
|
</div>
|
|
583
599
|
<div style="min-width:0;">
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
600
|
+
<div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong>${statusBadge}</div>
|
|
601
|
+
<div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
|
|
602
|
+
${userLink ? `<div class="text-muted" style="font-size: 12px;">Réservée par: ${userLink}</div>` : ''}
|
|
603
|
+
<div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
|
|
588
604
|
</div>
|
|
589
605
|
</div>
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
<button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
|
|
606
|
+
<!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
|
|
607
|
+
<div class="d-flex gap-2 flex-wrap justify-content-end">
|
|
608
|
+
${actionButtons}
|
|
594
609
|
</div>
|
|
595
610
|
</div>
|
|
596
611
|
`;
|
|
@@ -862,7 +877,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
862
877
|
const rid = btn.getAttribute('data-rid');
|
|
863
878
|
|
|
864
879
|
// Prevent accidental double-open of modals on mobile/trackpad double taps
|
|
865
|
-
if (rid && (action === 'approve' || action === 'refuse')) {
|
|
880
|
+
if (rid && (action === 'approve' || action === 'refuse' || action === 'mark-paid')) {
|
|
866
881
|
const openKey = `open:${action}:${rid}`;
|
|
867
882
|
if (actionLocks.has(openKey)) return;
|
|
868
883
|
actionLocks.add(openKey);
|
|
@@ -1044,9 +1059,29 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
1044
1059
|
|
|
1045
1060
|
// Remove the row immediately on success for a snappier UX
|
|
1046
1061
|
const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
|
|
1047
|
-
const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action
|
|
1062
|
+
const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action]')) : [btn];
|
|
1048
1063
|
|
|
1049
1064
|
try {
|
|
1065
|
+
if (action === 'mark-paid') {
|
|
1066
|
+
bootbox.confirm(
|
|
1067
|
+
`Marquer la réservation <strong>${escapeHtml(rid)}</strong> comme payée manuellement ?<br><small class="text-muted">Un email de confirmation sera envoyé à l'adhérent.</small>`,
|
|
1068
|
+
async (ok) => {
|
|
1069
|
+
if (!ok) return;
|
|
1070
|
+
await withLock(`mark-paid:${rid}`, rowBtns, async () => {
|
|
1071
|
+
try {
|
|
1072
|
+
await markPaid(rid, { note: 'Paiement validé manuellement depuis ACP' });
|
|
1073
|
+
if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
|
|
1074
|
+
showAlert('success', `Réservation ${rid} marquée comme payée.`);
|
|
1075
|
+
await refreshPending();
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
showAlert('error', `Impossible de marquer comme payée : ${e && e.message ? e.message : e}`);
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1050
1085
|
if (action === 'refuse') {
|
|
1051
1086
|
const html = `
|
|
1052
1087
|
<div class="mb-3">
|