Skip to content

Subscription model updated, Recently approved flag, fixes#581

Merged
ussaama merged 9 commits into
mainfrom
fixes-and-ui-changes
May 20, 2026
Merged

Subscription model updated, Recently approved flag, fixes#581
ussaama merged 9 commits into
mainfrom
fixes-and-ui-changes

Conversation

@ussaama
Copy link
Copy Markdown
Contributor

@ussaama ussaama commented May 20, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added annual/monthly billing period selection to workspace upgrade and creation flows
    • Tier pricing now displays structured pricing per billing period
    • Admin workspace request approval now includes billing period confirmation and override
    • "Recently approved" badge now appears on newly created workspaces
  • Email Templates

    • Workspace request emails now include proposed and approved billing period information
  • Documentation

    • Added architecture decision record for billing period selection design

Review Change Stack

ussaama added 6 commits May 18, 2026 15:51
  - Rename "Add workspace" to "Request workspace" on dashboard
  - Add step descriptions and free-tier nudge to request wizard
  - Update Review step placeholder and add character count indicator
  - Change pending status text to "requested on <date>"
  - Fix external members modal: pre-render first input, remove cost copy, fix "on" → "from"
  - Show pilot tier price (€349 one-time) in tier picker
  - Admin upgrades table: add "Open details" button, use date-fns relative time, cleaner spacing
  - Hide "Tier expires at" field from admin approve dialog
  Show a green badge on workspaces created within the last 24 hours.
  Pipes created_at from Directus through the API to the frontend.
…nd add i18n support

  - Add TierPricingCards with animated gradient border selection (CSS module)
  - Rewrite TierCapacityMatrix as flat HTML table with sticky column
  - Deduplicate Tier type, TIER_ORDER across FeatureGate, AdminSettingsRoute into shared tiers.ts
  - Add t() wrappers for tier taglines, bestFor, capacityShort, and pricing text
  - Use TierPricingCards in UpgradeModal, admin approval dialog, and workspace request wizard
  - Change free tier duration from "permanent" to "—"
  Introduces a workspace-request billing-period toggle (annual / monthly) on
  every tier pricing surface: workspace creation wizard, FeatureGate upgrade
  modal, admin approval dialog, and the matrix on workspace + admin settings.
  The selected cadence flows through submit, lands on workspace_request as
  proposed_billing_period / approved_billing_period (kept separate as an
  audit trail), and surfaces in the staff notification, both transactional
  emails, and the admin Upgrades + Usage tables.

  Tier pricing API moves from a flat price_eur_monthly / price_note shape to
  a nested pricing object with explicit annual_billing / monthly_billing /
  one_time slots — drops the regex parsing of price_note on the frontend.
  Monthly rate is derived from the annual anchor via a single code constant
  (MONTHLY_BILLING_PREMIUM_PCT = 10) in tier_capacity.py.

  Default is annual on first paint so existing prices are preserved. Pilot
  and Free are toggle-independent; backend rejects (pilot + cadence) and
  (pioneer+ + null cadence) with 400.

  New PostHog event workspace_request_submitted captures proposed_tier,
  proposed_billing_period, kind for both submit paths.

  See docs/adr/0002-billing-period-toggle.md for the architectural decisions
  (nested API shape, dual-column schema, no-flag rollout) and CONTEXT.md for
  the new glossary entries.

  Schema: scripts/create_schema.py --step 22 adds the two nullable columns
  to workspace_request. No backfill of pre-existing rows.
  - BillingPeriodToggle: monthly on left, annual on right (annual still
    default); bump size sm→md (compact xs→sm) and override Mantine's
    SegmentedControl + Badge slots so the "10% off" badge renders fully
    instead of being clipped by text-overflow: ellipsis.
  - TierPricingCards (desktop): fit all five tiers in one row; replace the
    spinning conic-gradient selection ring with a solid primary border;
    add a soft layered shadow + translateY(-4px) lift on the highlighted
    (innovator) card; surface hour overage and training in card specs so
    they match the matrix.
  - TierPricingCards (mobile): drop the radio-circle row layout in favor
    of full-width cards that mirror the desktop look; only the selected
    card is expanded, others collapse. Price + "billed annually/monthly"
    always visible in the card header. Expand/collapse animated via
    Mantine Collapse.
  - Center the BillingPeriodToggle above the cards and matrix on every
    surface (create-workspace wizard, FeatureGate upgrade modal, admin
    approval modal + matrix, workspace settings billing tab); add mb="xs"
    so it doesn't sit flush against the cards.
  - Widen containers to give the row room: CreateWorkspaceRoute
    Container sm→xl; FeatureGate + AdminSettings approve modal xl→72rem.
  - CreateWorkspaceRoute wizard bottom buttons (Cancel/Back, Next,
    Request workspace) bumped sm→md with px="xl" for a weightier feel.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Warning

Rate limit exceeded

@ussaama has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 21 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69b510a4-0ff9-4f6a-a511-6e55f7f50472

📥 Commits

Reviewing files that changed from the base of the PR and between 7313c3a and e547f44.

📒 Files selected for processing (5)
  • echo/frontend/biome.json
  • echo/frontend/src/components/workspace/TierPricingCards.tsx
  • echo/server/dembrane/api/v2/workspace_requests.py
  • echo/server/dembrane/api/v2/workspace_settings.py
  • echo/server/dembrane/tier_capacity.py

Walkthrough

This PR implements billing period (annual vs monthly) as a request-time pricing choice with staff override capability. It spans schema, APIs, pricing models, tier selection UI, workspace request workflows, and approval flows—introducing structured pricing payloads, cadence validation rules, and end-to-end email notifications.

Changes

Billing Period Feature

