Epic: Sponsor page — unified card grid, declutter donate buttons, and family shared-trip funds #827

Closed
opened 2026-07-24 16:46:38 +00:00 by spikerj · 4 comments
Owner

Problem

The /sponsor page (learn.spikersoft.com/sponsor) has three issues:

  1. Wasted width / only 3 columns. .profiles-section is capped at max-width: 1200px and .profiles-grid maxes at repeat(3, 1fr), so laptop/desktop screens leave large empty margins. Family and solo cards render in two separate stacked grids, not one row.
  2. Donate-button clutter on family cards. A 4-member family shows ~5 donate-ish CTAs (one per member row + spotlight + "Sponsor this family"). We want one "Sponsor / Donate" button per card, with the per-member donate hidden until a member is clicked (the member spotlight already has it).
  3. "Sponsor family" isn't a real donation. Today every family "Donate" is just a routerLink to an individual's /sponsor/:profileId checkout — there is no family-scoped donation, no split, and no way to keep a gift made to the family from being spent on one member's solo trip.

Solution

  • Layout: one unified full-width 4-column grid; family cards span 2 columns, individual cards span 1.
  • Declutter: one "Sponsor / Donate family" button per card; individual-member donation lives in the member spotlight.
  • Family shared-trip fund (the real feature): a donation to a family is pooled into a family fund (keyed by familyKey, mirroring the LocationFund subsystem — atomic guarded debit + append-only ledger). Staff disburse from the pool into members' trip funds, but only earmarked to a destination the whole family shares (SharedDestinationCodes), never a member's solo/individual destination. Donations are always allowed; if a family has no shared destination yet, the money waits in the pool.

Key architectural fact: the backend has no per-traveler wallet and no spend step — totalRaisedCents is a computed sum of completed Donation rows attributed by RecipientProfileId + earmarked via TargetCountry. So the anti-abuse guard is enforced at attribution time: a family disbursement's TargetCountry must be a shared destination.

Locked decisions

  • Held family pool + staff-controlled disbursement (mirror LocationFund).
  • Strict shared-only spend rule; donations always allowed (money waits in pool if no shared destination yet).

Sub-tasks

  • Backend: DonationType.Family/FamilyDisbursement, FamilyFund + FamilyFundTransaction entities, IFamilyFundLedger, checkout branch, webhook credit, FamilyFundsController (staff disburse with shared-only guard, ledger, adjust).
  • Angular: unified 4-col grid + declutter family card; domain models; FamilyDonateComponent + route; rewire the family button; admin disburse panel; i18n.

Cross-repo: spikersoft-backend + spikersoft-angular.

## Problem The `/sponsor` page (learn.spikersoft.com/sponsor) has three issues: 1. **Wasted width / only 3 columns.** `.profiles-section` is capped at `max-width: 1200px` and `.profiles-grid` maxes at `repeat(3, 1fr)`, so laptop/desktop screens leave large empty margins. Family and solo cards render in two separate stacked grids, not one row. 2. **Donate-button clutter on family cards.** A 4-member family shows ~5 donate-ish CTAs (one per member row + spotlight + "Sponsor this family"). We want **one** "Sponsor / Donate" button per card, with the per-member donate hidden until a member is clicked (the member spotlight already has it). 3. **"Sponsor family" isn't a real donation.** Today every family "Donate" is just a routerLink to an individual's `/sponsor/:profileId` checkout — there is no family-scoped donation, no split, and no way to keep a gift made to the family from being spent on one member's **solo** trip. ## Solution - **Layout:** one unified full-width 4-column grid; family cards span 2 columns, individual cards span 1. - **Declutter:** one "Sponsor / Donate family" button per card; individual-member donation lives in the member spotlight. - **Family shared-trip fund (the real feature):** a donation to a family is pooled into a **family fund** (keyed by `familyKey`, mirroring the `LocationFund` subsystem — atomic guarded debit + append-only ledger). Staff disburse from the pool into members' trip funds, but **only earmarked to a destination the whole family shares** (`SharedDestinationCodes`), never a member's solo/individual destination. Donations are always allowed; if a family has no shared destination yet, the money waits in the pool. **Key architectural fact:** the backend has no per-traveler wallet and no spend step — `totalRaisedCents` is a computed sum of completed `Donation` rows attributed by `RecipientProfileId` + earmarked via `TargetCountry`. So the anti-abuse guard is enforced at *attribution time*: a family disbursement's `TargetCountry` must be a shared destination. ## Locked decisions - Held family pool + **staff-controlled** disbursement (mirror LocationFund). - **Strict shared-only** spend rule; donations always allowed (money waits in pool if no shared destination yet). ## Sub-tasks - Backend: `DonationType.Family`/`FamilyDisbursement`, `FamilyFund` + `FamilyFundTransaction` entities, `IFamilyFundLedger`, checkout branch, webhook credit, `FamilyFundsController` (staff disburse with shared-only guard, ledger, adjust). - Angular: unified 4-col grid + declutter family card; domain models; `FamilyDonateComponent` + route; rewire the family button; admin disburse panel; i18n. Cross-repo: spikersoft-backend + spikersoft-angular.
Author
Owner

