> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Slack revenue data flow

# Slack Revenue Alerts — Data Flow & SOC 2 Posture

**Modules:**

* `Silvia-Web/lib/slack-revenue.ts` — per-event alerts fired from the Stripe webhook handler
* `Silvia-Admin/lib/slack-digest.ts` — recurring 14:05 UTC daily pulse + Monday 15:00 UTC weekly digest

> **Why the digest lives in `Silvia-Admin`:** `Silvia-Web` is at its Vercel
> cron-slot ceiling. Both repos run against the same Supabase project, so
> the digest crons access the same `stripe_subscriptions` /
> `stripe_subscription_history` / `all_users` / `ai_token_usage_daily`
> tables and the same `get_user_token_usage_in_window` /
> `get_cap_crossings_in_range` RPCs that the per-event alerts use — the
> two surfaces remain in numerical agreement by construction. The
> `Silvia-Admin` digest module sends the same data classes to the same
> Slack channel under the same SOC 2 control SLACK-REV-001; nothing
> about the data-minimization posture changes from this split.

**Subprocessor:** Slack (data sent via Incoming Webhook to a private channel)
**Channel:** `#silvia-revenue` (same channel for both surfaces; distinct visual treatment)
**Audience:** Named ops members only

This document describes the data we send to Slack from the revenue-alerts
modules, why we send it, and the SOC 2 Type 2 controls in place.

***

## What we send

Every alert contains exactly these classes of data:

| Class | Example | Sensitivity | Why we send it |
| - | - | - | - |
| Internal pseudonymous identifier | `user_37nb6k...` (Clerk ID) | Pseudo-PII (low) | Ops needs an actionable handle to look up a user via authenticated admin tooling for retention DMs / billing investigation. The Clerk ID alone does not identify a real person without an authenticated lookup. |
| Aggregate revenue counters | `MRR: $180`, `ARR: $2,160`, `7d: +$80 new, -$20 lost`, `4 paying (4 Pro · 0 Max)` | None | Business metrics. Pure aggregates. |
| Tier names | `Pro`, `Max` | None | Product tier label. |
| Subscription state and dates | `status: active`, `current_period_end: 2026-06-01` | None | Operational subscription state. |
| Per-user token-cap usage at signup | `159.0M / 100.0M (159%) · Paywall-driven` | Low (aggregate counter) | Answers the "was this conversion paywall-driven or organic?" question on `new_subscription` alerts. Rounded to 0.1M (via `formatTokens`) with a binary verdict — does not reveal usage *content* or per-surface patterns; same data class as the MRR/ARR aggregates above. |
| Stripe subscription ID (in dedup key only) | `sub_xxx` | Pseudo-PII (low) | Used only as the value of `slack_revenue_alerts_sent.dedup_key` for the duplicate-suppression mechanism. Not displayed in Slack messages. |

## What we deliberately do NOT send

| Class | Example | Why excluded |
| - | - | - |
| Personal names | First name, last name | Direct PII. We do not call `decryptFirstName` from this module. |
| Email addresses | `user@example.com` | Direct PII. We do not call `decryptEmail` from this module. |
| Phone numbers | `+1...` | Direct PII. Pulled from `all_users.phone_number` for SMS notifications only, never for Slack. |
| Stripe customer ID | `cus_xxx` | Resolves directly into Stripe's PII record (payment method last4, billing address, card brand). The Slack alert linked to a clerk\_id is sufficient; ops resolves to a Stripe customer via authenticated admin tooling when needed. |
| Raw usage content | Chat messages, radar prompts, research bodies | Most sensitive class. We only read row timestamps for "last active N days ago", never the content. |
| Per-surface token-usage breakdown | `chat: 40M, radar: 80M, research: 30M` | Could reveal per-product engagement patterns at user level. The cap-usage badge sends only the **summed** `combined_total_tokens` + cap %, never the per-surface split. |

## SOC 2 Type 2 control description

**Control name:** SLACK-REV-001 — Data minimization for revenue notifications

**Stated control:** The Slack revenue notifications module sends only
pseudonymous internal identifiers and aggregate business metrics to
the configured Slack webhook. Direct PII (names, emails, phone
numbers) and externally-resolvable identifiers (Stripe customer IDs)
are excluded by design and verified at code-review time.

**Operating evidence:**