Layer / File(s) Summary
Directus schema & workflow operations
echo/directus/sync/snapshot/fields/workspace_request/approved_billing_period.json, echo/directus/sync/snapshot/fields/workspace_request/proposed_billing_period.json, echo/directus/sync/collections/operations.json
Adds two nullable enum fields (annual/monthly) for proposed and approved billing cadence on workspace_request; updates workflow operation node UUID/syncId metadata.
Database migration step 22
echo/scripts/create_schema.py
Adds new migration function to create proposed/approved billing period select-dropdown fields on workspace_request collection.
Tier capacity & pricing models
echo/server/dembrane/tier_capacity.py, echo/server/dembrane/api/v2/workspaces.py
Introduces nested pricing types (AnnualPricing, MonthlyPricing, OneTimePricing, TierPricing) with billing_period_applicable flag; computes monthly uplift via 10% premium and builds tier pricing per billing mode.
Workspace billing period resolution
echo/server/dembrane/billing_period.py, echo/server/dembrane/api/v2/admin.py
Adds single & batch resolution of billing periods from latest approved workspace request; extends admin models (BillingRow, WorkspaceRequestRow, DecideWorkspaceRequestBody) with cadence fields; updates approval notifications with divergence flag.
Workspace request submission & validation
echo/server/dembrane/api/v2/workspace_requests.py
Extends submit endpoint to accept proposed_billing_period; validates cadence requirements (pioneer+ require it, pilot forbids it); includes billing period in staff notifications and email payloads.
Workspace settings & detail endpoints
echo/server/dembrane/api/v2/workspace_settings.py, echo/server/dembrane/api/v2/schemas.py
Adds created_at field to workspace summary; extends workspace detail response with derived billing_period field.
Email templates
echo/server/email_templates/workspace_request_submitted.{html,txt}, echo/server/email_templates/workspace_request_approved.{html,txt}
Conditionally renders proposed/approved billing periods and cadence divergence messaging.
Frontend tier utilities & types
echo/frontend/src/lib/tiers.ts
Adds BillingPeriod type, pricingForBillingPeriod lookup, fetchTierCapacities query, and updated tier tagline/best-for strings with Lingui translations.
BillingPeriodToggle component
echo/frontend/src/components/workspace/BillingPeriodToggle.tsx
New controlled SegmentedControl for annual/monthly selection with 10% off badge and compact mode.
TierPricingCards component & styling
echo/frontend/src/components/workspace/TierPricingCards.tsx, echo/frontend/src/components/workspace/tier-pricing-cards.module.css
Wide/mobile tier card selector with react-query tier-capacity fetching and billing-period-aware pricing display; includes fallback hardcoded pricing and accessible radio-group UX.
TierCapacityMatrix refactored
echo/frontend/src/components/workspace/TierCapacityMatrix.tsx
Accepts billingPeriod prop, uses tier utilities from lib, and derives pricing via pricingForBillingPeriod; reorganizes table layout with Row abstraction and conditional row groups.
FeatureGate & UpgradeModal
echo/frontend/src/components/workspace/FeatureGate.tsx
UpgradeModal adds billing period state, includes it in tier upgrade POST (null for pilot), invalidates workspaces cache, captures PostHog event, and replaces matrix with BillingPeriodToggle + TierPricingCards.
Workspace hooks & UsageCard
echo/frontend/src/hooks/useWorkspaceUsage.ts, echo/frontend/src/components/workspace/UsageCard.tsx
UsageCard migrates to nested TierPricing structure for next-tier pricing display.
CreateWorkspaceRoute
echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx
Adds billing period state and tier-capacity fetch, derives target organisation from URL, replaces tier radio list with BillingPeriodToggle + TierPricingCards, includes proposed_billing_period in submission, displays tier price summary with conditional billing row.
WorkspaceSelectorRoute
echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
Adds created_at field to Workspace interface; derives isRecentlyApproved badge (24-hour window); updates PendingRequestCard date display and AddWorkspaceCard label.
WorkspaceSettingsRoute
echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
Adds billing period override state, wires BillingPeriodToggle in billing tab, passes resolved period to TierCapacityMatrix, extends WorkspaceDetail with billing_period field.
AdminSettingsRoute
echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
Adds billing_period to BillingRow, introduces statusFilter & billingPeriod state, refactors BillingTable setup and column rendering; refactors ApproveDialog with approvedBillingPeriod state, BillingPeriodToggle, TierPricingCards, and conditional cadence messaging.
WorkspaceInviteWizard
echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
Reformats copy to compact Trans blocks; adds auto-add-external-row behavior on step 0 "Next".
ADR documentation
echo/docs/adr/0002-billing-period-toggle.md
Documents billing period design decision, pricing API contract, premium percentage, persistence, approval/notification flows, and constraints.
Tests
echo/server/tests/api/test_tier_capacities_api.py, echo/server/tests/test_tier_capacity.py, echo/server/tests/test_workspace_requests.py
Integration & unit tests for tier-capacities API pricing shape, monthly premium math, tier pricing structure, and workspace request cadence validation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Dembrane/echo#220: Both PRs modify echo/directus/sync/collections/operations.json to swap UUID references and _syncId values for duplicated workflow operations.
  • Dembrane/echo#221: Both PRs adjust the same duplicated email/report/language workflow nodes by changing _syncId and resolve/reject targets in operations.json.
  • Dembrane/echo#577: Extends existing workspace_request submit/approve workflow with proposed/approved billing period fields and validation on top of free-tier request foundation.

Suggested labels

Feature, improvement

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fixes-and-ui-changes

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
echo/frontend/src/routes/admin/AdminSettingsRoute.tsx (1)

1890-2022: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Modal size "72rem" is a pretty chunky boi.

That's a 1152px modal. Might overflow on smaller screens or feel like a full-page takeover. If intentional for the pricing cards layout, ship it. Otherwise consider "xl" (1140px) or making it responsive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/frontend/src/routes/admin/AdminSettingsRoute.tsx` around lines 1890 -
2022, The Modal in ApproveDialog is using a fixed oversized width ("72rem");
change it to a sensible responsive option by replacing size="72rem" with
size="xl" (or compute a responsive value) and add a small-screen fallback to
render full-screen (use a media query hook and pass fullScreen={isSmall} or
similar) so the pricing cards layout stays usable without overflowing on smaller
viewports.
echo/frontend/src/hooks/useWorkspaceUsage.ts (1)

23-40: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract nested object contracts into named interfaces.

Line 23 and Line 34 currently embed object shapes inline inside WorkspaceUsageData. Please lift both into named interfaces so this API contract is reusable and easier to maintain.