WIP PRs opened (land together):

  • Backend: spikerj/spikersoft-backend#473DonationType.Family/FamilyDisbursement, FamilyFund + ledger, checkout branch, webhook credit, FamilyFundsController with the shared-only disburse guard. Unit tests green (SpikerSoft.UnitTests.slnf builds clean).
  • Angular: spikerj/spikersoft-angular#564 — unified 4-col grid + family-card declutter, FamilyDonateComponent + route, admin "Family Funds" disburse tab, domain models + i18n. nx build of domain-sponsor / feature-sponsor / spikersoft (app) all green; lint clean.

Both are draft/WIP pending the runtime end-to-end walk (checkout → Stripe webhook → pool credit → staff disburse) once the backend is deployed with test Stripe.

Design as locked with the requester: held family pool + staff-controlled disbursement, strict shared-only spend rule, donations always allowed (money waits in the pool if the family has no shared trip yet).

WIP PRs opened (land together): - **Backend:** spikerj/spikersoft-backend#473 — `DonationType.Family`/`FamilyDisbursement`, `FamilyFund` + ledger, checkout branch, webhook credit, `FamilyFundsController` with the shared-only disburse guard. Unit tests green (`SpikerSoft.UnitTests.slnf` builds clean). - **Angular:** spikerj/spikersoft-angular#564 — unified 4-col grid + family-card declutter, `FamilyDonateComponent` + route, admin "Family Funds" disburse tab, domain models + i18n. `nx build` of domain-sponsor / feature-sponsor / spikersoft (app) all green; lint clean. Both are draft/WIP pending the runtime end-to-end walk (checkout → Stripe webhook → pool credit → staff disburse) once the backend is deployed with test Stripe. Design as locked with the requester: **held family pool + staff-controlled disbursement**, **strict shared-only** spend rule, donations always allowed (money waits in the pool if the family has no shared trip yet).
Author
Owner

Both PRs merged to master (2026-07-24): backend #473 and angular #564 (merge 2cad2c2d). Deploying master for the manual end-to-end walk (checkout → Stripe webhook → pool credit → staff disburse). Keeping this epic open until that runtime pass is done + the follow-ups (prune orphaned i18n keys, optional auto-disbursement rules).

Both PRs **merged to master** (2026-07-24): backend #473 and angular #564 (merge `2cad2c2d`). Deploying master for the manual end-to-end walk (checkout → Stripe webhook → pool credit → staff disburse). Keeping this epic open until that runtime pass is done + the follow-ups (prune orphaned i18n keys, optional auto-disbursement rules).
Author
Owner

Status check — code complete on both sides, but the frontend has NOT reached production yet. Keeping this open.

Backend — merged AND live

spikersoft-backend PR #473 (feat/sponsor-family-fund → master, e7a244bc) landed all of the epic's backend sub-tasks, verified on master:

  • DonationType.Family = 5 / FamilyDisbursement = 6 (SpikerSoft.Data/Mongos/Donation.cs)
  • FamilyFund + FamilyFundTransaction (SpikerSoft.Data/Mongos/FamilyFund.cs)
  • IFamilyFundLedger / FamilyFundLedger with atomic guarded debit + append-only ledger (SpikerSoft.Business/Domain/Funds/Services/)
  • checkout branch + idempotent webhook credit (StripeService, SponsorController)
  • FamilyFundsController (api/sponsor/family-funds): public summary, staff disburse (shared-only + membership + child-approval guards), ledger, adjust
  • SponsorFamilyBuilder.ResolveParentKeycloakId

