diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
index db03b3744..2569cbc5b 100644
--- a/packages/docs/src/pages/docs/kit-backend.tsx
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -17,7 +17,7 @@ function KitBackend() {
title="Purchase Verification with IAPKit"
description="Purchase verification with IAPKit at kit.openiap.dev handles Apple StoreKit 2, Google Play, Amazon Appstore, and Meta Horizon verification, public per-product client payloads, lifecycle webhooks, subscription state, revenue metrics, and store product sync."
path="/docs/kit-backend"
- keywords="IAPKit, kit.openiap.dev, OpenIAP kit, hosted backend, purchase verification, receipt validation, product client payload, TOML metadata, Amazon Fire OS, Vega OS, subscription state, App Store Connect, Play Console, MCP server"
+ keywords="IAPKit, kit.openiap.dev, OpenIAP kit, hosted backend, purchase verification, receipt validation, order lookup, product client payload, TOML metadata, Amazon Fire OS, Vega OS, subscription state, App Store Connect, Play Console, MCP server"
/>
Purchase Verification
@@ -191,6 +191,10 @@ function KitBackend() {
or Play Console (via the service-account JSON), plus a public client
payload editor for app-readable TOML, JSON, or text rules.
+
+ Orders — read-only Apple / Google order ID lookup
+ for customer support (see Order lookup).
+
Webhooks — copyable inbound lifecycle webhook URLs
for Apple ASN v2 and Google RTDN.
@@ -198,6 +202,48 @@ function KitBackend() {
+
+
+ Order lookup
+
+
+ Order lookup is read-only support tooling for customer inquiries:
+ paste an Apple or Google order ID from a customer receipt and IAPKit
+ returns the full order details, plus — when the order contains a
+ subscription and the store returns it — the current subscription
+ status. The subscription fetch is best-effort: if it fails, the order
+ result still stands and the reason is shown alongside it. It lives in
+ the project dashboard under Orders.
+
+
+ Lookups reuse the store credentials the project already configured for
+ purchase verification — the App Store Server API key for Apple and the
+ Play service-account JSON for Google — so in most projects there is
+ nothing new to set up.
+
+
+
+ Apple lookups are production-only. Order IDs exist
+ only for real App Store purchases, and the App Store Server API
+ order-lookup endpoint has no sandbox counterpart, so sandbox
+ transactions cannot be found by order ID. Because the lookup always
+ verifies against production, the project also needs its{' '}
+ App Apple ID filled in under Settings → iOS — it is
+ optional for sandbox-only verification, but required here.
+
+
+ Google lookups need financial data access. The
+ project's Play service account must have the{' '}
+ View financial data permission in Play Console; without it
+ the Play Developer API rejects order lookups.
+
+
+
+ Lookups are proxied live to the store APIs and never stored — IAPKit
+ keeps no record of searched order IDs or their results.
+
+
+
Purchase verification from SDKs
diff --git a/packages/kit/README.md b/packages/kit/README.md
index db3be91aa..bd2961f7b 100644
--- a/packages/kit/README.md
+++ b/packages/kit/README.md
@@ -47,6 +47,7 @@ One package, one binary, one Fly.io app.
- **OpenAPI spec** auto-generated by `hono-openapi`
- **Codex / Claude Code MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows
- **Per-product client payloads** for public, app-readable TOML, JSON, or text rules without a separate metadata service
+- **Read-only order lookup** in the dashboard — paste an Apple or Google order ID from a customer receipt to see the live order and, when available, subscription status, using the project's existing store credentials; lookups are proxied live and never stored
## Quick Start
diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts
index be501a539..9499a68f2 100644
--- a/packages/kit/convex/_generated/api.d.ts
+++ b/packages/kit/convex/_generated/api.d.ts
@@ -25,6 +25,8 @@ import type * as files_storage from "../files/storage.js";
import type * as files_validation from "../files/validation.js";
import type * as http from "../http.js";
import type * as migrations from "../migrations.js";
+import type * as orders_action from "../orders/action.js";
+import type * as orders_shared from "../orders/shared.js";
import type * as organizations_internal from "../organizations/internal.js";
import type * as organizations_mutation from "../organizations/mutation.js";
import type * as organizations_query from "../organizations/query.js";
@@ -108,6 +110,8 @@ declare const fullApi: ApiFromModules<{
"files/validation": typeof files_validation;
http: typeof http;
migrations: typeof migrations;
+ "orders/action": typeof orders_action;
+ "orders/shared": typeof orders_shared;
"organizations/internal": typeof organizations_internal;
"organizations/mutation": typeof organizations_mutation;
"organizations/query": typeof organizations_query;
diff --git a/packages/kit/convex/orders/action.ts b/packages/kit/convex/orders/action.ts
new file mode 100644
index 000000000..914ef509c
--- /dev/null
+++ b/packages/kit/convex/orders/action.ts
@@ -0,0 +1,445 @@
+"use node";
+import {
+ AppStoreServerAPIClient,
+ Environment,
+ APIException,
+ OrderLookupStatus,
+} from "@apple/app-store-server-library";
+import { google } from "googleapis";
+import { ConvexError, v } from "convex/values";
+import { getAuthUserId } from "@convex-dev/auth/server";
+
+import { action, type ActionCtx } from "../_generated/server";
+import { internal } from "../_generated/api";
+import type { Doc, Id } from "../_generated/dataModel";
+import {
+ getAppStoreServerCredentials,
+ verifyJWSTransaction,
+} from "../purchases/ios";
+import { AppStoreProductType } from "../purchases/shared";
+import { parseAndValidateServiceAccountKey } from "../purchases/android";
+import { extractHttpStatus, retryOnTransient } from "../purchases/retry";
+import type { AppStoreReceiptData } from "../purchases/shared";
+import {
+ isValidAppleOrderId,
+ orderLookupResponseValidator,
+ orderLookupStoreValidator,
+ selectAppleSubscriptionItem,
+ summarizeAppleTransactions,
+ summarizeGoogleOrder,
+ summarizeGoogleSubscription,
+ type OrderLookupResponse,
+ type OrderSubscriptionStatus,
+} from "./shared";
+
+// Read-only order lookup for the dashboard (discussion #284).
+//
+// Auth: dashboard session + organization membership — this is support
+// tooling for project operators, not part of the public /api/v1
+// surface, so it never accepts an apiKey.
+//
+// Privacy: nothing is persisted. The action proxies the store API with
+// the project's already-configured credentials and returns the result.
+
+export const lookupOrder = action({
+ args: {
+ projectId: v.id("projects"),
+ store: orderLookupStoreValidator,
+ orderId: v.string(),
+ },
+ returns: orderLookupResponseValidator,
+ handler: async (ctx, args): Promise => {
+ const project = await getProjectForMember(ctx, args.projectId);
+ const orderId = args.orderId.trim();
+ if (!orderId) {
+ throw new ConvexError("Order ID is required");
+ }
+
+ if (args.store === "apple") {
+ return await lookupAppleOrder(ctx, project, orderId);
+ }
+ return await lookupGoogleOrder(ctx, project, orderId);
+ },
+});
+
+async function getProjectForMember(
+ ctx: ActionCtx,
+ projectId: Id<"projects">,
+): Promise> {
+ const userId: Id<"users"> | null = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new ConvexError("Not authenticated");
+ }
+
+ const project: Doc<"projects"> | null = await ctx.runQuery(
+ internal.projects.internal.getProjectById,
+ { projectId },
+ );
+ if (!project) {
+ throw new ConvexError("Project not found");
+ }
+
+ const membership = await ctx.runQuery(
+ internal.organizations.internal.getMembership,
+ { userId, organizationId: project.organizationId },
+ );
+ if (!membership) {
+ throw new ConvexError("Not a member of this organization");
+ }
+
+ return project;
+}
+
+async function lookupAppleOrder(
+ ctx: ActionCtx,
+ project: Doc<"projects">,
+ orderId: string,
+): Promise {
+ if (!project.iosBundleId) {
+ throw new ConvexError(
+ "Apple order lookup requires the project's App Store bundle ID (Settings → iOS).",
+ );
+ }
+
+ // Order lookup always runs against production (order IDs do not exist
+ // in sandbox), and SignedDataVerifier refuses to construct for
+ // production without the app's Apple ID. Fail with an actionable
+ // message here instead of deep inside JWS verification.
+ if (project.iosAppAppleId === undefined) {
+ throw new ConvexError(
+ "Apple order lookup verifies against production, which requires the project's App Apple ID (Settings → iOS).",
+ );
+ }
+
+ if (!isValidAppleOrderId(orderId)) {
+ throw new ConvexError(
+ "Apple order IDs are alphanumeric. Check the order number from the customer's App Store receipt.",
+ );
+ }
+
+ const credentials = await getAppStoreServerCredentials(ctx, project);
+
+ // Order IDs only exist for real App Store orders; the lookup endpoint
+ // has no sandbox counterpart, so the client always targets production.
+ const client = new AppStoreServerAPIClient(
+ credentials.privateKey,
+ credentials.keyId,
+ credentials.issuerId,
+ project.iosBundleId,
+ Environment.PRODUCTION,
+ );
+
+ let response;
+ try {
+ response = await retryOnTransient(() => client.lookUpOrderId(orderId));
+ } catch (error) {
+ if (error instanceof APIException && error.httpStatusCode === 404) {
+ return { store: "apple", found: false, orderId, raw: "{}" };
+ }
+ throw new ConvexError(formatAppleApiError(error));
+ }
+
+ if (
+ response.status !== OrderLookupStatus.VALID ||
+ !response.signedTransactions?.length
+ ) {
+ return {
+ store: "apple",
+ found: false,
+ orderId,
+ raw: JSON.stringify({ status: response.status }),
+ };
+ }
+
+ const transactions: AppStoreReceiptData[] = [];
+ for (const signed of response.signedTransactions) {
+ transactions.push(
+ await verifyJWSTransaction(
+ signed,
+ project.iosBundleId,
+ Environment.PRODUCTION,
+ project.iosAppAppleId,
+ ),
+ );
+ }
+
+ const result: OrderLookupResponse = {
+ store: "apple",
+ found: true,
+ orderId,
+ transactions,
+ raw: JSON.stringify({
+ status: response.status,
+ transactions,
+ }),
+ };
+
+ const summary = summarizeAppleTransactions(transactions);
+ if (summary) {
+ result.summary = summary;
+ }
+
+ // Only auto-renewable orders have a subscription status. Every Apple
+ // transaction carries an originalTransactionId (it equals
+ // transactionId for one-time purchases), so gating on that alone
+ // would fire this request — and surface a failure notice — for
+ // consumable orders too. This mirrors the Google path, which gates on
+ // the line item actually being a subscription.
+ const subscriptionTransactionIds = transactions
+ .filter(
+ (transaction) =>
+ transaction.type === AppStoreProductType.AUTO_RENEWABLE_SUBSCRIPTION,
+ )
+ .map((transaction) => transaction.originalTransactionId)
+ .filter((id): id is string => Boolean(id));
+
+ if (subscriptionTransactionIds.length > 0) {
+ result.subscription = await fetchAppleSubscriptionStatus(
+ client,
+ subscriptionTransactionIds,
+ );
+ }
+
+ return result;
+}
+
+// Optional secondary fetch — failure must not invalidate the lookup.
+async function fetchAppleSubscriptionStatus(
+ client: AppStoreServerAPIClient,
+ orderTransactionIds: string[],
+): Promise {
+ try {
+ const statuses = await retryOnTransient(() =>
+ client.getAllSubscriptionStatuses(orderTransactionIds[0]),
+ );
+
+ const lastTransaction = selectAppleSubscriptionItem(
+ statuses.data,
+ orderTransactionIds,
+ );
+ if (!lastTransaction) {
+ return { error: "No subscription status available for this order" };
+ }
+
+ const result: OrderSubscriptionStatus = {
+ state: describeAppleSubscriptionStatus(lastTransaction.status),
+ raw: JSON.stringify(statuses),
+ };
+
+ if (lastTransaction.signedRenewalInfo) {
+ const renewal = decodeJwsPayloadLoose(lastTransaction.signedRenewalInfo);
+ if (renewal) {
+ if (typeof renewal.autoRenewStatus === "number") {
+ result.autoRenewing = renewal.autoRenewStatus === 1;
+ }
+ if (typeof renewal.autoRenewProductId === "string") {
+ result.renewalProductId = renewal.autoRenewProductId;
+ }
+ if (typeof renewal.gracePeriodExpiresDate === "number") {
+ result.gracePeriodExpiresDate = renewal.gracePeriodExpiresDate;
+ }
+ }
+ }
+
+ if (lastTransaction.signedTransactionInfo) {
+ const transaction = decodeJwsPayloadLoose(
+ lastTransaction.signedTransactionInfo,
+ );
+ if (transaction && typeof transaction.expiresDate === "number") {
+ result.expiresDate = transaction.expiresDate;
+ }
+ }
+
+ return result;
+ } catch (error) {
+ // APIException carries its detail on errorMessage/httpStatusCode —
+ // its `message` is always empty — so read it through the same
+ // formatter the main lookup uses, and never surface a blank notice.
+ return {
+ error: formatAppleApiError(error) || "Subscription status request failed",
+ };
+ }
+}
+
+function describeAppleSubscriptionStatus(status: number | undefined): string {
+ switch (status) {
+ case 1:
+ return "Active";
+ case 2:
+ return "Expired";
+ case 3:
+ return "Billing retry";
+ case 4:
+ return "Billing grace period";
+ case 5:
+ return "Revoked";
+ default:
+ return `Unknown (${String(status)})`;
+ }
+}
+
+// The renewal/transaction payloads inside a status response come from
+// Apple over TLS; decoding without signature re-verification is
+// acceptable for read-only display, mirroring how the App Store Server
+// library models these fields.
+function decodeJwsPayloadLoose(jws: string): Record | null {
+ const parts = jws.split(".");
+ if (parts.length !== 3) return null;
+ try {
+ return JSON.parse(
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
+ ) as Record;
+ } catch {
+ return null;
+ }
+}
+
+function formatAppleApiError(error: unknown): string {
+ if (error instanceof APIException) {
+ const status = error.httpStatusCode;
+ const hints: Record = {
+ 401: "Unauthorized. Verify the Issuer ID, Key ID, and .p8 key in project settings.",
+ 403: "Forbidden. Confirm the App Store Server API key has access to this bundle.",
+ };
+ const hint = status ? hints[status] : undefined;
+ // `??` would stop at APIException's empty `message`, leaving the
+ // dashboard with "Apple order lookup failed:" and nothing after it.
+ const reason =
+ error.errorMessage || error.message || `HTTP ${String(status)}`;
+ return `Apple order lookup failed: ${reason}${hint ? ` – ${hint}` : ""}`;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+async function lookupGoogleOrder(
+ ctx: ActionCtx,
+ project: Doc<"projects">,
+ orderId: string,
+): Promise {
+ const packageName = project.androidPackageName;
+ if (!packageName) {
+ throw new ConvexError(
+ "Google order lookup requires the project's Android package name (Settings → Android).",
+ );
+ }
+
+ const serviceAccountFile = await ctx.runQuery(
+ internal.files.internal.getGooglePlayFileByProjectInternal,
+ { projectId: project._id },
+ );
+ if (!serviceAccountFile) {
+ throw new ConvexError(
+ "Google order lookup requires the project's Play service-account key (Settings → Android).",
+ );
+ }
+
+ const fileContent = await ctx.runAction(
+ internal.files.internal.readFileAsText,
+ {
+ fileId: serviceAccountFile._id,
+ },
+ );
+ if (!fileContent?.content) {
+ throw new ConvexError("Unable to read the stored Play service-account key");
+ }
+
+ const keyData = parseAndValidateServiceAccountKey(fileContent.content);
+ const auth = new google.auth.GoogleAuth({
+ credentials: keyData,
+ scopes: ["https://www.googleapis.com/auth/androidpublisher"],
+ });
+ const androidpublisher = google.androidpublisher({ version: "v3", auth });
+
+ let order;
+ try {
+ const response = await retryOnTransient(() =>
+ androidpublisher.orders.get({ packageName, orderId }),
+ );
+ order = response.data;
+ } catch (error) {
+ if (isGoogleNotFound(error)) {
+ return { store: "google", found: false, orderId, raw: "{}" };
+ }
+ throw new ConvexError(formatGoogleApiError(error));
+ }
+
+ if (!order) {
+ return { store: "google", found: false, orderId, raw: "{}" };
+ }
+
+ const result: OrderLookupResponse = {
+ store: "google",
+ found: true,
+ orderId,
+ summary: summarizeGoogleOrder(order),
+ raw: JSON.stringify(order),
+ };
+
+ if (order.purchaseToken) {
+ result.purchaseToken = order.purchaseToken;
+ if (order.lineItems?.some((item) => item.subscriptionDetails)) {
+ result.subscription = await fetchGoogleSubscriptionStatus(
+ androidpublisher,
+ packageName,
+ order.purchaseToken,
+ );
+ }
+ }
+
+ return result;
+}
+
+// Optional secondary fetch — failure must not invalidate the lookup.
+async function fetchGoogleSubscriptionStatus(
+ androidpublisher: ReturnType,
+ packageName: string,
+ purchaseToken: string,
+): Promise {
+ try {
+ const response = await retryOnTransient(() =>
+ androidpublisher.purchases.subscriptionsv2.get({
+ packageName,
+ token: purchaseToken,
+ }),
+ );
+ if (!response.data) {
+ return { error: "No subscription status available for this token" };
+ }
+ const summary = summarizeGoogleSubscription(response.data);
+ // A response with no state and no line items would render an empty
+ // card; report it as unavailable instead.
+ if (
+ summary.state === undefined &&
+ summary.autoRenewing === undefined &&
+ summary.expiresDate === undefined &&
+ summary.renewalProductId === undefined
+ ) {
+ return {
+ error: "No subscription status available for this token",
+ ...(summary.raw !== undefined ? { raw: summary.raw } : {}),
+ };
+ }
+ return summary;
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
+
+function isGoogleNotFound(error: unknown): boolean {
+ // extractHttpStatus already normalizes every shape the Google client
+ // reports a status in (gaxios `.code`, `.status`, `.response.status`).
+ return extractHttpStatus(error) === 404;
+}
+
+function formatGoogleApiError(error: unknown): string {
+ const status = extractHttpStatus(error);
+ const hints: Record = {
+ 401: "Unauthorized. Verify the service-account key in project settings.",
+ 403: "Forbidden. Grant the service account 'View financial data' access in Play Console (required for order lookups).",
+ };
+ const hint = status ? hints[status] : undefined;
+ const message = error instanceof Error ? error.message : String(error);
+ return `Google order lookup failed: ${message}${hint ? ` – ${hint}` : ""}`;
+}
diff --git a/packages/kit/convex/orders/shared.test.ts b/packages/kit/convex/orders/shared.test.ts
new file mode 100644
index 000000000..137ccab09
--- /dev/null
+++ b/packages/kit/convex/orders/shared.test.ts
@@ -0,0 +1,402 @@
+import { describe, expect, it } from "vitest";
+import type { androidpublisher_v3 } from "googleapis";
+import type { AppStoreReceiptData } from "../purchases/shared";
+import {
+ isValidAppleOrderId,
+ orderLookupResponseValidator,
+ selectAppleSubscriptionItem,
+ summarizeAppleTransactions,
+ summarizeGoogleOrder,
+ summarizeGoogleSubscription,
+ type OrderLookupResponse,
+} from "./shared";
+
+describe("isValidAppleOrderId", () => {
+ it("accepts alphanumeric App Store order IDs", () => {
+ expect(isValidAppleOrderId("MT0000000000000")).toBe(true);
+ expect(isValidAppleOrderId("ABC123xyz")).toBe(true);
+ });
+
+ it("rejects values that could steer the request path", () => {
+ // lookUpOrderId concatenates the value into the request path
+ // without percent-encoding, so separators must never reach it.
+ expect(isValidAppleOrderId("../../v2/history/123")).toBe(false);
+ expect(isValidAppleOrderId("MT123/extra")).toBe(false);
+ expect(isValidAppleOrderId("MT123?query=1")).toBe(false);
+ expect(isValidAppleOrderId("MT123#frag")).toBe(false);
+ expect(isValidAppleOrderId("MT 123")).toBe(false);
+ expect(isValidAppleOrderId("")).toBe(false);
+ });
+});
+
+describe("selectAppleSubscriptionItem", () => {
+ const groups = [
+ {
+ lastTransactions: [
+ { originalTransactionId: "1000000000000001", status: 2 },
+ { originalTransactionId: "1000000000000002", status: 1 },
+ ],
+ },
+ {
+ lastTransactions: [
+ { originalTransactionId: "1000000000000003", status: 5 },
+ ],
+ },
+ ];
+
+ it("returns the entry belonging to the looked-up order", () => {
+ // Regression: taking [0][0] blindly reported an unrelated
+ // subscription's status for customers holding several.
+ expect(selectAppleSubscriptionItem(groups, ["1000000000000002"])).toEqual({
+ originalTransactionId: "1000000000000002",
+ status: 1,
+ });
+ });
+
+ it("searches across every subscription group", () => {
+ expect(selectAppleSubscriptionItem(groups, ["1000000000000003"])).toEqual({
+ originalTransactionId: "1000000000000003",
+ status: 5,
+ });
+ });
+
+ it("returns undefined when the order's subscription is absent", () => {
+ expect(
+ selectAppleSubscriptionItem(groups, ["9999999999999999"]),
+ ).toBeUndefined();
+ });
+
+ it("tolerates missing data and entries without an id", () => {
+ expect(selectAppleSubscriptionItem(undefined, ["1"])).toBeUndefined();
+ expect(selectAppleSubscriptionItem([{}], ["1"])).toBeUndefined();
+ expect(
+ selectAppleSubscriptionItem([{ lastTransactions: [{}] }], ["1"]),
+ ).toBeUndefined();
+ });
+});
+
+describe("summarizeGoogleOrder", () => {
+ it("maps a subscription line item with servicePeriodEndTime to a subscription summary", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ orderId: "GPA.1111-2222-3333-44444",
+ state: "CONFIRMED",
+ createTime: "2026-01-01T00:00:00.000Z",
+ lineItems: [
+ {
+ productId: "dev.hyo.martie.premium",
+ productTitle: "Martie Premium",
+ subscriptionDetails: {
+ basePlanId: "premium",
+ servicePeriodStartTime: "2026-01-01T00:00:00.000Z",
+ servicePeriodEndTime: "2026-02-01T00:00:00.000Z",
+ },
+ },
+ ],
+ total: { currencyCode: "USD", units: "4", nanos: 990_000_000 },
+ };
+
+ expect(summarizeGoogleOrder(order)).toEqual({
+ productId: "dev.hyo.martie.premium",
+ productTitle: "Martie Premium",
+ productType: "subscription",
+ state: "CONFIRMED",
+ purchaseDate: Date.parse("2026-01-01T00:00:00.000Z"),
+ expiresDate: Date.parse("2026-02-01T00:00:00.000Z"),
+ currency: "USD",
+ priceAmountMicros: 4_990_000,
+ });
+ });
+
+ it("maps a one-time purchase line item to inapp without an expiry", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ orderId: "GPA.5555-6666-7777-88888",
+ state: "CONFIRMED",
+ createTime: "2026-01-15T10:30:00.000Z",
+ lineItems: [
+ {
+ productId: "dev.hyo.martie.10bulbs",
+ productTitle: "10 Bulbs",
+ oneTimePurchaseDetails: { quantity: 1 },
+ },
+ ],
+ total: { currencyCode: "KRW", units: "1200" },
+ };
+
+ const summary = summarizeGoogleOrder(order);
+
+ expect(summary).toEqual({
+ productId: "dev.hyo.martie.10bulbs",
+ productTitle: "10 Bulbs",
+ productType: "inapp",
+ state: "CONFIRMED",
+ purchaseDate: Date.parse("2026-01-15T10:30:00.000Z"),
+ currency: "KRW",
+ priceAmountMicros: 1_200_000_000,
+ });
+ expect(summary.expiresDate).toBeUndefined();
+ });
+
+ it("converts Money nanos without units to micros", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ total: { currencyCode: "USD", nanos: 500_000_000 },
+ };
+
+ expect(summarizeGoogleOrder(order).priceAmountMicros).toBe(500_000);
+ });
+
+ it("drops the price when Money units are not numeric", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ total: { currencyCode: "USD", units: "not-a-number" },
+ };
+
+ expect(summarizeGoogleOrder(order).priceAmountMicros).toBeUndefined();
+ });
+
+ it("omits the price when Money has neither units nor nanos", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ total: { currencyCode: "USD" },
+ };
+
+ expect("priceAmountMicros" in summarizeGoogleOrder(order)).toBe(false);
+ });
+
+ it("omits every key for an order with no mappable fields", () => {
+ expect(Object.keys(summarizeGoogleOrder({}))).toEqual([]);
+ });
+
+ it("reports the line item count only when the order has more than one", () => {
+ // The summary describes lineItems[0]; the count lets the dashboard
+ // say so instead of silently hiding the rest.
+ const single: androidpublisher_v3.Schema$Order = {
+ lineItems: [{ productId: "a" }],
+ };
+ const multiple: androidpublisher_v3.Schema$Order = {
+ lineItems: [{ productId: "a" }, { productId: "b" }, { productId: "c" }],
+ };
+
+ expect("lineItemCount" in summarizeGoogleOrder(single)).toBe(false);
+ expect(summarizeGoogleOrder(multiple).lineItemCount).toBe(3);
+ expect(summarizeGoogleOrder(multiple).productId).toBe("a");
+ });
+
+ it("leaves timestamps unset when the store returns unparsable times", () => {
+ const order: androidpublisher_v3.Schema$Order = {
+ createTime: "not-a-date",
+ lineItems: [
+ {
+ productId: "dev.hyo.martie.premium",
+ subscriptionDetails: { servicePeriodEndTime: "also-not-a-date" },
+ },
+ ],
+ };
+
+ const summary = summarizeGoogleOrder(order);
+
+ expect(summary.purchaseDate).toBeUndefined();
+ expect(summary.expiresDate).toBeUndefined();
+ expect(summary.productType).toBe("subscription");
+ });
+});
+
+describe("summarizeAppleTransactions", () => {
+ const baseTransaction: AppStoreReceiptData = {
+ transactionId: "2000000123456789",
+ productId: "dev.hyo.martie.premium",
+ type: "Auto-Renewable Subscription",
+ environment: "Production",
+ purchaseDate: 1_700_000_000_000,
+ currency: "USD",
+ price: 9_990,
+ quantity: 1,
+ };
+
+ it("returns undefined for an empty transaction list", () => {
+ expect(summarizeAppleTransactions([])).toBeUndefined();
+ });
+
+ it("summarizes an active subscription as Valid with milliunit price in micros", () => {
+ const expiresDate = Date.now() + 86_400_000;
+
+ expect(
+ summarizeAppleTransactions([{ ...baseTransaction, expiresDate }]),
+ ).toEqual({
+ productId: "dev.hyo.martie.premium",
+ productType: "Auto-Renewable Subscription",
+ state: "Valid",
+ environment: "Production",
+ purchaseDate: 1_700_000_000_000,
+ expiresDate,
+ currency: "USD",
+ priceAmountMicros: 9_990_000,
+ quantity: 1,
+ });
+ });
+
+ it("marks a past expiresDate as Expired", () => {
+ const summary = summarizeAppleTransactions([
+ { ...baseTransaction, expiresDate: Date.now() - 86_400_000 },
+ ]);
+
+ expect(summary?.state).toBe("Expired");
+ });
+
+ it("marks a revoked transaction as Revoked even when it is also expired", () => {
+ const summary = summarizeAppleTransactions([
+ {
+ ...baseTransaction,
+ expiresDate: Date.now() - 86_400_000,
+ revocationDate: 1_710_000_000_000,
+ },
+ ]);
+
+ expect(summary?.state).toBe("Revoked");
+ });
+
+ it("treats a transaction without expiresDate as Valid", () => {
+ const summary = summarizeAppleTransactions([
+ { ...baseTransaction, type: "Consumable" },
+ ]);
+
+ expect(summary?.state).toBe("Valid");
+ expect(summary?.expiresDate).toBeUndefined();
+ });
+
+ it("summarizes only the first transaction", () => {
+ const summary = summarizeAppleTransactions([
+ baseTransaction,
+ { ...baseTransaction, productId: "dev.hyo.martie.other" },
+ ]);
+
+ expect(summary?.productId).toBe("dev.hyo.martie.premium");
+ });
+
+ it("omits unset optional fields", () => {
+ expect(summarizeAppleTransactions([{ environment: "Sandbox" }])).toEqual({
+ state: "Valid",
+ environment: "Sandbox",
+ });
+ });
+});
+
+describe("summarizeGoogleSubscription", () => {
+ it("maps state, auto-renew, expiry, and renewal product with raw passthrough", () => {
+ const sub: androidpublisher_v3.Schema$SubscriptionPurchaseV2 = {
+ subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",
+ lineItems: [
+ {
+ productId: "dev.hyo.martie.premium",
+ expiryTime: "2026-02-01T00:00:00.000Z",
+ autoRenewingPlan: { autoRenewEnabled: true },
+ },
+ ],
+ };
+
+ expect(summarizeGoogleSubscription(sub)).toEqual({
+ state: "SUBSCRIPTION_STATE_ACTIVE",
+ autoRenewing: true,
+ expiresDate: Date.parse("2026-02-01T00:00:00.000Z"),
+ renewalProductId: "dev.hyo.martie.premium",
+ raw: JSON.stringify(sub),
+ });
+ });
+
+ it("reports autoRenewing false when the plan omits autoRenewEnabled", () => {
+ const sub: androidpublisher_v3.Schema$SubscriptionPurchaseV2 = {
+ lineItems: [{ autoRenewingPlan: {} }],
+ };
+
+ expect(summarizeGoogleSubscription(sub).autoRenewing).toBe(false);
+ });
+
+ it("omits autoRenewing entirely for plans without autoRenewingPlan", () => {
+ const sub: androidpublisher_v3.Schema$SubscriptionPurchaseV2 = {
+ subscriptionState: "SUBSCRIPTION_STATE_EXPIRED",
+ lineItems: [
+ {
+ productId: "dev.hyo.martie.premium",
+ expiryTime: "2025-12-06T09:55:21.497Z",
+ prepaidPlan: {},
+ },
+ ],
+ };
+
+ const summary = summarizeGoogleSubscription(sub);
+
+ expect("autoRenewing" in summary).toBe(false);
+ expect(summary.expiresDate).toBe(Date.parse("2025-12-06T09:55:21.497Z"));
+ });
+
+ it("leaves expiresDate unset for an unparsable expiryTime", () => {
+ const sub: androidpublisher_v3.Schema$SubscriptionPurchaseV2 = {
+ lineItems: [{ expiryTime: "not-a-date" }],
+ };
+
+ expect(summarizeGoogleSubscription(sub).expiresDate).toBeUndefined();
+ });
+
+ it("always includes the raw JSON payload", () => {
+ expect(summarizeGoogleSubscription({})).toEqual({ raw: "{}" });
+ });
+});
+
+describe("orderLookupResponseValidator", () => {
+ // No existing test exercises convex `v.*` validators at runtime (they have
+ // no public parse API), so acceptance is asserted through the Infer'd
+ // OrderLookupResponse type: these fixtures fail `bun run lint` if the
+ // validator shape stops accepting them.
+ it("declares the response fields the dashboard consumes", () => {
+ expect(orderLookupResponseValidator.kind).toBe("object");
+ expect(Object.keys(orderLookupResponseValidator.fields).sort()).toEqual([
+ "found",
+ "orderId",
+ "purchaseToken",
+ "raw",
+ "store",
+ "subscription",
+ "summary",
+ "transactions",
+ ]);
+ });
+
+ it("accepts a representative full response shape", () => {
+ const response: OrderLookupResponse = {
+ store: "google",
+ found: true,
+ orderId: "GPA.1111-2222-3333-44444",
+ summary: {
+ productId: "dev.hyo.martie.premium",
+ productTitle: "Martie Premium",
+ productType: "subscription",
+ state: "CONFIRMED",
+ purchaseDate: 1_700_000_000_000,
+ expiresDate: 1_769_904_000_000,
+ currency: "USD",
+ priceAmountMicros: 4_990_000,
+ },
+ purchaseToken: "sub-token",
+ subscription: {
+ state: "SUBSCRIPTION_STATE_ACTIVE",
+ autoRenewing: true,
+ expiresDate: 1_769_904_000_000,
+ renewalProductId: "dev.hyo.martie.premium",
+ raw: "{}",
+ },
+ raw: "{}",
+ };
+
+ expect(response.found).toBe(true);
+ expect(response.store).toBe("google");
+ });
+
+ it("accepts a not-found response without optional sections", () => {
+ const response: OrderLookupResponse = {
+ store: "apple",
+ found: false,
+ orderId: "MT0000000000000",
+ raw: "{}",
+ };
+
+ expect(response.found).toBe(false);
+ expect(response.summary).toBeUndefined();
+ });
+});
diff --git a/packages/kit/convex/orders/shared.ts b/packages/kit/convex/orders/shared.ts
new file mode 100644
index 000000000..3f327418f
--- /dev/null
+++ b/packages/kit/convex/orders/shared.ts
@@ -0,0 +1,194 @@
+import { v, type Infer } from "convex/values";
+import type { androidpublisher_v3 } from "googleapis";
+import type { AppStoreReceiptData } from "../purchases/shared";
+
+// Local RFC3339 → millis parser: importing the one in
+// purchases/android.ts would pull a "use node" module into this
+// runtime-agnostic file, which the Convex bundler rejects.
+function parseTimeToMillis(time?: string | null): number | undefined {
+ const value = time?.trim();
+ if (!value) return undefined;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+// Read-only order lookup (dashboard support tooling, discussion #284).
+// No rows are persisted: the action proxies the store APIs with the
+// project's already-configured credentials and returns a normalized
+// summary plus the raw payloads for the collapsible technical section.
+
+export const orderLookupStoreValidator = v.union(
+ v.literal("apple"),
+ v.literal("google"),
+);
+
+export type OrderLookupStore = Infer;
+
+const orderSummaryValidator = v.object({
+ productId: v.optional(v.string()),
+ productTitle: v.optional(v.string()),
+ productType: v.optional(v.string()),
+ state: v.optional(v.string()),
+ environment: v.optional(v.string()),
+ purchaseDate: v.optional(v.number()),
+ expiresDate: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ // Store-native minor/micro units are normalized to micros where the
+ // store defines the scale; Apple order lookup prices are milliunits.
+ priceAmountMicros: v.optional(v.number()),
+ quantity: v.optional(v.number()),
+ // Google orders can carry several line items; the summary describes
+ // the first one, so the UI can say how many the raw payload holds.
+ lineItemCount: v.optional(v.number()),
+});
+
+export type OrderSummary = Infer;
+
+const subscriptionStatusValidator = v.object({
+ // Non-fatal by contract: a failed optional status fetch reports its
+ // message here instead of invalidating the order lookup itself.
+ error: v.optional(v.string()),
+ state: v.optional(v.string()),
+ autoRenewing: v.optional(v.boolean()),
+ expiresDate: v.optional(v.number()),
+ renewalProductId: v.optional(v.string()),
+ gracePeriodExpiresDate: v.optional(v.number()),
+ raw: v.optional(v.string()),
+});
+
+export type OrderSubscriptionStatus = Infer;
+
+export const orderLookupResponseValidator = v.object({
+ store: orderLookupStoreValidator,
+ found: v.boolean(),
+ orderId: v.string(),
+ summary: v.optional(orderSummaryValidator),
+ // Apple: one entry per decoded signed transaction in the order.
+ transactions: v.optional(v.array(v.any())),
+ // Google: purchase token is returned unmasked; the dashboard masks it
+ // by default with explicit Show / Copy actions.
+ purchaseToken: v.optional(v.string()),
+ subscription: v.optional(subscriptionStatusValidator),
+ raw: v.string(),
+});
+
+export type OrderLookupResponse = Infer;
+
+// App Store Server API order IDs are alphanumeric, and the client
+// concatenates the value straight into the request path without
+// percent-encoding — so anything else is rejected before it can steer
+// the authenticated request to a different API path.
+export function isValidAppleOrderId(orderId: string): boolean {
+ return /^[A-Za-z0-9]+$/.test(orderId);
+}
+
+// getAllSubscriptionStatuses returns every subscription group the
+// customer holds in this app, each with one entry per subscription.
+// Pick the entry belonging to the looked-up order instead of the first
+// one, or a customer with several subscriptions would see an unrelated
+// subscription's status reported as this order's.
+export function selectAppleSubscriptionItem<
+ T extends { originalTransactionId?: string },
+>(
+ groups: { lastTransactions?: T[] }[] | undefined,
+ orderTransactionIds: string[],
+): T | undefined {
+ const wanted = new Set(orderTransactionIds);
+ return groups
+ ?.flatMap((group) => group.lastTransactions ?? [])
+ .find(
+ (item) =>
+ item.originalTransactionId !== undefined &&
+ wanted.has(item.originalTransactionId),
+ );
+}
+
+export function summarizeAppleTransactions(
+ transactions: AppStoreReceiptData[],
+): OrderSummary | undefined {
+ const first = transactions[0];
+ if (!first) return undefined;
+ return {
+ ...(first.productId !== undefined ? { productId: first.productId } : {}),
+ ...(first.type !== undefined ? { productType: first.type } : {}),
+ ...(first.revocationDate !== undefined
+ ? { state: "Revoked" }
+ : first.expiresDate !== undefined && first.expiresDate < Date.now()
+ ? { state: "Expired" }
+ : { state: "Valid" }),
+ ...(first.environment !== undefined
+ ? { environment: first.environment }
+ : {}),
+ ...(first.purchaseDate !== undefined
+ ? { purchaseDate: first.purchaseDate }
+ : {}),
+ ...(first.expiresDate !== undefined
+ ? { expiresDate: first.expiresDate }
+ : {}),
+ ...(first.currency !== undefined ? { currency: first.currency } : {}),
+ ...(typeof first.price === "number"
+ ? { priceAmountMicros: first.price * 1000 }
+ : {}),
+ ...(first.quantity !== undefined ? { quantity: first.quantity } : {}),
+ };
+}
+
+export function summarizeGoogleOrder(
+ order: androidpublisher_v3.Schema$Order,
+): OrderSummary {
+ const lineItem = order.lineItems?.[0];
+ const total = order.total;
+ return {
+ ...(lineItem?.productId ? { productId: lineItem.productId } : {}),
+ ...(lineItem?.productTitle ? { productTitle: lineItem.productTitle } : {}),
+ ...(lineItem?.subscriptionDetails
+ ? { productType: "subscription" }
+ : lineItem?.oneTimePurchaseDetails
+ ? { productType: "inapp" }
+ : {}),
+ ...(order.state ? { state: order.state } : {}),
+ ...(order.createTime
+ ? { purchaseDate: parseTimeToMillis(order.createTime) }
+ : {}),
+ ...(lineItem?.subscriptionDetails?.servicePeriodEndTime
+ ? {
+ expiresDate: parseTimeToMillis(
+ lineItem.subscriptionDetails.servicePeriodEndTime,
+ ),
+ }
+ : {}),
+ ...(total?.currencyCode ? { currency: total.currencyCode } : {}),
+ ...(total?.units !== undefined || total?.nanos !== undefined
+ ? { priceAmountMicros: moneyToMicros(total) }
+ : {}),
+ ...(order.lineItems && order.lineItems.length > 1
+ ? { lineItemCount: order.lineItems.length }
+ : {}),
+ };
+}
+
+export function summarizeGoogleSubscription(
+ sub: androidpublisher_v3.Schema$SubscriptionPurchaseV2,
+): OrderSubscriptionStatus {
+ const lineItem = sub.lineItems?.[0];
+ return {
+ ...(sub.subscriptionState ? { state: sub.subscriptionState } : {}),
+ ...(lineItem?.autoRenewingPlan
+ ? { autoRenewing: lineItem.autoRenewingPlan.autoRenewEnabled === true }
+ : {}),
+ ...(lineItem?.expiryTime
+ ? { expiresDate: parseTimeToMillis(lineItem.expiryTime) }
+ : {}),
+ ...(lineItem?.productId ? { renewalProductId: lineItem.productId } : {}),
+ raw: JSON.stringify(sub),
+ };
+}
+
+function moneyToMicros(
+ money: androidpublisher_v3.Schema$Money,
+): number | undefined {
+ const units = Number(money.units ?? 0);
+ const nanos = money.nanos ?? 0;
+ if (!Number.isFinite(units)) return undefined;
+ return units * 1_000_000 + Math.round(nanos / 1_000);
+}
diff --git a/packages/kit/convex/purchases/android.ts b/packages/kit/convex/purchases/android.ts
index 507d08418..f25e0aab0 100644
--- a/packages/kit/convex/purchases/android.ts
+++ b/packages/kit/convex/purchases/android.ts
@@ -269,7 +269,7 @@ interface GoogleServiceAccountKey {
universe_domain?: string;
}
-function parseAndValidateServiceAccountKey(
+export function parseAndValidateServiceAccountKey(
content: string,
): GoogleServiceAccountKey {
let keyData;
diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts
index 40faaae73..39ff8d190 100644
--- a/packages/kit/convex/purchases/ios.ts
+++ b/packages/kit/convex/purchases/ios.ts
@@ -222,7 +222,7 @@ function decodeJwsPayload(jws: string): JWSTransactionDecodedPayload {
}
}
-async function verifyJWSTransaction(
+export async function verifyJWSTransaction(
jws: string,
bundleId: string,
environment: Environment,
@@ -295,7 +295,7 @@ type AppStoreServerCredentials = {
privateKey: string;
};
-async function getAppStoreServerCredentials(
+export async function getAppStoreServerCredentials(
ctx: ActionCtx,
project: Doc<"projects">,
): Promise {
diff --git a/packages/kit/src/pages/auth/index.tsx b/packages/kit/src/pages/auth/index.tsx
index e54d0c404..0432fa66f 100644
--- a/packages/kit/src/pages/auth/index.tsx
+++ b/packages/kit/src/pages/auth/index.tsx
@@ -19,6 +19,7 @@ import ProjectProducts from "./organization/project/products";
import ProjectWebhooks from "./organization/project/webhooks";
import ProjectSettings from "./organization/project/settings";
import ProjectPurchaseDetail from "./organization/project/purchase-detail";
+import ProjectOrders from "./organization/project/orders";
import OrganizationUsagePage from "./organization/usage";
import BlogLayout from "../blog/BlogLayout";
import BlogIndex from "../blog";
@@ -251,6 +252,14 @@ export default function AuthenticatedPages() {
}
/>
+
+
+
+ }
+ />
;
+ organizationId: Id<"organizations">;
+ name: string;
+ slug: string;
+ platform?: string;
+}
+
+interface OutletContext {
+ project: ProjectData;
+}
+
+type OrderLookupResult = FunctionReturnType<
+ typeof api.orders.action.lookupOrder
+>;
+type OrderLookupStore = OrderLookupResult["store"];
+
+// The action returns Apple transactions as decoded JWS payloads typed
+// `any[]` on the wire (`v.array(v.any())`); only the identifier fields
+// the dashboard renders are typed here.
+interface AppleOrderTransaction {
+ transactionId?: string;
+ originalTransactionId?: string;
+ webOrderLineItemId?: string;
+ appTransactionId?: string;
+}
+
+const STORE_LABELS: Record = {
+ apple: "App Store",
+ google: "Google Play",
+};
+
+const STORE_FORM_HINTS: Record = {
+ apple:
+ "Apple order lookups only match real App Store orders — sandbox order numbers are not supported.",
+ google:
+ "Google order lookups require the Play service account to have 'View financial data' access in Play Console.",
+};
+
+const STORE_NOT_FOUND_HINTS: Record = {
+ apple:
+ "Apple order lookup works for production orders only — sandbox order numbers cannot be looked up. Double-check the order ID from the customer's App Store receipt email.",
+ google:
+ "Verify the order ID in Play Console → Order management and confirm the Play service account has financial-data access.",
+};
+
+// Status colors per the #284 spec: green = valid/active, yellow =
+// expired/canceled/pending, red = revoked/refunded/not found. States
+// arrive as free-form store strings (Apple: "Valid" / "Revoked" /
+// "Billing grace period"; Google: "PROCESSED" /
+// "SUBSCRIPTION_STATE_ACTIVE" / ...), so this maps by keyword instead
+// of reusing the HarmonizedPurchaseState display helper.
+function getOrderStateVariant(state?: string): BadgeVariant {
+ if (!state) {
+ return "outline";
+ }
+ const normalized = state.toLowerCase();
+ // Refund/revocation wins over the pending keyword so Google's
+ // "PENDING_REFUND" lands red, not yellow.
+ if (/revoke|refund/.test(normalized)) {
+ return "danger";
+ }
+ if (/expired|cancel|pending|grace|retry|hold|paused/.test(normalized)) {
+ return "warning";
+ }
+ if (/valid|active|processed|purchased/.test(normalized)) {
+ return "success";
+ }
+ return "outline";
+}
+
+// "SUBSCRIPTION_STATE_ACTIVE" → "Active"; strings that already contain
+// lowercase letters (Apple's "Billing grace period") pass through.
+function formatOrderStateLabel(state: string): string {
+ const stripped = state
+ .replace(/^SUBSCRIPTION_STATE_/, "")
+ .replace(/^ORDER_STATE_/, "");
+ if (/[a-z]/.test(stripped)) {
+ return stripped;
+ }
+ const words = stripped.toLowerCase().split("_").join(" ");
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+function formatProductTypeLabel(type?: string): string | undefined {
+ if (!type) {
+ return undefined;
+ }
+ if (type === "inapp") {
+ return "In-app product";
+ }
+ if (type === "subscription") {
+ return "Subscription";
+ }
+ return type;
+}
+
+function formatRawJson(raw: string): string {
+ try {
+ return JSON.stringify(JSON.parse(raw), null, 2);
+ } catch {
+ return raw;
+ }
+}
+
+// The action throws ConvexError so its guidance survives Convex's
+// production redaction of plain Error messages. The payload arrives on
+// `data`; `message` would carry the framework's wrapper text.
+//
+// Reused verification helpers throw ReceiptVerificationError, whose
+// payload is a JSON envelope `{error, message, details}` — unwrap it to
+// the sentence rather than printing the envelope, the same way
+// server/convex.ts does.
+function describeLookupError(error: unknown): string {
+ if (error instanceof ConvexError) {
+ if (typeof error.data !== "string") {
+ return JSON.stringify(error.data);
+ }
+ try {
+ const parsed: unknown = JSON.parse(error.data);
+ if (
+ typeof parsed === "object" &&
+ parsed !== null &&
+ typeof (parsed as { message?: unknown }).message === "string"
+ ) {
+ return (parsed as { message: string }).message;
+ }
+ } catch {
+ // Plain-string payload — use it as-is.
+ }
+ return error.data;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+function maskToken(token: string): string {
+ return `${token.slice(0, 6)}…`;
+}
+
+function OrderStateBadge({ state }: { state?: string }) {
+ if (!state) {
+ return {FALLBACK_VALUE};
+ }
+ return (
+
+ {formatOrderStateLabel(state)}
+
+ );
+}
+
+function SectionCard({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {value === undefined || value === null || value === ""
+ ? FALLBACK_VALUE
+ : value}
+
+
+ );
+}
+
+// Blue technical notice — a failed optional subscription-status fetch
+// is reported by the action without invalidating the order lookup.
+function TechnicalNotice({ message }: { message: string }) {
+ return (
+