mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
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:
co-authored by
Claude Opus 5.5
parent
e8dc7b84ca
commit
ab09d82114
@@ -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.
|
||||
Reference in New Issue
Block a user