The strict shared-only rule is enforced exactly as the epic locked it — GUARD 1 rejects any TargetCountry not in SharedDestinationCodes, and the disbursement writes a completed FamilyDisbursement row earmarked to that shared destination so the existing raised-total math credits only the shared trip.

Live production check (backend image published and rolled):

GET https://api.spikersoft.com/api/sponsor/family-funds/f8fe93afc8cd25ff13d86a49  -> 200
{"fund":{"balanceCents":0,"totalDonatedCents":0,"totalDisbursedCents":0,"currency":"USD","isActive":true},
 "sharedDestinationCodes":["US-LA"],"members":[Joseph S. (parent), Todd S. (child)],"recentActivity":[]}

Angular — merged to master, NOT deployed ⚠️

spikersoft-angular PRs #564, #566 (i18n orphan), #567 (test fix) are merged. Verified on master:

  • Layout: one unified grid — families and solos now flow through a single .profiles-grid; .profiles-section is max-width: min(100% - 4rem, 1760px) (was 1200px); columns 1 → 2 (640) → 3 (1024) → 4 (1440); app-sponsor-family-card { grid-column: span 2 } from 640px up. Matches "family spans 2, solo spans 1".
  • Declutter: the family card now has exactly one CTA — sponsor-family-donate-link/sponsor/donate-family/:familyKey. The per-member donate (sponsor.family.donateFor) lives only inside .spotlight-actions, i.e. hidden until a member is clicked.
  • Family fund UI: FamilyDonateComponent + route, family-fund-management admin disburse panel wired into sponsorship-management, sponsor domain models, en/es i18n.

But production is still running a pre-#564 build. The deployed bundle main-VSL3ATV3.js on learn.spikersoft.com contains sponsor/family/:family, sponsor/fund/:code, sponsor/donate-org — and no sponsor/donate-family. Cause: e2e-anonymous has failed on every master run since the #564 merge (run 18112, 17:52Z), and publish is gated on it, so publish has been skipped on every master run since 17:47Z.

The e2e failure is a direct miss in #564 itself: the new route was added to routes.ts but never classified in e2e/support/route-manifest.ts, which also dipped the anonymous ratchet 29.5% → 29.2%. Filed as #828, fix up in spikersoft-angular PR #568 (classify as public + skipWalk, re-derive both baselines per the documented exception (2)).

Remaining to close this epic

  1. Land PR #568 → master goes green → publish runs → frontend deploys.
  2. Re-verify on learn.spikersoft.com: 4-column grid at ≥1440px, single donate CTA per family card, /sponsor/donate-family/f8fe93afc8cd25ff13d86a49 renders and checks out.
  3. Sanity-check one real family donation end-to-end (pool credit on webhook) and one staff disbursement earmarked to US-LA (the only shared destination on prod today) — plus confirm the guard rejects a disbursement toward Todd's solo US-ID.

— Opus 5 Agent

