import type { OneSubErrorCode } from './constants.js'; /** Subscription status. * * Lifecycle states: * - active — paid period, entitlement valid * - grace_period — payment failed but Apple/Google grants temporary access * while retrying. Treat as entitled. * - on_hold — payment failed, retry window expired or grace ended. * Entitlement REVOKED until the user fixes payment. * (Apple "billing retry"; Google "on hold".) * - paused — user voluntarily paused the subscription (Google only). * Entitlement REVOKED until autoResumeTime or user resumes. * Distinct from on_hold: paused is intentional, not a * payment failure. UX should say "재개 예정" not * "결제 정보를 업데이트하세요". * - expired — subscription ended without renewal * - canceled — refunded or revoked by store * - none — no record */ export type SubscriptionStatus = 'active' | 'grace_period' | 'on_hold' | 'paused' | 'expired' | 'canceled' | 'none'; /** Store platform */ export type Platform = 'apple' | 'google'; /** Receipt validation request */ export interface ValidateReceiptRequest { platform: Platform; receipt: string; userId: string; productId: string; /** Which app this receipt belongs to on a multi-app server. See `OneSubConfig.appId`. */ appId?: string; } /** Receipt validation response */ export interface ValidateReceiptResponse { valid: boolean; subscription: SubscriptionInfo | null; /** Human-readable error. For programmatic handling use `errorCode`. */ error?: string; /** Machine-readable canonical error code. */ errorCode?: OneSubErrorCode; } /** Subscription info returned by server */ export interface SubscriptionInfo { userId: string; productId: string; platform: Platform; status: SubscriptionStatus; expiresAt: string; originalTransactionId: string; purchasedAt: string; willRenew: boolean; /** * Google-only. The previous purchaseToken in an upgrade/downgrade/replace * chain — set when this subscription was started by replacing another one. * Lets the host follow user identity across plan changes (Google issues a * new token per plan change). Null/undefined for first-purchase or Apple. */ linkedPurchaseToken?: string; /** * Google-only. When `status === 'paused'`, the RFC3339 timestamp at which * Google plans to auto-resume the subscription (from * `pausedStateContext.autoResumeTime` in subscriptionsv2). Lets the host UX * show "재개 예정: YYYY-MM-DD" instead of just "일시정지 중". Undefined when * not paused or if Google didn't supply it. */ autoResumeTime?: string; /** * Account identity baked into the receipt at purchase time (Apple * `appAccountToken` / Google `obfuscatedExternalAccountId`). Transient: * populated by the receipt validators, consumed by the validate route's * account-binding guard and the Google webhook's userId seeding, and * stripped by every route before the record is stored. */ boundAccountId?: string; /** * True when the receipt came from Apple's Sandbox (TestFlight / StoreKit * testing) rather than Production. Transient in the same sense as * `boundAccountId`: the validators set it, the validate route reads it to * decide whether a test override may apply, and the route strips it before * the record reaches a store. Never persisted. */ sandbox?: boolean; } /** Subscription status check response */ export interface StatusResponse { active: boolean; subscription: SubscriptionInfo | null; error?: string; errorCode?: OneSubErrorCode; } /** Apple Server Notification V2 */ export interface AppleNotificationPayload { notificationType: string; subtype?: string; /** * UUID Apple stamps on each notification. Stable across retries — used as * the idempotency key in `WebhookEventStore`. * https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload */ notificationUUID?: string; data: { signedTransactionInfo: string; signedRenewalInfo: string; environment?: string; bundleId?: string; }; } /** * Apple App Store Server API ConsumptionRequest body — the response Apple * expects when it sends a CONSUMPTION_REQUEST notification asking whether to * grant or decline a consumable refund. * * https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest */ export interface AppleConsumptionRequest { /** REQUIRED — must be true; if false, Apple ignores the response. */ customerConsented: boolean; /** 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed */ consumptionStatus: 0 | 1 | 2 | 3; /** 0 = undeclared, 1 = delivered & working, 2 = quality issue, 3 = wrong item, 4 = server outage, 5 = currency change */ deliveryStatus: 0 | 1 | 2 | 3 | 4 | 5; /** 0 = undeclared, 1 = grant refund, 2 = decline, 3 = no preference */ refundPreference?: 0 | 1 | 2 | 3; /** 0 = undeclared, 1 = active, 2 = suspended, 3 = terminated, 4 = limited */ userStatus?: 0 | 1 | 2 | 3 | 4; /** 0 = undeclared, 1 = <3 days, 2 = 3-10d, 3 = 10-30d, 4 = 30-90d, 5 = >90d */ accountTenure?: 0 | 1 | 2 | 3 | 4 | 5; /** 0 = undeclared, 1 = <5min, 2 = 5-60min, 3 = 1-6h, 4 = 6-24h, 5 = 1-4d, 6 = >4d */ playTime?: 0 | 1 | 2 | 3 | 4 | 5 | 6; /** 0 = undeclared, 1 = $0, 2 = $0.01-$49.99, ... 7 = >$1999.99 */ lifetimeDollarsPurchased?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; /** Same buckets as lifetimeDollarsPurchased */ lifetimeDollarsRefunded?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; /** 0 = undeclared, 1 = Apple, 2 = Non-Apple */ platform?: 0 | 1 | 2; sampleContentProvided?: boolean; /** UUID — same value the client passed via setAppAccountToken */ appAccountToken?: string; } /** Context passed to the consumptionInfoProvider hook for a CONSUMPTION_REQUEST notification. */ export interface AppleConsumptionContext { transactionId: string; originalTransactionId: string; productId: string; bundleId: string; environment: 'Production' | 'Sandbox'; } /** Context passed to the Google onPriceChangeConfirmed hook. */ export interface GooglePriceChangeContext { /** purchaseToken — the same id stored as originalTransactionId for Google subs. */ purchaseToken: string; /** Subscription productId (Google: subscriptionId). */ subscriptionId: string; packageName: string; } /** Google RTDN (Real-Time Developer Notification) */ export interface GoogleNotificationPayload { message: { data: string; messageId: string; }; subscription: string; } /** * Log sink — compatible with the common shape of `pino`, `winston`, `bunyan`, and * `console`. Pass your own implementation via `OneSubServerConfig.logger` to * redirect onesub's runtime logs. * * `@onesub/server` calls this with **exactly one string argument** per log, so a * `pino` or `winston` host receives it as `msg` with nothing to interpolate. * Contextual values arrive inside that string as `key=value` pairs, which logfmt * parsers (Loki, Splunk, Datadog, CloudWatch Insights) extract as fields. The * server renders them itself rather than passing an object, because a trailing * object is only escaped by some sinks and is dropped entirely by a JSON * serialiser when it holds an `Error`. * * The React Native SDK uses the same type but still calls it printf-style, with * `'[onesub]'` as the first argument. * * Default: `console` (when `logger` is omitted). */ export interface OneSubLogger { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; } /** Apple provider credentials for one app. */ export type OneSubAppleConfig = NonNullable; /** Google provider credentials for one app. */ export type OneSubGoogleConfig = NonNullable; /** * One app served by a multi-app onesub instance. See `OneSubServerConfig.apps`. */ export interface OneSubAppConfig { /** * Stable identifier a client can send as `appId`. Matching also accepts the * app's Apple bundleId or Google packageName, so a client that sends its * platform identifier (`Application.identifier`) resolves without extra config. */ id: string; apple?: OneSubAppleConfig; google?: OneSubGoogleConfig; } /** Server config */ export interface OneSubServerConfig { apple?: { bundleId: string; sharedSecret?: string; keyId?: string; issuerId?: string; privateKey?: string; /** Skip JWS signature verification (for development/testing only) */ skipJwsVerification?: boolean; /** * Max age (hours) accepted for one-time-purchase receipts. Default 72 — * the replay-attack window. Raise it when validating historical receipts * on purpose (migrating purchasers from another IAP backend, e2e tests). */ productReceiptMaxAgeHours?: number; /** * Mock provider mode — when true, bypass all Apple API calls and decide * receipt validity from the receipt string pattern (see `providers/mock.ts`). * Use for local development, CI, and AI-driven integration testing without * real App Store Connect credentials. **NEVER enable in production.** */ mockMode?: boolean; /** * Hook to provide consumption info when Apple sends a CONSUMPTION_REQUEST * notification (consumable refund review). * * If set, the webhook handler calls this with the refunded transaction's * context and PUTs the returned ConsumptionRequest to Apple's * /inApps/v1/transactions/consumption/{txId} endpoint. Without this hook, * Apple has no usage signal and tends to grant the refund. * * Requires keyId, issuerId, and privateKey to be configured (the API call * is JWT-authenticated). Return null to skip this particular request. */ consumptionInfoProvider?: (ctx: AppleConsumptionContext) => Promise; /** * Promotional offer signing key ID from App Store Connect → * Users and Access → Keys → Subscriptions. * Required for `POST /onesub/apple/offer-signature`. */ offerKeyId?: string; /** * ES256 private key (PEM) for signing promotional offer payloads. * Pair with `offerKeyId`. Never set both `offerPrivateKey` and `privateKey` * to the same value — they are separate keys with different scopes. */ offerPrivateKey?: string; }; google?: { packageName?: string; serviceAccountKey?: string; /** * Expected `aud` claim for incoming Pub/Sub push JWT tokens. * When set, the Google webhook endpoint verifies the `Authorization: Bearer ` * header as a Google-signed JWT whose `aud` matches this value. * * **Required in production** unless `allowUnauthenticatedWebhook` is set. With * neither, `POST /onesub/webhook/google` answers 401 when `NODE_ENV=production`. * Outside production the webhook still accepts unauthenticated requests so local * and CI setups need no credentials. * * Set this to the push endpoint URL registered in your Pub/Sub subscription, * e.g. `https://your-server.example.com/onesub/webhook/google`. */ pushAudience?: string; /** * Run the Google webhook unauthenticated in production, on purpose. * * Only correct when something in front of the server already authenticates the * request — Cloud Run with IAM, a VPC-internal ingress, mTLS at a proxy. It is * not a way to postpone configuring `pushAudience`: with neither set, an RTDN is * accepted from anyone who can reach the endpoint, and a caller who knows a * `purchaseToken` or `orderId` can cancel a subscription or delete a one-time * purchase. Those values are not secrets — for Google subscriptions the * purchase token *is* the record's `originalTransactionId`. * * Ignored outside production, where the webhook is unauthenticated regardless. */ allowUnauthenticatedWebhook?: boolean; /** * Service-account email your Pub/Sub push subscription authenticates as. * When set (together with `pushAudience`), incoming push JWTs must carry a * matching verified `email` claim — without it any Google-signed OIDC * token minted for the same audience passes. */ pushServiceAccountEmail?: string; /** * Max age (hours) accepted for one-time-purchase receipts. Default 72 — * the replay-attack window. Raise it when validating historical receipts * on purpose (migrations, e2e tests). */ productReceiptMaxAgeHours?: number; /** * Mock provider mode — same as `apple.mockMode` but for Google Play. * Bypass Play Developer API calls and decide receipt validity from the * receipt string pattern. **NEVER enable in production.** */ mockMode?: boolean; /** * Called when a SUBSCRIPTION_PRICE_CHANGE_CONFIRMED RTDN arrives — the * user has agreed to the price change and the new price applies on the * next renewal. Useful for analytics / in-app notifications / audit logs. * * Fire-and-forget: failures are logged, never thrown — the webhook still * 200s. Receives only the routing context; for the actual new price, * call purchases.subscriptionsv2 directly (the lineItem's * autoRenewingPlan.priceChangeDetails carries newPrice + chargeTime). */ onPriceChangeConfirmed?: (ctx: GooglePriceChangeContext) => void | Promise; }; database: { url: string; }; /** * Additional apps served by this one onesub instance. * * `apple`/`google` above configure a single app and stay the default, so an * existing single-app deployment keeps working untouched. Listing apps here * lets one server validate receipts for N bundles — each app carries its own * Apple bundleId and Google packageName + service account. * * How an incoming request is matched to an app: * 1. the request's `appId`, when the client sends one; * 2. otherwise, for Apple, the `bundleId` baked into the receipt itself; * 3. otherwise `defaultAppId` (or the top-level `apple`/`google` config). * * Google purchase tokens do not name their package, so a Google request for a * non-default app must carry `appId`. * * @example * apps: [ * { id: 'coffee', apple: { bundleId: 'gg.pryzm.coffee' }, google: { packageName: 'gg.pryzm.coffee', serviceAccountKey: coffeeSa } }, * { id: 'penguinrun', apple: { bundleId: 'gg.pryzm.penguinrun' }, google: { packageName: 'gg.pryzm.penguinrun', serviceAccountKey: penguinSa } }, * ] */ apps?: OneSubAppConfig[]; /** * App used when a request names none and the receipt cannot identify one. * Defaults to the top-level `apple`/`google` config, or the first `apps` entry. */ defaultAppId?: string; webhookSecret?: string; /** * Shared secret required for admin endpoints (purchase reset / manual grant). * If set, admin routes are enabled and require the `X-Admin-Secret` header * to match. If unset, admin routes return 404. */ adminSecret?: string; /** * How long an aggregate `/onesub/metrics/*` response may be reused, in * seconds. Defaults to 30. Set `0` to disable caching and reduce the store on * every request. * * Each metrics endpoint reduces every record in the store, and the dashboard * overview calls four of them per render with no client-side caching — so an * uncached deployment re-scans both tables on every browser refresh, by every * operator. These are aggregate counts, where a few seconds of staleness is * unremarkable; nothing that decides entitlement is ever cached. * * The cache is private to each middleware instance rather than shared through * the `cache` adapter, because a metrics key describes "every record in this * store" and cannot distinguish one store from another. So a K-process * deployment recomputes at most K times per window, not once. */ metricsCacheTtlSeconds?: number; /** * Log sink for onesub's runtime logs. If omitted, logs go to * `console.info/warn/error`. Any object that implements `OneSubLogger` * (`pino`, `winston`, `bunyan`, `console`) works. * * Receives one pre-formatted string per log, with contextual values as * `key=value` pairs inside it — see `OneSubLogger`. */ logger?: OneSubLogger; /** * Entitlement definitions — maps host-defined access rights to productIds. * Lets app code branch on stable entitlement names ("premium") instead of * productIds ("pro_monthly"), insulating it from SKU changes / promos. * * When set, `/onesub/entitlement` and `/onesub/entitlements` routes are * mounted. Without it, the routes are not mounted and Express returns 404. * * @example * entitlements: { * premium: { productIds: ['pro_monthly', 'pro_yearly', 'lifetime_pass'] }, * promode: { productIds: ['dev_tools_addon'] }, * } */ entitlements?: EntitlementsConfig; /** * How to handle subscription refunds (Apple REFUND/REVOKE, Google * voidedPurchaseNotification productType=1). * * - `'immediate'` (default): mark `status` as `canceled` right away. The * user loses entitlement immediately on the next /onesub/status check. * Strict, fraud-resistant. * * - `'until_expiry'`: keep `status` and `expiresAt` untouched, only flip * `willRenew` to `false`. The user keeps entitlement until the original * expiry passes (status route's stale-record check then drops them * automatically). Better UX for goodwill refunds; heavier on fraud risk. * * One-time purchases (consumable / non-consumable) are NOT affected by * this setting — those always revoke immediately on refund because they * have no expiry concept. */ refundPolicy?: 'immediate' | 'until_expiry'; } /** * Entitlement abstraction — maps host-defined access rights (e.g. "premium") * to one or more store-side productIds. Lets the app code branch on stable * entitlement names instead of brittle productId strings, so adding a promo * SKU or migrating from monthly→yearly doesn't ripple through the codebase. * * Defined statically in `OneSubServerConfig.entitlements`. A user is entitled * to `'premium'` if any of: * - they have an active subscription (status === 'active' || 'grace_period', * not yet expired) for a productId in `productIds` * - they have a non-consumable purchase for a productId in `productIds` * * Consumables are NOT considered for entitlement checks — they grant the user * a one-time consumable resource (coins, lives), not an ongoing right. */ export interface Entitlement { productIds: string[]; } /** Map of entitlement id → definition. Pass via `OneSubServerConfig.entitlements`. */ export type EntitlementsConfig = Record; /** Result of evaluating a single entitlement for a userId. */ export interface EntitlementStatus { /** True when at least one matching active subscription or non-consumable purchase exists. */ active: boolean; /** * Where the entitlement was sourced from when active: * - 'subscription' — an active SubscriptionInfo matched * - 'purchase' — a non-consumable PurchaseInfo matched * - null when not active */ source: 'subscription' | 'purchase' | null; /** The matched productId (only present when active). */ productId?: string; /** Subscription expiry (only present when source === 'subscription'). */ expiresAt?: string; } /** Response from `GET /onesub/entitlement?userId=&id=premium`. */ export interface EntitlementResponse extends EntitlementStatus { /** The entitlement id queried, echoed back for client-side caching. */ id: string; /** Human-readable error. For programmatic handling use `errorCode`. */ error?: string; /** Machine-readable canonical error code. */ errorCode?: OneSubErrorCode; } /** * Response from `GET /onesub/entitlements?userId=` — every configured * entitlement evaluated for the user in one round-trip. */ export interface EntitlementsResponse { entitlements: Record; error?: string; errorCode?: OneSubErrorCode; } /** Purchase type */ export type PurchaseType = 'consumable' | 'non_consumable' | 'subscription'; /** One-time purchase info (consumable or non-consumable) */ export interface PurchaseInfo { userId: string; productId: string; platform: Platform; type: PurchaseType; transactionId: string; purchasedAt: string; quantity: number; } /** Purchase validation request */ export interface ValidatePurchaseRequest { platform: Platform; receipt: string; userId: string; productId: string; type: PurchaseType; /** Which app this receipt belongs to on a multi-app server. See `OneSubConfig.appId`. */ appId?: string; } /** Purchase validation response */ export interface ValidatePurchaseResponse { valid: boolean; purchase: PurchaseInfo | null; /** Human-readable error. For programmatic handling use `errorCode`. */ error?: string; /** Machine-readable canonical error code. */ errorCode?: OneSubErrorCode; /** * Present on `valid: true` only: * - 'new' — freshly inserted (first time this transactionId seen) * - 'restored' — transactionId already existed (idempotent or reassigned) * use this to show "복원됨" instead of "구매 완료". */ action?: 'new' | 'restored'; } /** Purchase status response */ export interface PurchaseStatusResponse { purchases: PurchaseInfo[]; error?: string; errorCode?: OneSubErrorCode; } /** * Snapshot of currently-entitled users at the moment of the request. * Aggregates active subscriptions + non-consumable purchases. */ export interface MetricsActiveResponse { /** Total entitled users (active subs + grace_period subs + non-consumable owners). */ total: number; /** Active + grace_period subscriptions only (no purchases). */ activeSubscriptions: number; /** grace_period subset of activeSubscriptions — the "at risk" cohort. */ gracePeriodSubscriptions: number; /** Non-consumable purchase rows (lifetime products). */ nonConsumablePurchases: number; /** Subscription product distribution. Counted from activeSubscriptions only. */ byProduct: Record; /** * Non-consumable purchase product distribution. Separate from `byProduct` * (which is subs-only) so the dashboard can render two distinct panels for * lifetime products vs subscriptions. Hosts that don't sell non-consumables * see this as an empty object. */ byProductPurchases: Record; /** 'apple' | 'google' counts across both subs and purchases. */ byPlatform: Record; } /** * Filter options for `GET /onesub/admin/subscriptions`. All optional — * unspecified fields are ignored. `limit` defaults to 50 (max 200). * * Used by the dashboard's subscriptions list page; can be called directly by * any admin tool that needs to enumerate or page through subscription records. */ export interface ListSubscriptionsQuery { userId?: string; status?: SubscriptionStatus; productId?: string; platform?: Platform; limit?: number; offset?: number; } /** Response from `GET /onesub/admin/subscriptions`. */ export interface ListSubscriptionsResponse { items: SubscriptionInfo[]; /** Total matches before limit/offset — used by the dashboard for pagination. */ total: number; limit: number; offset: number; } /** * Customer profile bundle — every record onesub knows about for a single * `userId`. Returned by `GET /onesub/admin/customers/:userId`. Used by the * dashboard's customer detail page so an operator can see subscriptions, * purchases, and entitlements in one round-trip when handling support tickets. * * `entitlements` is `undefined` when the server has no `entitlements` config * (the routes that depend on it return 503 ENTITLEMENTS_NOT_CONFIGURED). * Hosts using only non-consumables / no entitlement abstraction will see this * field omitted and should hide the entitlement panel. */ export interface CustomerProfileResponse { userId: string; subscriptions: SubscriptionInfo[]; purchases: PurchaseInfo[]; entitlements?: Record; } /** * One day in a `MetricsCountResponse.buckets` series. Date is ISO 8601 * `YYYY-MM-DD` interpreted as UTC midnight; count is the number of records * whose anchor timestamp (purchasedAt for started, expiresAt for expired) * fell within that calendar day. */ export interface MetricsBucket { /** UTC calendar day in `YYYY-MM-DD` form. */ date: string; count: number; } /** * Aggregation granularity for metrics endpoints. `'none'` (default) returns * window totals only; `'day'` additionally fills in `buckets` with one entry * per UTC day in the window (zero-filled). */ export type MetricsGroupBy = 'none' | 'day'; /** * Count of records that started or ended within the given window. * Used for cohort / churn analysis. */ export interface MetricsCountResponse { /** ISO range echoed back for client-side caching. */ from: string; to: string; /** Total matching records in the range. */ total: number; byProduct: Record; byPlatform: Record; /** * Daily breakdown — only present when the request was made with * `?groupBy=day`. One entry per UTC day in the window, zero-filled. Sorted * ascending by date. Used by the dashboard's growth chart. */ buckets?: MetricsBucket[]; } /** SDK config (client-side) */ export interface OneSubConfig { serverUrl: string; productId: string; /** Apple product ID (defaults to productId) */ appleProductId?: string; /** Google product ID (defaults to productId) */ googleProductId?: string; /** * Which app this client belongs to on a multi-app server (see * `OneSubServerConfig.apps`). Sent with every validate request. The server * matches it against an app's `id`, Apple bundleId, or Google packageName. * * Required for a non-default app on Android: a Google purchase token does * not name its package, so without `appId` the server falls back to the * default app's credentials and rejects the receipt. Optional (but harmless * and slightly faster) on iOS, where the receipt carries its own bundleId. */ appId?: string; /** * One-time product IDs that are CONSUMABLE. Declare every consumable you sell. * * A store transaction does not say whether its product is consumable — only * `subs` vs `inapp` — so the SDK normally learns it from the in-flight * `purchaseProduct(id, 'consumable')` call. An ORPHAN REPLAY has no in-flight * entry: the app died between payment and validation, and the store redelivers * the transaction at next launch. Without this list the SDK must guess, and it * guesses `non_consumable`, which is wrong twice over: * * - the server records `type: 'non_consumable'`, so host code that reconciles * consumable grants by type never sees the purchase — paid, never granted; * - `finishTransaction` acknowledges instead of consuming, so on Android the * SKU stays owned and the user can never buy it again. * * Both are permanent and silent. Listing your consumable IDs here makes the * replay path resolve the same type the original call would have. * * Subscriptions do not belong here; they are detected from the transaction. */ consumableProductIds?: string[]; /** * Mock mode — when true, purchase/subscribe/restore return synthetic * success responses without calling react-native-iap or the server. * Useful for local UI development in Expo Go or simulators without * configured store credentials. NEVER enable in production builds. */ mockMode?: boolean; /** * When true, the SDK emits verbose `[onesub]` traces at every step of the * purchase lifecycle: IAP connection, listener events with productId and * transactionId, in-flight matches, server validations, finishTransaction * calls, and drain-window transitions. Recommended while debugging an * integration; leave unset (falsy) in production. */ debug?: boolean; /** * Structured logger for SDK logs. If omitted, logs go to `console`. Any * object with `{ info, warn, error }` works (`pino`, `winston`, `console`, * or a custom sink). `debug` traces always route through the same logger. */ logger?: OneSubLogger; } /** Paywall config */ export interface PaywallConfig { title: string; subtitle?: string; features: string[]; price: string; ctaText: string; /** Restore button text */ restoreText?: string; } //# sourceMappingURL=types.d.ts.map