feat(api): grant the Capacitor of a promotional RevenueCat entitlement

An operator gives Go or Plus by granting the entitlement in RevenueCat.
The grant has no product, so the reconciliation skipped it and the user
got the entitlement without Capacitor Flux. A promotional entitlement now
uses the one quota that the configured products of that entitlement share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RainbowBird
2026-10-29 17:14:55 +07:00
co-authored by Claude Opus 5.5
parent e8dc7b84ca
commit ab09d82114
7 changed files with 169 additions and 15 deletions
+1
View File
@@ -203,6 +203,7 @@ and `REVENUECAT_PROJECT_ID`, and calls
A known period keeps its spent Flux and takes the reported end time.
No active Capacitor expires the bucket now.
Product-to-Capacitor mapping lives in ConfigKV `REVENUECAT_CAPACITORS`.
A promotional subscription, which an operator grants in RevenueCat, has no product. It uses the one quota of the products of its entitlement.
The server grants only the subscriptions of `REVENUECAT_ENVIRONMENT`, which is `production` by default.
`REVENUECAT_SANDBOX_USER_IDS` lists the test accounts whose sandbox subscriptions production also grants.
A missed webhook delays the grant until the next webhook for that
@@ -103,14 +103,19 @@ describe('revenuecat subscriber client', () => {
})
// https://github.com/moeru-ai/airi/pull/2910#discussion_r4240813205
it('grants nothing for a promotional subscription, which has no product', async () => {
const promotional = { ...goSubscription, product_id: null }
it('returns a promotional subscription without a product and reads no product', async () => {
const promotional = { ...goSubscription, product_id: null, auto_renewal_status: 'will_not_renew' }
const { client, paths } = revenuecat({
subscriptions: [promotional],
activeEntitlements: [{ entitlement_id: 'entl_go', expires_at: periodEnd }],
})
expect(await client.fetchEntitlements('user-1')).toEqual([])
expect(await client.fetchEntitlements('user-1')).toEqual([{
entitlementId: 'airi_go',
productId: null,
purchasedAt: new Date(periodStart),
accessUntil: new Date(periodEnd),
}])
expect(paths.filter(path => path.startsWith('/products'))).toEqual([])
})
@@ -53,8 +53,11 @@ type Subscription = InferOutput<typeof subscriptionSchema>
export interface SubscriberEntitlement {
/** The entitlement lookup key, for example `airi_go`. */
entitlementId: string
/** The store product identifier of the subscription that grants it. */
productId: string
/**
* The store product identifier of the subscription that grants it.
* Null for a promotional subscription, which an operator grants for one entitlement without a product.
*/
productId: string | null
/** Start of the current billing period. */
purchasedAt: Date
/** The end of access, grace period included. Null means no expiry. */
@@ -189,18 +192,22 @@ export function createRevenuecatSubscriberClient(input: {
const seen = new Set<string>()
for (const subscription of granting) {
const granted = subscription.entitlements.items.filter(item => accessUntil.has(item.id) && !seen.has(item.id))
// A promotional subscription has no product. No Capacitor is mapped to it, so it grants none.
if (granted.length === 0 || subscription.product_id === null)
if (granted.length === 0)
continue
const product = safeParse(productSchema, await read(projectUrl(`/products/${encodeURIComponent(subscription.product_id)}`)))
if (!product.success)
throw createBadGatewayError('RevenueCat subscriber response is invalid')
// A promotional subscription has no product. The entitlement alone names the Capacitor.
let productId: string | null = null
if (subscription.product_id !== null) {
const product = safeParse(productSchema, await read(projectUrl(`/products/${encodeURIComponent(subscription.product_id)}`)))
if (!product.success)
throw createBadGatewayError('RevenueCat subscriber response is invalid')
productId = product.output.store_identifier
}
for (const item of granted) {
seen.add(item.id)
const expiresAt = sharedWithExcluded.has(item.id) ? subscription.current_period_ends_at : accessUntil.get(item.id)!
entitlements.push({
entitlementId: item.lookup_key,
productId: product.output.store_identifier,
productId,
purchasedAt: new Date(subscription.current_period_starts_at),
accessUntil: expiresAt === null ? null : new Date(expiresAt),
})
@@ -102,6 +102,37 @@ describe('revenuecat subscription sync', () => {
expect(synced).toEqual([null])
})
it('syncs a promotional entitlement with the quota of its products', async () => {
const promotional = { ...goEntitlement, entitlementId: 'airi_plus', productId: null }
const { synced } = await reconcile([promotional])
expect(synced).toEqual([{ quota: 5000, periodStart: promotional.purchasedAt, expiresAt: future }])
})
it('selects a later promotional entitlement over a paid capacitor', async () => {
const { synced } = await reconcile([
goEntitlement,
{ entitlementId: 'airi_plus', productId: null, purchasedAt: new Date('2026-10-20T00:00:00.000Z'), accessUntil: future },
])
expect(synced).toMatchObject([{ quota: 5000 }])
})
it('syncs one quota when two products of the promotional entitlement share it', async () => {
const configKV = createConfigKV({ ...starterCapacitors, rc_go_web_monthly: { entitlementId: 'airi_go', quota: 2000 } })
const { synced } = await reconcile([{ ...goEntitlement, productId: null }], configKV)
expect(synced).toMatchObject([{ quota: 2000 }])
})
it('syncs no period when the products of the promotional entitlement have different quotas', async () => {
const configKV = createConfigKV({ ...starterCapacitors, rc_go_yearly: { entitlementId: 'airi_go', quota: 24000 } })
const { synced } = await reconcile([{ ...goEntitlement, productId: null }], configKV)
expect(synced).toEqual([null])
})
it('syncs no period when no product grants the promotional entitlement', async () => {
const { synced } = await reconcile([{ ...goEntitlement, entitlementId: 'other', productId: null }])
expect(synced).toEqual([null])
})
it('leaves the wallet as it is when the capacitor map is empty', async () => {
const { synced, fetchEntitlements } = await reconcile([goEntitlement], createConfigKV({}))
expect(synced).toEqual([])
@@ -1,6 +1,6 @@
import type { BillingService, CapacitorPeriod } from '../domain/billing/billing-service'
import type { ConfigDefinitions, ConfigKVService } from './config-kv'
import type { RevenuecatSubscriberClient } from './revenuecat-subscriber'
import type { RevenuecatSubscriberClient, SubscriberEntitlement } from './revenuecat-subscriber'
import { useLogger } from '@guiiai/logg'
@@ -13,6 +13,26 @@ export interface CapacitorCatalog {
resetInterval: NonNullable<ConfigDefinitions['CAPACITOR_RESET_INTERVAL']> | null
}
/**
* The Capacitor quota of one entitlement, or null when it grants no Capacitor.
* A store subscription uses the quota of its product, if the product grants this entitlement.
* A promotional subscription has no product. It uses the one quota that the products of its entitlement share.
* When those products have different quotas, the grant names no Capacitor, so it grants none.
*/
export function capacitorQuota(
capacitors: NonNullable<ConfigDefinitions['REVENUECAT_CAPACITORS']>,
entitlement: Pick<SubscriberEntitlement, 'entitlementId' | 'productId'>,
): number | null {
if (entitlement.productId !== null) {
const capacitor = capacitors[entitlement.productId]
return capacitor?.entitlementId === entitlement.entitlementId ? capacitor.quota : null
}
const quotas = new Set(Object.values(capacitors)
.filter(capacitor => capacitor.entitlementId === entitlement.entitlementId)
.map(capacitor => capacitor.quota))
return quotas.size === 1 ? [...quotas][0]! : null
}
/**
* Copies the capacitor period that RevenueCat reports into the capacitor bucket of the Flux wallet.
* It reads the customer's current entitlements. It does not read webhook
@@ -36,14 +56,17 @@ export function createRevenuecatSubscriptionSync(
// The latest purchase wins when two capacitors are active, so an upgrade
// replaces the old capacitor. Capacitors without an expiry are not sold.
const [current] = (await subscriber.fetchEntitlements(userId))
.filter(item => capacitors[item.productId]?.entitlementId === item.entitlementId)
.filter(item => item.accessUntil != null && item.accessUntil > now)
.flatMap((item) => {
const quota = capacitorQuota(capacitors, item)
return quota === null ? [] : [{ ...item, quota }]
})
.sort((left, right) => right.purchasedAt.getTime() - left.purchasedAt.getTime())
if (!current)
return null
return {
quota: capacitors[current.productId]!.quota,
quota: current.quota,
periodStart: current.purchasedAt,
expiresAt: current.accessUntil!,
}
@@ -48,6 +48,7 @@ The client calls it after a purchase and when the Capacitor page opens.
Thus a late webhook does not reject the first requests of a new subscriber, and a lost webhook is repaired on the next page visit.
The route allows 10 calls for each user in one minute, because each call reads RevenueCat.
The latest purchase wins when two mapped Capacitors are active.
A promotional subscription has no product. It uses the quota of its entitlement, as [Promotional Capacitors](./2026-10-29-promotional-capacitors.md) describes.
`syncCapacitor` stores the quota, the expiry, and the period start. It does not grant Flux directly.
@@ -147,7 +148,6 @@ The rule also covers App Store and Google Play subscriptions, which the server c
A subscription can still charge unless its `status` is `expired` or `incomplete`, or its `auto_renewal_status` is `will_not_renew`.
For the status `unknown`, the renewal status decides.
A promotional subscription has no product, so it grants no Capacitor.
Thus a cancelled subscription does not refuse. It keeps its access until the period ends, and the deletion clears the wallet.
A subscription in `in_billing_retry` refuses. It gives no access, but a recovered payment charges the customer.
@@ -0,0 +1,87 @@
# Promotional Capacitors
Status: accepted. The requester confirmed on 2026-10-29 that an operator gives a Go or Plus subscription through a RevenueCat granted entitlement.
## Context
An operator gives a subscription by granting an entitlement in RevenueCat, for example `airi_go` or `airi_plus`.
The grant has an end date. RevenueCat lists it as a subscription with `product_id: null`, and sends a production `NON_RENEWING_PURCHASE` webhook with the store `PROMOTIONAL`.
`REVENUECAT_CAPACITORS` maps a store product to an entitlement and a quota.
The reconciliation looked the quota up by product, so a promotional subscription granted no Capacitor.
[Subscription credits](./2026-10-04-subscription-credits.md) recorded that rule when the API v2 schema started to accept a null product.
The client still unlocked the entitlement, so the user saw Go or Plus without Capacitor Flux.
## Decision
A promotional subscription grants the Capacitor of its entitlement.
- `fetchEntitlements` returns a promotional entitlement with `productId: null`. It reads no product.
- The quota is the one quota that the `REVENUECAT_CAPACITORS` products of that entitlement share.
- When those products have different quotas, or no product grants the entitlement, the grant names no Capacitor and grants none.
- The period starts at `current_period_starts_at` and ends with the active entitlement, as for a store subscription.
- A grant without an end date grants no Capacitor, because a Capacitor without an expiry is not sold.
- The latest period start wins when two Capacitors are active. A grant after a paid subscription replaces its Capacitor until the grant ends.
The webhook already reconciles every event type except `TEST`, so the grant webhook needs no change.
## Scope and non-goals
The change is in `revenuecat-subscriber.ts` and `revenuecat-subscriptions.ts`. It does not change the webhook, the wallet, the database schema, or ConfigKV.
Non-goals:
- An API route that grants or revokes. The admin API calls RevenueCat.
- A quota per grant. RevenueCat stores only the entitlement and the end date.
- A change to the account deletion check. It reads `status` and `auto_renewal_status` for every subscription, promotional ones included.
## Dependencies and affected files
```mermaid
flowchart LR
Admin[Admin API or RevenueCat dashboard] --> RC[RevenueCat granted entitlement]
RC --> Webhook[RevenueCat webhook]
Webhook --> Sync[revenuecat-subscriptions.ts]
SyncRoute["POST /revenuecat/sync"] --> Sync
Sync --> Reader[revenuecat-subscriber.ts]
Sync --> Config[ConfigKV REVENUECAT_CAPACITORS]
Reader --> RCAPI[RevenueCat API v2]
Sync --> Billing[BillingService.syncCapacitor]
```
```text
server/
apps/api/README.md
apps/api/src/services/adapters/revenuecat-subscriber.ts
apps/api/src/services/adapters/revenuecat-subscriber.test.ts
apps/api/src/services/adapters/revenuecat-subscriptions.ts
apps/api/src/services/adapters/revenuecat-subscriptions.test.ts
docs/ai/adr/2026-10-04-subscription-credits.md
docs/ai/adr/2026-10-29-promotional-capacitors.md
```
## Sequence
```mermaid
sequenceDiagram
participant Operator
participant RC as RevenueCat
participant API
participant DB as Postgres
Operator->>RC: Grant airi_plus until a date
RC->>API: NON_RENEWING_PURCHASE webhook
API->>RC: GET /v2 subscriptions and active entitlements
RC-->>API: Subscription with product_id null and entitlement airi_plus
API->>API: Find the one quota of the airi_plus products
alt One quota
API->>DB: syncCapacitor with the quota and the grant period
else Different quotas or no product
API->>DB: syncCapacitor with no Capacitor
end
```
## Test plan
- `revenuecat-subscriber.test.ts`: a promotional subscription returns its entitlement with a null product and reads no product.
- `revenuecat-subscriptions.test.ts`: a promotional entitlement syncs the quota of its products; a later grant replaces a paid Capacitor; two products with one quota sync it; products with different quotas sync nothing; an entitlement without a product syncs nothing.
- Manual: grant a sandbox test account an entitlement in RevenueCat and read its wallet through the admin dashboard.