@rmdes/indiekit-endpoint-donation 0.1.0-alpha.1

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stripe webhook receiver.
3
+ * @module controllers/webhook
4
+ *
5
+ * Implementation notes:
6
+ * - express.raw() must be applied at this route so signature verification
7
+ * sees the original bytes (see index.js routesPublic).
8
+ * - Signature verification rejects unsigned / replayed payloads.
9
+ * - Only events listed in HANDLED_EVENTS are processed; everything else
10
+ * returns 200 OK without action (so Stripe doesn't retry).
11
+ */
12
+
13
+ import { verifyStripeSignature } from "../stripe/verify-signature.js";
14
+ import { mapSessionToDonation } from "../stripe/map-session.js";
15
+ import { upsertDonation, markRefunded } from "../storage/donations.js";
16
+ import { triggerRebuild } from "../build/trigger.js";
17
+
18
+ // Locked-in per chardonsbleus decision 2026-05-21:
19
+ // - checkout.session.completed
20
+ // - checkout.session.async_payment_succeeded
21
+ // - invoice.paid (recurring)
22
+ // - charge.refunded (rollback)
23
+ const HANDLED_EVENTS = new Set([
24
+ "checkout.session.completed",
25
+ "checkout.session.async_payment_succeeded",
26
+ "invoice.paid",
27
+ "charge.refunded",
28
+ ]);
29
+
30
+ async function receive(request, response, next) {
31
+ try {
32
+ const { application } = request.app.locals;
33
+ const cfg = application.donationConfig ?? {};
34
+ if (!cfg.stripeWebhookSecret) {
35
+ console.error("[Donation] STRIPE_WEBHOOK_SECRET missing");
36
+ return response.status(503).json({ error: "webhook secret not configured" });
37
+ }
38
+
39
+ const sig = request.headers["stripe-signature"];
40
+ let event;
41
+ try {
42
+ event = verifyStripeSignature({
43
+ payload: request.body, // Buffer from express.raw()
44
+ signature: sig,
45
+ secret: cfg.stripeWebhookSecret,
46
+ });
47
+ } catch (err) {
48
+ console.warn(`[Donation] webhook signature rejected: ${err.message}`);
49
+ return response.status(400).json({ error: "invalid signature" });
50
+ }
51
+
52
+ if (!HANDLED_EVENTS.has(event.type)) {
53
+ // Acknowledge so Stripe doesn't retry; we just don't act on it.
54
+ return response.status(200).json({ received: true, handled: false, type: event.type });
55
+ }
56
+
57
+ if (event.type === "charge.refunded") {
58
+ const stripeId = event.data?.object?.payment_intent ?? event.data?.object?.id;
59
+ if (stripeId) await markRefunded(application, stripeId);
60
+ } else {
61
+ const donation = await mapSessionToDonation(event, cfg);
62
+ if (donation) await upsertDonation(application, donation);
63
+ }
64
+
65
+ await triggerRebuild(application);
66
+ return response.status(200).json({ received: true, handled: true, type: event.type });
67
+ } catch (err) {
68
+ next(err);
69
+ }
70
+ }
71
+
72
+ export const webhookController = { receive };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Campaign cache CRUD.
3
+ * Campaigns are owned by Stripe; this collection is a queryable cache
4
+ * synced from Stripe Products hourly + on-demand.
5
+ * @module storage/campaigns
6
+ */
7
+
8
+ function collection(application) {
9
+ return application.collections?.get("campaignsCache");
10
+ }
11
+
12
+ export async function upsertCampaign(application, campaign) {
13
+ const c = collection(application);
14
+ if (!c) throw new Error("campaignsCache unavailable");
15
+ await c.updateOne(
16
+ { stripe_product_id: campaign.stripe_product_id },
17
+ { $set: { ...campaign, last_synced_at: new Date() } },
18
+ { upsert: true },
19
+ );
20
+ }
21
+
22
+ export async function listCampaigns(application, { active } = {}) {
23
+ const c = collection(application);
24
+ if (!c) return [];
25
+ const query = {};
26
+ if (typeof active === "boolean") query.active = active;
27
+ return c.find(query).sort({ active: -1, display_order: 1, ends: -1 }).toArray();
28
+ }
29
+
30
+ export async function getCampaign(application, stripe_product_id) {
31
+ const c = collection(application);
32
+ if (!c) return null;
33
+ return c.findOne({ stripe_product_id });
34
+ }
35
+
36
+ export async function deactivateCampaign(application, stripe_product_id) {
37
+ const c = collection(application);
38
+ if (!c) return;
39
+ await c.updateOne(
40
+ { stripe_product_id },
41
+ { $set: { active: false, last_synced_at: new Date() } },
42
+ );
43
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Donation CRUD against MongoDB.
3
+ * @module storage/donations
4
+ */
5
+
6
+ function collection(application) {
7
+ return application.collections?.get("donations");
8
+ }
9
+
10
+ /**
11
+ * Insert a donation, upsert by stripe_id so duplicate webhooks are idempotent.
12
+ * @param {object} application - indiekit application locals
13
+ * @param {object} donation - shape per CLAUDE.md
14
+ * @returns {Promise<object>} the upserted donation
15
+ */
16
+ export async function upsertDonation(application, donation) {
17
+ const c = collection(application);
18
+ if (!c) throw new Error("donations collection unavailable");
19
+ const now = new Date();
20
+ const doc = {
21
+ ...donation,
22
+ updated_at: now,
23
+ created_at: donation.created_at ?? now,
24
+ };
25
+ await c.updateOne(
26
+ { stripe_id: donation.stripe_id },
27
+ { $set: doc, $setOnInsert: { _firstSeenAt: now } },
28
+ { upsert: true },
29
+ );
30
+ return c.findOne({ stripe_id: donation.stripe_id });
31
+ }
32
+
33
+ /**
34
+ * List donations with filters.
35
+ * @param {object} application
36
+ * @param {object} [opts]
37
+ * @param {string} [opts.campaign_id]
38
+ * @param {string} [opts.source] - "stripe" | "givewp_migrated" | "manual"
39
+ * @param {boolean} [opts.includeRefunded]
40
+ * @param {number} [opts.limit=50]
41
+ * @param {number} [opts.skip=0]
42
+ */
43
+ export async function listDonations(application, opts = {}) {
44
+ const c = collection(application);
45
+ if (!c) return [];
46
+ const query = {};
47
+ if (opts.campaign_id) query.campaign_id = opts.campaign_id;
48
+ if (opts.source) query.source = opts.source;
49
+ if (!opts.includeRefunded) query.refunded = { $ne: true };
50
+ return c
51
+ .find(query)
52
+ .sort({ date: -1 })
53
+ .skip(opts.skip ?? 0)
54
+ .limit(opts.limit ?? 50)
55
+ .toArray();
56
+ }
57
+
58
+ /** Sum amounts for a campaign (in cents). */
59
+ export async function sumByCampaign(application, campaign_id) {
60
+ const c = collection(application);
61
+ if (!c) return { total: 0, count: 0 };
62
+ const [agg] = await c
63
+ .aggregate([
64
+ { $match: { campaign_id, refunded: { $ne: true } } },
65
+ { $group: { _id: null, total: { $sum: "$amount_cents" }, count: { $sum: 1 } } },
66
+ ])
67
+ .toArray();
68
+ return agg ?? { total: 0, count: 0 };
69
+ }
70
+
71
+ /** Lifetime stats: total raised, unique donors. */
72
+ export async function lifetimeStats(application) {
73
+ const c = collection(application);
74
+ if (!c) return { total: 0, donor_count: 0, donation_count: 0 };
75
+ const [agg] = await c
76
+ .aggregate([
77
+ { $match: { refunded: { $ne: true } } },
78
+ {
79
+ $group: {
80
+ _id: null,
81
+ total: { $sum: "$amount_cents" },
82
+ donation_count: { $sum: 1 },
83
+ donors: { $addToSet: "$donor.donor_id" },
84
+ },
85
+ },
86
+ {
87
+ $project: {
88
+ _id: 0,
89
+ total: 1,
90
+ donation_count: 1,
91
+ donor_count: { $size: "$donors" },
92
+ },
93
+ },
94
+ ])
95
+ .toArray();
96
+ return agg ?? { total: 0, donor_count: 0, donation_count: 0 };
97
+ }
98
+
99
+ export async function getDonation(application, id) {
100
+ const c = collection(application);
101
+ if (!c) return null;
102
+ return c.findOne({ stripe_id: id });
103
+ }
104
+
105
+ export async function updateConsent(application, id, consent_public) {
106
+ const c = collection(application);
107
+ if (!c) throw new Error("donations collection unavailable");
108
+ await c.updateOne(
109
+ { stripe_id: id },
110
+ {
111
+ $set: {
112
+ "donor.consent_public": Boolean(consent_public),
113
+ "donor.display": consent_public ? null : undefined, // don't auto-clear; admin decides
114
+ updated_at: new Date(),
115
+ },
116
+ },
117
+ );
118
+ return getDonation(application, id);
119
+ }
120
+
121
+ export async function markRefunded(application, id, refundedAt = new Date()) {
122
+ const c = collection(application);
123
+ if (!c) throw new Error("donations collection unavailable");
124
+ await c.updateOne(
125
+ { stripe_id: id },
126
+ { $set: { refunded: true, refunded_at: refundedAt, updated_at: new Date() } },
127
+ );
128
+ }
129
+
130
+ export async function removeDonation(application, id) {
131
+ const c = collection(application);
132
+ if (!c) throw new Error("donations collection unavailable");
133
+ await c.deleteOne({ stripe_id: id });
134
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MongoDB index creation for donation collections.
3
+ * Called once on init() after waitForReady gate clears.
4
+ * @module storage/indexes
5
+ */
6
+
7
+ export async function createIndexes(Indiekit) {
8
+ const collections = Indiekit.config.application.collections;
9
+ if (!collections) return;
10
+
11
+ const donations = collections.get("donations");
12
+ if (donations) {
13
+ await donations.createIndex({ stripe_id: 1 }, { unique: true, name: "stripe_id_unique" });
14
+ await donations.createIndex({ campaign_id: 1, date: -1 }, { name: "campaign_date" });
15
+ await donations.createIndex({ "donor.donor_id": 1, date: -1 }, { name: "donor_date" });
16
+ await donations.createIndex({ source: 1, date: -1 }, { name: "source_date" });
17
+ await donations.createIndex({ refunded: 1 }, { sparse: true, name: "refunded_sparse" });
18
+ await donations.createIndex({ date: -1 }, { name: "date_desc" });
19
+ }
20
+
21
+ const campaigns = collections.get("campaignsCache");
22
+ if (campaigns) {
23
+ await campaigns.createIndex({ stripe_product_id: 1 }, { unique: true, name: "product_id_unique" });
24
+ await campaigns.createIndex({ active: 1, display_order: 1 }, { name: "active_order" });
25
+ }
26
+
27
+ const meta = collections.get("donationMeta");
28
+ if (meta) {
29
+ await meta.createIndex({ key: 1 }, { unique: true, name: "key_unique" });
30
+ }
31
+
32
+ console.log("[Donation] indexes ensured on donations, campaignsCache, donationMeta");
33
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Lazy Stripe SDK singleton.
3
+ * Created on first access so the plugin can boot even with no key
4
+ * (admin UI still loads, just shows a "Stripe not configured" banner).
5
+ * @module stripe/client
6
+ */
7
+
8
+ import Stripe from "stripe";
9
+
10
+ let _client = null;
11
+
12
+ export function getStripe(cfg) {
13
+ if (!cfg?.stripeSecretKey) {
14
+ throw new Error("STRIPE_SECRET_KEY not configured");
15
+ }
16
+ if (!_client) {
17
+ _client = new Stripe(cfg.stripeSecretKey, {
18
+ // Pin API version to avoid surprise behavior changes; bump
19
+ // intentionally when reviewing Stripe changelogs.
20
+ apiVersion: "2025-09-30.acacia",
21
+ typescript: false,
22
+ maxNetworkRetries: 2,
23
+ timeout: 15000,
24
+ appInfo: {
25
+ name: "indiekit-endpoint-donation",
26
+ version: "0.1.0",
27
+ },
28
+ });
29
+ }
30
+ return _client;
31
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Map a Stripe Product (+ its primary Payment Link) → campaigns schema.
3
+ * @module stripe/map-product
4
+ */
5
+
6
+ /**
7
+ * @param {object} product - Stripe Product object (with metadata)
8
+ * @param {string|null} paymentLinkUrl - Resolved Payment Link URL
9
+ * @returns {object} campaign document
10
+ */
11
+ export function mapProductToCampaign(product, paymentLinkUrl = null) {
12
+ const m = product.metadata ?? {};
13
+ return {
14
+ stripe_product_id: product.id,
15
+ title: product.name,
16
+ subtitle: m.subtitle ?? "",
17
+ description: product.description ?? "",
18
+ goal_cents: Number(m.goal_cents ?? 0) || 0,
19
+ active: Boolean(product.active),
20
+ started: m.campaign_starts ? new Date(m.campaign_starts) : null,
21
+ ends: m.campaign_ends ? new Date(m.campaign_ends) : null,
22
+ display_order: Number(m.display_order ?? 100),
23
+ stripe_payment_link: paymentLinkUrl,
24
+ };
25
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Map a Stripe webhook event (Checkout Session, Invoice, etc.) →
3
+ * donation schema.
4
+ * @module stripe/map-session
5
+ *
6
+ * Custom field labels expected on the Payment Link (decided 2026-05-21):
7
+ * - "Afficher mon don ?" dropdown: named | anon | hide
8
+ * - "Nom à afficher (optionnel)" text
9
+ * - "Message court (optionnel)" text
10
+ */
11
+
12
+ const CONSENT_KEY = "consent"; // dropdown
13
+ const DISPLAY_KEY = "display_name"; // text
14
+ const MESSAGE_KEY = "message"; // text
15
+
16
+ function customField(session, key) {
17
+ const fields = session?.custom_fields ?? [];
18
+ return fields.find((f) => f.key === key);
19
+ }
20
+
21
+ function readConsent(session) {
22
+ const f = customField(session, CONSENT_KEY);
23
+ const value = f?.dropdown?.value ?? "anon"; // safe default
24
+ // Map the three options to the internal flags.
25
+ // - "named": consent_public=true, display set (from display field)
26
+ // - "anon": consent_public=true, display=null (shown as "Anonyme")
27
+ // - "hide": consent_public=false, display=null (hidden from public list)
28
+ switch (value) {
29
+ case "named":
30
+ return { consent_public: true, named: true };
31
+ case "anon":
32
+ return { consent_public: true, named: false };
33
+ case "hide":
34
+ default:
35
+ return { consent_public: false, named: false };
36
+ }
37
+ }
38
+
39
+ export async function mapSessionToDonation(event, _cfg) {
40
+ const session = event.data?.object;
41
+ if (!session) return null;
42
+
43
+ // Resolve product_id from line items (Stripe webhook embeds them).
44
+ const lineItem = session.line_items?.data?.[0] ?? session.lines?.data?.[0];
45
+ const campaign_id =
46
+ lineItem?.price?.product ??
47
+ session.metadata?.campaign_id ??
48
+ null;
49
+
50
+ if (!campaign_id) {
51
+ console.warn(`[Donation] webhook event ${event.id} had no resolvable campaign_id`);
52
+ return null;
53
+ }
54
+
55
+ const { consent_public, named } = readConsent(session);
56
+ const displayField = customField(session, DISPLAY_KEY)?.text?.value?.trim() || null;
57
+ const messageField = customField(session, MESSAGE_KEY)?.text?.value?.trim() || null;
58
+
59
+ const display = named ? (displayField || "Donateur") : null;
60
+ const message = consent_public ? messageField : null;
61
+
62
+ return {
63
+ stripe_id: session.id ?? session.payment_intent ?? event.id,
64
+ date: new Date((session.created ?? event.created) * 1000),
65
+ amount_cents: session.amount_total ?? session.amount_paid ?? 0,
66
+ currency: (session.currency ?? "eur").toUpperCase(),
67
+ campaign_id,
68
+ recurring: event.type === "invoice.paid",
69
+ donor: {
70
+ donor_id: session.customer ?? `session_${session.id}`,
71
+ display,
72
+ consent_public,
73
+ message,
74
+ },
75
+ source: "stripe",
76
+ refunded: false,
77
+ meta: {
78
+ event_type: event.type,
79
+ payment_status: session.payment_status,
80
+ payment_intent: session.payment_intent,
81
+ mode: session.mode,
82
+ },
83
+ created_at: new Date(),
84
+ updated_at: new Date(),
85
+ };
86
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Stripe webhook signature verification.
3
+ * Wraps stripe.webhooks.constructEvent so we have a single place to
4
+ * adjust signing logic + log rejections.
5
+ * @module stripe/verify-signature
6
+ */
7
+
8
+ import Stripe from "stripe";
9
+
10
+ export function verifyStripeSignature({ payload, signature, secret }) {
11
+ if (!signature) throw new Error("missing Stripe-Signature header");
12
+ if (!secret) throw new Error("missing webhook secret");
13
+ // Stripe SDK's static helper doesn't require an instance.
14
+ return Stripe.webhooks.constructEvent(payload, signature, secret);
15
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Hourly background sync of Stripe Products → campaignsCache.
3
+ * Stripe is the source of truth; this collection is just a queryable mirror.
4
+ * @module sync/scheduler
5
+ */
6
+
7
+ import { getStripe } from "../stripe/client.js";
8
+ import { mapProductToCampaign } from "../stripe/map-product.js";
9
+ import { upsertCampaign, listCampaigns, deactivateCampaign } from "../storage/campaigns.js";
10
+
11
+ let _interval = null;
12
+ let _running = false;
13
+
14
+ export async function runFullSync(Indiekit) {
15
+ if (_running) return { skipped: true };
16
+ _running = true;
17
+ try {
18
+ const application = Indiekit.config?.application ?? Indiekit;
19
+ const cfg = application.donationConfig;
20
+ const stripe = getStripe(cfg);
21
+
22
+ // List all products + their default prices + payment links.
23
+ // For now: just products + metadata. Payment Link resolution can come later
24
+ // when we add `lib/stripe/payment-links.js`.
25
+ const products = await stripe.products.list({ active: true, limit: 100 });
26
+
27
+ let upserted = 0;
28
+ for (const product of products.data) {
29
+ const campaign = mapProductToCampaign(product, null);
30
+ await upsertCampaign(application, campaign);
31
+ upserted++;
32
+ }
33
+
34
+ // Mark previously-cached campaigns no longer in Stripe as inactive.
35
+ const seenIds = new Set(products.data.map((p) => p.id));
36
+ const cached = await listCampaigns(application);
37
+ for (const c of cached) {
38
+ if (!seenIds.has(c.stripe_product_id) && c.active) {
39
+ await deactivateCampaign(application, c.stripe_product_id);
40
+ }
41
+ }
42
+
43
+ console.log(`[Donation] sync ok: upserted ${upserted} campaigns`);
44
+ return { upserted, deactivated: cached.length - upserted };
45
+ } catch (err) {
46
+ console.error(`[Donation] sync failed: ${err.message}`);
47
+ return { error: err.message };
48
+ } finally {
49
+ _running = false;
50
+ }
51
+ }
52
+
53
+ export function startStripeSync(Indiekit, options) {
54
+ // Fire once on startup, then every hour.
55
+ runFullSync(Indiekit).catch((e) => console.error("[Donation] initial sync error:", e));
56
+ _interval = setInterval(
57
+ () => runFullSync(Indiekit).catch((e) => console.error("[Donation] periodic sync error:", e)),
58
+ options.syncInterval ?? 3600000,
59
+ );
60
+ }
61
+
62
+ export function stopStripeSync() {
63
+ if (_interval) clearInterval(_interval);
64
+ _interval = null;
65
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "donation": {
3
+ "title": "Donations",
4
+ "description": "Manage Stripe-backed donations and campaigns",
5
+ "never": "Never",
6
+ "save": "Save",
7
+ "cancel": "Cancel",
8
+ "edit": "Edit",
9
+ "delete": "Delete",
10
+ "confirm": "Are you sure?",
11
+
12
+ "stats": {
13
+ "title": "Overview",
14
+ "lifetime_total": "Lifetime total",
15
+ "donor_count": "Unique donors",
16
+ "donation_count": "Donations",
17
+ "active_campaigns": "Active campaigns",
18
+ "last_sync": "Last Stripe sync"
19
+ },
20
+
21
+ "actions": {
22
+ "title": "Actions",
23
+ "sync_stripe": "Sync from Stripe",
24
+ "trigger_rebuild": "Trigger site rebuild",
25
+ "add_manual": "Add offline donation"
26
+ },
27
+
28
+ "donations": {
29
+ "title": "Donations",
30
+ "empty": "No donations yet."
31
+ },
32
+
33
+ "campaigns": {
34
+ "title": "Campaigns",
35
+ "active": "Active",
36
+ "archived": "Archived",
37
+ "synced_from_stripe": "Synced from Stripe",
38
+ "raised": "Raised",
39
+ "goal": "Goal",
40
+ "donors": "Donors",
41
+ "hidden_on_public": "Hide on public transparence page"
42
+ },
43
+
44
+ "manual": {
45
+ "title": "Add an offline donation",
46
+ "action": "Add donation",
47
+ "fields": {
48
+ "amount": "Amount (€)",
49
+ "campaign": "Campaign",
50
+ "date": "Date received",
51
+ "donor_display": "Donor name (or leave empty for anonymous)",
52
+ "message": "Donor message",
53
+ "consent_public": "Show donor name publicly",
54
+ "note": "Internal note (not shown publicly)"
55
+ },
56
+ "error": {
57
+ "title": "Could not add donation",
58
+ "required": "Amount, campaign, and date are required."
59
+ }
60
+ },
61
+
62
+ "consent": {
63
+ "public": "Public",
64
+ "anonymous": "Anonymous",
65
+ "hidden": "Hidden",
66
+ "toggle": "Toggle public display"
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "donation": {
3
+ "title": "Dons",
4
+ "description": "Gérer les dons et campagnes Stripe",
5
+ "never": "Jamais",
6
+ "save": "Enregistrer",
7
+ "cancel": "Annuler",
8
+ "edit": "Modifier",
9
+ "delete": "Supprimer",
10
+ "confirm": "Êtes-vous sûr ?",
11
+
12
+ "stats": {
13
+ "title": "Aperçu",
14
+ "lifetime_total": "Total collecté à vie",
15
+ "donor_count": "Donateurs uniques",
16
+ "donation_count": "Dons",
17
+ "active_campaigns": "Campagnes actives",
18
+ "last_sync": "Dernière synchronisation Stripe"
19
+ },
20
+
21
+ "actions": {
22
+ "title": "Actions",
23
+ "sync_stripe": "Synchroniser depuis Stripe",
24
+ "trigger_rebuild": "Déclencher la reconstruction du site",
25
+ "add_manual": "Ajouter un don hors-ligne"
26
+ },
27
+
28
+ "donations": {
29
+ "title": "Dons",
30
+ "empty": "Aucun don pour le moment."
31
+ },
32
+
33
+ "campaigns": {
34
+ "title": "Campagnes",
35
+ "active": "Actives",
36
+ "archived": "Archivées",
37
+ "synced_from_stripe": "Synchronisées depuis Stripe",
38
+ "raised": "Collecté",
39
+ "goal": "Objectif",
40
+ "donors": "Donateurs",
41
+ "hidden_on_public": "Masquer sur la page de transparence publique"
42
+ },
43
+
44
+ "manual": {
45
+ "title": "Ajouter un don hors-ligne",
46
+ "action": "Ajouter un don",
47
+ "fields": {
48
+ "amount": "Montant (€)",
49
+ "campaign": "Campagne",
50
+ "date": "Date de réception",
51
+ "donor_display": "Nom du donateur (vide = anonyme)",
52
+ "message": "Message du donateur",
53
+ "consent_public": "Afficher le nom publiquement",
54
+ "note": "Note interne (non publique)"
55
+ },
56
+ "error": {
57
+ "title": "Impossible d'ajouter le don",
58
+ "required": "Le montant, la campagne et la date sont requis."
59
+ }
60
+ },
61
+
62
+ "consent": {
63
+ "public": "Public",
64
+ "anonymous": "Anonyme",
65
+ "hidden": "Masqué",
66
+ "toggle": "Basculer l'affichage public"
67
+ }
68
+ }
69
+ }