diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts
index 7b603dc4..70e7a0c5 100644
--- a/packages/kit/convex/projects/mutation.ts
+++ b/packages/kit/convex/projects/mutation.ts
@@ -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"),
@@ -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 } : {}),
@@ -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);
@@ -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
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index 8931667d..73a4b6c1 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -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
@@ -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
@@ -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
diff --git a/packages/kit/convex/subscriptions/query.test.ts b/packages/kit/convex/subscriptions/query.test.ts
new file mode 100644
index 00000000..9e306dd2
--- /dev/null
+++ b/packages/kit/convex/subscriptions/query.test.ts
@@ -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 }],
+ });
+ });
+});
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 0bd904ae..6f546056 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -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"),
@@ -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 {
+ 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
@@ -265,11 +309,13 @@ 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
@@ -277,6 +323,9 @@ export const metricsSummary = query({
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);
@@ -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;
@@ -410,14 +460,22 @@ 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,
@@ -425,12 +483,11 @@ export const metricsSummary = query({
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,
};
},
});
diff --git a/packages/kit/convex/utils/currency.ts b/packages/kit/convex/utils/currency.ts
new file mode 100644
index 00000000..e5bf4464
--- /dev/null
+++ b/packages/kit/convex/utils/currency.ts
@@ -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;
+}
diff --git a/packages/kit/src/lib/utils.test.ts b/packages/kit/src/lib/utils.test.ts
new file mode 100644
index 00000000..807982e2
--- /dev/null
+++ b/packages/kit/src/lib/utils.test.ts
@@ -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");
+ });
+});
diff --git a/packages/kit/src/lib/utils.ts b/packages/kit/src/lib/utils.ts
index a5ef1935..de783be9 100644
--- a/packages/kit/src/lib/utils.ts
+++ b/packages/kit/src/lib/utils.ts
@@ -4,3 +4,48 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export const DEFAULT_REPORTING_CURRENCY = "USD";
+
+export const currencyCodePattern = /^[A-Z]{3}$/;
+
+export function normalizeCurrencyCode(
+ input: string | null | undefined,
+ fallback = DEFAULT_REPORTING_CURRENCY,
+): string {
+ const normalized = input?.trim().toUpperCase() ?? "";
+ if (currencyCodePattern.test(normalized)) {
+ return normalized;
+ }
+ return fallback;
+}
+
+export interface FormatMicrosOptions {
+ currency?: string | null;
+ compact?: boolean;
+ emptyWhenZero?: boolean;
+}
+
+export function formatMicros(
+ micros: number,
+ {
+ currency,
+ compact = false,
+ emptyWhenZero = false,
+ }: FormatMicrosOptions = {},
+): string {
+ if (!micros) {
+ if (emptyWhenZero) return "—";
+ if (compact) return "0";
+ return `${currency ?? ""} 0.00`.trim();
+ }
+
+ const value = micros / 1_000_000;
+ if (compact) {
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
+ if (value < 10) return value.toFixed(2);
+ return value.toFixed(0);
+ }
+
+ return `${currency ?? ""} ${value.toFixed(2)}`.trim();
+}
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index 371f9a49..9020fa5a 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -28,7 +28,7 @@ import {
import type { Doc } from "@/convex";
import { api } from "@/convex";
import { PageLoading } from "@/components/LoadingSpinner";
-import { cn } from "@/lib/utils";
+import { cn, formatMicros, normalizeCurrencyCode } from "@/lib/utils";
type ProjectContext = { project: Doc<"projects"> };
@@ -190,26 +190,41 @@ export default function ProjectAnalytics() {
// bail to `
{card.label}
- {formatMicros(cardTotals.revenueMicros, currency)} + {formatMicros(cardTotals.revenueMicros, { + currency: reportingCurrency, + })}
{cardTotals.activeSubs} active · {cardTotals.newSubs} new @@ -504,30 +551,48 @@ export default function ProjectAnalytics() { />
+ MRR excludes non-reporting currencies +
++ The main MRR card only includes {reportingCurrency}. Other + currencies are shown separately and are not converted or summed. +
+