1. **Code review:** `lib/slack-revenue.ts` imports a fixed allowlist of
   non-PII helpers from `./token-limit` (`priceIdToTier`,
   `tierToPriceId`, `calendarMonthBounds`) and `./token-limit-client`
   (`TIER_LIMITS`, `TIER_NAMES`, `TIER_PRICES_USD`, `formatTokens`, and
   the `Tier` type). It does NOT import `decryptFirstName`,
   `decryptEmail`, or any other decryption function. The rule the
   reviewer enforces is "no module that returns plaintext from an
   encrypted PII column may be imported here" — adding any new helper
   that does NOT match that rule is the only thing requiring this
   enumeration to update. Pull request review enforces this.

2. **Database column allowlist:** The `getUserContext` helper selects
   only `created_at` from `all_users`. It does not select `first_name`,
   `email`, `phone_number`, or any other PII column. It also reads
   non-PII subscription metadata (`created_at` / `stripe_subscription_id`
   from `stripe_subscription_history`; `price_id` / `created_at` from
   `stripe_subscriptions`) to derive prior-paid tenure and tier for the
   alert's "Lifecycle" field — timestamps and a Stripe subscription /
   price identifier, none of which are PII.

3. **Per-event field allowlist:** The `buildEventFields` switch
   emits only Clerk ID, tier, price, dates, and aggregate counters.
   It does not reference Stripe customer ID from the event payload.

4. **Dedup table audit:** The `slack_revenue_alerts_sent` table is the
   only persistent record of "what alerts fired and when." Queryable
   for audit purposes.

5. **Channel access control:** `SLACK_REVENUE_WEBHOOK_URL` points at
   a private Slack channel with named, restricted membership. Slack
   itself is covered by our Data Processing Agreement and listed as
   a subprocessor in the privacy policy.

## Escalation path

If the SOC 2 auditor determines that even the Clerk ID is too
identifying to send to a third-party SaaS, the next step is:

* HMAC-SHA256 the Clerk ID with a server-side **secret key** (HMAC
  takes a confidential cryptographic key per NIST FIPS 198-1 / RFC
  2104; the key must be kept secret, unlike a salt which is a
  non-secret password-hashing primitive)
* Truncate to 8-12 hex chars
* Post the truncated hash instead of the raw Clerk ID
* Build an admin tool that accepts a hash and returns the Clerk ID
  (or the user record) so ops can still act on alerts

This is not implemented today. The agreed-upon trigger for the work
is an auditor recommendation in writing.

## Change log