♻️ Proposed refactor
+export interface WorkspaceUsageProject {
+	id: string;
+	name: string;
+	audio_hours: number;
+	conversation_count: number;
+}
+
+export interface WorkspaceUsageNextTier {
+	tier: string;
+	tagline: string;
+	pricing: TierPricing | null;
+	included_hours: number | null;
+	included_seats: number | null;
+}
+
 export interface WorkspaceUsageData {
@@
-	projects: {
-		id: string;
-		name: string;
-		audio_hours: number;
-		conversation_count: number;
-	}[];
+	projects: WorkspaceUsageProject[];
@@
-	next_tier?: {
-		tier: string;
-		tagline: string;
-		pricing: TierPricing | null;
-		included_hours: number | null;
-		included_seats: number | null;
-	} | null;
+	next_tier?: WorkspaceUsageNextTier | null;
 }

As per coding guidelines: "Prefer interface for defining object shapes in TypeScript files".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/frontend/src/hooks/useWorkspaceUsage.ts` around lines 23 - 40, The
WorkspaceUsageData type embeds inline object shapes for the projects array and
next_tier; extract those inline shapes into named interfaces (e.g., define
ProjectUsage and NextTierUsage interfaces) and update WorkspaceUsageData to
reference ProjectUsage[] for the projects field and NextTierUsage | null for
next_tier, ensuring optional fields (seat_invite_blocked, overage_forecast_eur,
seat_overage_eur) keep the same types and export the new interfaces if they are
used elsewhere; update any imports/exports and type references in
useWorkspaceUsage.ts accordingly.
echo/server/dembrane/api/v2/admin.py (1)

438-442: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Billing rollup totals ignore monthly cadence premium.

You resolve billing_period but base_price_eur always uses annual-anchor pricing, so monthly-billed pioneer+ workspaces are undercounted in row totals, total_forecast_eur, and mrr_eur.

💡 Suggested fix
@@
-        base_price = TIER_BASE_PRICE_EUR.get(tier)
+        base_price = TIER_BASE_PRICE_EUR.get(tier)
+        billing_period = workspace_billing_periods.get(ws_id)
+        if base_price is not None and billing_period == "monthly":
+            from dembrane.tier_capacity import compute_monthly_billing_price
+            base_price = float(compute_monthly_billing_price(int(base_price)))
@@
-            billing_period=workspace_billing_periods.get(ws_id),
+            billing_period=billing_period,

Also applies to: 497-497

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/server/dembrane/api/v2/admin.py` around lines 438 - 442, The row total
calculation currently always pulls from TIER_BASE_PRICE_EUR (annual prices) into
base_price/base_price_eur and so undercounts monthly-billed pioneer+ workspaces;
update the logic in admin.py where base_price is computed (the block using
TIER_BASE_PRICE_EUR, billing_period, base_price/base_price_eur, and total) to
select the correct cadence-aware price: if billing_period indicates monthly use
the monthly pricing map (or divide annual by 12) and then compute total,
total_forecast_eur, and mrr_eur from that cadence-correct base price so monthly
workspaces are charged correctly; ensure the same fix is applied to the other
occurrence referenced (around the total_forecast_eur/mrr_eur computation).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@echo/docs/adr/0002-billing-period-toggle.md`:
- Line 4: The ADR currently only lists "proposed (2026-05-19)" as its Status;
add standard approval metadata to the ADR file
(echo/docs/adr/0002-billing-period-toggle.md) by expanding the Status section to
include "Proposed by:", "Approvers:" and "Approved on:" fields (use "TBD" if
approval date or approvers aren't known) so readers can see who proposed and who
has approved the ADR and when.
- Around line 3-4: The "## Status" heading in the ADR lacks a blank line after
it; update the markdown in the ADR by inserting a single blank line immediately
after the "## Status" heading (the heading text "proposed (2026-05-19)" should
appear on a new line separated by an empty line) to satisfy MD022 and ensure
proper heading spacing.
- Around line 6-7: The markdown heading "## Context" in the ADR lacks a blank
line below it; update the document so there is an empty line immediately after
the "## Context" heading (i.e., insert a single blank line between the "##
Context" line and the following paragraph) to satisfy MD022 and static analysis
checks.
- Around line 9-17: The ADR file 0002-billing-period-toggle.md is missing a
blank line after the heading and lacks an "Alternatives Considered" section; add
a single blank line immediately after the top-level heading to satisfy MD022 and
append a concise "Alternatives Considered" section that lists and rejects
alternatives mentioned in the comment (e.g., single billing_period column,
feature flag, client-side premium calculation), referencing the existing
decisions (pricing shape, MONTHLY_BILLING_PREMIUM_PCT, workspace_request
columns) to explain why each alternative was rejected.
- Around line 19-24: The Consequences section needs a blank line after the
heading and must be expanded to cover rollback implications for the schema
changes: add a blank line after the "## Consequences" heading, and in the same
section document whether the new migration columns proposed_billing_period and
approved_billing_period (added in step 22) should be nullable to allow safe
rollback, describe the data retention/restore policy for those columns if a
deploy is reverted, and note the impact on in-flight workspace requests and the
audit trail (keep the requirement that both columns stay in sync); also remind
readers that tier_capacity.py treats price_eur_monthly as the annual-billing
per-month price and that TIER_CAPACITY_SHORT and lib/tiers.ts i18n fallbacks
must be kept consistent with the matrix.

In `@echo/frontend/src/components/workspace/FeatureGate.tsx`:
- Line 364: The Modal in FeatureGate.tsx is using a fixed size="72rem" which
causes horizontal overflow on tablets; change the modal sizing to be responsive
by replacing size="72rem" with a semantic breakpoint value (e.g., size="lg") or
add a responsive constraint such as applying a maxWidth plus width:100% on the
modal container (so it caps at 72rem but shrinks on smaller viewports). Update
the Modal declaration inside the FeatureGate component and ensure
TierPricingCards (which already uses useMediaQuery) still manages its internal
layout.

In `@echo/frontend/src/components/workspace/tier-pricing-cards.module.css`:
- Around line 1-7: The .wrap CSS rule uses a hardcoded background color "`#fff`"
which breaks dark mode; update the .wrap selector to use the theme CSS variable
(e.g., replace "`#fff`" with var(--app-background) or var(--mantine-color-body))
so the component follows the active theme, keeping the existing border, radius
and transition declarations unchanged.

