From 63cfe06ed4a33ebc9dd1f8860a1bd1765a4ebbb9 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 7 May 2026 22:37:36 +0900 Subject: [PATCH 1/8] feat(kit): add reporting currency setting Add a project-level reporting currency and keep MRR/revenue totals pinned to that currency unless explicit FX conversion exists. Non-reporting currencies stay visible as excluded per-currency breakdowns.\n\nCloses #135 --- packages/kit/convex/projects/mutation.ts | 18 ++++ packages/kit/convex/schema.ts | 6 ++ .../kit/convex/subscriptions/query.test.ts | 44 +++++++++ packages/kit/convex/subscriptions/query.ts | 76 ++++++++++++--- .../auth/organization/project/analytics.tsx | 49 ++++++---- .../auth/organization/project/settings.tsx | 95 +++++++++++++++++++ .../organization/project/subscriptions.tsx | 59 +++++++++++- .../kit/src/pages/docs/sections/analytics.tsx | 10 +- 8 files changed, 318 insertions(+), 39 deletions(-) create mode 100644 packages/kit/convex/subscriptions/query.test.ts diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index 7b603dc4..9eba65d5 100644 --- a/packages/kit/convex/projects/mutation.ts +++ b/packages/kit/convex/projects/mutation.ts @@ -151,6 +151,17 @@ function normalizeHorizonAppSecret(input: string): string { return normalized; } +function normalizeReportingCurrency(input: string): string { + const normalized = input.trim().toUpperCase(); + if (!/^[A-Z]{3}$/.test(normalized)) { + throw createError( + ErrorCode.INVALID_INPUT, + "Reporting currency must be a 3-letter ISO 4217 code (e.g. USD, EUR, GBP).", + ); + } + return normalized; +} + // Helper to generate URL-friendly slug function generateSlug(name: string): string { return name @@ -225,6 +236,7 @@ export const createProject = mutation({ name: args.name, slug: finalSlug, apiKey, // Keep for backward compatibility, will be deprecated + reportingCurrency: "USD", createdAt: now, updatedAt: now, ...(args.platform ? { platform: args.platform } : {}), @@ -275,6 +287,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 +343,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..b85d6a49 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; + // without explicit 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 diff --git a/packages/kit/convex/subscriptions/query.test.ts b/packages/kit/convex/subscriptions/query.test.ts new file mode 100644 index 00000000..4e468a39 --- /dev/null +++ b/packages/kit/convex/subscriptions/query.test.ts @@ -0,0 +1,44 @@ +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 }, + ], + }); + }); +}); diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts index 0bd904ae..8e25df3c 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -5,6 +5,8 @@ import type { Doc, Id } from "../_generated/dataModel"; import { monthlyMicrosForSub } from "./monthlyMicros"; import { selectMostRecentlyUpdatedSubscription } from "./selectLatest"; +const DEFAULT_REPORTING_CURRENCY = "USD"; + const subscriptionStateValidator = v.union( v.literal("Active"), v.literal("InGracePeriod"), @@ -69,6 +71,38 @@ async function projectByApiKey( .unique(); } +function reportingCurrencyForProject(project: Doc<"projects">): string { + const candidate = project.reportingCurrency?.trim().toUpperCase(); + return candidate && /^[A-Z]{3}$/.test(candidate) + ? candidate + : DEFAULT_REPORTING_CURRENCY; +} + +export type MrrCurrencyEntry = { currency: string; mrrMicros: number }; + +export function selectReportingMrr( + entries: MrrCurrencyEntry[], + reportingCurrency: string, +): { + currency: string; + mrrMicros: number; + excludedMrrByCurrency: MrrCurrencyEntry[]; +} { + const normalizedReportingCurrency = + reportingCurrency.trim().toUpperCase() || DEFAULT_REPORTING_CURRENCY; + 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 +299,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 +313,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,10 +327,13 @@ export const metricsSummary = query({ refunded30d: 0, canceled30d: 0, mrrMicros: 0, - currency: undefined, + currency: DEFAULT_REPORTING_CURRENCY, + reportingCurrency: DEFAULT_REPORTING_CURRENCY, mrrByCurrency: [], + excludedMrrByCurrency: [], }; } + const reportingCurrency = reportingCurrencyForProject(project); const now = Date.now(); const cutoff = now - 30 * 24 * 60 * 60 * 1000; @@ -410,14 +452,19 @@ 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 + // without FX conversion. 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, reportingCurrency); return { activeSubs, @@ -425,12 +472,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/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx index 371f9a49..b333128b 100644 --- a/packages/kit/src/pages/auth/organization/project/analytics.tsx +++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx @@ -36,6 +36,7 @@ type Platform = "IOS" | "Android"; type PlatformFilter = "all" | Platform; const DAY_MS = 86_400_000; +const DEFAULT_REPORTING_CURRENCY = "USD"; // Stable empty defaults. The kickoff render before `useQuery` // returns has `metrics === undefined`; we still need to invoke @@ -190,26 +191,31 @@ 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 = + project.reportingCurrency ?? DEFAULT_REPORTING_CURRENCY; // 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 currency = selectedCurrency ?? reportingCurrency; + const currencyOptions = useMemo( + () => Array.from(new Set([reportingCurrency, ...metricsCurrencies])).sort(), + [reportingCurrency, metricsCurrencies], + ); + const excludedCurrencies = useMemo( + () => metricsCurrencies.filter((candidate) => candidate !== currency), + [metricsCurrencies, currency], + ); // Client-side filtering. Range is also a client filter now (we // fetched the max range above), so flipping range chiclets stays @@ -504,25 +510,36 @@ 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 but excluded from current totals. */} + 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} + /> + {!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..3d26a21d 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -18,6 +18,8 @@ import { Badge } from "../../../../components/Badge"; type ProjectContext = { project: Doc<"projects"> }; +const DEFAULT_REPORTING_CURRENCY = "USD"; + const STATE_FILTERS = [ { id: "all", label: "All" }, { id: "Active", label: "Active" }, @@ -46,8 +48,16 @@ export default function ProjectSubscriptions() { const formattedMrr = useMemo(() => { if (!metrics) return "—"; - return formatMicros(metrics.mrrMicros, metrics.currency); + return formatMicros( + metrics.mrrMicros, + metrics.reportingCurrency ?? metrics.currency, + metrics.mrrByCurrency.length === 0, + ); }, [metrics]); + const reportingCurrency = + metrics?.reportingCurrency ?? + project.reportingCurrency ?? + DEFAULT_REPORTING_CURRENCY; if (subscriptions === undefined || metrics === undefined) { return ; @@ -83,9 +93,46 @@ 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 not converted or summed without explicit FX + conversion. +

