Skip to content

feat(billing): unify upgrade routing with reason context + storage/tables limit emails#5171

Merged
waleedlatif1 merged 16 commits into
stagingfrom
feat/usage-limit-upgrade-system
Jun 23, 2026
Merged

feat(billing): unify upgrade routing with reason context + storage/tables limit emails#5171
waleedlatif1 merged 16 commits into
stagingfrom
feat/usage-limit-upgrade-system

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Add a single upgrade-reason registry (lib/billing/upgrade-reasons.ts) — the source of truth for the language shown when a usage limit routes a user to the upgrade page. The same copy drives both the upgrade-page header and the threshold emails so they never drift.
  • Upgrade page reads ?reason= (nuqs) and swaps its header ("Upgrade to scale your tables", "…with your teammates", etc.); generic header when absent.
  • Every in-app limit surface now deep-links through buildUpgradeHref(workspaceId, reason): credits chip (credits), teammates (seats), tables row-limit toast (tables), file-upload storage error (storage, via a shared useLimitUpgradeToast). Generic "Explore plans" links (billing settings, deploy gate) route through the same helper without a reason.
  • New storage + tables threshold emails (80% warning / 100% reached) via one parameterized LimitThresholdEmail. Dedup is a race-free atomic claim (single conditional UPDATE … WHERE current < desired RETURNING against a new limit_notifications jsonb column on user_stats/organization, migration 0248), with hysteresis re-arm below 70%. One shared maybeNotifyLimit resolves user vs. org scope for both call sites.
  • Fix a latent bug: the existing credits threshold emails linked to a dead URL (/workspace?billing=upgrade, which redirects to home and drops the param). Re-pointed to the live upgrade/billing-settings routes.

Type of Change

  • New feature (+ bug fix for the dead credits link)

Notes

  • Seats threshold email is intentionally deferred — Team seats auto-scale (Stripe quantity, no cap) and enterprise seats are fixed but redirected away from the upgrade page, so there's no clean trigger. The seats in-app CTA + header ship now; the email infra (LimitCategory includes seats) is ready if the seat model changes.
  • Limit emails respect the same opt-outs as credits (getEmailPreferences + billingUsageNotificationsEnabled) and are best-effort/fire-and-forget so they never block a mutation.

Testing

  • Unit tests added: upgrade-reasons.test.ts (4) and limit-notifications.test.ts (9 — claim win/lose, dead band, re-arm, opt-outs, billing-disabled). Existing logger/tables suites green (60 tests total).
  • bun run check:api-validation, bun run check:react-query, typecheck, and biome all pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)
@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 23, 2026 5:40pm

Request Review

@cursor

cursor Bot commented Jun 22, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches billing notifications, DB migration for dedup state, and many insert/upload hot paths (fire-and-forget); scope is bounded by billing flags and existing opt-outs.

Overview
Introduces a shared upgrade-reason registry (upgrade-reasons.ts) so upgrade-page headers, in-app deep links, and email copy stay aligned. The upgrade page reads ?reason= (nuqs) and swaps its <h1> when present; limit surfaces route through buildUpgradeHref(workspaceId, reason) (credits, seats, tables, storage) plus a useLimitUpgradeToast for storage upload failures.

Adds 80% / 100% threshold emails for storage and table row limits via LimitThresholdEmail and maybeSendLimitThresholdEmail, with dedup on new limit_notifications jsonb columns (migration 0248), atomic claims, and re-arm below 70%. Storage tracking and table insert paths fire notifications after commits; credits threshold emails are fixed to use real upgrade/billing URLs instead of dead query links.

Minor: Pi block icon/branding tweak; upgrade page wrapped in Suspense for nuqs.

Reviewed by Cursor Bugbot for commit 79a4d08. Configure here.

