Skip to main content

Design Doc: Withdrawals Report

DraftAuthor: @relbnsPRD: withdrawals-report-CU-86ca1xm1k
Discussion document β€” for a mini design review

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 Withdrawal can 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 month values (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 = 0 so 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 junk month values β†’ 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 $match runs IXSCAN (+FETCH) on the existing {organizationId, month} index β€” confirmed on IL prod at 22.5k docs, no COLLSCAN. No new index needed.
  • Full DR $facet pipeline 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.
  • month has junk values on prod β€” distinct months span 2019–2070 (future-dated 2029/2030/2046/2070) β†’ the month picker must bound the selectable range (Β§6). (Equality month === 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: true for the blocking $sort is load-bearing, not just a safety net; a future index covering totalPaid for 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​

SurfaceExisting brickThe connection / glueVerdict
Report datamonthly-resources-stats (one doc per event/month; columns map straight to its fields β€” Β§4.1)one single-month query: map fields, compute Gross + Estimationglue
Report endpointgateway/RPC org-reports pattern + resolveWithdrawalsClamp (POC)single-month query; corrected field mappingglue
Filter optionssame RPC pattern (POC)aggregation faceting + cross-narrowing, scoped to the selected monthglue
ExportEXPORT_REPORT_TO_EMAIL async-email pathwire FINANCE_WITHDRAWALS_BY_EVENT into the switch + gateway routeglue
Schedule reportShipped "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 typereuse (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 workglue
KPI hero / table / filter bardesign-system components4 DS deltas (accent, faceted, FilterBar, Table)DS variants/composites
Month pickerexisting FormDatePicker (MUI DatePicker wrapper, month/year view; already used in coupon-form)reuse β€” no DS changereuse
FE page / store / clientPOC scaffold proves the shapeapply the corrected semantics (below)glue
Tenancy / permissionPermissionsHelper clamp + admin permissioncommunity admins see only their scope; clamp on every readreuse

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 totalRevenue is 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​

RepoWhat changes
design-system2 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-servicesAdditive 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-dashboardAdapt 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 elementSource fieldNotes
Event id / nameresourceId / resourceNamerow identity
HuboriginalCommunityId / originalCommunityNamefacet
Responsible CentercenterResponsiblefacet; may be "" (treat as unassigned)
Budget LinebudgetLinefacet; stored as string, may be "" (treat as unassigned)
Currencycurrencycommunity currency (FR-25)
Monthmonth (Date, first-of-month)the selected month matches one bucket
IncometotalPaid
CommissiontotalCommissions
RefundtotalRefunded
Withdrawal (selected month)totalWithdrawalsrenamed from "Transfer"
Gross Revenuecomputed = totalPaid + totalCommissions + totalRefundedtotalRevenue is NET β€” do not use
Estimationcomputed = totalPaid βˆ’ totalWithdrawalsIncome βˆ’ Withdrawal
Freshness (asOf)updatedAtmost-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).

CapabilityGateway (method + path)RPC topic (enum)Request β†’ ResponseMaps to
Get reportGET .../withdrawals-by-eventOrganizationReportEndpointEnum.GET_WITHDRAWALS_BY_EVENT (exists)WithdrawalsByEventPayloadInterface β†’ WithdrawalsByEventReportInterfaceFR-12..25
Faceted filter optionsGET .../withdrawals-by-event/filter-optionsGET_WITHDRAWALS_FILTER_OPTIONS (exists)WithdrawalsFilterOptionsPayloadInterface β†’ WithdrawalsFilterOptionsInterfaceFR-03..07
Export (xlsx/csv)POST .../withdrawals-by-event/exportEXPORT_REPORT_TO_EMAIL (exists) with reportType=FINANCE_WITHDRAWALS_BY_EVENTOrganizationReportPayloadWithUserIdInterface β†’ { accepted: true }FR-26
Schedules (create/list/get/cancel)POST / GET / GET :id / POST :id/cancel .../schedulesCREATE_REPORT_SCHEDULE, GET_REPORT_SCHEDULES, GET_REPORT_SCHEDULE_BY_ID, CANCEL_REPORT_SCHEDULE (all exist)CreateReportScheduleDto β†’ schedule docsFR-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:

  1. $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.
  2. $addFields β€” grossRevenue = totalPaid + totalCommissions + totalRefunded; withdrawal = totalWithdrawals; estimation = totalPaid βˆ’ totalWithdrawals.
  3. $facet (with allowDiskUse: true) β€” one round trip, two scopes: kpis = $group _id:null summing 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 builds getPagination.

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)ComponentReuse / New
Header title / eyebrow / descriptionTypographyreuse
Header actions (Export, Schedule report)Button, BeWithBasicButton, Modalreuse
Filter bar (4 faceted multi-selects + Month picker)WithdrawalsReportFilterBar compositeNEW (delta)
Faceted entity filters (Center / Budget / Hub return whole; Event = async type-to-search + lazy-scroll, Β§5.2)MultiSelectSearchInput faceted modeNEW variant (delta)
Single Month pickerexisting 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/homereuse β€” adapt filters to single month + faceted
Schedule report dialogShipped "Schedule report" modal on the Financial Reports pagereuse β€” point at the withdrawals report type
Empty / Loading / ErrorChartEmptyMessage, Skeleton, the StatsCards / CustomTable loading states, Typography+Buttonreuse
Export toastuseSnackreuse

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.

EventWhere it fires
withdrawals_report_viewedreport page, once on first successful load
withdrawals_report_filtereda facet filter changes
withdrawals_report_month_changedthe Month picker changes (replaces the POC's date_preset_changed)
withdrawals_report_exportedexport trigger (format in payload)
withdrawals_report_scheduledschedule created / cancelled
withdrawals_report_chatthe floating-button entry is used

8. Integration Points & Dependencies​

DependencyWhyFailure-mode handling
PermissionsHelper (user/groups)tenancy clamp on every readorg/community mismatch β†’ 403; clamp intersects requested ids with permitted ids
report-schedules + due-runnerscheduled deliveryreuse existing CRUD/audit
EXPORT_REPORT_TO_EMAIL pathasync xlsx/csv to emailruns off-request; no request timeout
monthly-resources-stats writerthe read data; payouts attributed per event upstreamreport 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 monthAggregation 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 (FE src/config/feature-flags.ts), default off; the page SSR-gates to notFound when 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; the totalPaid !== 0 row 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 sets allowDiskUse: true β€” assert against an explain() 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 (no date_preset_changed).
  • Unit (design-system): stories for the 2 deltas β€” MultiSelectSearchInput faceted (incl. the Event async/lazy-scroll mode) and WithdrawalsReportFilterBar. 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)RepoEffortRiskDepends on
1DS β€” withdrawals report componentsdesign-systemMMedβ€”
2BE β€” withdrawals read APIbackend-servicesMMedβ€”
3FE β€” withdrawals report (behind the flag)organization-dashboardLMed1, 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-stats at scale β†’ Mitigation (validated on IL prod): single-month $match on the existing {organizationId, month} index (IXSCAN); the worst real org-month (org 5 / Tel-Aviv, 22,525 docs) runs the full $facet pipeline in ~300 ms median (548 ms max) from a secondary β€” sub-second, index-bounded, with allowDiskUse load-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; a totalPaid-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 $facet with the income gate only on the rows branch; document that hero Total Withdrawal can exceed the visible column (product owner confirmed).

  • Alternative: multiplex through the existing GET_ORGANIZATION_DATA_BY_TYPE report-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 Withdrawal can 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.