diff --git a/packages/bsky/src/api/app/bsky/purchase/getActiveSubscriptions.ts b/packages/bsky/src/api/app/bsky/purchase/getActiveSubscriptions.ts new file mode 100644 index 00000000000..035db74cec4 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/purchase/getActiveSubscriptions.ts @@ -0,0 +1,32 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { Subscription as ProtoSubscription } from '../../../../proto/bsync_pb' +import { Subscription as XrpcSubscription } from '../../../../lexicon/types/app/bsky/purchase/getActiveSubscriptions' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.purchase.getActiveSubscriptions({ + auth: ctx.authVerifier.standard, + handler: async ({ auth }) => { + const viewer = auth.credentials.iss + + const { subscriptions } = await ctx.bsyncClient.getActiveSubscriptions({ + actorDid: viewer, + }) + return { + encoding: 'application/json', + body: { + subscriptions: subscriptions.map(subscriptionProtoToXrpc), + }, + } + }, + }) +} + +const subscriptionProtoToXrpc = ( + subscription: ProtoSubscription, +): XrpcSubscription => ({ + ...subscription, + periodEndsAt: subscription.periodEndsAt?.toDate().toISOString(), + periodStartsAt: subscription.periodStartsAt?.toDate().toISOString(), + purchasedAt: subscription.purchasedAt?.toDate().toISOString(), +}) diff --git a/packages/bsky/src/api/app/bsky/purchase/getFeatures.ts b/packages/bsky/src/api/app/bsky/purchase/getFeatures.ts new file mode 100644 index 00000000000..d5799bafe9f --- /dev/null +++ b/packages/bsky/src/api/app/bsky/purchase/getFeatures.ts @@ -0,0 +1,50 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { Features } from '@atproto/api/dist/client/types/app/bsky/purchase/getFeatures' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.purchase.getFeatures({ + auth: ctx.authVerifier.standard, + handler: async ({ auth }) => { + const viewer = auth.credentials.iss + + const features = await getFeaturesForViewerEntitlements(viewer, ctx) + + return { + encoding: 'application/json', + body: { + features, + }, + } + }, + }) +} + +const defaultFeatures: Features = { + customProfileColor: false, +} + +const coreEntitlementFeatures: Features = { + customProfileColor: true, +} + +const getFeaturesForViewerEntitlements = async ( + viewerDid: string, + ctx: AppContext, +): Promise => { + const { purchaseEntitlements } = await ctx.dataplane.getPurchaseEntitlements({ + dids: [viewerDid], + }) + + if (purchaseEntitlements?.length === 0) { + return defaultFeatures + } + + const { entitlements } = purchaseEntitlements[0] + + if (entitlements.includes('core')) { + return coreEntitlementFeatures + } else { + return defaultFeatures + } +} diff --git a/packages/bsky/src/api/app/bsky/purchase/getSubscriptionGroup.ts b/packages/bsky/src/api/app/bsky/purchase/getSubscriptionGroup.ts new file mode 100644 index 00000000000..f472ba7f7dc --- /dev/null +++ b/packages/bsky/src/api/app/bsky/purchase/getSubscriptionGroup.ts @@ -0,0 +1,27 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.purchase.getSubscriptionGroup({ + handler: async ({ params }) => { + const { group, platform } = params + + const { offerings } = await ctx.bsyncClient.getSubscriptionGroup({ + group, + platform, + }) + + return { + encoding: 'application/json', + body: { + group, + offerings: offerings.map(({ id, product }) => ({ + id, + platform, + product, + })), + }, + } + }, + }) +} diff --git a/packages/bsky/src/api/app/bsky/purchase/refreshCache.ts b/packages/bsky/src/api/app/bsky/purchase/refreshCache.ts new file mode 100644 index 00000000000..ff2804a2b82 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/purchase/refreshCache.ts @@ -0,0 +1,38 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { AuthRequiredError } from '@atproto/xrpc-server' +import { RoleOutput, StandardOutput } from '../../../../auth-verifier' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.purchase.refreshCache({ + auth: ctx.authVerifier.standardOrRole, + handler: async ({ auth, input }) => { + const { did } = input.body + validateCredentials(did, auth) + + await ctx.bsyncClient.addPurchaseOperation({ + actorDid: did, + }) + + return { + encoding: 'application/json', + body: {}, + } + }, + }) +} + +const validateCredentials = ( + did: string, + auth: StandardOutput | RoleOutput, +) => { + // admins can refresh any user's subscription cache + if (auth.credentials.type === 'role') { + return + } + + // users can only refresh their own subscription cache + if (auth.credentials.iss !== did) { + throw new AuthRequiredError('bad issuer') + } +} diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index e869c908280..d38b9d3558c 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -47,6 +47,10 @@ import listNotifications from './app/bsky/notification/listNotifications' import updateSeen from './app/bsky/notification/updateSeen' import putPreferences from './app/bsky/notification/putPreferences' import registerPush from './app/bsky/notification/registerPush' +import getActiveSubscriptions from './app/bsky/purchase/getActiveSubscriptions' +import getFeatures from './app/bsky/purchase/getFeatures' +import getSubscriptionGroup from './app/bsky/purchase/getSubscriptionGroup' +import refreshCache from './app/bsky/purchase/refreshCache' import getConfig from './app/bsky/unspecced/getConfig' import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators' import getTaggedSuggestions from './app/bsky/unspecced/getTaggedSuggestions' @@ -113,6 +117,10 @@ export default function (server: Server, ctx: AppContext) { updateSeen(server, ctx) putPreferences(server, ctx) registerPush(server, ctx) + getActiveSubscriptions(server, ctx) + getFeatures(server, ctx) + getSubscriptionGroup(server, ctx) + refreshCache(server, ctx) getConfig(server, ctx) getPopularFeedGenerators(server, ctx) getTaggedSuggestions(server, ctx) diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index d1bc06cc8d0..577dc762615 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -35,14 +35,14 @@ export enum RoleStatus { Missing, } -type NullOutput = { +export type NullOutput = { credentials: { type: 'none' iss: null } } -type StandardOutput = { +export type StandardOutput = { credentials: { type: 'standard' aud: string @@ -50,14 +50,14 @@ type StandardOutput = { } } -type RoleOutput = { +export type RoleOutput = { credentials: { type: 'role' admin: boolean } } -type ModServiceOutput = { +export type ModServiceOutput = { credentials: { type: 'mod_service' aud: string diff --git a/packages/bsky/src/data-plane/bsync/index.ts b/packages/bsky/src/data-plane/bsync/index.ts index a8ad57467bc..67e6959f83c 100644 --- a/packages/bsky/src/data-plane/bsync/index.ts +++ b/packages/bsky/src/data-plane/bsync/index.ts @@ -9,6 +9,7 @@ import { Database } from '../server/db' import { Service } from '../../proto/bsync_connect' import { MuteOperation_Type } from '../../proto/bsync_pb' import { ids } from '../../lexicon/lexicons' +import { Timestamp } from '@bufbuild/protobuf' export class MockBsync { constructor(public server: http.Server) {} @@ -138,6 +139,64 @@ const createRoutes = (db: Database) => (router: ConnectRouter) => throw new Error('not implemented') }, + async addPurchaseOperation(req) { + const { actorDid } = req + + // Simulates that a call to the subscription service returns the 'core' entitlement. + const entitlements = ['core'] + + await db.db + .insertInto('purchase') + .values({ + did: actorDid, + entitlements: JSON.stringify(entitlements), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .onConflict((oc) => + oc.column('did').doUpdateSet({ + entitlements: JSON.stringify(entitlements), + updatedAt: new Date().toISOString(), + }), + ) + .execute() + }, + + async getActiveSubscriptions() { + // Simulates that a call to the subscription service returns this subscription. + return { + subscriptions: [ + { + status: 'active', + renewalStatus: 'will_renew', + group: 'core', + platform: 'web', + offering: 'coreMonthly', + periodEndsAt: Timestamp.fromDate(new Date('2025-01-03T18:31:27Z')), + periodStartsAt: Timestamp.fromDate( + new Date('2024-12-03T18:31:27Z'), + ), + purchasedAt: Timestamp.fromDate(new Date('2024-12-03T18:31:27Z')), + }, + ], + } + }, + + async getSubscriptionGroup() { + return { + offerings: [ + { + id: 'coreMonthly', + product: 'bluesky_plus_core_v1_monthly', + }, + { + id: 'coreAnnual', + product: 'bluesky_plus_core_v1_annual', + }, + ], + } + }, + async ping() { return {} }, diff --git a/packages/bsky/src/data-plane/server/db/database-schema.ts b/packages/bsky/src/data-plane/server/db/database-schema.ts index 195b09483e0..bf564d93b6e 100644 --- a/packages/bsky/src/data-plane/server/db/database-schema.ts +++ b/packages/bsky/src/data-plane/server/db/database-schema.ts @@ -37,6 +37,7 @@ import * as blobTakedown from './tables/blob-takedown' import * as labeler from './tables/labeler' import * as starterPack from './tables/starter-pack' import * as quote from './tables/quote' +import * as purchase from './tables/purchase' export type DatabaseSchemaType = duplicateRecord.PartialDB & profile.PartialDB & @@ -75,7 +76,8 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB & labeler.PartialDB & starterPack.PartialDB & taggedSuggestion.PartialDB & - quote.PartialDB + quote.PartialDB & + purchase.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/bsky/src/data-plane/server/db/migrations/20241206T231908523Z-purchase.ts b/packages/bsky/src/data-plane/server/db/migrations/20241206T231908523Z-purchase.ts new file mode 100644 index 00000000000..5f2223763b6 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/migrations/20241206T231908523Z-purchase.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('purchase') + .addColumn('did', 'varchar', (col) => col.primaryKey()) + .addColumn('entitlements', 'jsonb', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('purchase').execute() +} diff --git a/packages/bsky/src/data-plane/server/db/migrations/index.ts b/packages/bsky/src/data-plane/server/db/migrations/index.ts index 5b8684a2a78..fc2f12a5893 100644 --- a/packages/bsky/src/data-plane/server/db/migrations/index.ts +++ b/packages/bsky/src/data-plane/server/db/migrations/index.ts @@ -45,3 +45,4 @@ export * as _20240808T224251220Z from './20240808T224251220Z-post-gate-flags' export * as _20240829T211238293Z from './20240829T211238293Z-simplify-actor-sync' export * as _20240831T134810923Z from './20240831T134810923Z-pinned-posts' export * as _20241114T153108102Z from './20241114T153108102Z-add-starter-packs-name' +export * as _20241206T231908523Z from './20241206T231908523Z-purchase' diff --git a/packages/bsky/src/data-plane/server/db/tables/purchase.ts b/packages/bsky/src/data-plane/server/db/tables/purchase.ts new file mode 100644 index 00000000000..75cfab20d32 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/tables/purchase.ts @@ -0,0 +1,13 @@ +import { ColumnType } from 'kysely' + +export const tableName = 'purchase' + +export interface Purchase { + did: string + // https://github.com/kysely-org/kysely/issues/137 + entitlements: ColumnType + createdAt: string + updatedAt: string +} + +export type PartialDB = { [tableName]: Purchase } diff --git a/packages/bsky/src/data-plane/server/routes/index.ts b/packages/bsky/src/data-plane/server/routes/index.ts index 5504d38f812..95c85b345fb 100644 --- a/packages/bsky/src/data-plane/server/routes/index.ts +++ b/packages/bsky/src/data-plane/server/routes/index.ts @@ -15,6 +15,7 @@ import mutes from './mutes' import notifs from './notifs' import posts from './posts' import profile from './profile' +import purchases from './purchases' import quotes from './quotes' import records from './records' import relationships from './relationships' @@ -43,6 +44,7 @@ export default (db: Database, idResolver: IdResolver) => ...notifs(db), ...posts(db), ...profile(db), + ...purchases(db), ...quotes(db), ...records(db), ...relationships(db), diff --git a/packages/bsky/src/data-plane/server/routes/purchases.ts b/packages/bsky/src/data-plane/server/routes/purchases.ts new file mode 100644 index 00000000000..2ff3228aa76 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/purchases.ts @@ -0,0 +1,33 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { keyBy } from '@atproto/common' +import { Timestamp } from '@bufbuild/protobuf' + +export default (db: Database): Partial> => ({ + async getPurchaseEntitlements(req) { + const { dids } = req + + if (dids.length === 0) { + return { purchaseEntitlements: [] } + } + + const res = await db.db + .selectFrom('purchase') + .select(['did', 'entitlements', 'createdAt']) + .where('did', 'in', dids ?? []) + .execute() + + const byDid = keyBy(res, 'did') + const purchaseEntitlements = res.map((row) => { + const purchase = byDid[row.did] ?? {} + + return { + entitlements: purchase.entitlements ?? [], + createdAt: Timestamp.fromDate(new Date(purchase.createdAt)), + } + }) + + return { purchaseEntitlements } + }, +}) diff --git a/packages/bsky/src/proto/bsync_pb.ts b/packages/bsky/src/proto/bsync_pb.ts index 74c8ed76d75..f859f309e11 100644 --- a/packages/bsky/src/proto/bsync_pb.ts +++ b/packages/bsky/src/proto/bsync_pb.ts @@ -1013,6 +1013,61 @@ export class GetActiveSubscriptionsResponse extends Message { + /** + * @generated from field: string id = 1; + */ + id = '' + + /** + * @generated from field: string product = 2; + */ + product = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsync.SubscriptionOffering' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'id', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'product', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromJsonString(jsonString, options) + } + + static equals( + a: SubscriptionOffering | PlainMessage | undefined, + b: SubscriptionOffering | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SubscriptionOffering, a, b) + } +} + /** * @generated from message bsync.GetSubscriptionGroupRequest */ @@ -1079,9 +1134,9 @@ export class GetSubscriptionGroupRequest extends Message { /** - * @generated from field: repeated string products = 1; + * @generated from field: repeated bsync.SubscriptionOffering offerings = 1; */ - products: string[] = [] + offerings: SubscriptionOffering[] = [] constructor(data?: PartialMessage) { super() @@ -1093,9 +1148,9 @@ export class GetSubscriptionGroupResponse extends Message [ { no: 1, - name: 'products', - kind: 'scalar', - T: 9 /* ScalarType.STRING */, + name: 'offerings', + kind: 'message', + T: SubscriptionOffering, repeated: true, }, ]) diff --git a/packages/bsky/tests/views/purchases.test.ts b/packages/bsky/tests/views/purchases.test.ts new file mode 100644 index 00000000000..7ca1bfb5969 --- /dev/null +++ b/packages/bsky/tests/views/purchases.test.ts @@ -0,0 +1,131 @@ +import { AtpAgent } from '@atproto/api' +import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' +import { ids } from '../../src/lexicon/lexicons' + +describe('purchases', () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + // account dids, for convenience + let alice: string + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_views_purchases', + }) + agent = network.bsky.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + await network.processAll() + + alice = sc.dids.alice + }) + + afterAll(async () => { + await network.close() + }) + + describe('purchase cached data', () => { + it('returns false for features if user has no cached entitlements', async () => { + const { data } = await agent.app.bsky.purchase.getFeatures( + {}, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseGetFeatures, + ), + }, + ) + + expect(data).toStrictEqual({ + features: { + customProfileColor: false, + }, + }) + }) + + it('refreshes the purchase cache and returns true for features if user has cached entitlement', async () => { + await agent.app.bsky.purchase.refreshCache( + { did: alice }, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseRefreshCache, + ), + }, + ) + + const { data } = await agent.app.bsky.purchase.getFeatures( + {}, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseGetFeatures, + ), + }, + ) + + expect(data).toStrictEqual({ + features: { + customProfileColor: true, + }, + }) + }) + + it('returns the active subscriptions for the account', async () => { + const { data } = await agent.app.bsky.purchase.getActiveSubscriptions( + {}, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseGetActiveSubscriptions, + ), + }, + ) + + expect(data).toStrictEqual({ + subscriptions: [ + { + status: 'active', + renewalStatus: 'will_renew', + group: 'core', + platform: 'web', + offering: 'coreMonthly', + periodEndsAt: '2025-01-03T18:31:27.000Z', + periodStartsAt: '2024-12-03T18:31:27.000Z', + purchasedAt: '2024-12-03T18:31:27.000Z', + }, + ], + }) + }) + + it('returns the subscription group for the group ID and platform', async () => { + const { data } = await agent.app.bsky.purchase.getSubscriptionGroup( + { group: 'core', platform: 'ios' }, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseGetSubscriptionGroup, + ), + }, + ) + + expect(data).toStrictEqual({ + group: 'core', + offerings: [ + { + id: 'coreMonthly', + platform: 'ios', + product: 'bluesky_plus_core_v1_monthly', + }, + { + id: 'coreAnnual', + platform: 'ios', + product: 'bluesky_plus_core_v1_annual', + }, + ], + }) + }) + }) +}) diff --git a/packages/bsync/proto/bsync.proto b/packages/bsync/proto/bsync.proto index 621c4f7bfbd..ceb5a60e14c 100644 --- a/packages/bsync/proto/bsync.proto +++ b/packages/bsync/proto/bsync.proto @@ -93,13 +93,18 @@ message GetActiveSubscriptionsResponse { repeated Subscription subscriptions = 1; } +message SubscriptionOffering { + string id = 1; + string product = 2; +} + message GetSubscriptionGroupRequest { string group = 1; string platform = 2; } message GetSubscriptionGroupResponse { - repeated string products = 1; + repeated SubscriptionOffering offerings = 1; } // Ping diff --git a/packages/bsync/src/api/revenueCat.ts b/packages/bsync/src/api/revenueCat.ts index 8a63b4f8a92..e67fb119c97 100644 --- a/packages/bsync/src/api/revenueCat.ts +++ b/packages/bsync/src/api/revenueCat.ts @@ -1,18 +1,18 @@ import express, { RequestHandler } from 'express' import { AppContext } from '..' -import { rcEventBodySchema, RevenueCatClient } from '../purchases' +import { rcEventBodySchema, PurchasesClient } from '../purchases' import { addPurchaseOperation, RcEventBody } from '../purchases' import { isValidDid } from '../routes/util' import { httpLogger as log } from '..' -type AppContextWithRevenueCatClient = AppContext & { - revenueCatClient: RevenueCatClient +type AppContextWithPurchasesClient = AppContext & { + purchasesClient: PurchasesClient } const auth = - (ctx: AppContextWithRevenueCatClient): RequestHandler => + (ctx: AppContextWithPurchasesClient): RequestHandler => (req: express.Request, res: express.Response, next: express.NextFunction) => - ctx.revenueCatClient.isWebhookAuthorizationValid( + ctx.purchasesClient.isRcWebhookAuthorizationValid( req.header('Authorization'), ) ? next() @@ -22,9 +22,9 @@ const auth = }) const webhookHandler = - (ctx: AppContextWithRevenueCatClient): RequestHandler => + (ctx: AppContextWithPurchasesClient): RequestHandler => async (req, res) => { - const { revenueCatClient } = ctx + const { purchasesClient: purchasesClient } = ctx let body: RcEventBody try { @@ -50,8 +50,7 @@ const webhookHandler = } try { - const entitlements = - await revenueCatClient.getEntitlementIdentifiers(actorDid) + const entitlements = await purchasesClient.getEntitlements(actorDid) const id = await addPurchaseOperation(ctx.db, actorDid, entitlements) @@ -67,10 +66,10 @@ const webhookHandler = } } -const assertAppContextWithRevenueCatClient: ( +const assertAppContextWithPurchasesClient: ( ctx: AppContext, -) => asserts ctx is AppContextWithRevenueCatClient = (ctx: AppContext) => { - if (!ctx.revenueCatClient) { +) => asserts ctx is AppContextWithPurchasesClient = (ctx: AppContext) => { + if (!ctx.purchasesClient) { throw new Error( 'RevenueCat webhook was tried to be set up without configuring a RevenueCat client.', ) @@ -78,7 +77,7 @@ const assertAppContextWithRevenueCatClient: ( } export const createRouter = (ctx: AppContext): express.Router => { - assertAppContextWithRevenueCatClient(ctx) + assertAppContextWithPurchasesClient(ctx) const router = express.Router() router.use(auth(ctx)) diff --git a/packages/bsync/src/config.ts b/packages/bsync/src/config.ts index 1cb480331ec..2f5a42c38bf 100644 --- a/packages/bsync/src/config.ts +++ b/packages/bsync/src/config.ts @@ -23,17 +23,38 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { apiKeys: new Set(env.apiKeys), } - let revenueCatCfg: RevenueCatConfig | undefined + let purchasesCfg: PurchasesConfig | undefined if (env.revenueCatV1ApiKey) { - assert(env.revenueCatV1ApiUrl, 'missing revenue cat v1 api url') + assert(env.revenueCatV1ApiUrl, 'missing RevenueCat V1 api url') assert( env.revenueCatWebhookAuthorization, - 'missing revenue cat webhook authorization', + 'missing RevenueCat webhook authorization', ) - revenueCatCfg = { - v1ApiKey: env.revenueCatV1ApiKey, - v1ApiUrl: env.revenueCatV1ApiUrl, - webhookAuthorization: env.revenueCatWebhookAuthorization, + assert( + env.stripePriceIdMonthly, + 'missing Stripe Price ID for monthly subscription', + ) + assert( + env.stripePriceIdAnnual, + 'missing Stripe Product ID for annual subscription', + ) + assert( + env.stripeProductIdMonthly, + 'missing Stripe Product ID for monthly subscription', + ) + assert( + env.stripeProductIdAnnual, + 'missing Stripe Product ID for annual subscription', + ) + + purchasesCfg = { + revenueCatV1ApiKey: env.revenueCatV1ApiKey, + revenueCatV1ApiUrl: env.revenueCatV1ApiUrl, + revenueCatWebhookAuthorization: env.revenueCatWebhookAuthorization, + stripePriceIdMonthly: env.stripePriceIdMonthly, + stripePriceIdAnnual: env.stripePriceIdAnnual, + stripeProductIdMonthly: env.stripeProductIdMonthly, + stripeProductIdAnnual: env.stripeProductIdAnnual, } } @@ -41,7 +62,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { service: serviceCfg, db: dbCfg, auth: authCfg, - revenueCat: revenueCatCfg, + purchases: purchasesCfg, } } @@ -49,7 +70,7 @@ export type ServerConfig = { service: ServiceConfig db: DatabaseConfig auth: AuthConfig - revenueCat?: RevenueCatConfig + purchases?: PurchasesConfig } type ServiceConfig = { @@ -71,10 +92,14 @@ type AuthConfig = { apiKeys: Set } -type RevenueCatConfig = { - v1ApiUrl: string - v1ApiKey: string - webhookAuthorization: string +type PurchasesConfig = { + revenueCatV1ApiKey: string + revenueCatV1ApiUrl: string + revenueCatWebhookAuthorization: string + stripePriceIdMonthly: string + stripePriceIdAnnual: string + stripeProductIdMonthly: string + stripeProductIdAnnual: string } export const readEnv = (): ServerEnvironment => { @@ -92,12 +117,16 @@ export const readEnv = (): ServerEnvironment => { dbMigrate: envBool('BSYNC_DB_MIGRATE'), // secrets apiKeys: envList('BSYNC_API_KEYS'), - // revenue cat + // purchases revenueCatV1ApiKey: envStr('BSYNC_REVENUE_CAT_V1_API_KEY'), revenueCatV1ApiUrl: envStr('BSYNC_REVENUE_CAT_V1_API_URL'), revenueCatWebhookAuthorization: envStr( 'BSYNC_REVENUE_CAT_WEBHOOK_AUTHORIZATION', ), + stripePriceIdMonthly: envStr('BSYNC_STRIPE_PRICE_ID_MONTHLY'), + stripePriceIdAnnual: envStr('BSYNC_STRIPE_PRICE_ID_ANNUAL'), + stripeProductIdMonthly: envStr('BSYNC_STRIPE_PRODUCT_ID_MONTHLY'), + stripeProductIdAnnual: envStr('BSYNC_STRIPE_PRODUCT_ID_ANNUAL'), } } @@ -115,8 +144,12 @@ export type ServerEnvironment = { dbMigrate?: boolean // secrets apiKeys: string[] - // revenue cat - revenueCatV1ApiUrl?: string + // purchases revenueCatV1ApiKey?: string + revenueCatV1ApiUrl?: string revenueCatWebhookAuthorization?: string + stripePriceIdMonthly?: string + stripePriceIdAnnual?: string + stripeProductIdMonthly?: string + stripeProductIdAnnual?: string } diff --git a/packages/bsync/src/context.ts b/packages/bsync/src/context.ts index b5b0e454f64..d1ad52a0876 100644 --- a/packages/bsync/src/context.ts +++ b/packages/bsync/src/context.ts @@ -4,26 +4,26 @@ import Database from './db' import { createMuteOpChannel } from './db/schema/mute_op' import { createNotifOpChannel } from './db/schema/notif_op' import { EventEmitter } from 'stream' -import { RevenueCatClient } from './purchases' import { createPurchaseOpChannel } from './db/schema/purchase_op' +import { PurchasesClient } from './purchases' export type AppContextOptions = { db: Database - revenueCatClient: RevenueCatClient | undefined + purchasesClient: PurchasesClient | undefined cfg: ServerConfig shutdown: AbortSignal } export class AppContext { db: Database - revenueCatClient: RevenueCatClient | undefined + purchasesClient: PurchasesClient | undefined cfg: ServerConfig shutdown: AbortSignal events: TypedEventEmitter constructor(opts: AppContextOptions) { this.db = opts.db - this.revenueCatClient = opts.revenueCatClient + this.purchasesClient = opts.purchasesClient this.cfg = opts.cfg this.shutdown = opts.shutdown this.events = new EventEmitter() as TypedEventEmitter @@ -42,16 +42,27 @@ export class AppContext { poolIdleTimeoutMs: cfg.db.poolIdleTimeoutMs, }) - let revenueCatClient: RevenueCatClient | undefined - if (cfg.revenueCat) { - revenueCatClient = new RevenueCatClient({ - v1ApiKey: cfg.revenueCat.v1ApiKey, - v1ApiUrl: cfg.revenueCat.v1ApiUrl, - webhookAuthorization: cfg.revenueCat.webhookAuthorization, + let purchasesClient: PurchasesClient | undefined + if (cfg.purchases) { + purchasesClient = new PurchasesClient({ + revenueCatV1ApiKey: cfg.purchases.revenueCatV1ApiKey, + revenueCatV1ApiUrl: cfg.purchases.revenueCatV1ApiUrl, + revenueCatWebhookAuthorization: + cfg.purchases.revenueCatWebhookAuthorization, + stripePriceIdMonthly: cfg.purchases.stripePriceIdMonthly, + stripePriceIdAnnual: cfg.purchases.stripePriceIdAnnual, + stripeProductIdMonthly: cfg.purchases.stripeProductIdMonthly, + stripeProductIdAnnual: cfg.purchases.stripeProductIdAnnual, }) } - return new AppContext({ db, revenueCatClient, cfg, shutdown, ...overrides }) + return new AppContext({ + db, + purchasesClient, + cfg, + shutdown, + ...overrides, + }) } } diff --git a/packages/bsync/src/index.ts b/packages/bsync/src/index.ts index cc0c93f6569..4557dba24a2 100644 --- a/packages/bsync/src/index.ts +++ b/packages/bsync/src/index.ts @@ -57,7 +57,7 @@ export class BsyncService { ) app.use(health.createRouter(ctx)) - if (ctx.revenueCatClient) { + if (ctx.purchasesClient) { app.use('/webhooks/revenuecat', revenueCat.createRouter(ctx)) } diff --git a/packages/bsync/src/proto/bsync_pb.ts b/packages/bsync/src/proto/bsync_pb.ts index 74c8ed76d75..f859f309e11 100644 --- a/packages/bsync/src/proto/bsync_pb.ts +++ b/packages/bsync/src/proto/bsync_pb.ts @@ -1013,6 +1013,61 @@ export class GetActiveSubscriptionsResponse extends Message { + /** + * @generated from field: string id = 1; + */ + id = '' + + /** + * @generated from field: string product = 2; + */ + product = '' + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsync.SubscriptionOffering' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'id', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'product', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): SubscriptionOffering { + return new SubscriptionOffering().fromJsonString(jsonString, options) + } + + static equals( + a: SubscriptionOffering | PlainMessage | undefined, + b: SubscriptionOffering | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(SubscriptionOffering, a, b) + } +} + /** * @generated from message bsync.GetSubscriptionGroupRequest */ @@ -1079,9 +1134,9 @@ export class GetSubscriptionGroupRequest extends Message { /** - * @generated from field: repeated string products = 1; + * @generated from field: repeated bsync.SubscriptionOffering offerings = 1; */ - products: string[] = [] + offerings: SubscriptionOffering[] = [] constructor(data?: PartialMessage) { super() @@ -1093,9 +1148,9 @@ export class GetSubscriptionGroupResponse extends Message [ { no: 1, - name: 'products', - kind: 'scalar', - T: 9 /* ScalarType.STRING */, + name: 'offerings', + kind: 'message', + T: SubscriptionOffering, repeated: true, }, ]) diff --git a/packages/bsync/src/purchases/index.ts b/packages/bsync/src/purchases/index.ts index 4a4b515dc03..c79f6a18f2b 100644 --- a/packages/bsync/src/purchases/index.ts +++ b/packages/bsync/src/purchases/index.ts @@ -1,3 +1,3 @@ export * from './addPurchaseOperation' +export * from './purchasesClient' export * from './revenueCatClient' -export * from './revenueCatTypes' diff --git a/packages/bsync/src/purchases/purchasesClient.ts b/packages/bsync/src/purchases/purchasesClient.ts new file mode 100644 index 00000000000..f2e6fdfc0ce --- /dev/null +++ b/packages/bsync/src/purchases/purchasesClient.ts @@ -0,0 +1,254 @@ +import { Subscription } from '../proto/bsync_pb' +import { RcSubscription, RevenueCatClient } from './revenueCatClient' + +type PlatformId = 'web' | 'ios' | 'android' + +type SubscriptionGroupId = 'core' + +type SubscriptionOfferingId = 'coreMonthly' | 'coreAnnual' + +const assertSubscriptionGroup: ( + group: string, +) => asserts group is SubscriptionGroupId = (group: string) => { + if (['core'].includes(group)) { + return + } + throw new Error(`invalid subscription group: '${group}'`) +} + +const assertPlatform: (platform: string) => asserts platform is PlatformId = ( + platform: string, +) => { + if (['web', 'ios', 'android'].includes(platform)) { + return + } + throw new Error(`invalid platform: '${platform}'`) +} + +export type PurchasesClientOpts = { + revenueCatV1ApiKey: string + revenueCatV1ApiUrl: string + revenueCatWebhookAuthorization: string + stripePriceIdMonthly: string + stripePriceIdAnnual: string + stripeProductIdMonthly: string + stripeProductIdAnnual: string +} + +const mapStoreToPlatform = (store: string): PlatformId | undefined => { + switch (store) { + case 'stripe': + return 'web' + case 'app_store': + return 'ios' + case 'play_store': + return 'android' + default: + return undefined + } +} + +export class PurchasesClient { + private revenueCatClient: RevenueCatClient + private STRIPE_PRODUCTS: { + [subscriptionOfferingId in SubscriptionOfferingId]: string + } + + /** + * All of our purchaseable product IDs for all platforms. + */ + private PRODUCTS: { + [platform in PlatformId]: { + [subscriptionOfferingId in SubscriptionOfferingId]: string + } + } + + /** + * Manual groupings of products into "Subscription Groups", mimicking the way + * Apple does it. + */ + private GROUPS: { + [group in SubscriptionGroupId]: { + [platform in PlatformId]: { + id: SubscriptionOfferingId + product: string + }[] + } + } + + constructor(opts: PurchasesClientOpts) { + this.revenueCatClient = new RevenueCatClient({ + v1ApiKey: opts.revenueCatV1ApiKey, + v1ApiUrl: opts.revenueCatV1ApiUrl, + webhookAuthorization: opts.revenueCatWebhookAuthorization, + }) + + this.STRIPE_PRODUCTS = { + coreMonthly: opts.stripeProductIdMonthly, + coreAnnual: opts.stripeProductIdAnnual, + } + + this.PRODUCTS = { + web: { + coreMonthly: opts.stripePriceIdMonthly, + coreAnnual: opts.stripePriceIdAnnual, + }, + ios: { + coreMonthly: 'bluesky_plus_core_v1_monthly', + coreAnnual: 'bluesky_plus_core_v1_annual', + }, + android: { + coreMonthly: 'bluesky_plus_core_v1:monthly', + coreAnnual: 'bluesky_plus_core_v1:annual', + }, + } + + this.GROUPS = { + core: { + web: [ + { id: 'coreMonthly', product: this.PRODUCTS.web.coreMonthly }, + { id: 'coreAnnual', product: this.PRODUCTS.web.coreAnnual }, + ], + ios: [ + { id: 'coreMonthly', product: this.PRODUCTS.ios.coreMonthly }, + { id: 'coreAnnual', product: this.PRODUCTS.ios.coreAnnual }, + ], + android: [ + { id: 'coreMonthly', product: this.PRODUCTS.android.coreMonthly }, + { id: 'coreAnnual', product: this.PRODUCTS.android.coreAnnual }, + ], + }, + } + } + + isRcWebhookAuthorizationValid(authorization: string | undefined): boolean { + return this.revenueCatClient.isWebhookAuthorizationValid(authorization) + } + + getSubscriptionGroup(group: string, platform: string) { + assertSubscriptionGroup(group) + assertPlatform(platform) + return this.GROUPS[group][platform] + } + + async getEntitlements(did: string): Promise { + const { subscriber } = await this.revenueCatClient.getSubscriber(did) + + const now = Date.now() + return Object.entries(subscriber.entitlements) + .filter( + ([_, entitlement]) => + now < new Date(entitlement.expires_date).valueOf(), + ) + .map(([entitlementIdentifier]) => entitlementIdentifier) + } + + async getSubscriptions(did: string): Promise { + const { subscriber } = await this.revenueCatClient.getSubscriber(did) + + const subscriptions = Object.entries(subscriber.subscriptions) + + return subscriptions + .map(this.createSubscriptionObject) + .filter(Boolean) as Subscription[] + } + + private createSubscriptionObject = ([productId, s]: [ + string, + RcSubscription, + ]): Subscription | undefined => { + const platform = mapStoreToPlatform(s.store) + if (!platform) return undefined + const fullProductId = + platform === 'android' + ? `${productId}:${s.product_plan_identifier}` + : productId + const group = this.mapStoreIdentifierToSubscriptionGroup(fullProductId) + if (!group) return undefined + + const now = new Date() + const expiresAt = new Date(s.expires_date) + + let status = 'unknown' + if (s.auto_resume_date) { + if (now > expiresAt) { + status = 'paused' + } + } else if (now > expiresAt) { + status = 'expired' + } else if (now < expiresAt) { + status = 'active' + } + + let renewalStatus = 'unknown' + if (s.auto_resume_date) { + if (now < expiresAt) { + renewalStatus = 'will_pause' + } else if (now > expiresAt) { + renewalStatus = 'will_renew' + } + } else if (now < expiresAt) { + renewalStatus = 'will_renew' + if (s.unsubscribe_detected_at) { + renewalStatus = 'will_not_renew' + } + } else if (now > expiresAt) { + renewalStatus = 'will_not_renew' + } + + let periodEndsAt = s.expires_date + if (s.auto_resume_date) { + if (now > expiresAt) { + periodEndsAt = s.auto_resume_date + } + } + + const offering = + this.mapStoreIdentifierToSubscriptionOfferingId(fullProductId) + if (!offering) return undefined + + return Subscription.fromJson({ + status, + renewalStatus, + group, + platform, + offering, + periodEndsAt: periodEndsAt, + periodStartsAt: s.purchase_date, + purchasedAt: s.original_purchase_date, + }) + } + + private mapStoreIdentifierToSubscriptionOfferingId = ( + identifier: string, + ): SubscriptionOfferingId | undefined => { + switch (identifier) { + case this.STRIPE_PRODUCTS.coreMonthly: + case this.PRODUCTS.ios.coreMonthly: + case this.PRODUCTS.android.coreMonthly: + return 'coreMonthly' + case this.STRIPE_PRODUCTS.coreAnnual: + case this.PRODUCTS.ios.coreAnnual: + case this.PRODUCTS.android.coreAnnual: + return 'coreAnnual' + default: + return undefined + } + } + + private mapStoreIdentifierToSubscriptionGroup = ( + identifier: string, + ): SubscriptionGroupId | undefined => { + switch (identifier) { + case this.STRIPE_PRODUCTS.coreMonthly: + case this.STRIPE_PRODUCTS.coreAnnual: + case this.PRODUCTS.ios.coreMonthly: + case this.PRODUCTS.ios.coreAnnual: + case this.PRODUCTS.android.coreMonthly: + case this.PRODUCTS.android.coreAnnual: + return 'core' + default: + return undefined + } + } +} diff --git a/packages/bsync/src/purchases/revenueCatClient.ts b/packages/bsync/src/purchases/revenueCatClient.ts index f223a43baaa..b6eeb7966be 100644 --- a/packages/bsync/src/purchases/revenueCatClient.ts +++ b/packages/bsync/src/purchases/revenueCatClient.ts @@ -1,6 +1,51 @@ -import { RcGetSubscriberResponse } from './revenueCatTypes' +import { z } from 'zod' -type Config = { +// Reference: https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields#events-format +export type RcEventBody = { + api_version: '1.0' + event: { + app_user_id: string + type: string + } +} + +export const rcEventBodySchema = z.object({ + api_version: z.literal('1.0'), + event: z.object({ + app_user_id: z.string(), + type: z.string(), + }), +}) + +// Reference: https://www.revenuecat.com/docs/api-v1#tag/customers +export type RcGetSubscriberResponse = { + subscriber: RcSubscriber +} + +export type RcSubscriber = { + entitlements: { + [entitlementIdentifier: string]: RcEntitlement + } + subscriptions: { + [productIdentifier: string]: RcSubscription + } +} + +export type RcEntitlement = { + expires_date: string +} + +export type RcSubscription = { + auto_resume_date: string | null + expires_date: string + original_purchase_date: string + product_plan_identifier: string + purchase_date: string + store: 'play_store' | 'app_store' | 'stripe' + unsubscribe_detected_at: string | null +} + +type RevenueCatClientOpts = { v1ApiKey: string v1ApiUrl: string webhookAuthorization: string @@ -11,7 +56,11 @@ export class RevenueCatClient { private v1ApiUrl: string private webhookAuthorization: string - constructor({ v1ApiKey, v1ApiUrl, webhookAuthorization }: Config) { + constructor({ + v1ApiKey, + v1ApiUrl, + webhookAuthorization, + }: RevenueCatClientOpts) { this.v1ApiKey = v1ApiKey this.v1ApiUrl = v1ApiUrl this.webhookAuthorization = webhookAuthorization @@ -36,24 +85,12 @@ export class RevenueCatClient { return res.json() as T } - private getSubscriber(did: string): Promise { + getSubscriber(did: string): Promise { return this.fetch( `/subscribers/${encodeURIComponent(did)}`, ) } - async getEntitlementIdentifiers(did: string): Promise { - const { subscriber } = await this.getSubscriber(did) - - const now = Date.now() - return Object.entries(subscriber.entitlements) - .filter( - ([_, entitlement]) => - now < new Date(entitlement.expires_date).valueOf(), - ) - .map(([entitlementIdentifier]) => entitlementIdentifier) - } - isWebhookAuthorizationValid(authorization: string | undefined): boolean { return authorization === this.webhookAuthorization } diff --git a/packages/bsync/src/purchases/revenueCatTypes.ts b/packages/bsync/src/purchases/revenueCatTypes.ts deleted file mode 100644 index 50842d05324..00000000000 --- a/packages/bsync/src/purchases/revenueCatTypes.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod' - -// Reference: https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields#events-format -export type RcEventBody = { - api_version: '1.0' - event: { - app_user_id: string - type: string - } -} - -// Reference: https://www.revenuecat.com/docs/api-v1#tag/customers -export type RcGetSubscriberResponse = { - subscriber: RcSubscriber -} - -export type RcSubscriber = { - entitlements: { - [entitlementIdentifier: string]: RcEntitlement - } -} - -export type RcEntitlement = { - expires_date: string -} - -export const rcEventBodySchema = z.object({ - api_version: z.literal('1.0'), - event: z.object({ - app_user_id: z.string(), - type: z.string(), - }), -}) diff --git a/packages/bsync/src/routes/add-purchase-operation.ts b/packages/bsync/src/routes/add-purchase-operation.ts new file mode 100644 index 00000000000..06a8b33b068 --- /dev/null +++ b/packages/bsync/src/routes/add-purchase-operation.ts @@ -0,0 +1,35 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../proto/bsync_connect' +import { AddPurchaseOperationResponse } from '../proto/bsync_pb' +import AppContext from '../context' +import { authWithApiKey } from './auth' +import { isValidDid } from './util' +import { addPurchaseOperation } from '../purchases/addPurchaseOperation' + +export default (ctx: AppContext): Partial> => ({ + async addPurchaseOperation(req, handlerCtx) { + authWithApiKey(ctx, handlerCtx) + + const { db, purchasesClient } = ctx + if (!purchasesClient) { + throw new ConnectError( + 'PurchasesClient is not configured on bsync', + Code.Unavailable, + ) + } + + const { actorDid } = req + if (!isValidDid(actorDid)) { + throw new ConnectError( + 'actor_did must be a valid did', + Code.InvalidArgument, + ) + } + + const entitlements = await purchasesClient.getEntitlements(actorDid) + + await addPurchaseOperation(db, actorDid, entitlements) + + return new AddPurchaseOperationResponse() + }, +}) diff --git a/packages/bsync/src/routes/get-active-subscriptions.ts b/packages/bsync/src/routes/get-active-subscriptions.ts new file mode 100644 index 00000000000..4dcd39c227f --- /dev/null +++ b/packages/bsync/src/routes/get-active-subscriptions.ts @@ -0,0 +1,34 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../proto/bsync_connect' +import { GetActiveSubscriptionsResponse } from '../proto/bsync_pb' +import AppContext from '../context' +import { authWithApiKey } from './auth' +import { isValidDid } from './util' + +export default (ctx: AppContext): Partial> => ({ + async getActiveSubscriptions(req, handlerCtx) { + authWithApiKey(ctx, handlerCtx) + + const { purchasesClient } = ctx + if (!purchasesClient) { + throw new ConnectError( + 'PurchasesClient is not configured on bsync', + Code.Unavailable, + ) + } + + const { actorDid } = req + if (!isValidDid(actorDid)) { + throw new ConnectError( + 'actor_did must be a valid did', + Code.InvalidArgument, + ) + } + + const subscriptions = await purchasesClient.getSubscriptions(actorDid) + + return new GetActiveSubscriptionsResponse({ + subscriptions, + }) + }, +}) diff --git a/packages/bsync/src/routes/get-subscription-group.ts b/packages/bsync/src/routes/get-subscription-group.ts new file mode 100644 index 00000000000..81ac52fc606 --- /dev/null +++ b/packages/bsync/src/routes/get-subscription-group.ts @@ -0,0 +1,30 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../proto/bsync_connect' +import { GetSubscriptionGroupResponse } from '../proto/bsync_pb' +import AppContext from '../context' +import { authWithApiKey } from './auth' + +export default (ctx: AppContext): Partial> => ({ + async getSubscriptionGroup(req, handlerCtx) { + authWithApiKey(ctx, handlerCtx) + + const { purchasesClient } = ctx + if (!purchasesClient) { + throw new ConnectError( + 'PurchasesClient is not configured on bsync', + Code.Unavailable, + ) + } + + const { group, platform } = req + try { + const offerings = purchasesClient.getSubscriptionGroup(group, platform) + + return new GetSubscriptionGroupResponse({ + offerings, + }) + } catch (error) { + throw new ConnectError((error as Error).message, Code.InvalidArgument) + } + }, +}) diff --git a/packages/bsync/src/routes/index.ts b/packages/bsync/src/routes/index.ts index 5bdb654d16e..87e4e9e7ebe 100644 --- a/packages/bsync/src/routes/index.ts +++ b/packages/bsync/src/routes/index.ts @@ -6,6 +6,9 @@ import addMuteOperation from './add-mute-operation' import scanMuteOperations from './scan-mute-operations' import addNotifOperation from './add-notif-operation' import scanNotifOperations from './scan-notif-operations' +import addPurchaseOperation from './add-purchase-operation' +import getActiveSubscriptions from './get-active-subscriptions' +import getSubscriptionGroup from './get-subscription-group' export default (ctx: AppContext) => (router: ConnectRouter) => { return router.service(Service, { @@ -13,6 +16,9 @@ export default (ctx: AppContext) => (router: ConnectRouter) => { ...scanMuteOperations(ctx), ...addNotifOperation(ctx), ...scanNotifOperations(ctx), + ...addPurchaseOperation(ctx), + ...getActiveSubscriptions(ctx), + ...getSubscriptionGroup(ctx), async ping() { const { db } = ctx await sql`select 1`.execute(db.db) diff --git a/packages/bsync/tests/purchases.test.ts b/packages/bsync/tests/purchases.test.ts index 239c34b8cd2..e293801e10c 100644 --- a/packages/bsync/tests/purchases.test.ts +++ b/packages/bsync/tests/purchases.test.ts @@ -1,17 +1,26 @@ import http from 'node:http' import { once } from 'node:events' import getPort from 'get-port' -import { BsyncService, Database, envToCfg } from '../src' +import { + authWithApiKey, + BsyncClient, + BsyncService, + createClient, + Database, + envToCfg, +} from '../src' import { RcEntitlement, RcEventBody, RcGetSubscriberResponse, } from '../src/purchases' +import { Code, ConnectError } from '@connectrpc/connect' const revenueCatWebhookAuthorization = 'Bearer any-token' describe('purchases', () => { let bsync: BsyncService + let client: BsyncClient let bsyncUrl: string const actorDid = 'did:example:a' @@ -27,6 +36,9 @@ describe('purchases', () => { expires_date: new Date(Date.now() - TEN_MINUTES).toISOString(), } + const stripePriceIdMonthly = 'price_id_monthly' + const stripePriceIdAnnual = 'price_id_annual' + beforeAll(async () => { const revenueCatPort = await getPort() @@ -46,6 +58,10 @@ describe('purchases', () => { revenueCatV1ApiKey: 'any-key', revenueCatV1ApiUrl: `http://localhost:${revenueCatPort}`, revenueCatWebhookAuthorization, + stripePriceIdMonthly: stripePriceIdMonthly, + stripePriceIdAnnual: stripePriceIdAnnual, + stripeProductIdMonthly: 'product_id_monthly', + stripeProductIdAnnual: 'product_id_annual', }), ) @@ -53,6 +69,11 @@ describe('purchases', () => { await bsync.ctx.db.migrateToLatestOrThrow() await bsync.start() + client = createClient({ + httpVersion: '1.1', + baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`, + interceptors: [authWithApiKey('key-1')], + }) }) afterAll(async () => { @@ -77,8 +98,10 @@ describe('purchases', () => { }) expect(response.status).toBe(403) - expect(response.json()).resolves.toMatchObject({ + const body = await response.json() + expect({ ...body }).toStrictEqual({ error: 'Forbidden: invalid authentication for RevenueCat webhook', + success: false, }) }) @@ -86,8 +109,10 @@ describe('purchases', () => { const response = await callWebhook(bsyncUrl, buildWebhookBody('invalid')) expect(response.status).toBe(400) - expect(response.json()).resolves.toMatchObject({ + const body = await response.json() + expect({ ...body }).toStrictEqual({ error: 'Bad request: invalid DID in app_user_id', + success: false, }) }) @@ -97,8 +122,10 @@ describe('purchases', () => { } as unknown as RcEventBody) expect(response.status).toBe(400) - expect(response.json()).resolves.toMatchObject({ + const body = await response.json() + expect({ ...body }).toStrictEqual({ error: 'Bad request: body schema validation failed', + success: false, }) }) @@ -106,6 +133,7 @@ describe('purchases', () => { revenueCatApiMock.mockReturnValueOnce({ subscriber: { entitlements: { entitlementExpired }, + subscriptions: {}, }, }) @@ -118,7 +146,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op0).toMatchObject({ + expect(op0).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: [], @@ -131,7 +159,7 @@ describe('purchases', () => { .selectAll() .where('actorDid', '=', actorDid) .executeTakeFirstOrThrow(), - ).resolves.toMatchObject({ + ).resolves.toStrictEqual({ actorDid, entitlements: [], fromId: op0.id, @@ -140,6 +168,7 @@ describe('purchases', () => { revenueCatApiMock.mockReturnValueOnce({ subscriber: { entitlements: { entitlementValid, entitlementExpired }, + subscriptions: {}, }, }) @@ -152,7 +181,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op1).toMatchObject({ + expect(op1).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: ['entitlementValid'], @@ -165,7 +194,7 @@ describe('purchases', () => { .selectAll() .where('actorDid', '=', actorDid) .executeTakeFirstOrThrow(), - ).resolves.toMatchObject({ + ).resolves.toStrictEqual({ actorDid, entitlements: ['entitlementValid'], fromId: op1.id, @@ -174,7 +203,7 @@ describe('purchases', () => { it('sets empty array in the cache if no entitlements are present at all', async () => { revenueCatApiMock.mockReturnValue({ - subscriber: { entitlements: {} }, + subscriber: { entitlements: {}, subscriptions: {} }, }) await callWebhook(bsyncUrl, buildWebhookBody(actorDid)) @@ -186,7 +215,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op).toMatchObject({ + expect(op).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: [], @@ -199,13 +228,194 @@ describe('purchases', () => { .selectAll() .where('actorDid', '=', actorDid) .executeTakeFirstOrThrow(), - ).resolves.toMatchObject({ + ).resolves.toStrictEqual({ actorDid, entitlements: [], fromId: op.id, }) }) }) + + describe('addPurchaseOperation', () => { + it('fails on bad inputs', async () => { + await expect( + client.addPurchaseOperation({ + actorDid: 'invalid', + }), + ).rejects.toStrictEqual( + new ConnectError('actor_did must be a valid did', Code.InvalidArgument), + ) + }) + + it('stores valid entitlements from the API response, excluding expired', async () => { + revenueCatApiMock.mockReturnValueOnce({ + subscriber: { + entitlements: { entitlementExpired }, + subscriptions: {}, + }, + }) + + await client.addPurchaseOperation({ actorDid }) + + const op0 = await bsync.ctx.db.db + .selectFrom('purchase_op') + .selectAll() + .where('actorDid', '=', actorDid) + .orderBy('id', 'desc') + .executeTakeFirstOrThrow() + + expect(op0).toStrictEqual({ + id: expect.any(Number), + actorDid, + entitlements: [], + createdAt: expect.any(Date), + }) + + await expect( + bsync.ctx.db.db + .selectFrom('purchase_item') + .selectAll() + .where('actorDid', '=', actorDid) + .executeTakeFirstOrThrow(), + ).resolves.toStrictEqual({ + actorDid, + entitlements: [], + fromId: op0.id, + }) + + revenueCatApiMock.mockReturnValueOnce({ + subscriber: { + entitlements: { entitlementValid, entitlementExpired }, + subscriptions: {}, + }, + }) + + await client.addPurchaseOperation({ actorDid }) + + const op1 = await bsync.ctx.db.db + .selectFrom('purchase_op') + .selectAll() + .where('actorDid', '=', actorDid) + .orderBy('id', 'desc') + .executeTakeFirstOrThrow() + + expect(op1).toStrictEqual({ + id: expect.any(Number), + actorDid, + entitlements: ['entitlementValid'], + createdAt: expect.any(Date), + }) + + await expect( + bsync.ctx.db.db + .selectFrom('purchase_item') + .selectAll() + .where('actorDid', '=', actorDid) + .executeTakeFirstOrThrow(), + ).resolves.toStrictEqual({ + actorDid, + entitlements: ['entitlementValid'], + fromId: op1.id, + }) + }) + + it('sets empty array in the cache if no entitlements are present at all', async () => { + revenueCatApiMock.mockReturnValue({ + subscriber: { entitlements: {}, subscriptions: {} }, + }) + + await client.addPurchaseOperation({ actorDid }) + + const op = await bsync.ctx.db.db + .selectFrom('purchase_op') + .selectAll() + .where('actorDid', '=', actorDid) + .orderBy('id', 'desc') + .executeTakeFirstOrThrow() + + expect(op).toStrictEqual({ + id: expect.any(Number), + actorDid, + entitlements: [], + createdAt: expect.any(Date), + }) + + await expect( + bsync.ctx.db.db + .selectFrom('purchase_item') + .selectAll() + .where('actorDid', '=', actorDid) + .executeTakeFirstOrThrow(), + ).resolves.toStrictEqual({ + actorDid, + entitlements: [], + fromId: op.id, + }) + }) + }) + + describe('getActiveSubscriptions', () => {}) + + describe('getSubscriptionGroup', () => { + type Input = { group: string; platform: string } + type Expected = { offerings: { id: string; product: string }[] } + + it('returns the expected data when input is correct', async () => { + const t = async (input: Input, expected: Expected) => { + const res = await client.getSubscriptionGroup(input) + expect({ + offerings: res.offerings.map((o) => ({ ...o })), + }).toStrictEqual(expected) + } + + await t( + { group: 'core', platform: 'android' }, + { + offerings: [ + { id: 'coreMonthly', product: 'bluesky_plus_core_v1:monthly' }, + { id: 'coreAnnual', product: 'bluesky_plus_core_v1:annual' }, + ], + }, + ) + + await t( + { group: 'core', platform: 'ios' }, + { + offerings: [ + { id: 'coreMonthly', product: 'bluesky_plus_core_v1_monthly' }, + { id: 'coreAnnual', product: 'bluesky_plus_core_v1_annual' }, + ], + }, + ) + + await t( + { group: 'core', platform: 'web' }, + { + offerings: [ + { id: 'coreMonthly', product: stripePriceIdMonthly }, + { id: 'coreAnnual', product: stripePriceIdAnnual }, + ], + }, + ) + }) + + it('throws the expected error when input is incorrect', async () => { + const t = async (input: Input, expected: string) => { + await expect(client.getSubscriptionGroup(input)).rejects.toThrow( + expected, + ) + } + + await t( + { group: 'wrong-group', platform: 'android' }, + `invalid subscription group: 'wrong-group'`, + ) + await t( + { group: 'core', platform: 'wrong-platform' }, + `invalid platform: 'wrong-platform'`, + ) + }) + }) }) const clearPurchases = async (db: Database) => {