szabadpalya/SPEC.md

405 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Szabadpálya — spec overview
**Status:** draft v0.7 — 2026-08-22
**Working name:** Szabadpálya · szabadpalya.hu
**Reference UX:** reward-based crowdfunding, Kickstarter pattern
---
## 1. Positioning
Curated, invite-only crowdfunding platform for public projects: helyi szervezetek,
Lepke-típusú kulturális egyesületek, szövetséges NGO-k.
**Party-adjacent, not party-branded.** The platform has its own name, identity and
operating entity. Momentum's name, logo and colours do not appear in the product,
the domain, or the legal footer. The connection is in who gets approved and who
founded it — not in the branding. Practical consequences:
- Separate legal operator: a **new egyesület**, to be founded (see 1c).
- Own name and mark (see 1b). The visual *world* is shared with the Momentum
**rendszerbontó** identity — palette, typography, motif — but the Momentum mark
and wordmark never appear. Shared language, separate signature.
- Impressum names the egyesület. No "a Momentum kezdeményezése" line.
No campaign may fund a party, a party campaign, or a candidate. That boundary is
what keeps the platform outside párttörvény (2007. évi CLXXXI. tv.) reporting and
donation-cap rules, and it is enforced as an explicit check in the approval queue,
not as a policy sentence.
Campaign creation is **closed**: creators apply, an admin approves. Main structural
departure from the usual reward-crowdfunding model, which is open self-serve.
## 1b. Brand
**Name:** Szabadpálya · `szabadpalya.hu` (no DNS as of 2026-08-22 — verify at a
registrar and register before any design work ships)
**Tagline:** *Szabad a pálya.* ("the track is clear")
From the living idiom "szabad a pálya!" — the coast is clear, go ahead, nothing is
in your way. Exactly what the platform says to a public project waiting for a
permission it never actually needed. `Pálya` also reads as track / field / career:
young, forward-moving, unideological.
The name carries **szabad** openly. We are not concealing the political lineage —
the platform is party-adjacent and that is fine — we are simply not dressing it in
Momentum's identity. No Momentum logo, colours, typography or naming conventions.
Szabadpálya has its own mark.
**Visual basis:** the Momentum **rendszerbontó** brand edition
(`../arculat/arculat3_assets/`; the handbook PDF sits in `brand/`, gitignored).
| Token | Value | Role |
|---|---|---|
| Electric purple | `#7360F9` | primary accent, CTA, progress lane |
| Lime | `#B9D440` | overshoot, emphasis, the arrow |
| Lavender | `#BC9BF3` | secondary chips |
| Cream paper | `#FFF1EB` | ground |
| Ink | `#241C3B` | text |
| Heritage purple | `#8E6FCE` | sparing; ties back to the older identity |
Ratio per the handbook: cream + ink ~55%, electric ~25%, lime + lavender ~15%.
Typography: **Playfair Display** (display serif) + **Poppins** (body).
The handbook has no dark mode — the ink-grounded variant is our extension.
**Logo:** placeholder, in `brand/out/`. Three lanes, the middle one open, running
into a lime arrow. Text is converted to paths, so it renders without the fonts
installed. At 32 px the dimmed lanes wash out — a small-size variant is needed if
this mark survives.
**Voice** (Hungarian UI copy): second person singular, active verbs, no NGO
passive. `Indíts kampányt`, not `Kampány indítása`. `Beleadom`, not `Támogatás`.
**Primary CTA vocabulary:**
| Context | String |
|---|---|
| Pledge button (tier) | `Beleadom` |
| Main campaign CTA | `Beleadom ebbe a projektbe` |
| Launch a campaign | `Indíts kampányt` |
| Browse | `Nézd meg, mi épül` |
| Hero | `Szabad a pálya.` |
## 1c. Operator
A **new egyesület**, founded for this purpose. Not Momentum, not an existing
organisation. Consequences to plan around:
- The domain, the mark and the Stripe **platform** account are all registered to the
egyesület — not to a private person.
- Hungarian egyesület registration (törvényszék) takes weeks to months and needs 10
founding members, statutes, and a registered seat. Build can proceed in parallel;
**Stripe test mode needs nothing, live mode needs the registered entity**, tax
number and bank account. That is the real launch gate, not the code.
- Creators onboard as their own Connect accounts, so the egyesület never holds
campaign money — it only operates the platform.
- With a 0% platform fee the egyesület has running costs (hosting, domain, mail) and
no revenue from campaigns. Budget for it, or the "ingyenes" promise is the first
thing to break.
## 2. Funding model
**Keep-what-you-raise.** Contributions are kept regardless of whether the goal is
reached. The progress bar is motivational, not binding.
### Fulfillment guard (mitigation)
Physical rewards + immediate charge = shipping debt if the goal misses. Charging is
therefore decided **per tier type**:
| Tier type | Charge timing | Rationale |
|---|---|---|
| `donation` (symbolic, no good) | immediate | Nothing owed in return |
| `digital` (PDF, credits listing) | immediate | Zero marginal cost to deliver |
| `physical` (book, print, bundle) | **on goal reached, else released at close** | Never owe a good you can't produce |
If a campaign with physical tiers ends under goal, physical pledges are released
uncaptured and the backer is notified; donation and digital money is kept. Config
flag `campaign.physical_tier_policy = guard | immediate`.
### Legal consequence
Charging for a shippable good is **távollévők közötti szerződés** under
45/2014. (II. 26.) Korm. rendelet — 14-day withdrawal right, ÁFA due at charge,
mandatory pre-contract disclosures at checkout. Physical tiers must carry: seller
identity, delivery deadline, total price incl. ÁFA and shipping, withdrawal notice.
Donation tiers do not.
## 3. Payments and fees
**No platform fee. 0%.** `application_fee_amount = 0` on every charge. The operator
entity absorbs hosting and running costs; this is cost-recovery-free by design and
is a headline selling point against the 4.5–14.9% the commercial platforms charge.
**Stripe Connect** (HUF), platform-as-facilitator:
- Each approved creator org onboards as a **connected account** (Express).
KYC/AML is Stripe's responsibility — important given who stands behind the operator.
- Backer pays via **Payment Intent** with `setup_future_usage` so the card is vaulted;
guarded physical tiers use **manual capture**, re-authorized at close.
- Money never touches the operator's bank account. Creator is merchant of record.
- Payout on Stripe's standard schedule, min. net 50 000 Ft threshold.
- Phase 2: bank transfer (átutalás) with manual reconciliation for large donors.
**Stripe's own processing fee is unavoidable** and is deducted from the creator's
payout — we cannot zero that out, only our own cut. The `/dijszabas` page must state
this plainly so "ingyenes" is not read as "the creator receives 100%". Verify current
Stripe HU card rates before publishing that page; do not hardcode a number in the
product from memory.
*Open:* offer backers an optional "fedezem a tranzakciós költséget" checkbox at
checkout? Common on NGO donation flows, keeps creator payout whole. Not MVP-blocking.
## 4. Page inventory
### 4.1 Campaign detail — `/kampanyok/:slug`
1. **Header** — category chip, title, creator avatar + name, `Kedvencekhez adás`
2. **Media gallery** — carousel, image counter `1/10`, video support
3. **Funding bar** — `Cél: X Ft` · `Összegyűlt: Y Ft` · `N%` progress · `N támogató`
· `N nap van hátra` · status badge
4. **Sticky CTA** — `Beleadom ebbe a projektbe`, `Megosztás`, `Megfigyelés`
5. **Tabs** — `Sztori` · `Frissítések` · `Hozzászólások`
6. **Story** — rich content (`Összefoglalás`, `Bemutatás`), `A csapat` bios
7. **Reward tiers** — see 4.2
8. **Footer**
Desktop: story left, sticky tier rail right. Mobile: single column, funding bar and
CTA pinned to viewport bottom.
**No `Támogatók` tab.** The commercial platforms publish a backer list; we do not. Backer
identities are never public — not by name, not by avatar, not opt-in. Consequences:
- The aggregate **count** stays (`164 támogató`) — non-identifying and load-bearing
for social proof.
- Full backer list is visible only to the campaign creator in `/vezerlopult`, and
only for fulfillment. Never exportable to a general-purpose mailing list.
- The `100 000 Ft` reference tier's "name printed in the credits" perk still works —
that is the creator's own publication, not platform-published data.
Rationale: a donor list attached to a party-adjacent platform is both a GDPR exposure
and a political liability. Not collecting public consent at all is cleaner than
managing it.
### 4.2 Reward tier card
```
[ -20% ] [ Legnépszerűbb ]
12 000 Ft ~~15 000 Ft~~
Könyv és művészi nyomat
· 1 db könyv
· 1 db A4 művészi nyomat
14 támogató · 20 db-ra limitálva
Várható szállítás: 2026-11-30
[ Beleadom ]
```
Fields: `price`, `list_price?`, `title`, `includes[]`, `backer_count`,
`inventory_limit?`, `estimated_delivery`, `is_featured`, `type`
(donation | digital | physical), `requires_shipping`.
### 4.3 Other pages
| Route | Purpose |
|---|---|
| `/` | Landing: featured campaigns, how-it-works, social proof |
| `/kampanyok` | Browse — filters `Összes` / `Aktív` / `Legnépszerűbb` / by category |
| `/kampanyok/:slug/tamogatas` | Checkout: tier → qty → shipping → Stripe → confirm |
| `/sikersztorik` | Completed campaigns |
| `/hogyan-mukodik` | Explainer |
| `/dijszabas` | Zero platform fee + honest note on Stripe processing costs |
| `/belepes`, `/regisztracio` | Auth |
| `/vezerlopult` | Creator dashboard: editor, backers (private), updates, payouts |
| `/admin` | Approval queue, moderation, political-content screening |
## 5. Domain model
```
User ──< Campaign ──< RewardTier ──< Pledge >── User (backer)
│ │
├──< Update └──< ShippingAddress?
├──< Comment
└──< Payout
```
**Campaign**: slug, title, category, creator_org, goal_amount, currency=HUF,
starts_at, **ends_at**, status (`draft` → `beadva` → `aktiv` → `finanszirozott` /
`lezart` / `elutasitva`), story (rich), media[], team[], physical_tier_policy.
**Pledge**: backer, tier, quantity, amount, stripe_payment_intent_id, status
(`authorized` | `captured` | `released` | `refunded`), shipping_address?.
No `is_anonymous` field — every pledge is private, so there is nothing to toggle.
**Derived, never stored:** raised_amount, backer_count, progress_pct, days_left.
Computed from captured + authorized pledges so the bar cannot drift from reality.
## 6. Campaign duration
The creator picks a concrete **end date**, not a length in days. It is shown as a
date everywhere in the creator UI (`A kampány eddig tart: 2026-11-30`) and rendered
as a countdown only on the public page (`9 nap van hátra`).
- Bounded: `ends_at` must fall within **90 days** of launch. Configurable ceiling,
enforced server-side at submit and at launch approval.
- Campaigns close at **23:59:59 Europe/Budapest** on the chosen date, not at the
wall-clock instant of launch — a Hungarian creator picking "november 30" means
the whole day.
- Extension after launch: admin-only, and never past the 90-day ceiling. Creators
cannot quietly move their own deadline once money is in.
- `estimated_delivery` on physical tiers must be **after** `ends_at`. Validated.
## 7. Key flows
**Back a project** — tier select → qty → shipping (if `requires_shipping`) → account
or guest email → Stripe Payment Element → confirm → receipt email. No public trace.
**Campaign close** — scheduled job at `ends_at`: evaluate goal → capture guarded
physical pledges or release them → notify backers → notify creator → open the
fulfillment view in `/vezerlopult`.
**Creator apply** — form → admin review (incl. explicit political-content check) →
Stripe Connect onboarding → editor unlocked → submit for launch approval.
## 8. Stack
Self-hosted on our own managed server. Everything below runs on one box under
Docker Compose; nothing depends on a PaaS.
**Two languages, on purpose.** The stack was TypeScript end to end until the
backend was rewritten in Go; the original argument (one language, one dependency
tree) was sound and is now overridden by a deliberate decision. What it costs is
recorded honestly below, because the reasoning that produced this table should
not have to be reconstructed from a diff later.
### Application
| Layer | Choice | Why |
|---|---|---|
| Backend | **Go + Gin**, single static binary | One process serves the API, the HTML document, the share images and the built assets. 49 MB image, nothing to install. |
| Frontend | **React 19 + React Router + Vite** | The campaign pages were already components; only their data source changed. |
| HTML document | **Go `html/template`** | Per-campaign `og:` tags without a JS runtime — see below. |
| DB | **Postgres 17**, same host | Tiny dataset. No managed service needed at this scale. |
| Data access | **pgx + hand-written SQL** | The three queries that matter are aggregates over pledges; SQL states them more plainly than an ORM did. Migrations are plain `.sql` applied by a ~60-line runner. |
| Auth | **Own magic links** (`api/internal/auth`) | Go has no better-auth. ~200 lines: hashed tokens, single-use redemption, cookie sessions. |
| Share images | **Drawn in Go** (`api/internal/ogimage`) | Go has no Satori. Rectangles and text on a 1200×630 canvas. |
| Editor | **Tiptap** | Campaign story rich text. Not built yet. |
| UI | **Tailwind 4**, rendszerbontó tokens | See 1b. |
| Proxy/TLS | **Caddy** | Automatic Let's Encrypt, two lines of config. |
| Deploy | **Docker Compose** on the box | `db`, `migrate`, `app`. Nothing depends on a PaaS. |
**What the second language costs.** Money code loses Stripe's TypeScript types,
which this document previously called the best guardrail available for it — Go's
Stripe library is typed but less expressive about the shapes Stripe actually
returns. Payments are still unbuilt (section 2 of TODO.md), so that cost lands
in full on the work that has not started yet. Nothing about the rewrite makes it
easier; it is a price paid for the rest of the stack.
**Why the server renders HTML:** the commercial Hungarian platforms are
client-rendered SPAs — fetching a campaign page returns a ~3 KB empty shell, and
the real content only exists after JavaScript runs. Every campaign shared to
Facebook therefore shows the same generic site-wide card instead of the
project's own photo, title and progress. For a platform whose growth *is*
sharing, that is a permanent self-inflicted wound and the cheapest place for us
to be strictly better.
Go renders the document, React renders the page. The seam is sharp: Go owns
`<head>` — title, description, `og:` tags, the theme script, and the hashed
asset tags read out of Vite's `manifest.json` — and React owns everything inside
`#root`. Nothing is rendered in two places, so nothing can drift.
**What this does and does not buy, stated plainly.** It buys the share cards,
which is the harm named above: crawlers read `og:` tags without executing
JavaScript. It does *not* buy what full SSR bought — the campaign page no longer
ships ~56 KB of real HTML, and a reader with JavaScript off sees nothing. Most
of the practical difference is clawed back by inlining the data the server
already loaded to build the tags, in a `<script type="application/json"
id="__DATA__">` block the client reads once; the first paint still waits for
the bundle. If body text in the HTML ever turns out to matter for search, that
is the point at which to reconsider, and the honest answer will be a JS runtime,
not more Go templating.
### What self-hosting makes our problem
These are load-bearing for **money**, not just uptime:
- **Stripe webhooks** need a reliably reachable HTTPS endpoint. Stripe retries, so
handlers must be idempotent (dedupe on `event.id`) and must verify signatures.
Extended downtime during a campaign close means captures do not happen.
- **The close job** (`ends_at` → capture or release) runs as a systemd timer or a
small worker container, not Vercel Cron. It must be idempotent and safe to re-run:
never double-charge a pledge.
- **Backups are the real risk.** Nightly `pg_dump`, encrypted, shipped **off the
box**, with a restore actually tested. The DB holds pledges, addresses and a
private donor list — losing it is worse than downtime, leaking it is worse still.
- **Do not self-host SMTP.** Use Resend, Postmark or SES for transactional mail. A
small VPS sending receipts lands in spam, and payment receipts must arrive.
- **Monitoring:** uptime check (Uptime Kuma / healthchecks.io) plus Sentry. A dead-man
switch on the close job matters more than CPU graphs.
- **Media** on a mounted volume, included in the backup set. Object storage
(Hetzner/R2/MinIO) only if volume grows.
- **Migrations run as their own step**, not on app boot: `compose.yaml` has a
`migrate` service (Dockerfile target `migrator`, the same image running
`server -migrate`) that runs once and exits; `app` waits for it to succeed.
The server refusing to migrate at boot is load-bearing, not tidiness — a
process that exits when Postgres is unreachable can never report that Postgres
is unreachable, and one blip would take the site down instead of degrading it.
### Server placement
Host in the **EU but outside Hungary** — Frankfurt or Helsinki. The database holds a
private list of who funded which public project; for a party-adjacent platform that is
politically sensitive data, and Hungarian-jurisdiction infrastructure is more exposed
to Hungarian pressure than German-hosted infrastructure is. This is a threat-model
decision, not only a GDPR one. Full-disk encryption on the box, encrypted backups.
### Known gotchas
**HUF in Stripe does not follow the plain smallest-unit rule** most currencies use.
Verify current Stripe behaviour for forint before writing any money code, and store
every amount as an integer with an explicit documented unit — never a float.
(In the schema `goalAmount`, `price` and `amount` are all `Int`, forint.)
**The Vite manifest is a boot dependency.** The Go server reads
`web/.vite/manifest.json` at startup to find the hashed entry files. `build.manifest`
must stay on in `vite.config.ts`; without it the server refuses to boot rather than
serving a document with no script tag.
**Share tags are not exercised in development.** Vite owns `index.html` there and
the Go document template only runs in the container, so a broken `og:` tag will
look fine locally. The CI container smoke test is the only thing that catches
it — that is why it asserts the tags, the PNG magic bytes and the inlined
payload rather than just a 200.
**No live database at build time.** The image build compiles Go and bundles the
frontend; neither touches Postgres. The seed and the migrations are separate
binaries run against a live database afterwards.
**The seeder is not in the shipping image.** It starts by deleting every
campaign, organisation and user, so it lives in `api/test/` and is built only by
the `seeder` Dockerfile target, which `--target runner` never reaches. CI
asserts the runner image does not carry it.
**`Intl` and Go disagree about spaces.** `Intl.NumberFormat("hu-HU")` groups
digits with U+00A0, so `api/internal/hu` uses the same character. A plain space
there would make the share card's numbers differ from the page's.
## 9. Out of scope for MVP
- **AI Kampány Varázsló** — AI-generated campaign drafts. Post-MVP.
- **Post-campaign marketplace** — selling the product after the campaign. Not planned.
- Bank transfer payments (phase 2).
- English localization (scaffolded, not translated).
## 10. Open questions
1. Optional "cover the transaction cost" checkbox at checkout — yes or no?
2. Category taxonomy — reuse the conventional set (`Kultúra & Művészet`, …) or define our own?
3. Does the placeholder logo stay, or does a designer take it over before launch?
4. Who funds the egyesület's running costs, given the 0% fee?
**Resolved:** name (Szabadpálya), operator form (new egyesület), funding model
(keep-what-you-raise + fulfillment guard), fees (0%), payments (Stripe Connect),
backer privacy (never public), duration (creator-picked end date, 90-day ceiling),
visual system (rendszerbontó arculat, own mark).