Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/deno-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Deno Tests

on:
pull_request:
paths:
- "supabase/functions/**"
- ".github/workflows/deno-tests.yml"

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- uses: denoland/setup-deno@v2
with:
deno-version: v2.x

# deno.lock is a real, committed lockfile (it also pins
# scripts/backfill-mailerlite.ts's esm.sh imports) — deliberately not
# using --no-lock here so this step verifies those hashes. Deno also
# rewrites the lockfile's npm-workspace section to reflect the root
# package.json on every resolve; that's expected and harmless since
# CI never commits the checkout back.
#
# No --allow-net: rc-client.test.ts fully replaces globalThis.fetch
# rather than making real network calls, so no permissions are needed
# today. If a future test hits a real network/filesystem/env, add the
# specific flag it needs rather than granting broadly up front.
- run: deno test supabase/functions
26 changes: 26 additions & 0 deletions .github/workflows/lint-typecheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Lint & Typecheck

on:
pull_request:

permissions:
contents: read

jobs:
lint-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"

- run: npm ci

- run: npm run lint

- run: npm run typecheck
11 changes: 6 additions & 5 deletions docs/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,9 @@ The webhook is the realtime path. The drift health check is the out-of-band back

**What it does:**

1. Lists every RevenueCat customer for the project (paginated via `starting_after`, 100 per page).
2. Reads `id, entitlement` from every `profiles` row.
3. Classifies each user into one of three drift categories. Healthy users are not reported.
4. If drift > 0, emits **one** Sentry event with a stable fingerprint so consecutive runs collapse into a single issue.
1. Reads `id, entitlement` from every `profiles` row, then looks up each user's RevenueCat state individually (`GET /v2/projects/{id}/customers/{customer_id}`) — see the "Why no orphan category?" note below for why this isn't a bulk RC customer listing.
2. Classifies each user into one of three drift categories. Healthy users are not reported.
3. If drift > 0, emits **one** Sentry event with a stable fingerprint so consecutive runs collapse into a single issue.

**Drift categories:**

Expand All @@ -93,11 +92,13 @@ The Sentry event's `extra` payload includes the first 50 affected ids per catego
Every run prints one heartbeat to the Edge Function logs regardless of outcome:

```json
{"event":"drift_check_complete","drift_count":0,"count_missing":0,"count_stale":0,"supabase_profile_count":47,"run_at":"..."}
{"event":"drift_check_complete","drift_count":0,"count_missing":0,"count_stale":0,"failed_count":0,"supabase_profile_count":47,"run_at":"..."}
```

`mcp__supabase__get_logs --service edge-function` is the fastest way to find it. A missing heartbeat means the cron job didn't run, which is itself a signal worth investigating.

**Partial coverage (`rc_drift_check_partial_failure`):** each per-customer RC lookup gets one retry on a transient failure (5xx, network error, timeout) before being given up on; a customer that still fails isn't retried further within that run — it's excluded from drift classification and counted in `failed_count`. If any customers failed, a separate Sentry warning fires with fingerprint `rc-drift-check-partial-failure` (distinct from `rc-entitlement-drift`, so the two never suppress each other). No action needed beyond noting it — the next scheduled run re-checks every profile from scratch, so a skipped customer is covered again in ≤6 hours. Only worth investigating if `failed_count` is persistently non-zero across consecutive runs (points to a sustained RC-side or network problem, not a blip).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Operator runbook — drift event fires:**

1. Open the Sentry issue. Look at `tags.count_missing` first; that's the urgent class.
Expand Down
132 changes: 44 additions & 88 deletions supabase/functions/rc-entitlement-drift-check/index.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import * as Sentry from "npm:@sentry/node";
import {
classifyProfilesDrift,
resolvePremiumEntitlementId,
} from "./rc-client.ts";

Sentry.init({
dsn: Deno.env.get("SENTRY_DSN"),
tracesSampleRate: 0,
});

const RC_API_BASE = "https://api.revenuecat.com/v2";
const JOB_NAME = "rc-entitlement-drift-check";
const MAX_EXTRA_IDS = 50;

interface RcEntitlement {
id: string;
lookup_key: string;
}

interface RcCustomerActiveEntitlement {
entitlement_id: string;
// expires_at is intentionally not checked client-side — we trust RC's
// server-side active filter, which keeps in-grace-period entitlements active.
expires_at: number | null;
}

interface RcCustomer {
id: string;
active_entitlements?: {
items?: RcCustomerActiveEntitlement[];
};
}

interface RcListResponse<T> {
items: T[];
next_page: string | null;
}
serve(handler);

