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
3 changes: 2 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(python3 /tmp/add_kmp_tabs.py)"
"Bash(python3 /tmp/add_kmp_tabs.py)",
"Bash(awk:*)"
]
}
}
71 changes: 70 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/kit/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type * as subscriptions_internal from "../subscriptions/internal.js";
import type * as subscriptions_monthlyMicros from "../subscriptions/monthlyMicros.js";
import type * as subscriptions_mutation from "../subscriptions/mutation.js";
import type * as subscriptions_query from "../subscriptions/query.js";
import type * as subscriptions_revenueMetrics from "../subscriptions/revenueMetrics.js";
import type * as subscriptions_selectLatest from "../subscriptions/selectLatest.js";
import type * as subscriptions_stateMachine from "../subscriptions/stateMachine.js";
import type * as subscriptions_stats from "../subscriptions/stats.js";
Expand Down Expand Up @@ -137,6 +138,7 @@ declare const fullApi: ApiFromModules<{
"subscriptions/monthlyMicros": typeof subscriptions_monthlyMicros;
"subscriptions/mutation": typeof subscriptions_mutation;
"subscriptions/query": typeof subscriptions_query;
"subscriptions/revenueMetrics": typeof subscriptions_revenueMetrics;
"subscriptions/selectLatest": typeof subscriptions_selectLatest;
"subscriptions/stateMachine": typeof subscriptions_stateMachine;
"subscriptions/stats": typeof subscriptions_stats;
Expand Down
23 changes: 23 additions & 0 deletions packages/kit/convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ crons.interval(
{ batchSize: 50 },
);

// Revenue rollup. Walks `webhookEvents` over the trailing 3-day
// window and refreshes the `revenueMetricsDaily` rows that power
// the Analytics dashboard. Trailing window covers Apple ASN v2 and
// Google RTDN late-arrival retries (real-world p99 < 48h); each
// tick overwrites the trailing window so a webhook arriving up to 3
// days late still lands in its correct day's bucket.
//
// 10-minute cadence (vs. daily for the stats drift cron) keeps the
// dashboard close to real time — at daily cadence with batchSize=50
// a 500-project deployment cycled in 10 days, which is unacceptable
// staleness for revenue analytics. The picker walks
// `revenueMetricsRunStatus.by_run` so it self-rotates regardless
// of how often it runs; each per-project recompute is its own
// scheduled mutation with an independent 40k document-read budget.
// 100 projects × 6 ticks/hour × 24h = 14,400 project-runs/day,
// which keeps the typical deployment current within minutes.
crons.interval(
"recompute revenue metrics",
{ minutes: 10 },
internal.subscriptions.revenueMetrics.recomputeAllRevenueMetrics,
{ batchSize: 100 },
);

// Mark stuck product-sync jobs as failed. Convex caps actions at
// ~10min; the worker sets `expectedDeadline = startedAt + 9min`,
// and this reaper flips anything still `running` past
Expand Down
39 changes: 39 additions & 0 deletions packages/kit/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,14 @@ const schema = defineSchema({
day: v.string(), // ISO date (YYYY-MM-DD), UTC
productId: v.string(),
currency: v.string(),
// Platform split is part of the key — same SKU sold on iOS and
// Android on the same day produces two distinct rollup rows so
// the dashboard can chart per-store revenue. The populator only
// ever writes `IOS` or `Android` (the upstream `webhookEvents`
// and `subscriptions` schemas enforce the same union), so the
// validator stays strict — there is no legacy / sentinel
// value to absorb.
platform: v.union(v.literal("IOS"), v.literal("Android")),
Comment thread
hyochan marked this conversation as resolved.
Comment thread
hyochan marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
activeSubs: v.number(),
newSubs: v.number(),
renewals: v.number(),
Expand All @@ -730,6 +738,13 @@ const schema = defineSchema({
revenueMicros: v.number(),
updatedAt: v.number(),
})
// Primary range-scan index for the dashboard read path. Layout
// `[projectId, day, currency]` lets `getRevenueMetrics` do
// `eq(projectId).gte(day).lte(day)` for a project's full
// window in one index hit; product / platform / currency
// filters are applied in-memory afterward (the trailing
// window is small enough — TRAILING_DAYS × productCount ×
// currencyCount × platformCount, typically tens of rows).
.index("by_project_and_day_and_currency", ["projectId", "day", "currency"])
.index("by_project_and_product_and_day_and_currency", [
"projectId",
Expand All @@ -738,6 +753,30 @@ const schema = defineSchema({
"currency",
]),

// Per-project picker state for the `recomputeAllRevenueMetrics`
// cron. Walked by `lastRunAt` ascending so the picker rotates
// through every project regardless of how many subs / events the
// project has. Kept in a dedicated table (rather than piggybacking
// on `subscriptionStats.updatedAt`) so the revenue cron rotates
// independently of the subscription-stats drift cron — otherwise
// the picker that touches `subscriptionStats.updatedAt` last
// controls rotation for both, and a deployment that skews the two
// cadences ends up reprocessing the same projects.
//
// INVARIANT: at most one row per `projectId`. Convex has no unique
// constraint, so callers must look the row up via `by_project`
// and patch it instead of inserting a second one — see
// `markRevenueMetricsRun` in `subscriptions/revenueMetrics.ts`
// for the canonical upsert pattern. Two rows for the same project
// would let the `by_run` picker double-pick that project until
// both rows rotate to the head, wasting budget.
revenueMetricsRunStatus: defineTable({
projectId: v.id("projects"),
lastRunAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_run", ["lastRunAt"]),

// Unified product catalog. Mirrors what onesub holds in @onesub/providers
// — the subset of App Store Connect / Play Console that kit can read /
// create / update on the project owner's behalf. The auth-credential
Expand Down
164 changes: 164 additions & 0 deletions packages/kit/convex/subscriptions/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,170 @@ export const metricsSummary = query({
},
});

// Daily revenue + lifecycle metrics for the Analytics dashboard. Reads
// pre-computed rollups from `revenueMetricsDaily` (populated by the
// `recomputeAllRevenueMetrics` cron) so the dashboard never scans the
// raw webhookEvents log on render.
//
// `fromDay` and `toDay` are inclusive ISO date strings (YYYY-MM-DD,
// UTC) — same format `revenueMetricsDaily.day` is stored under, so
// the index range is a direct string comparison.
//
// Return shape: one entry per rollup row, i.e. one per
// (day, currency, productId, platform). Aggregation across rows
// happens client-side (`analytics.tsx`) so the dashboard can switch
// between filter combinations without re-querying. Summing across
// currencies is a UI-side concern — `revenueMicros` from a USD row
// and a EUR row cannot be added without an FX rate.
const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));

export const getRevenueMetrics = query({
args: {
apiKey: v.string(),
fromDay: v.string(),
toDay: v.string(),
// Server-side `productId` / `currency` / `platform` filters were
// removed because the dashboard does all of that filtering
// client-side (the unfiltered fetch is what backs the filter-
// dropdown population — narrowing the scan would defeat that),
// and a server-side narrowing path was incompatible with that
// contract: when a productId was pinned the dropdowns silently
// collapsed to that SKU's currencies / platforms only. If a
// future caller needs server-side narrowing for a non-dashboard
// surface, add a separate query — don't reintroduce these as
// optional args on this one.
},
returns: v.object({
days: v.array(
v.object({
day: v.string(),
currency: v.string(),
productId: v.string(),
platform: platformValidator,
activeSubs: v.number(),
newSubs: v.number(),
renewals: v.number(),
cancellations: v.number(),
refunds: v.number(),
revenueMicros: v.number(),
}),
),
// Available filter values surfaced to the dashboard so the UI
// can render dropdowns / chiclets for everything the project
// actually has data for, without a second round-trip.
currencies: v.array(v.string()),
productIds: v.array(v.string()),
platforms: v.array(platformValidator),
// True when the underlying scan hit `REVENUE_SCAN_CAP` and the
// returned rows are a partial view of the requested window. The
// dashboard surfaces this as a banner so a truncated chart is
// visible to the operator instead of silently rendering a
// partial tail.
truncated: v.boolean(),
}),
handler: async (ctx, args) => {
const project = await projectByApiKey(ctx, args.apiKey);
if (!project) {
return {
days: [],
currencies: [],
productIds: [],
platforms: [],
truncated: false,
};
}

// Reject ranges past the dashboard's longest preset (90 days)
// before issuing the index scan. A misbehaving client can
// otherwise request `fromDay = "1970-01-01"` and force the
// server to materialize every rollup row in the project. The
// 90-day cap matches `RANGES` in `analytics.tsx`; widening
// there should bump this in lockstep.
const MAX_RANGE_DAYS = 92;
if (args.fromDay > args.toDay) {
throw new Error(
`getRevenueMetrics: fromDay (${args.fromDay}) is after toDay (${args.toDay}).`,
);
}
const fromMs = Date.parse(`${args.fromDay}T00:00:00.000Z`);
const toMs = Date.parse(`${args.toDay}T00:00:00.000Z`);
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
throw new Error(
`getRevenueMetrics: invalid ISO date(s) fromDay=${args.fromDay} toDay=${args.toDay}.`,
);
}
const spanDays = Math.round((toMs - fromMs) / 86_400_000) + 1;
if (spanDays > MAX_RANGE_DAYS) {
throw new Error(
`getRevenueMetrics: span of ${spanDays} days exceeds MAX_RANGE_DAYS=${MAX_RANGE_DAYS}.`,
);
}

// Range scan over `revenueMetricsDaily` via
// `by_project_and_day_and_currency` (`[projectId, day, currency]`).
// The dashboard does all filtering (currency / product /
// platform) client-side, so we deliberately return the full
// window — narrowing here would prune the data the dashboard
// needs to populate its filter dropdowns.
//
// Capped at REVENUE_SCAN_CAP to stay under Convex's 32k
// document-scan limit per query. A 92-day range across a
// maximalist project (30 SKUs × 3 currencies × 2 platforms =
// 180 rows/day → ~16.5k rows for 92 days) fits inside this
// cap; truncation surfaces as the `truncated` flag below and
// an amber banner on the dashboard so a partial chart is
// never silently rendered.
const REVENUE_SCAN_CAP = 20_000;
const allRows = await ctx.db
.query("revenueMetricsDaily")
.withIndex("by_project_and_day_and_currency", (q) =>
q
.eq("projectId", project._id)
.gte("day", args.fromDay)
.lte("day", args.toDay),
)
.take(REVENUE_SCAN_CAP);
const truncated = allRows.length === REVENUE_SCAN_CAP;
if (truncated) {
console.warn(
`[getRevenueMetrics] revenueMetricsDaily scan hit REVENUE_SCAN_CAP=${REVENUE_SCAN_CAP} for project=${project._id} range=${args.fromDay}..${args.toDay}; chart will undercount the tail.`,
);
}

// Populate filter-dropdown choices from the unfiltered range
// scan so the UI can render every available currency /
// productId / platform regardless of which filter the user
// currently has active.
const currencies = new Set<string>();
const productIds = new Set<string>();
const platforms = new Set<"IOS" | "Android">();
for (const row of allRows) {
if (row.currency) currencies.add(row.currency);
productIds.add(row.productId);
platforms.add(row.platform);
}

return {
days: allRows.map((row) => ({
day: row.day,
currency: row.currency,
productId: row.productId,
platform: row.platform,
activeSubs: row.activeSubs,
newSubs: row.newSubs,
renewals: row.renewals,
cancellations: row.cancellations,
refunds: row.refunds,
revenueMicros: row.revenueMicros,
})),
currencies: Array.from(currencies).sort(),
productIds: Array.from(productIds).sort(),
platforms: Array.from(platforms).sort(),
truncated,
};
},
});

async function loadPeriodByProductId(
ctx: QueryCtx,
projectId: Id<"projects">,
Expand Down
Loading