Comment thread apps/sim/lib/billing/storage/tracking.ts
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR unifies upgrade routing with a central reason registry (upgrade-reasons.ts) so the upgrade page header and threshold emails share the same copy, and adds storage + table row limit emails (80% warning / 100% reached) with atomic race-free deduplication via a new limit_notifications jsonb column on user_stats / organization. It also fixes a latent bug where credits threshold emails linked to dead URLs (/workspace?billing=upgrade), replacing them with live workspace-scoped upgrade/billing-settings routes.

  • New maybeSendLimitThresholdEmail/maybeNotifyLimit handle scope resolution, opt-outs, and a single atomic conditional UPDATE … RETURNING claim so concurrent inserts can't both win; re-arm fires when usage drops below 70%, and per-admin send failures are isolated.
  • notifyTableRowUsage is edge-triggered — it fires only when an insert crosses UP into the 80% or 100% band (not on every near-limit write), and is called post-commit to avoid burning the dedup claim on a rolled-back transaction.
  • Upgrade-page ?reason= is parsed via nuqs's parseAsStringLiteral(UPGRADE_REASONS) with history: 'replace' so invalid or absent reasons fall back to the generic header without polluting back-stack history.

Confidence Score: 5/5

Safe to merge — the notification path is fully fire-and-forget and never blocks mutations; the migration adds non-destructive columns with safe defaults; previously flagged concurrency and re-arm bugs are addressed.

The new dedup logic (single atomic conditional UPDATE, mutually exclusive re-arm/claim per call, isolated per-recipient failures) is sound. The edge-triggered notifyTableRowUsage only fires on threshold crossings and is called post-commit. The dead-link fix for credits emails is correct. Unit tests cover the key invariants (warn/reached, dead band, re-arm, opt-outs, concurrent claim loss). The only omission — file deletions don't pass workspaceId to decrementStorageUsage, so re-arm is skipped after a delete — is a small hysteresis gap, not a data-correctness or delivery bug.

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts — the deleteWorkspaceFile path does not pass workspaceId to decrementStorageUsage, so storage threshold re-arm is skipped after file deletion.

Important Files Changed

