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
11 changes: 11 additions & 0 deletions packages/kit/convex/projects/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { getAuthUserId } from "@convex-dev/auth/server";
import { deleteProjectWithData } from "./helpers";
import { generateApiKey } from "../utils/helpers";
import { createError, ErrorCode } from "../utils/errors";
import {
DEFAULT_REPORTING_CURRENCY,
normalizeReportingCurrency,
} from "../utils/currency";

const projectPlatformValidator = v.union(
v.literal("react-native"),
Expand Down Expand Up @@ -225,6 +229,7 @@ export const createProject = mutation({
name: args.name,
slug: finalSlug,
apiKey, // Keep for backward compatibility, will be deprecated
reportingCurrency: DEFAULT_REPORTING_CURRENCY,
createdAt: now,
updatedAt: now,
...(args.platform ? { platform: args.platform } : {}),
Expand Down Expand Up @@ -275,6 +280,7 @@ export const updateProject = mutation({
horizonEnabled: v.optional(v.boolean()),
horizonAppId: v.optional(v.string()),
horizonAppSecret: v.optional(v.string()),
reportingCurrency: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
Expand Down Expand Up @@ -330,6 +336,11 @@ export const updateProject = mutation({
if (args.iosAscKeyId !== undefined) {
updates.iosAscKeyId = normalizeAppStoreKeyId(args.iosAscKeyId);
}
if (args.reportingCurrency !== undefined) {
updates.reportingCurrency = normalizeReportingCurrency(
args.reportingCurrency,
);
}

// Horizon fields: validated only when the feature is being
// enabled or when populated values are supplied. Toggling off
Expand Down
17 changes: 11 additions & 6 deletions packages/kit/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ const schema = defineSchema({
horizonAppId: v.optional(v.union(v.string(), v.null())),
horizonAppSecret: v.optional(v.union(v.string(), v.null())),

// Stable presentation currency for dashboard analytics. Raw
// purchases/subscriptions keep their original store currency;
// IAPKit does not do FX conversion; reporting totals only include
// rows that already match this code.
reportingCurrency: v.optional(v.string()),

// Per-platform "active product-sync job" lock. Read-and-patched
// inside `enqueueProductSync` so Convex's optimistic concurrency
// control collapses two concurrent enqueue mutations onto the
Expand Down Expand Up @@ -679,9 +685,9 @@ const schema = defineSchema({
// budget, which silently undercounted projects above that
// threshold.
//
// Keyed by currency because MRR can't be summed across
// currencies without a presentation-layer FX conversion (matches
// the same reasoning on `revenueMetricsDaily`).
// Keyed by currency because IAPKit deliberately does not sum MRR
// across currencies (matches the same reasoning on
// `revenueMetricsDaily`).
//
// 30-day rolling counters (refunded, canceled) are NOT stored
// here — those are bounded-size by definition (limited by 30 days
Expand Down Expand Up @@ -714,9 +720,8 @@ const schema = defineSchema({
// by (projectId, day, productId) would either mix incompatible
// `revenueMicros` totals or have one currency overwrite another,
// both of which produce wrong dashboard numbers for multi-region
// apps. Aggregating across currencies is a presentation-layer
// concern (FX conversion happens in the UI, with whatever rates the
// operator picks).
// apps. IAPKit does not convert or aggregate those currencies; any
// accounting-grade conversion belongs outside the dashboard.
revenueMetricsDaily: defineTable({
projectId: v.id("projects"),
day: v.string(), // ISO date (YYYY-MM-DD), UTC
Expand Down
60 changes: 60 additions & 0 deletions packages/kit/convex/subscriptions/query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";

import { selectReportingMrr } from "./query";

describe("selectReportingMrr", () => {
it("uses only the reporting currency for the headline MRR", () => {
const result = selectReportingMrr(
[
{ currency: "EUR", mrrMicros: 8_500_000 },
{ currency: "USD", mrrMicros: 9_990_000 },
{ currency: "HUF", mrrMicros: 12_000_000 },
],
"USD",
);

expect(result).toEqual({
currency: "USD",
mrrMicros: 9_990_000,
excludedMrrByCurrency: [
{ currency: "EUR", mrrMicros: 8_500_000 },
{ currency: "HUF", mrrMicros: 12_000_000 },
],
});
});

it("returns zero when the reporting currency has no matching MRR", () => {
const result = selectReportingMrr(
[
{ currency: "EUR", mrrMicros: 8_500_000 },
{ currency: "HUF", mrrMicros: 12_000_000 },
],
"USD",
);

expect(result).toEqual({
currency: "USD",
mrrMicros: 0,
excludedMrrByCurrency: [
{ currency: "EUR", mrrMicros: 8_500_000 },
{ currency: "HUF", mrrMicros: 12_000_000 },
],
});
});

it("falls back to USD for invalid reporting currency input", () => {
const result = selectReportingMrr(
[
{ currency: "USD", mrrMicros: 9_990_000 },
{ currency: "EUR", mrrMicros: 8_500_000 },
],
"US",
);

expect(result).toEqual({
currency: "USD",
mrrMicros: 9_990_000,
excludedMrrByCurrency: [{ currency: "EUR", mrrMicros: 8_500_000 }],
});
});
});
89 changes: 73 additions & 16 deletions packages/kit/convex/subscriptions/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { Doc, Id } from "../_generated/dataModel";

import { monthlyMicrosForSub } from "./monthlyMicros";
import { selectMostRecentlyUpdatedSubscription } from "./selectLatest";
import {
DEFAULT_REPORTING_CURRENCY,
normalizeReportingCurrencyOrDefault,
} from "../utils/currency";

const subscriptionStateValidator = v.union(
v.literal("Active"),
Expand Down Expand Up @@ -69,6 +73,46 @@ async function projectByApiKey(
.unique();
}

export interface MrrCurrencyEntry {
currency: string;
mrrMicros: number;
}

/**
* Selects the headline MRR entry for a project's reporting currency.
*
* `reportingCurrency` is normalized via `normalizeReportingCurrencyOrDefault`,
* so unknown or invalid input falls back to `DEFAULT_REPORTING_CURRENCY`.
* If no entry matches the normalized currency, returned `mrrMicros` is `0`.
* Returned `excludedMrrByCurrency` contains every non-reporting-currency entry.
*
* @param entries Per-currency MRR rows already summed for the project.
* @param reportingCurrency Project-configured reporting currency, raw or normalized.
* @returns The normalized `currency`, selected `mrrMicros`, and excluded rows.
*/
export function selectReportingMrr(
entries: MrrCurrencyEntry[],
reportingCurrency: string | null | undefined,
): {
currency: string;
mrrMicros: number;
excludedMrrByCurrency: MrrCurrencyEntry[];
} {
const normalizedReportingCurrency =
normalizeReportingCurrencyOrDefault(reportingCurrency);
const reportingEntry = entries.find(
(entry) => entry.currency === normalizedReportingCurrency,
);

return {
Comment thread
hyochan marked this conversation as resolved.
currency: normalizedReportingCurrency,
mrrMicros: reportingEntry?.mrrMicros ?? 0,
excludedMrrByCurrency: entries.filter(
(entry) => entry.currency !== normalizedReportingCurrency,
),
};
}

// Match onesub's `/onesub/status?userId=` — returns the most-recently-
// updated active subscription when the user is entitled, otherwise the
// most-recently-updated subscription overall, plus one `active` boolean
Expand Down Expand Up @@ -265,18 +309,23 @@ export const metricsSummary = query({
inBillingRetry: v.number(),
refunded30d: v.number(),
canceled30d: v.number(),
// Headline MRR in the project's most-popular currency, normalized
// to monthly. Historical field name kept for backward compat with
// dashboard / MCP consumers.
// Headline MRR in the project's reporting currency, normalized
// to monthly. Historical field name kept for dashboard / MCP
// consumers, but the value is no longer a cross-currency or
// "most popular currency" total.
mrrMicros: v.number(),
currency: v.optional(v.string()),
reportingCurrency: v.string(),
// Full per-currency breakdown so consumers that care about
// multi-currency aren't left guessing. Each entry's `mrrMicros`
// is summed only over subscriptions in that currency, normalized
// to monthly via the product's billingPeriod.
mrrByCurrency: v.array(
v.object({ currency: v.string(), mrrMicros: v.number() }),
),
excludedMrrByCurrency: v.array(
v.object({ currency: v.string(), mrrMicros: v.number() }),
),
}),
handler: async (ctx, args) => {
const project = await projectByApiKey(ctx, args.apiKey);
Expand All @@ -288,11 +337,12 @@ export const metricsSummary = query({
refunded30d: 0,
canceled30d: 0,
mrrMicros: 0,
currency: undefined,
currency: DEFAULT_REPORTING_CURRENCY,
reportingCurrency: DEFAULT_REPORTING_CURRENCY,
mrrByCurrency: [],
excludedMrrByCurrency: [],
};
}

const now = Date.now();
const cutoff = now - 30 * 24 * 60 * 60 * 1000;

Expand Down Expand Up @@ -410,27 +460,34 @@ export const metricsSummary = query({
}
}

// Pick the most-popular currency (largest accumulator) as the
// headline `currency` + `mrrMicros` so dashboards / MCP consumers
// that don't yet read the multi-currency breakdown still show a
// sensible single value. Stable tie-break via alphabetical sort.
// Sort per-currency MRR for deterministic UI rendering. The
// headline `mrrMicros` below intentionally uses only the
// project's reporting currency; other currencies remain visible
// in `excludedMrrByCurrency` instead of being silently summed
// by IAPKit.
const sorted = Array.from(mrrAccumulators.entries()).sort(
([a, av], [b, bv]) => (bv !== av ? bv - av : a.localeCompare(b)),
);
const headline = sorted[0];
const mrrByCurrency = sorted.map(([currency, mrrMicros]) => ({
currency,
mrrMicros,
}));
const reportingMrr = selectReportingMrr(
mrrByCurrency,
project.reportingCurrency,
);

return {
activeSubs,
inGracePeriod,
inBillingRetry,
refunded30d,
canceled30d,
mrrMicros: headline ? headline[1] : 0,
currency: headline ? headline[0] : undefined,
mrrByCurrency: sorted.map(([currency, mrrMicros]) => ({
currency,
mrrMicros,
})),
mrrMicros: reportingMrr.mrrMicros,
currency: reportingMrr.currency,
reportingCurrency: reportingMrr.currency,
mrrByCurrency,
excludedMrrByCurrency: reportingMrr.excludedMrrByCurrency,
};
},
});
Expand Down
33 changes: 33 additions & 0 deletions packages/kit/convex/utils/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createError, ErrorCode } from "./errors";

export const DEFAULT_REPORTING_CURRENCY = "USD";

const currencyCodePattern = /^[A-Z]{3}$/;

export function isValidCurrencyCode(code: string): boolean {
return currencyCodePattern.test(code);
}

function normalizeCurrencyCandidate(input: string | null | undefined): string {
return input?.trim().toUpperCase() ?? "";
}

export function normalizeReportingCurrencyOrDefault(
input: string | null | undefined,
): string {
const normalized = normalizeCurrencyCandidate(input);
return isValidCurrencyCode(normalized)
? normalized
: DEFAULT_REPORTING_CURRENCY;
}

export function normalizeReportingCurrency(input: string): string {
const normalized = normalizeCurrencyCandidate(input);
if (!isValidCurrencyCode(normalized)) {
throw createError(
ErrorCode.INVALID_INPUT,
"Reporting currency must be a 3-letter ISO 4217 code (e.g. USD, EUR, GBP).",
);
}
return normalized;
}
49 changes: 49 additions & 0 deletions packages/kit/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";

import {
DEFAULT_REPORTING_CURRENCY,
formatMicros,
normalizeCurrencyCode,
} from "./utils";

describe("normalizeCurrencyCode", () => {
it("trims and uppercases valid ISO currency codes", () => {
expect(normalizeCurrencyCode(" usd ")).toBe("USD");
});

it("falls back when the currency code is invalid", () => {
expect(normalizeCurrencyCode("usdollar", "EUR")).toBe("EUR");
});

it("falls back to DEFAULT_REPORTING_CURRENCY for nullish input", () => {
expect(normalizeCurrencyCode(null)).toBe(DEFAULT_REPORTING_CURRENCY);
expect(normalizeCurrencyCode(undefined)).toBe(DEFAULT_REPORTING_CURRENCY);
});

it("uses the explicit fallback for undefined input", () => {
expect(normalizeCurrencyCode(undefined, "GBP")).toBe("GBP");
});
});

describe("formatMicros", () => {
it("formats zero as a currency amount in non-compact mode", () => {
expect(formatMicros(0, { currency: "USD" })).toBe("USD 0.00");
});

it("can hide zero values when a metric is empty", () => {
expect(formatMicros(0, { currency: "USD", emptyWhenZero: true })).toBe("—");
});

it("keeps compact formatting for chart axes", () => {
expect(formatMicros(1_200_000_000, { compact: true })).toBe("1.2k");
});

it("formats compact values between ten and one thousand without decimals", () => {
expect(formatMicros(10_500_000, { compact: true })).toBe("11");
expect(formatMicros(999_400_000, { compact: true })).toBe("999");
});

it("preserves cents for compact values below ten", () => {
expect(formatMicros(500_000, { compact: true })).toBe("0.50");
});
});
Comment thread
hyochan marked this conversation as resolved.
Loading