+
+ {metrics.mrrByCurrency.map((entry) => ( + + {formatMicros(entry.mrrMicros, 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 without FX conversion. 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. Full FX conversion is intentionally + separate work because analytics conversion needs a documented rate + source, update cadence, effective date, and rounding policy.

Churn definition

From 33cb4433d9a26a0e49707a7706eb69fd9da085e1 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 7 May 2026 22:42:29 +0900 Subject: [PATCH 2/8] docs(kit): clarify reporting currency scope --- packages/kit/convex/schema.ts | 13 ++++++------- packages/kit/convex/subscriptions/query.ts | 2 +- .../pages/auth/organization/project/analytics.tsx | 2 +- .../pages/auth/organization/project/settings.tsx | 4 ++-- .../auth/organization/project/subscriptions.tsx | 3 +-- packages/kit/src/pages/docs/sections/analytics.tsx | 12 ++++++------ 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index b85d6a49..73a4b6c1 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -221,7 +221,7 @@ const schema = defineSchema({ // Stable presentation currency for dashboard analytics. Raw // purchases/subscriptions keep their original store currency; - // without explicit FX conversion, reporting totals only include + // IAPKit does not do FX conversion; reporting totals only include // rows that already match this code. reportingCurrency: v.optional(v.string()), @@ -685,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 @@ -720,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.ts b/packages/kit/convex/subscriptions/query.ts index 8e25df3c..9a555531 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -456,7 +456,7 @@ export const metricsSummary = query({ // headline `mrrMicros` below intentionally uses only the // project's reporting currency; other currencies remain visible // in `excludedMrrByCurrency` instead of being silently summed - // without FX conversion. + // by IAPKit. const sorted = Array.from(mrrAccumulators.entries()).sort( ([a, av], [b, bv]) => (bv !== av ? bv - av : a.localeCompare(b)), ); diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx index b333128b..fafdf6f8 100644 --- a/packages/kit/src/pages/auth/organization/project/analytics.tsx +++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx @@ -536,7 +536,7 @@ export default function ProjectAnalytics() { Revenue totals are pinned to {currency}.{" "} {excludedCurrencies.join(", ")}{" "} {excludedCurrencies.length === 1 ? "is" : "are"} excluded from this - total because FX conversion is not enabled. + total because IAPKit does not convert currencies.
)} diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index c0829c3d..c10755f9 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -708,8 +708,8 @@ export default function ProjectSettings() {

Main MRR and revenue totals only include rows already stored in this - currency. Other currencies stay visible separately until explicit FX - conversion is added. + currency. IAPKit keeps other currencies visible separately and does + not convert them.

diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index 3d26a21d..e00b472f 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -110,8 +110,7 @@ export default function ProjectSubscriptions() {

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

{metrics.mrrByCurrency.map((entry) => ( diff --git a/packages/kit/src/pages/docs/sections/analytics.tsx b/packages/kit/src/pages/docs/sections/analytics.tsx index 1807aab1..8d777f0a 100644 --- a/packages/kit/src/pages/docs/sections/analytics.tsx +++ b/packages/kit/src/pages/docs/sections/analytics.tsx @@ -125,12 +125,12 @@ export default function AnalyticsPage() {

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 without FX conversion. 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. Full FX conversion is intentionally - separate work because analytics conversion needs a documented rate - source, update cadence, effective date, and rounding policy. + 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

From a01672a97d663cd89faec6a4f5b786fe21067c12 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 7 May 2026 23:01:59 +0900 Subject: [PATCH 3/8] fix(kit): address reporting currency review --- packages/kit/src/lib/utils.test.ts | 27 ++++++++++++ packages/kit/src/lib/utils.ts | 42 +++++++++++++++++++ .../auth/organization/project/analytics.tsx | 32 +++++--------- .../organization/project/subscriptions.tsx | 40 ++++++++---------- 4 files changed, 96 insertions(+), 45 deletions(-) create mode 100644 packages/kit/src/lib/utils.test.ts diff --git a/packages/kit/src/lib/utils.test.ts b/packages/kit/src/lib/utils.test.ts new file mode 100644 index 00000000..ea927321 --- /dev/null +++ b/packages/kit/src/lib/utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { 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"); + }); +}); + +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"); + }); +}); diff --git a/packages/kit/src/lib/utils.ts b/packages/kit/src/lib/utils.ts index a5ef1935..1f452471 100644 --- a/packages/kit/src/lib/utils.ts +++ b/packages/kit/src/lib/utils.ts @@ -4,3 +4,45 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export const DEFAULT_REPORTING_CURRENCY = "USD"; + +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 function formatMicros( + micros: number, + { + currency, + compact = false, + emptyWhenZero = false, + }: { + currency?: string | null; + compact?: boolean; + emptyWhenZero?: boolean; + } = {}, +): 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`; + 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 fafdf6f8..617708d0 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"> }; @@ -36,7 +36,6 @@ type Platform = "IOS" | "Android"; type PlatformFilter = "all" | Platform; const DAY_MS = 86_400_000; -const DEFAULT_REPORTING_CURRENCY = "USD"; // Stable empty defaults. The kickoff render before `useQuery` // returns has `metrics === undefined`; we still need to invoke @@ -191,8 +190,7 @@ 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 = - project.reportingCurrency ?? DEFAULT_REPORTING_CURRENCY; + 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 @@ -472,7 +470,7 @@ export default function ProjectAnalytics() {

{card.label}

- {formatMicros(cardTotals.revenueMicros, currency)} + {formatMicros(cardTotals.revenueMicros, { currency })}

{cardTotals.activeSubs} active · {cardTotals.newSubs} new @@ -544,7 +542,7 @@ export default function ProjectAnalytics() { formatMicros(v, currency, true)} + tickFormatter={(v) => + formatMicros(v, { currency, compact: true }) + } /> formatMicros(value, currency)} + formatter={(value: number) => + formatMicros(value, { currency }) + } contentStyle={tooltipStyle} /> = 1000) return `${(value / 1000).toFixed(1)}k`; - return value.toFixed(0); - } - return `${currency} ${value.toFixed(2)}`.trim(); -} diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index e00b472f..7d9091e3 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -15,11 +15,14 @@ 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"> }; -const DEFAULT_REPORTING_CURRENCY = "USD"; - const STATE_FILTERS = [ { id: "all", label: "All" }, { id: "Active", label: "Active" }, @@ -46,18 +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.reportingCurrency ?? metrics.currency, - metrics.mrrByCurrency.length === 0, - ); - }, [metrics]); - const reportingCurrency = - metrics?.reportingCurrency ?? - project.reportingCurrency ?? - DEFAULT_REPORTING_CURRENCY; + return formatMicros(metrics.mrrMicros, { + currency: reportingCurrency, + emptyWhenZero: metrics.mrrByCurrency.length === 0, + }); + }, [metrics, reportingCurrency]); if (subscriptions === undefined || metrics === undefined) { return ; @@ -122,7 +124,9 @@ export default function ProjectSubscriptions() { : "border-border bg-muted/30 text-muted-foreground" }`} > - {formatMicros(entry.mrrMicros, entry.currency)} + {formatMicros(entry.mrrMicros, { + currency: entry.currency, + })} {entry.currency === reportingCurrency ? " included" : ""} ))} @@ -251,13 +255,3 @@ function StateBadge({ state }: { state: string }) { function formatDate(epoch: number): string { return new Date(epoch).toISOString().slice(0, 16).replace("T", " "); } - -function formatMicros( - micros: number, - currency?: string, - emptyWhenZero = true, -): string { - if (!micros && emptyWhenZero) return "—"; - const value = micros / 1_000_000; - return `${currency ?? ""} ${value.toFixed(2)}`.trim(); -} From 2e135cc3fc0e3e27a3308f0cef56fb05e7ca769e Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 7 May 2026 23:11:39 +0900 Subject: [PATCH 4/8] fix(kit): address reporting currency follow-up --- packages/kit/convex/subscriptions/query.ts | 8 +++----- packages/kit/src/lib/utils.ts | 2 +- .../kit/src/pages/auth/organization/project/settings.tsx | 3 +-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts index 9a555531..74eed818 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -88,17 +88,15 @@ export function selectReportingMrr( mrrMicros: number; excludedMrrByCurrency: MrrCurrencyEntry[]; } { - const normalizedReportingCurrency = - reportingCurrency.trim().toUpperCase() || DEFAULT_REPORTING_CURRENCY; const reportingEntry = entries.find( - (entry) => entry.currency === normalizedReportingCurrency, + (entry) => entry.currency === reportingCurrency, ); return { - currency: normalizedReportingCurrency, + currency: reportingCurrency, mrrMicros: reportingEntry?.mrrMicros ?? 0, excludedMrrByCurrency: entries.filter( - (entry) => entry.currency !== normalizedReportingCurrency, + (entry) => entry.currency !== reportingCurrency, ), }; } diff --git a/packages/kit/src/lib/utils.ts b/packages/kit/src/lib/utils.ts index 1f452471..4f05e82c 100644 --- a/packages/kit/src/lib/utils.ts +++ b/packages/kit/src/lib/utils.ts @@ -7,7 +7,7 @@ export function cn(...inputs: ClassValue[]) { export const DEFAULT_REPORTING_CURRENCY = "USD"; -const currencyCodePattern = /^[A-Z]{3}$/; +export const currencyCodePattern = /^[A-Z]{3}$/; export function normalizeCurrencyCode( input: string | null | undefined, diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index c10755f9..3a5c47a7 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -19,6 +19,7 @@ import { import { GuideModal } from "../../../../components/GuideModal"; import { PageLoading } from "@/components/LoadingSpinner"; import { ButtonPrimary } from "@/components/ButtonPrimary"; +import { DEFAULT_REPORTING_CURRENCY, currencyCodePattern } from "@/lib/utils"; const androidPackagePattern = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/; @@ -26,8 +27,6 @@ const iosBundlePattern = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/; const appStoreIssuerPattern = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i; const appStoreKeyPattern = /^[A-Z0-9]{10}$/; -const currencyCodePattern = /^[A-Z]{3}$/; -const DEFAULT_REPORTING_CURRENCY = "USD"; interface ProjectData { _id: Id<"projects">; From dda1cd546841c9588178ba1e21a6a811528a854d Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 7 May 2026 23:22:48 +0900 Subject: [PATCH 5/8] fix(kit): resolve reporting currency review --- packages/kit/convex/projects/mutation.ts | 17 ++--- .../kit/convex/subscriptions/query.test.ts | 16 ++++ packages/kit/convex/subscriptions/query.ts | 19 ++--- packages/kit/convex/utils/currency.ts | 33 +++++++++ .../auth/organization/project/analytics.tsx | 73 ++++++++++++++----- 5 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 packages/kit/convex/utils/currency.ts diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index 9eba65d5..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"), @@ -151,17 +155,6 @@ function normalizeHorizonAppSecret(input: string): string { return normalized; } -function normalizeReportingCurrency(input: string): string { - const normalized = input.trim().toUpperCase(); - if (!/^[A-Z]{3}$/.test(normalized)) { - throw createError( - ErrorCode.INVALID_INPUT, - "Reporting currency must be a 3-letter ISO 4217 code (e.g. USD, EUR, GBP).", - ); - } - return normalized; -} - // Helper to generate URL-friendly slug function generateSlug(name: string): string { return name @@ -236,7 +229,7 @@ export const createProject = mutation({ name: args.name, slug: finalSlug, apiKey, // Keep for backward compatibility, will be deprecated - reportingCurrency: "USD", + reportingCurrency: DEFAULT_REPORTING_CURRENCY, createdAt: now, updatedAt: now, ...(args.platform ? { platform: args.platform } : {}), diff --git a/packages/kit/convex/subscriptions/query.test.ts b/packages/kit/convex/subscriptions/query.test.ts index 4e468a39..9e306dd2 100644 --- a/packages/kit/convex/subscriptions/query.test.ts +++ b/packages/kit/convex/subscriptions/query.test.ts @@ -41,4 +41,20 @@ describe("selectReportingMrr", () => { ], }); }); + + 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 74eed818..53c41c2d 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -4,8 +4,10 @@ import type { Doc, Id } from "../_generated/dataModel"; import { monthlyMicrosForSub } from "./monthlyMicros"; import { selectMostRecentlyUpdatedSubscription } from "./selectLatest"; - -const DEFAULT_REPORTING_CURRENCY = "USD"; +import { + DEFAULT_REPORTING_CURRENCY, + normalizeReportingCurrencyOrDefault, +} from "../utils/currency"; const subscriptionStateValidator = v.union( v.literal("Active"), @@ -72,10 +74,7 @@ async function projectByApiKey( } function reportingCurrencyForProject(project: Doc<"projects">): string { - const candidate = project.reportingCurrency?.trim().toUpperCase(); - return candidate && /^[A-Z]{3}$/.test(candidate) - ? candidate - : DEFAULT_REPORTING_CURRENCY; + return normalizeReportingCurrencyOrDefault(project.reportingCurrency); } export type MrrCurrencyEntry = { currency: string; mrrMicros: number }; @@ -88,15 +87,17 @@ export function selectReportingMrr( mrrMicros: number; excludedMrrByCurrency: MrrCurrencyEntry[]; } { + const normalizedReportingCurrency = + normalizeReportingCurrencyOrDefault(reportingCurrency); const reportingEntry = entries.find( - (entry) => entry.currency === reportingCurrency, + (entry) => entry.currency === normalizedReportingCurrency, ); return { - currency: reportingCurrency, + currency: normalizedReportingCurrency, mrrMicros: reportingEntry?.mrrMicros ?? 0, excludedMrrByCurrency: entries.filter( - (entry) => entry.currency !== reportingCurrency, + (entry) => entry.currency !== normalizedReportingCurrency, ), }; } 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/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx index 617708d0..f574a949 100644 --- a/packages/kit/src/pages/auth/organization/project/analytics.tsx +++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx @@ -205,14 +205,20 @@ export default function ProjectAnalytics() { // 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 currency = selectedCurrency ?? reportingCurrency; const currencyOptions = useMemo( () => Array.from(new Set([reportingCurrency, ...metricsCurrencies])).sort(), [reportingCurrency, metricsCurrencies], ); - const excludedCurrencies = useMemo( - () => metricsCurrencies.filter((candidate) => candidate !== currency), - [metricsCurrencies, currency], + 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], ); // Client-side filtering. Range is also a client filter now (we @@ -230,8 +236,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 @@ -255,6 +262,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) => { @@ -271,6 +290,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], @@ -279,6 +302,10 @@ 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], @@ -300,13 +327,13 @@ export default function ProjectAnalytics() { const totals = useMemo( () => - series.reduce( - (acc, row) => { + lifecycleSeries.reduce( + (acc, row, i) => { acc.newSubs += row.newSubs; acc.renewals += row.renewals; acc.cancellations += row.cancellations; acc.refunds += row.refunds; - acc.revenueMicros += row.revenueMicros; + acc.revenueMicros += reportingRevenueSeries[i]?.revenueMicros ?? 0; acc.activeSubsLast = row.activeSubs; return acc; }, @@ -319,7 +346,7 @@ export default function ProjectAnalytics() { activeSubsLast: 0, }, ), - [series], + [lifecycleSeries, reportingRevenueSeries], ); // Churn = (cancellations + refunds) / activeSubs at end of window. @@ -343,7 +370,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; }); @@ -361,7 +388,7 @@ export default function ProjectAnalytics() { }); } return byFilter; - }, [metricsDays, fromDay, currency, selectedProduct]); + }, [metricsDays, fromDay, reportingCurrency, selectedProduct]); if (metrics === undefined) { return ; @@ -470,7 +497,9 @@ export default function ProjectAnalytics() {

{card.label}

- {formatMicros(cardTotals.revenueMicros, { currency })} + {formatMicros(cardTotals.revenueMicros, { + currency: reportingCurrency, + })}

{cardTotals.activeSubs} active · {cardTotals.newSubs} new @@ -515,7 +544,7 @@ export default function ProjectAnalytics() { currencies without an FX rate, so the chart must always be pinned to exactly one. The project reporting currency is the default; other currencies - are visible here but excluded from current totals. */} + are visible here for chart exploration only. */} setSelectedCurrency(v ?? null)} className="min-w-[100px]" options={currencyOptions.map((c) => ({ @@ -569,12 +574,15 @@ export default function ProjectAnalytics() { {excludedReportingCurrencies.length > 0 && (

- Headline revenue totals are pinned to {reportingCurrency}.{" "} + Headline revenue totals only include {reportingCurrency}.{" "} {currency !== reportingCurrency && `The revenue chart is showing ${currency}. `} - {excludedReportingCurrencies.join(", ")}{" "} - {excludedReportingCurrencies.length === 1 ? "is" : "are"} excluded - from headline totals because IAPKit does not convert currencies. + {calloutExcludedCurrencies.length > 0 + ? `${calloutExcludedCurrencies.join(", ")} ${ + calloutExcludedCurrencies.length === 1 ? "is" : "are" + } also kept separate. ` + : "Other currency slices are kept separate. "} + IAPKit does not convert currencies.
)} @@ -614,7 +622,7 @@ export default function ProjectAnalytics() {
{!isReportingCurrencyValid && ( -

+

Enter a 3-letter ISO 4217 code.

)} From d0f93a4783b8a903c555b2c1849d17e16e04ead9 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 00:16:39 +0900 Subject: [PATCH 8/8] test(kit): cover compact micros rounding --- packages/kit/src/lib/utils.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kit/src/lib/utils.test.ts b/packages/kit/src/lib/utils.test.ts index 24fd97ca..807982e2 100644 --- a/packages/kit/src/lib/utils.test.ts +++ b/packages/kit/src/lib/utils.test.ts @@ -38,6 +38,11 @@ describe("formatMicros", () => { 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"); });