Filename Overview
apps/sim/lib/billing/core/limit-notifications.ts New file: atomic claim/re-arm dedup for storage/tables/seats emails; re-arm and claim are mutually exclusive per call, per-admin send failures isolated, opt-outs checked before claim to avoid burning dedup state.
apps/sim/lib/table/billing.ts Added notifyTableRowUsage (edge-triggered, post-commit) and changed assertRowCapacity to return the resolved limit; limit <= 0 guard handles unlimited plans correctly.
apps/sim/lib/billing/storage/tracking.ts Adds maybeNotifyStorageLimit called post-DB-update; rearmOnly=true on decrements; subscription resolved once and reused across limit/usage lookups to avoid triple fetching.
apps/sim/lib/billing/upgrade-reasons.ts Clean single source of truth for upgrade copy; buildUpgradeHref produces correct paths with and without reason; isUpgradeReason type guard is sound.
packages/db/migrations/0248_limit_notifications.sql Adds limit_notifications jsonb NOT NULL DEFAULT '{}' to both organization and user_stats; non-destructive additive migration with a safe default.
apps/sim/lib/billing/core/usage.ts Dead-link fix: credits emails now use live workspace-scoped upgrade/billing-settings URLs; workspaceId is optional so callers that don't have it fall back to /workspace.
apps/sim/lib/table/rows/service.ts All insert paths (insertRow, batchInsertRows, replaceTableRows, upsertRow) now call notifyTableRowUsage post-commit with correct pre-insert counts; rowLimit already resolved from assertRowCapacity.
apps/sim/components/emails/billing/limit-threshold-email.tsx Single parameterised email template for storage/tables/seats; copy driven by UPGRADE_REASON_COPY; correct one-time-notification footer per kind.
apps/sim/lib/billing/core/limit-notifications.test.ts Good coverage: warn/reached crossing, dead band, re-arm, opt-outs (toggle + unsubscribe), billing-disabled, claim-lost, zero-usage re-arm, non-positive limit guard.
apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx Reads ?reason= via nuqs parseAsStringLiteral; falls back to DEFAULT_UPGRADE_HEADER cleanly; no new concerns.
apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts Upload/content-update paths now pass workspaceId for storage notifications; file-deletion path not covered (re-arm skipped on delete), leaving a small hysteresis gap.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as Upload / Insert
    participant T as tracking.ts / billing.ts
    participant NL as maybeNotifyLimit
    participant ML as maybeSendLimitThresholdEmail
    participant DB as DB (user_stats / organization)
    participant E as Email

    U->>T: "incrementStorageUsage(userId, bytes, workspaceId)<br/>OR notifyTableRowUsage(workspaceId, current, added, limit)"
    T->>T: edge-trigger check (crossedUp into 80% / 100% band?)
    alt no threshold crossing
        T-->>U: return (no-op)
    else threshold crossed
        T->>NL: maybeNotifyLimit(category, billedUserId, usage, limit)
        NL->>NL: resolve org vs user scope
        NL->>ML: maybeSendLimitThresholdEmail(scope, usage, limit, ...)
        alt "percent < 70% (re-arm band)"
            ML->>DB: "rearmThreshold: UPDATE SET stored=0 WHERE stored>0"
            ML-->>NL: return (no email)
        else "70% <= percent < 80% (dead band)"
            ML-->>NL: return (no-op)
        else "percent >= 80%"
            ML->>ML: resolveRecipients (opt-out checks)
            alt no eligible recipients
                ML-->>NL: return (claim not burned)
            else recipients found
                ML->>DB: "claimThreshold: UPDATE SET stored=desired WHERE stored < desired RETURNING"
                alt claim lost
                    DB-->>ML: [] (0 rows)
                    ML-->>NL: return
                else claim won
                    DB-->>ML: "[{id}]"
                    loop per recipient (failures isolated)
                        ML->>E: renderLimitThresholdEmail + sendEmail
                    end
                end
            end
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as Upload / Insert
    participant T as tracking.ts / billing.ts
    participant NL as maybeNotifyLimit
    participant ML as maybeSendLimitThresholdEmail
    participant DB as DB (user_stats / organization)
    participant E as Email

    U->>T: "incrementStorageUsage(userId, bytes, workspaceId)<br/>OR notifyTableRowUsage(workspaceId, current, added, limit)"
    T->>T: edge-trigger check (crossedUp into 80% / 100% band?)
    alt no threshold crossing
        T-->>U: return (no-op)
    else threshold crossed
        T->>NL: maybeNotifyLimit(category, billedUserId, usage, limit)
        NL->>NL: resolve org vs user scope
        NL->>ML: maybeSendLimitThresholdEmail(scope, usage, limit, ...)
        alt "percent < 70% (re-arm band)"
            ML->>DB: "rearmThreshold: UPDATE SET stored=0 WHERE stored>0"
            ML-->>NL: return (no email)
        else "70% <= percent < 80% (dead band)"
            ML-->>NL: return (no-op)
        else "percent >= 80%"
            ML->>ML: resolveRecipients (opt-out checks)
            alt no eligible recipients
                ML-->>NL: return (claim not burned)
            else recipients found
                ML->>DB: "claimThreshold: UPDATE SET stored=desired WHERE stored < desired RETURNING"
                alt claim lost
                    DB-->>ML: [] (0 rows)
                    ML-->>NL: return
                else claim won
                    DB-->>ML: "[{id}]"
                    loop per recipient (failures isolated)
                        ML->>E: renderLimitThresholdEmail + sendEmail
                    end
                end
            end
        end
    end
Loading

Reviews (14): Last reviewed commit: "docs(billing): drop self-explanatory inl..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/billing.ts Outdated
Comment thread apps/sim/lib/billing/core/limit-notifications.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/billing/core/limit-notifications.ts Outdated
Comment thread apps/sim/lib/billing/core/limit-notifications.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/billing/storage/tracking.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/billing/core/limit-notifications.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/billing.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/billing.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1 waleedlatif1 force-pushed the feat/usage-limit-upgrade-system branch from edfe73e to d43e683 Compare June 23, 2026 17:07
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a7ae72d. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f467991. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 375317b. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 79a4d08. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile re-review latest commit

@waleedlatif1 waleedlatif1 merged commit 77976bc into staging Jun 23, 2026
16 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/usage-limit-upgrade-system branch June 23, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant