From 6842680bc15d9df665431f7cdba358f49d44288d Mon Sep 17 00:00:00 2001 From: rafael Date: Fri, 6 Dec 2024 20:00:07 -0300 Subject: [PATCH] add purchases endpoints on bsky --- .../src/api/app/bsky/purchase/getFeatures.ts | 50 +++ .../app/bsky/purchase/getSubscriptionGroup.ts | 27 ++ .../api/app/bsky/purchase/getSubscriptions.ts | 32 ++ .../src/api/app/bsky/purchase/refreshCache.ts | 38 ++ packages/bsky/src/api/index.ts | 8 + packages/bsky/src/auth-verifier.ts | 8 +- packages/bsky/src/data-plane/bsync/index.ts | 62 +++ .../data-plane/server/db/database-schema.ts | 4 +- .../20241206T231908523Z-purchase.ts | 15 + .../data-plane/server/db/migrations/index.ts | 1 + .../data-plane/server/db/tables/purchase.ts | 13 + .../src/data-plane/server/routes/index.ts | 2 + .../src/data-plane/server/routes/purchases.ts | 33 ++ packages/bsky/src/proto/bsync_pb.ts | 65 ++- packages/bsky/tests/views/purchases.test.ts | 131 ++++++ packages/bsync/proto/bsync.proto | 7 +- packages/bsync/src/api/revenueCat.ts | 55 +-- packages/bsync/src/config.ts | 65 ++- packages/bsync/src/context.ts | 33 +- packages/bsync/src/index.ts | 2 +- packages/bsync/src/proto/bsync_connect.ts | 14 +- packages/bsync/src/proto/bsync_pb.ts | 141 +++++-- packages/bsync/src/purchases/index.ts | 2 +- .../bsync/src/purchases/purchasesClient.ts | 269 +++++++++++++ .../bsync/src/purchases/revenueCatClient.ts | 75 +++- .../bsync/src/purchases/revenueCatTypes.ts | 33 -- .../src/routes/add-purchase-operation.ts | 35 ++ .../src/routes/get-subscription-group.ts | 30 ++ .../bsync/src/routes/get-subscriptions.ts | 44 ++ packages/bsync/src/routes/index.ts | 6 + packages/bsync/tests/purchases.test.ts | 379 +++++++++++++++++- 31 files changed, 1493 insertions(+), 186 deletions(-) create mode 100644 packages/bsky/src/api/app/bsky/purchase/getFeatures.ts create mode 100644 packages/bsky/src/api/app/bsky/purchase/getSubscriptionGroup.ts create mode 100644 packages/bsky/src/api/app/bsky/purchase/getSubscriptions.ts create mode 100644 packages/bsky/src/api/app/bsky/purchase/refreshCache.ts create mode 100644 packages/bsky/src/data-plane/server/db/migrations/20241206T231908523Z-purchase.ts create mode 100644 packages/bsky/src/data-plane/server/db/tables/purchase.ts create mode 100644 packages/bsky/src/data-plane/server/routes/purchases.ts create mode 100644 packages/bsky/tests/views/purchases.test.ts create mode 100644 packages/bsync/src/purchases/purchasesClient.ts delete mode 100644 packages/bsync/src/purchases/revenueCatTypes.ts create mode 100644 packages/bsync/src/routes/add-purchase-operation.ts create mode 100644 packages/bsync/src/routes/get-subscription-group.ts create mode 100644 packages/bsync/src/routes/get-subscriptions.ts 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/getSubscriptions.ts b/packages/bsky/src/api/app/bsky/purchase/getSubscriptions.ts new file mode 100644 index 00000000000..0147704ad5e --- /dev/null +++ b/packages/bsky/src/api/app/bsky/purchase/getSubscriptions.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/getSubscriptions' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.purchase.getSubscriptions({ + auth: ctx.authVerifier.standard, + handler: async ({ auth }) => { + const viewer = auth.credentials.iss + + const { subscriptions } = await ctx.bsyncClient.getSubscriptions({ + 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/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..5848822a2a2 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 getSubscriptions from './app/bsky/purchase/getSubscriptions' +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) + getSubscriptions(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..5b9c53e9c80 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,67 @@ 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 getSubscriptions() { + const TEN_DAYS = 864_000_000 + const now = Date.now() + const start = new Date(now - TEN_DAYS) + const end = new Date(now + TEN_DAYS) + + // 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(end), + periodStartsAt: Timestamp.fromDate(start), + purchasedAt: Timestamp.fromDate(start), + }, + ], + } + }, + + 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 d2e0c811d0b..ca31cd9689f 100644 --- a/packages/bsky/src/proto/bsync_pb.ts +++ b/packages/bsky/src/proto/bsync_pb.ts @@ -1013,6 +1013,61 @@ export class GetSubscriptionsResponse extends Message } } +/** + * @generated from message bsync.SubscriptionOffering + */ +export class SubscriptionOffering 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..577e26e2834 --- /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 subscriptions for the account', async () => { + const { data } = await agent.app.bsky.purchase.getSubscriptions( + {}, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyPurchaseGetSubscriptions, + ), + }, + ) + + expect(data).toStrictEqual({ + subscriptions: [ + { + status: 'active', + renewalStatus: 'will_renew', + group: 'core', + platform: 'web', + offering: 'coreMonthly', + periodEndsAt: expect.any(String), + periodStartsAt: expect.any(String), + purchasedAt: expect.any(String), + }, + ], + }) + }) + + 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 952cc3663af..a47886157b3 100644 --- a/packages/bsync/proto/bsync.proto +++ b/packages/bsync/proto/bsync.proto @@ -94,13 +94,18 @@ message GetSubscriptionsResponse { repeated Subscription subscriptions = 2; } +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..d8a737c5e88 100644 --- a/packages/bsync/src/api/revenueCat.ts +++ b/packages/bsync/src/api/revenueCat.ts @@ -1,30 +1,36 @@ 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 => - (req: express.Request, res: express.Response, next: express.NextFunction) => - ctx.revenueCatClient.isWebhookAuthorizationValid( - req.header('Authorization'), - ) - ? next() - : res.status(403).send({ - success: false, - error: 'Forbidden: invalid authentication for RevenueCat webhook', - }) + (ctx: AppContextWithPurchasesClient): RequestHandler => + (req: express.Request, res: express.Response, next: express.NextFunction) => { + const validAuthorization = + ctx.purchasesClient.isRcWebhookAuthorizationValid( + req.header('Authorization'), + ) + + if (validAuthorization) { + return next() + } + + return res.status(403).json({ + success: false, + error: 'Forbidden: invalid authentication for RevenueCat webhook', + }) + } const webhookHandler = - (ctx: AppContextWithRevenueCatClient): RequestHandler => + (ctx: AppContextWithPurchasesClient): RequestHandler => async (req, res) => { - const { revenueCatClient } = ctx + const { purchasesClient } = ctx let body: RcEventBody try { @@ -32,7 +38,7 @@ const webhookHandler = } catch (error) { log.error({ error }, 'RevenueCat webhook body schema validation failed') - return res.status(400).send({ + return res.status(400).json({ success: false, error: 'Bad request: body schema validation failed', }) @@ -43,23 +49,22 @@ const webhookHandler = if (!isValidDid(actorDid)) { log.error({ actorDid }, 'RevenueCat webhook got invalid DID') - return res.status(400).send({ + return res.status(400).json({ success: false, error: 'Bad request: invalid DID in app_user_id', }) } try { - const entitlements = - await revenueCatClient.getEntitlementIdentifiers(actorDid) + const entitlements = await purchasesClient.getEntitlements(actorDid) const id = await addPurchaseOperation(ctx.db, actorDid, entitlements) - res.send({ success: true, operationId: id }) + return res.json({ success: true, operationId: id }) } catch (error) { log.error({ error }, 'Error while processing RevenueCat webhook') - res.status(500).send({ + return res.status(500).json({ success: false, error: 'Internal server error: an error happened while processing the request', @@ -67,10 +72,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 +83,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_connect.ts b/packages/bsync/src/proto/bsync_connect.ts index c2d8caac987..6674e0f09da 100644 --- a/packages/bsync/src/proto/bsync_connect.ts +++ b/packages/bsync/src/proto/bsync_connect.ts @@ -10,10 +10,10 @@ import { AddNotifOperationResponse, AddPurchaseOperationRequest, AddPurchaseOperationResponse, - GetActiveSubscriptionsRequest, - GetActiveSubscriptionsResponse, GetSubscriptionGroupRequest, GetSubscriptionGroupResponse, + GetSubscriptionsRequest, + GetSubscriptionsResponse, PingRequest, PingResponse, ScanMuteOperationsRequest, @@ -79,12 +79,12 @@ export const Service = { kind: MethodKind.Unary, }, /** - * @generated from rpc bsync.Service.GetActiveSubscriptions + * @generated from rpc bsync.Service.GetSubscriptions */ - getActiveSubscriptions: { - name: 'GetActiveSubscriptions', - I: GetActiveSubscriptionsRequest, - O: GetActiveSubscriptionsResponse, + getSubscriptions: { + name: 'GetSubscriptions', + I: GetSubscriptionsRequest, + O: GetSubscriptionsResponse, kind: MethodKind.Unary, }, /** diff --git a/packages/bsync/src/proto/bsync_pb.ts b/packages/bsync/src/proto/bsync_pb.ts index 74c8ed76d75..ca31cd9689f 100644 --- a/packages/bsync/src/proto/bsync_pb.ts +++ b/packages/bsync/src/proto/bsync_pb.ts @@ -892,21 +892,21 @@ export class Subscription extends Message { } /** - * @generated from message bsync.GetActiveSubscriptionsRequest + * @generated from message bsync.GetSubscriptionsRequest */ -export class GetActiveSubscriptionsRequest extends Message { +export class GetSubscriptionsRequest extends Message { /** * @generated from field: string actor_did = 1; */ actorDid = '' - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super() proto3.util.initPartial(data, this) } static readonly runtime: typeof proto3 = proto3 - static readonly typeName = 'bsync.GetActiveSubscriptionsRequest' + static readonly typeName = 'bsync.GetSubscriptionsRequest' static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: 'actor_did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, ]) @@ -914,60 +914,63 @@ export class GetActiveSubscriptionsRequest extends Message, - ): GetActiveSubscriptionsRequest { - return new GetActiveSubscriptionsRequest().fromBinary(bytes, options) + ): GetSubscriptionsRequest { + return new GetSubscriptionsRequest().fromBinary(bytes, options) } static fromJson( jsonValue: JsonValue, options?: Partial, - ): GetActiveSubscriptionsRequest { - return new GetActiveSubscriptionsRequest().fromJson(jsonValue, options) + ): GetSubscriptionsRequest { + return new GetSubscriptionsRequest().fromJson(jsonValue, options) } static fromJsonString( jsonString: string, options?: Partial, - ): GetActiveSubscriptionsRequest { - return new GetActiveSubscriptionsRequest().fromJsonString( - jsonString, - options, - ) + ): GetSubscriptionsRequest { + return new GetSubscriptionsRequest().fromJsonString(jsonString, options) } static equals( a: - | GetActiveSubscriptionsRequest - | PlainMessage + | GetSubscriptionsRequest + | PlainMessage | undefined, b: - | GetActiveSubscriptionsRequest - | PlainMessage + | GetSubscriptionsRequest + | PlainMessage | undefined, ): boolean { - return proto3.util.equals(GetActiveSubscriptionsRequest, a, b) + return proto3.util.equals(GetSubscriptionsRequest, a, b) } } /** - * @generated from message bsync.GetActiveSubscriptionsResponse + * @generated from message bsync.GetSubscriptionsResponse */ -export class GetActiveSubscriptionsResponse extends Message { +export class GetSubscriptionsResponse extends Message { /** - * @generated from field: repeated bsync.Subscription subscriptions = 1; + * @generated from field: string email = 1; + */ + email = '' + + /** + * @generated from field: repeated bsync.Subscription subscriptions = 2; */ subscriptions: Subscription[] = [] - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super() proto3.util.initPartial(data, this) } static readonly runtime: typeof proto3 = proto3 - static readonly typeName = 'bsync.GetActiveSubscriptionsResponse' + static readonly typeName = 'bsync.GetSubscriptionsResponse' static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'email', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, { - no: 1, + no: 2, name: 'subscriptions', kind: 'message', T: Subscription, @@ -978,38 +981,90 @@ export class GetActiveSubscriptionsResponse extends Message, - ): GetActiveSubscriptionsResponse { - return new GetActiveSubscriptionsResponse().fromBinary(bytes, options) + ): GetSubscriptionsResponse { + return new GetSubscriptionsResponse().fromBinary(bytes, options) } static fromJson( jsonValue: JsonValue, options?: Partial, - ): GetActiveSubscriptionsResponse { - return new GetActiveSubscriptionsResponse().fromJson(jsonValue, options) + ): GetSubscriptionsResponse { + return new GetSubscriptionsResponse().fromJson(jsonValue, options) } static fromJsonString( jsonString: string, options?: Partial, - ): GetActiveSubscriptionsResponse { - return new GetActiveSubscriptionsResponse().fromJsonString( - jsonString, - options, - ) + ): GetSubscriptionsResponse { + return new GetSubscriptionsResponse().fromJsonString(jsonString, options) } static equals( a: - | GetActiveSubscriptionsResponse - | PlainMessage + | GetSubscriptionsResponse + | PlainMessage | undefined, b: - | GetActiveSubscriptionsResponse - | PlainMessage + | GetSubscriptionsResponse + | PlainMessage | undefined, ): boolean { - return proto3.util.equals(GetActiveSubscriptionsResponse, a, b) + return proto3.util.equals(GetSubscriptionsResponse, a, b) + } +} + +/** + * @generated from message bsync.SubscriptionOffering + */ +export class SubscriptionOffering 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) } } @@ -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..86f370420c6 --- /dev/null +++ b/packages/bsync/src/purchases/purchasesClient.ts @@ -0,0 +1,269 @@ +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 getSubscriptionsAndEmail( + did: string, + ): Promise<{ subscriptions: Subscription[]; email?: string }> { + const { subscriber } = await this.revenueCatClient.getSubscriber(did) + + const email = subscriber.subscriber_attributes.email?.value + + const subscriptions = Object.entries(subscriber.subscriptions) + + return { + email: email, + subscriptions: 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..70c89acd38e 100644 --- a/packages/bsync/src/purchases/revenueCatClient.ts +++ b/packages/bsync/src/purchases/revenueCatClient.ts @@ -1,6 +1,57 @@ -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 + } + subscriber_attributes: { + [attributeName: string]: { + value: string + } + } +} + +export type RcEntitlement = { + expires_date: string +} + +export type RcSubscription = { + auto_resume_date: string | null + expires_date: string + original_purchase_date: string + // product_plan_identifier is only present for Android subscriptions. + 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 +62,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 +91,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..2e4dd9a3d7b --- /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.Unimplemented, + ) + } + + 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-subscription-group.ts b/packages/bsync/src/routes/get-subscription-group.ts new file mode 100644 index 00000000000..379909bb3e9 --- /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.Unimplemented, + ) + } + + 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/get-subscriptions.ts b/packages/bsync/src/routes/get-subscriptions.ts new file mode 100644 index 00000000000..071ea661d65 --- /dev/null +++ b/packages/bsync/src/routes/get-subscriptions.ts @@ -0,0 +1,44 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../proto/bsync_connect' +import { GetSubscriptionsResponse } from '../proto/bsync_pb' +import AppContext from '../context' +import { authWithApiKey } from './auth' +import { isValidDid } from './util' +import { httpLogger } from '../logger' + +export default (ctx: AppContext): Partial> => ({ + async getSubscriptions(req, handlerCtx) { + authWithApiKey(ctx, handlerCtx) + + const { purchasesClient } = ctx + if (!purchasesClient) { + throw new ConnectError( + 'PurchasesClient is not configured on bsync', + Code.Unimplemented, + ) + } + + const { actorDid } = req + if (!isValidDid(actorDid)) { + throw new ConnectError( + 'actor_did must be a valid did', + Code.InvalidArgument, + ) + } + + const { email, subscriptions } = + await purchasesClient.getSubscriptionsAndEmail(actorDid) + + if (!email) { + httpLogger.warn( + { actorDid }, + `getSubscriptions didn't get email for user`, + ) + } + + return new GetSubscriptionsResponse({ + email, + subscriptions, + }) + }, +}) diff --git a/packages/bsync/src/routes/index.ts b/packages/bsync/src/routes/index.ts index 5bdb654d16e..d59bbd78474 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 getSubscriptions from './get-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), + ...getSubscriptions(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..9c684e3afd9 100644 --- a/packages/bsync/tests/purchases.test.ts +++ b/packages/bsync/tests/purchases.test.ts @@ -1,17 +1,29 @@ 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' +import { GetSubscriptionsResponse } from '../src/proto/bsync_pb' +import { Timestamp } from '@bufbuild/protobuf' +import { DAY } from '@atproto/common' const revenueCatWebhookAuthorization = 'Bearer any-token' describe('purchases', () => { let bsync: BsyncService + let client: BsyncClient let bsyncUrl: string const actorDid = 'did:example:a' @@ -19,14 +31,24 @@ describe('purchases', () => { let revenueCatServer: http.Server let revenueCatApiMock: jest.Mock - const TEN_MINUTES = 600_000 - const entitlementValid: RcEntitlement = { - expires_date: new Date(Date.now() + TEN_MINUTES).toISOString(), - } + const ONE_WEEK = DAY * 7 + const now = new Date() + const twoWeeksAgo = new Date(now.valueOf() - ONE_WEEK * 2) + const lastWeek = new Date(now.valueOf() - ONE_WEEK) + const nextWeek = new Date(now.valueOf() + ONE_WEEK) + const entitlementExpired: RcEntitlement = { - expires_date: new Date(Date.now() - TEN_MINUTES).toISOString(), + expires_date: lastWeek.toISOString(), + } + const entitlementValid: RcEntitlement = { + expires_date: nextWeek.toISOString(), } + const stripePriceIdMonthly = 'price_id_monthly' + const stripePriceIdAnnual = 'price_id_annual' + const stripeProductIdMonthly = 'product_id_monthly' + const stripeProductIdAnnual = 'product_id_annual' + beforeAll(async () => { const revenueCatPort = await getPort() @@ -46,6 +68,10 @@ describe('purchases', () => { revenueCatV1ApiKey: 'any-key', revenueCatV1ApiUrl: `http://localhost:${revenueCatPort}`, revenueCatWebhookAuthorization, + stripePriceIdMonthly, + stripePriceIdAnnual, + stripeProductIdMonthly, + stripeProductIdAnnual, }), ) @@ -53,6 +79,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,18 +108,26 @@ describe('purchases', () => { }) expect(response.status).toBe(403) - expect(response.json()).resolves.toMatchObject({ - error: 'Forbidden: invalid authentication for RevenueCat webhook', - }) + const body = await response.json() + expect(body).toStrictEqual( + structuredClone({ + error: 'Forbidden: invalid authentication for RevenueCat webhook', + success: false, + }), + ) }) it('replies 400 if DID is invalid', async () => { const response = await callWebhook(bsyncUrl, buildWebhookBody('invalid')) expect(response.status).toBe(400) - expect(response.json()).resolves.toMatchObject({ - error: 'Bad request: invalid DID in app_user_id', - }) + const body = await response.json() + expect(body).toStrictEqual( + structuredClone({ + error: 'Bad request: invalid DID in app_user_id', + success: false, + }), + ) }) it('replies 400 if body is invalid', async () => { @@ -97,15 +136,21 @@ describe('purchases', () => { } as unknown as RcEventBody) expect(response.status).toBe(400) - expect(response.json()).resolves.toMatchObject({ - error: 'Bad request: body schema validation failed', - }) + const body = await response.json() + expect(body).toStrictEqual( + structuredClone({ + error: 'Bad request: body schema validation failed', + success: false, + }), + ) }) it('stores valid entitlements from the API response, excluding expired', async () => { revenueCatApiMock.mockReturnValueOnce({ subscriber: { entitlements: { entitlementExpired }, + subscriptions: {}, + subscriber_attributes: {}, }, }) @@ -118,7 +163,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op0).toMatchObject({ + expect(op0).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: [], @@ -131,7 +176,7 @@ describe('purchases', () => { .selectAll() .where('actorDid', '=', actorDid) .executeTakeFirstOrThrow(), - ).resolves.toMatchObject({ + ).resolves.toStrictEqual({ actorDid, entitlements: [], fromId: op0.id, @@ -140,6 +185,8 @@ describe('purchases', () => { revenueCatApiMock.mockReturnValueOnce({ subscriber: { entitlements: { entitlementValid, entitlementExpired }, + subscriptions: {}, + subscriber_attributes: {}, }, }) @@ -152,7 +199,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op1).toMatchObject({ + expect(op1).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: ['entitlementValid'], @@ -165,7 +212,7 @@ describe('purchases', () => { .selectAll() .where('actorDid', '=', actorDid) .executeTakeFirstOrThrow(), - ).resolves.toMatchObject({ + ).resolves.toStrictEqual({ actorDid, entitlements: ['entitlementValid'], fromId: op1.id, @@ -174,7 +221,11 @@ describe('purchases', () => { it('sets empty array in the cache if no entitlements are present at all', async () => { revenueCatApiMock.mockReturnValue({ - subscriber: { entitlements: {} }, + subscriber: { + entitlements: {}, + subscriptions: {}, + subscriber_attributes: {}, + }, }) await callWebhook(bsyncUrl, buildWebhookBody(actorDid)) @@ -186,7 +237,7 @@ describe('purchases', () => { .orderBy('id', 'desc') .executeTakeFirstOrThrow() - expect(op).toMatchObject({ + expect(op).toStrictEqual({ id: expect.any(Number), actorDid, entitlements: [], @@ -199,13 +250,297 @@ 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: {}, + subscriber_attributes: {}, + }, + }) + + 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: {}, + subscriber_attributes: {}, + }, + }) + + 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: {}, + subscriber_attributes: {}, + }, + }) + + 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('getSubscriptions', () => { + it('returns the email if returned from RC', async () => { + revenueCatApiMock.mockReturnValue({ + subscriber: { + entitlements: {}, + subscriptions: {}, + subscriber_attributes: { + email: { + value: 'test@test', + }, + }, + }, + }) + + const res = await client.getSubscriptions({ actorDid }) + + expect(res).toStrictEqual( + new GetSubscriptionsResponse({ + email: 'test@test', + subscriptions: [], + }), + ) + }) + + it('returns empty subscriptions if none returned from RC', async () => { + revenueCatApiMock.mockReturnValue({ + subscriber: { + entitlements: {}, + subscriptions: {}, + subscriber_attributes: {}, + }, + }) + + const res = await client.getSubscriptions({ actorDid }) + + expect(res).toStrictEqual( + new GetSubscriptionsResponse({ + email: '', + subscriptions: [], + }), + ) + }) + + it('returns the mapped subscriptions data returned from RC', async () => { + revenueCatApiMock.mockReturnValue({ + subscriber: { + entitlements: {}, + subscriptions: { + [stripeProductIdMonthly]: { + auto_resume_date: null, + expires_date: lastWeek.toISOString(), + original_purchase_date: twoWeeksAgo.toISOString(), + purchase_date: twoWeeksAgo.toISOString(), + store: 'stripe', + unsubscribe_detected_at: null, + }, + [stripeProductIdAnnual]: { + auto_resume_date: null, + expires_date: nextWeek.toISOString(), + original_purchase_date: lastWeek.toISOString(), + purchase_date: lastWeek.toISOString(), + store: 'stripe', + unsubscribe_detected_at: null, + }, + }, + subscriber_attributes: {}, + }, + }) + + const res = await client.getSubscriptions({ actorDid }) + + expect(res).toStrictEqual( + new GetSubscriptionsResponse({ + email: '', + subscriptions: [ + { + status: 'expired', + renewalStatus: 'will_not_renew', + group: 'core', + platform: 'web', + offering: 'coreMonthly', + periodEndsAt: Timestamp.fromDate(lastWeek), + periodStartsAt: Timestamp.fromDate(twoWeeksAgo), + purchasedAt: Timestamp.fromDate(twoWeeksAgo), + }, + { + status: 'active', + renewalStatus: 'will_renew', + group: 'core', + platform: 'web', + offering: 'coreAnnual', + periodEndsAt: Timestamp.fromDate(nextWeek), + periodStartsAt: Timestamp.fromDate(lastWeek), + purchasedAt: Timestamp.fromDate(lastWeek), + }, + ], + }), + ) + }) + }) + + 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(structuredClone(res)).toStrictEqual(structuredClone(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) => {