@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.
- package/README.md +87 -0
- package/index.js +147 -0
- package/lib/build/trigger.js +35 -0
- package/lib/controllers/campaigns.js +61 -0
- package/lib/controllers/consent.js +22 -0
- package/lib/controllers/dashboard.js +42 -0
- package/lib/controllers/donations.js +70 -0
- package/lib/controllers/manual.js +61 -0
- package/lib/controllers/stats.js +82 -0
- package/lib/controllers/sync.js +25 -0
- package/lib/controllers/webhook.js +72 -0
- package/lib/storage/campaigns.js +43 -0
- package/lib/storage/donations.js +134 -0
- package/lib/storage/indexes.js +33 -0
- package/lib/stripe/client.js +31 -0
- package/lib/stripe/map-product.js +25 -0
- package/lib/stripe/map-session.js +86 -0
- package/lib/stripe/verify-signature.js +15 -0
- package/lib/sync/scheduler.js +65 -0
- package/locales/en.json +69 -0
- package/locales/fr.json +69 -0
- package/package.json +48 -0
- package/views/donation-campaign-edit.njk +33 -0
- package/views/donation-campaigns.njk +24 -0
- package/views/donation-dashboard.njk +100 -0
- package/views/donation-donation-edit.njk +37 -0
- package/views/donation-donations.njk +29 -0
- package/views/donation-manual-error.njk +8 -0
- package/views/donation-manual.njk +45 -0
- package/views/donation-not-found.njk +8 -0
- package/views/layouts/donation.njk +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @rmdes/indiekit-endpoint-donation
|
|
2
|
+
|
|
3
|
+
Stripe-backed donation endpoint for Indiekit. Treats Stripe Products as
|
|
4
|
+
campaigns, Stripe Checkout Sessions / PaymentIntents as donations,
|
|
5
|
+
captures donor consent via Stripe Payment Link custom fields, and feeds
|
|
6
|
+
a static Eleventy site with live JSON + rebuild triggers.
|
|
7
|
+
|
|
8
|
+
## Status
|
|
9
|
+
|
|
10
|
+
**Alpha — scaffold only.** Routes and views are in place; webhook
|
|
11
|
+
verification, sync scheduling, and storage are implemented. Tested
|
|
12
|
+
end-to-end is still TODO. Do not connect a live Stripe key against a
|
|
13
|
+
production webhook URL yet.
|
|
14
|
+
|
|
15
|
+
See [CLAUDE.md](./CLAUDE.md) for architecture, data model, and routes.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @rmdes/indiekit-endpoint-donation stripe
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// indiekit.config.js
|
|
25
|
+
import DonationEndpoint from "@rmdes/indiekit-endpoint-donation";
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
plugins: [
|
|
29
|
+
"@indiekit/endpoint-auth",
|
|
30
|
+
new DonationEndpoint({
|
|
31
|
+
mountPath: "/donation",
|
|
32
|
+
// siteDir + rebuildTrigger usually picked up from env vars
|
|
33
|
+
}),
|
|
34
|
+
// ...
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Environment
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
STRIPE_SECRET_KEY=sk_live_…
|
|
43
|
+
STRIPE_WEBHOOK_SECRET=whsec_…
|
|
44
|
+
INDIEKIT_DONATION_SITE_DIR=/app/data/eleventy-site
|
|
45
|
+
INDIEKIT_DONATION_REBUILD_TRIGGER=/app/data/eleventy-site/.rebuild-trigger
|
|
46
|
+
INDIEKIT_DONATION_CURRENCY=EUR
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Stripe setup (one-time)
|
|
50
|
+
|
|
51
|
+
1. Create a **Product** per campaign in Stripe Dashboard. Set product
|
|
52
|
+
metadata: `goal_cents`, `subtitle`, `campaign_starts`, `campaign_ends`,
|
|
53
|
+
`display_order`.
|
|
54
|
+
2. Create a **Payment Link** for each Product. Add 3 custom fields:
|
|
55
|
+
- `consent` — dropdown: "Oui, avec mon nom" / "Oui, anonymement" /
|
|
56
|
+
"Non, garder privé" (required)
|
|
57
|
+
- `display_name` — text, optional (≤60 chars)
|
|
58
|
+
- `message` — text, optional (≤200 chars)
|
|
59
|
+
3. Create a **Webhook endpoint** in Stripe pointing at
|
|
60
|
+
`https://yoursite.example/donation/webhook`. Subscribe to:
|
|
61
|
+
- `checkout.session.completed`
|
|
62
|
+
- `checkout.session.async_payment_succeeded`
|
|
63
|
+
- `invoice.paid`
|
|
64
|
+
- `charge.refunded`
|
|
65
|
+
4. Copy the webhook signing secret (`whsec_…`) into `STRIPE_WEBHOOK_SECRET`.
|
|
66
|
+
|
|
67
|
+
## Admin UI
|
|
68
|
+
|
|
69
|
+
- `/donation` — dashboard (lifetime stats, recent donations, active campaigns)
|
|
70
|
+
- `/donation/donations` — full donation list with filters
|
|
71
|
+
- `/donation/donations/:id` — edit a single donation (display name, message, consent)
|
|
72
|
+
- `/donation/campaigns` — cached campaign list (synced from Stripe)
|
|
73
|
+
- `/donation/manual` — record an offline donation (cash, bank transfer)
|
|
74
|
+
- `POST /donation/sync` — trigger an immediate Stripe Product sync
|
|
75
|
+
- `POST /donation/rebuild` — touch the rebuild trigger file
|
|
76
|
+
|
|
77
|
+
## Public API
|
|
78
|
+
|
|
79
|
+
- `GET /donation/stats.json` — full live state (campaigns + recent
|
|
80
|
+
donations, donor names hidden if consent is false). Cache-Control: 60s.
|
|
81
|
+
- `GET /donation/stats/:campaignId.json` — single-campaign totals.
|
|
82
|
+
- `POST /donation/webhook` — Stripe webhook receiver (rejects without
|
|
83
|
+
signature).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { waitForReady } from "@rmdes/indiekit-startup-gate";
|
|
6
|
+
|
|
7
|
+
import { dashboardController } from "./lib/controllers/dashboard.js";
|
|
8
|
+
import { donationsController } from "./lib/controllers/donations.js";
|
|
9
|
+
import { campaignsController } from "./lib/controllers/campaigns.js";
|
|
10
|
+
import { manualController } from "./lib/controllers/manual.js";
|
|
11
|
+
import { syncController } from "./lib/controllers/sync.js";
|
|
12
|
+
import { consentController } from "./lib/controllers/consent.js";
|
|
13
|
+
import { webhookController } from "./lib/controllers/webhook.js";
|
|
14
|
+
import { statsController } from "./lib/controllers/stats.js";
|
|
15
|
+
|
|
16
|
+
import { createIndexes } from "./lib/storage/indexes.js";
|
|
17
|
+
import { startStripeSync, stopStripeSync } from "./lib/sync/scheduler.js";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
const defaults = {
|
|
22
|
+
mountPath: "/donation",
|
|
23
|
+
// Hourly background sync of Stripe Products → campaigns cache.
|
|
24
|
+
syncInterval: 3600000,
|
|
25
|
+
// Site directory for rebuild trigger (file touch).
|
|
26
|
+
siteDir: process.env.INDIEKIT_DONATION_SITE_DIR ?? "/app/data/eleventy-site",
|
|
27
|
+
rebuildTrigger: process.env.INDIEKIT_DONATION_REBUILD_TRIGGER
|
|
28
|
+
?? "/app/data/eleventy-site/.rebuild-trigger",
|
|
29
|
+
currency: process.env.INDIEKIT_DONATION_CURRENCY ?? "EUR",
|
|
30
|
+
// Stripe credentials from env. Required at runtime; checked on init.
|
|
31
|
+
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
|
32
|
+
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default class DonationEndpoint {
|
|
36
|
+
name = "Donation endpoint";
|
|
37
|
+
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.options = { ...defaults, ...options };
|
|
40
|
+
this.mountPath = this.options.mountPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get localesDirectory() {
|
|
44
|
+
return path.join(__dirname, "locales");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get viewsDirectory() {
|
|
48
|
+
return path.join(__dirname, "views");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Adds "Dons" to the admin sidebar.
|
|
52
|
+
get navigationItems() {
|
|
53
|
+
return {
|
|
54
|
+
href: this.options.mountPath,
|
|
55
|
+
text: "donation.title",
|
|
56
|
+
requiresDatabase: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Adds a quick-action tile on the indiekit dashboard.
|
|
61
|
+
get shortcutItems() {
|
|
62
|
+
return {
|
|
63
|
+
url: path.join(this.options.mountPath, "manual"),
|
|
64
|
+
name: "donation.manual.action",
|
|
65
|
+
iconName: "createPost",
|
|
66
|
+
requiresDatabase: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Authenticated admin routes — wrapped by indiekit's session middleware.
|
|
71
|
+
get routes() {
|
|
72
|
+
const router = express.Router();
|
|
73
|
+
|
|
74
|
+
router.get("/", dashboardController.get);
|
|
75
|
+
|
|
76
|
+
router.get("/donations", donationsController.list);
|
|
77
|
+
router.get("/donations/:id", donationsController.detail);
|
|
78
|
+
router.post("/donations/:id/edit", donationsController.update);
|
|
79
|
+
router.post("/donations/:id/consent", consentController.toggle);
|
|
80
|
+
router.post("/donations/:id/delete", donationsController.remove);
|
|
81
|
+
|
|
82
|
+
router.get("/campaigns", campaignsController.list);
|
|
83
|
+
router.get("/campaigns/:id", campaignsController.detail);
|
|
84
|
+
router.post("/campaigns/:id/edit", campaignsController.update);
|
|
85
|
+
|
|
86
|
+
router.get("/manual", manualController.form);
|
|
87
|
+
router.post("/manual", manualController.create);
|
|
88
|
+
|
|
89
|
+
router.post("/sync", syncController.runOnce);
|
|
90
|
+
router.post("/rebuild", syncController.triggerRebuild);
|
|
91
|
+
|
|
92
|
+
return router;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Public routes — Stripe webhook + JSON stats consumed by the static site.
|
|
96
|
+
get routesPublic() {
|
|
97
|
+
const router = express.Router();
|
|
98
|
+
|
|
99
|
+
// Stripe sends raw body; mount express.raw() at this route only so
|
|
100
|
+
// signature verification works. Indiekit's global JSON parser would
|
|
101
|
+
// otherwise consume the body before we can verify the signature.
|
|
102
|
+
router.post(
|
|
103
|
+
"/webhook",
|
|
104
|
+
express.raw({ type: "application/json" }),
|
|
105
|
+
webhookController.receive,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
router.get("/stats.json", statsController.json);
|
|
109
|
+
router.get("/stats/:campaignId.json", statsController.byCampaign);
|
|
110
|
+
|
|
111
|
+
return router;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
init(Indiekit) {
|
|
115
|
+
Indiekit.addEndpoint(this);
|
|
116
|
+
|
|
117
|
+
Indiekit.addCollection("donations");
|
|
118
|
+
Indiekit.addCollection("campaignsCache");
|
|
119
|
+
Indiekit.addCollection("donationMeta");
|
|
120
|
+
|
|
121
|
+
// Make config + DB accessor available to controllers via application locals.
|
|
122
|
+
Indiekit.config.application.donationConfig = this.options;
|
|
123
|
+
Indiekit.config.application.donationEndpoint = this.mountPath;
|
|
124
|
+
Indiekit.config.application.getDonationDb = () => Indiekit.database;
|
|
125
|
+
|
|
126
|
+
// Background Stripe Product sync — only starts after Mongo is connected.
|
|
127
|
+
if (Indiekit.config.application.mongodbUrl && this.options.stripeSecretKey) {
|
|
128
|
+
this._stopGate = waitForReady(
|
|
129
|
+
async () => {
|
|
130
|
+
await createIndexes(Indiekit);
|
|
131
|
+
startStripeSync(Indiekit, this.options);
|
|
132
|
+
},
|
|
133
|
+
{ label: "Donation" },
|
|
134
|
+
);
|
|
135
|
+
} else if (!this.options.stripeSecretKey) {
|
|
136
|
+
console.warn(
|
|
137
|
+
"[Donation] STRIPE_SECRET_KEY not set — admin UI will load but " +
|
|
138
|
+
"campaigns sync and webhook verification are disabled.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
destroy() {
|
|
144
|
+
this._stopGate?.();
|
|
145
|
+
stopStripeSync();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eleventy rebuild trigger via file touch.
|
|
3
|
+
* The Eleventy `--watch` process picks up the touch and rebuilds in ~0.6s.
|
|
4
|
+
* Safe to call multiple times in quick succession — chokidar debounces.
|
|
5
|
+
* @module build/trigger
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { utimes, open, mkdir } from "node:fs/promises";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
export async function triggerRebuild(application) {
|
|
12
|
+
const triggerPath = application.donationConfig?.rebuildTrigger;
|
|
13
|
+
if (!triggerPath) {
|
|
14
|
+
console.warn("[Donation] no rebuildTrigger configured; skipping touch");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const now = new Date();
|
|
18
|
+
try {
|
|
19
|
+
await utimes(triggerPath, now, now);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === "ENOENT") {
|
|
22
|
+
// Create the file (and parent dir) on first run.
|
|
23
|
+
try {
|
|
24
|
+
await mkdir(dirname(triggerPath), { recursive: true });
|
|
25
|
+
const fh = await open(triggerPath, "w");
|
|
26
|
+
await fh.close();
|
|
27
|
+
await utimes(triggerPath, now, now);
|
|
28
|
+
} catch (createErr) {
|
|
29
|
+
console.error(`[Donation] rebuild trigger create failed: ${createErr.message}`);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
console.error(`[Donation] rebuild trigger touch failed: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaign list + detail + manual override.
|
|
3
|
+
* Campaigns are authoritatively defined in Stripe Products — this admin
|
|
4
|
+
* UI is read-mostly. The only writable fields are local overrides
|
|
5
|
+
* (e.g. a "do not display on /transparence/" flag if you want to hide
|
|
6
|
+
* a campaign from the public page without archiving it in Stripe).
|
|
7
|
+
* @module controllers/campaigns
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { listCampaigns, getCampaign, upsertCampaign } from "../storage/campaigns.js";
|
|
11
|
+
import { sumByCampaign } from "../storage/donations.js";
|
|
12
|
+
|
|
13
|
+
async function list(request, response, next) {
|
|
14
|
+
try {
|
|
15
|
+
const { application } = request.app.locals;
|
|
16
|
+
const campaigns = await listCampaigns(application);
|
|
17
|
+
// Augment each campaign with its current donation total.
|
|
18
|
+
const augmented = await Promise.all(
|
|
19
|
+
campaigns.map(async (c) => {
|
|
20
|
+
const { total, count } = await sumByCampaign(application, c.stripe_product_id);
|
|
21
|
+
return { ...c, raised_cents: total, donor_count: count };
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
response.render("donation-campaigns", {
|
|
25
|
+
title: request.__("donation.campaigns.title"),
|
|
26
|
+
campaigns: augmented,
|
|
27
|
+
baseUrl: application.donationEndpoint,
|
|
28
|
+
});
|
|
29
|
+
} catch (err) { next(err); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function detail(request, response, next) {
|
|
33
|
+
try {
|
|
34
|
+
const { application } = request.app.locals;
|
|
35
|
+
const c = await getCampaign(application, request.params.id);
|
|
36
|
+
if (!c) return response.status(404).render("donation-not-found");
|
|
37
|
+
const { total, count } = await sumByCampaign(application, c.stripe_product_id);
|
|
38
|
+
response.render("donation-campaign-edit", {
|
|
39
|
+
title: c.title,
|
|
40
|
+
campaign: { ...c, raised_cents: total, donor_count: count },
|
|
41
|
+
baseUrl: application.donationEndpoint,
|
|
42
|
+
});
|
|
43
|
+
} catch (err) { next(err); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function update(request, response, next) {
|
|
47
|
+
try {
|
|
48
|
+
const { application } = request.app.locals;
|
|
49
|
+
const c = await getCampaign(application, request.params.id);
|
|
50
|
+
if (!c) return response.status(404).render("donation-not-found");
|
|
51
|
+
// Only local overrides editable; everything else syncs from Stripe.
|
|
52
|
+
await upsertCampaign(application, {
|
|
53
|
+
...c,
|
|
54
|
+
hidden_on_public: request.body.hidden_on_public === "on",
|
|
55
|
+
display_order: Number(request.body.display_order ?? c.display_order ?? 100),
|
|
56
|
+
});
|
|
57
|
+
response.redirect(`${application.donationEndpoint}/campaigns/${request.params.id}`);
|
|
58
|
+
} catch (err) { next(err); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const campaignsController = { list, detail, update };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle a donation's public-display consent flag.
|
|
3
|
+
* Used both by the admin UI (manual toggle from /donations/:id) and
|
|
4
|
+
* eventually by the donor self-service link (v2).
|
|
5
|
+
* @module controllers/consent
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getDonation, updateConsent } from "../storage/donations.js";
|
|
9
|
+
import { triggerRebuild } from "../build/trigger.js";
|
|
10
|
+
|
|
11
|
+
async function toggle(request, response, next) {
|
|
12
|
+
try {
|
|
13
|
+
const { application } = request.app.locals;
|
|
14
|
+
const current = await getDonation(application, request.params.id);
|
|
15
|
+
if (!current) return response.status(404).render("donation-not-found");
|
|
16
|
+
await updateConsent(application, request.params.id, !current.donor?.consent_public);
|
|
17
|
+
await triggerRebuild(application);
|
|
18
|
+
response.redirect(`${application.donationEndpoint}/donations/${request.params.id}`);
|
|
19
|
+
} catch (err) { next(err); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const consentController = { toggle };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Donation admin dashboard.
|
|
3
|
+
* Shows lifetime stats, recent donations, active campaigns, and quick actions.
|
|
4
|
+
* @module controllers/dashboard
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { lifetimeStats, listDonations } from "../storage/donations.js";
|
|
8
|
+
import { listCampaigns } from "../storage/campaigns.js";
|
|
9
|
+
|
|
10
|
+
async function get(request, response, next) {
|
|
11
|
+
const { application } = request.app.locals;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const [stats, recent, campaigns] = await Promise.all([
|
|
15
|
+
lifetimeStats(application),
|
|
16
|
+
listDonations(application, { limit: 10 }),
|
|
17
|
+
listCampaigns(application),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const activeCampaigns = campaigns.filter((c) => c.active);
|
|
21
|
+
const archivedCampaigns = campaigns.filter((c) => !c.active);
|
|
22
|
+
|
|
23
|
+
response.render("donation-dashboard", {
|
|
24
|
+
title: request.__("donation.title"),
|
|
25
|
+
stats: {
|
|
26
|
+
lifetime_total_cents: stats.total ?? 0,
|
|
27
|
+
donation_count: stats.donation_count ?? 0,
|
|
28
|
+
donor_count: stats.donor_count ?? 0,
|
|
29
|
+
active_campaign_count: activeCampaigns.length,
|
|
30
|
+
},
|
|
31
|
+
recent,
|
|
32
|
+
activeCampaigns,
|
|
33
|
+
archivedCampaigns,
|
|
34
|
+
currency: application.donationConfig?.currency ?? "EUR",
|
|
35
|
+
baseUrl: application.donationEndpoint ?? "/donation",
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
next(err);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const dashboardController = { get };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Donations list + detail + edit + delete.
|
|
3
|
+
* @module controllers/donations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { listDonations, getDonation, upsertDonation, removeDonation } from "../storage/donations.js";
|
|
7
|
+
|
|
8
|
+
async function list(request, response, next) {
|
|
9
|
+
try {
|
|
10
|
+
const { application } = request.app.locals;
|
|
11
|
+
const { campaign_id, source, page = 1, limit = 50 } = request.query;
|
|
12
|
+
const items = await listDonations(application, {
|
|
13
|
+
campaign_id,
|
|
14
|
+
source,
|
|
15
|
+
limit: Number(limit),
|
|
16
|
+
skip: (Number(page) - 1) * Number(limit),
|
|
17
|
+
includeRefunded: true,
|
|
18
|
+
});
|
|
19
|
+
response.render("donation-donations", {
|
|
20
|
+
title: request.__("donation.donations.title"),
|
|
21
|
+
donations: items,
|
|
22
|
+
filters: { campaign_id, source, page, limit },
|
|
23
|
+
baseUrl: application.donationEndpoint,
|
|
24
|
+
});
|
|
25
|
+
} catch (err) { next(err); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function detail(request, response, next) {
|
|
29
|
+
try {
|
|
30
|
+
const { application } = request.app.locals;
|
|
31
|
+
const donation = await getDonation(application, request.params.id);
|
|
32
|
+
if (!donation) return response.status(404).render("donation-not-found");
|
|
33
|
+
response.render("donation-donation-edit", {
|
|
34
|
+
title: donation.stripe_id,
|
|
35
|
+
donation,
|
|
36
|
+
baseUrl: application.donationEndpoint,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) { next(err); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function update(request, response, next) {
|
|
42
|
+
// TODO: validate inputs, only allow editing display name / message / consent flag.
|
|
43
|
+
// Stripe data (amount, date, campaign, donor_id) is canonical from Stripe — not editable.
|
|
44
|
+
try {
|
|
45
|
+
const { application } = request.app.locals;
|
|
46
|
+
const current = await getDonation(application, request.params.id);
|
|
47
|
+
if (!current) return response.status(404).render("donation-not-found");
|
|
48
|
+
const next_ = {
|
|
49
|
+
...current,
|
|
50
|
+
donor: {
|
|
51
|
+
...current.donor,
|
|
52
|
+
display: request.body.display ?? current.donor.display,
|
|
53
|
+
message: request.body.message ?? current.donor.message,
|
|
54
|
+
consent_public: request.body.consent_public === "on",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
await upsertDonation(application, next_);
|
|
58
|
+
response.redirect(`${application.donationEndpoint}/donations/${request.params.id}`);
|
|
59
|
+
} catch (err) { next(err); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function remove(request, response, next) {
|
|
63
|
+
try {
|
|
64
|
+
const { application } = request.app.locals;
|
|
65
|
+
await removeDonation(application, request.params.id);
|
|
66
|
+
response.redirect(`${application.donationEndpoint}/donations`);
|
|
67
|
+
} catch (err) { next(err); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const donationsController = { list, detail, update, remove };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual offline-donation entry — fills the gap for cash/bank-transfer
|
|
3
|
+
* gifts that never went through Stripe.
|
|
4
|
+
* @module controllers/manual
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { upsertDonation } from "../storage/donations.js";
|
|
8
|
+
import { listCampaigns } from "../storage/campaigns.js";
|
|
9
|
+
import { triggerRebuild } from "../build/trigger.js";
|
|
10
|
+
|
|
11
|
+
async function form(request, response, next) {
|
|
12
|
+
try {
|
|
13
|
+
const { application } = request.app.locals;
|
|
14
|
+
const campaigns = await listCampaigns(application, { active: true });
|
|
15
|
+
response.render("donation-manual", {
|
|
16
|
+
title: request.__("donation.manual.title"),
|
|
17
|
+
campaigns,
|
|
18
|
+
baseUrl: application.donationEndpoint,
|
|
19
|
+
});
|
|
20
|
+
} catch (err) { next(err); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function create(request, response, next) {
|
|
24
|
+
try {
|
|
25
|
+
const { application } = request.app.locals;
|
|
26
|
+
const { amount, currency, campaign_id, donor_display, message, consent_public, date } = request.body;
|
|
27
|
+
|
|
28
|
+
const amount_cents = Math.round(Number(amount) * 100);
|
|
29
|
+
if (!amount_cents || !campaign_id || !date) {
|
|
30
|
+
return response.status(400).render("donation-manual-error", {
|
|
31
|
+
title: request.__("donation.manual.error.title"),
|
|
32
|
+
message: request.__("donation.manual.error.required"),
|
|
33
|
+
baseUrl: application.donationEndpoint,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const id = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
38
|
+
const donation = {
|
|
39
|
+
stripe_id: id,
|
|
40
|
+
date: new Date(date),
|
|
41
|
+
amount_cents,
|
|
42
|
+
currency: currency || application.donationConfig?.currency || "EUR",
|
|
43
|
+
campaign_id,
|
|
44
|
+
recurring: false,
|
|
45
|
+
donor: {
|
|
46
|
+
donor_id: `manual_${id}`,
|
|
47
|
+
display: donor_display?.trim() || null,
|
|
48
|
+
consent_public: consent_public === "on",
|
|
49
|
+
message: message?.trim() || null,
|
|
50
|
+
},
|
|
51
|
+
source: "manual",
|
|
52
|
+
refunded: false,
|
|
53
|
+
meta: { entered_by: request.session?.me ?? "unknown", note: request.body.note ?? null },
|
|
54
|
+
};
|
|
55
|
+
await upsertDonation(application, donation);
|
|
56
|
+
await triggerRebuild(application);
|
|
57
|
+
response.redirect(application.donationEndpoint);
|
|
58
|
+
} catch (err) { next(err); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const manualController = { form, create };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public JSON stats endpoints.
|
|
3
|
+
* Consumed by the static Eleventy site at build time and (optionally)
|
|
4
|
+
* by client-side fetches for live counter updates without rebuild.
|
|
5
|
+
* @module controllers/stats
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { lifetimeStats, listDonations, sumByCampaign } from "../storage/donations.js";
|
|
9
|
+
import { listCampaigns } from "../storage/campaigns.js";
|
|
10
|
+
|
|
11
|
+
async function json(request, response, next) {
|
|
12
|
+
try {
|
|
13
|
+
const { application } = request.app.locals;
|
|
14
|
+
const [stats, campaigns, donations] = await Promise.all([
|
|
15
|
+
lifetimeStats(application),
|
|
16
|
+
listCampaigns(application),
|
|
17
|
+
listDonations(application, { limit: 100 }),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// Augment campaigns with their current totals.
|
|
21
|
+
const enriched = await Promise.all(
|
|
22
|
+
campaigns.map(async (c) => {
|
|
23
|
+
const { total, count } = await sumByCampaign(application, c.stripe_product_id);
|
|
24
|
+
return {
|
|
25
|
+
id: c.stripe_product_id,
|
|
26
|
+
title: c.title,
|
|
27
|
+
subtitle: c.subtitle,
|
|
28
|
+
description: c.description,
|
|
29
|
+
goal_cents: c.goal_cents,
|
|
30
|
+
raised_cents: total,
|
|
31
|
+
donor_count: count,
|
|
32
|
+
active: c.active,
|
|
33
|
+
started: c.started,
|
|
34
|
+
ends: c.ends,
|
|
35
|
+
stripe_payment_link: c.stripe_payment_link,
|
|
36
|
+
};
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Hide donor identifying info unless donor explicitly consented.
|
|
41
|
+
const sanitized = donations.map((d) => ({
|
|
42
|
+
id: d.stripe_id,
|
|
43
|
+
date: d.date,
|
|
44
|
+
amount_cents: d.amount_cents,
|
|
45
|
+
currency: d.currency,
|
|
46
|
+
campaign_id: d.campaign_id,
|
|
47
|
+
recurring: d.recurring,
|
|
48
|
+
donor: {
|
|
49
|
+
display: d.donor?.consent_public ? d.donor?.display : null,
|
|
50
|
+
consent_public: Boolean(d.donor?.consent_public),
|
|
51
|
+
message: d.donor?.consent_public ? d.donor?.message : null,
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
response.setHeader("Cache-Control", "public, max-age=60");
|
|
56
|
+
response.json({
|
|
57
|
+
updated_at: new Date().toISOString(),
|
|
58
|
+
currency: application.donationConfig?.currency ?? "EUR",
|
|
59
|
+
lifetime_total_cents: stats.total ?? 0,
|
|
60
|
+
lifetime_donor_count: stats.donor_count ?? 0,
|
|
61
|
+
donation_count: stats.donation_count ?? 0,
|
|
62
|
+
campaigns: enriched,
|
|
63
|
+
donations: sanitized,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) { next(err); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function byCampaign(request, response, next) {
|
|
69
|
+
try {
|
|
70
|
+
const { application } = request.app.locals;
|
|
71
|
+
const { total, count } = await sumByCampaign(application, request.params.campaignId);
|
|
72
|
+
response.setHeader("Cache-Control", "public, max-age=60");
|
|
73
|
+
response.json({
|
|
74
|
+
campaign_id: request.params.campaignId,
|
|
75
|
+
raised_cents: total,
|
|
76
|
+
donor_count: count,
|
|
77
|
+
updated_at: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
} catch (err) { next(err); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const statsController = { json, byCampaign };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual Stripe sync trigger + rebuild trigger.
|
|
3
|
+
* @module controllers/sync
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runFullSync } from "../sync/scheduler.js";
|
|
7
|
+
import { triggerRebuild } from "../build/trigger.js";
|
|
8
|
+
|
|
9
|
+
async function runOnce(request, response, next) {
|
|
10
|
+
try {
|
|
11
|
+
const { application } = request.app.locals;
|
|
12
|
+
const result = await runFullSync(application);
|
|
13
|
+
response.redirect(`${application.donationEndpoint}?synced=${result.upserted ?? 0}`);
|
|
14
|
+
} catch (err) { next(err); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function triggerRebuildAction(request, response, next) {
|
|
18
|
+
try {
|
|
19
|
+
const { application } = request.app.locals;
|
|
20
|
+
await triggerRebuild(application);
|
|
21
|
+
response.redirect(`${application.donationEndpoint}?rebuild=triggered`);
|
|
22
|
+
} catch (err) { next(err); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const syncController = { runOnce, triggerRebuild: triggerRebuildAction };
|