diff --git a/packages/stage-ui/src/composables/use-subscription.ts b/packages/stage-ui/src/composables/use-subscription.ts index f04a7c5a1a..446e63d55e 100644 --- a/packages/stage-ui/src/composables/use-subscription.ts +++ b/packages/stage-ui/src/composables/use-subscription.ts @@ -351,7 +351,7 @@ export function useSubscription(options: { throw new Error(t('settings.pages.capacitor.checkout.error')) // NOTICE: - // `productChangeInfo` makes the checkout replace the subscription instead of adding a second one. + // `productChangeInfo` requests a change. Without a configured path, the SDK creates a new subscription. // The SDK marks the field internal while product changes are in beta, so its type omits it. // https://www.revenuecat.com/docs/web/product-changes // Remove this local type when `PurchaseParams` declares `productChangeInfo`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3dbefc189..0536f5faeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,7 +409,7 @@ catalogs: specifier: ^0.1.7 version: 0.1.7 '@revenuecat/purchases-js': - specifier: ^1.70.0 + specifier: 1.70.0 version: 1.70.0 '@rive-app/canvas-lite': specifier: ^2.40.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fdb8ccdf66..5a8275eca0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -184,7 +184,7 @@ catalog: '@proj-airi/unocss-preset-chromatic': ^1.1.4 '@proj-airi/unplugin-fetch': ^0.2.3 '@proj-airi/unplugin-live2d-sdk': ^0.1.7 - '@revenuecat/purchases-js': ^1.70.0 + '@revenuecat/purchases-js': 1.70.0 '@rive-app/canvas-lite': ^2.40.1 '@sherpaw/asr': 0.3.0 '@sherpaw/kws': 0.3.0 diff --git a/server/apps/api/src/services/adapters/revenuecat-subscriber.test.ts b/server/apps/api/src/services/adapters/revenuecat-subscriber.test.ts index 8e5a2f85fc..7de84724e7 100644 --- a/server/apps/api/src/services/adapters/revenuecat-subscriber.test.ts +++ b/server/apps/api/src/services/adapters/revenuecat-subscriber.test.ts @@ -162,6 +162,39 @@ describe('revenuecat subscriber client', () => { expect((await customer(renewed))[0]!.pendingChange).toBeNull() }) + // https://github.com/moeru-ai/airi/pull/3003#discussion_r4302328417 + // ROOT CAUSE: + // RevenueCat omits unchanged products from pending_changes. + // Requiring product rejected early and failed renewals with 502. + // The nested product is now optional and still accepts null. + it.each([ + { + scenario: 'early renewal', + autoRenewalStatus: 'has_already_renewed', + pendingChanges: { current_period_starts_at: periodEnd, current_period_ends_at: Date.parse('2026-12-01T00:00:00Z') }, + }, + { + scenario: 'failed upcoming renewal', + autoRenewalStatus: 'will_renew', + pendingChanges: { status: 'in_billing_retry', pending_payment: true, gives_access: false }, + }, + ])('accepts $scenario without a pending product (Issue #3003)', async ({ autoRenewalStatus, pendingChanges }) => { + const { client } = revenuecat({ + subscriptions: [{ ...goSubscription, auto_renewal_status: autoRenewalStatus, pending_changes: pendingChanges }], + activeEntitlements: [{ entitlement_id: 'entl_go', expires_at: periodEnd }], + }) + + expect(await client.fetchEntitlements('user-1')).toEqual([{ + entitlementId: 'airi_go', + productId: 'rc_go_monthly', + purchasedAt: new Date(periodStart), + accessUntil: new Date(periodEnd), + pendingChange: null, + }]) + expect(await client.hasRenewingSubscription('user-1')).toBe(true) + expect(await client.grantProductChange('user-1')).toEqual({ subscriptionId: 'sub_go', subscriberToken: 'subscriber-token' }) + }) + // ROOT CAUSE: // // The server granted subscriptions of both environments. diff --git a/server/apps/api/src/services/adapters/revenuecat-subscriber.ts b/server/apps/api/src/services/adapters/revenuecat-subscriber.ts index 0c1a463a0c..1ae5d0e23b 100644 --- a/server/apps/api/src/services/adapters/revenuecat-subscriber.ts +++ b/server/apps/api/src/services/adapters/revenuecat-subscriber.ts @@ -33,10 +33,10 @@ const subscriptionSchema = object({ current_period_ends_at: nullable(number()), /** * The state at the next renewal. `product` differs from `product_id` when a downgrade is scheduled. - * It also appears with the same product after an early renewal, or before a failed renewal. + * Early renewals and failed upcoming renewals can omit `product` when it stays the same. */ pending_changes: optional(nullable(object({ - product: nullable(object({ id: string() })), + product: optional(nullable(object({ id: string() }))), }))), entitlements: object({ items: array(object({ id: string(), lookup_key: string() })), diff --git a/server/docs/ai/adr/2026-10-04-subscription-credits.md b/server/docs/ai/adr/2026-10-04-subscription-credits.md index 1d306bf53b..c0d0638a4e 100644 --- a/server/docs/ai/adr/2026-10-04-subscription-credits.md +++ b/server/docs/ai/adr/2026-10-04-subscription-credits.md @@ -141,9 +141,10 @@ Purchased Flux capacity and `GET /flux/history` read only the wallet pool. ### Product change -The Web SDK replaces a web subscription with another Capacitor through `purchase({ rcPackage, productChangeInfo })`. +The Web SDK requests a subscription change through `purchase({ rcPackage, productChangeInfo })`. See the [RevenueCat Web SDK product changes](https://www.revenuecat.com/docs/web/product-changes). The SDK marks `productChangeInfo` internal during the beta, so the client declares the field locally with a `NOTICE`. +The workspace pins the SDK to `1.70.0` because internal fields have no semver guarantee. `POST /api/v1/revenuecat/product-change` gives the signed-in user a `subscriptionId` and a `subscriberToken`. The server picks the latest subscription with access that `rc_billing` or `stripe` bills. @@ -155,6 +156,8 @@ The route allows 10 calls for each user in one minute. RevenueCat starts an upgrade at once and charges the prorated difference. It starts a downgrade at the next renewal. The dashboard defines the allowed changes under **Product Catalog > Products > Subscription changes**. +Without a configured path, the SDK opens a regular purchase and can create a second subscription. +The client does not check these paths before checkout. PR #3003 remains a draft until this duplicate-charge risk is resolved. An upgrade sends `PRODUCT_CHANGE` and `RENEWAL`. The webhook then reconciles the new period and quota. The client reports a downgrade as scheduled with the renewal date. `POST /api/v1/revenuecat/sync` returns `pendingChange` from `pending_changes.product` of the subscription.