| Date | Change | Reason |
| - | - | - |
| 2026-05-15 | Removed name decryption from per-event fields | SOC 2 data minimization; names are direct PII. |
| 2026-05-15 | Removed Stripe customer ID from per-event fields | Externally-resolvable identifier to Stripe's PII record. |
| 2026-05-15 | Stopped fetching `first_name` / `first_name_key_version` from `all_users` | Eliminates the read path entirely; no decryption attempt. |
| 2026-05-15 | Added explicit "what is / is not sent" docstring to `slack-revenue.ts` | Documentation evidence for auditor + future contributor. |
| 2026-05-16 | Compacted per-event alert to a 3-block layout (header + event fields + single "money line"); dropped the conversion-funnel section, the 30d new-MRR redundancy, the new-free-signups counter, and the voluntary/involuntary churn breakdown from per-event alerts. | Readability: previous layout buried the per-event headline (e.g. "🎉 New Pro subscription") under \~12 fields of repeating snapshot numbers that didn't change between consecutive alerts. Smaller payload also drops one Supabase RPC round-trip (`get_token_limit_funnel`) per alert. No SOC 2 impact -- the dropped fields were all aggregate metrics; the strict "what is / is not sent" allowlist above is unchanged. |
| 2026-05-16 | Renamed the per-event "Conversion: 🎯 Paywall / 📈 Organic" field to "Paused-radar email (30d): Yes/No". | Truth-in-labeling: the underlying lookup hits `token_limit_email_notifications`, which is only written when the radar-processor Lambda hits a 429 on a scheduled run and the paused-radar email/SMS successfully sends. Chat 429s, research-start blocks, manual radar-trigger blocks, and in-app paywall modals do NOT write to that table, so the old label was overstating attribution by claiming "Organic" for users with paywall touchpoints invisible to the lookup. Same data sent to Slack, more honest label. Broader cap-hit tracking via a `user_paywall_events` table is the recommended follow-up to restore "Paywall-driven / Organic" semantics. |
| 2026-05-17 | Added a "Cap usage at signup" field to `new_subscription` per-event alerts. Format: `<tokens_used> / <cap> (<pct>%) · <verdict>`, e.g. `159.0M / 100.0M (159%) · Paywall-driven`. Sourced from the existing `get_user_token_usage_in_window` RPC (same RPC the live token gate calls), summed over the calendar month containing the Stripe-provided payment timestamp (`current_period_start`, threaded through `detectRevenueEvents` from the Stripe webhook handler so the window math anchors to the actual moment Stripe transitioned the user to paid -- critical near UTC month boundary). Percentage uses `Math.floor` so the displayed pct is consistent with the strict-`>=`-cap verdict (`Math.round` would cause `99.5M / 100.0M (100%) · Under cap` rendering otherwise). | Resolves the under-attribution gap from the 2026-05-16 rename without needing a new `user_paywall_events` table: the token-usage RPC already aggregates across every cap-consuming surface (chat / radar / research / feed) since they all write to `ai_token_usage_daily`, so this single number captures cap-hits driven by any surface — including the background radar-processor 429s that fire no client-side telemetry. New data class is "per-user token-cap usage at signup" (rounded to 0.1M with a binary verdict); same sensitivity class as the MRR/ARR aggregates already in the money line, no PII added. |
| 2026-05-17 | Added recurring digest posts (`lib/slack-digest.ts`): a daily pulse (14:00 UTC) and a weekly digest (Mondays 15:00 UTC), both posting to the same `#silvia-revenue` channel. Daily pulse: MRR/ARR/paying count + yesterday-bounded new-sub / cancellation / payment-failure counts + yesterday's signups/cap-crossings/conversions funnel. Weekly digest: same revenue snapshot + 7d vs prior 7d net-MRR WoW comparison + 7d cohort funnel + top-5 Pro users at >= 70% of their 200M cap (Max-upgrade candidates list — Clerk IDs and `pct_of_cap` only, no raw token counts). | Replaces opening a dashboard for headline numbers; gives the team a CEO/COO-grade view directly in Slack. Data classes are identical to the per-event alert surface (Clerk ID, aggregate counters, tier names, dates, dollar amounts, cap-usage percentages). No new PII added; the Pro upsell list surfaces percentages (rounded to integer) without exposing exact token counts. SOC 2 control SLACK-REV-001 applies unchanged. |
| 2026-05-17 | Moved `lib/slack-digest.ts` and the two cron route handlers (`/api/cron/slack-daily-pulse`, `/api/cron/slack-weekly-digest`) from `Silvia-Web` to `Silvia-Admin`. Daily pulse shifted from `0 14 * * *` to `5 14 * * *` UTC to deconflict with `scan-dormant-users` on the destination repo's cron schedule. Per-event alerts in `lib/slack-revenue.ts` stay in `Silvia-Web` alongside the Stripe webhook handler. | `Silvia-Web` reached the Vercel Pro cron-slot cap; `Silvia-Admin` shares the same Supabase project and had headroom. Same data classes, same Slack channel, same SOC 2 control SLACK-REV-001 — purely an infrastructure move. The `get_cap_crossings_in_range` RPC added in migration `20260517_09` remains in the shared Supabase project (created via `Silvia-Web/supabase/migrations/`); the Silvia-Admin module calls it through the same `service_role` GRANT. |
| 2026-08-26 | Extended the "Cap usage at signup" field on `new_subscription` per-event alerts from one line to two: "This cycle: `<used> / <cap> (<pct>%) · <verdict>`" (unchanged semantics, the user's current free-tier billing window) plus a new "All-time: `<used>`" line covering the same user's lifetime usage across every cycle. Sourced from the same `get_user_cost_in_window` (or `get_user_token_usage_in_window` when the USD cap is off) RPC as the cycle line, called a second time with a wide window (`2020-01-01` → now). The two calls fire in parallel, so the alert adds one extra Supabase RPC per new\_subscription event with negligible added wall-clock (both hit the same clerk-scoped index on `ai_token_usage_daily`). All-time is best-effort: on RPC error or a hard 5s timeout the cycle line still renders and the all-time line reads "unknown", so the primary "did they hit the wall" answer is never blocked on the wide-window read. No cap comparison on the all-time line (lifetime vs. a monthly cap would just render an alarming triple-digit percentage on any active user). | Disambiguates three shapes that all render as small-cycle-usage today but are operationally very different: (1) a genuine low-usage voluntary converter (small cycle, small lifetime); (2) a heavy free user who finally converted (small cycle, large lifetime, from a new-cycle-just-started coincidence); (3) a lapsed Pro/Max returner (small cycle, large lifetime plus a Lifecycle badge showing the prior paid spell). Same data class as the pre-existing cycle number ("per-user usage summary, rounded"); no new PII. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.