diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 3340534f..6db8bbe7 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -4,23 +4,22 @@ name: Deploy web (Cloudflare Pages) # "civfix-web". TWO environments, selected by the REF this runs on # (issue https://github.com/civfix/issue-tracker/issues/108): # -# push to main -> STAGING --branch=staging -> civfix.dev (api.civfix.dev, Stripe TEST) -# a published v* tag -> PRODUCTION --branch=main -> civfix.org (api.civfix.org, Stripe LIVE) +# push to main -> STAGING --branch=staging -> civfix.dev (api.civfix.dev) +# a published v* tag -> PRODUCTION --branch=main -> civfix.org (api.civfix.org) # # There is no `dev` branch any more: feature branches PR into main, main IS staging, and cutting a # release promotes to production. # # WHY THIS IS A REBUILD AND NOT A BYTE-PROMOTION. The backend's Docker images are built once and the # same digests are promoted to prod. A Next static export cannot work that way: every NEXT_PUBLIC_* is -# INLINED INTO THE BUNDLE at build time, so the staging artifact has api.civfix.dev and the Stripe TEST -# key compiled into it. Production is therefore rebuilt FROM THE RELEASE TAG — the same commit, with +# INLINED INTO THE BUNDLE at build time, so the staging artifact has api.civfix.dev compiled into it. +# Production is therefore rebuilt FROM THE RELEASE TAG — the same commit, with # production values. Same source, not the same bytes; see civfix-infra/docs/DEPLOY.md §Promotion. # # Required repository configuration (Settings -> Secrets and variables -> Actions): # Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID # Variables: NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_TURNSTILE_SITEKEY, -# NEXT_PUBLIC_CARTO_API_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE (production only), -# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_TEST (staging only) +# NEXT_PUBLIC_CARTO_API_KEY # # On a push, only paths that feed the web build trigger a deploy; a mobile-only change never redeploys # the site. A release always deploys, so a tag produces a complete, self-consistent production. @@ -80,8 +79,8 @@ jobs: - name: Prove the production ref (tag shape + merged into main) # THIS IS A BRANCH-PROTECTION CONTROL, not a formality. Without it the only thing standing - # between an arbitrary commit and civfix.org — with the LIVE Stripe key — is the ability to push - # a tag. `main` requires a reviewed PR; tags do not, so a tag on an unreviewed branch would be a + # between an arbitrary commit and civfix.org is the ability to push a tag. + # `main` requires a reviewed PR; tags do not, so a tag on an unreviewed branch would be a # complete bypass straight to production. civfix-backend's deploy.yml carries the same proof; # keep the regex identical to that one and to CIVFIX_RELEASE_TAG_REGEX in civfix-infra. if: env.DEPLOY_ENV == 'production' @@ -135,16 +134,14 @@ jobs: PROD_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }} PROD_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }} PROD_TURNSTILE: ${{ vars.NEXT_PUBLIC_TURNSTILE_SITEKEY }} - STRIPE_LIVE: ${{ vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE }} - STRIPE_TEST: ${{ vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_TEST }} run: | set -euo pipefail if [ "$DEPLOY_ENV" = "production" ]; then api="$PROD_API_URL"; site="$PROD_SITE_URL"; turnstile="$PROD_TURNSTILE" - stripe="$STRIPE_LIVE"; pages_branch=main + pages_branch=main else api="https://api.civfix.dev"; site="https://civfix.dev"; turnstile="" - stripe="$STRIPE_TEST"; pages_branch=staging + pages_branch=staging fi # Heredoc delimiters, not `KEY=value` lines: a repo variable containing a newline would # otherwise inject arbitrary extra KEY=VALUE pairs into the job environment. @@ -152,29 +149,20 @@ jobs: emit NEXT_PUBLIC_API_URL "$api" emit NEXT_PUBLIC_SITE_URL "$site" emit NEXT_PUBLIC_TURNSTILE_SITEKEY "$turnstile" - emit NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY "$stripe" emit PAGES_BRANCH "$pages_branch" - - name: Assert the build targets and the keys agree + - name: Assert the build targets agree # The one check that makes a cross-environment mix-up impossible rather than merely unlikely. # It runs BEFORE the build, so a mismatch costs nothing, and it checks the values that were # actually resolved — not the expressions that were supposed to resolve them. run: | set -euo pipefail fail() { echo "::error::$*"; exit 1; } - case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in - ""|pk_live_*|pk_test_*) ;; - *) fail "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is neither empty nor a pk_live_/pk_test_ key." ;; - esac if [ "$DEPLOY_ENV" = "production" ]; then [ "$NEXT_PUBLIC_API_URL" = "https://api.civfix.org" ] \ || fail "production build points at '$NEXT_PUBLIC_API_URL', not https://api.civfix.org." [ "$NEXT_PUBLIC_SITE_URL" = "https://civfix.org" ] \ || fail "production build declares site '$NEXT_PUBLIC_SITE_URL', not https://civfix.org." - case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in - pk_live_*) ;; - *) fail "production build has no LIVE Stripe key (got '${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:0:8}...'). Check vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE." ;; - esac [ -n "$NEXT_PUBLIC_TURNSTILE_SITEKEY" ] \ || fail "production build has no Turnstile sitekey; signup abuse controls would be off." [ "$PAGES_BRANCH" = "main" ] || fail "production must publish to the Pages production branch." @@ -183,13 +171,9 @@ jobs: https://api.civfix.dev) ;; *) fail "staging build points at '$NEXT_PUBLIC_API_URL', not https://api.civfix.dev." ;; esac - case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in - ""|pk_test_*) ;; - *) fail "staging build carries a non-test Stripe key. Refusing to publish it publicly." ;; - esac [ "$PAGES_BRANCH" != "main" ] || fail "staging must not publish to the Pages production branch." fi - echo "$DEPLOY_ENV: api=$NEXT_PUBLIC_API_URL site=$NEXT_PUBLIC_SITE_URL stripe=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:0:8}... pages-branch=$PAGES_BRANCH" + echo "$DEPLOY_ENV: api=$NEXT_PUBLIC_API_URL site=$NEXT_PUBLIC_SITE_URL pages-branch=$PAGES_BRANCH" - name: Build static export # NEXT_PUBLIC_* come from $GITHUB_ENV (resolved and asserted above) and are inlined here. diff --git a/README.md b/README.md index b0fd45cb..673a7a73 100644 --- a/README.md +++ b/README.md @@ -89,13 +89,13 @@ to production only when a release is cut (issue | Event | What happens | | --- | --- | -| push to `main` | `deploy-web.yml` builds the web static export against `api.civfix.dev` with the Stripe TEST key and publishes it to the `staging` branch of the Cloudflare Pages project `civfix-web` (https://civfix.dev). `publish-shared.yml` publishes `@civfix/shared` if its version is ahead of the registry. | -| published `v*` release | `deploy-web.yml` rebuilds **the same commit** against `api.civfix.org` with the LIVE Stripe key and Turnstile on, and publishes it to the Pages production branch (https://civfix.org). | +| push to `main` | `deploy-web.yml` builds the web static export against `api.civfix.dev` and publishes it to the `staging` branch of the Cloudflare Pages project `civfix-web` (https://civfix.dev). `publish-shared.yml` publishes `@civfix/shared` if its version is ahead of the registry. | +| published `v*` release | `deploy-web.yml` rebuilds **the same commit** against `api.civfix.org` with Turnstile on, and publishes it to the Pages production branch (https://civfix.org). | A static export inlines every `NEXT_PUBLIC_*` at build time, so production is a rebuild of the release commit rather than a byte-copy of the staging artifact — unlike the backend, whose Docker images really are promoted as-is. The workflow resolves those values once and then asserts they match the target, so a -build cannot ship a test key to civfix.org or a live key to the public preview. +build cannot ship the staging API URL to civfix.org or the production one to the public preview. The mobile app deploys through `.github/workflows/deploy-mobile.yml` on the same lane: a push to `main` that touches the app or the packages builds the `testflight` profile (staging API) on a diff --git a/apps/community-mobile/APP-REVIEW-NOTES.md b/apps/community-mobile/APP-REVIEW-NOTES.md index c7f9beb7..f0f9728e 100644 --- a/apps/community-mobile/APP-REVIEW-NOTES.md +++ b/apps/community-mobile/APP-REVIEW-NOTES.md @@ -5,41 +5,37 @@ Paste the relevant section into **App Store Connect → App Review Information --- -## Donations to nonprofit event hosts - -**Donations are collected outside the app, on the web, by the nonprofit — not by civfix, and not in-app.** - -- Some events are hosted by independent US 501(c)(3) nonprofit organizations. On such an event - (and on that organization's page) the app shows a **Donate** button. -- Tapping it opens `https://civfix.org/donate/` in an **SFSafariViewController** - (Android: a Custom Tab) with the **address bar visible**. It is a normal web page: the reviewer can - see the URL, read it, and dismiss it. Nothing about the donation is entered, rendered or confirmed - inside the app itself. -- **No payment information is ever entered in the app.** Card entry happens on that web page, inside - Stripe's own iframe. civfix's app has no payment SDK, no Apple Pay entitlement, and declares no - payment-related privacy-manifest API. -- **The nonprofit is the merchant of record.** Funds settle directly to the organization's own Stripe - account. civfix facilitates the payment and charges a disclosed 5% platform fee; civfix never holds - the funds. -- The donation page carries the disclosures California Gov. Code §12599.9 requires (recipient, the - possibility that the charity may not receive the donation, remittance timing, fees, deductibility, - merchant of record, refund policy) before the donor can continue. +## External donation links + +**civfix processes no payments anywhere in its stack. A donation link is a plain external URL a host +chose, and it opens in the browser.** + +- An event host, an organization or a person may add a **donation link** to their own profile, + organization or event: an https URL to a page they run elsewhere (their own fundraiser, a + nonprofit's giving page, a payment page they own). civfix stores only the URL. +- Where such a link exists, the app shows a small **Donate** card naming who it supports and the + link's hostname. Tapping **Open donation page** opens that URL in an **SFSafariViewController** + (Android: a Custom Tab) with the **address bar visible**. It is an ordinary third-party web page: + the reviewer can see the URL, read it, and dismiss it. Nothing about a donation is entered, + rendered or confirmed inside the app. +- **No payment information is ever entered in the app, and civfix never holds or moves funds.** The + app has no payment SDK, no Apple Pay entitlement, no checkout screen and no payment-related + privacy-manifest API. civfix is not a party to whatever happens on the host's page. - **Nothing being funded is digital content or an in-app feature.** A donation unlocks no - functionality, content, subscription or service in the app. It is a charitable contribution to a - third-party nonprofit, so **IAP does not apply** (Guidelines 3.1.1 and 3.2.2(iv); Google Play's - equivalent charitable-donation carve-out). + functionality, content, subscription or service in the app, so **IAP does not apply** + (Guidelines 3.1.1 and 3.2.2(iv); Google Play's equivalent carve-out for donations to third parties + made outside the app). **Reviewer steps** -1. Open any event hosted by a verified nonprofit (or the organization page from that event). -2. Tap **Donate** → a Safari view opens on `civfix.org/donate/` with the address bar - showing. -3. Test card `4242 4242 4242 4242`, any future expiry, any CVC, any postcode. The donation is a real - Stripe test-mode charge on the sandbox account; nothing is charged. -4. Dismiss the Safari view with **Close** to return to the app. +1. Open an event, organization or profile that shows a **Donate** card (a host sets the link under + Settings → Account → Donation link; an organization sets it in its web settings). +2. Tap **Open donation page** → a Safari view opens on the host's own page with the address bar + showing the third-party hostname. +3. Dismiss the Safari view with **Close** to return to the app. No state in the app changes. -**If a reviewer treats the SFSafariViewController as "in-app"**, flip the app to hand donation links to -the system browser instead — no binary change and no resubmission: +**If a reviewer objects to the SFSafariViewController**, flip the app to hand donation links to the +system browser instead — no binary change and no resubmission: ```sh EXPO_PUBLIC_DONATE_BROWSER_MODE=system eas update --branch production diff --git a/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md b/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md index 8f7da941..041e0ab2 100644 --- a/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md +++ b/apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md @@ -140,7 +140,7 @@ Permission↔usage parity is clean and there is **no tracking/ads/analytics SDK* - 🟡 **M9 — Dev-only promo-notification helper still imported in prod layout.** `src/dev/promoNotification.ts` is imported + invoked in `RootLayout` (`_layout.tsx:52-54, 310-311`). Inert unless `EXPO_PUBLIC_PROMO_NOTIF==="1"`, but the file's own header says "Remove before shipping." If that env var were ever set, it fires a fake "Your report has been resolved" notification (2.3 risk). *Fix:* delete the file + import/call. - 🟡 **M10 — Notification icon/sound are explicit placeholders.** `app.config.ts:104-110` comment: "Placeholder assets; replace with branded notification icon/sound before launch." Only a `color` is set → Android falls back to a default glyph. *Fix:* add a branded monochrome notification icon. - 🟡 **M11 — "Donate" external links.** Two distinct surfaces now, only one of them addressed: - - **New (events overhaul, `@civfix/ui` 0.58.0):** a nonprofit event host's **Donate** CTA (`DonateBlock` on the event + organization pages) opens `https://civfix.org/donate/` in an in-app Safari view (`SFSafariViewController` / Android Custom Tab, **address bar visible**, `enableBarCollapsing:false`), wired in `apps/community-mobile/app/_layout.tsx` as `openExternal.openInAppBrowser`. No payment data is entered in the app; the nonprofit is the merchant of record; the page carries the Gov. Code §12599.9 disclosures. The reviewer note (incl. the test card and the `EXPO_PUBLIC_DONATE_BROWSER_MODE=system` escape hatch) is in `apps/community-mobile/APP-REVIEW-NOTES.md`. *Nothing further to verify beyond the page being live in prod.* + - **Host donation links (`@civfix/ui` 0.60.0):** a host, organization or person can set their own external `donationUrl` (https-only, validated by the contract). It renders as a **Donate** card (`DonateBlock`) on the event page, the organization page and the profile, and opens that third-party URL in an in-app Safari view (`SFSafariViewController` / Android Custom Tab, **address bar visible**, `enableBarCollapsing:false`), wired in `apps/community-mobile/app/_layout.tsx` as `openExternal.openInAppBrowser`. civfix processes no payment anywhere in its stack: no payment SDK, no checkout page, no funds held. The reviewer note (incl. the `EXPO_PUBLIC_DONATE_BROWSER_MODE=system` escape hatch) is in `apps/community-mobile/APP-REVIEW-NOTES.md`. *Nothing further to verify.* - **Legacy, STILL OPEN:** the About card + Settings row open `@civfix/ui` `externalUrls.ts` `DONATE_URL` = `https://reachoutla.org/help` (a third-party page; WebFetch returned HTTP 403 — bot-block, **inconclusive not confirmed-broken**). That constant lives in `@civfix/ui`, not in this repo. *Fix:* manually confirm it loads, or re-point it at civfix's own donation page. *(Terms/Privacy on the same card both returned HTTP 200 with real content — good.)* - ⚪ **L3 — Version is `0.1.0` (pre-1.0).** Not a rejection alone, but with missing build numbers it signals a pre-release binary. *Fix:* bump to `1.0.0` + set build number. - ⚪ **L4 — Stale comment** claims the API defaults to `localhost:8080`; the real default is `https://api.civfix.org` (`app.config.ts:14`, `src/config.ts:17`). Doc-only. diff --git a/apps/community-mobile/app.config.js b/apps/community-mobile/app.config.js index 047a3459..e522df54 100644 --- a/apps/community-mobile/app.config.js +++ b/apps/community-mobile/app.config.js @@ -70,7 +70,7 @@ module.exports = ({ config }) => ({ name: "civfix", slug: "civfix-community", scheme: "civfix", - version: "1.2.0", + version: "1.2.1", orientation: "portrait", userInterfaceStyle: "automatic", icon: "./assets/icon.png", diff --git a/apps/community-mobile/app/me/donations.tsx b/apps/community-mobile/app/me/donations.tsx deleted file mode 100644 index 643d5178..00000000 --- a/apps/community-mobile/app/me/donations.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { seedEntry } from "@/components/MobileNavAdapter" -import DeepLinkHost from "@/components/DeepLinkHost" - -export default function MyDonationsHostScreen() { - return seedEntry({ kind: "my-donations" })} /> -} diff --git a/apps/community-mobile/eas.json b/apps/community-mobile/eas.json index ce076f1e..39945c73 100644 --- a/apps/community-mobile/eas.json +++ b/apps/community-mobile/eas.json @@ -36,7 +36,7 @@ "autoIncrement": true, "env": { "//api": "Dev/TestFlight testing builds bake the staging API base URL. App Store releases use the production profile, which sets no EXPO_PUBLIC_API_URL and therefore falls back to https://api.civfix.org (src/lib/apiUrl.ts).", - "//donate": "How a donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. OTA-flippable with `eas update` if App Review treats the in-app browser as in-app purchasing (see APP-REVIEW-NOTES.md).", + "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", "EXPO_PUBLIC_API_URL": "https://api.civfix.dev", "EXPO_PUBLIC_CARTO_API_KEY": "cb1_2800_1_9e1f147ec5d25247379fe9cf", @@ -59,7 +59,7 @@ "autoIncrement": true, "env": { "//dsym": "iOS links React Native's prebuilt core frameworks (buildReactNativeFromSource: false in app.config.js), so App Store Connect's 'Upload Symbols Failed' warning for React.framework / ReactNativeDependencies.framework (alongside hermes and MapLibre) is expected and non-blocking.", - "//donate": "How a donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. OTA-flippable with `eas update` if App Review treats the in-app browser as in-app purchasing (see APP-REVIEW-NOTES.md).", + "//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).", "EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app", "EXPO_PUBLIC_CARTO_API_KEY": "cb1_2800_1_9e1f147ec5d25247379fe9cf", "EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID": "521996499476-d86mdmhsuopfp9gmqf7qarc2ousv6sqt.apps.googleusercontent.com", diff --git a/apps/community-mobile/package.json b/apps/community-mobile/package.json index 8d920839..feabd614 100644 --- a/apps/community-mobile/package.json +++ b/apps/community-mobile/package.json @@ -1,6 +1,6 @@ { "name": "community-mobile", - "version": "1.2.0", + "version": "1.2.1", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/apps/community-mobile/src/lib/links.test.ts b/apps/community-mobile/src/lib/links.test.ts index f0ff51ff..91b8309c 100644 --- a/apps/community-mobile/src/lib/links.test.ts +++ b/apps/community-mobile/src/lib/links.test.ts @@ -130,20 +130,17 @@ test("a non-string target is never opened", () => { assert.equal(isExternalUrl(42), false) }) -test("an organization page and the donation history are push-link targets", () => { +test("an organization page is a push-link target", () => { assert.equal(isInternalLink("/orgs/acme"), true) assert.equal(isInternalLink("/orgs/acme?tab=events"), true) assert.equal(isInternalLink("/orgs"), false) assert.equal(isInternalLink("/orgsomething"), false) - assert.equal(isInternalLink("/me/donations"), true) - assert.equal(isInternalLink("/me/donations?cursor=x"), true) assert.equal(isInternalLink("/me"), false) - assert.equal(isInternalLink("/me/donationsomething"), false) + assert.equal(isInternalLink("/me/anything"), false) }) -test("the host console and the donate page are never push-link targets", () => { +test("the host console and unsubscribe are never push-link targets", () => { assert.equal(isInternalLink("/manage/events/e1"), false) - assert.equal(isInternalLink("/donate/acme"), false) assert.equal(isInternalLink("/unsubscribe"), false) }) diff --git a/apps/community-mobile/src/lib/links.ts b/apps/community-mobile/src/lib/links.ts index fbdc70a3..711fdbbf 100644 --- a/apps/community-mobile/src/lib/links.ts +++ b/apps/community-mobile/src/lib/links.ts @@ -4,7 +4,6 @@ export const ALLOWED_LINK_PREFIXES = [ "/pin/", "/cleanups/", "/orgs/", - "/me/donations", "/messages/", "/people/", "/post/", diff --git a/apps/community-mobile/src/lib/navBridge.test.ts b/apps/community-mobile/src/lib/navBridge.test.ts index 10a5eb80..5ad032ec 100644 --- a/apps/community-mobile/src/lib/navBridge.test.ts +++ b/apps/community-mobile/src/lib/navBridge.test.ts @@ -340,9 +340,3 @@ test("the quick broadcast stays in the sheet so its draft guard applies", () => const { actions } = run([{ active: { kind: "host-broadcast-quick", id: "c1" }, now: 0 }]) assert.equal(actions[0].type, "none") }) - -test("my-donations is reached from settings in the sheet, never as a native screen", () => { - assert.equal(bridgeKey({ kind: "my-donations" }), null) - assert.equal(bridgeRoute({ kind: "my-donations" }), null) - assert.equal(nativeBridgeKey({ name: "me/donations" }), null) -}) diff --git a/apps/community-mobile/src/lib/universalLinks.test.ts b/apps/community-mobile/src/lib/universalLinks.test.ts index cbf6ebf9..db8c80f5 100644 --- a/apps/community-mobile/src/lib/universalLinks.test.ts +++ b/apps/community-mobile/src/lib/universalLinks.test.ts @@ -210,8 +210,8 @@ test("garbage and unknown paths go home instead of nowhere", () => { assert.deepEqual(resolveIncomingPath(42), home) }) -test("the host console, the donate flow and unsubscribe belong to the browser, not the app", () => { - for (const path of ["/manage", "/manage/events/e1", "/donate/acme", "/unsubscribe"]) { +test("the host console and unsubscribe belong to the browser, not the app", () => { + for (const path of ["/manage", "/manage/events/e1", "/unsubscribe"]) { assert.deepEqual( resolveIncomingPath(`https://civfix.org${path}`), external(`https://civfix.org${path}`), @@ -253,11 +253,11 @@ test("a shared signup page opens the event it belongs to, in the app's own route assert.deepEqual(resolveIncomingPath("https://civfix.org/e"), home) }) -test("an organization page and the donation history are addressable", () => { +test("an organization page is addressable and nothing under /me is", () => { assert.deepEqual(resolveIncomingPath("https://civfix.org/orgs/acme"), internal("/orgs/acme")) assert.deepEqual(resolveIncomingPath("https://civfix.org/orgs"), home) - assert.deepEqual(resolveIncomingPath("https://civfix.org/me/donations"), internal("/me/donations")) assert.deepEqual(resolveIncomingPath("https://civfix.org/me"), home) + assert.deepEqual(resolveIncomingPath("https://civfix.org/me/donations"), home) assert.deepEqual(resolveIncomingPath("https://civfix.org/me/anything-else"), home) }) diff --git a/apps/community-mobile/src/lib/universalLinks.ts b/apps/community-mobile/src/lib/universalLinks.ts index 58ada0fc..17d828b6 100644 --- a/apps/community-mobile/src/lib/universalLinks.ts +++ b/apps/community-mobile/src/lib/universalLinks.ts @@ -21,7 +21,6 @@ const BROWSER_ONLY_ROOTS = new Set([ "skeleton", "bodies", "manage", - "donate", "unsubscribe", ]) @@ -146,8 +145,6 @@ function internalPathFor(parts: readonly string[]): string | null { return a ? `/cleanups/${a}` : null case "orgs": return a ? `/orgs/${a}` : null - case "me": - return a === "donations" && !b ? "/me/donations" : null case "cleanups": if (!a) return "/cleanups" return cleanupPathFor(a, b, c) diff --git a/apps/community-mobile/src/query/cache-policy.test.ts b/apps/community-mobile/src/query/cache-policy.test.ts index 4c4fe06a..1285a6b3 100644 --- a/apps/community-mobile/src/query/cache-policy.test.ts +++ b/apps/community-mobile/src/query/cache-policy.test.ts @@ -233,11 +233,9 @@ test("an event's day-of numbers survive a cold start; every other host surface i assert.equal(isPersistedQueryKey(["tickets", "mine", "c1"]), false) }) -test("a public org page is cacheable and a donation record is not", () => { +test("a public org page and the viewer's own org list are cacheable", () => { assert.equal(isPersistedQueryKey(["org", "acme"]), true) assert.equal(isPersistedQueryKey(["orgs", "mine"]), true) - assert.equal(isPersistedQueryKey(["donations", "mine"]), false) - assert.equal(isPersistedQueryKey(["donations", "org", "acme"]), false) }) test("a paused mutation is never written to disk - its variables can carry a seat token", () => { diff --git a/apps/community-mobile/tests/notificationNav.test.ts b/apps/community-mobile/tests/notificationNav.test.ts index d0bb8623..86192a38 100644 --- a/apps/community-mobile/tests/notificationNav.test.ts +++ b/apps/community-mobile/tests/notificationNav.test.ts @@ -18,7 +18,6 @@ const NOTIFICATION_LINKS = [ "/cleanups/c1/ticket", "/cleanups/c1/ticket/s1", "/orgs/acme", - "/me/donations", "/people/u1", "/post/p1", "/reports", @@ -126,6 +125,5 @@ test("an event push that names a host surface lands in a shell, not on a native test("a broadcast push that lands in the sheet claims no native bridge key", () => { assert.equal(bridgeKey({ kind: "host-broadcast-quick", id: "c1" }), null) - assert.equal(bridgeKey({ kind: "my-donations" }), null) assert.equal(shellHostsEntries({ name: "compose" }), false) }) diff --git a/apps/community-web/.env.example b/apps/community-web/.env.example index dd297d68..83158bab 100644 --- a/apps/community-web/.env.example +++ b/apps/community-web/.env.example @@ -20,11 +20,3 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # Leave unset in development: the Turnstile widget renders a no-op placeholder and reports an # empty token so gated flows can still be exercised. # NEXT_PUBLIC_TURNSTILE_SITEKEY= - -# Stripe PUBLISHABLE key for the /donate/* route (pk_test_... locally and on staging, pk_live_... only -# on production). Public by nature - it identifies the platform account to Stripe.js and can do nothing -# on its own. Leave unset in development: the donate page then renders its "payments unavailable" -# state, which is the state the whole flow must degrade to when payments are off. -# Under DIRECT charges the connected account is supplied per request from the donation page DTO, not -# from an env var, so there is exactly one publishable key per environment. -# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/apps/community-web/.eslintrc.json b/apps/community-web/.eslintrc.json index fec148df..cac5248b 100644 --- a/apps/community-web/.eslintrc.json +++ b/apps/community-web/.eslintrc.json @@ -17,49 +17,6 @@ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } - ], - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "@/lib/stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/stripe-js/pure", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/react-stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/react-stripe-js/checkout", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - } - ], - "patterns": [ - { - "group": [ - "**/lib/stripe-js", - "**/lib/stripe-js.*" - ], - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "group": [ - "@stripe/*", - "@stripe/*/**" - ], - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - } - ] - } ] }, "overrides": [ @@ -126,26 +83,6 @@ { "name": "maplibre-gl", "message": "The console renders no map." - }, - { - "name": "@/lib/stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/stripe-js/pure", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/react-stripe-js", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "name": "@stripe/react-stripe-js/checkout", - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." } ], "patterns": [ @@ -174,111 +111,11 @@ "maplibre-gl/**" ], "message": "The console renders no map." - }, - { - "group": [ - "**/lib/stripe-js", - "**/lib/stripe-js.*" - ], - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." - }, - { - "group": [ - "@stripe/*", - "@stripe/*/**" - ], - "message": "Stripe.js may be loaded ONLY from the donate surface (src/features/donate/**, src/app/donate/**). Importing it anywhere else puts js.stripe.com in a chunk the rest of the site loads, which breaks the pervasive CSP and the cookie commitment that Stripe runs on /donate/* only." } ] } ] } - }, - { - "files": [ - "src/features/donate/**", - "src/app/donate/**" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "@civfix/ui", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/theme", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/bodies", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/nav", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/surface", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/report", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/realtime", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/typography", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "@civfix/ui/capabilities", - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "name": "maplibre-gl", - "message": "The donate page renders no map." - } - ], - "patterns": [ - { - "group": [ - "react-native", - "react-native/*", - "react-native-*", - "react-native-*/**", - "@gorhom/*", - "@shopify/react-native-*" - ], - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - }, - { - "group": [ - "@civfix/ui/*/*", - "@civfix/ui/*/**", - "!@civfix/ui/theme/schemes" - ], - "message": "The donate page is plain DOM and ships no React Native: @civfix/ui barrels reach react-native and would land in the one chunk that also loads Stripe.js. Use @civfix/ui/theme/schemes for scheme names and src/lib/color-scheme.ts to read the same `.dark` class the tokens key off." - } - ] - } - ] - } - }, - { - "files": [ - "src/lib/stripe-js.ts", - "src/lib/stripe-js.test.ts", - "src/lib/stripe-appearance.ts" - ], - "rules": { - "no-restricted-imports": "off" - } } ] } diff --git a/apps/community-web/package.json b/apps/community-web/package.json index 68e00eeb..9cc6f96f 100644 --- a/apps/community-web/package.json +++ b/apps/community-web/package.json @@ -22,8 +22,6 @@ "@civfix/ui": "workspace:*", "@gorhom/bottom-sheet": "^5.2.14", "@radix-ui/react-slot": "^1.1.1", - "@stripe/react-stripe-js": "^6.9.0", - "@stripe/stripe-js": "^9.15.0", "@tanstack/react-query": "^5.62.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/community-web/public/.well-known/apple-app-site-association b/apps/community-web/public/.well-known/apple-app-site-association index 1d44ec44..db77b8bb 100644 --- a/apps/community-web/public/.well-known/apple-app-site-association +++ b/apps/community-web/public/.well-known/apple-app-site-association @@ -14,7 +14,6 @@ { "/": "/bodies*", "exclude": true }, { "/": "/manage*", "exclude": true }, { "/": "/e/*", "exclude": true }, - { "/": "/donate/*", "exclude": true }, { "/": "/unsubscribe*", "exclude": true }, { "/": "/*" } ] diff --git a/apps/community-web/public/.well-known/apple-developer-merchantid-domain-association b/apps/community-web/public/.well-known/apple-developer-merchantid-domain-association deleted file mode 100644 index 521a7af1..00000000 --- a/apps/community-web/public/.well-known/apple-developer-merchantid-domain-association +++ /dev/null @@ -1 +0,0 @@ -CIVFIX_PLACEHOLDER_APPLE_PAY_DOMAIN_ASSOCIATION_REPLACE_FROM_STRIPE_DASHBOARD diff --git a/apps/community-web/public/_headers b/apps/community-web/public/_headers index 42e7e6c5..5d96f47a 100644 --- a/apps/community-web/public/_headers +++ b/apps/community-web/public/_headers @@ -34,46 +34,6 @@ ! Cache-Control Cache-Control: public, max-age=31536000, immutable -# --- Donation checkout: the ONLY route Stripe.js is allowed to run on. --- -# -# Cloudflare comma-JOINS a header set by two matching rules, and two comma-joined CSP headers are -# INTERSECTED by the browser - so simply adding a second Content-Security-Policy here would produce a -# policy no stricter than the pervasive one and Stripe would still be blocked. The `!` drops remove the -# inherited value first (same mechanism the /_next/static/* Cache-Control block uses), then the full -# policy is RESTATED with the Stripe origins folded in. Verify on staging with -# `curl -sI https://civfix.dev/donate/x/ | grep -ci content-security-policy` - it must print 1. -# -# What changes vs. the pervasive policy, and why: -# script-src += js.stripe.com, *.js.stripe.com Stripe.js itself (loaded lazily; see src/lib/stripe-js.ts) -# frame-src += js.stripe.com, *.js.stripe.com, hooks.stripe.com -# the Payment Element iframe + the 3DS challenge frame -# form-action += hooks.stripe.com, *.js.stripe.com -# redirect-based methods POST the buyer back through Stripe -# Permissions-Policy payment=(self "https://js.stripe.com") -# the pervasive `payment=()` HARD-BLOCKS Apple Pay, -# Google Pay and Link. This is the only route that -# may use the Payment Request API. -# connect-src already allows `https:` (api/m/q/errors.stripe.com are covered). frame-ancestors stays -# 'none' and COOP stays same-origin-allow-popups (correct for the 3DS popup; Connect embedded -# components are NOT used). -# -# X-Robots-Tag: noindex - a donation form has no business in a search index, and there is no OG -# function for /donate/* in v1. -# -# There is deliberately NO Content-Security-Policy-Report-Only line here. A Report-Only copy of an -# identical policy collects nothing without a report-to/report-uri endpoint, and civfix runs no CSP -# report collector (adding one would be a new third-party data flow and a privacy decision). The -# staging device matrix - desktop Chrome/Safari, iOS Safari + in-app browser, Android Custom Tab; -# 4242..., 3DS 4000002500003155, decline 4000000000000002, Apple Pay on a real device, Google Pay - -# is walked with the browser console open instead, and any violation shows there against the -# enforcing policy below. -/donate/* - ! Content-Security-Policy - ! Permissions-Policy - Permissions-Policy: accelerometer=(), autoplay=(self), camera=(), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(self "https://js.stripe.com"), usb=() - Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://hooks.stripe.com https://*.js.stripe.com; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com - X-Robots-Tag: noindex - # --- Broadcast unsubscribe: never indexed (the URL carries a one-click token). --- /unsubscribe* X-Robots-Tag: noindex @@ -87,18 +47,6 @@ /.well-known/apple-app-site-association Content-Type: application/json -# --- Apple Pay merchant-domain association file. --- -# Stripe registers civfix.org as a payment_method_domain per CONNECTED ACCOUNT, and Apple fetches -# /.well-known/apple-developer-merchantid-domain-association to verify it. The file has no extension -# and is plain text; Cloudflare would otherwise guess a content type from the missing extension, and a -# text/html guess fails Apple's verification silently (the wallet just never appears). Only -# Content-Type is set here, so the comma-join described in MERGE SEMANTICS cannot corrupt it. -# The committed file is a PLACEHOLDER until the real contents are pasted from the Stripe Dashboard. -# scripts/cf-pages-postbuild.mjs warns about the placeholder on a non-production build and FAILS the -# build on a production one, so this cannot reach civfix.org unresolved. -/.well-known/apple-developer-merchantid-domain-association - Content-Type: text/plain - # --- Share-card artwork: pin the image content type. --- # iMessage/WhatsApp only render a link card when the og:image answers with an image content type on # the FIRST response (no redirect, no sniffing), so state it explicitly rather than trusting the diff --git a/apps/community-web/public/_redirects b/apps/community-web/public/_redirects index 01d48435..85366491 100644 --- a/apps/community-web/public/_redirects +++ b/apps/community-web/public/_redirects @@ -58,7 +58,7 @@ /leaderboard/* /leaderboard/_/ 200 /service-record/* /service-record/_/ 200 -# 3) Host-platform routes added by the events overhaul. All four are catch-all placeholder shells with +# 3) Host-platform routes added by the events overhaul. Both are catch-all placeholder shells with # NO browse page at out//index.html, so - like /pin, /groups and /channels - none needs a # protective /__spa/ rule (the collision detector in scripts/cf-pages-postbuild.mjs never fires for # them, and the new SPA-fallback-completeness gate proves the reverse: every emitted /_/ shell HAS a @@ -67,14 +67,12 @@ # /manage/* the host console (src/app/manage/[...path]), event id in the path # /e/* the public signup page (src/app/e/[...slug]); ALSO routed through # functions/e/[[path]].ts for per-event link previews, hence the _routes.json include -# /donate/* the donation checkout + its /complete return view (src/app/donate/[...slug]) # # /unsubscribe deliberately has NO rule: it is a STATIC page (out/unsubscribe/index.html) whose token # rides in the query string, so a splat rule here would shadow the page it points at and Cloudflare # would drop it as an infinite loop. /manage/* /manage/_/ 200 /e/* /e/_/ 200 -/donate/* /donate/_/ 200 # /orgs/ is the PUBLIC organization page (src/app/orgs/[...slug]); the in-app nav also maps # /orgs/ to the shared org panel, so a cold load boots this standalone shell and a warm click diff --git a/apps/community-web/scripts/cf-pages-postbuild.mjs b/apps/community-web/scripts/cf-pages-postbuild.mjs index 72dee8a4..586db02c 100644 --- a/apps/community-web/scripts/cf-pages-postbuild.mjs +++ b/apps/community-web/scripts/cf-pages-postbuild.mjs @@ -20,13 +20,10 @@ import { withNoindexRule, } from "./robots-policy.mjs" import { - MERCHANT_FILE_RELATIVE, aasaExcludeOrder, isPlaceholderLegalHash, legalDocumentHash, - merchantFileVerdict, missingAasaExcludes, - publishableKeyVerdict, spaFallbackGaps, } from "./postbuild-gates.mjs" @@ -137,7 +134,6 @@ const REQUIRED_AASA_EXCLUDES = [ "/claim*", "/manage*", "/e/*", - "/donate/*", "/unsubscribe*", ] @@ -149,8 +145,7 @@ if (missingExcludes.length > 0) { `[cf-pages] ERROR: ${AASA_RELATIVE} is missing required exclude(s): ` + `${missingExcludes.join(", ")}. Without them iOS claims those paths as Universal Links and ` + `opens the civfix app instead of the browser - which breaks the host console, the public ` + - `signup page, the donation checkout (the app must NEVER show a payment form) and one-click ` + - `unsubscribe.`, + `signup page and one-click unsubscribe.`, ) process.exit(1) } @@ -168,45 +163,6 @@ if (!excludeOrder.ok) { process.exit(1) } -const merchantOut = join(outDir, MERCHANT_FILE_RELATIVE) -const merchantSource = join(appDir, "public", MERCHANT_FILE_RELATIVE) - -if (!existsSync(merchantOut) && existsSync(merchantSource)) { - mkdirSync(dirname(merchantOut), { recursive: true }) - copyFileSync(merchantSource, merchantOut) -} - -const merchantVerdict = merchantFileVerdict( - existsSync(merchantOut) ? readFileSync(merchantOut, "utf8") : null, -) -const paymentsShipped = (process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "").trim().length > 0 - -if (merchantVerdict === "ok") { - console.log(`[cf-pages] ${MERCHANT_FILE_RELATIVE} present with real contents.`) -} else if (!paymentsShipped) { - console.log( - `[cf-pages] ${MERCHANT_FILE_RELATIVE} is ${merchantVerdict}, which cannot matter on this build: ` + - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is unset, so Stripe.js never loads and /donate/* renders ` + - `its payments-unavailable state. This gate arms itself the moment a build ships a key.`, - ) -} else if (!productionBuild) { - console.warn( - `[cf-pages] WARNING: this build ships a Stripe publishable key but ${MERCHANT_FILE_RELATIVE} is ` + - `${merchantVerdict}. Apple Pay and Google Pay will NOT appear on /donate/*. Tolerated because ` + - `${siteOrigin} is not the production origin; the same state fails a production build.`, - ) -} else { - console.error( - `[cf-pages] ERROR: this build ships a Stripe publishable key for ${siteOrigin}, but ` + - `${MERCHANT_FILE_RELATIVE} is ${merchantVerdict}. Apple fetches this file to verify civfix.org ` + - `as a payment_method_domain for every connected account; an empty file, an HTML 404 body, or ` + - `the committed placeholder all fail verification SILENTLY - the wallet button simply never ` + - `renders and nobody finds out until a donor complains. Paste the real contents from the Stripe ` + - `Dashboard into apps/community-web/public/${MERCHANT_FILE_RELATIVE}.`, - ) - process.exit(1) -} - console.log(`[cf-pages] ${AASA_RELATIVE} present and parseable.`) const ROUTES_FILE = "_routes.json" @@ -372,38 +328,6 @@ console.log( `"${NOINDEX_RULE}" on /* so no staging URL is indexed as a duplicate of civfix.org.`, ) -const keyVerdict = publishableKeyVerdict( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, - productionBuild, -) - -if (keyVerdict === "live-on-non-production" || keyVerdict === "test-on-production") { - console.error( - `[cf-pages] ERROR: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is ${keyVerdict} for origin ` + - `${siteOrigin}. A live key on a staging origin takes REAL money from anyone who lands on ` + - `/donate/*, and a test key on production silently refuses every real donation. The key is ` + - `branch-selected in .github/workflows/deploy.yml (main -> _LIVE, dev -> _TEST, any other ` + - `ref -> no key); ` + - `fix the repo variable rather than the build.`, - ) - process.exit(1) -} - -if (keyVerdict === "malformed") { - console.error( - `[cf-pages] ERROR: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is set but is neither pk_live_ nor ` + - `pk_test_. Stripe.js would fail to initialize and the donate page would render its ` + - `"payments unavailable" state on every visit.`, - ) - process.exit(1) -} - -console.log( - keyVerdict === "absent" - ? `[cf-pages] Stripe publishable key: absent, /donate/* renders its payments-unavailable state.` - : `[cf-pages] Stripe publishable key mode OK for ${siteOrigin}.`, -) - const legalOutDir = join(outDir, "legal") const legalRoutes = existsSync(legalOutDir) ? readdirSync(legalOutDir, { withFileTypes: true }) diff --git a/apps/community-web/scripts/cf-pages-postbuild.test.mjs b/apps/community-web/scripts/cf-pages-postbuild.test.mjs index c90bf05c..835ade6f 100644 --- a/apps/community-web/scripts/cf-pages-postbuild.test.mjs +++ b/apps/community-web/scripts/cf-pages-postbuild.test.mjs @@ -6,15 +6,12 @@ import { describe, expect, it } from "vitest" import { LEGAL_DOCUMENTS } from "@civfix/shared/legal" import { - MERCHANT_PLACEHOLDER_TOKEN, aasaExcludeOrder, canonicalDocumentText, extractLegalArticle, isPlaceholderLegalHash, legalDocumentHash, - merchantFileVerdict, missingAasaExcludes, - publishableKeyVerdict, spaFallbackGaps, } from "./postbuild-gates.mjs" @@ -33,13 +30,12 @@ const REQUIRED_EXCLUDES = [ "/claim*", "/manage*", "/e/*", - "/donate/*", "/unsubscribe*", ] describe("SPA-fallback completeness gate", () => { it("passes for the committed _redirects and the routes that ship a placeholder shell", () => { - const shipped = ["pin", "cleanups", "people", "messages", "groups", "channels", "post", "compose", "leaderboard", "service-record", "manage", "e", "donate", "orgs"] + const shipped = ["pin", "cleanups", "people", "messages", "groups", "channels", "post", "compose", "leaderboard", "service-record", "manage", "e", "orgs"] expect(spaFallbackGaps(shipped, redirects)).toEqual([]) }) @@ -72,7 +68,7 @@ describe("AASA gate", () => { applinks: { details: [ { - components: [{ "/": "/*" }, { "/": "/donate/*", exclude: true }], + components: [{ "/": "/*" }, { "/": "/nope/*", exclude: true }], }, ], }, @@ -80,7 +76,7 @@ describe("AASA gate", () => { const verdict = aasaExcludeOrder(broken) expect(verdict.ok).toBe(false) expect(verdict.reason).toContain("exclude-after-catch-all") - expect(verdict.lateExcludes).toEqual(["/donate/*"]) + expect(verdict.lateExcludes).toEqual(["/nope/*"]) }) it("fails when there is no catch-all at all", () => { @@ -92,8 +88,8 @@ describe("AASA gate", () => { const association = { applinks: { details: [ - { components: [{ "/": "/donate/*", exclude: true }, { "/": "/*" }] }, - { components: [{ "/": "/*" }, { "/": "/donate/*", exclude: true }] }, + { components: [{ "/": "/nope/*", exclude: true }, { "/": "/*" }] }, + { components: [{ "/": "/*" }, { "/": "/nope/*", exclude: true }] }, ], }, } @@ -104,53 +100,6 @@ describe("AASA gate", () => { }) }) -describe("Apple Pay merchant-domain file gate", () => { - it("rejects every way the file can be wrong", () => { - expect(merchantFileVerdict(null)).toBe("missing") - expect(merchantFileVerdict(" \n")).toBe("empty") - expect(merchantFileVerdict("404")).toBe("markup") - expect(merchantFileVerdict(`${MERCHANT_PLACEHOLDER_TOKEN}_REPLACE_FROM_STRIPE_DASHBOARD\n`)).toBe( - "placeholder", - ) - }) - - it("accepts a real Stripe-issued body", () => { - expect(merchantFileVerdict("7B227073704964223A2237423" + "\n")).toBe("ok") - }) - - it("still sees the committed file as the placeholder it is", () => { - const committed = readFileSync( - join(publicDir, ".well-known", "apple-developer-merchantid-domain-association"), - "utf8", - ) - expect(merchantFileVerdict(committed)).toBe("placeholder") - }) -}) - -describe("publishable-key mode gate", () => { - it("refuses a live key on a non-production origin", () => { - expect(publishableKeyVerdict("pk_live_abc", false)).toBe("live-on-non-production") - }) - - it("refuses a test key on production", () => { - expect(publishableKeyVerdict("pk_test_abc", true)).toBe("test-on-production") - }) - - it("accepts the two correct pairings", () => { - expect(publishableKeyVerdict("pk_live_abc", true)).toBe("ok") - expect(publishableKeyVerdict("pk_test_abc", false)).toBe("ok") - }) - - it("treats an unset key as an intentional payments-off build", () => { - expect(publishableKeyVerdict(undefined, true)).toBe("absent") - expect(publishableKeyVerdict(" ", false)).toBe("absent") - }) - - it("refuses a key that is neither", () => { - expect(publishableKeyVerdict("sk_live_secret", true)).toBe("malformed") - }) -}) - describe("legal document hashing", () => { const html = [ "x", diff --git a/apps/community-web/scripts/legal-hashes.mjs b/apps/community-web/scripts/legal-hashes.mjs index 84b90bce..46759164 100644 --- a/apps/community-web/scripts/legal-hashes.mjs +++ b/apps/community-web/scripts/legal-hashes.mjs @@ -12,7 +12,7 @@ const legalOutDir = join(appDir, "out", "legal") if (!existsSync(legalOutDir)) { console.error( `[legal-hashes] no export found at ${legalOutDir}. The hash is taken from the SERVED HTML, not ` + - `from source, so the canonical text is exactly what a donor saw. Run \`pnpm build\` first.`, + `from source, so the canonical text is exactly what a reader saw. Run \`pnpm build\` first.`, ) process.exit(1) } @@ -53,10 +53,7 @@ for (const row of rows) { if (unrendered.length > 0) { console.error( `\n[legal-hashes] ERROR: no /legal/ page renders: ${unrendered.join(", ")}. Every type in ` + - `LEGAL_DOCUMENTS must have a rendered page, or its hash is a number that verifies no text. ` + - `The §318 org agreement and the donation disclosure template are rendered at ` + - `/legal/org-donation-agreement and /legal/donation-disclosure and embedded from there into ` + - `the console sheet and the donate page, so the text accepted is the text hashed.`, + `LEGAL_DOCUMENTS must have a rendered page, or its hash is a number that verifies no text.`, ) process.exitCode = 1 } diff --git a/apps/community-web/scripts/postbuild-gates.mjs b/apps/community-web/scripts/postbuild-gates.mjs index 49a23759..aedf09f0 100644 --- a/apps/community-web/scripts/postbuild-gates.mjs +++ b/apps/community-web/scripts/postbuild-gates.mjs @@ -1,8 +1,5 @@ import { createHash } from "node:crypto" -export const MERCHANT_FILE_RELATIVE = ".well-known/apple-developer-merchantid-domain-association" -export const MERCHANT_PLACEHOLDER_TOKEN = "CIVFIX_PLACEHOLDER_APPLE_PAY_DOMAIN_ASSOCIATION" - export function spaFallbackGaps(shellRoutes, redirects) { const rules = new Set() for (const raw of redirects.split("\n")) { @@ -75,26 +72,6 @@ export function missingAasaExcludes(association, required) { return required.filter((pattern) => missing.has(pattern)) } -export function merchantFileVerdict(contents) { - if (contents === null || contents === undefined) return "missing" - const trimmed = contents.trim() - if (trimmed.length === 0) return "empty" - if (trimmed.startsWith("<")) return "markup" - if (trimmed.includes(MERCHANT_PLACEHOLDER_TOKEN)) return "placeholder" - return "ok" -} - -export function publishableKeyVerdict(key, isProductionOrigin) { - const value = typeof key === "string" ? key.trim() : "" - if (value.length === 0) return "absent" - const live = value.startsWith("pk_live_") - const test = value.startsWith("pk_test_") - if (!live && !test) return "malformed" - if (live && !isProductionOrigin) return "live-on-non-production" - if (test && isProductionOrigin) return "test-on-production" - return "ok" -} - export function canonicalDocumentText(html) { const withoutScripts = html .replace(/]*>[\s\S]*?<\/script>/gi, " ") diff --git a/apps/community-web/src/app/donate/[...slug]/page.tsx b/apps/community-web/src/app/donate/[...slug]/page.tsx deleted file mode 100644 index 0eeed219..00000000 --- a/apps/community-web/src/app/donate/[...slug]/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Metadata } from "next" - -import { DonateView } from "@/features/donate/donate-view" - -import "../donate.css" - -export const metadata: Metadata = { - title: "Donate · civfix", - description: "Donate to a verified nonprofit organization through civfix.", - robots: { index: false, follow: false }, -} - -export function generateStaticParams(): Array<{ slug: string[] }> { - return [{ slug: ["_"] }] -} - -export const dynamicParams = false - -export default function DonatePage() { - return ( - <> - - - - ) -} diff --git a/apps/community-web/src/app/donate/donate.css b/apps/community-web/src/app/donate/donate.css deleted file mode 100644 index 06d2671e..00000000 --- a/apps/community-web/src/app/donate/donate.css +++ /dev/null @@ -1,495 +0,0 @@ -/* ========================================================================= - Donation checkout: /donate/ and /donate//complete. - - A standalone DOM route (the /legal and /service-record precedent), NOT a - react-native-web body. Three reasons this page is plain DOM: - - 1. The Stripe Payment Element is an iframe. It has to sit in a normal - document flow that a screen reader and the keyboard can walk in DOM - order; RN-web's absolutely-positioned flex scaffolding fights that. - 2. WCAG 3.3.4 requires a reviewable confirmation step before a financial - commitment, and Berman v. Freedom Financial requires the assent notice - to sit ABOVE the button in body-size text. Both are statements about - DOM ORDER, so DOM order is the layout. - 3. The donation page is the one route allowed to load Stripe.js. Keeping - it a leaf route keeps that blast radius one route wide. - - Every value below is a design token from src/styles/design.css, which - already flips for `.dark`; there are no literal colors here. - ========================================================================= */ - -.donate-page { - min-height: 100dvh; - background: var(--bg-1); - color: var(--fg-1); - font-family: var(--font-body); - font-size: var(--fs-16); - line-height: var(--lh-base); -} - -.donate-shell { - max-width: 560px; - margin: 0 auto; - padding: var(--space-6) var(--space-5) var(--space-16); - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -/* ---- Organization identity ---- */ -.donate-org { - display: flex; - align-items: flex-start; - gap: var(--space-4); -} -.donate-org-logo { - width: 56px; - height: 56px; - border-radius: var(--radius-md); - object-fit: cover; - background: var(--bg-2); - flex: none; -} -.donate-org-identity h1 { - display: flex; - align-items: center; - gap: var(--space-2); - margin: 0; - font-family: var(--font-display); - font-size: var(--fs-24); - line-height: var(--lh-snug); - letter-spacing: var(--tracking-snug); -} -.donate-org-verified { - color: var(--fg-on-color); - fill: var(--sky-600); - flex: none; -} -.donate-org-legal, -.donate-org-event { - margin: var(--space-1) 0 0; - color: var(--fg-2); - font-size: var(--fs-14); -} - -/* ---- A banner for the AT_RISK state (donations still work) ---- */ -.donate-banner { - margin: 0; - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--sun-100); - background: var(--sun-50); - color: var(--fg-1); - font-size: var(--fs-14); -} - -/* ---- Section chrome shared by amount / donor / fees / disclosures ---- */ -.donate-page h2 { - margin: 0 0 var(--space-3); - font-family: var(--font-display); - font-size: var(--fs-18); - line-height: var(--lh-snug); -} - -.donate-amount, -.donate-donor, -.donate-fees, -.donate-disclosures, -.donate-payment { - padding: var(--space-5); - border-radius: var(--radius-lg); - border: 1px solid var(--border); - background: var(--bg-card); - box-shadow: var(--shadow-1); -} - -/* ---- Amount: native radios in a fieldset, plus free entry ---- - Native radios in a fieldset, visually presented as chips. - Deliberately NOT role="radio" buttons: the browser then owns arrow-key - navigation, roving focus and the group's accessible name for free, which an - ARIA reimplementation has to get right by hand and usually does not. The - input is visually hidden but still focusable, and the label is the chip - so - the focus ring is drawn on the chip via :has(). */ -.donate-amount-presets { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin: 0; - padding: 0; - border: none; -} -.donate-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} -.donate-amount-preset { - position: relative; -} -.donate-amount-preset input { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; -} -.donate-amount-preset label { - display: flex; - align-items: center; - justify-content: center; - min-height: 44px; - margin: 0; - padding: 0 var(--space-4); - border-radius: var(--radius-pill); - border: 1px solid var(--border-strong); - background: var(--bg-card); - color: var(--fg-1); - font-family: var(--font-body); - font-size: var(--fs-16); - font-weight: 600; - cursor: pointer; - transition: border-color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out); -} -.donate-amount-preset[data-checked="true"] label { - border-color: var(--accent); - background: var(--bloom-50); -} -.donate-amount-preset:has(input:focus-visible) label { - box-shadow: var(--ring); -} -.donate-amount-presets:disabled .donate-amount-preset label { - opacity: 0.55; - cursor: not-allowed; -} -.donate-continue:focus-visible, -.donate-pay:focus-visible, -.donate-secondary:focus-visible, -.donate-page input:focus-visible { - outline: none; - box-shadow: var(--ring); -} - -.donate-amount-custom { - margin-top: var(--space-4); -} -.donate-amount-input-wrap { - display: flex; - align-items: center; - gap: var(--space-2); - padding: 0 var(--space-3); - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - background: var(--bg-card); -} -.donate-amount-input-wrap span { - color: var(--fg-3); - font-size: var(--fs-16); -} -.donate-amount-input-wrap input { - flex: 1; - min-height: 44px; - border: none; - background: transparent; - color: var(--fg-1); - font-family: var(--font-body); - font-size: var(--fs-20); - font-weight: 600; -} - -/* ---- Donor fields ---- */ -.donate-field + .donate-field { - margin-top: var(--space-4); -} -.donate-page label { - display: block; - margin-bottom: var(--space-2); - font-size: var(--fs-14); - font-weight: 600; - color: var(--fg-2); -} -.donate-field input { - width: 100%; - min-height: 44px; - padding: 0 var(--space-3); - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - background: var(--bg-card); - color: var(--fg-1); - font-family: var(--font-body); - font-size: var(--fs-16); -} - -.donate-field-hint { - margin: var(--space-2) 0 0; - color: var(--fg-3); - font-size: var(--fs-13); - line-height: var(--lh-base); -} -.donate-field-error { - margin: var(--space-2) 0 0; - color: var(--danger); - font-size: var(--fs-13); - font-weight: 600; -} - -/* ---- Fee breakdown: itemized, per Gov. Code section 12599.9(e)(4) ---- */ -.donate-fees dl { - margin: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.donate-fee-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-4); - font-size: var(--fs-14); -} -.donate-fee-row dt { - color: var(--fg-2); -} -.donate-fee-row dd { - margin: 0; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} -.donate-fee-total { - padding-top: var(--space-3); - border-top: 1px solid var(--border); - font-size: var(--fs-16); - font-weight: 700; -} -.donate-fee-total dt { - color: var(--fg-1); -} -.donate-fee-note { - color: var(--fg-3); - font-weight: 400; - font-size: var(--fs-13); -} -.donate-fees-empty { - margin: 0; - color: var(--fg-3); - font-size: var(--fs-14); -} - -/* ---- Disclosures: adjacent, never collapsed, always above the pay button ---- */ -.donate-disclosures p { - margin: 0 0 var(--space-3); - font-size: var(--fs-14); - line-height: var(--lh-base); -} -.donate-disclosures p:last-child { - margin-bottom: 0; -} -.donate-disclosures ul { - margin: 0 0 var(--space-3); - padding-left: var(--space-5); - font-size: var(--fs-14); - color: var(--fg-2); -} - -/* ---- Consent checkboxes: native inputs, body-size labels, unchecked at rest ---- */ -.donate-check { - display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: var(--bg-2); -} -.donate-check input[type="checkbox"] { - width: 20px; - height: 20px; - margin-top: 2px; - flex: none; - accent-color: var(--accent); -} -.donate-check label { - margin-bottom: 0; - font-size: var(--fs-15); - font-weight: 400; - color: var(--fg-1); -} - -/* ---- Commit + pay ---- */ -.donate-continue, -.donate-pay { - width: 100%; - min-height: 52px; - border: none; - border-radius: var(--radius-pill); - background: var(--accent); - color: var(--accent-fg); - font-family: var(--font-body); - font-size: var(--fs-16); - font-weight: 700; - cursor: pointer; -} -.donate-continue:disabled, -.donate-pay:disabled { - opacity: 0.5; - cursor: not-allowed; -} -.donate-pay { - margin-top: var(--space-4); -} -.donate-secondary { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-height: 44px; - padding: 0 var(--space-4); - border-radius: var(--radius-pill); - border: 1px solid var(--border-strong); - background: var(--bg-card); - color: var(--fg-1); - font-family: var(--font-body); - font-size: var(--fs-14); - font-weight: 600; - cursor: pointer; -} - -.donate-payment .donate-fees { - margin-top: var(--space-4); - padding: var(--space-4); - background: var(--bg-2); - box-shadow: none; -} - -/* ---- Confirmation + terminal states ---- */ -.donate-state { - align-items: center; - text-align: center; - padding-top: var(--space-16); -} -.donate-state h1 { - margin: 0; - font-family: var(--font-display); - font-size: var(--fs-24); -} -.donate-state p { - margin: 0; - color: var(--fg-2); -} -.donate-complete-icon[data-icon="success"] { - color: var(--moss-600); -} -.donate-complete-icon[data-icon="alert"] { - color: var(--danger); -} -.donate-complete-icon[data-icon="pending"] { - color: var(--fg-3); -} -.donate-complete-icon[data-icon="none"] { - display: none; -} -.donate-complete .donate-shell { - align-items: center; - text-align: center; - padding-top: var(--space-12); -} -.donate-complete h1 { - margin: 0; - font-family: var(--font-display); - font-size: var(--fs-30); -} -.donate-complete-lead { - font-size: var(--fs-18); -} -.donate-complete-facts { - margin: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); - width: 100%; - max-width: 360px; - padding: var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: var(--bg-card); - text-align: left; -} -.donate-complete-facts > div { - display: flex; - justify-content: space-between; - gap: var(--space-4); - font-size: var(--fs-14); -} -.donate-complete-facts dt { - color: var(--fg-2); -} -.donate-complete-facts dd { - margin: 0; - font-weight: 600; -} -.donate-complete-links, -.donate-foot { - color: var(--fg-3); - font-size: var(--fs-13); -} -.donate-page a { - color: var(--bloom-600); -} - -.donate-spin { - animation: donate-spin 900ms linear infinite; -} - -@keyframes donate-spin { - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .donate-spin { - animation: none; - } - .donate-amount-preset label, - .donate-continue, - .donate-pay { - transition: none; - } -} - -/* 320px reflow: nothing may scroll horizontally at the narrowest supported width. */ -@media (max-width: 380px) { - .donate-shell { - padding-left: var(--space-4); - padding-right: var(--space-4); - } - .donate-fee-row { - flex-direction: column; - gap: var(--space-1); - } - .donate-fee-row dd { - align-self: flex-start; - } -} - -/* Print: force the LIGHT palette regardless of the viewer's scheme, because a dark-mode receipt - prints as a black rectangle. These are the light-scheme token values, restated because a print - stylesheet cannot re-enter the :root.dark cascade. */ -@media print { - .donate-complete-actions, - .donate-complete-links { - display: none; - } - .donate-page { - background: var(--card); - color: var(--ink); - } - .donate-complete-facts { - border-color: var(--ink-4); - } -} diff --git a/apps/community-web/src/app/legal/cookies/page.tsx b/apps/community-web/src/app/legal/cookies/page.tsx index b4e3fb2e..9fabe8c3 100644 --- a/apps/community-web/src/app/legal/cookies/page.tsx +++ b/apps/community-web/src/app/legal/cookies/page.tsx @@ -36,27 +36,6 @@ const WEB_ROWS: StorageRow[] = [ "A small cosmetic cache of your own profile (name, avatar) so the app can render your signed-in state instantly on reload instead of flashing a logged-out shell.", classification: "Strictly necessary / first-party preference", }, - { - item: "__stripe_mid", - where: "HTTP cookie set by Stripe, only on /donate/*", - purpose: - "Stripe's fraud-prevention device identifier. It is set only while you are on a donation page, so that Stripe can tell a returning legitimate donor from a card-testing bot. Expires after 1 year.", - classification: "Strictly necessary (payment fraud prevention)", - }, - { - item: "__stripe_sid", - where: "HTTP cookie set by Stripe, only on /donate/*", - purpose: - "Stripe's per-session fraud-prevention identifier for the same purpose as __stripe_mid. Expires after 30 minutes.", - classification: "Strictly necessary (payment fraud prevention)", - }, - { - item: "civfix.donate.status.", - where: "Browser sessionStorage, only on /donate/*", - purpose: - "The short-lived capability token that lets the confirmation page read the status of the donation you just made, kept so a page reload does not lose your receipt confirmation. Cleared when you close the tab.", - classification: "Strictly necessary (donation confirmation)", - }, { item: "Map layer toggles", where: "Browser localStorage", @@ -97,12 +76,6 @@ export default function CookiesPage() { technology; we set no third-party tracking cookies; and we do not sell or share your information for advertising.