serve(async (req) => {
async function handler(req: Request): Promise<Response> {
const runAt = new Date().toISOString();

try {
Expand Down Expand Up @@ -133,27 +114,26 @@ serve(async (req) => {
// unreliable. The webhook's `revenuecat_webhook_user_not_found`
// captures one half of that gap (RC event for an unknown profile) and
// orphans from already-deleted accounts are not operationally urgent.
const driftPremiumMissing: string[] = [];
const driftPremiumStale: string[] = [];

for (const profile of profiles) {
const rcActivePremium = await isCustomerActivePremium(
profile.id,
const { driftPremiumMissing, driftPremiumStale, driftCheckFailed } =
await classifyProfilesDrift(
profiles,
projectId,
rcKey,
premiumEntitlementId,
(profileId, error) => {
console.error(JSON.stringify({
event: "rc_customer_check_failed",
job: JOB_NAME,
profile_id: profileId,
error,
}));
},
);

if (profile.entitlement === "premium" && !rcActivePremium) {
driftPremiumStale.push(profile.id);
} else if (profile.entitlement === "free" && rcActivePremium) {
driftPremiumMissing.push(profile.id);
}
}

const countMissing = driftPremiumMissing.length;
const countStale = driftPremiumStale.length;
const driftCount = countMissing + countStale;
const countFailed = driftCheckFailed.length;

// 6. Single Sentry event when drift > 0; stable fingerprint collapses
// consecutive runs into one issue. No event on the healthy path —
Expand All @@ -180,18 +160,43 @@ serve(async (req) => {
await Sentry.flush(2000);
}

// 7. Separate signal for runs with incomplete coverage (RC lookups that
// failed even after retry). Distinct fingerprint from the drift event
// above so the two never collapse into or suppress each other — a run
// can have both real drift AND some unchecked profiles at once. The
// next scheduled run re-checks every profile from scratch, so no
// backfill is needed once failed_count returns to 0.
if (countFailed > 0) {
Sentry.captureMessage("rc_drift_check_partial_failure", {
level: "warning",
fingerprint: ["rc-drift-check-partial-failure"],
tags: {
function: JOB_NAME,
context: "rc_drift_check_partial_failure",
failed_count: String(countFailed),
profile_count: String(profiles.length),
},
extra: {
failed_profile_ids: driftCheckFailed.slice(0, MAX_EXTRA_IDS),
run_at: runAt,
},
});
await Sentry.flush(2000);
}

console.log(JSON.stringify({
event: "drift_check_complete",
job: JOB_NAME,
run_at: runAt,
drift_count: driftCount,
count_missing: countMissing,
count_stale: countStale,
failed_count: countFailed,
supabase_profile_count: profiles.length,
}));

return new Response(
JSON.stringify({ drift_count: driftCount }),
JSON.stringify({ drift_count: driftCount, failed_count: countFailed }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} finally {
Expand All @@ -217,53 +222,4 @@ serve(async (req) => {
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
});

async function resolvePremiumEntitlementId(
projectId: string,
rcKey: string,
): Promise<string | null> {
// MapVault has one entitlement today (lookup_key="premium"). If the project
// ever has more than 100 entitlements, this needs pagination via
// starting_after — but well before that point, the data model has changed
// enough that the drift check itself should be re-evaluated.
const res = await fetch(
`${RC_API_BASE}/projects/${projectId}/entitlements?limit=100`,
{
headers: { Authorization: `Bearer ${rcKey}` },
signal: AbortSignal.timeout(10_000),
},
);
if (!res.ok) {
throw new Error(`rc_list_entitlements_${res.status}`);
}
const data = (await res.json()) as RcListResponse<RcEntitlement>;
return data.items.find((e) => e.lookup_key === "premium")?.id ?? null;
}

async function isCustomerActivePremium(
customerId: string,
projectId: string,
rcKey: string,
premiumEntitlementId: string,
): Promise<boolean> {
const res = await fetch(
`${RC_API_BASE}/projects/${projectId}/customers/${customerId}`,
{
headers: { Authorization: `Bearer ${rcKey}` },
signal: AbortSignal.timeout(10_000),
},
);
// 404 = customer has no RC record at all. That's not "drift" by itself;
// a free-tier Supabase user who never went near payments will look like
// this, which is the healthy state. Treat as "not active premium".
if (res.status === 404) {
return false;
}
if (!res.ok) {
throw new Error(`rc_get_customer_${res.status}`);
}
const customer = (await res.json()) as RcCustomer;
const items = customer.active_entitlements?.items ?? [];
return items.some((e) => e.entitlement_id === premiumEntitlementId);
}
Loading
Loading