From b630ce68475507c911809297f7b66df808e08e27 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 17:45:46 -0700 Subject: [PATCH 1/8] remove platform-processed donations and add donation links --- .changeset/tidy-pumas-shave.md | 5 + packages/shared/DECISIONS.md | 198 +++--- .../shared/__tests__/admin-schemas.test.ts | 3 - packages/shared/__tests__/client.test.ts | 6 +- .../__tests__/host-dashboard-fakes.test.ts | 13 +- .../shared/__tests__/host-endpoints.test.ts | 35 +- .../shared/__tests__/host-insights.test.ts | 54 +- .../shared/__tests__/host-schemas.test.ts | 58 +- .../shared/__tests__/payments-schemas.test.ts | 437 ------------ packages/shared/package.json | 10 - packages/shared/src/client/endpoints.ts | 325 +-------- .../src/fakes/__tests__/payments.fake.test.ts | 435 ------------ .../shared/src/fakes/host-dashboard.fake.ts | 18 - packages/shared/src/fakes/index.ts | 1 - packages/shared/src/fakes/payments.fake.ts | 657 ------------------ .../src/host/__tests__/capabilities.test.ts | 30 +- packages/shared/src/host/capabilities.ts | 3 - packages/shared/src/index.ts | 2 - packages/shared/src/interfaces/index.ts | 1 - packages/shared/src/interfaces/payments.ts | 234 ------- .../donation-disclosure-template.test.ts | 106 --- packages/shared/src/legal/documents.ts | 21 - .../src/legal/donation-disclosure-template.ts | 117 ---- packages/shared/src/legal/index.ts | 1 - .../payments/__tests__/donation-state.test.ts | 119 ---- .../payments/__tests__/eligibility.test.ts | 245 ------- .../src/payments/__tests__/fee-math.test.ts | 94 --- .../shared/src/payments/donation-state.ts | 39 -- packages/shared/src/payments/eligibility.ts | 174 ----- packages/shared/src/payments/fee-math.ts | 80 --- packages/shared/src/payments/index.ts | 3 - packages/shared/src/schemas/admin/index.ts | 1 - packages/shared/src/schemas/admin/orgs.ts | 5 +- packages/shared/src/schemas/admin/payments.ts | 261 ------- packages/shared/src/schemas/common.ts | 69 +- packages/shared/src/schemas/entities.ts | 71 +- packages/shared/src/schemas/host/exports.ts | 12 - packages/shared/src/schemas/host/insights.ts | 11 - .../shared/src/schemas/host/organizations.ts | 5 +- packages/shared/src/schemas/host/pages.ts | 1 - packages/shared/src/schemas/payments.ts | 559 --------------- packages/shared/src/schemas/social.ts | 9 +- packages/shared/src/types/errors.ts | 6 - packages/shared/tsup.config.ts | 1 - 44 files changed, 193 insertions(+), 4342 deletions(-) create mode 100644 .changeset/tidy-pumas-shave.md delete mode 100644 packages/shared/__tests__/payments-schemas.test.ts delete mode 100644 packages/shared/src/fakes/__tests__/payments.fake.test.ts delete mode 100644 packages/shared/src/fakes/payments.fake.ts delete mode 100644 packages/shared/src/interfaces/payments.ts delete mode 100644 packages/shared/src/legal/__tests__/donation-disclosure-template.test.ts delete mode 100644 packages/shared/src/legal/donation-disclosure-template.ts delete mode 100644 packages/shared/src/payments/__tests__/donation-state.test.ts delete mode 100644 packages/shared/src/payments/__tests__/eligibility.test.ts delete mode 100644 packages/shared/src/payments/__tests__/fee-math.test.ts delete mode 100644 packages/shared/src/payments/donation-state.ts delete mode 100644 packages/shared/src/payments/eligibility.ts delete mode 100644 packages/shared/src/payments/fee-math.ts delete mode 100644 packages/shared/src/payments/index.ts delete mode 100644 packages/shared/src/schemas/admin/payments.ts delete mode 100644 packages/shared/src/schemas/payments.ts diff --git a/.changeset/tidy-pumas-shave.md b/.changeset/tidy-pumas-shave.md new file mode 100644 index 00000000..5f2b5df2 --- /dev/null +++ b/.changeset/tidy-pumas-shave.md @@ -0,0 +1,5 @@ +--- +"@civfix/shared": minor +--- + +remove platform-processed donations; add donationUrl to organizations and people diff --git a/packages/shared/DECISIONS.md b/packages/shared/DECISIONS.md index 70aab5c2..bc656526 100644 --- a/packages/shared/DECISIONS.md +++ b/packages/shared/DECISIONS.md @@ -670,7 +670,7 @@ removed or re-valued, so every existing consumer that reads `tokens.color.*` is An Eventbrite-class host toolset lands as SIBLING resources under `/cleanups/:id/...` plus `/orgs/*`, `/pages/:slug`, `/me/hosted-events` and `/me/host-exports/*`. `cleanups` is not renamed and `CleanupDTO` is not replaced: it GROWS by optional/nullable/defaulted fields (`endsAt`, -`timezone`, `visibility`, `coverUrl`, `galleryUrls`, `donationUrl`, `donationOrg`, `pageSlug`, +`timezone`, `visibility`, `coverUrl`, `galleryUrls`, `donationUrl`, `pageSlug`, `registrationOpensAt`/`ClosesAt`, `organization`, `ticketTypes`, `registrationState`, `myRegistration`, `myCapabilities`, the host counters). A pre-0.40.0 payload still parses and every existing consumer is untouched. @@ -682,13 +682,13 @@ existing consumer is untouched. at the same position are fine. The problem is the VALUE space - a slug and a uuid are both plain path segments, so one handler would have to guess which it was given, and a slug shaped like a uuid would resolve to the wrong organization. The two public organization reads are therefore - `GET /orgs/by-slug/:slug` and `GET /orgs/by-slug/:slug/donate`; every other organization route is - `/orgs/:id/...`. The signup page is its own public namespace, `GET /pages/:slug`. + `GET /orgs/by-slug/:slug` and `GET /orgs/by-slug/:slug/events` (§46 retired + `GET /orgs/by-slug/:slug/donate`); every other organization route is `/orgs/:id/...`. The signup page is its own public namespace, `GET /pages/:slug`. - **`staff` is enum growth, deliberately.** `CleanupMemberRole` appends `staff` LAST (day-of helper: roster read + check-in, no guest contact, no answers, no analytics, no broadcast, no chat moderation). `parseResponse` never throws, so an older client degrades to "unknown role" rather than failing. The backend `CLEANUP_MEMBER_ROLE_VALUES` mirror must stay byte-identical. -- **Capabilities, not roles, are the contract.** `HostCapability` (18 values) is what routes check +- **Capabilities, not roles, are the contract.** `HostCapability` (17 values after §46) is what routes check and what `CleanupDTO.myCapabilities` / `HostedEventDTO.myCapabilities` carry. A client renders an action from a capability it holds and must treat an UNKNOWN capability string as "no extra permission" - that is what lets the matrix change without a client release. @@ -861,40 +861,14 @@ is no analytics SDK, no cookie, no identifier, and no per-person series anywhere includes the viewer's scope, because two team members with different capabilities may legitimately see different numbers. The fixed `AnalyticsRange` enums exist to bound that key space. -## 26. Donations: direct charges, integer minor units, one error code (0.40.0) - -civfix never holds funds. A donation is a DIRECT charge on the recipient organization's own -connected account; the organization is the merchant of record, pays the processor's fees, and civfix -takes a disclosed `application_fee` on top. - -- **Money is an integer minor unit plus an explicit currency, everywhere.** `MoneyDTO` - (`{ amountMinor, currency: "USD" }`) and `FeeBreakdownDTO` are integers with `z.number().int()`; - a float amount fails validation. There is no decimal string and no `number` of dollars in the - contract - rounding a donation is a compliance defect, not a display bug. -- **The platform fee is omitted at zero, not sent as zero.** `platformFeeBps` of `0` means the - adapter omits `application_fee_amount` entirely, so turning the fee off is an env change with no - code change if counsel rules it unlawful. -- **Donation status is monotonic; a dispute is a separate field.** `DonationStatus` - (`pending → succeeded → refunded/partially_refunded`, or `failed`) only ever advances, so - out-of-order webhooks are safe to apply. `DonationDisputeState` is its own column because a - disputed charge is still a succeeded charge, and collapsing the two would lose the receipt's basis. -- **Exactly one new `ErrorCode`: `PAYMENT_UNAVAILABLE` (503).** It covers payments disabled, an - organization blocked or charges-disabled, an ineligible organization, and processor 5xx - every - case where the answer is "not now", indeterminate from the caller's side. There is no - `PAYMENT_DECLINED` (declines happen in the browser at confirm time; no server path produces one), - no `ORG_NOT_ELIGIBLE` and no `AMOUNT_OUT_OF_RANGE`: amount problems are `VALIDATION.fields`, a - missing organization is `NOT_FOUND`, and stale consent is `CONFLICT`. -- **The donate page renders from ONE public read.** `DonationPageDTO` carries the organization, the - `donateState` gate, the connected account id, the fee preview, every server-authored disclosure - string, the disclosure version, the charitable registration number, the wallet list and the legal - document versions. A client never composes legally required text and never loads the payment - script for a state other than `READY`/`AT_RISK`. -- **Donor identity is opt-in and default-off in the type system.** `DonorSharingPolicy.defaultOn` is - the literal `false`, and `OrgDonationRowDTO.donorName`/`donorEmail` are `null` for every donor who - did not opt in - not a placeholder the organization could de-anonymize. -- **Nothing in the contract can carry card data or a secret.** No shape has a PAN, CVC, expiry, - payment method or Stripe key; the connected account id is public by necessity and the publishable - key is a build-time client variable, never an API response. +## 26. RETIRED by §46 - donations: direct charges, integer minor units, one error code (0.40.0) + +Retired in 0.48.0. civfix no longer processes payments of any kind, so every shape this entry +governed is gone: `MoneyDTO`, `FeeBreakdownDTO`, `DonationDTO`, `DonationPageDTO`, the `Payments` +seam, `DonationStatus`/`DonationDisputeState`, the `application_fee` reasoning and +`ErrorCode.PAYMENT_UNAVAILABLE`. An organization, a person and an event now each carry an EXTERNAL +`donationUrl` that the client opens out of the app; civfix touches no funds and holds no donation +record. See §46. ## 27. The admin plane gets its own reads, not a wider edge matcher (0.40.0) @@ -1003,29 +977,11 @@ mutation-shaped call (DECISIONS §17 in reverse) and would still not stop the co signup URL, which is where residents actually get it. `GetGuestEventTicketRequest.token` is the CONTRAST: a capability token that authenticates a specific guest IS a secret, so it rides a POST body. -## 31. The donation status ratchet has exactly one backward exception (0.40.0) - -`advanceStatus` is monotone over `DONATION_STATUS_RANK` and stays that way: fulfilment, dispute and -webhook replay can only move a donation forward, which is what makes out-of-order Stripe deliveries -safe. - -Refund totals are the one place that rule produced a wrong number. A card refund can be created -`pending` and later move to `failed` (a card closed between the charge and the refund). With a -ratchet, the donation that was marked `refunded` on the pending refund could never be corrected, so -the org's payout ledger, the receipt, the §321 report and the platform-fee ledger all stayed wrong -about money that never left civfix's control. +## 31. RETIRED by §46 - the donation status ratchet has exactly one backward exception (0.40.0) -`refundedDonationStatus({ current, amountMinor, refundedTotalMinor })` therefore DERIVES the status -from the recomputed total instead of advancing to it: - -- a donation that has not settled (`pending`, `failed`) is returned unchanged — this exception never - un-settles a donation, and never resurrects one that failed; -- inside the settled band the status is a pure function of the total: `0` → `succeeded`, - `< amountMinor` → `partially_refunded`, `>= amountMinor` → `refunded`. - -`isRefundStatusCorrection(current, next)` names the backward case so the caller can log it at error -level — a correction is always worth an operator's attention, because the application fee civfix -already returned for that refund cannot be un-refunded at Stripe. +Retired in 0.48.0 with the rest of the platform-payments contract. `DONATION_STATUS_RANK`, +`advanceStatus`, `refundedDonationStatus` and `isRefundStatusCorrection` shipped in +`@civfix/shared/payments`, which no longer exists - there is no donation record to ratchet. See §46. ## 32. The admin plane manages organizations through its own writes, and an org invite is a record (0.41.0) @@ -1130,8 +1086,8 @@ a client concern, not a tuple concern. privacy line is the point of the tier and it is drawn in two places at once: a coordinator can see that a person is coming and what they answered, and can never see an email or a phone number (`view_guest_contact`) and can never take the roster off the platform (`export`). Withholding one -without the other would be theatre - an export IS the contact sheet. `manage_payments` and -`view_donations` stay org-only, as for every event role. +without the other would be theatre - an export IS the contact sheet. (`manage_payments` and +`view_donations` were org-only tokens here; §46 removed both.) **Re-inviting an already-invited user is idempotent, not a 409.** `inviteEventTeamMember` follows §32's org-invite rule rather than inventing a second one: a repeat invite to the same identifier @@ -1195,7 +1151,7 @@ verification, which is evidence-backed and does scale. So `VerificationStatus`, `SetUserVerifiedRequest`, `PersonDTO.verified`, `LeaderboardEntryDTO.verified`, `AdminUserDTO.verificationStatus` and the `verification` media purpose are all REMOVED. Org verification is untouched (`OrgVerificationStatus`/`Kind`, `OrganizationRefDTO.verified`, the admin -verification queue, the donations eligibility gate), and so is `reportVerified`, which is a +verification queue), and so is `reportVerified`, which is a different claim about a different thing (this reporter's reports may be auto-forwarded to the city). These removals are NOT additive, and that is a deliberate exception to §11 rather than an oversight. @@ -1267,9 +1223,9 @@ event, and `COHOST_CAPABILITIES` carries `export` - the attendee contact roster withholds from admins. The two jobs needed two tokens: `manage_org_members` governs the ORG roster (list, invite, revoke, role, remove) and is held by owner and admin; `manage_team` stays event-shaped and organizer/owner-only. The org owner keeps the powers that are genuinely -owner-shaped (`manage_payments`, `cancel_event`, `manage_org_link`, `request_resources`, `export`, -`manage_team`). SEATING is narrower than managing: inviting is `manage_org_members`, but changing a -member's ROLE is OWNER-only, which is what the web console has always enforced ("Only the owner can +owner-shaped (`cancel_event`, `manage_org_link`, `request_resources`, `export`, `manage_team`; +`manage_payments` was in this list until §46 removed it). SEATING is narrower than managing: +inviting is `manage_org_members`, but changing a member's ROLE is OWNER-only, which is what the web console has always enforced ("Only the owner can change roles."); REMOVAL stays with any manager on a member the server marked `canRemove`, which is also what the console does. The backend that ADOPTS 0.43.0 must move its gates with it: `inviteOrganizationMember`, `listOrganizationMembers`, `listOrganizationInvites` and @@ -1298,24 +1254,10 @@ recurring event, a published event PAGE does not (it owns a slug and its own ana `includePage` defaults false. The gate is `manage_event` and the copy counts against `assertHostEventBudget`, because a duplicate is a new event by every measure the budget cares about. -**Org payouts amend §26 without breaking it: civfix still never holds funds.** §26 established that -donations are DIRECT charges on the org's connected account. Payouts follow the same line: the -`Payments` seam gains `retrieveBalance`, `createPayout` and `listPayouts`, and all three execute ON -the connected account (Stripe-Account header). The money never enters a civfix balance, so -`createOrgPayout` is the org moving its own funds to its own bank, not civfix disbursing. The -endpoints are `getOrgBalance` (`GET /orgs/:id/payments/balance`, `view_donations`), -`createOrgPayout` (`POST /orgs/:id/payments/payouts`, `manage_payments`, csrf, rate-limited, with a -REQUIRED `idempotencyKey` - a retried payout is real money moving twice) and `listOrgPayouts` -(`GET /orgs/:id/payments/payouts`, `view_donations`). `org_payouts` is an audit mirror of the Stripe -object, not a ledger civfix reconciles against. - -`OrgBalanceDTO` carries the payout SCHEDULE next to the balances on purpose. The connected accounts -are Stripe Standard, where an automatic daily schedule usually leaves `available` at ~0 and a manual -payout then fails for a reason the user cannot see. Shipping the schedule with the balance lets the -client disable the button and say why, instead of surfacing a Stripe error after the fact. Stripe's -`payouts_not_allowed` and `balance_insufficient` map to `AppError.validation` / `AppError.conflict` -with user-safe copy; `PayoutStatus` (`pending|in_transit|paid|failed|canceled`) mirrors Stripe's own -vocabulary so the `payout.paid|failed|canceled` webhooks need no translation table. +**RETIRED by §46 - org payouts amended §26 without breaking it.** The `Payments` seam's +`retrieveBalance`/`createPayout`/`listPayouts`, `OrgBalanceDTO`, `PayoutStatus`, `org_payouts` and +the three `getOrgBalance`/`createOrgPayout`/`listOrgPayouts` endpoints were removed in 0.48.0. +There is no connected account to pay out from. **The admin plane shows memberships, and stops showing a verification status.** `AdminUserDTO.organizations` (`{ id, slug, name, role }[]`, optional) lets the Users page answer @@ -1352,10 +1294,10 @@ brand, hue, chipInk, category and cleanup set are unchanged, and every DARK valu `getEventInsights` (`GET /cleanups/:id/insights`) returns one per-event read for the host surface: `phase`, a `clock`, seat counts, a registration trend, per-ticket-type and per-source seats, the -broadcast log, arrival offsets, credited hours, donations and returning volunteers. Every number in +broadcast log, arrival offsets, credited hours and returning volunteers. Every number in it is EXACT. There is no `suppressed` flag, no `k`, and no nullable count standing in for a hidden -one - the nullability in the response means "this host cannot see donations" (`money`) or "there is -no comparison set" (`returning`), never "this number was withheld". +one - the nullability in the response means "there is no comparison set" (`returning`), never +"this number was withheld". (A `money` block sat beside `returning` until §46 removed it.) That is a deliberate departure from §25, and it is narrow. §25's k-suppression protects a host from recovering an individual out of an aggregate. It cannot do that here, because the viewer of this @@ -1379,10 +1321,6 @@ and a registry backend can skew for a deploy window, and an old server must stil with suppressed cells. The five `eventAnalytics*` panels and the `seriesClosure` machinery behind them are untouched and stay for the console and the CSV exports; §25 continues to govern them. -`money.netMinor` is a SIGNED integer while `grossMinor` and `refundedMinor` stay non-negative: a fully -refunded donation leaves the processor fee behind, so the honest net for that event is below zero and -the client renders it as a negative amount rather than the server clamping the truth away at 0. - `EventPhase` (`upcoming | live | ended | cancelled`) is the one phase vocabulary, and `eventPhase()` in `@civfix/shared/host` is the one implementation: `cancelled` from the status, `ended` from a `done` status or two hours past the end (an absent `endsAt` means a four-hour event), `live` from two hours @@ -1486,3 +1424,81 @@ This retires the "geographic centre of the contiguous US" (39.8283, -98.5795) fr Registry count 344 → 345. **`ipLocate()` and `GEOJS_URL` (`src/geocode.ts`) are deprecated as of 0.47.0.** They called a third party (get.geojs.io) straight from the client for the same "roughly where is this caller" answer this endpoint now gives first-party, and the consumer plane has no callers left: the report flow's location step, the address-search proximity bias, the create-event initial point and `useUserLocation` all resolve through `getApproximateLocation` (the `useApproximateLocation()` hook, or the imperative `fetchApproximateLocation()` in `@civfix/ui/data`, which share one query-cache entry with the map). The export stays in 0.47.0 because that version is already published and a removal is a recorded 0.x minor (§4.2); it is slated for removal in the next minor, whose DECISIONS entry names the delivery set. Nothing in civfix-backend ever imported it. + +## 46. Platform-processed donations removed; a donation link is an external URL (0.48.0) + +civfix does not middleman transactions. The whole platform-payments contract is REMOVED from +`@civfix/shared`, and the only donation surface left is a link the host owns: an organization, a +person and an event each carry an external `donationUrl`, and the client opens it out of the app. +No money, no fee, no merchant of record, no donation record, no receipt, no payout, no eligibility +check and no payment processor anywhere in the contract. + +**Removed.** Modules: `schemas/payments.ts`, `schemas/admin/payments.ts`, `interfaces/payments.ts` +(the `Payments` seam, leaving 11), `fakes/payments.fake.ts` (`FakePayments`), the whole `payments/` +subpath export (`fee-math`, `donation-state`, `eligibility` - the `./payments` entry is gone from +`package.json` `exports` and from `tsup.config.ts`) and +`legal/donation-disclosure-template.ts`. Entities: `MoneyDTO`, `FeeBreakdownDTO`, `DonationDTO`, +`CleanupDonationOrgRef`, `OrganizationDTO.donationsEnabled`, `OrganizationDTO.donateSlug`, +`CleanupDTO.donationOrg`, `PublicEventPageDTO.donateSlug`, `AdminOrgDTO.donationsEnabled`, +`AdminOrgDTO.paymentsState`, `AdminOrgListQuery.donationsEnabled`, `EventInsights.money` +(`EventInsightsMoney`), `ListOrgDonationExportsRequest`/`Response`. Enums: `DonationStatus`, +`PayoutStatus`, `DonationDisputeState`, `OrgPaymentsState`, `DonateState`, `EligibilityVerdict`, +`EligibilitySource`; `HostCapability` loses `manage_payments` and `view_donations` (19 -> 17); +`HostExportKind` loses `donations` (the export kind had no producer left); +`LegalDocumentType` loses `donations`, `org_donation_agreement` and `donation_disclosure` (4 left); +`ConsentSurface` loses `web_donate` and `web_org_settings` and keeps exactly the registration +surfaces `web_register`, `mobile_register`, `onboarding`. Errors: +`ErrorCode.PAYMENT_UNAVAILABLE`, its 503 mapping and `AppError.paymentUnavailable`. Registry: the +`paymentsEndpoints` group itself and 27 endpoints (18 payments + 9 admin), so `endpoints` goes +345 -> 318 and the admin subset 107 -> 98. `getLegalVersions` (`GET /legal/versions`) lived in that +group and is KEPT - it moved into `coreEndpoints` unchanged, same name, method, path and auth. + +**Kept.** `CleanupDTO.donationUrl` and `CleanupDTO.donationClicks`, the `donate` page block and its +`SafeHttpsLinkSchema` url, `CreateCleanupRequest.donationUrl`, `PublicEventPageDTO.donationUrl`, +the donation-click metric on `host/analytics.ts`, `LegalDocumentVersionDTO` and the terms / +privacy / cookies / subprocessors documents, `HttpsUrlSchema` and `SafeHttpsLinkSchema`, and §25's +"donation clicks are a metric, not a route". + +**Added, additive and nullable.** `HttpsUrlSchema` (unchanged shape: trimmed, `.url()`, max 500, +`https://` prefix) now defines the field in all four places, and its DEFINITION moved from +`schemas/host/organizations.ts` to `schemas/entities.ts` so an entity can use it without an import +cycle (§4); `schemas/host/organizations.ts` re-exports it, so every existing import path still +resolves. + +- `OrganizationDTO.donationUrl: HttpsUrl | null | undefined`, in the slot `donationsEnabled` / + `donateSlug` vacated, plus `UpdateOrganizationRequest.donationUrl?: HttpsUrl | null` and a + read-only `AdminOrgDTO.donationUrl`. +- `PersonDTO.donationUrl: HttpsUrl | null | undefined`, which `UserProfileDTO` inherits through + its `PersonDTOSchema.extend(...)`, plus `UpdateProfileRequest.donationUrl?: HttpsUrl | null` + beside `socialLinks`. +- `CleanupDTO.organization` narrows from `OrganizationRefDTO` to `CleanupOrganizationRef`, which is + `OrganizationRefDTO` extended with `donationUrl` - a SUPERSET, so every existing consumer of the + org ref (the affiliation badge, the linked-event card) is unaffected and the backend only has to + project the extra column on the cleanup path. + +**Resolution rule.** An event's effective donation link is + +``` +cleanup.donationUrl ?? cleanup.organization?.donationUrl ?? cleanup.organizer.donationUrl ?? null +``` + +Event first (the host set a link for THIS event), then the hosting organization, then the +organizer as a person. `cleanup.organizer` is a `PersonDTO`, so its link rides along with no new +projection. The result is an external https URL validated by `HttpsUrlSchema` and opened OUT of the +app (in-app browser on native, a new context on web); civfix never processes the payment, never +sees the amount and stores nothing but the click count it already stored. + +**Delivery set (this is a 0.x removal, §4.2).** civfix-backend `services/api` AND +`services/media-worker`, civfix-admin, civfix-govt-web. civfix-govt-web is a tokens-only consumer +and imports nothing removed here, but it still bumps under the no-consumer-left-behind rule. +civfix-app's `apps/community-web` and `apps/community-mobile` and `packages/ui` are workspace +consumers and move in the same commit series with no adoption step. The backend must also drop the +Stripe adapter, the `Payments` seam wiring in `di.ts`, the donation/payout/eligibility services and +routes and the `PAYMENTS_*`/`STRIPE_*`/`DONATION_*` env, and update +`test/unit/route-coverage.test.ts` to 318. + +**Retired by this entry:** §26 (donations: direct charges), §31 (the donation status ratchet) and +the org-payouts amendment inside §33. Amended: §23 (`donationOrg` gone from the `CleanupDTO` +growth list, `/orgs/by-slug/:slug/donate` gone, `HostCapability` count), §33 and §34 (the +`manage_payments` / `view_donations` capability lines, the donations eligibility gate) and §36 +(the insights `money` block). diff --git a/packages/shared/__tests__/admin-schemas.test.ts b/packages/shared/__tests__/admin-schemas.test.ts index 6d976eb2..dab7ad19 100644 --- a/packages/shared/__tests__/admin-schemas.test.ts +++ b/packages/shared/__tests__/admin-schemas.test.ts @@ -504,7 +504,6 @@ describe("admin org management", () => { expect(base.success).toBe(true) if (base.success) { expect(base.data.memberCount).toBe(0) - expect(base.data.donationsEnabled).toBe(false) expect(base.data.suspendedAt).toBeUndefined() } expect( @@ -525,13 +524,11 @@ describe("admin org management", () => { verified: "verified", kind: "nonprofit", suspended: "false", - donationsEnabled: "1", limit: "25", }) expect(q.success).toBe(true) if (q.success) { expect(q.data.suspended).toBe(false) - expect(q.data.donationsEnabled).toBe(true) expect(q.data.limit).toBe(25) } expect(AdminOrgListQuerySchema.safeParse({ suspended: "yes" }).success).toBe(false) diff --git a/packages/shared/__tests__/client.test.ts b/packages/shared/__tests__/client.test.ts index dc818ae1..c85ca43b 100644 --- a/packages/shared/__tests__/client.test.ts +++ b/packages/shared/__tests__/client.test.ts @@ -22,7 +22,7 @@ function jsonResponse( describe("endpoint registry", () => { it("covers the full Phase 1 + admin surface with unique paths per method", () => { const names = Object.keys(endpoints) - expect(names.length).toBe(345) + expect(names.length).toBe(318) const seen = new Set() for (const name of names) { const e = endpoints[name as keyof typeof endpoints] @@ -33,11 +33,11 @@ describe("endpoint registry", () => { } }) - it("registers exactly 107 admin endpoints under /admin", () => { + it("registers exactly 98 admin endpoints under /admin", () => { const adminNames = Object.keys(endpoints).filter((n) => endpoints[n as keyof typeof endpoints].path.startsWith("/admin"), ) - expect(adminNames.length).toBe(107) + expect(adminNames.length).toBe(98) }) it("registers GET /geo/approximate as an optional, csrf-free v1 endpoint", () => { diff --git a/packages/shared/__tests__/host-dashboard-fakes.test.ts b/packages/shared/__tests__/host-dashboard-fakes.test.ts index d8109a9d..f376d17a 100644 --- a/packages/shared/__tests__/host-dashboard-fakes.test.ts +++ b/packages/shared/__tests__/host-dashboard-fakes.test.ts @@ -91,22 +91,11 @@ describe("fakeEventInsights", () => { expect(fakeEventInsights("cancelled", { now: NOW }).arrivals).toEqual([]) }) - it("carries both the present and the null money and returning variants", () => { - expect(fakeEventInsights("ended", { now: NOW }).money).not.toBeNull() - expect(fakeEventInsights("cancelled", { now: NOW }).money).toBeNull() - expect(fakeEventInsights("ended", { now: NOW, money: false }).money).toBeNull() - expect(fakeEventInsights("cancelled", { now: NOW, money: true }).money).not.toBeNull() + it("carries both the present and the null returning variants", () => { expect(fakeEventInsights("ended", { now: NOW }).returning).not.toBeNull() expect(fakeEventInsights("ended", { now: NOW, returning: false }).returning).toBeNull() }) - it("turns a fully refunded event into a negative net", () => { - const money = fakeEventInsights("ended", { now: NOW, refunded: true }).money - expect(money?.netMinor).toBeLessThan(0) - expect(money?.refundedMinor).toBe(money?.grossMinor) - expect(fakeEventInsights("ended", { now: NOW }).money?.netMinor).toBeGreaterThan(0) - }) - it("gives the ended phase a ranked volunteer list and every other phase none", () => { const ended = fakeEventInsights("ended", { now: NOW }) expect(ended.topVolunteers.length).toBeGreaterThan(0) diff --git a/packages/shared/__tests__/host-endpoints.test.ts b/packages/shared/__tests__/host-endpoints.test.ts index 8fc39e38..9d71fd7d 100644 --- a/packages/shared/__tests__/host-endpoints.test.ts +++ b/packages/shared/__tests__/host-endpoints.test.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from "vitest" -import { - endpoints, - hostEndpoints, - paymentsEndpoints, - hostAdminEndpoints, -} from "../src/client/endpoints.js" +import { endpoints, hostEndpoints, hostAdminEndpoints } from "../src/client/endpoints.js" import { extractParams, fillPath } from "../src/client/client.js" @@ -12,23 +7,20 @@ const UUID = "123e4567-e89b-12d3-a456-426614174000" const NEW_NAMES = [ ...Object.keys(hostEndpoints), - ...Object.keys(paymentsEndpoints), ...Object.keys(hostAdminEndpoints), ] as Array const CSRF_FREE_MUTATIONS = new Set([ "unsubscribeBroadcasts", "getGuestEventTicket", - "createDonationCheckout", "recordEventPageView", ]) describe("host platform endpoint registry", () => { - it("adds 134 endpoints, of which 30 are admin", () => { - expect(NEW_NAMES).toHaveLength(134) + it("adds 106 endpoints, of which 21 are admin", () => { + expect(NEW_NAMES).toHaveLength(106) expect(Object.keys(hostEndpoints)).toHaveLength(85) - expect(Object.keys(paymentsEndpoints)).toHaveLength(19) - expect(Object.keys(hostAdminEndpoints)).toHaveLength(30) + expect(Object.keys(hostAdminEndpoints)).toHaveLength(21) for (const name of Object.keys(hostAdminEndpoints)) { expect(endpoints[name as keyof typeof endpoints].path.startsWith("/admin"), name).toBe(true) } @@ -62,11 +54,9 @@ describe("host platform endpoint registry", () => { } }) - it("routes organizations by uuid except the two public slug reads", () => { + it("routes organizations by uuid except the public slug reads", () => { expect(endpoints.getOrganization.path).toBe("/orgs/by-slug/:slug") expect(endpoints.getOrganization.auth).toBe("optional") - expect(endpoints.getPublicOrgDonationPage.path).toBe("/orgs/by-slug/:slug/donate") - expect(endpoints.getPublicOrgDonationPage.auth).toBe("optional") for (const name of Object.keys(endpoints)) { const e = endpoints[name as keyof typeof endpoints] if (!e.path.startsWith("/orgs/")) continue @@ -131,17 +121,10 @@ describe("host platform endpoint registry", () => { expect(endpoints.previewEventBroadcast.path).not.toContain("/broadcasts/") }) - it("gives the donations kind of host export a retrieval path of its own", () => { - expect(endpoints.listOrgDonationExports.path).toBe("/orgs/:id/donations/exports") - expect(endpoints.requestOrgDonationExport.path).toBe("/orgs/:id/donations/export") + it("keeps the host export download on its own /me path", () => { expect(endpoints.downloadHostExport.path).toBe("/me/host-exports/:id/download") }) - it("pins /admin/donations/summary as a STATIC sibling of the /admin/donations list", () => { - expect(endpoints.adminListDonations.path).toBe("/admin/donations") - expect(endpoints.adminDonationTotalsByOrg.path).toBe("/admin/donations/summary") - }) - it("reads page CONTENT through the admin plane, not the public page route", () => { expect(endpoints.adminGetEventPage.path).toBe("/admin/pages/:id") expect(endpoints.adminGetEventPage.method).toBe("GET") @@ -278,11 +261,6 @@ describe("host platform endpoint registry", () => { it("fills :slug, :registrationId, :ticketTypeId and friends from the typed input", () => { const cases: Array<[keyof typeof endpoints, Record, string]> = [ ["getOrganization", { slug: "reach-out-la" }, "/orgs/by-slug/reach-out-la"], - [ - "getPublicOrgDonationPage", - { slug: "reach-out-la" }, - "/orgs/by-slug/reach-out-la/donate", - ], ["getPublicEventPage", { slug: "beach-sweep" }, "/pages/beach-sweep"], [ "getEventRegistration", @@ -312,7 +290,6 @@ describe("host platform endpoint registry", () => { `/cleanups/${UUID}/team/invites/${UUID}`, ], ["downloadHostExport", { id: UUID }, `/me/host-exports/${UUID}/download`], - ["getDonationStatus", { id: UUID }, `/donations/${UUID}/status`], ] for (const [name, input, expected] of cases) { const e = endpoints[name] diff --git a/packages/shared/__tests__/host-insights.test.ts b/packages/shared/__tests__/host-insights.test.ts index 25c65e95..512f00b6 100644 --- a/packages/shared/__tests__/host-insights.test.ts +++ b/packages/shared/__tests__/host-insights.test.ts @@ -57,7 +57,6 @@ function minimalInsights(): unknown { unmarked: 14, }, hours: { credited: 0, attendeesCredited: 0, attendeesCheckedIn: 0 }, - money: null, returning: null, } } @@ -98,7 +97,6 @@ describe("getEventInsights contract", () => { expect(parsed.broadcasts).toEqual([]) expect(parsed.arrivals).toEqual([]) expect(parsed.phase).toBe("upcoming") - expect(parsed.money).toBeNull() expect(parsed.returning).toBeNull() expect(EventInsightsSchema.parse(minimalInsights())).toEqual(parsed) }) @@ -155,57 +153,15 @@ describe("getEventInsights contract", () => { expect(GetEventInsightsResponseSchema.safeParse(atCap).success).toBe(true) }) - it("carries money and returning as whole nullable blocks, never partial ones", () => { - const withMoney = { + it("carries returning as a whole nullable block, never a partial one", () => { + const withReturning = { ...(minimalInsights() as Record), - money: { - currency: "USD", - donationCount: 3, - grossMinor: 12000, - netMinor: 11500, - refundedMinor: 0, - lastChargedAt: "2026-09-12T18:30:00.000Z", - }, returning: { seats: 5, ofRegistered: 14 }, } - const parsed = GetEventInsightsResponseSchema.parse(withMoney) - expect(parsed.money?.currency).toBe("USD") + const parsed = GetEventInsightsResponseSchema.parse(withReturning) expect(parsed.returning).toEqual({ seats: 5, ofRegistered: 14 }) - const eur = { - ...withMoney, - money: { ...(withMoney.money as Record), currency: "EUR" }, - } - expect(GetEventInsightsResponseSchema.safeParse(eur).success).toBe(false) - }) - - it("lets a fully refunded event report a negative net", () => { - const refunded = { - ...(minimalInsights() as Record), - money: { - currency: "USD", - donationCount: 2, - grossMinor: 12000, - netMinor: -640, - refundedMinor: 12000, - lastChargedAt: "2026-09-12T18:30:00.000Z", - }, - } - expect(GetEventInsightsResponseSchema.parse(refunded).money?.netMinor).toBe(-640) - const fractional = { - ...refunded, - money: { ...(refunded.money as Record), netMinor: -6.4 }, - } - expect(GetEventInsightsResponseSchema.safeParse(fractional).success).toBe(false) - const negativeGross = { - ...refunded, - money: { ...(refunded.money as Record), grossMinor: -1 }, - } - expect(GetEventInsightsResponseSchema.safeParse(negativeGross).success).toBe(false) - const negativeRefund = { - ...refunded, - money: { ...(refunded.money as Record), refundedMinor: -1 }, - } - expect(GetEventInsightsResponseSchema.safeParse(negativeRefund).success).toBe(false) + const partial = { ...withReturning, returning: { seats: 5 } } + expect(GetEventInsightsResponseSchema.safeParse(partial).success).toBe(false) }) it("names exactly four phases and carries no suppression flag", () => { diff --git a/packages/shared/__tests__/host-schemas.test.ts b/packages/shared/__tests__/host-schemas.test.ts index dd67fcbf..1a1a9d16 100644 --- a/packages/shared/__tests__/host-schemas.test.ts +++ b/packages/shared/__tests__/host-schemas.test.ts @@ -45,7 +45,6 @@ import { EventPageBlockSchema, EventRegistrationDTOSchema, HostedEventDTOSchema, - MoneyDTOSchema, OrganizationDTOSchema, PersonDTOSchema, TicketTypeDTOSchema, @@ -199,12 +198,7 @@ describe("host platform enum tuples (mirrored byte-identical by the backend)", ( "contact", ]) expect([...ThemeAccentSchema.options]).toEqual(["bloom", "moss", "sun", "sky", "lilac"]) - expect([...HostExportKindSchema.options]).toEqual([ - "roster", - "answers", - "checkins", - "donations", - ]) + expect([...HostExportKindSchema.options]).toEqual(["roster", "answers", "checkins"]) expect([...HostExportStatusSchema.options]).toEqual([ "queued", "running", @@ -251,25 +245,19 @@ describe("host platform enum tuples (mirrored byte-identical by the backend)", ( "privacy", "cookies", "subprocessors", - "donations", - "org_donation_agreement", - "donation_disclosure", ]) expect([...ConsentSurfaceSchema.options]).toEqual([ - "web_donate", - "web_org_settings", "web_register", "mobile_register", "onboarding", ]) }) - it("enumerates the 19 host capabilities in a stable order", () => { - expect(HOST_CAPABILITY_VALUES).toHaveLength(19) + it("enumerates the 17 host capabilities in a stable order", () => { + expect(HOST_CAPABILITY_VALUES).toHaveLength(17) expect(HOST_CAPABILITY_VALUES).toBe(HostCapabilitySchema.options) expect(HOST_CAPABILITY_VALUES[0]).toBe("view_event_private") - expect(HOST_CAPABILITY_VALUES.at(-3)).toBe("manage_payments") - expect(HOST_CAPABILITY_VALUES.at(-2)).toBe("view_donations") + expect(HOST_CAPABILITY_VALUES.at(-2)).toBe("request_resources") expect(HOST_CAPABILITY_VALUES.at(-1)).toBe("manage_org_members") expect(HostCapabilitySchema.safeParse("delete_everything").success).toBe(false) }) @@ -323,7 +311,7 @@ describe("additive DTO growth stays backward compatible", () => { expect(parsed.ticketTypes).toEqual([]) expect(parsed.myCapabilities).toEqual([]) expect(parsed.endsAt).toBeUndefined() - expect(parsed.donationOrg).toBeUndefined() + expect(parsed.organization).toBeUndefined() }) it("carries the host fields when the server sends them", () => { @@ -335,16 +323,23 @@ describe("additive DTO growth stays backward compatible", () => { pageSlug: "beach-sweep", registrationState: "waitlist", myCapabilities: ["view_roster", "check_in"], - donationOrg: { slug: "reach-out-la", name: "Reach Out LA", enabled: true }, - organization: { id: UUID2, slug: "reach-out-la", name: "Reach Out LA", verified: true }, + donationUrl: "https://example.org/give", + organization: { + id: UUID2, + slug: "reach-out-la", + name: "Reach Out LA", + verified: true, + donationUrl: "https://reachoutla.org/donate", + }, }) expect(parsed.visibility).toBe("unlisted") expect(parsed.myCapabilities).toEqual(["view_roster", "check_in"]) - expect(parsed.donationOrg?.enabled).toBe(true) + expect(parsed.donationUrl).toBe("https://example.org/give") expect(parsed.organization?.verified).toBe(true) + expect(parsed.organization?.donationUrl).toBe("https://reachoutla.org/donate") }) - it("defaults OrganizationDTO.verifiedStatus and leaves the donation fields absent", () => { + it("defaults OrganizationDTO.verifiedStatus and leaves donationUrl absent", () => { const org = OrganizationDTOSchema.parse({ id: UUID, slug: "reach-out-la", @@ -352,8 +347,16 @@ describe("additive DTO growth stays backward compatible", () => { createdAt: ISO, }) expect(org.verifiedStatus).toBe("unverified") - expect(org.donationsEnabled).toBeUndefined() - expect(org.donateSlug).toBeUndefined() + expect(org.donationUrl).toBeUndefined() + expect( + OrganizationDTOSchema.safeParse({ + id: UUID, + slug: "reach-out-la", + name: "Reach Out LA", + createdAt: ISO, + donationUrl: "http://reachoutla.org/donate", + }).success, + ).toBe(false) }) it("defaults the ticket-type counters", () => { @@ -608,14 +611,6 @@ describe("analytics envelopes carry suppression, never opens or clicks", () => { }) }) -describe("money is always integer minor units", () => { - it("rejects a float amount and a non-USD currency", () => { - expect(MoneyDTOSchema.safeParse({ amountMinor: 2500, currency: "USD" }).success).toBe(true) - expect(MoneyDTOSchema.safeParse({ amountMinor: 25.5, currency: "USD" }).success).toBe(false) - expect(MoneyDTOSchema.safeParse({ amountMinor: 2500, currency: "EUR" }).success).toBe(false) - }) -}) - describe("organization creation", () => { it("requires https on the website and rejects an unknown key", () => { const base = { name: "Reach Out LA", slug: "reach-out-la" } @@ -1030,7 +1025,6 @@ describe("hours on the host read models (0.45.0, DECISIONS §39)", () => { unmarked: 1, }, hours: { credited: 24, attendeesCredited: 12, attendeesCheckedIn: 12 }, - money: null, returning: null, ...extra, } diff --git a/packages/shared/__tests__/payments-schemas.test.ts b/packages/shared/__tests__/payments-schemas.test.ts deleted file mode 100644 index c523c741..00000000 --- a/packages/shared/__tests__/payments-schemas.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { describe, it, expect } from "vitest" -import { ERROR_HTTP_STATUS, ErrorCode, AppError } from "../src/types/errors.js" -import { - DonateStateSchema, - DonationDisputeStateSchema, - DonationStatusSchema, - EligibilitySourceSchema, - EligibilityVerdictSchema, - OrgPaymentsStateSchema, - PayoutStatusSchema, -} from "../src/schemas/common.js" -import { FeeBreakdownDTOSchema, LegalDocumentVersionDTOSchema } from "../src/schemas/entities.js" -import { - AcceptOrgDonationAgreementRequestSchema, - CreateDonationCheckoutRequestSchema, - CreateOrgPayoutRequestSchema, - DonationPageDTOSchema, - GetDonationStatusRequestSchema, - ListOrgPayoutsRequestSchema, - ListOrgPayoutsResponseSchema, - OrgBalanceDTOSchema, - OrgPaymentsStatusDTOSchema, - PayoutDTOSchema, -} from "../src/schemas/payments.js" -import { GetLegalVersionsResponseSchema } from "../src/schemas/legal.js" -import { SetOrgDonationsEnabledRequestSchema } from "../src/schemas/admin/payments.js" -import { endpoints } from "../src/client/endpoints.js" - -const UUID = "123e4567-e89b-12d3-a456-426614174000" -const ISO = "2026-09-01T10:00:00.000Z" - -describe("PAYMENT_UNAVAILABLE is the one new error code", () => { - it("maps to 503 and nothing else moved", () => { - expect(ERROR_HTTP_STATUS[ErrorCode.PAYMENT_UNAVAILABLE]).toBe(503) - expect(AppError.paymentUnavailable().httpStatus).toBe(503) - expect(AppError.paymentUnavailable().code).toBe(ErrorCode.PAYMENT_UNAVAILABLE) - expect(ERROR_HTTP_STATUS[ErrorCode.INTERNAL]).toBe(500) - expect(ERROR_HTTP_STATUS[ErrorCode.CONFLICT]).toBe(409) - expect(ERROR_HTTP_STATUS[ErrorCode.VALIDATION]).toBe(422) - }) - - it("adds no PAYMENT_DECLINED / ORG_NOT_ELIGIBLE / AMOUNT_OUT_OF_RANGE code", () => { - const codes = Object.values(ErrorCode) - expect(codes).toHaveLength(16) - expect(codes).not.toContain("PAYMENT_DECLINED") - expect(codes).not.toContain("ORG_NOT_ELIGIBLE") - expect(codes).not.toContain("AMOUNT_OUT_OF_RANGE") - }) -}) - -describe("donation enum tuples", () => { - it("pins each tuple in mirror order", () => { - expect([...DonationStatusSchema.options]).toEqual([ - "pending", - "succeeded", - "failed", - "refunded", - "partially_refunded", - ]) - expect([...DonationDisputeStateSchema.options]).toEqual([ - "none", - "open", - "won", - "lost", - "warning", - ]) - expect([...OrgPaymentsStateSchema.options]).toEqual([ - "not_started", - "onboarding", - "ready", - "at_risk", - "blocked", - ]) - expect([...DonateStateSchema.options]).toEqual(["READY", "AT_RISK", "BLOCKED", "OFF"]) - expect([...EligibilityVerdictSchema.options]).toEqual([ - "unknown", - "eligible", - "grace", - "ineligible", - "review_required", - ]) - expect([...EligibilitySourceSchema.options]).toEqual([ - "irs_pub78", - "irs_eo_bmf", - "irs_auto_revocation", - "ftb_revoked", - "ca_ag_mnos", - "ofac_sdn", - "central_org_confirmation", - ]) - }) - - it("keeps a dispute out of the monotonic donation status", () => { - expect(DonationStatusSchema.safeParse("disputed").success).toBe(false) - expect(DonationDisputeStateSchema.parse("open")).toBe("open") - }) -}) - -describe("fee breakdown", () => { - it("is integer minor units with an explicit estimate flag", () => { - const fees = FeeBreakdownDTOSchema.parse({ - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - platformFeeBps: 500, - }) - expect(fees.currency).toBe("USD") - expect(fees.processorFeeIsEstimate).toBe(true) - expect( - FeeBreakdownDTOSchema.safeParse({ - grossMinor: 25.0, - platformFeeMinor: 1.25, - processorFeeMinor: 1.03, - processorFeeIsEstimate: true, - netMinor: 22.72, - platformFeeBps: 500, - }).success, - ).toBe(false) - }) - - it("caps the platform fee bps inside the basis-point space", () => { - const base = { - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - } - expect(FeeBreakdownDTOSchema.safeParse({ ...base, platformFeeBps: 10001 }).success).toBe(false) - expect(FeeBreakdownDTOSchema.safeParse({ ...base, platformFeeBps: 0 }).success).toBe(true) - }) -}) - -describe("donation checkout request", () => { - const base = { - orgSlug: "reach-out-la", - amountMinor: 2500, - email: "Donor@Example.COM", - idempotencyKey: "abcd1234efgh", - consent: { - termsVersion: "2026-01-01", - privacyVersion: "2026-01-01", - donationTermsVersion: "2026-01-01", - disclosureVersion: "2026-01-01", - surface: "web_donate" as const, - screenRoute: "/donate/reach-out-la", - uiTemplateVersion: "1", - }, - } - - it("defaults shareIdentity OFF and lowercases the donor email", () => { - const parsed = CreateDonationCheckoutRequestSchema.parse(base) - expect(parsed.shareIdentity).toBe(false) - expect(parsed.email).toBe("donor@example.com") - expect(parsed.currency).toBe("USD") - }) - - it("refuses a float amount, a zero amount and an unknown key", () => { - expect( - CreateDonationCheckoutRequestSchema.safeParse({ ...base, amountMinor: 25.5 }).success, - ).toBe(false) - expect(CreateDonationCheckoutRequestSchema.safeParse({ ...base, amountMinor: 0 }).success).toBe( - false, - ) - expect( - CreateDonationCheckoutRequestSchema.safeParse({ ...base, cardNumber: "4242" }).success, - ).toBe(false) - expect( - CreateDonationCheckoutRequestSchema.safeParse({ ...base, acceptedAt: ISO }).success, - ).toBe(false) - }) - - it("requires the whole consent bundle", () => { - const { consent, ...withoutConsent } = base - expect(consent).toBeDefined() - expect(CreateDonationCheckoutRequestSchema.safeParse(withoutConsent).success).toBe(false) - }) -}) - -describe("donation page DTO carries everything the donor surface renders", () => { - const page = { - org: { - slug: "reach-out-la", - displayName: "Reach Out LA", - legalName: "Reach Out Los Angeles, Inc.", - verified: true, - }, - donateState: "READY", - donationsEnabled: true, - stripeAccount: "acct_123", - eligibility: { state: "open" }, - minAmountMinor: 500, - maxAmountMinor: 1000000, - platformFeeBps: 500, - processingFeeBps: 290, - processingFeeFixedMinor: 30, - feePreview: { - grossMinor: 2500, - platformFeeMinor: 125, - processorFeeMinor: 103, - processorFeeIsEstimate: true, - netMinor: 2272, - platformFeeBps: 500, - }, - disclosures: { - recipient: "Your donation goes to Reach Out Los Angeles, Inc.", - mayNotReceive: "The organization may not receive the full amount.", - mayNotReceiveUrl: "https://civfix.org/legal/donations#may-not-receive", - remittanceTiming: "Funds settle to the organization's account immediately.", - feePointer: "See the itemized fees above.", - deductibility: "Contributions are tax deductible to the extent allowed by law.", - merchantOfRecord: - "civfix facilitates this payment. Reach Out Los Angeles, Inc. is the merchant of record.", - refundPolicy: "Donations are non-refundable except as required by law.", - }, - disclosureVersion: "2026-01-01", - registrationNumber: "CT0123456", - taxDeductibility: { - deductible: true, - percentage: 100, - statement: "No goods or services were provided in exchange for this contribution.", - }, - donorSharing: { - defaultOn: false, - optInLabel: "Share my name and email with this organization", - whatIsShared: "Your name and email address.", - }, - } - - it("parses with the W4.R-required fields and safe defaults", () => { - const parsed = DonationPageDTOSchema.parse(page) - expect(parsed.stripeAccount).toBe("acct_123") - expect(parsed.donateState).toBe("READY") - expect(parsed.disclosureVersion).toBe("2026-01-01") - expect(parsed.registrationNumber).toBe("CT0123456") - expect(parsed.walletsAvailable).toEqual([]) - expect(parsed.legalVersions).toEqual([]) - expect(parsed.currency).toBe("USD") - expect(parsed.requiresTurnstile).toBe(true) - }) - - it("never says a donation is 100% of the gift", () => { - const parsed = DonationPageDTOSchema.parse(page) - const text = Object.values(parsed.disclosures).join(" ") - expect(text).not.toContain("100%") - }) - - it("keeps donor sharing default-off as a literal, not a settable flag", () => { - expect( - DonationPageDTOSchema.safeParse({ - ...page, - donorSharing: { ...page.donorSharing, defaultOn: true }, - }).success, - ).toBe(false) - }) -}) - -describe("org payments status", () => { - it("parses a not-started org with empty requirement lists", () => { - const parsed = OrgPaymentsStatusDTOSchema.parse({ - organizationId: UUID, - state: "not_started", - stripeAccountId: null, - agreement: { version: null }, - eligibility: { verdict: "unknown" }, - donateState: "OFF", - }) - expect(parsed.currentlyDue).toEqual([]) - expect(parsed.chargesEnabled).toBe(false) - expect(parsed.donationsEnabled).toBe(false) - expect(parsed.agreement.current).toBe(false) - expect(parsed.eligibility.checks).toEqual([]) - }) -}) - -describe("consent and operator actions require an explicit record", () => { - it("requires an affirmative authority declaration on the §318 agreement", () => { - const base = { id: UUID, version: "2026-01-01" } - expect(AcceptOrgDonationAgreementRequestSchema.safeParse(base).success).toBe(false) - const ok = AcceptOrgDonationAgreementRequestSchema.safeParse({ - ...base, - authorityAffirmed: true, - }) - expect(ok.success).toBe(true) - expect(ok.success && ok.data.surface).toBe("web_org_settings") - expect( - AcceptOrgDonationAgreementRequestSchema.safeParse({ ...base, authorityAffirmed: false }) - .success, - ).toBe(false) - }) - - it("requires a reason before an operator can flip an org's donations", () => { - expect( - SetOrgDonationsEnabledRequestSchema.safeParse({ id: UUID, enabled: false }).success, - ).toBe(false) - expect( - SetOrgDonationsEnabledRequestSchema.safeParse({ id: UUID, enabled: false, reason: "" }) - .success, - ).toBe(false) - expect( - SetOrgDonationsEnabledRequestSchema.safeParse({ - id: UUID, - enabled: false, - reason: "AG registry lapsed", - }).success, - ).toBe(true) - }) - - it("lets a guest read a donation status with a capability token and nothing else", () => { - expect(GetDonationStatusRequestSchema.safeParse({ id: UUID }).success).toBe(true) - expect(GetDonationStatusRequestSchema.safeParse({ id: UUID, token: "t".repeat(24) }).success).toBe( - true, - ) - expect( - GetDonationStatusRequestSchema.safeParse({ id: UUID, email: "a@b.com" }).success, - ).toBe(false) - }) -}) - -describe("legal versions", () => { - it("shapes a document version as type + version + hash + effective date + url", () => { - const doc = LegalDocumentVersionDTOSchema.parse({ - type: "donations", - version: "2026-01-01", - sha256: "a".repeat(64), - effectiveAt: ISO, - url: "https://civfix.org/legal/donations", - }) - expect(doc.type).toBe("donations") - expect( - GetLegalVersionsResponseSchema.parse({ documents: [doc], generatedAt: ISO }).documents, - ).toHaveLength(1) - }) -}) - -describe("org balance and payouts (0.43.0)", () => { - const money = { amountMinor: 12500, currency: "USD" } - - it("keeps PayoutStatus in Stripe's own order", () => { - expect([...PayoutStatusSchema.options]).toEqual([ - "pending", - "in_transit", - "paid", - "failed", - "canceled", - ]) - }) - - it("round-trips a payout row and tolerates an unscheduled arrival", () => { - const payout = { - id: UUID, - stripePayoutId: "po_1", - amount: money, - status: "pending", - createdAt: ISO, - } - const parsed = PayoutDTOSchema.parse(payout) - expect(parsed.arrivalDate).toBeUndefined() - expect(parsed.failureMessage).toBeUndefined() - expect( - PayoutDTOSchema.safeParse({ - ...payout, - status: "failed", - arrivalDate: null, - failureMessage: "Account closed", - }).success, - ).toBe(true) - expect(PayoutDTOSchema.safeParse({ ...payout, status: "settled" }).success).toBe(false) - }) - - it("carries the schedule with the balance so the client can explain a disabled button", () => { - const balance = { - available: money, - pending: { amountMinor: 0, currency: "USD" }, - payoutsEnabled: true, - payoutSchedule: { interval: "daily", delayDays: 2 }, - lastSyncedAt: ISO, - } - expect(OrgBalanceDTOSchema.parse(balance).payoutSchedule?.interval).toBe("daily") - expect(OrgBalanceDTOSchema.safeParse({ ...balance, payoutSchedule: null }).success).toBe(true) - expect( - OrgBalanceDTOSchema.parse({ ...balance, payoutsEnabled: undefined }).payoutsEnabled, - ).toBe(false) - expect( - OrgBalanceDTOSchema.safeParse({ ...balance, payoutSchedule: { interval: "hourly" } }).success, - ).toBe(false) - expect(OrgBalanceDTOSchema.safeParse({ ...balance, lastSyncedAt: undefined }).success).toBe( - false, - ) - }) - - it("requires an idempotency key and treats an absent amount as the whole balance", () => { - expect( - CreateOrgPayoutRequestSchema.parse({ id: UUID, idempotencyKey: UUID }), - ).toEqual({ id: UUID, currency: "USD", idempotencyKey: UUID }) - expect(CreateOrgPayoutRequestSchema.safeParse({ id: UUID }).success).toBe(false) - expect( - CreateOrgPayoutRequestSchema.safeParse({ id: UUID, idempotencyKey: "not-a-uuid" }).success, - ).toBe(false) - expect( - CreateOrgPayoutRequestSchema.safeParse({ id: UUID, idempotencyKey: UUID, amountMinor: 0 }) - .success, - ).toBe(false) - expect( - CreateOrgPayoutRequestSchema.safeParse({ id: UUID, idempotencyKey: UUID, currency: "EUR" }) - .success, - ).toBe(false) - expect( - CreateOrgPayoutRequestSchema.safeParse({ id: UUID, idempotencyKey: UUID, extra: 1 }).success, - ).toBe(false) - }) - - it("pages payouts like every other org list", () => { - expect(ListOrgPayoutsRequestSchema.parse({ id: UUID, limit: "10" })).toEqual({ - id: UUID, - limit: 10, - }) - expect(ListOrgPayoutsRequestSchema.safeParse({ id: UUID, limit: 51 }).success).toBe(false) - expect(ListOrgPayoutsResponseSchema.safeParse({ items: [], nextCursor: null }).success).toBe( - true, - ) - }) - - it("registers the three payout endpoints under /orgs/:id/payments", () => { - expect(endpoints.getOrgBalance.path).toBe("/orgs/:id/payments/balance") - expect(endpoints.getOrgBalance.method).toBe("GET") - expect(endpoints.getOrgBalance.csrf).toBe(false) - expect(endpoints.createOrgPayout.path).toBe("/orgs/:id/payments/payouts") - expect(endpoints.createOrgPayout.method).toBe("POST") - expect(endpoints.createOrgPayout.csrf).toBe(true) - expect(endpoints.listOrgPayouts.path).toBe("/orgs/:id/payments/payouts") - expect(endpoints.listOrgPayouts.method).toBe("GET") - expect(endpoints.listOrgPayouts.auth).toBe("required") - }) -}) diff --git a/packages/shared/package.json b/packages/shared/package.json index 6e574170..a3af2d5e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -140,16 +140,6 @@ "default": "./dist/ics/index.cjs" } }, - "./payments": { - "import": { - "types": "./dist/payments/index.d.ts", - "default": "./dist/payments/index.js" - }, - "require": { - "types": "./dist/payments/index.d.cts", - "default": "./dist/payments/index.cjs" - } - }, "./legal": { "import": { "types": "./dist/legal/index.d.ts", diff --git a/packages/shared/src/client/endpoints.ts b/packages/shared/src/client/endpoints.ts index a5441f27..dbe388b1 100644 --- a/packages/shared/src/client/endpoints.ts +++ b/packages/shared/src/client/endpoints.ts @@ -466,10 +466,6 @@ import { UndoEventCheckInResponseSchema, } from "../schemas/host/checkin.js" import { GetEventIcsRequestSchema, GetEventIcsResponseSchema } from "../schemas/host/ics.js" -import { - ListOrgDonationExportsRequestSchema, - ListOrgDonationExportsResponseSchema, -} from "../schemas/host/exports.js" import { CancelEventBroadcastRequestSchema, CancelEventBroadcastResponseSchema, @@ -524,42 +520,6 @@ import { RequestEventExportRequestSchema, RequestEventExportResponseSchema, } from "../schemas/host/exports.js" -import { - AcceptOrgDonationAgreementRequestSchema, - AcceptOrgDonationAgreementResponseSchema, - CreateDonationCheckoutRequestSchema, - CreateDonationCheckoutResponseSchema, - CreateOrgStripeAccountLinkRequestSchema, - CreateOrgStripeAccountLinkResponseSchema, - CreateOrgStripeAccountRequestSchema, - CreateOrgStripeAccountResponseSchema, - GetDonationStatusRequestSchema, - GetDonationStatusResponseSchema, - GetMyDonationReceiptRequestSchema, - GetMyDonationReceiptResponseSchema, - GetOrgDonationSettingsRequestSchema, - CreateOrgPayoutRequestSchema, - CreateOrgPayoutResponseSchema, - GetOrgBalanceRequestSchema, - GetOrgBalanceResponseSchema, - GetOrgDonationSettingsResponseSchema, - GetOrgDonationSummaryRequestSchema, - GetOrgDonationSummaryResponseSchema, - GetOrgPaymentsStatusRequestSchema, - GetOrgPaymentsStatusResponseSchema, - GetPublicOrgDonationPageRequestSchema, - GetPublicOrgDonationPageResponseSchema, - ListMyDonationsRequestSchema, - ListMyDonationsResponseSchema, - ListOrgPayoutsRequestSchema, - ListOrgPayoutsResponseSchema, - ListOrgDonationsRequestSchema, - ListOrgDonationsResponseSchema, - RequestOrgDonationExportRequestSchema, - RequestOrgDonationExportResponseSchema, - UpdateOrgDonationSettingsRequestSchema, - UpdateOrgDonationSettingsResponseSchema, -} from "../schemas/payments.js" import { GetLegalVersionsResponseSchema } from "../schemas/legal.js" import { AdminAddOrgMemberRequestSchema, @@ -605,25 +565,6 @@ import { UnpublishEventPageRequestSchema, UnpublishEventPageResponseSchema, } from "../schemas/admin/pages.js" -import { - AdminDonationListQuerySchema, - AdminDonationListResponseSchema, - AdminDonationTotalsByOrgQuerySchema, - AdminDonationTotalsByOrgResponseSchema, - AdminPaymentsEligibilityListQuerySchema, - AdminPaymentsEligibilityListResponseSchema, - ConfirmOrgCentralOrgRequestSchema, - ConfirmOrgCentralOrgResponseSchema, - EvaluateOrgEligibilityRequestSchema, - EvaluateOrgEligibilityResponseSchema, - GetAdminOrgPaymentsRequestSchema, - GetAdminOrgPaymentsResponseSchema, - GetAdminPlatformDonationSettingsResponseSchema, - SetOrgDonationsEnabledRequestSchema, - SetOrgDonationsEnabledResponseSchema, - SetOrgEligibilityEinRequestSchema, - SetOrgEligibilityEinResponseSchema, -} from "../schemas/admin/payments.js" import { AdminGetMediaRequestSchema, AdminGetMediaResponseSchema, @@ -2593,6 +2534,15 @@ export const coreEndpoints = { csrf: false, version: "v1", }), + getLegalVersions: def({ + method: "GET", + path: "/legal/versions", + request: null, + response: GetLegalVersionsResponseSchema, + auth: "public", + csrf: false, + version: "v1", + }), } as const export const hostEndpoints = { @@ -3377,180 +3327,6 @@ export const hostEndpoints = { }), } as const -export const paymentsEndpoints = { - createOrgStripeAccount: def({ - method: "POST", - path: "/orgs/:id/payments/account", - request: CreateOrgStripeAccountRequestSchema, - response: CreateOrgStripeAccountResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - createOrgStripeAccountLink: def({ - method: "POST", - path: "/orgs/:id/payments/account-link", - request: CreateOrgStripeAccountLinkRequestSchema, - response: CreateOrgStripeAccountLinkResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - getOrgPaymentsStatus: def({ - method: "GET", - path: "/orgs/:id/payments", - request: GetOrgPaymentsStatusRequestSchema, - response: GetOrgPaymentsStatusResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - getOrgBalance: def({ - method: "GET", - path: "/orgs/:id/payments/balance", - request: GetOrgBalanceRequestSchema, - response: GetOrgBalanceResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - createOrgPayout: def({ - method: "POST", - path: "/orgs/:id/payments/payouts", - request: CreateOrgPayoutRequestSchema, - response: CreateOrgPayoutResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - listOrgPayouts: def({ - method: "GET", - path: "/orgs/:id/payments/payouts", - request: ListOrgPayoutsRequestSchema, - response: ListOrgPayoutsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - getOrgDonationSettings: def({ - method: "GET", - path: "/orgs/:id/donation-settings", - request: GetOrgDonationSettingsRequestSchema, - response: GetOrgDonationSettingsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - updateOrgDonationSettings: def({ - method: "PUT", - path: "/orgs/:id/donation-settings", - request: UpdateOrgDonationSettingsRequestSchema, - response: UpdateOrgDonationSettingsResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - acceptOrgDonationAgreement: def({ - method: "POST", - path: "/orgs/:id/donation-agreement", - request: AcceptOrgDonationAgreementRequestSchema, - response: AcceptOrgDonationAgreementResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - getPublicOrgDonationPage: def({ - method: "GET", - path: "/orgs/by-slug/:slug/donate", - request: GetPublicOrgDonationPageRequestSchema, - response: GetPublicOrgDonationPageResponseSchema, - auth: "optional", - csrf: false, - version: "v1", - }), - createDonationCheckout: def({ - method: "POST", - path: "/donations/checkout", - request: CreateDonationCheckoutRequestSchema, - response: CreateDonationCheckoutResponseSchema, - auth: "optional", - csrf: false, - version: "v1", - }), - getDonationStatus: def({ - method: "GET", - path: "/donations/:id/status", - request: GetDonationStatusRequestSchema, - response: GetDonationStatusResponseSchema, - auth: "optional", - csrf: false, - version: "v1", - }), - listMyDonations: def({ - method: "GET", - path: "/me/donations", - request: ListMyDonationsRequestSchema, - response: ListMyDonationsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - getMyDonationReceipt: def({ - method: "GET", - path: "/me/donations/:id/receipt", - request: GetMyDonationReceiptRequestSchema, - response: GetMyDonationReceiptResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - listOrgDonations: def({ - method: "GET", - path: "/orgs/:id/donations", - request: ListOrgDonationsRequestSchema, - response: ListOrgDonationsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - getOrgDonationSummary: def({ - method: "GET", - path: "/orgs/:id/donations/summary", - request: GetOrgDonationSummaryRequestSchema, - response: GetOrgDonationSummaryResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - listOrgDonationExports: def({ - method: "GET", - path: "/orgs/:id/donations/exports", - request: ListOrgDonationExportsRequestSchema, - response: ListOrgDonationExportsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - requestOrgDonationExport: def({ - method: "POST", - path: "/orgs/:id/donations/export", - request: RequestOrgDonationExportRequestSchema, - response: RequestOrgDonationExportResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - getLegalVersions: def({ - method: "GET", - path: "/legal/versions", - request: null, - response: GetLegalVersionsResponseSchema, - auth: "public", - csrf: false, - version: "v1", - }), -} as const - export const hostAdminEndpoints = { adminListOrgVerifications: def({ method: "GET", @@ -3708,87 +3484,6 @@ export const hostAdminEndpoints = { version: "v1", }), - adminGetOrgPayments: def({ - method: "GET", - path: "/admin/orgs/:id/payments", - request: GetAdminOrgPaymentsRequestSchema, - response: GetAdminOrgPaymentsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - adminListPaymentsEligibility: def({ - method: "GET", - path: "/admin/payments/eligibility", - request: AdminPaymentsEligibilityListQuerySchema, - response: AdminPaymentsEligibilityListResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - adminSetOrgDonationsEnabled: def({ - method: "POST", - path: "/admin/orgs/:id/payments/donations", - request: SetOrgDonationsEnabledRequestSchema, - response: SetOrgDonationsEnabledResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - adminSetOrgEligibilityEin: def({ - method: "POST", - path: "/admin/orgs/:id/payments/eligibility/ein", - request: SetOrgEligibilityEinRequestSchema, - response: SetOrgEligibilityEinResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - adminConfirmOrgCentralOrg: def({ - method: "POST", - path: "/admin/orgs/:id/payments/eligibility/central-org", - request: ConfirmOrgCentralOrgRequestSchema, - response: ConfirmOrgCentralOrgResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - adminEvaluateOrgEligibility: def({ - method: "POST", - path: "/admin/orgs/:id/payments/eligibility/evaluate", - request: EvaluateOrgEligibilityRequestSchema, - response: EvaluateOrgEligibilityResponseSchema, - auth: "required", - csrf: true, - version: "v1", - }), - adminListDonations: def({ - method: "GET", - path: "/admin/donations", - request: AdminDonationListQuerySchema, - response: AdminDonationListResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - adminDonationTotalsByOrg: def({ - method: "GET", - path: "/admin/donations/summary", - request: AdminDonationTotalsByOrgQuerySchema, - response: AdminDonationTotalsByOrgResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), - adminGetPlatformDonationSettings: def({ - method: "GET", - path: "/admin/payments/settings", - request: null, - response: GetAdminPlatformDonationSettingsResponseSchema, - auth: "required", - csrf: false, - version: "v1", - }), adminListHosts: def({ method: "GET", path: "/admin/hosts", @@ -3829,11 +3524,9 @@ export const hostAdminEndpoints = { export const endpoints: typeof coreEndpoints & typeof hostEndpoints & - typeof paymentsEndpoints & typeof hostAdminEndpoints = { ...coreEndpoints, ...hostEndpoints, - ...paymentsEndpoints, ...hostAdminEndpoints, } diff --git a/packages/shared/src/fakes/__tests__/payments.fake.test.ts b/packages/shared/src/fakes/__tests__/payments.fake.test.ts deleted file mode 100644 index 6cfcc19d..00000000 --- a/packages/shared/src/fakes/__tests__/payments.fake.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest" -import { WebhookSignatureError } from "../../interfaces/payments.js" -import { - FAKE_PAYMENTS_CLOCK_START_MS, - FAKE_WEBHOOK_SECRETS, - FakePayments, - WEBHOOK_TOLERANCE_SEC, -} from "../payments.fake.js" -import { hmacSha256Hex } from "../hmac.js" - -const ACCOUNT_INPUT = { - orgId: "org-1", - email: "ops@example.org", - legalName: "Example Org", - idempotencyKey: "acct:org-1:v1", -} - -function checkoutInput(accountId: string) { - return { - accountId, - donationId: "don-1", - orgId: "org-1", - amountMinor: 5000, - currency: "usd" as const, - productName: "Donation to Example Org", - applicationFeeMinor: 250, - expiresAtSec: Math.floor(FAKE_PAYMENTS_CLOCK_START_MS / 1000) + 1800, - idempotencyKey: "donation:don-1:checkout:v1", - } -} - -describe("FakePayments determinism", () => { - it("mints the same ids from a fresh instance and never reads the wall clock", () => { - const first = new FakePayments() - const second = new FakePayments() - return Promise.all([ - first.createConnectedAccount(ACCOUNT_INPUT), - second.createConnectedAccount(ACCOUNT_INPUT), - ]).then(([a, b]) => { - expect(a.accountId).toBe("acct_fake_1") - expect(b.accountId).toBe(a.accountId) - expect(a.livemode).toBe(false) - }) - }) - - it("resets back to the initial state", async () => { - const payments = new FakePayments() - await payments.createConnectedAccount(ACCOUNT_INPUT) - payments.reset() - const again = await payments.createConnectedAccount(ACCOUNT_INPUT) - expect(again.accountId).toBe("acct_fake_1") - }) -}) - -describe("FakePayments connected accounts", () => { - let payments: FakePayments - - beforeEach(() => { - payments = new FakePayments() - }) - - it("starts an account blocked and replays the same one for the same idempotency key", async () => { - const created = await payments.createConnectedAccount(ACCOUNT_INPUT) - expect(created.chargesEnabled).toBe(false) - expect(created.disabledReason).toBe("requirements.past_due") - expect(created.currentlyDue.length).toBeGreaterThan(0) - const again = await payments.createConnectedAccount(ACCOUNT_INPUT) - expect(again.accountId).toBe(created.accountId) - }) - - it("mints a NEW account for a new idempotency key, as Stripe does on reconnect", async () => { - const created = await payments.createConnectedAccount(ACCOUNT_INPUT) - const replacement = await payments.createConnectedAccount({ - ...ACCOUNT_INPUT, - idempotencyKey: "acct:org-1:v2:1", - }) - expect(replacement.accountId).not.toBe(created.accountId) - }) - - it("settleAccount flips it to a ready state", async () => { - const created = await payments.createConnectedAccount(ACCOUNT_INPUT) - const settled = payments.settleAccount(created.accountId) - expect(settled).toMatchObject({ - chargesEnabled: true, - payoutsEnabled: true, - detailsSubmitted: true, - disabledReason: null, - currentlyDue: [], - }) - expect(await payments.retrieveAccount(created.accountId)).toEqual(settled) - }) - - it("settleAccount can express the at-risk and blocked shapes", async () => { - const created = await payments.createConnectedAccount(ACCOUNT_INPUT) - const atRisk = payments.settleAccount(created.accountId, { - currentlyDue: ["individual.id_number"], - currentDeadlineSec: 1800000000, - }) - expect(atRisk.chargesEnabled).toBe(true) - expect(atRisk.currentlyDue).toEqual(["individual.id_number"]) - const blocked = payments.settleAccount(created.accountId, { - chargesEnabled: false, - disabledReason: "rejected.fraud", - }) - expect(blocked.disabledReason).toBe("rejected.fraud") - }) - - it("issues account links and registers payment method domains", async () => { - const created = await payments.createConnectedAccount(ACCOUNT_INPUT) - const link = await payments.createAccountLink({ - accountId: created.accountId, - type: "onboarding", - refreshUrl: "https://civfix.org/manage/orgs/1/payments?stripe=refresh", - returnUrl: "https://civfix.org/manage/orgs/1/payments?stripe=return", - }) - expect(link.url.startsWith("https://connect.stripe.test/onboarding/")).toBe(true) - expect(link.expiresAtSec).toBe(Math.floor(FAKE_PAYMENTS_CLOCK_START_MS / 1000) + 300) - - const pending = await payments.registerPaymentMethodDomain(created.accountId, "civfix.org") - expect(pending.enabled).toBe(false) - payments.settleAccount(created.accountId) - const repeat = await payments.registerPaymentMethodDomain(created.accountId, "civfix.org") - expect(repeat.id).toBe(pending.id) - }) - - it("refuses to act on an unknown account", async () => { - await expect(payments.retrieveAccount("acct_nope")).rejects.toThrow(/unknown connected account/) - }) -}) - -describe("FakePayments checkout lifecycle", () => { - let payments: FakePayments - let accountId: string - - beforeEach(async () => { - payments = new FakePayments() - const account = await payments.createConnectedAccount(ACCOUNT_INPUT) - accountId = account.accountId - payments.settleAccount(accountId) - }) - - it("creates a session and replays the same one for a repeated donation", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - expect(session.sessionId).toMatch(/^cs_fake_\d+$/) - expect(session.paymentIntentId).toMatch(/^pi_fake_\d+$/) - expect(session.clientSecret.startsWith(session.sessionId)).toBe(true) - const replay = await payments.createDonationCheckout(checkoutInput(accountId)) - expect(replay).toEqual(session) - }) - - it("rejects an invalid amount or an application fee above the amount", async () => { - await expect( - payments.createDonationCheckout({ ...checkoutInput(accountId), amountMinor: 0 }), - ).rejects.toThrow(/amountMinor/) - await expect( - payments.createDonationCheckout({ - ...checkoutInput(accountId), - applicationFeeMinor: 99999, - }), - ).rejects.toThrow(/applicationFeeMinor/) - }) - - it("reports an open session as unpaid until it is completed", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - const open = await payments.retrieveDonation(accountId, session.sessionId) - expect(open).toMatchObject({ status: "open", paymentStatus: "unpaid", chargeId: null, netMinor: null }) - - payments.completeCheckout(session.sessionId) - const paid = await payments.retrieveDonation(accountId, session.sessionId) - expect(paid.status).toBe("complete") - expect(paid.paymentStatus).toBe("paid") - expect(paid.stripeFeeMinor).toBe(175) - expect(paid.netMinor).toBe(5000 - 175 - 250) - expect(paid.cardLast4).toBe("4242") - expect(paid.chargeId).toMatch(/^ch_fake_\d+$/) - expect(paid.applicationFeeId).toMatch(/^fee_fake_\d+$/) - expect(paid.chargedAtSec).toBe(Math.floor(FAKE_PAYMENTS_CLOCK_START_MS / 1000)) - }) - - it("completing twice is idempotent on the charge id", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - const first = payments.completeCheckout(session.sessionId) - const second = payments.completeCheckout(session.sessionId) - expect(second.chargeId).toBe(first.chargeId) - expect(second.applicationFeeId).toBe(first.applicationFeeId) - }) - - it("omits the application fee object when the fee is zero", async () => { - const session = await payments.createDonationCheckout({ - ...checkoutInput(accountId), - applicationFeeMinor: 0, - }) - payments.completeCheckout(session.sessionId) - const snapshot = await payments.retrieveDonation(accountId, session.sessionId) - expect(snapshot.applicationFeeId).toBeNull() - }) - - it("expires an open session and refuses contradictory transitions", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - payments.expireCheckout(session.sessionId) - const snapshot = await payments.retrieveDonation(accountId, session.sessionId) - expect(snapshot.status).toBe("expired") - expect(snapshot.paymentStatus).toBe("unpaid") - expect(() => payments.completeCheckout(session.sessionId)).toThrow(/expired/) - }) - - it("returns the live client secret of an open session and withholds it once it is not", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - const open = await payments.retrieveCheckoutSession(accountId, session.sessionId) - expect(open.status).toBe("open") - expect(open.clientSecret).toBe(session.clientSecret) - expect(open.paymentIntentId).toBe(session.paymentIntentId) - - payments.expireCheckout(session.sessionId) - const expired = await payments.retrieveCheckoutSession(accountId, session.sessionId) - expect(expired.status).toBe("expired") - expect(expired.clientSecret).toBeNull() - }) - - it("refuses to read a session across connected accounts", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - const other = await payments.createConnectedAccount({ ...ACCOUNT_INPUT, orgId: "org-2" }) - await expect(payments.retrieveDonation(other.accountId, session.sessionId)).rejects.toThrow( - /does not belong/, - ) - }) - - it("refunds the application fee once and reports a zero refund as skipped", async () => { - const refund = await payments.refundApplicationFee("fee_fake_1", 100, "appfeerefund:1:v1") - expect(refund).toMatchObject({ amountMinor: 100, status: "succeeded" }) - const skipped = await payments.refundApplicationFee("fee_fake_1", 0, "appfeerefund:1:v2") - expect(skipped.status).toBe("skipped") - await expect(payments.refundApplicationFee("fee_fake_1", -1, "k")).rejects.toThrow(RangeError) - }) -}) - -describe("FakePayments webhook signatures", () => { - const payload = { id: "evt_1", type: "checkout.session.completed", data: { object: { id: "cs_1" } } } - let clockMs: number - let payments: FakePayments - - beforeEach(() => { - clockMs = FAKE_PAYMENTS_CLOCK_START_MS - payments = new FakePayments({ now: () => clockMs }) - }) - - it("signs with a real HMAC-SHA256 over `${t}.${raw}`", () => { - const signed = payments.signWebhook(payload, "connect") - const timestamp = signed.signatureHeader.split(",")[0]?.slice(2) as string - const expected = hmacSha256Hex(FAKE_WEBHOOK_SECRETS.connect, `${timestamp}.${signed.rawBody}`) - expect(signed.signatureHeader).toBe(`t=${timestamp},v1=${expected}`) - }) - - it("verifies a freshly signed event and carries the scope through", () => { - const signed = payments.signWebhook({ ...payload, account: "acct_fake_1" }, "connect") - const event = payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect") - expect(event).toMatchObject({ - id: "evt_1", - type: "checkout.session.completed", - scope: "connect", - accountId: "acct_fake_1", - livemode: false, - }) - expect(event.data.object).toEqual({ id: "cs_1" }) - }) - - it("accepts a raw Uint8Array body", () => { - const signed = payments.signWebhook(payload, "platform") - const bytes = new TextEncoder().encode(signed.rawBody) - expect(payments.verifyWebhookSignature(bytes, signed.signatureHeader, "platform").id).toBe("evt_1") - }) - - it("rejects a signature minted for the other scope", () => { - const signed = payments.signWebhook(payload, "connect") - expect(() => payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "platform")).toThrow( - WebhookSignatureError, - ) - }) - - it("rejects a tampered body and a forged signature", () => { - const signed = payments.signWebhook(payload, "connect") - expect(() => - payments.verifyWebhookSignature(`${signed.rawBody} `, signed.signatureHeader, "connect"), - ).toThrow(WebhookSignatureError) - const forged = signed.signatureHeader.replace(/v1=.*/u, `v1=${"0".repeat(64)}`) - expect(() => payments.verifyWebhookSignature(signed.rawBody, forged, "connect")).toThrow( - WebhookSignatureError, - ) - }) - - it("accepts a v1 among several during a secret roll and ignores v0", () => { - const signed = payments.signWebhook(payload, "connect") - const withNoise = `${signed.signatureHeader},v1=${"a".repeat(64)},v0=${"b".repeat(64)}` - expect(payments.verifyWebhookSignature(signed.rawBody, withNoise, "connect").id).toBe("evt_1") - }) - - it("rejects a v0-only header and a header with no timestamp", () => { - const signed = payments.signWebhook(payload, "connect") - const v0Only = signed.signatureHeader.replace("v1=", "v0=") - expect(() => payments.verifyWebhookSignature(signed.rawBody, v0Only, "connect")).toThrow( - WebhookSignatureError, - ) - const noTimestamp = signed.signatureHeader.split(",").slice(1).join(",") - expect(() => payments.verifyWebhookSignature(signed.rawBody, noTimestamp, "connect")).toThrow( - WebhookSignatureError, - ) - expect(() => payments.verifyWebhookSignature(signed.rawBody, "garbage", "connect")).toThrow( - WebhookSignatureError, - ) - }) - - it("enforces the 300 second tolerance in both directions", () => { - expect(WEBHOOK_TOLERANCE_SEC).toBe(300) - const signed = payments.signWebhook(payload, "connect") - clockMs += WEBHOOK_TOLERANCE_SEC * 1000 - expect(payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect").id).toBe( - "evt_1", - ) - clockMs += 1000 - expect(() => payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect")).toThrow( - /tolerance/, - ) - clockMs = FAKE_PAYMENTS_CLOCK_START_MS - (WEBHOOK_TOLERANCE_SEC + 1) * 1000 - expect(() => payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect")).toThrow( - /tolerance/, - ) - }) - - it("honours injected webhook secrets", () => { - const custom = new FakePayments({ now: () => clockMs, webhookSecrets: { connect: "whsec_other" } }) - const signed = custom.signWebhook(payload, "connect") - expect(signed.signatureHeader).not.toBe(payments.signWebhook(payload, "connect").signatureHeader) - expect(custom.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect").id).toBe( - "evt_1", - ) - expect(() => payments.verifyWebhookSignature(signed.rawBody, signed.signatureHeader, "connect")).toThrow( - WebhookSignatureError, - ) - }) - - it("rejects a correctly signed body that is not JSON", () => { - const timestamp = Math.floor(clockMs / 1000) - const rawBody = "not json" - const signature = hmacSha256Hex(FAKE_WEBHOOK_SECRETS.connect, `${timestamp}.${rawBody}`) - expect(() => - payments.verifyWebhookSignature(rawBody, `t=${timestamp},v1=${signature}`, "connect"), - ).toThrow(/JSON/) - }) -}) - -describe("FakePayments balance and payouts", () => { - let payments: FakePayments - let accountId: string - - beforeEach(async () => { - payments = new FakePayments() - const account = await payments.createConnectedAccount(ACCOUNT_INPUT) - accountId = account.accountId - payments.settleAccount(accountId) - }) - - it("reports zero available until a charge settles, then the net of that charge", async () => { - const empty = await payments.retrieveBalance(accountId) - expect(empty.availableMinor).toBe(0) - expect(empty.pendingMinor).toBe(0) - expect(empty.payoutsEnabled).toBe(true) - expect(empty.payoutSchedule).toEqual({ interval: "manual" }) - - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - payments.completeCheckout(session.sessionId, { stripeFeeMinor: 175 }) - const funded = await payments.retrieveBalance(accountId) - expect(funded.availableMinor).toBe(5000 - 175 - 250) - }) - - it("moves available funds, is idempotent per key, and refuses to overdraw", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - payments.completeCheckout(session.sessionId, { stripeFeeMinor: 175 }) - - const payout = await payments.createPayout(accountId, { - amountMinor: 1000, - currency: "usd", - idempotencyKey: "payout:org-1:v1", - }) - expect(payout.status).toBe("pending") - expect(payout.amountMinor).toBe(1000) - - const replay = await payments.createPayout(accountId, { - amountMinor: 1000, - currency: "usd", - idempotencyKey: "payout:org-1:v1", - }) - expect(replay.id).toBe(payout.id) - - expect((await payments.retrieveBalance(accountId)).availableMinor).toBe(5000 - 175 - 250 - 1000) - await expect( - payments.createPayout(accountId, { - amountMinor: 999_999, - currency: "usd", - idempotencyKey: "payout:org-1:v2", - }), - ).rejects.toThrow(/balance_insufficient/) - }) - - it("refuses a payout while payouts are disabled on the account", async () => { - payments.settleAccount(accountId, { payoutsEnabled: false }) - await expect( - payments.createPayout(accountId, { - amountMinor: 100, - currency: "usd", - idempotencyKey: "payout:org-1:v3", - }), - ).rejects.toThrow(/payouts_not_allowed/) - }) - - it("lists payouts newest first and pages with startingAfter", async () => { - const session = await payments.createDonationCheckout(checkoutInput(accountId)) - payments.completeCheckout(session.sessionId, { stripeFeeMinor: 175 }) - const first = await payments.createPayout(accountId, { - amountMinor: 100, - currency: "usd", - idempotencyKey: "payout:a", - }) - const second = await payments.createPayout(accountId, { - amountMinor: 200, - currency: "usd", - idempotencyKey: "payout:b", - }) - const page = await payments.listPayouts(accountId, { limit: 1 }) - expect(page.items.map((item) => item.id)).toEqual([second.id]) - expect(page.nextCursor).toBe(second.id) - const rest = await payments.listPayouts(accountId, { startingAfter: page.nextCursor }) - expect(rest.items.map((item) => item.id)).toEqual([first.id]) - expect(rest.nextCursor).toBeNull() - }) -}) diff --git a/packages/shared/src/fakes/host-dashboard.fake.ts b/packages/shared/src/fakes/host-dashboard.fake.ts index 4ea79369..ea4e8d19 100644 --- a/packages/shared/src/fakes/host-dashboard.fake.ts +++ b/packages/shared/src/fakes/host-dashboard.fake.ts @@ -40,8 +40,6 @@ const FAKE_TIMEZONE = "America/Los_Angeles" export interface FakeEventInsightsOptions { now: number seed?: number - money?: boolean - refunded?: boolean returning?: boolean timezone?: string } @@ -75,10 +73,6 @@ function fakeTopVolunteers( }) } -const FAKE_DONATION_GROSS_MINOR = 48_500 -const FAKE_DONATION_NET_MINOR = 46_130 -const FAKE_DONATION_REFUNDED_MINOR = 2_500 -const FAKE_DONATION_REFUNDED_NET_MINOR = -2_370 export interface FakeHostedEventsAnalyticsOptions { now: number @@ -296,8 +290,6 @@ export function fakeEventInsights( const profile = phaseProfile(phase, now) const endsAt = profile.startsAt + FAKE_EVENT_DURATION_MS const unmarked = Math.max(0, FAKE_REGISTERED - profile.checkedIn - profile.noShow) - const refunded = options.refunded ?? false - const withMoney = options.money ?? (refunded || phase !== "cancelled") const withReturning = options.returning ?? phase !== "cancelled" return { @@ -331,16 +323,6 @@ export function fakeEventInsights( attendeesCheckedIn: profile.checkedIn, }, topVolunteers: phase === "ended" ? fakeTopVolunteers(nextId, FAKE_EVENT_VOLUNTEER_HOURS) : [], - money: withMoney - ? { - currency: "USD", - donationCount: 11, - grossMinor: FAKE_DONATION_GROSS_MINOR, - netMinor: refunded ? FAKE_DONATION_REFUNDED_NET_MINOR : FAKE_DONATION_NET_MINOR, - refundedMinor: refunded ? FAKE_DONATION_GROSS_MINOR : FAKE_DONATION_REFUNDED_MINOR, - lastChargedAt: new Date(profile.startsAt - 2 * DAY_MS).toISOString(), - } - : null, returning: withReturning ? { seats: 17, ofRegistered: FAKE_REGISTERED } : null, } } diff --git a/packages/shared/src/fakes/index.ts b/packages/shared/src/fakes/index.ts index abc87aac..1588740c 100644 --- a/packages/shared/src/fakes/index.ts +++ b/packages/shared/src/fakes/index.ts @@ -10,6 +10,5 @@ export * from "./push-sender.fake.js" export * from "./routing-provider.fake.js" export * from "./abuse-checks.fake.js" export * from "./jobs.fake.js" -export * from "./payments.fake.js" export * from "./host-dashboard.fake.js" export { makeIdFactory } from "./ids.js" diff --git a/packages/shared/src/fakes/payments.fake.ts b/packages/shared/src/fakes/payments.fake.ts deleted file mode 100644 index 317eae2f..00000000 --- a/packages/shared/src/fakes/payments.fake.ts +++ /dev/null @@ -1,657 +0,0 @@ -import { - WebhookSignatureError, - type AccountBalance, - type AccountLink, - type ApplicationFeeRecord, - type ApplicationFeeRefund, - type BalanceTransactionRecord, - type CheckoutSessionSnapshot, - type ConnectedAccountStatus, - type CreateAccountLinkInput, - type CreateConnectedAccountInput, - type CreateDonationCheckoutInput, - type CreatePayoutInput, - type DonationCheckoutSession, - type DonationPaymentStatus, - type DonationSessionStatus, - type ListPayoutsInput, - type PaymentMethodDomainRegistration, - type PaymentRefundRecord, - type PaymentSnapshot, - type Payments, - type PaymentsListInput, - type PaymentsMode, - type PaymentsPage, - type PaymentsWebhookEvent, - type PaymentsWebhookScope, - type PayoutRecord, -} from "../interfaces/payments.js" -import { constantTimeEqual, hmacSha256Hex } from "./hmac.js" - -export const FAKE_WEBHOOK_SECRETS: Readonly> = { - connect: "whsec_fake_connect", - platform: "whsec_fake_platform", -} - -export const FAKE_PAYMENTS_CLOCK_START_MS = Date.UTC(2026, 0, 1, 0, 0, 0) -export const WEBHOOK_TOLERANCE_SEC = 300 -export const CHECKOUT_MIN_TTL_SEC = 30 * 60 -export const CHECKOUT_MAX_TTL_SEC = 24 * 60 * 60 -const PROCESSOR_FEE_BPS = 290 -const PROCESSOR_FEE_FIXED_MINOR = 30 - -export interface FakeRefund { - id: string - chargeId: string - amountMinor: number - status: string - reason: string | null - createdSec: number -} - -export interface FakeCheckout { - sessionId: string - paymentIntentId: string - clientSecret: string - accountId: string - donationId: string - orgId: string - amountMinor: number - applicationFeeMinor: number - expiresAtSec: number - status: DonationSessionStatus - paymentStatus: DonationPaymentStatus - chargeId: string | null - applicationFeeId: string | null - stripeFeeMinor: number | null - cardBrand: string | null - cardLast4: string | null - chargedAtSec: number | null -} - -export interface CompleteCheckoutOptions { - stripeFeeMinor?: number - cardBrand?: string - cardLast4?: string - chargedAtSec?: number -} - -export interface FakePaymentsOptions { - now?: () => number - mode?: PaymentsMode - webhookSecrets?: Partial> - processorFeeMinor?: (amountMinor: number) => number -} - -export interface FakeRefundOptions { - refundId?: string - status?: string - reason?: string | null - createdSec?: number -} - -export interface SignedWebhook { - rawBody: string - signatureHeader: string -} - -function defaultProcessorFee(amountMinor: number): number { - return Math.round((amountMinor * PROCESSOR_FEE_BPS) / 10000) + PROCESSOR_FEE_FIXED_MINOR -} - -export const FAKE_PAYMENTS_PAGE_LIMIT = 100 - -function pageOf( - items: readonly T[], - input: PaymentsListInput, -): PaymentsPage { - const since = input.since ?? null - const filtered = since === null ? [...items] : items.filter((item) => item.createdSec > since) - const cursor = input.cursor ?? null - const start = cursor === null ? 0 : filtered.findIndex((item) => item.id === cursor) + 1 - const limit = input.limit ?? FAKE_PAYMENTS_PAGE_LIMIT - const page = filtered.slice(start, start + limit) - const last = page[page.length - 1] - return { - items: page, - nextCursor: last !== undefined && start + page.length < filtered.length ? last.id : null, - } -} - -export class FakePayments implements Payments { - private readonly accounts = new Map() - private readonly accountsByOrg = new Map() - private readonly checkouts = new Map() - private readonly checkoutsByDonation = new Map() - private readonly domains = new Map() - private readonly refundedApplicationFeeMinor = new Map() - private readonly refunds: FakeRefund[] = [] - private readonly payouts: PayoutRecord[] = [] - private readonly payoutAccounts = new Map() - private readonly payoutsByIdempotency = new Map() - private readonly secrets: Record - private readonly clock: () => number - private readonly processorFee: (amountMinor: number) => number - private readonly paymentsMode: PaymentsMode - private counter = 0 - - constructor(options: FakePaymentsOptions = {}) { - this.clock = options.now ?? (() => FAKE_PAYMENTS_CLOCK_START_MS) - this.paymentsMode = options.mode ?? "test" - this.secrets = { - connect: options.webhookSecrets?.connect ?? FAKE_WEBHOOK_SECRETS.connect, - platform: options.webhookSecrets?.platform ?? FAKE_WEBHOOK_SECRETS.platform, - } - this.processorFee = options.processorFeeMinor ?? defaultProcessorFee - } - - private nextId(prefix: string): string { - this.counter += 1 - return `${prefix}_fake_${this.counter}` - } - - private nowSec(): number { - return Math.floor(this.clock() / 1000) - } - - private accountOrThrow(accountId: string): ConnectedAccountStatus { - const account = this.accounts.get(accountId) - if (account === undefined) throw new Error(`FakePayments: unknown connected account "${accountId}"`) - return account - } - - private checkoutOrThrow(sessionId: string): FakeCheckout { - const checkout = this.checkouts.get(sessionId) - if (checkout === undefined) throw new Error(`FakePayments: unknown checkout session "${sessionId}"`) - return checkout - } - - mode(): PaymentsMode { - return this.paymentsMode - } - - private livemode(): boolean { - return this.paymentsMode === "live" - } - - async createConnectedAccount(input: CreateConnectedAccountInput): Promise { - const requestKey = `${input.orgId}:${input.idempotencyKey}` - const existingId = this.accountsByOrg.get(requestKey) - if (existingId !== undefined) return this.accountOrThrow(existingId) - const accountId = this.nextId("acct") - const account: ConnectedAccountStatus = { - accountId, - livemode: this.livemode(), - detailsSubmitted: false, - chargesEnabled: false, - payoutsEnabled: false, - disabledReason: "requirements.past_due", - currentlyDue: ["business_profile.url", "individual.verification.document"], - pastDue: [], - pendingVerification: [], - futureCurrentlyDue: [], - currentDeadlineSec: null, - capabilities: { card_payments: "inactive", transfers: "inactive" }, - } - this.accounts.set(accountId, account) - this.accountsByOrg.set(requestKey, accountId) - return account - } - - async createAccountLink(input: CreateAccountLinkInput): Promise { - this.accountOrThrow(input.accountId) - const token = this.nextId("acctlink") - return { - url: `https://connect.stripe.test/${input.type}/${input.accountId}/${token}`, - expiresAtSec: this.nowSec() + 300, - } - } - - async retrieveAccount(accountId: string): Promise { - return this.accountOrThrow(accountId) - } - - async registerPaymentMethodDomain( - accountId: string, - domain: string, - ): Promise { - const account = this.accountOrThrow(accountId) - const key = `${accountId}:${domain}` - const existing = this.domains.get(key) - if (existing !== undefined) return existing - const registration: PaymentMethodDomainRegistration = { - id: this.nextId("pmd"), - domain, - enabled: account.chargesEnabled, - } - this.domains.set(key, registration) - return registration - } - - async createDonationCheckout(input: CreateDonationCheckoutInput): Promise { - this.accountOrThrow(input.accountId) - if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor <= 0) { - throw new RangeError("FakePayments: amountMinor must be a positive integer") - } - if ( - !Number.isSafeInteger(input.applicationFeeMinor) || - input.applicationFeeMinor < 0 || - input.applicationFeeMinor > input.amountMinor - ) { - throw new RangeError("FakePayments: applicationFeeMinor must be between 0 and amountMinor") - } - const ttlSec = input.expiresAtSec - this.nowSec() - if (ttlSec < CHECKOUT_MIN_TTL_SEC || ttlSec > CHECKOUT_MAX_TTL_SEC) { - throw new RangeError( - `FakePayments: expires_at must be ${CHECKOUT_MIN_TTL_SEC}-${CHECKOUT_MAX_TTL_SEC} seconds ahead, received ${ttlSec}`, - ) - } - const existingId = this.checkoutsByDonation.get(input.donationId) - if (existingId !== undefined) { - const existing = this.checkoutOrThrow(existingId) - return { - sessionId: existing.sessionId, - paymentIntentId: existing.paymentIntentId, - clientSecret: existing.clientSecret, - expiresAtSec: existing.expiresAtSec, - } - } - const sessionId = this.nextId("cs") - const paymentIntentId = this.nextId("pi") - const checkout: FakeCheckout = { - sessionId, - paymentIntentId, - clientSecret: `${sessionId}_secret_${this.counter}`, - accountId: input.accountId, - donationId: input.donationId, - orgId: input.orgId, - amountMinor: input.amountMinor, - applicationFeeMinor: input.applicationFeeMinor, - expiresAtSec: input.expiresAtSec, - status: "open", - paymentStatus: "unpaid", - chargeId: null, - applicationFeeId: null, - stripeFeeMinor: null, - cardBrand: null, - cardLast4: null, - chargedAtSec: null, - } - this.checkouts.set(sessionId, checkout) - this.checkoutsByDonation.set(input.donationId, sessionId) - return { - sessionId, - paymentIntentId, - clientSecret: checkout.clientSecret, - expiresAtSec: checkout.expiresAtSec, - } - } - - async retrieveCheckoutSession( - accountId: string, - sessionId: string, - ): Promise { - const checkout = this.checkoutOrThrow(sessionId) - if (checkout.accountId !== accountId) { - throw new Error("FakePayments: checkout session does not belong to that connected account") - } - const expired = checkout.status === "expired" || checkout.expiresAtSec <= this.nowSec() - const status: DonationSessionStatus = - checkout.status === "complete" ? "complete" : expired ? "expired" : "open" - return { - sessionId: checkout.sessionId, - paymentIntentId: checkout.paymentIntentId, - clientSecret: status === "open" ? checkout.clientSecret : null, - status, - expiresAtSec: checkout.expiresAtSec, - } - } - - async retrieveDonation(accountId: string, sessionId: string): Promise { - const checkout = this.checkoutOrThrow(sessionId) - if (checkout.accountId !== accountId) { - throw new Error("FakePayments: checkout session does not belong to that connected account") - } - const netMinor = - checkout.stripeFeeMinor === null - ? null - : Math.max(0, checkout.amountMinor - checkout.stripeFeeMinor - checkout.applicationFeeMinor) - return { - status: checkout.status, - paymentStatus: checkout.paymentStatus, - amountMinor: checkout.amountMinor, - stripeFeeMinor: checkout.stripeFeeMinor, - applicationFeeMinor: checkout.stripeFeeMinor === null ? null : checkout.applicationFeeMinor, - netMinor, - cardBrand: checkout.cardBrand, - cardLast4: checkout.cardLast4, - chargedAtSec: checkout.chargedAtSec, - livemode: this.livemode(), - chargeId: checkout.chargeId, - applicationFeeId: checkout.applicationFeeId, - } - } - - async listRefunds(accountId: string, chargeId: string): Promise { - this.accountOrThrow(accountId) - return this.refunds - .filter((refund) => refund.chargeId === chargeId) - .map((refund) => ({ - id: refund.id, - amountMinor: refund.amountMinor, - status: refund.status, - reason: refund.reason, - createdSec: refund.createdSec, - })) - } - - async listBalanceTransactions( - accountId: string, - input: PaymentsListInput, - ): Promise> { - this.accountOrThrow(accountId) - const settled = [...this.checkouts.values()] - .filter( - (checkout) => - checkout.accountId === accountId && - checkout.chargedAtSec !== null && - checkout.stripeFeeMinor !== null, - ) - .sort((a, b) => (a.chargedAtSec ?? 0) - (b.chargedAtSec ?? 0)) - .map((checkout) => this.balanceTransactionOf(checkout)) - return pageOf(settled, input) - } - - async listApplicationFees(input: PaymentsListInput): Promise> { - const fees = [...this.checkouts.values()] - .filter((checkout) => checkout.applicationFeeId !== null && checkout.chargedAtSec !== null) - .sort((a, b) => (a.chargedAtSec ?? 0) - (b.chargedAtSec ?? 0)) - .map((checkout) => ({ - id: checkout.applicationFeeId as string, - chargeId: checkout.chargeId, - amountMinor: checkout.applicationFeeMinor, - amountRefundedMinor: this.refundedApplicationFeeMinor.get( - checkout.applicationFeeId as string, - ) ?? 0, - currency: "usd", - createdSec: checkout.chargedAtSec as number, - livemode: this.livemode(), - })) - return pageOf(fees, input) - } - - private balanceTransactionOf(checkout: FakeCheckout): BalanceTransactionRecord { - const stripeFeeMinor = checkout.stripeFeeMinor ?? 0 - const applicationFeeMinor = checkout.applicationFeeMinor - return { - id: `txn_for_${checkout.chargeId ?? checkout.sessionId}`, - type: "charge", - amountMinor: checkout.amountMinor, - feeMinor: stripeFeeMinor + applicationFeeMinor, - stripeFeeMinor, - applicationFeeMinor, - netMinor: checkout.amountMinor - stripeFeeMinor - applicationFeeMinor, - currency: "usd", - createdSec: checkout.chargedAtSec as number, - sourceId: checkout.chargeId, - } - } - - async refundApplicationFee( - applicationFeeId: string, - amountMinor: number, - _idempotencyKey: string, - ): Promise { - if (!Number.isSafeInteger(amountMinor) || amountMinor < 0) { - throw new RangeError("FakePayments: refund amountMinor must be a non-negative integer") - } - const already = this.refundedApplicationFeeMinor.get(applicationFeeId) ?? 0 - if (amountMinor === 0) { - return { id: this.nextId("fr"), amountMinor: 0, status: "skipped" } - } - this.refundedApplicationFeeMinor.set(applicationFeeId, already + amountMinor) - return { id: this.nextId("fr"), amountMinor, status: "succeeded" } - } - - private settledNetMinor(accountId: string): number { - return [...this.checkouts.values()] - .filter( - (checkout) => - checkout.accountId === accountId && - checkout.chargedAtSec !== null && - checkout.stripeFeeMinor !== null, - ) - .reduce( - (total, checkout) => - total + checkout.amountMinor - (checkout.stripeFeeMinor ?? 0) - checkout.applicationFeeMinor, - 0, - ) - } - - private paidOutMinor(accountId: string): number { - return this.payouts - .filter( - (payout) => - this.payoutAccounts.get(payout.id) === accountId && - payout.status !== "failed" && - payout.status !== "canceled", - ) - .reduce((total, payout) => total + payout.amountMinor, 0) - } - - private availableMinor(accountId: string): number { - return this.settledNetMinor(accountId) - this.paidOutMinor(accountId) - } - - async retrieveBalance(accountId: string): Promise { - const account = this.accountOrThrow(accountId) - return { - availableMinor: this.availableMinor(accountId), - pendingMinor: 0, - currency: "usd", - payoutsEnabled: account.payoutsEnabled, - payoutSchedule: { interval: "manual" }, - } - } - - async createPayout(accountId: string, input: CreatePayoutInput): Promise { - const account = this.accountOrThrow(accountId) - const requestKey = `${accountId}:${input.idempotencyKey}` - const existingId = this.payoutsByIdempotency.get(requestKey) - if (existingId !== undefined) { - const existing = this.payouts.find((payout) => payout.id === existingId) - if (existing === undefined) throw new Error(`FakePayments: unknown payout "${existingId}"`) - return existing - } - if (!account.payoutsEnabled) throw new Error("FakePayments: payouts_not_allowed") - if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor <= 0) { - throw new RangeError("FakePayments: payout amountMinor must be a positive integer") - } - if (input.amountMinor > this.availableMinor(accountId)) { - throw new Error("FakePayments: balance_insufficient") - } - const payout: PayoutRecord = { - id: this.nextId("po"), - amountMinor: input.amountMinor, - currency: input.currency, - status: "pending", - arrivalDateSec: null, - createdSec: this.nowSec(), - failureMessage: null, - } - this.payouts.push(payout) - this.payoutAccounts.set(payout.id, accountId) - this.payoutsByIdempotency.set(requestKey, payout.id) - return payout - } - - async listPayouts( - accountId: string, - input: ListPayoutsInput, - ): Promise> { - this.accountOrThrow(accountId) - const owned = [...this.payouts] - .reverse() - .filter((payout) => this.payoutAccounts.get(payout.id) === accountId) - .sort((a, b) => b.createdSec - a.createdSec) - const startingAfter = input.startingAfter ?? null - const start = - startingAfter === null ? 0 : owned.findIndex((payout) => payout.id === startingAfter) + 1 - const limit = input.limit ?? FAKE_PAYMENTS_PAGE_LIMIT - const page = owned.slice(start, start + limit) - const last = page[page.length - 1] - return { - items: page, - nextCursor: last !== undefined && start + page.length < owned.length ? last.id : null, - } - } - - verifyWebhookSignature( - rawBody: string | Uint8Array, - signatureHeader: string, - scope: PaymentsWebhookScope, - ): PaymentsWebhookEvent { - const body = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody) - let timestamp: string | null = null - const signatures: string[] = [] - for (const part of signatureHeader.split(",")) { - const separator = part.indexOf("=") - if (separator === -1) continue - const key = part.slice(0, separator).trim() - const value = part.slice(separator + 1).trim() - if (key === "t" && timestamp === null) timestamp = value - else if (key === "v1") signatures.push(value) - } - if (timestamp === null || signatures.length === 0) { - throw new WebhookSignatureError(scope, "missing t or v1 in the signature header") - } - const timestampSec = Number(timestamp) - if (!Number.isFinite(timestampSec)) { - throw new WebhookSignatureError(scope, "signature timestamp is not a number") - } - if (Math.abs(this.nowSec() - timestampSec) > WEBHOOK_TOLERANCE_SEC) { - throw new WebhookSignatureError(scope, "signature timestamp is outside the tolerance window") - } - const expected = hmacSha256Hex(this.secrets[scope], `${timestamp}.${body}`) - if (!signatures.some((candidate) => constantTimeEqual(candidate, expected))) { - throw new WebhookSignatureError(scope, "no v1 signature matched") - } - - let payload: Record - try { - payload = JSON.parse(body) as Record - } catch { - throw new WebhookSignatureError(scope, "signed body is not valid JSON") - } - const account = typeof payload.account === "string" ? payload.account : null - const data = (payload.data ?? {}) as { object?: Record } - return { - id: typeof payload.id === "string" ? payload.id : this.nextId("evt"), - type: typeof payload.type === "string" ? payload.type : "unknown", - scope, - accountId: account, - livemode: payload.livemode === true, - apiVersion: typeof payload.api_version === "string" ? payload.api_version : null, - createdSec: typeof payload.created === "number" ? payload.created : timestampSec, - data: { object: data.object ?? {} }, - } - } - - signWebhook(payload: Record, scope: PaymentsWebhookScope): SignedWebhook { - const rawBody = JSON.stringify(payload) - const timestamp = this.nowSec() - const signature = hmacSha256Hex(this.secrets[scope], `${timestamp}.${rawBody}`) - return { rawBody, signatureHeader: `t=${timestamp},v1=${signature}` } - } - - settleAccount(accountId: string, patch: Partial = {}): ConnectedAccountStatus { - const account = this.accountOrThrow(accountId) - const settled: ConnectedAccountStatus = { - ...account, - detailsSubmitted: true, - chargesEnabled: true, - payoutsEnabled: true, - disabledReason: null, - currentlyDue: [], - pastDue: [], - pendingVerification: [], - futureCurrentlyDue: [], - currentDeadlineSec: null, - capabilities: { card_payments: "active", transfers: "active" }, - ...patch, - accountId, - } - this.accounts.set(accountId, settled) - return settled - } - - completeCheckout(sessionId: string, options: CompleteCheckoutOptions = {}): FakeCheckout { - const checkout = this.checkoutOrThrow(sessionId) - if (checkout.status === "expired") throw new Error("FakePayments: cannot complete an expired session") - checkout.status = "complete" - checkout.paymentStatus = "paid" - checkout.chargeId = checkout.chargeId ?? this.nextId("ch") - checkout.applicationFeeId = - checkout.applicationFeeMinor > 0 ? (checkout.applicationFeeId ?? this.nextId("fee")) : null - checkout.stripeFeeMinor = options.stripeFeeMinor ?? this.processorFee(checkout.amountMinor) - checkout.cardBrand = options.cardBrand ?? "visa" - checkout.cardLast4 = options.cardLast4 ?? "4242" - checkout.chargedAtSec = options.chargedAtSec ?? this.nowSec() - return checkout - } - - refundCheckout( - sessionId: string, - amountMinor: number, - options: FakeRefundOptions = {}, - ): FakeRefund { - const checkout = this.checkoutOrThrow(sessionId) - if (checkout.chargeId === null) { - throw new Error("FakePayments: cannot refund a session that was never charged") - } - const refund: FakeRefund = { - id: options.refundId ?? this.nextId("re"), - chargeId: checkout.chargeId, - amountMinor, - status: options.status ?? "succeeded", - reason: options.reason ?? null, - createdSec: options.createdSec ?? this.nowSec(), - } - this.refunds.push(refund) - return refund - } - - setRefundStatus(refundId: string, status: string): FakeRefund { - const refund = this.refunds.find((entry) => entry.id === refundId) - if (refund === undefined) throw new Error(`FakePayments: unknown refund "${refundId}"`) - refund.status = status - return refund - } - - expireCheckout(sessionId: string): FakeCheckout { - const checkout = this.checkoutOrThrow(sessionId) - if (checkout.status === "complete") throw new Error("FakePayments: cannot expire a completed session") - checkout.status = "expired" - checkout.paymentStatus = "unpaid" - return checkout - } - - checkoutFor(donationId: string): FakeCheckout | undefined { - const sessionId = this.checkoutsByDonation.get(donationId) - return sessionId === undefined ? undefined : this.checkouts.get(sessionId) - } - - reset(): void { - this.accounts.clear() - this.accountsByOrg.clear() - this.checkouts.clear() - this.checkoutsByDonation.clear() - this.domains.clear() - this.refundedApplicationFeeMinor.clear() - this.refunds.length = 0 - this.payouts.length = 0 - this.payoutAccounts.clear() - this.payoutsByIdempotency.clear() - this.counter = 0 - } -} diff --git a/packages/shared/src/host/__tests__/capabilities.test.ts b/packages/shared/src/host/__tests__/capabilities.test.ts index 634c28ac..086b6680 100644 --- a/packages/shared/src/host/__tests__/capabilities.test.ts +++ b/packages/shared/src/host/__tests__/capabilities.test.ts @@ -13,7 +13,7 @@ const EVENT_ROLES: (CleanupMemberRole | null)[] = [null, ...CleanupMemberRoleSch const ORG_ROLES: (OrganizationMemberRole | null)[] = [null, ...OrganizationMemberRoleSchema.options] const ALL_CAPABILITIES = HostCapabilitySchema.options -const ORG_ONLY: HostCapability[] = ["manage_payments", "view_donations", "manage_org_members"] +const ORG_ONLY: HostCapability[] = ["manage_org_members"] const NOT_COHOST: HostCapability[] = [ "manage_team", "cancel_event", @@ -57,7 +57,6 @@ function expected(standing: HostStanding): HostCapability[] { } if (standing.orgRole === "admin") { for (const cap of eventCaps) if (!NOT_COHOST.includes(cap) && cap !== "export") out.add(cap) - out.add("view_donations") out.add("manage_org_members") } return [...out].sort() @@ -77,7 +76,7 @@ describe("hostCapabilities", () => { expect(hostCapabilities({ eventRole: "member", orgRole: "member" }).size).toBe(0) }) - it("gives an organizer every capability except the org-only payment ones", () => { + it("gives an organizer every capability except the org-only ones", () => { const caps = hostCapabilities({ eventRole: "organizer", orgRole: null }) for (const cap of ALL_CAPABILITIES) { expect(caps.has(cap)).toBe(!ORG_ONLY.includes(cap)) @@ -111,8 +110,7 @@ describe("hostCapabilities", () => { "cancel_event", "manage_org_link", "request_resources", - "manage_payments", - "view_donations", + "manage_org_members", ]), ) for (const cap of NOT_COORDINATOR) expect(caps.has(cap), cap).toBe(false) @@ -150,27 +148,9 @@ describe("hostCapabilities", () => { expect(can({ eventRole: "member", orgRole: "member" }, "view_analytics")).toBe(false) }) - it("gives manage_payments to the org owner only", () => { - for (const eventRole of EVENT_ROLES) { - expect(can({ eventRole, orgRole: "owner" }, "manage_payments")).toBe(true) - expect(can({ eventRole, orgRole: "admin" }, "manage_payments")).toBe(false) - expect(can({ eventRole, orgRole: "member" }, "manage_payments")).toBe(false) - expect(can({ eventRole, orgRole: null }, "manage_payments")).toBe(false) - } - }) - - it("gives view_donations to org owner and org admin only", () => { - for (const eventRole of EVENT_ROLES) { - expect(can({ eventRole, orgRole: "owner" }, "view_donations")).toBe(true) - expect(can({ eventRole, orgRole: "admin" }, "view_donations")).toBe(true) - expect(can({ eventRole, orgRole: "member" }, "view_donations")).toBe(false) - expect(can({ eventRole, orgRole: null }, "view_donations")).toBe(false) - } - }) - - it("withholds export, manage_team, cancel_event, manage_org_link, request_resources and manage_payments from an org admin", () => { + it("withholds export, manage_team, cancel_event, manage_org_link and request_resources from an org admin", () => { const caps = hostCapabilities({ eventRole: null, orgRole: "admin" }) - const withheld = ["export", ...NOT_COHOST, "manage_payments"] as HostCapability[] + const withheld = ["export", ...NOT_COHOST] as HostCapability[] for (const cap of withheld) { expect(caps.has(cap)).toBe(false) } diff --git a/packages/shared/src/host/capabilities.ts b/packages/shared/src/host/capabilities.ts index 9d85c93c..cc3c078b 100644 --- a/packages/shared/src/host/capabilities.ts +++ b/packages/shared/src/host/capabilities.ts @@ -55,14 +55,11 @@ const STAFF_CAPABILITIES: readonly HostCapability[] = ["view_event_private", "vi const ORG_OWNER_CAPABILITIES: readonly HostCapability[] = [ ...ORGANIZER_CAPABILITIES, - "manage_payments", - "view_donations", "manage_org_members", ] const ORG_ADMIN_CAPABILITIES: readonly HostCapability[] = [ ...COHOST_CAPABILITIES.filter((cap) => cap !== "export"), - "view_donations", "manage_org_members", ] diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7f175954..c95930d3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,7 +19,6 @@ export * from "./schemas/media.js" export * from "./schemas/volunteer.js" export * from "./schemas/certificates.js" export * from "./schemas/host/index.js" -export * from "./schemas/payments.js" export * from "./schemas/legal.js" export * from "./schemas/admin/index.js" @@ -43,6 +42,5 @@ export * from "./tokens/design-tokens.js" export * from "./host/index.js" export * from "./markdown/index.js" export * from "./ics/index.js" -export * from "./payments/index.js" export * from "./legal/index.js" export * from "./tokens/chip-contrast.js" diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 650b347b..769a7f4b 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -10,4 +10,3 @@ export * from "./push-sender.js" export * from "./routing-provider.js" export * from "./abuse-checks.js" export * from "./jobs.js" -export * from "./payments.js" diff --git a/packages/shared/src/interfaces/payments.ts b/packages/shared/src/interfaces/payments.ts deleted file mode 100644 index f11d8df8..00000000 --- a/packages/shared/src/interfaces/payments.ts +++ /dev/null @@ -1,234 +0,0 @@ -export type PaymentsWebhookScope = "connect" | "platform" - -export type AccountLinkKind = "onboarding" | "update" - -export type PaymentsMode = "live" | "test" - -export interface CreateConnectedAccountInput { - orgId: string - email?: string - legalName: string - url?: string - statementDescriptorHint?: string - idempotencyKey: string -} - -export interface ConnectedAccountStatus { - accountId: string - livemode: boolean - detailsSubmitted: boolean - chargesEnabled: boolean - payoutsEnabled: boolean - disabledReason: string | null - currentlyDue: string[] - pastDue: string[] - pendingVerification: string[] - futureCurrentlyDue: string[] - currentDeadlineSec: number | null - capabilities: Record -} - -export interface CreateAccountLinkInput { - accountId: string - type: AccountLinkKind - refreshUrl: string - returnUrl: string -} - -export interface AccountLink { - url: string - expiresAtSec: number -} - -export interface PaymentMethodDomainRegistration { - id: string - domain: string - enabled: boolean -} - -export interface CreateDonationCheckoutInput { - accountId: string - donationId: string - orgId: string - amountMinor: number - currency: "usd" - productName: string - customerEmail?: string - applicationFeeMinor: number - expiresAtSec: number - idempotencyKey: string -} - -export interface DonationCheckoutSession { - sessionId: string - paymentIntentId: string - clientSecret: string - expiresAtSec: number -} - -export type DonationSessionStatus = "open" | "complete" | "expired" - -export interface CheckoutSessionSnapshot { - sessionId: string - paymentIntentId: string | null - clientSecret: string | null - status: DonationSessionStatus - expiresAtSec: number | null -} - -export type DonationPaymentStatus = "paid" | "unpaid" | "no_payment_required" - -export interface PaymentSnapshot { - status: DonationSessionStatus - paymentStatus: DonationPaymentStatus - amountMinor: number - stripeFeeMinor: number | null - applicationFeeMinor: number | null - netMinor: number | null - cardBrand: string | null - cardLast4: string | null - chargedAtSec: number | null - livemode: boolean - chargeId: string | null - applicationFeeId: string | null -} - -export interface ApplicationFeeRefund { - id: string - amountMinor: number - status: "succeeded" | "skipped" -} - -export interface PaymentRefundRecord { - id: string - amountMinor: number - status: string - reason: string | null - createdSec: number | null -} - -export interface PaymentsListInput { - since?: number | null - cursor?: string | null - limit?: number -} - -export interface PaymentsPage { - items: T[] - nextCursor: string | null -} - -export interface BalanceTransactionRecord { - id: string - type: string - amountMinor: number - feeMinor: number - stripeFeeMinor: number - applicationFeeMinor: number - netMinor: number - currency: string - createdSec: number - sourceId: string | null -} - -export interface ApplicationFeeRecord { - id: string - chargeId: string | null - amountMinor: number - amountRefundedMinor: number - currency: string - createdSec: number - livemode: boolean -} - -export interface AccountBalance { - availableMinor: number - pendingMinor: number - currency: string - payoutsEnabled: boolean - payoutSchedule: PayoutSchedule | null -} - -export type PayoutInterval = "daily" | "weekly" | "monthly" | "manual" - -export interface PayoutSchedule { - interval: PayoutInterval - delayDays?: number -} - -export type PayoutRecordStatus = "pending" | "in_transit" | "paid" | "failed" | "canceled" - -export interface PayoutRecord { - id: string - amountMinor: number - currency: string - status: PayoutRecordStatus - arrivalDateSec: number | null - createdSec: number - failureMessage: string | null -} - -export interface CreatePayoutInput { - amountMinor: number - currency: string - idempotencyKey: string -} - -export interface ListPayoutsInput { - limit?: number - startingAfter?: string | null -} - -export interface PaymentsWebhookEvent { - id: string - type: string - scope: PaymentsWebhookScope - accountId: string | null - livemode: boolean - apiVersion: string | null - createdSec: number - data: { object: Record } -} - -export class WebhookSignatureError extends Error { - readonly scope: PaymentsWebhookScope - - constructor(scope: PaymentsWebhookScope, message: string) { - super(message) - this.name = "WebhookSignatureError" - this.scope = scope - } -} - -export interface Payments { - mode(): PaymentsMode - createConnectedAccount(input: CreateConnectedAccountInput): Promise - createAccountLink(input: CreateAccountLinkInput): Promise - retrieveAccount(accountId: string): Promise - registerPaymentMethodDomain( - accountId: string, - domain: string, - ): Promise - createDonationCheckout(input: CreateDonationCheckoutInput): Promise - retrieveCheckoutSession(accountId: string, sessionId: string): Promise - retrieveDonation(accountId: string, sessionId: string): Promise - listRefunds(accountId: string, chargeId: string): Promise - listBalanceTransactions( - accountId: string, - input: PaymentsListInput, - ): Promise> - listApplicationFees(input: PaymentsListInput): Promise> - refundApplicationFee( - applicationFeeId: string, - amountMinor: number, - idempotencyKey: string, - ): Promise - retrieveBalance(accountId: string): Promise - createPayout(accountId: string, input: CreatePayoutInput): Promise - listPayouts(accountId: string, input: ListPayoutsInput): Promise> - verifyWebhookSignature( - rawBody: string | Uint8Array, - signatureHeader: string, - scope: PaymentsWebhookScope, - ): PaymentsWebhookEvent -} diff --git a/packages/shared/src/legal/__tests__/donation-disclosure-template.test.ts b/packages/shared/src/legal/__tests__/donation-disclosure-template.test.ts deleted file mode 100644 index c6d67371..00000000 --- a/packages/shared/src/legal/__tests__/donation-disclosure-template.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - DONATION_DISCLOSURE_TEMPLATE, - DONATION_DISCLOSURE_VARS, - donationDisclosureText, - fillDonationDisclosure, - platformFeePercent, - renderDonationDisclosures, -} from "../donation-disclosure-template.js" - -const VARS = { - orgLegalName: "Reach Out Los Angeles", - platformFeePercent: "5.00", - webOrigin: "https://civfix.org", -} - -function templateStrings(): string[] { - const t = DONATION_DISCLOSURE_TEMPLATE - return [ - t.recipient, - t.mayNotReceive, - ...t.mayNotReceiveReasons, - t.mayNotReceiveUrl, - t.remittanceTiming, - t.feePointer, - t.deductible, - t.notDeductible, - t.merchantOfRecord, - t.defaultRefundPolicy, - ] -} - -describe("donation disclosure template", () => { - it("uses only declared placeholders", () => { - const declared = new Set(DONATION_DISCLOSURE_VARS) - for (const template of templateStrings()) { - for (const match of template.matchAll(/\{\{([a-zA-Z]+)\}\}/g)) { - expect(declared.has(match[1] ?? "")).toBe(true) - } - } - }) - - it("leaves no placeholder unresolved once filled", () => { - for (const template of templateStrings()) { - expect(fillDonationDisclosure(template, VARS)).not.toMatch(/\{\{/) - } - }) - - it("rejects an unknown placeholder rather than emitting it to a donor", () => { - expect(() => fillDonationDisclosure("hello {{nope}}", VARS)).toThrow(RangeError) - }) - - it("renders the seven disclosures with the organization's legal name substituted", () => { - const copy = renderDonationDisclosures(VARS, { deductible: true }) - expect(copy.recipient).toBe( - "Your donation is made to Reach Out Los Angeles, which receives the funds directly. civfix never holds your money.", - ) - expect(copy.feePointer).toBe( - "civfix deducts a 5.00% platform fee, itemized before you pay. The organization pays its own payment-processing fees.", - ) - expect(copy.merchantOfRecord).toContain("Reach Out Los Angeles is the merchant of record") - expect(copy.mayNotReceiveUrl).toBe("https://civfix.org/legal/donations#may-not-receive") - expect(copy.mayNotReceiveReasons).toHaveLength(3) - }) - - it("switches the deductibility sentence on the verdict and never claims a deduction it cannot", () => { - expect(renderDonationDisclosures(VARS, { deductible: false }).deductibility).toBe( - "This donation may not be tax deductible. civfix does not provide tax advice.", - ) - expect(renderDonationDisclosures(VARS, { deductible: true }).deductibility).toContain( - "tax deductible to the extent allowed by law", - ) - }) - - it("prefers an organization refund policy over the default and falls back on blank", () => { - expect( - renderDonationDisclosures(VARS, { deductible: true, refundPolicyText: "We refund anything." }) - .refundPolicy, - ).toBe("We refund anything.") - expect( - renderDonationDisclosures(VARS, { deductible: true, refundPolicyText: "" }).refundPolicy, - ).toBe(DONATION_DISCLOSURE_TEMPLATE.defaultRefundPolicy) - expect( - renderDonationDisclosures(VARS, { deductible: true, refundPolicyText: null }).refundPolicy, - ).toBe(DONATION_DISCLOSURE_TEMPLATE.defaultRefundPolicy) - }) - - it("never advertises a fee-free donation", () => { - for (const template of templateStrings()) expect(template).not.toContain("100%") - }) - - it("formats the platform fee from basis points", () => { - expect(platformFeePercent(500)).toBe("5.00") - expect(platformFeePercent(0)).toBe("0.00") - }) - - it("exposes the canonical text carrying every template sentence", () => { - const text = donationDisclosureText() - for (const template of templateStrings()) expect(text).toContain(template) - }) - - it("is frozen so no consumer can rewrite a disclosure at runtime", () => { - expect(Object.isFrozen(DONATION_DISCLOSURE_TEMPLATE)).toBe(true) - expect(Object.isFrozen(DONATION_DISCLOSURE_TEMPLATE.mayNotReceiveReasons)).toBe(true) - }) -}) diff --git a/packages/shared/src/legal/documents.ts b/packages/shared/src/legal/documents.ts index b38b570e..ef4216ec 100644 --- a/packages/shared/src/legal/documents.ts +++ b/packages/shared/src/legal/documents.ts @@ -41,27 +41,6 @@ const DOCUMENTS: Readonly> = { effectiveAt: CURRENT_EFFECTIVE_AT, url: `${LEGAL_BASE_URL}/subprocessors`, }, - donations: { - type: "donations", - version: CURRENT_VERSION, - sha256: "461190ba108e771ff6a3c89cec0b5451bf5d05ff89e33231ff4c5493e0723544", - effectiveAt: CURRENT_EFFECTIVE_AT, - url: `${LEGAL_BASE_URL}/donations`, - }, - org_donation_agreement: { - type: "org_donation_agreement", - version: CURRENT_VERSION, - sha256: "9b6592f3d5218c4a275577e6ab56ee5d08cd15e0c59b562800f537c21567149e", - effectiveAt: CURRENT_EFFECTIVE_AT, - url: `${LEGAL_BASE_URL}/org-donation-agreement`, - }, - donation_disclosure: { - type: "donation_disclosure", - version: CURRENT_VERSION, - sha256: "d5f86995dd134183128d16526d50a4676c09e25dc3ea5d8eb9c6f9dcdfd2fa10", - effectiveAt: CURRENT_EFFECTIVE_AT, - url: `${LEGAL_BASE_URL}/donation-disclosure`, - }, } export const LEGAL_DOCUMENTS: readonly LegalDocumentVersion[] = Object.freeze( diff --git a/packages/shared/src/legal/donation-disclosure-template.ts b/packages/shared/src/legal/donation-disclosure-template.ts deleted file mode 100644 index 35bafc35..00000000 --- a/packages/shared/src/legal/donation-disclosure-template.ts +++ /dev/null @@ -1,117 +0,0 @@ -export const DONATION_DISCLOSURE_IDS = [ - "recipient", - "mayNotReceive", - "remittanceTiming", - "feePointer", - "deductibility", - "merchantOfRecord", - "refundPolicy", -] as const - -export type DonationDisclosureId = (typeof DONATION_DISCLOSURE_IDS)[number] - -export interface DonationDisclosureVars { - orgLegalName: string - platformFeePercent: string - webOrigin: string -} - -export const DONATION_DISCLOSURE_VARS = [ - "orgLegalName", - "platformFeePercent", - "webOrigin", -] as const - -export const DONATION_DISCLOSURE_TEMPLATE = Object.freeze({ - recipient: - "Your donation is made to {{orgLegalName}}, which receives the funds directly. civfix never holds your money.", - mayNotReceive: - "There are limited circumstances in which this organization may not receive your donation.", - mayNotReceiveReasons: Object.freeze([ - "The organization is removed from our platform or loses its good standing before the funds are delivered.", - "The organization's account with our payment processor stops accepting payments.", - "Your payment is reversed, refunded or successfully disputed.", - ]) as readonly string[], - mayNotReceiveUrl: "{{webOrigin}}/legal/donations#may-not-receive", - remittanceTiming: - "Funds settle to the organization's own payment account, normally within a few business days of your gift and no later than 30 days.", - feePointer: - "civfix deducts a {{platformFeePercent}}% platform fee, itemized before you pay. The organization pays its own payment-processing fees.", - deductible: - "{{orgLegalName}} states that donations are tax deductible to the extent allowed by law. civfix does not provide tax advice.", - notDeductible: "This donation may not be tax deductible. civfix does not provide tax advice.", - merchantOfRecord: - "{{orgLegalName}} is the merchant of record for this payment. civfix is a facilitator and is not the organizer of any event.", - defaultRefundPolicy: - "Donations are generally non-refundable. The organization may refund a gift at its discretion; where it does, civfix refunds its platform fee proportionally. Payment-processing fees are not returned.", -}) - -const PLACEHOLDER = /\{\{([a-zA-Z]+)\}\}/g - -export function fillDonationDisclosure(template: string, vars: DonationDisclosureVars): string { - return template.replace(PLACEHOLDER, (match, name: string) => { - const value = (vars as unknown as Record)[name] - if (typeof value !== "string") { - throw new RangeError(`unknown donation disclosure placeholder "${name}"`) - } - return value - }) -} - -export interface DonationDisclosureCopy { - recipient: string - mayNotReceive: string - mayNotReceiveReasons: string[] - mayNotReceiveUrl: string - remittanceTiming: string - feePointer: string - deductibility: string - merchantOfRecord: string - refundPolicy: string -} - -export function renderDonationDisclosures( - vars: DonationDisclosureVars, - options: { deductible: boolean; refundPolicyText?: string | null }, -): DonationDisclosureCopy { - const fill = (template: string) => fillDonationDisclosure(template, vars) - const refundPolicyText = options.refundPolicyText ?? null - return { - recipient: fill(DONATION_DISCLOSURE_TEMPLATE.recipient), - mayNotReceive: fill(DONATION_DISCLOSURE_TEMPLATE.mayNotReceive), - mayNotReceiveReasons: DONATION_DISCLOSURE_TEMPLATE.mayNotReceiveReasons.map(fill), - mayNotReceiveUrl: fill(DONATION_DISCLOSURE_TEMPLATE.mayNotReceiveUrl), - remittanceTiming: fill(DONATION_DISCLOSURE_TEMPLATE.remittanceTiming), - feePointer: fill(DONATION_DISCLOSURE_TEMPLATE.feePointer), - deductibility: fill( - options.deductible - ? DONATION_DISCLOSURE_TEMPLATE.deductible - : DONATION_DISCLOSURE_TEMPLATE.notDeductible, - ), - merchantOfRecord: fill(DONATION_DISCLOSURE_TEMPLATE.merchantOfRecord), - refundPolicy: - refundPolicyText !== null && refundPolicyText.length > 0 - ? refundPolicyText - : fill(DONATION_DISCLOSURE_TEMPLATE.defaultRefundPolicy), - } -} - -export function platformFeePercent(feeBps: number): string { - return (feeBps / 100).toFixed(2) -} - -export function donationDisclosureText(): string { - const t = DONATION_DISCLOSURE_TEMPLATE - return [ - t.recipient, - t.mayNotReceive, - ...t.mayNotReceiveReasons, - t.mayNotReceiveUrl, - t.remittanceTiming, - t.feePointer, - t.deductible, - t.notDeductible, - t.merchantOfRecord, - t.defaultRefundPolicy, - ].join("\n") -} diff --git a/packages/shared/src/legal/index.ts b/packages/shared/src/legal/index.ts index 18479430..9e86f2fb 100644 --- a/packages/shared/src/legal/index.ts +++ b/packages/shared/src/legal/index.ts @@ -1,2 +1 @@ export * from "./documents.js" -export * from "./donation-disclosure-template.js" diff --git a/packages/shared/src/payments/__tests__/donation-state.test.ts b/packages/shared/src/payments/__tests__/donation-state.test.ts deleted file mode 100644 index fc839c9b..00000000 --- a/packages/shared/src/payments/__tests__/donation-state.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from "vitest" -import { DonationStatusSchema, type DonationStatus } from "../../schemas/common.js" -import { - DONATION_STATUS_RANK, - advanceStatus, - isRefundStatusCorrection, - isSettledDonationStatus, - isTerminalDonationStatus, - refundedDonationStatus, -} from "../donation-state.js" - -const ALL: readonly DonationStatus[] = DonationStatusSchema.options - -describe("DONATION_STATUS_RANK", () => { - it("ranks every donation status exactly once", () => { - expect(Object.keys(DONATION_STATUS_RANK).sort()).toEqual([...ALL].sort()) - const ranks = Object.values(DONATION_STATUS_RANK) - expect(new Set(ranks).size).toBe(ranks.length) - }) - - it("orders pending < failed < succeeded < partially_refunded < refunded", () => { - expect(DONATION_STATUS_RANK.pending).toBeLessThan(DONATION_STATUS_RANK.failed) - expect(DONATION_STATUS_RANK.failed).toBeLessThan(DONATION_STATUS_RANK.succeeded) - expect(DONATION_STATUS_RANK.succeeded).toBeLessThan(DONATION_STATUS_RANK.partially_refunded) - expect(DONATION_STATUS_RANK.partially_refunded).toBeLessThan(DONATION_STATUS_RANK.refunded) - }) -}) - -describe("advanceStatus", () => { - it("is monotone over every ordered pair", () => { - for (const current of ALL) { - for (const next of ALL) { - const result = advanceStatus(current, next) - expect(DONATION_STATUS_RANK[result]).toBeGreaterThanOrEqual(DONATION_STATUS_RANK[current]) - expect(result).toBe( - DONATION_STATUS_RANK[next] > DONATION_STATUS_RANK[current] ? next : current, - ) - } - } - }) - - it("is idempotent and never regresses out of order webhooks", () => { - expect(advanceStatus("succeeded", "pending")).toBe("succeeded") - expect(advanceStatus("succeeded", "failed")).toBe("succeeded") - expect(advanceStatus("refunded", "partially_refunded")).toBe("refunded") - expect(advanceStatus("succeeded", "succeeded")).toBe("succeeded") - }) - - it("still lets a retried session move failed to succeeded", () => { - expect(advanceStatus("failed", "succeeded")).toBe("succeeded") - expect(advanceStatus("pending", "failed")).toBe("failed") - }) - - it("is associative over a shuffled event stream", () => { - const stream: DonationStatus[] = ["pending", "failed", "succeeded", "partially_refunded", "refunded"] - const forward = stream.reduce((acc, next) => advanceStatus(acc, next), "pending") - const reversed = [...stream].reverse().reduce((acc, next) => advanceStatus(acc, next), "pending") - expect(forward).toBe("refunded") - expect(reversed).toBe("refunded") - }) -}) - -describe("refundedDonationStatus", () => { - it("derives the settled status from the recomputed refunded total", () => { - const amountMinor = 5000 - expect(refundedDonationStatus({ current: "succeeded", amountMinor, refundedTotalMinor: 0 })).toBe( - "succeeded", - ) - expect( - refundedDonationStatus({ current: "succeeded", amountMinor, refundedTotalMinor: 2500 }), - ).toBe("partially_refunded") - expect( - refundedDonationStatus({ current: "succeeded", amountMinor, refundedTotalMinor: 5000 }), - ).toBe("refunded") - }) - - it("walks a donation back when the only refund failed", () => { - expect( - refundedDonationStatus({ current: "refunded", amountMinor: 5000, refundedTotalMinor: 0 }), - ).toBe("succeeded") - expect( - refundedDonationStatus({ - current: "refunded", - amountMinor: 5000, - refundedTotalMinor: 2500, - }), - ).toBe("partially_refunded") - }) - - it("never un-settles a donation that has not settled", () => { - for (const current of ["pending", "failed"] as const) { - expect(refundedDonationStatus({ current, amountMinor: 5000, refundedTotalMinor: 5000 })).toBe( - current, - ) - } - }) - - it("flags only backward moves inside the settled band as corrections", () => { - expect(isRefundStatusCorrection("refunded", "succeeded")).toBe(true) - expect(isRefundStatusCorrection("partially_refunded", "succeeded")).toBe(true) - expect(isRefundStatusCorrection("succeeded", "refunded")).toBe(false) - expect(isRefundStatusCorrection("succeeded", "succeeded")).toBe(false) - expect(isRefundStatusCorrection("pending", "failed")).toBe(false) - }) -}) - -describe("status predicates", () => { - it("treats only refunded as terminal", () => { - for (const status of ALL) expect(isTerminalDonationStatus(status)).toBe(status === "refunded") - }) - - it("treats succeeded and beyond as settled", () => { - expect(isSettledDonationStatus("pending")).toBe(false) - expect(isSettledDonationStatus("failed")).toBe(false) - expect(isSettledDonationStatus("succeeded")).toBe(true) - expect(isSettledDonationStatus("partially_refunded")).toBe(true) - expect(isSettledDonationStatus("refunded")).toBe(true) - }) -}) diff --git a/packages/shared/src/payments/__tests__/eligibility.test.ts b/packages/shared/src/payments/__tests__/eligibility.test.ts deleted file mode 100644 index de88114a..00000000 --- a/packages/shared/src/payments/__tests__/eligibility.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - DEFAULT_DEDUCTIBLE_BMF_CODES, - MNOS_GRACE_BUSINESS_DAYS, - REVIEW_REQUIRED_BLOCKS_DONATIONS, - addBusinessDays, - evaluateEligibility, - isDonationEligible, - verdictPermitsDonations, - type EligibilityEvidence, -} from "../eligibility.js" - -const CLEAN: EligibilityEvidence = { - pub78Listed: true, - bmfListed: true, - deductibilityCode: "1", - autoRevocationListed: false, - ftbRevoked: false, - mnosListed: false, - ofacMatch: false, - groupExemptionSubordinate: false, - centralOrgConfirmed: false, - mnosFirstSeenOn: null, - asOf: "2026-09-06", -} - -function evidence(patch: Partial): EligibilityEvidence { - return { ...CLEAN, ...patch } -} - -describe("addBusinessDays", () => { - it("skips weekends", () => { - expect(addBusinessDays("2026-09-02", 5)).toBe("2026-09-09") - expect(addBusinessDays("2026-09-04", 1)).toBe("2026-09-07") - expect(addBusinessDays("2026-09-05", 1)).toBe("2026-09-07") - expect(addBusinessDays("2026-09-06", 1)).toBe("2026-09-07") - expect(addBusinessDays("2026-09-04", 5)).toBe("2026-09-11") - }) - - it("is a no-op at zero and refuses bad input", () => { - expect(addBusinessDays("2026-09-06", 0)).toBe("2026-09-06") - expect(() => addBusinessDays("2026-9-6", 1)).toThrow(RangeError) - expect(() => addBusinessDays("2026-09-06", -1)).toThrow(RangeError) - }) -}) - -describe("evaluateEligibility", () => { - it("is eligible on a clean Pub 78 listing", () => { - const result = evaluateEligibility(CLEAN) - expect(result.verdict).toBe("eligible") - expect(result.reasons).toEqual([]) - expect(isDonationEligible(result)).toBe(true) - }) - - it("is eligible on a BMF listing with a deductible code alone", () => { - expect(DEFAULT_DEDUCTIBLE_BMF_CODES).toEqual(["1"]) - expect(evaluateEligibility(evidence({ pub78Listed: false })).verdict).toBe("eligible") - }) - - it("is unknown when the BMF code is not deductible", () => { - const result = evaluateEligibility(evidence({ pub78Listed: false, deductibilityCode: "2" })) - expect(result.verdict).toBe("unknown") - expect(result.reasons).toEqual(["no_positive_listing", "bmf_not_deductible"]) - }) - - it("is unknown with no listing at all", () => { - const result = evaluateEligibility( - evidence({ pub78Listed: false, bmfListed: false, deductibilityCode: null }), - ) - expect(result.verdict).toBe("unknown") - expect(result.reasons).toEqual(["no_positive_listing"]) - }) - - it("accepts a caller-supplied deductible code set", () => { - const ev = evidence({ pub78Listed: false, deductibilityCode: "4" }) - expect(evaluateEligibility(ev).verdict).toBe("unknown") - expect(evaluateEligibility(ev, { deductibleCodes: ["1", "4"] }).verdict).toBe("eligible") - }) - - it("keeps a reinstated org eligible despite the auto-revocation list", () => { - const result = evaluateEligibility(evidence({ autoRevocationListed: true, pub78Listed: true })) - expect(result.verdict).toBe("eligible") - }) - - it("blocks an auto-revoked org that is not back on Pub 78", () => { - const result = evaluateEligibility(evidence({ autoRevocationListed: true, pub78Listed: false })) - expect(result.verdict).toBe("ineligible") - expect(result.reasons).toContain("irs_auto_revoked") - }) - - it("blocks an FTB-revoked org", () => { - const result = evaluateEligibility(evidence({ ftbRevoked: true })) - expect(result.verdict).toBe("ineligible") - expect(result.reasons).toContain("ftb_revoked") - }) - - it("blocks a group subordinate without central-org confirmation", () => { - const result = evaluateEligibility(evidence({ groupExemptionSubordinate: true })) - expect(result.verdict).toBe("ineligible") - expect(result.reasons).toContain("group_subordinate_unconfirmed") - }) - - it("clears a group subordinate once the central org confirms", () => { - expect( - evaluateEligibility(evidence({ groupExemptionSubordinate: true, centralOrgConfirmed: true })) - .verdict, - ).toBe("eligible") - }) - - it("grants a five-business-day grace on a fresh MNOS hit", () => { - expect(MNOS_GRACE_BUSINESS_DAYS).toBe(5) - const result = evaluateEligibility( - evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: "2026-09-08" }), - ) - expect(result.verdict).toBe("grace") - expect(result.reasons).toEqual(["ca_ag_mnos_grace"]) - expect(result.graceExpiresOn).toBe("2026-09-09") - expect(isDonationEligible(result)).toBe(true) - }) - - it("blocks on the day after the grace expires", () => { - const onExpiry = evaluateEligibility( - evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: "2026-09-09" }), - ) - expect(onExpiry.verdict).toBe("grace") - const afterExpiry = evaluateEligibility( - evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: "2026-09-10" }), - ) - expect(afterExpiry.verdict).toBe("ineligible") - expect(afterExpiry.reasons).toContain("ca_ag_mnos") - expect(isDonationEligible(afterExpiry)).toBe(false) - }) - - it("refuses to grant a grace with no known start date or no evaluation date", () => { - expect(evaluateEligibility(evidence({ mnosListed: true, mnosFirstSeenOn: null })).verdict).toBe( - "ineligible", - ) - expect( - evaluateEligibility( - evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: null }), - ).verdict, - ).toBe("ineligible") - }) - - it("routes an OFAC match to review rather than auto-blocking", () => { - const result = evaluateEligibility(evidence({ ofacMatch: true })) - expect(result.verdict).toBe("review_required") - expect(result.reasons).toEqual(["ofac_sdn_match"]) - expect(REVIEW_REQUIRED_BLOCKS_DONATIONS).toBe(false) - expect(isDonationEligible(result)).toBe(true) - expect(isDonationEligible(result, { reviewRequiredBlocks: true })).toBe(false) - }) - - it("permits donations only on eligible, grace and (by policy) review_required", () => { - expect(verdictPermitsDonations("eligible")).toBe(true) - expect(verdictPermitsDonations("grace")).toBe(true) - expect(verdictPermitsDonations("review_required")).toBe(true) - expect(verdictPermitsDonations("review_required", { reviewRequiredBlocks: true })).toBe(false) - expect(verdictPermitsDonations("unknown")).toBe(false) - expect(verdictPermitsDonations("ineligible")).toBe(false) - }) - - it("names the remediation when the positive sources were never screened", () => { - const never = evaluateEligibility( - evidence({ - pub78Listed: false, - bmfListed: false, - deductibilityCode: null, - pub78Checked: false, - bmfChecked: false, - }), - ) - expect(never.verdict).toBe("unknown") - expect(never.reasons).toEqual(["positive_sources_not_yet_checked"]) - - const screened = evaluateEligibility( - evidence({ - pub78Listed: false, - bmfListed: false, - deductibilityCode: null, - pub78Checked: true, - bmfChecked: false, - }), - ) - expect(screened.reasons).toEqual(["no_positive_listing"]) - }) - - it("lets a hard disqualifier win over an OFAC review", () => { - const result = evaluateEligibility(evidence({ ofacMatch: true, ftbRevoked: true })) - expect(result.verdict).toBe("ineligible") - expect(result.contributionsDeductible).toBe(false) - }) - - it("never lets an OFAC review stand in for a positive listing", () => { - const unlisted = evaluateEligibility( - evidence({ ofacMatch: true, pub78Listed: false, bmfListed: false, deductibilityCode: null, pub78Checked: true, bmfChecked: true }), - ) - expect(unlisted.verdict).toBe("unknown") - expect(unlisted.reasons).toEqual(["no_positive_listing", "ofac_sdn_match"]) - expect(isDonationEligible(unlisted)).toBe(false) - expect(unlisted.contributionsDeductible).toBe(false) - - const neverChecked = evaluateEligibility( - evidence({ ofacMatch: true, pub78Listed: false, bmfListed: false, deductibilityCode: null, pub78Checked: false, bmfChecked: false }), - ) - expect(neverChecked.verdict).toBe("unknown") - expect(neverChecked.reasons).toEqual(["positive_sources_not_yet_checked", "ofac_sdn_match"]) - expect(isDonationEligible(neverChecked)).toBe(false) - }) - - it("derives deductibility from the exemption evidence, not from the review flag", () => { - expect(evaluateEligibility(CLEAN).contributionsDeductible).toBe(true) - expect(evaluateEligibility(evidence({ ofacMatch: true })).contributionsDeductible).toBe(true) - expect( - evaluateEligibility(evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: "2026-09-08" })).contributionsDeductible, - ).toBe(true) - expect( - evaluateEligibility(evidence({ pub78Listed: false, deductibilityCode: "2" })).contributionsDeductible, - ).toBe(false) - }) - - it("collects every disqualifying reason", () => { - const result = evaluateEligibility( - evidence({ - ftbRevoked: true, - autoRevocationListed: true, - pub78Listed: false, - groupExemptionSubordinate: true, - mnosListed: true, - }), - ) - expect(result.verdict).toBe("ineligible") - expect(result.reasons).toEqual([ - "ftb_revoked", - "irs_auto_revoked", - "group_subordinate_unconfirmed", - "ca_ag_mnos", - ]) - }) - - it("is deterministic", () => { - const ev = evidence({ mnosListed: true, mnosFirstSeenOn: "2026-09-02", asOf: "2026-09-08" }) - expect(evaluateEligibility(ev)).toEqual(evaluateEligibility(ev)) - }) -}) diff --git a/packages/shared/src/payments/__tests__/fee-math.test.ts b/packages/shared/src/payments/__tests__/fee-math.test.ts deleted file mode 100644 index 1349c6be..00000000 --- a/packages/shared/src/payments/__tests__/fee-math.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - DEFAULT_PROCESSOR_FEE_BPS, - DEFAULT_PROCESSOR_FEE_FIXED_MINOR, - effectiveFeeBps, - estimateProcessorFeeMinor, - platformFeeMinor, - previewDonationFees, -} from "../fee-math.js" - -describe("platformFeeMinor", () => { - it("applies the bps rate with half-up rounding", () => { - expect(platformFeeMinor(10000, 500)).toBe(500) - expect(platformFeeMinor(101, 500)).toBe(5) - expect(platformFeeMinor(110, 500)).toBe(6) - expect(platformFeeMinor(1, 5000)).toBe(1) - expect(platformFeeMinor(1, 4999)).toBe(0) - }) - - it("is zero when the rate or the amount is zero", () => { - expect(platformFeeMinor(10000, 0)).toBe(0) - expect(platformFeeMinor(0, 500)).toBe(0) - }) - - it("never exceeds the gross amount", () => { - expect(platformFeeMinor(500, 10000)).toBe(500) - }) - - it("refuses non-integer, negative and out-of-range input", () => { - expect(() => platformFeeMinor(10.5, 500)).toThrow(RangeError) - expect(() => platformFeeMinor(-1, 500)).toThrow(RangeError) - expect(() => platformFeeMinor(1000, -1)).toThrow(RangeError) - expect(() => platformFeeMinor(1000, 10001)).toThrow(RangeError) - expect(() => platformFeeMinor(Number.NaN, 500)).toThrow(RangeError) - }) -}) - -describe("estimateProcessorFeeMinor", () => { - it("defaults to 2.9% + 30c", () => { - expect(DEFAULT_PROCESSOR_FEE_BPS).toBe(290) - expect(DEFAULT_PROCESSOR_FEE_FIXED_MINOR).toBe(30) - expect(estimateProcessorFeeMinor(10000)).toBe(320) - expect(estimateProcessorFeeMinor(500)).toBe(45) - }) - - it("accepts an override and never exceeds the gross", () => { - expect(estimateProcessorFeeMinor(10000, { bps: 250, fixedMinor: 0 })).toBe(250) - expect(estimateProcessorFeeMinor(10, { bps: 0, fixedMinor: 500 })).toBe(10) - expect(estimateProcessorFeeMinor(0)).toBe(0) - }) -}) - -describe("previewDonationFees", () => { - it("returns an integer breakdown that reconciles", () => { - const preview = previewDonationFees({ amountMinor: 5000, platformFeeBps: 500 }) - expect(preview).toEqual({ - grossMinor: 5000, - platformFeeMinor: 250, - estimatedProcessingFeeMinor: 175, - estimatedNetMinor: 4575, - platformFeeBps: 500, - }) - expect( - preview.platformFeeMinor + preview.estimatedProcessingFeeMinor + preview.estimatedNetMinor, - ).toBe(preview.grossMinor) - }) - - it("clamps the net at zero when fees swallow a tiny donation", () => { - const preview = previewDonationFees({ - amountMinor: 50, - platformFeeBps: 10000, - processingFeeBps: 290, - processingFeeFixedMinor: 30, - }) - expect(preview.estimatedNetMinor).toBe(0) - }) - - it("omits the platform fee at 0 bps", () => { - expect(previewDonationFees({ amountMinor: 2500, platformFeeBps: 0 }).platformFeeMinor).toBe(0) - }) - - it("is deterministic", () => { - const input = { amountMinor: 12345, platformFeeBps: 500 } - expect(previewDonationFees(input)).toEqual(previewDonationFees(input)) - }) -}) - -describe("effectiveFeeBps", () => { - it("takes the lower of the configured and the agreed rate", () => { - expect(effectiveFeeBps(500, 500)).toBe(500) - expect(effectiveFeeBps(800, 500)).toBe(500) - expect(effectiveFeeBps(300, 500)).toBe(300) - }) -}) diff --git a/packages/shared/src/payments/donation-state.ts b/packages/shared/src/payments/donation-state.ts deleted file mode 100644 index b5227ae0..00000000 --- a/packages/shared/src/payments/donation-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DonationStatus } from "../schemas/common.js" - -export const DONATION_STATUS_RANK: Readonly> = { - pending: 0, - failed: 1, - succeeded: 2, - partially_refunded: 3, - refunded: 4, -} - -export function donationStatusRank(status: DonationStatus): number { - return DONATION_STATUS_RANK[status] -} - -export function advanceStatus(current: DonationStatus, next: DonationStatus): DonationStatus { - return donationStatusRank(next) > donationStatusRank(current) ? next : current -} - -export function isTerminalDonationStatus(status: DonationStatus): boolean { - return status === "refunded" -} - -export function isSettledDonationStatus(status: DonationStatus): boolean { - return donationStatusRank(status) >= DONATION_STATUS_RANK.succeeded -} - -export function refundedDonationStatus(input: { - current: DonationStatus - amountMinor: number - refundedTotalMinor: number -}): DonationStatus { - if (!isSettledDonationStatus(input.current)) return input.current - if (input.refundedTotalMinor <= 0) return "succeeded" - return input.refundedTotalMinor >= input.amountMinor ? "refunded" : "partially_refunded" -} - -export function isRefundStatusCorrection(current: DonationStatus, next: DonationStatus): boolean { - return isSettledDonationStatus(current) && donationStatusRank(next) < donationStatusRank(current) -} diff --git a/packages/shared/src/payments/eligibility.ts b/packages/shared/src/payments/eligibility.ts deleted file mode 100644 index d4c2b498..00000000 --- a/packages/shared/src/payments/eligibility.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { EligibilityVerdict } from "../schemas/common.js" - -export const MNOS_GRACE_BUSINESS_DAYS = 5 - -export const DEFAULT_DEDUCTIBLE_BMF_CODES: readonly string[] = ["1"] - -export const REVIEW_REQUIRED_BLOCKS_DONATIONS = false - -const DAY_MS = 86400000 -const DAY_RE = /^\d{4}-\d{2}-\d{2}$/ - -export type EligibilityReason = - | "irs_auto_revoked" - | "ftb_revoked" - | "ca_ag_mnos" - | "ca_ag_mnos_grace" - | "group_subordinate_unconfirmed" - | "ofac_sdn_match" - | "no_positive_listing" - | "positive_sources_not_yet_checked" - | "bmf_not_deductible" - -export interface EligibilityEvidence { - pub78Listed: boolean - bmfListed: boolean - deductibilityCode: string | null - autoRevocationListed: boolean - ftbRevoked: boolean - mnosListed: boolean - ofacMatch: boolean - groupExemptionSubordinate: boolean - centralOrgConfirmed: boolean - mnosFirstSeenOn?: string | null - asOf?: string | null - pub78Checked?: boolean - bmfChecked?: boolean -} - -export interface DonationEligibilityOptions { - reviewRequiredBlocks?: boolean -} - -export interface EligibilityOptions { - deductibleCodes?: readonly string[] - graceBusinessDays?: number -} - -export interface EligibilityResult { - verdict: EligibilityVerdict - reasons: EligibilityReason[] - graceExpiresOn: string | null - contributionsDeductible: boolean -} - -function dayToUtcMs(day: string): number { - if (!DAY_RE.test(day)) return Number.NaN - const year = Number(day.slice(0, 4)) - const month = Number(day.slice(5, 7)) - const date = Number(day.slice(8, 10)) - const ms = Date.UTC(year, month - 1, date) - return utcMsToDay(ms) === day ? ms : Number.NaN -} - -function utcMsToDay(ms: number): string { - const d = new Date(ms) - return ( - `${String(d.getUTCFullYear()).padStart(4, "0")}-` + - `${String(d.getUTCMonth() + 1).padStart(2, "0")}-` + - `${String(d.getUTCDate()).padStart(2, "0")}` - ) -} - -export function addBusinessDays(day: string, businessDays: number): string { - const start = dayToUtcMs(day) - if (!Number.isFinite(start)) throw new RangeError("addBusinessDays expects a YYYY-MM-DD calendar day") - if (!Number.isInteger(businessDays) || businessDays < 0) { - throw new RangeError("addBusinessDays expects a non-negative integer count") - } - let ms = start - let remaining = businessDays - while (remaining > 0) { - ms += DAY_MS - const weekday = new Date(ms).getUTCDay() - if (weekday !== 0 && weekday !== 6) remaining -= 1 - } - return utcMsToDay(ms) -} - -function isDeductibleCode(code: string | null, allowed: readonly string[]): boolean { - if (code === null) return false - const normalized = code.trim().toUpperCase() - if (normalized.length === 0) return false - return allowed.some((entry) => entry.trim().toUpperCase() === normalized) -} - -export function evaluateEligibility( - evidence: EligibilityEvidence, - options: EligibilityOptions = {}, -): EligibilityResult { - const deductibleCodes = options.deductibleCodes ?? DEFAULT_DEDUCTIBLE_BMF_CODES - const graceDays = - Number.isInteger(options.graceBusinessDays) && (options.graceBusinessDays as number) >= 0 - ? (options.graceBusinessDays as number) - : MNOS_GRACE_BUSINESS_DAYS - - const asOfMs = evidence.asOf ? dayToUtcMs(evidence.asOf) : Number.NaN - const mnosStartMs = evidence.mnosFirstSeenOn ? dayToUtcMs(evidence.mnosFirstSeenOn) : Number.NaN - const graceExpiresOn = - evidence.mnosListed && Number.isFinite(mnosStartMs) - ? addBusinessDays(evidence.mnosFirstSeenOn as string, graceDays) - : null - const graceExpiresMs = graceExpiresOn === null ? Number.NaN : dayToUtcMs(graceExpiresOn) - const withinGrace = - evidence.mnosListed && - Number.isFinite(asOfMs) && - Number.isFinite(graceExpiresMs) && - asOfMs <= graceExpiresMs - - const disqualifying: EligibilityReason[] = [] - if (evidence.ftbRevoked) disqualifying.push("ftb_revoked") - if (evidence.autoRevocationListed && !evidence.pub78Listed) disqualifying.push("irs_auto_revoked") - if (evidence.groupExemptionSubordinate && !evidence.centralOrgConfirmed) { - disqualifying.push("group_subordinate_unconfirmed") - } - if (evidence.mnosListed && !withinGrace) disqualifying.push("ca_ag_mnos") - - if (disqualifying.length > 0) { - return { verdict: "ineligible", reasons: disqualifying, graceExpiresOn, contributionsDeductible: false } - } - - const deductibleBmf = evidence.bmfListed && isDeductibleCode(evidence.deductibilityCode, deductibleCodes) - if (!evidence.pub78Listed && !deductibleBmf) { - const neverChecked = evidence.pub78Checked === false && evidence.bmfChecked === false - const reasons: EligibilityReason[] = [ - neverChecked ? "positive_sources_not_yet_checked" : "no_positive_listing", - ] - if (evidence.bmfListed) reasons.push("bmf_not_deductible") - if (evidence.ofacMatch) reasons.push("ofac_sdn_match") - return { verdict: "unknown", reasons, graceExpiresOn, contributionsDeductible: false } - } - - if (evidence.ofacMatch) { - return { - verdict: "review_required", - reasons: ["ofac_sdn_match"], - graceExpiresOn, - contributionsDeductible: true, - } - } - - if (withinGrace) { - return { verdict: "grace", reasons: ["ca_ag_mnos_grace"], graceExpiresOn, contributionsDeductible: true } - } - - return { verdict: "eligible", reasons: [], graceExpiresOn, contributionsDeductible: true } -} - -export function verdictPermitsDonations( - verdict: EligibilityVerdict, - options: DonationEligibilityOptions = {}, -): boolean { - if (verdict === "eligible" || verdict === "grace") return true - if (verdict === "review_required") { - return !(options.reviewRequiredBlocks ?? REVIEW_REQUIRED_BLOCKS_DONATIONS) - } - return false -} - -export function isDonationEligible( - result: EligibilityResult, - options: DonationEligibilityOptions = {}, -): boolean { - return verdictPermitsDonations(result.verdict, options) -} diff --git a/packages/shared/src/payments/fee-math.ts b/packages/shared/src/payments/fee-math.ts deleted file mode 100644 index 4fee894d..00000000 --- a/packages/shared/src/payments/fee-math.ts +++ /dev/null @@ -1,80 +0,0 @@ -export const BPS_DENOMINATOR = 10000 -export const DEFAULT_PROCESSOR_FEE_BPS = 290 -export const DEFAULT_PROCESSOR_FEE_FIXED_MINOR = 30 -export const MAX_FEE_BPS = 2000 - -function requireMinor(label: string, value: number): number { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${label} must be a non-negative integer amount in minor units`) - } - return value -} - -function requireBps(label: string, value: number): number { - if (!Number.isSafeInteger(value) || value < 0 || value > BPS_DENOMINATOR) { - throw new RangeError(`${label} must be an integer between 0 and ${BPS_DENOMINATOR}`) - } - return value -} - -export function platformFeeMinor(grossMinor: number, bps: number): number { - const gross = requireMinor("grossMinor", grossMinor) - const rate = requireBps("bps", bps) - if (rate === 0 || gross === 0) return 0 - return Math.min(Math.round((gross * rate) / BPS_DENOMINATOR), gross) -} - -export interface ProcessorFeeOptions { - bps?: number - fixedMinor?: number -} - -export function estimateProcessorFeeMinor( - grossMinor: number, - options: ProcessorFeeOptions = {}, -): number { - const gross = requireMinor("grossMinor", grossMinor) - const rate = requireBps("processingFeeBps", options.bps ?? DEFAULT_PROCESSOR_FEE_BPS) - const fixed = requireMinor( - "processingFeeFixedMinor", - options.fixedMinor ?? DEFAULT_PROCESSOR_FEE_FIXED_MINOR, - ) - if (gross === 0) return 0 - return Math.min(Math.round((gross * rate) / BPS_DENOMINATOR) + fixed, gross) -} - -export interface DonationFeePreviewInput { - amountMinor: number - platformFeeBps: number - processingFeeBps?: number - processingFeeFixedMinor?: number -} - -export interface DonationFeePreview { - grossMinor: number - platformFeeMinor: number - estimatedProcessingFeeMinor: number - estimatedNetMinor: number - platformFeeBps: number -} - -export function previewDonationFees(input: DonationFeePreviewInput): DonationFeePreview { - const gross = requireMinor("amountMinor", input.amountMinor) - const bps = requireBps("platformFeeBps", input.platformFeeBps) - const platformFee = platformFeeMinor(gross, bps) - const processorFee = estimateProcessorFeeMinor(gross, { - bps: input.processingFeeBps, - fixedMinor: input.processingFeeFixedMinor, - }) - return { - grossMinor: gross, - platformFeeMinor: platformFee, - estimatedProcessingFeeMinor: processorFee, - estimatedNetMinor: Math.max(0, gross - platformFee - processorFee), - platformFeeBps: bps, - } -} - -export function effectiveFeeBps(envBps: number, agreedBps: number): number { - return Math.min(requireBps("envBps", envBps), requireBps("agreedBps", agreedBps)) -} diff --git a/packages/shared/src/payments/index.ts b/packages/shared/src/payments/index.ts deleted file mode 100644 index d1019f1e..00000000 --- a/packages/shared/src/payments/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./fee-math.js" -export * from "./donation-state.js" -export * from "./eligibility.js" diff --git a/packages/shared/src/schemas/admin/index.ts b/packages/shared/src/schemas/admin/index.ts index 2d78aab1..a5532f51 100644 --- a/packages/shared/src/schemas/admin/index.ts +++ b/packages/shared/src/schemas/admin/index.ts @@ -11,7 +11,6 @@ export * from "./orgs.js" export * from "./pages.js" export * from "./media.js" export * from "./hosts.js" -export * from "./payments.js" export * from "./users.js" export * from "./gov.js" export * from "./moderation.js" diff --git a/packages/shared/src/schemas/admin/orgs.ts b/packages/shared/src/schemas/admin/orgs.ts index f8cefb4c..4b549e09 100644 --- a/packages/shared/src/schemas/admin/orgs.ts +++ b/packages/shared/src/schemas/admin/orgs.ts @@ -3,7 +3,6 @@ import { IdSchema, ISODateSchema, OrganizationMemberRoleSchema, - OrgPaymentsStateSchema, OrgVerificationKindSchema, OrgVerificationStatusSchema, PaginationQuerySchema, @@ -71,8 +70,7 @@ const AdminOrgDTOObjectSchema = z.object({ eventCount: z.number().int().nonnegative().default(0), owner: AdminActorRefSchema.nullable().optional(), verification: AdminOrgVerificationListItemDTOSchema.nullable().optional(), - donationsEnabled: z.boolean().default(false), - paymentsState: OrgPaymentsStateSchema.nullable().optional(), + donationUrl: z.string().nullable().optional(), // 0.41.0 additions, all optional so a 0.40.0 server payload still parses. suspendedAt: ISODateSchema.nullable().optional(), suspendedReason: z.string().nullable().optional(), @@ -114,7 +112,6 @@ export const AdminOrgListQuerySchema = AdminListQuerySchema.extend({ verified: OrgVerificationStatusSchema.optional(), kind: OrgVerificationKindSchema.optional(), suspended: QueryBooleanSchema.optional(), - donationsEnabled: QueryBooleanSchema.optional(), }) export type AdminOrgListQuery = z.infer diff --git a/packages/shared/src/schemas/admin/payments.ts b/packages/shared/src/schemas/admin/payments.ts deleted file mode 100644 index 35054a9d..00000000 --- a/packages/shared/src/schemas/admin/payments.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { z } from "zod" -import { - DonationDisputeStateSchema, - DonationStatusSchema, - EligibilityVerdictSchema, - IdSchema, - ISODateSchema, - OrgPaymentsStateSchema, - pageResponse, -} from "../common.js" -import { MoneyDTOSchema } from "../entities.js" -import { OrgEligibilityDTOSchema, OrgPaymentsStatusDTOSchema } from "../payments.js" -import { AdminActorRefSchema, AdminListQuerySchema } from "./common.js" - - -export const GetAdminOrgPaymentsRequestSchema = z.object({ id: IdSchema }).strict() -export type GetAdminOrgPaymentsRequest = z.infer - -const AdminOrgPaymentsDTOObjectSchema = z.object({ - organizationId: IdSchema, - orgName: z.string(), - orgSlug: z.string(), - status: OrgPaymentsStatusDTOSchema, - agreementHistory: z - .array( - z.object({ - version: z.string(), - acceptedAt: ISODateSchema, - acceptedByName: z.string().nullable().optional(), - surface: z.string().nullable().optional(), - }), - ) - .default([]), - lifetimeGrossMinor: z.number().int().nonnegative().default(0), - lifetimeDonationCount: z.number().int().nonnegative().default(0), - disabledBy: AdminActorRefSchema.nullable().optional(), - disabledReasonText: z.string().nullable().optional(), -}) -export type AdminOrgPaymentsDTO = z.infer -export const AdminOrgPaymentsDTOSchema: z.ZodType = - AdminOrgPaymentsDTOObjectSchema - -export const GetAdminOrgPaymentsResponseSchema = AdminOrgPaymentsDTOSchema -export type GetAdminOrgPaymentsResponse = z.infer - -export const AdminPaymentsEligibilityListQuerySchema = AdminListQuerySchema.extend({ - verdict: EligibilityVerdictSchema.optional(), - state: OrgPaymentsStateSchema.optional(), -}) -export type AdminPaymentsEligibilityListQuery = z.infer< - typeof AdminPaymentsEligibilityListQuerySchema -> - -export const EinSourceSchema = z.enum(["org_verification", "operator"]) -export type EinSource = z.infer - -const AdminPaymentsEligibilityRowDTOObjectSchema = z.object({ - organizationId: IdSchema, - orgName: z.string(), - orgSlug: z.string(), - paymentsState: OrgPaymentsStateSchema, - donationsEnabled: z.boolean().default(false), - donationsDisabledReason: z.string().nullable().optional(), - eligibility: OrgEligibilityDTOSchema, - einSource: EinSourceSchema.nullable().optional(), - groupExemptionSubordinate: z.boolean().optional(), - centralOrgConfirmedAt: ISODateSchema.nullable().optional(), -}) -export type AdminPaymentsEligibilityRowDTO = z.infer -export const AdminPaymentsEligibilityRowDTOSchema: z.ZodType = - AdminPaymentsEligibilityRowDTOObjectSchema - -export const AdminPaymentsEligibilityListResponseSchema = pageResponse( - AdminPaymentsEligibilityRowDTOSchema, -).extend({ - counts: z - .object({ - eligible: z.number().int().nonnegative(), - grace: z.number().int().nonnegative(), - ineligible: z.number().int().nonnegative(), - reviewRequired: z.number().int().nonnegative(), - unknown: z.number().int().nonnegative(), - }) - .optional(), -}) -export type AdminPaymentsEligibilityListResponse = z.infer< - typeof AdminPaymentsEligibilityListResponseSchema -> - -export const SetOrgEligibilityEinRequestSchema = z - .object({ - id: IdSchema, - ein: z - .string() - .trim() - .regex(/^\d{2}-?\d{7}$/, "must be a nine-digit EIN"), - }) - .strict() -export type SetOrgEligibilityEinRequest = z.infer - -export const SetOrgEligibilityEinResponseSchema = z - .object({ - ok: z.literal(true), - einLast4: z.string(), - }) - .strict() -export type SetOrgEligibilityEinResponse = z.infer - -export const ConfirmOrgCentralOrgRequestSchema = z - .object({ - id: IdSchema, - confirmed: z.boolean(), - note: z.string().trim().min(1).max(500).optional(), - }) - .strict() -export type ConfirmOrgCentralOrgRequest = z.infer - -export const ConfirmOrgCentralOrgResponseSchema = z - .object({ - ok: z.literal(true), - centralOrgConfirmedAt: ISODateSchema.nullable(), - }) - .strict() -export type ConfirmOrgCentralOrgResponse = z.infer - -export const EvaluateOrgEligibilityRequestSchema = z.object({ id: IdSchema }).strict() -export type EvaluateOrgEligibilityRequest = z.infer - -export const EvaluateOrgEligibilityResponseSchema = z - .object({ - ok: z.literal(true), - queued: z.boolean(), - }) - .strict() -export type EvaluateOrgEligibilityResponse = z.infer - -export const SetOrgDonationsEnabledRequestSchema = z - .object({ - id: IdSchema, - enabled: z.boolean(), - reason: z.string().trim().min(1).max(1000), - }) - .strict() -export type SetOrgDonationsEnabledRequest = z.infer - -export const SetOrgDonationsEnabledResponseSchema = z - .object({ - ok: z.literal(true), - donationsEnabled: z.boolean(), - }) - .strict() -export type SetOrgDonationsEnabledResponse = z.infer - -export const AdminDonationListQuerySchema = AdminListQuerySchema.extend({ - status: DonationStatusSchema.optional(), - organizationId: IdSchema.optional(), - from: ISODateSchema.optional(), - to: ISODateSchema.optional(), -}) -export type AdminDonationListQuery = z.infer - -const AdminDonationListItemDTOObjectSchema = z.object({ - id: IdSchema, - reference: z.string(), - organizationId: IdSchema, - orgName: z.string(), - amount: MoneyDTOSchema, - platformFeeMinor: z.number().int().nonnegative(), - status: DonationStatusSchema, - disputeState: DonationDisputeStateSchema.default("none"), - refundedTotalMinor: z.number().int().nonnegative().default(0), - chargedAt: ISODateSchema.nullable(), - createdAt: ISODateSchema, - receiptSentAt: ISODateSchema.nullable().optional(), - livemode: z.boolean().default(false), -}) -export type AdminDonationListItemDTO = z.infer -export const AdminDonationListItemDTOSchema: z.ZodType = - AdminDonationListItemDTOObjectSchema - -const AdminDonationListResponseObjectSchema = pageResponse(AdminDonationListItemDTOSchema).extend({ - totals: z - .object({ - grossMinor: z.number().int().nonnegative(), - platformFeeMinor: z.number().int().nonnegative(), - refundedMinor: z.number().int().nonnegative(), - count: z.number().int().nonnegative(), - }) - .optional(), -}) -export type AdminDonationListResponse = z.infer -export const AdminDonationListResponseSchema: z.ZodType = - AdminDonationListResponseObjectSchema - -export const AdminDonationTotalsByOrgQuerySchema = z - .object({ - from: ISODateSchema, - to: ISODateSchema, - status: DonationStatusSchema.optional(), - limit: z.coerce.number().int().positive().max(500).optional(), - }) - .strict() -export type AdminDonationTotalsByOrgQuery = z.infer - -const AdminDonationOrgTotalsDTOObjectSchema = z.object({ - organizationId: IdSchema, - orgName: z.string(), - orgSlug: z.string(), - count: z.number().int().nonnegative(), - grossMinor: z.number().int().nonnegative(), - platformFeeMinor: z.number().int().nonnegative(), - refundedMinor: z.number().int().nonnegative(), - netMinor: z.number().int(), - firstChargedAt: ISODateSchema.nullable(), - lastChargedAt: ISODateSchema.nullable(), -}) -export type AdminDonationOrgTotalsDTO = z.infer -export const AdminDonationOrgTotalsDTOSchema: z.ZodType = - AdminDonationOrgTotalsDTOObjectSchema - -const AdminDonationTotalsByOrgResponseObjectSchema = z.object({ - from: ISODateSchema, - to: ISODateSchema, - items: z.array(AdminDonationOrgTotalsDTOSchema), - truncated: z.boolean().default(false), - totals: z.object({ - count: z.number().int().nonnegative(), - grossMinor: z.number().int().nonnegative(), - platformFeeMinor: z.number().int().nonnegative(), - refundedMinor: z.number().int().nonnegative(), - netMinor: z.number().int(), - }), -}) -export type AdminDonationTotalsByOrgResponse = z.infer< - typeof AdminDonationTotalsByOrgResponseObjectSchema -> -export const AdminDonationTotalsByOrgResponseSchema: z.ZodType = - AdminDonationTotalsByOrgResponseObjectSchema - -const AdminPlatformDonationSettingsDTOObjectSchema = z.object({ - paymentsEnabled: z.boolean(), - registrationNumber: z.string().nullable(), - platformFeeBps: z.number().int().nonnegative(), - minAmountMinor: z.number().int().positive(), - maxAmountMinor: z.number().int().positive(), - currency: z.string(), - eligibilityStaleGraceHours: z.number().int().nonnegative(), - paymentMethodDomains: z.array(z.string()).default([]), - reviewRequiredBlocks: z.boolean().optional(), -}) -export type AdminPlatformDonationSettingsDTO = z.infer< - typeof AdminPlatformDonationSettingsDTOObjectSchema -> -export const AdminPlatformDonationSettingsDTOSchema: z.ZodType = - AdminPlatformDonationSettingsDTOObjectSchema - -export const GetAdminPlatformDonationSettingsResponseSchema = - AdminPlatformDonationSettingsDTOSchema -export type GetAdminPlatformDonationSettingsResponse = z.infer< - typeof GetAdminPlatformDonationSettingsResponseSchema -> diff --git a/packages/shared/src/schemas/common.ts b/packages/shared/src/schemas/common.ts index 9c13ac7c..80b151de 100644 --- a/packages/shared/src/schemas/common.ts +++ b/packages/shared/src/schemas/common.ts @@ -304,7 +304,7 @@ export type EventPageBlockKind = z.infer export const ThemeAccentSchema = z.enum(["bloom", "moss", "sun", "sky", "lilac"]) export type ThemeAccent = z.infer -export const HostExportKindSchema = z.enum(["roster", "answers", "checkins", "donations"]) +export const HostExportKindSchema = z.enum(["roster", "answers", "checkins"]) export type HostExportKind = z.infer export const HostExportStatusSchema = z.enum([ @@ -333,8 +333,6 @@ export const HostCapabilitySchema = z.enum([ "manage_org_link", "moderate_chat", "request_resources", - "manage_payments", - "view_donations", "manage_org_members", ]) export type HostCapability = z.infer @@ -401,71 +399,10 @@ export const PageViewSourceSchema = z.enum([ ]) export type PageViewSource = z.infer -export const DonationStatusSchema = z.enum([ - "pending", - "succeeded", - "failed", - "refunded", - "partially_refunded", -]) -export type DonationStatus = z.infer - -export const PayoutStatusSchema = z.enum(["pending", "in_transit", "paid", "failed", "canceled"]) -export type PayoutStatus = z.infer - -export const DonationDisputeStateSchema = z.enum(["none", "open", "won", "lost", "warning"]) -export type DonationDisputeState = z.infer - -export const OrgPaymentsStateSchema = z.enum([ - "not_started", - "onboarding", - "ready", - "at_risk", - "blocked", -]) -export type OrgPaymentsState = z.infer - -export const DonateStateSchema = z.enum(["READY", "AT_RISK", "BLOCKED", "OFF"]) -export type DonateState = z.infer - -export const EligibilityVerdictSchema = z.enum([ - "unknown", - "eligible", - "grace", - "ineligible", - "review_required", -]) -export type EligibilityVerdict = z.infer - -export const EligibilitySourceSchema = z.enum([ - "irs_pub78", - "irs_eo_bmf", - "irs_auto_revocation", - "ftb_revoked", - "ca_ag_mnos", - "ofac_sdn", - "central_org_confirmation", -]) -export type EligibilitySource = z.infer - -export const LegalDocumentTypeSchema = z.enum([ - "terms", - "privacy", - "cookies", - "subprocessors", - "donations", - "org_donation_agreement", - "donation_disclosure", -]) +export const LegalDocumentTypeSchema = z.enum(["terms", "privacy", "cookies", "subprocessors"]) export type LegalDocumentType = z.infer -export const ConsentSurfaceSchema = z.enum([ - "web_donate", - "web_org_settings", - "web_register", - "mobile_register", - "onboarding", -]) +export const ConsentSurfaceSchema = z.enum(["web_register", "mobile_register", "onboarding"]) export type ConsentSurface = z.infer diff --git a/packages/shared/src/schemas/entities.ts b/packages/shared/src/schemas/entities.ts index a932ef7f..8388b413 100644 --- a/packages/shared/src/schemas/entities.ts +++ b/packages/shared/src/schemas/entities.ts @@ -6,8 +6,6 @@ import { BroadcastStatusSchema, CheckinMethodSchema, CleanupMemberRoleSchema, - DonationDisputeStateSchema, - DonationStatusSchema, EventPageStatusSchema, EventVisibilitySchema, HostCapabilitySchema, @@ -33,6 +31,8 @@ import { import { MARKDOWN_SUBSET_MAX_CHARS } from "../markdown/parse.js" import { isSafeHttpsUrl } from "../markdown/safe-url.js" +export const HttpsUrlSchema = z.string().trim().url().max(500).startsWith("https://") + export const AvatarPairSchema = z.tuple([z.string(), z.string()]).nullable().optional() const OrganizationRefDTOObjectSchema = z.object({ @@ -103,6 +103,7 @@ export const PersonDTOSchema = z.object({ * membership; null when the person belongs to no organization. Optional so an older server parses. */ organization: OrganizationRefDTOSchema.nullable().optional(), + donationUrl: HttpsUrlSchema.nullable().optional(), deleted: z.boolean().optional(), }) export type PersonDTO = z.infer @@ -274,27 +275,6 @@ export const ReportPinDTOSchema = z.object({ export type ReportPinDTO = z.infer -const MoneyDTOObjectSchema = z.object({ - amountMinor: z.number().int(), - currency: z.literal("USD"), -}) -export type MoneyDTO = z.infer -export const MoneyDTOSchema: z.ZodType = - MoneyDTOObjectSchema - -const FeeBreakdownDTOObjectSchema = z.object({ - grossMinor: z.number().int().nonnegative(), - platformFeeMinor: z.number().int().nonnegative(), - processorFeeMinor: z.number().int().nonnegative(), - processorFeeIsEstimate: z.boolean(), - netMinor: z.number().int(), - platformFeeBps: z.number().int().nonnegative().max(10000), - currency: z.literal("USD").default("USD"), -}) -export type FeeBreakdownDTO = z.infer -export const FeeBreakdownDTOSchema: z.ZodType = - FeeBreakdownDTOObjectSchema - const LegalDocumentVersionDTOObjectSchema = z.object({ type: LegalDocumentTypeSchema, version: z.string().min(1).max(32), @@ -324,8 +304,7 @@ const OrganizationDTOObjectSchema = z.object({ volunteerHours: z.number().nonnegative().optional(), volunteerCount: z.number().int().nonnegative().optional(), myRole: OrganizationMemberRoleSchema.nullable().optional(), - donationsEnabled: z.boolean().optional(), - donateSlug: z.string().nullable().optional(), + donationUrl: HttpsUrlSchema.nullable().optional(), // 0.41.0: an operator-suspended org (DECISIONS §32). Optional so a 0.40.0 server still parses. suspended: z.boolean().optional(), }) @@ -704,31 +683,6 @@ export type BroadcastDTO = z.infer export const BroadcastDTOSchema: z.ZodType = BroadcastDTOObjectSchema -const DonationDTOObjectSchema = z.object({ - id: IdSchema, - reference: z.string(), - organizationId: IdSchema, - orgName: z.string(), - orgLegalName: z.string().nullable().optional(), - amount: MoneyDTOSchema, - fees: FeeBreakdownDTOSchema.nullable().optional(), - status: DonationStatusSchema, - disputeState: DonationDisputeStateSchema.default("none"), - refundedTotalMinor: z.number().int().nonnegative().default(0), - createdAt: ISODateSchema, - chargedAt: ISODateSchema.nullable().optional(), - cardBrand: z.string().nullable().optional(), - cardLast4: z.string().nullable().optional(), - receiptAvailable: z.boolean().default(false), - receiptSentAt: ISODateSchema.nullable().optional(), - sharedIdentityWithOrg: z.boolean().default(false), - eventId: IdSchema.nullable().optional(), - eventTitle: z.string().nullable().optional(), -}) -export type DonationDTO = z.infer -export const DonationDTOSchema: z.ZodType = - DonationDTOObjectSchema - export const RegistrationStateSchema = z.enum([ "open", "not_yet_open", @@ -738,14 +692,12 @@ export const RegistrationStateSchema = z.enum([ ]) export type RegistrationState = z.infer -const CleanupDonationOrgRefObjectSchema = z.object({ - slug: z.string(), - name: z.string(), - enabled: z.boolean(), +const CleanupOrganizationRefObjectSchema = OrganizationRefDTOObjectSchema.extend({ + donationUrl: HttpsUrlSchema.nullable().optional(), }) -export type CleanupDonationOrgRef = z.infer -export const CleanupDonationOrgRefSchema: z.ZodType = - CleanupDonationOrgRefObjectSchema +export type CleanupOrganizationRef = z.infer +export const CleanupOrganizationRefSchema: z.ZodType = + CleanupOrganizationRefObjectSchema const CleanupObjectSchema = z.object({ id: IdSchema, @@ -774,15 +726,14 @@ const CleanupObjectSchema = z.object({ visibility: EventVisibilitySchema.default("public"), coverUrl: z.string().nullable().optional(), galleryUrls: z.array(z.string()).default([]), - donationUrl: z.string().nullable().optional(), + donationUrl: HttpsUrlSchema.nullable().optional(), donationClicks: z.number().int().nonnegative().nullable().optional(), - donationOrg: CleanupDonationOrgRefSchema.nullable().optional(), pageSlug: z.string().nullable().optional(), pageStatus: EventPageStatusSchema.nullable().optional(), registrationOpensAt: ISODateSchema.nullable().optional(), registrationClosesAt: ISODateSchema.nullable().optional(), capacity: z.number().int().nonnegative().nullable().optional(), - organization: OrganizationRefDTOSchema.nullable().optional(), + organization: CleanupOrganizationRefSchema.nullable().optional(), ticketTypes: z.array(TicketTypeDTOSchema).default([]), registrationState: RegistrationStateSchema.nullable().optional(), myRegistration: MyEventRegistrationRefSchema.nullable().optional(), diff --git a/packages/shared/src/schemas/host/exports.ts b/packages/shared/src/schemas/host/exports.ts index 76f545bf..a44d3966 100644 --- a/packages/shared/src/schemas/host/exports.ts +++ b/packages/shared/src/schemas/host/exports.ts @@ -63,18 +63,6 @@ export type ListEventExportsResponse = z.infer = ListEventExportsResponseObjectSchema -export const ListOrgDonationExportsRequestSchema = PaginationQuerySchema.extend({ - id: IdSchema, -}).strict() -export type ListOrgDonationExportsRequest = z.infer - -const ListOrgDonationExportsResponseObjectSchema = pageResponse(HostExportDTOSchema) -export type ListOrgDonationExportsResponse = z.infer< - typeof ListOrgDonationExportsResponseObjectSchema -> -export const ListOrgDonationExportsResponseSchema: z.ZodType = - ListOrgDonationExportsResponseObjectSchema - export const GetEventExportRequestSchema = z.object({ id: IdSchema, exportId: IdSchema }).strict() export type GetEventExportRequest = z.infer diff --git a/packages/shared/src/schemas/host/insights.ts b/packages/shared/src/schemas/host/insights.ts index b9bbdce4..feb531df 100644 --- a/packages/shared/src/schemas/host/insights.ts +++ b/packages/shared/src/schemas/host/insights.ts @@ -84,16 +84,6 @@ export const EventInsightsHoursSchema = z.object({ }) export type EventInsightsHours = z.infer -export const EventInsightsMoneySchema = z.object({ - currency: z.literal("USD"), - donationCount: z.number().int().nonnegative(), - grossMinor: z.number().int().nonnegative(), - netMinor: z.number().int(), - refundedMinor: z.number().int().nonnegative(), - lastChargedAt: ISODateSchema.nullable(), -}) -export type EventInsightsMoney = z.infer - export const EventInsightsReturningSchema = z.object({ seats: z.number().int().nonnegative(), ofRegistered: z.number().int().nonnegative(), @@ -112,7 +102,6 @@ const EventInsightsObjectSchema = z.object({ arrivals: z.array(ArrivalOffsetBucketSchema).max(MAX_INSIGHTS_ARRIVAL_BUCKETS).default([]), hours: EventInsightsHoursSchema, topVolunteers: z.array(LeaderboardEntryDTOSchema).max(MAX_INSIGHTS_TOP_VOLUNTEERS).default([]), - money: EventInsightsMoneySchema.nullable(), returning: EventInsightsReturningSchema.nullable(), }) export type EventInsights = z.infer diff --git a/packages/shared/src/schemas/host/organizations.ts b/packages/shared/src/schemas/host/organizations.ts index 8bc8709c..97d6f003 100644 --- a/packages/shared/src/schemas/host/organizations.ts +++ b/packages/shared/src/schemas/host/organizations.ts @@ -10,6 +10,7 @@ import { } from "../common.js" import { CleanupDTOSchema, + HttpsUrlSchema, OrganizationDTOSchema, OrganizationMemberDTOSchema, OrganizationRefDTOSchema, @@ -19,6 +20,7 @@ import { export { + HttpsUrlSchema, OrganizationDTOSchema, OrganizationRefDTOSchema, OrganizationMemberDTOSchema, @@ -47,8 +49,6 @@ export const OrgSlugSchema = z .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) export type OrgSlug = z.infer -export const HttpsUrlSchema = z.string().trim().url().max(500).startsWith("https://") - export const CreateOrganizationRequestSchema = z .object({ name: z.string().trim().min(1).max(MAX_ORG_NAME), @@ -100,6 +100,7 @@ export const UpdateOrganizationRequestSchema = z name: z.string().trim().min(1).max(MAX_ORG_NAME).optional(), description: z.string().max(MAX_ORG_DESCRIPTION).nullable().optional(), websiteUrl: HttpsUrlSchema.nullable().optional(), + donationUrl: HttpsUrlSchema.nullable().optional(), logoMediaId: IdSchema.nullable().optional(), socialLinks: SocialLinksSchema.nullable().optional(), }) diff --git a/packages/shared/src/schemas/host/pages.ts b/packages/shared/src/schemas/host/pages.ts index 06a8ad4f..04680739 100644 --- a/packages/shared/src/schemas/host/pages.ts +++ b/packages/shared/src/schemas/host/pages.ts @@ -143,7 +143,6 @@ const PublicEventPageObjectSchema = z.object({ .strict(), waitlistEnabled: z.boolean().default(false), donationUrl: z.string().nullable().optional(), - donateSlug: z.string().nullable().optional(), requiresTurnstile: z.boolean().default(true), }) export type PublicEventPageDTO = z.infer diff --git a/packages/shared/src/schemas/payments.ts b/packages/shared/src/schemas/payments.ts deleted file mode 100644 index 193a4d2f..00000000 --- a/packages/shared/src/schemas/payments.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { z } from "zod" -import { - ConsentSurfaceSchema, - DonateStateSchema, - DonationDisputeStateSchema, - DonationStatusSchema, - EligibilitySourceSchema, - EligibilityVerdictSchema, - IdSchema, - ISODateSchema, - OrgPaymentsStateSchema, - PaginationQuerySchema, - PayoutStatusSchema, - pageResponse, -} from "./common.js" -import { - DonationDTOSchema, - FeeBreakdownDTOSchema, - LegalDocumentVersionDTOSchema, - MoneyDTOSchema, -} from "./entities.js" -import { HostExportDTOSchema, HostExportFiltersSchema } from "./host/exports.js" -import { OrgSlugSchema } from "./host/organizations.js" - - -export { - DonationDTOSchema, - FeeBreakdownDTOSchema, - MoneyDTOSchema, - LegalDocumentVersionDTOSchema, -} from "./entities.js" -export type { - DonationDTO, - FeeBreakdownDTO, - MoneyDTO, - LegalDocumentVersionDTO, -} from "./entities.js" -export { - DonateStateSchema, - DonationDisputeStateSchema, - DonationStatusSchema, - EligibilitySourceSchema, - EligibilityVerdictSchema, - OrgPaymentsStateSchema, -} from "./common.js" -export type { - DonateState, - DonationDisputeState, - DonationStatus, - EligibilitySource, - EligibilityVerdict, - OrgPaymentsState, -} from "./common.js" - -export const DONATION_CURRENCY = "USD" as const -export const MAX_DONOR_NAME = 120 -export const MAX_REFUND_POLICY_TEXT = 500 -export const MAX_MISSION_BLURB = 280 -export const MAX_SUGGESTED_AMOUNTS = 6 - -export const DonorEmailSchema = z.string().trim().email().max(254).toLowerCase() - -export const AmountMinorSchema = z.number().int().positive().max(100_000_000) - -const OrgEligibilityCheckDTOObjectSchema = z.object({ - source: EligibilitySourceSchema, - sourceRevisionDate: z.string(), - matched: z.boolean(), - verdictContribution: z.enum(["supports", "disqualifies", "neutral"]), - detail: z.string().nullable().optional(), - checkedAt: ISODateSchema, -}) -export type OrgEligibilityCheckDTO = z.infer -export const OrgEligibilityCheckDTOSchema: z.ZodType = - OrgEligibilityCheckDTOObjectSchema - -const OrgEligibilityDTOObjectSchema = z.object({ - verdict: EligibilityVerdictSchema, - reasons: z.array(z.string()).default([]), - einLast4: z.string().nullable().optional(), - irsLegalName: z.string().nullable().optional(), - deductibilityCode: z.string().nullable().optional(), - foundationCode: z.string().nullable().optional(), - graceExpiresAt: ISODateSchema.nullable().optional(), - evaluatedAt: ISODateSchema.nullable().optional(), - nextCheckAt: ISODateSchema.nullable().optional(), - checks: z.array(OrgEligibilityCheckDTOSchema).default([]), -}) -export type OrgEligibilityDTO = z.infer -export const OrgEligibilityDTOSchema: z.ZodType = - OrgEligibilityDTOObjectSchema - -export const OrgPaymentMethodDomainSchema = z.object({ - domain: z.string(), - enabled: z.boolean(), - registeredAt: ISODateSchema.nullable().optional(), -}) -export type OrgPaymentMethodDomain = z.infer - -const OrgDonationAgreementDTOObjectSchema = z.object({ - version: z.string().nullable(), - acceptedAt: ISODateSchema.nullable().optional(), - acceptedByName: z.string().nullable().optional(), - current: z.boolean().default(false), - requiredVersion: z.string().nullable().optional(), -}) -export type OrgDonationAgreementDTO = z.infer -export const OrgDonationAgreementDTOSchema: z.ZodType = - OrgDonationAgreementDTOObjectSchema - -const OrgPaymentsStatusObjectSchema = z.object({ - organizationId: IdSchema, - state: OrgPaymentsStateSchema, - stripeAccountId: z.string().nullable(), - livemode: z.boolean().default(false), - detailsSubmitted: z.boolean().default(false), - chargesEnabled: z.boolean().default(false), - payoutsEnabled: z.boolean().default(false), - disabledReason: z.string().nullable().optional(), - currentlyDue: z.array(z.string()).default([]), - pastDue: z.array(z.string()).default([]), - pendingVerification: z.array(z.string()).default([]), - futureCurrentlyDue: z.array(z.string()).default([]), - currentDeadline: ISODateSchema.nullable().optional(), - capabilities: z.record(z.string()).default({}), - paymentMethodDomains: z.array(OrgPaymentMethodDomainSchema).default([]), - walletsAvailable: z.array(z.string()).default([]), - donationsEnabled: z.boolean().default(false), - donationsDisabledReason: z - .enum(["org", "operator", "eligibility", "stripe_blocked", "deauthorized"]) - .nullable() - .optional(), - agreement: OrgDonationAgreementDTOSchema, - eligibility: OrgEligibilityDTOSchema, - donateState: DonateStateSchema, - lastSyncedAt: ISODateSchema.nullable().optional(), -}) -export type OrgPaymentsStatusDTO = z.infer - -export const OrgPaymentsStatusDTOSchema: z.ZodType = - OrgPaymentsStatusObjectSchema - -export const CreateOrgStripeAccountRequestSchema = z.object({ id: IdSchema }).strict() -export type CreateOrgStripeAccountRequest = z.infer - -export const CreateOrgStripeAccountResponseSchema = OrgPaymentsStatusDTOSchema -export type CreateOrgStripeAccountResponse = z.infer - -export const CreateOrgStripeAccountLinkRequestSchema = z - .object({ id: IdSchema, type: z.enum(["onboarding", "update"]).default("onboarding") }) - .strict() -export type CreateOrgStripeAccountLinkRequest = z.infer< - typeof CreateOrgStripeAccountLinkRequestSchema -> - -export const CreateOrgStripeAccountLinkResponseSchema = z.object({ - url: z.string(), - expiresAt: ISODateSchema, -}) -export type CreateOrgStripeAccountLinkResponse = z.infer< - typeof CreateOrgStripeAccountLinkResponseSchema -> - -export const GetOrgPaymentsStatusRequestSchema = z.object({ id: IdSchema }).strict() -export type GetOrgPaymentsStatusRequest = z.infer - -export const GetOrgPaymentsStatusResponseSchema = OrgPaymentsStatusDTOSchema -export type GetOrgPaymentsStatusResponse = z.infer - -/** - * One payout from the org's connected account to its own bank (0.43.0, DECISIONS §34). civfix never - * holds the money (§26 stands): the payout is executed ON the connected account, and this row is an - * audit mirror of the Stripe object so the app can show history without a Stripe round trip. - */ -const PayoutDTOObjectSchema = z.object({ - id: IdSchema, - stripePayoutId: z.string(), - amount: MoneyDTOSchema, - status: PayoutStatusSchema, - arrivalDate: ISODateSchema.nullable().optional(), - createdAt: ISODateSchema, - failureMessage: z.string().nullable().optional(), -}) -export type PayoutDTO = z.infer -export const PayoutDTOSchema: z.ZodType = PayoutDTOObjectSchema - -/** - * The connected account's balance and payout schedule. `available` is the only amount a manual - * payout can move; a Standard account on an automatic schedule usually holds ~0 there, which is why - * the schedule travels with the balance instead of the client guessing why the button is disabled. - */ -const OrgBalanceDTOObjectSchema = z.object({ - available: MoneyDTOSchema, - pending: MoneyDTOSchema, - payoutsEnabled: z.boolean().default(false), - payoutSchedule: z - .object({ - interval: z.enum(["daily", "weekly", "monthly", "manual"]), - delayDays: z.number().int().nonnegative().optional(), - }) - .nullable(), - lastSyncedAt: ISODateSchema, -}) -export type OrgBalanceDTO = z.infer -export const OrgBalanceDTOSchema: z.ZodType = - OrgBalanceDTOObjectSchema - -export const GetOrgBalanceRequestSchema = z.object({ id: IdSchema }).strict() -export type GetOrgBalanceRequest = z.infer - -export const GetOrgBalanceResponseSchema = OrgBalanceDTOSchema -export type GetOrgBalanceResponse = z.infer - -/** - * `amountMinor` absent means "pay out the whole available balance". `idempotencyKey` is required, not - * optional: a retried payout is real money moving twice. - */ -export const CreateOrgPayoutRequestSchema = z - .object({ - id: IdSchema, - amountMinor: z.number().int().positive().optional(), - currency: z.literal("USD").default("USD"), - idempotencyKey: z.string().uuid(), - }) - .strict() -export type CreateOrgPayoutRequest = z.infer - -const CreateOrgPayoutResponseObjectSchema = z.object({ payout: PayoutDTOSchema }) -export type CreateOrgPayoutResponse = z.infer -export const CreateOrgPayoutResponseSchema: z.ZodType = - CreateOrgPayoutResponseObjectSchema - -export const ListOrgPayoutsRequestSchema = PaginationQuerySchema.extend({ id: IdSchema }).strict() -export type ListOrgPayoutsRequest = z.infer - -const ListOrgPayoutsResponseObjectSchema = pageResponse(PayoutDTOSchema) -export type ListOrgPayoutsResponse = z.infer -export const ListOrgPayoutsResponseSchema: z.ZodType = - ListOrgPayoutsResponseObjectSchema - -const OrgDonationSettingsDTOObjectSchema = z.object({ - organizationId: IdSchema, - enabled: z.boolean(), - donorSharingDefault: z.boolean().default(false), - missionBlurb: z.string().max(MAX_MISSION_BLURB).nullable().optional(), - designationNote: z.string().max(MAX_MISSION_BLURB).nullable().optional(), - refundPolicyText: z.string().max(MAX_REFUND_POLICY_TEXT).nullable().optional(), - minAmountMinor: z.number().int().nonnegative(), - maxAmountMinor: z.number().int().nonnegative(), - suggestedAmountsMinor: z.array(z.number().int().positive()).max(MAX_SUGGESTED_AMOUNTS).default([]), - agreedFeeBps: z.number().int().min(0).max(2000), - effectiveFeeBps: z.number().int().min(0).max(2000), - legalName: z.string().nullable().optional(), - einLast4: z.string().nullable().optional(), - agreement: OrgDonationAgreementDTOSchema, - eligibility: OrgEligibilityDTOSchema, - donateState: DonateStateSchema, -}) -export type OrgDonationSettingsDTO = z.infer -export const OrgDonationSettingsDTOSchema: z.ZodType = - OrgDonationSettingsDTOObjectSchema - -export const GetOrgDonationSettingsRequestSchema = z.object({ id: IdSchema }).strict() -export type GetOrgDonationSettingsRequest = z.infer - -export const GetOrgDonationSettingsResponseSchema = OrgDonationSettingsDTOSchema -export type GetOrgDonationSettingsResponse = z.infer - -export const UpdateOrgDonationSettingsRequestSchema = z - .object({ - id: IdSchema, - enabled: z.boolean().optional(), - donorSharingDefault: z.boolean().optional(), - missionBlurb: z.string().max(MAX_MISSION_BLURB).nullable().optional(), - designationNote: z.string().max(MAX_MISSION_BLURB).nullable().optional(), - refundPolicyText: z.string().max(MAX_REFUND_POLICY_TEXT).nullable().optional(), - minAmountMinor: z.number().int().positive().optional(), - maxAmountMinor: z.number().int().positive().optional(), - suggestedAmountsMinor: z - .array(z.number().int().positive()) - .max(MAX_SUGGESTED_AMOUNTS) - .optional(), - }) - .strict() -export type UpdateOrgDonationSettingsRequest = z.infer< - typeof UpdateOrgDonationSettingsRequestSchema -> - -export const UpdateOrgDonationSettingsResponseSchema = OrgDonationSettingsDTOSchema -export type UpdateOrgDonationSettingsResponse = z.infer< - typeof UpdateOrgDonationSettingsResponseSchema -> - -export const AcceptOrgDonationAgreementRequestSchema = z - .object({ - id: IdSchema, - version: z.string().min(1).max(32), - documentSha256: z.string().min(1).max(128).optional(), - surface: ConsentSurfaceSchema.default("web_org_settings"), - screenRoute: z.string().max(200).optional(), - uiTemplateVersion: z.string().max(32).optional(), - authorityAffirmed: z.literal(true), - }) - .strict() -export type AcceptOrgDonationAgreementRequest = z.infer< - typeof AcceptOrgDonationAgreementRequestSchema -> - -export const AcceptOrgDonationAgreementResponseSchema = z.object({ - ok: z.literal(true), - agreement: OrgDonationAgreementDTOSchema, -}) -export type AcceptOrgDonationAgreementResponse = z.infer< - typeof AcceptOrgDonationAgreementResponseSchema -> - -export const DonationDisclosuresSchema = z.object({ - recipient: z.string(), - mayNotReceive: z.string(), - mayNotReceiveUrl: z.string(), - mayNotReceiveReasons: z.array(z.string()).default([]), - remittanceTiming: z.string(), - feePointer: z.string(), - deductibility: z.string(), - deductibilityCheckedAt: ISODateSchema.nullable().optional(), - merchantOfRecord: z.string(), - refundPolicy: z.string(), -}) -export type DonationDisclosures = z.infer - -export const DonationTaxDeductibilitySchema = z.object({ - deductible: z.boolean(), - percentage: z.number().min(0).max(100).nullable(), - statement: z.string(), - checkedAt: ISODateSchema.nullable().optional(), -}) -export type DonationTaxDeductibility = z.infer - -export const DonorSharingPolicySchema = z.object({ - defaultOn: z.literal(false), - optInLabel: z.string(), - whatIsShared: z.string(), -}) -export type DonorSharingPolicy = z.infer - -export const DonationPageOrgSchema = z.object({ - slug: z.string(), - displayName: z.string(), - legalName: z.string(), - logoUrl: z.string().nullable().optional(), - verified: z.boolean(), - einLast4: z.string().nullable().optional(), - city: z.string().nullable().optional(), - state: z.string().nullable().optional(), -}) -export type DonationPageOrg = z.infer - -export const DonationPageEventRefSchema = z.object({ - id: IdSchema, - title: z.string(), - startsAt: ISODateSchema, -}) -export type DonationPageEventRef = z.infer - -const DonationPageObjectSchema = z.object({ - org: DonationPageOrgSchema, - donateState: DonateStateSchema, - donationsEnabled: z.boolean(), - stripeAccount: z.string().nullable(), - eligibility: z.object({ - state: z.enum(["open", "closed"]), - closedReason: z.string().nullable().optional(), - }), - currency: z.literal("USD").default("USD"), - minAmountMinor: z.number().int().positive(), - maxAmountMinor: z.number().int().positive(), - suggestedAmountsMinor: z.array(z.number().int().positive()).default([]), - platformFeeBps: z.number().int().min(0).max(2000), - processingFeeBps: z.number().int().min(0).max(2000), - processingFeeFixedMinor: z.number().int().nonnegative(), - feePreview: FeeBreakdownDTOSchema, - disclosures: DonationDisclosuresSchema, - disclosureVersion: z.string(), - registrationNumber: z.string().nullable(), - taxDeductibility: DonationTaxDeductibilitySchema, - donorSharing: DonorSharingPolicySchema, - legalVersions: z.array(LegalDocumentVersionDTOSchema).default([]), - walletsAvailable: z.array(z.string()).default([]), - requiresTurnstile: z.boolean().default(true), - event: DonationPageEventRefSchema.nullable().optional(), -}) -export type DonationPageDTO = z.infer - -export const DonationPageDTOSchema: z.ZodType = - DonationPageObjectSchema - -export const GetPublicOrgDonationPageRequestSchema = z.object({ slug: OrgSlugSchema }).strict() -export type GetPublicOrgDonationPageRequest = z.infer - -export const GetPublicOrgDonationPageResponseSchema = DonationPageDTOSchema -export type GetPublicOrgDonationPageResponse = z.infer< - typeof GetPublicOrgDonationPageResponseSchema -> - -export const DonationConsentInputSchema = z - .object({ - termsVersion: z.string().min(1).max(32), - privacyVersion: z.string().min(1).max(32), - donationTermsVersion: z.string().min(1).max(32), - disclosureVersion: z.string().min(1).max(32), - surface: ConsentSurfaceSchema, - screenRoute: z.string().max(200), - uiTemplateVersion: z.string().max(32), - }) - .strict() -export type DonationConsentInput = z.infer - -export const CreateDonationCheckoutRequestSchema = z - .object({ - orgSlug: OrgSlugSchema, - amountMinor: AmountMinorSchema, - currency: z.literal("USD").default("USD"), - email: DonorEmailSchema, - name: z.string().trim().max(MAX_DONOR_NAME).optional(), - eventId: IdSchema.optional(), - shareIdentity: z.boolean().default(false), - consent: DonationConsentInputSchema, - idempotencyKey: z.string().min(8).max(128), - turnstileToken: z.string().min(1).max(2048).optional(), - }) - .strict() -export type CreateDonationCheckoutRequest = z.infer - -const CreateDonationCheckoutResponseObjectSchema = z.object({ - donationId: IdSchema, - clientSecret: z.string(), - stripeAccount: z.string(), - statusToken: z.string(), - returnUrl: z.string(), - expiresAt: ISODateSchema, - feeBreakdown: FeeBreakdownDTOSchema, -}) -export type CreateDonationCheckoutResponse = z.infer -export const CreateDonationCheckoutResponseSchema: z.ZodType = - CreateDonationCheckoutResponseObjectSchema - -export const GetDonationStatusRequestSchema = z - .object({ - id: IdSchema, - token: z.string().min(16).max(512).optional(), - sessionId: z.string().min(1).max(255).optional(), - }) - .strict() -export type GetDonationStatusRequest = z.infer - -const DonationStatusDTOObjectSchema = z.object({ - status: DonationStatusSchema, - amount: MoneyDTOSchema, - orgLegalName: z.string(), - maskedEmail: z.string().nullable(), - reference: z.string(), - chargedAt: ISODateSchema.nullable(), - receiptSent: z.boolean().default(false), -}) -export type DonationStatusDTO = z.infer -export const DonationStatusDTOSchema: z.ZodType = - DonationStatusDTOObjectSchema - -export const GetDonationStatusResponseSchema = DonationStatusDTOSchema -export type GetDonationStatusResponse = z.infer - -export const ListMyDonationsRequestSchema = PaginationQuerySchema.strict() -export type ListMyDonationsRequest = z.infer - -const ListMyDonationsResponseObjectSchema = pageResponse(DonationDTOSchema) -export type ListMyDonationsResponse = z.infer -export const ListMyDonationsResponseSchema: z.ZodType = - ListMyDonationsResponseObjectSchema - -export const GetMyDonationReceiptRequestSchema = z.object({ id: IdSchema }).strict() -export type GetMyDonationReceiptRequest = z.infer - -const GetMyDonationReceiptResponseObjectSchema = z.object({ - url: z.string(), - expiresAt: ISODateSchema, - filename: z.string(), -}) -export type GetMyDonationReceiptResponse = z.infer -export const GetMyDonationReceiptResponseSchema: z.ZodType = - GetMyDonationReceiptResponseObjectSchema - -const OrgDonationRowDTOObjectSchema = z.object({ - id: IdSchema, - reference: z.string(), - amount: MoneyDTOSchema, - platformFeeMinor: z.number().int().nonnegative(), - processorFeeMinor: z.number().int().nonnegative(), - netMinor: z.number().int(), - status: DonationStatusSchema, - disputeState: DonationDisputeStateSchema.default("none"), - refundedTotalMinor: z.number().int().nonnegative().default(0), - chargedAt: ISODateSchema.nullable(), - donorName: z.string().nullable(), - donorEmail: z.string().nullable(), - sharedIdentity: z.boolean().default(false), - eventId: IdSchema.nullable().optional(), - eventTitle: z.string().nullable().optional(), - receiptSentAt: ISODateSchema.nullable().optional(), -}) -export type OrgDonationRowDTO = z.infer -export const OrgDonationRowDTOSchema: z.ZodType = - OrgDonationRowDTOObjectSchema - -export const ListOrgDonationsRequestSchema = PaginationQuerySchema.extend({ - id: IdSchema, - status: DonationStatusSchema.optional(), - from: ISODateSchema.optional(), - to: ISODateSchema.optional(), -}).strict() -export type ListOrgDonationsRequest = z.infer - -const ListOrgDonationsResponseObjectSchema = pageResponse(OrgDonationRowDTOSchema) -export type ListOrgDonationsResponse = z.infer -export const ListOrgDonationsResponseSchema: z.ZodType = - ListOrgDonationsResponseObjectSchema - -export const GetOrgDonationSummaryRequestSchema = z - .object({ id: IdSchema, from: ISODateSchema.optional(), to: ISODateSchema.optional() }) - .strict() -export type GetOrgDonationSummaryRequest = z.infer - -const OrgDonationSummaryDTOObjectSchema = z.object({ - currency: z.literal("USD").default("USD"), - donationCount: z.number().int().nonnegative(), - grossMinor: z.number().int().nonnegative(), - platformFeeMinor: z.number().int().nonnegative(), - processorFeeMinor: z.number().int().nonnegative(), - netMinor: z.number().int(), - refundedMinor: z.number().int().nonnegative(), - disputedCount: z.number().int().nonnegative(), - from: ISODateSchema.nullable().optional(), - to: ISODateSchema.nullable().optional(), -}) -export type OrgDonationSummaryDTO = z.infer -export const OrgDonationSummaryDTOSchema: z.ZodType = - OrgDonationSummaryDTOObjectSchema - -export const GetOrgDonationSummaryResponseSchema = OrgDonationSummaryDTOSchema -export type GetOrgDonationSummaryResponse = z.infer - -export const RequestOrgDonationExportRequestSchema = z - .object({ id: IdSchema, filters: HostExportFiltersSchema.optional() }) - .strict() -export type RequestOrgDonationExportRequest = z.infer - -export const RequestOrgDonationExportResponseSchema = HostExportDTOSchema -export type RequestOrgDonationExportResponse = z.infer< - typeof RequestOrgDonationExportResponseSchema -> diff --git a/packages/shared/src/schemas/social.ts b/packages/shared/src/schemas/social.ts index 2d900591..cae407b7 100644 --- a/packages/shared/src/schemas/social.ts +++ b/packages/shared/src/schemas/social.ts @@ -1,6 +1,12 @@ import { z } from "zod" import { IdSchema, PaginationQuerySchema, pageResponse } from "./common.js" -import { PersonDTOSchema, CleanupDTOSchema, AvatarPairSchema, SocialLinksSchema } from "./entities.js" +import { + PersonDTOSchema, + CleanupDTOSchema, + AvatarPairSchema, + HttpsUrlSchema, + SocialLinksSchema, +} from "./entities.js" import { UserDTOSchema, LocaleEnum } from "./auth.js" @@ -77,6 +83,7 @@ export const UpdateProfileRequestSchema = z bio: z.string().trim().max(MAX_BIO_LENGTH).nullable().optional(), avatarUploadId: IdSchema.optional(), socialLinks: SocialLinksSchema.nullable().optional(), + donationUrl: HttpsUrlSchema.nullable().optional(), }) .strict() export type UpdateProfileRequest = z.infer diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index 8236a94a..da2a46c4 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -14,7 +14,6 @@ export enum ErrorCode { CONFLICT = "CONFLICT", UNSUPPORTED_API_VERSION = "UNSUPPORTED_API_VERSION", API_VERSION_SUNSET = "API_VERSION_SUNSET", - PAYMENT_UNAVAILABLE = "PAYMENT_UNAVAILABLE", INTERNAL = "INTERNAL", } @@ -33,7 +32,6 @@ export const ERROR_HTTP_STATUS: Record = { [ErrorCode.CONFLICT]: 409, [ErrorCode.UNSUPPORTED_API_VERSION]: 400, [ErrorCode.API_VERSION_SUNSET]: 410, - [ErrorCode.PAYMENT_UNAVAILABLE]: 503, [ErrorCode.INTERNAL]: 500, } @@ -130,10 +128,6 @@ export class AppError extends Error { return new AppError(ErrorCode.API_VERSION_SUNSET, message) } - static paymentUnavailable(message = "Payments are temporarily unavailable"): AppError { - return new AppError(ErrorCode.PAYMENT_UNAVAILABLE, message) - } - static internal(message = "Internal error"): AppError { return new AppError(ErrorCode.INTERNAL, message) } diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index b9e51a5f..2d5e83a6 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -15,7 +15,6 @@ export default defineConfig({ "src/host/index.ts", "src/markdown/index.ts", "src/ics/index.ts", - "src/payments/index.ts", "src/legal/index.ts", "src/tokens/chip-contrast.ts", ], From 4f04bd5f9f28b121f005a4b53f82ce3b1f86bb3c Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 17:47:28 -0700 Subject: [PATCH 2/8] @civfix/shared 0.48.0 --- .changeset/tidy-pumas-shave.md | 5 ----- packages/shared/CHANGELOG.md | 6 ++++++ packages/shared/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/tidy-pumas-shave.md diff --git a/.changeset/tidy-pumas-shave.md b/.changeset/tidy-pumas-shave.md deleted file mode 100644 index 5f2b5df2..00000000 --- a/.changeset/tidy-pumas-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@civfix/shared": minor ---- - -remove platform-processed donations; add donationUrl to organizations and people diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 55a78e5d..7e56243a 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,11 @@ # @civfix/shared +## 0.48.0 + +### Minor Changes + +- b630ce6: remove platform-processed donations; add donationUrl to organizations and people + ## 0.47.0 ### Minor Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index a3af2d5e..e02ba2cb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@civfix/shared", - "version": "0.47.0", + "version": "0.48.0", "description": "civfix shared contract package: zod schemas, domain types, vendor interfaces, in-memory fakes, design tokens, and a typed API client. Single source of truth for backend, web, and mobile.", "license": "UNLICENSED", "private": false, From d8794d4d841529e061a76e72affd31087cec5501 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 18:14:37 -0700 Subject: [PATCH 3/8] replace platform donations with external donation links in ui and mobile --- apps/community-mobile/APP-REVIEW-NOTES.md | 56 ++-- .../APP-STORE-SUBMISSION-AUDIT.md | 2 +- apps/community-mobile/app/me/donations.tsx | 6 - apps/community-mobile/eas.json | 4 +- apps/community-mobile/src/lib/links.test.ts | 9 +- apps/community-mobile/src/lib/links.ts | 1 - .../src/lib/navBridge.test.ts | 6 - .../src/lib/universalLinks.test.ts | 8 +- .../src/lib/universalLinks.ts | 3 - .../src/query/cache-policy.test.ts | 4 +- .../tests/notificationNav.test.ts | 2 - packages/ui/src/bodies/EventDetailBody.tsx | 19 +- packages/ui/src/bodies/PersonDetailBody.tsx | 11 +- packages/ui/src/bodies/ProfileView.tsx | 3 + .../ui/src/bodies/SettingsAccountBody.tsx | 20 ++ packages/ui/src/bodies/SettingsBody.tsx | 6 - .../src/bodies/__tests__/donationLink.test.ts | 76 +++++ .../bodies/__tests__/settingsSurfaces.test.ts | 14 +- packages/ui/src/bodies/donationLink.ts | 25 ++ .../ui/src/bodies/host/EventDashboardBody.tsx | 10 +- .../ui/src/bodies/host/HostInsightsPanels.tsx | 26 -- .../ui/src/bodies/host/MyDonationsBody.tsx | 264 --------------- packages/ui/src/bodies/host/OrgPageBody.tsx | 14 +- .../host/__tests__/consentPayload.test.ts | 6 - .../bodies/host/__tests__/donateGate.test.ts | 43 --- .../host/__tests__/hostModeSurface.test.ts | 4 - .../host/__tests__/hostSurfaceModel.test.ts | 18 +- .../bodies/host/dashboard/DonationLinkRow.tsx | 50 +++ .../bodies/host/dashboard/MoneySection.tsx | 317 ------------------ .../__tests__/dashboardModel.test.ts | 119 +------ .../bodies/host/dashboard/dashboardModel.ts | 73 ---- packages/ui/src/bodies/host/donationFormat.ts | 19 -- .../ui/src/bodies/host/hostSurfaceModel.ts | 2 - packages/ui/src/bodies/host/index.ts | 2 - packages/ui/src/bodies/index.ts | 11 + .../bodies/settings/DonationLinkEditor.tsx | 115 +++++++ .../__tests__/donationLinkField.test.ts | 47 +++ .../src/bodies/settings/donationLinkField.ts | 22 ++ .../__tests__/event-ics-and-exports.test.ts | 48 --- .../__tests__/orgPayoutIdempotency.test.ts | 44 --- packages/ui/src/data/context.tsx | 12 +- packages/ui/src/data/hooks/donations.ts | 53 --- packages/ui/src/data/hooks/index.ts | 19 -- packages/ui/src/data/hooks/orgs.ts | 32 -- packages/ui/src/data/hooks/payouts.ts | 177 ---------- packages/ui/src/data/index.ts | 27 -- packages/ui/src/data/keys.ts | 9 - .../ui/src/i18n/locales/de/donation-link.json | 26 ++ .../ui/src/i18n/locales/de/donations.json | 34 -- packages/ui/src/i18n/locales/de/enums.json | 27 -- .../src/i18n/locales/de/event-dashboard.json | 46 +-- .../ui/src/i18n/locales/de/host-common.json | 3 +- .../ui/src/i18n/locales/de/host-exports.json | 3 +- .../ui/src/i18n/locales/de/host-mode.json | 7 - packages/ui/src/i18n/locales/de/host-org.json | 8 +- .../ui/src/i18n/locales/de/host-payments.json | 190 ----------- packages/ui/src/i18n/locales/de/nav.json | 1 - packages/ui/src/i18n/locales/de/settings.json | 6 +- .../ui/src/i18n/locales/en/donation-link.json | 26 ++ .../ui/src/i18n/locales/en/donations.json | 34 -- packages/ui/src/i18n/locales/en/enums.json | 27 -- .../src/i18n/locales/en/event-dashboard.json | 46 +-- .../ui/src/i18n/locales/en/host-common.json | 3 +- .../ui/src/i18n/locales/en/host-exports.json | 3 +- .../ui/src/i18n/locales/en/host-mode.json | 7 - packages/ui/src/i18n/locales/en/host-org.json | 8 +- .../ui/src/i18n/locales/en/host-payments.json | 190 ----------- packages/ui/src/i18n/locales/en/nav.json | 1 - packages/ui/src/i18n/locales/en/settings.json | 6 +- .../ui/src/i18n/locales/es/donation-link.json | 26 ++ .../ui/src/i18n/locales/es/donations.json | 34 -- packages/ui/src/i18n/locales/es/enums.json | 27 -- .../src/i18n/locales/es/event-dashboard.json | 46 +-- .../ui/src/i18n/locales/es/host-common.json | 3 +- .../ui/src/i18n/locales/es/host-exports.json | 3 +- .../ui/src/i18n/locales/es/host-mode.json | 7 - packages/ui/src/i18n/locales/es/host-org.json | 8 +- .../ui/src/i18n/locales/es/host-payments.json | 190 ----------- packages/ui/src/i18n/locales/es/nav.json | 1 - packages/ui/src/i18n/locales/es/settings.json | 6 +- .../ui/src/i18n/locales/ko/donation-link.json | 26 ++ .../ui/src/i18n/locales/ko/donations.json | 34 -- packages/ui/src/i18n/locales/ko/enums.json | 27 -- .../src/i18n/locales/ko/event-dashboard.json | 46 +-- .../ui/src/i18n/locales/ko/host-common.json | 3 +- .../ui/src/i18n/locales/ko/host-exports.json | 3 +- .../ui/src/i18n/locales/ko/host-mode.json | 7 - packages/ui/src/i18n/locales/ko/host-org.json | 8 +- .../ui/src/i18n/locales/ko/host-payments.json | 188 ----------- packages/ui/src/i18n/locales/ko/nav.json | 1 - packages/ui/src/i18n/locales/ko/settings.json | 6 +- packages/ui/src/i18n/resources.ts | 45 +-- .../ui/src/nav/__tests__/hostRoutes.test.ts | 3 - packages/ui/src/nav/routes.ts | 6 - packages/ui/src/nav/types.ts | 2 - packages/ui/src/primitives/DonateBlock.tsx | 94 +++--- .../primitives/__tests__/donationUrl.test.ts | 74 ++++ .../primitives/__tests__/externalUrls.test.ts | 45 +-- .../ui/src/primitives/donateTarget.native.ts | 4 +- .../ui/src/primitives/donateTarget.types.ts | 3 +- .../ui/src/primitives/donateTarget.web.ts | 8 +- packages/ui/src/primitives/donationUrl.ts | 11 + packages/ui/src/primitives/externalUrls.ts | 20 +- packages/ui/src/primitives/index.ts | 5 +- packages/ui/src/shell/BodyRouter.tsx | 3 - packages/ui/src/shell/bodyLayout.ts | 1 - packages/ui/src/shell/bodyRoutes.ts | 2 - packages/ui/src/typography/icon-map.ts | 3 + 108 files changed, 740 insertions(+), 2849 deletions(-) delete mode 100644 apps/community-mobile/app/me/donations.tsx create mode 100644 packages/ui/src/bodies/__tests__/donationLink.test.ts create mode 100644 packages/ui/src/bodies/donationLink.ts delete mode 100644 packages/ui/src/bodies/host/MyDonationsBody.tsx delete mode 100644 packages/ui/src/bodies/host/__tests__/donateGate.test.ts create mode 100644 packages/ui/src/bodies/host/dashboard/DonationLinkRow.tsx delete mode 100644 packages/ui/src/bodies/host/dashboard/MoneySection.tsx delete mode 100644 packages/ui/src/bodies/host/donationFormat.ts create mode 100644 packages/ui/src/bodies/settings/DonationLinkEditor.tsx create mode 100644 packages/ui/src/bodies/settings/__tests__/donationLinkField.test.ts create mode 100644 packages/ui/src/bodies/settings/donationLinkField.ts delete mode 100644 packages/ui/src/data/__tests__/orgPayoutIdempotency.test.ts delete mode 100644 packages/ui/src/data/hooks/donations.ts delete mode 100644 packages/ui/src/data/hooks/payouts.ts create mode 100644 packages/ui/src/i18n/locales/de/donation-link.json delete mode 100644 packages/ui/src/i18n/locales/de/donations.json delete mode 100644 packages/ui/src/i18n/locales/de/host-payments.json create mode 100644 packages/ui/src/i18n/locales/en/donation-link.json delete mode 100644 packages/ui/src/i18n/locales/en/donations.json delete mode 100644 packages/ui/src/i18n/locales/en/host-payments.json create mode 100644 packages/ui/src/i18n/locales/es/donation-link.json delete mode 100644 packages/ui/src/i18n/locales/es/donations.json delete mode 100644 packages/ui/src/i18n/locales/es/host-payments.json create mode 100644 packages/ui/src/i18n/locales/ko/donation-link.json delete mode 100644 packages/ui/src/i18n/locales/ko/donations.json delete mode 100644 packages/ui/src/i18n/locales/ko/host-payments.json create mode 100644 packages/ui/src/primitives/__tests__/donationUrl.test.ts create mode 100644 packages/ui/src/primitives/donationUrl.ts 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/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/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/packages/ui/src/bodies/EventDetailBody.tsx b/packages/ui/src/bodies/EventDetailBody.tsx index 489c967c..bcf60bc1 100644 --- a/packages/ui/src/bodies/EventDetailBody.tsx +++ b/packages/ui/src/bodies/EventDetailBody.tsx @@ -38,7 +38,7 @@ import { managesEvent, useEventQuestions, } from "../data/hooks/host" -import { useOrgDonationPage } from "../data/hooks/donations" +import { donationLinkFor } from "./donationLink" import { useNavStore } from "../nav" import { useHaptics } from "../capabilities" import { useLocale, useRelativeTime, useT, useViewerTimeZone } from "../i18n" @@ -259,9 +259,7 @@ function EventDetailContent({ cleanup }: { cleanup: CleanupDTO }) { useNavStore.getState().push({ kind: "my-ticket", id: cleanup.id, title: cleanup.title }) }, [cleanup.id, cleanup.title]) const hasTicketTypes = cleanup.ticketTypes.length > 0 - const donatePage = useOrgDonationPage(cleanup.donationOrg?.slug, { - enabled: cleanup.donationOrg?.enabled === true, - }) + const donation = useMemo(() => donationLinkFor(cleanup), [cleanup]) const boundaryAt = nextEventBoundaryMs(cleanup, Date.now()) const now = useNow(boundaryAt === null ? 0 : NOW_TICK_MS, { boundaryAt }) useEventBoundaryRefresh(cleanup, now, cleanup.id) @@ -672,18 +670,9 @@ function EventDetailContent({ cleanup }: { cleanup: CleanupDTO }) { - {donatePage.data?.org ? ( + {donation ? ( - + ) : null} diff --git a/packages/ui/src/bodies/PersonDetailBody.tsx b/packages/ui/src/bodies/PersonDetailBody.tsx index a961ae94..cdb905c5 100644 --- a/packages/ui/src/bodies/PersonDetailBody.tsx +++ b/packages/ui/src/bodies/PersonDetailBody.tsx @@ -10,15 +10,16 @@ import { makeThemedStyles, radius, useTheme, wash, focusRingProps, useLayoutMode import { Text, Icon, iconMap } from "../typography" import { Avatar, - MetaDot, - FollowButton, + DonateBlock, EmptyState, + FollowButton, + MetaDot, + PopoverMenu, + ReportContentSheet, SkeletonBlock, SkeletonGroup, SkeletonList, SkeletonText, - ReportContentSheet, - PopoverMenu, usePopoverAnchor, useToast, } from "../primitives" @@ -415,6 +416,8 @@ export function PersonDetailBody({ id, onBack }: { id: string; onBack?: () => vo onOpenConnections={onOpenConnections} /> + + {profile.blockedByMe ? ( {t("blocked.label")} diff --git a/packages/ui/src/bodies/ProfileView.tsx b/packages/ui/src/bodies/ProfileView.tsx index 54dafe1b..340c8989 100644 --- a/packages/ui/src/bodies/ProfileView.tsx +++ b/packages/ui/src/bodies/ProfileView.tsx @@ -13,6 +13,7 @@ import { makeThemedStyles, space, radius, useTheme, noShadow, focusRingProps, he import { Text, Icon, iconMap } from "../typography" import { Avatar, + DonateBlock, SkeletonBlock, SkeletonGroup, SkeletonList, @@ -218,6 +219,8 @@ export function ProfileView({ + + {dashboardSlot ? {dashboardSlot} : null} {actions ? {actions} : null} diff --git a/packages/ui/src/bodies/SettingsAccountBody.tsx b/packages/ui/src/bodies/SettingsAccountBody.tsx index d95e6da8..5241a0dc 100644 --- a/packages/ui/src/bodies/SettingsAccountBody.tsx +++ b/packages/ui/src/bodies/SettingsAccountBody.tsx @@ -33,6 +33,7 @@ import { avatarErrorMessage, uploadAvatar } from "./settings/avatarUpload" import { BioEditor } from "./settings/BioEditor" import { ChangeUsernameEditor } from "./settings/ChangeUsernameEditor" import { DisplayNameEditor } from "./settings/DisplayNameEditor" +import { DonationLinkEditor } from "./settings/DonationLinkEditor" import { PrimaryOrganizationPicker } from "./settings/PrimaryOrganizationPicker" import { SocialLinksEditor } from "./settings/SocialLinksEditor" @@ -139,6 +140,20 @@ export function SettingsAccountBody() { [profile, updateProfile], ) + const onSaveDonationUrl = useCallback( + (donationUrl: string | null): Promise => { + if (!profile) return Promise.resolve() + return updateProfile + .mutateAsync({ + handle: profile.handle ?? "", + displayName: profile.name, + donationUrl, + }) + .then(() => undefined) + }, + [profile, updateProfile], + ) + const onRequestData = useCallback(() => { if (requestMyData.isPending) return requestMyData.mutate() @@ -245,6 +260,11 @@ export function SettingsAccountBody() { saving={updateProfile.isPending} onSave={onSaveSocialLinks} /> + diff --git a/packages/ui/src/bodies/SettingsBody.tsx b/packages/ui/src/bodies/SettingsBody.tsx index 9087c430..a89e8b57 100644 --- a/packages/ui/src/bodies/SettingsBody.tsx +++ b/packages/ui/src/bodies/SettingsBody.tsx @@ -84,12 +84,6 @@ export function SettingsBody() { sub={t("privacy.sub")} onPress={() => pushKind("settings-privacy")} /> - pushKind("my-donations")} - /> )} diff --git a/packages/ui/src/bodies/__tests__/donationLink.test.ts b/packages/ui/src/bodies/__tests__/donationLink.test.ts new file mode 100644 index 00000000..003789ca --- /dev/null +++ b/packages/ui/src/bodies/__tests__/donationLink.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import { donationLinkFor, type DonationLinkSource } from "../donationLink" + +const ORG = { + id: "o1", + slug: "river-keepers", + name: "River Keepers", + verified: true, + donationUrl: "https://give.example.org/river-keepers", +} as DonationLinkSource["organization"] + +function source(over: Partial = {}): DonationLinkSource { + return { + title: "Ballona Creek cleanup", + donationUrl: null, + organization: null, + organizer: { id: "u1", name: "Jane Doe", donationUrl: null } as DonationLinkSource["organizer"], + ...over, + } +} + +describe("donationLinkFor", () => { + it("prefers the event's own link, credited to the organization the event is hosted as", () => { + expect( + donationLinkFor(source({ donationUrl: "https://pay.example.org/creek", organization: ORG })), + ).toEqual({ url: "https://pay.example.org/creek", ownerName: "River Keepers" }) + }) + + it("credits a personal event's own link to the organizer", () => { + expect(donationLinkFor(source({ donationUrl: "https://pay.example.org/creek" }))).toEqual({ + url: "https://pay.example.org/creek", + ownerName: "Jane Doe", + }) + }) + + it("falls back to the organization's link, then the organizer's", () => { + expect(donationLinkFor(source({ organization: ORG }))).toEqual({ + url: "https://give.example.org/river-keepers", + ownerName: "River Keepers", + }) + expect( + donationLinkFor( + source({ + organization: { ...ORG, donationUrl: null } as DonationLinkSource["organization"], + organizer: { + id: "u1", + name: "Jane Doe", + donationUrl: "https://ko-fi.example.org/jane", + } as DonationLinkSource["organizer"], + }), + ), + ).toEqual({ url: "https://ko-fi.example.org/jane", ownerName: "Jane Doe" }) + }) + + it("skips an unsafe link at one level rather than rendering it or hiding the safe one below", () => { + expect( + donationLinkFor(source({ donationUrl: "http://pay.example.org/creek", organization: ORG })), + ).toEqual({ url: "https://give.example.org/river-keepers", ownerName: "River Keepers" }) + }) + + it("renders nothing when no level carries a link", () => { + expect(donationLinkFor(source())).toBeNull() + expect(donationLinkFor(source({ organization: { ...ORG, donationUrl: undefined } as DonationLinkSource["organization"] }))).toBeNull() + }) + + it("uses the event title when neither the organization nor the organizer has a name to show", () => { + expect( + donationLinkFor( + source({ + donationUrl: "https://pay.example.org/creek", + organizer: { id: "u1", name: " ", donationUrl: null } as DonationLinkSource["organizer"], + }), + ), + ).toEqual({ url: "https://pay.example.org/creek", ownerName: "Ballona Creek cleanup" }) + }) +}) diff --git a/packages/ui/src/bodies/__tests__/settingsSurfaces.test.ts b/packages/ui/src/bodies/__tests__/settingsSurfaces.test.ts index 1d9a3eff..7689addc 100644 --- a/packages/ui/src/bodies/__tests__/settingsSurfaces.test.ts +++ b/packages/ui/src/bodies/__tests__/settingsSurfaces.test.ts @@ -8,6 +8,7 @@ const EDITORS = [ "../settings/BioEditor.tsx", "../settings/ChangeUsernameEditor.tsx", "../settings/SocialLinksEditor.tsx", + "../settings/DonationLinkEditor.tsx", ] describe("every Settings > Account editor is a row that opens a modal", () => { @@ -52,6 +53,16 @@ describe("every Settings > Account editor is a row that opens a modal", () => { const social = read("../settings/SocialLinksEditor.tsx") expect(social).toContain("const canSave = !saving && dirty") expect(social).toContain("SocialLinksSchema.safeParse(normalizedLinks(drafts))") + const donation = read("../settings/DonationLinkEditor.tsx") + expect(donation).toContain("const canSave = !saving && valid && dirty") + expect(donation).toContain("donationLinkFieldError(draft)") + }) + + it("clears the donation link to NULL when it is emptied or removed, matching UpdateProfileRequest", () => { + const donation = read("../settings/DonationLinkEditor.tsx") + expect(donation).toContain("commit(normalizeDonationLink(draft))") + expect(donation).toContain("commit(null)") + expect(read("../SettingsAccountBody.tsx")).toContain("donationUrl,") }) it("keeps the handle cooldown a DISABLED ROW - a locked field has no dialog to open", () => { @@ -75,13 +86,14 @@ describe("every Settings > Account editor is a row that opens a modal", () => { expect(read("../SettingsAccountBody.tsx")).toContain("bio: bio.length > 0 ? bio : null") }) - it("mounts all four editors in the identity section", () => { + it("mounts all five editors in the identity section", () => { const account = read("../SettingsAccountBody.tsx") for (const tag of [ " + +export function donationLinkFor(cleanup: DonationLinkSource): DonationLink | null { + const orgName = cleanup.organization?.name.trim() ?? "" + const organizerName = cleanup.organizer.name.trim() + const eventOwner = orgName || organizerName || cleanup.title + const candidates: ReadonlyArray = [ + [cleanup.donationUrl, eventOwner], + [cleanup.organization?.donationUrl, orgName || eventOwner], + [cleanup.organizer.donationUrl, organizerName || eventOwner], + ] + for (const [raw, ownerName] of candidates) { + const url = safeDonationUrl(raw) + if (url) return { url, ownerName } + } + return null +} diff --git a/packages/ui/src/bodies/host/EventDashboardBody.tsx b/packages/ui/src/bodies/host/EventDashboardBody.tsx index b1bf5266..d07a2b26 100644 --- a/packages/ui/src/bodies/host/EventDashboardBody.tsx +++ b/packages/ui/src/bodies/host/EventDashboardBody.tsx @@ -53,7 +53,7 @@ import { FirstEventCard } from "./dashboard/FirstEventCard" import { HostedEventRow } from "./dashboard/HostedEventRow" import { ImpactCard } from "./dashboard/ImpactCard" import { NextUpCard } from "./dashboard/NextUpCard" -import { MoneySection } from "./dashboard/MoneySection" +import { DonationLinkRow } from "./dashboard/DonationLinkRow" import { ATTENTION_MAX_ROWS, attentionRows, @@ -470,12 +470,8 @@ export function EventDashboardBody() { )} - {scope.org ? ( - <> - - - - ) : null} + + {scope.org ? : null} - - - - - - ) -} - export function HostInsightsPanels({ insights, phase, @@ -343,7 +318,6 @@ export function HostInsightsPanels({ {panels.signups ? : null} {panels.byTicketType ? : null} {panels.arrivals ? : null} - {panels.messages ? : null} ) diff --git a/packages/ui/src/bodies/host/MyDonationsBody.tsx b/packages/ui/src/bodies/host/MyDonationsBody.tsx deleted file mode 100644 index d38d7e5b..00000000 --- a/packages/ui/src/bodies/host/MyDonationsBody.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React, { useCallback, useMemo } from "react" -import { View, StyleSheet } from "react-native" -import type { DonationDTO } from "@civfix/shared" -import { makeThemedStyles, useTheme } from "../../theme" -import { Text, TextLink, Icon, iconMap } from "../../typography" -import { SignInPrompt, useToast } from "../../primitives" -import { useOpenExternal } from "../../capabilities" -import { useAuthState, useRequireAuth } from "../../data" -import { donationRows, useMyDonationReceipt, useMyDonations } from "../../data/hooks/donations" -import { useLocale, useT } from "../../i18n" -import { useScrollHost } from "../../shell/ScrollHost" -import { FeedNotice } from "../FeedNotice" -import { formatMoney } from "./donationFormat" - -function DonationRow({ - donation, - locale, - onReceipt, - receiptPending, -}: { - donation: DonationDTO - locale: string - onReceipt: (id: string) => void - receiptPending: boolean -}) { - const styles = useStyles() - const th = useTheme() - const { t } = useT("donations") - const when = donation.chargedAt ?? donation.createdAt - const dateLabel = (() => { - const parsed = new Date(when) - if (Number.isNaN(parsed.getTime())) return "" - try { - return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(parsed) - } catch { - return parsed.toISOString().slice(0, 10) - } - })() - - return ( - - - - {donation.orgLegalName?.trim() || donation.orgName} - - - {[dateLabel, t(`enums:donationStatus.${donation.status}`), donation.eventTitle ?? null] - .filter((part): part is string => !!part && part.length > 0) - .join(" · ")} - - {donation.receiptAvailable ? ( - onReceipt(donation.id)} - accessibilityLabel={t("receipt.download_a11y")} - > - {t("receipt.download")} - - ) : null} - - - {formatMoney(donation.amount, locale)} - {donation.refundedTotalMinor > 0 ? ( - - - - {t("row.refunded", { - amount: formatMoney( - { amountMinor: donation.refundedTotalMinor, currency: donation.amount.currency }, - locale, - ), - })} - - - ) : null} - - - ) -} - -export function MyDonationsBody() { - const styles = useStyles() - const { t } = useT("donations") - const { ScrollView } = useScrollHost() - const { locale } = useLocale() - const toast = useToast() - const openExternal = useOpenExternal() - const requireAuth = useRequireAuth() - const { isAuthenticated, isPending } = useAuthState() - - const query = useMyDonations() - const receipt = useMyDonationReceipt() - const rows = useMemo(() => donationRows(query.data?.pages), [query.data?.pages]) - - const onReceipt = useCallback( - (id: string) => { - receipt.mutate( - { id }, - { - onSuccess: (res) => { - if (!openExternal) { - toast.show(t("receipt.unavailable"), { variant: "error" }) - return - } - void openExternal.open(res.url) - }, - onError: () => toast.show(t("receipt.error"), { variant: "error" }), - }, - ) - }, - [openExternal, receipt, t, toast], - ) - - const { fetchNextPage, hasNextPage, isFetchingNextPage } = query - const loadMore = useCallback(() => { - if (!hasNextPage || isFetchingNextPage) return - void fetchNextPage() - }, [fetchNextPage, hasNextPage, isFetchingNextPage]) - - if (!isAuthenticated && !isPending) { - return ( - - requireAuth(() => {}, { next: "/me/donations" })} - /> - - ) - } - - if (isPending || query.isLoading) { - return ( - - {t("state.loading")} - - ) - } - - if (query.isError) { - return ( - - - - ) - } - - if (rows.length === 0) { - return ( - - - - ) - } - - return ( - - {rows.map((donation) => ( - - ))} - {hasNextPage ? ( - - - {isFetchingNextPage ? t("list.loading_more") : t("list.load_more")} - - - ) : null} - {t("list.footnote")} - - ) -} - -const useStyles = makeThemedStyles((t) => ({ - scroll: { - flex: 1, - }, - fill: { - flex: 1, - }, - content: { - paddingHorizontal: t.space["4"], - paddingTop: t.space["2"], - paddingBottom: t.space["10"], - }, - row: { - flexDirection: "row", - alignItems: "flex-start", - gap: t.space["3"], - paddingVertical: t.space["3"], - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: t.colors.border, - }, - rowMeta: { - flex: 1, - minWidth: 0, - gap: 2, - }, - rowOrg: { - fontFamily: t.fontFamily.bodySemiBold, - fontSize: t.fontSize["14"], - color: t.colors.text, - }, - rowSub: { - fontFamily: t.fontFamily.bodyRegular, - fontSize: t.fontSize["12"], - color: t.colors.textSubtle, - }, - rowAmount: { - alignItems: "flex-end", - gap: 2, - }, - amount: { - fontFamily: t.fontFamily.bodyBold, - fontSize: t.fontSize["15"], - color: t.colors.text, - }, - refundRow: { - flexDirection: "row", - alignItems: "center", - gap: 4, - }, - refund: { - fontFamily: t.fontFamily.bodyRegular, - fontSize: t.fontSize["12"], - color: t.colors.textSubtle, - }, - more: { - alignSelf: "flex-start", - paddingVertical: t.space["3"], - }, - footnote: { - fontFamily: t.fontFamily.bodyRegular, - fontSize: t.fontSize["12"], - lineHeight: 16, - color: t.colors.textSubtle, - marginTop: t.space["3"], - }, - muted: { - fontFamily: t.fontFamily.bodyRegular, - fontSize: t.fontSize["13"], - color: t.colors.textSubtle, - }, -})) diff --git a/packages/ui/src/bodies/host/OrgPageBody.tsx b/packages/ui/src/bodies/host/OrgPageBody.tsx index df0a0215..7e58dcb1 100644 --- a/packages/ui/src/bodies/host/OrgPageBody.tsx +++ b/packages/ui/src/bodies/host/OrgPageBody.tsx @@ -12,7 +12,6 @@ import { useOrganizationEvents, type OrganizationEventsWindow, } from "../../data/hooks/orgs" -import { donationsOffered, useOrgDonationPage } from "../../data/hooks/donations" import { useLocale, useT } from "../../i18n" import { useNavStore } from "../../nav/useNavStore" import { useScrollHost } from "../../shell/ScrollHost" @@ -157,7 +156,6 @@ export function OrgPageBody({ slug }: { slug: string }) { const query = useOrganization(slug) const org = query.data ?? null - const donate = useOrgDonationPage(slug, { enabled: org?.donationsEnabled === true }) const socials = useMemo(() => socialEntries(org?.socialLinks), [org?.socialLinks]) @@ -269,17 +267,7 @@ export function OrgPageBody({ slug }: { slug: string }) { - {donationsOffered(donate.data?.donateState) && donate.data?.org ? ( - - ) : null} + diff --git a/packages/ui/src/bodies/host/__tests__/consentPayload.test.ts b/packages/ui/src/bodies/host/__tests__/consentPayload.test.ts index 0ada8b1a..69455e6d 100644 --- a/packages/ui/src/bodies/host/__tests__/consentPayload.test.ts +++ b/packages/ui/src/bodies/host/__tests__/consentPayload.test.ts @@ -11,12 +11,6 @@ describe("consentPayload", () => { expect(payload.disclosureVersion).toBe(currentVersion("privacy")) }) - it("does NOT point the disclosure at the donation document", () => { - const payload = consentPayload(EMPTY_CONSENT) - expect(payload.disclosureVersion).toBe(currentVersion("privacy")) - expect(currentVersion("privacy")).toBe(currentVersion("donation_disclosure")) - }) - it("ALWAYS carries smsOptIn from state - an express consent is never silently dropped", () => { expect(consentPayload({ terms: true, hostContactOptIn: false, smsOptIn: true }).smsOptIn).toBe(true) expect(consentPayload({ terms: true, hostContactOptIn: false, smsOptIn: false }).smsOptIn).toBe(false) diff --git a/packages/ui/src/bodies/host/__tests__/donateGate.test.ts b/packages/ui/src/bodies/host/__tests__/donateGate.test.ts deleted file mode 100644 index 632157d2..00000000 --- a/packages/ui/src/bodies/host/__tests__/donateGate.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFileSync } from "node:fs" -import { fileURLToPath } from "node:url" -import { join } from "node:path" -import { describe, expect, it } from "vitest" -import { DonateStateSchema } from "@civfix/shared" -import { donationsOffered } from "../../../data/hooks/donations" - -const SRC = fileURLToPath(new URL("../../../", import.meta.url)) -const read = (rel: string) => readFileSync(join(SRC, rel), "utf8") - -describe("donationsOffered", () => { - it("is READY and nothing else", () => { - for (const state of DonateStateSchema.options) { - expect(donationsOffered(state), state).toBe(state === "READY") - } - expect(donationsOffered(null)).toBe(false) - expect(donationsOffered(undefined)).toBe(false) - }) -}) - -describe("DonateBlock's callers pass a REAL donateState", () => { - it("DonateBlock itself gates on READY", () => { - expect(read("primitives/DonateBlock.tsx")).toContain('org.donateState !== "READY"') - }) - - it("EventDetailBody never synthesizes the state from `donationOrg.enabled`", () => { - const detail = read("bodies/EventDetailBody.tsx") - expect(detail).not.toMatch(/donateState:\s*[^,\n]*\?\s*"READY"/) - expect(detail).not.toMatch(/donateState:\s*"READY"/) - expect(detail).toContain("donateState: donatePage.data.donateState") - }) - - it("both callers read the state from the same authoritative public endpoint", () => { - for (const rel of ["bodies/EventDetailBody.tsx", "bodies/host/OrgPageBody.tsx"]) { - expect(read(rel), rel).toMatch(/useOrgDonationPage\(/) - } - }) - - it("neither caller reaches into the donation page's org block before proving it is there", () => { - expect(read("bodies/host/OrgPageBody.tsx")).toContain("donate.data?.org ?") - expect(read("bodies/EventDetailBody.tsx")).toContain("donatePage.data?.org ?") - }) -}) diff --git a/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts b/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts index e141bed9..4d792874 100644 --- a/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts +++ b/packages/ui/src/bodies/host/__tests__/hostModeSurface.test.ts @@ -220,10 +220,6 @@ describe("the panels render only what the phase asked for", () => { expect(panels).toContain("{panels.messages ?") }) - it("hides the money card unless the server sent money", () => { - expect(panels).toContain("if (money === null) return null") - }) - it("hides the ticket-type and sign-up cards when there is nothing to draw", () => { expect(panels).toContain("if (insights.byTicketType.length < 2) return null") expect(panels).toContain("if (points.length < 2) return null") diff --git a/packages/ui/src/bodies/host/__tests__/hostSurfaceModel.test.ts b/packages/ui/src/bodies/host/__tests__/hostSurfaceModel.test.ts index f3629b01..58f86315 100644 --- a/packages/ui/src/bodies/host/__tests__/hostSurfaceModel.test.ts +++ b/packages/ui/src/bodies/host/__tests__/hostSurfaceModel.test.ts @@ -117,7 +117,6 @@ function insights(over: Partial = {}): EventInsights { arrivals: [], hours: { credited: 62.5, attendeesCredited: 25, attendeesCheckedIn: 31 }, topVolunteers: [], - money: null, returning: null, ...over, } @@ -332,21 +331,10 @@ describe("stat tiles per phase", () => { expect(hostStatTiles(insights(), "ended").map((tile) => tile.key)).toContain("not_marked") }) - it("adds money and returning only when the server sent them", () => { - expect(hostStatTiles(insights(), "ended").map((tile) => tile.key)).not.toContain("donations") - const rich = insights({ - money: { - currency: "USD", - donationCount: 18, - grossMinor: 124000, - netMinor: 115100, - refundedMinor: 0, - lastChargedAt: null, - }, - returning: { seats: 9, ofRegistered: 38 }, - }) + it("adds returning only when the server sent it", () => { + expect(hostStatTiles(insights(), "ended").map((tile) => tile.key)).not.toContain("returning") + const rich = insights({ returning: { seats: 9, ofRegistered: 38 } }) const tiles = hostStatTiles(rich, "ended") - expect(tiles.map((tile) => tile.key)).toContain("donations") expect(tiles.find((tile) => tile.key === "returning")).toEqual({ key: "returning", value: 9, diff --git a/packages/ui/src/bodies/host/dashboard/DonationLinkRow.tsx b/packages/ui/src/bodies/host/dashboard/DonationLinkRow.tsx new file mode 100644 index 00000000..fe6994d5 --- /dev/null +++ b/packages/ui/src/bodies/host/dashboard/DonationLinkRow.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from "react" +import type { OrganizationDTO } from "@civfix/shared" +import { IconTile, ListRow, SectionCard } from "../../../primitives" +import { consoleReachable, openConsolePath } from "../../../primitives/consoleReach" +import { donationUrlHost, safeDonationUrl } from "../../../primitives/donationUrl" +import { manageOrgSettingsPath } from "../../../primitives/externalUrls" +import { useOpenExternal } from "../../../capabilities" +import { useMyProfile } from "../../../data" +import { useT } from "../../../i18n" +import { useNavStore } from "../../../nav" + +export interface DonationLinkRowProps { + org: OrganizationDTO | null +} + +export function DonationLinkRow({ org }: DonationLinkRowProps) { + const { t } = useT("donation-link") + const openExternal = useOpenExternal() + const profile = useMyProfile() + + const url = safeDonationUrl(org ? org.donationUrl : profile.data?.profile.donationUrl) + const orgId = org?.id ?? null + + const openOrgSettings = useCallback(() => { + if (!orgId) return + openConsolePath(manageOrgSettingsPath(orgId), openExternal) + }, [openExternal, orgId]) + const openAccountSettings = useCallback(() => { + useNavStore.getState().push({ kind: "settings-account" }) + }, []) + + const onPress = orgId ? (consoleReachable ? openOrgSettings : null) : openAccountSettings + const sub = url + ? t("dashboard.row_set", { host: donationUrlHost(url) }) + : orgId && !consoleReachable + ? t("dashboard.row_org_web") + : t("dashboard.row_unset") + + return ( + + } + title={t("dashboard.row")} + titleLines={1} + sub={sub} + {...(onPress ? { chevron: true, onPress } : {})} + /> + + ) +} diff --git a/packages/ui/src/bodies/host/dashboard/MoneySection.tsx b/packages/ui/src/bodies/host/dashboard/MoneySection.tsx deleted file mode 100644 index 3708ade4..00000000 --- a/packages/ui/src/bodies/host/dashboard/MoneySection.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { useCallback, useMemo, useRef, useState } from "react" -import { View } from "react-native" -import type { OrganizationDTO, PayoutDTO } from "@civfix/shared" -import { makeThemedStyles, useTheme } from "../../../theme" -import { Text, iconMap } from "../../../typography" -import { - IconTile, - LIST_DIVIDER_INSET, - ListRow, - ModalCardSheet, - PopoverMenu, - PrimaryButton, - SecondaryButton, - SectionCard, - useToast, - usePopoverAnchor, -} from "../../../primitives" -import type { AnchorRect } from "../../../primitives" -import { useOpenExternal } from "../../../capabilities" -import { useLocale, useT } from "../../../i18n" -import { - payoutRows, - useCreateOrgPayout, - useCreateOrgStripeAccountLink, - useOrgBalance, - useOrgDonationSummary, - useOrgPaymentsStatus, - useOrgPayouts, -} from "../../../data/hooks/payouts" -import { FeedNotice } from "../../FeedNotice" -import { appErrorCode } from "../../errorCode" -import { RowsSkeleton } from "../HostSkeletons" -import { formatMinor, formatMoney } from "../donationFormat" -import { - canManageOrgPayments, - canViewOrgMoney, - DASHBOARD_RANGES, - DEFAULT_DASHBOARD_RANGE, - donationSummaryFrom, - payoutBlockedKey, - payoutButtonModel, - payoutErrorKey, - type DashboardRange, -} from "./dashboardModel" - -const RECENT_PAYOUTS = 3 - -const CLOSED = "closed" - -function payoutDateLabel(payout: PayoutDTO, locale: string): string { - const parsed = new Date(payout.arrivalDate ?? payout.createdAt) - if (Number.isNaN(parsed.getTime())) return "" - try { - return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(parsed) - } catch { - return parsed.toISOString().slice(0, 10) - } -} - -export interface MoneySectionProps { - org: OrganizationDTO -} - -export function MoneySection({ org }: MoneySectionProps) { - const styles = useStyles() - const th = useTheme() - const { t } = useT("event-dashboard") - const { locale } = useLocale() - const toast = useToast() - const openExternal = useOpenExternal() - - const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE) - const [rangeOpen, setRangeOpen] = useState(CLOSED) - const [rangeRect, setRangeRect] = useState(null) - const { ref: rangeAnchorRef, measure: measureRange } = usePopoverAnchor(setRangeRect) - - const canView = canViewOrgMoney(org.myRole) - const status = useOrgPaymentsStatus(org.id, { enabled: canView }) - const connected = !!status.data?.stripeAccountId - const balance = useOrgBalance(org.id, { enabled: connected }) - const summaryRange = useMemo( - () => ({ from: donationSummaryFrom(range, new Date()) }), - [range], - ) - const summary = useOrgDonationSummary(org.id, summaryRange, { enabled: connected }) - const payouts = useOrgPayouts(org.id, { enabled: connected }) - const createPayout = useCreateOrgPayout(org.id) - const accountLink = useCreateOrgStripeAccountLink(org.id) - - const [confirming, setConfirming] = useState(false) - const sendingRef = useRef(false) - - const canManage = canManageOrgPayments(org.myRole) - const button = payoutButtonModel({ - role: org.myRole, - balance: balance.data ?? null, - pending: createPayout.isPending, - }) - const blockedKey = payoutBlockedKey(button.reason) - - const rows = useMemo(() => payoutRows(payouts.data?.pages).slice(0, RECENT_PAYOUTS), [payouts.data]) - - const onboard = useCallback(() => { - accountLink.mutate( - { type: "onboarding" }, - { - onSuccess: (res) => { - const opener = openExternal?.openInAppBrowser ?? openExternal?.open - if (!opener) { - toast.show(t("money.link_unavailable"), { variant: "error" }) - return - } - void opener(res.url) - }, - onError: () => toast.show(t("money.link_error"), { variant: "error" }), - }, - ) - }, [accountLink, openExternal, t, toast]) - - const askPayout = useCallback(() => { - if (!button.enabled) return - setConfirming(true) - }, [button.enabled]) - - const confirmPayout = useCallback(() => { - if (sendingRef.current) return - sendingRef.current = true - createPayout.mutate( - {}, - { - onSuccess: () => { - setConfirming(false) - toast.show(t("money.payout_started"), { variant: "success" }) - }, - onError: (err) => { - setConfirming(false) - toast.show(t(payoutErrorKey(appErrorCode(err))), { variant: "error" }) - }, - onSettled: () => { - sendingRef.current = false - }, - }, - ) - }, [createPayout, t, toast]) - - if (!canView) return null - - if (status.isPending) return - - if (status.isError) { - return ( - - void status.refetch()} - /> - - ) - } - - if (!connected) { - return ( - - } - title={t("money.connect")} - titleLines={1} - sub={canManage ? t("money.not_connected") : t("money.connect_owner_only")} - {...(canManage ? { chevron: true, onPress: onboard } : {})} - /> - - ) - } - - const payoutSub = blockedKey - ? t(blockedKey) - : !button.visible - ? t("money.payout_owner_only") - : balance.data?.payoutSchedule - ? t(`money.schedule_${balance.data.payoutSchedule.interval}`) - : undefined - - const captions = [ - balance.isError ? t("money.balance_error") : null, - summary.data - ? t("money.donations_total", { - amount: formatMinor(summary.data.netMinor, "USD", locale), - count: summary.data.donationCount, - }) - : null, - summary.isError ? t("money.summary_error") : null, - payouts.isError ? t("money.payouts_error") : null, - ].filter((line): line is string => line !== null) - - const rangePill = ( - - { - measureRange() - setRangeOpen("range") - }} - /> - - ) - - return ( - - - } - title={t("money.available")} - titleLines={1} - trailing={balance.data ? formatMoney(balance.data.available, locale) : t("money.dash")} - /> - } - title={t("money.pending")} - titleLines={1} - trailing={balance.data ? formatMoney(balance.data.pending, locale) : t("money.dash")} - /> - } - title={t("money.payout")} - titleLines={1} - {...(payoutSub ? { sub: payoutSub } : {})} - {...(button.enabled ? { chevron: true, onPress: askPayout } : {})} - /> - {rows.map((payout) => ( - } - title={t("money.sent_on", { date: payoutDateLabel(payout, locale) })} - titleLines={1} - sub={t(`money.payout_status_${payout.status}`)} - trailing={formatMoney(payout.amount, locale)} - /> - ))} - {captions.length > 0 ? ( - - {captions.map((caption) => ( - - {caption} - - ))} - - ) : null} - - - setRangeOpen(CLOSED)} - items={DASHBOARD_RANGES.map((key) => ({ - key, - label: t(`range.${key}`), - ...(key === range ? { icon: "Check" as const } : {}), - onPress: () => { - setRangeOpen(CLOSED) - setRange(key) - }, - }))} - /> - - setConfirming(false)} - onCommit={confirmPayout} - headerIcon="ReceiptText" - headerIconColor={th.colors.moss["700"]} - title={t("money.confirm_title")} - dismissLabel={t("money.confirm_dismiss_a11y")} - backdropDismissDisabled={createPayout.isPending} - actions={ - <> - setConfirming(false)} - size="sm" - disabled={createPayout.isPending} - /> - - - } - > - - {t("money.confirm_body", { - amount: balance.data ? formatMoney(balance.data.available, locale) : t("money.dash"), - })} - - - - ) -} - -const useStyles = makeThemedStyles((t) => ({ - captionRow: { - gap: t.space["1"], - paddingHorizontal: t.space["4"], - paddingVertical: t.space["3"], - }, -})) diff --git a/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts b/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts index a5a93d1c..1a7c37c9 100644 --- a/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts +++ b/packages/ui/src/bodies/host/dashboard/__tests__/dashboardModel.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest" import type { HostedEventDTO, HostedEventsAnalyticsResponse, - OrgBalanceDTO, OrganizationDTO, OrganizationInviteDTO, OrganizationMemberDTO, @@ -13,15 +12,10 @@ import { can } from "@civfix/shared/host" import { ATTENTION_MAX_ROWS, attentionRows, - canManageOrgPayments, canManageOrgTeam, canSetOrgMemberRole, - canViewOrgMoney, collaboratorErrorKey, dashboardScope, - DASHBOARD_RANGES, - DEFAULT_DASHBOARD_RANGE, - donationSummaryFrom, duplicateErrorKey, duplicateReady, firstEventState, @@ -41,9 +35,6 @@ import { orgMemberActions, orgMemberHasActions, pastRowMeta, - payoutBlockedKey, - payoutButtonModel, - payoutErrorKey, pendingOrgInvites, portfolioKpis, sharePathFor, @@ -115,17 +106,6 @@ function invite(id: string, over: Partial = {}): Organiza } as OrganizationInviteDTO } -function balance(over: Partial = {}): OrgBalanceDTO { - return { - available: { amountMinor: 5000, currency: "USD" }, - pending: { amountMinor: 200, currency: "USD" }, - payoutsEnabled: true, - payoutSchedule: { interval: "manual" }, - lastSyncedAt: "2026-09-10T00:00:00.000Z", - ...over, - } as OrgBalanceDTO -} - describe("dashboard scope", () => { it("hides the selector and stays personal when the viewer is in no org", () => { const scope = dashboardScope([], "o1") @@ -233,63 +213,6 @@ describe("org role gating", () => { expect(canManageOrgTeam(null)).toBe(false) }) - it("lets owners and admins see money but only owners move it", () => { - expect(canViewOrgMoney("owner")).toBe(true) - expect(canViewOrgMoney("admin")).toBe(true) - expect(canViewOrgMoney("member")).toBe(false) - expect(canManageOrgPayments("owner")).toBe(true) - expect(canManageOrgPayments("admin")).toBe(false) - }) -}) - -describe("payout button", () => { - it("stays hidden for an admin, who is read-only on money", () => { - expect(payoutButtonModel({ role: "admin", balance: balance(), pending: false })).toEqual({ - visible: false, - enabled: false, - reason: "role", - }) - }) - - it("is enabled for an owner with an available balance", () => { - expect(payoutButtonModel({ role: "owner", balance: balance(), pending: false })).toEqual({ - visible: true, - enabled: true, - reason: null, - }) - }) - - it("explains itself when Stripe has not enabled payouts", () => { - const model = payoutButtonModel({ - role: "owner", - balance: balance({ payoutsEnabled: false }), - pending: false, - }) - expect(model).toEqual({ visible: true, enabled: false, reason: "payouts_disabled" }) - expect(payoutBlockedKey(model.reason)).toBe("money.payout_blocked_disabled") - }) - - it("explains itself when nothing is available", () => { - const model = payoutButtonModel({ - role: "owner", - balance: balance({ available: { amountMinor: 0, currency: "USD" } }), - pending: false, - }) - expect(model).toEqual({ visible: true, enabled: false, reason: "no_balance" }) - expect(payoutBlockedKey(model.reason)).toBe("money.payout_blocked_empty") - }) - - it("blocks a second press while one payout is in flight", () => { - expect(payoutButtonModel({ role: "owner", balance: balance(), pending: true }).enabled).toBe( - false, - ) - }) - - it("shows no blocked explanation while the balance is still loading", () => { - const model = payoutButtonModel({ role: "owner", balance: null, pending: false }) - expect(model).toEqual({ visible: true, enabled: false, reason: null }) - expect(payoutBlockedKey(model.reason)).toBeNull() - }) }) describe("collaborator actions", () => { @@ -428,28 +351,14 @@ describe("duplicate scheduling", () => { }) }) -describe("donation summary range", () => { - it("turns each range chip into a from-date", () => { - const now = new Date("2026-09-10T00:00:00.000Z") - expect(donationSummaryFrom("30d", now)).toBe("2026-08-11T00:00:00.000Z") - expect(donationSummaryFrom("365d", now)).toBe("2025-09-10T00:00:00.000Z") - }) - - it("keeps three money ranges and defaults to the shortest", () => { - expect(DEFAULT_DASHBOARD_RANGE).toBe("30d") - expect(DASHBOARD_RANGES).toEqual(["30d", "90d", "365d"]) - }) -}) - describe("error copy", () => { const en = catalog("en", "event-dashboard") as { events: Record - money: Record team: Record } const leaf = (key: string): string | undefined => { - const [section, rest] = key.split(".") as ["events" | "money" | "team", string] + const [section, rest] = key.split(".") as ["events" | "team", string] return en[section]?.[rest] } @@ -459,12 +368,6 @@ describe("error copy", () => { } }) - it("maps every payout error code to real copy", () => { - for (const code of ["VALIDATION", "CONFLICT", "FORBIDDEN", "RATE_LIMITED", undefined]) { - expect(leaf(payoutErrorKey(code))).toBeTruthy() - } - }) - it("maps every collaborator and invite error code to real copy", () => { for (const code of ["CONFLICT", "FORBIDDEN", "NOT_FOUND", undefined]) { expect(leaf(collaboratorErrorKey(code))).toBeTruthy() @@ -508,12 +411,6 @@ describe("dashboard wiring", () => { expect(body).toContain("openHostDashboard") }) - it("opens the Stripe link through the injected capability, never a raw window call", () => { - const money = source("../MoneySection.tsx") - expect(money).toContain("openExternal?.openInAppBrowser ?? openExternal?.open") - expect(money).not.toContain("window.") - }) - it("renders the console link only on web", () => { expect(source("../ConsoleLinkRow.native.tsx")).toContain("return null") expect(source("../ConsoleLinkRow.web.tsx")).toContain("manageOrgPath") @@ -884,7 +781,7 @@ describe("portfolio surface", () => { "../FirstEventCard.tsx", "../HostedEventRow.tsx", "../InviteRows.tsx", - "../MoneySection.tsx", + "../DonationLinkRow.tsx", "../CollaboratorsSection.tsx", "../OrgInviteSheet.tsx", "../DuplicateEventSheet.tsx", @@ -944,11 +841,11 @@ describe("portfolio surface", () => { expect(invites).not.toContain('justifyContent: "flex-end"') }) - it("keeps money, team and the console link in the shared list card", () => { - const money = dashboardSource("MoneySection.tsx") - expect(money).toContain('variant="list"') - expect(money).toContain('icon="ReceiptText"') - expect(money).not.toContain("StatTileRow") + it("keeps the donation link, team and the console link in the shared list card", () => { + const donation = dashboardSource("DonationLinkRow.tsx") + expect(donation).toContain('variant="list"') + expect(donation).toContain('icon="HandHeart"') + expect(donation).toContain(" { ["events", "check_in_a11y"], ["first_event", "title"], ["first_event", "cta"], - ["money", "range_a11y"], - ["money", "sent_on"], ["team", "section"], ["team", "invite_row_sub"], ] diff --git a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts index 73c75db5..306a92dd 100644 --- a/packages/ui/src/bodies/host/dashboard/dashboardModel.ts +++ b/packages/ui/src/bodies/host/dashboard/dashboardModel.ts @@ -6,7 +6,6 @@ import type { HostPortfolioKpis, HostedEventsAnalyticsResponse, ListMyHostedEventsResponse, - OrgBalanceDTO, OrganizationDTO, OrganizationInviteDTO, OrganizationMemberDTO, @@ -29,21 +28,10 @@ import { import { addWallClockDays, formInstantMs } from "../../calendarModel" import { viewerTimeZone } from "../../../i18n" -export const DASHBOARD_RANGES = ["30d", "90d", "365d"] as const -export type DashboardRange = (typeof DASHBOARD_RANGES)[number] - -export const DEFAULT_DASHBOARD_RANGE: DashboardRange = "30d" - export const ATTENTION_MAX_ROWS = 3 const DAY_MS = 86_400_000 -const RANGE_DAYS: Readonly> = { - "30d": 30, - "90d": 90, - "365d": 365, -} - export interface DashboardScope { orgId: string | null org: OrganizationDTO | null @@ -124,49 +112,6 @@ export function canSetOrgMemberRole(role: OrganizationMemberRole | null | undefi return role === "owner" } -export function canViewOrgMoney(role: OrganizationMemberRole | null | undefined): boolean { - return orgRoleCan(role, "view_donations") -} - -export function canManageOrgPayments(role: OrganizationMemberRole | null | undefined): boolean { - return orgRoleCan(role, "manage_payments") -} - -export type PayoutBlockReason = "role" | "payouts_disabled" | "no_balance" | null - -export interface PayoutButtonInput { - role: OrganizationMemberRole | null | undefined - balance: OrgBalanceDTO | null | undefined - pending: boolean -} - -export interface PayoutButtonModel { - visible: boolean - enabled: boolean - reason: PayoutBlockReason -} - -export function payoutButtonModel(input: PayoutButtonInput): PayoutButtonModel { - const { role, balance, pending } = input - if (!canManageOrgPayments(role)) { - return { visible: false, enabled: false, reason: "role" } - } - if (!balance) return { visible: true, enabled: false, reason: null } - if (!balance.payoutsEnabled) { - return { visible: true, enabled: false, reason: "payouts_disabled" } - } - if (balance.available.amountMinor <= 0) { - return { visible: true, enabled: false, reason: "no_balance" } - } - return { visible: true, enabled: !pending, reason: null } -} - -export function payoutBlockedKey(reason: PayoutBlockReason): string | null { - if (reason === "payouts_disabled") return "money.payout_blocked_disabled" - if (reason === "no_balance") return "money.payout_blocked_empty" - return null -} - export function portfolioKpis( pages: readonly ListMyHostedEventsResponse[] | undefined, ): HostPortfolioKpis | null { @@ -377,16 +322,6 @@ export function orgMemberHasActions(actions: OrgMemberActions): boolean { return actions.roles.length > 0 || actions.canRemove } -/** - * The start of the donation window, floored to the UTC day so the value - and the cache key built - * from it - is stable for every mount within the same day rather than minting a fresh key per render. - */ -export function donationSummaryFrom(range: DashboardRange, now: Date): string { - const start = new Date(now.getTime() - RANGE_DAYS[range] * DAY_MS) - start.setUTCHours(0, 0, 0, 0) - return start.toISOString() -} - export interface DuplicateStartSeed { instantMs: number wallClock: WallClock @@ -438,14 +373,6 @@ export function duplicateErrorKey(code: string | undefined): string { return "events.duplicate_error_generic" } -export function payoutErrorKey(code: string | undefined): string { - if (code === "VALIDATION") return "money.payout_error_invalid" - if (code === "CONFLICT") return "money.payout_error_conflict" - if (code === "FORBIDDEN") return "money.payout_error_forbidden" - if (code === "RATE_LIMITED") return "money.payout_error_rate_limited" - return "money.payout_error_generic" -} - export function orgInviteIdentifierErrorKey(kind: OrgInviteIdentifierKind): string { return kind === "email" ? "team.invite_email_invalid" : "team.invite_handle_invalid" } diff --git a/packages/ui/src/bodies/host/donationFormat.ts b/packages/ui/src/bodies/host/donationFormat.ts deleted file mode 100644 index f88e6b52..00000000 --- a/packages/ui/src/bodies/host/donationFormat.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { MoneyDTO } from "@civfix/shared" - -export function formatMoney(money: MoneyDTO, locale?: string): string { - const amount = money.amountMinor / 100 - try { - return new Intl.NumberFormat(locale, { - style: "currency", - currency: money.currency, - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount) - } catch { - return `${money.currency} ${amount.toFixed(2)}` - } -} - -export function formatMinor(amountMinor: number, currency: "USD" = "USD", locale?: string): string { - return formatMoney({ amountMinor, currency }, locale) -} diff --git a/packages/ui/src/bodies/host/hostSurfaceModel.ts b/packages/ui/src/bodies/host/hostSurfaceModel.ts index aae8d531..e634859d 100644 --- a/packages/ui/src/bodies/host/hostSurfaceModel.ts +++ b/packages/ui/src/bodies/host/hostSurfaceModel.ts @@ -37,7 +37,6 @@ export type HostTileKey = | "no_shows" | "not_marked" | "hours" - | "donations" | "returning" export type HostCardKey = "grow" | "communicate" | "operate" | "configure" | "danger" @@ -257,7 +256,6 @@ export function hostStatTiles(insights: EventInsights, phase: EventPhase): HostT push("no_shows", seats.noShow) if (seats.unmarked > 0) push("not_marked", seats.unmarked) push("hours", insights.hours.credited, insights.hours.attendeesCheckedIn) - if (insights.money !== null) push("donations", insights.money.netMinor) if (insights.returning !== null) { push("returning", insights.returning.seats, insights.returning.ofRegistered) } diff --git a/packages/ui/src/bodies/host/index.ts b/packages/ui/src/bodies/host/index.ts index 97e26c48..a60df995 100644 --- a/packages/ui/src/bodies/host/index.ts +++ b/packages/ui/src/bodies/host/index.ts @@ -5,7 +5,6 @@ export { HostTeamBody } from "./HostTeamBody" export { HostLogHoursBody } from "./HostLogHoursBody" export { MyTicketBody } from "./MyTicketBody" export { OrgPageBody } from "./OrgPageBody" -export { MyDonationsBody } from "./MyDonationsBody" export { EventDashboardBody } from "./EventDashboardBody" export { PhaseHeader, PhaseDot } from "./PhaseHeader" @@ -80,7 +79,6 @@ export { } from "./broadcastQuickModel" export type { QuickSegmentKind } from "./broadcastQuickModel" export { formatTicketCode, ticketWhen, ticketWhere, ticketSeatCount } from "./ticketModel" -export { formatMoney, formatMinor } from "./donationFormat" export { RegistrationBlock } from "./registration/RegistrationBlock" export type { RegistrationBlockProps } from "./registration/RegistrationBlock" diff --git a/packages/ui/src/bodies/index.ts b/packages/ui/src/bodies/index.ts index 54274065..fcfba5de 100644 --- a/packages/ui/src/bodies/index.ts +++ b/packages/ui/src/bodies/index.ts @@ -19,6 +19,17 @@ export { } from "./onboardingTour" export type { OnboardingTourPresenter } from "./onboardingTour" export { SettingsAccountBody } from "./SettingsAccountBody" +export { DonationLinkEditor } from "./settings/DonationLinkEditor" +export type { DonationLinkEditorProps } from "./settings/DonationLinkEditor" +export { + DONATION_LINK_MAX_LENGTH, + donationLinkDirty, + donationLinkFieldError, + normalizeDonationLink, +} from "./settings/donationLinkField" +export type { DonationLinkFieldError } from "./settings/donationLinkField" +export { donationLinkFor } from "./donationLink" +export type { DonationLink, DonationLinkSource } from "./donationLink" export { SettingsPrivacyBody } from "./SettingsPrivacyBody" export { ConnectionsBody } from "./ConnectionsBody" diff --git a/packages/ui/src/bodies/settings/DonationLinkEditor.tsx b/packages/ui/src/bodies/settings/DonationLinkEditor.tsx new file mode 100644 index 00000000..c389c29a --- /dev/null +++ b/packages/ui/src/bodies/settings/DonationLinkEditor.tsx @@ -0,0 +1,115 @@ +import React, { useState } from "react" +import { Text, TextLink } from "../../typography" +import { + ModalCardSheet, + PrimaryButton, + SecondaryButton, + SettingsRow, + TextField, +} from "../../primitives" +import { donationUrlHost, safeDonationUrl } from "../../primitives/donationUrl" +import { useT } from "../../i18n" +import { useEditorStyles } from "./editorStyles" +import { + DONATION_LINK_MAX_LENGTH, + donationLinkDirty, + donationLinkFieldError, + normalizeDonationLink, +} from "./donationLinkField" + +export interface DonationLinkEditorProps { + currentUrl: string | null | undefined + saving?: boolean + onSave: (url: string | null) => Promise +} + +export function DonationLinkEditor({ currentUrl, saving, onSave }: DonationLinkEditorProps) { + const styles = useEditorStyles() + const { t } = useT("donation-link") + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(currentUrl ?? "") + const [submitError, setSubmitError] = useState(null) + + const saved = currentUrl ?? null + const savedSafe = safeDonationUrl(saved) + const fieldError = donationLinkFieldError(draft) + const valid = fieldError === null + const dirty = donationLinkDirty(draft, saved) + const canSave = !saving && valid && dirty + + const begin = () => { + setDraft(currentUrl ?? "") + setSubmitError(null) + setEditing(true) + } + const cancel = () => { + setSubmitError(null) + setEditing(false) + } + const commit = (url: string | null) => { + setSubmitError(null) + void onSave(url) + .then(() => setEditing(false)) + .catch(() => setSubmitError(t("editor.error"))) + } + const save = () => { + if (!canSave) return + commit(normalizeDonationLink(draft)) + } + const remove = () => { + if (saving || saved === null) return + commit(null) + } + + return ( + <> + + + + + + } + > + {t("editor.hint")} + + {fieldError ? ( + {t("editor.invalid")} + ) : null} + {saved !== null ? ( + + {t("editor.remove")} + + ) : null} + + + ) +} diff --git a/packages/ui/src/bodies/settings/__tests__/donationLinkField.test.ts b/packages/ui/src/bodies/settings/__tests__/donationLinkField.test.ts new file mode 100644 index 00000000..4e2f6fe1 --- /dev/null +++ b/packages/ui/src/bodies/settings/__tests__/donationLinkField.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" +import { HttpsUrlSchema } from "@civfix/shared" +import { + DONATION_LINK_MAX_LENGTH, + donationLinkDirty, + donationLinkFieldError, + normalizeDonationLink, +} from "../donationLinkField" + +describe("the donation-link field rule", () => { + it("treats blank as 'no link', which saves as null", () => { + expect(normalizeDonationLink(" ")).toBeNull() + expect(donationLinkFieldError(" ")).toBeNull() + }) + + it("accepts a full https link and trims it", () => { + expect(normalizeDonationLink(" https://give.example.org/x ")).toBe("https://give.example.org/x") + expect(donationLinkFieldError(" https://give.example.org/x ")).toBeNull() + }) + + it("flags anything that does not start with https:// or is not a safe link", () => { + for (const raw of [ + "http://give.example.org", + "give.example.org", + "ftp://x", + "https://", + "https://10.0.0.1/x", + "https://not a url", + ]) { + expect(donationLinkFieldError(raw), raw).toBe("invalid") + } + }) + + it("is dirty only when the normalized value differs from what is saved", () => { + expect(donationLinkDirty(" https://a.org ", "https://a.org")).toBe(false) + expect(donationLinkDirty("", null)).toBe(false) + expect(donationLinkDirty("", "https://a.org")).toBe(true) + expect(donationLinkDirty("https://b.org", "https://a.org")).toBe(true) + }) + + it("caps the field at the contract's own maximum", () => { + const base = "https://give.example.org/" + const atMax = base + "a".repeat(DONATION_LINK_MAX_LENGTH - base.length) + expect(HttpsUrlSchema.safeParse(atMax).success).toBe(true) + expect(HttpsUrlSchema.safeParse(`${atMax}a`).success).toBe(false) + }) +}) diff --git a/packages/ui/src/bodies/settings/donationLinkField.ts b/packages/ui/src/bodies/settings/donationLinkField.ts new file mode 100644 index 00000000..28dd29e1 --- /dev/null +++ b/packages/ui/src/bodies/settings/donationLinkField.ts @@ -0,0 +1,22 @@ +import { HttpsUrlSchema } from "@civfix/shared" +import { safeDonationUrl } from "../../primitives/donationUrl" + +export const DONATION_LINK_MAX_LENGTH = 500 + +export type DonationLinkFieldError = "invalid" | null + +export function normalizeDonationLink(raw: string): string | null { + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : null +} + +export function donationLinkFieldError(raw: string): DonationLinkFieldError { + const value = normalizeDonationLink(raw) + if (value === null) return null + if (!HttpsUrlSchema.safeParse(value).success) return "invalid" + return safeDonationUrl(value) === null ? "invalid" : null +} + +export function donationLinkDirty(raw: string, saved: string | null | undefined): boolean { + return normalizeDonationLink(raw) !== (saved ?? null) +} diff --git a/packages/ui/src/data/__tests__/event-ics-and-exports.test.ts b/packages/ui/src/data/__tests__/event-ics-and-exports.test.ts index 5e9bc894..e859fe16 100644 --- a/packages/ui/src/data/__tests__/event-ics-and-exports.test.ts +++ b/packages/ui/src/data/__tests__/event-ics-and-exports.test.ts @@ -1,22 +1,8 @@ import { describe, expect, it, vi } from "vitest" -import type { HostExportDTO, HostExportStatus } from "@civfix/shared" import { queryKeys } from "../keys" import { invalidationKeysForTopic } from "../signals" -import { ORG_DONATION_EXPORTS_POLL_MS, exportsPollInterval } from "../hooks/orgs" import { fetchEventIcs } from "../eventIcs" -function row(status: HostExportStatus): HostExportDTO { - return { - id: `x-${status}`, - cleanupId: null, - organizationId: "o1", - kind: "donations", - status, - truncated: false, - requestedAt: "2026-05-01T10:00:00.000Z", - } -} - describe("queryKeys.eventIcs", () => { it("is a CHILD of the event, so a reschedule invalidates the calendar file with it", () => { const prefix = queryKeys.cleanup("e1") @@ -28,40 +14,6 @@ describe("queryKeys.eventIcs", () => { }) }) -describe("queryKeys.orgDonationExports", () => { - it("sits on a first segment NEITHER host persists - not the org namespaces mobile writes to disk", () => { - expect(queryKeys.orgDonationExports("o1")).toEqual(["host-exports", "org", "o1"]) - const head = queryKeys.orgDonationExports("o1")[0] - expect(["org", "orgs", "cleanup", "cleanups", "profile", "host", "notifications", "threads"]).not.toContain(head) - }) - - it("keeps two organizations' ledgers apart", () => { - expect(queryKeys.orgDonationExports("o1")).not.toEqual(queryKeys.orgDonationExports("o2")) - }) -}) - -describe("exportsPollInterval", () => { - it("polls while a row is still being built", () => { - expect(exportsPollInterval([row("queued")], 3000, false)).toBe(3000) - expect(exportsPollInterval([row("ready"), row("running")], 3000, false)).toBe(3000) - }) - - it("stops the moment nothing is unfinished", () => { - expect(exportsPollInterval([row("ready")], 3000, false)).toBe(false) - expect(exportsPollInterval([row("failed"), row("expired")], 3000, false)).toBe(false) - expect(exportsPollInterval([], 3000, false)).toBe(false) - expect(exportsPollInterval(undefined, 3000, false)).toBe(false) - }) - - it("stops on a FAILED read even with an unfinished row cached, so it cannot hammer a failing endpoint", () => { - expect(exportsPollInterval([row("queued")], 3000, true)).toBe(false) - }) - - it("ships a poll interval that is polite to a per-identity budget", () => { - expect(ORG_DONATION_EXPORTS_POLL_MS).toBeGreaterThanOrEqual(2000) - }) -}) - describe("the host realtime topic does not pretend to cover exports", () => { it("still resolves to the event subtree only - nothing publishes a signal when an export is ready", () => { expect(invalidationKeysForTopic("host", undefined, "e1")).toEqual([queryKeys.hostEvent("e1")]) diff --git a/packages/ui/src/data/__tests__/orgPayoutIdempotency.test.ts b/packages/ui/src/data/__tests__/orgPayoutIdempotency.test.ts deleted file mode 100644 index e8c108a9..00000000 --- a/packages/ui/src/data/__tests__/orgPayoutIdempotency.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - payoutIdempotencyKey, - payoutIntent, - releasePayoutIntent, -} from "../hooks/payouts" - -describe("a payout idempotency key is bound to the payout it authorizes", () => { - it("hands the SAME key back while the org and amount are unchanged", () => { - const intent = payoutIntent("org_a", 5000) - const first = payoutIdempotencyKey(intent) - - expect(payoutIdempotencyKey(intent)).toBe(first) - expect(payoutIdempotencyKey(payoutIntent("org_a", 5000))).toBe(first) - - releasePayoutIntent(intent) - }) - - it("mints a DIFFERENT key once the amount changes, so a corrected retry is its own payout", () => { - const five = payoutIntent("org_b", 5000) - const other = payoutIntent("org_b", 500) - - expect(payoutIdempotencyKey(five)).not.toBe(payoutIdempotencyKey(other)) - - releasePayoutIntent(five) - releasePayoutIntent(other) - }) - - it("keeps orgs apart, and reads a missing amount as the whole available balance", () => { - expect(payoutIntent("org_c", null)).toBe("org_c:available") - expect(payoutIntent("org_c", undefined)).toBe("org_c:available") - expect(payoutIntent("org_c", null)).not.toBe(payoutIntent("org_d", null)) - }) - - it("releases the key only after the payout lands, so the next one is genuinely new", () => { - const intent = payoutIntent("org_e", 2500) - const first = payoutIdempotencyKey(intent) - - releasePayoutIntent(intent) - - expect(payoutIdempotencyKey(intent)).not.toBe(first) - releasePayoutIntent(intent) - }) -}) diff --git a/packages/ui/src/data/context.tsx b/packages/ui/src/data/context.tsx index 5bef80a1..d77acfab 100644 --- a/packages/ui/src/data/context.tsx +++ b/packages/ui/src/data/context.tsx @@ -8,9 +8,8 @@ * selector hooks so a body imports exactly the slice it needs (the api client, the auth state, the * requireAuth gate, or logout) rather than the whole bundle. */ -import React, { createContext, useCallback, useContext } from "react" +import React, { createContext, useContext } from "react" import type { ApiClient } from "@civfix/shared/client" -import { clearPayoutIntents } from "./hooks/payouts" import type { AuthState, ChatSocketLike, DataContextValue } from "./types" const DataContext = createContext(null) @@ -66,16 +65,9 @@ export function useRequireAuth(): DataContextValue["requireAuth"] { /** * Sign the viewer out (host owns any cache teardown). - * - * The shared module state the host's query-cache purge cannot reach is dropped here: a held payout - * idempotency key must never outlive the session that authorized it. */ export function useLogout(): DataContextValue["logout"] { - const logout = useDataContext().logout - return useCallback(() => { - clearPayoutIntents() - return logout() - }, [logout]) + return useDataContext().logout } /** diff --git a/packages/ui/src/data/hooks/donations.ts b/packages/ui/src/data/hooks/donations.ts deleted file mode 100644 index c57eb537..00000000 --- a/packages/ui/src/data/hooks/donations.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" -import type { - DonateState, - DonationDTO, - DonationPageDTO, - GetMyDonationReceiptResponse, - ListMyDonationsResponse, -} from "@civfix/shared" -import { useApi, useAuthState } from "../context" -import { queryKeys } from "../keys" - -export function donationsOffered(state: DonateState | null | undefined): boolean { - return state === "READY" -} - -export function useMyDonations(opts: { enabled?: boolean } = {}) { - const api = useApi() - const { isAuthenticated } = useAuthState() - return useInfiniteQuery({ - queryKey: queryKeys.myDonations, - enabled: isAuthenticated && (opts.enabled ?? true), - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }) => - api.listMyDonations({ ...(typeof pageParam === "string" ? { cursor: pageParam } : {}) }), - getNextPageParam: (lastPage: ListMyDonationsResponse) => lastPage.nextCursor ?? undefined, - staleTime: 10_000, - retry: false, - }) -} - -export function donationRows( - pages: readonly ListMyDonationsResponse[] | undefined, -): DonationDTO[] { - return (pages ?? []).flatMap((page) => page.items) -} - -export function useMyDonationReceipt() { - const api = useApi() - return useMutation({ - mutationFn: ({ id }) => api.getMyDonationReceipt({ id }), - gcTime: 0, - }) -} - -export function useOrgDonationPage(slug: string | undefined, opts: { enabled?: boolean } = {}) { - const api = useApi() - return useQuery({ - queryKey: queryKeys.orgDonate(slug ?? "unknown"), - enabled: !!slug && (opts.enabled ?? true), - queryFn: () => api.getPublicOrgDonationPage({ slug: slug as string }), - retry: false, - }) -} diff --git a/packages/ui/src/data/hooks/index.ts b/packages/ui/src/data/hooks/index.ts index 72e495f4..f88894a5 100644 --- a/packages/ui/src/data/hooks/index.ts +++ b/packages/ui/src/data/hooks/index.ts @@ -145,9 +145,7 @@ export { export type { FeedFilter, CreatePostVars } from "./posts" export { - ORG_DONATION_EXPORTS_POLL_MS, ORG_MEMBERS_PAGE_SIZE, - exportsPollInterval, actableOrganizations, invalidateMyOrgInvites, organizationEventRows, @@ -161,7 +159,6 @@ export { useOrganizationEvents, useOrganizationInvites, useOrganizationMembers, - useOrgDonationExports, useRemoveOrganizationMember, useRevokeOrganizationInvite, useSetOrganizationMemberRole, @@ -174,19 +171,3 @@ export type { export { useHostedEventsAnalytics } from "./dashboard" -export { - ORG_PAYOUTS_PAGE_SIZE, - clearPayoutIntents, - payoutRows, - useCreateOrgPayout, - useCreateOrgStripeAccountLink, - useOrgBalance, - useOrgDonationSummary, - useOrgPaymentsStatus, - useOrgPayouts, -} from "./payouts" -export type { - CreateOrgPayoutVars, - OrgDonationSummaryRange, - OrgStripeAccountLinkKind, -} from "./payouts" diff --git a/packages/ui/src/data/hooks/orgs.ts b/packages/ui/src/data/hooks/orgs.ts index 13e7f513..722d39fb 100644 --- a/packages/ui/src/data/hooks/orgs.ts +++ b/packages/ui/src/data/hooks/orgs.ts @@ -9,7 +9,6 @@ import type { AcceptMyOrgInviteResponse, CleanupDTO, DeclineMyOrgInviteResponse, - HostExportDTO, InviteOrganizationMemberResponse, ListOrganizationEventsResponse, ListOrganizationMembersResponse, @@ -26,20 +25,6 @@ import type { import { useApi, useAuthState } from "../context" import { queryKeys } from "../keys" -export const ORG_DONATION_EXPORTS_POLL_MS = 3000 - -export function exportsPollInterval( - rows: readonly HostExportDTO[] | undefined, - pollMs: number, - failed: boolean, -): number | false { - if (failed) return false - const unfinished = (rows ?? []).some( - (row) => row.status === "queued" || row.status === "running", - ) - return unfinished ? pollMs : false -} - export function useOrganization(slug: string | undefined) { const api = useApi() return useQuery({ @@ -75,23 +60,6 @@ export function actableOrganizations( return orgs?.filter((org) => org.suspended !== true) } -export function useOrgDonationExports( - orgId: string | undefined, - opts: { enabled?: boolean; pollMs?: number } = {}, -) { - const api = useApi() - const { isAuthenticated } = useAuthState() - const pollMs = opts.pollMs ?? ORG_DONATION_EXPORTS_POLL_MS - return useQuery({ - queryKey: queryKeys.orgDonationExports(orgId ?? "unknown"), - enabled: !!orgId && isAuthenticated && (opts.enabled ?? true), - queryFn: async () => (await api.listOrgDonationExports({ id: orgId as string })).items, - refetchInterval: (query) => - exportsPollInterval(query.state.data, pollMs, query.state.status === "error"), - retry: false, - }) -} - export type OrganizationEventsWindow = "upcoming" | "past" export function useOrganizationEvents( diff --git a/packages/ui/src/data/hooks/payouts.ts b/packages/ui/src/data/hooks/payouts.ts deleted file mode 100644 index b7f3491e..00000000 --- a/packages/ui/src/data/hooks/payouts.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import type { - CreateOrgPayoutResponse, - CreateOrgStripeAccountLinkResponse, - ListOrgPayoutsResponse, - OrgBalanceDTO, - OrgDonationSummaryDTO, - OrgPaymentsStatusDTO, - PayoutDTO, -} from "@civfix/shared" -import { useApi, useAuthState } from "../context" -import { queryKeys } from "../keys" -import { randomId } from "../randomId" -import { appErrorCode } from "../../bodies/errorCode" - -export interface OrgDonationSummaryRange { - from?: string - to?: string -} - -export function useOrgPaymentsStatus(orgId: string | undefined, opts: { enabled?: boolean } = {}) { - const api = useApi() - const { isAuthenticated } = useAuthState() - return useQuery({ - queryKey: queryKeys.orgPaymentsStatus(orgId ?? "unknown"), - enabled: !!orgId && isAuthenticated && (opts.enabled ?? true), - queryFn: () => api.getOrgPaymentsStatus({ id: orgId as string }), - retry: false, - }) -} - -export function useOrgDonationSummary( - orgId: string | undefined, - range: OrgDonationSummaryRange = {}, - opts: { enabled?: boolean } = {}, -) { - const api = useApi() - const { isAuthenticated } = useAuthState() - return useQuery({ - queryKey: queryKeys.orgDonationSummary(orgId ?? "unknown", range.from ?? "all", range.to ?? "now"), - enabled: !!orgId && isAuthenticated && (opts.enabled ?? true), - queryFn: () => - api.getOrgDonationSummary({ - id: orgId as string, - ...(range.from ? { from: range.from } : {}), - ...(range.to ? { to: range.to } : {}), - }), - retry: false, - }) -} - -export function useOrgBalance(orgId: string | undefined, opts: { enabled?: boolean } = {}) { - const api = useApi() - const { isAuthenticated } = useAuthState() - return useQuery({ - queryKey: queryKeys.orgBalance(orgId ?? "unknown"), - enabled: !!orgId && isAuthenticated && (opts.enabled ?? true), - queryFn: () => api.getOrgBalance({ id: orgId as string }), - retry: false, - }) -} - -export const ORG_PAYOUTS_PAGE_SIZE = 20 - -export function useOrgPayouts(orgId: string | undefined, opts: { enabled?: boolean } = {}) { - const api = useApi() - const { isAuthenticated } = useAuthState() - return useInfiniteQuery({ - queryKey: queryKeys.orgPayouts(orgId ?? "unknown"), - enabled: !!orgId && isAuthenticated && (opts.enabled ?? true), - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }) => - api.listOrgPayouts({ - id: orgId as string, - limit: ORG_PAYOUTS_PAGE_SIZE, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - getNextPageParam: (lastPage: ListOrgPayoutsResponse) => lastPage.nextCursor ?? undefined, - retry: false, - }) -} - -export function payoutRows(pages: readonly ListOrgPayoutsResponse[] | undefined): PayoutDTO[] { - return (pages ?? []).flatMap((page) => page.items) -} - -export interface CreateOrgPayoutVars { - amountMinor?: number - idempotencyKey?: string -} - -const payoutIntentKeys = new Map() - -export function payoutIntent(orgId: string, amountMinor: number | null | undefined): string { - return `${orgId}:${amountMinor ?? "available"}` -} - -export function payoutIdempotencyKey(intent: string): string { - const held = payoutIntentKeys.get(intent) - if (held) return held - const minted = randomId() - payoutIntentKeys.set(intent, minted) - return minted -} - -export function releasePayoutIntent(intent: string): void { - payoutIntentKeys.delete(intent) -} - -export function clearPayoutIntents(): void { - payoutIntentKeys.clear() -} - -const SETTLED_PAYOUT_REFUSALS: ReadonlySet = new Set([ - "VALIDATION", - "CONFLICT", - "FORBIDDEN", - "NOT_FOUND", - "RATE_LIMITED", -]) - -/** - * A refusal the server ANSWERED is a settled outcome - no money moved, and the backend has already - * spent this key on a failed row it will replay forever. The key has to rotate or the button is dead. - * A transport failure carries no code: the request may have landed, so the key is held for the retry. - */ -export function payoutIntentSettledBy(err: unknown): boolean { - const code = appErrorCode(err) - return code !== undefined && SETTLED_PAYOUT_REFUSALS.has(code) -} - -export function useCreateOrgPayout(orgId: string | undefined) { - const api = useApi() - const qc = useQueryClient() - return useMutation({ - scope: { id: `org-payout:${orgId ?? "unknown"}` }, - mutationFn: (vars) => { - const intent = payoutIntent(orgId as string, vars?.amountMinor) - return api.createOrgPayout({ - id: orgId as string, - currency: "USD", - idempotencyKey: vars?.idempotencyKey ?? payoutIdempotencyKey(intent), - ...(vars?.amountMinor != null ? { amountMinor: vars.amountMinor } : {}), - }) - }, - onSuccess: (_res, vars) => { - releasePayoutIntent(payoutIntent(orgId as string, vars?.amountMinor)) - if (!orgId) return - void qc.invalidateQueries({ queryKey: queryKeys.orgBalance(orgId) }) - void qc.invalidateQueries({ queryKey: queryKeys.orgPayouts(orgId) }) - void qc.invalidateQueries({ queryKey: queryKeys.orgDonationSummaryRoot(orgId) }) - }, - onError: (err, vars) => { - if (payoutIntentSettledBy(err)) { - releasePayoutIntent(payoutIntent(orgId as string, vars?.amountMinor)) - } - }, - }) -} - -export type OrgStripeAccountLinkKind = "onboarding" | "update" - -export function useCreateOrgStripeAccountLink(orgId: string | undefined) { - const api = useApi() - const qc = useQueryClient() - return useMutation< - CreateOrgStripeAccountLinkResponse, - unknown, - { type?: OrgStripeAccountLinkKind } | void - >({ - mutationFn: (vars) => - api.createOrgStripeAccountLink({ id: orgId as string, type: vars?.type ?? "onboarding" }), - onSuccess: () => { - if (orgId) void qc.invalidateQueries({ queryKey: queryKeys.orgPaymentsStatus(orgId) }) - }, - }) -} diff --git a/packages/ui/src/data/index.ts b/packages/ui/src/data/index.ts index 23a407dc..e732ac8d 100644 --- a/packages/ui/src/data/index.ts +++ b/packages/ui/src/data/index.ts @@ -227,9 +227,7 @@ export type { } from "./hooks/host" export { - ORG_DONATION_EXPORTS_POLL_MS, ORG_MEMBERS_PAGE_SIZE, - exportsPollInterval, actableOrganizations, invalidateMyOrgInvites, organizationEventRows, @@ -243,7 +241,6 @@ export { useOrganizationEvents, useOrganizationInvites, useOrganizationMembers, - useOrgDonationExports, useRemoveOrganizationMember, useRevokeOrganizationInvite, useSetOrganizationMemberRole, @@ -256,33 +253,9 @@ export type { export { useHostedEventsAnalytics } from "./hooks/dashboard" -export { - ORG_PAYOUTS_PAGE_SIZE, - clearPayoutIntents, - payoutRows, - useCreateOrgPayout, - useCreateOrgStripeAccountLink, - useOrgBalance, - useOrgDonationSummary, - useOrgPaymentsStatus, - useOrgPayouts, -} from "./hooks/payouts" -export type { - CreateOrgPayoutVars, - OrgDonationSummaryRange, - OrgStripeAccountLinkKind, -} from "./hooks/payouts" export { fetchEventIcs, useEventIcs } from "./eventIcs" -export { - donationRows, - donationsOffered, - useMyDonationReceipt, - useMyDonations, - useOrgDonationPage, -} from "./hooks/donations" - export { CHECKIN_OUTBOX_KEY, CHECKIN_OUTBOX_MAX, diff --git a/packages/ui/src/data/keys.ts b/packages/ui/src/data/keys.ts index 00e7bfd3..294ad3ad 100644 --- a/packages/ui/src/data/keys.ts +++ b/packages/ui/src/data/keys.ts @@ -85,18 +85,9 @@ export const queryKeys = { hostedEventsAnalytics: (range: string, orgId: string | null) => ["hosted-events", "analytics", range, orgId ?? "all"] as const, myEventInvites: ["event-invites", "mine"] as const, - myDonations: ["donations", "mine"] as const, - orgDonate: (slug: string) => ["donations", "org", slug] as const, - orgDonationExports: (orgId: string) => ["host-exports", "org", orgId] as const, orgEventsRoot: ["org-events"] as const, orgEvents: (slug: string, when: string) => ["org-events", slug, when] as const, orgMembers: (orgId: string) => ["org-admin", orgId, "members"] as const, orgInvites: (orgId: string) => ["org-admin", orgId, "invites"] as const, myOrgInvites: ["org-invites", "mine"] as const, - orgPaymentsStatus: (orgId: string) => ["org-admin", orgId, "payments"] as const, - orgDonationSummaryRoot: (orgId: string) => ["org-admin", orgId, "donation-summary"] as const, - orgDonationSummary: (orgId: string, from: string, to: string) => - ["org-admin", orgId, "donation-summary", from, to] as const, - orgBalance: (orgId: string) => ["org-admin", orgId, "balance"] as const, - orgPayouts: (orgId: string) => ["org-admin", orgId, "payouts"] as const, } diff --git a/packages/ui/src/i18n/locales/de/donation-link.json b/packages/ui/src/i18n/locales/de/donation-link.json new file mode 100644 index 00000000..86689d63 --- /dev/null +++ b/packages/ui/src/i18n/locales/de/donation-link.json @@ -0,0 +1,26 @@ +{ + "card": { + "title": "Spenden", + "supports": "Unterstützt {{name}}", + "open": "Spendenseite öffnen", + "open_a11y": "Spendenseite von {{name}} auf {{host}} öffnen" + }, + "editor": { + "title": "Spendenlink", + "add": "Füge einen Link hinzu, über den Leute spenden können", + "hint": "Füge einen Link zu deiner eigenen Spenden- oder Fundraising-Seite ein. Er wird in deinem Profil und bei Events angezeigt, die du veranstaltest. civfix wickelt niemals Geld ab.", + "field_label": "Spendenlink", + "placeholder": "https://", + "invalid": "Muss ein vollständiger Link sein, der mit https:// beginnt", + "remove": "Link entfernen", + "save": "Speichern", + "dismiss": "Spendenlink schließen", + "error": "Der Link konnte nicht gespeichert werden. Versuche es erneut." + }, + "dashboard": { + "row": "Spendenlink", + "row_set": "Sichtbar bei deinen Events und auf deiner Seite: {{host}}", + "row_unset": "Noch nicht festgelegt", + "row_org_web": "Lege ihn in den Organisationseinstellungen im Web fest" + } +} diff --git a/packages/ui/src/i18n/locales/de/donations.json b/packages/ui/src/i18n/locales/de/donations.json deleted file mode 100644 index 739f1e72..00000000 --- a/packages/ui/src/i18n/locales/de/donations.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "block": { - "heading": "Diese Organisation unterstützen", - "body": "Spenden gehen an {{org}}. civfix leitet deine Spende weiter und hält die Mittel nie.", - "cta": "An {{org}} spenden", - "cta_a11y": "An {{org}} spenden", - "merchant_of_record": "civfix wickelt diese Zahlung ab. {{org}} ist der Merchant of Record.", - "fee_note": "Es fallen eine von der Organisation getragene Kartengebühr und eine civfix-Plattformgebühr von 5% an." - }, - "list": { - "load_more": "Mehr laden", - "loading_more": "Wird geladen…", - "load_more_a11y": "Weitere Spenden laden", - "footnote": "civfix stellt die Belege als bevollmächtigter Vertreter der Organisation aus. Rückerstattungen übernimmt die Organisation." - }, - "row": { - "refunded": "{{amount}} erstattet" - }, - "receipt": { - "download": "Beleg ansehen", - "download_a11y": "Den Beleg zu dieser Spende öffnen", - "error": "Wir konnten diesen Beleg nicht abrufen. Bitte versuche es erneut.", - "unavailable": "Dieses Gerät kann den Beleg nicht öffnen." - }, - "state": { - "loading": "Deine Spenden werden geladen…", - "error_title": "Spenden nicht verfügbar", - "error_body": "Wir konnten deine Spenden nicht laden. Prüfe deine Verbindung und versuche es erneut.", - "empty_title": "Noch keine Spenden", - "empty_body": "Wenn du auf civfix an eine Organisation spendest, erscheint sie hier mit ihrem Beleg.", - "signed_out_title": "Melde dich an, um deine Spenden zu sehen", - "signed_out_body": "Deine Spendenhistorie und deine Belege sind mit deinem Konto verknüpft." - } -} diff --git a/packages/ui/src/i18n/locales/de/enums.json b/packages/ui/src/i18n/locales/de/enums.json index 6a943604..3b91a8ab 100644 --- a/packages/ui/src/i18n/locales/de/enums.json +++ b/packages/ui/src/i18n/locales/de/enums.json @@ -153,33 +153,6 @@ "suppressed": "Unterdrückt", "skipped": "Übersprungen" }, - "donationStatus": { - "pending": "Ausstehend", - "succeeded": "Abgeschlossen", - "failed": "Fehlgeschlagen", - "refunded": "Erstattet", - "partially_refunded": "Teilweise erstattet" - }, - "donationDisputeState": { - "none": "Keine", - "open": "Offen", - "won": "Gewonnen", - "lost": "Verloren", - "warning": "Warnung" - }, - "orgPaymentsState": { - "not_started": "Nicht begonnen", - "onboarding": "In Bearbeitung", - "ready": "Bereit", - "at_risk": "Handlungsbedarf", - "blocked": "Gesperrt" - }, - "donateState": { - "READY": "Nimmt Spenden an", - "AT_RISK": "Handlungsbedarf", - "BLOCKED": "Gesperrt", - "OFF": "Aus" - }, "attendeeKind": { "member": "Mitglied", "guest": "Gast" diff --git a/packages/ui/src/i18n/locales/de/event-dashboard.json b/packages/ui/src/i18n/locales/de/event-dashboard.json index 7582f1bd..d3966f14 100644 --- a/packages/ui/src/i18n/locales/de/event-dashboard.json +++ b/packages/ui/src/i18n/locales/de/event-dashboard.json @@ -119,50 +119,6 @@ "error_body": "civfix ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.", "retry": "Erneut versuchen" }, - "money": { - "section": "Geld", - "dash": "-", - "available": "Jetzt verfügbar", - "pending": "Unterwegs", - "not_connected": "Verbinde ein Bankkonto, um Spenden zu sammeln und Geld auf dein Konto zu senden.", - "connect": "Auszahlungen einrichten", - "connect_owner_only": "Nur die Inhaberin oder der Inhaber kann Auszahlungen einrichten.", - "error_title": "Auszahlungen konnten nicht geladen werden", - "error_body": "Wir konnten civfix nicht erreichen. Prüfe deine Verbindung und versuche es erneut.", - "retry": "Erneut versuchen", - "balance_error": "Der Kontostand konnte gerade nicht geladen werden.", - "summary_error": "Die Spendensummen konnten gerade nicht geladen werden.", - "donations_total": "{{amount}} aus {{count}} Spenden in diesem Zeitraum", - "schedule_daily": "Stripe zahlt täglich automatisch aus.", - "schedule_weekly": "Stripe zahlt wöchentlich automatisch aus.", - "schedule_monthly": "Stripe zahlt monatlich automatisch aus.", - "schedule_manual": "Auszahlungen erfolgen nur, wenn du sie auslöst.", - "payout": "Ans Bankkonto senden", - "payout_blocked_disabled": "Stripe hat Auszahlungen für dieses Konto noch nicht freigegeben.", - "payout_blocked_empty": "Es ist noch nichts zum Senden verfügbar.", - "payout_owner_only": "Nur die Inhaberin oder der Inhaber kann Geld auf die Bank senden.", - "payout_started": "Auszahlung gestartet", - "payout_error_invalid": "Stripe hat diesen Betrag nicht akzeptiert.", - "payout_error_conflict": "Stripe konnte diese Auszahlung gerade nicht ausführen.", - "payout_error_forbidden": "Du darfst keine Auszahlungen auslösen.", - "payout_error_rate_limited": "Vorerst zu viele Auszahlungen. Versuche es später erneut.", - "payout_error_generic": "Die Auszahlung konnte nicht gestartet werden. Versuche es erneut.", - "recent_payouts": "Letzte Auszahlungen", - "payouts_error": "Die letzten Auszahlungen konnten nicht geladen werden.", - "payout_status_pending": "Ausstehend", - "payout_status_in_transit": "Unterwegs", - "payout_status_paid": "Ausgezahlt", - "payout_status_failed": "Fehlgeschlagen", - "payout_status_canceled": "Abgebrochen", - "confirm_title": "Geld auf die Bank senden", - "confirm_body": "{{amount}} gehen an das hinterlegte Bankkonto. Stripe braucht meist einige Werktage.", - "confirm_action": "Senden", - "confirm_dismiss_a11y": "Geld senden schließen", - "link_unavailable": "Dieses Gerät kann die Einrichtungsseite nicht öffnen.", - "link_error": "Die Einrichtungsseite konnte nicht geöffnet werden. Versuche es erneut.", - "range_a11y": "Zeitraum für Geld", - "sent_on": "Gesendet am {{date}}" - }, "team": { "section": "Team", "invite": "Einladen", @@ -175,7 +131,7 @@ "handle": "Nutzername", "email": "E-Mail-Adresse", "invite_role": "Rolle", - "role_hint_admin": "Kann Events veranstalten, Leute einladen und Spenden sehen.", + "role_hint_admin": "Kann Events veranstalten und Leute einladen.", "role_hint_member": "Kann Events im Namen der Organisation veranstalten.", "invite_privacy_note": "Wir nennen ihr, wer sie eingeladen hat und zu welcher Organisation.", "invite_sent": "Einladung gesendet", diff --git a/packages/ui/src/i18n/locales/de/host-common.json b/packages/ui/src/i18n/locales/de/host-common.json index 005a5296..34715146 100644 --- a/packages/ui/src/i18n/locales/de/host-common.json +++ b/packages/ui/src/i18n/locales/de/host-common.json @@ -145,8 +145,7 @@ "forbidden": "Dazu hast du keine Berechtigung.", "conflict": "Jemand hat das vor dir geändert. Lade neu und versuche es erneut.", "not_found": "Wir konnten das nicht finden.", - "abuse_held": "Das wird geprüft, bevor es rausgeht.", - "payment_unavailable": "Zahlungen sind vorübergehend nicht verfügbar. Es wurde nichts abgebucht." + "abuse_held": "Das wird geprüft, bevor es rausgeht." }, "crash": { "title": "In der Konsole ist ein Fehler aufgetreten", diff --git a/packages/ui/src/i18n/locales/de/host-exports.json b/packages/ui/src/i18n/locales/de/host-exports.json index 87f90f67..40a92f99 100644 --- a/packages/ui/src/i18n/locales/de/host-exports.json +++ b/packages/ui/src/i18n/locales/de/host-exports.json @@ -12,7 +12,6 @@ "kind": { "roster": "Teilnehmende", "answers": "Antworten", - "checkins": "Check-ins", - "donations": "Spenden" + "checkins": "Check-ins" } } diff --git a/packages/ui/src/i18n/locales/de/host-mode.json b/packages/ui/src/i18n/locales/de/host-mode.json index 5d6f8b5a..7edd7841 100644 --- a/packages/ui/src/i18n/locales/de/host-mode.json +++ b/packages/ui/src/i18n/locales/de/host-mode.json @@ -62,7 +62,6 @@ "hours_hint": "{{credited}} von {{attended}} Teilnehmenden gutgeschrieben", "hours_hint_credited_one": "{{count}} Person gutgeschrieben", "hours_hint_credited_other": "{{count}} Personen gutgeschrieben", - "donations": "Spenden", "returning": "Wiederkehrend", "returning_hint": "von {{total}} Angemeldeten waren schon einmal dabei" }, @@ -92,17 +91,11 @@ "row": "{{sent}} gesendet · {{failed}} fehlgeschlagen · {{suppressed}} übersprungen", "pending": "Noch nicht gesendet" }, - "money": { - "net": "Für diese Veranstaltung gesammelt", - "donations": "Spenden", - "gross": "{{amount}} vor Gebühren" - }, "section": { "grow": "Bekannt machen", "communicate": "Kommunizieren", "operate": "Vor Ort", "configure": "Einrichten", - "money": "Geld", "danger": "Gefahrenzone", "attendees": "Teilnehmende" }, diff --git a/packages/ui/src/i18n/locales/de/host-org.json b/packages/ui/src/i18n/locales/de/host-org.json index 48c87d82..503a6619 100644 --- a/packages/ui/src/i18n/locales/de/host-org.json +++ b/packages/ui/src/i18n/locales/de/host-org.json @@ -32,17 +32,13 @@ "nav": { "events": "Veranstaltungen", "overview": "Überblick", - "members": "Mitglieder", - "payments": "Zahlungen" + "members": "Mitglieder" }, "overview": { "title": "Organisation", "events": "Veranstaltungen", "members": "Mitglieder", - "created": "Erstellt", - "donations": "Spenden", - "donations_on": "Nimmt Spenden an", - "donations_off": "Nimmt keine Spenden an" + "created": "Erstellt" }, "members": { "title": "Mitglieder", diff --git a/packages/ui/src/i18n/locales/de/host-payments.json b/packages/ui/src/i18n/locales/de/host-payments.json deleted file mode 100644 index b3f11c87..00000000 --- a/packages/ui/src/i18n/locales/de/host-payments.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "no_access_title": "Zahlungen sind für Inhaber*innen und Admins der Organisation", - "no_access_body": "Frag eine*n Inhaber*in dieser Organisation, wenn du Zugriff brauchst.", - "connect": { - "title": "Auszahlungskonto", - "start": "Mit Stripe verbinden", - "continue": "Einrichtung fortsetzen", - "fix": "Bei Stripe klären", - "open_dashboard": "Stripe-Dashboard öffnen", - "review_agreement": "Vereinbarung ansehen", - "agreement_required": "Nimm die Spendenvereinbarung an, bevor du ein Auszahlungskonto verbindest.", - "agreement_first": "Nimm zuerst die aktuelle Spendenvereinbarung an.", - "not_eligible": "Diese Organisation erfüllt noch nicht die Voraussetzungen, um Spenden anzunehmen.", - "owner_only": "Nur Inhaber*innen der Organisation können die Auszahlungseinstellungen ändern.", - "requirements": "Stripe braucht noch", - "requirement_past_due": "überfällig", - "requirement_currently_due": "jetzt fällig", - "requirement_pending": "wird geprüft", - "deadline": "Fällig bis {{when}}", - "charges": "Zahlungseingänge", - "payouts": "Auszahlungen", - "enabled": "Aktiviert", - "disabled": "Deaktiviert", - "wallets": "Wallets", - "wallets_none": "Noch keine", - "last_synced": "Zuletzt abgeglichen", - "never": "Nie", - "blocked_reason": "Stripe hat dieses Konto gesperrt ({{reason}}). Wende dich an den civfix-Support.", - "blocked_generic": "Stripe hat dieses Konto gesperrt. Wende dich an den civfix-Support.", - "state_not_started": "Noch kein Auszahlungskonto. Verbinde eins, um Spenden anzunehmen.", - "state_onboarding": "Stripe braucht noch ein paar Angaben, bevor dieses Konto Zahlungen annehmen kann.", - "state_ready": "Dieses Konto kann Spenden annehmen.", - "state_at_risk": "Stripe braucht bald weitere Informationen, sonst können Auszahlungen pausieren.", - "state_blocked": "Dieses Konto kann derzeit keine Spenden annehmen." - }, - "return": { - "toast_title": "Zurück von Stripe", - "toast_body": "Stripe bestätigt Konten im Hintergrund. Wir prüfen das gerade.", - "pending": "Wir warten auf die Bestätigung von Stripe. Das kann eine Minute dauern." - }, - "refresh": { - "toast_title": "Dieser Stripe-Link ist abgelaufen", - "toast_body": "Starte die Einrichtung erneut, um einen frischen Link zu bekommen." - }, - "eligibility": { - "title": "Gemeinnützigkeit", - "legal_name": "Rechtlicher Name laut IRS", - "ein": "EIN", - "deductibility": "Code zur Absetzbarkeit", - "evaluated_at": "Zuletzt geprüft", - "next_check": "Nächste Prüfung", - "grace_expires": "Übergangsfrist endet", - "reasons": "Warum", - "evidence": "Belege", - "revision": "Liste von {{revision}}", - "verdict_eligible": "Voraussetzungen erfüllt", - "verdict_grace": "Übergangsfrist", - "verdict_ineligible": "Voraussetzungen nicht erfüllt", - "verdict_review_required": "Prüfung nötig", - "verdict_unknown": "Noch nicht geprüft", - "contribution_supports": "spricht dafür", - "contribution_disqualifies": "schließt aus", - "contribution_neutral": "ohne Auswirkung", - "source_irs_pub78": "IRS Publication 78", - "source_irs_eo_bmf": "IRS-Verzeichnis steuerbefreiter Organisationen", - "source_irs_auto_revocation": "IRS-Liste automatischer Widerrufe", - "source_ftb_revoked": "Widerrufe der kalifornischen FTB", - "source_ca_ag_mnos": "Register der kalifornischen Generalstaatsanwaltschaft", - "source_ofac_sdn": "OFAC-Sanktionsliste", - "source_central_org_confirmation": "Bestätigung der Dachorganisation", - "remediation_grace": "Kläre den Eintrag im kalifornischen Register, bevor die Übergangsfrist endet, sonst werden Spenden gestoppt.", - "remediation_ineligible": "Spenden lassen sich erst wieder aktivieren, wenn die Gemeinnützigkeit der Organisation wiederhergestellt ist.", - "remediation_review_required": "civfix prüft diese Organisation gerade. Wir melden uns bei dir.", - "remediation_unknown": "Die Voraussetzungen wurden noch nicht geprüft. Das läuft automatisch." - }, - "agreement": { - "title": "Spendenvereinbarung", - "version": "Version {{version}}", - "version_unknown": "Aktuelle Version wird geladen…", - "read_full": "Vollständige Vereinbarung lesen", - "section_who": "Wer das Geld bekommt", - "body_who": "Deine Organisation ist die Empfängerin und tritt als verantwortlicher Händler der Zahlung auf. civfix verwahrt deine Gelder nie.", - "section_fees": "Gebühren", - "body_fees": "civfix erhebt auf jede Spende eine offen ausgewiesene Plattformgebühr. Die Gebühren des Zahlungsdienstleisters zahlt deine Organisation selbst.", - "section_duties": "Deine Pflichten", - "body_duties": "Du bestätigst den Gemeinnützigkeitsstatus der Organisation, meldest civfix eine Änderung innerhalb von fünf Werktagen und bleibst für Belege, Rückerstattungen und die Steuermeldung verantwortlich.", - "check_accept": "Ich nehme diese Vereinbarung im Namen der Organisation an.", - "check_authority": "Ich bin berechtigt, sie im Namen der Organisation anzunehmen.", - "accept": "Annehmen", - "accepted": "Vereinbarung angenommen", - "close": "Schließen", - "owner_only": "Nur Inhaber*innen der Organisation können das annehmen.", - "drift": "Die Vereinbarung wurde geändert, während das hier offen war. Lade neu und lies die neue Version.", - "history": "Version {{version}} angenommen am {{when}} von {{who}}" - }, - "settings": { - "title": "Spendeneinstellungen", - "enable": "Spenden annehmen", - "enable_caption": "Zeigt auf deinen Organisations- und Veranstaltungsseiten eine Spenden-Schaltfläche.", - "enable_unavailable": "Spenden lassen sich erst aktivieren, wenn das Auszahlungskonto bereit ist, die Organisation die Voraussetzungen erfüllt und die aktuelle Vereinbarung angenommen wurde.", - "operator_locked": "civfix hat Spenden für diese Organisation deaktiviert. Wende dich an den Support.", - "owner_only": "Nur Inhaber*innen der Organisation können das ändern.", - "review_agreement": "Ansehen", - "blocker_account": "Das Auszahlungskonto ist nicht bereit.", - "blocker_eligibility": "Die Gemeinnützigkeit ist nicht bestätigt.", - "blocker_agreement": "Die aktuelle Spendenvereinbarung wurde nicht angenommen.", - "blocker_operator": "civfix hat Spenden für diese Organisation deaktiviert.", - "legal_name": "Rechtlicher Name", - "ein": "EIN", - "fee_disclosure": "civfix berechnet {{percent}} % jeder Spende. Die Gebühren des Zahlungsdienstleisters zahlt deine Organisation zusätzlich. Spendende sehen vor der Zahlung immer die vollständige Aufschlüsselung.", - "mission": "Text zur Mission", - "designation": "Wofür Spenden verwendet werden", - "refund_policy": "Rückerstattungsregeln", - "refund_policy_hint": "Wird Spendenden vor der Zahlung gezeigt.", - "min_amount": "Mindestspende", - "max_amount": "Höchstspende", - "suggested": "Vorgeschlagene Beträge", - "suggested_hint": "Bis zu {{max}} Beträge, durch Kommas getrennt.", - "current_range": "Aktuell {{min}} bis {{max}}", - "sharing_default": "Weitergabe der Spenderdaten vorschlagen", - "sharing_default_caption": "Spendende entscheiden immer selbst. Das legt nur fest, wie das Kontrollkästchen anfangs steht, und von Gesetzes wegen steht es aus.", - "saved": "Spendeneinstellungen gespeichert", - "refund_policy_error": "Eine Rückerstattungsrichtlinie darf nicht behaupten, dass 100 % einer Spende bei der Organisation ankommen: Die Gebühr des Zahlungsdienstleisters wird immer abgezogen, und die Spendenseite zeigt die tatsächliche Aufteilung." - }, - "column": { - "date": "Datum", - "reference": "Referenz", - "amount": "Betrag", - "platform_fee": "civfix-Gebühr", - "processing_fee": "Zahlungsgebühr", - "net": "Netto", - "donor": "Spender*in", - "status": "Status", - "receipt": "Beleg gesendet" - }, - "donation_status": { - "succeeded": "Abgeschlossen", - "refunded": "Erstattet", - "partially_refunded": "Teilweise erstattet", - "failed": "Fehlgeschlagen", - "pending": "Ausstehend" - }, - "report": { - "title": "Spenden", - "table_caption": "Spenden an diese Organisation", - "anonymous": "Anonyme Spende", - "donor_identity_note": "Name und E-Mail-Adresse einer spendenden Person erscheinen nur dort, wo sie sich fürs Teilen entschieden hat. Alles andere ist eine anonyme Spende.", - "all_statuses": "Alle", - "filter_status": "Status", - "filter_from": "Von", - "filter_to": "Bis", - "total_gross": "Gesamt eingenommen", - "total_platform_fee": "civfix-Gebühren", - "total_processing_fee": "Zahlungsgebühren", - "total_net": "Netto für euch", - "donations_one": "{{count}} Spende", - "donations_other": "{{count}} Spenden", - "export_visible": "Angezeigtes exportieren", - "csv_title": "{{org}} — Spenden", - "empty_title": "Noch keine Spenden", - "empty_body": "Spenden erscheinen hier, sobald jemand etwas gibt.", - "loaded_pages_note": "Enthält nur die beim Erstellen dieser Datei geladenen Zeilen, nicht die gesamte Historie." - }, - "csv": { - "source": "Quelle", - "reference": "Referenz", - "generated_at": "Erstellt", - "filters": "Filter", - "none": "keine", - "suppression_k": "Schwelle für Unterdrückung (k)", - "note": "Hinweis" - }, - "exports": { - "title": "Spenden-Exporte", - "hint": "Eine CSV-Datei mit dem gesamten Spendenverzeichnis der Organisation, nicht nur den angezeigten Zeilen.", - "request": "Vollständigen Export anfordern", - "requested": "Export angefordert", - "requested_hint": "Er erscheint unten, sobald er fertig ist.", - "empty_title": "Noch keine Exporte", - "empty_body": "Fordere einen an, dann erscheint er hier.", - "download": "Herunterladen", - "expires": "Verfügbar bis {{when}}", - "expired": "Abgelaufen – bitte neu anfordern", - "rows_one": "{{count}} Zeile", - "rows_other": "{{count}} Zeilen", - "truncated": "Gekürzt", - "note": "Ein Export enthält Namen und E-Mail-Adressen von Spendenden, sofern diese sie geteilt haben. Der Download-Link ist kurzlebig und die Datei wird nach Ablauf gelöscht.", - "failed_reason": "Fehlgeschlagen: {{code}}" - } -} diff --git a/packages/ui/src/i18n/locales/de/nav.json b/packages/ui/src/i18n/locales/de/nav.json index 0ee6bd72..c8d4c8ea 100644 --- a/packages/ui/src/i18n/locales/de/nav.json +++ b/packages/ui/src/i18n/locales/de/nav.json @@ -39,7 +39,6 @@ "host_log_hours": "Freiwilligenstunden erfassen", "my_ticket": "Dein Ticket", "org": "Organisation", - "my_donations": "Deine Spenden", "event_dashboard": "Event-Dashboard" }, "tab": { diff --git a/packages/ui/src/i18n/locales/de/settings.json b/packages/ui/src/i18n/locales/de/settings.json index ae54c219..6b8fcf54 100644 --- a/packages/ui/src/i18n/locales/de/settings.json +++ b/packages/ui/src/i18n/locales/de/settings.json @@ -33,9 +33,5 @@ "support_civfix": "civfix unterstützen", "terms": "Nutzungsbedingungen", "privacy_policy": "Datenschutzerklärung", - "sign_out": "Abmelden", - "donations": { - "label": "Spenden", - "sub": "Deine Spendenhistorie und Belege" - } + "sign_out": "Abmelden" } diff --git a/packages/ui/src/i18n/locales/en/donation-link.json b/packages/ui/src/i18n/locales/en/donation-link.json new file mode 100644 index 00000000..681d1aae --- /dev/null +++ b/packages/ui/src/i18n/locales/en/donation-link.json @@ -0,0 +1,26 @@ +{ + "card": { + "title": "Donate", + "supports": "Supports {{name}}", + "open": "Open donation page", + "open_a11y": "Open the donation page for {{name}} on {{host}}" + }, + "editor": { + "title": "Donation link", + "add": "Add a link people can donate through", + "hint": "Paste a link to your own donation or fundraiser page. It is shown on your profile and on events you host. civfix never handles the money.", + "field_label": "Donation link", + "placeholder": "https://", + "invalid": "Must be a full link starting with https://", + "remove": "Remove link", + "save": "Save", + "dismiss": "Close donation link", + "error": "Could not save the link. Try again." + }, + "dashboard": { + "row": "Donation link", + "row_set": "Live on your events and page: {{host}}", + "row_unset": "Not set yet", + "row_org_web": "Set it in the organization settings on the web" + } +} diff --git a/packages/ui/src/i18n/locales/en/donations.json b/packages/ui/src/i18n/locales/en/donations.json deleted file mode 100644 index e5d3f91b..00000000 --- a/packages/ui/src/i18n/locales/en/donations.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "block": { - "heading": "Support this organization", - "body": "Donations go to {{org}}. civfix passes your gift on and never holds the funds.", - "cta": "Donate to {{org}}", - "cta_a11y": "Donate to {{org}}", - "merchant_of_record": "civfix facilitates this payment. {{org}} is the merchant of record.", - "fee_note": "A card processing fee, paid by the organization, and a 5% civfix platform fee apply." - }, - "list": { - "load_more": "Load more", - "loading_more": "Loading…", - "load_more_a11y": "Load more donations", - "footnote": "Receipts are issued by civfix as the organization's authorized agent. Refunds are handled by the organization." - }, - "row": { - "refunded": "{{amount}} refunded" - }, - "receipt": { - "download": "Get receipt", - "download_a11y": "Open the receipt for this donation", - "error": "We could not fetch that receipt. Please try again.", - "unavailable": "This device cannot open the receipt." - }, - "state": { - "loading": "Loading your donations…", - "error_title": "Donations unavailable", - "error_body": "We could not load your donations. Check your connection and try again.", - "empty_title": "No donations yet", - "empty_body": "When you donate to an organization on civfix, it will show up here with its receipt.", - "signed_out_title": "Sign in to see your donations", - "signed_out_body": "Your donation history and receipts are tied to your account." - } -} diff --git a/packages/ui/src/i18n/locales/en/enums.json b/packages/ui/src/i18n/locales/en/enums.json index 34257ebd..8c98e8ba 100644 --- a/packages/ui/src/i18n/locales/en/enums.json +++ b/packages/ui/src/i18n/locales/en/enums.json @@ -153,33 +153,6 @@ "suppressed": "Suppressed", "skipped": "Skipped" }, - "donationStatus": { - "pending": "Pending", - "succeeded": "Completed", - "failed": "Failed", - "refunded": "Refunded", - "partially_refunded": "Partly refunded" - }, - "donationDisputeState": { - "none": "None", - "open": "Open", - "won": "Won", - "lost": "Lost", - "warning": "Warning" - }, - "orgPaymentsState": { - "not_started": "Not started", - "onboarding": "In progress", - "ready": "Ready", - "at_risk": "Needs attention", - "blocked": "Blocked" - }, - "donateState": { - "READY": "Accepting donations", - "AT_RISK": "Needs attention", - "BLOCKED": "Blocked", - "OFF": "Off" - }, "attendeeKind": { "member": "Member", "guest": "Guest" diff --git a/packages/ui/src/i18n/locales/en/event-dashboard.json b/packages/ui/src/i18n/locales/en/event-dashboard.json index 09b779af..4c42b470 100644 --- a/packages/ui/src/i18n/locales/en/event-dashboard.json +++ b/packages/ui/src/i18n/locales/en/event-dashboard.json @@ -119,50 +119,6 @@ "error_body": "We could not reach civfix. Check your connection and try again.", "retry": "Try again" }, - "money": { - "section": "Money", - "dash": "-", - "available": "Available now", - "pending": "On the way", - "not_connected": "Connect a bank account to collect donations and send money to your bank.", - "connect": "Set up payouts", - "connect_owner_only": "Only the owner can set up payouts.", - "error_title": "Could not load payouts", - "error_body": "We could not reach civfix. Check your connection and try again.", - "retry": "Try again", - "balance_error": "Could not load the balance right now.", - "summary_error": "Could not load donation totals right now.", - "donations_total": "{{amount}} from {{count}} donations in this period", - "schedule_daily": "Stripe pays out daily on its own.", - "schedule_weekly": "Stripe pays out weekly on its own.", - "schedule_monthly": "Stripe pays out monthly on its own.", - "schedule_manual": "Payouts happen only when you send them.", - "payout": "Send to bank", - "payout_blocked_disabled": "Stripe has not enabled payouts for this account yet.", - "payout_blocked_empty": "Nothing is available to send yet.", - "payout_owner_only": "Only the owner can send money to the bank.", - "payout_started": "Payout started", - "payout_error_invalid": "Stripe would not accept that amount.", - "payout_error_conflict": "Stripe could not send that payout right now.", - "payout_error_forbidden": "You do not have permission to send payouts.", - "payout_error_rate_limited": "Too many payouts for now. Try again later.", - "payout_error_generic": "Could not start the payout. Try again.", - "recent_payouts": "Recent payouts", - "payouts_error": "Could not load recent payouts.", - "payout_status_pending": "Pending", - "payout_status_in_transit": "On the way", - "payout_status_paid": "Paid", - "payout_status_failed": "Failed", - "payout_status_canceled": "Canceled", - "confirm_title": "Export money to bank", - "confirm_body": "{{amount}} goes to the bank account on file. Stripe usually takes a few business days.", - "confirm_action": "Send it", - "confirm_dismiss_a11y": "Close export money", - "link_unavailable": "This device cannot open the setup page.", - "link_error": "Could not open the setup page. Try again.", - "range_a11y": "Money time range", - "sent_on": "Sent {{date}}" - }, "team": { "section": "Team", "invite": "Invite", @@ -175,7 +131,7 @@ "handle": "Handle", "email": "Email address", "invite_role": "Role", - "role_hint_admin": "Can host events, invite people and see donations.", + "role_hint_admin": "Can host events and invite people.", "role_hint_member": "Can host events as the organization.", "invite_privacy_note": "We tell them who invited them and to which organization.", "invite_sent": "Invitation sent", diff --git a/packages/ui/src/i18n/locales/en/host-common.json b/packages/ui/src/i18n/locales/en/host-common.json index 10e0dedd..856de6aa 100644 --- a/packages/ui/src/i18n/locales/en/host-common.json +++ b/packages/ui/src/i18n/locales/en/host-common.json @@ -145,8 +145,7 @@ "forbidden": "You do not have permission to do that.", "conflict": "Someone changed this before you did. Reload and try again.", "not_found": "We could not find that.", - "abuse_held": "This is being reviewed before it goes out.", - "payment_unavailable": "Payments are temporarily unavailable. Nothing was charged." + "abuse_held": "This is being reviewed before it goes out." }, "crash": { "title": "The console hit a problem", diff --git a/packages/ui/src/i18n/locales/en/host-exports.json b/packages/ui/src/i18n/locales/en/host-exports.json index d6d576ef..05ae120e 100644 --- a/packages/ui/src/i18n/locales/en/host-exports.json +++ b/packages/ui/src/i18n/locales/en/host-exports.json @@ -12,7 +12,6 @@ "kind": { "roster": "Attendees", "answers": "Answers", - "checkins": "Check-ins", - "donations": "Donations" + "checkins": "Check-ins" } } diff --git a/packages/ui/src/i18n/locales/en/host-mode.json b/packages/ui/src/i18n/locales/en/host-mode.json index 55d6b80a..bd955b42 100644 --- a/packages/ui/src/i18n/locales/en/host-mode.json +++ b/packages/ui/src/i18n/locales/en/host-mode.json @@ -62,7 +62,6 @@ "hours_hint": "{{credited}} of {{attended}} attendees credited", "hours_hint_credited_one": "{{count}} attendee credited", "hours_hint_credited_other": "{{count}} attendees credited", - "donations": "Donations", "returning": "Returning", "returning_hint": "of {{total}} registered have been before" }, @@ -92,17 +91,11 @@ "row": "{{sent}} sent · {{failed}} failed · {{suppressed}} skipped", "pending": "Not sent yet" }, - "money": { - "net": "Raised for this event", - "donations": "Donations", - "gross": "{{amount}} before fees" - }, "section": { "grow": "Grow", "communicate": "Communicate", "operate": "Operate", "configure": "Configure", - "money": "Money", "danger": "Danger zone", "attendees": "Attendees" }, diff --git a/packages/ui/src/i18n/locales/en/host-org.json b/packages/ui/src/i18n/locales/en/host-org.json index b36a193a..116edd06 100644 --- a/packages/ui/src/i18n/locales/en/host-org.json +++ b/packages/ui/src/i18n/locales/en/host-org.json @@ -32,17 +32,13 @@ "nav": { "events": "Events", "overview": "Overview", - "members": "Members", - "payments": "Payments" + "members": "Members" }, "overview": { "title": "Organization", "events": "Events", "members": "Members", - "created": "Created", - "donations": "Donations", - "donations_on": "Accepting donations", - "donations_off": "Not accepting donations" + "created": "Created" }, "members": { "title": "Members", diff --git a/packages/ui/src/i18n/locales/en/host-payments.json b/packages/ui/src/i18n/locales/en/host-payments.json deleted file mode 100644 index 37c30f4b..00000000 --- a/packages/ui/src/i18n/locales/en/host-payments.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "no_access_title": "Payments are for org owners and admins", - "no_access_body": "Ask an owner of this organization if you need access.", - "connect": { - "title": "Payout account", - "start": "Connect with Stripe", - "continue": "Continue setup", - "fix": "Resolve with Stripe", - "open_dashboard": "Open the Stripe dashboard", - "review_agreement": "Review the agreement", - "agreement_required": "Accept the donation agreement before connecting a payout account.", - "agreement_first": "Accept the current donation agreement first.", - "not_eligible": "This organization is not yet eligible to accept donations.", - "owner_only": "Only the organization's owner can change payout settings.", - "requirements": "Stripe still needs", - "requirement_past_due": "overdue", - "requirement_currently_due": "due now", - "requirement_pending": "being verified", - "deadline": "Due by {{when}}", - "charges": "Charges", - "payouts": "Payouts", - "enabled": "Enabled", - "disabled": "Disabled", - "wallets": "Wallets", - "wallets_none": "None yet", - "last_synced": "Last synced", - "never": "Never", - "blocked_reason": "Stripe has blocked this account ({{reason}}). Contact civfix support.", - "blocked_generic": "Stripe has blocked this account. Contact civfix support.", - "state_not_started": "No payout account yet. Connect one to accept donations.", - "state_onboarding": "Stripe still needs some details before this account can take payments.", - "state_ready": "This account can accept donations.", - "state_at_risk": "Stripe needs more information soon, or payouts may pause.", - "state_blocked": "This account cannot accept donations right now." - }, - "return": { - "toast_title": "Back from Stripe", - "toast_body": "Stripe confirms accounts in the background. We are checking.", - "pending": "Waiting for Stripe to confirm. This can take a minute." - }, - "refresh": { - "toast_title": "That Stripe link expired", - "toast_body": "Start setup again to get a fresh link." - }, - "eligibility": { - "title": "Nonprofit eligibility", - "legal_name": "IRS legal name", - "ein": "EIN", - "deductibility": "Deductibility code", - "evaluated_at": "Last evaluated", - "next_check": "Next check", - "grace_expires": "Grace period ends", - "reasons": "Why", - "evidence": "Evidence", - "revision": "list of {{revision}}", - "verdict_eligible": "Eligible", - "verdict_grace": "Grace period", - "verdict_ineligible": "Not eligible", - "verdict_review_required": "Needs review", - "verdict_unknown": "Not checked yet", - "contribution_supports": "supports eligibility", - "contribution_disqualifies": "blocks eligibility", - "contribution_neutral": "no effect", - "source_irs_pub78": "IRS Publication 78", - "source_irs_eo_bmf": "IRS Exempt Organizations file", - "source_irs_auto_revocation": "IRS auto-revocation list", - "source_ftb_revoked": "California FTB revocations", - "source_ca_ag_mnos": "California AG registry", - "source_ofac_sdn": "OFAC sanctions list", - "source_central_org_confirmation": "Central organization confirmation", - "remediation_grace": "Resolve the California registry listing before the grace period ends, or donations will stop.", - "remediation_ineligible": "Donations cannot be enabled until the organization's nonprofit status is restored.", - "remediation_review_required": "civfix is reviewing this organization. We will be in touch.", - "remediation_unknown": "Eligibility has not been checked yet. This runs automatically." - }, - "agreement": { - "title": "Donation agreement", - "version": "Version {{version}}", - "version_unknown": "Loading the current version…", - "read_full": "Read the full agreement", - "section_who": "Who receives the money", - "body_who": "Your organization is the recipient and the merchant of record. civfix never holds your funds.", - "section_fees": "Fees", - "body_fees": "civfix charges a disclosed platform fee on each donation. Your organization pays the payment processor's own fees.", - "section_duties": "Your obligations", - "body_duties": "You confirm the organization's nonprofit status, tell civfix within five business days if it changes, and remain responsible for receipts, refunds and tax reporting.", - "check_accept": "I accept this agreement on behalf of the organization.", - "check_authority": "I am authorized to accept it on the organization's behalf.", - "accept": "Accept", - "accepted": "Agreement accepted", - "close": "Close", - "owner_only": "Only the organization's owner can accept this.", - "drift": "The agreement was updated while this was open. Reload and read the new version.", - "history": "Version {{version}} accepted {{when}} by {{who}}" - }, - "settings": { - "title": "Donation settings", - "enable": "Accept donations", - "enable_caption": "Shows a donate button on your organization and event pages.", - "enable_unavailable": "Donations cannot be turned on until the payout account is ready, the organization is eligible, and the current agreement is accepted.", - "operator_locked": "civfix disabled donations for this organization. Contact support.", - "owner_only": "Only the organization's owner can change this.", - "review_agreement": "Review", - "blocker_account": "The payout account is not ready.", - "blocker_eligibility": "Nonprofit eligibility is not confirmed.", - "blocker_agreement": "The current donation agreement has not been accepted.", - "blocker_operator": "civfix disabled donations for this organization.", - "legal_name": "Legal name", - "ein": "EIN", - "fee_disclosure": "civfix charges {{percent}}% of each donation. Your organization also pays the payment processor's fees. Donors always see the full breakdown before they pay.", - "mission": "Mission blurb", - "designation": "What donations fund", - "refund_policy": "Refund policy", - "refund_policy_hint": "Shown to donors before they pay.", - "min_amount": "Minimum donation", - "max_amount": "Maximum donation", - "suggested": "Suggested amounts", - "suggested_hint": "Up to {{max}} amounts, comma separated.", - "current_range": "Currently {{min}} to {{max}}", - "sharing_default": "Suggest sharing donor details", - "sharing_default_caption": "Donors always choose. This only sets which way the checkbox starts, and it starts off by law.", - "saved": "Donation settings saved", - "refund_policy_error": "A refund policy cannot say that 100% of a donation reaches the organization: the payment processor's fee is always deducted, and the donate page shows the real split." - }, - "column": { - "date": "Date", - "reference": "Reference", - "amount": "Amount", - "platform_fee": "civfix fee", - "processing_fee": "Processing fee", - "net": "Net", - "donor": "Donor", - "status": "Status", - "receipt": "Receipt sent" - }, - "donation_status": { - "succeeded": "Completed", - "refunded": "Refunded", - "partially_refunded": "Partly refunded", - "failed": "Failed", - "pending": "Pending" - }, - "report": { - "title": "Donations", - "table_caption": "Donations to this organization", - "anonymous": "Anonymous donor", - "donor_identity_note": "A donor's name and email appear only where they chose to share them. Everything else is an anonymous donation.", - "all_statuses": "All", - "filter_status": "Status", - "filter_from": "From", - "filter_to": "To", - "total_gross": "Total raised", - "total_platform_fee": "civfix fees", - "total_processing_fee": "Processing fees", - "total_net": "Net to you", - "donations_one": "{{count}} donation", - "donations_other": "{{count}} donations", - "export_visible": "Export what is shown", - "csv_title": "{{org}} — donations", - "empty_title": "No donations yet", - "empty_body": "Donations appear here as soon as someone gives.", - "loaded_pages_note": "Covers only the rows loaded on screen when this file was generated, not the full history." - }, - "csv": { - "source": "Source", - "reference": "Reference", - "generated_at": "Generated", - "filters": "Filters", - "none": "none", - "suppression_k": "Suppression threshold (k)", - "note": "Note" - }, - "exports": { - "title": "Donation exports", - "hint": "A CSV of the organization's whole donation ledger, not only the rows on screen.", - "request": "Request full export", - "requested": "Export requested", - "requested_hint": "It appears below as soon as it is ready.", - "empty_title": "No exports yet", - "empty_body": "Request one and it will appear here.", - "download": "Download", - "expires": "Available until {{when}}", - "expired": "Expired — request a new one", - "rows_one": "{{count}} row", - "rows_other": "{{count}} rows", - "truncated": "Truncated", - "note": "An export carries donor names and emails wherever donors chose to share them. The download link is short-lived and the file is deleted when it expires.", - "failed_reason": "Failed: {{code}}" - } -} diff --git a/packages/ui/src/i18n/locales/en/nav.json b/packages/ui/src/i18n/locales/en/nav.json index 12b8abae..cedd61d4 100644 --- a/packages/ui/src/i18n/locales/en/nav.json +++ b/packages/ui/src/i18n/locales/en/nav.json @@ -39,7 +39,6 @@ "host_log_hours": "Log volunteer hours", "my_ticket": "Your ticket", "org": "Organization", - "my_donations": "Your donations", "event_dashboard": "Event dashboard" }, "tab": { diff --git a/packages/ui/src/i18n/locales/en/settings.json b/packages/ui/src/i18n/locales/en/settings.json index 39b3ecdb..0ba5b6aa 100644 --- a/packages/ui/src/i18n/locales/en/settings.json +++ b/packages/ui/src/i18n/locales/en/settings.json @@ -33,9 +33,5 @@ "support_civfix": "Support civfix", "terms": "Terms of service", "privacy_policy": "Privacy policy", - "sign_out": "Sign out", - "donations": { - "label": "Donations", - "sub": "Your donation history and receipts" - } + "sign_out": "Sign out" } diff --git a/packages/ui/src/i18n/locales/es/donation-link.json b/packages/ui/src/i18n/locales/es/donation-link.json new file mode 100644 index 00000000..517811bb --- /dev/null +++ b/packages/ui/src/i18n/locales/es/donation-link.json @@ -0,0 +1,26 @@ +{ + "card": { + "title": "Donar", + "supports": "Apoya a {{name}}", + "open": "Abrir página de donación", + "open_a11y": "Abrir la página de donación de {{name}} en {{host}}" + }, + "editor": { + "title": "Enlace de donación", + "add": "Añade un enlace por el que la gente pueda donar", + "hint": "Pega un enlace a tu propia página de donaciones o de recaudación. Se muestra en tu perfil y en los eventos que organizas. civfix nunca gestiona el dinero.", + "field_label": "Enlace de donación", + "placeholder": "https://", + "invalid": "Debe ser un enlace completo que empiece por https://", + "remove": "Quitar enlace", + "save": "Guardar", + "dismiss": "Cerrar enlace de donación", + "error": "No se pudo guardar el enlace. Inténtalo de nuevo." + }, + "dashboard": { + "row": "Enlace de donación", + "row_set": "Visible en tus eventos y tu página: {{host}}", + "row_unset": "Aún sin configurar", + "row_org_web": "Configúralo en los ajustes de la organización en la web" + } +} diff --git a/packages/ui/src/i18n/locales/es/donations.json b/packages/ui/src/i18n/locales/es/donations.json deleted file mode 100644 index d15bca14..00000000 --- a/packages/ui/src/i18n/locales/es/donations.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "block": { - "heading": "Apoya a esta organización", - "body": "Las donaciones van a {{org}}. civfix traslada tu donativo y nunca retiene los fondos.", - "cta": "Donar a {{org}}", - "cta_a11y": "Donar a {{org}}", - "merchant_of_record": "civfix facilita este pago. {{org}} es el comerciante registrado.", - "fee_note": "Se aplican una comisión de procesamiento de tarjeta, que paga la organización, y una comisión de plataforma de civfix del 5%." - }, - "list": { - "load_more": "Cargar más", - "loading_more": "Cargando…", - "load_more_a11y": "Cargar más donaciones", - "footnote": "civfix emite los recibos como agente autorizado de la organización. Los reembolsos los gestiona la organización." - }, - "row": { - "refunded": "{{amount}} reembolsados" - }, - "receipt": { - "download": "Ver recibo", - "download_a11y": "Abrir el recibo de esta donación", - "error": "No pudimos obtener ese recibo. Inténtalo de nuevo.", - "unavailable": "Este dispositivo no puede abrir el recibo." - }, - "state": { - "loading": "Cargando tus donaciones…", - "error_title": "Donaciones no disponibles", - "error_body": "No pudimos cargar tus donaciones. Revisa tu conexión e inténtalo de nuevo.", - "empty_title": "Todavía no hay donaciones", - "empty_body": "Cuando dones a una organización en civfix, aparecerá aquí con su recibo.", - "signed_out_title": "Inicia sesión para ver tus donaciones", - "signed_out_body": "Tu historial de donaciones y tus recibos están vinculados a tu cuenta." - } -} diff --git a/packages/ui/src/i18n/locales/es/enums.json b/packages/ui/src/i18n/locales/es/enums.json index fe034190..dc8bb119 100644 --- a/packages/ui/src/i18n/locales/es/enums.json +++ b/packages/ui/src/i18n/locales/es/enums.json @@ -153,33 +153,6 @@ "suppressed": "Suprimido", "skipped": "Omitido" }, - "donationStatus": { - "pending": "Pendiente", - "succeeded": "Completada", - "failed": "Fallida", - "refunded": "Reembolsada", - "partially_refunded": "Reembolsada en parte" - }, - "donationDisputeState": { - "none": "Ninguna", - "open": "Abierta", - "won": "Ganada", - "lost": "Perdida", - "warning": "Aviso" - }, - "orgPaymentsState": { - "not_started": "Sin empezar", - "onboarding": "En curso", - "ready": "Lista", - "at_risk": "Requiere atención", - "blocked": "Bloqueada" - }, - "donateState": { - "READY": "Aceptando donaciones", - "AT_RISK": "Requiere atención", - "BLOCKED": "Bloqueada", - "OFF": "Desactivada" - }, "attendeeKind": { "member": "Miembro", "guest": "Invitado" diff --git a/packages/ui/src/i18n/locales/es/event-dashboard.json b/packages/ui/src/i18n/locales/es/event-dashboard.json index 2754ed37..602f4548 100644 --- a/packages/ui/src/i18n/locales/es/event-dashboard.json +++ b/packages/ui/src/i18n/locales/es/event-dashboard.json @@ -119,50 +119,6 @@ "error_body": "No pudimos conectar con civfix. Revisa tu conexión e inténtalo de nuevo.", "retry": "Reintentar" }, - "money": { - "section": "Dinero", - "dash": "-", - "available": "Disponible ahora", - "pending": "En camino", - "not_connected": "Conecta una cuenta bancaria para recibir donaciones y enviar dinero a tu banco.", - "connect": "Configurar pagos", - "connect_owner_only": "Solo la persona propietaria puede configurar los pagos.", - "error_title": "No se pudieron cargar los pagos", - "error_body": "No pudimos conectar con civfix. Revisa tu conexión e inténtalo de nuevo.", - "retry": "Reintentar", - "balance_error": "No se pudo cargar el saldo ahora mismo.", - "summary_error": "No se pudieron cargar los totales de donaciones ahora mismo.", - "donations_total": "{{amount}} de {{count}} donaciones en este periodo", - "schedule_daily": "Stripe paga automáticamente cada día.", - "schedule_weekly": "Stripe paga automáticamente cada semana.", - "schedule_monthly": "Stripe paga automáticamente cada mes.", - "schedule_manual": "Los pagos solo salen cuando tú los envías.", - "payout": "Enviar al banco", - "payout_blocked_disabled": "Stripe aún no ha habilitado los pagos de esta cuenta.", - "payout_blocked_empty": "Todavía no hay nada disponible para enviar.", - "payout_owner_only": "Solo la persona propietaria puede enviar dinero al banco.", - "payout_started": "Pago iniciado", - "payout_error_invalid": "Stripe no aceptó ese importe.", - "payout_error_conflict": "Stripe no pudo enviar ese pago ahora mismo.", - "payout_error_forbidden": "No tienes permiso para enviar pagos.", - "payout_error_rate_limited": "Demasiados pagos por ahora. Inténtalo más tarde.", - "payout_error_generic": "No se pudo iniciar el pago. Inténtalo de nuevo.", - "recent_payouts": "Pagos recientes", - "payouts_error": "No se pudieron cargar los pagos recientes.", - "payout_status_pending": "Pendiente", - "payout_status_in_transit": "En camino", - "payout_status_paid": "Pagado", - "payout_status_failed": "Fallido", - "payout_status_canceled": "Cancelado", - "confirm_title": "Enviar dinero al banco", - "confirm_body": "{{amount}} irán a la cuenta bancaria registrada. Stripe suele tardar unos días hábiles.", - "confirm_action": "Enviar", - "confirm_dismiss_a11y": "Cerrar enviar dinero", - "link_unavailable": "Este dispositivo no puede abrir la página de configuración.", - "link_error": "No se pudo abrir la página de configuración. Inténtalo de nuevo.", - "range_a11y": "Periodo de dinero", - "sent_on": "Enviado el {{date}}" - }, "team": { "section": "Equipo", "invite": "Invitar", @@ -175,7 +131,7 @@ "handle": "Usuario", "email": "Dirección de correo", "invite_role": "Rol", - "role_hint_admin": "Puede organizar eventos, invitar personas y ver donaciones.", + "role_hint_admin": "Puede organizar eventos e invitar personas.", "role_hint_member": "Puede organizar eventos en nombre de la organización.", "invite_privacy_note": "Le diremos quién le invitó y a qué organización.", "invite_sent": "Invitación enviada", diff --git a/packages/ui/src/i18n/locales/es/host-common.json b/packages/ui/src/i18n/locales/es/host-common.json index c83e6435..8c635374 100644 --- a/packages/ui/src/i18n/locales/es/host-common.json +++ b/packages/ui/src/i18n/locales/es/host-common.json @@ -145,8 +145,7 @@ "forbidden": "No tienes permiso para hacer eso.", "conflict": "Alguien cambió esto antes que tú. Recarga la página e inténtalo de nuevo.", "not_found": "No pudimos encontrar eso.", - "abuse_held": "Esto está en revisión antes de enviarse.", - "payment_unavailable": "Los pagos no están disponibles temporalmente. No se ha cobrado nada." + "abuse_held": "Esto está en revisión antes de enviarse." }, "crash": { "title": "La consola tuvo un problema", diff --git a/packages/ui/src/i18n/locales/es/host-exports.json b/packages/ui/src/i18n/locales/es/host-exports.json index 9bcbc74f..82d451ba 100644 --- a/packages/ui/src/i18n/locales/es/host-exports.json +++ b/packages/ui/src/i18n/locales/es/host-exports.json @@ -12,7 +12,6 @@ "kind": { "roster": "Asistentes", "answers": "Respuestas", - "checkins": "Registros de entrada", - "donations": "Donaciones" + "checkins": "Registros de entrada" } } diff --git a/packages/ui/src/i18n/locales/es/host-mode.json b/packages/ui/src/i18n/locales/es/host-mode.json index 770b452a..11bd78bb 100644 --- a/packages/ui/src/i18n/locales/es/host-mode.json +++ b/packages/ui/src/i18n/locales/es/host-mode.json @@ -62,7 +62,6 @@ "hours_hint": "{{credited}} de {{attended}} asistentes acreditados", "hours_hint_credited_one": "{{count}} asistente acreditado", "hours_hint_credited_other": "{{count}} asistentes acreditados", - "donations": "Donaciones", "returning": "Repiten", "returning_hint": "de {{total}} inscritos ya habían venido" }, @@ -92,17 +91,11 @@ "row": "{{sent}} enviados · {{failed}} fallidos · {{suppressed}} omitidos", "pending": "Aún sin enviar" }, - "money": { - "net": "Recaudado en este evento", - "donations": "Donaciones", - "gross": "{{amount}} antes de comisiones" - }, "section": { "grow": "Difundir", "communicate": "Comunicar", "operate": "Gestionar el día", "configure": "Configurar", - "money": "Dinero", "danger": "Zona de riesgo", "attendees": "Asistentes" }, diff --git a/packages/ui/src/i18n/locales/es/host-org.json b/packages/ui/src/i18n/locales/es/host-org.json index be83db42..b025146d 100644 --- a/packages/ui/src/i18n/locales/es/host-org.json +++ b/packages/ui/src/i18n/locales/es/host-org.json @@ -32,17 +32,13 @@ "nav": { "events": "Eventos", "overview": "Resumen", - "members": "Miembros", - "payments": "Pagos" + "members": "Miembros" }, "overview": { "title": "Organización", "events": "Eventos", "members": "Miembros", - "created": "Creada", - "donations": "Donaciones", - "donations_on": "Acepta donaciones", - "donations_off": "No acepta donaciones" + "created": "Creada" }, "members": { "title": "Miembros", diff --git a/packages/ui/src/i18n/locales/es/host-payments.json b/packages/ui/src/i18n/locales/es/host-payments.json deleted file mode 100644 index 5c07d9e4..00000000 --- a/packages/ui/src/i18n/locales/es/host-payments.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "no_access_title": "Los pagos son para propietarios y administradores de la organización", - "no_access_body": "Pide acceso a una persona propietaria de esta organización si lo necesitas.", - "connect": { - "title": "Cuenta de cobro", - "start": "Conectar con Stripe", - "continue": "Continuar con la configuración", - "fix": "Resolverlo en Stripe", - "open_dashboard": "Abrir el panel de Stripe", - "review_agreement": "Revisar el acuerdo", - "agreement_required": "Acepta el acuerdo de donaciones antes de conectar una cuenta de cobro.", - "agreement_first": "Acepta primero el acuerdo de donaciones vigente.", - "not_eligible": "Esta organización todavía no cumple los requisitos para aceptar donaciones.", - "owner_only": "Solo la persona propietaria de la organización puede cambiar los ajustes de cobro.", - "requirements": "Stripe todavía necesita", - "requirement_past_due": "fuera de plazo", - "requirement_currently_due": "pendiente ahora", - "requirement_pending": "en verificación", - "deadline": "Fecha límite: {{when}}", - "charges": "Cobros", - "payouts": "Transferencias", - "enabled": "Activados", - "disabled": "Desactivados", - "wallets": "Monederos", - "wallets_none": "Ninguno todavía", - "last_synced": "Última sincronización", - "never": "Nunca", - "blocked_reason": "Stripe ha bloqueado esta cuenta ({{reason}}). Ponte en contacto con el soporte de civfix.", - "blocked_generic": "Stripe ha bloqueado esta cuenta. Ponte en contacto con el soporte de civfix.", - "state_not_started": "Todavía no hay cuenta de cobro. Conecta una para aceptar donaciones.", - "state_onboarding": "Stripe necesita algunos datos más antes de que esta cuenta pueda recibir pagos.", - "state_ready": "Esta cuenta puede aceptar donaciones.", - "state_at_risk": "Stripe necesitará más información pronto o las transferencias podrían detenerse.", - "state_blocked": "Esta cuenta no puede aceptar donaciones ahora mismo." - }, - "return": { - "toast_title": "De vuelta de Stripe", - "toast_body": "Stripe confirma las cuentas en segundo plano. Lo estamos comprobando.", - "pending": "Esperando la confirmación de Stripe. Puede tardar un minuto." - }, - "refresh": { - "toast_title": "Ese enlace de Stripe ha caducado", - "toast_body": "Vuelve a empezar la configuración para obtener un enlace nuevo." - }, - "eligibility": { - "title": "Requisitos como entidad sin ánimo de lucro", - "legal_name": "Nombre legal según el IRS", - "ein": "EIN", - "deductibility": "Código de deducibilidad", - "evaluated_at": "Última comprobación", - "next_check": "Próxima comprobación", - "grace_expires": "El periodo de gracia termina", - "reasons": "Por qué", - "evidence": "Pruebas", - "revision": "lista de {{revision}}", - "verdict_eligible": "Cumple los requisitos", - "verdict_grace": "Periodo de gracia", - "verdict_ineligible": "No cumple los requisitos", - "verdict_review_required": "Requiere revisión", - "verdict_unknown": "Sin comprobar todavía", - "contribution_supports": "respalda los requisitos", - "contribution_disqualifies": "impide cumplir los requisitos", - "contribution_neutral": "sin efecto", - "source_irs_pub78": "Publicación 78 del IRS", - "source_irs_eo_bmf": "Archivo de organizaciones exentas del IRS", - "source_irs_auto_revocation": "Lista de revocaciones automáticas del IRS", - "source_ftb_revoked": "Revocaciones de la FTB de California", - "source_ca_ag_mnos": "Registro de la Fiscalía General de California", - "source_ofac_sdn": "Lista de sanciones de la OFAC", - "source_central_org_confirmation": "Confirmación de la organización matriz", - "remediation_grace": "Resuelve la inscripción en el registro de California antes de que acabe el periodo de gracia o las donaciones se detendrán.", - "remediation_ineligible": "No se pueden activar las donaciones hasta que se restablezca la condición de entidad sin ánimo de lucro de la organización.", - "remediation_review_required": "civfix está revisando esta organización. Nos pondremos en contacto contigo.", - "remediation_unknown": "Todavía no se han comprobado los requisitos. Esta comprobación se hace automáticamente." - }, - "agreement": { - "title": "Acuerdo de donaciones", - "version": "Versión {{version}}", - "version_unknown": "Cargando la versión vigente…", - "read_full": "Leer el acuerdo completo", - "section_who": "Quién recibe el dinero", - "body_who": "Tu organización es la destinataria del dinero y el comercio responsable de la transacción. civfix nunca custodia tus fondos.", - "section_fees": "Comisiones", - "body_fees": "civfix cobra una comisión de plataforma, siempre informada, sobre cada donación. Tu organización paga las comisiones del propio procesador de pagos.", - "section_duties": "Tus obligaciones", - "body_duties": "Confirmas la condición de entidad sin ánimo de lucro de la organización, avisas a civfix en un plazo de cinco días hábiles si cambia, y sigues siendo responsable de los recibos, los reembolsos y la declaración fiscal.", - "check_accept": "Acepto este acuerdo en nombre de la organización.", - "check_authority": "Estoy autorizado para aceptarlo en nombre de la organización.", - "accept": "Aceptar", - "accepted": "Acuerdo aceptado", - "close": "Cerrar", - "owner_only": "Solo la persona propietaria de la organización puede aceptarlo.", - "drift": "El acuerdo se actualizó mientras esto estaba abierto. Recarga y lee la nueva versión.", - "history": "Versión {{version}} aceptada el {{when}} por {{who}}" - }, - "settings": { - "title": "Ajustes de donaciones", - "enable": "Aceptar donaciones", - "enable_caption": "Muestra un botón de donar en las páginas de tu organización y de tus eventos.", - "enable_unavailable": "No se pueden activar las donaciones hasta que la cuenta de cobro esté lista, la organización cumpla los requisitos y se acepte el acuerdo vigente.", - "operator_locked": "civfix ha desactivado las donaciones de esta organización. Ponte en contacto con el soporte.", - "owner_only": "Solo la persona propietaria de la organización puede cambiar esto.", - "review_agreement": "Revisar", - "blocker_account": "La cuenta de cobro no está lista.", - "blocker_eligibility": "No se ha confirmado el cumplimiento de los requisitos como entidad sin ánimo de lucro.", - "blocker_agreement": "No se ha aceptado el acuerdo de donaciones vigente.", - "blocker_operator": "civfix ha desactivado las donaciones de esta organización.", - "legal_name": "Nombre legal", - "ein": "EIN", - "fee_disclosure": "civfix cobra el {{percent}}% de cada donación. Tu organización paga además las comisiones del procesador de pagos. Quien dona siempre ve el desglose completo antes de pagar.", - "mission": "Texto sobre la misión", - "designation": "A qué se destinan las donaciones", - "refund_policy": "Política de reembolsos", - "refund_policy_hint": "Se muestra a quien dona antes de pagar.", - "min_amount": "Donación mínima", - "max_amount": "Donación máxima", - "suggested": "Importes sugeridos", - "suggested_hint": "Hasta {{max}} importes, separados por comas.", - "current_range": "Actualmente de {{min}} a {{max}}", - "sharing_default": "Sugerir compartir los datos de quien dona", - "sharing_default_caption": "Quien dona siempre decide. Esto solo define cómo aparece la casilla al principio y, por ley, aparece desmarcada.", - "saved": "Ajustes de donaciones guardados", - "refund_policy_error": "Una política de reembolso no puede afirmar que el 100 % de una donación llega a la organización: siempre se descuenta la comisión del procesador de pagos, y la página de donación muestra el reparto real." - }, - "column": { - "date": "Fecha", - "reference": "Referencia", - "amount": "Importe", - "platform_fee": "Comisión de civfix", - "processing_fee": "Comisión de procesamiento", - "net": "Neto", - "donor": "Donante", - "status": "Estado", - "receipt": "Recibo enviado" - }, - "donation_status": { - "succeeded": "Completada", - "refunded": "Reembolsada", - "partially_refunded": "Reembolsada en parte", - "failed": "Fallida", - "pending": "Pendiente" - }, - "report": { - "title": "Donaciones", - "table_caption": "Donaciones a esta organización", - "anonymous": "Donante anónimo", - "donor_identity_note": "El nombre y el correo de quien dona solo aparecen si esa persona eligió compartirlos. Todo lo demás es una donación anónima.", - "all_statuses": "Todos", - "filter_status": "Estado", - "filter_from": "Desde", - "filter_to": "Hasta", - "total_gross": "Total recaudado", - "total_platform_fee": "Comisiones de civfix", - "total_processing_fee": "Comisiones de procesamiento", - "total_net": "Neto para ti", - "donations_one": "{{count}} donación", - "donations_other": "{{count}} donaciones", - "export_visible": "Exportar lo que se muestra", - "csv_title": "{{org}} — donaciones", - "empty_title": "Todavía no hay donaciones", - "empty_body": "Las donaciones aparecen aquí en cuanto alguien da.", - "loaded_pages_note": "Incluye solo las filas cargadas en pantalla al generar este archivo, no el historial completo." - }, - "csv": { - "source": "Fuente", - "reference": "Referencia", - "generated_at": "Generado", - "filters": "Filtros", - "none": "ninguno", - "suppression_k": "Umbral de supresión (k)", - "note": "Nota" - }, - "exports": { - "title": "Exportaciones de donaciones", - "hint": "Un CSV con todo el registro de donaciones de la organización, no solo las filas en pantalla.", - "request": "Solicitar exportación completa", - "requested": "Exportación solicitada", - "requested_hint": "Aparecerá abajo en cuanto esté lista.", - "empty_title": "Todavía no hay exportaciones", - "empty_body": "Solicita una y aparecerá aquí.", - "download": "Descargar", - "expires": "Disponible hasta {{when}}", - "expired": "Caducada: solicita una nueva", - "rows_one": "{{count}} fila", - "rows_other": "{{count}} filas", - "truncated": "Truncada", - "note": "Una exportación incluye nombres y correos de donantes cuando ellos eligieron compartirlos. El enlace de descarga dura poco y el archivo se elimina al caducar.", - "failed_reason": "Error: {{code}}" - } -} diff --git a/packages/ui/src/i18n/locales/es/nav.json b/packages/ui/src/i18n/locales/es/nav.json index 187f64e7..9fe7cf34 100644 --- a/packages/ui/src/i18n/locales/es/nav.json +++ b/packages/ui/src/i18n/locales/es/nav.json @@ -39,7 +39,6 @@ "host_log_hours": "Registrar horas de voluntariado", "my_ticket": "Tu entrada", "org": "Organización", - "my_donations": "Tus donaciones", "event_dashboard": "Panel de eventos" }, "tab": { diff --git a/packages/ui/src/i18n/locales/es/settings.json b/packages/ui/src/i18n/locales/es/settings.json index a1e25dec..b8ee2940 100644 --- a/packages/ui/src/i18n/locales/es/settings.json +++ b/packages/ui/src/i18n/locales/es/settings.json @@ -33,9 +33,5 @@ "support_civfix": "Apoya a civfix", "terms": "Términos del servicio", "privacy_policy": "Política de privacidad", - "sign_out": "Cerrar sesión", - "donations": { - "label": "Donaciones", - "sub": "Tu historial de donaciones y tus recibos" - } + "sign_out": "Cerrar sesión" } diff --git a/packages/ui/src/i18n/locales/ko/donation-link.json b/packages/ui/src/i18n/locales/ko/donation-link.json new file mode 100644 index 00000000..20ced888 --- /dev/null +++ b/packages/ui/src/i18n/locales/ko/donation-link.json @@ -0,0 +1,26 @@ +{ + "card": { + "title": "기부하기", + "supports": "{{name}} 후원", + "open": "기부 페이지 열기", + "open_a11y": "{{host}}에서 {{name}}의 기부 페이지 열기" + }, + "editor": { + "title": "기부 링크", + "add": "사람들이 기부할 수 있는 링크 추가", + "hint": "본인의 기부 또는 모금 페이지 링크를 붙여넣으세요. 프로필과 주최하는 이벤트에 표시됩니다. civfix는 금전을 처리하지 않습니다.", + "field_label": "기부 링크", + "placeholder": "https://", + "invalid": "https://로 시작하는 전체 링크여야 합니다", + "remove": "링크 삭제", + "save": "저장", + "dismiss": "기부 링크 닫기", + "error": "링크를 저장하지 못했습니다. 다시 시도하세요." + }, + "dashboard": { + "row": "기부 링크", + "row_set": "이벤트와 페이지에 표시 중: {{host}}", + "row_unset": "아직 설정되지 않음", + "row_org_web": "웹의 단체 설정에서 설정하세요" + } +} diff --git a/packages/ui/src/i18n/locales/ko/donations.json b/packages/ui/src/i18n/locales/ko/donations.json deleted file mode 100644 index c3d515a9..00000000 --- a/packages/ui/src/i18n/locales/ko/donations.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "block": { - "heading": "이 단체를 후원하세요", - "body": "후원금은 {{org}}에 전달됩니다. civfix는 후원금을 전달할 뿐 보관하지 않습니다.", - "cta": "{{org}}에 후원하기", - "cta_a11y": "{{org}}에 후원하기", - "merchant_of_record": "civfix는 결제를 중개합니다. 판매자(Merchant of Record)는 {{org}}입니다.", - "fee_note": "단체가 부담하는 카드 결제 수수료와 civfix 플랫폼 수수료 5%가 적용됩니다." - }, - "list": { - "load_more": "더 보기", - "loading_more": "불러오는 중…", - "load_more_a11y": "후원 내역 더 불러오기", - "footnote": "영수증은 civfix가 단체의 위임 대리인으로서 발행합니다. 환불은 단체가 처리합니다." - }, - "row": { - "refunded": "{{amount}} 환불됨" - }, - "receipt": { - "download": "영수증 보기", - "download_a11y": "이 후원의 영수증 열기", - "error": "영수증을 가져오지 못했습니다. 다시 시도해 주세요.", - "unavailable": "이 기기에서는 영수증을 열 수 없습니다." - }, - "state": { - "loading": "후원 내역을 불러오는 중…", - "error_title": "후원 내역을 사용할 수 없습니다", - "error_body": "후원 내역을 불러오지 못했습니다. 연결 상태를 확인한 뒤 다시 시도해 주세요.", - "empty_title": "아직 후원 내역이 없습니다", - "empty_body": "civfix에서 단체에 후원하면 영수증과 함께 여기에 표시됩니다.", - "signed_out_title": "로그인하면 후원 내역을 볼 수 있습니다", - "signed_out_body": "후원 내역과 영수증은 계정에 연결되어 있습니다." - } -} diff --git a/packages/ui/src/i18n/locales/ko/enums.json b/packages/ui/src/i18n/locales/ko/enums.json index 1c8b7853..ca435d77 100644 --- a/packages/ui/src/i18n/locales/ko/enums.json +++ b/packages/ui/src/i18n/locales/ko/enums.json @@ -153,33 +153,6 @@ "suppressed": "차단됨", "skipped": "건너뜀" }, - "donationStatus": { - "pending": "처리 중", - "succeeded": "완료", - "failed": "실패", - "refunded": "환불됨", - "partially_refunded": "부분 환불" - }, - "donationDisputeState": { - "none": "없음", - "open": "진행 중", - "won": "승소", - "lost": "패소", - "warning": "경고" - }, - "orgPaymentsState": { - "not_started": "시작 전", - "onboarding": "진행 중", - "ready": "준비 완료", - "at_risk": "확인 필요", - "blocked": "차단됨" - }, - "donateState": { - "READY": "후원 받는 중", - "AT_RISK": "확인 필요", - "BLOCKED": "차단됨", - "OFF": "사용 안 함" - }, "attendeeKind": { "member": "회원", "guest": "게스트" diff --git a/packages/ui/src/i18n/locales/ko/event-dashboard.json b/packages/ui/src/i18n/locales/ko/event-dashboard.json index 464c71ac..cc2975f3 100644 --- a/packages/ui/src/i18n/locales/ko/event-dashboard.json +++ b/packages/ui/src/i18n/locales/ko/event-dashboard.json @@ -119,50 +119,6 @@ "error_body": "civfix에 연결하지 못했어요. 연결 상태를 확인하고 다시 시도해 주세요.", "retry": "다시 시도" }, - "money": { - "section": "정산", - "dash": "-", - "available": "출금 가능", - "pending": "처리 중", - "not_connected": "기부금을 받고 은행으로 송금하려면 은행 계좌를 연결하세요.", - "connect": "출금 설정", - "connect_owner_only": "소유자만 출금을 설정할 수 있습니다.", - "error_title": "출금 정보를 불러오지 못했습니다", - "error_body": "civfix에 연결하지 못했습니다. 연결 상태를 확인한 뒤 다시 시도해 주세요.", - "retry": "다시 시도", - "balance_error": "잔액을 지금 불러오지 못했습니다.", - "summary_error": "기부 합계를 지금 불러오지 못했습니다.", - "donations_total": "이 기간 기부 {{count}}건, {{amount}}", - "schedule_daily": "Stripe가 매일 자동으로 지급합니다.", - "schedule_weekly": "Stripe가 매주 자동으로 지급합니다.", - "schedule_monthly": "Stripe가 매월 자동으로 지급합니다.", - "schedule_manual": "직접 보낼 때만 출금됩니다.", - "payout": "은행으로 보내기", - "payout_blocked_disabled": "Stripe가 이 계정의 출금을 아직 허용하지 않았습니다.", - "payout_blocked_empty": "아직 보낼 수 있는 금액이 없습니다.", - "payout_owner_only": "소유자만 은행으로 송금할 수 있습니다.", - "payout_started": "송금을 시작했습니다", - "payout_error_invalid": "Stripe가 해당 금액을 받아들이지 않았습니다.", - "payout_error_conflict": "Stripe가 지금은 송금할 수 없습니다.", - "payout_error_forbidden": "송금할 권한이 없습니다.", - "payout_error_rate_limited": "지금은 송금이 너무 많습니다. 나중에 다시 시도해 주세요.", - "payout_error_generic": "송금을 시작하지 못했습니다. 다시 시도해 주세요.", - "recent_payouts": "최근 송금", - "payouts_error": "최근 송금 내역을 불러오지 못했습니다.", - "payout_status_pending": "대기 중", - "payout_status_in_transit": "처리 중", - "payout_status_paid": "완료", - "payout_status_failed": "실패", - "payout_status_canceled": "취소됨", - "confirm_title": "은행으로 송금", - "confirm_body": "{{amount}}이(가) 등록된 은행 계좌로 이체됩니다. Stripe는 보통 영업일 기준 며칠이 걸립니다.", - "confirm_action": "보내기", - "confirm_dismiss_a11y": "송금 닫기", - "link_unavailable": "이 기기에서는 설정 페이지를 열 수 없습니다.", - "link_error": "설정 페이지를 열지 못했습니다. 다시 시도해 주세요.", - "range_a11y": "정산 기간", - "sent_on": "{{date}} 송금" - }, "team": { "section": "팀", "invite": "초대", @@ -175,7 +131,7 @@ "handle": "아이디", "email": "이메일 주소", "invite_role": "역할", - "role_hint_admin": "이벤트를 주최하고, 사람을 초대하고, 기부 내역을 볼 수 있습니다.", + "role_hint_admin": "이벤트를 주최하고 사람을 초대할 수 있습니다.", "role_hint_member": "단체 이름으로 이벤트를 주최할 수 있습니다.", "invite_privacy_note": "초대한 사람과 단체 이름을 상대방에게 알려 줍니다.", "invite_sent": "초대를 보냈습니다", diff --git a/packages/ui/src/i18n/locales/ko/host-common.json b/packages/ui/src/i18n/locales/ko/host-common.json index c9621d7f..0cfd7b60 100644 --- a/packages/ui/src/i18n/locales/ko/host-common.json +++ b/packages/ui/src/i18n/locales/ko/host-common.json @@ -142,8 +142,7 @@ "forbidden": "그 작업을 수행할 권한이 없습니다.", "conflict": "다른 사람이 먼저 변경했습니다. 새로고침한 뒤 다시 시도해 주세요.", "not_found": "해당 항목을 찾지 못했습니다.", - "abuse_held": "전송 전에 검토를 진행하고 있습니다.", - "payment_unavailable": "결제를 일시적으로 사용할 수 없습니다. 청구된 금액은 없습니다." + "abuse_held": "전송 전에 검토를 진행하고 있습니다." }, "crash": { "title": "콘솔에 문제가 발생했습니다", diff --git a/packages/ui/src/i18n/locales/ko/host-exports.json b/packages/ui/src/i18n/locales/ko/host-exports.json index 211a3108..4154122c 100644 --- a/packages/ui/src/i18n/locales/ko/host-exports.json +++ b/packages/ui/src/i18n/locales/ko/host-exports.json @@ -12,7 +12,6 @@ "kind": { "roster": "참가자", "answers": "답변", - "checkins": "체크인", - "donations": "기부" + "checkins": "체크인" } } diff --git a/packages/ui/src/i18n/locales/ko/host-mode.json b/packages/ui/src/i18n/locales/ko/host-mode.json index c6bcc9f1..3be37b77 100644 --- a/packages/ui/src/i18n/locales/ko/host-mode.json +++ b/packages/ui/src/i18n/locales/ko/host-mode.json @@ -62,7 +62,6 @@ "hours_hint": "참가자 {{attended}}명 중 {{credited}}명 인정", "hours_hint_credited_one": "참가자 {{count}}명 인정", "hours_hint_credited_other": "참가자 {{count}}명 인정", - "donations": "기부", "returning": "재참여", "returning_hint": "등록 {{total}}명 중 이전에 참여한 인원" }, @@ -92,17 +91,11 @@ "row": "발송 {{sent}} · 실패 {{failed}} · 제외 {{suppressed}}", "pending": "아직 발송되지 않음" }, - "money": { - "net": "이 행사로 모인 금액", - "donations": "기부 건수", - "gross": "수수료 전 {{amount}}" - }, "section": { "grow": "알리기", "communicate": "소통", "operate": "현장 운영", "configure": "설정", - "money": "정산", "danger": "위험 구역", "attendees": "참가자" }, diff --git a/packages/ui/src/i18n/locales/ko/host-org.json b/packages/ui/src/i18n/locales/ko/host-org.json index 21ba08e0..5121d3ca 100644 --- a/packages/ui/src/i18n/locales/ko/host-org.json +++ b/packages/ui/src/i18n/locales/ko/host-org.json @@ -29,17 +29,13 @@ "nav": { "events": "행사", "overview": "개요", - "members": "구성원", - "payments": "결제" + "members": "구성원" }, "overview": { "title": "단체", "events": "행사", "members": "구성원", - "created": "생성일", - "donations": "기부", - "donations_on": "기부를 받는 중", - "donations_off": "기부를 받지 않음" + "created": "생성일" }, "members": { "title": "구성원", diff --git a/packages/ui/src/i18n/locales/ko/host-payments.json b/packages/ui/src/i18n/locales/ko/host-payments.json deleted file mode 100644 index 0b0109b7..00000000 --- a/packages/ui/src/i18n/locales/ko/host-payments.json +++ /dev/null @@ -1,188 +0,0 @@ -{ - "no_access_title": "결제 설정은 단체 소유자와 관리자만 볼 수 있습니다", - "no_access_body": "접근 권한이 필요하면 이 단체의 소유자에게 요청하세요.", - "connect": { - "title": "정산 계정", - "start": "Stripe 연결", - "continue": "설정 이어서 하기", - "fix": "Stripe에서 해결하기", - "open_dashboard": "Stripe 대시보드 열기", - "review_agreement": "약정 검토", - "agreement_required": "정산 계정을 연결하기 전에 기부 약정에 동의해 주세요.", - "agreement_first": "먼저 현재 기부 약정에 동의해 주세요.", - "not_eligible": "이 단체는 아직 기부를 받을 수 있는 자격 요건을 충족하지 않았습니다.", - "owner_only": "정산 설정은 단체 소유자만 변경할 수 있습니다.", - "requirements": "Stripe에 아직 필요한 항목", - "requirement_past_due": "기한 초과", - "requirement_currently_due": "지금 제출 필요", - "requirement_pending": "확인 중", - "deadline": "기한: {{when}}", - "charges": "결제 수납", - "payouts": "정산 지급", - "enabled": "사용", - "disabled": "사용 안 함", - "wallets": "간편결제", - "wallets_none": "아직 없음", - "last_synced": "마지막 동기화", - "never": "없음", - "blocked_reason": "Stripe가 이 계정을 차단했습니다({{reason}}). civfix 고객지원에 문의해 주세요.", - "blocked_generic": "Stripe가 이 계정을 차단했습니다. civfix 고객지원에 문의해 주세요.", - "state_not_started": "아직 정산 계정이 없습니다. 기부를 받으려면 계정을 연결하세요.", - "state_onboarding": "이 계정으로 결제를 받으려면 Stripe에 몇 가지 정보를 더 제출해야 합니다.", - "state_ready": "이 계정으로 기부를 받을 수 있습니다.", - "state_at_risk": "Stripe가 곧 추가 정보를 요구합니다. 제출하지 않으면 정산이 중단될 수 있습니다.", - "state_blocked": "이 계정으로는 지금 기부를 받을 수 없습니다." - }, - "return": { - "toast_title": "Stripe에서 돌아왔습니다", - "toast_body": "Stripe는 백그라운드에서 계정을 확인합니다. 지금 확인하고 있습니다.", - "pending": "Stripe의 확인을 기다리고 있습니다. 1분 정도 걸릴 수 있습니다." - }, - "refresh": { - "toast_title": "Stripe 링크가 만료되었습니다", - "toast_body": "설정을 다시 시작하면 새 링크를 받을 수 있습니다." - }, - "eligibility": { - "title": "비영리 자격 요건", - "legal_name": "IRS 등록 법인명", - "ein": "EIN", - "deductibility": "공제 코드", - "evaluated_at": "최근 확인", - "next_check": "다음 확인", - "grace_expires": "유예 기간 종료", - "reasons": "사유", - "evidence": "근거 자료", - "revision": "{{revision}} 기준 목록", - "verdict_eligible": "자격 충족", - "verdict_grace": "유예 기간", - "verdict_ineligible": "자격 미충족", - "verdict_review_required": "검토 필요", - "verdict_unknown": "아직 확인 전", - "contribution_supports": "자격을 뒷받침함", - "contribution_disqualifies": "자격을 막음", - "contribution_neutral": "영향 없음", - "source_irs_pub78": "IRS Publication 78", - "source_irs_eo_bmf": "IRS 면세단체 명부", - "source_irs_auto_revocation": "IRS 자동 취소 목록", - "source_ftb_revoked": "캘리포니아 FTB 취소 명단", - "source_ca_ag_mnos": "캘리포니아 법무장관실 등록부", - "source_ofac_sdn": "OFAC 제재 명단", - "source_central_org_confirmation": "상위 단체 확인", - "remediation_grace": "유예 기간이 끝나기 전에 캘리포니아 등록부 문제를 해결하지 않으면 기부가 중단됩니다.", - "remediation_ineligible": "단체의 비영리 지위가 회복되기 전에는 기부를 활성화할 수 없습니다.", - "remediation_review_required": "civfix가 이 단체를 검토하고 있습니다. 결과가 나오면 연락드리겠습니다.", - "remediation_unknown": "자격 요건을 아직 확인하지 않았습니다. 확인은 자동으로 진행됩니다." - }, - "agreement": { - "title": "기부 약정", - "version": "버전 {{version}}", - "version_unknown": "현재 버전을 불러오는 중…", - "read_full": "약정 전문 읽기", - "section_who": "누가 기부금을 받는지", - "body_who": "기부금을 받는 주체이자 결제상의 판매자는 단체입니다. civfix는 단체의 자금을 보유하지 않습니다.", - "section_fees": "수수료", - "body_fees": "civfix는 기부 건마다 사전에 공개된 플랫폼 수수료를 부과합니다. 결제 대행사 자체 수수료는 단체가 부담합니다.", - "section_duties": "단체의 의무", - "body_duties": "단체의 비영리 지위를 확인하고, 변경이 생기면 영업일 기준 5일 이내에 civfix에 알리며, 영수증·환불·세무 신고에 대한 책임은 계속 단체에 있습니다.", - "check_accept": "단체를 대표하여 이 약정에 동의합니다.", - "check_authority": "단체를 대표해 동의할 권한이 있습니다.", - "accept": "동의", - "accepted": "약정에 동의했습니다", - "close": "닫기", - "owner_only": "단체 소유자만 동의할 수 있습니다.", - "drift": "이 화면을 열어 둔 사이 약정이 변경되었습니다. 새로고침한 뒤 새 버전을 읽어 주세요.", - "history": "{{who}} 님이 {{when}}에 버전 {{version}}에 동의함" - }, - "settings": { - "title": "기부 설정", - "enable": "기부 받기", - "enable_caption": "단체 페이지와 행사 페이지에 기부 버튼이 표시됩니다.", - "enable_unavailable": "정산 계정이 준비되고, 단체가 자격 요건을 충족하고, 현재 약정에 동의하기 전에는 기부를 켤 수 없습니다.", - "operator_locked": "civfix가 이 단체의 기부 기능을 비활성화했습니다. 고객지원에 문의해 주세요.", - "owner_only": "단체 소유자만 변경할 수 있습니다.", - "review_agreement": "검토", - "blocker_account": "정산 계정이 준비되지 않았습니다.", - "blocker_eligibility": "비영리 자격 요건이 확인되지 않았습니다.", - "blocker_agreement": "현재 기부 약정에 동의하지 않았습니다.", - "blocker_operator": "civfix가 이 단체의 기부 기능을 비활성화했습니다.", - "legal_name": "법인명", - "ein": "EIN", - "fee_disclosure": "civfix는 기부 금액의 {{percent}}%를 수수료로 받습니다. 결제 대행사 수수료는 단체가 별도로 부담합니다. 기부자는 결제 전에 항상 전체 내역을 확인합니다.", - "mission": "단체 소개 문구", - "designation": "기부금의 사용처", - "refund_policy": "환불 정책", - "refund_policy_hint": "기부자가 결제하기 전에 표시됩니다.", - "min_amount": "최소 기부 금액", - "max_amount": "최대 기부 금액", - "suggested": "추천 금액", - "suggested_hint": "쉼표로 구분해 최대 {{max}}개까지 입력할 수 있습니다.", - "current_range": "현재 {{min}}부터 {{max}}까지", - "sharing_default": "기부자 정보 공유를 기본 제안", - "sharing_default_caption": "선택은 언제나 기부자가 합니다. 이 설정은 체크박스의 초기 상태만 정하며, 법에 따라 기본값은 해제입니다.", - "saved": "기부 설정을 저장했습니다", - "refund_policy_error": "환불 정책에 기부금의 100%가 단체에 전달된다고 적을 수 없습니다. 결제 대행 수수료는 항상 차감되며, 기부 페이지에 실제 배분이 표시됩니다." - }, - "column": { - "date": "날짜", - "reference": "참조 번호", - "amount": "금액", - "platform_fee": "civfix 수수료", - "processing_fee": "결제 수수료", - "net": "순액", - "donor": "기부자", - "status": "상태", - "receipt": "영수증 발송" - }, - "donation_status": { - "succeeded": "완료", - "refunded": "환불됨", - "partially_refunded": "부분 환불", - "failed": "실패", - "pending": "처리 중" - }, - "report": { - "title": "기부", - "table_caption": "이 단체에 들어온 기부", - "anonymous": "익명 기부자", - "donor_identity_note": "기부자의 이름과 이메일은 본인이 공개하기로 선택한 경우에만 표시됩니다. 그 외에는 모두 익명 기부입니다.", - "all_statuses": "전체", - "filter_status": "상태", - "filter_from": "시작", - "filter_to": "종료", - "total_gross": "총 모금액", - "total_platform_fee": "civfix 수수료", - "total_processing_fee": "결제 수수료", - "total_net": "단체 수령액", - "donations_other": "기부 {{count}}건", - "export_visible": "표시된 내역 내보내기", - "csv_title": "{{org}} — 기부", - "empty_title": "아직 기부가 없습니다", - "empty_body": "누군가 기부하면 여기에 바로 표시됩니다.", - "loaded_pages_note": "이 파일을 만들 때 화면에 불러온 행만 포함하며, 전체 내역은 아닙니다." - }, - "csv": { - "source": "출처", - "reference": "참조 번호", - "generated_at": "생성 시각", - "filters": "필터", - "none": "없음", - "suppression_k": "비공개 처리 기준 (k)", - "note": "참고" - }, - "exports": { - "title": "기부 내보내기", - "hint": "화면에 보이는 행만이 아니라 단체의 전체 기부 원장을 담은 CSV입니다.", - "request": "전체 내보내기 요청", - "requested": "내보내기를 요청했습니다", - "requested_hint": "준비되는 대로 아래에 표시됩니다.", - "empty_title": "아직 내보내기가 없습니다", - "empty_body": "요청하면 여기에 표시됩니다.", - "download": "다운로드", - "expires": "{{when}}까지 이용 가능", - "expired": "만료됨 — 새로 요청하세요", - "rows_other": "{{count}}행", - "truncated": "잘림", - "note": "내보내기에는 기부자가 공유를 선택한 경우 이름과 이메일이 포함됩니다. 다운로드 링크는 곧 만료되며 파일도 만료 시 삭제됩니다.", - "failed_reason": "실패: {{code}}" - } -} diff --git a/packages/ui/src/i18n/locales/ko/nav.json b/packages/ui/src/i18n/locales/ko/nav.json index a4ecea42..5666c493 100644 --- a/packages/ui/src/i18n/locales/ko/nav.json +++ b/packages/ui/src/i18n/locales/ko/nav.json @@ -38,7 +38,6 @@ "host_log_hours": "봉사 시간 기록", "my_ticket": "내 티켓", "org": "단체", - "my_donations": "내 후원 내역", "event_dashboard": "이벤트 대시보드" }, "tab": { diff --git a/packages/ui/src/i18n/locales/ko/settings.json b/packages/ui/src/i18n/locales/ko/settings.json index 1be2f269..4e6e7bf2 100644 --- a/packages/ui/src/i18n/locales/ko/settings.json +++ b/packages/ui/src/i18n/locales/ko/settings.json @@ -33,9 +33,5 @@ "support_civfix": "civfix 후원하기", "terms": "서비스 약관", "privacy_policy": "개인정보 처리방침", - "sign_out": "로그아웃", - "donations": { - "label": "후원", - "sub": "내 후원 내역과 영수증" - } + "sign_out": "로그아웃" } diff --git a/packages/ui/src/i18n/resources.ts b/packages/ui/src/i18n/resources.ts index b77a1f6a..4e20b5ca 100644 --- a/packages/ui/src/i18n/resources.ts +++ b/packages/ui/src/i18n/resources.ts @@ -23,7 +23,7 @@ import en_conversation_reactions from "./locales/en/conversation-reactions.json" import en_discussion from "./locales/en/discussion.json" import en_discussion_composer from "./locales/en/discussion-composer.json" import en_discussion_delete from "./locales/en/discussion-delete.json" -import en_donations from "./locales/en/donations.json" +import en_donation_link from "./locales/en/donation-link.json" import en_enums from "./locales/en/enums.json" import en_event_bring from "./locales/en/event-bring.json" import en_event_cancel from "./locales/en/event-cancel.json" @@ -53,7 +53,6 @@ import en_host_exports from "./locales/en/host-exports.json" import en_host_mode from "./locales/en/host-mode.json" import en_host_org from "./locales/en/host-org.json" import en_host_page_builder from "./locales/en/host-page-builder.json" -import en_host_payments from "./locales/en/host-payments.json" import en_host_portfolio from "./locales/en/host-portfolio.json" import en_host_settings from "./locales/en/host-settings.json" import en_host_team from "./locales/en/host-team.json" @@ -90,8 +89,8 @@ import en_report_cluster from "./locales/en/report-cluster.json" import en_report_content from "./locales/en/report-content.json" import en_report_detail from "./locales/en/report-detail.json" import en_report_linked from "./locales/en/report-linked.json" -import en_report_picker from "./locales/en/report-picker.json" import en_report_list from "./locales/en/report-list.json" +import en_report_picker from "./locales/en/report-picker.json" import en_report_row from "./locales/en/report-row.json" import en_report_wizard from "./locales/en/report-wizard.json" import en_search from "./locales/en/search.json" @@ -122,7 +121,7 @@ import es_conversation_reactions from "./locales/es/conversation-reactions.json" import es_discussion from "./locales/es/discussion.json" import es_discussion_composer from "./locales/es/discussion-composer.json" import es_discussion_delete from "./locales/es/discussion-delete.json" -import es_donations from "./locales/es/donations.json" +import es_donation_link from "./locales/es/donation-link.json" import es_enums from "./locales/es/enums.json" import es_event_bring from "./locales/es/event-bring.json" import es_event_cancel from "./locales/es/event-cancel.json" @@ -152,7 +151,6 @@ import es_host_exports from "./locales/es/host-exports.json" import es_host_mode from "./locales/es/host-mode.json" import es_host_org from "./locales/es/host-org.json" import es_host_page_builder from "./locales/es/host-page-builder.json" -import es_host_payments from "./locales/es/host-payments.json" import es_host_portfolio from "./locales/es/host-portfolio.json" import es_host_settings from "./locales/es/host-settings.json" import es_host_team from "./locales/es/host-team.json" @@ -189,8 +187,8 @@ import es_report_cluster from "./locales/es/report-cluster.json" import es_report_content from "./locales/es/report-content.json" import es_report_detail from "./locales/es/report-detail.json" import es_report_linked from "./locales/es/report-linked.json" -import es_report_picker from "./locales/es/report-picker.json" import es_report_list from "./locales/es/report-list.json" +import es_report_picker from "./locales/es/report-picker.json" import es_report_row from "./locales/es/report-row.json" import es_report_wizard from "./locales/es/report-wizard.json" import es_search from "./locales/es/search.json" @@ -221,7 +219,7 @@ import de_conversation_reactions from "./locales/de/conversation-reactions.json" import de_discussion from "./locales/de/discussion.json" import de_discussion_composer from "./locales/de/discussion-composer.json" import de_discussion_delete from "./locales/de/discussion-delete.json" -import de_donations from "./locales/de/donations.json" +import de_donation_link from "./locales/de/donation-link.json" import de_enums from "./locales/de/enums.json" import de_event_bring from "./locales/de/event-bring.json" import de_event_cancel from "./locales/de/event-cancel.json" @@ -251,7 +249,6 @@ import de_host_exports from "./locales/de/host-exports.json" import de_host_mode from "./locales/de/host-mode.json" import de_host_org from "./locales/de/host-org.json" import de_host_page_builder from "./locales/de/host-page-builder.json" -import de_host_payments from "./locales/de/host-payments.json" import de_host_portfolio from "./locales/de/host-portfolio.json" import de_host_settings from "./locales/de/host-settings.json" import de_host_team from "./locales/de/host-team.json" @@ -288,8 +285,8 @@ import de_report_cluster from "./locales/de/report-cluster.json" import de_report_content from "./locales/de/report-content.json" import de_report_detail from "./locales/de/report-detail.json" import de_report_linked from "./locales/de/report-linked.json" -import de_report_picker from "./locales/de/report-picker.json" import de_report_list from "./locales/de/report-list.json" +import de_report_picker from "./locales/de/report-picker.json" import de_report_row from "./locales/de/report-row.json" import de_report_wizard from "./locales/de/report-wizard.json" import de_search from "./locales/de/search.json" @@ -320,7 +317,7 @@ import ko_conversation_reactions from "./locales/ko/conversation-reactions.json" import ko_discussion from "./locales/ko/discussion.json" import ko_discussion_composer from "./locales/ko/discussion-composer.json" import ko_discussion_delete from "./locales/ko/discussion-delete.json" -import ko_donations from "./locales/ko/donations.json" +import ko_donation_link from "./locales/ko/donation-link.json" import ko_enums from "./locales/ko/enums.json" import ko_event_bring from "./locales/ko/event-bring.json" import ko_event_cancel from "./locales/ko/event-cancel.json" @@ -350,7 +347,6 @@ import ko_host_exports from "./locales/ko/host-exports.json" import ko_host_mode from "./locales/ko/host-mode.json" import ko_host_org from "./locales/ko/host-org.json" import ko_host_page_builder from "./locales/ko/host-page-builder.json" -import ko_host_payments from "./locales/ko/host-payments.json" import ko_host_portfolio from "./locales/ko/host-portfolio.json" import ko_host_settings from "./locales/ko/host-settings.json" import ko_host_team from "./locales/ko/host-team.json" @@ -387,8 +383,8 @@ import ko_report_cluster from "./locales/ko/report-cluster.json" import ko_report_content from "./locales/ko/report-content.json" import ko_report_detail from "./locales/ko/report-detail.json" import ko_report_linked from "./locales/ko/report-linked.json" -import ko_report_picker from "./locales/ko/report-picker.json" import ko_report_list from "./locales/ko/report-list.json" +import ko_report_picker from "./locales/ko/report-picker.json" import ko_report_row from "./locales/ko/report-row.json" import ko_report_wizard from "./locales/ko/report-wizard.json" import ko_search from "./locales/ko/search.json" @@ -422,7 +418,7 @@ export const namespaces = [ "discussion", "discussion-composer", "discussion-delete", - "donations", + "donation-link", "enums", "event-bring", "event-cancel", @@ -452,7 +448,6 @@ export const namespaces = [ "host-mode", "host-org", "host-page-builder", - "host-payments", "host-portfolio", "host-settings", "host-team", @@ -489,8 +484,8 @@ export const namespaces = [ "report-content", "report-detail", "report-linked", - "report-picker", "report-list", + "report-picker", "report-row", "report-wizard", "search", @@ -526,7 +521,7 @@ export const resources: Resource = { "discussion": en_discussion, "discussion-composer": en_discussion_composer, "discussion-delete": en_discussion_delete, - "donations": en_donations, + "donation-link": en_donation_link, "enums": en_enums, "event-bring": en_event_bring, "event-cancel": en_event_cancel, @@ -556,7 +551,6 @@ export const resources: Resource = { "host-mode": en_host_mode, "host-org": en_host_org, "host-page-builder": en_host_page_builder, - "host-payments": en_host_payments, "host-portfolio": en_host_portfolio, "host-settings": en_host_settings, "host-team": en_host_team, @@ -593,8 +587,8 @@ export const resources: Resource = { "report-content": en_report_content, "report-detail": en_report_detail, "report-linked": en_report_linked, - "report-picker": en_report_picker, "report-list": en_report_list, + "report-picker": en_report_picker, "report-row": en_report_row, "report-wizard": en_report_wizard, "search": en_search, @@ -627,7 +621,7 @@ export const resources: Resource = { "discussion": es_discussion, "discussion-composer": es_discussion_composer, "discussion-delete": es_discussion_delete, - "donations": es_donations, + "donation-link": es_donation_link, "enums": es_enums, "event-bring": es_event_bring, "event-cancel": es_event_cancel, @@ -657,7 +651,6 @@ export const resources: Resource = { "host-mode": es_host_mode, "host-org": es_host_org, "host-page-builder": es_host_page_builder, - "host-payments": es_host_payments, "host-portfolio": es_host_portfolio, "host-settings": es_host_settings, "host-team": es_host_team, @@ -694,8 +687,8 @@ export const resources: Resource = { "report-content": es_report_content, "report-detail": es_report_detail, "report-linked": es_report_linked, - "report-picker": es_report_picker, "report-list": es_report_list, + "report-picker": es_report_picker, "report-row": es_report_row, "report-wizard": es_report_wizard, "search": es_search, @@ -728,7 +721,7 @@ export const resources: Resource = { "discussion": de_discussion, "discussion-composer": de_discussion_composer, "discussion-delete": de_discussion_delete, - "donations": de_donations, + "donation-link": de_donation_link, "enums": de_enums, "event-bring": de_event_bring, "event-cancel": de_event_cancel, @@ -758,7 +751,6 @@ export const resources: Resource = { "host-mode": de_host_mode, "host-org": de_host_org, "host-page-builder": de_host_page_builder, - "host-payments": de_host_payments, "host-portfolio": de_host_portfolio, "host-settings": de_host_settings, "host-team": de_host_team, @@ -795,8 +787,8 @@ export const resources: Resource = { "report-content": de_report_content, "report-detail": de_report_detail, "report-linked": de_report_linked, - "report-picker": de_report_picker, "report-list": de_report_list, + "report-picker": de_report_picker, "report-row": de_report_row, "report-wizard": de_report_wizard, "search": de_search, @@ -829,7 +821,7 @@ export const resources: Resource = { "discussion": ko_discussion, "discussion-composer": ko_discussion_composer, "discussion-delete": ko_discussion_delete, - "donations": ko_donations, + "donation-link": ko_donation_link, "enums": ko_enums, "event-bring": ko_event_bring, "event-cancel": ko_event_cancel, @@ -859,7 +851,6 @@ export const resources: Resource = { "host-mode": ko_host_mode, "host-org": ko_host_org, "host-page-builder": ko_host_page_builder, - "host-payments": ko_host_payments, "host-portfolio": ko_host_portfolio, "host-settings": ko_host_settings, "host-team": ko_host_team, @@ -896,8 +887,8 @@ export const resources: Resource = { "report-content": ko_report_content, "report-detail": ko_report_detail, "report-linked": ko_report_linked, - "report-picker": ko_report_picker, "report-list": ko_report_list, + "report-picker": ko_report_picker, "report-row": ko_report_row, "report-wizard": ko_report_wizard, "search": ko_search, diff --git a/packages/ui/src/nav/__tests__/hostRoutes.test.ts b/packages/ui/src/nav/__tests__/hostRoutes.test.ts index d7ab1098..91355bfb 100644 --- a/packages/ui/src/nav/__tests__/hostRoutes.test.ts +++ b/packages/ui/src/nav/__tests__/hostRoutes.test.ts @@ -13,7 +13,6 @@ const HOST_KINDS = [ "host-log-hours", "my-ticket", "org", - "my-donations", "event-dashboard", ] as const @@ -43,7 +42,6 @@ describe("URL round trip", () => { ["/cleanups/e1/ticket", { kind: "my-ticket", id: "e1" }], ["/cleanups/e1/ticket/seat-9", { kind: "my-ticket", id: "e1", seatId: "seat-9" }], ["/orgs/river-keepers", { kind: "org", slug: "river-keepers" }], - ["/me/donations", { kind: "my-donations" }], ] it.each(cases)("%s", (path, entry) => { @@ -105,7 +103,6 @@ describe("parent view + flow protection", () => { "host-log-hours", "my-ticket", "org", - "my-donations", "event-dashboard", ] as const) { expect(FLOW_KINDS.has(kind), kind).toBe(false) diff --git a/packages/ui/src/nav/routes.ts b/packages/ui/src/nav/routes.ts index 02da12c6..78ff1984 100644 --- a/packages/ui/src/nav/routes.ts +++ b/packages/ui/src/nav/routes.ts @@ -73,8 +73,6 @@ export function entryFromPath(path: string | null | undefined): DetailEntry | nu return id ? { kind: "cleanup", id } : null case "orgs": return id ? { kind: "org", slug: id } : null - case "me": - return id === "donations" ? { kind: "my-donations" } : null case "people": if (id && parts[2] === "followers") return { kind: "followers", id } if (id && parts[2] === "following") return { kind: "following", id } @@ -180,8 +178,6 @@ export function pathForEntry(entry: DetailEntry | null): string { : `/cleanups/${entry.id}/ticket` case "org": return entry.slug ? `/orgs/${entry.slug}` : "/" - case "my-donations": - return "/me/donations" case "create-cleanup": return "/host" case "people": @@ -341,8 +337,6 @@ export function titleForEntry(entry: DetailEntry | null): string { return "title.my_ticket" case "org": return "title.org" - case "my-donations": - return "title.my_donations" case "event-dashboard": return "title.event_dashboard" case "cluster": diff --git a/packages/ui/src/nav/types.ts b/packages/ui/src/nav/types.ts index 2f460f7b..f96014d6 100644 --- a/packages/ui/src/nav/types.ts +++ b/packages/ui/src/nav/types.ts @@ -56,7 +56,6 @@ export type DetailKind = | "host-log-hours" | "my-ticket" | "org" - | "my-donations" | "event-dashboard" export const ALL_DETAIL_KINDS = [ @@ -102,7 +101,6 @@ export const ALL_DETAIL_KINDS = [ "host-log-hours", "my-ticket", "org", - "my-donations", "event-dashboard", ] as const satisfies readonly DetailKind[] diff --git a/packages/ui/src/primitives/DonateBlock.tsx b/packages/ui/src/primitives/DonateBlock.tsx index 3030c62f..350d3e82 100644 --- a/packages/ui/src/primitives/DonateBlock.tsx +++ b/packages/ui/src/primitives/DonateBlock.tsx @@ -1,46 +1,43 @@ import React, { useCallback } from "react" import { View, Pressable, StyleSheet } from "react-native" -import type { DonateState } from "@civfix/shared" import { focusRingProps, makeThemedStyles, useTheme, webCursor, webHover, webTransition } from "../theme" import { Text, Icon, iconMap } from "../typography" import { useOpenExternal } from "../capabilities" import { useT } from "../i18n" +import { PrimaryButton } from "./PrimaryButton" import { openDonate } from "./donateTarget" - -export interface DonateBlockOrg { - slug: string - displayName: string - legalName?: string | null - verified?: boolean - donateState?: DonateState | null -} +import { donationUrlHost, safeDonationUrl } from "./donationUrl" export interface DonateBlockProps { - org: DonateBlockOrg - eventId?: string | null + url: string | null | undefined + ownerName: string variant?: "card" | "row" } -export function DonateBlock({ org, eventId, variant = "card" }: DonateBlockProps) { +export function DonateBlock({ url, ownerName, variant = "card" }: DonateBlockProps) { const styles = useStyles() const th = useTheme() - const { t } = useT("donations") + const { t } = useT("donation-link") const openExternal = useOpenExternal() + const safeUrl = safeDonationUrl(url) const onPress = useCallback(() => { - openDonate({ orgSlug: org.slug, eventId: eventId ?? null, openExternal }) - }, [eventId, openExternal, org.slug]) + if (!safeUrl) return + openDonate({ url: safeUrl, openExternal }) + }, [openExternal, safeUrl]) - if (org.donateState !== "READY") return null + if (!safeUrl) return null - const recipient = org.legalName?.trim() || org.displayName + const host = donationUrlHost(safeUrl) + const supports = t("card.supports", { name: ownerName }) + const openLabel = t("card.open_a11y", { name: ownerName, host }) if (variant === "row") { return ( [ styles.row, @@ -52,10 +49,12 @@ export function DonateBlock({ org, eventId, variant = "card" }: DonateBlockProps > - {t("block.cta", { org: recipient })} - {t("block.merchant_of_record", { org: recipient })} + {t("card.title")} + + {supports} · {host} + - + ) } @@ -64,26 +63,21 @@ export function DonateBlock({ org, eventId, variant = "card" }: DonateBlockProps - {t("block.heading")} + {t("card.title")} - {t("block.body", { org: recipient })} - {supports} + [ - styles.cta, - webTransition, - webCursor(), - webHover(state) ? styles.ctaHovered : null, - state.pressed ? styles.pressed : null, - ]} - > - {t("block.cta", { org: recipient })} - - {t("block.merchant_of_record", { org: recipient })} - {t("block.fee_note")} + accessibilityLabel={openLabel} + /> + + + + {host} + + ) } @@ -113,23 +107,13 @@ const useStyles = makeThemedStyles((t) => ({ lineHeight: 18, color: t.colors.textMuted, }, - cta: { - minHeight: 44, + hostRow: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", - borderRadius: t.radius.pill, - backgroundColor: t.colors.brand.bloom, - paddingHorizontal: t.space["5"], - }, - ctaHovered: { - opacity: 0.92, - }, - ctaLabel: { - fontFamily: t.fontFamily.bodyBold, - fontSize: t.fontSize["14"], - color: t.colors.onAccent, + gap: t.space["1"], }, - mor: { + host: { + flexShrink: 1, fontFamily: t.fontFamily.bodyRegular, fontSize: t.fontSize["12"], lineHeight: 16, diff --git a/packages/ui/src/primitives/__tests__/donationUrl.test.ts b/packages/ui/src/primitives/__tests__/donationUrl.test.ts new file mode 100644 index 00000000..f85f1a7b --- /dev/null +++ b/packages/ui/src/primitives/__tests__/donationUrl.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest" +import { donationUrlHost, safeDonationUrl } from "../donationUrl" +import { openDonate as openDonateWeb } from "../donateTarget.web" +import { openDonate as openDonateNative } from "../donateTarget.native" + +describe("safeDonationUrl", () => { + it("accepts a plain https link and trims it", () => { + expect(safeDonationUrl(" https://give.example.org/river ")).toBe("https://give.example.org/river") + }) + + it("refuses anything that is not a safe https link, so the card renders nothing", () => { + for (const bad of [ + "http://give.example.org", + "javascript:alert(1)", + "https://192.168.0.1/pay", + "https://xn--80ak6aa92e.com/", + "https://user@give.example.org/", + "give.example.org", + "", + null, + undefined, + ]) { + expect(safeDonationUrl(bad), String(bad)).toBeNull() + } + }) +}) + +describe("donationUrlHost", () => { + it("shows the bare hostname, without www, port, path or query", () => { + expect(donationUrlHost("https://www.give.example.org:8443/river?x=1#top")).toBe("give.example.org") + expect(donationUrlHost("https://opencollective.com/river-keepers")).toBe("opencollective.com") + }) +}) + +describe("openDonate", () => { + it("native prefers the in-app browser and falls back to a plain open", async () => { + const calls: string[] = [] + openDonateNative({ + url: "https://give.example.org", + openExternal: { + open: async (url) => { + calls.push(`open:${url}`) + }, + openInAppBrowser: async (url) => { + calls.push(`inapp:${url}`) + }, + }, + }) + openDonateNative({ + url: "https://give.example.org", + openExternal: { + open: async (url) => { + calls.push(`open:${url}`) + }, + }, + }) + openDonateNative({ url: "https://give.example.org" }) + await Promise.resolve() + expect(calls).toEqual(["inapp:https://give.example.org", "open:https://give.example.org"]) + }) + + it("web opens a new, unlinked tab and never navigates the shell away", () => { + const opened: unknown[] = [] + const win = { open: (...args: unknown[]) => opened.push(args) } + const previous = (globalThis as { window?: unknown }).window + ;(globalThis as { window?: unknown }).window = win + try { + openDonateWeb({ url: "https://give.example.org" }) + } finally { + ;(globalThis as { window?: unknown }).window = previous + } + expect(opened).toEqual([["https://give.example.org", "_blank", "noopener,noreferrer"]]) + }) +}) diff --git a/packages/ui/src/primitives/__tests__/externalUrls.test.ts b/packages/ui/src/primitives/__tests__/externalUrls.test.ts index 476b64bf..34664305 100644 --- a/packages/ui/src/primitives/__tests__/externalUrls.test.ts +++ b/packages/ui/src/primitives/__tests__/externalUrls.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from "vitest" import { - DONATION_TERMS_URL, PRIVACY_URL, TERMS_URL, WEB_ORIGIN, - donatePath, - donateUrl, legalUrlFor, managePath, + manageOrgSettingsPath, manageUrl, orgPagePath, setWebOrigin, @@ -16,18 +14,17 @@ import { } from "../externalUrls" describe("the configurable web origin", () => { - it("defaults to production and follows the host's setter for post, donate and manage links", () => { + it("defaults to production and follows the host's setter for post and manage links", () => { expect(webOrigin()).toBe(WEB_ORIGIN) try { setWebOrigin("https://civfix.dev/") expect(webOrigin()).toBe("https://civfix.dev") - expect(donateUrl("reachout")).toBe("https://civfix.dev/donate/reachout") expect(manageUrl("evt")).toBe("https://civfix.dev/manage/events/evt") expect(TERMS_URL).toBe(`${WEB_ORIGIN}/legal/terms`) } finally { setWebOrigin(WEB_ORIGIN) } - expect(donateUrl("reachout")).toBe(`${WEB_ORIGIN}/donate/reachout`) + expect(manageUrl("evt")).toBe(`${WEB_ORIGIN}/manage/events/evt`) }) }) @@ -35,44 +32,17 @@ describe("legal URLs", () => { it("derives every civfix-hosted legal URL from the one origin", () => { expect(TERMS_URL).toBe(`${WEB_ORIGIN}/legal/terms`) expect(PRIVACY_URL).toBe(`${WEB_ORIGIN}/legal/privacy`) - expect(DONATION_TERMS_URL).toBe(`${WEB_ORIGIN}/legal/donations`) }) it("maps every legal document type to a URL, and an unknown one to the legal index", () => { - for (const type of [ - "terms", - "privacy", - "cookies", - "subprocessors", - "donations", - "org_donation_agreement", - "donation_disclosure", - ]) { + for (const type of ["terms", "privacy", "cookies", "subprocessors"]) { expect(legalUrlFor(type), type).toMatch(/^https:\/\//) } + expect(legalUrlFor("donations")).toBe(`${WEB_ORIGIN}/legal`) expect(legalUrlFor("who-knows")).toBe(`${WEB_ORIGIN}/legal`) }) }) -describe("donate", () => { - it("is a RELATIVE path on web, so the checkout stays same-origin", () => { - expect(donatePath("river-keepers")).toBe("/donate/river-keepers") - expect(donatePath("river-keepers").startsWith("/")).toBe(true) - }) - - it("carries the event as a query param when the donation came from an event", () => { - expect(donatePath("river-keepers", "e1")).toBe("/donate/river-keepers?event=e1") - }) - - it("is absolute for native, which opens it in a browser with the address bar visible", () => { - expect(donateUrl("river-keepers")).toBe(`${WEB_ORIGIN}/donate/river-keepers`) - }) - - it("encodes a slug and an event id rather than pasting them into the URL raw", () => { - expect(donatePath("a/b", "x y")).toBe("/donate/a%2Fb?event=x%20y") - }) -}) - describe("manage + org + signup paths", () => { it("points the host console at /manage/events/:id", () => { expect(managePath("e1")).toBe("/manage/events/e1") @@ -84,8 +54,13 @@ describe("manage + org + signup paths", () => { expect(signupPagePath("river-cleanup")).toBe("/e/river-cleanup") }) + it("points an organization's donation-link editing at its console settings", () => { + expect(manageOrgSettingsPath("o1")).toBe("/manage/orgs/o1/settings") + }) + it("encodes the ids it is handed", () => { expect(managePath("a b")).toBe("/manage/events/a%20b") expect(orgPagePath("a/b")).toBe("/orgs/a%2Fb") + expect(manageOrgSettingsPath("a/b")).toBe("/manage/orgs/a%2Fb/settings") }) }) diff --git a/packages/ui/src/primitives/donateTarget.native.ts b/packages/ui/src/primitives/donateTarget.native.ts index 067e5039..f32d5ada 100644 --- a/packages/ui/src/primitives/donateTarget.native.ts +++ b/packages/ui/src/primitives/donateTarget.native.ts @@ -1,10 +1,8 @@ -import { donateUrl } from "./externalUrls" import type { DonateTarget } from "./donateTarget.types" export function openDonate(target: DonateTarget): void { - const url = donateUrl(target.orgSlug, target.eventId ?? null) const openExternal = target.openExternal if (!openExternal) return const inApp = openExternal.openInAppBrowser - void (inApp ? inApp.call(openExternal, url) : openExternal.open(url)) + void (inApp ? inApp.call(openExternal, target.url) : openExternal.open(target.url)) } diff --git a/packages/ui/src/primitives/donateTarget.types.ts b/packages/ui/src/primitives/donateTarget.types.ts index cdb8e5b1..288d397b 100644 --- a/packages/ui/src/primitives/donateTarget.types.ts +++ b/packages/ui/src/primitives/donateTarget.types.ts @@ -1,7 +1,6 @@ import type { OpenExternalCapability } from "../capabilities" export interface DonateTarget { - orgSlug: string - eventId?: string | null + url: string openExternal?: OpenExternalCapability | undefined } diff --git a/packages/ui/src/primitives/donateTarget.web.ts b/packages/ui/src/primitives/donateTarget.web.ts index 6979da2d..2590699c 100644 --- a/packages/ui/src/primitives/donateTarget.web.ts +++ b/packages/ui/src/primitives/donateTarget.web.ts @@ -1,11 +1,9 @@ -import { donatePath } from "./externalUrls" import type { DonateTarget } from "./donateTarget.types" export function openDonate(target: DonateTarget): void { - const path = donatePath(target.orgSlug, target.eventId ?? null) - if (typeof window !== "undefined" && window.location) { - window.location.assign(path) + if (typeof window !== "undefined" && typeof window.open === "function") { + window.open(target.url, "_blank", "noopener,noreferrer") return } - void target.openExternal?.open(path) + void target.openExternal?.open(target.url) } diff --git a/packages/ui/src/primitives/donationUrl.ts b/packages/ui/src/primitives/donationUrl.ts new file mode 100644 index 00000000..0ab18911 --- /dev/null +++ b/packages/ui/src/primitives/donationUrl.ts @@ -0,0 +1,11 @@ +import { SafeHttpsLinkSchema } from "@civfix/shared" + +export function safeDonationUrl(value: string | null | undefined): string | null { + if (typeof value !== "string") return null + const parsed = SafeHttpsLinkSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +export function donationUrlHost(url: string): string { + return new URL(url).hostname.replace(/^www\./, "") +} diff --git a/packages/ui/src/primitives/externalUrls.ts b/packages/ui/src/primitives/externalUrls.ts index 0ba64d80..b897d6d9 100644 --- a/packages/ui/src/primitives/externalUrls.ts +++ b/packages/ui/src/primitives/externalUrls.ts @@ -16,7 +16,6 @@ export const TERMS_URL = `${WEB_ORIGIN}/legal/terms` export const PRIVACY_URL = `${WEB_ORIGIN}/legal/privacy` export const COOKIES_URL = `${WEB_ORIGIN}/legal/cookies` export const SUBPROCESSORS_URL = `${WEB_ORIGIN}/legal/subprocessors` -export const DONATION_TERMS_URL = `${WEB_ORIGIN}/legal/donations` export function legalUrlFor(type: string): string { switch (type) { @@ -28,26 +27,11 @@ export function legalUrlFor(type: string): string { return COOKIES_URL case "subprocessors": return SUBPROCESSORS_URL - case "donations": - return DONATION_TERMS_URL - case "org_donation_agreement": - return `${WEB_ORIGIN}/legal/org-donation-agreement` - case "donation_disclosure": - return `${WEB_ORIGIN}/legal/donation-disclosure` default: return `${WEB_ORIGIN}/legal` } } -export function donatePath(orgSlug: string, eventId?: string | null): string { - const base = `/donate/${encodeURIComponent(orgSlug)}` - return eventId ? `${base}?event=${encodeURIComponent(eventId)}` : base -} - -export function donateUrl(orgSlug: string, eventId?: string | null): string { - return webOrigin() + donatePath(orgSlug, eventId) -} - export function managePath(eventId: string): string { return `/manage/events/${encodeURIComponent(eventId)}` } @@ -64,6 +48,10 @@ export function manageOrgPath(orgId: string): string { return `/manage/orgs/${encodeURIComponent(orgId)}/overview` } +export function manageOrgSettingsPath(orgId: string): string { + return `/manage/orgs/${encodeURIComponent(orgId)}/settings` +} + export function orgPagePath(orgSlug: string): string { return `/orgs/${encodeURIComponent(orgSlug)}` } diff --git a/packages/ui/src/primitives/index.ts b/packages/ui/src/primitives/index.ts index 37c3afc7..97117aef 100644 --- a/packages/ui/src/primitives/index.ts +++ b/packages/ui/src/primitives/index.ts @@ -96,13 +96,11 @@ export { PRIVACY_URL, COOKIES_URL, SUBPROCESSORS_URL, - DONATION_TERMS_URL, legalUrlFor, - donatePath, - donateUrl, managePath, manageUrl, manageOrgPath, + manageOrgSettingsPath, managePortfolioPath, orgPagePath, signupPagePath, @@ -134,6 +132,7 @@ export type { DonateTarget } from "./donateTarget.types" export { DonateBlock } from "./DonateBlock" export type { DonateBlockProps } from "./DonateBlock" +export { safeDonationUrl, donationUrlHost } from "./donationUrl" export { saveCalendarFile, calendarSaveAvailable } from "./calendarFile" export type { CalendarSaveInput, CalendarSaveResult } from "./calendarFile.types" diff --git a/packages/ui/src/shell/BodyRouter.tsx b/packages/ui/src/shell/BodyRouter.tsx index 71b2f657..528dcca0 100644 --- a/packages/ui/src/shell/BodyRouter.tsx +++ b/packages/ui/src/shell/BodyRouter.tsx @@ -45,7 +45,6 @@ import { HostLogHoursBody, MyTicketBody, OrgPageBody, - MyDonationsBody, EventDashboardBody, } from "../bodies" import type { DetailEntry, View as NavView } from "../nav" @@ -173,8 +172,6 @@ function renderBodyId(id: BodyId, entry: DetailEntry | null): React.ReactNode { ) case "orgPage": return - case "myDonations": - return case "eventDashboard": return case "stub": { diff --git a/packages/ui/src/shell/bodyLayout.ts b/packages/ui/src/shell/bodyLayout.ts index 42862707..36947eb8 100644 --- a/packages/ui/src/shell/bodyLayout.ts +++ b/packages/ui/src/shell/bodyLayout.ts @@ -47,7 +47,6 @@ export const BODY_LAYOUT: Record = { "host-log-hours": "scroll", "my-ticket": "scroll", org: "scroll", - "my-donations": "scroll", "event-dashboard": "scroll", "home-view": "scroll", } diff --git a/packages/ui/src/shell/bodyRoutes.ts b/packages/ui/src/shell/bodyRoutes.ts index 84be5bed..3775322b 100644 --- a/packages/ui/src/shell/bodyRoutes.ts +++ b/packages/ui/src/shell/bodyRoutes.ts @@ -44,7 +44,6 @@ export type BodyId = | "hostLogHours" | "myTicket" | "orgPage" - | "myDonations" | "eventDashboard" | "stub" @@ -102,6 +101,5 @@ export const DETAIL_BODY: Record = { "host-log-hours": "hostLogHours", "my-ticket": "myTicket", org: "orgPage", - "my-donations": "myDonations", "event-dashboard": "eventDashboard", } diff --git a/packages/ui/src/typography/icon-map.ts b/packages/ui/src/typography/icon-map.ts index 7235207d..53485c2d 100644 --- a/packages/ui/src/typography/icon-map.ts +++ b/packages/ui/src/typography/icon-map.ts @@ -97,6 +97,7 @@ import { UserCheck, Hourglass, HandHeart, + ExternalLink, ReceiptText, Radio, TrendingUp, @@ -205,6 +206,7 @@ export type IconName = | "UserCheck" | "Hourglass" | "HandHeart" + | "ExternalLink" | "ReceiptText" | "Radio" | "TrendingUp" @@ -311,6 +313,7 @@ export const iconMap: Record = { UserCheck, Hourglass, HandHeart, + ExternalLink, ReceiptText, Radio, TrendingUp, From 9eaed13b2ea18b7a42731ba5e5ea598df9a1e57c Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 18:12:36 -0700 Subject: [PATCH 4/8] remove the stripe donation checkout and org payments console from web Deletes the /donate route and feature, the org payments tab and its console route section, the Stripe.js loader and appearance, the three donation legal documents, the Apple Pay merchant-domain file, the /donate/* edge headers and redirect, the publishable-key and merchant-file build gates, the Stripe eslint fences and the two @stripe deps. deploy-web.yml loses the STRIPE_LIVE/STRIPE_TEST variables, the key emission and the three key assertions; Turnstile and the API/site assertions stay. The privacy, terms, cookies and subprocessors pages now describe donation links as external addresses civfix never processes. --- .github/workflows/deploy-web.yml | 38 +- apps/community-web/.env.example | 8 - apps/community-web/.eslintrc.json | 163 ------ apps/community-web/package.json | 2 - .../.well-known/apple-app-site-association | 1 - ...le-developer-merchantid-domain-association | 1 - apps/community-web/public/_headers | 52 -- apps/community-web/public/_redirects | 4 +- .../scripts/cf-pages-postbuild.mjs | 78 +-- .../scripts/cf-pages-postbuild.test.mjs | 61 +-- apps/community-web/scripts/legal-hashes.mjs | 7 +- .../community-web/scripts/postbuild-gates.mjs | 23 - .../src/app/donate/[...slug]/page.tsx | 37 -- apps/community-web/src/app/donate/donate.css | 495 ------------------ .../src/app/legal/cookies/page.tsx | 79 +-- .../app/legal/donation-disclosure/page.tsx | 150 ------ .../src/app/legal/donations/page.tsx | 251 --------- .../app/legal/org-donation-agreement/page.tsx | 23 - .../src/app/legal/privacy/page.tsx | 97 +--- .../src/app/legal/subprocessors/page.tsx | 30 -- .../src/app/legal/terms/page.tsx | 176 +------ .../components/console/chips/chip-kinds.ts | 45 +- .../src/components/console/route.test.ts | 9 +- .../src/components/console/route.ts | 3 +- .../src/components/console/url-state.ts | 1 - .../src/components/dev/bodies-gallery.tsx | 5 - .../src/components/dev/dashboard-fixtures.ts | 117 +---- .../src/components/legal/legal-entity.ts | 8 - .../src/components/legal/legal-page.tsx | 27 +- .../legal/legal-source-parity.test.ts | 81 --- .../legal/org-donation-agreement-body.tsx | 437 ---------------- .../src/features/donate/amount-field.tsx | 97 ---- .../donate/complete-view.source.test.ts | 56 -- .../src/features/donate/complete-view.tsx | 241 --------- .../src/features/donate/consent-block.tsx | 90 ---- .../donate/consent-gating.dom.test.tsx | 237 --------- .../src/features/donate/disclosures-block.tsx | 95 ---- .../features/donate/disclosures-url.test.ts | 38 -- .../features/donate/disclosures.dom.test.tsx | 126 ----- .../src/features/donate/donate-amount.test.ts | 81 --- .../src/features/donate/donate-amount.ts | 81 --- .../src/features/donate/donate-fixture.ts | 85 --- .../src/features/donate/donate-intent.ts | Bin 934 -> 0 bytes .../src/features/donate/donate-route.test.ts | 96 ---- .../src/features/donate/donate-route.ts | 77 --- .../donate/donate-status-poll.test.ts | 52 -- .../src/features/donate/donate-status-poll.ts | 37 -- .../src/features/donate/donate-view.tsx | 444 ---------------- .../src/features/donate/donor-fields.tsx | 67 --- .../donate/fee-breakdown-ids.dom.test.tsx | 71 --- .../donate/fee-breakdown.dom.test.tsx | 140 ----- .../src/features/donate/fee-breakdown.tsx | 72 --- .../src/features/donate/intent-key.test.ts | 53 -- .../src/features/donate/org-header.tsx | 56 -- .../src/features/donate/payment-panel.tsx | 153 ------ .../src/features/donate/states.tsx | 42 -- .../src/features/host/console-keys.ts | 6 - .../src/features/host/error-copy.ts | 1 - .../src/features/host/org/org-screen.tsx | 40 +- .../host/org/payments/connect-status-card.tsx | 251 --------- .../org/payments/consent-agreement-flow.tsx | 199 ------- .../payments/donation-exports.dom.test.tsx | 163 ------ .../host/org/payments/donation-exports.tsx | 156 ------ .../org/payments/donation-settings-form.tsx | 300 ----------- .../payments/donations-report.dom.test.tsx | 32 -- .../host/org/payments/donations-report.tsx | 346 ------------ .../host/org/payments/eligibility-card.tsx | 129 ----- .../org/payments/payments-tab.dom.test.tsx | 368 ------------- .../host/org/payments/payments-tab.tsx | 109 ---- .../features/host/org/verification-screen.tsx | 8 +- .../host/portfolio/portfolio-screen.tsx | 2 +- .../src/lib/edge-headers.test.ts | 83 +-- apps/community-web/src/lib/edge-headers.ts | 36 -- .../src/lib/stripe-appearance.ts | 51 -- apps/community-web/src/lib/stripe-js.test.ts | 113 ---- apps/community-web/src/lib/stripe-js.ts | 70 --- apps/community-web/src/lib/turnstile.ts | 1 - pnpm-lock.yaml | 26 - 78 files changed, 108 insertions(+), 7478 deletions(-) delete mode 100644 apps/community-web/public/.well-known/apple-developer-merchantid-domain-association delete mode 100644 apps/community-web/src/app/donate/[...slug]/page.tsx delete mode 100644 apps/community-web/src/app/donate/donate.css delete mode 100644 apps/community-web/src/app/legal/donation-disclosure/page.tsx delete mode 100644 apps/community-web/src/app/legal/donations/page.tsx delete mode 100644 apps/community-web/src/app/legal/org-donation-agreement/page.tsx delete mode 100644 apps/community-web/src/components/legal/legal-source-parity.test.ts delete mode 100644 apps/community-web/src/components/legal/org-donation-agreement-body.tsx delete mode 100644 apps/community-web/src/features/donate/amount-field.tsx delete mode 100644 apps/community-web/src/features/donate/complete-view.source.test.ts delete mode 100644 apps/community-web/src/features/donate/complete-view.tsx delete mode 100644 apps/community-web/src/features/donate/consent-block.tsx delete mode 100644 apps/community-web/src/features/donate/consent-gating.dom.test.tsx delete mode 100644 apps/community-web/src/features/donate/disclosures-block.tsx delete mode 100644 apps/community-web/src/features/donate/disclosures-url.test.ts delete mode 100644 apps/community-web/src/features/donate/disclosures.dom.test.tsx delete mode 100644 apps/community-web/src/features/donate/donate-amount.test.ts delete mode 100644 apps/community-web/src/features/donate/donate-amount.ts delete mode 100644 apps/community-web/src/features/donate/donate-fixture.ts delete mode 100644 apps/community-web/src/features/donate/donate-intent.ts delete mode 100644 apps/community-web/src/features/donate/donate-route.test.ts delete mode 100644 apps/community-web/src/features/donate/donate-route.ts delete mode 100644 apps/community-web/src/features/donate/donate-status-poll.test.ts delete mode 100644 apps/community-web/src/features/donate/donate-status-poll.ts delete mode 100644 apps/community-web/src/features/donate/donate-view.tsx delete mode 100644 apps/community-web/src/features/donate/donor-fields.tsx delete mode 100644 apps/community-web/src/features/donate/fee-breakdown-ids.dom.test.tsx delete mode 100644 apps/community-web/src/features/donate/fee-breakdown.dom.test.tsx delete mode 100644 apps/community-web/src/features/donate/fee-breakdown.tsx delete mode 100644 apps/community-web/src/features/donate/intent-key.test.ts delete mode 100644 apps/community-web/src/features/donate/org-header.tsx delete mode 100644 apps/community-web/src/features/donate/payment-panel.tsx delete mode 100644 apps/community-web/src/features/donate/states.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/connect-status-card.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/consent-agreement-flow.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/donation-exports.dom.test.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/donation-exports.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/donation-settings-form.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/donations-report.dom.test.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/donations-report.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/eligibility-card.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/payments-tab.dom.test.tsx delete mode 100644 apps/community-web/src/features/host/org/payments/payments-tab.tsx delete mode 100644 apps/community-web/src/lib/stripe-appearance.ts delete mode 100644 apps/community-web/src/lib/stripe-js.test.ts delete mode 100644 apps/community-web/src/lib/stripe-js.ts 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/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/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 aea2ce96c76e532b980a16ebf4f39ad3815c42a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 934 zcmZ9LQIFC<5XXJ)rim%@)evLaR!^4GHXwowRK7M zmLE0_?P=}^^y|R9robF8Bz}P+X!KU7X>=lvM<>t{fyo4XzoT@JfR!U3hTw;F)=&ON z7s#%#1hzRNsWJ$Q-l0Ztic&20qSyGNnBBWioO~_6x0(1O}$^EHZQS5@KL97N|vmx z+S5}!n{amV8tobT n$cxndS@L!7FtZ { - 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-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), - })} -

    - -
    - -