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 `` after the hooks have been registered. const metricsDays = metrics?.days ?? EMPTY_DAYS; const metricsCurrencies = metrics?.currencies ?? EMPTY_STRINGS; + const reportingCurrency = normalizeCurrencyCode(project.reportingCurrency); // Multi-currency projects: we always pin to a single currency for // chart rendering because revenueMicros can't be summed across // currencies without an FX rate. `selectedCurrency` resolves to - // the explicit user choice, falling back to the first available + // the explicit user choice, falling back to the project reporting // currency. The currency selector below is REQUIRED (not // clearable) when multiple currencies exist so a user can never // end up in the broken "no currency selected, sum across all" // state — otherwise the totals would mix USD + EUR + JPY into a // single number labeled with one currency code. // - // Empty-project case (no rollup rows yet) leaves both - // `selectedCurrency` and `metricsCurrencies[0]` undefined; we - // resolve to "" deliberately and let the `EmptyState` below take - // over rendering — the chart subtree is gated on - // `metricsDays.length > 0` so a "" currency never reaches the - // axis labels. - const currency = - selectedCurrency ?? - (metricsCurrencies.length > 0 ? metricsCurrencies[0] : ""); + // Empty-project case (no rollup rows yet) still resolves to the + // project reporting currency so the UI does not drift based on + // whichever store event arrives first. + const currencyOptions = useMemo( + () => Array.from(new Set([reportingCurrency, ...metricsCurrencies])).sort(), + [reportingCurrency, metricsCurrencies], + ); + const currency = useMemo(() => { + if (selectedCurrency && currencyOptions.includes(selectedCurrency)) { + return selectedCurrency; + } + return reportingCurrency; + }, [selectedCurrency, currencyOptions, reportingCurrency]); + const excludedReportingCurrencies = useMemo( + () => + metricsCurrencies.filter((candidate) => candidate !== reportingCurrency), + [metricsCurrencies, reportingCurrency], + ); + const calloutExcludedCurrencies = useMemo( + () => + excludedReportingCurrencies.filter((candidate) => candidate !== currency), + [excludedReportingCurrencies, currency], + ); // Client-side filtering. Range is also a client filter now (we // fetched the max range above), so flipping range chiclets stays @@ -226,8 +241,9 @@ export default function ProjectAnalytics() { // visually to zero on the first day. // // Two parallel pipelines: - // - `revenueRows`: pinned to `currency` because `revenueMicros` - // can't be summed across currencies without an FX rate. + // - `revenueRows`: pinned to the selected chart currency. + // - `reportingRevenueRows`: pinned to the project reporting + // currency for headline totals. // - `lifecycleRows`: NOT pinned to currency. activeSubs / new / // renewals / cancellations / refunds are counts, not money, // and aggregating them across currencies gives the correct @@ -251,6 +267,18 @@ export default function ProjectAnalytics() { }), [metricsDays, currency, selectedProduct, platformFilter], ); + const reportingRevenueRows = useMemo( + () => + metricsDays.filter((row) => { + if (row.currency !== reportingCurrency) return false; + if (selectedProduct && row.productId !== selectedProduct) return false; + if (platformFilter !== "all" && row.platform !== platformFilter) { + return false; + } + return true; + }), + [metricsDays, reportingCurrency, selectedProduct, platformFilter], + ); const lifecycleRows = useMemo( () => metricsDays.filter((row) => { @@ -267,6 +295,10 @@ export default function ProjectAnalytics() { () => aggregateByDay(revenueRows, range.days, fromDay), [revenueRows, range.days, fromDay], ); + const reportingRevenueDaily = useMemo( + () => aggregateByDay(reportingRevenueRows, range.days, fromDay), + [reportingRevenueRows, range.days, fromDay], + ); const lifecycleDaily = useMemo( () => aggregateByDay(lifecycleRows, range.days, fromDay), [lifecycleRows, range.days, fromDay], @@ -275,34 +307,47 @@ export default function ProjectAnalytics() { () => bucketByPeriod(revenueDaily, periodId), [revenueDaily, periodId], ); + const reportingRevenueSeries = useMemo( + () => bucketByPeriod(reportingRevenueDaily, periodId), + [reportingRevenueDaily, periodId], + ); const lifecycleSeries = useMemo( () => bucketByPeriod(lifecycleDaily, periodId), [lifecycleDaily, periodId], ); + const revenueByBucket = useMemo( + () => new Map(revenueSeries.map((row) => [row.dayKey, row.revenueMicros])), + [revenueSeries], + ); + const reportingRevenueByBucket = useMemo( + () => + new Map( + reportingRevenueSeries.map((row) => [row.dayKey, row.revenueMicros]), + ), + [reportingRevenueSeries], + ); + // Final chart series merges the lifecycle counters (cross-currency) - // with the revenue total (currency-pinned) per bucket. Same - // bucket-period axis on both sides means we can join positionally - // since `bucketByPeriod` produces deterministic output for a - // given `periodId` / `range`. + // with the revenue total (currency-pinned) by bucket key. const series = useMemo( () => - lifecycleSeries.map((row, i) => ({ + lifecycleSeries.map((row) => ({ ...row, - revenueMicros: revenueSeries[i]?.revenueMicros ?? 0, + revenueMicros: revenueByBucket.get(row.dayKey) ?? 0, })), - [lifecycleSeries, revenueSeries], + [lifecycleSeries, revenueByBucket], ); const totals = useMemo( () => - series.reduce( + lifecycleSeries.reduce( (acc, row) => { acc.newSubs += row.newSubs; acc.renewals += row.renewals; acc.cancellations += row.cancellations; acc.refunds += row.refunds; - acc.revenueMicros += row.revenueMicros; + acc.revenueMicros += reportingRevenueByBucket.get(row.dayKey) ?? 0; acc.activeSubsLast = row.activeSubs; return acc; }, @@ -315,7 +360,7 @@ export default function ProjectAnalytics() { activeSubsLast: 0, }, ), - [series], + [lifecycleSeries, reportingRevenueByBucket], ); // Churn = (cancellations + refunds) / activeSubs at end of window. @@ -339,7 +384,7 @@ export default function ProjectAnalytics() { }); const revenueBaseRows = metricsDays.filter((row) => { if (row.day < fromDay) return false; - if (currency && row.currency !== currency) return false; + if (row.currency !== reportingCurrency) return false; if (selectedProduct && row.productId !== selectedProduct) return false; return true; }); @@ -357,7 +402,7 @@ export default function ProjectAnalytics() { }); } return byFilter; - }, [metricsDays, fromDay, currency, selectedProduct]); + }, [metricsDays, fromDay, reportingCurrency, selectedProduct]); if (metrics === undefined) { return ; @@ -466,7 +511,9 @@ export default function ProjectAnalytics() {

