);
}
diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx
index 2eabdcd1..c2664816 100644
--- a/packages/docs/src/pages/docs/index.tsx
+++ b/packages/docs/src/pages/docs/index.tsx
@@ -86,6 +86,8 @@ import APIsIsBillingProgramAvailableAndroid from './apis/android/is-billing-prog
import APIsLaunchExternalLinkAndroid from './apis/android/launch-external-link-android';
import APIsCreateBillingProgramReportingDetailsAndroid from './apis/android/create-billing-program-reporting-details-android';
import Events from './events';
+import Webhooks from './webhooks';
+import KitBackend from './kit-backend';
import EventsPurchaseUpdatedListener from './events/purchase-updated-listener';
import EventsPurchaseErrorListener from './events/purchase-error-listener';
import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener';
@@ -1115,6 +1117,8 @@ function Docs() {
element={}
/>
} />
+ } />
+ } />
}
diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
new file mode 100644
index 00000000..bafa0ca8
--- /dev/null
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -0,0 +1,230 @@
+import AnchorLink from '../../components/AnchorLink';
+import CodeBlock from '../../components/CodeBlock';
+import LanguageTabs from '../../components/LanguageTabs';
+import SEO from '../../components/SEO';
+import { useScrollToHash } from '../../hooks/useScrollToHash';
+
+function KitBackend() {
+ useScrollToHash();
+
+ return (
+
+
+
kit backend
+
+ kit (kit.openiap.dev) is the hosted backend you can drop in
+ instead of running your own server. It handles every step that comes
+ after a user taps "buy" — receipt validation, lifecycle webhooks,
+ subscription state, revenue metrics, and App Store Connect / Play
+ Console product sync — and exposes everything through one URL surface
+ that all five SDKs and an MCP server speak.
+
+
+
+
+ Surface map
+
+
+ Every endpoint takes the project's API key as a path segment so the
+ same URL works in App Store Connect, Pub/Sub push subscribers, mobile
+ WebViews, and stdio MCP tools without juggling bearer tokens.
+
+
+
+ POST /v1/purchase/verify — receipt validation (Apple
+ JWS, Google purchaseToken, Meta Horizon).
+
+
+ POST /v1/webhooks/apple/{apiKey} — App Store
+ Server Notifications v2 receiver.
+
+
+ POST /v1/webhooks/google/{apiKey} — Google
+ Pub/Sub RTDN receiver (OIDC verified).
+
+
+ GET /v1/webhooks/stream/{apiKey} — SSE stream
+ of normalized WebhookEvents, driven by Convex's
+ reactive subscribe.
+
+
+ GET /v1/subscriptions/status/{apiKey}?userId={' '}
+ — fast entitlement gate.
+
+
+
+ GET /v1/subscriptions/entitlements/{apiKey}?userId=
+ {' '}
+ — every active productId for a user.
+
+
+ GET /v1/subscriptions/list/{apiKey} —
+ filtered subscription list (for the dashboard).
+
+
+ GET /v1/subscriptions/metrics/{apiKey} — MRR,
+ churn, refund counts.
+
+
+ POST /v1/subscriptions/bind-user/{apiKey} —
+ attach a userId to a verified purchase.
+
+
+ POST /v1/products/{apiKey}/sync/{ios|android}
+ {' '}
+ — push-sync with App Store Connect / Play Console.
+
+
+
+
+
+
+ Dashboard UX
+
+
+ The hosted dashboard at kit.openiap.dev wires every
+ project-scoped endpoint into a UI:
+
+
+
+ Subscriptions — live state filtered by{' '}
+ Active / InGracePeriod /{' '}
+ InBillingRetry / Expired / etc., with the
+ metrics summary at the top.
+
+
+ Products — kit-side catalog with one-click sync to
+ App Store Connect (via the project's uploaded .p8 key)
+ or Play Console (via the service-account JSON).
+
+
+ Webhooks — copyable Apple ASN v2 / Google RTDN
+ endpoints, the SSE stream URL, and a curl recipe for emitting a
+ synthetic test notification without going through the App Store /
+ Play Console.
+
+
+
+
+
+
+ Entitlement check from a client
+
+
+ The fastest gate ("is this user paying?") is one HTTP request. Each
+ SDK ships a typed wrapper so you don't construct URLs by hand:
+
+ kit's products table is a cache of every productId your
+ app uses. The sync action runs against App Store Connect (using a
+ freshly-minted ES256 JWT signed with the project's .p8)
+ and Play Developer API (using the project's service account JSON) and
+ supports three directions:
+
+
+
+ pull — pull every IAP / subscription from the
+ upstream store into kit.
+
+
+ push — push every state: "Draft" kit
+ row to the upstream store.
+
+
+ both — default; pull then push so the catalog
+ converges.
+
+
+
+ The{' '}
+
+ POST /v1/products/{apiKey}/sync/{ios|android}
+ {' '}
+ endpoint returns the count of pulled / pushed rows plus per-product
+ failure messages so the dashboard surfaces upstream rejections
+ (price-tier conflicts, locale issues, missing review notes) without
+ dropping silent failures.
+
+
+
+
+
+ MCP server
+
+
+ @hyodotdev/openiap-mcp-server is a stdio Model Context
+ Protocol server with 10 tools covering setup, status checks,
+ troubleshooting, product CRUD, subscription listing, sandbox
+ simulation, and full-state inspection. Plug it into Claude Desktop /
+ Cursor / Codex via:
+
+ Every tool funnels through the same kit HTTP surface as the dashboard
+ and the SDKs, so an LLM action ("disable this product on Android") and
+ a manual edit produce identical state changes.
+
+
+
+ );
+}
+
+export default KitBackend;
diff --git a/packages/docs/src/pages/docs/lifecycle/subscription.tsx b/packages/docs/src/pages/docs/lifecycle/subscription.tsx
index 57796d2c..010af7fb 100644
--- a/packages/docs/src/pages/docs/lifecycle/subscription.tsx
+++ b/packages/docs/src/pages/docs/lifecycle/subscription.tsx
@@ -24,6 +24,42 @@ function Subscription() {
differently, especially when it comes to renewal information.
+
Platform Comparison
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index 9d847974..efbbfd55 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -143,7 +143,7 @@ function Releases() {
rel="noopener noreferrer"
className="external-link"
>
- PR #105
+ PR #105 (https://github.com/hyodotdev/openiap/pull/105)
{' '}
for the full diff.
@@ -1562,7 +1562,7 @@ product.priceFormatStyle.locale.currencyCode`}
target="_blank"
rel="noopener noreferrer"
>
- PR #80
+ PR #80 (https://github.com/hyodotdev/openiap/pull/80)
diff --git a/packages/docs/src/pages/docs/webhooks.tsx b/packages/docs/src/pages/docs/webhooks.tsx
new file mode 100644
index 00000000..4a60e3e0
--- /dev/null
+++ b/packages/docs/src/pages/docs/webhooks.tsx
@@ -0,0 +1,299 @@
+import AnchorLink from '../../components/AnchorLink';
+import CodeBlock from '../../components/CodeBlock';
+import LanguageTabs from '../../components/LanguageTabs';
+import SEO from '../../components/SEO';
+import { useScrollToHash } from '../../hooks/useScrollToHash';
+
+function Webhooks() {
+ useScrollToHash();
+
+ return (
+
+
+
Webhooks
+
+ OpenIAP normalizes Apple{' '}
+
+ App Store Server Notifications v2
+ {' '}
+ and Google{' '}
+
+ Real-Time Developer Notifications
+ {' '}
+ into a single cross-store event stream and pushes them straight to your
+ client SDK over Server-Sent Events. Apps can react to renewals,
+ billing-retry, refunds, and revokes without operating any backend of
+ their own.
+
+
+
+
+ Architecture
+
+
+ The kit service hosted at https://kit.openiap.dev is
+ registered as the webhook endpoint with Apple and Google. It verifies
+ each notification's signature, normalizes the payload into the spec's{' '}
+ WebhookEvent shape, dedups on the source notification id,
+ and stores the result for at least 30 days. Authenticated SDK clients
+ connect to GET /v1/webhooks/stream/{apiKey} and
+ receive new events as Server-Sent Events along with reconnect support
+ via the Last-Event-ID header.
+
+ Open the kit dashboard's Webhooks tab and copy the
+ single POST /v1/webhooks/{apiKey} URL. Paste it
+ into both store consoles below — kit auto-detects the payload shape
+ (Apple ASN v2 vs Google Pub/Sub) and dispatches to the right verifier,
+ so one URL covers both stores.
+
+ Sidebar → App Information. Scroll to{' '}
+ App Store Server Notifications.
+
+
+ Set Version to Version 2. Paste the
+ kit URL into both Production Server URL and{' '}
+ Sandbox Server URL.
+
+
+ Save, then click Send Test Notification. A{' '}
+ TestNotification event should appear in the Webhooks
+ tab within seconds.
+
+
+
Google — Real-Time Developer Notifications
+
+
+
+ Google Cloud Console
+ {' '}
+ → select the project linked to your Play Console app →{' '}
+ Pub/Sub → Topics → Create topic (e.g.{' '}
+ play-rtdn).
+
+
+ On that topic → Subscriptions → Create subscription
+ . Delivery type Push; Endpoint URL{' '}
+ = the kit URL. Enable Authentication with a service
+ account that has the{' '}
+ roles/iam.serviceAccountTokenCreator role on itself,
+ and set the OIDC Audience to your kit deployment
+ origin.
+
+
+ Grant roles/pubsub.publisher on the topic to{' '}
+
+ google-play-developer-notifications@system.gserviceaccount.com
+
+ .
+
+
+
+ Play Console
+ {' '}
+ → your app → Monetization setup →{' '}
+ Real-time developer notifications. Paste the topic
+ name (projects/<gcp-project>/topics/play-rtdn) →{' '}
+ Send test notification.
+
+
+
+ Tip: the lifecycle webhook URL is{' '}
+ POST-only. Opening it in a browser shows a blank /
+ 404 page — that's expected. Use the dashboard's Live test{' '}
+ curl recipe (or App Store Connect / Pub/Sub's "Send test notification"
+ buttons) to verify wiring.
+
+
+
+
+
+ Consuming the SSE stream
+
+
+ The second URL —{' '}
+ GET /v1/webhooks/stream/{apiKey} — is a
+ long-lived text/event-stream response, not an HTML page.
+ Open it in a browser and you'll see a blank tab; that's correct
+ behavior because the response never closes and only emits
+ comment-style keepalive frames (:keepalive\n\n) until a
+ real WebhookEvent arrives. To actually consume it use one
+ of the SDK helpers below or call it directly with{' '}
+ EventSource / curl -N.
+
+
+
+
+
+ Event shape
+
+
+ Each event delivered over the SSE stream conforms to the GraphQL{' '}
+ WebhookEvent type defined in{' '}
+ packages/gql/src/webhook.graphql. The unified event types
+ are:
+
+ The id field is the stable per-notification identifier (
+ notificationUUID on Apple, messageId on
+ Google) — use it for application-level idempotency. The full source ↔
+ openiap mapping table lives at{' '}
+ knowledge/external/webhook-mapping.md.
+
+
+
+
+
+ Usage
+
+
+ {{
+ typescript: (
+ {`// react-native-iap (and expo-iap) ship a useWebhookEvents hook.
+import { useWebhookEvents } from 'react-native-iap';
+// React Native does not ship a global EventSource; pass one in.
+import EventSource from 'react-native-sse';
+
+const { events, lastError, isConnected } = useWebhookEvents({
+ apiKey: process.env.OPENIAP_API_KEY!,
+ // baseUrl defaults to https://kit.openiap.dev
+ eventSourceFactory: (url) => new EventSource(url),
+ onEvent: (event) => {
+ if (event.type === 'SubscriptionRenewed') {
+ grantEntitlement(event.purchaseToken);
+ }
+ },
+});`}
+ ),
+ dart: (
+ {`import 'package:flutter_inapp_purchase/webhook_client.dart';
+
+final listener = connectWebhookStream(apiKey: 'sk_live_...');
+listener.events.listen((event) {
+ if (event.type == WebhookEventType.SubscriptionRenewed) {
+ grantEntitlement(event.purchaseToken);
+ }
+});`}
+ ),
+ kotlin: (
+ {`import io.github.hyochan.kmpiap.openiap.WebhookEventParser
+import io.github.hyochan.kmpiap.openiap.webhookStreamUrl
+
+// Pure parser + types live in commonMain. Wire your platform's HTTP
+// client to webhookStreamUrl(apiKey = "...") and feed each SSE
+// data frame to WebhookEventParser.parse().
+val event = WebhookEventParser.parse(rawJson) ?: return
+when (event.type) {
+ WebhookEventType.SubscriptionRenewed -> grantEntitlement(event.purchaseToken)
+ else -> Unit
+}`}
+ ),
+ gdscript: (
+ {`extends Node
+
+@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new()
+
+func _ready() -> void:
+ webhook.api_key = "sk_live_..."
+ webhook.event_received.connect(_on_event)
+ add_child(webhook)
+ webhook.connect_stream()
+
+func _on_event(event: Dictionary) -> void:
+ if event["type"] == "SubscriptionRenewed":
+ grant_entitlement(event["purchaseToken"])`}
+ ),
+ }}
+
+
+
+
+
+ Reconnect and replay
+
+
+ The SSE stream auto-reconnects on transport errors. The standard{' '}
+ Last-Event-ID header is honored — kit looks up the named
+ event's receivedAt and resumes from there, so events that
+ fired while the connection was closed are delivered in order on the
+ next connect.
+
+
+ For long-offline reconciliation, call the{' '}
+ webhookEventsSince Convex query directly with a
+ checkpoint timestamp; it returns up to 500 events at a time, capped at
+ the 30-day retention window.
+
+
+
+ );
+}
+
+export default Webhooks;
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
index 9697826f..de782cf0 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
@@ -962,6 +962,265 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) {
fun toJson(): String = rawValue
}
+public enum class SubscriptionState(val rawValue: String) {
+ Active("active"),
+ InGracePeriod("in-grace-period"),
+ InBillingRetry("in-billing-retry"),
+ Expired("expired"),
+ Revoked("revoked"),
+ Refunded("refunded"),
+ Paused("paused"),
+ Unknown("unknown");
+
+ companion object {
+ fun fromJson(value: String): SubscriptionState = when (value) {
+ "active" -> SubscriptionState.Active
+ "Active" -> SubscriptionState.Active
+ "in-grace-period" -> SubscriptionState.InGracePeriod
+ "InGracePeriod" -> SubscriptionState.InGracePeriod
+ "in-billing-retry" -> SubscriptionState.InBillingRetry
+ "InBillingRetry" -> SubscriptionState.InBillingRetry
+ "expired" -> SubscriptionState.Expired
+ "Expired" -> SubscriptionState.Expired
+ "revoked" -> SubscriptionState.Revoked
+ "Revoked" -> SubscriptionState.Revoked
+ "refunded" -> SubscriptionState.Refunded
+ "Refunded" -> SubscriptionState.Refunded
+ "paused" -> SubscriptionState.Paused
+ "Paused" -> SubscriptionState.Paused
+ "unknown" -> SubscriptionState.Unknown
+ "Unknown" -> SubscriptionState.Unknown
+ else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookCancellationReason(val rawValue: String) {
+ UserCanceled("user-canceled"),
+ BillingError("billing-error"),
+ PriceIncreaseDeclined("price-increase-declined"),
+ ProductUnavailable("product-unavailable"),
+ Refunded("refunded"),
+ Other("other");
+
+ companion object {
+ fun fromJson(value: String): WebhookCancellationReason = when (value) {
+ "user-canceled" -> WebhookCancellationReason.UserCanceled
+ "USER_CANCELED" -> WebhookCancellationReason.UserCanceled
+ "UserCanceled" -> WebhookCancellationReason.UserCanceled
+ "billing-error" -> WebhookCancellationReason.BillingError
+ "BILLING_ERROR" -> WebhookCancellationReason.BillingError
+ "BillingError" -> WebhookCancellationReason.BillingError
+ "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "product-unavailable" -> WebhookCancellationReason.ProductUnavailable
+ "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable
+ "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable
+ "refunded" -> WebhookCancellationReason.Refunded
+ "REFUNDED" -> WebhookCancellationReason.Refunded
+ "Refunded" -> WebhookCancellationReason.Refunded
+ "other" -> WebhookCancellationReason.Other
+ "OTHER" -> WebhookCancellationReason.Other
+ "Other" -> WebhookCancellationReason.Other
+ else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventEnvironment(val rawValue: String) {
+ Production("production"),
+ Sandbox("sandbox"),
+ Xcode("xcode");
+
+ companion object {
+ fun fromJson(value: String): WebhookEventEnvironment = when (value) {
+ "production" -> WebhookEventEnvironment.Production
+ "Production" -> WebhookEventEnvironment.Production
+ "sandbox" -> WebhookEventEnvironment.Sandbox
+ "Sandbox" -> WebhookEventEnvironment.Sandbox
+ "xcode" -> WebhookEventEnvironment.Xcode
+ "Xcode" -> WebhookEventEnvironment.Xcode
+ else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventSource(val rawValue: String) {
+ AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"),
+ GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"),
+ /**
+ * Synthetic source for Meta Horizon Store. Meta has no webhook /
+ * push notification system so kit polls `verify_entitlement` on a
+ * cron and emits these synthetic events when an entitlement
+ * transitions. SDK consumers see them on the SSE stream alongside
+ * real Apple / Google webhooks.
+ */
+ MetaHorizonReconciler("meta-horizon-reconciler");
+
+ companion object {
+ fun fromJson(value: String): WebhookEventSource = when (value) {
+ "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2
+ "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2
+ "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications
+ "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications
+ "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler
+ "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler
+ else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventType(val rawValue: String) {
+ /**
+ * Initial purchase or first conversion from a free trial / intro offer.
+ * iOS: SUBSCRIBED (initialBuy / resubscribe).
+ * Android: SUBSCRIPTION_PURCHASED.
+ */
+ SubscriptionStarted("subscription-started"),
+ /**
+ * Auto-renewal succeeded for an existing subscription.
+ * iOS: DID_RENEW.
+ * Android: SUBSCRIPTION_RENEWED.
+ */
+ SubscriptionRenewed("subscription-renewed"),
+ /**
+ * Subscription reached its expiration without a successful renewal.
+ * iOS: EXPIRED.
+ * Android: SUBSCRIPTION_EXPIRED.
+ */
+ SubscriptionExpired("subscription-expired"),
+ /**
+ * Billing failed; the subscription is in a grace period during which the user
+ * retains entitlement while payment is retried.
+ * iOS: DID_FAIL_TO_RENEW (with grace period active).
+ * Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ */
+ SubscriptionInGracePeriod("subscription-in-grace-period"),
+ /**
+ * Billing failed and the subscription is in account-hold / billing retry,
+ * during which entitlement is paused but the subscription is not yet expired.
+ * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry).
+ * Android: SUBSCRIPTION_ON_HOLD.
+ */
+ SubscriptionInBillingRetry("subscription-in-billing-retry"),
+ /**
+ * Subscription returned to active state after a billing issue or pause.
+ * iOS: DID_RECOVER.
+ * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto-
+ * renew re-enabled (Uncanceled), not billing recovery.
+ */
+ SubscriptionRecovered("subscription-recovered"),
+ /**
+ * User turned off auto-renew. Access continues until the current period ends.
+ * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
+ * Android: SUBSCRIPTION_CANCELED.
+ */
+ SubscriptionCanceled("subscription-canceled"),
+ /**
+ * User reactivated auto-renew before the subscription expired.
+ * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
+ * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ */
+ SubscriptionUncanceled("subscription-uncanceled"),
+ /**
+ * Access immediately revoked (family sharing removal, admin action, fraud).
+ * iOS: REVOKE.
+ * Android: SUBSCRIPTION_REVOKED.
+ */
+ SubscriptionRevoked("subscription-revoked"),
+ /**
+ * A price change is pending or has been confirmed by the user.
+ * iOS: PRICE_INCREASE.
+ * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ */
+ SubscriptionPriceChange("subscription-price-change"),
+ /**
+ * User upgraded, downgraded, or crossgraded their plan.
+ * iOS: DID_CHANGE_RENEWAL_PREF.
+ * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ */
+ SubscriptionProductChanged("subscription-product-changed"),
+ /**
+ * Subscription paused (Android only feature). Also fired when the
+ * pause schedule is changed — RTDN does not have a separate signal.
+ * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ */
+ SubscriptionPaused("subscription-paused"),
+ /**
+ * Paused subscription resumed (Android only feature). RTDN signals
+ * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle
+ * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the
+ * resume.
+ * Android: SUBSCRIPTION_RECOVERED (after pause).
+ */
+ SubscriptionResumed("subscription-resumed"),
+ /**
+ * Refund issued for a one-time purchase or subscription period.
+ * iOS: REFUND.
+ * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ */
+ PurchaseRefunded("purchase-refunded"),
+ /**
+ * iOS-only: App Store requests a consumption status report for a refund decision.
+ * Servers should respond via the StoreKit consumption API.
+ */
+ PurchaseConsumptionRequest("purchase-consumption-request"),
+ /**
+ * Sandbox or test notification fired by the store for diagnostic purposes.
+ * Useful for verifying webhook plumbing without a live transaction.
+ */
+ TestNotification("test-notification");
+
+ companion object {
+ fun fromJson(value: String): WebhookEventType = when (value) {
+ "subscription-started" -> WebhookEventType.SubscriptionStarted
+ "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted
+ "subscription-renewed" -> WebhookEventType.SubscriptionRenewed
+ "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed
+ "subscription-expired" -> WebhookEventType.SubscriptionExpired
+ "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired
+ "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod
+ "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod
+ "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry
+ "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry
+ "subscription-recovered" -> WebhookEventType.SubscriptionRecovered
+ "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered
+ "subscription-canceled" -> WebhookEventType.SubscriptionCanceled
+ "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled
+ "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled
+ "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled
+ "subscription-revoked" -> WebhookEventType.SubscriptionRevoked
+ "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked
+ "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange
+ "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange
+ "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged
+ "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged
+ "subscription-paused" -> WebhookEventType.SubscriptionPaused
+ "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused
+ "subscription-resumed" -> WebhookEventType.SubscriptionResumed
+ "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed
+ "purchase-refunded" -> WebhookEventType.PurchaseRefunded
+ "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded
+ "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest
+ "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest
+ "test-notification" -> WebhookEventType.TestNotification
+ "TestNotification" -> WebhookEventType.TestNotification
+ else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
// MARK: - Interfaces
public interface ProductCommon {
@@ -3587,6 +3846,121 @@ public data class VerifyPurchaseWithProviderResult(
public typealias VoidResult = Unit
+public data class WebhookEvent(
+ /**
+ * Reason for cancellation, when applicable.
+ */
+ val cancellationReason: WebhookCancellationReason? = null,
+ /**
+ * Localized currency code (ISO 4217) at event time, when available.
+ */
+ val currency: String? = null,
+ val environment: WebhookEventEnvironment,
+ /**
+ * When the current subscription period ends. Epoch milliseconds.
+ */
+ val expiresAt: Double? = null,
+ /**
+ * Stable identifier suitable for idempotency. Derived from the source notification
+ * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ * otherwise hashed from the canonicalized payload.
+ */
+ val id: String,
+ /**
+ * Time the underlying event occurred at the store. Epoch milliseconds.
+ */
+ val occurredAt: Double,
+ val platform: IapPlatform,
+ /**
+ * Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ * Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ */
+ val priceAmountMicros: Double? = null,
+ /**
+ * Product the event pertains to. May be null for account-level events.
+ */
+ val productId: String? = null,
+ /**
+ * kit project that owns the subscription / purchase this event refers to.
+ */
+ val projectId: String,
+ /**
+ * Cross-platform purchase identity used to correlate this event with an existing
+ * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ * payloads carry no transaction); always present for every other event type.
+ */
+ val purchaseToken: String? = null,
+ /**
+ * Original signed payload from the store. ASN v2 events expose the JWS string;
+ * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ * consumers can independently verify or extract platform-specific fields. kit
+ * always validates this payload before emitting the event.
+ */
+ val rawSignedPayload: String? = null,
+ /**
+ * Time kit ingested and normalized this event. Epoch milliseconds.
+ */
+ val receivedAt: Double,
+ /**
+ * When auto-renewal will charge again. Epoch milliseconds.
+ */
+ val renewsAt: Double? = null,
+ val source: WebhookEventSource,
+ /**
+ * Normalized subscription state at the time of event, when the event refers to
+ * a subscription. Null for one-time purchase events.
+ */
+ val subscriptionState: SubscriptionState? = null,
+ val type: WebhookEventType
+) {
+
+ companion object {
+ fun fromJson(json: Map): WebhookEvent {
+ return WebhookEvent(
+ cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) },
+ currency = json["currency"] as? String,
+ environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
+ expiresAt = (json["expiresAt"] as? Number)?.toDouble(),
+ id = json["id"] as? String ?: "",
+ occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
+ platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
+ priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(),
+ productId = json["productId"] as? String,
+ projectId = json["projectId"] as? String ?: "",
+ purchaseToken = json["purchaseToken"] as? String,
+ rawSignedPayload = json["rawSignedPayload"] as? String,
+ receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
+ renewsAt = (json["renewsAt"] as? Number)?.toDouble(),
+ source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2,
+ subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) },
+ type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "WebhookEvent",
+ "cancellationReason" to cancellationReason?.toJson(),
+ "currency" to currency,
+ "environment" to environment.toJson(),
+ "expiresAt" to expiresAt,
+ "id" to id,
+ "occurredAt" to occurredAt,
+ "platform" to platform.toJson(),
+ "priceAmountMicros" to priceAmountMicros,
+ "productId" to productId,
+ "projectId" to projectId,
+ "purchaseToken" to purchaseToken,
+ "rawSignedPayload" to rawSignedPayload,
+ "receivedAt" to receivedAt,
+ "renewsAt" to renewsAt,
+ "source" to source.toJson(),
+ "subscriptionState" to subscriptionState?.toJson(),
+ "type" to type.toJson(),
+ )
+}
+
// MARK: - Input Objects
public data class AndroidSubscriptionOfferInput(
diff --git a/packages/gql/codegen.ts b/packages/gql/codegen.ts
index 9ec107cf..6bc8cb7e 100644
--- a/packages/gql/codegen.ts
+++ b/packages/gql/codegen.ts
@@ -11,6 +11,7 @@ const config: CodegenConfig = {
'src/api-android.graphql',
'src/error.graphql',
'src/event.graphql',
+ 'src/webhook.graphql',
],
generates: {
'src/generated/types.ts': {
diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts
index 86e9a5ec..48c64433 100644
--- a/packages/gql/codegen/core/parser.ts
+++ b/packages/gql/codegen/core/parser.ts
@@ -33,6 +33,7 @@ const DEFAULT_SCHEMA_PATHS = [
'../src/api-android.graphql',
'../src/error.graphql',
'../src/event.graphql',
+ '../src/webhook.graphql',
];
// ============================================================================
diff --git a/packages/gql/package.json b/packages/gql/package.json
index 27fff14f..7dd62db6 100644
--- a/packages/gql/package.json
+++ b/packages/gql/package.json
@@ -5,6 +5,7 @@
"main": "src/generated/types.ts",
"exports": {
".": "./src/generated/types.ts",
+ "./webhook-client": "./src/webhook-client.ts",
"./swift": "./src/generated/Types.swift",
"./kotlin": "./src/generated/Types.kt",
"./dart": "./src/generated/types.dart",
diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs
index 77cfafc5..adafa4ba 100644
--- a/packages/gql/scripts/fix-generated-types.mjs
+++ b/packages/gql/scripts/fix-generated-types.mjs
@@ -10,6 +10,11 @@ const schemaFiles = [
resolve(__dirname, '../src/api.graphql'),
resolve(__dirname, '../src/api-ios.graphql'),
resolve(__dirname, '../src/api-android.graphql'),
+ // webhook.graphql adds `webhookEventsSince` to the Query interface
+ // and marks it `# Future` so it gets the Promise<> wrap that all
+ // async query fields require. Without this entry, the marker would
+ // be silently ignored — caught in PR #123 (https://github.com/hyodotdev/openiap/pull/123) review.
+ resolve(__dirname, '../src/webhook.graphql'),
];
const schemaDefinitionFiles = [
'../src/schema.graphql',
diff --git a/packages/gql/scripts/sync-to-platforms.mjs b/packages/gql/scripts/sync-to-platforms.mjs
index 4fd1b3bc..5f76ed84 100755
--- a/packages/gql/scripts/sync-to-platforms.mjs
+++ b/packages/gql/scripts/sync-to-platforms.mjs
@@ -43,6 +43,30 @@ const tsSource = resolve(gqlRoot, 'src/generated/types.ts');
const rnTsTarget = resolve(monorepoRoot, 'libraries/react-native-iap/src/types.ts');
const expoTsTarget = resolve(monorepoRoot, 'libraries/expo-iap/src/types.ts');
+// `webhook-client.ts` is a hand-maintained runtime helper rather than
+// generated output, but it lives in `packages/gql` so RN and Expo can
+// share a single canonical implementation. Sync alongside the types so
+// the two never drift.
+const webhookClientSource = resolve(gqlRoot, 'src/webhook-client.ts');
+const rnWebhookClientTarget = resolve(
+ monorepoRoot,
+ 'libraries/react-native-iap/src/webhook-client.ts',
+);
+const expoWebhookClientTarget = resolve(
+ monorepoRoot,
+ 'libraries/expo-iap/src/webhook-client.ts',
+);
+
+const kitApiSource = resolve(gqlRoot, 'src/kit-api.ts');
+const rnKitApiTarget = resolve(
+ monorepoRoot,
+ 'libraries/react-native-iap/src/kit-api.ts',
+);
+const expoKitApiTarget = resolve(
+ monorepoRoot,
+ 'libraries/expo-iap/src/kit-api.ts',
+);
+
const kmpSource = resolve(gqlRoot, 'src/generated/Types.kt');
const kmpTarget = resolve(
monorepoRoot,
@@ -114,6 +138,29 @@ if (existsSync(tsSource)) {
console.log(` ${expoTsTarget}\n`);
}
+// Sync the webhook client to react-native-iap + expo-iap. Doing this
+// during type-sync means the per-library copies can never silently
+// drift from the canonical implementation in `packages/gql`.
+if (existsSync(webhookClientSource)) {
+ for (const target of [rnWebhookClientTarget, expoWebhookClientTarget]) {
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(webhookClientSource, target);
+ }
+ console.log('✅ webhook-client → react-native-iap + expo-iap');
+ console.log(` ${rnWebhookClientTarget}`);
+ console.log(` ${expoWebhookClientTarget}\n`);
+}
+
+if (existsSync(kitApiSource)) {
+ for (const target of [rnKitApiTarget, expoKitApiTarget]) {
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(kitApiSource, target);
+ }
+ console.log('✅ kit-api → react-native-iap + expo-iap');
+ console.log(` ${rnKitApiTarget}`);
+ console.log(` ${expoKitApiTarget}\n`);
+}
+
// Sync Kotlin to kmp-iap with the library-specific package declaration and
// the enum-companion semicolon that Kotlin requires. This mirrors the
// post-process that packages/google runs; without it the KMP module would
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index 75ffa755..0d073c5d 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -1051,6 +1051,295 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) {
fun toJson(): String = rawValue
}
+public enum class SubscriptionState(val rawValue: String) {
+ Active("active"),
+ InGracePeriod("in-grace-period"),
+ InBillingRetry("in-billing-retry"),
+ Expired("expired"),
+ Revoked("revoked"),
+ Refunded("refunded"),
+ Paused("paused"),
+ Unknown("unknown")
+
+ companion object {
+ fun fromJson(value: String): SubscriptionState = when (value) {
+ "active" -> SubscriptionState.Active
+ "ACTIVE" -> SubscriptionState.Active
+ "Active" -> SubscriptionState.Active
+ "in-grace-period" -> SubscriptionState.InGracePeriod
+ "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod
+ "InGracePeriod" -> SubscriptionState.InGracePeriod
+ "in-billing-retry" -> SubscriptionState.InBillingRetry
+ "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry
+ "InBillingRetry" -> SubscriptionState.InBillingRetry
+ "expired" -> SubscriptionState.Expired
+ "EXPIRED" -> SubscriptionState.Expired
+ "Expired" -> SubscriptionState.Expired
+ "revoked" -> SubscriptionState.Revoked
+ "REVOKED" -> SubscriptionState.Revoked
+ "Revoked" -> SubscriptionState.Revoked
+ "refunded" -> SubscriptionState.Refunded
+ "REFUNDED" -> SubscriptionState.Refunded
+ "Refunded" -> SubscriptionState.Refunded
+ "paused" -> SubscriptionState.Paused
+ "PAUSED" -> SubscriptionState.Paused
+ "Paused" -> SubscriptionState.Paused
+ "unknown" -> SubscriptionState.Unknown
+ "UNKNOWN" -> SubscriptionState.Unknown
+ "Unknown" -> SubscriptionState.Unknown
+ else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookCancellationReason(val rawValue: String) {
+ UserCanceled("user-canceled"),
+ BillingError("billing-error"),
+ PriceIncreaseDeclined("price-increase-declined"),
+ ProductUnavailable("product-unavailable"),
+ Refunded("refunded"),
+ Other("other")
+
+ companion object {
+ fun fromJson(value: String): WebhookCancellationReason = when (value) {
+ "user-canceled" -> WebhookCancellationReason.UserCanceled
+ "USER_CANCELED" -> WebhookCancellationReason.UserCanceled
+ "UserCanceled" -> WebhookCancellationReason.UserCanceled
+ "billing-error" -> WebhookCancellationReason.BillingError
+ "BILLING_ERROR" -> WebhookCancellationReason.BillingError
+ "BillingError" -> WebhookCancellationReason.BillingError
+ "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined
+ "product-unavailable" -> WebhookCancellationReason.ProductUnavailable
+ "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable
+ "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable
+ "refunded" -> WebhookCancellationReason.Refunded
+ "REFUNDED" -> WebhookCancellationReason.Refunded
+ "Refunded" -> WebhookCancellationReason.Refunded
+ "other" -> WebhookCancellationReason.Other
+ "OTHER" -> WebhookCancellationReason.Other
+ "Other" -> WebhookCancellationReason.Other
+ else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventEnvironment(val rawValue: String) {
+ Production("production"),
+ Sandbox("sandbox"),
+ Xcode("xcode")
+
+ companion object {
+ fun fromJson(value: String): WebhookEventEnvironment = when (value) {
+ "production" -> WebhookEventEnvironment.Production
+ "PRODUCTION" -> WebhookEventEnvironment.Production
+ "Production" -> WebhookEventEnvironment.Production
+ "sandbox" -> WebhookEventEnvironment.Sandbox
+ "SANDBOX" -> WebhookEventEnvironment.Sandbox
+ "Sandbox" -> WebhookEventEnvironment.Sandbox
+ "xcode" -> WebhookEventEnvironment.Xcode
+ "XCODE" -> WebhookEventEnvironment.Xcode
+ "Xcode" -> WebhookEventEnvironment.Xcode
+ else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventSource(val rawValue: String) {
+ AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"),
+ GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"),
+ /**
+ * Synthetic source for Meta Horizon Store. Meta has no webhook /
+ * push notification system so kit polls `verify_entitlement` on a
+ * cron and emits these synthetic events when an entitlement
+ * transitions. SDK consumers see them on the SSE stream alongside
+ * real Apple / Google webhooks.
+ */
+ MetaHorizonReconciler("meta-horizon-reconciler")
+
+ companion object {
+ fun fromJson(value: String): WebhookEventSource = when (value) {
+ "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2
+ "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2
+ "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2
+ "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications
+ "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications
+ "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications
+ "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler
+ "META_HORIZON_RECONCILER" -> WebhookEventSource.MetaHorizonReconciler
+ "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler
+ else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
+public enum class WebhookEventType(val rawValue: String) {
+ /**
+ * Initial purchase or first conversion from a free trial / intro offer.
+ * iOS: SUBSCRIBED (initialBuy / resubscribe).
+ * Android: SUBSCRIPTION_PURCHASED.
+ */
+ SubscriptionStarted("subscription-started"),
+ /**
+ * Auto-renewal succeeded for an existing subscription.
+ * iOS: DID_RENEW.
+ * Android: SUBSCRIPTION_RENEWED.
+ */
+ SubscriptionRenewed("subscription-renewed"),
+ /**
+ * Subscription reached its expiration without a successful renewal.
+ * iOS: EXPIRED.
+ * Android: SUBSCRIPTION_EXPIRED.
+ */
+ SubscriptionExpired("subscription-expired"),
+ /**
+ * Billing failed; the subscription is in a grace period during which the user
+ * retains entitlement while payment is retried.
+ * iOS: DID_FAIL_TO_RENEW (with grace period active).
+ * Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ */
+ SubscriptionInGracePeriod("subscription-in-grace-period"),
+ /**
+ * Billing failed and the subscription is in account-hold / billing retry,
+ * during which entitlement is paused but the subscription is not yet expired.
+ * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry).
+ * Android: SUBSCRIPTION_ON_HOLD.
+ */
+ SubscriptionInBillingRetry("subscription-in-billing-retry"),
+ /**
+ * Subscription returned to active state after a billing issue or pause.
+ * iOS: DID_RECOVER.
+ * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto-
+ * renew re-enabled (Uncanceled), not billing recovery.
+ */
+ SubscriptionRecovered("subscription-recovered"),
+ /**
+ * User turned off auto-renew. Access continues until the current period ends.
+ * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
+ * Android: SUBSCRIPTION_CANCELED.
+ */
+ SubscriptionCanceled("subscription-canceled"),
+ /**
+ * User reactivated auto-renew before the subscription expired.
+ * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
+ * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ */
+ SubscriptionUncanceled("subscription-uncanceled"),
+ /**
+ * Access immediately revoked (family sharing removal, admin action, fraud).
+ * iOS: REVOKE.
+ * Android: SUBSCRIPTION_REVOKED.
+ */
+ SubscriptionRevoked("subscription-revoked"),
+ /**
+ * A price change is pending or has been confirmed by the user.
+ * iOS: PRICE_INCREASE.
+ * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ */
+ SubscriptionPriceChange("subscription-price-change"),
+ /**
+ * User upgraded, downgraded, or crossgraded their plan.
+ * iOS: DID_CHANGE_RENEWAL_PREF.
+ * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ */
+ SubscriptionProductChanged("subscription-product-changed"),
+ /**
+ * Subscription paused (Android only feature). Also fired when the
+ * pause schedule is changed — RTDN does not have a separate signal.
+ * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ */
+ SubscriptionPaused("subscription-paused"),
+ /**
+ * Paused subscription resumed (Android only feature). RTDN signals
+ * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle
+ * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the
+ * resume.
+ * Android: SUBSCRIPTION_RECOVERED (after pause).
+ */
+ SubscriptionResumed("subscription-resumed"),
+ /**
+ * Refund issued for a one-time purchase or subscription period.
+ * iOS: REFUND.
+ * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ */
+ PurchaseRefunded("purchase-refunded"),
+ /**
+ * iOS-only: App Store requests a consumption status report for a refund decision.
+ * Servers should respond via the StoreKit consumption API.
+ */
+ PurchaseConsumptionRequest("purchase-consumption-request"),
+ /**
+ * Sandbox or test notification fired by the store for diagnostic purposes.
+ * Useful for verifying webhook plumbing without a live transaction.
+ */
+ TestNotification("test-notification")
+
+ companion object {
+ fun fromJson(value: String): WebhookEventType = when (value) {
+ "subscription-started" -> WebhookEventType.SubscriptionStarted
+ "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted
+ "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted
+ "subscription-renewed" -> WebhookEventType.SubscriptionRenewed
+ "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed
+ "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed
+ "subscription-expired" -> WebhookEventType.SubscriptionExpired
+ "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired
+ "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired
+ "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod
+ "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod
+ "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod
+ "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry
+ "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry
+ "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry
+ "subscription-recovered" -> WebhookEventType.SubscriptionRecovered
+ "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered
+ "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered
+ "subscription-canceled" -> WebhookEventType.SubscriptionCanceled
+ "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled
+ "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled
+ "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled
+ "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled
+ "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled
+ "subscription-revoked" -> WebhookEventType.SubscriptionRevoked
+ "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked
+ "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked
+ "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange
+ "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange
+ "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange
+ "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged
+ "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged
+ "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged
+ "subscription-paused" -> WebhookEventType.SubscriptionPaused
+ "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused
+ "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused
+ "subscription-resumed" -> WebhookEventType.SubscriptionResumed
+ "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed
+ "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed
+ "purchase-refunded" -> WebhookEventType.PurchaseRefunded
+ "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded
+ "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded
+ "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest
+ "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest
+ "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest
+ "test-notification" -> WebhookEventType.TestNotification
+ "TEST_NOTIFICATION" -> WebhookEventType.TestNotification
+ "TestNotification" -> WebhookEventType.TestNotification
+ else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
// MARK: - Interfaces
public interface ProductCommon {
@@ -3676,6 +3965,121 @@ public data class VerifyPurchaseWithProviderResult(
public typealias VoidResult = Unit
+public data class WebhookEvent(
+ /**
+ * Reason for cancellation, when applicable.
+ */
+ val cancellationReason: WebhookCancellationReason? = null,
+ /**
+ * Localized currency code (ISO 4217) at event time, when available.
+ */
+ val currency: String? = null,
+ val environment: WebhookEventEnvironment,
+ /**
+ * When the current subscription period ends. Epoch milliseconds.
+ */
+ val expiresAt: Double? = null,
+ /**
+ * Stable identifier suitable for idempotency. Derived from the source notification
+ * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ * otherwise hashed from the canonicalized payload.
+ */
+ val id: String,
+ /**
+ * Time the underlying event occurred at the store. Epoch milliseconds.
+ */
+ val occurredAt: Double,
+ val platform: IapPlatform,
+ /**
+ * Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ * Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ */
+ val priceAmountMicros: Double? = null,
+ /**
+ * Product the event pertains to. May be null for account-level events.
+ */
+ val productId: String? = null,
+ /**
+ * kit project that owns the subscription / purchase this event refers to.
+ */
+ val projectId: String,
+ /**
+ * Cross-platform purchase identity used to correlate this event with an existing
+ * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ * payloads carry no transaction); always present for every other event type.
+ */
+ val purchaseToken: String? = null,
+ /**
+ * Original signed payload from the store. ASN v2 events expose the JWS string;
+ * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ * consumers can independently verify or extract platform-specific fields. kit
+ * always validates this payload before emitting the event.
+ */
+ val rawSignedPayload: String? = null,
+ /**
+ * Time kit ingested and normalized this event. Epoch milliseconds.
+ */
+ val receivedAt: Double,
+ /**
+ * When auto-renewal will charge again. Epoch milliseconds.
+ */
+ val renewsAt: Double? = null,
+ val source: WebhookEventSource,
+ /**
+ * Normalized subscription state at the time of event, when the event refers to
+ * a subscription. Null for one-time purchase events.
+ */
+ val subscriptionState: SubscriptionState? = null,
+ val type: WebhookEventType
+) {
+
+ companion object {
+ fun fromJson(json: Map): WebhookEvent {
+ return WebhookEvent(
+ cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) },
+ currency = json["currency"] as? String,
+ environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
+ expiresAt = (json["expiresAt"] as? Number)?.toDouble(),
+ id = json["id"] as? String ?: "",
+ occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
+ platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
+ priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(),
+ productId = json["productId"] as? String,
+ projectId = json["projectId"] as? String ?: "",
+ purchaseToken = json["purchaseToken"] as? String,
+ rawSignedPayload = json["rawSignedPayload"] as? String,
+ receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
+ renewsAt = (json["renewsAt"] as? Number)?.toDouble(),
+ source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2,
+ subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) },
+ type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "WebhookEvent",
+ "cancellationReason" to cancellationReason?.toJson(),
+ "currency" to currency,
+ "environment" to environment.toJson(),
+ "expiresAt" to expiresAt,
+ "id" to id,
+ "occurredAt" to occurredAt,
+ "platform" to platform.toJson(),
+ "priceAmountMicros" to priceAmountMicros,
+ "productId" to productId,
+ "projectId" to projectId,
+ "purchaseToken" to purchaseToken,
+ "rawSignedPayload" to rawSignedPayload,
+ "receivedAt" to receivedAt,
+ "renewsAt" to renewsAt,
+ "source" to source.toJson(),
+ "subscriptionState" to subscriptionState?.toJson(),
+ "type" to type.toJson(),
+ )
+}
+
// MARK: - Input Objects
public data class AndroidSubscriptionOfferInput(
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index efacb85c..79115757 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -424,6 +424,113 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable {
case keepExisting = "keep-existing"
}
+public enum SubscriptionState: String, Codable, CaseIterable {
+ case active = "active"
+ case inGracePeriod = "in-grace-period"
+ case inBillingRetry = "in-billing-retry"
+ case expired = "expired"
+ case revoked = "revoked"
+ case refunded = "refunded"
+ case paused = "paused"
+ case unknown = "unknown"
+}
+
+public enum WebhookCancellationReason: String, Codable, CaseIterable {
+ case userCanceled = "user-canceled"
+ case billingError = "billing-error"
+ case priceIncreaseDeclined = "price-increase-declined"
+ case productUnavailable = "product-unavailable"
+ case refunded = "refunded"
+ case other = "other"
+}
+
+public enum WebhookEventEnvironment: String, Codable, CaseIterable {
+ case production = "production"
+ case sandbox = "sandbox"
+ case xcode = "xcode"
+}
+
+public enum WebhookEventSource: String, Codable, CaseIterable {
+ case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2"
+ case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications"
+ /// Synthetic source for Meta Horizon Store. Meta has no webhook /
+ /// push notification system so kit polls `verify_entitlement` on a
+ /// cron and emits these synthetic events when an entitlement
+ /// transitions. SDK consumers see them on the SSE stream alongside
+ /// real Apple / Google webhooks.
+ case metaHorizonReconciler = "meta-horizon-reconciler"
+}
+
+public enum WebhookEventType: String, Codable, CaseIterable {
+ /// Initial purchase or first conversion from a free trial / intro offer.
+ /// iOS: SUBSCRIBED (initialBuy / resubscribe).
+ /// Android: SUBSCRIPTION_PURCHASED.
+ case subscriptionStarted = "subscription-started"
+ /// Auto-renewal succeeded for an existing subscription.
+ /// iOS: DID_RENEW.
+ /// Android: SUBSCRIPTION_RENEWED.
+ case subscriptionRenewed = "subscription-renewed"
+ /// Subscription reached its expiration without a successful renewal.
+ /// iOS: EXPIRED.
+ /// Android: SUBSCRIPTION_EXPIRED.
+ case subscriptionExpired = "subscription-expired"
+ /// Billing failed; the subscription is in a grace period during which the user
+ /// retains entitlement while payment is retried.
+ /// iOS: DID_FAIL_TO_RENEW (with grace period active).
+ /// Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ case subscriptionInGracePeriod = "subscription-in-grace-period"
+ /// Billing failed and the subscription is in account-hold / billing retry,
+ /// during which entitlement is paused but the subscription is not yet expired.
+ /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry).
+ /// Android: SUBSCRIPTION_ON_HOLD.
+ case subscriptionInBillingRetry = "subscription-in-billing-retry"
+ /// Subscription returned to active state after a billing issue or pause.
+ /// iOS: DID_RECOVER.
+ /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto-
+ /// renew re-enabled (Uncanceled), not billing recovery.
+ case subscriptionRecovered = "subscription-recovered"
+ /// User turned off auto-renew. Access continues until the current period ends.
+ /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
+ /// Android: SUBSCRIPTION_CANCELED.
+ case subscriptionCanceled = "subscription-canceled"
+ /// User reactivated auto-renew before the subscription expired.
+ /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
+ /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ case subscriptionUncanceled = "subscription-uncanceled"
+ /// Access immediately revoked (family sharing removal, admin action, fraud).
+ /// iOS: REVOKE.
+ /// Android: SUBSCRIPTION_REVOKED.
+ case subscriptionRevoked = "subscription-revoked"
+ /// A price change is pending or has been confirmed by the user.
+ /// iOS: PRICE_INCREASE.
+ /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ case subscriptionPriceChange = "subscription-price-change"
+ /// User upgraded, downgraded, or crossgraded their plan.
+ /// iOS: DID_CHANGE_RENEWAL_PREF.
+ /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ case subscriptionProductChanged = "subscription-product-changed"
+ /// Subscription paused (Android only feature). Also fired when the
+ /// pause schedule is changed — RTDN does not have a separate signal.
+ /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ case subscriptionPaused = "subscription-paused"
+ /// Paused subscription resumed (Android only feature). RTDN signals
+ /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle
+ /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the
+ /// resume.
+ /// Android: SUBSCRIPTION_RECOVERED (after pause).
+ case subscriptionResumed = "subscription-resumed"
+ /// Refund issued for a one-time purchase or subscription period.
+ /// iOS: REFUND.
+ /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ case purchaseRefunded = "purchase-refunded"
+ /// iOS-only: App Store requests a consumption status report for a refund decision.
+ /// Servers should respond via the StoreKit consumption API.
+ case purchaseConsumptionRequest = "purchase-consumption-request"
+ /// Sandbox or test notification fired by the store for diagnostic purposes.
+ /// Useful for verifying webhook plumbing without a live transaction.
+ case testNotification = "test-notification"
+}
+
// MARK: - Interfaces
public protocol ProductCommon: Codable {
@@ -1320,6 +1427,49 @@ public struct VerifyPurchaseWithProviderResult: Codable {
public typealias VoidResult = Void
+public struct WebhookEvent: Codable {
+ /// Reason for cancellation, when applicable.
+ public var cancellationReason: WebhookCancellationReason? = nil
+ /// Localized currency code (ISO 4217) at event time, when available.
+ public var currency: String? = nil
+ public var environment: WebhookEventEnvironment
+ /// When the current subscription period ends. Epoch milliseconds.
+ public var expiresAt: Double? = nil
+ /// Stable identifier suitable for idempotency. Derived from the source notification
+ /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ /// otherwise hashed from the canonicalized payload.
+ public var id: String
+ /// Time the underlying event occurred at the store. Epoch milliseconds.
+ public var occurredAt: Double
+ public var platform: IapPlatform
+ /// Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ public var priceAmountMicros: Double? = nil
+ /// Product the event pertains to. May be null for account-level events.
+ public var productId: String? = nil
+ /// kit project that owns the subscription / purchase this event refers to.
+ public var projectId: String
+ /// Cross-platform purchase identity used to correlate this event with an existing
+ /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ /// payloads carry no transaction); always present for every other event type.
+ public var purchaseToken: String? = nil
+ /// Original signed payload from the store. ASN v2 events expose the JWS string;
+ /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ /// consumers can independently verify or extract platform-specific fields. kit
+ /// always validates this payload before emitting the event.
+ public var rawSignedPayload: String? = nil
+ /// Time kit ingested and normalized this event. Epoch milliseconds.
+ public var receivedAt: Double
+ /// When auto-renewal will charge again. Epoch milliseconds.
+ public var renewsAt: Double? = nil
+ public var source: WebhookEventSource
+ /// Normalized subscription state at the time of event, when the event refers to
+ /// a subscription. Null for one-time purchase events.
+ public var subscriptionState: SubscriptionState? = nil
+ public var type: WebhookEventType
+}
+
// MARK: - Input Objects
public struct AndroidSubscriptionOfferInput: Codable {
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index 1645922e..210dd596 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -928,6 +928,245 @@ enum SubscriptionReplacementModeAndroid {
String toJson() => value;
}
+enum SubscriptionState {
+ Active('active'),
+ InGracePeriod('in-grace-period'),
+ InBillingRetry('in-billing-retry'),
+ Expired('expired'),
+ Revoked('revoked'),
+ Refunded('refunded'),
+ Paused('paused'),
+ Unknown('unknown');
+
+ const SubscriptionState(this.value);
+ final String value;
+
+ factory SubscriptionState.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'active':
+ return SubscriptionState.Active;
+ case 'in-grace-period':
+ return SubscriptionState.InGracePeriod;
+ case 'in-billing-retry':
+ return SubscriptionState.InBillingRetry;
+ case 'expired':
+ return SubscriptionState.Expired;
+ case 'revoked':
+ return SubscriptionState.Revoked;
+ case 'refunded':
+ return SubscriptionState.Refunded;
+ case 'paused':
+ return SubscriptionState.Paused;
+ case 'unknown':
+ return SubscriptionState.Unknown;
+ }
+ throw ArgumentError('Unknown SubscriptionState value: $value');
+ }
+
+ String toJson() => value;
+}
+
+enum WebhookCancellationReason {
+ UserCanceled('user-canceled'),
+ BillingError('billing-error'),
+ PriceIncreaseDeclined('price-increase-declined'),
+ ProductUnavailable('product-unavailable'),
+ Refunded('refunded'),
+ Other('other');
+
+ const WebhookCancellationReason(this.value);
+ final String value;
+
+ factory WebhookCancellationReason.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'user-canceled':
+ return WebhookCancellationReason.UserCanceled;
+ case 'billing-error':
+ return WebhookCancellationReason.BillingError;
+ case 'price-increase-declined':
+ return WebhookCancellationReason.PriceIncreaseDeclined;
+ case 'product-unavailable':
+ return WebhookCancellationReason.ProductUnavailable;
+ case 'refunded':
+ return WebhookCancellationReason.Refunded;
+ case 'other':
+ return WebhookCancellationReason.Other;
+ }
+ throw ArgumentError('Unknown WebhookCancellationReason value: $value');
+ }
+
+ String toJson() => value;
+}
+
+enum WebhookEventEnvironment {
+ Production('production'),
+ Sandbox('sandbox'),
+ Xcode('xcode');
+
+ const WebhookEventEnvironment(this.value);
+ final String value;
+
+ factory WebhookEventEnvironment.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'production':
+ return WebhookEventEnvironment.Production;
+ case 'sandbox':
+ return WebhookEventEnvironment.Sandbox;
+ case 'xcode':
+ return WebhookEventEnvironment.Xcode;
+ }
+ throw ArgumentError('Unknown WebhookEventEnvironment value: $value');
+ }
+
+ String toJson() => value;
+}
+
+enum WebhookEventSource {
+ AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'),
+ GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'),
+ /// Synthetic source for Meta Horizon Store. Meta has no webhook /
+ /// push notification system so kit polls `verify_entitlement` on a
+ /// cron and emits these synthetic events when an entitlement
+ /// transitions. SDK consumers see them on the SSE stream alongside
+ /// real Apple / Google webhooks.
+ MetaHorizonReconciler('meta-horizon-reconciler');
+
+ const WebhookEventSource(this.value);
+ final String value;
+
+ factory WebhookEventSource.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'apple-app-store-server-notifications-v2':
+ return WebhookEventSource.AppleAppStoreServerNotificationsV2;
+ case 'google-play-real-time-developer-notifications':
+ return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications;
+ case 'meta-horizon-reconciler':
+ return WebhookEventSource.MetaHorizonReconciler;
+ }
+ throw ArgumentError('Unknown WebhookEventSource value: $value');
+ }
+
+ String toJson() => value;
+}
+
+enum WebhookEventType {
+ /// Initial purchase or first conversion from a free trial / intro offer.
+ /// iOS: SUBSCRIBED (initialBuy / resubscribe).
+ /// Android: SUBSCRIPTION_PURCHASED.
+ SubscriptionStarted('subscription-started'),
+ /// Auto-renewal succeeded for an existing subscription.
+ /// iOS: DID_RENEW.
+ /// Android: SUBSCRIPTION_RENEWED.
+ SubscriptionRenewed('subscription-renewed'),
+ /// Subscription reached its expiration without a successful renewal.
+ /// iOS: EXPIRED.
+ /// Android: SUBSCRIPTION_EXPIRED.
+ SubscriptionExpired('subscription-expired'),
+ /// Billing failed; the subscription is in a grace period during which the user
+ /// retains entitlement while payment is retried.
+ /// iOS: DID_FAIL_TO_RENEW (with grace period active).
+ /// Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ SubscriptionInGracePeriod('subscription-in-grace-period'),
+ /// Billing failed and the subscription is in account-hold / billing retry,
+ /// during which entitlement is paused but the subscription is not yet expired.
+ /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry).
+ /// Android: SUBSCRIPTION_ON_HOLD.
+ SubscriptionInBillingRetry('subscription-in-billing-retry'),
+ /// Subscription returned to active state after a billing issue or pause.
+ /// iOS: DID_RECOVER.
+ /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto-
+ /// renew re-enabled (Uncanceled), not billing recovery.
+ SubscriptionRecovered('subscription-recovered'),
+ /// User turned off auto-renew. Access continues until the current period ends.
+ /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
+ /// Android: SUBSCRIPTION_CANCELED.
+ SubscriptionCanceled('subscription-canceled'),
+ /// User reactivated auto-renew before the subscription expired.
+ /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
+ /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ SubscriptionUncanceled('subscription-uncanceled'),
+ /// Access immediately revoked (family sharing removal, admin action, fraud).
+ /// iOS: REVOKE.
+ /// Android: SUBSCRIPTION_REVOKED.
+ SubscriptionRevoked('subscription-revoked'),
+ /// A price change is pending or has been confirmed by the user.
+ /// iOS: PRICE_INCREASE.
+ /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ SubscriptionPriceChange('subscription-price-change'),
+ /// User upgraded, downgraded, or crossgraded their plan.
+ /// iOS: DID_CHANGE_RENEWAL_PREF.
+ /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ SubscriptionProductChanged('subscription-product-changed'),
+ /// Subscription paused (Android only feature). Also fired when the
+ /// pause schedule is changed — RTDN does not have a separate signal.
+ /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ SubscriptionPaused('subscription-paused'),
+ /// Paused subscription resumed (Android only feature). RTDN signals
+ /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle
+ /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the
+ /// resume.
+ /// Android: SUBSCRIPTION_RECOVERED (after pause).
+ SubscriptionResumed('subscription-resumed'),
+ /// Refund issued for a one-time purchase or subscription period.
+ /// iOS: REFUND.
+ /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ PurchaseRefunded('purchase-refunded'),
+ /// iOS-only: App Store requests a consumption status report for a refund decision.
+ /// Servers should respond via the StoreKit consumption API.
+ PurchaseConsumptionRequest('purchase-consumption-request'),
+ /// Sandbox or test notification fired by the store for diagnostic purposes.
+ /// Useful for verifying webhook plumbing without a live transaction.
+ TestNotification('test-notification');
+
+ const WebhookEventType(this.value);
+ final String value;
+
+ factory WebhookEventType.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'subscription-started':
+ return WebhookEventType.SubscriptionStarted;
+ case 'subscription-renewed':
+ return WebhookEventType.SubscriptionRenewed;
+ case 'subscription-expired':
+ return WebhookEventType.SubscriptionExpired;
+ case 'subscription-in-grace-period':
+ return WebhookEventType.SubscriptionInGracePeriod;
+ case 'subscription-in-billing-retry':
+ return WebhookEventType.SubscriptionInBillingRetry;
+ case 'subscription-recovered':
+ return WebhookEventType.SubscriptionRecovered;
+ case 'subscription-canceled':
+ return WebhookEventType.SubscriptionCanceled;
+ case 'subscription-uncanceled':
+ return WebhookEventType.SubscriptionUncanceled;
+ case 'subscription-revoked':
+ return WebhookEventType.SubscriptionRevoked;
+ case 'subscription-price-change':
+ return WebhookEventType.SubscriptionPriceChange;
+ case 'subscription-product-changed':
+ return WebhookEventType.SubscriptionProductChanged;
+ case 'subscription-paused':
+ return WebhookEventType.SubscriptionPaused;
+ case 'subscription-resumed':
+ return WebhookEventType.SubscriptionResumed;
+ case 'purchase-refunded':
+ return WebhookEventType.PurchaseRefunded;
+ case 'purchase-consumption-request':
+ return WebhookEventType.PurchaseConsumptionRequest;
+ case 'test-notification':
+ return WebhookEventType.TestNotification;
+ }
+ throw ArgumentError('Unknown WebhookEventType value: $value');
+ }
+
+ String toJson() => value;
+}
+
// MARK: - Interfaces
abstract class ProductCommon {
@@ -3689,6 +3928,114 @@ class VerifyPurchaseWithProviderResult {
typedef VoidResult = void;
+class WebhookEvent {
+ const WebhookEvent({
+ this.cancellationReason,
+ this.currency,
+ required this.environment,
+ this.expiresAt,
+ required this.id,
+ required this.occurredAt,
+ required this.platform,
+ this.priceAmountMicros,
+ this.productId,
+ required this.projectId,
+ this.purchaseToken,
+ this.rawSignedPayload,
+ required this.receivedAt,
+ this.renewsAt,
+ required this.source,
+ this.subscriptionState,
+ required this.type,
+ });
+
+ /// Reason for cancellation, when applicable.
+ final WebhookCancellationReason? cancellationReason;
+ /// Localized currency code (ISO 4217) at event time, when available.
+ final String? currency;
+ final WebhookEventEnvironment environment;
+ /// When the current subscription period ends. Epoch milliseconds.
+ final double? expiresAt;
+ /// Stable identifier suitable for idempotency. Derived from the source notification
+ /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ /// otherwise hashed from the canonicalized payload.
+ final String id;
+ /// Time the underlying event occurred at the store. Epoch milliseconds.
+ final double occurredAt;
+ final IapPlatform platform;
+ /// Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ final double? priceAmountMicros;
+ /// Product the event pertains to. May be null for account-level events.
+ final String? productId;
+ /// kit project that owns the subscription / purchase this event refers to.
+ final String projectId;
+ /// Cross-platform purchase identity used to correlate this event with an existing
+ /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ /// payloads carry no transaction); always present for every other event type.
+ final String? purchaseToken;
+ /// Original signed payload from the store. ASN v2 events expose the JWS string;
+ /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ /// consumers can independently verify or extract platform-specific fields. kit
+ /// always validates this payload before emitting the event.
+ final String? rawSignedPayload;
+ /// Time kit ingested and normalized this event. Epoch milliseconds.
+ final double receivedAt;
+ /// When auto-renewal will charge again. Epoch milliseconds.
+ final double? renewsAt;
+ final WebhookEventSource source;
+ /// Normalized subscription state at the time of event, when the event refers to
+ /// a subscription. Null for one-time purchase events.
+ final SubscriptionState? subscriptionState;
+ final WebhookEventType type;
+
+ factory WebhookEvent.fromJson(Map json) {
+ return WebhookEvent(
+ cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null,
+ currency: json['currency'] as String?,
+ environment: WebhookEventEnvironment.fromJson(json['environment'] as String),
+ expiresAt: (json['expiresAt'] as num?)?.toDouble(),
+ id: json['id'] as String,
+ occurredAt: (json['occurredAt'] as num).toDouble(),
+ platform: IapPlatform.fromJson(json['platform'] as String),
+ priceAmountMicros: (json['priceAmountMicros'] as num?)?.toDouble(),
+ productId: json['productId'] as String?,
+ projectId: json['projectId'] as String,
+ purchaseToken: json['purchaseToken'] as String?,
+ rawSignedPayload: json['rawSignedPayload'] as String?,
+ receivedAt: (json['receivedAt'] as num).toDouble(),
+ renewsAt: (json['renewsAt'] as num?)?.toDouble(),
+ source: WebhookEventSource.fromJson(json['source'] as String),
+ subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null,
+ type: WebhookEventType.fromJson(json['type'] as String),
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'WebhookEvent',
+ 'cancellationReason': cancellationReason?.toJson(),
+ 'currency': currency,
+ 'environment': environment.toJson(),
+ 'expiresAt': expiresAt,
+ 'id': id,
+ 'occurredAt': occurredAt,
+ 'platform': platform.toJson(),
+ 'priceAmountMicros': priceAmountMicros,
+ 'productId': productId,
+ 'projectId': projectId,
+ 'purchaseToken': purchaseToken,
+ 'rawSignedPayload': rawSignedPayload,
+ 'receivedAt': receivedAt,
+ 'renewsAt': renewsAt,
+ 'source': source.toJson(),
+ 'subscriptionState': subscriptionState?.toJson(),
+ 'type': type.toJson(),
+ };
+ }
+}
+
// MARK: - Input Objects
class AndroidSubscriptionOfferInput {
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index bc96928f..416cd5fb 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -295,6 +295,74 @@ enum SubscriptionReplacementModeAndroid {
KEEP_EXISTING = 6,
}
+enum SubscriptionState {
+ ACTIVE = 0,
+ IN_GRACE_PERIOD = 1,
+ IN_BILLING_RETRY = 2,
+ EXPIRED = 3,
+ REVOKED = 4,
+ REFUNDED = 5,
+ PAUSED = 6,
+ UNKNOWN = 7,
+}
+
+enum WebhookCancellationReason {
+ USER_CANCELED = 0,
+ BILLING_ERROR = 1,
+ PRICE_INCREASE_DECLINED = 2,
+ PRODUCT_UNAVAILABLE = 3,
+ REFUNDED = 4,
+ OTHER = 5,
+}
+
+enum WebhookEventEnvironment {
+ PRODUCTION = 0,
+ SANDBOX = 1,
+ XCODE = 2,
+}
+
+enum WebhookEventSource {
+ APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0,
+ GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1,
+ ## Synthetic source for Meta Horizon Store. Meta has no webhook / push notification system so kit polls `verify_entitlement` on a cron and emits these synthetic events when an entitlement transitions. SDK consumers see them on the SSE stream alongside real Apple / Google webhooks.
+ META_HORIZON_RECONCILER = 2,
+}
+
+enum WebhookEventType {
+ ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED.
+ SUBSCRIPTION_STARTED = 0,
+ ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED.
+ SUBSCRIPTION_RENEWED = 1,
+ ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED.
+ SUBSCRIPTION_EXPIRED = 2,
+ ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ SUBSCRIPTION_IN_GRACE_PERIOD = 3,
+ ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD.
+ SUBSCRIPTION_IN_BILLING_RETRY = 4,
+ ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- renew re-enabled (Uncanceled), not billing recovery.
+ SUBSCRIPTION_RECOVERED = 5,
+ ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED.
+ SUBSCRIPTION_CANCELED = 6,
+ ## User reactivated auto-renew before the subscription expired. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ SUBSCRIPTION_UNCANCELED = 7,
+ ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED.
+ SUBSCRIPTION_REVOKED = 8,
+ ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ SUBSCRIPTION_PRICE_CHANGE = 9,
+ ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ SUBSCRIPTION_PRODUCT_CHANGED = 10,
+ ## Subscription paused (Android only feature). Also fired when the pause schedule is changed — RTDN does not have a separate signal. Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ SUBSCRIPTION_PAUSED = 11,
+ ## Paused subscription resumed (Android only feature). RTDN signals resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the resume. Android: SUBSCRIPTION_RECOVERED (after pause).
+ SUBSCRIPTION_RESUMED = 12,
+ ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ PURCHASE_REFUNDED = 13,
+ ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API.
+ PURCHASE_CONSUMPTION_REQUEST = 14,
+ ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction.
+ TEST_NOTIFICATION = 15,
+}
+
# ============================================================================
# Types
# ============================================================================
@@ -3238,6 +3306,146 @@ class VoidResult:
dict["success"] = success
return dict
+class WebhookEvent:
+ ## Stable identifier suitable for idempotency. Derived from the source notification
+ var id: String = ""
+ var type: WebhookEventType
+ var source: WebhookEventSource
+ var platform: IapPlatform
+ ## kit project that owns the subscription / purchase this event refers to.
+ var project_id: String = ""
+ ## Time the underlying event occurred at the store. Epoch milliseconds.
+ var occurred_at: float = 0.0
+ ## Time kit ingested and normalized this event. Epoch milliseconds.
+ var received_at: float = 0.0
+ var environment: WebhookEventEnvironment
+ ## Cross-platform purchase identity used to correlate this event with an existing
+ var purchase_token: Variant = null
+ ## Product the event pertains to. May be null for account-level events.
+ var product_id: Variant = null
+ ## Normalized subscription state at the time of event, when the event refers to
+ var subscription_state: SubscriptionState
+ ## When the current subscription period ends. Epoch milliseconds.
+ var expires_at: Variant = null
+ ## When auto-renewal will charge again. Epoch milliseconds.
+ var renews_at: Variant = null
+ ## Reason for cancellation, when applicable.
+ var cancellation_reason: WebhookCancellationReason
+ ## Localized currency code (ISO 4217) at event time, when available.
+ var currency: Variant = null
+ ## Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ var price_amount_micros: Variant = null
+ ## Original signed payload from the store. ASN v2 events expose the JWS string;
+ var raw_signed_payload: Variant = null
+
+ static func from_dict(data: Dictionary) -> WebhookEvent:
+ var obj = WebhookEvent.new()
+ if data.has("id") and data["id"] != null:
+ obj.id = data["id"]
+ if data.has("type") and data["type"] != null:
+ var enum_str = data["type"]
+ if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str):
+ obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str]
+ else:
+ obj.type = enum_str
+ if data.has("source") and data["source"] != null:
+ var enum_str = data["source"]
+ if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str):
+ obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str]
+ else:
+ obj.source = enum_str
+ if data.has("platform") and data["platform"] != null:
+ var enum_str = data["platform"]
+ if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str):
+ obj.platform = IAP_PLATFORM_FROM_STRING[enum_str]
+ else:
+ obj.platform = enum_str
+ if data.has("projectId") and data["projectId"] != null:
+ obj.project_id = data["projectId"]
+ if data.has("occurredAt") and data["occurredAt"] != null:
+ obj.occurred_at = data["occurredAt"]
+ if data.has("receivedAt") and data["receivedAt"] != null:
+ obj.received_at = data["receivedAt"]
+ if data.has("environment") and data["environment"] != null:
+ var enum_str = data["environment"]
+ if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str):
+ obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str]
+ else:
+ obj.environment = enum_str
+ if data.has("purchaseToken") and data["purchaseToken"] != null:
+ obj.purchase_token = data["purchaseToken"]
+ if data.has("productId") and data["productId"] != null:
+ obj.product_id = data["productId"]
+ if data.has("subscriptionState") and data["subscriptionState"] != null:
+ var enum_str = data["subscriptionState"]
+ if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str):
+ obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str]
+ else:
+ obj.subscription_state = enum_str
+ if data.has("expiresAt") and data["expiresAt"] != null:
+ obj.expires_at = data["expiresAt"]
+ if data.has("renewsAt") and data["renewsAt"] != null:
+ obj.renews_at = data["renewsAt"]
+ if data.has("cancellationReason") and data["cancellationReason"] != null:
+ var enum_str = data["cancellationReason"]
+ if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str):
+ obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str]
+ else:
+ obj.cancellation_reason = enum_str
+ if data.has("currency") and data["currency"] != null:
+ obj.currency = data["currency"]
+ if data.has("priceAmountMicros") and data["priceAmountMicros"] != null:
+ obj.price_amount_micros = data["priceAmountMicros"]
+ if data.has("rawSignedPayload") and data["rawSignedPayload"] != null:
+ obj.raw_signed_payload = data["rawSignedPayload"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ dict["id"] = id
+ if WEBHOOK_EVENT_TYPE_VALUES.has(type):
+ dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type]
+ else:
+ dict["type"] = type
+ if WEBHOOK_EVENT_SOURCE_VALUES.has(source):
+ dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source]
+ else:
+ dict["source"] = source
+ if IAP_PLATFORM_VALUES.has(platform):
+ dict["platform"] = IAP_PLATFORM_VALUES[platform]
+ else:
+ dict["platform"] = platform
+ dict["projectId"] = project_id
+ dict["occurredAt"] = occurred_at
+ dict["receivedAt"] = received_at
+ if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment):
+ dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment]
+ else:
+ dict["environment"] = environment
+ if purchase_token != null:
+ dict["purchaseToken"] = purchase_token
+ if product_id != null:
+ dict["productId"] = product_id
+ if SUBSCRIPTION_STATE_VALUES.has(subscription_state):
+ dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state]
+ else:
+ dict["subscriptionState"] = subscription_state
+ if expires_at != null:
+ dict["expiresAt"] = expires_at
+ if renews_at != null:
+ dict["renewsAt"] = renews_at
+ if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason):
+ dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason]
+ else:
+ dict["cancellationReason"] = cancellation_reason
+ if currency != null:
+ dict["currency"] = currency
+ if price_amount_micros != null:
+ dict["priceAmountMicros"] = price_amount_micros
+ if raw_signed_payload != null:
+ dict["rawSignedPayload"] = raw_signed_payload
+ return dict
+
# ============================================================================
# Input Types
# ============================================================================
@@ -4569,6 +4777,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = {
SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing"
}
+const SUBSCRIPTION_STATE_VALUES = {
+ SubscriptionState.ACTIVE: "active",
+ SubscriptionState.IN_GRACE_PERIOD: "in-grace-period",
+ SubscriptionState.IN_BILLING_RETRY: "in-billing-retry",
+ SubscriptionState.EXPIRED: "expired",
+ SubscriptionState.REVOKED: "revoked",
+ SubscriptionState.REFUNDED: "refunded",
+ SubscriptionState.PAUSED: "paused",
+ SubscriptionState.UNKNOWN: "unknown"
+}
+
+const WEBHOOK_CANCELLATION_REASON_VALUES = {
+ WebhookCancellationReason.USER_CANCELED: "user-canceled",
+ WebhookCancellationReason.BILLING_ERROR: "billing-error",
+ WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined",
+ WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable",
+ WebhookCancellationReason.REFUNDED: "refunded",
+ WebhookCancellationReason.OTHER: "other"
+}
+
+const WEBHOOK_EVENT_ENVIRONMENT_VALUES = {
+ WebhookEventEnvironment.PRODUCTION: "production",
+ WebhookEventEnvironment.SANDBOX: "sandbox",
+ WebhookEventEnvironment.XCODE: "xcode"
+}
+
+const WEBHOOK_EVENT_SOURCE_VALUES = {
+ WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2",
+ WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications",
+ WebhookEventSource.META_HORIZON_RECONCILER: "meta-horizon-reconciler"
+}
+
+const WEBHOOK_EVENT_TYPE_VALUES = {
+ WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started",
+ WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed",
+ WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired",
+ WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period",
+ WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry",
+ WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered",
+ WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled",
+ WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled",
+ WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked",
+ WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change",
+ WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed",
+ WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused",
+ WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed",
+ WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded",
+ WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request",
+ WebhookEventType.TEST_NOTIFICATION: "test-notification"
+}
+
# ============================================================================
# Enum Reverse Lookup (string -> enum for deserialization)
# ============================================================================
@@ -4787,6 +5046,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = {
"keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING
}
+const SUBSCRIPTION_STATE_FROM_STRING = {
+ "active": SubscriptionState.ACTIVE,
+ "in-grace-period": SubscriptionState.IN_GRACE_PERIOD,
+ "in-billing-retry": SubscriptionState.IN_BILLING_RETRY,
+ "expired": SubscriptionState.EXPIRED,
+ "revoked": SubscriptionState.REVOKED,
+ "refunded": SubscriptionState.REFUNDED,
+ "paused": SubscriptionState.PAUSED,
+ "unknown": SubscriptionState.UNKNOWN
+}
+
+const WEBHOOK_CANCELLATION_REASON_FROM_STRING = {
+ "user-canceled": WebhookCancellationReason.USER_CANCELED,
+ "billing-error": WebhookCancellationReason.BILLING_ERROR,
+ "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED,
+ "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE,
+ "refunded": WebhookCancellationReason.REFUNDED,
+ "other": WebhookCancellationReason.OTHER
+}
+
+const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = {
+ "production": WebhookEventEnvironment.PRODUCTION,
+ "sandbox": WebhookEventEnvironment.SANDBOX,
+ "xcode": WebhookEventEnvironment.XCODE
+}
+
+const WEBHOOK_EVENT_SOURCE_FROM_STRING = {
+ "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2,
+ "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS,
+ "meta-horizon-reconciler": WebhookEventSource.META_HORIZON_RECONCILER
+}
+
+const WEBHOOK_EVENT_TYPE_FROM_STRING = {
+ "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED,
+ "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED,
+ "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED,
+ "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD,
+ "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY,
+ "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED,
+ "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED,
+ "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED,
+ "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED,
+ "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE,
+ "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED,
+ "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED,
+ "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED,
+ "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED,
+ "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST,
+ "test-notification": WebhookEventType.TEST_NOTIFICATION
+}
+
# ============================================================================
# Query Types
# ============================================================================
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index 48f0103b..2647f51b 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -1896,6 +1896,8 @@ export interface SubscriptionProductReplacementParamsAndroid {
*/
export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing';
+export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown';
+
export interface SubscriptionStatusIOS {
renewalInfo?: (RenewalInfoIOS | null);
state: string;
@@ -2057,6 +2059,67 @@ export interface VerifyPurchaseWithProviderResult {
export type VoidResult = void;
+export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled';
+
+export interface WebhookEvent {
+ /** Reason for cancellation, when applicable. */
+ cancellationReason?: (WebhookCancellationReason | null);
+ /** Localized currency code (ISO 4217) at event time, when available. */
+ currency?: (string | null);
+ environment: WebhookEventEnvironment;
+ /** When the current subscription period ends. Epoch milliseconds. */
+ expiresAt?: (number | null);
+ /**
+ * Stable identifier suitable for idempotency. Derived from the source notification
+ * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ * otherwise hashed from the canonicalized payload.
+ */
+ id: string;
+ /** Time the underlying event occurred at the store. Epoch milliseconds. */
+ occurredAt: number;
+ platform: IapPlatform;
+ /**
+ * Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ * Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ */
+ priceAmountMicros?: (number | null);
+ /** Product the event pertains to. May be null for account-level events. */
+ productId?: (string | null);
+ /** kit project that owns the subscription / purchase this event refers to. */
+ projectId: string;
+ /**
+ * Cross-platform purchase identity used to correlate this event with an existing
+ * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ * payloads carry no transaction); always present for every other event type.
+ */
+ purchaseToken?: (string | null);
+ /**
+ * Original signed payload from the store. ASN v2 events expose the JWS string;
+ * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ * consumers can independently verify or extract platform-specific fields. kit
+ * always validates this payload before emitting the event.
+ */
+ rawSignedPayload?: (string | null);
+ /** Time kit ingested and normalized this event. Epoch milliseconds. */
+ receivedAt: number;
+ /** When auto-renewal will charge again. Epoch milliseconds. */
+ renewsAt?: (number | null);
+ source: WebhookEventSource;
+ /**
+ * Normalized subscription state at the time of event, when the event refers to
+ * a subscription. Null for one-time purchase events.
+ */
+ subscriptionState?: (SubscriptionState | null);
+ type: WebhookEventType;
+}
+
+export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode';
+
+export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications' | 'meta-horizon-reconciler';
+
+export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification';
+
/**
* Win-back offer input for iOS 18+ (StoreKit 2)
* Win-back offers are used to re-engage churned subscribers.
diff --git a/packages/gql/src/kit-api.test.ts b/packages/gql/src/kit-api.test.ts
new file mode 100644
index 00000000..4e98eaf9
--- /dev/null
+++ b/packages/gql/src/kit-api.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+import { kitApi, KitApiError } from "./kit-api";
+
+function fakeFetch(
+ recipe: (
+ path: string,
+ init?: RequestInit,
+ ) => {
+ status: number;
+ body: unknown;
+ },
+) {
+ return vi.fn(async (input: string, init?: RequestInit) => {
+ const url = new URL(input);
+ const path = url.pathname + url.search;
+ const { status, body } = recipe(path, init);
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers(),
+ text: async () =>
+ typeof body === "string" ? body : JSON.stringify(body),
+ } as unknown as Response;
+ });
+}
+
+describe("kitApi", () => {
+ it("calls /v1/subscriptions/status with the apiKey + userId", async () => {
+ const fetchImpl = fakeFetch(() => ({
+ status: 200,
+ body: { active: true, subscription: null },
+ }));
+ const api = kitApi({
+ apiKey: "k",
+ baseUrl: "http://localhost",
+ fetchImpl: fetchImpl as never,
+ });
+ const result = await api.status("user-1");
+ expect(result.active).toBe(true);
+ expect(fetchImpl).toHaveBeenCalledWith(
+ "http://localhost/v1/subscriptions/status/k?userId=user-1",
+ expect.anything(),
+ );
+ });
+
+ it("URL-encodes apiKey and userId", async () => {
+ const fetchImpl = fakeFetch(() => ({
+ status: 200,
+ body: { userId: "u 1", productIds: [], subscriptions: [] },
+ }));
+ const api = kitApi({
+ apiKey: "k 1",
+ baseUrl: "http://localhost",
+ fetchImpl: fetchImpl as never,
+ });
+ await api.entitlements("u 1");
+ expect(fetchImpl).toHaveBeenCalledWith(
+ "http://localhost/v1/subscriptions/entitlements/k%201?userId=u%201",
+ expect.anything(),
+ );
+ });
+
+ it("throws KitApiError on non-2xx", async () => {
+ const fetchImpl = fakeFetch(() => ({
+ status: 401,
+ body: { errors: [{ code: "INVALID_API_KEY", message: "nope" }] },
+ }));
+ const api = kitApi({
+ apiKey: "bad",
+ baseUrl: "http://localhost",
+ fetchImpl: fetchImpl as never,
+ });
+ await expect(api.status("u")).rejects.toBeInstanceOf(KitApiError);
+ });
+
+ it("bindUser POSTs JSON", async () => {
+ const fetchImpl = fakeFetch((_path, init) => {
+ expect(init?.method).toBe("POST");
+ const body = JSON.parse(init?.body as string);
+ expect(body).toEqual({ purchaseToken: "tok", userId: "user" });
+ return { status: 200, body: { ok: true, bound: true } };
+ });
+ const api = kitApi({
+ apiKey: "k",
+ baseUrl: "http://localhost",
+ fetchImpl: fetchImpl as never,
+ });
+ const result = await api.bindUser("tok", "user");
+ expect(result).toEqual({ ok: true, bound: true });
+ });
+});
diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts
new file mode 100644
index 00000000..72f68247
--- /dev/null
+++ b/packages/gql/src/kit-api.ts
@@ -0,0 +1,225 @@
+// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS
+// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of
+// `packages/mcp-server/src/kit-client.ts` so the same operations are
+// reachable from both LLM tools and end-user apps without each
+// duplicating the URL layout.
+
+export type KitApiOptions = {
+ apiKey: string;
+ baseUrl?: string;
+ // Optional fetch override for runtimes without a global (older RN
+ // builds) or for injection in tests.
+ fetchImpl?: (input: string, init?: RequestInit) => Promise;
+};
+
+export type KitSubscription = {
+ id: string;
+ productId: string;
+ platform: "IOS" | "Android";
+ state: string;
+ expiresAt?: number;
+ renewsAt?: number;
+ willRenew?: boolean;
+ cancellationReason?: string;
+ currency?: string;
+ priceAmountMicros?: number;
+ startedAt: number;
+ updatedAt: number;
+ purchaseToken: string;
+ userId?: string;
+};
+
+export type EntitlementsResponse = {
+ userId: string;
+ productIds: string[];
+ subscriptions: KitSubscription[];
+};
+
+export type StatusResponse = {
+ active: boolean;
+ subscription: KitSubscription | null;
+};
+
+const DEFAULT_BASE_URL = "https://kit.openiap.dev";
+
+// Merge caller-supplied headers with kit defaults (`accept`,
+// optionally `content-type`). When the runtime exposes a global
+// `Headers` constructor we use it directly so callers passing a
+// `Headers` instance (a `HeadersInit`) keep that exact instance's
+// values. When `Headers` is missing — older React Native builds where
+// the operator wires up `fetchImpl` without a `Headers` polyfill —
+// we fall back to a case-insensitive merge into a plain record so
+// the request still goes through. Either way, caller-set values take
+// precedence over kit defaults.
+function mergeHeaders(
+ callerHeaders: HeadersInit | undefined,
+ hasBody: boolean,
+): HeadersInit {
+ if (typeof Headers === "function") {
+ const merged = new Headers(callerHeaders);
+ if (!merged.has("accept")) merged.set("accept", "application/json");
+ if (hasBody && !merged.has("content-type")) {
+ merged.set("content-type", "application/json");
+ }
+ return merged;
+ }
+ // Plain-object fallback path. Build a case-insensitive name map
+ // from whatever the caller passed (Headers-shaped, array-of-pairs,
+ // or plain record) and re-emit as a record `fetchImpl` accepts.
+ const lower = new Map();
+ const setIfAbsent = (name: string, value: string) => {
+ const key = name.toLowerCase();
+ if (!lower.has(key)) lower.set(key, { name, value });
+ };
+ const setForce = (name: string, value: string) => {
+ const key = name.toLowerCase();
+ lower.set(key, { name, value });
+ };
+ if (callerHeaders) {
+ if (Array.isArray(callerHeaders)) {
+ for (const [name, value] of callerHeaders) setForce(name, value);
+ } else if (
+ typeof (callerHeaders as { forEach?: unknown }).forEach === "function"
+ ) {
+ // `Headers`-like (without being our `typeof Headers === "function"`
+ // global). RN polyfills sometimes attach `Headers` only to
+ // request/response instances rather than the global scope.
+ // Standard signature is `forEach((value, key, parent))`; we
+ // bind the first two positionally so a polyfill that omits
+ // the third argument still works. `key` is the header name.
+ (
+ callerHeaders as {
+ forEach: (cb: (value: string, key: string) => void) => void;
+ }
+ ).forEach((value, key) => setForce(key, value));
+ } else {
+ for (const [name, value] of Object.entries(
+ callerHeaders as Record,
+ )) {
+ setForce(name, value);
+ }
+ }
+ }
+ setIfAbsent("accept", "application/json");
+ if (hasBody) setIfAbsent("content-type", "application/json");
+ const out: Record = {};
+ for (const { name, value } of lower.values()) out[name] = value;
+ return out;
+}
+
+export class KitApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly body: unknown,
+ message: string,
+ ) {
+ super(message);
+ this.name = "KitApiError";
+ }
+}
+
+export function kitApi(options: KitApiOptions) {
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
+ const fetchImpl: (input: string, init?: RequestInit) => Promise =
+ (() => {
+ if (options.fetchImpl) return options.fetchImpl;
+ if (typeof fetch === "function") {
+ return (input: string, init?: RequestInit) => fetch(input, init);
+ }
+ throw new Error(
+ "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.",
+ );
+ })();
+
+ async function call(path: string, init?: RequestInit): Promise {
+ // Normalize headers without depending on a global `Headers`
+ // constructor: older React Native runtimes ship `fetch` (or a
+ // polyfill via `fetchImpl`) without exposing `Headers` globally.
+ // The prior implementation crashed before the first request on
+ // those runtimes. We use `new Headers()` when available (preserves
+ // caller-supplied `Headers` instances exactly), and otherwise fall
+ // back to a small case-insensitive merge into a plain record.
+ // Either way, kit defaults only apply when the caller hasn't set
+ // the same name.
+ const headers = mergeHeaders(init?.headers, init?.body != null);
+ // Prepend a leading slash if `path` is missing one. Today's
+ // call sites all hard-code the leading "/", but normalizing here
+ // makes the helper safe for future additions and matches the
+ // already-stripped `baseUrl` (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+ const response = await fetchImpl(`${baseUrl}${normalizedPath}`, {
+ ...init,
+ headers,
+ });
+ const text = await response.text();
+ // Empty body normalizes to null so callers expecting JSON
+ // (status / entitlements / list*) don't get a truthy ""
+ // and crash on property access.
+ let parsed: unknown = null;
+ let parseError: unknown = null;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch (error) {
+ // Non-JSON body (a misconfigured proxy returning HTML, a
+ // CDN-injected error page, etc.) on a 2xx response would
+ // otherwise reach the caller as `parsed = text` and crash
+ // on property access via `parsed as T`. Throw a structured
+ // KitApiError instead so callers see a typed failure.
+ parseError = error;
+ }
+ }
+ if (!response.ok) {
+ // Surface the raw body (text or parsed) on the error path so
+ // operators can read the upstream error message verbatim.
+ throw new KitApiError(
+ response.status,
+ parsed ?? text,
+ `kit ${path} returned ${response.status}`,
+ );
+ }
+ if (parseError) {
+ throw new KitApiError(
+ response.status,
+ text,
+ `kit ${path} returned a non-JSON ${response.status} body (${
+ parseError instanceof Error ? parseError.message : String(parseError)
+ })`,
+ );
+ }
+ return parsed as T;
+ }
+
+ return {
+ apiKey: options.apiKey,
+ baseUrl,
+
+ /** GET /v1/subscriptions/status — the `active` boolean is the
+ * fastest gate for "is this user paying?". */
+ status: (userId: string) =>
+ call(
+ `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`,
+ ),
+
+ /** GET /v1/subscriptions/entitlements — every productId the user
+ * is entitled to. Use this when feature gating depends on which
+ * specific tier the user owns. */
+ entitlements: (userId: string) =>
+ call(
+ `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`,
+ ),
+
+ /** POST /v1/subscriptions/bind-user — call after a successful
+ * verifyReceipt so kit knows which userId owns the verified
+ * `purchaseToken`. Idempotent. */
+ bindUser: (purchaseToken: string, userId: string) =>
+ call<{ ok: boolean; bound: boolean }>(
+ `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`,
+ {
+ method: "POST",
+ body: JSON.stringify({ purchaseToken, userId }),
+ },
+ ),
+ };
+}
diff --git a/packages/gql/src/webhook-client.test.ts b/packages/gql/src/webhook-client.test.ts
new file mode 100644
index 00000000..b9306bed
--- /dev/null
+++ b/packages/gql/src/webhook-client.test.ts
@@ -0,0 +1,245 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ WEBHOOK_EVENT_TYPES,
+ connectWebhookStream,
+ parseWebhookEventData,
+ type WebhookEventPayload,
+ type WebhookEventStream,
+} from "./webhook-client";
+
+const validEvent: WebhookEventPayload = {
+ id: "uuid-1",
+ type: "SubscriptionRenewed",
+ source: "AppleAppStoreServerNotificationsV2",
+ platform: "IOS",
+ environment: "Production",
+ projectId: "project-1",
+ occurredAt: 1_711_000_000_000,
+ receivedAt: 1_711_000_001_000,
+ purchaseToken: "token-1",
+ productId: "com.example.premium",
+ subscriptionState: "Active",
+};
+
+describe("parseWebhookEventData", () => {
+ it("parses a valid event JSON into an event payload", () => {
+ const result = parseWebhookEventData(JSON.stringify(validEvent));
+ expect(result.kind).toBe("ok");
+ if (result.kind === "ok") {
+ expect(result.event.id).toBe("uuid-1");
+ expect(result.event.type).toBe("SubscriptionRenewed");
+ expect(result.event.purchaseToken).toBe("token-1");
+ }
+ });
+
+ it("skips heartbeats (empty payload)", () => {
+ expect(parseWebhookEventData("")).toEqual({
+ kind: "skip",
+ reason: "heartbeat",
+ });
+ });
+
+ it("skips stream-control envelopes that have no `type`", () => {
+ const result = parseWebhookEventData(JSON.stringify({ cursor: 12345 }));
+ expect(result.kind).toBe("skip");
+ });
+
+ it("returns parse error on malformed JSON", () => {
+ const result = parseWebhookEventData("not json");
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") {
+ expect(result.message).toMatch(/parse SSE payload/);
+ }
+ });
+
+ it("returns error when required fields are missing", () => {
+ const result = parseWebhookEventData(
+ JSON.stringify({
+ type: "SubscriptionRenewed",
+ // missing id / purchaseToken / occurredAt / receivedAt
+ }),
+ );
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") {
+ expect(result.message).toMatch(/missing required fields/);
+ }
+ });
+});
+
+describe("connectWebhookStream", () => {
+ it("subscribes to typed SSE events and forwards events", () => {
+ const onEvent = vi.fn();
+ const onError = vi.fn();
+
+ const handlers = new Map<
+ string,
+ (event: { data: string; lastEventId?: string }) => void
+ >();
+
+ const fakeStream: WebhookEventStream = {
+ onmessage: null,
+ onerror: null,
+ addEventListener: (type, listener) => {
+ handlers.set(type, listener);
+ },
+ close: vi.fn(),
+ };
+
+ const factory = vi.fn(() => fakeStream);
+
+ const listener = connectWebhookStream({
+ apiKey: "test-key",
+ baseUrl: "http://localhost:3100",
+ onEvent,
+ onError,
+ eventSourceFactory: factory,
+ });
+
+ expect(factory).toHaveBeenCalledWith(
+ "http://localhost:3100/v1/webhooks/stream/test-key",
+ {},
+ );
+
+ expect(handlers.get("message")).toBeDefined();
+ for (const eventType of WEBHOOK_EVENT_TYPES) {
+ expect(handlers.get(eventType)).toBeDefined();
+ }
+ handlers.get("SubscriptionRenewed")!({
+ data: JSON.stringify(validEvent),
+ });
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ expect(onEvent.mock.calls[0][0].id).toBe("uuid-1");
+ expect(onError).not.toHaveBeenCalled();
+
+ listener.close();
+ expect(fakeStream.close).toHaveBeenCalled();
+ });
+
+ it("dedupes when a polyfill dispatches the same frame twice", () => {
+ const onEvent = vi.fn();
+ const handlers = new Map<
+ string,
+ (event: { data: string; lastEventId?: string }) => void
+ >();
+
+ connectWebhookStream({
+ apiKey: "test-key",
+ baseUrl: "http://localhost:3100",
+ onEvent,
+ eventSourceFactory: () => ({
+ onmessage: null,
+ onerror: null,
+ addEventListener: (type, listener) => {
+ handlers.set(type, listener);
+ },
+ close: () => {},
+ }),
+ });
+
+ const data = JSON.stringify(validEvent);
+ handlers.get("SubscriptionRenewed")!({ data });
+ handlers.get("message")!({ data });
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to onmessage when addEventListener is not provided", () => {
+ const onEvent = vi.fn();
+
+ const fakeStream: WebhookEventStream = {
+ onmessage: null,
+ onerror: null,
+ close: () => {},
+ };
+
+ connectWebhookStream({
+ apiKey: "test-key",
+ baseUrl: "http://localhost:3100",
+ onEvent,
+ eventSourceFactory: () => fakeStream,
+ });
+
+ expect(fakeStream.onmessage).not.toBeNull();
+ fakeStream.onmessage!({ data: JSON.stringify(validEvent) });
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onError with TRANSPORT_ERROR when the stream errors", () => {
+ const onError = vi.fn();
+ const fakeStream: WebhookEventStream = {
+ onmessage: null,
+ onerror: null,
+ close: () => {},
+ };
+
+ connectWebhookStream({
+ apiKey: "test-key",
+ baseUrl: "http://localhost:3100",
+ onEvent: () => {},
+ onError,
+ eventSourceFactory: () => fakeStream,
+ });
+
+ fakeStream.onerror?.(new Error("boom"));
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ code: "TRANSPORT_ERROR" }),
+ );
+ });
+
+ it("emits NO_EVENTSOURCE when factory throws and returns a no-op listener", () => {
+ const onError = vi.fn();
+ const result = connectWebhookStream({
+ apiKey: "key",
+ baseUrl: "http://localhost",
+ onEvent: () => {},
+ onError,
+ eventSourceFactory: () => {
+ throw new Error("missing");
+ },
+ });
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ code: "NO_EVENTSOURCE" }),
+ );
+ expect(typeof result.close).toBe("function");
+ });
+
+ it("trims trailing slashes in baseUrl", () => {
+ const factory = vi.fn(
+ (): WebhookEventStream => ({
+ onmessage: null,
+ onerror: null,
+ close: () => {},
+ }),
+ );
+ connectWebhookStream({
+ apiKey: "k",
+ baseUrl: "https://kit.openiap.dev/",
+ onEvent: () => {},
+ eventSourceFactory: factory,
+ });
+ expect(factory.mock.calls[0][0]).toBe(
+ "https://kit.openiap.dev/v1/webhooks/stream/k",
+ );
+ });
+
+ it("URL-encodes the apiKey", () => {
+ const factory = vi.fn(
+ (): WebhookEventStream => ({
+ onmessage: null,
+ onerror: null,
+ close: () => {},
+ }),
+ );
+ connectWebhookStream({
+ apiKey: "key with spaces & symbols",
+ baseUrl: "http://localhost",
+ onEvent: () => {},
+ eventSourceFactory: factory,
+ });
+ expect(factory.mock.calls[0][0]).toBe(
+ "http://localhost/v1/webhooks/stream/key%20with%20spaces%20%26%20symbols",
+ );
+ });
+});
diff --git a/packages/gql/src/webhook-client.ts b/packages/gql/src/webhook-client.ts
new file mode 100644
index 00000000..ad13e90d
--- /dev/null
+++ b/packages/gql/src/webhook-client.ts
@@ -0,0 +1,312 @@
+// Transport-agnostic webhook client for the openiap kit SSE stream
+// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS
+// wrappers (react-native-iap, expo-iap) but written without React or
+// React-Native imports so it can also run in plain Node, browser, or
+// any other JS runtime.
+//
+// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`
+// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.
+//
+// Parser logic is split out from the connection so it can be unit-
+// tested without a live server. See `webhook-client.test.ts`.
+
+export type WebhookEventType =
+ | "SubscriptionStarted"
+ | "SubscriptionRenewed"
+ | "SubscriptionExpired"
+ | "SubscriptionInGracePeriod"
+ | "SubscriptionInBillingRetry"
+ | "SubscriptionRecovered"
+ | "SubscriptionCanceled"
+ | "SubscriptionUncanceled"
+ | "SubscriptionRevoked"
+ | "SubscriptionPriceChange"
+ | "SubscriptionProductChanged"
+ | "SubscriptionPaused"
+ | "SubscriptionResumed"
+ | "PurchaseRefunded"
+ | "PurchaseConsumptionRequest"
+ | "TestNotification";
+
+export const WEBHOOK_EVENT_TYPES = [
+ "SubscriptionStarted",
+ "SubscriptionRenewed",
+ "SubscriptionExpired",
+ "SubscriptionInGracePeriod",
+ "SubscriptionInBillingRetry",
+ "SubscriptionRecovered",
+ "SubscriptionCanceled",
+ "SubscriptionUncanceled",
+ "SubscriptionRevoked",
+ "SubscriptionPriceChange",
+ "SubscriptionProductChanged",
+ "SubscriptionPaused",
+ "SubscriptionResumed",
+ "PurchaseRefunded",
+ "PurchaseConsumptionRequest",
+ "TestNotification",
+] as const satisfies readonly WebhookEventType[];
+
+export type WebhookEventPayload = {
+ id: string;
+ type: WebhookEventType;
+ source: string;
+ platform: "IOS" | "Android";
+ environment: "Production" | "Sandbox" | "Xcode";
+ projectId: string;
+ occurredAt: number;
+ receivedAt: number;
+ // Optional because TestNotification frames carry no transaction;
+ // every other event type populates this.
+ purchaseToken?: string;
+ productId?: string;
+ subscriptionState?: string;
+ expiresAt?: number;
+ renewsAt?: number;
+ cancellationReason?: string;
+ currency?: string;
+ priceAmountMicros?: number;
+ rawSignedPayload?: string;
+};
+
+export type WebhookListenerOptions = {
+ /**
+ * Project API key. Embedded in the URL path because Apple ASN
+ * registration cannot send custom headers; the same path is reused
+ * here for symmetry.
+ */
+ apiKey: string;
+ /**
+ * Override the kit base URL. Defaults to https://kit.openiap.dev.
+ * In tests, point this at a local server.
+ */
+ baseUrl?: string;
+ /** Called on every successfully-parsed webhook event. */
+ onEvent: (event: WebhookEventPayload) => void;
+ /**
+ * Called on transport errors. The connection auto-reconnects
+ * unconditionally; this callback exists for telemetry / surfacing
+ * to the host UI.
+ */
+ onError?: (error: WebhookListenerError) => void;
+ /**
+ * Optional injection of an EventSource constructor. Lets RN /
+ * Expo plug in `react-native-event-source` when running on a JS
+ * runtime that lacks the global, or vitest plug in a stub.
+ */
+ eventSourceFactory?: (
+ url: string,
+ headers: Record,
+ ) => WebhookEventStream;
+};
+
+export interface WebhookEventStream {
+ close(): void;
+ onmessage: ((event: { data: string; lastEventId?: string }) => void) | null;
+ onerror: ((error: unknown) => void) | null;
+ addEventListener?: (
+ type: string,
+ listener: (event: { data: string; lastEventId?: string }) => void,
+ ) => void;
+}
+
+export type WebhookListener = {
+ /** Tear down the connection and stop receiving events. */
+ close(): void;
+};
+
+export type WebhookListenerError = {
+ code:
+ | "TRANSPORT_ERROR"
+ | "PARSE_ERROR"
+ | "MALFORMED_EVENT"
+ | "NO_EVENTSOURCE";
+ message: string;
+ cause?: unknown;
+};
+
+const DEFAULT_BASE_URL = "https://kit.openiap.dev";
+
+export function connectWebhookStream(
+ options: WebhookListenerOptions,
+): WebhookListener {
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
+ const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;
+
+ const factory = options.eventSourceFactory ?? defaultEventSourceFactory;
+ let stream: WebhookEventStream;
+ try {
+ stream = factory(url, {});
+ } catch (error) {
+ options.onError?.({
+ code: "NO_EVENTSOURCE",
+ message:
+ error instanceof Error
+ ? error.message
+ : "EventSource constructor unavailable in this runtime",
+ cause: error,
+ });
+ return { close: () => {} };
+ }
+
+ const seenIds = new Set();
+ const seenOrder: string[] = [];
+ const markSeen = (id: string): boolean => {
+ if (seenIds.has(id)) {
+ return true;
+ }
+ seenIds.add(id);
+ seenOrder.push(id);
+ if (seenOrder.length > 1024) {
+ const evicted = seenOrder.shift();
+ if (evicted !== undefined) {
+ seenIds.delete(evicted);
+ }
+ }
+ return false;
+ };
+
+ const handleData = (raw: string) => {
+ const parsed = parseWebhookEventData(raw);
+ if (parsed.kind === "error") {
+ options.onError?.({
+ code: "PARSE_ERROR",
+ message: parsed.message,
+ });
+ return;
+ }
+ if (parsed.kind === "skip") {
+ return;
+ }
+ if (markSeen(parsed.event.id)) {
+ return;
+ }
+ options.onEvent(parsed.event);
+ };
+
+ if (typeof stream.addEventListener === "function") {
+ stream.addEventListener("message", (event) => handleData(event.data));
+ // WHATWG EventSource dispatches frames with `event: Foo` only to
+ // listeners registered for `Foo`, not to `message` / `onmessage`.
+ // Kit emits webhook frames as typed SSE events, so subscribe to
+ // every known webhook type and keep `message` for older servers or
+ // polyfills that collapse typed frames into the generic channel.
+ for (const eventType of WEBHOOK_EVENT_TYPES) {
+ stream.addEventListener(eventType, (event) => handleData(event.data));
+ }
+ } else {
+ stream.onmessage = (event) => handleData(event.data);
+ }
+
+ stream.onerror = (error) => {
+ options.onError?.({
+ code: "TRANSPORT_ERROR",
+ message: "SSE transport error (auto-reconnecting)",
+ cause: error,
+ });
+ };
+
+ return {
+ close: () => {
+ try {
+ stream.close();
+ } catch {
+ // Closing an already-closed EventSource is a no-op in browsers
+ // but throws in some polyfills.
+ }
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Pure helpers (exported for testing).
+// ---------------------------------------------------------------------------
+
+export type ParsedEventResult =
+ | { kind: "ok"; event: WebhookEventPayload }
+ | { kind: "skip"; reason: "heartbeat" | "stream-control" }
+ | { kind: "error"; message: string };
+
+export function parseWebhookEventData(raw: string): ParsedEventResult {
+ if (!raw) {
+ return { kind: "skip", reason: "heartbeat" };
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (error) {
+ return {
+ kind: "error",
+ message:
+ error instanceof Error
+ ? `Failed to parse SSE payload: ${error.message}`
+ : "Failed to parse SSE payload",
+ };
+ }
+
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ !("type" in parsed) ||
+ typeof (parsed as Record).type !== "string"
+ ) {
+ // Stream-control messages (the `ready`/`stream-error` envelopes
+ // emitted by the kit server) have no `type` and are surfaced as
+ // skips so consumers don't see them as events.
+ return { kind: "skip", reason: "stream-control" };
+ }
+
+ const event = parsed as WebhookEventPayload;
+
+ if (
+ typeof event.id !== "string" ||
+ typeof event.occurredAt !== "number" ||
+ typeof event.receivedAt !== "number"
+ ) {
+ return {
+ kind: "error",
+ message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,
+ };
+ }
+ // purchaseToken is required for every event type *except*
+ // TestNotification — Apple ASN v2 / Google RTDN test payloads
+ // carry no transaction. Hard-rejecting here would surface valid
+ // test webhooks as MALFORMED_EVENT and never reach listeners.
+ if (
+ event.type !== "TestNotification" &&
+ typeof event.purchaseToken !== "string"
+ ) {
+ return {
+ kind: "error",
+ message: `WebhookEvent missing required field purchaseToken`,
+ };
+ }
+
+ return { kind: "ok", event };
+}
+
+function trimTrailingSlash(url: string): string {
+ return url.endsWith("/") ? url.slice(0, -1) : url;
+}
+
+function defaultEventSourceFactory(
+ url: string,
+ _headers: Record,
+): WebhookEventStream {
+ // EventSource is part of the WHATWG spec and available in all
+ // browser environments and most JS runtimes (Bun, Node 22+, Deno).
+ // RN does not ship it natively — consumers must pass
+ // `eventSourceFactory` from `react-native-sse` or similar.
+ const ctor = (
+ globalThis as {
+ EventSource?: new (url: string) => WebhookEventStream;
+ }
+ ).EventSource;
+ if (!ctor) {
+ throw new Error(
+ "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.",
+ );
+ }
+ return new ctor(url);
+}
diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql
new file mode 100644
index 00000000..b52dd660
--- /dev/null
+++ b/packages/gql/src/webhook.graphql
@@ -0,0 +1,241 @@
+# Server-side lifecycle webhook events normalized across stores.
+#
+# kit (kit.openiap.dev) ingests Apple App Store Server Notifications v2 (ASN v2)
+# and Google Play Real-Time Developer Notifications (RTDN), normalizes them into
+# a unified WebhookEvent shape, and streams them to authenticated clients via the
+# `webhookEvent` GraphQL Subscription.
+#
+# Design goals:
+# - Single event shape regardless of which store fired the notification.
+# - Idempotency: every normalized event has a stable `id` so consumers can dedupe.
+# - Escape hatch: the raw signed payload from the store is preserved in
+# `rawSignedPayload` for consumers that need platform-specific fields.
+
+# What kind of lifecycle change occurred. Mapped from ASN v2 notificationType
+# and RTDN notificationType to a unified vocabulary.
+enum WebhookEventType {
+ """
+ Initial purchase or first conversion from a free trial / intro offer.
+ iOS: SUBSCRIBED (initialBuy / resubscribe).
+ Android: SUBSCRIPTION_PURCHASED.
+ """
+ SubscriptionStarted
+ """
+ Auto-renewal succeeded for an existing subscription.
+ iOS: DID_RENEW.
+ Android: SUBSCRIPTION_RENEWED.
+ """
+ SubscriptionRenewed
+ """
+ Subscription reached its expiration without a successful renewal.
+ iOS: EXPIRED.
+ Android: SUBSCRIPTION_EXPIRED.
+ """
+ SubscriptionExpired
+ """
+ Billing failed; the subscription is in a grace period during which the user
+ retains entitlement while payment is retried.
+ iOS: DID_FAIL_TO_RENEW (with grace period active).
+ Android: SUBSCRIPTION_IN_GRACE_PERIOD.
+ """
+ SubscriptionInGracePeriod
+ """
+ Billing failed and the subscription is in account-hold / billing retry,
+ during which entitlement is paused but the subscription is not yet expired.
+ iOS: DID_FAIL_TO_RENEW (no grace period; billing retry).
+ Android: SUBSCRIPTION_ON_HOLD.
+ """
+ SubscriptionInBillingRetry
+ """
+ Subscription returned to active state after a billing issue or pause.
+ iOS: DID_RECOVER.
+ Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto-
+ renew re-enabled (Uncanceled), not billing recovery.
+ """
+ SubscriptionRecovered
+ """
+ User turned off auto-renew. Access continues until the current period ends.
+ iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
+ Android: SUBSCRIPTION_CANCELED.
+ """
+ SubscriptionCanceled
+ """
+ User reactivated auto-renew before the subscription expired.
+ iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
+ Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
+ """
+ SubscriptionUncanceled
+ """
+ Access immediately revoked (family sharing removal, admin action, fraud).
+ iOS: REVOKE.
+ Android: SUBSCRIPTION_REVOKED.
+ """
+ SubscriptionRevoked
+ """
+ A price change is pending or has been confirmed by the user.
+ iOS: PRICE_INCREASE.
+ Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED.
+ """
+ SubscriptionPriceChange
+ """
+ User upgraded, downgraded, or crossgraded their plan.
+ iOS: DID_CHANGE_RENEWAL_PREF.
+ Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
+ """
+ SubscriptionProductChanged
+ """
+ Subscription paused (Android only feature). Also fired when the
+ pause schedule is changed — RTDN does not have a separate signal.
+ Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11).
+ """
+ SubscriptionPaused
+ """
+ Paused subscription resumed (Android only feature). RTDN signals
+ resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle
+ starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the
+ resume.
+ Android: SUBSCRIPTION_RECOVERED (after pause).
+ """
+ SubscriptionResumed
+ """
+ Refund issued for a one-time purchase or subscription period.
+ iOS: REFUND.
+ Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE.
+ """
+ PurchaseRefunded
+ """
+ iOS-only: App Store requests a consumption status report for a refund decision.
+ Servers should respond via the StoreKit consumption API.
+ """
+ PurchaseConsumptionRequest
+ """
+ Sandbox or test notification fired by the store for diagnostic purposes.
+ Useful for verifying webhook plumbing without a live transaction.
+ """
+ TestNotification
+}
+
+# Which store-side notification system produced this event.
+enum WebhookEventSource {
+ AppleAppStoreServerNotificationsV2
+ GooglePlayRealTimeDeveloperNotifications
+ """
+ Synthetic source for Meta Horizon Store. Meta has no webhook /
+ push notification system so kit polls `verify_entitlement` on a
+ cron and emits these synthetic events when an entitlement
+ transitions. SDK consumers see them on the SSE stream alongside
+ real Apple / Google webhooks.
+ """
+ MetaHorizonReconciler
+}
+
+# Environment of the source notification, as reported by the store.
+enum WebhookEventEnvironment {
+ Production
+ Sandbox
+ Xcode
+}
+
+# Normalized cross-store subscription state derived from the webhook event.
+enum SubscriptionState {
+ Active
+ InGracePeriod
+ InBillingRetry
+ Expired
+ Revoked
+ Refunded
+ Paused
+ Unknown
+}
+
+# Why a subscription was canceled, when applicable.
+enum WebhookCancellationReason {
+ UserCanceled
+ BillingError
+ PriceIncreaseDeclined
+ ProductUnavailable
+ Refunded
+ Other
+}
+
+# A normalized lifecycle event delivered to clients via Subscription.webhookEvent.
+type WebhookEvent {
+ """
+ Stable identifier suitable for idempotency. Derived from the source notification
+ UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
+ otherwise hashed from the canonicalized payload.
+ """
+ id: ID!
+ type: WebhookEventType!
+ source: WebhookEventSource!
+ platform: IapPlatform!
+ """
+ kit project that owns the subscription / purchase this event refers to.
+ """
+ projectId: ID!
+ """
+ Time the underlying event occurred at the store. Epoch milliseconds.
+ """
+ occurredAt: Float!
+ """
+ Time kit ingested and normalized this event. Epoch milliseconds.
+ """
+ receivedAt: Float!
+ environment: WebhookEventEnvironment!
+ """
+ Cross-platform purchase identity used to correlate this event with an existing
+ purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
+ Null for `TestNotification` events (Apple ASN v2 / Google RTDN test
+ payloads carry no transaction); always present for every other event type.
+ """
+ purchaseToken: String
+ """
+ Product the event pertains to. May be null for account-level events.
+ """
+ productId: String
+ """
+ Normalized subscription state at the time of event, when the event refers to
+ a subscription. Null for one-time purchase events.
+ """
+ subscriptionState: SubscriptionState
+ """
+ When the current subscription period ends. Epoch milliseconds.
+ """
+ expiresAt: Float
+ """
+ When auto-renewal will charge again. Epoch milliseconds.
+ """
+ renewsAt: Float
+ """
+ Reason for cancellation, when applicable.
+ """
+ cancellationReason: WebhookCancellationReason
+ """
+ Localized currency code (ISO 4217) at event time, when available.
+ """
+ currency: String
+ """
+ Price in micros (1/1,000,000 of the currency unit) at event time, when available.
+ Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
+ """
+ priceAmountMicros: Float
+ """
+ Original signed payload from the store. ASN v2 events expose the JWS string;
+ RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that
+ consumers can independently verify or extract platform-specific fields. kit
+ always validates this payload before emitting the event.
+ """
+ rawSignedPayload: String
+}
+
+# Note: webhook event transport is intentionally NOT modeled as a
+# GraphQL Subscription / Query field on this spec. The streaming
+# endpoint is a kit-server concern delivered over SSE
+# (`GET /v1/webhooks/stream/{apiKey}`) — see the per-SDK
+# `webhook-client` helpers — and the backfill is a Convex query
+# internal to kit that reads `webhookEvents`. Exposing them here
+# would force every device-side IAP implementation to declare a
+# `webhookEvent` listener / `webhookEventsSince` method even though
+# the actual transport is HTTP, not GraphQL. PR #123 (https://github.com/hyodotdev/openiap/pull/123) review caught
+# the earlier draft surfacing them as required interface methods on
+# the KMP / Dart / Swift IAP classes.
diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile
index 17e712a8..7069242d 100644
--- a/packages/kit/Dockerfile
+++ b/packages/kit/Dockerfile
@@ -16,6 +16,11 @@ COPY packages/gql/package.json ./packages/gql/
COPY packages/apple/package.json ./packages/apple/
COPY packages/google/package.json ./packages/google/
COPY packages/docs/package.json ./packages/docs/
+# `mcp-server` is a workspace member (added in PR #124 (https://github.com/hyodotdev/openiap/pull/124)). Without
+# its package.json bun's workspace resolver disagrees with the
+# root lockfile and `--frozen-lockfile` rejects the install with
+# "lockfile had changes, but lockfile is frozen".
+COPY packages/mcp-server/package.json ./packages/mcp-server/
RUN bun install --frozen-lockfile --filter @hyodotdev/openiap-kit
# --- Build the unified app (Vite SPA + compiled Bun server) --------------
@@ -24,6 +29,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json bun.lock ./
COPY packages/kit ./packages/kit
+# bun 1.3.13 + workspaces installs some deps under each package's
+# local node_modules (e.g. `vite`, `@vitejs/plugin-react`) instead of
+# fully hoisting. Pull kit's local node_modules from the deps stage
+# AFTER the source COPY so it isn't clobbered. Without this the
+# build fails with `vite: command not found` (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review
+# fallout).
+COPY --from=deps /app/packages/kit/node_modules ./packages/kit/node_modules
WORKDIR /app/packages/kit
ARG VITE_KIT_CONVEX_URL
ENV VITE_KIT_CONVEX_URL=${VITE_KIT_CONVEX_URL}
diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts
index 917f69fc..a92cd711 100644
--- a/packages/kit/convex/_generated/api.d.ts
+++ b/packages/kit/convex/_generated/api.d.ts
@@ -17,6 +17,7 @@ import type * as apiKeys_query from "../apiKeys/query.js";
import type * as auth from "../auth.js";
import type * as certificates_apple_root_certificates from "../certificates/apple_root_certificates.js";
import type * as crons from "../crons.js";
+import type * as files_action from "../files/action.js";
import type * as files_internal from "../files/internal.js";
import type * as files_mutation from "../files/mutation.js";
import type * as files_query from "../files/query.js";
@@ -27,10 +28,17 @@ 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";
import type * as plans from "../plans.js";
+import type * as products_asc from "../products/asc.js";
+import type * as products_jwt from "../products/jwt.js";
+import type * as products_mutation from "../products/mutation.js";
+import type * as products_play from "../products/play.js";
+import type * as products_query from "../products/query.js";
+import type * as products_sync from "../products/sync.js";
import type * as projects_helpers from "../projects/helpers.js";
import type * as projects_internal from "../projects/internal.js";
import type * as projects_mutation from "../projects/mutation.js";
import type * as projects_query from "../projects/query.js";
+import type * as projects_setupStatus from "../projects/setupStatus.js";
import type * as purchases_action from "../purchases/action.js";
import type * as purchases_android from "../purchases/android.js";
import type * as purchases_cleanup from "../purchases/cleanup.js";
@@ -44,15 +52,30 @@ import type * as purchases_query from "../purchases/query.js";
import type * as purchases_retry from "../purchases/retry.js";
import type * as purchases_shared from "../purchases/shared.js";
import type * as purchases_stats from "../purchases/stats.js";
+import type * as subscriptions_horizon from "../subscriptions/horizon.js";
+import type * as subscriptions_horizonInternal from "../subscriptions/horizonInternal.js";
+import type * as subscriptions_internal from "../subscriptions/internal.js";
+import type * as subscriptions_monthlyMicros from "../subscriptions/monthlyMicros.js";
+import type * as subscriptions_mutation from "../subscriptions/mutation.js";
+import type * as subscriptions_query from "../subscriptions/query.js";
+import type * as subscriptions_stateMachine from "../subscriptions/stateMachine.js";
+import type * as subscriptions_stats from "../subscriptions/stats.js";
import type * as userProfiles_action from "../userProfiles/action.js";
import type * as userProfiles_internal from "../userProfiles/internal.js";
import type * as userProfiles_mutation from "../userProfiles/mutation.js";
import type * as userProfiles_query from "../userProfiles/query.js";
import type * as users_internal from "../users/internal.js";
import type * as users_query from "../users/query.js";
+import type * as utils_concurrency from "../utils/concurrency.js";
import type * as utils_errors from "../utils/errors.js";
import type * as utils_helpers from "../utils/helpers.js";
import type * as utils_validation from "../utils/validation.js";
+import type * as webhooks_apple from "../webhooks/apple.js";
+import type * as webhooks_google from "../webhooks/google.js";
+import type * as webhooks_internal from "../webhooks/internal.js";
+import type * as webhooks_query from "../webhooks/query.js";
+import type * as webhooks_shared from "../webhooks/shared.js";
+import type * as webhooks_validators from "../webhooks/validators.js";
import type {
ApiFromModules,
@@ -70,6 +93,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
"certificates/apple_root_certificates": typeof certificates_apple_root_certificates;
crons: typeof crons;
+ "files/action": typeof files_action;
"files/internal": typeof files_internal;
"files/mutation": typeof files_mutation;
"files/query": typeof files_query;
@@ -80,10 +104,17 @@ declare const fullApi: ApiFromModules<{
"organizations/mutation": typeof organizations_mutation;
"organizations/query": typeof organizations_query;
plans: typeof plans;
+ "products/asc": typeof products_asc;
+ "products/jwt": typeof products_jwt;
+ "products/mutation": typeof products_mutation;
+ "products/play": typeof products_play;
+ "products/query": typeof products_query;
+ "products/sync": typeof products_sync;
"projects/helpers": typeof projects_helpers;
"projects/internal": typeof projects_internal;
"projects/mutation": typeof projects_mutation;
"projects/query": typeof projects_query;
+ "projects/setupStatus": typeof projects_setupStatus;
"purchases/action": typeof purchases_action;
"purchases/android": typeof purchases_android;
"purchases/cleanup": typeof purchases_cleanup;
@@ -97,15 +128,30 @@ declare const fullApi: ApiFromModules<{
"purchases/retry": typeof purchases_retry;
"purchases/shared": typeof purchases_shared;
"purchases/stats": typeof purchases_stats;
+ "subscriptions/horizon": typeof subscriptions_horizon;
+ "subscriptions/horizonInternal": typeof subscriptions_horizonInternal;
+ "subscriptions/internal": typeof subscriptions_internal;
+ "subscriptions/monthlyMicros": typeof subscriptions_monthlyMicros;
+ "subscriptions/mutation": typeof subscriptions_mutation;
+ "subscriptions/query": typeof subscriptions_query;
+ "subscriptions/stateMachine": typeof subscriptions_stateMachine;
+ "subscriptions/stats": typeof subscriptions_stats;
"userProfiles/action": typeof userProfiles_action;
"userProfiles/internal": typeof userProfiles_internal;
"userProfiles/mutation": typeof userProfiles_mutation;
"userProfiles/query": typeof userProfiles_query;
"users/internal": typeof users_internal;
"users/query": typeof users_query;
+ "utils/concurrency": typeof utils_concurrency;
"utils/errors": typeof utils_errors;
"utils/helpers": typeof utils_helpers;
"utils/validation": typeof utils_validation;
+ "webhooks/apple": typeof webhooks_apple;
+ "webhooks/google": typeof webhooks_google;
+ "webhooks/internal": typeof webhooks_internal;
+ "webhooks/query": typeof webhooks_query;
+ "webhooks/shared": typeof webhooks_shared;
+ "webhooks/validators": typeof webhooks_validators;
}>;
/**
diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts
index 45a59ff5..679a61b6 100644
--- a/packages/kit/convex/crons.ts
+++ b/packages/kit/convex/crons.ts
@@ -1,5 +1,6 @@
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
+import { WEBHOOK_RETENTION_MS } from "./webhooks/internal";
const crons = cronJobs();
@@ -35,4 +36,49 @@ crons.interval(
internal.userProfiles.internal.drainPendingDeletionOrganizations,
);
+// Prune webhook events older than the 30-day retention window so the
+// `webhookEventsSince` backfill query stays bounded. Runs hourly with
+// a small per-tick batch size — webhook traffic is low-volume per
+// project so even a tight batch keeps the table from growing
+// unbounded. Matches the retention promise documented in
+// `packages/gql/src/webhook.graphql`.
+crons.interval(
+ "prune webhook events past retention",
+ { hours: 1 },
+ internal.webhooks.internal.pruneWebhookEvents,
+ { olderThanMs: WEBHOOK_RETENTION_MS },
+);
+
+// Meta Horizon Store has no webhook system — Meta only exposes a
+// synchronous `verify_entitlement` Graph API. We poll every 6h to
+// reconcile Active / InGracePeriod / Paused subscriptions against
+// Meta's authoritative answer, feeding the deltas through the same
+// state machine the Apple/Google webhook receivers use.
+crons.interval(
+ "reconcile horizon entitlements",
+ { hours: 6 },
+ internal.subscriptions.horizon.reconcileHorizonEntitlements,
+ {},
+);
+
+// Daily drift correction for the incrementally-maintained
+// `subscriptionStats` table. The incremental path in
+// applySubscriptionEvent / recordHorizonStatus is correct in steady
+// state, but a missed invocation (action timeout, manual db.patch,
+// schema drift during rollout) can drift the counters. Recomputing
+// the most-stale 100 projects per tick keeps the dashboard self-
+// healing without operator intervention.
+crons.interval(
+ "recompute subscription stats (drift correction)",
+ { hours: 24 },
+ internal.subscriptions.stats.recomputeAllSubscriptionStats,
+ // batchSize=50 projects per daily tick. Each project recompute
+ // runs as its own scheduled mutation (independent 40k document-
+ // read budget), so the picker mutation only does a tiny index
+ // scan + 50 schedule calls. With daily cadence + batchSize=50,
+ // a deployment with up to 1500 projects cycles through every
+ // project at least monthly.
+ { batchSize: 50 },
+);
+
export default crons;
diff --git a/packages/kit/convex/files/action.ts b/packages/kit/convex/files/action.ts
new file mode 100644
index 00000000..6616daf8
--- /dev/null
+++ b/packages/kit/convex/files/action.ts
@@ -0,0 +1,76 @@
+"use node";
+import { action } from "../_generated/server";
+import { v, ConvexError } from "convex/values";
+import { internal } from "../_generated/api";
+import { getAuthUserId } from "@convex-dev/auth/server";
+import type { Id } from "../_generated/dataModel";
+
+// Public action to download an uploaded credential file (Apple .p8 or
+// Google service-account JSON). The dashboard's Settings page calls
+// this so an org admin can re-fetch the original file they uploaded —
+// useful when rotating keys, copying to a new project, or
+// double-checking the file kit holds matches the one in App Store
+// Connect / Play Console.
+//
+// Auth: same admin-or-owner check `files.mutation.remove` enforces.
+// Members can't download because the .p8 / service-account JSON are
+// effectively credentials.
+//
+// Returns the file content as a base64 string so the frontend can
+// reconstruct a Blob and trigger a browser download. We don't return
+// a storage URL because Convex storage URLs are publicly fetchable —
+// the auth check belongs in this action, not on a URL the browser
+// hands to a third-party.
+export const downloadFile = action({
+ args: { fileId: v.id("files") },
+ returns: v.object({
+ fileName: v.string(),
+ mimeType: v.string(),
+ base64: v.string(),
+ }),
+ handler: async (
+ ctx,
+ args,
+ ): Promise<{ fileName: string; mimeType: string; base64: string }> => {
+ const userId: Id<"users"> | null = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new ConvexError("Not authenticated");
+ }
+
+ // The Convex `files` table stores the MIME type in `fileType` (see
+ // `files/internal.ts`). The prior typing pulled `mimeType` and so
+ // every download fell back to `application/octet-stream` — the
+ // dashboard would then build the Blob with the wrong content type
+ // and the browser would mis-handle the .p8 / .json download.
+ const file: {
+ _id: Id<"files">;
+ fileName: string;
+ organizationId: Id<"organizations">;
+ fileType?: string;
+ } | null = await ctx.runQuery(internal.files.internal.getFileRecord, {
+ fileId: args.fileId,
+ });
+ if (!file) {
+ throw new ConvexError("File not found");
+ }
+
+ const membership = await ctx.runQuery(
+ internal.organizations.internal.getMembership,
+ { userId, organizationId: file.organizationId },
+ );
+ if (!membership || membership.role === "member") {
+ throw new ConvexError("Insufficient permissions");
+ }
+
+ const result: { content: string; fileName: string } = await ctx.runAction(
+ internal.files.internal.readFileAsBase64,
+ { fileId: args.fileId },
+ );
+
+ return {
+ fileName: result.fileName,
+ mimeType: file.fileType ?? "application/octet-stream",
+ base64: result.content,
+ };
+ },
+});
diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts
index b812595a..f3ca9d82 100644
--- a/packages/kit/convex/files/internal.ts
+++ b/packages/kit/convex/files/internal.ts
@@ -3,7 +3,7 @@ import {
internalMutation,
internalAction,
} from "../_generated/server";
-import type { Doc } from "../_generated/dataModel";
+import type { Doc, Id } from "../_generated/dataModel";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
import { internal } from "../_generated/api";
@@ -208,6 +208,7 @@ export const findFilesByPurpose = internalQuery({
organizationId: v.id("organizations"),
purpose: v.union(
v.literal("apple_p8_key"),
+ v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
),
},
@@ -298,6 +299,58 @@ export const getAppleP8Key = internalAction({
},
});
+// Internal action to get the App Store Connect API key (.p8). This is
+// a different key than `getAppleP8Key` returns — see schema.ts for the
+// distinction. Used by `products/asc.ts` push-sync.
+export const getAppleAscApiKey = internalAction({
+ args: {
+ organizationId: v.id("organizations"),
+ projectId: v.optional(v.id("projects")),
+ },
+ handler: async (
+ ctx,
+ args,
+ ): Promise<{
+ keyContent: string;
+ metadata: unknown;
+ fileId: Id<"files">;
+ }> => {
+ const files = await ctx.runQuery(
+ internal.files.internal.findFilesByPurpose,
+ {
+ organizationId: args.organizationId,
+ purpose: "apple_p8_asc_api_key",
+ },
+ );
+
+ let targetFile = files[0];
+ if (args.projectId) {
+ const projectFiles = files.filter(
+ (f: FilePublicProjection) => f.projectId === args.projectId,
+ );
+ targetFile = projectFiles[0] || files[0];
+ }
+
+ if (!targetFile) {
+ throw new ConvexError(
+ "No App Store Connect API key (.p8) uploaded for this project — generate one at App Store Connect → Users and Access → Integrations → App Store Connect API and upload it in Settings.",
+ );
+ }
+ const content = await ctx.runAction(
+ internal.files.internal.readFileAsText,
+ {
+ fileId: targetFile._id,
+ },
+ );
+
+ return {
+ keyContent: content.content,
+ metadata: content.metadata,
+ fileId: targetFile._id,
+ };
+ },
+});
+
// Internal mutation to cleanup old files
export const cleanupOldFiles = internalMutation({
args: {
@@ -317,8 +370,12 @@ export const cleanupOldFiles = internalMutation({
let deletedCount = 0;
for (const file of files) {
- // Don't delete internal files or keys
- if (file.isInternal || file.purpose === "apple_p8_key") {
+ // Don't delete internal files or keys (both Apple .p8 kinds).
+ if (
+ file.isInternal ||
+ file.purpose === "apple_p8_key" ||
+ file.purpose === "apple_p8_asc_api_key"
+ ) {
continue;
}
diff --git a/packages/kit/convex/files/mutation.ts b/packages/kit/convex/files/mutation.ts
index 5f49e43c..de7c0d0d 100644
--- a/packages/kit/convex/files/mutation.ts
+++ b/packages/kit/convex/files/mutation.ts
@@ -13,6 +13,7 @@ export const saveFile = mutation({
fileSize: v.number(),
purpose: v.union(
v.literal("apple_p8_key"),
+ v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
),
description: v.optional(v.string()),
diff --git a/packages/kit/convex/files/query.ts b/packages/kit/convex/files/query.ts
index 812ac7c5..e22a0862 100644
--- a/packages/kit/convex/files/query.ts
+++ b/packages/kit/convex/files/query.ts
@@ -9,7 +9,11 @@ export const list = query({
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
purpose: v.optional(
- v.union(v.literal("apple_p8_key"), v.literal("android_service_account")),
+ v.union(
+ v.literal("apple_p8_key"),
+ v.literal("apple_p8_asc_api_key"),
+ v.literal("android_service_account"),
+ ),
),
},
handler: async (ctx, args) => {
@@ -151,7 +155,11 @@ export const count = query({
args: {
organizationId: v.id("organizations"),
purpose: v.optional(
- v.union(v.literal("apple_p8_key"), v.literal("android_service_account")),
+ v.union(
+ v.literal("apple_p8_key"),
+ v.literal("apple_p8_asc_api_key"),
+ v.literal("android_service_account"),
+ ),
),
},
handler: async (ctx, args) => {
@@ -241,6 +249,50 @@ export const getAppStoreFileByProject = query({
},
});
+// Get the App Store Connect API key (.p8) by project. Genuinely a
+// different file from `getAppStoreFileByProject` — see schema.ts.
+// Used by `products/asc.ts` for push-sync.
+export const getAscApiKeyFileByProject = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ return null;
+ }
+ const project = await ctx.db.get(args.projectId);
+ if (!project) {
+ return null;
+ }
+ const membership = await ctx.db
+ .query("organizationMembers")
+ .withIndex("by_org_and_user", (q) =>
+ q.eq("organizationId", project.organizationId).eq("userId", userId),
+ )
+ .first();
+ if (!membership) {
+ return null;
+ }
+ const projectFiles = await ctx.db
+ .query("files")
+ .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
+ .collect();
+ const ascFile = projectFiles.find(
+ (f) => f.purpose === "apple_p8_asc_api_key",
+ );
+ if (!ascFile) {
+ return null;
+ }
+ return {
+ _id: ascFile._id,
+ fileName: ascFile.fileName,
+ fileSize: ascFile.fileSize,
+ uploadedAt: ascFile.createdAt,
+ };
+ },
+});
+
// Get Google Play file by project
export const getGooglePlayFileByProject = query({
args: {
diff --git a/packages/kit/convex/migrations.ts b/packages/kit/convex/migrations.ts
index 2dd1f05d..76ef9637 100644
--- a/packages/kit/convex/migrations.ts
+++ b/packages/kit/convex/migrations.ts
@@ -6,7 +6,6 @@ import {
extractProductIdFromRemoteResponse,
isValidState,
} from "./purchases/shared.js";
-import { HarmonizedPurchaseState } from "./purchases/purchaseState.js";
import {
applyPurchaseStatsDelta,
deltaForInsert,
@@ -69,7 +68,7 @@ export const removeLegacyProfileFields = migrations.define({
export const replaceIsAuthenticWithIsValid = migrations.define({
table: "purchases",
migrateOne: async (_ctx, doc) => {
- const isValid = isValidState(doc.state as HarmonizedPurchaseState);
+ const isValid = isValidState(doc.state);
return {
...doc,
@@ -226,7 +225,7 @@ export const backfillPurchaseProductIds = migrations.define({
* destructive step deliberately kept out of the migration runner. See
* `collapseDuplicatePurchasesByOrderId` in
* [convex/purchases/cleanup.ts](convex/purchases/cleanup.ts) and the
- * deploy sequence in PR #10 for the recommended order of operations.
+ * deploy sequence in PR #10 (https://github.com/hyodotdev/openiap/pull/10) for the recommended order of operations.
*/
export const backfillPurchaseOrderIds = migrations.define({
table: "purchases",
diff --git a/packages/kit/convex/organizations/internal.ts b/packages/kit/convex/organizations/internal.ts
index 62479aee..f796586c 100644
--- a/packages/kit/convex/organizations/internal.ts
+++ b/packages/kit/convex/organizations/internal.ts
@@ -104,6 +104,37 @@ export const organizationExists = internalQuery({
},
});
+// Lookup helper used by Convex actions that need to gate on
+// organization membership without dragging the full org schema into
+// the public mutation surface. Returns just the role so the caller
+// can do `role === "member"` checks.
+export const getMembership = internalQuery({
+ args: {
+ userId: v.id("users"),
+ organizationId: v.id("organizations"),
+ },
+ returns: v.union(
+ v.null(),
+ v.object({
+ role: v.union(
+ v.literal("owner"),
+ v.literal("admin"),
+ v.literal("member"),
+ ),
+ }),
+ ),
+ handler: async (ctx, args) => {
+ const membership = await ctx.db
+ .query("organizationMembers")
+ .withIndex("by_org_and_user", (q) =>
+ q.eq("organizationId", args.organizationId).eq("userId", args.userId),
+ )
+ .first();
+ if (!membership) return null;
+ return { role: membership.role };
+ },
+});
+
export async function recordVerificationUsageForOrganization(
ctx: MutationCtx,
organization: Doc<"organizations">,
diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts
new file mode 100644
index 00000000..a22fe69e
--- /dev/null
+++ b/packages/kit/convex/products/asc.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ mapAscOfferDurationToIso,
+ mapAscOfferKind,
+ mapBillingPeriodToAsc,
+ parseIntroOffers,
+ pickActivePriceRow,
+ pickPricePointIdMatching,
+} from "./asc";
+
+describe("pickPricePointIdMatching", () => {
+ const list = {
+ data: [
+ {
+ id: "tier-29",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: { customerPrice: "0.29" },
+ },
+ {
+ id: "tier-99",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: { customerPrice: "0.99" },
+ },
+ {
+ id: "tier-999",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: { customerPrice: "9.99" },
+ },
+ {
+ id: "tier-9999",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: { customerPrice: "99.99" },
+ },
+ {
+ id: "tier-malformed",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: { customerPrice: "abc" },
+ },
+ {
+ id: "tier-empty",
+ type: "inAppPurchasePricePoints" as const,
+ attributes: {},
+ },
+ ],
+ };
+
+ it("returns null when the catalog response is null", () => {
+ expect(pickPricePointIdMatching(null, 9_990_000)).toBeNull();
+ });
+
+ it("returns null when no tier matches the requested USD amount", () => {
+ expect(pickPricePointIdMatching(list, 1_500_000)).toBeNull();
+ });
+
+ it("matches an exact tier on the cent boundary", () => {
+ expect(pickPricePointIdMatching(list, 9_990_000)).toBe("tier-999");
+ expect(pickPricePointIdMatching(list, 290_000)).toBe("tier-29");
+ expect(pickPricePointIdMatching(list, 99_990_000)).toBe("tier-9999");
+ });
+
+ it("absorbs one-cent floating-point drift in the requested amount", () => {
+ expect(pickPricePointIdMatching(list, 9_989_999)).toBe("tier-999");
+ expect(pickPricePointIdMatching(list, 9_985_000)).toBe("tier-999");
+ });
+
+ it("skips malformed and missing customerPrice rows", () => {
+ expect(pickPricePointIdMatching(list, 0)).toBeNull();
+ });
+});
+
+describe("mapBillingPeriodToAsc", () => {
+ it.each([
+ ["P1W", "ONE_WEEK"],
+ ["P1M", "ONE_MONTH"],
+ ["P2M", "TWO_MONTHS"],
+ ["P3M", "THREE_MONTHS"],
+ ["P6M", "SIX_MONTHS"],
+ ["P1Y", "ONE_YEAR"],
+ ] as const)("maps %s → %s", (iso, asc) => {
+ expect(mapBillingPeriodToAsc(iso)).toBe(asc);
+ });
+
+ it("defaults undefined / unknown periods to ONE_MONTH so push doesn't silently drop the picker", () => {
+ expect(mapBillingPeriodToAsc(undefined)).toBe("ONE_MONTH");
+ // Unknown periods throw — silently coercing to ONE_MONTH used
+ // to provision the wrong subscription duration in ASC, which is
+ // much harder to unwind than a failed sync. The throw is caught
+ // inside processOneDraft and recorded as a per-row failure.
+ const wider = mapBillingPeriodToAsc as (
+ period: string | undefined,
+ ) => string;
+ expect(() => wider("P9X")).toThrow(/Invalid billing period/);
+ });
+});
+
+describe("mapAscOfferDurationToIso", () => {
+ it.each([
+ ["THREE_DAYS", "P3D"],
+ ["ONE_WEEK", "P1W"],
+ ["TWO_WEEKS", "P2W"],
+ ["ONE_MONTH", "P1M"],
+ ["TWO_MONTHS", "P2M"],
+ ["THREE_MONTHS", "P3M"],
+ ["SIX_MONTHS", "P6M"],
+ ["ONE_YEAR", "P1Y"],
+ ])("normalizes ASC enum %s → ISO %s", (asc, iso) => {
+ expect(mapAscOfferDurationToIso(asc)).toBe(iso);
+ });
+
+ it("returns undefined when no input", () => {
+ expect(mapAscOfferDurationToIso(undefined)).toBeUndefined();
+ });
+
+ it("passes unknown enum values through unchanged so future Apple values still render", () => {
+ expect(mapAscOfferDurationToIso("FOUR_MOONS")).toBe("FOUR_MOONS");
+ });
+});
+
+describe("mapAscOfferKind", () => {
+ it.each([
+ ["FREE_TRIAL", "FreeTrial"],
+ ["PAY_UP_FRONT", "IntroPayUpFront"],
+ ["PAY_AS_YOU_GO", "IntroPayAsYouGo"],
+ ] as const)("maps %s → %s", (mode, kind) => {
+ expect(mapAscOfferKind(mode)).toBe(kind);
+ });
+
+ it("falls back to FreeTrial for unknown / undefined modes", () => {
+ expect(mapAscOfferKind(undefined)).toBe("FreeTrial");
+ expect(mapAscOfferKind("UNKNOWN")).toBe("FreeTrial");
+ });
+});
+
+describe("pickActivePriceRow", () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const yesterday = new Date(Date.now() - 86_400_000)
+ .toISOString()
+ .slice(0, 10);
+ const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
+
+ it("returns null for empty input", () => {
+ expect(pickActivePriceRow([])).toBeNull();
+ });
+
+ it("picks the row whose date window covers today", () => {
+ const rows = [
+ { id: "future", attributes: { startDate: tomorrow, endDate: null } },
+ { id: "active", attributes: { startDate: yesterday, endDate: null } },
+ ];
+ expect(pickActivePriceRow(rows)?.id).toBe("active");
+ });
+
+ it("treats null start / end as open bounds", () => {
+ const rows = [
+ { id: "open", attributes: { startDate: null, endDate: null } },
+ ];
+ expect(pickActivePriceRow(rows)?.id).toBe("open");
+ });
+
+ it("rejects rows whose endDate has already passed", () => {
+ const rows = [
+ {
+ id: "expired",
+ attributes: { startDate: yesterday, endDate: yesterday },
+ },
+ { id: "active", attributes: { startDate: yesterday, endDate: tomorrow } },
+ ];
+ expect(pickActivePriceRow(rows)?.id).toBe("active");
+ });
+
+ it("falls back to the first row when no window covers today (defensive default)", () => {
+ const rows = [
+ { id: "future-a", attributes: { startDate: tomorrow, endDate: null } },
+ { id: "future-b", attributes: { startDate: tomorrow, endDate: null } },
+ ];
+ expect(pickActivePriceRow(rows)?.id).toBe("future-a");
+ });
+
+ it("accepts a row whose startDate equals today (only strictly-future startDates are rejected)", () => {
+ const rows = [
+ { id: "starts-today", attributes: { startDate: today, endDate: null } },
+ ];
+ expect(pickActivePriceRow(rows)?.id).toBe("starts-today");
+ });
+});
+
+describe("parseIntroOffers", () => {
+ const today = new Date().toISOString().slice(0, 10);
+
+ it("returns [] when no response or empty data", () => {
+ expect(parseIntroOffers(null)).toEqual([]);
+ expect(parseIntroOffers({ data: [] })).toEqual([]);
+ });
+
+ it("parses a free-trial offer (no pricePoint, just duration)", () => {
+ const out = parseIntroOffers({
+ data: [
+ {
+ id: "offer-free",
+ type: "subscriptionIntroductoryOffers" as const,
+ attributes: {
+ offerMode: "FREE_TRIAL",
+ duration: "ONE_WEEK",
+ numberOfPeriods: 1,
+ startDate: today,
+ endDate: null,
+ },
+ relationships: {},
+ },
+ ],
+ });
+ expect(out).toEqual([
+ {
+ id: "offer-free",
+ kind: "FreeTrial",
+ duration: "P1W",
+ numberOfPeriods: 1,
+ priceAmountMicros: undefined,
+ currency: undefined,
+ },
+ ]);
+ });
+
+ it("parses a pay-up-front intro with included pricePoint", () => {
+ const out = parseIntroOffers({
+ data: [
+ {
+ id: "offer-paid",
+ type: "subscriptionIntroductoryOffers" as const,
+ attributes: {
+ offerMode: "PAY_UP_FRONT",
+ duration: "THREE_MONTHS",
+ numberOfPeriods: 1,
+ },
+ relationships: {
+ subscriptionPricePoint: {
+ data: { id: "pp-99" },
+ },
+ },
+ },
+ ],
+ included: [
+ {
+ id: "pp-99",
+ type: "subscriptionPricePoints" as const,
+ attributes: { customerPrice: "0.99" },
+ },
+ ],
+ });
+ expect(out).toEqual([
+ {
+ id: "offer-paid",
+ kind: "IntroPayUpFront",
+ duration: "P3M",
+ numberOfPeriods: 1,
+ priceAmountMicros: 990_000,
+ currency: "USD",
+ },
+ ]);
+ });
+
+ it("filters out offers whose date window doesn't cover today", () => {
+ const future = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
+ const out = parseIntroOffers({
+ data: [
+ {
+ id: "offer-future",
+ type: "subscriptionIntroductoryOffers" as const,
+ attributes: {
+ offerMode: "FREE_TRIAL",
+ duration: "ONE_WEEK",
+ startDate: future,
+ endDate: null,
+ },
+ },
+ ],
+ });
+ expect(out).toEqual([]);
+ });
+});
diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts
new file mode 100644
index 00000000..c25eda37
--- /dev/null
+++ b/packages/kit/convex/products/asc.ts
@@ -0,0 +1,1798 @@
+"use node";
+import { v } from "convex/values";
+
+import { action, type ActionCtx } from "../_generated/server";
+import { internal } from "../_generated/api";
+import { getProjectByApiKey } from "../purchases/shared";
+import { mapWithConcurrency } from "../utils/concurrency";
+import { mintAscJwt } from "./jwt";
+import { coerceBillingPeriod } from "./sync";
+
+// Resolve App Store Connect API credentials (issuer ID + key ID + .p8
+// key content) for a project. Centralized so the two action handlers
+// (pushSyncProductsAppleIOS and listSubscriptionGroupsAppleIOS) share
+// one source of truth — both have to honor the same pair-resolution
+// rule (never mix new ASC slot with legacy Server API slot) and the
+// same .p8 fallback (dedicated ASC slot first, then legacy single
+// slot for projects mid-migration). Throws on missing config or
+// missing .p8 with the operator-actionable message we want surfaced.
+type AscCredentials = {
+ issuerId: string;
+ keyId: string;
+ keyContent: string;
+};
+async function resolveAscCredentials(
+ ctx: ActionCtx,
+ project: Awaited>,
+ options: { detailedErrors?: boolean } = {},
+): Promise {
+ // Resolve as a *pair* — never mix the new ASC Issuer ID with the
+ // legacy Server API Key ID (or vice versa). If only one of the
+ // new fields is populated the operator is mid-migration; in that
+ // case fall back to the legacy pair entirely so we don't sign a
+ // request with mismatched identifiers Apple will reject as 401.
+ const useAsc = project.iosAscIssuerId && project.iosAscKeyId;
+ const issuerId = useAsc
+ ? project.iosAscIssuerId
+ : project.iosAppStoreIssuerId;
+ const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId;
+ if (!issuerId || !keyId) {
+ throw new Error(
+ options.detailedErrors
+ ? "App Store Connect API Issuer ID / Key ID not configured. " +
+ "Generate them at App Store Connect → Users and Access → " +
+ "Integrations → App Store Connect API (NOT under In-App " +
+ "Purchase — those credentials are scoped to receipt " +
+ "verification only). Save them in Settings → iOS " +
+ "Configuration → 'App Store Connect API (push-sync)'."
+ : "App Store Connect API Issuer ID / Key ID not configured",
+ );
+ }
+ // Prefer the dedicated ASC .p8 file; fall back to the Server API
+ // .p8 when the user has only uploaded one. The wrong-kind hint
+ // from `call()` will tell them to upload a Team Key if Apple
+ // rejects whichever they have.
+ let keyContent: string | undefined;
+ try {
+ const ascKey = await ctx.runAction(
+ internal.files.internal.getAppleAscApiKey,
+ {
+ organizationId: project.organizationId,
+ projectId: project._id,
+ },
+ );
+ keyContent = ascKey?.keyContent;
+ } catch (error) {
+ // Only swallow the documented "no ASC key uploaded" case so we
+ // can fall through to the legacy slot. Storage / permission /
+ // transient errors must surface — masking them as "use legacy
+ // key" hides the real failure and ends up signing requests with
+ // the wrong key, producing confusing 401s downstream.
+ //
+ // The action throws a ConvexError whose message starts with
+ // "No App Store Connect API key (.p8) uploaded" when the file is
+ // missing. Anything else rethrows.
+ const message = error instanceof Error ? error.message : String(error);
+ if (!message.includes("No App Store Connect API key (.p8) uploaded")) {
+ throw error;
+ }
+ }
+ if (!keyContent) {
+ const legacyKey = await ctx.runAction(
+ internal.files.internal.getAppleP8Key,
+ {
+ organizationId: project.organizationId,
+ projectId: project._id,
+ },
+ );
+ keyContent = legacyKey?.keyContent;
+ }
+ if (!keyContent) {
+ throw new Error(
+ options.detailedErrors
+ ? "App Store Connect API key (.p8) not uploaded — generate one " +
+ "at App Store Connect → Users and Access → Integrations → " +
+ "App Store Connect API → Team Keys and upload it in Settings."
+ : "App Store Connect API key (.p8) not uploaded",
+ );
+ }
+ return { issuerId, keyId, keyContent };
+}
+
+// App Store Connect REST client + push-sync action.
+//
+// Auth: every request carries a freshly-minted ES256 JWT signed with
+// the project's `.p8` key (already stored for App Store Server API
+// reuse). Token TTL is 600s with a 60s safety margin before expiry.
+//
+// Surface area implemented (matches what `@onesub/providers` exposes):
+// - listInAppPurchases(appId) → GET /v1/apps/{id}/inAppPurchasesV2
+// - createInAppPurchase(args) → POST /v1/inAppPurchases
+// - patchInAppPurchase(id,...) → PATCH /v1/inAppPurchases/{id}
+// - listSubscriptionGroups(appId) → GET /v1/apps/{id}/subscriptionGroups
+// - listSubscriptions(groupId) → GET /v1/subscriptionGroups/{id}/subscriptions
+// - createSubscription(...) → POST /v1/subscriptions
+// - patchSubscription(...) → PATCH /v1/subscriptions/{id}
+// The `pushSyncProducts` action drives kit→ASC sync for a project.
+//
+// Failure model: ASC returns an `errors[]` array per the JSON:API
+// spec; we throw the response status + the first error's `detail` so
+// the dashboard / MCP / SDK surfaces a useful message instead of
+// "fetch failed".
+
+const ASC_BASE = "https://api.appstoreconnect.apple.com";
+const ASC_FETCH_TIMEOUT_MS = 30_000;
+
+type AscToken = { value: string; expiresAt: number };
+
+/**
+ * Thrown by `AscClient.call` on any non-OK ASC response. The status
+ * code is preserved so callers can branch on it — e.g. ignore 409
+ * Conflict on retried `createSubLocalization` / `createIapLocalization`
+ * pushes (the upstream resource already exists, the next step still
+ * applies). Earlier behaviour threw a generic `Error` and forced the
+ * caller to substring-match the message; this is the typed version.
+ */
+export class AscApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "AscApiError";
+ }
+}
+
+class AscClient {
+ private cached: AscToken | null = null;
+
+ constructor(
+ private readonly issuerId: string,
+ private readonly keyId: string,
+ private readonly privateKey: string,
+ ) {}
+
+ private async token(): Promise {
+ const now = Math.floor(Date.now() / 1000);
+ if (this.cached && this.cached.expiresAt - now > 60) {
+ return this.cached.value;
+ }
+ const value = mintAscJwt({
+ issuerId: this.issuerId,
+ keyId: this.keyId,
+ privateKey: this.privateKey,
+ ttlSeconds: 600,
+ });
+ this.cached = { value, expiresAt: now + 600 };
+ return value;
+ }
+
+ private async call(
+ path: string,
+ init: RequestInit & { body?: string } = {},
+ ): Promise {
+ // Per-request timeout. ASC's REST surface is generally responsive
+ // (<1s for reads, 1-3s for writes), so 30s is a generous bound
+ // that catches a hung upstream long before the surrounding
+ // Convex action's 10-min ceiling. Without this, a single hung
+ // request can stall the entire push-sync pass — ASC has no
+ // server-sent keepalive on the REST endpoints.
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ASC_FETCH_TIMEOUT_MS);
+ let response: Response;
+ try {
+ response = await fetch(`${ASC_BASE}${path}`, {
+ ...init,
+ signal: controller.signal,
+ headers: {
+ authorization: `Bearer ${await this.token()}`,
+ ...(init.body ? { "content-type": "application/json" } : {}),
+ accept: "application/json",
+ ...(init.headers as Record | undefined),
+ },
+ });
+ } finally {
+ clearTimeout(timer);
+ }
+ const text = await response.text();
+ let parsed: unknown = text;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ // leave as text
+ }
+ }
+ if (!response.ok) {
+ const errorMessage = extractAscError(parsed);
+ // Apple's 401 is the same generic "Provide a properly configured
+ // and signed bearer token" for several distinct failure modes,
+ // and the most common one — uploading the In-App Purchase Key
+ // instead of the App Store Connect API (Team / Individual) Key
+ // — looks indistinguishable from "expired token" or "wrong
+ // signature" without context. Surface a targeted hint so the
+ // operator stops debugging the JWT and starts checking the
+ // *kind* of key they uploaded.
+ const message =
+ response.status === 401
+ ? `ASC ${path} returned 401: ${errorMessage}\n` +
+ "HINT: ASC REST endpoints (/v1/apps/.../inAppPurchasesV2, " +
+ "subscriptionGroups, …) require the App Store Connect API " +
+ "Team Key (or Individual Key) — found under Users and " +
+ "Access → Integrations → App Store Connect API. The " +
+ "In-App Purchase Key (Users and Access → Integrations → " +
+ "In-App Purchase) is a different key and only works for " +
+ "the App Store Server API (receipt verification). Both " +
+ "are .p8 files but Apple scopes them separately. Re-upload " +
+ "the .p8 generated under 'App Store Connect API' and use " +
+ "ITS Issuer ID + Key ID in the dashboard."
+ : `ASC ${path} returned ${response.status}: ${errorMessage}`;
+ // Use a typed AscApiError so callers can branch on
+ // `.status === 409` to ignore "already exists" replays during
+ // retried localization / price-schedule pushes (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ throw new AscApiError(response.status, message);
+ }
+ return parsed as T;
+ }
+
+ // ASC list endpoints cap at 200 items per page. For accounts with
+ // larger catalogs we have to follow `links.next` until absent or
+ // pages > 200 (= 40k items, more than ASC actually allows per app
+ // — the bound just prevents a runaway loop on unexpected response
+ // shapes). Without pagination, accounts above the page limit silently
+ // lose products from kit's catalog.
+ async listInAppPurchases(appId: string): Promise {
+ return this.collectAllPages(
+ `/v1/apps/${encodeURIComponent(appId)}/inAppPurchasesV2?limit=200`,
+ );
+ }
+
+ async listSubscriptionGroups(
+ appId: string,
+ ): Promise {
+ return this.collectAllPages(
+ `/v1/apps/${encodeURIComponent(appId)}/subscriptionGroups?limit=200`,
+ );
+ }
+
+ async listSubscriptionsInGroup(groupId: string): Promise {
+ return this.collectAllPages(
+ `/v1/subscriptionGroups/${encodeURIComponent(groupId)}/subscriptions?limit=200`,
+ );
+ }
+
+ // Generic JSON:API paginator. ASC returns `{ data: [...],
+ // links: { self, next? } }` — we follow `next` (the cursor URL is
+ // absolute, so we hand it straight back to fetch via `call`'s base
+ // join logic). Capped at 200 pages as a runaway guard.
+ private async collectAllPages(
+ initialPath: string,
+ ): Promise<{ data: T[] }> {
+ const merged: T[] = [];
+ let path: string | null = initialPath;
+ let pages = 0;
+ while (path && pages < 200) {
+ const page: { data: T[]; links?: { next?: string } } =
+ await this.call(path);
+ merged.push(...page.data);
+ const nextUrl = page.links?.next ?? null;
+ path = nextUrl ? this.relativizePath(nextUrl) : null;
+ pages += 1;
+ }
+ return { data: merged };
+ }
+
+ // ASC `links.next` is fully qualified (`https://api.appstoreconnect…`).
+ // `call()` already prepends ASC_BASE, so strip the host before
+ // passing it back in.
+ private relativizePath(absoluteOrRelative: string): string {
+ if (absoluteOrRelative.startsWith(ASC_BASE)) {
+ return absoluteOrRelative.slice(ASC_BASE.length);
+ }
+ return absoluteOrRelative;
+ }
+
+ // Introductory offer attached to a subscription. Apple allows at
+ // most ONE introductoryOffer per subscription per territory at a
+ // time — the prior `pay-up-front $0.99 for 3 months` is replaced
+ // when you publish a new one. We pull the USA territory's active
+ // offer (if any) so the dashboard can render badges like
+ // "7-day free trial" / "$0.99 intro for 3 months". Returns Error
+ // so the caller can append a failure row instead of silently
+ // dropping offer metadata.
+ async subIntroductoryOffer(
+ subId: string,
+ ): Promise {
+ try {
+ return await this.call(
+ `/v1/subscriptions/${encodeURIComponent(subId)}/introductoryOffers?filter[territory]=USA&include=subscriptionPricePoint&limit=10`,
+ );
+ } catch (error) {
+ return error instanceof Error ? error : new Error(String(error));
+ }
+ }
+
+ // Per-product *configured* USA price. The naive
+ // `/{type}/{id}/pricePoints?filter[territory]=USA&limit=1` endpoint
+ // returns the entire USA *price matrix* (every tier the catalog
+ // offers — $0.29, $0.49, $0.99, …), not the price the operator
+ // assigned to the product, so `limit=1` always pinned the lowest
+ // tier and every IAP / sub showed up as $0.29. The actual assigned
+ // price lives on a different relationship — `iapPriceSchedule` for
+ // one-time IAPs, `prices` for subscriptions — with the matching
+ // pricePoint side-loaded via `include`.
+ // Returns either the price response or an Error so the caller can
+ // surface the actual ASC reason (404, 403, malformed schedule, …)
+ // through the sync result's `failures` array — silently swallowing
+ // these is what made one-time IAPs show "—" with no diagnostic.
+ async iapCurrentPrice(
+ iapId: string,
+ ): Promise {
+ // v2 IAPs expose the price-schedule relationship under `/v2/`
+ // (the per-resource endpoints moved with the V2 catalog), even
+ // though the catalog list is `/v1/apps/{id}/inAppPurchasesV2`
+ // and the JSON:API resource type is still `"inAppPurchases"`. The
+ // older `/v1/inAppPurchases/{id}/iapPriceSchedule` 404s with
+ // "relationship 'iapPriceSchedule' does not exist" because that
+ // path resolves to the legacy V1 IAP resource which has no such
+ // relationship. The downstream `manualPrices` collection lookup
+ // stays on `/v1/inAppPurchasePriceSchedules/...`.
+ try {
+ const schedule = await this.call(
+ `/v2/inAppPurchases/${encodeURIComponent(iapId)}/iapPriceSchedule`,
+ );
+ if (!schedule?.data?.id) {
+ return new Error(
+ "iapPriceSchedule returned no data — IAP has no price schedule yet",
+ );
+ }
+ const manual = await this.call(
+ `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/manualPrices?filter[territory]=USA&include=inAppPurchasePricePoint`,
+ );
+ // When the IAP uses Apple's equalized auto-pricing instead of
+ // per-territory manual prices, `manualPrices` comes back empty
+ // and the assigned USA price actually lives on the parallel
+ // `automaticPrices` collection (same envelope shape).
+ if (manual.data.length === 0) {
+ return await this.call(
+ `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/automaticPrices?filter[territory]=USA&include=inAppPurchasePricePoint`,
+ );
+ }
+ return manual;
+ } catch (error) {
+ return error instanceof Error ? error : new Error(String(error));
+ }
+ }
+ async subCurrentPrice(
+ subId: string,
+ ): Promise {
+ try {
+ return await this.call(
+ `/v1/subscriptions/${encodeURIComponent(subId)}/prices?filter[territory]=USA&include=subscriptionPricePoint`,
+ );
+ } catch (error) {
+ return error instanceof Error ? error : new Error(String(error));
+ }
+ }
+
+ // Find a USA price-point id whose `customerPrice` matches the
+ // requested USD amount. Apple manages prices via opaque tier ids
+ // (eyJ...) — to set a price you can't just send "9.99", you must
+ // pass the price-point resource id corresponding to that tier in
+ // USA. We fetch the catalog once per (resource, amount) lookup.
+ //
+ // Errors propagate verbatim so the call site can distinguish
+ // "no tier matches USD 9.99" (returns null after a successful
+ // list) from "ASC returned 401 / 429 / timeout" (throws). The
+ // prior `.catch(() => null)` collapsed both into the same null
+ // result and surfaced a real upstream failure as a bogus catalog
+ // validation error.
+ async findIapUsaPricePointId(
+ iapId: string,
+ targetMicros: number,
+ ): Promise {
+ const list = await this.call(
+ `/v1/inAppPurchases/${encodeURIComponent(iapId)}/pricePoints?filter[territory]=USA&limit=200`,
+ );
+ return pickPricePointIdMatching(list, targetMicros);
+ }
+ async findSubUsaPricePointId(
+ subId: string,
+ targetMicros: number,
+ ): Promise {
+ const list = await this.call(
+ `/v1/subscriptions/${encodeURIComponent(subId)}/pricePoints?filter[territory]=USA&limit=200`,
+ );
+ return pickPricePointIdMatching(list, targetMicros);
+ }
+
+ // Atomically create the IAP price schedule with the chosen USA
+ // price tier. Apple's pattern: POST `inAppPurchasePriceSchedules`
+ // with the IAP relationship + the manualPrices relationship inline,
+ // and pass the price rows in `included`. Returns the schedule id.
+ setIapPriceSchedule(args: {
+ iapId: string;
+ pricePointId: string;
+ startDate?: string; // YYYY-MM-DD; omit for "effective immediately"
+ }) {
+ const priceLid = "newPrice";
+ const today = args.startDate ?? new Date().toISOString().slice(0, 10);
+ return this.call<{ data: { id: string } }>(
+ `/v1/inAppPurchasePriceSchedules`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "inAppPurchasePriceSchedules",
+ relationships: {
+ inAppPurchase: {
+ data: { type: "inAppPurchases", id: args.iapId },
+ },
+ manualPrices: {
+ data: [{ type: "inAppPurchasePrices", id: priceLid }],
+ },
+ },
+ },
+ included: [
+ {
+ type: "inAppPurchasePrices",
+ id: priceLid,
+ attributes: { startDate: today },
+ relationships: {
+ inAppPurchasePricePoint: {
+ data: {
+ type: "inAppPurchasePricePoints",
+ id: args.pricePointId,
+ },
+ },
+ inAppPurchaseV2: {
+ data: { type: "inAppPurchases", id: args.iapId },
+ },
+ },
+ },
+ ],
+ }),
+ },
+ );
+ }
+ setSubPriceSchedule(args: {
+ subId: string;
+ pricePointId: string;
+ startDate?: string;
+ }) {
+ const priceLid = "newSubPrice";
+ const today = args.startDate ?? new Date().toISOString().slice(0, 10);
+ return this.call<{ data: { id: string } }>(`/v1/subscriptionPrices`, {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "subscriptionPrices",
+ id: priceLid,
+ attributes: { startDate: today },
+ relationships: {
+ subscription: {
+ data: { type: "subscriptions", id: args.subId },
+ },
+ subscriptionPricePoint: {
+ data: {
+ type: "subscriptionPricePoints",
+ id: args.pricePointId,
+ },
+ },
+ },
+ },
+ }),
+ });
+ }
+
+ // Attach an English (US) localization so reviewers and the
+ // dashboard see something other than the bare productId. Apple
+ // requires at least one locale before the IAP can be submitted; we
+ // always create en-US so first-submission isn't blocked.
+ createIapLocalization(args: {
+ iapId: string;
+ name: string;
+ description: string;
+ locale?: string;
+ }) {
+ return this.call<{ data: { id: string } }>(
+ `/v1/inAppPurchaseLocalizations`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "inAppPurchaseLocalizations",
+ attributes: {
+ name: args.name,
+ description: args.description,
+ locale: args.locale ?? "en-US",
+ },
+ relationships: {
+ inAppPurchaseV2: {
+ data: { type: "inAppPurchases", id: args.iapId },
+ },
+ },
+ },
+ }),
+ },
+ );
+ }
+ createSubLocalization(args: {
+ subId: string;
+ name: string;
+ description: string;
+ locale?: string;
+ }) {
+ return this.call<{ data: { id: string } }>(
+ `/v1/subscriptionLocalizations`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "subscriptionLocalizations",
+ attributes: {
+ name: args.name,
+ description: args.description,
+ locale: args.locale ?? "en-US",
+ },
+ relationships: {
+ subscription: {
+ data: { type: "subscriptions", id: args.subId },
+ },
+ },
+ },
+ }),
+ },
+ );
+ }
+
+ // Look up an existing subscription group by referenceName, or
+ // create one. Used by the Add Product flow when the operator types
+ // a group name on a Subscription draft — kit then resolves it to
+ // an ASC group id at push time so they don't need to copy/paste
+ // opaque ids from ASC's web console.
+ async findOrCreateSubscriptionGroup(args: {
+ appId: string;
+ referenceName: string;
+ }): Promise {
+ const groups = await this.listSubscriptionGroups(args.appId);
+ const existing = groups.data.find(
+ (g) => g.attributes.referenceName === args.referenceName,
+ );
+ if (existing) return existing.id;
+ const created = await this.call<{ data: { id: string } }>(
+ `/v1/subscriptionGroups`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "subscriptionGroups",
+ attributes: { referenceName: args.referenceName },
+ relationships: {
+ app: { data: { type: "apps", id: args.appId } },
+ },
+ },
+ }),
+ },
+ );
+ return created.data.id;
+ }
+
+ createInAppPurchase(args: {
+ appId: string;
+ productId: string;
+ name: string;
+ type: "CONSUMABLE" | "NON_CONSUMABLE" | "NON_RENEWING_SUBSCRIPTION";
+ reviewNote?: string;
+ }) {
+ return this.call(`/v1/inAppPurchases`, {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "inAppPurchases",
+ attributes: {
+ name: args.name,
+ productId: args.productId,
+ inAppPurchaseType: args.type,
+ reviewNote: args.reviewNote,
+ },
+ relationships: {
+ app: { data: { type: "apps", id: args.appId } },
+ },
+ },
+ }),
+ });
+ }
+
+ patchInAppPurchase(
+ id: string,
+ attributes: { name?: string; reviewNote?: string },
+ ) {
+ return this.call(
+ `/v1/inAppPurchases/${encodeURIComponent(id)}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: { type: "inAppPurchases", id, attributes },
+ }),
+ },
+ );
+ }
+
+ createSubscription(args: {
+ groupId: string;
+ productId: string;
+ name: string;
+ subscriptionPeriod:
+ | "ONE_WEEK"
+ | "ONE_MONTH"
+ | "TWO_MONTHS"
+ | "THREE_MONTHS"
+ | "SIX_MONTHS"
+ | "ONE_YEAR";
+ reviewNote?: string;
+ }) {
+ return this.call(`/v1/subscriptions`, {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "subscriptions",
+ attributes: {
+ name: args.name,
+ productId: args.productId,
+ subscriptionPeriod: args.subscriptionPeriod,
+ reviewNote: args.reviewNote,
+ },
+ relationships: {
+ group: {
+ data: { type: "subscriptionGroups", id: args.groupId },
+ },
+ },
+ },
+ }),
+ });
+ }
+
+ patchSubscription(
+ id: string,
+ attributes: { name?: string; reviewNote?: string },
+ ) {
+ return this.call(
+ `/v1/subscriptions/${encodeURIComponent(id)}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: { type: "subscriptions", id, attributes },
+ }),
+ },
+ );
+ }
+}
+
+type AscIapResource = {
+ data: {
+ id: string;
+ type: "inAppPurchases";
+ attributes: {
+ productId?: string;
+ name?: string;
+ inAppPurchaseType?: string;
+ state?: string;
+ reviewNote?: string;
+ };
+ };
+};
+
+type AscIapListResponse = {
+ data: AscIapResource["data"][];
+};
+
+type AscSubResource = {
+ data: {
+ id: string;
+ type: "subscriptions";
+ attributes: {
+ productId?: string;
+ name?: string;
+ subscriptionPeriod?: string;
+ state?: string;
+ reviewNote?: string;
+ };
+ };
+};
+
+type AscSubListResponse = {
+ data: AscSubResource["data"][];
+};
+
+type AscSubGroupListResponse = {
+ data: Array<{
+ id: string;
+ type: "subscriptionGroups";
+ attributes: { referenceName?: string };
+ }>;
+};
+
+// Reference catalog response: every USA price point Apple publishes
+// for a given IAP / sub. Used at push-time to translate a USD amount
+// into the corresponding opaque price-point id (`eyJ...`) Apple's
+// price-schedule POST requires. Different shape from the
+// per-product *configured* price (`AscManualPricesResponse`) — this
+// is the immutable tier ladder, that one is the operator's pick.
+type AscPricePointListResponse = {
+ data: Array<{
+ id: string;
+ type: "inAppPurchasePricePoints" | "subscriptionPricePoints";
+ attributes?: { customerPrice?: string };
+ }>;
+};
+
+// Find the price-point id whose `customerPrice` matches the desired
+// USD amount (within 1 cent for floating-point safety). Returns null
+// if Apple's catalog has no matching tier — caller should surface a
+// failure so the operator picks a tier ASC actually publishes.
+export function pickPricePointIdMatching(
+ list: AscPricePointListResponse | null,
+ targetMicros: number,
+): string | null {
+ if (!list) return null;
+ const targetCents = Math.round(targetMicros / 10_000);
+ for (const point of list.data) {
+ const raw = point.attributes?.customerPrice;
+ if (!raw) continue;
+ const n = Number(raw);
+ if (!Number.isFinite(n)) continue;
+ const pointCents = Math.round(n * 100);
+ if (Math.abs(pointCents - targetCents) <= 1) return point.id;
+ }
+ return null;
+}
+
+// Schedule lookup for one-time IAPs. We only need the resource id so
+// we can fetch its `manualPrices` collection; relationships and
+// attributes are intentionally untyped.
+type AscIapPriceScheduleResponse = {
+ data?: { id: string; type: "inAppPurchasePriceSchedules" } | null;
+};
+
+// `manualPrices` (one-time IAP) and `subscriptionPrices` (auto-renew
+// sub) share the same JSON:API envelope: a primary `data` row that
+// references a pricePoint, and the actual `customerPrice` lives on
+// the side-loaded resource in `included`. We narrow only the fields
+// we read.
+type AscManualPricesResponse = {
+ data: Array<{
+ id: string;
+ type: "inAppPurchasePrices";
+ attributes?: { startDate?: string | null; endDate?: string | null };
+ relationships?: {
+ inAppPurchasePricePoint?: { data?: { id: string } | null };
+ };
+ }>;
+ included?: Array<{
+ id: string;
+ type: "inAppPurchasePricePoints";
+ attributes?: { customerPrice?: string };
+ }>;
+};
+
+type AscSubscriptionPricesResponse = {
+ data: Array<{
+ id: string;
+ type: "subscriptionPrices";
+ attributes?: { startDate?: string | null; endDate?: string | null };
+ relationships?: {
+ subscriptionPricePoint?: { data?: { id: string } | null };
+ };
+ }>;
+ included?: Array<{
+ id: string;
+ type: "subscriptionPricePoints";
+ attributes?: { customerPrice?: string };
+ }>;
+};
+
+// Introductory offers list. Apple's `offerMode` enum:
+// - "FREE_TRIAL" — duration of free access; no pricePoint
+// - "PAY_UP_FRONT" — single discounted price for N periods
+// - "PAY_AS_YOU_GO" — discounted price each period for N periods
+// `numberOfPeriods` semantics differ by mode (free trial: 1; pay-up:
+// 1; pay-as-you-go: N) so we surface it as-is and let the dashboard
+// label it. `subscriptionPricePoint` is included for the discounted
+// price; absent for free trials.
+type AscIntroOfferListResponse = {
+ data: Array<{
+ id: string;
+ type: "subscriptionIntroductoryOffers";
+ attributes?: {
+ offerMode?: "FREE_TRIAL" | "PAY_UP_FRONT" | "PAY_AS_YOU_GO";
+ duration?: string; // ISO-8601-ish: "ONE_WEEK", "THREE_DAYS", etc.
+ numberOfPeriods?: number;
+ startDate?: string | null;
+ endDate?: string | null;
+ };
+ relationships?: {
+ subscriptionPricePoint?: { data?: { id: string } | null };
+ };
+ }>;
+ included?: Array<{
+ id: string;
+ type: "subscriptionPricePoints";
+ attributes?: { customerPrice?: string };
+ }>;
+};
+
+// Pick the price record that's currently in effect (today between
+// startDate and endDate, treating either bound's absence as "open").
+// ASC normally returns just one row when no scheduled change is
+// pending, but a future-dated price-change creates a second record so
+// we can't just take `data[0]`.
+export function pickActivePriceRow<
+ T extends {
+ attributes?: { startDate?: string | null; endDate?: string | null };
+ },
+>(rows: T[]): T | null {
+ if (!rows.length) return null;
+ const today = new Date().toISOString().slice(0, 10);
+ const active = rows.find((row) => {
+ const start = row.attributes?.startDate ?? null;
+ const end = row.attributes?.endDate ?? null;
+ if (start && start > today) return false;
+ if (end && end < today) return false;
+ return true;
+ });
+ return active ?? rows[0];
+}
+
+// Generic shape both manual-price (one-time IAP) and subscription-
+// price responses collapse into for parsing — primary row points to a
+// pricePoint resource via a named relationship, included carries the
+// `customerPrice`. Names of those keys vary between the two surfaces;
+// we pass them in instead of branching inside.
+type AscPriceCollectionResponse = {
+ data: Array<{
+ id: string;
+ type: string;
+ attributes?: { startDate?: string | null; endDate?: string | null };
+ relationships?: Record<
+ string,
+ { data?: { id: string } | null } | undefined
+ >;
+ }>;
+ included?: Array<{
+ id: string;
+ type: string;
+ attributes?: { customerPrice?: string };
+ }>;
+};
+
+// Resolve the active price record's pricePoint id and look up its
+// `customerPrice` from the `included` array. Returns empty fields
+// when nothing matches (no schedule, no USA price, ASC error) so the
+// caller can pass the result straight into upsertFromStore.
+function parseAssignedPrice(
+ resp: AscPriceCollectionResponse | null,
+ relationshipKey: "inAppPurchasePricePoint" | "subscriptionPricePoint",
+): { priceAmountMicros?: number; currency?: string } {
+ if (!resp) return {};
+ const row = pickActivePriceRow(resp.data);
+ if (!row) return {};
+ const pointId = row.relationships?.[relationshipKey]?.data?.id;
+ if (!pointId) return {};
+ const point = resp.included?.find((entry) => entry.id === pointId);
+ const raw = point?.attributes?.customerPrice;
+ if (!raw) return {};
+ const n = Number(raw);
+ if (!Number.isFinite(n)) return {};
+ return {
+ priceAmountMicros: Math.round(n * 1_000_000),
+ currency: "USD",
+ };
+}
+
+function extractAscError(parsed: unknown): string {
+ if (
+ parsed &&
+ typeof parsed === "object" &&
+ "errors" in parsed &&
+ Array.isArray((parsed as { errors: unknown[] }).errors)
+ ) {
+ const errors = (
+ parsed as { errors: Array<{ detail?: string; title?: string }> }
+ ).errors;
+ return (
+ errors.map((e) => e.detail ?? e.title ?? "").join("; ") || "(no detail)"
+ );
+ }
+ return typeof parsed === "string" ? parsed : "(non-JSON error)";
+}
+
+// ---------------------------------------------------------------------------
+// Push-sync action: pulls the project's catalog from ASC, upserts kit's
+// `products` rows from it, and pushes any kit-side products with state
+// = "Draft" / "Ready" upstream.
+// ---------------------------------------------------------------------------
+
+// NOTE on action duration: this handler runs sequentially across the
+// project's catalog (with mapWithConcurrency=6 fan-out per pull
+// step). Convex actions have a 10-minute hard ceiling. For typical
+// commercial apps with <100 SKUs the round trips finish in ~10-30s
+// even with throttled ASC. Catalogs north of ~500 SKUs may need
+// batching — splitting drafts into chunks and chaining
+// internal.scheduler runs from the kit dashboard. Tracked as a
+// follow-up; not addressed here because v0 SaaS targets the
+// long-tail-of-commercial-apps profile, not multi-thousand-SKU
+// enterprise catalogs.
+export const pushSyncProductsAppleIOS = action({
+ args: {
+ apiKey: v.string(),
+ direction: v.optional(
+ v.union(v.literal("pull"), v.literal("push"), v.literal("both")),
+ ),
+ // Dry-run mode: read-only against ASC. Skips every POST/PATCH
+ // (create subscription / IAP, localization, price schedule, group
+ // create) and instead returns the sequence of write attempts
+ // kit would have made. Lets the operator preview a Sync without
+ // polluting their App Store Connect catalog with test rows that
+ // Apple won't let them delete cleanly.
+ dryRun: v.optional(v.boolean()),
+ },
+ returns: v.object({
+ pulled: v.number(),
+ pushed: v.number(),
+ failures: v.array(v.object({ productId: v.string(), reason: v.string() })),
+ plannedWrites: v.optional(
+ v.array(
+ v.object({
+ productId: v.string(),
+ step: v.string(),
+ detail: v.optional(v.string()),
+ }),
+ ),
+ ),
+ }),
+ handler: async (
+ ctx,
+ args,
+ ): Promise<{
+ pulled: number;
+ pushed: number;
+ failures: Array<{ productId: string; reason: string }>;
+ plannedWrites?: Array<{
+ productId: string;
+ step: string;
+ detail?: string;
+ }>;
+ }> => {
+ const project = await getProjectByApiKey(ctx, args.apiKey);
+ if (!project.iosBundleId) {
+ throw new Error("Project iosBundleId is not configured");
+ }
+ if (!project.iosAppAppleId) {
+ throw new Error("Project iosAppAppleId is required for ASC push-sync");
+ }
+ // ASC push-sync uses the App Store Connect API key (Team Key /
+ // Individual Key), which is genuinely different from the App Store
+ // Server API key used for receipt verification — Apple scopes them
+ // separately at the gateway. We prefer the dedicated ASC slot when
+ // the operator has populated it, but fall back to the existing
+ // Server API slot so projects that upload a Team Key into the old
+ // (single-slot) workflow keep working without a re-config dance.
+ // The 401 from Apple's gateway is what catches a wrong-kind key
+ // either way — the helpful message in `call()` points the operator
+ // at the right Apple page. The full pair-resolve + .p8-fallback
+ // logic lives in `resolveAscCredentials` so the matching
+ // listSubscriptionGroupsAppleIOS handler stays in lockstep.
+ const { issuerId, keyId, keyContent } = await resolveAscCredentials(
+ ctx,
+ project,
+ { detailedErrors: true },
+ );
+ const client = new AscClient(issuerId, keyId, keyContent);
+
+ const direction = args.direction ?? "both";
+ const failures: Array<{ productId: string; reason: string }> = [];
+ let pulled = 0;
+ let pushed = 0;
+ const dryRun = args.dryRun ?? false;
+ const plannedWrites: Array<{
+ productId: string;
+ step: string;
+ detail?: string;
+ }> = [];
+
+ const appIdStr = String(project.iosAppAppleId);
+
+ // ── PULL: ASC → kit catalog ────────────────────────────────────
+ if (direction === "pull" || direction === "both") {
+ const iaps = await client.listInAppPurchases(appIdStr).catch((error) => {
+ failures.push({
+ productId: "(asc list iaps)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ });
+ if (iaps) {
+ // Apple throttles ASC pretty aggressively (~50 req/min);
+ // concurrency=6 keeps the pull fast for catalogs with dozens
+ // of IAPs while staying well clear of 429 territory. Switching
+ // from a sequential await loop dropped a 30-IAP pull from
+ // ~30s to ~5s in local testing.
+ const iapResults = await mapWithConcurrency(
+ iaps.data,
+ 6,
+ async (item) => {
+ const productId = item.attributes.productId;
+ if (!productId) return null;
+ const type = mapAscIapType(item.attributes.inAppPurchaseType);
+ const pricePoint = await client.iapCurrentPrice(item.id);
+ return { item, productId, type, pricePoint };
+ },
+ );
+ for (const result of iapResults) {
+ if (!result) continue;
+ const { item, productId, type, pricePoint } = result;
+ if (pricePoint instanceof Error) {
+ failures.push({
+ productId: `${productId} (price lookup)`,
+ reason: pricePoint.message,
+ });
+ }
+ const { priceAmountMicros, currency } = parseAssignedPrice(
+ pricePoint instanceof Error ? null : pricePoint,
+ "inAppPurchasePricePoint",
+ );
+ // upsertFromStore runs serially — Convex coalesces writes
+ // anyway and parallel mutations on the same row would race
+ // on the (projectId, platform, productId) lookup.
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId,
+ platform: "IOS",
+ type,
+ title: item.attributes.name ?? productId,
+ priceAmountMicros,
+ currency,
+ storeRef: item.id,
+ state: mapAscState(item.attributes.state),
+ });
+ pulled += 1;
+ }
+ }
+
+ const groups = await client
+ .listSubscriptionGroups(appIdStr)
+ .catch((error) => {
+ failures.push({
+ productId: "(asc list groups)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ });
+ if (groups) {
+ for (const group of groups.data) {
+ const subs = await client
+ .listSubscriptionsInGroup(group.id)
+ .catch((error) => {
+ failures.push({
+ productId: `(asc list subs in group ${group.id})`,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ });
+ if (!subs) continue;
+ // Same parallelization as the IAP loop above. Within each
+ // sub, price lookup and intro-offer lookup are independent
+ // — fire them as a Promise.all to halve the per-item RTT
+ // before walking on to the upsert.
+ const subResults = await mapWithConcurrency(
+ subs.data,
+ 6,
+ async (sub) => {
+ const productId = sub.attributes.productId;
+ if (!productId) return null;
+ const [pricePoint, introOffers] = await Promise.all([
+ client.subCurrentPrice(sub.id),
+ client.subIntroductoryOffer(sub.id),
+ ]);
+ return { sub, productId, pricePoint, introOffers };
+ },
+ );
+ for (const result of subResults) {
+ if (!result) continue;
+ const { sub, productId, pricePoint, introOffers } = result;
+ if (pricePoint instanceof Error) {
+ failures.push({
+ productId: `${productId} (price lookup)`,
+ reason: pricePoint.message,
+ });
+ }
+ const { priceAmountMicros, currency } = parseAssignedPrice(
+ pricePoint instanceof Error ? null : pricePoint,
+ "subscriptionPricePoint",
+ );
+ if (introOffers instanceof Error) {
+ failures.push({
+ productId: `${productId} (offers lookup)`,
+ reason: introOffers.message,
+ });
+ }
+ const offers = parseIntroOffers(
+ introOffers instanceof Error ? null : introOffers,
+ );
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId,
+ platform: "IOS",
+ type: "Subscription",
+ title: sub.attributes.name ?? productId,
+ priceAmountMicros,
+ currency,
+ storeRef: sub.id,
+ state: mapAscState(sub.attributes.state),
+ billingPeriod: coerceBillingPeriod(
+ mapAscOfferDurationToIso(
+ sub.attributes.subscriptionPeriod ?? undefined,
+ ),
+ ),
+ subscriptionGroupId: group.id,
+ subscriptionGroupName: group.attributes.referenceName,
+ offers: offers.length ? offers : undefined,
+ });
+ pulled += 1;
+ }
+ }
+ }
+ }
+
+ // ── PUSH: kit → ASC for Draft rows ─────────────────────────────
+ // Each draft becomes a multi-step flow: create → localize → set
+ // price. The first step alone leaves the IAP/sub in an unsubmittable
+ // state because Apple requires both an en-US localization and a
+ // USA price schedule before the row can move past Draft. We do
+ // the whole chain here so a single Sync click takes the catalog
+ // from "kit-only" to "Ready to Submit" in App Store Connect.
+ // Submission itself (screenshot upload + inAppPurchaseSubmissions
+ // POST) is a follow-up because it needs a screenshot file and a
+ // dashboard upload slot we haven't built yet — see TODO below.
+ if (direction === "push" || direction === "both") {
+ const drafts = await ctx.runQuery(
+ internal.products.sync.listDraftIosProducts,
+ { projectId: project._id },
+ );
+ // Cache subscriptionGroup find-or-create results across the
+ // entire push pass so a project with multiple drafts in the
+ // same group (Premium Monthly + Premium Yearly + Premium
+ // Weekly all referencing groupName="Premium") only triggers
+ // one ASC listSubscriptionGroups round-trip — and never two
+ // concurrent create calls racing for the same name.
+ //
+ // Stores the in-flight promise (not the resolved id) so two
+ // drafts that hit the same name concurrently share one ASC
+ // round-trip. Without this the parallel push fan-out below
+ // could race two find-or-create calls for the same group,
+ // ending up with one of them returning a 409.
+ const groupIdCache = new Map>();
+ // Dry-run uses a single up-front listSubscriptionGroups fetch
+ // (read-only) so the per-draft preview rendering doesn't
+ // re-list the groups for each Subscription row in drafts.
+ // Lazy: only fetched on the first Subscription draft we hit
+ // in dry-run, so projects without Sub drafts don't pay the
+ // call at all.
+ let dryRunGroupsCache: Awaited<
+ ReturnType
+ > | null = null;
+ const ensureDryRunGroups = async () => {
+ if (!dryRunGroupsCache) {
+ dryRunGroupsCache = await client.listSubscriptionGroups(appIdStr);
+ }
+ return dryRunGroupsCache;
+ };
+ // Bounded-parallel push. ASC throttles aggressively on the
+ // mutation endpoints (createSubscription / createInAppPurchase /
+ // setPriceSchedule) so the previous sequential `for (const row
+ // of drafts)` loop was the safe-but-slow path; a project with
+ // 20 draft products waited 20× the per-draft round-trip. Run
+ // PUSH_CONCURRENCY drafts in parallel and trade some risk of a
+ // 429 (where ASC returns Retry-After we'd surface to the
+ // failures array) for an N× speedup.
+ //
+ // Each draft's create → localize → setPrice steps stay strictly
+ // sequential within `processOneDraft` — ASC rejects ordering
+ // races on a single resource (a localize call landing before
+ // the create propagates returns 409). Cross-draft parallelism
+ // is safe because each upstream resource is independent. The
+ // groupIdCache holds in-flight promises so concurrent drafts in
+ // the same subscription group still issue exactly one
+ // findOrCreate call.
+ //
+ // Concurrency=4 keeps us well under ASC's per-app rate limit
+ // (anecdotally ~10 writes/sec before 429s start) while
+ // delivering ~4× wall-clock improvement on typical catalogs.
+ // mapWithConcurrency preserves input order for the result
+ // array (we don't actually use it; failures + pushed are
+ // accumulated by mutation).
+ const PUSH_CONCURRENCY = 4;
+ const processOneDraft = async (
+ row: (typeof drafts)[number],
+ ): Promise => {
+ // Track failures pushed *for this row* via a row-local flag.
+ // The previous `failuresAtStart = failures.length` snapshot
+ // worked when this loop was sequential, but with
+ // mapWithConcurrency (PUSH_CONCURRENCY=4) the shared
+ // `failures` array can grow because of OTHER concurrent
+ // drafts between the snapshot and the success-gate check —
+ // which would block this draft from calling markPushed even
+ // though every step for THIS row succeeded.
+ //
+ // Use a row-local boolean + a recordFailure helper so each
+ // draft's success gate is independent of cross-draft noise.
+ // A partial setup (create succeeded, localization failed)
+ // still leaves the row in Draft with a populated storeRef
+ // so the next sync resumes step 2 instead of re-creating
+ // the upstream resource.
+ let rowHadFailure = false;
+ const recordFailure = (failure: {
+ productId: string;
+ reason: string;
+ }) => {
+ rowHadFailure = true;
+ failures.push(failure);
+ };
+ try {
+ if (row.type === "Subscription") {
+ // Resolve the ASC subscriptionGroup from the operator-typed
+ // `subscriptionGroupName`. Find-or-create so the operator
+ // doesn't have to pre-create the group in ASC's web UI; if
+ // they don't pick a name we default to the productId so
+ // there's *some* group rather than a hard failure — but
+ // surface a non-fatal warning since per-product groups
+ // fragment the catalog and break StoreKit 2's
+ // upgrade/downgrade flow between Monthly and Yearly tiers
+ // (those need to share a group). In dry-run, list groups
+ // (read-only) and report which path the real run would
+ // take instead of creating anything.
+ //
+ // Skip both group-resolve and create when this row already
+ // has a storeRef from a prior partially-successful sync —
+ // re-creating would either duplicate or 409 against ASC.
+ const groupName = row.subscriptionGroupName ?? row.productId;
+ if (!row.subscriptionGroupName && !row.storeRef && dryRun) {
+ // Surface the per-product-group warning in dry-run only
+ // so operators see the recommendation while previewing
+ // (the most common time to fix the catalog), but a
+ // production sync isn't blocked or noisy. Pushing into
+ // `failures` would also trip the markPushed gate added
+ // for partial-failure resilience.
+ plannedWrites.push({
+ productId: row.productId,
+ step: "warning: no subscription group name set",
+ detail:
+ "Falling back to productId so this sub lands in its own group. Pick a shared name (e.g. 'Premium') for related tiers so StoreKit 2 upgrade/downgrade works.",
+ });
+ }
+ let storeRef: string;
+ if (row.storeRef) {
+ storeRef = row.storeRef;
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "skip create (resuming partial sync)",
+ detail: `existing storeRef=${storeRef}`,
+ });
+ }
+ } else {
+ let groupId: string;
+ if (dryRun) {
+ const groups = await ensureDryRunGroups();
+ const existing = groups.data.find(
+ (g) => g.attributes.referenceName === groupName,
+ );
+ groupId = existing?.id ?? "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: existing
+ ? "use existing subscription group"
+ : "create subscription group",
+ detail: groupName,
+ });
+ storeRef = "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create subscription",
+ detail: `${row.title} · ${mapBillingPeriodToAsc(row.billingPeriod)} · group=${groupName}`,
+ });
+ } else {
+ let cached = groupIdCache.get(groupName);
+ if (!cached) {
+ cached = client.findOrCreateSubscriptionGroup({
+ appId: appIdStr,
+ referenceName: groupName,
+ });
+ groupIdCache.set(groupName, cached);
+ // If the in-flight call rejects, evict the cached
+ // promise so a follow-up draft can retry instead of
+ // permanently inheriting the failure.
+ cached.catch(() => {
+ if (groupIdCache.get(groupName) === cached) {
+ groupIdCache.delete(groupName);
+ }
+ });
+ }
+ groupId = await cached;
+ const result = await client.createSubscription({
+ groupId,
+ productId: row.productId,
+ name: row.title,
+ subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod),
+ reviewNote: row.reviewNote,
+ });
+ storeRef = result.data.id;
+ // Persist the upstream id immediately so a subsequent
+ // step's failure doesn't lose the binding (and the
+ // next sync sees this row's storeRef populated and
+ // skips the create call above).
+ await ctx.runMutation(internal.products.sync.markStoreRef, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ }
+ // Localize so reviewers see the human-readable name +
+ // description instead of just the productId. ASC requires
+ // at least one locale before submission — failing here
+ // doesn't unwind the create (Apple has no rollback) so we
+ // record a failure and let the operator retry / fix in
+ // ASC web.
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create en-US localization",
+ detail: row.description ?? row.title,
+ });
+ } else {
+ try {
+ await client.createSubLocalization({
+ subId: storeRef,
+ name: row.title,
+ description: row.description ?? row.title,
+ });
+ } catch (error) {
+ // 409 Conflict means the en-US localization already
+ // exists from a prior partial sync. That's a benign
+ // retry — fall through to the price-setting step
+ // instead of marking the whole product failed.
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (localization)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+ // Set the USA price by resolving the operator's USD amount
+ // → Apple's nearest price-point id. We require currency =
+ // "USD" because the dashboard form lets them pick others
+ // but we only know the USA tier ladder here; non-USD prices
+ // are surfaced as an actionable failure rather than silently
+ // mis-priced. In dry-run, skip the lookup (the just-created
+ // subscription resource doesn't exist for read-back) and
+ // just record intent.
+ if (
+ row.priceAmountMicros !== undefined &&
+ (row.currency ?? "USD") === "USD"
+ ) {
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "set USA price",
+ detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
+ });
+ } else {
+ try {
+ const pricePointId = await client.findSubUsaPricePointId(
+ storeRef,
+ row.priceAmountMicros,
+ );
+ if (!pricePointId) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
+ });
+ } else {
+ await client.setSubPriceSchedule({
+ subId: storeRef,
+ pricePointId,
+ });
+ }
+ } catch (error) {
+ // 409 Conflict means a price schedule already exists
+ // for the (subscription, startDate=today) pair from a
+ // prior partial sync — Apple keys schedules by date,
+ // not by id. Treat as benign retry so the subsequent
+ // markPushed step still runs (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124)
+ // review).
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+ } else if (row.currency && row.currency !== "USD") {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
+ });
+ }
+ // Only flip state to Ready when every follow-up step
+ // succeeded. Partial setups stay in Draft (with storeRef
+ // populated) so the next sync resumes the missing pieces.
+ if (!dryRun && !rowHadFailure) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ pushed += 1;
+ } else {
+ let storeRef: string;
+ if (row.storeRef) {
+ storeRef = row.storeRef;
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "skip create (resuming partial sync)",
+ detail: `existing storeRef=${storeRef}`,
+ });
+ }
+ } else if (dryRun) {
+ storeRef = "(would-create)";
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create in-app purchase",
+ detail: `${row.title} · ${row.type}`,
+ });
+ } else {
+ const result = await client.createInAppPurchase({
+ appId: appIdStr,
+ productId: row.productId,
+ name: row.title,
+ type:
+ row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE",
+ reviewNote: row.reviewNote,
+ });
+ storeRef = result.data.id;
+ // Same partial-sync resilience as the Subscription
+ // branch — persist the upstream id before the
+ // localization / price steps that may fail.
+ await ctx.runMutation(internal.products.sync.markStoreRef, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "create en-US localization",
+ detail: row.description ?? row.title,
+ });
+ } else {
+ try {
+ await client.createIapLocalization({
+ iapId: storeRef,
+ name: row.title,
+ description: row.description ?? row.title,
+ });
+ } catch (error) {
+ // Same 409-is-benign rationale as the subscription
+ // localization path — see PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review.
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (localization)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+ if (
+ row.priceAmountMicros !== undefined &&
+ (row.currency ?? "USD") === "USD"
+ ) {
+ if (dryRun) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "set USA price",
+ detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`,
+ });
+ } else {
+ try {
+ const pricePointId = await client.findIapUsaPricePointId(
+ storeRef,
+ row.priceAmountMicros,
+ );
+ if (!pricePointId) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`,
+ });
+ } else {
+ await client.setIapPriceSchedule({
+ iapId: storeRef,
+ pricePointId,
+ });
+ }
+ } catch (error) {
+ // Same 409-is-benign rationale as the subscription
+ // price schedule path above — Apple keys IAP price
+ // schedules by (iapId, startDate) so a same-day
+ // retry hits Conflict. Allow the row to proceed to
+ // markPushed instead of stalling in Draft.
+ if (!(error instanceof AscApiError && error.status === 409)) {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+ } else if (row.currency && row.currency !== "USD") {
+ recordFailure({
+ productId: `${row.productId} (price)`,
+ reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
+ });
+ }
+ // Same gate as the Subscription branch — only flip Ready
+ // when no follow-up step recorded a failure for this row.
+ if (!dryRun && !rowHadFailure) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ }
+ pushed += 1;
+ }
+ // TODO(review-submit): once Settings has an upload slot for a
+ // project-level App Review screenshot
+ // (`apple_iap_review_screenshot` purpose), add a step here:
+ // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve)
+ // 2. PUT to the returned upload URL (binary)
+ // 3. PATCH ...screenshots/{id} with sourceFileChecksum
+ // 4. POST /v1/inAppPurchaseSubmissions
+ // Until then, the row stops at "Ready to Submit" in ASC and
+ // the operator hits Submit manually (or via next app version).
+ } catch (error) {
+ recordFailure({
+ productId: row.productId,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+ await mapWithConcurrency(drafts, PUSH_CONCURRENCY, processOneDraft);
+ }
+
+ return {
+ pulled,
+ pushed,
+ failures,
+ plannedWrites: dryRun ? plannedWrites : undefined,
+ };
+ },
+});
+
+// Lightweight read-only action so the dashboard can populate a
+// subscription-group autocomplete without the operator having to copy
+// reference names from ASC's web console. Returns just `{id,
+// referenceName}` per group — the heavier listSubscriptionsInGroup
+// fetch only happens during full pull-sync. Failures bubble back as a
+// thrown Error so the dashboard can show a toast and degrade
+// gracefully (the field stays a free-text input).
+export const listSubscriptionGroupsAppleIOS = action({
+ args: { apiKey: v.string() },
+ returns: v.array(v.object({ id: v.string(), referenceName: v.string() })),
+ handler: async (
+ ctx,
+ args,
+ ): Promise> => {
+ const project = await getProjectByApiKey(ctx, args.apiKey);
+ if (!project.iosAppAppleId) {
+ throw new Error("Project iosAppAppleId is not configured");
+ }
+ const { issuerId, keyId, keyContent } = await resolveAscCredentials(
+ ctx,
+ project,
+ );
+ const client = new AscClient(issuerId, keyId, keyContent);
+ const resp = await client.listSubscriptionGroups(
+ String(project.iosAppAppleId),
+ );
+ return resp.data
+ .map((g) => ({
+ id: g.id,
+ referenceName: g.attributes.referenceName ?? "",
+ }))
+ .filter((g) => g.referenceName.length > 0);
+ },
+});
+
+export function mapBillingPeriodToAsc(
+ period: string | undefined,
+):
+ | "ONE_WEEK"
+ | "ONE_MONTH"
+ | "TWO_MONTHS"
+ | "THREE_MONTHS"
+ | "SIX_MONTHS"
+ | "ONE_YEAR" {
+ switch (period) {
+ case "P1W":
+ return "ONE_WEEK";
+ case "P1M":
+ case undefined:
+ // Treat missing billingPeriod as monthly. The catalog form
+ // makes billingPeriod optional and a missing value commonly
+ // means "I forgot to fill this in"; defaulting to monthly is
+ // the least destructive interpretation (the operator can fix
+ // the row and re-sync).
+ return "ONE_MONTH";
+ case "P2M":
+ return "TWO_MONTHS";
+ case "P3M":
+ return "THREE_MONTHS";
+ case "P6M":
+ return "SIX_MONTHS";
+ case "P1Y":
+ return "ONE_YEAR";
+ default:
+ // Unknown period values used to silently coerce to ONE_MONTH,
+ // which provisioned the wrong subscription duration in ASC —
+ // a much harder-to-unwind mistake than a failed sync. Throw
+ // so the operator sees the typo immediately and the partial-
+ // failure tracking in processOneDraft records it as an
+ // actionable failure for that row.
+ throw new Error(
+ `Invalid billing period for ASC subscription: "${period}". ` +
+ `Expected one of P1W, P1M, P2M, P3M, P6M, P1Y (or omit for monthly).`,
+ );
+ }
+}
+
+function mapAscIapType(
+ raw: string | undefined,
+): "Subscription" | "NonConsumable" | "Consumable" {
+ switch (raw) {
+ case "CONSUMABLE":
+ return "Consumable";
+ case "NON_RENEWING_SUBSCRIPTION":
+ case "NON_CONSUMABLE":
+ return "NonConsumable";
+ default:
+ return "NonConsumable";
+ }
+}
+
+// Apple represents introductory-offer durations as enum strings
+// rather than ISO-8601 like the subscriptionPeriod field. Translate
+// to ISO so kit's `offers[].duration` is uniform across stores
+// (Play already uses ISO `P1W` / `P1M` / etc.). Unknown values fall
+// through as-is so the dashboard can still render whatever Apple
+// returned even if Apple ships a new enum value.
+export function mapAscOfferDurationToIso(
+ raw: string | undefined,
+): string | undefined {
+ if (!raw) return undefined;
+ switch (raw) {
+ case "THREE_DAYS":
+ return "P3D";
+ case "ONE_WEEK":
+ return "P1W";
+ case "TWO_WEEKS":
+ return "P2W";
+ case "ONE_MONTH":
+ return "P1M";
+ case "TWO_MONTHS":
+ return "P2M";
+ case "THREE_MONTHS":
+ return "P3M";
+ case "SIX_MONTHS":
+ return "P6M";
+ case "ONE_YEAR":
+ return "P1Y";
+ default:
+ return raw;
+ }
+}
+
+export function mapAscOfferKind(
+ mode: string | undefined,
+): "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" {
+ switch (mode) {
+ case "PAY_UP_FRONT":
+ return "IntroPayUpFront";
+ case "PAY_AS_YOU_GO":
+ return "IntroPayAsYouGo";
+ case "FREE_TRIAL":
+ default:
+ return "FreeTrial";
+ }
+}
+
+// Convert ASC introductory offers list into kit's `offers[]` shape.
+// Picks rows whose date range covers today (consistent with how
+// `pickActivePriceRow` resolves the active price). Free-trial offers
+// have no pricePoint — we emit them with no priceAmountMicros.
+export function parseIntroOffers(
+ resp: AscIntroOfferListResponse | null,
+): Array<{
+ id: string;
+ kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo";
+ duration?: string;
+ numberOfPeriods?: number;
+ priceAmountMicros?: number;
+ currency?: string;
+}> {
+ if (!resp || resp.data.length === 0) return [];
+ const today = new Date().toISOString().slice(0, 10);
+ return resp.data
+ .filter((row) => {
+ const start = row.attributes?.startDate ?? null;
+ const end = row.attributes?.endDate ?? null;
+ if (start && start > today) return false;
+ if (end && end < today) return false;
+ return true;
+ })
+ .map((row) => {
+ const pointId = row.relationships?.subscriptionPricePoint?.data?.id;
+ const point = pointId
+ ? resp.included?.find((entry) => entry.id === pointId)
+ : undefined;
+ const raw = point?.attributes?.customerPrice;
+ const n = raw ? Number(raw) : Number.NaN;
+ const priceAmountMicros = Number.isFinite(n)
+ ? Math.round(n * 1_000_000)
+ : undefined;
+ return {
+ id: row.id,
+ kind: mapAscOfferKind(row.attributes?.offerMode),
+ duration: mapAscOfferDurationToIso(row.attributes?.duration),
+ numberOfPeriods: row.attributes?.numberOfPeriods,
+ priceAmountMicros,
+ currency: priceAmountMicros !== undefined ? "USD" : undefined,
+ };
+ });
+}
+
+function mapAscState(
+ raw: string | undefined,
+): "Draft" | "Ready" | "Active" | "Removed" {
+ switch (raw) {
+ case "WAITING_FOR_REVIEW":
+ case "PENDING_DEVELOPER_RELEASE":
+ case "READY_TO_SUBMIT":
+ return "Ready";
+ case "APPROVED":
+ case "REPLACED":
+ return "Active";
+ case "DEVELOPER_REMOVED_FROM_SALE":
+ case "REMOVED_FROM_SALE":
+ return "Removed";
+ default:
+ return "Draft";
+ }
+}
diff --git a/packages/kit/convex/products/jwt.test.ts b/packages/kit/convex/products/jwt.test.ts
new file mode 100644
index 00000000..61a52474
--- /dev/null
+++ b/packages/kit/convex/products/jwt.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import {
+ generateKeyPairSync,
+ createVerify,
+ createPrivateKey,
+ createPublicKey,
+} from "node:crypto";
+import { derSignatureToJoseSignature, mintAscJwt } from "./jwt";
+
+function generateP8() {
+ const { privateKey } = generateKeyPairSync("ec", {
+ namedCurve: "P-256",
+ });
+ return privateKey.export({ format: "pem", type: "pkcs8" }).toString();
+}
+
+describe("mintAscJwt", () => {
+ it("mints a 3-segment JWT with ES256 header and ASC audience", () => {
+ const pem = generateP8();
+ const token = mintAscJwt({
+ keyId: "ABCD1234",
+ privateKey: pem,
+ issuerId: "00000000-0000-0000-0000-aaaaaaaaaaaa",
+ nowSeconds: () => 1_711_000_000,
+ });
+
+ const parts = token.split(".");
+ expect(parts).toHaveLength(3);
+ const header = JSON.parse(
+ Buffer.from(parts[0], "base64url").toString("utf-8"),
+ );
+ expect(header).toEqual({ alg: "ES256", kid: "ABCD1234", typ: "JWT" });
+
+ const payload = JSON.parse(
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
+ );
+ expect(payload.iss).toBe("00000000-0000-0000-0000-aaaaaaaaaaaa");
+ expect(payload.aud).toBe("appstoreconnect-v1");
+ expect(payload.iat).toBe(1_711_000_000);
+ expect(payload.exp).toBe(1_711_000_000 + 600);
+ });
+
+ it("respects custom ttlSeconds", () => {
+ const pem = generateP8();
+ const token = mintAscJwt({
+ keyId: "X",
+ privateKey: pem,
+ issuerId: "iss",
+ ttlSeconds: 1_200,
+ nowSeconds: () => 1,
+ });
+ const payload = JSON.parse(
+ Buffer.from(token.split(".")[1], "base64url").toString("utf-8"),
+ );
+ expect(payload.exp - payload.iat).toBe(1_200);
+ });
+
+ it("produces a signature that verifies against the public key with the JOSE r||s format", () => {
+ const pem = generateP8();
+ const token = mintAscJwt({
+ keyId: "k",
+ privateKey: pem,
+ issuerId: "iss",
+ });
+
+ const [headerB64, payloadB64, sigB64] = token.split(".");
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const joseSig = Buffer.from(sigB64, "base64url");
+ expect(joseSig.length).toBe(64);
+
+ // Convert back to DER for node verifier.
+ const r = bigIntFrom(joseSig.subarray(0, 32));
+ const s = bigIntFrom(joseSig.subarray(32));
+ const der = encodeDerSignature(r, s);
+
+ const privateKey = createPrivateKey({ key: pem, format: "pem" });
+ const publicKey = createPublicKey(privateKey)
+ .export({ format: "pem", type: "spki" })
+ .toString();
+ const verifier = createVerify("SHA256");
+ verifier.update(signingInput);
+ verifier.end();
+ expect(verifier.verify(publicKey, der)).toBe(true);
+ });
+});
+
+describe("derSignatureToJoseSignature", () => {
+ it("strips DER framing and left-pads r,s to fixed coord size", () => {
+ // DER for r=0x01, s=0x02 ECDSA over P-256.
+ // SEQUENCE 06 02 01 01 02 01 02
+ const der = Buffer.from([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]);
+ const jose = derSignatureToJoseSignature(der, 32);
+ expect(jose.length).toBe(64);
+ // r should be 31 zero bytes followed by 0x01
+ expect(jose[31]).toBe(0x01);
+ expect(jose[63]).toBe(0x02);
+ });
+});
+
+function bigIntFrom(buf: Buffer): Buffer {
+ // Strip excess leading zeros so the integer is canonical, then add a
+ // 0x00 prefix back if the high bit of the leading nonzero byte is
+ // set (DER says positive integers can't start with 0x80+). The prior
+ // version stripped leading zeros AFTER checking the high bit and
+ // missed the `00 80 ...` case — that pattern occurs ~1/65536 times
+ // per coord, which made the ECDSA round-trip test flake on CI.
+ let i = 0;
+ while (i < buf.length - 1 && buf[i] === 0) i += 1;
+ const stripped = buf.subarray(i);
+ if (((stripped[0] ?? 0) & 0x80) !== 0) {
+ return Buffer.concat([Buffer.from([0x00]), stripped]);
+ }
+ return stripped;
+}
+
+function encodeDerSignature(r: Buffer, s: Buffer): Buffer {
+ const rPart = Buffer.concat([Buffer.from([0x02, r.length]), r]);
+ const sPart = Buffer.concat([Buffer.from([0x02, s.length]), s]);
+ const inner = Buffer.concat([rPart, sPart]);
+ return Buffer.concat([Buffer.from([0x30, inner.length]), inner]);
+}
diff --git a/packages/kit/convex/products/jwt.ts b/packages/kit/convex/products/jwt.ts
new file mode 100644
index 00000000..fe6a902f
--- /dev/null
+++ b/packages/kit/convex/products/jwt.ts
@@ -0,0 +1,174 @@
+"use node";
+// Minimal ES256 JWT minter for App Store Connect API authentication.
+// ASC requires every request to carry a JWT in `Authorization: Bearer`
+// signed with the project's downloaded `.p8` key (kid + issuerId).
+//
+// We do NOT reach for `jose` / `jsonwebtoken` here — both pull
+// substantial node-only dependency trees into the Convex action
+// bundle, and ASC's JWT shape is tiny (3 fields + ES256 over the
+// canonical SHA-256 of the header.payload bytes). node:crypto on Bun
+// already supports raw ECDSA over P-256.
+//
+// Pure helpers only; no Convex imports so this is unit-testable in
+// vitest without an action runtime.
+
+import { createPrivateKey, createSign } from "node:crypto";
+
+export type AscJwtClaims = {
+ iss: string; // issuerId — ASC > Users and Access > Keys
+ scope?: string[]; // optional ASC scope claim
+ // aud is fixed to "appstoreconnect-v1" by ASC.
+ // iat / exp are computed from `nowSeconds`.
+};
+
+export type AscJwtOptions = {
+ keyId: string; // ASC > Keys > Key ID
+ privateKey: string; // PKCS#8 PEM (the .p8 file content)
+ issuerId: string;
+ // Token TTL in seconds. ASC enforces ≤ 1200s (20 min); default to a
+ // conservative 600s to leave headroom for clock skew.
+ ttlSeconds?: number;
+ nowSeconds?: () => number; // injected for tests
+};
+
+export function mintAscJwt(opts: AscJwtOptions): string {
+ const ttl = opts.ttlSeconds ?? 600;
+ const now = opts.nowSeconds
+ ? opts.nowSeconds()
+ : Math.floor(Date.now() / 1000);
+
+ const header = {
+ alg: "ES256",
+ kid: opts.keyId,
+ typ: "JWT",
+ };
+ const payload: Record = {
+ iss: opts.issuerId,
+ iat: now,
+ exp: now + ttl,
+ aud: "appstoreconnect-v1",
+ };
+
+ const headerB64 = base64UrlEncode(Buffer.from(JSON.stringify(header)));
+ const payloadB64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)));
+ const signingInput = `${headerB64}.${payloadB64}`;
+
+ const keyObj = createPrivateKey({
+ key: opts.privateKey,
+ format: "pem",
+ });
+ const signer = createSign("SHA256");
+ signer.update(signingInput);
+ signer.end();
+ const derSignature = signer.sign(keyObj);
+
+ // node:crypto signs in DER. ASC requires the JWS-flavored r||s
+ // concatenation — convert.
+ const jwsSignature = derSignatureToJoseSignature(derSignature, 32);
+ return `${signingInput}.${base64UrlEncode(jwsSignature)}`;
+}
+
+function base64UrlEncode(buf: Buffer | Uint8Array): string {
+ return Buffer.from(buf)
+ .toString("base64")
+ .replace(/=+$/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+}
+
+// DER-encoded ECDSA signature is `SEQUENCE { INTEGER r, INTEGER s }`.
+// JWS expects fixed-length r||s (each `coordSize` bytes). Strip the
+// leading 0x00 padding nodes adds for unsigned-positive encoding, then
+// left-pad each integer back out to coordSize.
+//
+// Bounds checks on every read: `node:crypto` always emits well-formed
+// DER, but a future caller passing an arbitrary buffer (e.g. user-
+// supplied signature blob from a webhook) without validation could
+// otherwise trigger out-of-range subarrays / silent NaN-style reads.
+// Each guard throws with a consistent shape so the caller can wrap a
+// single try/catch instead of branching on byte-level corruption.
+export function derSignatureToJoseSignature(
+ der: Buffer | Uint8Array,
+ coordSize: number,
+): Buffer {
+ const buf = Buffer.from(der);
+ // Minimum legal DER ECDSA signature is SEQUENCE + len + INTEGER + len
+ // + 1-byte r + INTEGER + len + 1-byte s = 8 bytes. Anything shorter
+ // can't possibly be valid; bailing here also makes the indexing
+ // below safe to do.
+ if (buf.length < 8) {
+ throw new Error(
+ `Invalid DER signature: buffer too short (${buf.length} bytes, need >= 8)`,
+ );
+ }
+ if (buf[0] !== 0x30) {
+ throw new Error("Invalid DER signature: missing SEQUENCE tag");
+ }
+ let offset = 2;
+ if ((buf[1] ?? 0) & 0x80) {
+ // long-form length — uncommon at this size but legal.
+ const lenBytes = (buf[1] ?? 0) & 0x7f;
+ offset = 2 + lenBytes;
+ if (offset > buf.length) {
+ throw new Error(
+ "Invalid DER signature: long-form length declares more bytes than the buffer holds",
+ );
+ }
+ }
+ if (offset + 1 >= buf.length) {
+ throw new Error(
+ "Invalid DER signature: truncated before first INTEGER tag",
+ );
+ }
+ if (buf[offset] !== 0x02) {
+ throw new Error("Invalid DER signature: missing first INTEGER tag");
+ }
+ const rLen = buf[offset + 1] ?? 0;
+ const rEnd = offset + 2 + rLen;
+ if (rEnd > buf.length) {
+ throw new Error(
+ `Invalid DER signature: r INTEGER (${rLen} bytes) extends past buffer end`,
+ );
+ }
+ const r = buf.subarray(offset + 2, rEnd);
+ offset = rEnd;
+ if (offset + 1 >= buf.length) {
+ throw new Error(
+ "Invalid DER signature: truncated before second INTEGER tag",
+ );
+ }
+ if (buf[offset] !== 0x02) {
+ throw new Error("Invalid DER signature: missing second INTEGER tag");
+ }
+ const sLen = buf[offset + 1] ?? 0;
+ const sEnd = offset + 2 + sLen;
+ if (sEnd > buf.length) {
+ throw new Error(
+ `Invalid DER signature: s INTEGER (${sLen} bytes) extends past buffer end`,
+ );
+ }
+ const s = buf.subarray(offset + 2, sEnd);
+
+ return Buffer.concat([
+ leftPad(stripLeadingZeros(r), coordSize),
+ leftPad(stripLeadingZeros(s), coordSize),
+ ]);
+}
+
+function stripLeadingZeros(buf: Buffer): Buffer {
+ let i = 0;
+ while (i < buf.length - 1 && buf[i] === 0) i += 1;
+ return buf.subarray(i);
+}
+
+function leftPad(buf: Buffer, size: number): Buffer {
+ if (buf.length === size) return buf;
+ if (buf.length > size) {
+ throw new Error(
+ `signature component is ${buf.length} bytes — cannot fit into ${size}`,
+ );
+ }
+ const out = Buffer.alloc(size);
+ buf.copy(out, size - buf.length);
+ return out;
+}
diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts
new file mode 100644
index 00000000..84e1cc82
--- /dev/null
+++ b/packages/kit/convex/products/mutation.ts
@@ -0,0 +1,219 @@
+import { mutation } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc } from "../_generated/dataModel";
+
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+const typeValidator = v.union(
+ v.literal("Subscription"),
+ v.literal("NonConsumable"),
+ v.literal("Consumable"),
+);
+const stateValidator = v.union(
+ v.literal("Draft"),
+ v.literal("Ready"),
+ v.literal("Active"),
+ v.literal("Removed"),
+);
+
+// Public mutation: upsert a product in kit's catalog. Authoritative
+// state lives in App Store Connect / Play Console; this row is a
+// kit-side cache so the dashboard, MCP server, and SDKs share one
+// canonical view. Phase 3 follow-ups will add ASC / Play push-sync
+// — until then, treat this as a hand-managed catalog.
+export const upsertProduct = mutation({
+ args: {
+ apiKey: v.string(),
+ productId: v.string(),
+ platform: platformValidator,
+ type: typeValidator,
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ billingPeriod: v.optional(
+ v.union(
+ v.literal("P1W"),
+ v.literal("P1M"),
+ v.literal("P2M"),
+ v.literal("P3M"),
+ v.literal("P6M"),
+ v.literal("P1Y"),
+ ),
+ ),
+ subscriptionGroupName: v.optional(v.string()),
+ reviewNote: v.optional(v.string()),
+ state: v.optional(stateValidator),
+ storeRef: v.optional(v.string()),
+ },
+ returns: v.object({
+ id: v.id("products"),
+ created: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) throw new Error("Invalid API key");
+
+ // Reject negative prices. The catalog row would otherwise round-
+ // trip into push-sync (asc.ts / play.ts) and either crash on
+ // Apple's price-tier lookup or land a negative `priceMicros` on
+ // Play, neither of which the operator can correct from the
+ // dashboard later (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (args.priceAmountMicros !== undefined && args.priceAmountMicros < 0) {
+ throw new Error("priceAmountMicros must be non-negative");
+ }
+
+ // iOS subscriptions REQUIRE a subscriptionGroupName upstream —
+ // related tiers must share a group for StoreKit 2's native
+ // upgrade/downgrade UI to work. The Apple push-sync (asc.ts)
+ // falls back to using the productId as the group name when this
+ // is missing, which results in each subscription landing in its
+ // own fragmented group and silently breaks the upgrade flow.
+ // Reject the upsert before that drift can happen so the operator
+ // gets a loud, actionable error instead of a broken store
+ // experience two sync passes later (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (
+ args.platform === "IOS" &&
+ args.type === "Subscription" &&
+ (!args.subscriptionGroupName || !args.subscriptionGroupName.trim())
+ ) {
+ throw new Error(
+ "subscriptionGroupName is required for iOS Subscription products — related tiers must share a group for StoreKit 2 upgrade/downgrade to work. Pick a group name (e.g. 'premium_tiers') and reuse it for every related subscription. kit's push-sync (asc.ts) will create the group in App Store Connect on first push and reuse the existing group on subsequent pushes if a group with the same name already exists upstream — you do not have to create it in ASC manually first.",
+ );
+ }
+
+ const existing: Doc<"products"> | null = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+
+ const now = Date.now();
+ if (existing) {
+ // State-only flips moved to `setProductState`. This mutation
+ // now treats every supplied field as authoritative — keeping
+ // the prior "blank title preserves existing" hack would still
+ // mask cases where a caller really did mean to clear a field.
+ await ctx.db.patch(existing._id, {
+ type: args.type,
+ title: args.title,
+ description: args.description ?? existing.description,
+ priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros,
+ currency: args.currency ?? existing.currency,
+ billingPeriod: args.billingPeriod ?? existing.billingPeriod,
+ subscriptionGroupName:
+ args.subscriptionGroupName ?? existing.subscriptionGroupName,
+ reviewNote: args.reviewNote ?? existing.reviewNote,
+ state: args.state ?? existing.state,
+ storeRef: args.storeRef ?? existing.storeRef,
+ updatedAt: now,
+ });
+ return { id: existing._id, created: false };
+ }
+
+ const id = await ctx.db.insert("products", {
+ projectId: project._id,
+ productId: args.productId,
+ platform: args.platform,
+ type: args.type,
+ title: args.title,
+ description: args.description,
+ priceAmountMicros: args.priceAmountMicros,
+ currency: args.currency,
+ billingPeriod: args.billingPeriod,
+ subscriptionGroupName: args.subscriptionGroupName,
+ reviewNote: args.reviewNote,
+ state: args.state ?? "Draft",
+ storeRef: args.storeRef,
+ updatedAt: now,
+ });
+ return { id, created: true };
+ },
+});
+
+// State-only mutation used by `manage_product` (MCP) and the
+// dashboard's enable/disable affordance. Distinct from `upsertProduct`
+// because the previous reuse pattern (passing a blank title +
+// hardcoded type so only `state` would update) would silently
+// overwrite the existing row's `type` — e.g. flipping a NonConsumable
+// to Subscription. Splitting the mutation prevents that class of
+// drive-by clobber.
+export const setProductState = mutation({
+ args: {
+ apiKey: v.string(),
+ productId: v.string(),
+ platform: platformValidator,
+ state: stateValidator,
+ },
+ returns: v.object({
+ id: v.id("products"),
+ state: stateValidator,
+ }),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) throw new Error("Invalid API key");
+
+ const existing = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+ if (!existing) throw new Error("Product not found");
+
+ await ctx.db.patch(existing._id, {
+ state: args.state,
+ updatedAt: Date.now(),
+ });
+ return { id: existing._id, state: args.state };
+ },
+});
+
+export const removeProduct = mutation({
+ args: {
+ apiKey: v.string(),
+ productId: v.string(),
+ platform: platformValidator,
+ },
+ returns: v.object({ ok: v.boolean() }),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) return { ok: false };
+
+ const existing = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+ if (!existing) return { ok: false };
+
+ // Soft-remove via state flag — keeps audit history for the
+ // dashboard and preserves any webhook events that reference this productId.
+ await ctx.db.patch(existing._id, {
+ state: "Removed",
+ updatedAt: Date.now(),
+ });
+ return { ok: true };
+ },
+});
diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts
new file mode 100644
index 00000000..1041319e
--- /dev/null
+++ b/packages/kit/convex/products/play.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+
+import { basePlanIdForPeriod, moneyToMicros } from "./play";
+
+describe("moneyToMicros", () => {
+ it("returns undefined when input is missing or has no units", () => {
+ expect(moneyToMicros(undefined)).toBeUndefined();
+ expect(moneyToMicros({ currencyCode: "USD" })).toBeUndefined();
+ });
+
+ it("converts whole dollars (units only) to micros", () => {
+ expect(moneyToMicros({ currencyCode: "USD", units: "9", nanos: 0 })).toBe(
+ 9_000_000,
+ );
+ });
+
+ it("converts units + nanos combination correctly", () => {
+ // $9.99 = units 9 + nanos 990_000_000 → 9_990_000 micros
+ expect(
+ moneyToMicros({ currencyCode: "USD", units: "9", nanos: 990_000_000 }),
+ ).toBe(9_990_000);
+ });
+
+ it("truncates nanos / 1000 conversion (sub-micro fraction is dropped, not rounded up)", () => {
+ // 999_999_999 nanos / 1000 = 999_999.999 → truncates to 999_999
+ // micros. We deliberately don't round up to 1_000_000; rounding
+ // would silently push prices across the unit boundary (PR #124 (https://github.com/hyodotdev/openiap/pull/124)
+ // review — "999_999_999 nanos rounding up to a full unit"), and
+ // Play stores prices in micros internally so truncation matches
+ // the canonical representation.
+ expect(
+ moneyToMicros({ currencyCode: "USD", units: "0", nanos: 999_999_999 }),
+ ).toBe(999_999);
+ });
+
+ it("uses BigInt math to preserve precision up to Number.MAX_SAFE_INTEGER", () => {
+ // 9_007_199_254 KRW is the largest unit value that, multiplied by
+ // 1_000_000 (micros), stays at or below Number.MAX_SAFE_INTEGER
+ // (9_007_199_254_740_992). Beyond this the new guard correctly
+ // returns undefined to avoid silent IEEE 754 truncation.
+ expect(
+ moneyToMicros({ currencyCode: "KRW", units: "9007199254", nanos: 0 }),
+ ).toBe(9_007_199_254_000_000);
+ });
+
+ it("returns undefined when the converted micros exceed Number.MAX_SAFE_INTEGER", () => {
+ // 1e10 KRW * 1_000_000 micros > 2^53 — the schema stores
+ // priceAmountMicros as a JS number (double), so anything past
+ // the safe range would silently round-trip to a corrupted value.
+ // The guard surfaces "price unknown" so the dashboard can show
+ // an affordance instead of a wrong number.
+ expect(
+ moneyToMicros({ currencyCode: "KRW", units: "10000000000", nanos: 0 }),
+ ).toBeUndefined();
+ });
+
+ it("returns undefined when units is not a parseable BigInt string", () => {
+ expect(
+ moneyToMicros({ currencyCode: "USD", units: "abc", nanos: 0 }),
+ ).toBeUndefined();
+ });
+});
+
+describe("basePlanIdForPeriod", () => {
+ it.each([
+ ["P1W", "weekly"],
+ ["P1M", "monthly"],
+ ["P2M", "bimonthly"],
+ ["P3M", "quarterly"],
+ ["P6M", "semiannual"],
+ ["P1Y", "yearly"],
+ ])("maps %s → %s", (iso, label) => {
+ expect(basePlanIdForPeriod(iso)).toBe(label);
+ });
+
+ it("falls back to monthly for undefined / unknown periods", () => {
+ expect(basePlanIdForPeriod(undefined)).toBe("monthly");
+ expect(basePlanIdForPeriod("P9X")).toBe("monthly");
+ });
+});
diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts
new file mode 100644
index 00000000..bc4ebe68
--- /dev/null
+++ b/packages/kit/convex/products/play.ts
@@ -0,0 +1,963 @@
+"use node";
+import { v } from "convex/values";
+import { google } from "googleapis";
+import type { androidpublisher_v3 } from "googleapis";
+
+import { action } from "../_generated/server";
+import { internal } from "../_generated/api";
+import { getProjectByApiKey } from "../purchases/shared";
+import { coerceBillingPeriod } from "./sync";
+
+/**
+ * Per-product upstream rejection reported back to the dashboard. Used
+ * inside `pushSyncProductsGoogle`'s `failures` array; extracted so the
+ * shape stays in lockstep across every site that pushes into it.
+ */
+export interface ProductSyncFailure {
+ productId: string;
+ reason: string;
+}
+
+// Google Play Developer API client + push-sync action.
+//
+// Auth: reuses the same per-project service-account JSON kit already
+// stores for receipt verification (see `convex/purchases/android.ts`).
+// The googleapis SDK handles OAuth token minting.
+//
+// Surface area:
+// - inappproducts.list → kit ← Play one-time products
+// - inappproducts.get
+// - inappproducts.insert → kit → Play (create new)
+// - inappproducts.patch → kit → Play (update existing)
+// - monetization.subscriptions.list/insert → subscription products
+// The `pushSyncProductsGoogle` action drives both directions.
+
+/**
+ * Pull, push, or two-way sync the project's product catalog with
+ * Google Play's Android Publisher API.
+ *
+ * `direction = "pull"`: import every IAP / subscription that exists
+ * upstream into kit. `direction = "push"`: promote every kit-side row
+ * with `state: "Draft"` to Play. `direction = "both"` (default): pull
+ * first, then push so the catalog converges.
+ *
+ * NOTE on action duration: same caveat as `pushSyncProductsAppleIOS`
+ * — this handler walks the project's catalog sequentially with
+ * per-page Promise.all fan-out. Convex actions have a 10-minute hard
+ * ceiling. Typical commercial apps (<100 SKUs) finish well inside that
+ * bound; catalogs >500 SKUs may need a batched + scheduler-chained
+ * variant. Tracked as a follow-up.
+ *
+ * @returns Counts of `pulled` / `pushed` rows plus a `failures` list
+ * carrying per-product upstream rejection reasons so the
+ * dashboard can render them.
+ */
+export const pushSyncProductsGoogle = action({
+ args: {
+ apiKey: v.string(),
+ direction: v.optional(
+ v.union(v.literal("pull"), v.literal("push"), v.literal("both")),
+ ),
+ },
+ returns: v.object({
+ pulled: v.number(),
+ pushed: v.number(),
+ failures: v.array(v.object({ productId: v.string(), reason: v.string() })),
+ }),
+ handler: async (
+ ctx,
+ args,
+ ): Promise<{
+ pulled: number;
+ pushed: number;
+ failures: ProductSyncFailure[];
+ }> => {
+ const project = await getProjectByApiKey(ctx, args.apiKey);
+ if (!project.androidPackageName) {
+ throw new Error("Project androidPackageName is not configured");
+ }
+
+ const serviceAccountFile = await ctx.runQuery(
+ internal.files.internal.getGooglePlayFileByProjectInternal,
+ { projectId: project._id },
+ );
+ if (!serviceAccountFile) {
+ throw new Error(
+ "Google Play service account JSON not found — upload it before running push-sync",
+ );
+ }
+ const fileContent = await ctx.runAction(
+ internal.files.internal.readFileAsText,
+ { fileId: serviceAccountFile._id },
+ );
+ if (!fileContent?.content) {
+ throw new Error("Service account JSON file is unreadable");
+ }
+ // Wrap the parse so a malformed JSON upload yields an actionable
+ // config error ("Service account JSON is invalid") instead of a
+ // raw SyntaxError from JSON.parse, which surfaces as a generic
+ // 500 with no operator-friendly hint.
+ let credentials: Record;
+ try {
+ credentials = JSON.parse(fileContent.content) as Record;
+ } catch {
+ throw new Error(
+ "Service account JSON is invalid — re-upload the file from Google Cloud Console",
+ );
+ }
+
+ const auth = new google.auth.GoogleAuth({
+ credentials,
+ scopes: ["https://www.googleapis.com/auth/androidpublisher"],
+ });
+ const androidpublisher = google.androidpublisher({ version: "v3", auth });
+ const packageName = project.androidPackageName;
+ const direction = args.direction ?? "both";
+ const failures: ProductSyncFailure[] = [];
+ let pulled = 0;
+ let pushed = 0;
+
+ // ── PULL: Play → kit ─────────────────────────────────────────
+ if (direction === "pull" || direction === "both") {
+ // One-time products. Play has TWO catalog APIs and apps live in
+ // different ones depending on when/how they were set up:
+ //
+ // - `inappproducts.list` — legacy v1 endpoint. Apps created
+ // before the new monetization framework store products here.
+ // - `monetization.onetimeproducts.list` — new endpoint. Apps
+ // onboarded under "Manage products" in the modern Play
+ // Console store products HERE and `inappproducts.list`
+ // silently returns empty for them. (This is why "Sync with
+ // Play Console" was only pulling subscriptions for accounts
+ // using the new console — the one-time products were
+ // invisible to the legacy endpoint.)
+ //
+ // We hit both, dedupe by SKU, and keep going on either failing
+ // — that way an account that lives entirely in one or the other
+ // still gets a complete pull instead of failing on the missing
+ // half.
+ //
+ // ORDER MATTERS: hit the new monetization API first so its
+ // USD-preferred regional price wins. The legacy
+ // `inappproducts.list` only exposes a single `defaultPrice`
+ // (whatever currency the merchant set in Play Console — often
+ // their home currency) which made products like
+ // `dev.hyo.martie.10bulbs` show up as "AED 3.89" on the
+ // dashboard for an operator using a Korean Play Console where
+ // AED happens to be a regional override. New endpoint runs
+ // first; legacy only fills in skus the new endpoint missed.
+ const seenOneTimeSkus = new Set();
+ try {
+ // Defensive guard: the new monetization API isn't surfaced in
+ // any typed shape by `googleapis` yet, so we cast through
+ // `unknown` and read the (possibly-missing) `onetimeproducts`
+ // property. `androidpublisher.monetization` is documented but
+ // could change shape in a future SDK release; failing soft
+ // (treating it as "no monetization endpoint here") lets the
+ // legacy `inappproducts.list` path below still pull what it
+ // can instead of bailing the entire pull half-done. The
+ // outer try/catch records the failure in the per-product
+ // `failures` array so the operator sees something happened.
+ const monetizationApi = androidpublisher.monetization as
+ | { onetimeproducts?: unknown }
+ | undefined;
+ const onetime = (
+ monetizationApi as unknown as {
+ onetimeproducts?: {
+ list: (params: {
+ packageName: string;
+ pageToken?: string;
+ }) => Promise<{
+ data: {
+ oneTimeProducts?: Array<{
+ productId?: string;
+ listings?: Array<{
+ languageCode?: string;
+ title?: string;
+ description?: string;
+ }>;
+ purchaseOptions?: Array<{
+ state?: string;
+ purchaseOptionId?: string;
+ buyOption?: { legacyCompatible?: boolean };
+ rentOption?: unknown;
+ // Pricing lives DIRECTLY on the purchaseOption,
+ // NOT nested inside buyOption. The earlier shape
+ // (buyOption.regionalPricingAndAvailabilityConfigs)
+ // was wrong — every one-time product surfaced
+ // with no price because the lookup never matched.
+ regionalPricingAndAvailabilityConfigs?: Array<{
+ regionCode?: string;
+ // Google's enum: "AVAILABLE",
+ // "NO_LONGER_AVAILABLE", "AVAILABLE_IF_RELEASED".
+ // Stale rows from removed regions still ship
+ // back with a price attached, so without this
+ // field we'd happily display a price the
+ // operator turned off years ago.
+ availability?: string;
+ price?: {
+ currencyCode?: string;
+ units?: string;
+ nanos?: number;
+ };
+ }>;
+ }>;
+ }>;
+ nextPageToken?: string;
+ };
+ }>;
+ };
+ }
+ ).onetimeproducts;
+ if (onetime?.list) {
+ let token: string | undefined;
+ let pageCount = 0;
+ do {
+ const resp = await onetime.list({
+ packageName,
+ ...(token ? { pageToken: token } : {}),
+ });
+ for (const product of resp.data.oneTimeProducts ?? []) {
+ if (!product.productId) continue;
+ if (seenOneTimeSkus.has(product.productId)) continue;
+ seenOneTimeSkus.add(product.productId);
+ const listing = product.listings?.[0];
+ // Walk every purchaseOption × regionalPricingAndAvailabilityConfig
+ // (pricing lives on the option, not inside buyOption).
+ // Two filters before ranking:
+ // - drop regions explicitly NO_LONGER_AVAILABLE so we
+ // don't surface stale pricing the operator removed.
+ // - require the price to have a `units` field — Google
+ // ships zero-priced placeholder rows for some regions
+ // and they'd outrank real prices alphabetically.
+ // Ranking: regionCode === "US" first (canonical kit
+ // display currency, deterministically maps to USD),
+ // then any USD-currency region (covers operators who
+ // override the US region price into a non-USD currency
+ // — rare but possible), then the first remaining region
+ // alphabetically by currency for a stable result.
+ const priceCandidates: Array<{
+ regionCode?: string;
+ currencyCode?: string;
+ units?: string;
+ nanos?: number;
+ }> = [];
+ for (const opt of product.purchaseOptions ?? []) {
+ for (const region of opt.regionalPricingAndAvailabilityConfigs ??
+ []) {
+ if (region.availability === "NO_LONGER_AVAILABLE") continue;
+ if (region.price && typeof region.price.units === "string") {
+ priceCandidates.push({
+ regionCode: region.regionCode,
+ currencyCode: region.price.currencyCode,
+ units: region.price.units,
+ nanos: region.price.nanos,
+ });
+ }
+ }
+ }
+ priceCandidates.sort((a, b) =>
+ (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""),
+ );
+ const preferred =
+ priceCandidates.find((p) => p.regionCode === "US") ??
+ priceCandidates.find((p) => p.currencyCode === "USD") ??
+ priceCandidates[0];
+ const priceAmountMicros = preferred
+ ? moneyToMicros({
+ units: preferred.units,
+ nanos: preferred.nanos,
+ })
+ : undefined;
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId: product.productId,
+ platform: "Android",
+ // The new API doesn't carry a "consumable vs.
+ // non-consumable" distinction the same way — Play
+ // tracks consumption at purchase time. Default to
+ // NonConsumable; operators can edit on the kit side.
+ type: "NonConsumable",
+ title: listing?.title ?? product.productId,
+ description: listing?.description ?? undefined,
+ priceAmountMicros,
+ currency: preferred?.currencyCode ?? undefined,
+ storeRef: product.productId,
+ state: "Active",
+ });
+ pulled += 1;
+ }
+ token = resp.data.nextPageToken ?? undefined;
+ pageCount += 1;
+ if (pageCount > 50) break;
+ } while (token);
+ }
+ } catch (error) {
+ failures.push({
+ productId: "(play list onetimeproducts)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ // Legacy `inappproducts.list` runs SECOND so any sku already
+ // surfaced by the new endpoint (with USD-preferred pricing) wins
+ // via the dedupe set. Only skus invisible to the new endpoint
+ // get filled in here with whatever `defaultPrice` the merchant
+ // set in Play Console.
+ try {
+ let token: string | undefined;
+ let pageCount = 0;
+ do {
+ const oneTimes = await androidpublisher.inappproducts.list({
+ packageName,
+ ...(token ? { token } : {}),
+ });
+ for (const product of oneTimes.data.inappproduct ?? []) {
+ if (!product.sku) continue;
+ if (seenOneTimeSkus.has(product.sku)) continue;
+ seenOneTimeSkus.add(product.sku);
+ if (product.purchaseType === "subscription") continue;
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId: product.sku,
+ platform: "Android",
+ type: mapPlayOneTimeType(product),
+ title: pickPlayTitle(product) ?? product.sku,
+ description: pickPlayDescription(product),
+ priceAmountMicros: parsePlayPriceMicros(product),
+ currency: pickPlayCurrency(product),
+ storeRef: product.sku,
+ state: mapPlayStatus(product.status),
+ });
+ pulled += 1;
+ }
+ token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined;
+ pageCount += 1;
+ if (pageCount > 50) break;
+ } while (token);
+ } catch (error) {
+ // The legacy `inappproducts.list` endpoint is deprecated for
+ // newer Play Console accounts and Google now responds with
+ // "Please migrate to the new publishing API". That message is
+ // expected — the new `monetization.onetimeproducts.list` call
+ // above already covers this account — and surfacing it as a
+ // failure produces a noisy red toast every Sync. Suppress it
+ // when seen; surface anything else.
+ const reason = error instanceof Error ? error.message : String(error);
+ if (!/migrate to the new publishing API/i.test(reason)) {
+ failures.push({
+ productId: "(play list inappproducts)",
+ reason,
+ });
+ }
+ }
+
+ try {
+ let token: string | undefined;
+ let pageCount = 0;
+ do {
+ const subs = await androidpublisher.monetization.subscriptions.list({
+ packageName,
+ ...(token ? { pageToken: token } : {}),
+ });
+ for (const sub of subs.data.subscriptions ?? []) {
+ if (!sub.productId) continue;
+ const { priceAmountMicros, currency, basePlanId } =
+ pickSubBasePlanPrice(sub);
+ const offers = collectPlaySubscriptionOffers(sub);
+ // Pick the billingPeriod from the *same* base plan whose
+ // price we just selected (`basePlanId` returned by
+ // pickSubBasePlanPrice). If we can't find that exact plan
+ // in `offers`, fall back to the first BasePlan row — but
+ // this fallback only triggers when basePlanId is missing,
+ // which means the subscription has no price at all.
+ // Without the basePlanId match, mixed monthly + yearly
+ // products would pair the yearly USD price with the
+ // monthly duration and break MRR normalization.
+ const billingPeriod = (
+ basePlanId
+ ? offers.find(
+ (o) => o.kind === "BasePlan" && o.id === basePlanId,
+ )
+ : offers.find((o) => o.kind === "BasePlan")
+ )?.duration;
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId: sub.productId,
+ platform: "Android",
+ type: "Subscription",
+ title: sub.listings?.[0]?.title ?? sub.productId,
+ description: sub.listings?.[0]?.description ?? undefined,
+ priceAmountMicros,
+ currency,
+ storeRef: sub.productId,
+ state: "Active",
+ billingPeriod: coerceBillingPeriod(billingPeriod),
+ // Play has no first-class subscription "group" — base
+ // plans on a single subscription product play that role,
+ // and we surface them as `offers[].kind === "BasePlan"`
+ // rows. Leave the ASC-only group fields unset.
+ offers: offers.length ? offers : undefined,
+ });
+ pulled += 1;
+ }
+ token = subs.data.nextPageToken ?? undefined;
+ pageCount += 1;
+ if (pageCount > 50) break;
+ } while (token);
+ } catch (error) {
+ failures.push({
+ productId: "(play list subscriptions)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ // ── PUSH: kit → Play for Draft rows ──────────────────────────
+ if (direction === "push" || direction === "both") {
+ const drafts = await ctx.runQuery(
+ internal.products.sync.listDraftAndroidProducts,
+ { projectId: project._id },
+ );
+ for (const row of drafts) {
+ try {
+ // When this row already has a storeRef from a prior partial
+ // sync, run the appropriate update endpoint instead of the
+ // create endpoint — Play returns 409 Conflict on
+ // create-with-existing-productId and ASC's parity step
+ // (asc.ts) does the same patch flow. Listings + price are
+ // both safe to re-push idempotently. Without this, kit-side
+ // edits made after the initial push would silently never
+ // reach Play (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (row.storeRef) {
+ // Track whether the patch step succeeded — only flip to
+ // Ready when the upstream actually accepted our changes,
+ // otherwise the row stays Draft and surfaces in the next
+ // sync's drafts list for retry (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ let patchOk = true;
+ if (row.type === "Subscription") {
+ // Subscriptions: patch the listing via
+ // monetization.subscriptions.patch (en-US listing only —
+ // multi-language sync is a future feature). Base-plan
+ // price changes have to go through a separate
+ // monetization.subscriptions.basePlans endpoint, so we
+ // intentionally don't try to mutate price here; that
+ // requires a deactivate+recreate flow Play doesn't allow
+ // in a single call. The dashboard surfaces a hint when
+ // the kit-side row has a different price than the
+ // pulled row so the operator knows to do that step
+ // manually.
+ try {
+ await androidpublisher.monetization.subscriptions.patch({
+ packageName,
+ productId: row.storeRef,
+ updateMask: "listings",
+ // `regionsVersion` is required by the Play API on
+ // every patch/create — it pins the regional-pricing
+ // schema version (Google added the `2022/01` revision
+ // when they switched the regional config shape) and
+ // the request 400s without it. PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124)
+ // review. The googleapis SDK exposes this as a flat
+ // querystring param (`regionsVersion.version`).
+ "regionsVersion.version": "2022/01",
+ requestBody: {
+ productId: row.storeRef,
+ listings: [
+ {
+ languageCode: "en-US",
+ title: row.title,
+ description: row.description ?? row.title,
+ },
+ ],
+ },
+ });
+ } catch (error) {
+ // 404 = subscription was deleted upstream after our
+ // last pull; surface as a failure so the operator
+ // re-creates it. Anything else also surfaces.
+ patchOk = false;
+ failures.push({
+ productId: `${row.productId} (subscription patch)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ } else {
+ // One-time product: patch listings + price via the
+ // legacy inappproducts.patch endpoint, which accepts a
+ // partial body and merges it.
+ try {
+ await androidpublisher.inappproducts.patch({
+ packageName,
+ sku: row.storeRef,
+ requestBody: {
+ packageName,
+ sku: row.storeRef,
+ purchaseType: "managedUser",
+ listings: {
+ "en-US": {
+ title: row.title,
+ description: row.description ?? row.title,
+ },
+ },
+ ...(row.priceAmountMicros !== undefined && row.currency
+ ? {
+ defaultPrice: {
+ priceMicros: String(row.priceAmountMicros),
+ currency: row.currency,
+ },
+ }
+ : {}),
+ },
+ });
+ } catch (error) {
+ patchOk = false;
+ failures.push({
+ productId: `${row.productId} (inapp patch)`,
+ reason:
+ error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ if (patchOk) {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "Android",
+ storeRef: row.storeRef,
+ });
+ pushed += 1;
+ }
+ continue;
+ }
+ if (row.type === "Subscription") {
+ // Reject subscription creates that would land on Play with
+ // no base plan: such a subscription is created in a draft
+ // state that the Play app cannot purchase, which silently
+ // breaks the SDK's `requestPurchase` flow downstream. The
+ // operator must provide both a price and currency at
+ // minimum so we can synthesize a base plan.
+ if (!row.priceAmountMicros || !row.currency) {
+ throw new Error(
+ "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.",
+ );
+ }
+ const basePlanId = basePlanIdForPeriod(row.billingPeriod);
+ await androidpublisher.monetization.subscriptions.create({
+ packageName,
+ productId: row.productId,
+ // `regionsVersion` is required by the v3 API on every
+ // create — pins the regional-pricing schema revision
+ // (Google introduced `2022/01` when the regional-config
+ // shape changed). The request 400s without it. The
+ // googleapis SDK exposes this as a flat querystring
+ // param (`regionsVersion.version`).
+ "regionsVersion.version": "2022/01",
+ requestBody: {
+ productId: row.productId,
+ listings: [
+ {
+ languageCode: "en-US",
+ title: row.title,
+ description: row.description ?? row.title,
+ },
+ ],
+ // Auto-renewing base plan. Period from the catalog row;
+ // defaults to monthly when the operator hasn't picked
+ // one. The base-plan id mirrors the duration so a row
+ // upgraded later from monthly→yearly doesn't collide
+ // with an existing base plan id in Play Console.
+ basePlans: [
+ {
+ basePlanId,
+ autoRenewingBasePlanType: {
+ billingPeriodDuration: row.billingPeriod ?? "P1M",
+ },
+ regionalConfigs: [
+ {
+ regionCode: "US",
+ price: {
+ currencyCode: row.currency,
+ units: String(
+ Math.trunc(row.priceAmountMicros / 1_000_000),
+ ),
+ nanos: (row.priceAmountMicros % 1_000_000) * 1_000,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ });
+ // Activate the just-created base plan. Play's v3 API
+ // creates new base plans in DRAFT regardless of the
+ // `state` field on the create payload — the SKU isn't
+ // purchasable until `basePlans.activate` flips it to
+ // ACTIVE. Without this call we'd mark the row Ready while
+ // the upstream subscription is still non-purchasable
+ // (PR #124 (https://github.com/hyodotdev/openiap/pull/124)
+ // review).
+ await androidpublisher.monetization.subscriptions.basePlans.activate(
+ {
+ packageName,
+ productId: row.productId,
+ basePlanId,
+ requestBody: {
+ latencyTolerance:
+ "PRODUCT_UPDATE_LATENCY_TOLERANCE_LATENCY_TOLERANT",
+ },
+ },
+ );
+ } else {
+ await androidpublisher.inappproducts.insert({
+ packageName,
+ requestBody: {
+ packageName,
+ sku: row.productId,
+ // Play API uses `managedUser` for both consumable and
+ // non-consumable; the difference is consumed at
+ // purchase time via `consumeAsync`. Subscriptions go
+ // through `monetization.subscriptions.*` (see branch
+ // above), not this endpoint.
+ purchaseType: "managedUser",
+ status: "active",
+ defaultLanguage: "en-US",
+ listings: {
+ "en-US": {
+ title: row.title,
+ description: row.description ?? row.title,
+ },
+ },
+ ...(row.priceAmountMicros !== undefined && row.currency
+ ? {
+ defaultPrice: {
+ priceMicros: String(row.priceAmountMicros),
+ currency: row.currency,
+ },
+ }
+ : {}),
+ },
+ });
+ }
+ // Persist storeRef immediately after the create returns,
+ // BEFORE flipping state to Ready via markPushed. If the
+ // action times out / crashes between create and markPushed,
+ // the next sync still sees this row's storeRef populated
+ // and will skip the create call (avoiding 409 Conflict
+ // from re-creating the same productId in Play). Mirrors the
+ // partial-sync resilience pattern in pushSyncProductsAppleIOS.
+ // Play's productId IS the storeRef (no separate opaque id).
+ await ctx.runMutation(internal.products.sync.markStoreRef, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "Android",
+ storeRef: row.productId,
+ });
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "Android",
+ storeRef: row.productId,
+ });
+ pushed += 1;
+ } catch (error) {
+ failures.push({
+ productId: row.productId,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+
+ return { pulled, pushed, failures };
+ },
+});
+
+function mapPlayOneTimeType(
+ product: androidpublisher_v3.Schema$InAppProduct,
+): "Subscription" | "NonConsumable" | "Consumable" {
+ if (product.purchaseType === "managedUser") return "NonConsumable";
+ return "Consumable";
+}
+
+function mapPlayStatus(
+ status: string | null | undefined,
+): "Draft" | "Ready" | "Active" | "Removed" {
+ switch (status) {
+ case "active":
+ return "Active";
+ case "inactive":
+ return "Removed";
+ default:
+ return "Draft";
+ }
+}
+
+function pickPlayTitle(
+ product: androidpublisher_v3.Schema$InAppProduct,
+): string | undefined {
+ const def = product.defaultLanguage ?? "en-US";
+ return product.listings?.[def]?.title ?? undefined;
+}
+
+function pickPlayDescription(
+ product: androidpublisher_v3.Schema$InAppProduct,
+): string | undefined {
+ const def = product.defaultLanguage ?? "en-US";
+ return product.listings?.[def]?.description ?? undefined;
+}
+
+function parsePlayPriceMicros(
+ product: androidpublisher_v3.Schema$InAppProduct,
+): number | undefined {
+ const raw = product.defaultPrice?.priceMicros;
+ if (!raw) return undefined;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+function pickPlayCurrency(
+ product: androidpublisher_v3.Schema$InAppProduct,
+): string | undefined {
+ return product.defaultPrice?.currency ?? undefined;
+}
+
+// Pick a representative price + currency for a subscription. The
+// previous implementation had three bugs that combined to produce the
+// "wrong currency" / "missing price" output the dashboard surfaced:
+//
+// 1. It bailed out (returned null price) whenever
+// `legacyCompatibleSubscriptionOfferId` was set on the base plan.
+// That field's presence has nothing to do with whether the plan
+// has pricing — it's a migration shim from the static-pricing era
+// — so any sub configured with that compat id silently lost its
+// price. (Hence the second product showing "—" in the screenshot.)
+// 2. It always read `regionalConfigs?.[0]`, which is just whichever
+// region Google sorted first. That made the UI flip between AED /
+// USD / KRW depending on the response order.
+// 3. Currency and price were read independently and could disagree.
+//
+// New rule: walk every basePlan, walk every regionalConfig, prefer USD
+// if any region offers it, otherwise return the first region with a
+// readable price. Currency + price come from the SAME regionalConfig
+// so they're always consistent.
+function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): {
+ priceAmountMicros?: number;
+ currency?: string;
+ // The basePlanId of the plan whose price we picked, so the caller
+ // can pull `billingPeriod` from the *same* plan instead of guessing
+ // (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review:
+ // mixed monthly + yearly base plans previously paired the yearly
+ // USD price with the monthly billingPeriod, breaking MRR
+ // normalization).
+ basePlanId?: string;
+} {
+ type Candidate = {
+ price: androidpublisher_v3.Schema$Money;
+ basePlanId?: string;
+ };
+ const candidates: Candidate[] = [];
+ for (const plan of sub.basePlans ?? []) {
+ const basePlanId = plan.basePlanId ?? undefined;
+ for (const region of plan.regionalConfigs ?? []) {
+ if (region.price) candidates.push({ price: region.price, basePlanId });
+ }
+ }
+ if (candidates.length === 0) return {};
+ // Prefer USD when any region offers it — it's the most universally
+ // recognizable in a dashboard. The operator can edit per-region
+ // prices in Play Console; this just picks a stable display value.
+ const preferred =
+ candidates.find((c) => c.price.currencyCode === "USD") ?? candidates[0];
+ return {
+ priceAmountMicros: moneyToMicros(preferred.price),
+ currency: preferred.price.currencyCode ?? undefined,
+ basePlanId: preferred.basePlanId,
+ };
+}
+
+// Flatten a Play subscription's basePlans + (per base plan) offers
+// into kit's uniform `offers[]` shape. Each base plan becomes a
+// `kind: "BasePlan"` row carrying its billing period + USD price; each
+// associated subscription offer (free trial / intro discount, set up
+// in Play Console) becomes a Free-Trial / IntroPay* row. Prefers USD
+// regional price when present (mirrors `pickSubBasePlanPrice`'s
+// rationale) so the dashboard shows a stable currency.
+function collectPlaySubscriptionOffers(
+ sub: androidpublisher_v3.Schema$Subscription,
+): Array<{
+ id: string;
+ kind:
+ | "BasePlan"
+ | "FreeTrial"
+ | "IntroPayUpFront"
+ | "IntroPayAsYouGo"
+ | "PromotionalOffer";
+ duration?: string;
+ numberOfPeriods?: number;
+ priceAmountMicros?: number;
+ currency?: string;
+}> {
+ const out: Array<{
+ id: string;
+ kind:
+ | "BasePlan"
+ | "FreeTrial"
+ | "IntroPayUpFront"
+ | "IntroPayAsYouGo"
+ | "PromotionalOffer";
+ duration?: string;
+ numberOfPeriods?: number;
+ priceAmountMicros?: number;
+ currency?: string;
+ }> = [];
+ // Local shape for `basePlans[].offers[]` — googleapis' generated
+ // `Schema$BasePlan` doesn't expose offers despite the underlying
+ // REST resource carrying them, and we don't want to depend on the
+ // SDK regenerating to surface this. Mirrors the relevant fields
+ // from Play's `SubscriptionOffer` proto.
+ type PlanOfferShape = {
+ offerId?: string;
+ phases?: Array<{
+ duration?: string;
+ recurrenceCount?: number;
+ regionalConfigs?: Array<{
+ regionCode?: string;
+ price?: androidpublisher_v3.Schema$Money;
+ }>;
+ }>;
+ };
+ type PlanWithOffers = androidpublisher_v3.Schema$BasePlan & {
+ offers?: PlanOfferShape[];
+ };
+ for (const plan of (sub.basePlans ?? []) as PlanWithOffers[]) {
+ if (!plan.basePlanId) continue;
+ const planRegions = plan.regionalConfigs ?? [];
+ const planPrice =
+ planRegions.find((r) => r.price?.currencyCode === "USD")?.price ??
+ planRegions[0]?.price;
+ out.push({
+ id: plan.basePlanId,
+ kind: "BasePlan",
+ duration:
+ plan.autoRenewingBasePlanType?.billingPeriodDuration ?? undefined,
+ priceAmountMicros: moneyToMicros(planPrice ?? undefined),
+ currency: planPrice?.currencyCode ?? undefined,
+ });
+ for (const offer of plan.offers ?? []) {
+ if (!offer.offerId) continue;
+ // Walk the offer's phases. A FREE phase becomes FreeTrial; a
+ // DISCOUNTED phase with a single occurrence becomes
+ // IntroPayUpFront; multi-occurrence becomes IntroPayAsYouGo.
+ // Most offers only have one of these; if multiple, we emit
+ // multiple rows tagged with the same composite id so the
+ // dashboard can dedupe by basePlanId+offerId+phaseIndex.
+ const phases = offer.phases ?? [];
+ phases.forEach((phase, i) => {
+ const phaseRegions = phase.regionalConfigs ?? [];
+ const phasePrice =
+ phaseRegions.find((r) => r.price?.currencyCode === "USD")?.price ??
+ phaseRegions[0]?.price;
+ // Phase with no price = free trial; with `recurrenceCount > 1`
+ // = pay-as-you-go intro; otherwise = pay-up-front intro.
+ let kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" =
+ "FreeTrial";
+ const isFree =
+ !phasePrice ||
+ (phasePrice.units === "0" && (phasePrice.nanos ?? 0) === 0);
+ if (!isFree) {
+ kind =
+ (phase.recurrenceCount ?? 1) > 1
+ ? "IntroPayAsYouGo"
+ : "IntroPayUpFront";
+ }
+ out.push({
+ id: `${plan.basePlanId}/${offer.offerId}#${i}`,
+ kind,
+ duration: phase.duration ?? undefined,
+ numberOfPeriods: phase.recurrenceCount ?? undefined,
+ priceAmountMicros: isFree ? undefined : moneyToMicros(phasePrice),
+ currency: isFree
+ ? undefined
+ : (phasePrice?.currencyCode ?? undefined),
+ });
+ });
+ }
+ }
+ return out;
+}
+
+/**
+ * Convert a Google `Money` proto into the integer micros (1/1,000,000
+ * of the currency unit) representation kit stores on every product row.
+ *
+ * `units` is a BigInt-as-string in the Play proto, so the micros
+ * multiplication is done in BigInt to avoid IEEE 754 precision loss on
+ * large currency values (>2^53). The nanos → micros conversion is
+ * BigInt division which truncates (not `Math.round`, which would push
+ * `999_999_999` nanos up to a full 1_000_000 micros and silently add 1
+ * micro to sub-unit prices). Truncation matches how Google Play Console
+ * stores price points internally — Play uses micros as the canonical
+ * unit, so any rounding here would re-introduce drift we just cleaned
+ * up. Resolves to `undefined` when the input has no `units`, when the
+ * BigInt parse throws (malformed `units` string), or when the resulting
+ * micros exceed `Number.MAX_SAFE_INTEGER` (≈ USD 9 billion — kit treats
+ * those rows as price-unknown rather than silently corrupting them).
+ *
+ * PR #124 (https://github.com/hyodotdev/openiap/pull/124) review fix.
+ */
+export function moneyToMicros(
+ money: androidpublisher_v3.Schema$Money | undefined,
+): number | undefined {
+ if (!money?.units) return undefined;
+ try {
+ const microsBigInt =
+ BigInt(money.units) * 1_000_000n + BigInt(money.nanos ?? 0) / 1_000n;
+ // Drop values that exceed Number.MAX_SAFE_INTEGER. The schema
+ // stores `priceAmountMicros` as a JS `number` (IEEE 754 double),
+ // so anything above 2^53 - 1 would silently lose precision on
+ // round-trip. In practice no realistic IAP price hits that bound
+ // (it's ~9.0e15 micros = USD 9 billion), but for currencies with
+ // very high unit values like IDR / KRW it's worth the explicit
+ // guard rather than a silent corruption — kit treats the row as
+ // "price unknown" and the dashboard surfaces that affordance.
+ if (
+ microsBigInt > BigInt(Number.MAX_SAFE_INTEGER) ||
+ microsBigInt < BigInt(Number.MIN_SAFE_INTEGER)
+ ) {
+ return undefined;
+ }
+ return Number(microsBigInt);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Map an ISO 8601 billing-period string (`P1W` / `P1M` / `P1Y` / etc.)
+ * to a stable, descriptive basePlanId for the Play console. Play's
+ * product detail page surfaces this id verbatim, so "yearly" /
+ * "weekly" reads better than the default "monthly" hardcoded fallback
+ * we used before. Unknown / undefined periods collapse to `"monthly"`.
+ */
+export function basePlanIdForPeriod(period: string | undefined): string {
+ switch (period) {
+ case "P1W":
+ return "weekly";
+ case "P2M":
+ return "bimonthly";
+ case "P3M":
+ return "quarterly";
+ case "P6M":
+ return "semiannual";
+ case "P1Y":
+ return "yearly";
+ case "P1M":
+ case undefined:
+ default:
+ return "monthly";
+ }
+}
diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts
new file mode 100644
index 00000000..db0211e5
--- /dev/null
+++ b/packages/kit/convex/products/query.ts
@@ -0,0 +1,94 @@
+import { query } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc } from "../_generated/dataModel";
+
+const offerShape = v.object({
+ id: v.string(),
+ kind: v.union(
+ v.literal("FreeTrial"),
+ v.literal("IntroPayUpFront"),
+ v.literal("IntroPayAsYouGo"),
+ v.literal("PromotionalOffer"),
+ v.literal("BasePlan"),
+ ),
+ duration: v.optional(v.string()),
+ numberOfPeriods: v.optional(v.number()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+});
+
+const productShape = v.object({
+ productId: v.string(),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ type: v.union(
+ v.literal("Subscription"),
+ v.literal("NonConsumable"),
+ v.literal("Consumable"),
+ ),
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ state: v.union(
+ v.literal("Draft"),
+ v.literal("Ready"),
+ v.literal("Active"),
+ v.literal("Removed"),
+ ),
+ storeRef: v.optional(v.string()),
+ subscriptionGroupId: v.optional(v.string()),
+ subscriptionGroupName: v.optional(v.string()),
+ offers: v.optional(v.array(offerShape)),
+ updatedAt: v.number(),
+});
+
+function shape(product: Doc<"products">) {
+ return {
+ productId: product.productId,
+ platform: product.platform,
+ type: product.type,
+ title: product.title,
+ description: product.description,
+ priceAmountMicros: product.priceAmountMicros,
+ currency: product.currency,
+ state: product.state,
+ storeRef: product.storeRef,
+ subscriptionGroupId: product.subscriptionGroupId,
+ subscriptionGroupName: product.subscriptionGroupName,
+ offers: product.offers,
+ updatedAt: product.updatedAt,
+ };
+}
+
+export const listProducts = query({
+ args: {
+ apiKey: v.string(),
+ platform: v.optional(v.union(v.literal("IOS"), v.literal("Android"))),
+ },
+ returns: v.array(productShape),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) return [];
+
+ if (args.platform) {
+ const rows = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform", (q) =>
+ q.eq("projectId", project._id).eq("platform", args.platform!),
+ )
+ .collect();
+ return rows.map(shape);
+ }
+
+ const rows = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q.eq("projectId", project._id),
+ )
+ .collect();
+ return rows.map(shape);
+ },
+});
diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts
new file mode 100644
index 00000000..30501863
--- /dev/null
+++ b/packages/kit/convex/products/sync.ts
@@ -0,0 +1,348 @@
+import { internalMutation, internalQuery } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc, Id } from "../_generated/dataModel";
+
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+const typeValidator = v.union(
+ v.literal("Subscription"),
+ v.literal("NonConsumable"),
+ v.literal("Consumable"),
+);
+const stateValidator = v.union(
+ v.literal("Draft"),
+ v.literal("Ready"),
+ v.literal("Active"),
+ v.literal("Removed"),
+);
+
+const offerKindValidator = v.union(
+ v.literal("FreeTrial"),
+ v.literal("IntroPayUpFront"),
+ v.literal("IntroPayAsYouGo"),
+ v.literal("PromotionalOffer"),
+ v.literal("BasePlan"),
+);
+const offerValidator = v.object({
+ id: v.string(),
+ kind: offerKindValidator,
+ duration: v.optional(v.string()),
+ numberOfPeriods: v.optional(v.number()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+});
+
+// Coerce a free-form billingPeriod string into the schema's literal
+// union, returning undefined for unknown values. ASC and Play both
+// hand us ISO-8601 strings ("P1M" / "P1Y" / etc.) but a future Apple
+// enum or Play SDK quirk could leak something we don't model — in
+// that case we'd rather drop the field (so MRR shows 0 with a clear
+// "unknown period" log line) than persist garbage that breaks the
+// schema validator.
+export type BillingPeriodLiteral =
+ | "P1W"
+ | "P1M"
+ | "P2M"
+ | "P3M"
+ | "P6M"
+ | "P1Y";
+const KNOWN_BILLING_PERIODS = new Set([
+ "P1W",
+ "P1M",
+ "P2M",
+ "P3M",
+ "P6M",
+ "P1Y",
+]);
+export function coerceBillingPeriod(
+ raw: string | undefined,
+): BillingPeriodLiteral | undefined {
+ if (!raw) return undefined;
+ return KNOWN_BILLING_PERIODS.has(raw as BillingPeriodLiteral)
+ ? (raw as BillingPeriodLiteral)
+ : undefined;
+}
+
+// Internal mutation called by the ASC / Play push-sync actions when a
+// row is mirrored from the upstream store. Distinct from the public
+// `upsertProduct` mutation in mutation.ts so server-driven sync can't
+// be triggered by anyone holding the apiKey alone.
+export const upsertFromStore = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ productId: v.string(),
+ platform: platformValidator,
+ type: typeValidator,
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ storeRef: v.string(),
+ state: stateValidator,
+ // ISO-8601 billing period. Required for correct MRR
+ // normalization in metricsSummary — without this field synced
+ // subscriptions defaulted to undefined and monthlyMicrosForSub
+ // returned 0, silently zeroing every synced sub's contribution
+ // to the dashboard headline. Union mirrors the schema's
+ // `billingPeriod` literal — non-matching upstream values (a
+ // future Apple/Play enum) get coerced via mapBillingPeriodLiteral
+ // at the call site so this validator can stay strict.
+ billingPeriod: v.optional(
+ v.union(
+ v.literal("P1W"),
+ v.literal("P1M"),
+ v.literal("P2M"),
+ v.literal("P3M"),
+ v.literal("P6M"),
+ v.literal("P1Y"),
+ ),
+ ),
+ subscriptionGroupId: v.optional(v.string()),
+ subscriptionGroupName: v.optional(v.string()),
+ offers: v.optional(v.array(offerValidator)),
+ },
+ returns: v.id("products"),
+ handler: async (ctx, args) => {
+ // Match by (projectId, platform, productId) — apps commonly use
+ // the same productId on both stores, and the older
+ // (projectId, productId)-only lookup would collide and silently
+ // flip an existing Android row's platform to IOS (or vice versa)
+ // mid-sync, deleting one platform's catalog from the dashboard's
+ // perspective.
+ const existing: Doc<"products"> | null = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+ const now = Date.now();
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ type: args.type,
+ title: args.title || existing.title,
+ description: args.description ?? existing.description,
+ priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros,
+ currency: args.currency ?? existing.currency,
+ storeRef: args.storeRef,
+ state: args.state,
+ // Subscription metadata is sourced from the store on every
+ // pull, so we overwrite (not coalesce) — a sub that was
+ // moved between groups in ASC, or that lost a free trial in
+ // Play Console, should reflect that on the next sync rather
+ // than stick to whatever kit cached previously. Same applies
+ // to billingPeriod: the upstream is the source of truth.
+ billingPeriod: args.billingPeriod,
+ subscriptionGroupId: args.subscriptionGroupId,
+ subscriptionGroupName: args.subscriptionGroupName,
+ offers: args.offers,
+ syncedAt: now,
+ updatedAt: now,
+ });
+ return existing._id;
+ }
+ const id: Id<"products"> = await ctx.db.insert("products", {
+ projectId: args.projectId,
+ productId: args.productId,
+ platform: args.platform,
+ type: args.type,
+ title: args.title,
+ description: args.description,
+ priceAmountMicros: args.priceAmountMicros,
+ currency: args.currency,
+ storeRef: args.storeRef,
+ state: args.state,
+ billingPeriod: args.billingPeriod,
+ subscriptionGroupId: args.subscriptionGroupId,
+ subscriptionGroupName: args.subscriptionGroupName,
+ offers: args.offers,
+ syncedAt: now,
+ updatedAt: now,
+ });
+ return id;
+ },
+});
+
+// Persist the upstream resource id immediately after the create call
+// succeeds, *without* advancing state past Draft. The follow-up steps
+// (localization, price schedule) may still fail, and a hard failure
+// there shouldn't strand the upstream resource — the next sync needs
+// to find this row, see the populated storeRef, and resume from
+// step 2 instead of trying to create a duplicate. `markPushed`
+// remains the success path that flips state to Ready.
+export const markStoreRef = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ productId: v.string(),
+ platform: platformValidator,
+ storeRef: v.string(),
+ },
+ returns: v.union(v.id("products"), v.null()),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+ if (!existing) return null;
+ await ctx.db.patch(existing._id, {
+ storeRef: args.storeRef,
+ syncedAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ return existing._id;
+ },
+});
+
+// After a successful push, write the upstream resource id back so the
+// next pull doesn't double-create.
+export const markPushed = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ productId: v.string(),
+ platform: platformValidator,
+ storeRef: v.string(),
+ },
+ returns: v.union(v.id("products"), v.null()),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("platform", args.platform)
+ .eq("productId", args.productId),
+ )
+ .unique();
+ if (!existing) return null;
+ await ctx.db.patch(existing._id, {
+ storeRef: args.storeRef,
+ state: "Ready",
+ syncedAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ return existing._id;
+ },
+});
+
+// Pull every Draft iOS row that the push pass should attempt. We do
+// NOT gate on `storeRef === undefined` here: a previous sync may have
+// successfully created the upstream resource (storeRef now populated)
+// but failed on a subsequent step (localization / price schedule).
+// Such rows stay in state=Draft and the push branch needs to revisit
+// them — using their existing storeRef to skip the create call and
+// retry only the failed steps. The push branch handles the
+// "skip create when storeRef already set" decision.
+export const listDraftIosProducts = internalQuery({
+ args: { projectId: v.id("projects") },
+ returns: v.array(
+ v.object({
+ productId: v.string(),
+ platform: platformValidator,
+ type: typeValidator,
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ billingPeriod: v.optional(
+ v.union(
+ v.literal("P1W"),
+ v.literal("P1M"),
+ v.literal("P2M"),
+ v.literal("P3M"),
+ v.literal("P6M"),
+ v.literal("P1Y"),
+ ),
+ ),
+ subscriptionGroupName: v.optional(v.string()),
+ reviewNote: v.optional(v.string()),
+ storeRef: v.optional(v.string()),
+ }),
+ ),
+ handler: async (ctx, args) => {
+ const all = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform", (q) =>
+ q.eq("projectId", args.projectId).eq("platform", "IOS"),
+ )
+ .collect();
+ return all
+ .filter((row) => row.state === "Draft")
+ .map((row) => ({
+ productId: row.productId,
+ platform: row.platform,
+ type: row.type,
+ title: row.title,
+ description: row.description,
+ priceAmountMicros: row.priceAmountMicros,
+ currency: row.currency,
+ billingPeriod: row.billingPeriod,
+ subscriptionGroupName: row.subscriptionGroupName,
+ reviewNote: row.reviewNote,
+ storeRef: row.storeRef,
+ }));
+ },
+});
+
+// Same for Android — used by the Play push action.
+export const listDraftAndroidProducts = internalQuery({
+ args: { projectId: v.id("projects") },
+ returns: v.array(
+ v.object({
+ productId: v.string(),
+ platform: platformValidator,
+ type: typeValidator,
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ billingPeriod: v.optional(
+ v.union(
+ v.literal("P1W"),
+ v.literal("P1M"),
+ v.literal("P2M"),
+ v.literal("P3M"),
+ v.literal("P6M"),
+ v.literal("P1Y"),
+ ),
+ ),
+ storeRef: v.optional(v.string()),
+ }),
+ ),
+ handler: async (ctx, args) => {
+ const all = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform", (q) =>
+ q.eq("projectId", args.projectId).eq("platform", "Android"),
+ )
+ .collect();
+ // Mirror the iOS filter: state === Draft only. The earlier
+ // `storeRef === undefined` guard was added to avoid re-pushing
+ // Pull-imported rows that already existed upstream, but it also
+ // blocked partial-sync resumption — a Draft row whose create
+ // succeeded but whose listing/price step failed never got
+ // retried. play.ts now branches on `row.storeRef` at the top of
+ // the push loop and PATCHes existing storeRefs instead of
+ // creating, so both the partial-sync and pull-then-push cases
+ // are correct without the extra filter (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ return all
+ .filter((row) => row.state === "Draft")
+ .map((row) => ({
+ productId: row.productId,
+ platform: row.platform,
+ type: row.type,
+ title: row.title,
+ description: row.description,
+ priceAmountMicros: row.priceAmountMicros,
+ currency: row.currency,
+ billingPeriod: row.billingPeriod,
+ storeRef: row.storeRef,
+ }));
+ },
+});
diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts
index ef25b561..7b603dc4 100644
--- a/packages/kit/convex/projects/mutation.ts
+++ b/packages/kit/convex/projects/mutation.ts
@@ -260,6 +260,11 @@ export const updateProject = mutation({
iosAppAppleId: v.optional(v.number()),
iosAppStoreIssuerId: v.optional(v.string()),
iosAppStoreKeyId: v.optional(v.string()),
+ // App Store Connect API credentials — separate from the Server API
+ // (In-App Purchase) credentials above. See schema.ts for the
+ // distinction. Used by `products/asc.ts` push-sync.
+ iosAscIssuerId: v.optional(v.string()),
+ iosAscKeyId: v.optional(v.string()),
// Meta Horizon (Quest / VR) — piggybacks on the Android section
// in the dashboard since the client SDK is Google-Play-Billing-
// compatible. Validation only runs when horizonEnabled === true.
@@ -319,6 +324,12 @@ export const updateProject = mutation({
if (args.iosAppStoreKeyId !== undefined) {
updates.iosAppStoreKeyId = normalizeAppStoreKeyId(args.iosAppStoreKeyId);
}
+ if (args.iosAscIssuerId !== undefined) {
+ updates.iosAscIssuerId = normalizeAppStoreIssuerId(args.iosAscIssuerId);
+ }
+ if (args.iosAscKeyId !== undefined) {
+ updates.iosAscKeyId = normalizeAppStoreKeyId(args.iosAscKeyId);
+ }
// Horizon fields: validated only when the feature is being
// enabled or when populated values are supplied. Toggling off
diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts
new file mode 100644
index 00000000..08a663ab
--- /dev/null
+++ b/packages/kit/convex/projects/setupStatus.ts
@@ -0,0 +1,99 @@
+import { query } from "../_generated/server";
+import { v } from "convex/values";
+
+// Public query — surfaces which platforms a project has configured so
+// the dashboard, the SDK, and the MCP server can return a precise
+// "X not configured" error instead of a silent empty response.
+//
+// Auth via apiKey (same model as the rest of the v1 surface). Returns
+// `found: false` when the key is unknown so the dashboard can render
+// "log in to a different project" without leaking which keys exist.
+
+const platformShape = v.object({
+ configured: v.boolean(),
+ missing: v.array(v.string()),
+});
+
+export const getSetupStatus = query({
+ args: { apiKey: v.string() },
+ returns: v.object({
+ found: v.boolean(),
+ projectId: v.union(v.id("projects"), v.null()),
+ ios: platformShape,
+ android: platformShape,
+ horizon: platformShape,
+ appleP8Uploaded: v.boolean(),
+ googleServiceAccountUploaded: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+
+ if (!project) {
+ const empty = { configured: false, missing: ["project not found"] };
+ return {
+ found: false,
+ projectId: null,
+ ios: empty,
+ android: empty,
+ horizon: empty,
+ appleP8Uploaded: false,
+ googleServiceAccountUploaded: false,
+ };
+ }
+
+ // Pull the project's uploaded files once so we can both report
+ // field-level config AND surface .p8 / service-account presence
+ // in the same response — the dashboard's setup card was always
+ // rendering "missing" because the previous shape hardcoded both
+ // flags to false.
+ const projectFiles = await ctx.db
+ .query("files")
+ .withIndex("by_project", (q) => q.eq("projectId", project._id))
+ .collect();
+
+ const iosMissing: string[] = [];
+ if (!project.iosBundleId) iosMissing.push("iosBundleId");
+ if (!project.iosAppAppleId) iosMissing.push("iosAppAppleId");
+ if (!project.iosAppStoreIssuerId) iosMissing.push("iosAppStoreIssuerId");
+ if (!project.iosAppStoreKeyId) iosMissing.push("iosAppStoreKeyId");
+
+ const androidMissing: string[] = [];
+ if (!project.androidPackageName) androidMissing.push("androidPackageName");
+
+ const horizonMissing: string[] = [];
+ if (!project.horizonEnabled) horizonMissing.push("horizonEnabled");
+ if (!project.horizonAppId) horizonMissing.push("horizonAppId");
+ if (!project.horizonAppSecret) horizonMissing.push("horizonAppSecret");
+
+ return {
+ found: true,
+ projectId: project._id,
+ ios: {
+ configured: iosMissing.length === 0,
+ missing: iosMissing,
+ },
+ android: {
+ configured: androidMissing.length === 0,
+ missing: androidMissing,
+ },
+ horizon: {
+ configured: horizonMissing.length === 0,
+ missing: horizonMissing,
+ },
+ // The webhook receivers ALSO need the .p8 / service-account JSON
+ // file uploaded to the project; check the `files` table directly
+ // so the setup card reflects what the operator has actually
+ // uploaded instead of always reporting "missing".
+ appleP8Uploaded: projectFiles.some(
+ (f) =>
+ f.purpose === "apple_p8_key" || f.purpose === "apple_p8_asc_api_key",
+ ),
+ googleServiceAccountUploaded: projectFiles.some(
+ (f) => f.purpose === "android_service_account",
+ ),
+ };
+ },
+});
diff --git a/packages/kit/convex/purchases/cleanup.test.ts b/packages/kit/convex/purchases/cleanup.test.ts
index 78fad446..68dcfea8 100644
--- a/packages/kit/convex/purchases/cleanup.test.ts
+++ b/packages/kit/convex/purchases/cleanup.test.ts
@@ -132,7 +132,7 @@ class MemDb {
slug: "test-project",
createdAt: Date.now(),
updatedAt: Date.now(),
- } as Row);
+ });
return id;
}
@@ -154,7 +154,7 @@ class MemDb {
orderId: attrs.orderId,
isValid: attrs.isValid ?? true,
state: "ENTITLED",
- } as Row);
+ });
}
allPurchases(): Row[] {
diff --git a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts
index 9e7f226f..81f63308 100644
--- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts
+++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts
@@ -157,7 +157,7 @@ class MemDb {
createdAt: Date.now(),
updatedAt: Date.now(),
...doc,
- } as Row);
+ });
return id;
}
@@ -170,7 +170,7 @@ class MemDb {
slug: "test-project",
createdAt: Date.now(),
updatedAt: Date.now(),
- } as Row);
+ });
return id;
}
@@ -253,10 +253,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
it("re-validation with the same remoteId does NOT re-increment stats.total", async () => {
await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) });
- const afterFirst = await readPurchaseStats(
- ctx as never,
- PROJECT_ID as never,
- );
+ const afterFirst = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(afterFirst.total).toBe(1);
expect(afterFirst.google).toBe(1);
@@ -264,10 +261,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) });
await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) });
- const afterRepeat = await readPurchaseStats(
- ctx as never,
- PROJECT_ID as never,
- );
+ const afterRepeat = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(afterRepeat.total).toBe(1);
expect(afterRepeat.google).toBe(1);
});
@@ -294,7 +288,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
expect(db.purchaseCount()).toBe(1);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
// Total stays at 1. Valid moves 1 → 0, invalid moves 0 → 1.
expect(stats.total).toBe(1);
expect(stats.valid).toBe(0);
@@ -312,7 +306,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
});
expect(db.purchaseCount()).toBe(2);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.total).toBe(2);
expect(stats.google).toBe(2);
});
@@ -361,7 +355,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
expect(rows[0]?.remoteId).toBe("token_after_reissue");
expect(rows[0]?.orderId).toBe("GPA.3328-5001-2345-67890");
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.total).toBe(1);
expect(stats.google).toBe(1);
// Distinct Play Console orders: exactly one, because the two
@@ -392,7 +386,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
});
expect(db.purchaseCount()).toBe(2);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.total).toBe(2);
expect(stats.google).toBe(2);
expect(stats.googleOrders).toBe(2);
@@ -416,7 +410,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
expect(db.purchaseCount()).toBe(2);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
// Two rows but no distinct orders yet — googleOrders stays at 0
// until a later re-verify surfaces an orderId.
expect(stats.google).toBe(2);
@@ -436,10 +430,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
}),
});
- const beforeAck = await readPurchaseStats(
- ctx as never,
- PROJECT_ID as never,
- );
+ const beforeAck = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(beforeAck.googleOrders).toBe(0);
await savePurchaseInternal({
@@ -457,7 +448,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
});
expect(db.purchaseCount()).toBe(1);
- const afterAck = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const afterAck = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(afterAck.google).toBe(1);
expect(afterAck.googleOrders).toBe(1);
expect(afterAck.total).toBe(1);
@@ -514,10 +505,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
...buildArgs({ remoteId: TOKEN, remoteResponse: ackResponse }),
});
- const beforeError = await readPurchaseStats(
- ctx as never,
- PROJECT_ID as never,
- );
+ const beforeError = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(beforeError.googleOrders).toBe(1);
// A later re-verify gets a transient Google failure — we persist
@@ -539,10 +527,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
const rows = await db.query("purchases").collect();
expect(rows[0]?.orderId).toBe("GPA.order-stable");
- const afterError = await readPurchaseStats(
- ctx as never,
- PROJECT_ID as never,
- );
+ const afterError = await readPurchaseStats(ctx, PROJECT_ID as never);
// googleOrders must stay at 1: the distinct orderId on this row
// is still present in the database, just not in the latest
// response payload.
@@ -593,7 +578,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
expect(rows[0]?.remoteId).toBe("token_initial");
expect(rows[0]?.orderId).toBe("GPA.only-one-logical-order");
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.google).toBe(1);
expect(stats.googleOrders).toBe(1);
expect(stats.total).toBe(1);
@@ -657,7 +642,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => {
expect(rows[0]?.state).toBe(HarmonizedPurchaseState.ENTITLED);
expect(rows[0]?.isValid).toBe(true);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.total).toBe(1);
expect(stats.valid).toBe(1);
expect(stats.invalid).toBe(0);
diff --git a/packages/kit/convex/purchases/stats-integration.test.ts b/packages/kit/convex/purchases/stats-integration.test.ts
index 5cc319d5..f21e2d76 100644
--- a/packages/kit/convex/purchases/stats-integration.test.ts
+++ b/packages/kit/convex/purchases/stats-integration.test.ts
@@ -138,7 +138,7 @@ describe("stats helpers — round-trip integration", () => {
});
it("readPurchaseStats returns zeros when no row exists yet", async () => {
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats).toEqual({
total: 0,
apple: 0,
@@ -151,12 +151,12 @@ describe("stats helpers — round-trip integration", () => {
it("applyPurchaseStatsDelta creates a row on first insert-delta", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats).toEqual({
total: 1,
apple: 1,
@@ -169,29 +169,29 @@ describe("stats helpers — round-trip integration", () => {
it("accumulates correctly across multiple insert-deltas for mixed stores/validity", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
// pending-ack google insert — row count but no order yet
deltaForInsert("google", true),
);
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
// invalid google with orderId — order but marked invalid
deltaForInsert("google", false, true),
);
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", false),
);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats).toEqual({
total: 4,
apple: 2,
@@ -206,7 +206,7 @@ describe("stats helpers — round-trip integration", () => {
it("markReceiptInvalid-style flip preserves total and moves valid -> invalid", async () => {
// Seed: one apple valid receipt.
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
@@ -215,12 +215,12 @@ describe("stats helpers — round-trip integration", () => {
// Apple receipts don't carry a Google orderId, so the last two
// args are false/false.
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForUpdate("apple", true, "apple", false, false, false),
);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats).toEqual({
total: 1,
apple: 1,
@@ -234,7 +234,7 @@ describe("stats helpers — round-trip integration", () => {
describe("wasFirstValidTransition", () => {
it("is true on the insert that bumps valid from 0 to 1", async () => {
const result = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
@@ -243,12 +243,12 @@ describe("stats helpers — round-trip integration", () => {
it("is false on a second valid insert (valid was already 1)", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
const second = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
@@ -257,7 +257,7 @@ describe("stats helpers — round-trip integration", () => {
it("is false on an invalid insert (valid stayed at 0)", async () => {
const result = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", false),
);
@@ -267,13 +267,13 @@ describe("stats helpers — round-trip integration", () => {
it("is true when a retry flips an existing row from invalid to valid (0 → 1)", async () => {
// Seed: invalid row → valid:0, invalid:1
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("google", false),
);
// Retry succeeds — deltaForUpdate moves valid 0 → 1
const flip = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForUpdate("google", false, "google", true),
);
@@ -282,12 +282,12 @@ describe("stats helpers — round-trip integration", () => {
it("is false when a valid row is flipped to invalid (1 → 0, not an activation)", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
const flip = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForUpdate("apple", true, "apple", false),
);
@@ -296,7 +296,7 @@ describe("stats helpers — round-trip integration", () => {
it("is false on a no-op delta (early-return branch)", async () => {
const result = await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
{},
);
@@ -306,20 +306,20 @@ describe("stats helpers — round-trip integration", () => {
it("remoteId upsert with unchanged (store, isValid) emits no counter movement", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("google", true),
);
- const before = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const before = await readPurchaseStats(ctx, PROJECT_ID as never);
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForUpdate("google", true, "google", true),
);
- const after = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const after = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(after).toEqual(before);
});
@@ -327,12 +327,12 @@ describe("stats helpers — round-trip integration", () => {
// No insert — now simulate a rogue 'valid -> invalid' flip on nothing.
// Counters should not dip below zero.
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForUpdate("apple", true, "apple", false),
);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats.valid).toBe(0);
expect(stats.invalid).toBe(1);
expect(stats.total).toBeGreaterThanOrEqual(0);
@@ -340,14 +340,14 @@ describe("stats helpers — round-trip integration", () => {
it("deletePurchaseStatsForProject removes the stats row", async () => {
await applyPurchaseStatsDelta(
- ctx as never,
+ ctx,
PROJECT_ID as never,
deltaForInsert("apple", true),
);
- await deletePurchaseStatsForProject(ctx as never, PROJECT_ID as never);
+ await deletePurchaseStatsForProject(ctx, PROJECT_ID as never);
- const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const stats = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(stats).toEqual({
total: 0,
apple: 0,
@@ -415,7 +415,7 @@ describe("stats helpers — round-trip integration", () => {
});
const totals = await recomputePurchaseStatsForProject(
- ctx as never,
+ ctx,
PROJECT_ID as never,
);
expect(totals).toEqual({
@@ -430,7 +430,7 @@ describe("stats helpers — round-trip integration", () => {
});
// Persisted to the stats table so subsequent reads are O(1).
- const read = await readPurchaseStats(ctx as never, PROJECT_ID as never);
+ const read = await readPurchaseStats(ctx, PROJECT_ID as never);
expect(read).toEqual(totals);
});
@@ -450,11 +450,11 @@ describe("stats helpers — round-trip integration", () => {
});
const first = await recomputePurchaseStatsForProject(
- ctx as never,
+ ctx,
PROJECT_ID as never,
);
const second = await recomputePurchaseStatsForProject(
- ctx as never,
+ ctx,
PROJECT_ID as never,
);
expect(second).toEqual(first);
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index 5bc85fbe..3a42ba44 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -185,8 +185,22 @@ const schema = defineSchema({
androidPackageName: v.optional(v.string()),
iosBundleId: v.optional(v.string()),
iosAppAppleId: v.optional(v.number()),
+ // App Store Server API credentials — issued under "Users and
+ // Access → Integrations → In-App Purchase". Used by the receipt
+ // verifier in `purchases/ios.ts`. Pairs with the `.p8` file
+ // stored as `purpose: "apple_p8_key"`.
iosAppStoreIssuerId: v.optional(v.string()),
iosAppStoreKeyId: v.optional(v.string()),
+ // App Store Connect API credentials — issued under "Users and
+ // Access → Integrations → App Store Connect API → Team Keys"
+ // (or Individual Keys). Used by `products/asc.ts` push-sync.
+ // Genuinely a different key from the App Store Server API one;
+ // Apple scopes them separately at the gateway. Pairs with the
+ // `.p8` file stored as `purpose: "apple_p8_asc_api_key"`. Both
+ // are optional so existing iOS-only-receipt-verification
+ // projects keep working without push-sync.
+ iosAscIssuerId: v.optional(v.string()),
+ iosAscKeyId: v.optional(v.string()),
// Meta Horizon Billing (Quest / Meta VR). Piggybacks on the Android
// configuration card in the UI because the client SDK is
@@ -210,7 +224,11 @@ const schema = defineSchema({
})
.index("by_organization", ["organizationId"])
.index("by_api_key", ["apiKey"])
- .index("by_org_and_slug", ["organizationId", "slug"]),
+ .index("by_org_and_slug", ["organizationId", "slug"])
+ // Horizon polling reconciler iterates only the projects that
+ // opted into Meta Horizon billing — without this index the cron
+ // would full-scan every project on each tick.
+ .index("by_horizon_enabled", ["horizonEnabled"]),
// API Keys table - Multiple API keys per project
apiKeys: defineTable({
@@ -255,10 +273,18 @@ const schema = defineSchema({
fileType: v.string(), // MIME type
fileSize: v.number(), // Size in bytes
- // Purpose/category
+ // Purpose/category. Apple distributes two distinct .p8 key kinds
+ // and they're NOT interchangeable:
+ // - `apple_p8_key` — App Store Server API (the
+ // "In-App Purchase Key"). Used for receipt verification.
+ // - `apple_p8_asc_api_key` — App Store Connect API (the "Team
+ // Key" / "Individual Key"). Used for ASC REST endpoints
+ // (catalog list / create / patch). Push-sync calls these.
+ // Uploading the wrong kind for either purpose returns 401.
purpose: v.union(
- v.literal("apple_p8_key"), // Apple .p8 private key
- v.literal("android_service_account"), // Android Service Account
+ v.literal("apple_p8_key"),
+ v.literal("apple_p8_asc_api_key"),
+ v.literal("android_service_account"),
),
description: v.optional(v.string()),
@@ -401,6 +427,417 @@ const schema = defineSchema({
cursor: v.number(),
updatedAt: v.number(),
}).index("by_jobName", ["jobName"]),
+
+ // Normalized lifecycle webhook events ingested from Apple ASN v2 and
+ // Google RTDN. Mirrors the GraphQL `WebhookEvent` shape defined in
+ // `packages/gql/src/webhook.graphql` — kit's Subscription endpoint
+ // streams rows from this table to authenticated clients, and the
+ // `webhookEventsSince` query backfills events that occurred while a
+ // client's WebSocket was closed.
+ //
+ // Retention: rows are pruned by the `pruneWebhookEvents` cron after
+ // 30 days. The replay window matches `webhookEventsSince` so clients
+ // returning from a long offline period can still reconcile.
+ webhookEvents: defineTable({
+ projectId: v.id("projects"),
+ type: v.union(
+ v.literal("SubscriptionStarted"),
+ v.literal("SubscriptionRenewed"),
+ v.literal("SubscriptionExpired"),
+ v.literal("SubscriptionInGracePeriod"),
+ v.literal("SubscriptionInBillingRetry"),
+ v.literal("SubscriptionRecovered"),
+ v.literal("SubscriptionCanceled"),
+ v.literal("SubscriptionUncanceled"),
+ v.literal("SubscriptionRevoked"),
+ v.literal("SubscriptionPriceChange"),
+ v.literal("SubscriptionProductChanged"),
+ v.literal("SubscriptionPaused"),
+ v.literal("SubscriptionResumed"),
+ v.literal("PurchaseRefunded"),
+ v.literal("PurchaseConsumptionRequest"),
+ v.literal("TestNotification"),
+ ),
+ source: v.union(
+ v.literal("AppleAppStoreServerNotificationsV2"),
+ v.literal("GooglePlayRealTimeDeveloperNotifications"),
+ // Synthetic source for Meta Horizon Store entitlement
+ // transitions discovered by the polling reconciler. Mirrors
+ // the GraphQL `WebhookEventSource.MetaHorizonReconciler` enum.
+ v.literal("MetaHorizonReconciler"),
+ ),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ environment: v.union(
+ v.literal("Production"),
+ v.literal("Sandbox"),
+ v.literal("Xcode"),
+ ),
+ // Optional because TestNotification payloads (App Store Connect
+ // "Send Test Notification" / RTDN setup test) carry no transaction
+ // and therefore no purchaseToken. All real lifecycle event types
+ // populate this; the receiver guards apply the same nullability
+ // (see webhooks/internal.ts).
+ purchaseToken: v.optional(v.string()),
+ // Original notification id from the store (ASN v2 `notificationUUID`
+ // or RTDN Pub/Sub `messageId`). Surfaced as the GraphQL `id` field
+ // for clients and used to correlate events during pruning.
+ sourceNotificationId: v.string(),
+ productId: v.optional(v.string()),
+ subscriptionState: v.optional(
+ v.union(
+ v.literal("Active"),
+ v.literal("InGracePeriod"),
+ v.literal("InBillingRetry"),
+ v.literal("Expired"),
+ v.literal("Revoked"),
+ v.literal("Refunded"),
+ v.literal("Paused"),
+ v.literal("Unknown"),
+ ),
+ ),
+ expiresAt: v.optional(v.number()),
+ renewsAt: v.optional(v.number()),
+ cancellationReason: v.optional(
+ v.union(
+ v.literal("UserCanceled"),
+ v.literal("BillingError"),
+ v.literal("PriceIncreaseDeclined"),
+ v.literal("ProductUnavailable"),
+ v.literal("Refunded"),
+ v.literal("Other"),
+ ),
+ ),
+ currency: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ rawSignedPayload: v.optional(v.string()),
+ occurredAt: v.number(),
+ receivedAt: v.number(),
+ })
+ .index("by_project", ["projectId"])
+ .index("by_purchase_token", ["purchaseToken"])
+ .index("by_project_and_received", ["projectId", "receivedAt"])
+ .index("by_received_at", ["receivedAt"])
+ // Lookup helper used by the SSE stream's `Last-Event-ID` cursor
+ // resolution. The reconnect cursor needs to translate a stable
+ // notification id back to its `receivedAt` regardless of whether
+ // the event is in the first 500 or the 50,000th. A direct index
+ // hit is O(log n) vs O(n/page) for the prior linear scan.
+ .index("by_project_and_notification_id", [
+ "projectId",
+ "sourceNotificationId",
+ ])
+ // Composite (projectId, receivedAt, _creationTime) for the SSE
+ // backfill `webhookEventsSince` query — lets the boundary-cohort
+ // tail past the millisecond cursor be walked directly via the
+ // index (`gt("_creationTime", afterCreationTime)`) instead of an
+ // in-memory filter that would silently drop pages when a single
+ // millisecond's burst exceeds the take() cap (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ .index("by_project_and_received_and_creation", [
+ "projectId",
+ "receivedAt",
+ "_creationTime",
+ ]),
+
+ // Dedup table for webhook payloads. Insertion uses
+ // `(projectId, source, sourceNotificationId)` as the natural key.
+ // projectId is part of the key because Google Cloud Pub/Sub's
+ // messageId is only guaranteed unique *within a topic* — different
+ // kit projects can legitimately publish notifications with the
+ // same messageId, and a project-less key would cross-pollute their
+ // dedup state. (Apple's notificationUUID is globally unique so the
+ // projectId scope is redundant for ASN, but matching one shape
+ // keeps the lookup path simple.) Duplicates detected here cause
+ // kit to silently ACK the upstream request with 200 without
+ // re-emitting the event, matching Apple's documented retry
+ // expectation and Google's at-least-once Pub/Sub contract.
+ // `projectId` is optional during the rollout so already-written
+ // rows still validate; new inserts always populate it.
+ webhookIdempotencyKeys: defineTable({
+ projectId: v.optional(v.id("projects")),
+ source: v.union(v.literal("apple"), v.literal("google")),
+ sourceNotificationId: v.string(),
+ eventId: v.optional(v.id("webhookEvents")),
+ firstSeenAt: v.number(),
+ })
+ .index("by_source_and_id", ["source", "sourceNotificationId"])
+ .index("by_project_and_source_and_id", [
+ "projectId",
+ "source",
+ "sourceNotificationId",
+ ])
+ // Cheap range scan for the `pruneWebhookEvents` cron — without it,
+ // ageing out dedup rows means a full-table scan per tick, which
+ // gets expensive on a hosted multi-tenant deployment where the
+ // table grows ~1 row per webhook per project per day.
+ .index("by_first_seen_at", ["firstSeenAt"]),
+
+ // Authoritative per-(project, originalTransactionId) subscription record.
+ // Mirrors the spec from `packages/gql/src/webhook.graphql` and the role
+ // played by onesub's `onesub_subscriptions` table. State transitions are
+ // driven by webhook events through `applySubscriptionEvent`.
+ //
+ // Why per-`originalTransactionId` (Apple) / `purchaseToken` (Google) and
+ // not per-`(userId, productId)`: a single user can hold multiple historical
+ // entitlements (resub after expiry, cross-grade, family-shared); the
+ // store-issued purchase id is the only stable handle that survives all
+ // transitions. Entitlement evaluation aggregates by user as needed.
+ //
+ // Known limitation (Google `linkedPurchaseToken` chain): Google reissues
+ // `purchaseToken` across upgrade/downgrade/replace flows. The new token
+ // arrives via RTDN with no `linkedPurchaseToken` field in the webhook
+ // payload itself — that field is only available via a follow-up
+ // `purchases.subscriptionsv2.get` Play Developer API call. The webhook
+ // receiver intentionally does NOT make that synchronous call (it would
+ // violate Pub/Sub's fast-ACK contract and burn Play API quota per
+ // webhook). The result is one logical Google subscription can split
+ // into multiple rows after a token reissue, fragmenting the per-token
+ // state until a background reconciliation pass resolves the chain
+ // via the Play API and merges the rows.
+ //
+ // Apple does not have this problem — `originalTransactionId` is stable
+ // across the entire entitlement lifetime.
+ subscriptions: defineTable({
+ projectId: v.id("projects"),
+ purchaseToken: v.string(),
+ userId: v.optional(v.string()),
+ productId: v.string(),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ state: v.union(
+ v.literal("Active"),
+ v.literal("InGracePeriod"),
+ v.literal("InBillingRetry"),
+ v.literal("Expired"),
+ v.literal("Revoked"),
+ v.literal("Refunded"),
+ v.literal("Paused"),
+ v.literal("Unknown"),
+ ),
+ expiresAt: v.optional(v.number()),
+ renewsAt: v.optional(v.number()),
+ willRenew: v.optional(v.boolean()),
+ cancellationReason: v.optional(
+ v.union(
+ v.literal("UserCanceled"),
+ v.literal("BillingError"),
+ v.literal("PriceIncreaseDeclined"),
+ v.literal("ProductUnavailable"),
+ v.literal("Refunded"),
+ v.literal("Other"),
+ ),
+ ),
+ currency: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ startedAt: v.number(),
+ updatedAt: v.number(),
+ lastEventId: v.optional(v.id("webhookEvents")),
+ })
+ .index("by_project_and_token", ["projectId", "purchaseToken"])
+ .index("by_project_and_user", ["projectId", "userId"])
+ .index("by_project_and_state", ["projectId", "state"])
+ .index("by_project_and_updated", ["projectId", "updatedAt"])
+ .index("by_project_and_product", ["projectId", "productId"])
+ // Composite index for the (state + productId) filter combination
+ // in listSubscriptions. Without it, the prior over-fetch heuristic
+ // could miss matching rows past the take() boundary on projects
+ // with thousands of subs in the same state.
+ .index("by_project_and_state_and_product", [
+ "projectId",
+ "state",
+ "productId",
+ ])
+ // Composite (projectId, state, updatedAt) for the Horizon
+ // reconciler's per-state, oldest-first pagination. With this index
+ // we walk the staleest subs in each mutable state per cron tick;
+ // after Meta verify_entitlement writes the fresh `updatedAt`, the
+ // row moves to the back of the queue automatically. That makes
+ // the reconciler self-paginating across ticks — no separate
+ // continuation cursor needed (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ .index("by_project_and_state_and_updated", [
+ "projectId",
+ "state",
+ "updatedAt",
+ ]),
+
+ // Incrementally-maintained per-(project, currency) subscription
+ // counters + MRR. Updated by `applySubscriptionEvent` so the
+ // dashboard's `metricsSummary` query reads O(currencies) rows
+ // instead of scanning the whole `subscriptions` table — the prior
+ // implementation capped at 10,000 subs to bound Convex's read
+ // budget, which silently undercounted projects above that
+ // threshold.
+ //
+ // Keyed by currency because MRR can't be summed across
+ // currencies without a presentation-layer FX conversion (matches
+ // the same reasoning on `revenueMetricsDaily`).
+ //
+ // 30-day rolling counters (refunded, canceled) are NOT stored
+ // here — those are bounded-size by definition (limited by 30 days
+ // of churn, not by total subs) so the read path scans them via
+ // `by_project_and_state` filtered on `updatedAt >= cutoff`.
+ subscriptionStats: defineTable({
+ projectId: v.id("projects"),
+ currency: v.string(),
+ activeSubs: v.number(),
+ inGracePeriod: v.number(),
+ inBillingRetry: v.number(),
+ mrrMicros: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_project", ["projectId"])
+ .index("by_project_and_currency", ["projectId", "currency"])
+ // Ordered scan for `recomputeAllSubscriptionStats` cron picker —
+ // walks the most-stale rows first via .order("asc").take(limit)
+ // so we never collect the whole table to sort it client-side.
+ .index("by_updated_at", ["updatedAt"]),
+
+ // Daily revenue metrics rollup keyed by (projectId, day, productId,
+ // currency). Populated by `recomputeRevenueMetrics` cron (recomputes
+ // the trailing window from `subscriptions` so late-arriving webhook
+ // corrections are reflected). The dashboard reads from here to avoid
+ // scanning the full events log on every page render.
+ //
+ // Currency is part of the row key because the same SKU can sell in
+ // multiple storefront currencies on the same UTC day — keying only
+ // by (projectId, day, productId) would either mix incompatible
+ // `revenueMicros` totals or have one currency overwrite another,
+ // both of which produce wrong dashboard numbers for multi-region
+ // apps. Aggregating across currencies is a presentation-layer
+ // concern (FX conversion happens in the UI, with whatever rates the
+ // operator picks).
+ revenueMetricsDaily: defineTable({
+ projectId: v.id("projects"),
+ day: v.string(), // ISO date (YYYY-MM-DD), UTC
+ productId: v.string(),
+ currency: v.string(),
+ activeSubs: v.number(),
+ newSubs: v.number(),
+ renewals: v.number(),
+ cancellations: v.number(),
+ refunds: v.number(),
+ revenueMicros: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_project_and_day_and_currency", ["projectId", "day", "currency"])
+ .index("by_project_and_product_and_day_and_currency", [
+ "projectId",
+ "productId",
+ "day",
+ "currency",
+ ]),
+
+ // Unified product catalog. Mirrors what onesub holds in @onesub/providers
+ // — the subset of App Store Connect / Play Console that kit can read /
+ // create / update on the project owner's behalf. The auth-credential
+ // payloads themselves stay in `files` (existing kit pattern); this row is
+ // just the cached product metadata.
+ products: defineTable({
+ projectId: v.id("projects"),
+ productId: v.string(),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ type: v.union(
+ v.literal("Subscription"),
+ v.literal("NonConsumable"),
+ v.literal("Consumable"),
+ ),
+ title: v.string(),
+ description: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ state: v.union(
+ v.literal("Draft"),
+ v.literal("Ready"),
+ v.literal("Active"),
+ v.literal("Removed"),
+ ),
+ // Subscription billing period. ISO-8601-ish duration the ASC + Play
+ // push paths both accept (`P1W` / `P1M` / `P2M` / `P3M` / `P6M` /
+ // `P1Y`). Optional because non-subscription types don't use it.
+ // The push actions translate this to ASC `subscriptionPeriod` enum
+ // (`ONE_WEEK` / `ONE_MONTH` / `TWO_MONTHS` / …) and Play
+ // `autoRenewingBasePlanType.billingPeriodDuration`. Without this
+ // field, the prior implementation silently created every
+ // subscription as ONE_MONTH / P1M regardless of intent.
+ billingPeriod: v.optional(
+ v.union(
+ v.literal("P1W"),
+ v.literal("P1M"),
+ v.literal("P2M"),
+ v.literal("P3M"),
+ v.literal("P6M"),
+ v.literal("P1Y"),
+ ),
+ ),
+ // Subscription Group (ASC concept; Play has no first-class
+ // equivalent so these stay null for Android rows). All
+ // subscriptions in the same group are mutually exclusive on
+ // Apple's side — the user can switch between Premium / Premium
+ // Year via in-app upgrade/downgrade, but cannot hold both. Kit
+ // surfaces this in the dashboard so the operator can see at a
+ // glance which subs share a group, and downstream surfaces can
+ // pick a default selection within a group. `subscriptionGroupId`
+ // is Apple's internal resource id; `subscriptionGroupName` is the
+ // human-readable referenceName the operator sees in ASC.
+ subscriptionGroupId: v.optional(v.string()),
+ subscriptionGroupName: v.optional(v.string()),
+ // Captures the *non-base* monetization variants attached to a
+ // subscription: Apple introductory offers (free trial / pay as
+ // you go / pay up front) and Play base plan offers (same kinds,
+ // different shape). Stored as a generic shape so both stores can
+ // upsert without branching, and the dashboard can render badges
+ // ("7-day free trial", "$4.99 intro for 3 months") without
+ // re-deriving from raw store responses.
+ offers: v.optional(
+ v.array(
+ v.object({
+ // Identifier from the store: ASC offer id (eyJ...) for
+ // introductoryOffer / promotionalOffer, or Play's
+ // basePlanId+offerId composite for offers.
+ id: v.string(),
+ kind: v.union(
+ v.literal("FreeTrial"),
+ v.literal("IntroPayUpFront"),
+ v.literal("IntroPayAsYouGo"),
+ v.literal("PromotionalOffer"),
+ v.literal("BasePlan"),
+ ),
+ // ISO-8601 duration the offer covers (e.g. "P7D", "P3M").
+ // For BasePlan rows this is the recurring billing period.
+ duration: v.optional(v.string()),
+ // Number of billing periods the discounted/free price
+ // applies for (Apple's `numberOfPeriods`). Free trials and
+ // pay-up-front intros use 1; pay-as-you-go uses N.
+ numberOfPeriods: v.optional(v.number()),
+ priceAmountMicros: v.optional(v.number()),
+ currency: v.optional(v.string()),
+ }),
+ ),
+ ),
+ // Free-form note for App Store review. Maps to ASC's `reviewNote`
+ // attribute on inAppPurchases / subscriptions and is the field
+ // Apple's reviewer reads alongside the screenshot to understand
+ // how to trigger / verify the IAP. Length cap is 4000 chars on
+ // ASC's side; we don't enforce here so the operator gets Apple's
+ // own validation message if they exceed it.
+ reviewNote: v.optional(v.string()),
+ storeRef: v.optional(v.string()),
+ syncedAt: v.optional(v.number()),
+ updatedAt: v.number(),
+ })
+ // Lookup row by (projectId, platform, productId). Apps commonly
+ // ship the SAME productId on both iOS and Android (e.g.
+ // `dev.hyo.martie.premium` exists in both stores), so the
+ // (projectId, productId)-only index would have collisions and
+ // silently flip an existing row's platform on sync. Including
+ // platform in the natural key keeps each store's catalog row
+ // separate.
+ .index("by_project_and_platform_and_product", [
+ "projectId",
+ "platform",
+ "productId",
+ ])
+ .index("by_project_and_platform", ["projectId", "platform"]),
});
export default schema;
diff --git a/packages/kit/convex/subscriptions/horizon.ts b/packages/kit/convex/subscriptions/horizon.ts
new file mode 100644
index 00000000..ccffb315
--- /dev/null
+++ b/packages/kit/convex/subscriptions/horizon.ts
@@ -0,0 +1,304 @@
+"use node";
+import { createHash } from "node:crypto";
+import { v } from "convex/values";
+
+import { action, internalAction } from "../_generated/server";
+import { internal } from "../_generated/api";
+import type { Id } from "../_generated/dataModel";
+import { mapWithConcurrency } from "../utils/concurrency";
+
+// Horizon polling reconciler.
+//
+// Meta Horizon Store has no webhook / push notification system —
+// `developers.meta.com/horizon/documentation/native/ps-iap` only
+// exposes the synchronous `verify_entitlement` Graph API. So unlike
+// Apple ASN v2 / Google RTDN, kit cannot ingest "subscription
+// renewed" or "refunded" events the moment they happen on Meta's
+// side; we have to re-check entitlement on a schedule.
+//
+// This cron action walks every Horizon `subscriptions` row that
+// might have changed (state in {Active, InGracePeriod, Paused}),
+// hits Meta Graph for each (userId, sku), and feeds the result
+// through the same `applySubscriptionEvent` pipeline Apple/Google
+// use. Net effect: subscriptions table converges to Meta's
+// authoritative answer within one cron tick.
+//
+// Cadence: 6h (registered in `crons.ts`). Every project's Horizon
+// subs run in one tick because the population is small per project.
+// If a single project grows past ~1000 active Horizon subs we'll
+// want to paginate.
+
+const META_GRAPH_BASE = "https://graph.oculus.com";
+
+type HorizonProbe = {
+ userId: string;
+ sku: string;
+ purchaseToken: string;
+ state: string;
+};
+
+export const reconcileHorizonEntitlements = internalAction({
+ args: {},
+ returns: v.object({
+ checked: v.number(),
+ transitioned: v.number(),
+ failures: v.number(),
+ }),
+ handler: async (
+ ctx,
+ ): Promise<{
+ checked: number;
+ transitioned: number;
+ failures: number;
+ }> => {
+ const projects = await ctx.runQuery(
+ internal.subscriptions.horizonInternal.listHorizonProjects,
+ {},
+ );
+ let checked = 0;
+ let transitioned = 0;
+ let failures = 0;
+
+ for (const project of projects) {
+ if (
+ !project.horizonEnabled ||
+ !project.horizonAppId ||
+ !project.horizonAppSecret
+ ) {
+ continue;
+ }
+ const probes = await ctx.runQuery(
+ internal.subscriptions.horizonInternal.listHorizonSubscriptions,
+ { projectId: project._id },
+ );
+ const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`;
+
+ // Parallelize Meta Graph API checks per project. Meta's
+ // verify_entitlement endpoint isn't tightly throttled — App
+ // Access Tokens get the standard Graph rate limit (~200 calls
+ // per app per hour per user, but our user is the App ID
+ // itself), so concurrency=8 keeps the cron tick fast for
+ // projects with many subs without tripping 429s. The runMutation
+ // calls inside still serialize per probe to keep the
+ // recordHorizonStatus state-transitions atomic.
+ const HORIZON_PROBE_CONCURRENCY = 8;
+ checked += probes.length;
+ const probeResults = await mapWithConcurrency(
+ probes,
+ HORIZON_PROBE_CONCURRENCY,
+ async (probe) => {
+ try {
+ const granted = await checkHorizonEntitlement({
+ appId: project.horizonAppId!,
+ appAccessToken,
+ userId: probe.userId,
+ sku: probe.sku,
+ });
+ return { probe, granted, error: null as unknown };
+ } catch (error) {
+ return { probe, granted: null as boolean | null, error };
+ }
+ },
+ );
+ for (const result of probeResults) {
+ const { probe, granted, error } = result;
+ if (error) {
+ failures += 1;
+ // Don't log the raw probe.userId / probe.sku — those are
+ // user-linked identifiers and end up in stdout / log
+ // aggregators long-term. The purchaseToken hash is enough
+ // to correlate this entry to the row in `subscriptions`
+ // when an operator needs to investigate.
+ console.warn(
+ "[horizon-reconciler] check failed",
+ project._id,
+ { tokenHash: hashForLog(probe.purchaseToken) },
+ error instanceof Error ? error.message : error,
+ );
+ continue;
+ }
+ // Meta's response is binary: `granted: true` means the user
+ // currently holds the entitlement. Map to the same event
+ // types Apple/Google emit so the state machine / entitlements
+ // query don't need a Horizon-specific branch.
+ //
+ // Increment `transitioned` only when recordHorizonStatus
+ // returns a non-null subscription id — it returns null when
+ // there's no matching subscription row to transition (e.g.
+ // the kit-side row was never created), in which case we
+ // didn't actually mutate anything.
+ if (granted && probe.state !== "Active") {
+ const updated = await ctx.runMutation(
+ internal.subscriptions.horizonInternal.recordHorizonStatus,
+ {
+ projectId: project._id,
+ purchaseToken: probe.purchaseToken,
+ userId: probe.userId,
+ productId: probe.sku,
+ eventType: "SubscriptionRenewed",
+ },
+ );
+ if (updated) transitioned += 1;
+ } else if (!granted && probe.state === "Active") {
+ const updated = await ctx.runMutation(
+ internal.subscriptions.horizonInternal.recordHorizonStatus,
+ {
+ projectId: project._id,
+ purchaseToken: probe.purchaseToken,
+ userId: probe.userId,
+ productId: probe.sku,
+ eventType: "SubscriptionExpired",
+ },
+ );
+ if (updated) transitioned += 1;
+ }
+ }
+ }
+
+ return { checked, transitioned, failures };
+ },
+});
+
+// Manual one-off run trigger from the dashboard "Reconcile now" button
+// or the MCP `openiap_troubleshoot` tool. Same handler as the cron
+// path; just exposed under a public action for convenience.
+export const reconcileHorizonNow = action({
+ args: { apiKey: v.string() },
+ returns: v.object({
+ checked: v.number(),
+ transitioned: v.number(),
+ failures: v.number(),
+ }),
+ handler: async (
+ ctx,
+ args,
+ ): Promise<{
+ checked: number;
+ transitioned: number;
+ failures: number;
+ }> => {
+ const project = await ctx.runQuery(
+ internal.subscriptions.horizonInternal.getProjectByApiKey,
+ { apiKey: args.apiKey },
+ );
+ if (!project) throw new Error("Invalid API key");
+ if (
+ !project.horizonEnabled ||
+ !project.horizonAppId ||
+ !project.horizonAppSecret
+ ) {
+ throw new Error(
+ "Horizon is not configured for this project (set horizonEnabled + horizonAppId + horizonAppSecret in Settings).",
+ );
+ }
+ const probes = await ctx.runQuery(
+ internal.subscriptions.horizonInternal.listHorizonSubscriptions,
+ { projectId: project._id },
+ );
+ const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`;
+
+ let checked = 0;
+ let transitioned = 0;
+ let failures = 0;
+ for (const probe of probes) {
+ checked += 1;
+ try {
+ const granted = await checkHorizonEntitlement({
+ appId: project.horizonAppId,
+ appAccessToken,
+ userId: probe.userId,
+ sku: probe.sku,
+ });
+ // See the matching note in reconcileHorizonEntitlements: only
+ // increment when recordHorizonStatus actually returned a
+ // subscription id (null = no matching row, no transition).
+ if (granted && probe.state !== "Active") {
+ const updated = await ctx.runMutation(
+ internal.subscriptions.horizonInternal.recordHorizonStatus,
+ {
+ projectId: project._id,
+ purchaseToken: probe.purchaseToken,
+ userId: probe.userId,
+ productId: probe.sku,
+ eventType: "SubscriptionRenewed",
+ },
+ );
+ if (updated) transitioned += 1;
+ } else if (!granted && probe.state === "Active") {
+ const updated = await ctx.runMutation(
+ internal.subscriptions.horizonInternal.recordHorizonStatus,
+ {
+ projectId: project._id,
+ purchaseToken: probe.purchaseToken,
+ userId: probe.userId,
+ productId: probe.sku,
+ eventType: "SubscriptionExpired",
+ },
+ );
+ if (updated) transitioned += 1;
+ }
+ } catch (error) {
+ failures += 1;
+ console.warn("[horizon-reconciler] check failed", error);
+ }
+ }
+ return { checked, transitioned, failures };
+ },
+});
+
+// Per-request timeout for the Meta Graph call. Without this, a hung
+// upstream stalls the cron action indefinitely; the action's outer
+// 10-min ceiling would still fire, but the tick would burn most of
+// that budget on a single dead probe instead of moving on. 10s is
+// generous for a single Graph endpoint while still letting a stalled
+// project's cron tick complete in a reasonable wall time.
+const HORIZON_FETCH_TIMEOUT_MS = 10_000;
+
+async function checkHorizonEntitlement(args: {
+ appId: string;
+ appAccessToken: string;
+ userId: string;
+ sku: string;
+}): Promise {
+ const url = `${META_GRAPH_BASE}/${encodeURIComponent(args.appId)}/verify_entitlement`;
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ HORIZON_FETCH_TIMEOUT_MS,
+ );
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ signal: controller.signal,
+ body: new URLSearchParams({
+ access_token: args.appAccessToken,
+ user_id: args.userId,
+ sku: args.sku,
+ }).toString(),
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`Meta Graph API ${res.status}: ${text.slice(0, 256)}`);
+ }
+ const body = (await res.json()) as { success?: boolean };
+ return body.success === true;
+}
+
+// Privacy-safe one-way fingerprint of a purchase token for log lines.
+// We only need enough entropy to disambiguate "the same row keeps
+// failing" vs "every probe is failing"; truncating SHA-1 to 12 hex
+// chars (~48 bits) is collision-resistant enough to identify a row
+// without surfacing the original identifier in stdout.
+function hashForLog(input: string): string {
+ return createHash("sha1").update(input).digest("hex").slice(0, 12);
+}
+
+// Re-export with proper Id type usage so consumers in the same module
+// graph compile cleanly even though we pass `Id<"projects">` around.
+export type HorizonProjectId = Id<"projects">;
+export type HorizonProbeRow = HorizonProbe;
diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts
new file mode 100644
index 00000000..464b7a85
--- /dev/null
+++ b/packages/kit/convex/subscriptions/horizonInternal.ts
@@ -0,0 +1,306 @@
+import { internalMutation, internalQuery } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc } from "../_generated/dataModel";
+
+import {
+ applySubscriptionTransition,
+ type CurrentSubscription,
+} from "./stateMachine";
+import { applyStatsTransition, statsContributionFor } from "./stats";
+
+// Convex-runtime helpers used by the Horizon polling reconciler in
+// `horizon.ts`. Kept separate so the action's "use node" boundary
+// doesn't drag node-only imports into the regular Convex bundle.
+
+export const listHorizonProjects = internalQuery({
+ args: {},
+ returns: v.array(
+ v.object({
+ _id: v.id("projects"),
+ horizonEnabled: v.optional(v.boolean()),
+ horizonAppId: v.optional(v.union(v.string(), v.null())),
+ horizonAppSecret: v.optional(v.union(v.string(), v.null())),
+ }),
+ ),
+ handler: async (ctx) => {
+ // Use the by_horizon_enabled index instead of a full-table scan.
+ // Most projects don't opt into Meta Horizon, so this skips the
+ // bulk of the table on every cron tick.
+ const enabled = await ctx.db
+ .query("projects")
+ .withIndex("by_horizon_enabled", (q) => q.eq("horizonEnabled", true))
+ .collect();
+ return enabled.map((project) => ({
+ _id: project._id,
+ horizonEnabled: project.horizonEnabled,
+ horizonAppId: project.horizonAppId,
+ horizonAppSecret: project.horizonAppSecret,
+ }));
+ },
+});
+
+export const getProjectByApiKey = internalQuery({
+ args: { apiKey: v.string() },
+ returns: v.union(
+ v.null(),
+ v.object({
+ _id: v.id("projects"),
+ horizonEnabled: v.optional(v.boolean()),
+ horizonAppId: v.optional(v.union(v.string(), v.null())),
+ horizonAppSecret: v.optional(v.union(v.string(), v.null())),
+ }),
+ ),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) return null;
+ return {
+ _id: project._id,
+ horizonEnabled: project.horizonEnabled,
+ horizonAppId: project.horizonAppId,
+ horizonAppSecret: project.horizonAppSecret,
+ };
+ },
+});
+
+// All subscriptions for a Horizon project that might still mutate.
+// Refunded/Revoked/Expired-with-no-renewal rows are excluded so the
+// cron stays cheap as the historical archive grows.
+export const listHorizonSubscriptions = internalQuery({
+ args: { projectId: v.id("projects") },
+ returns: v.array(
+ v.object({
+ userId: v.string(),
+ sku: v.string(),
+ purchaseToken: v.string(),
+ state: v.string(),
+ }),
+ ),
+ handler: async (ctx, args) => {
+ // Hit by_project_and_state for each mutable state in parallel
+ // instead of full-scanning the project via by_project_and_updated
+ // and filtering in memory. The Refunded / Revoked / Expired
+ // historical archive is the bulk of any long-lived project — the
+ // index path skips it entirely.
+ // All states that can still mutate via Meta's verify_entitlement
+ // result. The historical archive (Refunded / Revoked / Expired
+ // with no auto-renew) is excluded so the cron stays cheap as the
+ // archive grows, but every live + transient state is included
+ // so a recovery (InBillingRetry → Active) or a Paused → expiry
+ // doesn't get stuck.
+ const STATES = [
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+ "Paused",
+ "Unknown",
+ ] as const;
+ // Per-state cap with self-paginating, oldest-first ordering.
+ //
+ // Bounded for two reasons: (1) Convex's 40k document-read limit
+ // per query — 5 states × 6_000 = 30k reads, leaving ~10k for
+ // downstream filtering; (2) the action that consumes this list
+ // calls Meta `verify_entitlement` once per row, which has its
+ // own per-cron-tick budget.
+ //
+ // Pagination strategy: order by `updatedAt` ASC via the
+ // `by_project_and_state_and_updated` composite index. The
+ // staleest subs per state surface first; once
+ // `recordHorizonStatus` runs and writes a fresh `updatedAt`,
+ // those rows move to the back of the queue so the next tick
+ // picks up the never-reconciled tail. Time-to-fully-reconcile
+ // for population N is ~ceil(N / PER_STATE_CAP) ticks. A
+ // pathological 100k-sub project converges in ~17 ticks instead
+ // of "tail forever stale" (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ //
+ // No external continuation cursor is needed because the cursor is
+ // implicit in `updatedAt` itself.
+ const PER_STATE_CAP = 6_000;
+ const perState = await Promise.all(
+ STATES.map((state) =>
+ ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_state_and_updated", (q) =>
+ q.eq("projectId", args.projectId).eq("state", state),
+ )
+ .order("asc")
+ .take(PER_STATE_CAP),
+ ),
+ );
+ // Operator visibility: log when a state bucket fully fills the
+ // per-tick cap. The reconciler still completes correctly because
+ // the tail surfaces next tick, but a sustained cap-hit signals
+ // that the cron interval may be too sparse for the population.
+ STATES.forEach((state, i) => {
+ if (perState[i].length === PER_STATE_CAP) {
+ console.info(
+ `[horizon-reconciler] project=${args.projectId} state=${state} filled PER_STATE_CAP=${PER_STATE_CAP}; remaining tail will reconcile on subsequent ticks via updatedAt cursor.`,
+ );
+ }
+ });
+ return perState
+ .flat()
+ .filter((sub) => sub.platform === "Android")
+ .filter((sub) => !!sub.userId)
+ .map((sub) => ({
+ userId: sub.userId!,
+ sku: sub.productId,
+ purchaseToken: sub.purchaseToken,
+ state: sub.state,
+ }));
+ },
+});
+
+// The reconciler hands us a synthetic "event" describing what Meta
+// just told us. We funnel it through the same state-machine the
+// webhook receivers use so transition semantics stay consistent.
+export const recordHorizonStatus = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ purchaseToken: v.string(),
+ userId: v.string(),
+ productId: v.string(),
+ eventType: v.union(
+ v.literal("SubscriptionRenewed"),
+ v.literal("SubscriptionExpired"),
+ ),
+ },
+ returns: v.union(v.null(), v.id("subscriptions")),
+ handler: async (ctx, args) => {
+ const existing: Doc<"subscriptions"> | null = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_token", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("purchaseToken", args.purchaseToken),
+ )
+ .unique();
+ if (!existing) return null;
+
+ const current: CurrentSubscription = {
+ state: existing.state,
+ productId: existing.productId,
+ expiresAt: existing.expiresAt,
+ renewsAt: existing.renewsAt,
+ willRenew: existing.willRenew,
+ cancellationReason: existing.cancellationReason,
+ currency: existing.currency,
+ priceAmountMicros: existing.priceAmountMicros,
+ };
+ const transition = applySubscriptionTransition(current, {
+ type: args.eventType,
+ productId: args.productId,
+ });
+ if (!transition.next) return existing._id;
+ const now = Date.now();
+
+ // Synthesize a webhookEvents row so the SSE stream re-broadcasts
+ // this Horizon transition to connected SDK clients. Without this
+ // the polling reconciler updated the subscription row but never
+ // surfaced the change on `/v1/webhooks/stream/{apiKey}` — Horizon
+ // listeners would silently miss every renewal / expiry until the
+ // next state-driven HTTP query.
+ //
+ // Source is `MetaHorizonReconciler` (synthetic; Horizon has no
+ // upstream webhook) and `sourceNotificationId` is a deterministic
+ // hash of (purchaseToken, eventType, productId) so re-running the
+ // cron with the same Meta Graph response doesn't double-emit.
+ const sourceNotificationId = `meta-horizon-${args.eventType}-${args.purchaseToken}-${args.productId}`;
+
+ // Dedup by (projectId, source, sourceNotificationId) — re-running
+ // the same Horizon poll result (cron retries, manual reconcile)
+ // would otherwise insert another webhookEvents row and re-broadcast
+ // the same SSE event, bypassing the first-seen-wins contract the
+ // Apple/Google webhook receivers honor. Reuse the existing event
+ // when one is already on file.
+ const existingEvent = await ctx.db
+ .query("webhookEvents")
+ .withIndex("by_project_and_notification_id", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("sourceNotificationId", sourceNotificationId),
+ )
+ .unique();
+ const eventId = existingEvent
+ ? existingEvent._id
+ : await ctx.db.insert("webhookEvents", {
+ projectId: args.projectId,
+ type: args.eventType,
+ source: "MetaHorizonReconciler",
+ platform: "Android",
+ environment: "Production",
+ purchaseToken: args.purchaseToken,
+ sourceNotificationId,
+ productId: args.productId,
+ subscriptionState: transition.next.state,
+ occurredAt: now,
+ receivedAt: now,
+ });
+ // If we found an existing event AND the existing subscription row
+ // already references it, the rest of this mutation is a no-op —
+ // the prior cron tick already applied this transition. Bump
+ // `updatedAt` so the row moves to the back of the
+ // `by_project_and_state_and_updated` queue used by
+ // `listHorizonSubscriptions` for paginated reconciliation;
+ // otherwise steady-state rows whose deterministic event id
+ // doesn't change would stay pinned at the front of the cursor and
+ // anything past PER_STATE_CAP would never be revisited (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (existing.lastEventId === eventId) {
+ await ctx.db.patch(existing._id, { updatedAt: now });
+ return existing._id;
+ }
+
+ // Capture stats contribution before patching so the delta below
+ // subtracts what the row used to count for and adds the new state.
+ // Horizon doesn't track billingPeriod (Meta doesn't expose one in
+ // verify_entitlement), so MRR contribution is 0 — matches the
+ // existing read-path semantics for Horizon-backed subs.
+ const beforeContribution = statsContributionFor(existing, undefined, now);
+
+ // Horizon-specific expiresAt handling. Meta's verify_entitlement
+ // is binary (granted / not granted) — there's no upstream expiry
+ // we can copy onto the row. The state machine's CurrentSubscription
+ // path carries the OLD expiresAt forward, which means a renewed-
+ // upstream sub whose previous expiresAt is now in the past would
+ // be patched back to "Active" with a stale (already-expired)
+ // timestamp; the entitlement read path's `isActive` check then
+ // immediately treats it as inactive again. Set a forward-looking
+ // expiry that comfortably outlasts the next poll cycle (cron runs
+ // every 6h) so an `Active` Horizon row stays entitled until either
+ // the next reconcile flips it or the operator pauses the cron for
+ // an extended outage.
+ //
+ // For SubscriptionExpired we let the state-machine's transition
+ // handle the timestamp; the row is moving to a non-active state
+ // so the stale expiresAt is irrelevant.
+ const HORIZON_RENEWAL_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
+ const horizonExpiresAt =
+ args.eventType === "SubscriptionRenewed"
+ ? now + HORIZON_RENEWAL_VALIDITY_MS
+ : transition.next.expiresAt;
+
+ await ctx.db.patch(existing._id, {
+ state: transition.next.state,
+ willRenew: transition.next.willRenew,
+ cancellationReason: transition.next.cancellationReason,
+ expiresAt: horizonExpiresAt,
+ updatedAt: now,
+ lastEventId: eventId,
+ });
+
+ const updatedRow = (await ctx.db.get(existing._id))!;
+ const afterContribution = statsContributionFor(updatedRow, undefined, now);
+ await applyStatsTransition(
+ ctx,
+ args.projectId,
+ beforeContribution,
+ afterContribution,
+ );
+
+ return existing._id;
+ },
+});
diff --git a/packages/kit/convex/subscriptions/internal.ts b/packages/kit/convex/subscriptions/internal.ts
new file mode 100644
index 00000000..1ffeaee3
--- /dev/null
+++ b/packages/kit/convex/subscriptions/internal.ts
@@ -0,0 +1,271 @@
+import { internalMutation, type MutationCtx } from "../_generated/server";
+import { v, type Infer } from "convex/values";
+import type { Doc, Id } from "../_generated/dataModel";
+
+import {
+ applySubscriptionTransition,
+ type CurrentSubscription,
+ type SubscriptionEventInput,
+} from "./stateMachine";
+import { applyStatsTransition, statsContributionFor } from "./stats";
+
+const subscriptionStateValidator = v.union(
+ v.literal("Active"),
+ v.literal("InGracePeriod"),
+ v.literal("InBillingRetry"),
+ v.literal("Expired"),
+ v.literal("Revoked"),
+ v.literal("Refunded"),
+ v.literal("Paused"),
+ v.literal("Unknown"),
+);
+
+const eventInputValidator = v.object({
+ type: v.string(),
+ productId: v.optional(v.string()),
+ subscriptionState: v.optional(subscriptionStateValidator),
+ expiresAt: v.optional(v.number()),
+ renewsAt: v.optional(v.number()),
+ cancellationReason: v.optional(v.string()),
+ currency: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ purchaseToken: v.string(),
+});
+
+type RawEventInput = Infer;
+
+// Apply a webhook event to the canonical `subscriptions` table. Idempotent
+// with respect to `lastEventId` so a re-run of the same event (after a
+// retry / replay) doesn't double-count metrics.
+export const applySubscriptionEvent = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ eventId: v.id("webhookEvents"),
+ event: eventInputValidator,
+ },
+ returns: v.object({
+ transition: v.union(v.string(), v.null()),
+ active: v.boolean(),
+ subscriptionId: v.optional(v.id("subscriptions")),
+ }),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_token", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("purchaseToken", args.event.purchaseToken),
+ )
+ .unique();
+
+ if (existing && existing.lastEventId === args.eventId) {
+ return {
+ transition: null,
+ active: isActive(existing),
+ subscriptionId: existing._id,
+ };
+ }
+
+ const current: CurrentSubscription = existing
+ ? {
+ state: existing.state,
+ productId: existing.productId,
+ expiresAt: existing.expiresAt,
+ renewsAt: existing.renewsAt,
+ willRenew: existing.willRenew,
+ cancellationReason: existing.cancellationReason,
+ currency: existing.currency,
+ priceAmountMicros: existing.priceAmountMicros,
+ }
+ : null;
+
+ const transition = applySubscriptionTransition(
+ current,
+ coerceEventInput(args.event),
+ );
+
+ if (!transition.next) {
+ return {
+ transition: transition.transition ?? null,
+ active: false,
+ subscriptionId: existing?._id,
+ };
+ }
+
+ const now = Date.now();
+ const next = transition.next;
+
+ // Pull billing period for MRR calculation. Skipped if state isn't
+ // counted (Active / InGracePeriod / InBillingRetry) since
+ // statsContributionFor returns null in that case anyway. The
+ // AFTER side always uses the new product's period.
+ const billingPeriod = await fetchBillingPeriod(
+ ctx,
+ args.projectId,
+ args.event.platform,
+ next.productId,
+ );
+
+ // BEFORE side has to use the OLD product's billing period — when
+ // an upgrade or downgrade event flips `productId`, using the new
+ // product's period to compute the BEFORE delta would subtract
+ // the wrong monthly-normalized amount from MRR and corrupt the
+ // incremental counter (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ // Reuse `billingPeriod` only when the productId didn't change.
+ const beforeBillingPeriod =
+ existing && existing.productId !== next.productId
+ ? await fetchBillingPeriod(
+ ctx,
+ args.projectId,
+ args.event.platform,
+ existing.productId,
+ )
+ : billingPeriod;
+
+ // Capture the BEFORE contribution against the still-existing row
+ // so the stats delta below subtracts what the row used to count
+ // for, then adds what it counts for after the patch.
+ const beforeContribution = existing
+ ? statsContributionFor(existing, beforeBillingPeriod, now)
+ : null;
+
+ let subscriptionId: Id<"subscriptions">;
+ let updatedRow: Doc<"subscriptions">;
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ productId: next.productId,
+ state: next.state,
+ expiresAt: next.expiresAt,
+ renewsAt: next.renewsAt,
+ willRenew: next.willRenew,
+ cancellationReason: next.cancellationReason,
+ currency: next.currency,
+ priceAmountMicros: next.priceAmountMicros,
+ updatedAt: now,
+ lastEventId: args.eventId,
+ });
+ subscriptionId = existing._id;
+ updatedRow = (await ctx.db.get(existing._id))!;
+ } else {
+ subscriptionId = await ctx.db.insert("subscriptions", {
+ projectId: args.projectId,
+ purchaseToken: args.event.purchaseToken,
+ productId: next.productId,
+ platform: args.event.platform,
+ state: next.state,
+ expiresAt: next.expiresAt,
+ renewsAt: next.renewsAt,
+ willRenew: next.willRenew,
+ cancellationReason: next.cancellationReason,
+ currency: next.currency,
+ priceAmountMicros: next.priceAmountMicros,
+ startedAt: now,
+ updatedAt: now,
+ lastEventId: args.eventId,
+ });
+ updatedRow = (await ctx.db.get(subscriptionId))!;
+ }
+
+ const afterContribution = statsContributionFor(
+ updatedRow,
+ billingPeriod,
+ now,
+ );
+ await applyStatsTransition(
+ ctx,
+ args.projectId,
+ beforeContribution,
+ afterContribution,
+ );
+
+ return {
+ transition: transition.transition ?? null,
+ active: transition.active,
+ subscriptionId,
+ };
+ },
+});
+
+// Look up a product's billing period from the kit-side catalog. We
+// Look up the row for the EXACT (platform, productId) — `products` is
+// keyed by (projectId, platform, productId) precisely because the
+// same SKU can exist on both stores with different billing periods.
+// Earlier behaviour preferred iOS over Android by walking both
+// platforms, which made an Android subscription inherit the iOS
+// period when those rows diverged and skewed `mrrMicros` on both the
+// incremental delta and the next recompute (PR #124
+// (https://github.com/hyodotdev/openiap/pull/124) review). Returns
+// undefined when the product isn't tracked or has no billingPeriod —
+// monthlyMicrosForSub treats that as a P1M fallback.
+async function fetchBillingPeriod(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ platform: "IOS" | "Android",
+ productId: string,
+): Promise {
+ const product = await ctx.db
+ .query("products")
+ .withIndex("by_project_and_platform_and_product", (q) =>
+ q
+ .eq("projectId", projectId)
+ .eq("platform", platform)
+ .eq("productId", productId),
+ )
+ .unique();
+ return product?.billingPeriod ?? undefined;
+}
+
+function coerceEventInput(raw: RawEventInput): SubscriptionEventInput {
+ return {
+ type: raw.type as SubscriptionEventInput["type"],
+ productId: raw.productId,
+ subscriptionState: raw.subscriptionState,
+ expiresAt: raw.expiresAt,
+ renewsAt: raw.renewsAt,
+ cancellationReason: raw.cancellationReason as
+ | SubscriptionEventInput["cancellationReason"]
+ | undefined,
+ currency: raw.currency,
+ priceAmountMicros: raw.priceAmountMicros,
+ };
+}
+
+function isActive(
+ sub: Doc<"subscriptions">,
+ now: number = Date.now(),
+): boolean {
+ const entitled = sub.state === "Active" || sub.state === "InGracePeriod";
+ if (!entitled) return false;
+ if (sub.expiresAt != null && sub.expiresAt <= now) return false;
+ return true;
+}
+
+// Bind a subscription to a userId. Called by the SDK after a successful
+// receipt validation when the host app knows which user owns the receipt.
+export const bindSubscriptionToUser = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ purchaseToken: v.string(),
+ userId: v.string(),
+ },
+ returns: v.union(v.id("subscriptions"), v.null()),
+ handler: async (ctx, args) => {
+ const sub = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_token", (q) =>
+ q
+ .eq("projectId", args.projectId)
+ .eq("purchaseToken", args.purchaseToken),
+ )
+ .unique();
+ if (!sub) return null;
+ if (sub.userId === args.userId) return sub._id;
+ await ctx.db.patch(sub._id, {
+ userId: args.userId,
+ updatedAt: Date.now(),
+ });
+ return sub._id;
+ },
+});
diff --git a/packages/kit/convex/subscriptions/monthlyMicros.ts b/packages/kit/convex/subscriptions/monthlyMicros.ts
new file mode 100644
index 00000000..7bf3fc17
--- /dev/null
+++ b/packages/kit/convex/subscriptions/monthlyMicros.ts
@@ -0,0 +1,49 @@
+import type { Doc } from "../_generated/dataModel";
+
+// Normalize a subscription's billing-period price to a per-month
+// micros figure so MRR can sum across products with different billing
+// periods. Formula uses calendar averages — yearly /12, weekly *4.345
+// (= 52.14/12), bi-weekly *2.17, daily *30.44 — chosen to land in the
+// same order of magnitude as the standard SaaS MRR convention. The
+// previous implementation summed `priceAmountMicros` raw, so a $120/yr
+// plan inflated MRR by 12×.
+//
+// Lives in its own file so both `query.ts` (read path) and `stats.ts`
+// (incremental aggregation) share the same calculation — splitting it
+// into two copies would let MRR drift between the live counter and a
+// future scan-based recomputation.
+export function monthlyMicrosForSub(
+ sub: Doc<"subscriptions">,
+ productPeriod: string | undefined,
+): number {
+ if (typeof sub.priceAmountMicros !== "number") return 0;
+ const amount = sub.priceAmountMicros;
+ switch (productPeriod) {
+ case "P1Y":
+ return Math.round(amount / 12);
+ case "P6M":
+ return Math.round(amount / 6);
+ case "P3M":
+ return Math.round(amount / 3);
+ case "P2M":
+ return Math.round(amount / 2);
+ case "P1W":
+ return Math.round(amount * 4.345);
+ case "P3D":
+ return Math.round(amount * (30.44 / 3));
+ case "P2W":
+ return Math.round(amount * (30.44 / 14));
+ case "P1M":
+ return amount;
+ case undefined:
+ default:
+ // One-time products (NonConsumable / Consumable) and rows
+ // with missing billing metadata don't contribute to recurring
+ // revenue. The previous fall-through to `amount` inflated MRR
+ // by the full sticker price every time a one-time purchase
+ // landed in `subscriptions` — which only happens when a
+ // catalog row was mis-classified, but a mis-classification
+ // shouldn't quietly skew the dashboard headline.
+ return 0;
+ }
+}
diff --git a/packages/kit/convex/subscriptions/mutation.ts b/packages/kit/convex/subscriptions/mutation.ts
new file mode 100644
index 00000000..5efc45bf
--- /dev/null
+++ b/packages/kit/convex/subscriptions/mutation.ts
@@ -0,0 +1,41 @@
+import { mutation } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc } from "../_generated/dataModel";
+
+// Public mutation called by SDKs after a successful receipt verification:
+// they know who the host-app user is, so they tell kit which userId owns
+// the verified purchaseToken. Idempotent — re-binding the same userId is
+// a no-op.
+export const bindUser = mutation({
+ args: {
+ apiKey: v.string(),
+ purchaseToken: v.string(),
+ userId: v.string(),
+ },
+ returns: v.object({ ok: v.boolean(), bound: v.boolean() }),
+ handler: async (ctx, args) => {
+ const project = await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey))
+ .unique();
+ if (!project) return { ok: false, bound: false };
+
+ const sub: Doc<"subscriptions"> | null = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_token", (q) =>
+ q.eq("projectId", project._id).eq("purchaseToken", args.purchaseToken),
+ )
+ .unique();
+ if (!sub) return { ok: true, bound: false };
+
+ if (sub.userId === args.userId) {
+ return { ok: true, bound: true };
+ }
+
+ await ctx.db.patch(sub._id, {
+ userId: args.userId,
+ updatedAt: Date.now(),
+ });
+ return { ok: true, bound: true };
+ },
+});
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
new file mode 100644
index 00000000..82337e85
--- /dev/null
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -0,0 +1,461 @@
+import { query, type QueryCtx } from "../_generated/server";
+import { v } from "convex/values";
+import type { Doc, Id } from "../_generated/dataModel";
+
+import { monthlyMicrosForSub } from "./monthlyMicros";
+import { selectMostRecentlyUpdatedSubscription } from "./selectLatest";
+
+const subscriptionStateValidator = v.union(
+ v.literal("Active"),
+ v.literal("InGracePeriod"),
+ v.literal("InBillingRetry"),
+ v.literal("Expired"),
+ v.literal("Revoked"),
+ v.literal("Refunded"),
+ v.literal("Paused"),
+ v.literal("Unknown"),
+);
+
+const subscriptionShape = v.object({
+ id: v.id("subscriptions"),
+ productId: v.string(),
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
+ state: subscriptionStateValidator,
+ expiresAt: v.optional(v.number()),
+ renewsAt: v.optional(v.number()),
+ willRenew: v.optional(v.boolean()),
+ cancellationReason: v.optional(v.string()),
+ currency: v.optional(v.string()),
+ priceAmountMicros: v.optional(v.number()),
+ startedAt: v.number(),
+ updatedAt: v.number(),
+ purchaseToken: v.string(),
+ userId: v.optional(v.string()),
+});
+
+function isActive(sub: Doc<"subscriptions">, now: number): boolean {
+ const entitled = sub.state === "Active" || sub.state === "InGracePeriod";
+ if (!entitled) return false;
+ if (sub.expiresAt != null && sub.expiresAt <= now) return false;
+ return true;
+}
+
+function shapeRow(sub: Doc<"subscriptions">) {
+ return {
+ id: sub._id,
+ productId: sub.productId,
+ platform: sub.platform,
+ state: sub.state,
+ expiresAt: sub.expiresAt,
+ renewsAt: sub.renewsAt,
+ willRenew: sub.willRenew,
+ cancellationReason: sub.cancellationReason,
+ currency: sub.currency,
+ priceAmountMicros: sub.priceAmountMicros,
+ startedAt: sub.startedAt,
+ updatedAt: sub.updatedAt,
+ purchaseToken: sub.purchaseToken,
+ userId: sub.userId,
+ };
+}
+
+async function projectByApiKey(
+ ctx: { db: any },
+ apiKey: string,
+): Promise | null> {
+ return await ctx.db
+ .query("projects")
+ .withIndex("by_api_key", (q: any) => q.eq("apiKey", apiKey))
+ .unique();
+}
+
+// Match onesub's `/onesub/status?userId=` — returns the most-recently-
+// updated active subscription when the user is entitled, otherwise the
+// most-recently-updated subscription overall, plus one `active` boolean
+// for simple gating.
+export const subscriptionStatus = query({
+ args: { apiKey: v.string(), userId: v.string() },
+ returns: v.object({
+ active: v.boolean(),
+ subscription: v.union(subscriptionShape, v.null()),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) return { active: false, subscription: null };
+
+ const subs = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_user", (q) =>
+ q.eq("projectId", project._id).eq("userId", args.userId),
+ )
+ .collect();
+
+ const now = Date.now();
+ const activeSubs = subs.filter((candidate) => isActive(candidate, now));
+ const sub = selectMostRecentlyUpdatedSubscription(
+ activeSubs.length > 0 ? activeSubs : subs,
+ );
+ if (!sub) return { active: false, subscription: null };
+
+ return {
+ active: activeSubs.length > 0,
+ subscription: shapeRow(sub),
+ };
+ },
+});
+
+// Match onesub's entitlement evaluation — every productId the user
+// currently has rights to. Aggregates across all subscription rows so
+// a user with multiple offers (resub, family share, cross-grade) sees
+// the union.
+export const entitlements = query({
+ args: { apiKey: v.string(), userId: v.string() },
+ returns: v.object({
+ userId: v.string(),
+ productIds: v.array(v.string()),
+ subscriptions: v.array(subscriptionShape),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) {
+ return { userId: args.userId, productIds: [], subscriptions: [] };
+ }
+
+ const all = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_user", (q) =>
+ q.eq("projectId", project._id).eq("userId", args.userId),
+ )
+ .collect();
+
+ const now = Date.now();
+ const active = all.filter((sub) => isActive(sub, now));
+ return {
+ userId: args.userId,
+ productIds: Array.from(new Set(active.map((sub) => sub.productId))),
+ subscriptions: active.map(shapeRow),
+ };
+ },
+});
+
+// Filtered list for the dashboard's subscriptions page. Mirrors
+// onesub's `SubscriptionStore.listFiltered` API.
+export const listSubscriptions = query({
+ args: {
+ apiKey: v.string(),
+ state: v.optional(subscriptionStateValidator),
+ productId: v.optional(v.string()),
+ userId: v.optional(v.string()),
+ limit: v.optional(v.number()),
+ },
+ returns: v.object({
+ items: v.array(subscriptionShape),
+ total: v.number(),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) return { items: [], total: 0 };
+
+ const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
+
+ // userId path: subscriptions per user is a small population
+ // (single digits in practice — a user with 50 subscriptions on a
+ // single project is pathological), so we collect the entire
+ // by_project_and_user slice and apply state/productId filters in
+ // memory rather than throwing. Earlier behaviour rejected the
+ // combo with an error, which made the dashboard "filter user X by
+ // state Active" path unusable (PR #124
+ // (https://github.com/hyodotdev/openiap/pull/124) review).
+ if (args.userId) {
+ const userRows = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_user", (q) =>
+ q.eq("projectId", project._id).eq("userId", args.userId),
+ )
+ .order("desc")
+ .collect();
+ const filtered = userRows.filter((sub) => {
+ if (args.state && sub.state !== args.state) return false;
+ if (args.productId && sub.productId !== args.productId) return false;
+ return true;
+ });
+ return {
+ items: filtered.slice(0, limit).map(shapeRow),
+ total: filtered.length,
+ };
+ }
+
+ // Pick the most-selective index for the supplied filters. Schema
+ // covers single-filter combinations directly; the composite
+ // (projectId, state, productId) index handles the dashboard's
+ // common "filter by state and SKU" combination so we don't need
+ // an over-fetch + in-memory post-filter that could miss rows
+ // past the take() boundary.
+ let rows: Array>;
+ if (args.state && args.productId) {
+ rows = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_state_and_product", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("state", args.state!)
+ .eq("productId", args.productId!),
+ )
+ .order("desc")
+ .take(limit);
+ } else if (args.state) {
+ rows = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_state", (q) =>
+ q.eq("projectId", project._id).eq("state", args.state!),
+ )
+ .order("desc")
+ .take(limit);
+ } else if (args.productId) {
+ rows = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_product", (q) =>
+ q.eq("projectId", project._id).eq("productId", args.productId!),
+ )
+ .order("desc")
+ .take(limit);
+ } else {
+ rows = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_updated", (q) =>
+ q.eq("projectId", project._id),
+ )
+ .order("desc")
+ .take(limit);
+ }
+
+ // All filter combinations hit an index that covers the supplied
+ // columns now (the (state + productId) composite was added in
+ // schema.ts), so no in-memory post-filter is needed here.
+
+ // `total` reflects the filtered window we actually materialized,
+ // not the full server-side count. Computing a true total would
+ // require a separate aggregate scan that defeats the take() bound
+ // we just put in. The dashboard treats `total` as "rows shown
+ // matching the current filter" and surfaces "+ more" affordances
+ // via the next page request.
+ return { items: rows.slice(0, limit).map(shapeRow), total: rows.length };
+ },
+});
+
+// Metrics aggregation. Reads incrementally-maintained per-currency
+// counters out of `subscriptionStats` for the live state buckets +
+// MRR (O(currencies-per-project) — typically 1-3 rows), and bounded
+// indexed scans over `by_project_and_state` for the 30-day rolling
+// counters. The prior implementation took up to 10,000 subscriptions
+// off the by_project_and_updated index and aggregated in memory,
+// which silently undercounted projects above that cap.
+//
+// Migration safety: when the stats table is empty for a project
+// (pre-rollout state) we fall through to a one-shot recompute via
+// the same statsContributionFor logic so the dashboard stays
+// correct on first read after deploy. The
+// `recomputeSubscriptionStats` internal mutation populates rows for
+// future reads.
+export const metricsSummary = query({
+ args: { apiKey: v.string() },
+ returns: v.object({
+ activeSubs: v.number(),
+ inGracePeriod: v.number(),
+ inBillingRetry: v.number(),
+ refunded30d: v.number(),
+ canceled30d: v.number(),
+ // Headline MRR in the project's most-popular currency, normalized
+ // to monthly. Historical field name kept for backward compat with
+ // dashboard / MCP consumers.
+ mrrMicros: v.number(),
+ currency: v.optional(v.string()),
+ // Full per-currency breakdown so consumers that care about
+ // multi-currency aren't left guessing. Each entry's `mrrMicros`
+ // is summed only over subscriptions in that currency, normalized
+ // to monthly via the product's billingPeriod.
+ mrrByCurrency: v.array(
+ v.object({ currency: v.string(), mrrMicros: v.number() }),
+ ),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) {
+ return {
+ activeSubs: 0,
+ inGracePeriod: 0,
+ inBillingRetry: 0,
+ refunded30d: 0,
+ canceled30d: 0,
+ mrrMicros: 0,
+ currency: undefined,
+ mrrByCurrency: [],
+ };
+ }
+
+ const now = Date.now();
+ const cutoff = now - 30 * 24 * 60 * 60 * 1000;
+
+ // Live state counters + MRR — read out of the incrementally
+ // maintained `subscriptionStats` table.
+ const statsRows = await ctx.db
+ .query("subscriptionStats")
+ .withIndex("by_project", (q) => q.eq("projectId", project._id))
+ .collect();
+
+ let activeSubs = 0;
+ let inGracePeriod = 0;
+ let inBillingRetry = 0;
+ const mrrAccumulators = new Map();
+
+ if (statsRows.length > 0) {
+ for (const row of statsRows) {
+ activeSubs += row.activeSubs;
+ inGracePeriod += row.inGracePeriod;
+ inBillingRetry += row.inBillingRetry;
+ if (row.currency && row.mrrMicros > 0) {
+ mrrAccumulators.set(
+ row.currency,
+ (mrrAccumulators.get(row.currency) ?? 0) + row.mrrMicros,
+ );
+ }
+ }
+ } else {
+ // No stats rows yet — pre-rollout state for this project.
+ // Compute on the fly so the dashboard isn't blank on first
+ // read after deploy. Bounded by the same per-project scan the
+ // backfill mutation does; for projects past the prior 10k cap
+ // this is a one-time cost until `recomputeSubscriptionStats`
+ // populates the table.
+ //
+ // Bounded by FALLBACK_SCAN_CAP so a project that's hugely past
+ // the prior 10k scan limit can't crash the dashboard render.
+ // The cap matches the previous implementation's bound; the
+ // first read after deploy schedules an async backfill via the
+ // drift-correction cron, after which subsequent reads come
+ // out of subscriptionStats and have no scan at all.
+ const FALLBACK_SCAN_CAP = 10_000;
+ const periodByProductId = await loadPeriodByProductId(ctx, project._id);
+ const allSubs = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_updated", (q) =>
+ q.eq("projectId", project._id),
+ )
+ .order("desc")
+ .take(FALLBACK_SCAN_CAP);
+ for (const sub of allSubs) {
+ if (sub.state === "Active" && isActive(sub, now)) {
+ activeSubs += 1;
+ if (typeof sub.priceAmountMicros === "number" && sub.currency) {
+ const monthly = monthlyMicrosForSub(
+ sub,
+ periodByProductId.get(sub.productId),
+ );
+ mrrAccumulators.set(
+ sub.currency,
+ (mrrAccumulators.get(sub.currency) ?? 0) + monthly,
+ );
+ }
+ } else if (sub.state === "InGracePeriod") {
+ inGracePeriod += 1;
+ } else if (sub.state === "InBillingRetry") {
+ inBillingRetry += 1;
+ }
+ }
+ }
+
+ // 30-day rolling counters — bounded by churn rather than by
+ // historical state archive. The previous implementation walked
+ // every `Refunded` row + every (Active|InGracePeriod|InBillingRetry
+ // |Expired) row for the project and filtered in memory, which
+ // grew unbounded as the historical archive accumulated. We now
+ // do a single time-windowed scan via `by_project_and_updated`
+ // with `gte(cutoff)`, then derive both refunded + canceled
+ // counters in one pass. The candidate set is bounded by the
+ // last 30 days of state changes (typically thousands per
+ // project, never the full lifetime).
+ // Cap the windowed scan so a project with > 10k state changes
+ // in 30 days can't exceed Convex's 40k document-read limit. The
+ // rolling counters degrade gracefully — if a project genuinely
+ // hits this bound the dashboard shows an approximate count that
+ // still tracks the cohort closely (this is the same trade-off
+ // the previous SUBS_SCAN_CAP made for active counts, before the
+ // incremental subscriptionStats path replaced it). Real-world
+ // monthly churn is well under 10k for any realistic deployment.
+ const ROLLING_SCAN_CAP = 10_000;
+ const recentlyChanged = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_updated", (q) =>
+ q.eq("projectId", project._id).gte("updatedAt", cutoff),
+ )
+ .take(ROLLING_SCAN_CAP);
+ let refunded30d = 0;
+ let canceled30d = 0;
+ const CANCELED_STATES = new Set([
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+ "Expired",
+ ]);
+ for (const sub of recentlyChanged) {
+ if (sub.state === "Refunded") {
+ refunded30d += 1;
+ }
+ if (
+ sub.willRenew === false &&
+ sub.cancellationReason === "UserCanceled" &&
+ CANCELED_STATES.has(sub.state)
+ ) {
+ canceled30d += 1;
+ }
+ }
+
+ // Pick the most-popular currency (largest accumulator) as the
+ // headline `currency` + `mrrMicros` so dashboards / MCP consumers
+ // that don't yet read the multi-currency breakdown still show a
+ // sensible single value. Stable tie-break via alphabetical sort.
+ const sorted = Array.from(mrrAccumulators.entries()).sort(
+ ([a, av], [b, bv]) => (bv !== av ? bv - av : a.localeCompare(b)),
+ );
+ const headline = sorted[0];
+
+ return {
+ activeSubs,
+ inGracePeriod,
+ inBillingRetry,
+ refunded30d,
+ canceled30d,
+ mrrMicros: headline ? headline[1] : 0,
+ currency: headline ? headline[0] : undefined,
+ mrrByCurrency: sorted.map(([currency, mrrMicros]) => ({
+ currency,
+ mrrMicros,
+ })),
+ };
+ },
+});
+
+async function loadPeriodByProductId(
+ ctx: QueryCtx,
+ projectId: Id<"projects">,
+): Promise