Design Doc: Withdrawals Report
Part A below is the brief for the DR (read this). Part B is the full build reference, collapsed β expand it only when generating the implementation tasks.
Part A β Briefβ
The storyβ
A community admin running paid municipal events has no single screen answering "how much was withdrawn to the city, for which events, in a given month." This adds a read-only Withdrawals Report to the org dashboard, mirroring the existing bank-transfer report: faceted budget filters, a single-month picker, a six-card KPI hero strip, and a per-event table. It reports money already withdrawn or owed β it never moves money. Almost everything already exists; the work is wiring and adapting the POC.
What we'll buildβ
- P0 β Report under the dashboard Reports area, gated to admins (scoped to their own communities/resources). Four faceted multi-select filters (Responsible Center, Hub, Budget Line, Event) + a single Month picker. A six-card KPI hero strip and a per-event table (Gross, Income, Commission, Refund, Withdrawal, Estimation). Rows = events that had income that month.
- P1 β Export (xlsx/csv) by email Β· Schedule report (reuse the shipped modal) Β· the floating-button entry on the trends page Β· RTL/Hebrew.
- P2 β Mini-dashboard preview Β· column sort.
Key decisions (confirmed by the product owner)β
- Gross Revenue = Income + Commission + Refund Β· Estimation = Income β Withdrawal.
- "Transfer" β "Withdrawal" (ΧΧ’ΧΧ¨Χ β ΧΧ©ΧΧΧ); one Estimation column (last); no bottom totals row β the KPI hero is the totals.
- KPIs sum the full month set (incl. events with a withdrawal but no income); the table shows only income-bearing events. So hero
Total Withdrawalcan exceed the visible column. - Admin permission is enough (no dedicated finance permission).
- Month picker reuses the existing MUI
FormDatePickerβ no design-system change. - "Chat" = the floating-button entry (reuse POC, adapt filters), not an LLM endpoint. Schedule = the shipped "Schedule report" modal. "Saved reports" was a typo for Schedule report β it does not exist as a feature.
Open for the DRβ
- Month-picker range β prod has junk
monthvalues (a 1970 epoch and future 2036). Which range should the picker offer? - KPI vs rows asymmetry β
Total Withdrawal(full month set) can exceed the sum of the visible rows. Surface it in the UI (tooltip/footnote) or leave it?
Risksβ
- Scale β validated low (numbers below): one index-bounded single-month aggregation, not the POC's in-memory accumulate-all. IL-prod (Tel-Aviv scale) still to be re-measured (~Sunday).
- Data quality β many docs have
totalPaid = 0so the income gate hides a lot; the filter dimensions' fill-rate varies by environment (well-populated for Israeli orgs, sparse on US β see Scale); plus junkmonthvalues β bound the picker.
Scale β measured (read-only)β
Measured on US prod, Israeli staging, and now Israeli prod (MONGODB_URI_IL, read from a secondary, 2026-06-21 β the Tel-Aviv measurement the earlier draft left pending).
- IL prod, worst real org-month: org 5 (Tel-Aviv) @ 2026-05 = 22,525 docs β the ~20k Tel-Aviv scale this doc predicted, and growing (~19.7k Dec-2025 β 22.5k May-2026). Collection total ~586k docs; the report reads one org + one month. (US prod / IL staging worst org-months were ~2k docs.)
- The
$matchruns IXSCAN (+FETCH) on the existing{organizationId, month}index β confirmed on IL prod at 22.5k docs, no COLLSCAN. No new index needed. - Full DR
$facetpipeline latency on that worst org-month: ~300 ms median (247 min / 548 max), 5 runs from a secondary. The earlier β26β50 ms figure was at ~2k docs (US prod / IL staging) β a ~10Γ smaller set; the honest production number at Tel-Aviv scale is ~300 ms, not 26 ms. monthhas junk values on prod β distinct months span 2019β2070 (future-dated 2029/2030/2046/2070) β the month picker must bound the selectable range (Β§6). (Equalitymonth ===is still safe; the values are first-of-month UTC, just some are absurd dates.)- Answer: it scales β sub-second, index-bounded, single org-month, read-only β but not at 26 ms. Budget ~300 ms median at Tel-Aviv scale, and watch the growth (org 5 trends up ~3k docs/5 months).
allowDiskUse: truefor the blocking$sortis load-bearing, not just a safety net; a future index coveringtotalPaidfor the sort is an option if an org-month crosses ~40k docs.
Reuse vs net-newβ
Net-new β nothing. The 4 design-system deltas are already prototyped on the POC; the month picker, the Schedule report modal, and the floating-button entry are reused; the report endpoint, faceting, export, and tenancy clamp are glue on existing patterns. (Full per-surface map in Part B Β§1.)
Diagramsβ
Architecture & data flow
Column & KPI math (per event, for the selected month)
Single-month query & scale
Measured on IL prod (2026-06-21, read-only from a secondary): worst real org-month = org 5 (Tel-Aviv) @ 2026-05 = 22,525 docs, full DR $facet pipeline ~300 ms median (548 max) on the existing {organizationId, month} IXSCAN β no new index. (US prod / IL staging at ~2k docs were β26β50 ms.) Pagination bounds the returned payload, not the work; allowDiskUse: true is load-bearing for the blocking sort at this scale, and org 5 is still growing.
Part B β Build referenceβ
Full design (Β§1βΒ§16) β the detailed source used to generate the build tasks. Click to expand.
1. Approach & Rationaleβ
The report rides the existing two-tier org-reports pattern: the external-gateway orchestrates and clamps tenancy; organizations-stats runs the query against the already-aggregated monthly-resources-stats collection (one document per event per month). The frontend reuses the POC scaffold and composes from @bewith-dev/design-system. The wiring map is the spine of this doc β per surface: reuse / glue / net-new.
The wiring mapβ
| Surface | Existing brick | The connection / glue | Verdict |
|---|---|---|---|
| Report data | monthly-resources-stats (one doc per event/month; columns map straight to its fields β Β§4.1) | one single-month query: map fields, compute Gross + Estimation | glue |
| Report endpoint | gateway/RPC org-reports pattern + resolveWithdrawalsClamp (POC) | single-month query; corrected field mapping | glue |
| Filter options | same RPC pattern (POC) | aggregation faceting + cross-narrowing, scoped to the selected month | glue |
| Export | EXPORT_REPORT_TO_EMAIL async-email path | wire FINANCE_WITHDRAWALS_BY_EVENT into the switch + gateway route | glue |
| Schedule report | Shipped "Schedule report" modal on the Financial Reports page (/organization/:id/reports): name, format (CSV/xlsx), recipients, recurrence, end date, timezone β over report-schedules (CREATE/GET/GET_BY_ID/CANCEL) | point it at the withdrawals report type | reuse (as-is) |
| Floating-button entry ("chat") | POC entry on the trends/home page (chat-modal / report wizard) | adapt the filters to the single-month + faceted model and make them work | glue |
| KPI hero / table / filter bar | design-system components | 4 DS deltas (accent, faceted, FilterBar, Table) | DS variants/composites |
| Month picker | existing FormDatePicker (MUI DatePicker wrapper, month/year view; already used in coupon-form) | reuse β no DS change | reuse |
| FE page / store / client | POC scaffold proves the shape | apply the corrected semantics (below) | glue |
| Tenancy / permission | PermissionsHelper clamp + admin permission | community admins see only their scope; clamp on every read | reuse |
There is essentially no net-new work. The month picker reuses the existing FormDatePicker (no DS change); the 4 DS deltas are already prototyped on the POC; everything else is reuse or glue. There is no separate "saved reports" feature β "save reports" in the requirements was a typo for Schedule report, already shipped on the Financial Reports page.
Confirmed semantics (product owner)β
Each monthly-resources-stats document is one event in one month; the report reads the selected month's document per event. Money columns and KPIs map to those fields:
- Gross Revenue = Income + Commission + Refund (computed from the three sourced fields; the schema's
totalRevenueis a NET figure and is not used). - Income = Withdrawal + Estimation βΉ Estimation = Income β Withdrawal.
- The column previously labelled "Transfer" is renamed "Withdrawal" (Hebrew: ΧΧ’ΧΧ¨Χ β ΧΧ©ΧΧΧ).
- One Estimation column, as the last column of each row (not one per month).
- No bottom totals row β the totals live in the KPI hero strip above.
2. Affected Reposβ
| Repo | What changes |
|---|---|
design-system | 2 deltas (narrowed after the local spike): MultiSelectSearchInput faceted mode + WithdrawalsReportFilterBar composite. The KPI hero reuses the existing org-dashboard StatsCards and the table reuses the existing CustomTable / TableViewProvider β so the earlier SummaryCard-accent and WithdrawalsReportTable deltas are dropped (validated on the POC; Gali asked for it to look like the existing Financial Reports). Both surviving deltas are backward-compatible; publish + version-bump before the FE consumes. The month picker reuses the MUI FormDatePicker β no DS change. |
backend-services | Additive endpoints on the org-reports module: reports/withdrawals-by-event (single-month, corrected mapping), reports/withdrawals-by-event/filter-options, export (reuse EXPORT_REPORT_TO_EMAIL with reportType=FINANCE_WITHDRAWALS_BY_EVENT). A sibling of the existing live reports/withdrawals payout report (hyphen, not a slash sub-path β see Β§3). Reuse report-schedules for scheduling. Admin permission (no dedicated finance permission); clamp on every read; 5-min scope-keyed Redis cache (Β§5.3). No change to the upstream withdrawals write path. |
organization-dashboard | Adapt the POC: page + store + http-client to the single-month contract; faceted filter bar + single Month picker (FormDatePicker, month/year view); KPI hero strip; table (rename Withdrawal, single Estimation, drop totals row); reuse schedule UI; adapt the floating-button entry filters; 6 analytics events; RTL/Hebrew copy; feature flag. |
3. Architectureβ
Dedicated endpoints (rather than multiplexing the existing GET_ORGANIZATION_DATA_BY_TYPE report-type switch) keep the faceted-filtering + computed-column logic explicit and testable. Controllers stay thin; logic lives in the orchestrator/services; RPC payloads are interfaces and @MessagePattern always takes an endpoint enum.
Naming β withdrawals-by-event is a sibling, not a sub-path of withdrawals. A live GET .../reports/withdrawals endpoint already exists: it serves the provider-level payout report (Payme/Stripe, per transaction) via GET_ORGANIZATION_DATA_BY_TYPE, and has its own sidebar entry. Our report is a different angle β money owed/withdrawn to the city, per event/month, aggregated from monthly-resources-stats via a dedicated GET_WITHDRAWALS_BY_EVENT RPC. A slash (withdrawals/by-event) would imply our report is a view of the payout report; it isn't. So the route is the hyphenated sibling reports/withdrawals-by-event, with filter-options and export as its own sub-resources. (This is also why the POC's export, mis-wired to FINANCE_WITHDRAWALS, pulled the payout collection β Β§5 fixes it with a dedicated FINANCE_WITHDRAWALS_BY_EVENT report type.)
4. Data Modelβ
The report reads an existing collection and reuses report-schedules. No new collection is required.
4.1 Read source: monthly-resources-stats β field mappingβ
apps/organizations-stats/src/resources-stats/schemas/monthly-resources-stats.schema.ts. One document per {organizationId, originalCommunityId, resourceId, month}. Confirmed against a production sample:
| Report element | Source field | Notes |
|---|---|---|
| Event id / name | resourceId / resourceName | row identity |
| Hub | originalCommunityId / originalCommunityName | facet |
| Responsible Center | centerResponsible | facet; may be "" (treat as unassigned) |
| Budget Line | budgetLine | facet; stored as string, may be "" (treat as unassigned) |
| Currency | currency | community currency (FR-25) |
| Month | month (Date, first-of-month) | the selected month matches one bucket |
| Income | totalPaid | |
| Commission | totalCommissions | |
| Refund | totalRefunded | |
| Withdrawal (selected month) | totalWithdrawals | renamed from "Transfer" |
| Gross Revenue | computed = totalPaid + totalCommissions + totalRefunded | totalRevenue is NET β do not use |
| Estimation | computed = totalPaid β totalWithdrawals | Income β Withdrawal |
Freshness (asOf) | updatedAt | most-recent across matched docs |
All required values are present in the document; only Gross and Estimation are computed (from present fields).
Indexes already present: unique {organizationId, originalCommunityId, resourceId, month}; {organizationId, resourceId, month}; {organizationId, originalCommunityId, month}; {organizationId, month}. The single-month query matches on {organizationId, month, β¦scope} β served by the existing indexes. No new index assumed (validated in prod β see Β§5.2).
Verified read-only (US prod + IL staging): the month values are first-of-month 00:00 UTC (0 anomalies), so exact month === equality is safe. Junk months exist (a 1970 epoch and future 2036 values) β the month picker must bound selectable months to a sane range (Β§6). (IL prod to be re-confirmed ~Sunday.)
4.2 Scheduling: reuse the shipped Schedule report featureβ
The "Schedule report" modal is a shipped, working feature on the Financial Reports page (/organization/:id/reports) β name, format (CSV/xlsx), recipients, recurrence (e.g. Daily 09:00), end date, timezone (Asia/Jerusalem), schedule summary. It is backed by apps/organizations-stats/src/report-schedule/schemas/report-schedule.schema.ts (per-user/org/community; action.config carries the report payload) with RPC CREATE_REPORT_SCHEDULE / GET_REPORT_SCHEDULES / GET_REPORT_SCHEDULE_BY_ID / CANCEL_REPORT_SCHEDULE. A withdrawals schedule is the same modal pointed at action.config.reportType = FINANCE_WITHDRAWALS_BY_EVENT with action.config.filterBy.month. No new collection, no new UI β reuse as-is.
4.3 Migrations / backfillsβ
None for the core (read-only report; scheduling reuses an existing collection). An index only if Β§5.2 explain() ever shows a gap (additive, online, authored in migrations-tool).
5. API / Contractsβ
Gateway routes under organizations-reports, prefix organizations/:organizationId/reports/..., @CommunityGuard(); every read clamped server-side via resolveWithdrawalsClamp so a community admin sees only their permitted communities/resources (FR-01/02). Internal RPC @Payload() = interfaces; gateway @Body()/@Query() = class DTOs. Pagination 0-based, built in the gateway with getPagination().
The report is always grouped per-event (one row per resourceId). The four dimensions β Responsible Center, Hub, Budget Line, Event β are filters, not groupings: all four narrow the same per-event result via $match. There is no dynamic group-by (the PRD didn't ask for one, so we don't open the scope); the wizard's "Analyze by" step is therefore copy-renamed "Filter by" so it doesn't imply grouping. All read endpoints are wrapped in a 5-min, scope-keyed Redis cache (Β§5.3).
| Capability | Gateway (method + path) | RPC topic (enum) | Request β Response | Maps to |
|---|---|---|---|---|
| Get report | GET .../withdrawals-by-event | OrganizationReportEndpointEnum.GET_WITHDRAWALS_BY_EVENT (exists) | WithdrawalsByEventPayloadInterface β WithdrawalsByEventReportInterface | FR-12..25 |
| Faceted filter options | GET .../withdrawals-by-event/filter-options | GET_WITHDRAWALS_FILTER_OPTIONS (exists) | WithdrawalsFilterOptionsPayloadInterface β WithdrawalsFilterOptionsInterface | FR-03..07 |
| Export (xlsx/csv) | POST .../withdrawals-by-event/export | EXPORT_REPORT_TO_EMAIL (exists) with reportType=FINANCE_WITHDRAWALS_BY_EVENT | OrganizationReportPayloadWithUserIdInterface β { accepted: true } | FR-26 |
| Schedules (create/list/get/cancel) | POST / GET / GET :id / POST :id/cancel .../schedules | CREATE_REPORT_SCHEDULE, GET_REPORT_SCHEDULES, GET_REPORT_SCHEDULE_BY_ID, CANCEL_REPORT_SCHEDULE (all exist) | CreateReportScheduleDto β schedule docs | FR-33..35 |
Auth: bearer JWT, CommunityGuard, admin permission (no dedicated finance permission needed), plus the data clamp on every endpoint.
5.1 Contract: single month + computed columnsβ
libs/interfaces/reports/withdrawals-by-event-payload.interface.ts:
export interface WithdrawalsByEventFilterByInterface {
month: string; // 'YYYY-MM' β resolved to the community-tz calendar month server-side (FR-11)
responsibleCenterIds?: number[];
hubIds?: number[];
budgetLineIds?: number[];
eventIds?: number[];
}
The gateway DTO validates month with @Matches(/^\d{4}-\d{2}$/) (a field-specifying param β never a bare @IsString); sortBy keeps the enum-validated WithdrawalsByEventSortByEnum. Each row carries grossRevenue, income, commission, refund, withdrawal, estimation. The response drops the POC's months[] matrix and per-month estimation.
5.2 The queryβ
WithdrawalsByEventService.getWithdrawalsByEventAndCount runs one monthly-resources-stats aggregation for the selected month monthStart:
$matchβorganizationId;month === monthStart; the clamp (originalCommunityId β communitiesIds,resourceId β resourcesIds); facet filters (centerResponsible β β¦,budgetLine β β¦,hubIds,eventIds). This is the full in-scope month set β no income gate here. Filter in the query, not in code.$addFieldsβgrossRevenue = totalPaid + totalCommissions + totalRefunded;withdrawal = totalWithdrawals;estimation = totalPaid β totalWithdrawals.$facet(withallowDiskUse: true) β one round trip, two scopes:kpis=$group _id:nullsumming over the full set (step 1);rows=$match totalPaid !== 0(the table shows only events with income that month β task remark 1 / FR-22), then$sort(DB-side) +$skip/$limit;totalCount= the same income-gated$count. The service returns[rows, totalCount, kpis, currency, asOf]; the gateway buildsgetPagination.
KPI hero strip (FR-13/14): sums over the full in-scope month set β including events with a withdrawal but no income that month (e.g. income earned in an earlier month). The product owner confirmed these count in the KPIs even though they are not shown as table rows, so Total Withdrawal (hero) can exceed the sum of the visible Withdrawal column. Cards: Total Gross Revenue, Total Income, Total Commission, Total Refunds, Total Withdrawal (= Ξ£ withdrawal, accented pink), Estimated Remaining (= Ξ£ estimation, accented blue). Because every document satisfies income = withdrawal + estimation, on this set Total Income = Total Withdrawal + Estimated Remaining and Total Gross = Income + Commission + Refunds β the hero is the totals, which is why the bottom totals row is dropped.
Scale (validated read-only β US prod, IL staging, and IL prod): the $match uses the existing {organizationId, month} index (IXSCAN+FETCH, no over-scan) β confirmed on IL prod at the worst real org-month (org 5 / Tel-Aviv @ 2026-05 = 22,525 docs; collection ~586k). The full pipeline runs in ~300 ms median there (247β548 ms, 5 runs from a secondary); the earlier β26β50 ms figure was at ~2k docs (US prod / IL staging), a ~10Γ smaller set. The report only ever touches one org-month. Pagination bounds the returned payload, not the work β the $group sums all in-scope docs and the $sort orders the income subset before paging; what bounds the cost is the index-bounded single-month match. allowDiskUse: true is load-bearing (not just a safety net) for the blocking sort at this scale. No new index needed β but org 5 is growing (~19.7kβ22.5k in 5 months); re-check if an org-month crosses ~40k docs (a totalPaid-covering sort index becomes the lever then).
WithdrawalsFilterOptionsService is a $group-based distinct-values aggregation per dimension (replacing the POC's read-all-into-memory loop, which on org 5 pulled ~22k docs and ran cross-narrowing in JS β the stall Aviad flagged). The other selected facets are pushed into $match (cross-narrowing, FR-06/07) with the selected month + totalPaid !== 0 as preconditions; empty centerResponsible/budgetLine ("") are excluded from the option lists (or surfaced as "unassigned"); search + keep-selected applied as a final $match/$or.
Event dimension β bounded, not "return everything". The dump confirms org 5 has ~14k distinct events β far too many to ship to a dropdown. Two mechanisms keep it usable: (1) cross-narrowing β once a Hub / Center / Budget Line is picked, the Event list collapses from thousands to dozens (the same $match push-down); (2) the Event control is an async, type-to-search autocomplete that returns a bounded top-N page and lazy-scrolls (server-side search + skip/limit; FE virtualizes the list). Responsible Centers / Budget Lines / Hubs (121 / 234 / 212 on org 5) are small enough to return whole. So the filter-options endpoint never materializes the full event set.
5.3 Caching (response-level, scope-keyed)β
Both read endpoints (withdrawals-by-event and its filter-options) are wrapped at the gateway in @RedisCache(TimeMultiplierEnum.FIVE, RedisCacheTimePeriodEnum.MINUTE) β the same 5-min mechanism already used by the Feeds and Communities controllers. A finance report tolerates 5-min staleness, and the cache absorbs the repeated identical calls a wizard makes while the admin tweaks filters.
Cache key includes the permission scope β not just the URL. The default key is method + path + SHA1(query); it carries organizationId (path) and the filters/page/month (query) but not the caller's permission scope. Two community-admins of the same org with different permittedCommunitiesIds hitting the same URL would otherwise share one cached response β cross-tenant leak. So the key is extended with the clamp scope that resolveWithdrawalsClamp already computes: "ALL" for org/super-admins (whole org), or hash(sorted communityIds) for community-admins. Result: admins with identical scope share a cache entry (more hits); different scopes get separate entries (zero leak). No userId in the key β scope is the correct granularity and yields more hits than per-user. Invalidation is TTL-only (5 min).
6. Frontend & Componentsβ
| Surface (PRD Β§7.2) | Component | Reuse / New |
|---|---|---|
| Header title / eyebrow / description | Typography | reuse |
| Header actions (Export, Schedule report) | Button, BeWithBasicButton, Modal | reuse |
| Filter bar (4 faceted multi-selects + Month picker) | WithdrawalsReportFilterBar composite | NEW (delta) |
| Faceted entity filters (Center / Budget / Hub return whole; Event = async type-to-search + lazy-scroll, Β§5.2) | MultiSelectSearchInput faceted mode | NEW variant (delta) |
| Single Month picker | existing FormDatePicker (MUI DatePicker, month/year view) | reuse β no DS change; bound the selectable range (Β§4.1) |
| KPI hero strip (6 cards; Total Withdrawal pink, Estimated Remaining blue) | existing org-dashboard StatsCards (@/components/stats) | reuse β accent via the existing card config; no DS delta |
| Per-event table (fixed first col; Gross, Income, Commission, Refund, Withdrawal, Estimation (last); no totals row; sortable) | existing CustomTable + TableViewProvider (MUI X-DataGrid; TableEnum + columnConfig) | reuse β no DS delta |
| Floating-button entry ("chat") | POC chat-modal / report wizard entry on trends/home | reuse β adapt filters to single month + faceted |
| Schedule report dialog | Shipped "Schedule report" modal on the Financial Reports page | reuse β point at the withdrawals report type |
| Empty / Loading / Error | ChartEmptyMessage, Skeleton, the StatsCards / CustomTable loading states, Typography+Button | reuse |
| Export toast | useSnack | reuse |
Copy changes from PRD Β§7.4: the month sub-column "Transfer / ΧΧ’ΧΧ¨Χ" becomes "Withdrawal / ΧΧ©ΧΧΧ"; the table shows a single "Estimation / ΧΧΧΧΧ" column as the last column; the bottom totals row is removed. The 2 surviving DS deltas (WithdrawalsReportFilterBar + faceted MultiSelectSearchInput) must ship + version-publish before the FE consumes them (Β§8); the KPI hero and table reuse existing org-dashboard components, so they don't gate on a DS release.
Naming caveat (pending product). This DR keeps "Withdrawal / ΧΧ©ΧΧΧ" (PRD Β§7.4) and the route
withdrawals-by-event. There is an open product question (Gali) on whether the right term is actually "Transfers / ΧΧ’ΧΧ¨ΧΧͺ" β municipalities use "ΧΧ’ΧΧ¨ΧΧͺ", and Gali already wrote that in the source table; "withdrawal" also collides with the existing payout report. Decision deferred to product; if she confirms "transfers", it's a contained rename (label + route βevent-transfers), not a redesign. Build proceeds under the current name.
7. Observability Instrumentationβ
Six analytics events, via the Intercom facade in organization-dashboard/src/utils/analytics/track.ts (β€5 metadata keys; org/community/user/ts on each). The PRD's seventh event (withdrawals_report_saved) is dropped β "saved reports" was a typo for Schedule report.
| Event | Where it fires |
|---|---|
withdrawals_report_viewed | report page, once on first successful load |
withdrawals_report_filtered | a facet filter changes |
withdrawals_report_month_changed | the Month picker changes (replaces the POC's date_preset_changed) |
withdrawals_report_exported | export trigger (format in payload) |
withdrawals_report_scheduled | schedule created / cancelled |
withdrawals_report_chat | the floating-button entry is used |
8. Integration Points & Dependenciesβ
| Dependency | Why | Failure-mode handling |
|---|---|---|
PermissionsHelper (user/groups) | tenancy clamp on every read | org/community mismatch β 403; clamp intersects requested ids with permitted ids |
report-schedules + due-runner | scheduled delivery | reuse existing CRUD/audit |
EXPORT_REPORT_TO_EMAIL path | async xlsx/csv to email | runs off-request; no request timeout |
monthly-resources-stats writer | the read data; payouts attributed per event upstream | report is a pure consumer; surface asOf for staleness |
Cross-repo merge order: (1) design-system β ship + publish the 2 deltas (filter bar + faceted multi-select; backward-compatible); (2) backend-services β additive endpoints + scope-keyed cache; (3) organization-dashboard β bump DS version, then adapt the POC (the KPI hero + table already reuse existing components, so only the filter bar gates on the DS release). FE merges behind the flag, so it can land before backend rollout completes.
9. Failure Modes & Error Handlingβ
| Scenario (PRD Β§6) | Technical handling |
|---|---|
| No event had income in the selected month | Aggregation returns no rows β empty state (FR-27); KPIs all 0 in the community currency. Distinguish from a query failure (empty = success; failure throws). |
| Negative Estimation (Withdrawal > Income that month) | Return the negative value as-is; do not clamp (FR-25). |
| Event with a withdrawal but no income that month (e.g. income earned earlier) | Not a table row (income gate, FR-22), but its withdrawal counts in the Total Withdrawal KPI (product owner confirmed) β KPIs sum the full in-scope month set, so the hero Total Withdrawal can exceed the visible rows' Withdrawal. |
Empty centerResponsible / budgetLine ("") | Treated as unassigned; excluded from facet option lists (or shown as "unassigned"); rows still render. |
| Data fetch fails (Mongo/RPC) | Service logs a unique message + context (organizationId, communitiesIds, month) and throw new ExceptionErrorBuilder; RPC β RpcException; gateway β `HttpException(e.message, e.code |
| Out-of-scope request (community/event the admin doesn't administer) | Report and data not returned; admin guard + clamp β 403 (FR-01). |
| Currency β βͺ | All amounts render in the community currency/locale; no conversion (FR-25). |
10. Breaking Changesβ
None beyond a normal deploy. Endpoints are additive and read-only; scheduling reuses an existing collection; any index is additive/online.
11. Rolloutβ
- Feature flag:
org_dashboard_withdrawals_report(FEsrc/config/feature-flags.ts), default off; the page SSR-gates tonotFoundwhen off. Flipped per community by R&D/ops. The admin permission + per-request clamp gate visibility even when on. - Migration / backfill: none for the core. Optional index after
explain(). - Phased rollout: staging seed β 1β2 pilot finance communities β widen per community after a clean watch window β general availability.
- Rollback: flip the flag off (route β
notFound, nav entry hidden) β no code deploy. Backend endpoints are additive/read-only and stay deployed.
12. Testing Strategyβ
- Unit (backend): the column math β
grossRevenue = paid + commission + refund;estimation = paid β withdrawal; thetotalPaid !== 0row gate;YYYY-MMβ community-tz calendar bounds; sort + pagination pushed to the DB; faceted cross-narrowing; empty-facet handling. The clamp (requested ids β© permitted ids β 403). - Scale assertion: the single-month aggregation uses IXSCAN on
{organizationId, month}(not COLLSCAN) and setsallowDiskUse: trueβ assert against anexplain()in an integration test. - Unit (FE): month β query mapping + URL round-trip; store
getRequestData; KPI accent mapping; minorβmajor money formatting; the 6 analytics payloads (nodate_preset_changed). - Unit (design-system): stories for the 2 deltas β
MultiSelectSearchInputfaceted (incl. the Event async/lazy-scroll mode) andWithdrawalsReportFilterBar. The KPI hero (StatsCards) and table (CustomTable) are existing components covered by their own suites; assert the withdrawals column config (fixed col, single Withdrawal + single Estimation, no totals row, muted zero, negative Estimation) in the FE unit layer. - Integration / RTL: page composition per view-state; filter change refetches; the floating-button entry opens the report with the chosen month + filters; schedule create/list/cancel; RTL/Hebrew direction + corrected copy (Withdrawal/ΧΧ©ΧΧΧ).
- E2E (β PRD Β§9): flag off β 404; flag on + permission β renders; pick a month β KPIs/table update; apply 4 facet filters β narrowed + selections preserved; export xlsx/csv β toast; schedule createβcancel.
13. Task Breakdownβ
Tracker split (confirmed): product items live in ClickUp β the feature umbrella CU-86ca1xm1k plus the open product calls (the transfers/ΧΧ’ΧΧ¨ΧΧͺ naming, the month-picker range, whether to surface the KPI-vs-rows asymmetry). Software work lives in GitHub Issues, one per PR. The granular steps are a checklist inside that issue β checkboxes, not separate issues (which would just flood the tracker). Reflection stays concise: the PR link + checklist state on the GitHub issue, and a one-line milestone note on the ClickUp umbrella (DS published / BE merged / FE behind flag / GA) β no play-by-play.
The breakdown is deliberately coarse: one PR per repo, in merge order (Β§8) β small enough to review, without fragmenting glue into crumbs. Two seams are pre-identified and split into a fast-follow PR only if the parent outgrows review: BE export/cache (off the read API) and FE P1 export/schedule (off the P0 render). Build happens locally first (in the worktrees) and is then carved onto these seams.
| # | PR (one tracked issue) | Repo | Effort | Risk | Depends on |
|---|---|---|---|---|---|
| 1 | DS β withdrawals report components | design-system | M | Med | β |
| 2 | BE β withdrawals read API | backend-services | M | Med | β |
| 3 | FE β withdrawals report (behind the flag) | organization-dashboard | L | Med | 1, 2 |
PR 1 β design-system (publish one version bump): the 2 surviving deltas β MultiSelectSearchInput faceted mode (incl. the Event async/lazy-scroll variant) Β· WithdrawalsReportFilterBar composite Β· publish + version-bump. Both prototyped on the POC and backward-compatible. The KPI hero (StatsCards) and the table (CustomTable/TableViewProvider) reuse existing org-dashboard components β no DS work; the month picker reuses the MUI FormDatePicker β no DS work.
PR 2 β backend-services (additive, read-only): withdrawals-by-event query (single-month $facet, field mapping, Gross + Estimation computed; KPIs over the full month set, table rows income-gated; DB-side sort/paginate; allowDiskUse) Β· filter-options ($group distinct + cross-narrowing + empty-facet handling; Event bounded top-N + search, not the full ~14k) Β· admin permission + clamp on every read Β· 5-min scope-keyed Redis cache on both reads Β· export wiring (FINANCE_WITHDRAWALS_BY_EVENT into EXPORT_REPORT_TO_EMAIL) Β· schedule wiring (reportType + filterBy.month over the existing report-schedules) Β· unit tests (Β§12). Export/cache are trivial reuse β fold in; spin the fast-follow only if PR 2 gets heavy (seam above).
PR 3 β organization-dashboard (behind org_dashboard_withdrawals_report, default off). P0 render: bump DS (filter bar only) + replace POC imports Β· single Month picker (drop the 8-preset wizard) on the report and the floating-button entry Β· page/store/client to the single-month contract Β· KPI hero strip (existing StatsCards) Β· table (existing CustomTable: rename Withdrawal, single Estimation, drop totals row) Β· the wizard "Analyze by"β"Filter by" copy Β· RTL/Hebrew + all states Β· FE units. P1 (seam β same PR unless heavy): export trigger + reuse the shipped Schedule report modal Β· 6 analytics events (drop date_preset_changed) Β· integration/E2E (Β§12).
Effort: XS = under 1 day Β· S = 1β3 days Β· M = up to 1 week Β· L = 1β2 weeks Β· XL = up to 1 month.
14. Risks & Alternatives Consideredβ
-
Risk: report scans
monthly-resources-statsat scale β Mitigation (validated on IL prod): single-month$matchon the existing{organizationId, month}index (IXSCAN); the worst real org-month (org 5 / Tel-Aviv, 22,525 docs) runs the full$facetpipeline in ~300 ms median (548 ms max) from a secondary β sub-second, index-bounded, withallowDiskUseload-bearing for the sort. (At ~2k docs on US prod / IL staging it was β26β50 ms.) Org 5 grows ~3k docs/5 months β watch it; atotalPaid-covering sort index is the lever past ~40k. The thing being replaced is the POC's in-memory accumulate-all read. -
Risk: the table (income-gated) and the KPIs (full month set) cover different row sets β Mitigation: compute both in one
$facetwith the income gate only on the rows branch; document that heroTotal Withdrawalcan exceed the visible column (product owner confirmed). -
Alternative: multiplex through the existing
GET_ORGANIZATION_DATA_BY_TYPEreport-type switch β Rejected: dedicated endpoints keep the faceted-filtering + computed-column logic explicit and testable. -
Alternative: a grounded-LLM chat backend β Dropped: there is no Q&A chat; the "chat" is the floating-button entry to the report, already built in the POC.
15. Open Questions for the Build Stageβ
The product owner resolved the design questions (recorded here for the DR):
- Gross Revenue = Income + Commission + Refund; Estimation = Income β Withdrawal.
- "Transfer" β "Withdrawal"; one Estimation column (last); no bottom totals row (the KPIs are the totals).
- KPIs sum the full in-scope month set, including events with a withdrawal but no income (so
Total Withdrawalcan exceed the visible rows); the table itself shows only income > 0 events. - Permission = admin (no dedicated finance permission), scoped to the admin's own communities/resources via the clamp.
- The month picker reuses the MUI
FormDatePicker(month/year view) β no design-system change. - The "chat" is the floating-button entry (reuse the POC, adapt filters) β not an LLM endpoint.
- Scheduling reuses the shipped "Schedule report" modal; there is no separate "saved reports" feature β it was a typo for Schedule report.
Three product items remain for product (non-blocking, tracked in ClickUp, not GitHub): the month-picker selectable range (prod has 1970/2036 junk months); whether to surface the KPI-vs-rows asymmetry in the UI; and the "Withdrawal" vs "Transfers / ΧΧ’ΧΧ¨ΧΧͺ" naming (Gali β see the Β§6 caveat). The build proceeds under the current names; all three are contained changes, not redesigns.
16. Definition of Doneβ
Feature-level DoD β checked once across the whole feature (each task/PR also carries its own checklist, enforced by process-guardian).
- All P0 acceptance criteria from PRD Β§9 pass.
- Code reviewed and merged on every affected repo (
design-system,backend-services,organization-dashboard). - Automated tests written and passing (unit + integration per Β§12).
- Every added/changed HTTP endpoint saved in the R&D Postman workspace with a sample request + saved response example.
- UI matches the design reference across all states (default / loading / empty / error / success / disabled / selected).
- Renders correctly in RTL (Hebrew) and on mobile + desktop.
- All 6 analytics events fire correctly, verified in the observability stack.
- Feature flag wired; verified on and off; admin permission + clamp verified.
- Accessibility: filters, month picker, and table keyboard-navigable with screen-reader labels; sorted columns + selected filters announced.
- No P0/P1 bugs open.
- PM sign-off against PRD acceptance criteria.
- Docs / changelog updated where relevant.