-

- There is exactly one third-party script anywhere in civfix, and it runs on exactly one page: - Stripe.js on the donation page, which sets two strictly necessary fraud-prevention cookies for - the payment you are making. Section 4 explains what they are, why they are necessary, and the - measures that keep them off every other page. -

Because everything we store is either strictly necessary or a first-party preference you set yourself - the categories that privacy and “cookie” laws exempt from consent -{" "} @@ -182,48 +155,10 @@ export default function CookiesPage() {

-

4. Payment cookies on the donation page only

-

- The donation page at /donate/<organization> is the one place in civfix that - loads a third-party script: Stripe.js, which draws the payment form inside an iframe so that - your card number never touches civfix’s servers. Stripe.js sets the two cookies listed in - Section 2 (__stripe_mid and __stripe_sid) for fraud prevention. -

-

- Three deliberate engineering choices keep this narrow, and they are enforced in the build, not - just described here: -

-
    -
  • - Stripe.js is loaded lazily and only from the donation page. Browse the map, - file a report, sign up for an event, or read this page and no Stripe script runs and no - Stripe cookie is set. -
  • -
  • - We disable Stripe’s optional advanced fraud signals beacon, so no telemetry - request is made to m.stripe.com merely because the page loaded. Stripe still - runs its fraud checks on its own servers when you submit a payment. -
  • -
  • - The site’s Content-Security-Policy permits Stripe’s origins on{" "} - /donate/* and nowhere else, so a Stripe script could not execute on another - page even by accident. -
  • -
-

- These cookies are strictly necessary for a payment you have chosen to make: without them - Stripe cannot distinguish you from an automated card-testing attack and the payment would be - refused. They are not used for advertising, profiling or measurement, and they are not read by - civfix. That is why the donation page also carries no consent banner. If you do not want them, - do not open the donation page. -

-
- -
-

5. No advertising or cross-site tracking

+

4. No advertising or cross-site tracking

- Apart from the payment cookies described in Section 4, civfix runs no advertising, analytics, - or third-party tracking technology inside the apps or website. We do not set third-party + civfix runs no advertising, analytics, or third-party tracking technology inside the apps or + website. We do not set third-party tracking cookies, do not build advertising profiles, and do not sell or “share” your personal information for cross-context behavioral advertising as those terms are used under US state privacy laws. Our error-tracking system is self-hosted on our own infrastructure @@ -242,19 +177,17 @@ export default function CookiesPage() {

-

6. Your controls

+

5. Your controls

You can clear the cookies and local storage above at any time through your browser settings, or by signing out (which clears your session). On mobile, signing out clears the stored authentication token, and removing the app clears its on-device storage. Clearing strictly - necessary storage will sign you out and reset your saved preferences. The Stripe payment - cookies clear with your browser’s cookies like any other; clearing them does not affect a - donation you have already completed. + necessary storage will sign you out and reset your saved preferences.

-

7. Contact us

+

6. Contact us

Questions about cookies or storage? Email{" "} roman@reachoutla.org. diff --git a/apps/community-web/src/app/legal/donation-disclosure/page.tsx b/apps/community-web/src/app/legal/donation-disclosure/page.tsx deleted file mode 100644 index c22d69ee..00000000 --- a/apps/community-web/src/app/legal/donation-disclosure/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import type { Metadata } from "next" -import { DONATION_DISCLOSURE_TEMPLATE } from "@civfix/shared/legal" - -import { LegalPage } from "@/components/legal/legal-page" - -export const metadata: Metadata = { - title: "Donation Disclosures · civfix", - description: - "The exact wording civfix displays to a donor before they give, and the values substituted into it for each organization.", - robots: { index: false, follow: true }, -} - -const T = DONATION_DISCLOSURE_TEMPLATE - -export default function DonationDisclosurePage() { - return ( - -

-

1. What this page is

-

- California Government Code section 12599.9 and 11 CCR section 319 require a charitable - fundraising platform to make specific disclosures to a donor, conspicuously, before the - donation is made. This page publishes the exact template those disclosures are built from, - so that the wording a donor was shown is a versioned, hashed document rather than - whatever the page happened to say that day. -

-

- The template lives in the civfix contract package and is the single source the donation - page and the server both render from. A change to the wording is a change to this - document: it moves the version, the effective date and the hash together. -

-

- This page is not addressed to donors. Donors see the finished sentences, with the values - below filled in, on the donation page itself. -

-
- -
-

2. Values substituted for each organization

-

- A double-braced name below is replaced, per organization and per donation, with a value - civfix holds — never with text the organization wrote: -

-
    -
  • - {"{{orgLegalName}}"} — the organization’s legal name exactly as - it appears in the IRS records civfix verified it against. -
  • -
  • - {"{{platformFeePercent}}"} — the civfix platform fee that applies to - this donation, as a percentage to two decimal places. -
  • -
  • - {"{{webOrigin}}"} — the civfix web origin, so the link resolves to - this site. -
  • -
-

- Two sentences vary by fact rather than by value: the deductibility sentence depends on - whether civfix has verified the organization as currently tax exempt, and the refund - sentence uses the organization’s own refund policy where it has supplied one. -

-
- -
-

3. Who receives the donation

-

{T.recipient}

-
- -
-

4. When the organization may not receive it

-

{T.mayNotReceive}

-
    - {T.mayNotReceiveReasons.map((reason) => ( -
  • {reason}
  • - ))} -
-

- The donor is shown that sentence next to a link to{" "} - {T.mayNotReceiveUrl}, which resolves to the{" "} - Donations & Fundraising page. -

-
- -
-

5. When the funds arrive

-

{T.remittanceTiming}

-
- -
-

6. Fees

-

{T.feePointer}

-

- The donor is also shown an itemized breakdown of the exact amounts — gross, - platform fee, estimated processing fee and net to the organization — before they - confirm the payment. civfix never states or implies that the whole donation reaches the - organization. -

-
- -
-

7. Tax deductibility

-

- Where civfix has verified the organization as currently exempt under section 501(c)(3), - the donor is shown: -

-

{T.deductible}

-

Otherwise the donor is shown:

-

{T.notDeductible}

-

- Either sentence is accompanied by the date on which civfix last checked the - organization’s status against public records. -

-
- -
-

8. Merchant of record

-

{T.merchantOfRecord}

-
- -
-

9. Refunds

-

- Where the organization has supplied its own refund policy, that text is shown. Where it - has not, the donor is shown: -

-

{T.defaultRefundPolicy}

-
- -
-

10. Where these appear

-

- All of the above are rendered together, above the payment form and above the terms - confirmation, so a donor reaches the pay button only after passing them. The version of - this document that produced them is recorded with the donation, alongside the donor’s - consent. -

-

- The organization’s side of the same arrangement is set out in the{" "} - Organization Donation Agreement. Broader donor - information is on the Donations & Fundraising page. -

-
- - ) -} diff --git a/apps/community-web/src/app/legal/donations/page.tsx b/apps/community-web/src/app/legal/donations/page.tsx deleted file mode 100644 index b18d915b..00000000 --- a/apps/community-web/src/app/legal/donations/page.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import type { Metadata } from "next" - -import { CA_REGISTRATION_LABEL, LEGAL_ENTITY, LegalPage } from "@/components/legal/legal-page" - -export const metadata: Metadata = { - title: "Donations & Fundraising · civfix", - description: - "How donations through civfix work: who receives your money, how we verify organizations, what the fees are, and your rights as a donor.", - robots: { index: true, follow: true }, -} - -export default function DonationsPage() { - return ( - -
-

1. What this page is

-

- California’s Assembly Bill 488 (Government Code section 12599.9) requires an online - platform that lets people donate to charities to publish, in one place, who it is, which - charities it enables people to support, how it verifies them, what it charges, and what - happens if a donation cannot be delivered. This page is that disclosure. It is written for - donors, and it is deliberately blunt. -

-
- -
-

2. Who we are

-

- civfix is operated by {LEGAL_ENTITY}, a 501(c)(3) nonprofit organization - based in Los Angeles, California. When you donate through civfix to another organization,{" "} - {LEGAL_ENTITY} is acting as a charitable fundraising platform - not as the - recipient of your gift. -

-

- California charitable fundraising platform registration number:{" "} - {CA_REGISTRATION_LABEL}. Our registration and any organization’s registration can be - checked on the California Attorney General’s{" "} - - Registry of Charities and Fundraisers search tool - - . -

-
- -
-

3. Who receives your donation

-

- The organization named on the donation page receives your donation. Not - civfix. Your card is charged directly on that organization’s own account with our - payment processor, Stripe, and the money settles into that organization’s account. The - organization is the merchant of record: it is the party that took your payment. -

-

- civfix never holds, pools, or disburses donated funds. There is no civfix - balance the money passes through and no point at which civfix could decide to send your - donation somewhere else. That is a structural property of how the payments are built, not a - promise about how we behave. -

-

- Because the funds settle into the organization’s own account as the payment clears, - there is no separate remittance step and no holding period: the maximum time between your - donation and the organization receiving it is the payment processor’s standard - settlement time for that organization’s account. -

-
- -
-

4. How we verify the organizations you can donate to

-

- Only organizations we have verified as tax-exempt under Internal Revenue Code section - 501(c)(3), and that are in good standing, can appear with a donate option. - We check, and re-check, against these public sources: -

-
    -
  • - the IRS Publication 78 data (organizations eligible to receive - tax-deductible contributions) and the Exempt Organizations Business Master File; -
  • -
  • - the IRS Automatic Revocation of Exemption List - with the important caveat - that an organization whose exemption was reinstated stays on that list forever, so - we never treat a bare appearance on it as disqualifying; -
  • -
  • - the California Franchise Tax Board’s revoked exempt organizations{" "} - list; -
  • -
  • - the California Attorney General’s Registry of Charities and Fundraisers, - including the “May Not Operate or Solicit for Charitable Purposes”{" "} - list, which is a hard bar; and -
  • -
  • the US Treasury OFAC sanctions list.
  • -
-

- Every organization is checked before it can be onboarded and re-checked on a schedule as each - source publishes a new revision. We record which revision of which list a verdict was computed - against, and we check again at the moment a donation is authorized - so a donation is never - authorized to an organization that has lost its standing since the page was loaded. -

-

- If an organization loses its good standing, its donate option is switched off. The donation - page shows you the date we last verified the organization’s deductibility status; if - that date looks stale to you, treat it as stale. -

-
- -
-

5. Fees

-

- civfix charges a platform fee of 5% of your donation. Separately, the payment - processor charges the recipient organization a card-processing fee, which is deducted from the - amount it receives. -

-

- Both are itemized on the donation page - your donation, the estimated processing fee, the - civfix platform fee, and the estimated amount the organization receives - and they are shown{" "} - before you enter any payment details. The processing fee is an estimate - because the exact amount depends on the card or wallet you use; your receipt carries the exact - figures. -

-

- - We will never tell you that 100% of your donation reaches the organization. - {" "} - It does not, and California law specifically prohibits saying so. -

-

- If your donation is refunded in whole or in part, civfix reverses its platform fee - proportionally. The processor generally keeps its own fee on a refunded payment; that cost is - borne by the organization, not by you. -

-
- -
-

6. When an organization may not receive your donation

-

- In the overwhelming majority of cases the organization you chose receives your donation. There - are narrow circumstances in which it may not: -

-
    -
  • - the organization loses its tax-exempt status or good standing - for example - it is revoked by the IRS or the Franchise Tax Board, or added to the California Attorney - General’s “May Not Operate or Solicit” list - between the moment you - donated and the moment the payment settles; -
  • -
  • - the payment processor disables the organization’s account (for - example during a verification review), so the payment cannot complete; -
  • -
  • - the payment fails, is reversed, or is disputed by your card issuer; or -
  • -
  • - the organization declines the donation or asks to be removed from civfix. -
  • -
-

- If any of these happens to your donation,{" "} - we will tell you and the donation will be refunded to your card. civfix does - not redirect a donation to a different organization, and it does not keep it. Because the money - never enters a civfix account, there is nothing for us to redirect. -

-
- -
-

7. Removing an organization

-

- An organization can ask to be removed from civfix at any time by emailing{" "} - roman@reachoutla.org from an address at its own - domain, or through its civfix account. We disable its donate option promptly and confirm in - writing. We also remove an organization on our own initiative when it loses good standing, when - a regulator directs us to, or when we reasonably suspect fraud or misuse. Removal does not - affect donations that have already settled into the organization’s account. -

-
- -
-

8. Your rights as a donor

-
    -
  • - A receipt. We email a receipt for every completed donation, as the - organization’s authorized agent, normally within minutes and in any case{" "} - within five business days. It names the organization, the amount, the date - your card was charged, whether the gift is tax-deductible and to what extent, and an - itemization of the fees. -
  • -
  • - Your donation history. If you donated while signed in to a civfix account, - every donation you have made and its receipt are available in your account. -
  • -
  • - Anonymity by default. The recipient organization does not learn who you are - unless you check the box on the donation page saying it may. That box is{" "} - off by default and we never pre-check it. -
  • -
  • - Refunds. Donations are non-refundable except where the law requires it or - the organization chooses to refund. The organization controls refunds for its own donations; - contact it first. If a charge was unauthorized, contact us and your card issuer. -
  • -
  • - Complaints. Email us at{" "} - roman@reachoutla.org. You can also complain to the - California Attorney General’s Registry of Charities and Fundraisers, at{" "} - - oag.ca.gov/charities/complaints - - . -
  • -
-
- -
-

9. Tax deductibility

-

- Whether your donation is deductible depends on the recipient organization’s tax status, - not on civfix. The donation page states the organization’s deductibility status and the - date we last verified it against IRS records, and your receipt repeats it. For a single - donation of $250 or more, US tax law requires you to hold a contemporaneous - written acknowledgment from the charity to claim a deduction - keep the receipt we send. We do - not give tax advice. -

-
- -
-

10. Contact

-

- Questions about a donation, this page, or an organization listed on civfix: email{" "} - roman@reachoutla.org. See also our{" "} - Terms of Service (donations, fees, refunds and host-organization - terms), our Privacy Policy (what we collect and how long we keep - it), and our Sub-processors list. -

-
-
- ) -} diff --git a/apps/community-web/src/app/legal/org-donation-agreement/page.tsx b/apps/community-web/src/app/legal/org-donation-agreement/page.tsx deleted file mode 100644 index fce12328..00000000 --- a/apps/community-web/src/app/legal/org-donation-agreement/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata } from "next" - -import { LegalPage } from "@/components/legal/legal-page" -import { OrgDonationAgreementBody } from "@/components/legal/org-donation-agreement-body" - -export const metadata: Metadata = { - title: "Organization Donation Agreement · civfix", - description: - "The agreement between civfix and an organization that raises donations through the platform: eligibility, fees, receipts, refunds, records and termination.", - robots: { index: true, follow: true }, -} - -export default function OrgDonationAgreementPage() { - return ( - - - - ) -} diff --git a/apps/community-web/src/app/legal/privacy/page.tsx b/apps/community-web/src/app/legal/privacy/page.tsx index e563c572..57255ca1 100644 --- a/apps/community-web/src/app/legal/privacy/page.tsx +++ b/apps/community-web/src/app/legal/privacy/page.tsx @@ -25,34 +25,12 @@ const RETENTION_ROWS: RetentionRow[] = [ basis: "A published report is a civic record that has been forwarded to or relied on by a government agency. It survives deletion of the account that filed it, shown as from a deleted user.", }, - { - category: "Donation records (amount, fees, dates, status, card brand and last four)", - period: "7 years from the date the card was charged", - basis: - "Financial record-keeping, charitable-solicitation reporting, receipt re-issue, and the dispute and chargeback window. This record is kept under a legal hold and is not deleted on request.", - }, - { - category: "Donor email address and name attached to a donation", - period: "7 years from the charge date, then removed from the donation record", - basis: - "Part of the receipt and the evidence a specific person made a specific gift. After 7 years the contact details are removed and only the pseudonymous financial record remains.", - }, { category: "Records of the terms and disclosures you accepted", period: "For the life of the record they relate to", basis: "The version and content hash of what you were shown is the only proof of what you agreed to; it is worthless if it is deleted before the record it evidences.", }, - { - category: "Payment card details", - period: "Never stored", - basis: "They go directly to Stripe and never reach civfix.", - }, - { - category: "Sanctions-screening evidence", - period: "10 years", - basis: "Required retention period for sanctions-screening records.", - }, { category: "Event registration answers to a host's questions", period: "30 days after the event", @@ -198,45 +176,18 @@ export default function PrivacyPage() { as a new follower or a report update).

-

Financial and payment information (donations)

+

Donation links

- If you donate to a host organization through civfix, your{" "} - card number, expiry and security code go directly to Stripe, our payment - processor, inside an iframe it controls. They never reach civfix’s servers and we never - store them. What we do receive and keep, as part of the donation record, is: + civfix does not process payments and never has access to your payment details.{" "} + An organization, a host or a person can add a donation link to their profile, + their organization page or an event. It is an ordinary external web address they choose. Opening + it takes you to that site, which is operated by them and their own payment processor: anything + you enter there - your name, your email address, your card - is collected by that site under its + own privacy notice, not ours. We receive no donation amount, no donor identity and no card data.

-
    -
  • - the donation amount and currency, the fees, the date and time your card was charged, and - whether the payment succeeded, failed, was refunded or disputed; -
  • -
  • - the brand and last four digits of the card, so a receipt and a later dispute - can be matched to the right payment; -
  • -
  • - your email address (required, so we can send the receipt) and your name if - you give one; -
  • -
  • - a record of the exact version and content hash of the terms, privacy notice and disclosures - you were shown when you paid, and whether you opted in to sharing your identity with the - recipient organization; and -
  • -
  • - the processor’s opaque references for the payment, which let us reconcile it and issue - a receipt. -
  • -

- Under California law this is “financial information”; the amounts and history of - your donations are also “commercial information”. We do not use either category to - profile you, to infer characteristics about you, or for any advertising purpose, and we never - sell or share it. -

-

- Notice at collection. The donation form itself repeats this in short form at - the point you enter your details, so you know what is collected and why before you pay. + We count how many times a donation link on an event is opened. That count is a daily total shown + to the host and carries no identifier of who opened it.

Technical, security, and anti-abuse information

@@ -345,23 +296,6 @@ export default function PrivacyPage() { challenges, and serve the map. They process information on our behalf under contract. See our{" "} Sub-processors list. -
  • - Stripe, our payment processor. Stripe processes donations on our behalf{" "} - and acts as an independent controller in its own right for fraud - prevention, anti-money-laundering and sanctions screening, and its own legal and regulatory - obligations. Where Stripe acts as an independent controller, its own privacy notice governs - and civfix cannot direct or delete that processing. Under the direct-charge model civfix uses, - the recipient organization is the merchant of record and holds its own relationship with - Stripe. -
  • -
  • - The organization you donate to. It always receives the donation amount, the - date, and its fee and net figures - it is the recipient of the money. It receives your{" "} - name and email address only if you check the optional box on the donation - page saying it may know who you are; that box is off by default. If you - leave it off, your donation appears to the organization as an anonymous donation with no - identifier it could use to work out who you are. -
  • Event hosts. If you register for an event, the host sees your display name, your ticket type and party size, your answers to the host’s registration questions, and @@ -517,19 +451,6 @@ export default function PrivacyPage() { public and civic record described in Section 7, and we may retain limited information where the law allows or requires it.
  • -
  • - Erasure and donations - what we can and cannot delete. Deleting your account - immediately unlinks your profile from any donation you made: the donation - record stops pointing at your account and stops appearing in your donation history. The{" "} - financial record itself is kept for seven years from the charge date, under - the exceptions for legal obligations and the establishment or defence of legal claims (GDPR - Article 17(3)(b) and (e); Civil Code section 1798.105(d)). The email address and name on that - record are removed at the end of the seven years, leaving only a pseudonymous financial entry. - Separately, civfix cannot delete a payment record held by{" "} - Stripe or by the recipient organization: Stripe keeps its own records as an - independent controller with its own legal obligations, and the organization is the merchant - of record for the payment. Contact them directly for their own records. -
  • Restriction. Ask us to limit how we use your information while a request is being resolved. diff --git a/apps/community-web/src/app/legal/subprocessors/page.tsx b/apps/community-web/src/app/legal/subprocessors/page.tsx index 461508b9..c463fe1b 100644 --- a/apps/community-web/src/app/legal/subprocessors/page.tsx +++ b/apps/community-web/src/app/legal/subprocessors/page.tsx @@ -49,13 +49,6 @@ const ROWS: Row[] = [ data: "The address text you type into location search and the coordinates of the location you select or pin on the map. Because the request comes from our server, your device IP address is not shared with komoot.", location: "Germany / EU", }, - { - name: "Stripe, Inc. and Stripe Payments Company", - purpose: - "Payment processing for donations to host organizations. Donations are DIRECT charges on the recipient organization's own Stripe connected account: the organization is the merchant of record and civfix never receives or holds the funds. Stripe also acts as an INDEPENDENT CONTROLLER (not our processor) for fraud prevention, anti-money-laundering, sanctions screening and its own regulatory obligations, and it is the licensed money transmitter in the flow.", - data: "Donor name and email address; the donation amount and currency; the card or wallet payment details you enter directly into Stripe's hosted iframe (these never reach civfix's servers); the last four digits and brand of the card, returned to us for your receipt; IP address and device signals Stripe collects for fraud prevention.", - location: "United States, with onward transfers to Stripe entities in the EU and UK", - }, { name: "Apple Inc.", purpose: @@ -121,15 +114,6 @@ export default function SubprocessorsPage() { When we forward a report to the government body responsible for a location, that agency receives the report as an independent recipient, not as our sub-processor.

    -

    - A payment processor plays BOTH roles at once. Stripe processes donor data on our behalf when - it charges a donation we initiated, and it is an independent controller of the same data for - fraud prevention, anti-money-laundering and sanctions screening, and its own legal and - regulatory reporting. Where Stripe acts as an independent controller, its own privacy notice - governs and civfix cannot direct or delete that processing. The recipient host organization is - likewise an independent controller of any donor identity it receives - which happens only when - you explicitly opt in on the donation page. -

  • @@ -144,20 +128,6 @@ export default function SubprocessorsPage() { Privacy Policy for details and how to request a copy of the safeguards.

    -

    - For payment processing specifically, Stripe self-certifies under the EU-US Data Privacy - Framework, the UK Extension and the Swiss-US Data Privacy Framework, and additionally relies on - the European Commission’s Standard Contractual Clauses (Modules One and Two) together - with the UK International Data Transfer Addendum. Stripe publishes these terms in its{" "} - - Data Processing Agreement - {" "} - and its{" "} - - Privacy Policy - - . -

    diff --git a/apps/community-web/src/app/legal/terms/page.tsx b/apps/community-web/src/app/legal/terms/page.tsx index 828c86a5..49607a4f 100644 --- a/apps/community-web/src/app/legal/terms/page.tsx +++ b/apps/community-web/src/app/legal/terms/page.tsx @@ -195,151 +195,33 @@ export default function TermsPage() {
    -

    8. Donations through civfix

    +

    8. Donation links

    - Some verified nonprofit host organizations can accept donations through civfix. When you - donate,{" "} - - the host organization is the recipient of your donation and the merchant of record - - . civfix acts only as the organization’s authorized agent to solicit and facilitate the - payment. Your card is charged directly on that organization’s own account with our - payment processor, Stripe; the money never enters a civfix bank account or balance, and civfix - never holds, pools, or disburses donated funds. + civfix does not process donations. An organization, a host or a person can add + a donation link - an ordinary external web address of their own choosing - to + their profile, their organization page or an event. civfix displays that link and nothing more.

    - Because civfix collects the payment as the organization’s authorized agent,{" "} + Following a donation link takes you to a site civfix does not operate. Any payment you make + there is a transaction between you and whoever runs that site, on their terms and through their + payment processor.{" "} - your payment to civfix satisfies your obligation to the recipient organization - {" "} - to the extent of the amount you paid, whether or not civfix or the processor subsequently - remits it. If a donation cannot be delivered to the organization you chose - for example if it - loses its good standing before the payment settles - we will tell you and refund it. -

    -

    - A donation is a voluntary contribution, not a purchase. You receive no goods or services in - exchange, and you must be authorized to use the payment method you provide. We may refuse or - reverse a donation we reasonably believe is fraudulent, unlawful, or made with a payment - method you are not authorized to use. -

    -

    - civfix is not a charity that is soliciting for itself when it hosts these pages. Whether a - donation is tax-deductible depends entirely on the recipient organization’s own tax - status, which we display on the donation page along with the date we last verified it against - public records. We do not give tax advice. -

    -
    - -
    -

    9. Fees on donations

    -

    - civfix charges a platform fee of 5% of the donation amount. Separately, the - payment processor charges the recipient organization its own card-processing fee. Both are - itemized on the donation page, with the estimated amount the organization receives,{" "} - before you enter any payment details and before you pay. We will never tell - you that 100% of your donation reaches the organization, because that would not be true. -

    -

    - The processing fee shown before payment is an estimate: the exact amount depends on the card - and method you use and is set by the processor, not by us. The exact figures appear on your - receipt. -

    -

    - Refundability of the platform fee: if a donation is refunded in full or in - part, civfix reverses its 5% platform fee proportionally, so you are not charged a civfix fee - on money you got back. The payment processor’s own fee is generally retained by the - processor on a refunded payment; that is the processor’s policy, not a civfix charge, and - it is borne by the recipient organization rather than by you. -

    -
    - -
    -

    10. Refunds

    -

    - Donations are non-refundable except where the law requires a refund or where - the recipient organization chooses to issue one. The recipient organization controls refunds - for its own donations, because it is the merchant of record; civfix cannot issue a refund on - its behalf. To request one, contact the organization first. If you believe a charge was - unauthorized or fraudulent, contact us at{" "} - roman@reachoutla.org and we will help you reach the - organization and, where appropriate, act on our own. -

    -

    - If a donation is refunded, the platform fee is reversed proportionally as described in Section - 9. Refunds are returned to the original payment method. -

    -
    - -
    -

    11. Terms for host organizations that accept donations

    -

    - If you connect an organization to civfix to receive donations, these additional terms apply to - you and to that organization, and you confirm you are authorized to accept them on its behalf. -

    -
      -
    • - Processor agreement. You accept the Stripe Connected Account Agreement - (including the Stripe Services Agreement) directly with Stripe during onboarding. That - agreement is between the organization and Stripe; civfix is not a party to it and cannot vary - it. -
    • -
    • - Eligibility and notice. The organization represents that it is a tax-exempt - organization in good standing under Internal Revenue Code section 501(c)(3), registered and - in good standing where its solicitation requires registration, and not on any list barring it - from soliciting. You must notify civfix{" "} - within five business days of losing, or being notified you may lose, any of - that status. We re-verify against public records on a schedule and will suspend donations - immediately if the organization ceases to be in good standing. -
    • -
    • - Merchant of record. The organization is the merchant of record for every - donation it receives. It is solely responsible for its own tax reporting (including any Form - 1099-K issued to it by the processor), for its donors’ substantiation, for chargebacks - and disputes, and for any refunds it chooses to give. Chargeback and refund amounts, and the - processor fees on them, are borne by the organization. -
    • -
    • - Receipts. You authorize civfix to issue donation receipts and - acknowledgments to donors on the organization’s behalf and in its name - , using the legal name, address and employer identification number shown in public tax - records, and to state on those receipts whether the donation is tax-deductible. You remain - responsible for the accuracy of your own tax status. -
    • -
    • - Fees. The organization agrees to the platform fee disclosed to it when it - enabled donations. We will not raise that fee without giving you notice and asking you to - accept a new version of this agreement; until you do, we charge the rate you already - accepted. -
    • -
    • - Suspension. civfix may suspend or disable an organization’s donation - page at any time - including immediately and without notice - if it loses good standing, if - the processor disables its account, if we are directed to by a regulator, or if we - reasonably suspect fraud or misuse. -
    • -
    -
    - -
    -

    12. Receipts and acknowledgments

    -

    - civfix sends a receipt for every completed donation, by email, as the recipient - organization’s authorized agent, normally within minutes and in any case{" "} - within five business days. The receipt names the recipient organization, the - amount, the date your card was charged, whether the donation is tax-deductible and to what - extent, and an itemization of the fees. It contains no promotional content. + civfix is not a party to it, never receives, holds or disburses the money, charges no fee on + it, and issues no receipt + + . We do not verify that a linked site belongs to the person or organization that posted it, that + it is a registered charity, or that a gift made there is tax-deductible. Check who you are + giving to before you give, and direct any question about a payment, a receipt or a refund to the + recipient.

    - For a single donation of $250 or more, US tax law requires you to hold a - contemporaneous written acknowledgment from the charity in order to claim a deduction; the - receipt civfix issues on the organization’s behalf is intended to serve that purpose, and - you should keep it. We do not give tax advice; consult your own advisor. + Posting a donation link means you confirm it is yours to post and that it is lawful to solicit + through it. We may remove a donation link at any time, including immediately and without notice, + if it is deceptive, unlawful, unsafe or otherwise breaks these Terms.

    -
    -

    13. Messages from event hosts

    +

    9. Messages from event hosts

    If you register for an event, the host can send you messages about that event - confirmations, reminders, changes, and updates - through civfix, by push notification and by email. Those @@ -367,7 +249,7 @@ export default function TermsPage() {

    -

    14. Third-party services

    +

    10. Third-party services

    civfix relies on third-party providers to operate, for example for sign-in, hosting, media storage and delivery, maps, email, and push notifications. Your use of those features may also @@ -379,7 +261,7 @@ export default function TermsPage() {

    -

    15. Intellectual property in civfix

    +

    11. Intellectual property in civfix

    civfix, including its software, design, brand, and the “civfix” name and logo, is owned by us and our licensors and is protected by intellectual-property laws. These Terms do @@ -389,7 +271,7 @@ export default function TermsPage() {

    -

    16. Disclaimers

    +

    12. Disclaimers

    civfix is provided “as is” and{" "} “as available,” without warranties of any kind, whether express, @@ -403,7 +285,7 @@ export default function TermsPage() {

    -

    17. Limitation of liability

    +

    13. Limitation of liability

    To the maximum extent permitted by law, civfix and its operators, owners, employees, and providers will not be liable for any indirect, incidental, special, consequential, exemplary, @@ -418,7 +300,7 @@ export default function TermsPage() {

    -

    18. Indemnification

    +

    14. Indemnification

    You agree to indemnify and hold harmless civfix and its operators and providers from any claims, damages, liabilities, and expenses (including reasonable legal fees) arising out of @@ -428,7 +310,7 @@ export default function TermsPage() {

    -

    19. Changes, suspension, and termination

    +

    15. Changes, suspension, and termination

    We may change, suspend, or discontinue any part of civfix at any time. We may update these Terms; when we make material changes we will update the “Last updated” date and, @@ -440,7 +322,7 @@ export default function TermsPage() {

    -

    20. Governing law and disputes

    +

    16. Governing law and disputes

    These Terms are governed by the laws of the State of California, without regard to its conflict-of-laws rules, and you and we submit to the exclusive jurisdiction of the state and @@ -451,15 +333,15 @@ export default function TermsPage() { If you are a consumer in the European Economic Area or the United Kingdom, this choice of law and forum does not deprive you of the protection of the mandatory consumer-protection laws of your country of residence, and you may bring proceedings in the courts of that country. - Nothing in these Terms (including the disclaimers in Section 16 and the liability limits in - Section 17) limits or excludes any right or remedy you have under that mandatory law, or any + Nothing in these Terms (including the disclaimers in Section 12 and the liability limits in + Section 13) limits or excludes any right or remedy you have under that mandatory law, or any liability that cannot lawfully be limited or excluded. Your data-protection rights are described in the Privacy Policy.

    -

    21. Miscellaneous

    +

    17. Miscellaneous

    These Terms, together with the Privacy Policy, are the entire agreement between you and us about civfix. If any provision is found unenforceable, the rest stays in effect. Our failure @@ -469,7 +351,7 @@ export default function TermsPage() {

    -

    22. Contact

    +

    18. Contact

    Questions about these Terms? Email{" "} roman@reachoutla.org. diff --git a/apps/community-web/src/app/orgs/org-page.css b/apps/community-web/src/app/orgs/org-page.css index 2a730a5a..a253c6b6 100644 --- a/apps/community-web/src/app/orgs/org-page.css +++ b/apps/community-web/src/app/orgs/org-page.css @@ -1,7 +1,7 @@ /* ========================================================================= Public organization page: /orgs/ - A standalone DOM route in the /e and /donate mould: the audience is a + A standalone DOM route in the /e mould: the audience is a visitor arriving from a shared link (often signed out, often on a phone), so the page is plain semantic HTML that paints without the app shell, the map or the RN stack. In-app navigation to an organization still opens the diff --git a/apps/community-web/src/components/console/charts/kpi-cell.tsx b/apps/community-web/src/components/console/charts/kpi-cell.tsx index 8f045b0d..e42b4f67 100644 --- a/apps/community-web/src/components/console/charts/kpi-cell.tsx +++ b/apps/community-web/src/components/console/charts/kpi-cell.tsx @@ -17,7 +17,7 @@ export interface KpiDelta { export interface KpiCellProps { label: string value: ReactNode - sub?: string + sub?: ReactNode delta?: KpiDelta spark?: readonly number[] size?: "card" | "strip" diff --git a/apps/community-web/src/components/console/chips/chip-kinds.ts b/apps/community-web/src/components/console/chips/chip-kinds.ts index c2eb4778..4fd23dde 100644 --- a/apps/community-web/src/components/console/chips/chip-kinds.ts +++ b/apps/community-web/src/components/console/chips/chip-kinds.ts @@ -6,14 +6,10 @@ import type { CleanupMemberRole, CleanupStatus, DeliveryStatus, - DonateState, - DonationDisputeState, - DonationStatus, EventPageStatus, EventVisibility, HostExportStatus, OrganizationMemberRole, - OrgPaymentsState, OrgVerificationStatus, RegistrantKind, RegistrationState, @@ -49,7 +45,6 @@ import { HandHeart, Hourglass, KeyRound, - Link2Off, Lock, Mail, Megaphone, @@ -57,15 +52,12 @@ import { PartyPopper, Pencil, RefreshCw, - RotateCcw, Send, - ShieldAlert, ShieldCheck, Smartphone, Sparkles, TicketCheck, TriangleAlert, - Undo2, UserCheck, UserCog, UserPlus, @@ -224,36 +216,9 @@ const deliveryStatus = { skipped: { hue: "neutral", icon: CircleSlash, labelKey: "enums:deliveryStatus.skipped" }, } satisfies Record -const donationStatus = { - pending: { hue: "neutral", icon: CircleDashed, labelKey: "enums:donationStatus.pending" }, - succeeded: { hue: "moss", icon: CircleCheck, labelKey: "enums:donationStatus.succeeded" }, - failed: { hue: "bloom", icon: TriangleAlert, labelKey: "enums:donationStatus.failed" }, - refunded: { hue: "sun", icon: RotateCcw, labelKey: "enums:donationStatus.refunded" }, - partially_refunded: { hue: "sun", icon: Undo2, labelKey: "enums:donationStatus.partially_refunded" }, -} satisfies Record - -const disputeState = { - none: { hue: "neutral", icon: CircleDashed, labelKey: "enums:donationDisputeState.none" }, - open: { hue: "sun", icon: ShieldAlert, labelKey: "enums:donationDisputeState.open" }, - won: { hue: "moss", icon: ShieldCheck, labelKey: "enums:donationDisputeState.won" }, - lost: { hue: "bloom", icon: Ban, labelKey: "enums:donationDisputeState.lost" }, - warning: { hue: "sun", icon: TriangleAlert, labelKey: "enums:donationDisputeState.warning" }, -} satisfies Record - -const orgPayments = { - not_started: { hue: "neutral", icon: CircleDashed, labelKey: "enums:orgPaymentsState.not_started" }, - onboarding: { hue: "sky", icon: Hourglass, labelKey: "enums:orgPaymentsState.onboarding" }, - ready: { hue: "moss", icon: CircleCheck, labelKey: "enums:orgPaymentsState.ready" }, - at_risk: { hue: "sun", icon: TriangleAlert, labelKey: "enums:orgPaymentsState.at_risk" }, - blocked: { hue: "bloom", icon: Ban, labelKey: "enums:orgPaymentsState.blocked", bold: true }, -} satisfies Record - -const donateState = { - READY: { hue: "moss", icon: HandHeart, labelKey: "enums:donateState.READY" }, - AT_RISK: { hue: "sun", icon: TriangleAlert, labelKey: "enums:donateState.AT_RISK" }, - BLOCKED: { hue: "bloom", icon: Ban, labelKey: "enums:donateState.BLOCKED" }, - OFF: { hue: "neutral", icon: Link2Off, labelKey: "enums:donateState.OFF" }, -} satisfies Record + + + const exportStatus = { queued: { hue: "neutral", icon: CircleDashed, labelKey: "enums:hostExportStatus.queued" }, @@ -295,10 +260,6 @@ export const CHIP_KINDS = { "broadcast-kind": broadcastKind, channel, "delivery-status": deliveryStatus, - "donation-status": donationStatus, - "dispute-state": disputeState, - "org-payments": orgPayments, - "donate-state": donateState, "export-status": exportStatus, } as const diff --git a/apps/community-web/src/components/console/route.test.ts b/apps/community-web/src/components/console/route.test.ts index bb480c49..486f6f4f 100644 --- a/apps/community-web/src/components/console/route.test.ts +++ b/apps/community-web/src/components/console/route.test.ts @@ -59,10 +59,14 @@ describe("parseConsoleRoute", () => { orgId: "o1", section: "overview", }) - expect(parseConsoleRoute("/manage/orgs/o1/payments/")).toEqual({ + expect(parseConsoleRoute("/manage/orgs/o1/members/")).toEqual({ kind: "org", orgId: "o1", - section: "payments", + section: "members", + }) + expect(parseConsoleRoute("/manage/orgs/o1/payments/")).toEqual({ + kind: "not-found", + path: "/manage/orgs/o1/payments/", }) }) @@ -178,7 +182,6 @@ describe("parseConsoleRoute", () => { { kind: "portfolio" }, { kind: "org-new" }, { kind: "org", orgId: "o1", section: "overview" }, - { kind: "org", orgId: "o1", section: "payments" }, ...ORG_SECTIONS.map((section): ConsoleRoute => ({ kind: "org", orgId: "o2", section })), { kind: "org-invite-accept", token: null }, { kind: "org-invite-accept", token: "abcdefghijklmnopqrstuvwxyz0123" }, diff --git a/apps/community-web/src/components/console/route.ts b/apps/community-web/src/components/console/route.ts index 0da51136..e1190c3b 100644 --- a/apps/community-web/src/components/console/route.ts +++ b/apps/community-web/src/components/console/route.ts @@ -18,12 +18,11 @@ export const ORG_SECTIONS = [ "members", "verification", "settings", - "payments", ] as const export type OrgSection = (typeof ORG_SECTIONS)[number] /** Org sections only an owner or admin may open; members see the rest. */ -export const ORG_MANAGE_SECTIONS: readonly OrgSection[] = ["verification", "settings", "payments"] +export const ORG_MANAGE_SECTIONS: readonly OrgSection[] = ["verification", "settings"] /** The `/manage/orgs/` that is a verb, not an org id. */ const ORG_NEW_SEGMENT = "new" diff --git a/apps/community-web/src/components/console/url-state.ts b/apps/community-web/src/components/console/url-state.ts index 8b661181..a6889145 100644 --- a/apps/community-web/src/components/console/url-state.ts +++ b/apps/community-web/src/components/console/url-state.ts @@ -17,7 +17,6 @@ export const CONSOLE_REPLACE_PARAM_KEYS = [ "block", "preview", "next", - "stripe", ] as const export const CONSOLE_PUSH_PARAM_KEYS = [ diff --git a/apps/community-web/src/components/dev/bodies-gallery.tsx b/apps/community-web/src/components/dev/bodies-gallery.tsx index 30bfaa17..723a4585 100644 --- a/apps/community-web/src/components/dev/bodies-gallery.tsx +++ b/apps/community-web/src/components/dev/bodies-gallery.tsx @@ -74,7 +74,6 @@ import { DASHBOARD_EVENT_ERROR_ID, DASHBOARD_EVENT_IDS, DASHBOARD_EVENT_PENDING_ID, - DASHBOARD_EVENT_REFUNDED_ID, emptyPortfolioOverrides, failing, makeDashboardFakeApi, @@ -1508,10 +1507,6 @@ export default function BodiesGallery() { - - - - diff --git a/apps/community-web/src/components/dev/dashboard-fixtures.ts b/apps/community-web/src/components/dev/dashboard-fixtures.ts index fe949dbd..e48f7017 100644 --- a/apps/community-web/src/components/dev/dashboard-fixtures.ts +++ b/apps/community-web/src/components/dev/dashboard-fixtures.ts @@ -11,9 +11,6 @@ import type { EventInsights, EventPhase, EventRegistrationDTO, - GetOrgBalanceResponse, - GetOrgDonationSummaryResponse, - GetOrgPaymentsStatusResponse, HostedEventDTO, HostedEventsAnalyticsResponse, LeaderboardEntryDTO, @@ -24,7 +21,6 @@ import type { ListMyOrgInvitesResponse, ListOrganizationInvitesResponse, ListOrganizationMembersResponse, - ListOrgPayoutsResponse, OrganizationDTO, PersonDTO, PortfolioAnalyticsRange, @@ -46,7 +42,6 @@ export const DASHBOARD_EVENT_IDS: Readonly> = { export const DASHBOARD_EVENT_PENDING_ID = "ev-pending" export const DASHBOARD_EVENT_ERROR_ID = "ev-error" -export const DASHBOARD_EVENT_REFUNDED_ID = "ev-refunded" const PHASE_BY_EVENT_ID: Readonly> = { [DASHBOARD_EVENT_IDS.upcoming]: "upcoming", @@ -55,7 +50,6 @@ const PHASE_BY_EVENT_ID: Readonly> = { [DASHBOARD_EVENT_IDS.cancelled]: "cancelled", [DASHBOARD_EVENT_PENDING_ID]: "live", [DASHBOARD_EVENT_ERROR_ID]: "live", - [DASHBOARD_EVENT_REFUNDED_ID]: "ended", } const PHASE_TITLES: Readonly> = { @@ -99,7 +93,6 @@ const FULL_HOST_CAPABILITIES: CleanupDTO["myCapabilities"] = [ "manage_org_link", "moderate_chat", "request_resources", - "view_donations", ] const EVENT_ORGANIZATION: NonNullable = { @@ -109,6 +102,7 @@ const EVENT_ORGANIZATION: NonNullable = { logoUrl: null, verified: true, verifiedKind: "nonprofit", + donationUrl: "https://bayviewstewards.org/give", } export const DASHBOARD_ORGS: readonly OrganizationDTO[] = [ @@ -128,8 +122,7 @@ export const DASHBOARD_ORGS: readonly OrganizationDTO[] = [ memberCount: 24, eventCount: 31, myRole: "owner", - donationsEnabled: true, - donateSlug: "bayview-stewards", + donationUrl: "https://bayviewstewards.org/give", suspended: false, }, { @@ -148,8 +141,7 @@ export const DASHBOARD_ORGS: readonly OrganizationDTO[] = [ memberCount: 9, eventCount: 6, myRole: "admin", - donationsEnabled: false, - donateSlug: null, + donationUrl: null, suspended: false, }, ] @@ -226,92 +218,9 @@ const ORG_INVITE_LIST: ListOrganizationInvitesResponse = { ], } -const PAYMENTS_STATUS: GetOrgPaymentsStatusResponse = { - organizationId: "org-bayview", - state: "ready", - stripeAccountId: "acct_gallery_bayview", - livemode: true, - detailsSubmitted: true, - chargesEnabled: true, - payoutsEnabled: true, - disabledReason: null, - currentlyDue: [], - pastDue: [], - pendingVerification: [], - futureCurrentlyDue: [], - currentDeadline: null, - capabilities: { card_payments: "active", transfers: "active" }, - paymentMethodDomains: [], - walletsAvailable: ["apple_pay", "google_pay"], - donationsEnabled: true, - donationsDisabledReason: null, - agreement: { - version: "2026-01", - acceptedAt: new Date(FIXTURE_NOW - 120 * DAY_MS).toISOString(), - acceptedByName: "Sam Okafor", - current: true, - requiredVersion: "2026-01", - }, - eligibility: { - verdict: "eligible", - reasons: [], - einLast4: "4417", - irsLegalName: "Bayview Stewards Inc", - deductibilityCode: "PC", - foundationCode: "15", - graceExpiresAt: null, - evaluatedAt: new Date(FIXTURE_NOW - 12 * DAY_MS).toISOString(), - nextCheckAt: new Date(FIXTURE_NOW + 18 * DAY_MS).toISOString(), - checks: [], - }, - donateState: "READY", - lastSyncedAt: new Date(FIXTURE_NOW - 2 * HOUR_MS).toISOString(), -} -const ORG_BALANCE: GetOrgBalanceResponse = { - available: { amountMinor: 128_400, currency: "USD" }, - pending: { amountMinor: 21_500, currency: "USD" }, - payoutsEnabled: true, - payoutSchedule: { interval: "manual" }, - lastSyncedAt: new Date(FIXTURE_NOW - 2 * HOUR_MS).toISOString(), -} -const DONATION_SUMMARY: GetOrgDonationSummaryResponse = { - currency: "USD", - donationCount: 37, - grossMinor: 214_500, - platformFeeMinor: 6_435, - processorFeeMinor: 7_320, - netMinor: 200_745, - refundedMinor: 5_000, - disputedCount: 0, - from: new Date(FIXTURE_NOW - 30 * DAY_MS).toISOString(), - to: null, -} -const ORG_PAYOUTS: ListOrgPayoutsResponse = { - items: [ - { - id: "payout-1", - stripePayoutId: "po_gallery_1", - amount: { amountMinor: 96_000, currency: "USD" }, - status: "paid", - arrivalDate: new Date(FIXTURE_NOW - 9 * DAY_MS).toISOString(), - createdAt: new Date(FIXTURE_NOW - 12 * DAY_MS).toISOString(), - failureMessage: null, - }, - { - id: "payout-2", - stripePayoutId: "po_gallery_2", - amount: { amountMinor: 42_500, currency: "USD" }, - status: "in_transit", - arrivalDate: new Date(FIXTURE_NOW + 2 * DAY_MS).toISOString(), - createdAt: new Date(FIXTURE_NOW - 1 * DAY_MS).toISOString(), - failureMessage: null, - }, - ], - nextCursor: null, -} const EXTRA_TOP_VOLUNTEERS: readonly LeaderboardEntryDTO[] = [ { @@ -404,15 +313,12 @@ export function soloPortfolioOverrides(): Record { } } -function insightsFor(id: string, phase: EventPhase): EventInsights { - return fakeEventInsights(phase, { - now: FIXTURE_NOW, - ...(id === DASHBOARD_EVENT_REFUNDED_ID ? { refunded: true } : {}), - }) +function insightsFor(phase: EventPhase): EventInsights { + return fakeEventInsights(phase, { now: FIXTURE_NOW }) } function phaseCleanup(id: string, phase: EventPhase): CleanupDTO { - const insights = insightsFor(id, phase) + const insights = insightsFor(phase) return { id, referenceCode: "BAY-4821", @@ -452,7 +358,7 @@ function phaseCleanup(id: string, phase: EventPhase): CleanupDTO { } function phaseCounters(id: string, phase: EventPhase): EventCheckinCountersDTO { - const insights = insightsFor(id, phase) + const insights = insightsFor(phase) const startsAt = Date.parse(insights.clock.startsAt) return { registered: insights.seats.registered, @@ -486,7 +392,7 @@ const ROSTER_PEOPLE: readonly { id: string; name: string; handle: string }[] = [ ] function phaseRoster(id: string, phase: EventPhase): ListEventRegistrationsResponse { - const insights = insightsFor(id, phase) + const insights = insightsFor(phase) const startsAt = Date.parse(insights.clock.startsAt) const checkedInRows = phase === "upcoming" || phase === "cancelled" ? 0 : 4 const items: EventRegistrationDTO[] = ROSTER_PEOPLE.map((who, i) => { @@ -568,11 +474,6 @@ export const DASHBOARD_FAKE_ENDPOINTS: Record = { }), listOrganizationMembers: async () => ORG_MEMBERS, listOrganizationInvites: async () => ORG_INVITE_LIST, - getOrgPaymentsStatus: async () => PAYMENTS_STATUS, - getOrgBalance: async () => ORG_BALANCE, - getOrgDonationSummary: async () => DONATION_SUMMARY, - listOrgPayouts: async () => ORG_PAYOUTS, - listOrgDonationExports: async () => ({ items: [] }), getCleanup: async (args) => { const id = argId(args) return phaseCleanup(id, phaseOf(id)) @@ -581,7 +482,7 @@ export const DASHBOARD_FAKE_ENDPOINTS: Record = { const id = argId(args) if (id === DASHBOARD_EVENT_PENDING_ID) return pendingForever()() if (id === DASHBOARD_EVENT_ERROR_ID) return failing("getEventInsights")() - return Promise.resolve(insightsFor(id, phaseOf(id))) + return Promise.resolve(insightsFor(phaseOf(id))) }, getEventCheckinCounters: (args) => { const id = argId(args) diff --git a/apps/community-web/src/components/legal/legal-entity.ts b/apps/community-web/src/components/legal/legal-entity.ts index 8d38f7f4..09e67d89 100644 --- a/apps/community-web/src/components/legal/legal-entity.ts +++ b/apps/community-web/src/components/legal/legal-entity.ts @@ -1,11 +1,3 @@ export const LEGAL_ENTITY = "Reach Out Los Angeles" -export const CA_REGISTRATION_NUMBER: string | null = null - -export const CA_REGISTRATION_LABEL = CA_REGISTRATION_NUMBER ?? "Registration pending" - -export const PLATFORM_FEE_LABEL = "5%" - export const LEGAL_CONTACT_EMAIL = "legal@civfix.org" - -export const DONATIONS_CONTACT_EMAIL = "donations@civfix.org" diff --git a/apps/community-web/src/components/legal/legal-page.tsx b/apps/community-web/src/components/legal/legal-page.tsx index b2f3e876..17173223 100644 --- a/apps/community-web/src/components/legal/legal-page.tsx +++ b/apps/community-web/src/components/legal/legal-page.tsx @@ -5,25 +5,15 @@ import type { LegalDocumentType } from "@civfix/shared" import { Wordmark } from "@/components/brand" -import { CA_REGISTRATION_LABEL, CA_REGISTRATION_NUMBER, LEGAL_ENTITY } from "./legal-entity" +import { LEGAL_ENTITY } from "./legal-entity" -export type LegalDocId = - | "terms" - | "privacy" - | "cookies" - | "subprocessors" - | "donations" - | "org-donation-agreement" - | "donation-disclosure" +export type LegalDocId = "terms" | "privacy" | "cookies" | "subprocessors" export const LEGAL_DOC_TYPE: Readonly> = { terms: "terms", privacy: "privacy", cookies: "cookies", subprocessors: "subprocessors", - donations: "donations", - "org-donation-agreement": "org_donation_agreement", - "donation-disclosure": "donation_disclosure", } const DOCS: { id: LegalDocId; href: string; label: string }[] = [ @@ -31,20 +21,9 @@ const DOCS: { id: LegalDocId; href: string; label: string }[] = [ { id: "privacy", href: "/legal/privacy", label: "Privacy Policy" }, { id: "cookies", href: "/legal/cookies", label: "Cookies & Storage" }, { id: "subprocessors", href: "/legal/subprocessors", label: "Sub-processors" }, - { id: "donations", href: "/legal/donations", label: "Donations & Fundraising" }, - { - id: "org-donation-agreement", - href: "/legal/org-donation-agreement", - label: "Organization Donation Agreement", - }, - { - id: "donation-disclosure", - href: "/legal/donation-disclosure", - label: "Donation Disclosures", - }, ] -export { CA_REGISTRATION_LABEL, CA_REGISTRATION_NUMBER, LEGAL_ENTITY } +export { LEGAL_ENTITY } export function legalVersionFor(id: LegalDocId): LegalDocumentVersion { return legalDocument(LEGAL_DOC_TYPE[id]) diff --git a/apps/community-web/src/components/legal/legal-source-parity.test.ts b/apps/community-web/src/components/legal/legal-source-parity.test.ts deleted file mode 100644 index 875fafef..00000000 --- a/apps/community-web/src/components/legal/legal-source-parity.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { readFileSync } from "node:fs" -import { join } from "node:path" -import { fileURLToPath } from "node:url" -import { DONATION_DISCLOSURE_TEMPLATE } from "@civfix/shared/legal" -import { describe, expect, it } from "vitest" - -const appDir = fileURLToPath(new URL("../../..", import.meta.url)) - -function source(...segments: string[]): string { - return readFileSync(join(appDir, "src", ...segments), "utf8") -} - -const consentFlow = source("features", "host", "org", "payments", "consent-agreement-flow.tsx") -const agreementPage = source("app", "legal", "org-donation-agreement", "page.tsx") -const disclosurePage = source("app", "legal", "donation-disclosure", "page.tsx") - -describe("§318 agreement: the org accepts the text that is hashed", () => { - it("renders the same body component on the legal page and in the console sheet", () => { - for (const file of [agreementPage, consentFlow]) { - expect(file).toContain('from "@/components/legal/org-donation-agreement-body"') - expect(file).toContain("") - } - }) - - it("sends the hash of the build's own document, not one fetched separately", () => { - expect(consentFlow).toContain('import { legalDocument } from "@civfix/shared/legal"') - expect(consentFlow).toContain("const shownDoc = legalDocument(AGREEMENT_DOCUMENT)") - expect(consentFlow).toContain("documentSha256: shownDoc.sha256") - expect(consentFlow).toContain("version: shownDoc.version") - }) - - it("refuses to accept when the server requires a version this build does not render", () => { - expect(consentFlow).toContain( - "const staleBuild = requiredVersion !== null && requiredVersion !== shownDoc.version", - ) - expect(consentFlow).toMatch(/primaryDisabled=\{[^}]*staleBuild/s) - }) - - it("links the published agreement route", () => { - expect(consentFlow).toContain('const AGREEMENT_PATH = "/legal/org-donation-agreement"') - }) -}) - -describe("donation disclosures: one template, rendered not retyped", () => { - it("renders the shared template module", () => { - expect(disclosurePage).toContain( - 'import { DONATION_DISCLOSURE_TEMPLATE } from "@civfix/shared/legal"', - ) - }) - - it("hardcodes none of the seven disclosure sentences", () => { - const sentences = [ - DONATION_DISCLOSURE_TEMPLATE.recipient, - DONATION_DISCLOSURE_TEMPLATE.mayNotReceive, - DONATION_DISCLOSURE_TEMPLATE.remittanceTiming, - DONATION_DISCLOSURE_TEMPLATE.feePointer, - DONATION_DISCLOSURE_TEMPLATE.deductible, - DONATION_DISCLOSURE_TEMPLATE.notDeductible, - DONATION_DISCLOSURE_TEMPLATE.merchantOfRecord, - DONATION_DISCLOSURE_TEMPLATE.defaultRefundPolicy, - ...DONATION_DISCLOSURE_TEMPLATE.mayNotReceiveReasons, - ] - for (const sentence of sentences) expect(disclosurePage).not.toContain(sentence) - }) - - it("renders every template sentence through the module", () => { - for (const key of [ - "recipient", - "mayNotReceive", - "mayNotReceiveReasons", - "remittanceTiming", - "feePointer", - "deductible", - "notDeductible", - "merchantOfRecord", - "defaultRefundPolicy", - ]) { - expect(disclosurePage).toContain(`T.${key}`) - } - }) -}) diff --git a/apps/community-web/src/components/legal/org-donation-agreement-body.tsx b/apps/community-web/src/components/legal/org-donation-agreement-body.tsx deleted file mode 100644 index c9ae42df..00000000 --- a/apps/community-web/src/components/legal/org-donation-agreement-body.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import * as React from "react" - -import { - CA_REGISTRATION_LABEL, - DONATIONS_CONTACT_EMAIL, - LEGAL_CONTACT_EMAIL, - LEGAL_ENTITY, - PLATFORM_FEE_LABEL, -} from "./legal-entity" - -export function OrgDonationAgreementBody() { - return ( - <> -

    -

    1. Parties and scope

    -

    - This Organization Donation Agreement (the “Agreement”) is - between {LEGAL_ENTITY}, which operates the civfix platform ( - “civfix”, “we”,{" "} - “us”), and the organization that accepts it ( - “you”, “your organization”). It - governs one thing only: your use of civfix to solicit and receive charitable donations from - the public. -

    -

    - It is required by California Government Code section 12599.9 and its implementing - regulation, 11 CCR section 318, which oblige a charitable fundraising platform to have a - written, versioned agreement with every charity it enables people to donate to, and to - record that charity’s consent to it before solicitation begins. -

    -

    - This Agreement supplements the civfix Terms of Service and Privacy Policy, which continue - to apply to your organization’s use of civfix generally. Where this Agreement and the - Terms of Service conflict on a donations question, this Agreement controls. -

    -

    - You are not required to use civfix to fundraise, and accepting this Agreement does not - oblige you to keep donations turned on. You can switch donations off at any time from your - organization’s Payments settings. -

    -
    - -
    -

    2. Definitions

    -
      -
    • - Donation — a charitable contribution a donor makes to your - organization through a civfix donation page. -
    • -
    • - Donation page — the civfix page that solicits donations for your - organization, including any event page that links to it. -
    • -
    • - Processor — Stripe, Inc. and its affiliates, the payment processor - civfix uses. civfix uses no other payment processor for donations. -
    • -
    • - Connected account — the Stripe account, held in your - organization’s own name, into which donations are charged and settled. -
    • -
    • - Platform fee — the fee civfix charges on a donation, described in - section 7. -
    • -
    • - Processing fees — the fees the Processor charges your connected - account for accepting a payment. -
    • -
    -
    - -
    -

    3. Who civfix is in this relationship

    -

    - civfix is a charitable fundraising platform under California Government - Code section 12599.9. It is not a commercial fundraiser, not a fundraising counsel, and not - your agent for any purpose except issuing donation receipts (section 9). -

    -

    - civfix does not receive, hold, pool or disburse your donations. Every - donation is charged directly on your connected account and settles to that account. There - is no civfix balance the money passes through, and no point at which civfix could route a - donation elsewhere. That is a property of how the payments are built, not a promise about - how we behave. -

    -

    - civfix’s California charitable fundraising platform registration number is{" "} - {CA_REGISTRATION_LABEL}. civfix will not solicit donations for your - organization before that registration is effective. -

    -
    - -
    -

    4. Your eligibility and representations

    -

    By accepting this Agreement, and each time a donation is made, you represent that:

    -
      -
    • - your organization is exempt from federal income tax under section 501(c)(3){" "} - of the Internal Revenue Code, and that exemption has not been revoked, suspended or - placed under examination for revocation; -
    • -
    • - your organization is in good standing: it is not on the IRS Automatic - Revocation of Exemption List, not revoked by the California Franchise Tax Board, not - listed on the California Attorney General’s May Not Operate or Solicit for - Charitable Purposes list, not on any U.S. Treasury OFAC sanctions list, and current - on its registration and reporting obligations in every jurisdiction where it solicits; -
    • -
    • - the legal name, employer identification number, address and contact details you have - given civfix are accurate and match your organization’s IRS records; -
    • -
    • - the individual accepting this Agreement is authorized to bind your - organization; and -
    • -
    • - donations solicited through civfix will be used for your organization’s charitable - purposes as described on the donation page. -
    • -
    -

    - Duty to notify. You will notify civfix in writing{" "} - within five (5) business days if any of these representations stops being - true — in particular if your tax-exempt status is revoked, suspended or challenged, - if you are added to any of the lists above, if your registration in a solicitation - jurisdiction lapses, or if your legal name or employer identification number changes. Send - the notice to {DONATIONS_CONTACT_EMAIL}. -

    -

    - civfix independently re-checks public sources (IRS Publication 78, the IRS Exempt - Organizations Business Master File and Auto-Revocation List, the California Franchise Tax - Board and Attorney General registries, and OFAC) on a recurring schedule, and may suspend - donations under section 12 on what it finds. That checking does not reduce your duty to - notify us. -

    -
    - -
    -

    5. Your Stripe connected account

    -

    - To receive donations you must complete Stripe’s onboarding and hold a connected - account in your organization’s own name.{" "} - - You accept the Stripe Connected Account Agreement directly with Stripe during that - onboarding - {" "} - — it is a separate contract between your organization and Stripe, not something - civfix accepts on your behalf. civfix cannot waive, vary or interpret it. -

    -

    Under that arrangement:

    -
      -
    • - Your organization is the merchant of record for every donation. It is - the party that took the payment. -
    • -
    • - Stripe pays out directly to your organization’s bank account on - Stripe’s payout schedule. civfix does not control, delay or approve payouts and - cannot release funds Stripe is holding. -
    • -
    • - Your organization pays the processing fees Stripe charges on each - donation. civfix does not absorb them and does not add them to the donor’s charge. -
    • -
    • - Stripe, not civfix, files any Form 1099-K for your connected account and - sets the pricing on it. -
    • -
    • - Stripe may request identity and business documentation, hold funds, or restrict your - account under its own agreement. If Stripe restricts your account, civfix will show - donations as unavailable until Stripe clears it. -
    • -
    -

    - You are responsible for your organization’s own tax reporting, acknowledgment and - substantiation obligations arising from donations, and for keeping your connected - account’s details current. -

    -
    - -
    -

    6. What civfix displays on your donation page

    -

    - California law requires a set of donor-facing disclosures on the page where a donation is - solicited. civfix authors and displays them; you cannot edit or suppress them.{" "} - They are generated from verified data, not from text you supply. The current wording is - published, versioned and hashed at{" "} - civfix.org/legal/donation-disclosure. -

    -

    Your donation page will state:

    -
      -
    • that your organization, named by its IRS legal name, receives the donation directly;
    • -
    • - that there are limited circumstances in which your organization may not receive a - donation, with those circumstances listed and linked; -
    • -
    • when the funds reach your organization;
    • -
    • - the civfix platform fee, itemized before the donor pays, and that your organization pays - its own processing fees; -
    • -
    • - whether donations to your organization are tax deductible, and when civfix last checked; -
    • -
    • that your organization is the merchant of record and civfix is not the organizer; and
    • -
    • the refund policy that applies.
    • -
    -

    - You may supply an organization refund-policy statement and a short mission description. - Both must be accurate and not misleading. civfix will never display a claim that 100% of a - donation reaches your organization, because it does not. -

    -
    - -
    -

    7. Fees

    -

    - civfix charges a platform fee of {PLATFORM_FEE_LABEL} of the donation amount. - It is collected by the Processor as an application fee on the same charge, so the donor sees - it itemized before paying and your organization receives the donation less that fee and - less the Processor’s own fees. -

    -

    - The rate that applies to your organization is recorded with your acceptance of this - Agreement. If the two ever disagree, civfix charges the lower of the rate recorded - with your acceptance and the platform’s configured rate. A fee increase is not a - settings change: it requires a new version of this Agreement, notice to you under section - 13, and your acceptance of that version before the new rate applies. -

    -

    - civfix does not charge your organization a subscription, listing, onboarding or payout fee, - and does not add a separate charge to the donor. Where civfix refunds a donation’s - platform fee it does so proportionally (section 8). -

    -
    - -
    -

    8. Refunds, chargebacks and disputes

    -

    - Refunds and disputes are your organization’s to resolve. Because you - are the merchant of record, a refund comes out of your connected account and a chargeback - is contested by your organization through Stripe. civfix has no authority to refund a - donation on your behalf and no liability for a negative balance on your account. -

    -

    - Donations are presented to donors as generally non-refundable, subject to your - organization’s discretion and to any right the donor has under law. Where a donation - is refunded or a dispute is lost, civfix refunds its platform fee - proportionally. Processing fees charged by the Processor are not returned by - civfix and may not be returned by the Processor. -

    -

    - You will handle donor complaints about your organization, its programs and its use of - donations. civfix will forward complaints it receives and may report to the California - Attorney General as required. -

    -
    - -
    -

    9. Receipts — civfix as your authorized agent

    -

    - - You appoint civfix as your organization’s authorized agent for the limited purpose - of issuing a written donation receipt to each donor on your behalf - - , as contemplated by 11 CCR section 318(a)(9). civfix will send that receipt{" "} - within five (5) business days of the donation being charged, to the email - address the donor gave. -

    -

    The receipt will identify your organization by its IRS legal name, employer identification number and address; state the donation amount and the date it was charged; state that no goods or services were provided in exchange for the contribution; state whether the contribution is tax deductible; itemize the platform fee and the processing fee; and carry the donation and receipt identifiers.

    -

    - This appointment is limited to issuing receipts. It does not make civfix your agent for - soliciting, accepting, holding or applying donations, and it does not relieve your - organization of its own substantiation obligations, including contemporaneous written - acknowledgment for contributions of $250 or more. civfix does not provide tax advice to - your organization or to donors. -

    -
    - -
    -

    10. Donor information and privacy

    -

    - civfix shares a donor’s identity with your organization only where that donor - has opted in. The donation form asks, unchecked by default, whether the donor wants - their name and email address shared with your organization. Where they do not, the donation - appears in your reports as an anonymous donation with no identifying detail. -

    -

    - Payment details never reach civfix or your organization through civfix: card data is - entered inside the Processor’s own hosted fields and is held by the Processor. -

    -

    - Where donor personal information is shared with you, your organization is an independent - controller of it. You will handle it in accordance with applicable privacy law, use it only - for donor stewardship and legally required reporting, honor deletion and opt-out requests - you receive, and not sell or share it for cross-context behavioral advertising. civfix’s - own handling is described in the Privacy Policy and its - processors are listed at Sub-processors. -

    -
    - -
    -

    11. Records, reporting and retention

    -

    - civfix keeps a record of every donation solicited for your organization — the amount, - the fees, the date, the donation and receipt identifiers, and the consent evidence for this - Agreement — for at least seven (7) years from the date of the charge, - and longer where a legal hold applies. Consent records are never deleted by any retention or - erasure process: they are the evidence that the donation beside them was lawfully collected. -

    -

    - civfix makes a donation report available to your organization in the host console, covering - each donation, the fees deducted, the net amount, the receipt status and (where shared) the - donor, exportable as CSV. That report is the record contemplated by 11 CCR section 321. -

    -

    - You will keep your own records of donations received through civfix and of how they were - applied, as required of your organization by law. -

    -
    - -
    -

    12. Suspension and termination

    -

    - civfix may suspend or disable donations for your organization immediately{" "} - if any representation in section 4 stops being true or civfix reasonably believes it has; - if a public source shows a loss of exempt status, a revocation, a sanctions listing or a - “may not solicit” determination; if the Processor restricts your connected - account; if a legal or regulatory authority requires it; or if civfix reasonably believes - the donation page is being used unlawfully or deceptively. -

    -

    - Where civfix suspends donations it will tell you why, and will restore them once the reason - is resolved. Suspension stops new donations; it does not affect donations already charged, - which remain your organization’s money and settle normally. -

    -

    - Either party may terminate this Agreement at any time on written notice. You can end it by - turning donations off and telling us. Sections 8 through 11 and section 15 survive - termination for as long as they are needed to complete, report on and retain records of - donations already made. -

    -
    - -
    -

    13. Changes to this Agreement

    -

    - civfix may publish a new version of this Agreement. Each version carries a version - identifier, an effective date and a cryptographic hash of its text, published at{" "} - civfix.org/legal/org-donation-agreement. Old - versions are never edited, because acceptance records point at them. -

    -

    - - civfix will notify your organization of a new version and will ask you to accept it. - {" "} - A change that increases the platform fee, changes who receives donations, or changes the - receipt agency in section 9 takes effect for your organization{" "} - only once you have accepted the new version. Until then civfix continues to - operate under the version you accepted, or pauses donations if it cannot. civfix keeps a log - of these change notifications. -

    -
    - -
    -

    14. Electronic acceptance and evidence

    -

    - This Agreement is accepted electronically. When your organization accepts it, civfix records - the document type, the version, the sha256 hash of the exact text displayed, the time of - acceptance, the account that accepted it, the surface and screen it was accepted on, and the - version of the acceptance interface. That record is the evidence of consent required by 11 - CCR section 318, and it is retained under section 11. -

    -

    - Acceptance also affirms that the person accepting has authority to bind your organization. - You agree that an electronic acceptance recorded this way has the same effect as a signature. -

    -
    - -
    -

    15. General

    -

    - Governing law. This Agreement is governed by the laws of the State of - California, without regard to its conflict-of-laws rules. The state and federal courts - located in Los Angeles County, California have exclusive jurisdiction over any dispute - arising out of it, and each party consents to that jurisdiction. -

    -

    - No partnership. Nothing here creates a partnership, joint venture, - employment or franchise relationship, or (except for the limited receipt agency in section - 9) an agency relationship between civfix and your organization. -

    -

    - Assignment. Neither party may assign this Agreement without the - other’s written consent, except to a successor in interest to substantially all of its - assets or charitable purpose. -

    -

    - Severability and waiver. If a provision is held unenforceable, the rest - stays in force. A failure to enforce a provision is not a waiver of it. -

    -

    - Entire agreement. This Agreement, together with the civfix Terms of Service - and Privacy Policy, is the entire agreement between the parties about donations through - civfix, and supersedes any prior understanding on that subject. It does not vary your - separate agreement with the Processor. -

    -
    - -
    -

    16. Contact

    -

    - Questions about this Agreement:{" "} - {LEGAL_CONTACT_EMAIL}. Notices required by - section 4 or section 12:{" "} - {DONATIONS_CONTACT_EMAIL}. Postal notices - may be sent to {LEGAL_ENTITY}, Los Angeles, California. -

    -

    - A donor or member of the public with a concern about a charity may contact the California - Attorney General’s Registry of Charities and Fundraisers at{" "} - - oag.ca.gov/charities - - . -

    -
    - - ) -} diff --git a/apps/community-web/src/features/donate/amount-field.tsx b/apps/community-web/src/features/donate/amount-field.tsx deleted file mode 100644 index 545c0641..00000000 --- a/apps/community-web/src/features/donate/amount-field.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client" - -import * as React from "react" - -import { amountInputValue, formatMinorCompact, parseAmountToMinor } from "./donate-amount" - -interface AmountFieldProps { - suggestedAmountsMinor: readonly number[] - minAmountMinor: number - maxAmountMinor: number - value: string - onChange: (next: string) => void - disabled: boolean - errorId?: string - error?: string | null -} - -export function AmountField({ - suggestedAmountsMinor, - minAmountMinor, - maxAmountMinor, - value, - onChange, - disabled, - errorId, - error, -}: AmountFieldProps) { - const parsed = parseAmountToMinor(value) - const selectedMinor = parsed.ok ? parsed.amountMinor : null - const otherSelected = selectedMinor === null || !suggestedAmountsMinor.includes(selectedMinor) - const otherRef = React.useRef(null) - - return ( -
    - - -
    - Suggested amounts - {suggestedAmountsMinor.map((minor) => { - const id = `donate-amount-preset-${minor}` - return ( -
    - onChange(amountInputValue(minor))} - /> - -
    - ) - })} -
    - { - onChange("") - otherRef.current?.focus() - }} - /> - -
    -
    - -
    - -
    - - onChange(cause.target.value)} - /> -
    - - {error ? ( - - ) : null} -
    -
    - ) -} diff --git a/apps/community-web/src/features/donate/complete-view.source.test.ts b/apps/community-web/src/features/donate/complete-view.source.test.ts deleted file mode 100644 index 27c580df..00000000 --- a/apps/community-web/src/features/donate/complete-view.source.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from "node:fs" -import { fileURLToPath } from "node:url" -import { describe, expect, it } from "vitest" - -const completeView = readFileSync( - fileURLToPath(new URL("./complete-view.tsx", import.meta.url)), - "utf8", -) -const donateView = readFileSync( - fileURLToPath(new URL("./donate-view.tsx", import.meta.url)), - "utf8", -) - -const MUTATIONS = [ - "createDonationCheckout", - "requestOrgDonationExport", - "updateOrgDonationSettings", - "acceptOrgDonationAgreement", - "useMutation", -] - -describe("the confirmation view never fulfills a donation", () => { - it("calls no mutation of any kind", () => { - for (const mutation of MUTATIONS) expect(completeView).not.toContain(mutation) - }) - - it("reads status through exactly one endpoint", () => { - const calls = [...completeView.matchAll(/api\s*\.\s*(\w+)\(/g)].map((match) => match[1]) - expect([...new Set(calls)]).toEqual(["getDonationStatus"]) - }) - - it("marks succeeded only from what the server said, never from the browser", () => { - expect(completeView).toContain('status.status === "succeeded"') - expect(completeView).not.toMatch(/setPhase\(\{\s*kind:\s*"settled"[^}]*status:\s*\{/) - }) -}) - -describe("the checkout commit happens once, before any payment UI", () => { - it("creates the Checkout Session only from the Continue handler", () => { - const calls = [...donateView.matchAll(/api\s*\.\s*(\w+)\(/g)].map((match) => match[1]) - expect([...new Set(calls)].sort()).toEqual([ - "createDonationCheckout", - "getPublicOrgDonationPage", - ]) - }) - - it("guards the commit against a double submit", () => { - expect(donateView).toContain('if (!canContinue || commit.kind !== "editing"') - }) - - it("never loads Stripe.js before the org DTO resolves READY or AT_RISK", () => { - expect(donateView).toContain('commit.kind === "committed" ? loadStripeForAccount(') - const gate = donateView.indexOf('page.donateState === "READY" || page.donateState === "AT_RISK"') - expect(gate).toBeGreaterThan(-1) - }) -}) diff --git a/apps/community-web/src/features/donate/complete-view.tsx b/apps/community-web/src/features/donate/complete-view.tsx deleted file mode 100644 index 6f6fb5a6..00000000 --- a/apps/community-web/src/features/donate/complete-view.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client" - -import * as React from "react" -import { CheckCircle2, CircleAlert, Loader2, Printer } from "lucide-react" -import { ErrorCode, type GetDonationStatusResponse } from "@civfix/shared" - -import { api, toAppError } from "@/lib/api" -import { formatMinor } from "./donate-amount" -import { pollDecision } from "./donate-status-poll" -import { parseCompleteParams, statusTokenStorageKey } from "./donate-route" - -type Phase = - | { readonly kind: "unaddressed" } - | { readonly kind: "polling"; readonly status: GetDonationStatusResponse | null } - | { readonly kind: "settled"; readonly status: GetDonationStatusResponse } - | { readonly kind: "slow"; readonly status: GetDonationStatusResponse | null } - | { readonly kind: "unreadable" } - -function readStoredToken(donationId: string): string | null { - if (typeof window === "undefined") return null - try { - return window.sessionStorage.getItem(statusTokenStorageKey(donationId)) - } catch { - return null - } -} - -export function rememberStatusToken(donationId: string, token: string): void { - if (typeof window === "undefined") return - try { - window.sessionStorage.setItem(statusTokenStorageKey(donationId), token) - } catch { - return - } -} - -const CHARGED_AT = new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", -}) - -function formatChargedAt(value: string | null): string | null { - if (value === null) return null - const at = new Date(value) - return Number.isNaN(at.getTime()) ? null : CHARGED_AT.format(at) -} - -export function CompleteView() { - const [phase, setPhase] = React.useState({ kind: "polling", status: null }) - - React.useEffect(() => { - const params = parseCompleteParams(window.location.search) - if (params.donationId === null) { - setPhase({ kind: "unaddressed" }) - return - } - const token = params.statusToken ?? readStoredToken(params.donationId) - if (params.statusToken !== null) rememberStatusToken(params.donationId, params.statusToken) - - let cancelled = false - let timer: ReturnType | null = null - - const run = async (attempt: number, elapsedMs: number): Promise => { - let status: GetDonationStatusResponse | null = null - try { - status = await api.getDonationStatus({ - id: params.donationId as string, - ...(token ? { token } : {}), - ...(params.sessionId ? { sessionId: params.sessionId } : {}), - }) - } catch (error) { - if (cancelled) return - const code = toAppError(error).code - if ( - code === ErrorCode.NOT_FOUND || - code === ErrorCode.FORBIDDEN || - code === ErrorCode.UNAUTHORIZED - ) { - setPhase({ kind: "unreadable" }) - return - } - } - if (cancelled) return - if (status !== null) setPhase({ kind: "polling", status }) - - const decision = pollDecision(status?.status ?? null, attempt, elapsedMs) - if (decision.kind === "stop") { - if (decision.reason === "settled" && status !== null) setPhase({ kind: "settled", status }) - else setPhase({ kind: "slow", status }) - return - } - timer = setTimeout(() => { - void run(attempt + 1, elapsedMs + decision.delayMs) - }, decision.delayMs) - } - - void run(0, 0) - - return () => { - cancelled = true - if (timer !== null) clearTimeout(timer) - } - }, []) - - if (phase.kind === "unaddressed") { - return ( - -

    - This page confirms a donation you have just made. Open it from the link you were returned to - after paying, or check your email for the receipt. -

    -
    - ) - } - - if (phase.kind === "unreadable") { - return ( - -

    - This confirmation link is no longer valid. If your payment went through you will still get a - receipt by email; nothing about your donation depends on this page. -

    -
    - ) - } - - const status = phase.kind === "settled" ? phase.status : phase.status - const chargedAt = formatChargedAt(status?.chargedAt ?? null) - - if (phase.kind === "settled" && status !== null && status.status === "succeeded") { - return ( - -

    - Your donation of {formatMinor(status.amount.amountMinor)} to{" "} - {status.orgLegalName} went through. -

    -
    -
    -
    Reference
    -
    {status.reference}
    -
    - {chargedAt === null ? null : ( -
    -
    Charged
    -
    {chargedAt}
    -
    - )} - {status.maskedEmail === null ? null : ( -
    -
    Receipt sent to
    -
    {status.maskedEmail}
    -
    - )} -
    -

    - {status.receiptSent - ? "Your receipt is on its way to that address." - : "Your receipt will arrive by email shortly."}{" "} - Keep it: for a donation of $250 or more you need it to claim a deduction. -

    -
    - -
    -

    You can close this window.

    -
    - ) - } - - if (phase.kind === "settled" && status !== null && status.status === "failed") { - return ( - -

    - Your card was not charged. Nothing was sent to {status.orgLegalName}. You can try again from - the donation page. -

    -
    - ) - } - - if (phase.kind === "settled" && status !== null) { - return ( - -

    - Your donation of {formatMinor(status.amount.amountMinor)} to {status.orgLegalName} is marked{" "} - {status.status.replace(/_/g, " ")}. Reference {status.reference}. -

    -
    - ) - } - - if (phase.kind === "slow") { - return ( - -

    - Your payment is taking longer than usual to settle. You do not need to wait here or pay again - — we will email your receipt as soon as it completes. -

    -
    - ) - } - - return ( - -

    Checking with the payment processor. This usually takes a few seconds.

    -
    - ) -} - -interface CompleteShellProps { - title: string - icon?: "success" | "alert" | "pending" - children: React.ReactNode -} - -function CompleteShell({ title, icon, children }: CompleteShellProps) { - return ( -
    -
    -
    - {icon === "success" ?
    -

    {title}

    - {children} -

    - How donations through civfix work ·{" "} - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - Back to civfix -

    -
    -
    - ) -} diff --git a/apps/community-web/src/features/donate/consent-block.tsx b/apps/community-web/src/features/donate/consent-block.tsx deleted file mode 100644 index daa993da..00000000 --- a/apps/community-web/src/features/donate/consent-block.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client" - -import * as React from "react" -import type { DonorSharingPolicy } from "@civfix/shared" - -interface ShareIdentityCheckboxProps { - policy: DonorSharingPolicy - checked: boolean - onChange: (next: boolean) => void - disabled: boolean -} - -export function ShareIdentityCheckbox({ - policy, - checked, - onChange, - disabled, -}: ShareIdentityCheckboxProps) { - return ( -
    - onChange(cause.target.checked)} - /> -
    - - -
    -
    - ) -} - -interface TermsCheckboxProps { - recipientLegalName: string - checked: boolean - onChange: (next: boolean) => void - disabled: boolean - error?: string | null -} - -export function TermsCheckbox({ - recipientLegalName, - checked, - onChange, - disabled, - error, -}: TermsCheckboxProps) { - return ( -
    - onChange(cause.target.checked)} - /> -
    - - {error ? ( - - ) : null} -
    -
    - ) -} diff --git a/apps/community-web/src/features/donate/consent-gating.dom.test.tsx b/apps/community-web/src/features/donate/consent-gating.dom.test.tsx deleted file mode 100644 index 46add59b..00000000 --- a/apps/community-web/src/features/donate/consent-gating.dom.test.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import * as React from "react" -import { cleanup, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { CHECKOUT, PAGE } from "./donate-fixture" - -const getPublicOrgDonationPage = vi.fn() -const createDonationCheckout = vi.fn() - -vi.mock("@/lib/api", () => ({ - api: { - getPublicOrgDonationPage: (...args: unknown[]) => getPublicOrgDonationPage(...args), - createDonationCheckout: (...args: unknown[]) => createDonationCheckout(...args), - }, - toAppError: (error: unknown) => (error === null ? { code: "INTERNAL" } : error), -})) - -vi.mock("@/lib/stripe-js", () => ({ - STRIPE_PUBLISHABLE_KEY: "pk_test_123", - loadStripeForAccount: () => Promise.resolve({}), -})) - -vi.mock("@/lib/turnstile", () => ({ - TURNSTILE_SITEKEY: undefined, - TURNSTILE_ACTION_DONATE: "donate", - runTurnstile: () => Promise.resolve(""), -})) - -vi.mock("@/store/auth-store", () => ({ - useAuthStore: (selector: (store: unknown) => unknown) => - selector({ status: "anonymous", user: null }), -})) - -vi.mock("./payment-panel", () => ({ - PaymentPanel: ({ recipientLegalName }: { recipientLegalName: string }) => ( -
    - -
    - ), -})) - -const { DonateView } = await import("./donate-view") - -function setPath(pathname: string): void { - window.history.replaceState(null, "", pathname) -} - -async function renderForm() { - setPath("/donate/reach-out-la/") - render() - await screen.findByRole("heading", { name: /Donate to Reach Out LA/i }) -} - -beforeEach(() => { - getPublicOrgDonationPage.mockReset().mockResolvedValue(PAGE) - createDonationCheckout.mockReset().mockResolvedValue(CHECKOUT) - window.sessionStorage.clear() -}) - -afterEach(() => { - cleanup() -}) - -function checkbox(name: RegExp): HTMLInputElement { - return screen.getByLabelText(name) as HTMLInputElement -} - -function continueButton(): HTMLButtonElement { - return screen.getByTestId("donate-continue") as HTMLButtonElement -} - -describe("consent gating", () => { - it("starts with BOTH checkboxes unchecked, as clickwrap assent requires", async () => { - await renderForm() - expect(checkbox(/I have read the statements above/i).checked).toBe(false) - expect(checkbox(/Share my name and email with Reach Out Los Angeles/i).checked).toBe(false) - }) - - it("keeps Continue disabled until the terms box is ticked", async () => { - const user = userEvent.setup() - await renderForm() - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - expect(continueButton().disabled).toBe(true) - await user.click(screen.getByLabelText(/I have read the statements above/i)) - expect(continueButton().disabled).toBe(false) - }) - - it("keeps Continue disabled without a deliverable email address", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - expect(continueButton().disabled).toBe(true) - await user.type(screen.getByLabelText(/Email address/i), "not-an-email") - expect(continueButton().disabled).toBe(true) - }) - - it("keeps Continue disabled below the organization's own minimum", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - const amount = screen.getByLabelText(/Amount in US dollars/i) - await user.clear(amount) - await user.type(amount, "1") - expect(continueButton().disabled).toBe(true) - }) - - it("sends shareIdentity false unless the donor opted in, and true when they did", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - expect(createDonationCheckout.mock.calls[0]?.[0]).toMatchObject({ shareIdentity: false }) - - cleanup() - createDonationCheckout.mockClear() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.click(screen.getByLabelText(/Share my name and email/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - expect(createDonationCheckout.mock.calls[0]?.[0]).toMatchObject({ shareIdentity: true }) - }) - - it("records which document versions the donor was shown", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - const consent = createDonationCheckout.mock.calls[0]?.[0]?.consent - expect(consent).toMatchObject({ - disclosureVersion: PAGE.disclosureVersion, - surface: "web_donate", - screenRoute: "/donate/reach-out-la", - }) - expect(consent.termsVersion).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(consent.privacyVersion).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(consent.donationTermsVersion).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(consent.acceptedAt).toBeUndefined() - }) - - it("keeps one stable idempotency key for one attempt", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - expect(createDonationCheckout).toHaveBeenCalledTimes(1) - expect(createDonationCheckout.mock.calls[0]?.[0]?.idempotencyKey.length).toBeGreaterThan(7) - }) - - it("stores the status token so a reload of the confirmation page still reads status", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - expect(window.sessionStorage.getItem("civfix.donate.status.don_1")).toBe(CHECKOUT.statusToken) - }) -}) - -describe("states that must never show a payment form", () => { - it("renders the unavailable state when donations are blocked", async () => { - getPublicOrgDonationPage.mockResolvedValue({ ...PAGE, donateState: "BLOCKED" }) - setPath("/donate/reach-out-la/") - render() - await screen.findByText(/Donations are temporarily unavailable/i) - expect(screen.queryByTestId("donate-continue")).toBe(null) - }) - - it("renders the unavailable state when the org is unknown", async () => { - getPublicOrgDonationPage.mockRejectedValue({ code: "NOT_FOUND" }) - setPath("/donate/nope/") - render() - await screen.findByText(/We couldn't find that organization/i) - expect(screen.queryByTestId("donate-continue")).toBe(null) - }) - - it("renders an offline state, not a broken form, when civfix is unreachable", async () => { - getPublicOrgDonationPage.mockRejectedValue({ code: "INTERNAL" }) - setPath("/donate/reach-out-la/") - render() - await screen.findByText(/We couldn't reach civfix/i) - }) -}) - -describe("retry safety after a failed commit", () => { - async function fillAndContinue(user: ReturnType) { - await user.click(checkbox(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(continueButton()) - } - - it("REUSES the idempotency key after an indeterminate failure, so a retry cannot double-charge", async () => { - const user = userEvent.setup() - createDonationCheckout.mockRejectedValueOnce({ code: "INTERNAL" }) - await renderForm() - await fillAndContinue(user) - await screen.findByRole("alert") - await user.click(continueButton()) - await screen.findByTestId("donate-pay") - expect(createDonationCheckout).toHaveBeenCalledTimes(2) - expect(createDonationCheckout.mock.calls[0]?.[0]?.idempotencyKey).toBe( - createDonationCheckout.mock.calls[1]?.[0]?.idempotencyKey, - ) - }) - - it("mints a NEW key after a refusal the server made before writing anything", async () => { - const user = userEvent.setup() - createDonationCheckout.mockRejectedValueOnce({ code: "VALIDATION", fields: {} }) - await renderForm() - await fillAndContinue(user) - await screen.findByRole("alert") - await user.click(continueButton()) - await screen.findByTestId("donate-pay") - expect(createDonationCheckout.mock.calls[0]?.[0]?.idempotencyKey).not.toBe( - createDonationCheckout.mock.calls[1]?.[0]?.idempotencyKey, - ) - }) - - it("says nothing was charged on every commit failure", async () => { - const user = userEvent.setup() - createDonationCheckout.mockRejectedValueOnce({ code: "INTERNAL" }) - await renderForm() - await fillAndContinue(user) - expect((await screen.findByRole("alert")).textContent).toMatch(/[Nn]othing was charged/) - }) -}) diff --git a/apps/community-web/src/features/donate/disclosures-block.tsx b/apps/community-web/src/features/donate/disclosures-block.tsx deleted file mode 100644 index e27fc4ab..00000000 --- a/apps/community-web/src/features/donate/disclosures-block.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client" - -import * as React from "react" -import { isSafeMarkdownHref } from "@civfix/shared/markdown" -import type { DonationDisclosures, DonationTaxDeductibility } from "@civfix/shared" - -export const DISCLOSURE_IDS = [ - "recipient", - "may-not-receive", - "remittance-timing", - "fee-pointer", - "deductibility", - "merchant-of-record", - "refund-policy", -] as const - -export type DisclosureId = (typeof DISCLOSURE_IDS)[number] - -export const MAY_NOT_RECEIVE_FALLBACK_URL = "/legal/donations#may-not-receive" - -const CHECKED_DATE = new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "long", - day: "numeric", - timeZone: "UTC", -}) - -export function safeMayNotReceiveUrl(value: string | null | undefined): string { - if (typeof value !== "string" || value.length === 0) return MAY_NOT_RECEIVE_FALLBACK_URL - if (value.startsWith("/") && !value.startsWith("//")) return value - return isSafeMarkdownHref(value) ? value : MAY_NOT_RECEIVE_FALLBACK_URL -} - -function formatCheckedAt(value: string | null | undefined): string | null { - if (!value) return null - const at = new Date(value) - return Number.isNaN(at.getTime()) ? null : CHECKED_DATE.format(at) -} - -interface DisclosuresBlockProps { - disclosures: DonationDisclosures - taxDeductibility: DonationTaxDeductibility - disclosureVersion: string -} - -export function DisclosuresBlock({ - disclosures, - taxDeductibility, - disclosureVersion, -}: DisclosuresBlockProps) { - const mayNotReceiveUrl = safeMayNotReceiveUrl(disclosures.mayNotReceiveUrl) - const checkedAt = formatCheckedAt( - disclosures.deductibilityCheckedAt ?? taxDeductibility.checkedAt ?? null, - ) - - return ( -
    - - -

    {disclosures.recipient}

    - -

    - {disclosures.mayNotReceive}{" "} - - When an organization may not receive your donation - - . -

    - {disclosures.mayNotReceiveReasons.length > 0 ? ( -
      - {disclosures.mayNotReceiveReasons.map((reason) => ( -
    • {reason}
    • - ))} -
    - ) : null} - -

    {disclosures.remittanceTiming}

    - -

    {disclosures.feePointer}

    - -

    - {disclosures.deductibility} - {checkedAt === null ? null : ` Verified against IRS records on ${checkedAt}.`} -

    - -

    {disclosures.merchantOfRecord}

    - -

    {disclosures.refundPolicy}

    -
    - ) -} diff --git a/apps/community-web/src/features/donate/disclosures-url.test.ts b/apps/community-web/src/features/donate/disclosures-url.test.ts deleted file mode 100644 index 7d9bc070..00000000 --- a/apps/community-web/src/features/donate/disclosures-url.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { MAY_NOT_RECEIVE_FALLBACK_URL, safeMayNotReceiveUrl } from "./disclosures-block" - -describe("safeMayNotReceiveUrl", () => { - it("keeps the server's https url", () => { - expect(safeMayNotReceiveUrl("https://civfix.org/legal/donations#may-not-receive")).toBe( - "https://civfix.org/legal/donations#may-not-receive", - ) - }) - - it("keeps a same-origin path", () => { - expect(safeMayNotReceiveUrl("/legal/donations#may-not-receive")).toBe( - "/legal/donations#may-not-receive", - ) - }) - - it("falls back rather than rendering a dangerous scheme", () => { - for (const value of [ - "javascript:alert(1)", - "data:text/html,", - "vbscript:msgbox(1)", - " javascript:alert(1)", - ]) { - expect(safeMayNotReceiveUrl(value)).toBe(MAY_NOT_RECEIVE_FALLBACK_URL) - } - }) - - it("falls back on a protocol-relative path", () => { - expect(safeMayNotReceiveUrl("//evil.example/x")).toBe(MAY_NOT_RECEIVE_FALLBACK_URL) - }) - - it("falls back on an empty or missing value", () => { - expect(safeMayNotReceiveUrl("")).toBe(MAY_NOT_RECEIVE_FALLBACK_URL) - expect(safeMayNotReceiveUrl(null)).toBe(MAY_NOT_RECEIVE_FALLBACK_URL) - expect(safeMayNotReceiveUrl(undefined)).toBe(MAY_NOT_RECEIVE_FALLBACK_URL) - }) -}) diff --git a/apps/community-web/src/features/donate/disclosures.dom.test.tsx b/apps/community-web/src/features/donate/disclosures.dom.test.tsx deleted file mode 100644 index 6c12a828..00000000 --- a/apps/community-web/src/features/donate/disclosures.dom.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import * as React from "react" -import { cleanup, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { DISCLOSURE_IDS } from "./disclosures-block" -import { CHECKOUT, PAGE } from "./donate-fixture" - -const getPublicOrgDonationPage = vi.fn() -const createDonationCheckout = vi.fn() - -vi.mock("@/lib/api", () => ({ - api: { - getPublicOrgDonationPage: (...args: unknown[]) => getPublicOrgDonationPage(...args), - createDonationCheckout: (...args: unknown[]) => createDonationCheckout(...args), - }, - toAppError: (error: unknown) => (error === null ? { code: "INTERNAL" } : error), -})) - -vi.mock("@/lib/stripe-js", () => ({ - STRIPE_PUBLISHABLE_KEY: "pk_test_123", - loadStripeForAccount: () => Promise.resolve({}), -})) - -vi.mock("@/lib/turnstile", () => ({ - TURNSTILE_SITEKEY: undefined, - TURNSTILE_ACTION_DONATE: "donate", - runTurnstile: () => Promise.resolve(""), -})) - -vi.mock("@/store/auth-store", () => ({ - useAuthStore: (selector: (store: unknown) => unknown) => - selector({ status: "anonymous", user: null }), -})) - -vi.mock("./payment-panel", () => ({ - PaymentPanel: ({ recipientLegalName }: { recipientLegalName: string }) => ( -
    - -
    - ), -})) - -const { DonateView } = await import("./donate-view") - -function setPath(pathname: string): void { - window.history.replaceState(null, "", pathname) -} - -async function renderForm() { - setPath("/donate/reach-out-la/") - render() - await screen.findByRole("heading", { name: /Donate to Reach Out LA/i }) -} - -beforeEach(() => { - getPublicOrgDonationPage.mockReset().mockResolvedValue(PAGE) - createDonationCheckout.mockReset().mockResolvedValue(CHECKOUT) - window.sessionStorage.clear() -}) - -afterEach(() => { - cleanup() -}) - -function precedes(before: Element, after: Element): boolean { - return (before.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 -} - -describe("required disclosures precede the commitment", () => { - it("renders all seven statutory statements", async () => { - await renderForm() - for (const id of DISCLOSURE_IDS) { - const element = document.querySelector(`[data-disclosure="${id}"]`) - expect(element, `missing disclosure ${id}`).not.toBe(null) - expect((element as Element).textContent?.trim().length).toBeGreaterThan(0) - } - }) - - it("puts every disclosure ABOVE the terms checkbox and the Continue button", async () => { - await renderForm() - const terms = screen.getByLabelText(/I have read the statements above/i) - const continueButton = screen.getByTestId("donate-continue") - for (const id of DISCLOSURE_IDS) { - const element = document.querySelector(`[data-disclosure="${id}"]`) as Element - expect(precedes(element, terms), `${id} must precede the terms checkbox`).toBe(true) - expect(precedes(element, continueButton), `${id} must precede Continue`).toBe(true) - } - }) - - it("puts every disclosure ABOVE the pay button once the payment panel appears", async () => { - const user = userEvent.setup() - await renderForm() - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - - const pay = await screen.findByTestId("donate-pay") - for (const id of DISCLOSURE_IDS) { - const element = document.querySelector(`[data-disclosure="${id}"]`) as Element - expect(precedes(element, pay), `${id} must precede the pay button`).toBe(true) - } - const terms = screen.getByLabelText(/I have read the statements above/i) - expect(precedes(terms, pay)).toBe(true) - }) - - it("never claims 100% of a donation reaches the organization", async () => { - await renderForm() - expect(document.body.textContent).not.toContain("100%") - }) - - it("links the may-not-receive reasons to the AB 488 disclosure page", async () => { - await renderForm() - const link = screen.getByRole("link", { - name: /When an organization may not receive your donation/i, - }) - expect(link.getAttribute("href")).toBe("/legal/donations#may-not-receive") - }) - - it("says when deductibility was last verified", async () => { - await renderForm() - const element = document.querySelector('[data-disclosure="deductibility"]') as Element - expect(element.textContent).toContain("Verified against IRS records on August 1, 2026") - }) -}) diff --git a/apps/community-web/src/features/donate/donate-amount.test.ts b/apps/community-web/src/features/donate/donate-amount.test.ts deleted file mode 100644 index 8e34e717..00000000 --- a/apps/community-web/src/features/donate/donate-amount.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - amountInputValue, - amountVerdict, - formatMinor, - formatMinorCompact, - parseAmountToMinor, -} from "./donate-amount" - -describe("parseAmountToMinor", () => { - it("reads plain dollars", () => { - expect(parseAmountToMinor("25")).toEqual({ ok: true, amountMinor: 2500 }) - expect(parseAmountToMinor(" 25 ")).toEqual({ ok: true, amountMinor: 2500 }) - }) - - it("reads a dollar sign", () => { - expect(parseAmountToMinor("$25")).toEqual({ ok: true, amountMinor: 2500 }) - expect(parseAmountToMinor("$1,250")).toEqual({ ok: true, amountMinor: 125000 }) - }) - - it("reads cents with either separator", () => { - expect(parseAmountToMinor("4.99")).toEqual({ ok: true, amountMinor: 499 }) - expect(parseAmountToMinor("25,00")).toEqual({ ok: true, amountMinor: 2500 }) - expect(parseAmountToMinor("25.5")).toEqual({ ok: true, amountMinor: 2550 }) - }) - - it("reads a comma as a thousands group only when the grouping is well formed", () => { - expect(parseAmountToMinor("1,000")).toEqual({ ok: true, amountMinor: 100000 }) - expect(parseAmountToMinor("1,000,000")).toEqual({ ok: true, amountMinor: 100000000 }) - expect(parseAmountToMinor("12,3456")).toEqual({ ok: false, reason: "too_precise" }) - }) - - it("refuses a dot followed by three digits rather than guessing between $1 and $1000", () => { - expect(parseAmountToMinor("1.000")).toEqual({ ok: false, reason: "too_precise" }) - }) - - it("refuses scientific notation rather than charging 100000 dollars", () => { - expect(parseAmountToMinor("1e5")).toEqual({ ok: false, reason: "not_a_number" }) - }) - - it("refuses sub-cent precision rather than rounding money", () => { - expect(parseAmountToMinor("25.005")).toEqual({ ok: false, reason: "too_precise" }) - expect(parseAmountToMinor("25.5000")).toEqual({ ok: false, reason: "too_precise" }) - }) - - it("refuses letters, signs and emptiness", () => { - expect(parseAmountToMinor("")).toEqual({ ok: false, reason: "empty" }) - expect(parseAmountToMinor("abc")).toEqual({ ok: false, reason: "not_a_number" }) - expect(parseAmountToMinor("-25")).toEqual({ ok: false, reason: "not_a_number" }) - expect(parseAmountToMinor("25 USD")).toEqual({ ok: false, reason: "not_a_number" }) - }) -}) - -describe("amountVerdict", () => { - it("enforces the org's own bounds", () => { - expect(amountVerdict("25", 500, 1_000_000)).toBe("ok") - expect(amountVerdict("4", 500, 1_000_000)).toBe("below_min") - expect(amountVerdict("20000", 500, 1_000_000)).toBe("above_max") - expect(amountVerdict("", 500, 1_000_000)).toBe("empty") - expect(amountVerdict("abc", 500, 1_000_000)).toBe("invalid") - }) -}) - -describe("formatting", () => { - it("always renders two decimals in the money the donor commits to", () => { - expect(formatMinor(2500)).toBe("$25.00") - expect(formatMinor(499)).toBe("$4.99") - }) - - it("drops trailing zeros only on the preset chips", () => { - expect(formatMinorCompact(2500)).toBe("$25") - expect(formatMinorCompact(2550)).toBe("$25.50") - }) - - it("round-trips a preset into the input", () => { - expect(amountInputValue(2500)).toBe("25") - expect(amountInputValue(2550)).toBe("25.50") - expect(parseAmountToMinor(amountInputValue(2550))).toEqual({ ok: true, amountMinor: 2550 }) - }) -}) diff --git a/apps/community-web/src/features/donate/donate-amount.ts b/apps/community-web/src/features/donate/donate-amount.ts deleted file mode 100644 index fd53fb52..00000000 --- a/apps/community-web/src/features/donate/donate-amount.ts +++ /dev/null @@ -1,81 +0,0 @@ -export type AmountParse = - | { readonly ok: true; readonly amountMinor: number } - | { readonly ok: false; readonly reason: "empty" | "not_a_number" | "too_precise" } - -const MAX_MINOR = 100_000_000 - -const GROUPED_WHOLE = /^\d{1,3}(?:,\d{3})*$/ - -export function parseAmountToMinor(input: string): AmountParse { - const raw = input.trim() - if (raw.length === 0) return { ok: false, reason: "empty" } - - const stripped = raw.replace(/[\s$ ]/g, "") - if (stripped.length === 0) return { ok: false, reason: "empty" } - if (!/^[0-9.,]+$/.test(stripped)) return { ok: false, reason: "not_a_number" } - - const lastComma = stripped.lastIndexOf(",") - const lastDot = stripped.lastIndexOf(".") - const index = Math.max(lastComma, lastDot) - - let whole = stripped - let fraction = "" - - if (index !== -1) { - const separator = index === lastComma ? "," : "." - const tail = stripped.slice(index + 1) - const head = stripped.slice(0, index) - - if (/^\d{1,2}$/.test(tail)) { - whole = head - fraction = tail - } else if (separator === "," && /^\d{3}$/.test(tail) && GROUPED_WHOLE.test(head)) { - whole = stripped - } else { - return { ok: false, reason: "too_precise" } - } - } - - const digits = whole.replace(/,/g, "") - if (!/^\d*$/.test(digits)) return { ok: false, reason: "not_a_number" } - if (digits.length === 0 && fraction.length === 0) return { ok: false, reason: "empty" } - - const dollars = digits.length === 0 ? 0 : Number.parseInt(digits, 10) - const cents = fraction.length === 0 ? 0 : Number.parseInt(fraction.padEnd(2, "0"), 10) - const amountMinor = dollars * 100 + cents - - if (!Number.isSafeInteger(amountMinor) || amountMinor > MAX_MINOR) { - return { ok: false, reason: "not_a_number" } - } - return { ok: true, amountMinor } -} - -export type AmountVerdict = "ok" | "empty" | "invalid" | "below_min" | "above_max" - -export function amountVerdict(input: string, minMinor: number, maxMinor: number): AmountVerdict { - const parsed = parseAmountToMinor(input) - if (!parsed.ok) return parsed.reason === "empty" ? "empty" : "invalid" - if (parsed.amountMinor < minMinor) return "below_min" - if (parsed.amountMinor > maxMinor) return "above_max" - return "ok" -} - -const USD = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) -const USD_WHOLE = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, -}) - -export function formatMinor(amountMinor: number): string { - return USD.format(amountMinor / 100) -} - -export function formatMinorCompact(amountMinor: number): string { - return amountMinor % 100 === 0 ? USD_WHOLE.format(amountMinor / 100) : formatMinor(amountMinor) -} - -export function amountInputValue(amountMinor: number): string { - return amountMinor % 100 === 0 ? String(amountMinor / 100) : (amountMinor / 100).toFixed(2) -} diff --git a/apps/community-web/src/features/donate/donate-fixture.ts b/apps/community-web/src/features/donate/donate-fixture.ts deleted file mode 100644 index 0747edd6..00000000 --- a/apps/community-web/src/features/donate/donate-fixture.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { GetPublicOrgDonationPageResponse } from "@civfix/shared" - -export const PAGE: GetPublicOrgDonationPageResponse = { - org: { - slug: "reach-out-la", - displayName: "Reach Out LA", - legalName: "Reach Out Los Angeles", - logoUrl: null, - verified: true, - einLast4: "1234", - city: "Los Angeles", - state: "CA", - }, - donateState: "READY", - donationsEnabled: true, - stripeAccount: "acct_1", - eligibility: { state: "open", closedReason: null }, - currency: "USD", - minAmountMinor: 500, - maxAmountMinor: 1_000_000, - suggestedAmountsMinor: [1000, 2500, 5000, 10000], - platformFeeBps: 500, - processingFeeBps: 290, - processingFeeFixedMinor: 30, - feePreview: { - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - platformFeeBps: 500, - currency: "USD", - }, - disclosures: { - recipient: "Your donation is made to Reach Out Los Angeles.", - mayNotReceive: - "In rare cases Reach Out Los Angeles may not receive your donation; if that happens we refund you.", - mayNotReceiveUrl: "/legal/donations#may-not-receive", - mayNotReceiveReasons: ["The organization loses its tax-exempt status."], - remittanceTiming: - "Funds settle directly into the organization's own account as the payment clears.", - feePointer: "civfix charges a 5% platform fee; the organization pays the card processing fee.", - deductibility: "Donations to this organization are tax-deductible to the extent allowed by law.", - deductibilityCheckedAt: "2026-08-01T00:00:00.000Z", - merchantOfRecord: - "civfix facilitates this payment. Reach Out Los Angeles is the merchant of record.", - refundPolicy: "Donations are non-refundable except where the law or the organization allows.", - }, - disclosureVersion: "2026-09-06", - registrationNumber: null, - taxDeductibility: { - deductible: true, - percentage: 100, - statement: "Tax-deductible to the extent allowed by law.", - checkedAt: "2026-08-01T00:00:00.000Z", - }, - donorSharing: { - defaultOn: false, - optInLabel: "Share my name and email with Reach Out Los Angeles", - whatIsShared: "Only your name and email address are shared, and only if you check this box.", - }, - legalVersions: [], - walletsAvailable: [], - requiresTurnstile: false, - event: null, -} - -export const CHECKOUT = { - donationId: "don_1", - clientSecret: "cs_secret", - stripeAccount: "acct_1", - statusToken: "tok_status_value_long_enough", - returnUrl: "https://civfix.org/donate/reach-out-la/complete?donation=don_1&t=tok", - expiresAt: "2026-09-06T01:00:00.000Z", - feeBreakdown: { - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - platformFeeBps: 500, - currency: "USD" as const, - }, -} - diff --git a/apps/community-web/src/features/donate/donate-intent.ts b/apps/community-web/src/features/donate/donate-intent.ts deleted file mode 100644 index aea2ce96..00000000 Binary files a/apps/community-web/src/features/donate/donate-intent.ts and /dev/null differ diff --git a/apps/community-web/src/features/donate/donate-route.test.ts b/apps/community-web/src/features/donate/donate-route.test.ts deleted file mode 100644 index 7518865c..00000000 --- a/apps/community-web/src/features/donate/donate-route.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - donateCompletePath, - donateFormPath, - parseCompleteParams, - parseDonateRoute, - parseEventParam, - statusTokenStorageKey, -} from "./donate-route" - -describe("parseDonateRoute", () => { - it("reads the org slug from the form path", () => { - expect(parseDonateRoute("/donate/reach-out-la/")).toEqual({ - kind: "form", - slug: "reach-out-la", - }) - expect(parseDonateRoute("/donate/reach-out-la")).toEqual({ - kind: "form", - slug: "reach-out-la", - }) - }) - - it("reads the complete view", () => { - expect(parseDonateRoute("/donate/reach-out-la/complete/")).toEqual({ - kind: "complete", - slug: "reach-out-la", - }) - }) - - it("treats the static-export placeholder as unaddressed", () => { - expect(parseDonateRoute("/donate/_/")).toEqual({ kind: "missing" }) - expect(parseDonateRoute("/donate/")).toEqual({ kind: "missing" }) - expect(parseDonateRoute(null)).toEqual({ kind: "missing" }) - expect(parseDonateRoute("/legal/terms/")).toEqual({ kind: "missing" }) - }) - - it("lowercases and decodes, and rejects a slug that cannot be one", () => { - expect(parseDonateRoute("/donate/Reach-Out-LA/")).toEqual({ - kind: "form", - slug: "reach-out-la", - }) - expect(parseDonateRoute("/donate/not a slug/")).toEqual({ - kind: "invalid", - raw: "not a slug", - }) - expect(parseDonateRoute("/donate/%zz/")).toEqual({ kind: "invalid", raw: "%zz" }) - }) - - it("rejects an unknown tail rather than silently donating", () => { - expect(parseDonateRoute("/donate/reach-out-la/refund/")).toEqual({ - kind: "invalid", - raw: "reach-out-la", - }) - }) -}) - -describe("parseCompleteParams", () => { - it("reads the donation id, status token and session id", () => { - expect(parseCompleteParams("?session_id=cs_1&donation=d1&t=tok")).toEqual({ - donationId: "d1", - statusToken: "tok", - sessionId: "cs_1", - }) - }) - - it("drops an unsubstituted Stripe template placeholder", () => { - expect(parseCompleteParams("?session_id={CHECKOUT_SESSION_ID}&donation=d1").sessionId).toBe(null) - }) - - it("is empty for a bare visit", () => { - expect(parseCompleteParams("")).toEqual({ - donationId: null, - statusToken: null, - sessionId: null, - }) - }) -}) - -describe("paths and keys", () => { - it("builds trailing-slash paths that match the export layout", () => { - expect(donateFormPath("reach-out-la")).toBe("/donate/reach-out-la/") - expect(donateCompletePath("reach-out-la")).toBe("/donate/reach-out-la/complete/") - }) - - it("scopes the sessionStorage key per donation", () => { - expect(statusTokenStorageKey("d1")).toBe("civfix.donate.status.d1") - expect(statusTokenStorageKey("d2")).not.toBe(statusTokenStorageKey("d1")) - }) - - it("reads the optional event id", () => { - expect(parseEventParam("?event=evt_1")).toBe("evt_1") - expect(parseEventParam("?event=")).toBe(null) - expect(parseEventParam("")).toBe(null) - }) -}) diff --git a/apps/community-web/src/features/donate/donate-route.ts b/apps/community-web/src/features/donate/donate-route.ts deleted file mode 100644 index d1882814..00000000 --- a/apps/community-web/src/features/donate/donate-route.ts +++ /dev/null @@ -1,77 +0,0 @@ -export const DONATE_SEGMENT = "donate" -export const DONATE_COMPLETE_SEGMENT = "complete" - -const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,58}[a-z0-9])?$/ - -export type DonateRoute = - | { readonly kind: "missing" } - | { readonly kind: "invalid"; readonly raw: string } - | { readonly kind: "form"; readonly slug: string } - | { readonly kind: "complete"; readonly slug: string } - -const MISSING: DonateRoute = { kind: "missing" } - -export function parseDonateRoute(pathname: string | null | undefined): DonateRoute { - if (!pathname) return MISSING - - const segments = pathname.split("/").filter((segment) => segment.length > 0) - if (segments[0] !== DONATE_SEGMENT) return MISSING - - const raw = segments[1] - if (raw === undefined || raw === "_") return MISSING - - let decoded: string - try { - decoded = decodeURIComponent(raw) - } catch { - return { kind: "invalid", raw } - } - - const slug = decoded.trim().toLowerCase() - if (!SLUG_PATTERN.test(slug)) return { kind: "invalid", raw: decoded } - - const tail = segments.slice(2) - if (tail.length === 0) return { kind: "form", slug } - if (tail.length === 1 && tail[0] === DONATE_COMPLETE_SEGMENT) return { kind: "complete", slug } - return { kind: "invalid", raw: decoded } -} - -export function donateFormPath(slug: string): string { - return `/${DONATE_SEGMENT}/${encodeURIComponent(slug)}/` -} - -export function donateCompletePath(slug: string): string { - return `/${DONATE_SEGMENT}/${encodeURIComponent(slug)}/${DONATE_COMPLETE_SEGMENT}/` -} - -export interface CompleteParams { - readonly donationId: string | null - readonly statusToken: string | null - readonly sessionId: string | null -} - -export function parseCompleteParams(search: string | null | undefined): CompleteParams { - const params = new URLSearchParams(search ?? "") - const read = (key: string): string | null => { - const value = params.get(key) - if (value === null) return null - const trimmed = value.trim() - return trimmed.length === 0 || trimmed.startsWith("{") ? null : trimmed - } - return { - donationId: read("donation"), - statusToken: read("t"), - sessionId: read("session_id"), - } -} - -export function parseEventParam(search: string | null | undefined): string | null { - const value = new URLSearchParams(search ?? "").get("event") - if (value === null) return null - const trimmed = value.trim() - return trimmed.length === 0 ? null : trimmed -} - -export function statusTokenStorageKey(donationId: string): string { - return `civfix.donate.status.${donationId}` -} diff --git a/apps/community-web/src/features/donate/donate-status-poll.test.ts b/apps/community-web/src/features/donate/donate-status-poll.test.ts deleted file mode 100644 index 3bab0ccb..00000000 --- a/apps/community-web/src/features/donate/donate-status-poll.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - POLL_GIVE_UP_MS, - elapsedAfterAttempts, - isTerminalDonationStatus, - nextPollDelayMs, - pollDecision, -} from "./donate-status-poll" - -describe("donation status polling", () => { - it("treats only pending as non-terminal", () => { - expect(isTerminalDonationStatus("pending")).toBe(false) - for (const status of ["succeeded", "failed", "refunded", "partially_refunded"] as const) { - expect(isTerminalDonationStatus(status)).toBe(true) - } - }) - - it("backs off exponentially and then holds", () => { - expect([0, 1, 2, 3, 4, 5].map(nextPollDelayMs)).toEqual([1000, 2000, 4000, 5000, 5000, 5000]) - }) - - it("stops the moment the donation settles", () => { - expect(pollDecision("succeeded", 0, 0)).toEqual({ kind: "stop", reason: "settled" }) - }) - - it("keeps waiting while pending and inside the budget", () => { - expect(pollDecision("pending", 0, 0)).toEqual({ kind: "wait", delayMs: 1000 }) - expect(pollDecision(null, 1, 1000)).toEqual({ kind: "wait", delayMs: 2000 }) - }) - - it("gives up rather than polling past the budget", () => { - expect(pollDecision("pending", 6, POLL_GIVE_UP_MS - 1)).toEqual({ - kind: "stop", - reason: "timed_out", - }) - }) - - it("never exceeds the budget across a full run", () => { - let attempt = 0 - let elapsed = 0 - for (;;) { - const decision = pollDecision("pending", attempt, elapsed) - if (decision.kind === "stop") break - elapsed += decision.delayMs - attempt += 1 - expect(attempt).toBeLessThan(100) - } - expect(elapsed).toBeLessThanOrEqual(POLL_GIVE_UP_MS) - expect(elapsed).toBe(elapsedAfterAttempts(attempt)) - }) -}) diff --git a/apps/community-web/src/features/donate/donate-status-poll.ts b/apps/community-web/src/features/donate/donate-status-poll.ts deleted file mode 100644 index 0fff3042..00000000 --- a/apps/community-web/src/features/donate/donate-status-poll.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DonationStatus } from "@civfix/shared" - -export const POLL_GIVE_UP_MS = 30_000 - -const BASE_DELAY_MS = 1_000 -const MAX_DELAY_MS = 5_000 - -export function isTerminalDonationStatus(status: DonationStatus): boolean { - return status !== "pending" -} - -export function nextPollDelayMs(attempt: number): number { - const bounded = Math.max(0, Math.floor(attempt)) - return Math.min(BASE_DELAY_MS * 2 ** bounded, MAX_DELAY_MS) -} - -export function elapsedAfterAttempts(attempts: number): number { - let total = 0 - for (let index = 0; index < attempts; index += 1) total += nextPollDelayMs(index) - return total -} - -export type PollDecision = - | { readonly kind: "stop"; readonly reason: "settled" } - | { readonly kind: "stop"; readonly reason: "timed_out" } - | { readonly kind: "wait"; readonly delayMs: number } - -export function pollDecision( - status: DonationStatus | null, - attempt: number, - elapsedMs: number, -): PollDecision { - if (status !== null && isTerminalDonationStatus(status)) return { kind: "stop", reason: "settled" } - const delayMs = nextPollDelayMs(attempt) - if (elapsedMs + delayMs > POLL_GIVE_UP_MS) return { kind: "stop", reason: "timed_out" } - return { kind: "wait", delayMs } -} diff --git a/apps/community-web/src/features/donate/donate-view.tsx b/apps/community-web/src/features/donate/donate-view.tsx deleted file mode 100644 index e271e568..00000000 --- a/apps/community-web/src/features/donate/donate-view.tsx +++ /dev/null @@ -1,444 +0,0 @@ -"use client" - -import * as React from "react" -import type { Stripe } from "@stripe/stripe-js" -import { - ErrorCode, - currentVersion, - previewDonationFees, - type CreateDonationCheckoutResponse, - type FeeBreakdownDTO, - type GetPublicOrgDonationPageResponse, -} from "@civfix/shared" - -import { api, toAppError } from "@/lib/api" -import { STRIPE_PUBLISHABLE_KEY, loadStripeForAccount } from "@/lib/stripe-js" -import { TURNSTILE_ACTION_DONATE, TURNSTILE_SITEKEY, runTurnstile } from "@/lib/turnstile" -import { useAuthStore } from "@/store/auth-store" -import { AmountField } from "./amount-field" -import { CompleteView, rememberStatusToken } from "./complete-view" -import { ShareIdentityCheckbox, TermsCheckbox } from "./consent-block" -import { amountInputValue, amountVerdict, formatMinor, parseAmountToMinor } from "./donate-amount" -import { DisclosuresBlock } from "./disclosures-block" -import { DonateShell, DonateUnavailable } from "./states" -import { DonorFields } from "./donor-fields" -import { FeeBreakdown } from "./fee-breakdown" -import { OrgHeader } from "./org-header" -import { PaymentPanel } from "./payment-panel" -import { donateCompletePath, parseDonateRoute, parseEventParam } from "./donate-route" -import { intentKey, newAttemptNonce } from "./donate-intent" - -export const DONATE_UI_TEMPLATE_VERSION = "donate-web-1" - -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - -type PageState = - | { readonly kind: "loading" } - | { readonly kind: "not_found" } - | { readonly kind: "offline" } - | { readonly kind: "unavailable" } - | { readonly kind: "ready"; readonly page: GetPublicOrgDonationPageResponse } - -export function DonateView() { - const [route, setRoute] = React.useState | null>(null) - - React.useEffect(() => { - setRoute(parseDonateRoute(window.location.pathname)) - }, []) - - if (route === null) return Loading… - if (route.kind === "complete") return - if (route.kind === "missing" || route.kind === "invalid") { - return ( - - This donation link is not valid. Check the link you followed, or find the organization on{" "} - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - civfix. - - ) - } - return -} - -function DonateForm({ slug }: { slug: string }) { - const [state, setState] = React.useState({ kind: "loading" }) - const viewerEmail = useAuthStore((store) => store.user?.email ?? null) - const isAuthenticated = useAuthStore((store) => store.status === "authenticated") - const eventId = React.useMemo( - () => (typeof window === "undefined" ? null : parseEventParam(window.location.search)), - [], - ) - - React.useEffect(() => { - let cancelled = false - api - .getPublicOrgDonationPage({ slug }) - .then((page) => { - if (cancelled) return - setState( - page.donateState === "READY" || page.donateState === "AT_RISK" - ? { kind: "ready", page } - : { kind: "unavailable" }, - ) - }) - .catch((error: unknown) => { - if (cancelled) return - const code = toAppError(error).code - if (code === ErrorCode.NOT_FOUND) setState({ kind: "not_found" }) - else if (code === ErrorCode.PAYMENT_UNAVAILABLE) setState({ kind: "unavailable" }) - else setState({ kind: "offline" }) - }) - return () => { - cancelled = true - } - }, [slug]) - - if (state.kind === "loading") return Loading the donation page… - - if (state.kind === "not_found") { - return ( - - This organization is not accepting donations through civfix. Find it on{" "} - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - civfix for other ways to help. - - ) - } - - if (state.kind === "unavailable") { - return ( - - Donations to this organization are paused right now. Nothing was charged. Please try again - later. - - ) - } - - if (state.kind === "offline") { - return ( - - The donation page could not be loaded, so nothing has been charged. Check your connection and - reload. - - ) - } - - if (!STRIPE_PUBLISHABLE_KEY || state.page.stripeAccount === null) { - return ( - - The payment form is not configured on this build, so nothing can be charged here. Please try - again later. - - ) - } - - return ( - - ) -} - -interface DonateDocumentProps { - page: GetPublicOrgDonationPageResponse - slug: string - eventId: string | null - isAuthenticated: boolean - viewerEmail: string | null -} - -type CommitState = - | { readonly kind: "editing" } - | { readonly kind: "committing" } - | { readonly kind: "committed"; readonly checkout: CreateDonationCheckoutResponse } - | { readonly kind: "succeeded" } - -function DonateDocument({ - page, - slug, - eventId, - isAuthenticated, - viewerEmail, -}: DonateDocumentProps) { - const [amountText, setAmountText] = React.useState(() => { - const seed = page.suggestedAmountsMinor[1] ?? page.suggestedAmountsMinor[0] - return seed === undefined ? "" : amountInputValue(seed) - }) - const [email, setEmail] = React.useState(viewerEmail ?? "") - const [name, setName] = React.useState("") - const [shareIdentity, setShareIdentity] = React.useState(false) - const [termsAccepted, setTermsAccepted] = React.useState(false) - const [showErrors, setShowErrors] = React.useState(false) - const [commit, setCommit] = React.useState({ kind: "editing" }) - const [commitError, setCommitError] = React.useState(null) - const commitInFlight = React.useRef(false) - const attemptNonce = React.useRef(newAttemptNonce()) - - const parsedAmount = parseAmountToMinor(amountText) - const amountState = amountVerdict(amountText, page.minAmountMinor, page.maxAmountMinor) - const emailValid = EMAIL_PATTERN.test(email.trim()) - const locked = commit.kind !== "editing" - - const previewBreakdown = React.useMemo(() => { - if (!parsedAmount.ok || parsedAmount.amountMinor <= 0) return null - const preview = previewDonationFees({ - amountMinor: parsedAmount.amountMinor, - platformFeeBps: page.platformFeeBps, - processingFeeBps: page.processingFeeBps, - processingFeeFixedMinor: page.processingFeeFixedMinor, - }) - return { - grossMinor: preview.grossMinor, - platformFeeMinor: preview.platformFeeMinor, - processorFeeMinor: preview.estimatedProcessingFeeMinor, - processorFeeIsEstimate: true, - netMinor: preview.estimatedNetMinor, - platformFeeBps: preview.platformFeeBps, - currency: "USD", - } - }, [parsedAmount, page.platformFeeBps, page.processingFeeBps, page.processingFeeFixedMinor]) - - const stripePromise = React.useMemo | null>( - () => - commit.kind === "committed" ? loadStripeForAccount(commit.checkout.stripeAccount) : null, - [commit], - ) - - const canContinue = amountState === "ok" && emailValid && termsAccepted - - const donorEmail = email.trim().toLowerCase() - const donorName = name.trim() - - const idempotencyKey = intentKey(attemptNonce.current, { - slug, - amountMinor: parsedAmount.ok ? parsedAmount.amountMinor : 0, - email: donorEmail, - name: donorName, - eventId, - shareIdentity, - }) - - const onContinue = React.useCallback(async () => { - setShowErrors(true) - if (!canContinue || commit.kind !== "editing" || !parsedAmount.ok) return - if (commitInFlight.current) return - commitInFlight.current = true - setCommit({ kind: "committing" }) - setCommitError(null) - try { - const turnstileToken = - !isAuthenticated && page.requiresTurnstile && TURNSTILE_SITEKEY - ? await runTurnstile(TURNSTILE_ACTION_DONATE) - : "" - const checkout = await api.createDonationCheckout({ - orgSlug: slug, - amountMinor: parsedAmount.amountMinor, - currency: "USD", - email: donorEmail, - ...(donorName.length > 0 ? { name: donorName } : {}), - ...(eventId ? { eventId } : {}), - shareIdentity, - consent: { - termsVersion: currentVersion("terms"), - privacyVersion: currentVersion("privacy"), - donationTermsVersion: currentVersion("donations"), - disclosureVersion: page.disclosureVersion, - surface: "web_donate", - screenRoute: `/donate/${slug}`, - uiTemplateVersion: DONATE_UI_TEMPLATE_VERSION, - }, - idempotencyKey, - ...(turnstileToken.length > 0 ? { turnstileToken } : {}), - }) - rememberStatusToken(checkout.donationId, checkout.statusToken) - setCommit({ kind: "committed", checkout }) - } catch (error) { - const appError = toAppError(error) - setCommit({ kind: "editing" }) - setCommitError(commitErrorMessage(appError.code, appError.fields)) - if (refusedBeforeAnyWrite(appError.code)) attemptNonce.current = newAttemptNonce() - } finally { - commitInFlight.current = false - } - }, [ - canContinue, - commit.kind, - donorEmail, - donorName, - eventId, - idempotencyKey, - isAuthenticated, - page.disclosureVersion, - page.requiresTurnstile, - parsedAmount, - shareIdentity, - slug, - ]) - - const onSucceededInPlace = React.useCallback(() => { - if (commit.kind !== "committed") return - const donationId = commit.checkout.donationId - window.history.replaceState( - null, - "", - `${donateCompletePath(slug)}?donation=${encodeURIComponent(donationId)}`, - ) - setCommit({ kind: "succeeded" }) - }, [commit, slug]) - - if (commit.kind === "succeeded") return - - const authoritativeBreakdown = - commit.kind === "committed" ? commit.checkout.feeBreakdown : previewBreakdown - - return ( -
    -
    - - - {page.donateState === "AT_RISK" ? ( -

    - This organization is completing a verification step with our payment processor. Donations - still go through, and we will email you if anything changes. -

    - ) : null} - - - - - - - - - - - - - - {commitError ? ( -

    - {commitError} -

    - ) : null} - - {commit.kind === "committed" && stripePromise !== null ? null : ( - - )} - - {commit.kind === "committed" && stripePromise !== null ? ( - - ) : null} - - -
    -
    - ) -} - -function refusedBeforeAnyWrite(code: ErrorCode): boolean { - return ( - code === ErrorCode.VALIDATION || - code === ErrorCode.CONFLICT || - code === ErrorCode.TURNSTILE_FAILED || - code === ErrorCode.NOT_FOUND - ) -} - -function amountErrorMessage( - verdict: ReturnType, - page: GetPublicOrgDonationPageResponse, -): string | null { - switch (verdict) { - case "ok": - return null - case "empty": - return "Enter an amount." - case "invalid": - return "Enter an amount in dollars and cents, for example 25 or 25.50." - case "below_min": - return `The smallest donation is ${formatMinor(page.minAmountMinor)}.` - case "above_max": - return `The largest donation through civfix is ${formatMinor(page.maxAmountMinor)}.` - } -} - -function commitErrorMessage( - code: ErrorCode, - fields: Readonly> | undefined, -): string { - switch (code) { - case ErrorCode.PAYMENT_UNAVAILABLE: - return "Donations to this organization are unavailable right now. Nothing was charged." - case ErrorCode.VALIDATION: - return fields?.amountMinor ?? fields?.email ?? "Please check the details above and try again." - case ErrorCode.CONFLICT: - return "Our terms were updated while you were on this page. Reload to see the current version." - case ErrorCode.RATE_LIMITED: - return "Too many attempts. Wait a minute and try again. Nothing was charged." - case ErrorCode.TURNSTILE_FAILED: - return "We could not confirm you are not a bot. Reload the page and try again." - default: - return "We could not start the payment. Nothing was charged. Please try again." - } -} diff --git a/apps/community-web/src/features/donate/donor-fields.tsx b/apps/community-web/src/features/donate/donor-fields.tsx deleted file mode 100644 index 3d25b391..00000000 --- a/apps/community-web/src/features/donate/donor-fields.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client" - -import * as React from "react" - -interface DonorFieldsProps { - email: string - name: string - onEmailChange: (next: string) => void - onNameChange: (next: string) => void - disabled: boolean - emailError?: string | null -} - -export function DonorFields({ - email, - name, - onEmailChange, - onNameChange, - disabled, - emailError, -}: DonorFieldsProps) { - return ( -
    - - -
    - - onEmailChange(cause.target.value)} - /> - {emailError ? ( - - ) : null} -
    - -
    - - onNameChange(cause.target.value)} - /> -
    - - -
    - ) -} diff --git a/apps/community-web/src/features/donate/fee-breakdown-ids.dom.test.tsx b/apps/community-web/src/features/donate/fee-breakdown-ids.dom.test.tsx deleted file mode 100644 index 3fefd09d..00000000 --- a/apps/community-web/src/features/donate/fee-breakdown-ids.dom.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react" -import { cleanup, render } from "@testing-library/react" -import { afterEach, describe, expect, it } from "vitest" -import type { FeeBreakdownDTO } from "@civfix/shared" - -import { FeeBreakdown } from "./fee-breakdown" - -const BREAKDOWN: FeeBreakdownDTO = { - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - platformFeeBps: 500, - currency: "USD", -} - -afterEach(() => { - cleanup() -}) - -describe("two fee breakdowns on one document", () => { - it("emits no duplicate id, so aria-labelledby cannot resolve to the wrong heading", () => { - render( - <> - - - , - ) - const ids = [...document.querySelectorAll("[id]")].map((node) => node.id) - expect(ids.length).toBe(2) - expect(new Set(ids).size).toBe(ids.length) - - for (const section of document.querySelectorAll("section[aria-labelledby]")) { - const target = section.getAttribute("aria-labelledby") as string - expect(document.querySelectorAll(`#${CSS.escape(target)}`).length).toBe(1) - expect(section.contains(document.getElementById(target))).toBe(true) - } - }) - - it("keeps the two breakdowns individually addressable", () => { - render( - <> - - - , - ) - const cell = (scope: string) => - document.querySelector(`[data-fee="net"][data-fee-scope="${scope}"]`)?.textContent?.trim() - expect(cell("donate-fees")).toBe("$22.72") - expect(cell("donate-pay-fees")).toBe("$99.99") - }) -}) diff --git a/apps/community-web/src/features/donate/fee-breakdown.dom.test.tsx b/apps/community-web/src/features/donate/fee-breakdown.dom.test.tsx deleted file mode 100644 index 15e0706c..00000000 --- a/apps/community-web/src/features/donate/fee-breakdown.dom.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from "react" -import { cleanup, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { CHECKOUT, PAGE } from "./donate-fixture" - -const getPublicOrgDonationPage = vi.fn() -const createDonationCheckout = vi.fn() - -vi.mock("@/lib/api", () => ({ - api: { - getPublicOrgDonationPage: (...args: unknown[]) => getPublicOrgDonationPage(...args), - createDonationCheckout: (...args: unknown[]) => createDonationCheckout(...args), - }, - toAppError: (error: unknown) => (error === null ? { code: "INTERNAL" } : error), -})) - -vi.mock("@/lib/stripe-js", () => ({ - STRIPE_PUBLISHABLE_KEY: "pk_test_123", - loadStripeForAccount: () => Promise.resolve({}), -})) - -vi.mock("@/lib/turnstile", () => ({ - TURNSTILE_SITEKEY: undefined, - TURNSTILE_ACTION_DONATE: "donate", - runTurnstile: () => Promise.resolve(""), -})) - -vi.mock("@/store/auth-store", () => ({ - useAuthStore: (selector: (store: unknown) => unknown) => - selector({ status: "anonymous", user: null }), -})) - -vi.mock("./payment-panel", () => ({ - PaymentPanel: ({ recipientLegalName }: { recipientLegalName: string }) => ( -
    - -
    - ), -})) - -const { DonateView } = await import("./donate-view") - -function setPath(pathname: string): void { - window.history.replaceState(null, "", pathname) -} - -async function renderForm() { - setPath("/donate/reach-out-la/") - render() - await screen.findByRole("heading", { name: /Donate to Reach Out LA/i }) -} - -beforeEach(() => { - getPublicOrgDonationPage.mockReset().mockResolvedValue(PAGE) - createDonationCheckout.mockReset().mockResolvedValue(CHECKOUT) - window.sessionStorage.clear() -}) - -afterEach(() => { - cleanup() -}) - -function feeCell(kind: string, scope = "donate-fees"): string { - return ( - document - .querySelector(`[data-fee="${kind}"][data-fee-scope="${scope}"]`) - ?.textContent?.trim() ?? "" - ) -} - -describe("fee breakdown", () => { - it("itemizes the four required rows from the shared fee math", async () => { - await renderForm() - expect(feeCell("gross")).toBe("$25.00") - expect(feeCell("processor")).toBe("\u2212$1.03") - expect(feeCell("platform")).toBe("\u2212$1.25") - expect(feeCell("net")).toBe("$22.72") - }) - - it("names the platform fee as civfix's and states the rate", async () => { - await renderForm() - const platformRow = document.querySelector('[data-fee="platform"]')?.parentElement - expect(platformRow?.textContent).toContain("civfix platform fee") - expect(platformRow?.textContent).toContain("5%") - }) - - it("says the organization pays the processing fee, and that the figure is an estimate", async () => { - await renderForm() - const processorRow = document.querySelector('[data-fee="processor"]')?.parentElement - expect(processorRow?.textContent).toContain("paid by the organization") - expect(processorRow?.textContent).toContain("Estimated card processing fee") - }) - - it("names the recipient's IRS legal name in the net row, not the display name", async () => { - await renderForm() - const netRow = document.querySelector('[data-fee="net"]')?.parentElement - expect(netRow?.textContent).toContain("Reach Out Los Angeles") - }) - - it("recomputes live as the amount changes, in a polite live region", async () => { - const user = userEvent.setup() - await renderForm() - const amount = screen.getByLabelText(/Amount in US dollars/i) - await user.clear(amount) - await user.type(amount, "100") - expect(feeCell("gross")).toBe("$100.00") - expect(feeCell("platform")).toBe("\u2212$5.00") - expect(document.querySelector("[data-fees-authoritative]")?.getAttribute("aria-live")).toBe( - "polite", - ) - }) - - it("marks the preview as non-authoritative until the server has priced it", async () => { - const user = userEvent.setup() - await renderForm() - expect( - document.querySelector("[data-fees-authoritative]")?.getAttribute("data-fees-authoritative"), - ).toBe("false") - - await user.click(screen.getByLabelText(/I have read the statements above/i)) - await user.type(screen.getByLabelText(/Email address/i), "donor@example.org") - await user.click(screen.getByTestId("donate-continue")) - await screen.findByTestId("donate-pay") - expect( - [...document.querySelectorAll("[data-fees-authoritative]")].some( - (node) => node.getAttribute("data-fees-authoritative") === "true", - ), - ).toBe(true) - }) - - it("prompts for an amount rather than showing zeros", async () => { - const user = userEvent.setup() - await renderForm() - await user.clear(screen.getByLabelText(/Amount in US dollars/i)) - expect(screen.getByText(/Enter an amount to see the breakdown/i)).toBeTruthy() - }) -}) diff --git a/apps/community-web/src/features/donate/fee-breakdown.tsx b/apps/community-web/src/features/donate/fee-breakdown.tsx deleted file mode 100644 index 77453ab6..00000000 --- a/apps/community-web/src/features/donate/fee-breakdown.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client" - -import * as React from "react" -import type { FeeBreakdownDTO } from "@civfix/shared" - -import { formatMinor } from "./donate-amount" - -interface FeeBreakdownProps { - breakdown: FeeBreakdownDTO | null - recipientLegalName: string - authoritative: boolean - idPrefix?: string -} - -export function FeeBreakdown({ - breakdown, - recipientLegalName, - authoritative, - idPrefix = "donate-fees", -}: FeeBreakdownProps) { - const headingId = `${idPrefix}-heading` - if (breakdown === null) { - return ( -
    -

    Where your donation goes

    -

    Enter an amount to see the breakdown.

    -
    - ) - } - - const platformPercent = (breakdown.platformFeeBps / 100).toFixed( - breakdown.platformFeeBps % 100 === 0 ? 0 : 2, - ) - - return ( -
    -

    Where your donation goes

    -
    -
    -
    Your donation
    -
    {formatMinor(breakdown.grossMinor)}
    -
    -
    -
    - {breakdown.processorFeeIsEstimate ? "Estimated card processing fee" : "Card processing fee"}{" "} - (paid by the organization) -
    -
    −{formatMinor(breakdown.processorFeeMinor)}
    -
    -
    -
    - civfix platform fee ({platformPercent}%) -
    -
    −{formatMinor(breakdown.platformFeeMinor)}
    -
    -
    -
    - {breakdown.processorFeeIsEstimate ? "Estimated amount to" : "Amount to"}{" "} - {recipientLegalName} -
    -
    {formatMinor(breakdown.netMinor)}
    -
    -
    - {breakdown.processorFeeIsEstimate ? ( -

    - The card processing fee is set by the payment processor and depends on the card you use, so - the figures above are an estimate. Your receipt carries the exact amounts. -

    - ) : null} -
    - ) -} diff --git a/apps/community-web/src/features/donate/intent-key.test.ts b/apps/community-web/src/features/donate/intent-key.test.ts deleted file mode 100644 index bca03e7c..00000000 --- a/apps/community-web/src/features/donate/intent-key.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { intentKey, type DonationIntent } from "./donate-intent" - -const BASE: DonationIntent = { - slug: "reach-out-la", - amountMinor: 2500, - email: "donor@example.org", - name: "Ada", - eventId: null, - shareIdentity: false, -} - -describe("intentKey", () => { - it("is stable for an unchanged intent, so an indeterminate retry REPLAYS", () => { - expect(intentKey("n1", BASE)).toBe(intentKey("n1", { ...BASE })) - }) - - it("changes when the amount changes, so a corrected amount is a NEW donation", () => { - expect(intentKey("n1", { ...BASE, amountMinor: 25000 })).not.toBe(intentKey("n1", BASE)) - }) - - it("changes when the receipt address changes", () => { - expect(intentKey("n1", { ...BASE, email: "typo@example.org" })).not.toBe(intentKey("n1", BASE)) - }) - - it("changes for name, event and the identity opt-in", () => { - for (const patch of [ - { name: "Grace" }, - { eventId: "evt_1" }, - { shareIdentity: true }, - { slug: "other-org" }, - ] as Array>) { - expect(intentKey("n1", { ...BASE, ...patch })).not.toBe(intentKey("n1", BASE)) - } - }) - - it("changes when the nonce is re-minted after a determinate refusal", () => { - expect(intentKey("n2", BASE)).not.toBe(intentKey("n1", BASE)) - }) - - it("cannot collide across a field boundary", () => { - const a = intentKey("n1", { ...BASE, email: "ab@x.org", name: "c" }) - const b = intentKey("n1", { ...BASE, email: "ab@x.orgc", name: "" }) - expect(a).not.toBe(b) - }) - - it("stays inside the contract's 8..128 character bound", () => { - const key = intentKey("n1", { ...BASE, name: "x".repeat(120), email: "y".repeat(200) }) - expect(key.length).toBeGreaterThanOrEqual(8) - expect(key.length).toBeLessThanOrEqual(128) - }) -}) diff --git a/apps/community-web/src/features/donate/org-header.tsx b/apps/community-web/src/features/donate/org-header.tsx deleted file mode 100644 index dc05da7b..00000000 --- a/apps/community-web/src/features/donate/org-header.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client" - -import * as React from "react" -import { BadgeCheck } from "lucide-react" -import type { DonationPageEventRef, DonationPageOrg } from "@civfix/shared" - -const EVENT_WHEN = new Intl.DateTimeFormat("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", -}) - -function formatEventWhen(value: string): string | null { - const at = new Date(value) - return Number.isNaN(at.getTime()) ? null : EVENT_WHEN.format(at) -} - -interface OrgHeaderProps { - org: DonationPageOrg - event?: DonationPageEventRef | null -} - -export function OrgHeader({ org, event }: OrgHeaderProps) { - const place = [org.city, org.state].filter((part) => !!part).join(", ") - const when = event ? formatEventWhen(event.startsAt) : null - - return ( -
    - {org.logoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : null} -
    -

    - Donate to {org.displayName} - {org.verified ? ( - - ) : null} -

    -

    - {org.legalName} - {org.einLast4 ? ` · EIN ending ${org.einLast4}` : ""} - {place.length > 0 ? ` · ${place}` : ""} -

    - {event ? ( -

    - In support of {event.title} - {when === null ? "" : ` · ${when}`} -

    - ) : null} -
    -
    - ) -} diff --git a/apps/community-web/src/features/donate/payment-panel.tsx b/apps/community-web/src/features/donate/payment-panel.tsx deleted file mode 100644 index 8d71507b..00000000 --- a/apps/community-web/src/features/donate/payment-panel.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client" - -import * as React from "react" -import { CheckoutElementsProvider, PaymentElement, useCheckoutElements } from "@stripe/react-stripe-js/checkout" -import type { Stripe } from "@stripe/stripe-js" -import type { FeeBreakdownDTO } from "@civfix/shared" - -import { useColorScheme } from "@/lib/color-scheme" -import { stripeAppearance } from "@/lib/stripe-appearance" -import { formatMinor } from "./donate-amount" -import { FeeBreakdown } from "./fee-breakdown" - -interface PaymentPanelProps { - stripe: Promise - clientSecret: string - email: string - amountMinor: number - feeBreakdown: FeeBreakdownDTO - recipientLegalName: string - returnUrl: string - onSucceededInPlace: () => void -} - -export function PaymentPanel({ - stripe, - clientSecret, - email, - amountMinor, - feeBreakdown, - recipientLegalName, - returnUrl, - onSucceededInPlace, -}: PaymentPanelProps) { - const scheme = useColorScheme() - const options = React.useMemo( - () => ({ - clientSecret, - elementsOptions: { appearance: stripeAppearance(scheme) }, - defaultValues: { email }, - }), - [clientSecret, scheme, email], - ) - - return ( - - - - ) -} - -interface PaymentFormProps { - amountMinor: number - feeBreakdown: FeeBreakdownDTO - recipientLegalName: string - returnUrl: string - onSucceededInPlace: () => void -} - -function PaymentForm({ - amountMinor, - feeBreakdown, - recipientLegalName, - returnUrl, - onSucceededInPlace, -}: PaymentFormProps) { - const result = useCheckoutElements() - const [submitting, setSubmitting] = React.useState(false) - const [error, setError] = React.useState(null) - const inFlight = React.useRef(false) - - const checkout = result.type === "success" ? result.checkout : null - - const pay = React.useCallback(async () => { - if (checkout === null || inFlight.current) return - inFlight.current = true - setSubmitting(true) - setError(null) - try { - const confirmed = await checkout.confirm({ returnUrl, redirect: "if_required" }) - if (confirmed.type === "error") { - setError(confirmed.error.message) - return - } - onSucceededInPlace() - } catch { - setError("We could not reach the payment processor. Your card has not been charged.") - } finally { - inFlight.current = false - setSubmitting(false) - } - }, [checkout, returnUrl, onSucceededInPlace]) - - if (result.type === "loading") { - return ( -
    - -

    Loading the secure payment form…

    -
    - ) - } - - if (result.type === "error") { - return ( -
    - -

    - The payment form could not be loaded, so your card has not been charged. Reload the page to - try again. -

    -
    - ) - } - - return ( -
    - - - - - - {error ? ( -

    - {error} -

    - ) : null} - - -
    - ) -} diff --git a/apps/community-web/src/features/donate/states.tsx b/apps/community-web/src/features/donate/states.tsx deleted file mode 100644 index e2dd9f51..00000000 --- a/apps/community-web/src/features/donate/states.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client" - -import * as React from "react" -import { CircleAlert, Loader2 } from "lucide-react" - -interface DonateShellProps { - busy?: boolean - children: React.ReactNode -} - -export function DonateShell({ busy, children }: DonateShellProps) { - return ( -
    -
    - {busy ?
    -
    - ) -} - -interface DonateUnavailableProps { - title: string - children: React.ReactNode -} - -export function DonateUnavailable({ title, children }: DonateUnavailableProps) { - return ( -
    -
    -
    -
    - ) -} diff --git a/apps/community-web/src/features/host/console-keys.ts b/apps/community-web/src/features/host/console-keys.ts index a617438b..b93212b2 100644 --- a/apps/community-web/src/features/host/console-keys.ts +++ b/apps/community-web/src/features/host/console-keys.ts @@ -21,12 +21,6 @@ export const consoleKeys = { orgInvites: (orgId: string) => ["org-console", orgId, "invites"] as const, orgVerification: (orgId: string) => ["org-console", orgId, "verification"] as const, orgEvents: (orgId: string, when: string) => ["org-console", orgId, "events", when] as const, - orgPayments: (orgId: string) => ["org-console", orgId, "payments"] as const, - orgDonationSettings: (orgId: string) => ["org-console", orgId, "donation-settings"] as const, - orgDonations: (orgId: string, status: string, from: string, to: string) => - ["org-console", orgId, "donations", status, from, to] as const, - orgDonationSummary: (orgId: string, from: string, to: string) => - ["org-console", orgId, "donations", "summary", from, to] as const, portfolioAnalytics: (range: string, orgId: string) => ["hosted-events", "analytics", range, orgId] as const, diff --git a/apps/community-web/src/features/host/error-copy.ts b/apps/community-web/src/features/host/error-copy.ts index 3e7d7df5..8bf61389 100644 --- a/apps/community-web/src/features/host/error-copy.ts +++ b/apps/community-web/src/features/host/error-copy.ts @@ -24,7 +24,6 @@ export function useConsoleErrors(): ConsoleErrorCopy { [ErrorCode.CONFLICT]: t("error.conflict"), [ErrorCode.NOT_FOUND]: t("error.not_found"), [ErrorCode.ABUSE_HELD]: t("error.abuse_held"), - [ErrorCode.PAYMENT_UNAVAILABLE]: t("error.payment_unavailable"), } const message = useCallback( diff --git a/apps/community-web/src/features/host/org/org-overview.tsx b/apps/community-web/src/features/host/org/org-overview.tsx index 5aa47d8a..01d8a7e4 100644 --- a/apps/community-web/src/features/host/org/org-overview.tsx +++ b/apps/community-web/src/features/host/org/org-overview.tsx @@ -20,6 +20,14 @@ import { useConsoleFormat } from "../format" import { publicOrgPath } from "./org-slug" import { useOrgVerification } from "./verification-screen" +function hostnameOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, "") + } catch { + return url + } +} + interface NextStep { id: string icon: LucideIcon @@ -44,6 +52,8 @@ export function OrgOverview() { const memberCount = org.memberCount ?? 0 const eventCount = org.eventCount ?? 0 const website = safeExternalHref(org.websiteUrl) + const donationLink = safeExternalHref(org.donationUrl) + const donationHost = donationLink === null ? null : hostnameOf(donationLink) const allSteps: NextStep[] = [ { @@ -73,7 +83,7 @@ export function OrgOverview() { icon: BadgeCheck, title: t("next.verification_title", { defaultValue: "Apply for verification" }), body: t("next.verification_body", { - defaultValue: "A badge on every event, and donations for nonprofits.", + defaultValue: "A badge on every event you host.", }), done: status !== "unverified", section: "verification", @@ -193,11 +203,35 @@ export function OrgOverview() { sub={org.verifiedAt ? format.date(org.verifiedAt) : undefined} /> - {org.donationsEnabled ? t("overview.donations_on") : t("overview.donations_off")} - + donationLink === null ? ( + + {t("overview.no_donation_link")} + + ) : ( + + {donationHost} + + + ) + } + sub={ + canManage ? ( + + {donationLink === null + ? t("overview.add_donation_link") + : t("overview.edit_donation_link")} + + ) : undefined } /> diff --git a/apps/community-web/src/features/host/org/org-profile-form.tsx b/apps/community-web/src/features/host/org/org-profile-form.tsx index b7423129..4c92b64a 100644 --- a/apps/community-web/src/features/host/org/org-profile-form.tsx +++ b/apps/community-web/src/features/host/org/org-profile-form.tsx @@ -11,6 +11,7 @@ import { MAX_ORG_NAME, ORG_SLUG_MAX, ORG_SLUG_MIN, + SafeHttpsLinkSchema, SOCIAL_PLATFORMS, SOCIAL_PLATFORM_LABELS, UpdateOrganizationRequestSchema, @@ -56,6 +57,7 @@ export interface OrgProfileDraft { slugTouched: boolean description: string websiteUrl: string + donationUrl: string facebook: string instagram: string tiktok: string @@ -69,6 +71,7 @@ export const EMPTY_ORG_DRAFT: OrgProfileDraft = { slugTouched: false, description: "", websiteUrl: "", + donationUrl: "", facebook: "", instagram: "", tiktok: "", @@ -85,6 +88,7 @@ export function draftFromOrg(org: OrganizationDTO | null): OrgProfileDraft { slugTouched: true, description: org.description ?? "", websiteUrl: org.websiteUrl ?? "", + donationUrl: org.donationUrl ?? "", facebook: links.facebook ?? "", instagram: links.instagram ?? "", tiktok: links.tiktok ?? "", @@ -118,16 +122,33 @@ interface ProfileBody { socialLinks: SocialLinks | null } +function trimmedOrNull(value: string): string | null { + const trimmed = value.trim() + return trimmed === "" ? null : trimmed +} + function bodyFromDraft(draft: OrgProfileDraft, logo: ConsoleImage | null): ProfileBody { return { name: draft.name.trim(), - description: draft.description.trim() === "" ? null : draft.description.trim(), - websiteUrl: draft.websiteUrl.trim() === "" ? null : draft.websiteUrl.trim(), + description: trimmedOrNull(draft.description), + websiteUrl: trimmedOrNull(draft.websiteUrl), logoMediaId: logo?.mediaId ?? null, socialLinks: socialLinksFromDraft(draft), } } +function editBodyFromDraft( + draft: OrgProfileDraft, + logo: ConsoleImage | null, +): ProfileBody & { donationUrl: string | null } { + return { ...bodyFromDraft(draft, logo), donationUrl: trimmedOrNull(draft.donationUrl) } +} + +function donationLinkUnsafe(value: string): boolean { + const trimmed = trimmedOrNull(value) + return trimmed !== null && !SafeHttpsLinkSchema.safeParse(trimmed).success +} + function issuesToFields(issues: readonly { path: readonly PropertyKey[]; message: string }[]) { const out: Record = {} for (const issue of issues) { @@ -173,11 +194,16 @@ export function OrgProfileForm({ const slug = mode === "create" ? draft.slug : (org?.slug ?? draft.slug) const localErrors = useMemo(() => { - const body = bodyFromDraft(draft, logo) const parsed = mode === "create" - ? CreateOrganizationRequestSchema.safeParse({ ...body, slug: draft.slug.trim() }) - : UpdateOrganizationRequestSchema.safeParse({ id: org?.id ?? "", ...body }) + ? CreateOrganizationRequestSchema.safeParse({ + ...bodyFromDraft(draft, logo), + slug: draft.slug.trim(), + }) + : UpdateOrganizationRequestSchema.safeParse({ + id: org?.id ?? "", + ...editBodyFromDraft(draft, logo), + }) const out = parsed.success ? {} : issuesToFields(parsed.error.issues) if (mode === "create") { const problem = orgSlugProblem(draft.slug) @@ -199,6 +225,9 @@ export function OrgProfileForm({ defaultValue: "Enter a full https:// address.", }) } + if (out.donationUrl || donationLinkUnsafe(draft.donationUrl)) { + out.donationUrl = t("form.donation_invalid") + } for (const platform of SOCIAL_PLATFORMS) { const key = `socialLinks.${platform}` if (out[key]) { @@ -221,9 +250,13 @@ export function OrgProfileForm({ ? SOCIAL_PLATFORM_LABELS[key.slice("socialLinks.".length) as SocialPlatform] : t(`form.${key}`, { defaultValue: - { name: "Name", slug: "Handle", description: "Description", websiteUrl: "Website" }[ - key - ] ?? key, + { + name: "Name", + slug: "Handle", + description: "Description", + websiteUrl: "Website", + donationUrl: "Donation link", + }[key] ?? key, }) const summary: FieldError[] = Object.entries(fieldErrors).map(([key, message]) => ({ id: `org-${key.replace(".", "-")}`, @@ -232,9 +265,10 @@ export function OrgProfileForm({ const save = useMutation({ mutationFn: async () => { - const body = bodyFromDraft(draft, logo) - if (mode === "edit" && org) return api.updateOrganization({ id: org.id, ...body }) - return api.createOrganization({ ...body, slug: draft.slug.trim() }) + if (mode === "edit" && org) { + return api.updateOrganization({ id: org.id, ...editBodyFromDraft(draft, logo) }) + } + return api.createOrganization({ ...bodyFromDraft(draft, logo), slug: draft.slug.trim() }) }, onSuccess: (saved) => { toast.toast({ @@ -478,6 +512,26 @@ export function OrgProfileForm({ onChange={(event) => patch({ websiteUrl: event.target.value })} /> + {mode === "edit" ? ( + + patch({ donationUrl: event.target.value })} + /> + + ) : null}
    diff --git a/apps/community-web/src/features/host/org/org-screen.tsx b/apps/community-web/src/features/host/org/org-screen.tsx index c010b662..52952e27 100644 --- a/apps/community-web/src/features/host/org/org-screen.tsx +++ b/apps/community-web/src/features/host/org/org-screen.tsx @@ -5,7 +5,6 @@ import { BadgeCheck, Building2, CalendarDays, - CreditCard, ExternalLink, LayoutDashboard, Settings, @@ -33,9 +32,6 @@ import { MembersScreen } from "./members-screen" import { publicOrgPath } from "./org-slug" import { SuspendedBanner } from "./suspended-banner" -const PaymentsTab = lazy(() => - import("./payments/payments-tab").then((m) => ({ default: m.PaymentsTab })), -) const VerificationScreen = lazy(() => import("./verification-screen").then((m) => ({ default: m.VerificationScreen })), ) @@ -49,7 +45,6 @@ const SECTION_ICON: Record = { members: Users, verification: BadgeCheck, settings: Settings, - payments: CreditCard, } const BOTTOM_TAB_ORDER: readonly OrgSection[] = ["overview", "events", "members", "verification", "settings"] @@ -68,9 +63,7 @@ export function OrgScreen({ orgId, section }: OrgScreenProps) { const gate = useGate(orgs) const org: OrganizationDTO | null = (orgs.data ?? []).find((entry) => entry.id === orgId) ?? null - const isOwner = org?.myRole === "owner" - const isAdmin = isOwner || org?.myRole === "admin" - const canManage = isAdmin + const canManage = org?.myRole === "owner" || org?.myRole === "admin" const navItems = useMemo(() => { const items: ConsoleNavItem[] = [ @@ -180,13 +173,7 @@ export function OrgScreen({ orgId, section }: OrgScreenProps) { ) : null} }> - + )} @@ -195,19 +182,7 @@ export function OrgScreen({ orgId, section }: OrgScreenProps) { ) } -function OrgSectionScreen({ - section, - orgId, - orgName, - isOwner, - canManage, -}: { - section: OrgSection - orgId: string - orgName: string - isOwner: boolean - canManage: boolean -}) { +function OrgSectionScreen({ section }: { section: OrgSection }) { switch (section) { case "overview": return @@ -219,14 +194,5 @@ function OrgSectionScreen({ return case "settings": return - case "payments": - return ( - - ) } } diff --git a/apps/community-web/src/features/host/org/payments/connect-status-card.tsx b/apps/community-web/src/features/host/org/payments/connect-status-card.tsx deleted file mode 100644 index 5622644c..00000000 --- a/apps/community-web/src/features/host/org/payments/connect-status-card.tsx +++ /dev/null @@ -1,251 +0,0 @@ -"use client" - -import { useEffect, useRef, useState } from "react" -import { ExternalLink, RefreshCw } from "lucide-react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import type { OrgPaymentsStatusDTO } from "@civfix/shared" -import { useApi } from "@civfix/ui/data" -import { useT } from "@civfix/ui/i18n" - -import { useConsoleUrlState } from "@/components/console/url-state" -import { ConsoleButton } from "@/components/console/button" -import { Chip } from "@/components/console/chips/chip" -import { useConsoleToast } from "@/components/console/overlay/toast" - -import { consoleKeys } from "../../console-keys" -import { useConsoleErrors } from "../../error-copy" -import { useConsoleFormat } from "../../format" - -const RETURN_POLL_DELAYS_MS = [2000, 4000, 8000, 15000, 30000] as const - -export interface ConnectStatusCardProps { - orgId: string - status: OrgPaymentsStatusDTO - canManage: boolean - agreementCurrent: boolean - onOpenAgreement: () => void -} - -export function ConnectStatusCard({ - orgId, - status, - canManage, - agreementCurrent, - onOpenAgreement, -}: ConnectStatusCardProps) { - const { t } = useT("host-payments") - const api = useApi() - const qc = useQueryClient() - const toast = useConsoleToast() - const errors = useConsoleErrors() - const format = useConsoleFormat() - const { params, set } = useConsoleUrlState() - - const [awaitingReturn, setAwaitingReturn] = useState(false) - const timers = useRef([]) - - useEffect( - () => () => { - for (const handle of timers.current) window.clearTimeout(handle) - timers.current = [] - }, - [], - ) - - useEffect(() => { - if (params.stripe === "return") { - setAwaitingReturn(true) - toast.toast({ title: t("return.toast_title"), description: t("return.toast_body") }) - for (const delay of RETURN_POLL_DELAYS_MS) { - timers.current.push( - window.setTimeout(() => { - void qc.invalidateQueries({ queryKey: consoleKeys.orgPayments(orgId) }) - }, delay), - ) - } - set({ stripe: null }) - } else if (params.stripe === "refresh") { - toast.toast({ title: t("refresh.toast_title"), description: t("refresh.toast_body") }) - set({ stripe: null }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params.stripe]) - - useEffect(() => { - if (awaitingReturn && (status.state === "ready" || status.chargesEnabled)) { - setAwaitingReturn(false) - } - }, [awaitingReturn, status.state, status.chargesEnabled]) - - const createAccount = useMutation({ - mutationFn: () => api.createOrgStripeAccount({ id: orgId }), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: consoleKeys.orgPayments(orgId) }) - link.mutate("onboarding") - }, - onError: (err) => - toast.toast({ - title: errors.message(err, { - CONFLICT: t("connect.agreement_first"), - PAYMENT_UNAVAILABLE: t("connect.not_eligible"), - }), - tone: "danger", - }), - }) - - const link = useMutation({ - mutationFn: (type: "onboarding" | "update") => - api.createOrgStripeAccountLink({ id: orgId, type }), - onSuccess: (res) => { - window.location.assign(res.url) - }, - onError: (err) => toast.toast({ title: errors.message(err), tone: "danger" }), - }) - - const busy = createAccount.isPending || link.isPending - const requirements = [ - ...status.pastDue.map((key) => ({ key, tone: "past_due" as const })), - ...status.currentlyDue.map((key) => ({ key, tone: "currently_due" as const })), - ...status.pendingVerification.map((key) => ({ key, tone: "pending" as const })), - ] - - return ( -
    -
    -

    - {t("connect.title")} -

    -
    - - -
    -
    - - {awaitingReturn ? ( -

    - - {t("return.pending")} -

    - ) : null} - -

    - {t(`connect.state_${status.state}`)} -

    - - {status.state === "not_started" ? ( -
    - {!agreementCurrent ? ( -

    - {t("connect.agreement_required")} -

    - ) : null} -
    - {!agreementCurrent ? ( - - {t("connect.review_agreement")} - - ) : ( - createAccount.mutate()} - > - {t("connect.start")} - - )} -
    -
    - ) : null} - - {status.state === "onboarding" || status.state === "at_risk" ? ( - link.mutate(status.state === "onboarding" ? "onboarding" : "update")} - > - - {status.state === "onboarding" ? t("connect.continue") : t("connect.fix")} - - ) : null} - - {status.state === "ready" ? ( - link.mutate("update")} - > - - {t("connect.open_dashboard")} - - ) : null} - - {status.state === "blocked" ? ( -

    - {status.disabledReason - ? t("connect.blocked_reason", { reason: status.disabledReason }) - : t("connect.blocked_generic")} -

    - ) : null} - - {requirements.length > 0 ? ( -
    -

    - {t("connect.requirements")} -

    -
      - {requirements.map((item) => ( -
    • - {item.key} - {t(`connect.requirement_${item.tone}`)} -
    • - ))} -
    - {status.currentDeadline ? ( -

    - {t("connect.deadline", { when: format.date(status.currentDeadline) })} -

    - ) : null} -
    - ) : null} - -
    -
    -
    {t("connect.charges")}
    -
    - {status.chargesEnabled ? t("connect.enabled") : t("connect.disabled")} -
    -
    -
    -
    {t("connect.payouts")}
    -
    - {status.payoutsEnabled ? t("connect.enabled") : t("connect.disabled")} -
    -
    -
    -
    {t("connect.wallets")}
    -
    - {status.walletsAvailable.length > 0 - ? status.walletsAvailable.join(", ") - : t("connect.wallets_none")} -
    -
    -
    -
    {t("connect.last_synced")}
    -
    - {status.lastSyncedAt ? format.dateTime(status.lastSyncedAt) : t("connect.never")} -
    -
    -
    -
    - ) -} diff --git a/apps/community-web/src/features/host/org/payments/consent-agreement-flow.tsx b/apps/community-web/src/features/host/org/payments/consent-agreement-flow.tsx deleted file mode 100644 index 8f4d491f..00000000 --- a/apps/community-web/src/features/host/org/payments/consent-agreement-flow.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client" - -import { useState } from "react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import type { GetLegalVersionsResponse, OrgDonationAgreementDTO } from "@civfix/shared" -import { legalDocument } from "@civfix/shared/legal" -import { useApi } from "@civfix/ui/data" -import { useT } from "@civfix/ui/i18n" - -import { GuidedSheet } from "@/components/console/overlay/guided-sheet" -import { OrgDonationAgreementBody } from "@/components/legal/org-donation-agreement-body" -import { useConsoleToast } from "@/components/console/overlay/toast" -import { ConsoleButton } from "@/components/console/button" - -import { consoleKeys } from "../../console-keys" -import { useConsoleErrors } from "../../error-copy" -import { useConsoleFormat } from "../../format" - -const AGREEMENT_DOCUMENT = "org_donation_agreement" -const UI_TEMPLATE_VERSION = "console-payments-2" -const AGREEMENT_PATH = "/legal/org-donation-agreement" - -const AGREEMENT_PROSE = [ - "max-h-[46vh] overflow-y-auto overscroll-contain rounded-sm border border-console-line bg-console-tint px-token-4 py-token-3", - "text-token-13 leading-relaxed text-console-ink-2", - "[&_h2]:mb-token-2 [&_h2]:mt-token-4 [&_h2]:text-token-14 [&_h2]:font-bold [&_h2]:text-console-ink", - "[&_section:first-child_h2]:mt-0", - "[&_p]:mb-token-2 [&_ul]:mb-token-2 [&_ul]:list-disc [&_ul]:pl-token-5 [&_li]:mb-token-1", - "[&_strong]:font-bold [&_strong]:text-console-ink", - "[&_a]:font-semibold [&_a]:text-console-sky-strong [&_a]:underline [&_a]:underline-offset-2", - "[&_.legal-note]:rounded-xs [&_.legal-note]:bg-console-surface [&_.legal-note]:px-token-3 [&_.legal-note]:py-token-2", -].join(" ") - -export interface ConsentAgreementFlowProps { - orgId: string - agreement: OrgDonationAgreementDTO - open: boolean - canManage: boolean - onClose: () => void -} - -export function ConsentAgreementFlow({ - orgId, - agreement, - open, - canManage, - onClose, -}: ConsentAgreementFlowProps) { - const { t } = useT("host-payments") - const api = useApi() - const qc = useQueryClient() - const toast = useConsoleToast() - const errors = useConsoleErrors() - const format = useConsoleFormat() - - const [accepted, setAccepted] = useState(false) - const [authority, setAuthority] = useState(false) - - const versions = useQuery({ - queryKey: ["legal", "versions"], - enabled: open, - queryFn: () => api.getLegalVersions({}), - staleTime: 5 * 60_000, - retry: false, - }) - - const shownDoc = legalDocument(AGREEMENT_DOCUMENT) - const serverDoc = versions.data?.documents.find((entry) => entry.type === AGREEMENT_DOCUMENT) - const agreementHref = AGREEMENT_PATH - const requiredVersion = agreement.requiredVersion ?? serverDoc?.version ?? null - const staleBuild = requiredVersion !== null && requiredVersion !== shownDoc.version - - const accept = useMutation({ - mutationFn: () => - api.acceptOrgDonationAgreement({ - id: orgId, - version: shownDoc.version, - documentSha256: shownDoc.sha256, - surface: "web_org_settings", - screenRoute: `/manage/orgs/${orgId}/payments`, - uiTemplateVersion: UI_TEMPLATE_VERSION, - authorityAffirmed: true, - }), - onSuccess: () => { - toast.toast({ title: t("agreement.accepted"), tone: "success" }) - setAccepted(false) - setAuthority(false) - void qc.invalidateQueries({ queryKey: consoleKeys.orgPayments(orgId) }) - void qc.invalidateQueries({ queryKey: consoleKeys.orgDonationSettings(orgId) }) - onClose() - }, - onError: (err) => { - toast.toast({ - title: errors.message(err, { CONFLICT: t("agreement.drift") }), - tone: "danger", - }) - void versions.refetch() - void qc.invalidateQueries({ queryKey: consoleKeys.orgPayments(orgId) }) - }, - }) - - return ( - accept.mutate()} - secondary={ - - {t("agreement.close")} - - } - sections={[ - { - id: "who", - title: t("agreement.section_who"), - content:

    {t("agreement.body_who")}

    , - }, - { - id: "fees", - title: t("agreement.section_fees"), - content:

    {t("agreement.body_fees")}

    , - }, - { - id: "duties", - title: t("agreement.section_duties"), - content:

    {t("agreement.body_duties")}

    , - }, - ]} - notice={ -
    -

    - {requiredVersion - ? t("agreement.version", { version: requiredVersion }) - : t("agreement.version_unknown")} -

    - - {t("agreement.read_full")} - - {staleBuild ? ( -

    - {t("agreement.drift")} -

    - ) : null} - {agreement.acceptedAt ? ( -

    - {t("agreement.history", { - version: agreement.version ?? "—", - when: format.dateTime(agreement.acceptedAt), - who: agreement.acceptedByName ?? "—", - })} -

    - ) : null} -
    - } - > -
    -
    - -
    - - - {!canManage ? ( -

    {t("agreement.owner_only")}

    - ) : null} -
    -
    - ) -} diff --git a/apps/community-web/src/features/host/org/payments/donation-exports.dom.test.tsx b/apps/community-web/src/features/host/org/payments/donation-exports.dom.test.tsx deleted file mode 100644 index fd27d184..00000000 --- a/apps/community-web/src/features/host/org/payments/donation-exports.dom.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest" -import { screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import type { HostExportDTO } from "@civfix/shared" - -vi.mock("@civfix/ui/i18n", async () => { - const { makeI18nMock } = await import("@/components/console/__testing__/i18n-mock") - return makeI18nMock() -}) - -import { renderConsole } from "@/components/console/__testing__/harness" -import { DonationExports, exportDisplayStatus } from "./donation-exports" - -const ORG_ID = "11111111-1111-4111-8111-111111111111" - -function row(over: Partial = {}): HostExportDTO { - return { - id: "22222222-2222-4222-8222-222222222222", - cleanupId: null, - organizationId: ORG_ID, - kind: "donations", - status: "ready", - rowCount: 12, - byteSize: 900, - truncated: false, - errorCode: null, - requestedAt: "2026-05-01T10:00:00.000Z", - completedAt: "2026-05-01T10:00:30.000Z", - expiresAt: "2099-05-02T10:00:00.000Z", - ...over, - } -} - -function renderExports( - listResult: { items: HostExportDTO[]; nextCursor: null } | Error, - over: Record = {}, -) { - const client = { - listOrgDonationExports: - listResult instanceof Error - ? vi.fn().mockRejectedValue(listResult) - : vi.fn().mockResolvedValue(listResult), - requestOrgDonationExport: vi.fn().mockResolvedValue(row({ status: "queued" })), - downloadHostExport: vi.fn().mockResolvedValue({ - url: "https://objects.civfix.test/export.csv", - expiresAt: "2026-05-01T10:10:00.000Z", - filename: "donations.csv", - }), - ...over, - } - const view = renderConsole(, { - api: client as never, - }) - return { ...view, client } -} - -let assign = vi.fn() - -beforeEach(() => { - assign = vi.fn() - Object.defineProperty(window, "location", { - writable: true, - value: { ...window.location, assign }, - }) -}) - -describe("DonationExports", () => { - it("renders nothing for a member who cannot view donations", () => { - const { container } = renderConsole(, { - api: { listOrgDonationExports: vi.fn() } as never, - }) - expect(container.textContent).toBe("") - }) - - it("shows the empty state when the organization has never exported", async () => { - renderExports({ items: [], nextCursor: null }) - expect(await screen.findByText("exports.empty_title")).toBeTruthy() - }) - - it("surfaces a failed read as an error with a retry, never as an empty ledger", async () => { - renderExports(new Error("boom")) - expect(await screen.findByText("state.error_title")).toBeTruthy() - expect(screen.queryByText("exports.empty_title")).toBeNull() - }) - - it("requests an export and refreshes the list", async () => { - const user = userEvent.setup() - const { client } = renderExports({ items: [], nextCursor: null }) - await screen.findByText("exports.empty_title") - await user.click(screen.getByRole("button", { name: /exports.request/ })) - await waitFor(() => expect(client.requestOrgDonationExport).toHaveBeenCalledWith({ id: ORG_ID })) - await waitFor(() => expect(client.listOrgDonationExports).toHaveBeenCalledTimes(2)) - }) - - it("offers a download only for a READY row that has not expired, and follows the minted url", async () => { - const user = userEvent.setup() - const { client } = renderExports({ items: [row()], nextCursor: null }) - const button = await screen.findByRole("button", { name: "exports.download" }) - await user.click(button) - await waitFor(() => - expect(client.downloadHostExport).toHaveBeenCalledWith({ id: row().id }), - ) - await waitFor(() => expect(assign).toHaveBeenCalledWith("https://objects.civfix.test/export.csv")) - }) - - it("treats a ready row whose object has expired as expired: no download button", async () => { - renderExports({ - items: [row({ expiresAt: "2020-01-01T00:00:00.000Z" })], - nextCursor: null, - }) - expect(await screen.findByText(/exports.expired/)).toBeTruthy() - expect(screen.queryByRole("button", { name: "exports.download" })).toBeNull() - }) - - it("never says 'Available until ' on a row the reaper already expired", async () => { - renderExports({ - items: [row({ status: "expired", expiresAt: "2020-01-01T00:00:00.000Z" })], - nextCursor: null, - }) - expect(await screen.findByText(/exports.expired/)).toBeTruthy() - expect(screen.queryByText(/exports.expires/)).toBeNull() - expect(screen.getByText("enums:hostExportStatus.expired")).toBeTruthy() - }) - - it("names the reason a build failed instead of only colouring it red", async () => { - renderExports({ - items: [row({ status: "failed", errorCode: "too_many_rows", expiresAt: null })], - nextCursor: null, - }) - expect(await screen.findByText(/exports.failed_reason\(code=too_many_rows\)/)).toBeTruthy() - }) - - it("shows a queued row with its status chip and no download", async () => { - renderExports({ - items: [row({ status: "queued", rowCount: null, completedAt: null, expiresAt: null })], - nextCursor: null, - }) - expect(await screen.findByText("enums:hostExportStatus.queued")).toBeTruthy() - expect(screen.queryByRole("button", { name: "exports.download" })).toBeNull() - }) - - it("always states what an export contains", async () => { - renderExports({ items: [row()], nextCursor: null }) - expect(await screen.findByText("exports.note")).toBeTruthy() - }) -}) - -describe("exportDisplayStatus", () => { - const NOW = Date.parse("2026-06-01T00:00:00.000Z") - - it("is ONE state per row, so the chip and the caption can never disagree", () => { - expect(exportDisplayStatus(row(), NOW)).toBe("ready") - expect(exportDisplayStatus(row({ expiresAt: "2020-01-01T00:00:00.000Z" }), NOW)).toBe("expired") - expect(exportDisplayStatus(row({ status: "expired" }), NOW)).toBe("expired") - expect(exportDisplayStatus(row({ status: "queued", expiresAt: null }), NOW)).toBe("queued") - expect(exportDisplayStatus(row({ status: "running", expiresAt: null }), NOW)).toBe("running") - expect(exportDisplayStatus(row({ status: "failed", expiresAt: null }), NOW)).toBe("failed") - }) - - it("a ready row with no expiry stays ready", () => { - expect(exportDisplayStatus(row({ expiresAt: null }), NOW)).toBe("ready") - }) -}) diff --git a/apps/community-web/src/features/host/org/payments/donation-exports.tsx b/apps/community-web/src/features/host/org/payments/donation-exports.tsx deleted file mode 100644 index 3b078a91..00000000 --- a/apps/community-web/src/features/host/org/payments/donation-exports.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client" - -import { useState } from "react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { Download } from "lucide-react" -import type { HostExportDTO } from "@civfix/shared" -import { queryKeys, useApi, useOrgDonationExports } from "@civfix/ui/data" -import { useT } from "@civfix/ui/i18n" - -import { useGate } from "@/components/console/query-state" -import { EmptyState, LoadingState, StateGate } from "@/components/console/states" -import { ConsoleButton } from "@/components/console/button" -import { Chip } from "@/components/console/chips/chip" -import { QRow } from "@/components/console/qrow" -import { useConsoleToast } from "@/components/console/overlay/toast" - -import { useConsoleErrors } from "../../error-copy" -import { useConsoleFormat } from "../../format" - -export interface DonationExportsProps { - orgId: string - canView: boolean -} - -export type ExportDisplayStatus = HostExportDTO["status"] - -export function exportDisplayStatus(row: HostExportDTO, now: number): ExportDisplayStatus { - if (row.status === "expired") return "expired" - if (row.status !== "ready") return row.status - if (row.expiresAt && Date.parse(row.expiresAt) <= now) return "expired" - return "ready" -} - -export function DonationExports({ orgId, canView }: DonationExportsProps) { - const { t } = useT("host-payments") - const { t: tc } = useT("host-common") - const api = useApi() - const qc = useQueryClient() - const toast = useConsoleToast() - const errors = useConsoleErrors() - const format = useConsoleFormat() - - const list = useOrgDonationExports(canView ? orgId : undefined) - const gate = useGate(list) - const rows = list.data ?? [] - const now = Date.now() - - const request = useMutation({ - mutationFn: () => api.requestOrgDonationExport({ id: orgId }), - onSuccess: () => { - toast.toast({ - title: t("exports.requested"), - description: t("exports.requested_hint"), - tone: "success", - }) - void qc.invalidateQueries({ queryKey: queryKeys.orgDonationExports(orgId) }) - }, - onError: (err) => toast.toast({ title: errors.message(err), tone: "danger" }), - }) - - const [downloading, setDownloading] = useState(null) - const download = useMutation({ - mutationFn: (exportId: string) => api.downloadHostExport({ id: exportId }), - onSuccess: (res) => { - window.location.assign(res.url) - }, - onError: (err) => toast.toast({ title: errors.message(err), tone: "danger" }), - onSettled: () => setDownloading(null), - }) - - if (!canView) return null - - return ( -
    -
    -

    - {t("exports.title")} -

    - request.mutate()} - > - - {t("exports.request")} - -
    - -

    {t("exports.hint")}

    - - void list.refetch()} - skeleton={} - empty={rows.length === 0} - emptyState={ - - } - > -
      - {rows.map((row) => { - const status = exportDisplayStatus(row, now) - return ( -
    • - part !== null) - .join(" · ")} - chips={} - trailing={ - status === "ready" ? ( - { - setDownloading(row.id) - download.mutate(row.id) - }} - > - {downloading === row.id ? tc("action.loading") : t("exports.download")} - - ) : null - } - /> -
    • - ) - })} -
    -
    - -

    {t("exports.note")}

    -
    - ) -} diff --git a/apps/community-web/src/features/host/org/payments/donation-settings-form.tsx b/apps/community-web/src/features/host/org/payments/donation-settings-form.tsx deleted file mode 100644 index 9e210588..00000000 --- a/apps/community-web/src/features/host/org/payments/donation-settings-form.tsx +++ /dev/null @@ -1,300 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import type { OrgDonationSettingsDTO, OrgPaymentsStatusDTO } from "@civfix/shared" -import { MAX_REFUND_POLICY_TEXT, MAX_SUGGESTED_AMOUNTS } from "@civfix/shared" -import { useApi } from "@civfix/ui/data" -import { useT } from "@civfix/ui/i18n" - -import { fieldErrorsFrom } from "@/components/console/query-state" -import { ConsoleButton } from "@/components/console/button" -import { Field } from "@/components/console/forms/field" -import { TextInput, TextArea } from "@/components/console/forms/inputs" -import { ToggleRow } from "@/components/console/forms/toggle-row" -import { useConsoleToast } from "@/components/console/overlay/toast" - -import { consoleKeys } from "../../console-keys" -import { useConsoleErrors } from "../../error-copy" -import { useConsoleFormat } from "../../format" - -export interface DonationSettingsFormProps { - orgId: string - settings: OrgDonationSettingsDTO - status: OrgPaymentsStatusDTO - canManage: boolean - onOpenAgreement: () => void -} - -interface Blocker { - id: string - label: string -} - -export function DonationSettingsForm({ - orgId, - settings, - status, - canManage, - onOpenAgreement, -}: DonationSettingsFormProps) { - const { t } = useT("host-payments") - const { t: tc } = useT("host-common") - const api = useApi() - const qc = useQueryClient() - const toast = useConsoleToast() - const errors = useConsoleErrors() - const format = useConsoleFormat() - - const [enabled, setEnabled] = useState(settings.enabled) - const [sharingDefault, setSharingDefault] = useState(settings.donorSharingDefault) - const [mission, setMission] = useState(settings.missionBlurb ?? "") - const [designation, setDesignation] = useState(settings.designationNote ?? "") - const [refundPolicy, setRefundPolicy] = useState(settings.refundPolicyText ?? "") - const [minAmount, setMinAmount] = useState(String(settings.minAmountMinor / 100)) - const [maxAmount, setMaxAmount] = useState(String(settings.maxAmountMinor / 100)) - const [suggested, setSuggested] = useState( - settings.suggestedAmountsMinor.map((value) => String(value / 100)).join(", "), - ) - const [fields, setFields] = useState>({}) - - useEffect(() => { - setEnabled(settings.enabled) - }, [settings.enabled]) - - const eligibilityOk = - settings.eligibility.verdict === "eligible" || settings.eligibility.verdict === "grace" - const accountOk = status.state === "ready" || status.state === "at_risk" - const operatorLocked = status.donationsDisabledReason === "operator" - - const blockers: Blocker[] = [ - ...(accountOk ? [] : [{ id: "account", label: t("settings.blocker_account") }]), - ...(eligibilityOk ? [] : [{ id: "eligibility", label: t("settings.blocker_eligibility") }]), - ...(settings.agreement.current ? [] : [{ id: "agreement", label: t("settings.blocker_agreement") }]), - ...(operatorLocked ? [{ id: "operator", label: t("settings.blocker_operator") }] : []), - ] - const canEnable = blockers.length === 0 - - const save = useMutation({ - mutationFn: () => { - const toMinor = (value: string) => Math.round(Number(value) * 100) - return api.updateOrgDonationSettings({ - id: orgId, - enabled, - donorSharingDefault: sharingDefault, - missionBlurb: mission.trim() === "" ? null : mission.trim(), - designationNote: designation.trim() === "" ? null : designation.trim(), - refundPolicyText: refundPolicy.trim() === "" ? null : refundPolicy.trim(), - minAmountMinor: toMinor(minAmount), - maxAmountMinor: toMinor(maxAmount), - suggestedAmountsMinor: suggested - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0) - .map(toMinor) - .filter((value) => Number.isFinite(value) && value > 0) - .slice(0, MAX_SUGGESTED_AMOUNTS), - }) - }, - onSuccess: (res) => { - toast.toast({ title: t("settings.saved"), tone: "success" }) - setFields({}) - qc.setQueryData(consoleKeys.orgDonationSettings(orgId), res) - void qc.invalidateQueries({ queryKey: consoleKeys.orgPayments(orgId) }) - }, - onError: (err) => { - setFields(fieldErrorsFrom(err)) - toast.toast({ - title: errors.message(err, { - PAYMENT_UNAVAILABLE: t("settings.enable_unavailable"), - FORBIDDEN: t("settings.operator_locked"), - }), - tone: "danger", - }) - }, - }) - - return ( -
    -

    - {t("settings.title")} -

    - - blocker.label).join(" · ") - } - onChange={setEnabled} - /> - - {blockers.length > 0 ? ( -
      - {blockers.map((blocker) => ( -
    • - {blocker.label} - {blocker.id === "agreement" ? ( - - ) : null} -
    • - ))} -
    - ) : null} - -
    -
    -
    {t("settings.legal_name")}
    -
    {settings.legalName ?? "—"}
    -
    -
    -
    {t("settings.ein")}
    -
    - {settings.einLast4 ? `••• ${settings.einLast4}` : "—"} -
    -
    -
    - -

    - {t("settings.fee_disclosure", { - percent: (settings.effectiveFeeBps / 100).toFixed(settings.effectiveFeeBps % 100 === 0 ? 0 : 2), - })} -

    - -
    - -