fix(capacitor): accept partial pending subscription changes

This commit is contained in:
RainbowBird
2026-10-17 18:22:40 +08:00
parent 723a4e7d06
commit 7fcbbd83da
6 changed files with 42 additions and 6 deletions
@@ -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`.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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.
@@ -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() })),
@@ -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.