In `@echo/frontend/src/components/workspace/TierCapacityMatrix.tsx`:
- Around line 267-280: The JSX redundantly renders {renderRows(usageRows)} and
{renderRows(overageRows)} in both compact and non-compact branches; simplify by
always rendering usageRows and overageRows unconditionally and only
conditionally render mainRows when present and trainingRows only when !compact.
Update the block that currently uses compact to instead call
renderRows(mainRows) once (conditional if needed), then unconditionally call
renderRows(usageRows) and renderRows(overageRows), and lastly conditionally call
renderRows(trainingRows) only when compact is false; the relevant symbols are
renderRows, mainRows, usageRows, overageRows, trainingRows, and compact.

In `@echo/frontend/src/components/workspace/TierPricingCards.tsx`:
- Around line 91-137: The fallback in buildFallbackCardData hardcodes
annualPerMonth and the 10% monthly premium which can drift from server pricing;
either import these values from a shared config/module or central constant
(e.g., PRICING_FALLBACK / FALLBACK_ANNUAL_PER_MONTH and MONTHLY_PREMIUM) instead
of inlining them, update buildFallbackCardData to reference those symbols and
keep the existing render-before-fetch behavior, and add/replace the inline
comment with a pointer to the ADR or the canonical server code so future changes
are synchronized.

In `@echo/frontend/src/components/workspace/UsageCard.tsx`:
- Around line 288-296: The hard-coded "/mo" suffix in the JSX fragment that
renders the next tier price (around the fragment using
data.next_tier.pricing.annual_billing.per_month_eur in UsageCard component) must
be localized; replace the raw string with a Lingui translation call — either
wrap the suffix in a <Trans> component (e.g., <Trans>/mo</Trans>) or use the
t`/mo` template literal and render it in the JSX so the UI text is translatable,
preserving spacing and concatenation with the formatted price.

In `@echo/frontend/src/lib/tiers.ts`:
- Around line 71-91: Remove the unused dead constant TIER_BEST_FOR and its
declaration block so the codebase only uses the i18n-wrapped strings from the
tierBestFor function; update any imports/usages if TIER_BEST_FOR is referenced
elsewhere (search for TIER_BEST_FOR) and ensure tierBestFor(tier: string | null
| undefined) remains as the single source of truth, retaining its use of isTier
and the t`` wrapped messages.

In `@echo/frontend/src/routes/admin/AdminSettingsRoute.tsx`:
- Around line 2503-2553: The billing period rendering is duplicated between the
column definition cell and the table body; extract the logic into a single
helper React component (e.g., BillingPeriodCell) that accepts props proposed and
approved (BillingPeriod | null) and returns the current badge/arrow/placeholder
UI, then replace the inline JSX in both the accessor/cell handler in the column
definition (the cell using
row.original.proposed_billing_period/approved_billing_period) and the table body
rendering (the duplicate block around lines 2758-2801) with <BillingPeriodCell
proposed={...} approved={...} /> so there is one source of truth for the UI and
styling.

In `@echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx`:
- Around line 147-161: The i18n template literal in reviewTierSummary currently
interpolates raw numeric currency values (resolved.amount_eur,
resolved.per_month_eur, resolved.total_per_year_eur) which prevents localized
number formatting; update reviewTierSummary to format those numbers before
passing them into t (e.g., use Intl.NumberFormat or a shared formatCurrency
util) so the strings interpolated into t are already localized, keeping the
existing t templates and branching logic (pricingForBillingPeriod,
resolved.kind) intact.

In `@echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx`:
- Line 171: The constant ONE_DAY_MS is currently declared inside the
WorkspaceSelectorRoute component causing it to be recreated on every render;
move the declaration for ONE_DAY_MS = 86_400_000 to module scope (above the
WorkspaceSelectorRoute function) so it is hoisted and not recreated on each
render, then remove the in-component declaration and keep all usages inside
WorkspaceSelectorRoute unchanged.

In `@echo/server/dembrane/api/v2/workspace_requests.py`:
- Around line 249-252: The digest summary construction in workspace request
summaries (variable summary built from requester_name and kind_label in
workspace_requests.py) omits the billing cadence so Pioneer+ requests lose
monthly/annual context; update the summary assembly to include
body.proposed_billing_period (e.g., append "· {body.proposed_billing_period}"
wherever you add body.proposed_tier — and also include it when org_name is
absent) so the throttled digest contains both proposed_tier and
proposed_billing_period.

In `@echo/server/dembrane/api/v2/workspace_settings.py`:
- Around line 239-242: The call to resolve_workspace_billing_period in
workspace_settings.py can raise and make the whole settings endpoint fail; wrap
the await resolve_workspace_billing_period(ctx.workspace_id) call in a
try/except, on exception set billing_period = None (and optionally log the error
with context), then continue to return the rest of the settings; ensure you only
catch expected transient errors (or Exception if unknown) so other failures
still surface appropriately.

