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 ( +
    +

    + {title} +

    +
    {children}
    +
    + ); +} + +function DetailField({ + label, + value, + monospace, +}: { + label: string; + value: React.ReactNode; + monospace?: boolean; +}) { + return ( +
    +
    + {label} +
    +
    + {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 ( +
    + +

    {message}

    +
    + ); +} + +function CopyButton({ getValue }: { getValue: () => string }) { + const [copied, setCopied] = useState(false); + + const copyValue = async () => { + if (typeof navigator === "undefined" || !navigator.clipboard) { + return; + } + try { + await navigator.clipboard.writeText(getValue()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + }; + + return ( + + ); +} + +// Masked by default — the purchase token grants API access to the +// purchase, so it is never auto-revealed. +function PurchaseTokenField({ token }: { token: string }) { + const [revealed, setRevealed] = useState(false); + + return ( +
    +
    + {"Purchase token"} +
    +
    + + {revealed ? token : maskToken(token)} + + + token} /> +
    +
    + ); +} + +export default function ProjectOrders() { + const { project } = useOutletContext(); + const lookupOrder = useAction(api.orders.action.lookupOrder); + + const [store, setStore] = useState("apple"); + const [orderId, setOrderId] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const handleLookup = async () => { + const trimmed = orderId.trim(); + if (!trimmed || isLoading) { + return; + } + setIsLoading(true); + setError(null); + setResult(null); + try { + const response = await lookupOrder({ + projectId: project._id, + store, + orderId: trimmed, + }); + setResult(response); + } catch (lookupError) { + // Never store an empty message: the error card gates on truthiness, + // so a blank one would leave the page silently unchanged. + setError( + describeLookupError(lookupError) || + "Order lookup failed without a reported reason.", + ); + } finally { + setIsLoading(false); + } + }; + + const summary = result?.summary; + const subscription = result?.subscription; + const appleTransactions = + result?.store === "apple" + ? ((result.transactions ?? []) as AppleOrderTransaction[]) + : []; + const formattedRaw = result?.found ? formatRawJson(result.raw) : null; + const formattedSubscriptionRaw = subscription?.raw + ? formatRawJson(subscription.raw) + : null; + + return ( +
    +
    +

    {"Order lookup"}

    +

    + { + "Look up a store order ID from a customer support request. Read-only — nothing is stored." + } +

    +
    + +
    +
    { + event.preventDefault(); + void handleLookup(); + }} + className="flex flex-col gap-3 lg:flex-row lg:items-center" + > +
    + + } + value={orderId} + onChange={(event) => setOrderId(event.target.value)} + placeholder={ + store === "apple" ? "MXXXXXXXXX" : "GPA.XXXX-XXXX-XXXX-XXXXX" + } + allowClear + /> +
    + +
    +
    + +

    {STORE_FORM_HINTS[store]}

    +
    +
    + + {error && ( +
    + +
    +

    + {"Order lookup failed"} +

    +

    {error}

    +
    +
    + )} + + {result && !result.found && ( +
    + +
    +

    + {"Order not found"} +

    +

    + {`No ${STORE_LABELS[result.store]} order matches "${result.orderId}". `} + {STORE_NOT_FOUND_HINTS[result.store]} +

    +
    +
    + )} + + {result?.found && ( + <> + + {summary?.lineItemCount !== undefined && ( +
    + +
    + )} +
    + + + + } + /> + + + + + +
    +
    + + + {result.store === "apple" ? ( + appleTransactions.length > 0 ? ( +
    + {appleTransactions.map((transaction, index) => ( +
    1 && + "border border-border rounded-lg p-4", + )} + > + {appleTransactions.length > 1 && ( +

    + {`Transaction ${index + 1} of ${appleTransactions.length}`} +

    + )} +
    + + + + +
    +
    + ))} +
    + ) : ( +

    + {"No decoded transactions were returned for this order."} +

    + ) + ) : ( +
    + + {result.purchaseToken && ( + + )} +
    + )} +
    + + {subscription && ( + +
    + {subscription.error !== undefined && ( + + )} + {(subscription.state !== undefined || + subscription.autoRenewing !== undefined || + subscription.expiresDate !== undefined || + subscription.renewalProductId !== undefined || + subscription.gracePeriodExpiresDate !== undefined) && ( +
    + } + /> + + + + +
    + )} +
    +
    + )} + + {formattedRaw && ( +
    + + {"Raw store response"} + +
    +
    +                  {formattedRaw}
    +                
    +
    +
    + )} + + {formattedSubscriptionRaw && ( +
    + + {"Raw subscription status"} + +
    +
    +                  {formattedSubscriptionRaw}
    +                
    +
    +
    + )} + + )} +
    + ); +}