**Status check — code complete on both sides, but the frontend has NOT reached production yet. Keeping this open.** ### Backend — merged AND live ✅ spikersoft-backend PR #473 (`feat/sponsor-family-fund` → master, `e7a244bc`) landed all of the epic's backend sub-tasks, verified on master: - `DonationType.Family = 5` / `FamilyDisbursement = 6` (`SpikerSoft.Data/Mongos/Donation.cs`) - `FamilyFund` + `FamilyFundTransaction` (`SpikerSoft.Data/Mongos/FamilyFund.cs`) - `IFamilyFundLedger` / `FamilyFundLedger` with atomic guarded debit + append-only ledger (`SpikerSoft.Business/Domain/Funds/Services/`) - checkout branch + idempotent webhook credit (`StripeService`, `SponsorController`) - `FamilyFundsController` (`api/sponsor/family-funds`): public summary, staff `disburse` (shared-only + membership + child-approval guards), `ledger`, `adjust` - `SponsorFamilyBuilder.ResolveParentKeycloakId` The strict shared-only rule is enforced exactly as the epic locked it — GUARD 1 rejects any `TargetCountry` not in `SharedDestinationCodes`, and the disbursement writes a completed `FamilyDisbursement` row earmarked to that shared destination so the existing raised-total math credits only the shared trip. Live production check (backend image published and rolled): ``` GET https://api.spikersoft.com/api/sponsor/family-funds/f8fe93afc8cd25ff13d86a49 -> 200 {"fund":{"balanceCents":0,"totalDonatedCents":0,"totalDisbursedCents":0,"currency":"USD","isActive":true}, "sharedDestinationCodes":["US-LA"],"members":[Joseph S. (parent), Todd S. (child)],"recentActivity":[]} ``` ### Angular — merged to master, NOT deployed ⚠️ spikersoft-angular PRs #564, #566 (i18n orphan), #567 (test fix) are merged. Verified on master: - **Layout:** one unified grid — families and solos now flow through a single `.profiles-grid`; `.profiles-section` is `max-width: min(100% - 4rem, 1760px)` (was 1200px); columns 1 → 2 (640) → 3 (1024) → **4 (1440)**; `app-sponsor-family-card { grid-column: span 2 }` from 640px up. Matches "family spans 2, solo spans 1". - **Declutter:** the family card now has exactly one CTA — `sponsor-family-donate-link` → `/sponsor/donate-family/:familyKey`. The per-member donate (`sponsor.family.donateFor`) lives only inside `.spotlight-actions`, i.e. hidden until a member is clicked. - **Family fund UI:** `FamilyDonateComponent` + route, `family-fund-management` admin disburse panel wired into `sponsorship-management`, sponsor domain models, en/es i18n. **But production is still running a pre-#564 build.** The deployed bundle `main-VSL3ATV3.js` on learn.spikersoft.com contains `sponsor/family/:family`, `sponsor/fund/:code`, `sponsor/donate-org` — and no `sponsor/donate-family`. Cause: `e2e-anonymous` has failed on every master run since the #564 merge (run 18112, 17:52Z), and `publish` is gated on it, so `publish` has been **skipped** on every master run since 17:47Z. The e2e failure is a direct miss in #564 itself: the new route was added to `routes.ts` but never classified in `e2e/support/route-manifest.ts`, which also dipped the anonymous ratchet 29.5% → 29.2%. Filed as **#828**, fix up in **spikersoft-angular PR #568** (classify as public + `skipWalk`, re-derive both baselines per the documented exception (2)). ### Remaining to close this epic 1. Land PR #568 → master goes green → `publish` runs → frontend deploys. 2. Re-verify on learn.spikersoft.com: 4-column grid at ≥1440px, single donate CTA per family card, `/sponsor/donate-family/f8fe93afc8cd25ff13d86a49` renders and checks out. 3. Sanity-check one real family donation end-to-end (pool credit on webhook) and one staff disbursement earmarked to `US-LA` (the only shared destination on prod today) — plus confirm the guard rejects a disbursement toward Todd's solo `US-ID`. — Opus 5 Agent
Author
Owner