In `@echo/server/dembrane/tier_capacity.py`:
- Around line 172-174: The hardcoded 349 in the build_tier_pricing branch for
cap.tier == "pilot" should be read from the TierCapacity data instead of
duplicated; add a one-time price field to the TierCapacity model (e.g.,
one_time_amount_eur or one_time_price_eur) and update the code in
build_tier_pricing to return {"one_time": {"amount_eur":
cap.one_time_price_eur}} (or read from the existing canonical pricing source on
TierCapacity) so the pilot price is a single source of truth.
- Around line 143-151: compute_monthly_billing_price uses Python's round()
(banker's rounding); switch to deterministic financial rounding: convert
annual_per_month and MONTHLY_BILLING_PREMIUM_PCT into Decimal, compute monthly =
annual * (1 + pct/100) using Decimal arithmetic, then quantize to 0 decimals
with ROUND_HALF_UP and return as int. Update compute_monthly_billing_price to
import Decimal and ROUND_HALF_UP and ensure the function remains pure and
returns an int.

---

Outside diff comments:
In `@echo/frontend/src/hooks/useWorkspaceUsage.ts`:
- Around line 23-40: The WorkspaceUsageData type embeds inline object shapes for
the projects array and next_tier; extract those inline shapes into named
interfaces (e.g., define ProjectUsage and NextTierUsage interfaces) and update
WorkspaceUsageData to reference ProjectUsage[] for the projects field and
NextTierUsage | null for next_tier, ensuring optional fields
(seat_invite_blocked, overage_forecast_eur, seat_overage_eur) keep the same
types and export the new interfaces if they are used elsewhere; update any
imports/exports and type references in useWorkspaceUsage.ts accordingly.

In `@echo/frontend/src/routes/admin/AdminSettingsRoute.tsx`:
- Around line 1890-2022: The Modal in ApproveDialog is using a fixed oversized
width ("72rem"); change it to a sensible responsive option by replacing
size="72rem" with size="xl" (or compute a responsive value) and add a
small-screen fallback to render full-screen (use a media query hook and pass
fullScreen={isSmall} or similar) so the pricing cards layout stays usable
without overflowing on smaller viewports.

In `@echo/server/dembrane/api/v2/admin.py`:
- Around line 438-442: The row total calculation currently always pulls from
TIER_BASE_PRICE_EUR (annual prices) into base_price/base_price_eur and so
undercounts monthly-billed pioneer+ workspaces; update the logic in admin.py
where base_price is computed (the block using TIER_BASE_PRICE_EUR,
billing_period, base_price/base_price_eur, and total) to select the correct
cadence-aware price: if billing_period indicates monthly use the monthly pricing
map (or divide annual by 12) and then compute total, total_forecast_eur, and
mrr_eur from that cadence-correct base price so monthly workspaces are charged
correctly; ensure the same fix is applied to the other occurrence referenced
(around the total_forecast_eur/mrr_eur computation).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: af4e0431-9609-40a2-a15b-549879f3986d

📥 Commits

Reviewing files that changed from the base of the PR and between d037759 and 7313c3a.

📒 Files selected for processing (33)
  • echo/directus/sync/collections/operations.json
  • echo/directus/sync/snapshot/fields/workspace_request/approved_billing_period.json
  • echo/directus/sync/snapshot/fields/workspace_request/proposed_billing_period.json
  • echo/docs/adr/0002-billing-period-toggle.md
  • echo/frontend/src/components/project/UploadLockedCard.tsx
  • echo/frontend/src/components/workspace/BillingPeriodToggle.tsx
  • echo/frontend/src/components/workspace/FeatureGate.tsx
  • echo/frontend/src/components/workspace/TierCapacityMatrix.tsx
  • echo/frontend/src/components/workspace/TierPricingCards.tsx
  • echo/frontend/src/components/workspace/UsageCard.tsx
  • echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
  • echo/frontend/src/components/workspace/tier-pricing-cards.module.css
  • echo/frontend/src/hooks/useWorkspaceUsage.ts
  • echo/frontend/src/lib/tiers.ts
  • echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
  • echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx
  • echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
  • echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
  • echo/scripts/create_schema.py
  • echo/server/dembrane/api/v2/admin.py
  • echo/server/dembrane/api/v2/schemas.py
  • echo/server/dembrane/api/v2/workspace_requests.py
  • echo/server/dembrane/api/v2/workspace_settings.py
  • echo/server/dembrane/api/v2/workspaces.py
  • echo/server/dembrane/billing_period.py
  • echo/server/dembrane/tier_capacity.py
  • echo/server/email_templates/workspace_request_approved.html
  • echo/server/email_templates/workspace_request_approved.txt
  • echo/server/email_templates/workspace_request_submitted.html
  • echo/server/email_templates/workspace_request_submitted.txt
  • echo/server/tests/api/test_tier_capacities_api.py
  • echo/server/tests/test_tier_capacity.py
  • echo/server/tests/test_workspace_requests.py

Comment on lines +3 to +4
## Status
proposed (2026-05-19)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Add blank line after heading.

📝 Proposed fix for markdown formatting
 ## Status
+
 proposed (2026-05-19)

As per coding guidelines from static analysis: MD022 expects blank lines around headings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Status
proposed (2026-05-19)
## Status
proposed (2026-05-19)
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 3-3: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/docs/adr/0002-billing-period-toggle.md` around lines 3 - 4, The "##
Status" heading in the ADR lacks a blank line after it; update the markdown in
the ADR by inserting a single blank line immediately after the "## Status"
heading (the heading text "proposed (2026-05-19)" should appear on a new line
separated by an empty line) to satisfy MD022 and ensure proper heading spacing.

# Billing period as a request-time choice with admin override capture

## Status
proposed (2026-05-19)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding approval metadata to ADR.

Standard ADR practice includes documenting who proposed, who approved, and approval date. Since status is "proposed", consider adding fields like:

## Status
proposed (2026-05-19)

**Proposed by:** [author]  
**Approvers:** [list]  
**Approved on:** [date or TBD]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/docs/adr/0002-billing-period-toggle.md` at line 4, The ADR currently
only lists "proposed (2026-05-19)" as its Status; add standard approval metadata
to the ADR file (echo/docs/adr/0002-billing-period-toggle.md) by expanding the
Status section to include "Proposed by:", "Approvers:" and "Approved on:" fields
(use "TBD" if approval date or approvers aren't known) so readers can see who
proposed and who has approved the ADR and when.

Comment on lines +6 to +7
## Context
Tier prices in the matrix today are flat per-month figures (Pioneer €200/mo, Innovator €500/mo, etc.) with billing cadence handled informally over email. We want a self-serve billing-period choice (annual vs monthly) on every pricing surface — cards (CreateWorkspace, FeatureGate upgrade modal, admin approval dialog) and matrix (WorkspaceSettings, AdminSettings) — with monthly billing carrying a +10% premium over annual. Pilot and Free are exempt; Pioneer/Innovator/Changemaker/Guardian carry the toggle. Automated billing is not yet built — today the cadence choice flows to a manual invoicing process.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Add blank line after heading.

📝 Proposed fix for markdown formatting
 ## Context
+
 Tier prices in the matrix today are flat per-month figures (Pioneer €200/mo, Innovator €500/mo, etc.) with billing cadence handled informally over email. We want a self-serve billing-period choice (annual vs monthly) on every pricing surface — cards (CreateWorkspace, FeatureGate upgrade modal, admin approval dialog) and matrix (WorkspaceSettings, AdminSettings) — with monthly billing carrying a +10% premium over annual. Pilot and Free are exempt; Pioneer/Innovator/Changemaker/Guardian carry the toggle. Automated billing is not yet built — today the cadence choice flows to a manual invoicing process.

As per coding guidelines from static analysis: MD022 expects blank lines around headings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Context
Tier prices in the matrix today are flat per-month figures (Pioneer €200/mo, Innovator €500/mo, etc.) with billing cadence handled informally over email. We want a self-serve billing-period choice (annual vs monthly) on every pricing surface — cards (CreateWorkspace, FeatureGate upgrade modal, admin approval dialog) and matrix (WorkspaceSettings, AdminSettings) — with monthly billing carrying a +10% premium over annual. Pilot and Free are exempt; Pioneer/Innovator/Changemaker/Guardian carry the toggle. Automated billing is not yet built — today the cadence choice flows to a manual invoicing process.
## Context
Tier prices in the matrix today are flat per-month figures (Pioneer €200/mo, Innovator €500/mo, etc.) with billing cadence handled informally over email. We want a self-serve billing-period choice (annual vs monthly) on every pricing surface — cards (CreateWorkspace, FeatureGate upgrade modal, admin approval dialog) and matrix (WorkspaceSettings, AdminSettings) — with monthly billing carrying a +10% premium over annual. Pilot and Free are exempt; Pioneer/Innovator/Changemaker/Guardian carry the toggle. Automated billing is not yet built — today the cadence choice flows to a manual invoicing process.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 6-6: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/docs/adr/0002-billing-period-toggle.md` around lines 6 - 7, The markdown
heading "## Context" in the ADR lacks a blank line below it; update the document
so there is an empty line immediately after the "## Context" heading (i.e.,
insert a single blank line between the "## Context" line and the following
paragraph) to satisfy MD022 and static analysis checks.

Comment on lines +9 to +17
## Decision
- **Pricing data is computed server-side and exposed as a nested object.** The `/v2/workspaces/tier-capacities` API returns `pricing: { annual_billing, monthly_billing, one_time }` per tier instead of the flat `price_eur_monthly` + `price_note` shape. Free → `pricing=null`; Pilot → `one_time` only; Pioneer+ → `annual_billing` and `monthly_billing` both populated. The frontend never multiplies or parses pricing strings.
- **The premium is a code constant.** `MONTHLY_BILLING_PREMIUM_PCT = 10` lives in `tier_capacity.py` next to the matrix. No env var, no DB config, no admin UI knob — changing it is a code review + deploy, same gate as changing tier prices.
- **The toggle multiplies base price only.** Seat overage and hour overage rates are flat across billing periods. The "10% off (annual)" badge is true for the base price; muddying it with overage bumps would force fine-print copy.
- **`workspace_request` gains two columns, not one.** `proposed_billing_period` captures the user's choice at submit time; `approved_billing_period` captures the admin's decision at approval time. Both are nullable (null for non-applicable tiers). Capturing both preserves intent for disputes and gives future automated billing a clean source of truth for `workspace.billing_period` backfill.
- **The cadence flows through the existing notification + email channels.** The staff `WORKSPACE_REQUEST_SUBMITTED` notification message becomes `org · tier · cadence` (omitting cadence for pilot/free). The `workspace_request_submitted` email template gains a `proposed_billing_period` field. The approval email surfaces the `approved_billing_period` and an extra sentence when admin overrode the user's choice.
- **No `workspace.billing_period` column yet.** Today billing is manual and the cadence info is fully consumed by the request + email channel. The column will be added when automated billing is built, backfilled from the most-recent approved request per workspace.
- **No feature flag.** The change is additive — the toggle defaults to annual, which matches today's prices and behavior. Rolling back means reverting the deploy.
- **PostHog `workspace_request_submitted` event lands in the same PR**, with `proposed_tier` and `proposed_billing_period` properties. No event on toggle keystroke — interaction noise.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add blank line after heading + consider "Alternatives Considered" section.

📝 Proposed fix for markdown formatting
 ## Decision
+
 - **Pricing data is computed server-side and exposed as a nested object.**

ADRs typically include an "Alternatives Considered" section explaining why other approaches (e.g., single billing_period column, feature flag, client-side premium calculation) were rejected. Documenting the tradeoffs strengthens the decision record.

As per coding guidelines from static analysis: MD022 expects blank lines around headings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Decision
- **Pricing data is computed server-side and exposed as a nested object.** The `/v2/workspaces/tier-capacities` API returns `pricing: { annual_billing, monthly_billing, one_time }` per tier instead of the flat `price_eur_monthly` + `price_note` shape. Free → `pricing=null`; Pilot → `one_time` only; Pioneer+ → `annual_billing` and `monthly_billing` both populated. The frontend never multiplies or parses pricing strings.
- **The premium is a code constant.** `MONTHLY_BILLING_PREMIUM_PCT = 10` lives in `tier_capacity.py` next to the matrix. No env var, no DB config, no admin UI knob — changing it is a code review + deploy, same gate as changing tier prices.
- **The toggle multiplies base price only.** Seat overage and hour overage rates are flat across billing periods. The "10% off (annual)" badge is true for the base price; muddying it with overage bumps would force fine-print copy.
- **`workspace_request` gains two columns, not one.** `proposed_billing_period` captures the user's choice at submit time; `approved_billing_period` captures the admin's decision at approval time. Both are nullable (null for non-applicable tiers). Capturing both preserves intent for disputes and gives future automated billing a clean source of truth for `workspace.billing_period` backfill.
- **The cadence flows through the existing notification + email channels.** The staff `WORKSPACE_REQUEST_SUBMITTED` notification message becomes `org · tier · cadence` (omitting cadence for pilot/free). The `workspace_request_submitted` email template gains a `proposed_billing_period` field. The approval email surfaces the `approved_billing_period` and an extra sentence when admin overrode the user's choice.
- **No `workspace.billing_period` column yet.** Today billing is manual and the cadence info is fully consumed by the request + email channel. The column will be added when automated billing is built, backfilled from the most-recent approved request per workspace.
- **No feature flag.** The change is additive — the toggle defaults to annual, which matches today's prices and behavior. Rolling back means reverting the deploy.
- **PostHog `workspace_request_submitted` event lands in the same PR**, with `proposed_tier` and `proposed_billing_period` properties. No event on toggle keystroke — interaction noise.
## Decision
- **Pricing data is computed server-side and exposed as a nested object.** The `/v2/workspaces/tier-capacities` API returns `pricing: { annual_billing, monthly_billing, one_time }` per tier instead of the flat `price_eur_monthly` + `price_note` shape. Free → `pricing=null`; Pilot → `one_time` only; Pioneer+ → `annual_billing` and `monthly_billing` both populated. The frontend never multiplies or parses pricing strings.
- **The premium is a code constant.** `MONTHLY_BILLING_PREMIUM_PCT = 10` lives in `tier_capacity.py` next to the matrix. No env var, no DB config, no admin UI knob — changing it is a code review + deploy, same gate as changing tier prices.
- **The toggle multiplies base price only.** Seat overage and hour overage rates are flat across billing periods. The "10% off (annual)" badge is true for the base price; muddying it with overage bumps would force fine-print copy.
- **`workspace_request` gains two columns, not one.** `proposed_billing_period` captures the user's choice at submit time; `approved_billing_period` captures the admin's decision at approval time. Both are nullable (null for non-applicable tiers). Capturing both preserves intent for disputes and gives future automated billing a clean source of truth for `workspace.billing_period` backfill.
- **The cadence flows through the existing notification + email channels.** The staff `WORKSPACE_REQUEST_SUBMITTED` notification message becomes `org · tier · cadence` (omitting cadence for pilot/free). The `workspace_request_submitted` email template gains a `proposed_billing_period` field. The approval email surfaces the `approved_billing_period` and an extra sentence when admin overrode the user's choice.
- **No `workspace.billing_period` column yet.** Today billing is manual and the cadence info is fully consumed by the request + email channel. The column will be added when automated billing is built, backfilled from the most-recent approved request per workspace.
- **No feature flag.** The change is additive — the toggle defaults to annual, which matches today's prices and behavior. Rolling back means reverting the deploy.
- **PostHog `workspace_request_submitted` event lands in the same PR**, with `proposed_tier` and `proposed_billing_period` properties. No event on toggle keystroke — interaction noise.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 9-9: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/docs/adr/0002-billing-period-toggle.md` around lines 9 - 17, The ADR
file 0002-billing-period-toggle.md is missing a blank line after the heading and
lacks an "Alternatives Considered" section; add a single blank line immediately
after the top-level heading to satisfy MD022 and append a concise "Alternatives
Considered" section that lists and rejects alternatives mentioned in the comment
(e.g., single billing_period column, feature flag, client-side premium
calculation), referencing the existing decisions (pricing shape,
MONTHLY_BILLING_PREMIUM_PCT, workspace_request columns) to explain why each
alternative was rejected.

Comment on lines +19 to +24
## Consequences
- **The matrix dataclass field `price_eur_monthly` is now load-bearing in two semantic senses.** Internally it still means "matrix per-month rate." At the API boundary it's relabeled as `pricing.annual_billing.per_month_eur`, and `pricing.monthly_billing.per_month_eur` is derived. The field name does not match the API contract — readers of `tier_capacity.py` should treat the value as "annual-billing per-month price" until a future refactor renames it.
- **Two columns to keep in sync at approval time.** A bug that writes `approved_billing_period` without `proposed_billing_period` (or vice versa) would split the audit trail. The submit handler always writes proposed; the approval handler always writes approved. Don't migrate this to a single column without considering the dispute case ("you upgraded me to monthly even though I asked for annual").
- **Overage rates are intentionally flat across cadences.** A monthly-billed Pioneer pays the same €25/seat and €5/hour as an annual Pioneer. If finance later wants symmetric premiums on overage, that's a new ADR — don't quietly multiply.
- **Pilot keeps its `one_time` shape forever, even if we add yearly Pilot variants later.** The nested `pricing` object is the contract — adding a new cadence slot is additive; reusing the existing slots for a different cadence shape is breaking.
- **`TIER_CAPACITY_SHORT` and the i18n fallback strings in `lib/tiers.ts` need to stay in sync with the matrix.** They are the offline fallback when the API call fails; they are not authoritative. Guardian's "custom pricing" copy is removed — its €5,000/mo is the published price.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Add blank line after heading + expand rollback strategy.

📝 Proposed fix for markdown formatting
 ## Consequences
+
 - **The matrix dataclass field `price_eur_monthly` is now load-bearing in two semantic senses.**

Line 16 states "Rolling back means reverting the deploy" but the Consequences section doesn't address schema rollback. Since proposed_billing_period and approved_billing_period columns are added in step 22 migration, consider documenting:

  • Whether columns should be nullable to support rollback
  • Data retention policy if rollback occurs
  • Impact on in-flight workspace requests

As per coding guidelines from static analysis: MD022 expects blank lines around headings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Consequences
- **The matrix dataclass field `price_eur_monthly` is now load-bearing in two semantic senses.** Internally it still means "matrix per-month rate." At the API boundary it's relabeled as `pricing.annual_billing.per_month_eur`, and `pricing.monthly_billing.per_month_eur` is derived. The field name does not match the API contract — readers of `tier_capacity.py` should treat the value as "annual-billing per-month price" until a future refactor renames it.
- **Two columns to keep in sync at approval time.** A bug that writes `approved_billing_period` without `proposed_billing_period` (or vice versa) would split the audit trail. The submit handler always writes proposed; the approval handler always writes approved. Don't migrate this to a single column without considering the dispute case ("you upgraded me to monthly even though I asked for annual").
- **Overage rates are intentionally flat across cadences.** A monthly-billed Pioneer pays the same €25/seat and €5/hour as an annual Pioneer. If finance later wants symmetric premiums on overage, that's a new ADR — don't quietly multiply.
- **Pilot keeps its `one_time` shape forever, even if we add yearly Pilot variants later.** The nested `pricing` object is the contract — adding a new cadence slot is additive; reusing the existing slots for a different cadence shape is breaking.
- **`TIER_CAPACITY_SHORT` and the i18n fallback strings in `lib/tiers.ts` need to stay in sync with the matrix.** They are the offline fallback when the API call fails; they are not authoritative. Guardian's "custom pricing" copy is removed — its €5,000/mo is the published price.
## Consequences
- **The matrix dataclass field `price_eur_monthly` is now load-bearing in two semantic senses.** Internally it still means "matrix per-month rate." At the API boundary it's relabeled as `pricing.annual_billing.per_month_eur`, and `pricing.monthly_billing.per_month_eur` is derived. The field name does not match the API contract — readers of `tier_capacity.py` should treat the value as "annual-billing per-month price" until a future refactor renames it.
- **Two columns to keep in sync at approval time.** A bug that writes `approved_billing_period` without `proposed_billing_period` (or vice versa) would split the audit trail. The submit handler always writes proposed; the approval handler always writes approved. Don't migrate this to a single column without considering the dispute case ("you upgraded me to monthly even though I asked for annual").
- **Overage rates are intentionally flat across cadences.** A monthly-billed Pioneer pays the same €25/seat and €5/hour as an annual Pioneer. If finance later wants symmetric premiums on overage, that's a new ADR — don't quietly multiply.
- **Pilot keeps its `one_time` shape forever, even if we add yearly Pilot variants later.** The nested `pricing` object is the contract — adding a new cadence slot is additive; reusing the existing slots for a different cadence shape is breaking.
- **`TIER_CAPACITY_SHORT` and the i18n fallback strings in `lib/tiers.ts` need to stay in sync with the matrix.** They are the offline fallback when the API call fails; they are not authoritative. Guardian's "custom pricing" copy is removed — its €5,000/mo is the published price.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 19-19: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/docs/adr/0002-billing-period-toggle.md` around lines 19 - 24, The
Consequences section needs a blank line after the heading and must be expanded
to cover rollback implications for the schema changes: add a blank line after
the "## Consequences" heading, and in the same section document whether the new
migration columns proposed_billing_period and approved_billing_period (added in
step 22) should be nullable to allow safe rollback, describe the data
retention/restore policy for those columns if a deploy is reverted, and note the
impact on in-flight workspace requests and the audit trail (keep the requirement
that both columns stay in sync); also remind readers that tier_capacity.py
treats price_eur_monthly as the annual-billing per-month price and that
TIER_CAPACITY_SHORT and lib/tiers.ts i18n fallbacks must be kept consistent with
the matrix.

}) {
const isAdminOrOwner =
workspace.role === "admin" || workspace.role === "owner";
const ONE_DAY_MS = 86_400_000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Move ONE_DAY_MS outside the component.

Declaring constants inside the component body means they're recreated on every render. Hoist this to module scope for cleaner code and zero recreation cost.

♻️ Suggested refactor
+const ONE_DAY_MS = 86_400_000;
+
 function WorkspaceCard({
 	workspace,
 	onSelect,
 	onManage,
 }: {
 	workspace: Workspace;
 	onSelect: () => void;
 	onManage?: () => void;
 }) {
 	const isAdminOrOwner =
 		workspace.role === "admin" || workspace.role === "owner";
-	const ONE_DAY_MS = 86_400_000;
 	const isRecentlyApproved =
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ONE_DAY_MS = 86_400_000;
const ONE_DAY_MS = 86_400_000;
function WorkspaceCard({
workspace,
onSelect,
onManage,
}: {
workspace: Workspace;
onSelect: () => void;
onManage?: () => void;
}) {
const isAdminOrOwner =
workspace.role === "admin" || workspace.role === "owner";
const isRecentlyApproved =
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx` at line 171,
The constant ONE_DAY_MS is currently declared inside the WorkspaceSelectorRoute
component causing it to be recreated on every render; move the declaration for
ONE_DAY_MS = 86_400_000 to module scope (above the WorkspaceSelectorRoute
function) so it is hoisted and not recreated on each render, then remove the
in-component declaration and keep all usages inside WorkspaceSelectorRoute
unchanged.

Comment thread echo/server/dembrane/api/v2/workspace_requests.py
Comment thread echo/server/dembrane/api/v2/workspace_settings.py
Comment on lines +143 to +151
def compute_monthly_billing_price(annual_per_month: int) -> int:
"""Monthly-cadence per-month price derived from the annual-billing rate.

The annual rate is treated as the anchor (regular price); the monthly
cadence is `annual × (1 + MONTHLY_BILLING_PREMIUM_PCT/100)`, rounded to
whole euros. Pure function — same input, same output.
"""
return round(annual_per_month * (1 + MONTHLY_BILLING_PREMIUM_PCT / 100))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from decimal import Decimal, ROUND_HALF_UP
samples = [95, 105, 115]
for x in samples:
    bankers = round(x * 1.10)
    half_up = int((Decimal(str(x)) * Decimal("1.10")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
    print(f"annual={x} -> round={bankers}, half_up={half_up}")
PY

Repository: Dembrane/echo

Length of output: 167


🏁 Script executed:

fd -t f "tier_capacity.py" | grep -E "dembrane.*tier_capacity"

Repository: Dembrane/echo

Length of output: 95


🏁 Script executed:

cat -n echo/server/dembrane/tier_capacity.py | head -180

Repository: Dembrane/echo

Length of output: 7653


🏁 Script executed:

cat -n echo/server/dembrane/tier_capacity.py | sed -n '151,200p'

Repository: Dembrane/echo

Length of output: 2012


Use deterministic money rounding for billing code. Python's round() uses half-to-even banker's rounding, which can diverge from standard financial practices on .5 boundaries. While current prices (200, 500, 1500, 5000) multiply evenly by 1.10, future prices (e.g., 95 EUR → 104.5 → 104 via bankers vs 105 via ROUND_HALF_UP) would silently differ. Replace with Decimal(...).quantize(..., ROUND_HALF_UP) to lock in predictable rounding for any future pricing adjustments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/server/dembrane/tier_capacity.py` around lines 143 - 151,
compute_monthly_billing_price uses Python's round() (banker's rounding); switch
to deterministic financial rounding: convert annual_per_month and
MONTHLY_BILLING_PREMIUM_PCT into Decimal, compute monthly = annual * (1 +
pct/100) using Decimal arithmetic, then quantize to 0 decimals with
ROUND_HALF_UP and return as int. Update compute_monthly_billing_price to import
Decimal and ROUND_HALF_UP and ensure the function remains pure and returns an
int.

Comment thread echo/server/dembrane/tier_capacity.py Outdated
@ussaama ussaama added this pull request to the merge queue May 20, 2026
Merged via the queue into main with commit b5a11bd May 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants