Subscription model updated, Recently approved flag, fixes#581
Conversation
- 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.
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
WalkthroughThis 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. ChangesBilling Period Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
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 valueModal 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 winExtract nested object contracts into named interfaces.
Line 23 and Line 34 currently embed object shapes inline inside
WorkspaceUsageData. Please lift both into namedinterfaces 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
interfacefor 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 winBilling rollup totals ignore monthly cadence premium.
You resolve
billing_periodbutbase_price_euralways uses annual-anchor pricing, so monthly-billed pioneer+ workspaces are undercounted in row totals,total_forecast_eur, andmrr_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
📒 Files selected for processing (33)
echo/directus/sync/collections/operations.jsonecho/directus/sync/snapshot/fields/workspace_request/approved_billing_period.jsonecho/directus/sync/snapshot/fields/workspace_request/proposed_billing_period.jsonecho/docs/adr/0002-billing-period-toggle.mdecho/frontend/src/components/project/UploadLockedCard.tsxecho/frontend/src/components/workspace/BillingPeriodToggle.tsxecho/frontend/src/components/workspace/FeatureGate.tsxecho/frontend/src/components/workspace/TierCapacityMatrix.tsxecho/frontend/src/components/workspace/TierPricingCards.tsxecho/frontend/src/components/workspace/UsageCard.tsxecho/frontend/src/components/workspace/WorkspaceInviteWizard.tsxecho/frontend/src/components/workspace/tier-pricing-cards.module.cssecho/frontend/src/hooks/useWorkspaceUsage.tsecho/frontend/src/lib/tiers.tsecho/frontend/src/routes/admin/AdminSettingsRoute.tsxecho/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsxecho/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsxecho/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsxecho/scripts/create_schema.pyecho/server/dembrane/api/v2/admin.pyecho/server/dembrane/api/v2/schemas.pyecho/server/dembrane/api/v2/workspace_requests.pyecho/server/dembrane/api/v2/workspace_settings.pyecho/server/dembrane/api/v2/workspaces.pyecho/server/dembrane/billing_period.pyecho/server/dembrane/tier_capacity.pyecho/server/email_templates/workspace_request_approved.htmlecho/server/email_templates/workspace_request_approved.txtecho/server/email_templates/workspace_request_submitted.htmlecho/server/email_templates/workspace_request_submitted.txtecho/server/tests/api/test_tier_capacities_api.pyecho/server/tests/test_tier_capacity.pyecho/server/tests/test_workspace_requests.py
| ## Status | ||
| proposed (2026-05-19) |
There was a problem hiding this comment.
🧹 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.
| ## 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) |
There was a problem hiding this comment.
🧹 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.
| ## 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. |
There was a problem hiding this comment.
🧹 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.
| ## 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.
| ## 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. |
There was a problem hiding this comment.
🧹 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.
| ## 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.
| ## 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. |
There was a problem hiding this comment.
🧹 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.
| ## 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; |
There was a problem hiding this comment.
🧹 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.
| 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.
| 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)) | ||
|
|
There was a problem hiding this comment.
🧩 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}")
PYRepository: 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 -180Repository: 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.
Summary by CodeRabbit
Release Notes
New Features
Email Templates
Documentation