{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() { />

)} - {metrics.currencies.length > 1 && ( + {currencyOptions.length > 1 && (
Currency: {/* No allowClear: revenue can't be summed across currencies without an FX rate, so the chart must - always be pinned to exactly one. We surface the - first available currency as the default rather - than letting the user end up in a "no currency, - sum across" state where amounts would be wrong. */} + always be pinned to exactly one. The project + reporting currency is the default; other currencies + are visible here for chart exploration only. */} + setReportingCurrency(event.target.value.toUpperCase()) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm uppercase tracking-normal focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="USD" + maxLength={3} + spellCheck={false} + aria-invalid={!isReportingCurrencyValid} + aria-describedby={ + !isReportingCurrencyValid ? reportingCurrencyErrorId : undefined + } + /> + {!isReportingCurrencyValid && ( +

+ Enter a 3-letter ISO 4217 code. +

+ )} +
+
+ + {savingReportingCurrency ? "Saving..." : "Save currency"} + +
+ + + {/* Supported platforms — gates which configuration cards render below. Identifiers + credentials live INSIDE each platform's card so the iOS/Android boundary is unambiguous: everything diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index ddb52595..7d9091e3 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -15,6 +15,11 @@ import type { Doc } from "@/convex"; import { api } from "@/convex"; import { PageLoading } from "@/components/LoadingSpinner"; import { Badge } from "../../../../components/Badge"; +import { + DEFAULT_REPORTING_CURRENCY, + formatMicros, + normalizeCurrencyCode, +} from "@/lib/utils"; type ProjectContext = { project: Doc<"projects"> }; @@ -44,10 +49,17 @@ export default function ProjectSubscriptions() { limit: 200, }); + const reportingCurrency = normalizeCurrencyCode( + metrics?.reportingCurrency ?? project.reportingCurrency, + DEFAULT_REPORTING_CURRENCY, + ); const formattedMrr = useMemo(() => { if (!metrics) return "—"; - return formatMicros(metrics.mrrMicros, metrics.currency); - }, [metrics]); + return formatMicros(metrics.mrrMicros, { + currency: reportingCurrency, + emptyWhenZero: metrics.mrrByCurrency.length === 0, + }); + }, [metrics, reportingCurrency]); if (subscriptions === undefined || metrics === undefined) { return ; @@ -83,9 +95,47 @@ export default function ProjectSubscriptions() { label="Billing retry" value={metrics.inBillingRetry} /> - + + {metrics.excludedMrrByCurrency.length > 0 && ( +
+
+ +
+

+ MRR excludes non-reporting currencies +

+

+ The main MRR card only includes {reportingCurrency}. Other + currencies are shown separately and are not converted or summed. +

+
+ {metrics.mrrByCurrency.map((entry) => ( + + {formatMicros(entry.mrrMicros, { + currency: entry.currency, + })} + {entry.currency === reportingCurrency ? " included" : ""} + + ))} +
+
+
+
+ )} +
Rollup rows are keyed by currency: the same SKU sold in USD and EUR on the same day produces two rows. The dashboard never sums revenue across - currencies (no built-in FX conversion). When a project has multiple - currencies, the Analytics tab surfaces a currency selector; each chart - renders for one currency at a time. If you need a unified revenue view, - apply your own FX rates downstream. + currencies. Each project has a reporting currency setting used for the + main MRR and revenue views; rows in other currencies stay visible as + separate currency slices and are excluded from the reporting-currency + total. IAPKit does not do FX conversion or payout reconciliation; use + your store financial reports or accounting system for currency + conversion.

Churn definition