Shipped — closing. The deploy blocker (#828) is cleared and all three parts of this epic are now live in production.

Deploy confirmed

spikersoft-angular master run 18124 (dd34b0a4) is green end to end and publish ran successfully. learn.spikersoft.com now serves main-SRPMVC66.js (was main-VSL3ATV3.js), and the new bundle contains sponsor/donate-family/:family.

Live verification

Check Result
GET /api/sponsor/family-funds/{familyKey} (public summary) 200 — pool + sharedDestinationCodes: ["US-LA"] + members
POST /api/sponsor/family-funds/admin/{familyKey}/disburse anonymous 401 — staff gate live
GET /api/sponsor/family-funds/admin/{familyKey}/ledger anonymous 401 — staff gate live
sponsor/donate-family/:familyKey in deployed bundle present

Against the epic's three problems

  1. Wasted width / 3 columns — fixed. Families and solos now flow through one shared .profiles-grid; .profiles-section is max-width: min(100% - 4rem, 1760px) (was 1200px); columns step 1 → 2 (640) → 3 (1024) → 4 (1440), with app-sponsor-family-card { grid-column: span 2 } from 640px up. Family cards span 2 tracks, solo cards 1, exactly as specified.
  2. Donate-button clutter — fixed. One CTA per family card (sponsor-family-donate-link); the per-member donate (sponsor.family.donateFor) exists only inside .spotlight-actions, so it appears after a member is clicked.
  3. "Sponsor family" isn't a real donation — fixed. DonationType.Family / FamilyDisbursement, FamilyFund + FamilyFundTransaction, IFamilyFundLedger / FamilyFundLedger (atomic guarded debit + append-only ledger on the raw driver), checkout branch, idempotent webhook credit, and FamilyFundsController with the locked decisions enforced: donations always allowed (money waits in the pool), disbursement rejected unless TargetCountry is in SharedDestinationCodes, plus membership and child-approval guards. The disbursement writes a completed FamilyDisbursement row earmarked to the shared destination, so the existing computed totalRaisedCents math credits the shared trip only — the attribution-time guard the epic called for.

Both repos' sub-task lists are complete: backend PR #473; angular PRs #564, #566 (i18n orphan), #567 (test fix), #568 (#828 deploy unblock).

Not verified by me — worth a human pass

Two acceptance steps need real money or staff credentials, so I did not exercise them:

  1. A real family donation through Stripe checkout crediting the pool on webhook completion (unit-tested, not prod-exercised).
  2. A staff disbursement in the admin panel — the happy path to US-LA, and the guard rejecting a disbursement toward Todd's solo US-ID. Prod currently has exactly one family (f8fe93afc8cd25ff13d86a49, spikerj + toddcan) with US-LA as the sole shared destination, so it is a clean test case for both.

Also note the pool balance is 0 and trip costs are still unset for this family, so the funding bar has nothing to show yet — that is data, not a code gap.

— Opus 5 Agent

**Shipped — closing.** The deploy blocker (#828) is cleared and all three parts of this epic are now live in production. ### Deploy confirmed spikersoft-angular master run 18124 (`dd34b0a4`) is green end to end and `publish` ran successfully. learn.spikersoft.com now serves `main-SRPMVC66.js` (was `main-VSL3ATV3.js`), and the new bundle contains `sponsor/donate-family/:family`. ### Live verification | Check | Result | |---|---| | `GET /api/sponsor/family-funds/{familyKey}` (public summary) | 200 — pool + `sharedDestinationCodes: ["US-LA"]` + members | | `POST /api/sponsor/family-funds/admin/{familyKey}/disburse` anonymous | **401** — staff gate live | | `GET /api/sponsor/family-funds/admin/{familyKey}/ledger` anonymous | **401** — staff gate live | | `sponsor/donate-family/:familyKey` in deployed bundle | present | ### Against the epic's three problems 1. **Wasted width / 3 columns** — fixed. Families and solos now flow through one shared `.profiles-grid`; `.profiles-section` is `max-width: min(100% - 4rem, 1760px)` (was 1200px); columns step 1 → 2 (640) → 3 (1024) → 4 (1440), with `app-sponsor-family-card { grid-column: span 2 }` from 640px up. Family cards span 2 tracks, solo cards 1, exactly as specified. 2. **Donate-button clutter** — fixed. One CTA per family card (`sponsor-family-donate-link`); the per-member donate (`sponsor.family.donateFor`) exists only inside `.spotlight-actions`, so it appears after a member is clicked. 3. **"Sponsor family" isn't a real donation** — fixed. `DonationType.Family` / `FamilyDisbursement`, `FamilyFund` + `FamilyFundTransaction`, `IFamilyFundLedger` / `FamilyFundLedger` (atomic guarded debit + append-only ledger on the raw driver), checkout branch, idempotent webhook credit, and `FamilyFundsController` with the locked decisions enforced: donations always allowed (money waits in the pool), disbursement rejected unless `TargetCountry` is in `SharedDestinationCodes`, plus membership and child-approval guards. The disbursement writes a completed `FamilyDisbursement` row earmarked to the shared destination, so the existing computed `totalRaisedCents` math credits the shared trip only — the attribution-time guard the epic called for. Both repos' sub-task lists are complete: backend PR #473; angular PRs #564, #566 (i18n orphan), #567 (test fix), #568 (#828 deploy unblock). ### Not verified by me — worth a human pass Two acceptance steps need real money or staff credentials, so I did not exercise them: 1. A real family donation through Stripe checkout crediting the pool on webhook completion (unit-tested, not prod-exercised). 2. A staff disbursement in the admin panel — the happy path to `US-LA`, and the guard rejecting a disbursement toward Todd's solo `US-ID`. Prod currently has exactly one family (`f8fe93afc8cd25ff13d86a49`, spikerj + toddcan) with `US-LA` as the sole shared destination, so it is a clean test case for both. Also note the pool balance is 0 and trip costs are still unset for this family, so the funding bar has nothing to show yet — that is data, not a code gap. — Opus 5 Agent
Sign in to join this conversation.