diff --git a/.changeset/gateway-monetization-managers.md b/.changeset/gateway-monetization-managers.md new file mode 100644 index 00000000..59f091cb --- /dev/null +++ b/.changeset/gateway-monetization-managers.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add the monetization managers. `client.application.entitlements` is an `EntitlementManager`, cached from `ENTITLEMENT_CREATE`, `ENTITLEMENT_UPDATE` and `ENTITLEMENT_DELETE` (which emit the new `entitlementCreate`, `entitlementUpdate` and `entitlementDelete` events), with `fetch()` for one entitlement or a filtered `Collection`, `createTest()`, `deleteTest()` and `consume()`. `client.application.fetchSKUs()` returns the `SKU`s of the application, and `sku.subscriptions` (or `client.application.subscriptions(sku)`) is a fetch-only `SubscriptionManager`. Adds the `Entitlement`, `SKU` and `Subscription` structures, `SKUFlagsBitField`, and the `EntitlementCreateInvalidOwner` error; `EntitlementResolvable`, `SKUResolvable` and `SubscriptionResolvable` now also accept the structures. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index 52a79635..5cd2bd34 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -163,6 +163,8 @@ client.on(GatewayEvents.MessageCreate, (message) => console.log(message.content) | `integrationCreate` | `integration` | | `integrationUpdate` | `oldIntegration \| null`, `newIntegration` | | `integrationDelete` | `integration \| null`, `data` | +| `entitlementCreate` / `entitlementDelete` | `entitlement` | +| `entitlementUpdate` | `oldEntitlement \| null`, `newEntitlement` | The previous state of update events and the entity of delete events come from the cache, and are `null` when it was not cached (or when the client has no cache). `data` is the raw dispatch data, @@ -1097,8 +1099,37 @@ await client.application.editRoleConnectionMetadataRecords(records); ``` `owner` is the `Team` (with its `members`, each a `TeamMember`) when a team owns the application, the owner `User` -otherwise. `application.commands`, `application.emojis` and the entitlements will be added to `ClientApplication` -by their own features, there are no placeholders for them. +otherwise. `application.commands` and `application.emojis` will be added to `ClientApplication` by their own +features, there are no placeholders for them. + +### Monetization + +`client.application.entitlements` is an `EntitlementManager`, a cached manager like the others: `ENTITLEMENT_CREATE`, +`ENTITLEMENT_UPDATE` and `ENTITLEMENT_DELETE` keep `entitlements.cache` up to date (and emit `entitlementCreate`, +`entitlementUpdate` and `entitlementDelete`), so processes using the same store share it. Its `fetch` takes an +entitlement or its ID, cache first, or filters to list them, like discord.js: + +```ts +const { entitlements } = client.application; + +await entitlements.fetch(entitlementId); // one, from the cache or the API +await entitlements.fetch(entitlementId, { force: true }); +await entitlements.fetch({ user, skus: [sku], excludeEnded: true, limit: 50 }); // a Collection, from the API + +await entitlements.createTest({ sku, user }); // or { sku, guild } +await entitlements.deleteTest(entitlement); +await entitlements.consume(entitlement); // one-time purchase consumables, also entitlement.consume() + +entitlement.isActive(); // not deleted, and not ended +entitlement.isTest(); +entitlement.isGuildSubscription(); // or isUserSubscription() +``` + +`application.fetchSKUs()` resolves to a `Collection` of `SKU`s, each with `sku.flags` (a `SKUFlagsBitField`) and +`sku.subscriptions`, a `SubscriptionManager` of that SKU (`application.subscriptions(skuOrId)` is the same when only +the ID of the SKU is known). Subscriptions are fetch-only: `subscriptions.fetch(subscriptionId)`, or +`subscriptions.fetch({ user, limit, before, after })` for a `Collection`. Nothing keeps a cached subscription up to +date for the bot, so they are never cached by the managers. ### Messages diff --git a/packages/plugin-gateway/src/errors/Messages.ts b/packages/plugin-gateway/src/errors/Messages.ts index 0f5b1fc1..dcda5985 100644 --- a/packages/plugin-gateway/src/errors/Messages.ts +++ b/packages/plugin-gateway/src/errors/Messages.ts @@ -116,5 +116,8 @@ export const GatewayErrorMessages = { VoiceStateNotOwn: "Only the bot's own voice state can request to speak", VoiceStateGuildUnknown: "This voice state does not belong to a guild", + EntitlementCreateInvalidOwner: + "You must provide either a guild or a user to create an entitlement, but not both", + WebhookTokenUnavailable: (webhookId: string) => `Webhook ${webhookId} has no token to post with`, } as const satisfies Record string)>; diff --git a/packages/plugin-gateway/src/managers/EntitlementManager.ts b/packages/plugin-gateway/src/managers/EntitlementManager.ts new file mode 100644 index 00000000..734bc03a --- /dev/null +++ b/packages/plugin-gateway/src/managers/EntitlementManager.ts @@ -0,0 +1,286 @@ +import { Collection } from "@discordjs/collection"; +import type { CacheEntityTypes } from "@wolfstar/plugin-cache"; +import { + EntitlementOwnerType, + type RESTGetAPIEntitlementsQuery, + type RESTPostAPIEntitlementJSONBody, +} from "discord-api-types/v10"; +import { GatewayTypeError } from "../errors/GatewayError.js"; +import type { GatewayClient } from "../GatewayClient.js"; +import type { Guild } from "../structures/guilds/Guild.js"; +import { Entitlement } from "../structures/monetization/Entitlement.js"; +import type { EntitlementResolvable, SKUResolvable, UserResolvable } from "../types.js"; +import { resolveSKUId } from "../util/Util.js"; +import { CachedManager, type FetchOptions } from "./CachedManager.js"; + +/** + * The options to list the entitlements of the application with. + * + * @remarks + * With both `before` and `after`, Discord only respects `before`. + */ +export interface EntitlementFetchOptions { + /** + * How many entitlements to fetch, up to 100. + */ + limit?: number; + /** + * Only the entitlements of this guild. + */ + guild?: Guild | string; + /** + * Only the entitlements of this user. + */ + user?: UserResolvable; + /** + * Only the entitlements to these SKUs. + */ + skus?: readonly SKUResolvable[]; + /** + * Whether to leave out the entitlements that ended. + */ + excludeEnded?: boolean; + /** + * Whether to leave out the entitlements that were deleted. + */ + excludeDeleted?: boolean; + /** + * Whether to store the fetched entitlements in the cache. + * + * @default true + */ + cache?: boolean; + /** + * Only the entitlements before this entitlement ID. + */ + before?: string; + /** + * Only the entitlements after this entitlement ID. + */ + after?: string; +} + +/** + * The options to create a test entitlement with. It is for a guild or for a user: exactly one of them. + */ +export interface EntitlementCreateOptions { + /** + * The SKU to grant. + */ + sku: SKUResolvable; + /** + * The guild to grant it to. + */ + guild?: Guild | string; + /** + * The user to grant it to. + */ + user?: UserResolvable; +} + +/** + * Manages the entitlements of the application, reached through `client.application.entitlements`: the access users + * and guilds were granted to its SKUs. + * + * @remarks + * Entitlements are cached as `ENTITLEMENT_CREATE`, `ENTITLEMENT_UPDATE` and `ENTITLEMENT_DELETE` come in, and as they + * are fetched. + */ +export class EntitlementManager extends CachedManager< + "entitlements", + Entitlement, + [entitlementId: string] +> { + public constructor(client: GatewayClient) { + super(client, "entitlements"); + } + + protected createStructure(data: CacheEntityTypes["entitlements"]): Entitlement { + return new Entitlement(data); + } + + public keyOf(data: CacheEntityTypes["entitlements"]): string { + return data.id; + } + + public resolveKey(entitlementId: string): string { + return entitlementId; + } + + /** + * Resolves an {@link EntitlementResolvable} to an entitlement ID. + * + * @param entitlement The entitlement, or its ID. + * @returns The ID, or `null` if the value holds none. + */ + public override resolveId(entitlement: EntitlementResolvable): string | null { + return super.resolveId(entitlement as Entitlement | string); + } + + /** + * Gets an entitlement from the cache, fetching it from the API (and caching it) on a cache miss. + * + * @example + * ```typescript + * const entitlement = await client.application.entitlements.fetch(entitlementId); + * ``` + * + * @param entitlement The entitlement, or its ID. + * @param options Whether to skip the cache, and whether to store the result. + */ + public override fetch( + entitlement: EntitlementResolvable, + options?: FetchOptions, + ): Promise; + /** + * Lists the entitlements of the application from the API. + * + * @example + * ```typescript + * const active = await client.application.entitlements.fetch({ user, excludeEnded: true }); + * ``` + * + * @param options What to list. + */ + public override fetch( + options?: EntitlementFetchOptions, + ): Promise>; + public override async fetch( + entitlementOrOptions?: EntitlementResolvable | EntitlementFetchOptions, + fetchOptions?: FetchOptions, + ): Promise> { + const id = + entitlementOrOptions === undefined + ? null + : this.resolveId(entitlementOrOptions as EntitlementResolvable); + if (id !== null) return super.fetch(id, fetchOptions ?? {}); + + return this.fetchMany((entitlementOrOptions ?? {}) as EntitlementFetchOptions); + } + + /** + * Creates a test entitlement, which grants a user or a guild a SKU without a purchase. + * + * @remarks + * The entitlement is not cached by this call: Discord sends its `ENTITLEMENT_CREATE` too, which does. + * + * @param options The SKU, and the guild or user to grant it to. + * @throws {GatewayTypeError} `InvalidType` for a SKU, guild or user that does not resolve, and + * `EntitlementCreateInvalidOwner` unless exactly one of the guild and user is given. + */ + public async createTest({ sku, guild, user }: EntitlementCreateOptions): Promise { + const skuId = resolveSKUId(sku); + if (!skuId) throw new GatewayTypeError("InvalidType", "sku", "SKUResolvable"); + + if ((guild && user) || (!guild && !user)) { + throw new GatewayTypeError("EntitlementCreateInvalidOwner"); + } + + const ownerId = guild + ? this.client.guilds.resolveId(guild) + : this.client.users.resolveId(user!); + if (!ownerId) { + throw guild + ? new GatewayTypeError("InvalidType", "guild", "GuildResolvable") + : new GatewayTypeError("InvalidType", "user", "UserResolvable"); + } + + const body: RESTPostAPIEntitlementJSONBody = { + sku_id: skuId, + owner_id: ownerId, + owner_type: guild ? EntitlementOwnerType.Guild : EntitlementOwnerType.User, + }; + const entitlement = await this.client.api.monetization.createTestEntitlement( + this.client.application.id, + body, + ); + // The response has neither of the dates: a test entitlement does not start nor end. + return this._add({ starts_at: null, ends_at: null, ...entitlement } as never, false); + } + + /** + * Deletes a test entitlement, and drops it from the cache. + * + * @param entitlement The entitlement, or its ID. + * @throws {GatewayTypeError} `InvalidType` when the value does not resolve. + */ + public async deleteTest(entitlement: EntitlementResolvable): Promise { + const id = this.requireId(entitlement); + await this.client.api.monetization.deleteTestEntitlement(this.client.application.id, id); + await this.cache.delete(this.resolveKey(id)); + } + + /** + * Marks an entitlement as consumed. Only available for one-time purchase consumable SKUs. + * + * @param entitlement The entitlement, or its ID. + * @throws {GatewayTypeError} `InvalidType` when the value does not resolve. + */ + public async consume(entitlement: EntitlementResolvable): Promise { + const id = this.requireId(entitlement); + await this.client.api.monetization.consumeEntitlement(this.client.application.id, id); + await this._patchCached(this.resolveKey(id), { consumed: true }); + } + + protected async fetchRaw(entitlementId: string) { + return this.client.api.monetization.getEntitlement(this.client.application.id, entitlementId); + } + + private async fetchMany({ + limit, + guild, + user, + skus, + excludeEnded, + excludeDeleted, + cache = true, + before, + after, + }: EntitlementFetchOptions): Promise> { + const query: RESTGetAPIEntitlementsQuery = { + limit, + guild_id: guild === undefined ? undefined : this.requireGuildId(guild), + user_id: user === undefined ? undefined : this.requireUserId(user), + sku_ids: skus?.map((sku) => this.requireSKUId(sku)).join(","), + exclude_ended: excludeEnded, + exclude_deleted: excludeDeleted, + before, + after, + }; + const entitlements = await this.client.api.monetization.getEntitlements( + this.client.application.id, + query, + ); + + const built = await Promise.all( + entitlements.map((entitlement) => this._add(entitlement, cache)), + ); + return new Collection(built.map((entitlement) => [entitlement.id, entitlement])); + } + + private requireId(entitlement: EntitlementResolvable): string { + const id = this.resolveId(entitlement); + if (id === null) { + throw new GatewayTypeError("InvalidType", "entitlement", "EntitlementResolvable"); + } + return id; + } + + private requireGuildId(guild: Guild | string): string { + const id = this.client.guilds.resolveId(guild); + if (id === null) throw new GatewayTypeError("InvalidType", "guild", "GuildResolvable"); + return id; + } + + private requireUserId(user: UserResolvable): string { + const id = this.client.users.resolveId(user); + if (id === null) throw new GatewayTypeError("InvalidType", "user", "UserResolvable"); + return id; + } + + private requireSKUId(sku: SKUResolvable): string { + const id = resolveSKUId(sku); + if (id === null) throw new GatewayTypeError("InvalidElement", "array", "skus", sku); + return id; + } +} diff --git a/packages/plugin-gateway/src/managers/SubscriptionManager.ts b/packages/plugin-gateway/src/managers/SubscriptionManager.ts new file mode 100644 index 00000000..2e12132c --- /dev/null +++ b/packages/plugin-gateway/src/managers/SubscriptionManager.ts @@ -0,0 +1,106 @@ +import { Collection } from "@discordjs/collection"; +import type { RESTGetAPISKUSubscriptionsQuery } from "discord-api-types/v10"; +import { GatewayTypeError } from "../errors/GatewayError.js"; +import type { GatewayClient } from "../GatewayClient.js"; +import { Subscription } from "../structures/monetization/Subscription.js"; +import { bindClient } from "../structures/Structure.js"; +import type { SubscriptionResolvable, UserResolvable } from "../types.js"; +import { BaseManager } from "./BaseManager.js"; + +/** + * The options to list the subscriptions to a SKU with. + * + * @remarks + * With both `before` and `after`, Discord only respects `before`. + */ +export interface SubscriptionFetchOptions { + /** + * How many subscriptions to fetch, up to 100. + */ + limit?: number; + /** + * Only the subscriptions of this user. + */ + user?: UserResolvable; + /** + * Only the subscriptions before this subscription ID. + */ + before?: string; + /** + * Only the subscriptions after this subscription ID. + */ + after?: string; +} + +/** + * Manages the subscriptions to one SKU, reached through `sku.subscriptions` and `client.application.subscriptions`. + * + * @remarks + * Subscriptions are fetched from the API and never cached: nothing keeps a cached subscription up to date for the + * bot, unlike entitlements. + */ +export class SubscriptionManager extends BaseManager { + /** + * The ID of the SKU the subscriptions are to. + */ + public readonly skuId: string; + + public constructor(client: GatewayClient, skuId: string) { + super(client); + this.skuId = skuId; + } + + /** + * Resolves a {@link SubscriptionResolvable} to a subscription ID. + * + * @param subscription The subscription, or its ID. + * @returns The ID, or `null` if the value holds none. + */ + public resolveId(subscription: SubscriptionResolvable): string | null { + if (typeof subscription === "string") return subscription; + const id = (subscription as { id?: unknown } | null)?.id; + return typeof id === "string" ? id : null; + } + + /** + * Fetches a subscription to the SKU. + * + * @param subscription The subscription, or its ID. + */ + public fetch(subscription: SubscriptionResolvable): Promise; + /** + * Lists the subscriptions to the SKU. + * + * @param options What to list. + */ + public fetch(options?: SubscriptionFetchOptions): Promise>; + public async fetch( + subscriptionOrOptions: SubscriptionResolvable | SubscriptionFetchOptions = {}, + ): Promise> { + const subscriptionId = this.resolveId(subscriptionOrOptions as SubscriptionResolvable); + if (subscriptionId !== null) { + const subscription = await this.client.api.monetization.getSKUSubscription( + this.skuId, + subscriptionId, + ); + return this.build(subscription); + } + + const { limit, user, before, after } = subscriptionOrOptions as SubscriptionFetchOptions; + const userId = user === undefined ? undefined : this.client.users.resolveId(user); + if (userId === null) throw new GatewayTypeError("InvalidType", "user", "UserResolvable"); + + const query: RESTGetAPISKUSubscriptionsQuery = { limit, user_id: userId, before, after }; + const subscriptions = await this.client.api.monetization.getSKUSubscriptions(this.skuId, query); + return new Collection( + subscriptions.map((subscription) => { + const built = this.build(subscription); + return [built.id, built]; + }), + ); + } + + private build(data: ConstructorParameters[0]): Subscription { + return bindClient(new Subscription(data), this.client); + } +} diff --git a/packages/plugin-gateway/src/managers/index.ts b/packages/plugin-gateway/src/managers/index.ts index 38054ac3..4ca78c20 100644 --- a/packages/plugin-gateway/src/managers/index.ts +++ b/packages/plugin-gateway/src/managers/index.ts @@ -6,6 +6,7 @@ export * from "./ChannelManager.js"; export * from "./ChannelMessageManager.js"; export * from "./ChannelThreadManager.js"; export * from "./DataManager.js"; +export * from "./EntitlementManager.js"; export * from "./GuildBanManager.js"; export * from "./GuildChannelManager.js"; export * from "./GuildEmojiManager.js"; @@ -27,6 +28,7 @@ export * from "./ReactionManager.js"; export * from "./ReactionUserManager.js"; export * from "./RoleManager.js"; export * from "./StageInstanceManager.js"; +export * from "./SubscriptionManager.js"; export * from "./ThreadChannelMemberManager.js"; export * from "./ThreadManager.js"; export * from "./ThreadMemberManager.js"; diff --git a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts index 2819b258..e24827ac 100644 --- a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -1,3 +1,4 @@ +import { Collection } from "@discordjs/collection"; import type { APIApplication, APIApplicationRoleConnectionMetadata, @@ -9,11 +10,17 @@ import type { OAuth2Scopes, RESTPatchCurrentApplicationJSONBody, } from "discord-api-types/v10"; +import { GatewayTypeError } from "../../errors/GatewayError.js"; +import { EntitlementManager } from "../../managers/EntitlementManager.js"; +import { SubscriptionManager } from "../../managers/SubscriptionManager.js"; +import type { SKUResolvable } from "../../types.js"; import { resolveImageOption, type ImageResolvable } from "../../util/DataResolver.js"; import { cachedGuild } from "../../util/cache.js"; import { ApplicationFlagsBitField, type ApplicationFlagsResolvable } from "../../util/flags.js"; import { PermissionsBitField, type PermissionResolvable } from "../../util/PermissionsBitField.js"; +import { resolveSKUId } from "../../util/Util.js"; import type { Guild } from "../guilds/Guild.js"; +import { SKU } from "../monetization/SKU.js"; import { bindClient, kClient, kData, kPatch } from "../Structure.js"; import { User } from "../users/User.js"; import { Application } from "./Application.js"; @@ -125,6 +132,9 @@ export class ClientApplication extends Application { // The owner is built once per payload, so that it stays the same object between reads. #owner: { team: unknown; user: unknown; value: Team | User | null } | null = null; + // Built on first use: it needs the client, which binds the application after constructing it. + #entitlements: EntitlementManager | null = null; + /** * Whether only the ID and flags of the application are known: it has neither been fetched nor edited yet. */ @@ -276,6 +286,36 @@ export class ClientApplication extends Application { return types ? [...types] : null; } + /** + * The entitlements of the application: the access users and guilds were granted to its SKUs. Cached from the + * gateway, like discord.js's `ClientApplication#entitlements`. + */ + public get entitlements(): EntitlementManager { + return (this.#entitlements ??= new EntitlementManager(this.client)); + } + + /** + * Gets the manager of the subscriptions to a SKU, which never caches them. The same as `sku.subscriptions`, for when + * only the ID of the SKU is known. + * + * @param sku The SKU, or its ID. + * @throws {GatewayTypeError} `InvalidType` when the value does not resolve. + */ + public subscriptions(sku: SKUResolvable): SubscriptionManager { + const skuId = resolveSKUId(sku); + if (!skuId) throw new GatewayTypeError("InvalidType", "sku", "SKUResolvable"); + return new SubscriptionManager(this.client, skuId); + } + + /** + * Fetches the SKUs of the application, by ID. + */ + public async fetchSKUs(): Promise> { + const skus = await this.client.api.monetization.getSKUs(this.id); + const client = this.client; + return new Collection(skus.map((data) => [data.id, bindClient(new SKU(data), client)])); + } + /** * Fetches the application from the API, and patches this structure with it. * diff --git a/packages/plugin-gateway/src/structures/index.ts b/packages/plugin-gateway/src/structures/index.ts index c375806d..4e7a30c7 100644 --- a/packages/plugin-gateway/src/structures/index.ts +++ b/packages/plugin-gateway/src/structures/index.ts @@ -6,6 +6,7 @@ export * from "./emojis/index.js"; export * from "./guilds/index.js"; export * from "./invites/index.js"; export * from "./messages/index.js"; +export * from "./monetization/index.js"; export * from "./polls/index.js"; export * from "./presences/index.js"; export * from "./soundboards/index.js"; diff --git a/packages/plugin-gateway/src/structures/monetization/Entitlement.ts b/packages/plugin-gateway/src/structures/monetization/Entitlement.ts new file mode 100644 index 00000000..3fd0b09b --- /dev/null +++ b/packages/plugin-gateway/src/structures/monetization/Entitlement.ts @@ -0,0 +1,136 @@ +import { Entitlement as BaseEntitlement } from "@discordjs/structures"; +import type { APIEntitlement } from "discord-api-types/v10"; +import { GatewayTypeError } from "../../errors/GatewayError.js"; +import { cachedGuild, cachedUser } from "../../util/cache.js"; +import { isDeepEqual } from "../../util/equal.js"; +import type { Guild } from "../guilds/Guild.js"; +import { Mixin } from "../Mixin.js"; +import { initStructure, kPatch, StructureMixin } from "../Structure.js"; +import type { User } from "../users/User.js"; + +/** + * The relations of an {@link Entitlement}, resolved from the cache. + */ +export interface EntitlementRelations { + guild?: Guild | null; + user?: User | null; +} + +export interface Entitlement extends StructureMixin {} + +/** + * A user's or a guild's access to a premium offer (a SKU) of the application: `@discordjs/structures`' + * `Entitlement`, with its guild and user, and actions through the client. + */ +export class Entitlement extends BaseEntitlement { + /** + * @param data The raw entitlement. + * @param relations The guild and user as resolved from the cache. + */ + public constructor(data: APIEntitlement, relations: EntitlementRelations = {}) { + super(data); + initStructure(this, data, relations); + } + + /** + * The guild that is granted access to the SKU, from the cache: `null` for an entitlement of a user, or when the + * guild is not cached. + */ + public get guild(): Guild | null { + return this.lazyRelation("guild", (client) => cachedGuild(client, this.guildId)); + } + + /** + * The user that is granted access to the SKU, from the cache: `null` for an entitlement of a guild, or when the + * user is not cached. + */ + public get user(): User | null { + return this.lazyRelation("user", (client) => cachedUser(client, this.userId)); + } + + /** + * The date at which the entitlement is valid from, `null` for a test entitlement. Like discord.js's + * `Entitlement#startsAt`. + */ + public get startsAt(): Date | null { + return this.startsDate; + } + + /** + * The date at which the entitlement stops being valid, `null` if it never does. Like discord.js's + * `Entitlement#endsAt`. + */ + public get endsAt(): Date | null { + return this.endsDate; + } + + public get createdAt(): Date | null { + return this.createdDate; + } + + /** + * Whether the entitlement is active: not deleted, and not ended. + */ + public isActive(): boolean { + const { endsTimestamp } = this; + return !this.deleted && (endsTimestamp === null || endsTimestamp > Date.now()); + } + + /** + * Whether the entitlement is a test one, created through `entitlements.createTest`. + */ + public isTest(): boolean { + return this.startsTimestamp === null; + } + + /** + * Whether the entitlement is a subscription of a user. + */ + public isUserSubscription(): boolean { + return !this.guildId; + } + + /** + * Whether the entitlement is a subscription of a guild. + */ + public isGuildSubscription(): boolean { + return Boolean(this.guildId); + } + + /** + * Fetches the user that is granted access to the SKU. + * + * @throws {GatewayTypeError} `IdUnresolvable` when the entitlement belongs to a guild, without a user. + */ + public fetchUser(): Promise { + const { userId } = this; + if (!userId) return Promise.reject(new GatewayTypeError("IdUnresolvable")); + return this.client.users.fetch(userId); + } + + /** + * Marks the entitlement as consumed. Only available for one-time purchase consumable SKUs. + */ + public async consume(): Promise { + await this.client.application.entitlements.consume(this.id); + this[kPatch]({ consumed: true }); + } + + /** + * Whether this entitlement has the same data as another one. `false` for anything that is neither an entitlement nor + * a raw one. + * + * @param other The entitlement, or raw entitlement, to compare with. + */ + public equals(other: unknown): boolean { + const entitlement = + other instanceof Entitlement + ? other + : typeof other === "object" && other !== null && "sku_id" in other && "id" in other + ? new Entitlement(other as APIEntitlement) + : null; + return entitlement !== null && isDeepEqual(this.toJSON(), entitlement.toJSON()); + } +} + +Mixin(Entitlement, [StructureMixin]); diff --git a/packages/plugin-gateway/src/structures/monetization/SKU.ts b/packages/plugin-gateway/src/structures/monetization/SKU.ts new file mode 100644 index 00000000..0d9f6a57 --- /dev/null +++ b/packages/plugin-gateway/src/structures/monetization/SKU.ts @@ -0,0 +1,69 @@ +import { SKU as BaseSKU } from "@discordjs/structures"; +import type { APISKU } from "discord-api-types/v10"; +import { SubscriptionManager } from "../../managers/SubscriptionManager.js"; +import { isDeepEqual } from "../../util/equal.js"; +import { SKUFlagsBitField } from "../../util/flags.js"; +import { Mixin } from "../Mixin.js"; +import { initStructure, kData, snowflakeTimestamp, StructureMixin } from "../Structure.js"; + +export interface SKU extends StructureMixin {} + +/** + * A premium offer of the application, to subscribe to or to buy: `@discordjs/structures`' `SKU`, with its + * subscriptions. + */ +export class SKU extends BaseSKU { + /** + * @param data The raw SKU. + */ + public constructor(data: APISKU) { + super(data); + initStructure(this, data, {}); + } + + /** + * The flags of the SKU. + */ + public override get flags(): Readonly { + return new SKUFlagsBitField(this[kData].flags ?? 0).freeze(); + } + + public get createdTimestamp(): number { + return snowflakeTimestamp(this.id); + } + + public get createdAt(): Date { + return new Date(this.createdTimestamp); + } + + /** + * The subscriptions to the SKU, fetched from the API and never cached. A manager is built on every access. + */ + public get subscriptions(): SubscriptionManager { + return new SubscriptionManager(this.client, this.id); + } + + /** + * The name of the SKU. + */ + public toString(): string { + return this.name; + } + + /** + * Whether this SKU has the same data as another one. `false` for anything that is neither a SKU nor a raw one. + * + * @param other The SKU, or raw SKU, to compare with. + */ + public equals(other: unknown): boolean { + const sku = + other instanceof SKU + ? other + : typeof other === "object" && other !== null && "slug" in other && "id" in other + ? new SKU(other as APISKU) + : null; + return sku !== null && isDeepEqual(this.toJSON(), sku.toJSON()); + } +} + +Mixin(SKU, [StructureMixin]); diff --git a/packages/plugin-gateway/src/structures/monetization/Subscription.ts b/packages/plugin-gateway/src/structures/monetization/Subscription.ts new file mode 100644 index 00000000..176441ef --- /dev/null +++ b/packages/plugin-gateway/src/structures/monetization/Subscription.ts @@ -0,0 +1,66 @@ +import { Subscription as BaseSubscription } from "@discordjs/structures"; +import type { APISubscription } from "discord-api-types/v10"; +import { isDeepEqual } from "../../util/equal.js"; +import { Mixin } from "../Mixin.js"; +import { initStructure, StructureMixin } from "../Structure.js"; + +export interface Subscription extends StructureMixin {} + +/** + * A user's recurring purchase of a SKU: `@discordjs/structures`' `Subscription`. + */ +export class Subscription extends BaseSubscription { + /** + * @param data The raw subscription. + */ + public constructor(data: APISubscription) { + super(data); + initStructure(this, data, {}); + } + + /** + * The time the subscription was created at, from its ID. + */ + public get createdAt(): Date | null { + return this.createdDate; + } + + /** + * The time the current period of the subscription starts at. + */ + public get currentPeriodStartAt(): Date | null { + return this.currentPeriodStartDate; + } + + /** + * The time the current period of the subscription ends at. + */ + public get currentPeriodEndAt(): Date | null { + return this.currentPeriodEndDate; + } + + /** + * The time the subscription was canceled at, `null` if it was not. + */ + public get canceledAt(): Date | null { + return this.canceledDate; + } + + /** + * Whether this subscription has the same data as another one. `false` for anything that is neither a subscription + * nor a raw one. + * + * @param other The subscription, or raw subscription, to compare with. + */ + public equals(other: unknown): boolean { + const subscription = + other instanceof Subscription + ? other + : typeof other === "object" && other !== null && "sku_ids" in other && "id" in other + ? new Subscription(other as APISubscription) + : null; + return subscription !== null && isDeepEqual(this.toJSON(), subscription.toJSON()); + } +} + +Mixin(Subscription, [StructureMixin]); diff --git a/packages/plugin-gateway/src/structures/monetization/index.ts b/packages/plugin-gateway/src/structures/monetization/index.ts new file mode 100644 index 00000000..b7c49943 --- /dev/null +++ b/packages/plugin-gateway/src/structures/monetization/index.ts @@ -0,0 +1,3 @@ +export * from "./Entitlement.js"; +export * from "./SKU.js"; +export * from "./Subscription.js"; diff --git a/packages/plugin-gateway/src/types.ts b/packages/plugin-gateway/src/types.ts index 25cc1f21..6d2d386c 100644 --- a/packages/plugin-gateway/src/types.ts +++ b/packages/plugin-gateway/src/types.ts @@ -38,6 +38,9 @@ import type { GuildInvite } from "./structures/invites/GuildInvite.js"; import type { Message } from "./structures/messages/Message.js"; import type { JSONEncodable } from "./structures/messages/MessagePayload.js"; import type { MessageReaction } from "./structures/messages/MessageReaction.js"; +import type { Entitlement } from "./structures/monetization/Entitlement.js"; +import type { SKU } from "./structures/monetization/SKU.js"; +import type { Subscription } from "./structures/monetization/Subscription.js"; import type { Activity } from "./structures/presences/Activity.js"; import type { Presence } from "./structures/presences/Presence.js"; import type { SoundboardSound } from "./structures/soundboards/SoundboardSound.js"; @@ -384,17 +387,17 @@ export type CommandOptionDataTypeResolvable = ApplicationCommandOptionType; /** * An entitlement, or its ID. */ -export type EntitlementResolvable = APIEntitlement | Snowflake; +export type EntitlementResolvable = Entitlement | APIEntitlement | Snowflake; /** * A SKU, or its ID. */ -export type SKUResolvable = APISKU | Snowflake; +export type SKUResolvable = SKU | APISKU | Snowflake; /** * A subscription, or its ID. */ -export type SubscriptionResolvable = APISubscription | Snowflake; +export type SubscriptionResolvable = Subscription | APISubscription | Snowflake; // --------------------------------------------------------------------------------------------------------------------- // Colors and dates diff --git a/packages/plugin-gateway/src/util/dispatch.ts b/packages/plugin-gateway/src/util/dispatch.ts index c6a277c5..de73f010 100644 --- a/packages/plugin-gateway/src/util/dispatch.ts +++ b/packages/plugin-gateway/src/util/dispatch.ts @@ -639,6 +639,23 @@ export const DispatchHandlers: { [Type in GatewayDispatchEvents]?: AnyDispatchHa build: (_client, data, previous) => [previous ?? null, data], }, + [GatewayDispatchEvents.EntitlementCreate]: { + event: GatewayEvents.EntitlementCreate, + build: async (client, data) => [await client.application.entitlements._build(data)], + }, + [GatewayDispatchEvents.EntitlementUpdate]: { + event: GatewayEvents.EntitlementUpdate, + before: (client, data) => previousOf(client.application.entitlements, data.id), + build: async (client, data, previous) => [ + previous ?? null, + await client.application.entitlements._build(data), + ], + }, + [GatewayDispatchEvents.EntitlementDelete]: { + event: GatewayEvents.EntitlementDelete, + build: async (client, data) => [await client.application.entitlements._build(data)], + }, + [GatewayDispatchEvents.AutoModerationActionExecution]: { event: GatewayEvents.AutoModerationActionExecution, build: async (client, data) => { diff --git a/packages/plugin-gateway/src/util/dispatchState.ts b/packages/plugin-gateway/src/util/dispatchState.ts index 1d0df509..12667197 100644 --- a/packages/plugin-gateway/src/util/dispatchState.ts +++ b/packages/plugin-gateway/src/util/dispatchState.ts @@ -153,6 +153,7 @@ export const DispatchStateCodecs: { [Type in GatewayDispatchEvents]?: DispatchSt client.guilds.integrations(data.guild_id), ), [GatewayDispatchEvents.UserUpdate]: single((client) => client.users), + [GatewayDispatchEvents.EntitlementUpdate]: single((client) => client.application.entitlements), [GatewayDispatchEvents.GuildEmojisUpdate]: list( (client, data) => client.guilds.emojis(data.guild_id), (data) => ({ guild_id: data.guild_id }), diff --git a/packages/plugin-gateway/src/util/entityManagers.ts b/packages/plugin-gateway/src/util/entityManagers.ts index 68b32a59..c0d87fed 100644 --- a/packages/plugin-gateway/src/util/entityManagers.ts +++ b/packages/plugin-gateway/src/util/entityManagers.ts @@ -11,6 +11,7 @@ export const ManagedEntityNames = [ "bans", "channels", "emojis", + "entitlements", "guilds", "integrations", "invites", @@ -43,6 +44,7 @@ const managers: { bans: (client, data) => client.guilds.bans(guildIdOf(data)), channels: (client) => client.channels, emojis: (client, data) => client.guilds.emojis(guildIdOf(data)), + entitlements: (client) => client.application.entitlements, guilds: (client) => client.guilds, integrations: (client, data) => client.guilds.integrations(guildIdOf(data)), invites: (client, data) => client.guilds.invites(guildIdOf(data)), diff --git a/packages/plugin-gateway/src/util/events.ts b/packages/plugin-gateway/src/util/events.ts index 9820d0c4..15e44a90 100644 --- a/packages/plugin-gateway/src/util/events.ts +++ b/packages/plugin-gateway/src/util/events.ts @@ -32,6 +32,7 @@ import type { Guild } from "../structures/guilds/Guild.js"; import type { GuildAuditLogsEntry } from "../structures/guilds/GuildAuditLogsEntry.js"; import type { GuildBan } from "../structures/guilds/GuildBan.js"; import type { GuildScheduledEvent } from "../structures/guilds/GuildScheduledEvent.js"; +import type { Entitlement } from "../structures/monetization/Entitlement.js"; import type { Integration } from "../structures/guilds/Integration.js"; import type { SoundboardSound } from "../structures/soundboards/SoundboardSound.js"; import type { StageInstance } from "../structures/stageInstances/StageInstance.js"; @@ -371,6 +372,19 @@ export interface GatewayEventMap { integrationCreate: [integration: Integration]; integrationUpdate: [oldIntegration: Integration | null, newIntegration: Integration]; integrationDelete: [integration: Integration | null, data: GatewayIntegrationDeleteDispatchData]; + /** + * Emitted when a user or a guild is granted a SKU of the application, by a purchase or a test entitlement. + */ + entitlementCreate: [entitlement: Entitlement]; + /** + * Emitted when an entitlement changes, e.g. a subscription renews. `oldEntitlement` is `null` when it was not + * cached. + */ + entitlementUpdate: [oldEntitlement: Entitlement | null, newEntitlement: Entitlement]; + /** + * Emitted when an entitlement is deleted, which Discord only does for test entitlements and refunds. + */ + entitlementDelete: [entitlement: Entitlement]; /** * Emitted when a member joins, leaves, or moves between voice channels, or changes their voice settings. */ @@ -525,6 +539,9 @@ export enum GatewayEvents { IntegrationCreate = "integrationCreate", IntegrationUpdate = "integrationUpdate", IntegrationDelete = "integrationDelete", + EntitlementCreate = "entitlementCreate", + EntitlementUpdate = "entitlementUpdate", + EntitlementDelete = "entitlementDelete", VoiceStateUpdate = "voiceStateUpdate", VoiceChannelStatusUpdate = "voiceChannelStatusUpdate", VoiceChannelStartTimeUpdate = "voiceChannelStartTimeUpdate", diff --git a/packages/plugin-gateway/src/util/flags.ts b/packages/plugin-gateway/src/util/flags.ts index 083dc8e9..7c0d0a51 100644 --- a/packages/plugin-gateway/src/util/flags.ts +++ b/packages/plugin-gateway/src/util/flags.ts @@ -12,6 +12,7 @@ import { GuildMemberFlags, GuildSystemChannelFlags, RoleFlags, + SKUFlags, UserFlags, type ActivityFlags, type AttachmentFlags, @@ -153,3 +154,13 @@ export type ApplicationFlagsResolvable = BitFieldResolvable { public static override readonly Flags = ApplicationFlags; } + +export type SKUFlagsString = keyof typeof SKUFlags; +export type SKUFlagsResolvable = BitFieldResolvable; + +/** + * The flags of a SKU. + */ +export class SKUFlagsBitField extends NumberFlagsBitField { + public static override readonly Flags = SKUFlags; +} diff --git a/packages/plugin-gateway/tests/client-cache.test.ts b/packages/plugin-gateway/tests/client-cache.test.ts index 60cd75e8..2e3588fd 100644 --- a/packages/plugin-gateway/tests/client-cache.test.ts +++ b/packages/plugin-gateway/tests/client-cache.test.ts @@ -196,8 +196,8 @@ describe("GatewayClient cache resolution", () => { }); test("GIVEN cacheOptions for an entity without a manager THEN its raw store is bounded", async () => { - const client = createClient({ cacheOptions: { entitlements: { maxSize: 1 } } }); - const store = client.cache!.entitlements!; + const client = createClient({ cacheOptions: { subscriptions: { maxSize: 1 } } }); + const store = client.cache!.subscriptions!; await store.set("1", { id: "1" } as never); await store.set("2", { id: "2" } as never); diff --git a/packages/plugin-gateway/tests/dispatch-state.test.ts b/packages/plugin-gateway/tests/dispatch-state.test.ts index 57f6e80a..fcfe6628 100644 --- a/packages/plugin-gateway/tests/dispatch-state.test.ts +++ b/packages/plugin-gateway/tests/dispatch-state.test.ts @@ -329,6 +329,18 @@ const sticker = (extra: object = {}) => ({ ...extra, }); +const entitlement = (extra: object = {}) => ({ + id: "500000000000000050", + sku_id: "510000000000000051", + application_id: "266624760782258186", + user_id: author.id, + type: 8, + deleted: false, + starts_at: "2026-01-01T00:00:00.000000+00:00", + ends_at: "2026-02-01T00:00:00.000000+00:00", + ...extra, +}); + const botUser: APIUser = { ...author, id: "266624760782258186", username: "bot" }; const cases: Case[] = [ @@ -546,6 +558,14 @@ const cases: Case[] = [ ], update: [GatewayDispatchEvents.GuildStickersUpdate, { guild_id: guildId, stickers: [] }], }, + { + name: "single, application manager (entitlement update)", + seed: [[GatewayDispatchEvents.EntitlementCreate, entitlement()]], + update: [ + GatewayDispatchEvents.EntitlementUpdate, + entitlement({ ends_at: "2026-03-01T00:00:00.000000+00:00" }), + ], + }, ]; describe("state round trip", () => { diff --git a/packages/plugin-gateway/tests/gateway-events.test.ts b/packages/plugin-gateway/tests/gateway-events.test.ts index 655c1890..5872e8fa 100644 --- a/packages/plugin-gateway/tests/gateway-events.test.ts +++ b/packages/plugin-gateway/tests/gateway-events.test.ts @@ -88,6 +88,9 @@ const eventMapKeys: Record = { integrationCreate: true, integrationUpdate: true, integrationDelete: true, + entitlementCreate: true, + entitlementUpdate: true, + entitlementDelete: true, voiceStateUpdate: true, voiceChannelStatusUpdate: true, voiceChannelStartTimeUpdate: true, diff --git a/packages/plugin-gateway/tests/monetization.test.ts b/packages/plugin-gateway/tests/monetization.test.ts new file mode 100644 index 00000000..f049194d --- /dev/null +++ b/packages/plugin-gateway/tests/monetization.test.ts @@ -0,0 +1,655 @@ +import { WebSocketShardEvents } from "@discordjs/ws"; +import { + EntitlementOwnerType, + EntitlementType, + GatewayDispatchEvents, + GatewayOpcodes, + SKUFlags, + SKUType, + SubscriptionStatus, + type APIEntitlement, + type APISKU, + type APISubscription, + type GatewayDispatchPayload, +} from "discord-api-types/v10"; +import { Collection } from "@discordjs/collection"; +import { createRedisCache } from "@wolfstar/plugin-cache"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { FakeRedis } from "../../../tests/fixtures/FakeRedis.js"; +import { + bindClient, + ClientApplication, + Entitlement, + EntitlementManager, + GatewayClient, + SKU, + SKUFlagsBitField, + Subscription, + SubscriptionManager, + type GatewayEventName, +} from "../src/index.js"; +import { cacheModes, type CacheMode } from "./fixtures/cacheModes.js"; + +const clientId = "266624760782258186"; +const guildId = "100000000000000010"; +const userId = "600000000000000600"; +const skuId = "510000000000000051"; +const entitlementId = "500000000000000050"; +const subscriptionId = "520000000000000052"; + +const user = { id: userId, username: "wolf", discriminator: "0", global_name: null, avatar: null }; + +function entitlementData(extra: Partial = {}): APIEntitlement { + return { + id: entitlementId, + sku_id: skuId, + application_id: clientId, + user_id: userId, + type: EntitlementType.ApplicationSubscription, + deleted: false, + starts_at: "2026-01-01T00:00:00.000000+00:00", + ends_at: "2999-01-01T00:00:00.000000+00:00", + ...extra, + }; +} + +function skuData(extra: Partial = {}): APISKU { + return { + id: skuId, + type: SKUType.Subscription, + application_id: clientId, + name: "Howl+", + slug: "howl-plus", + flags: SKUFlags.Available | SKUFlags.UserSubscription, + ...extra, + }; +} + +function subscriptionData(extra: Partial = {}): APISubscription { + return { + id: subscriptionId, + user_id: userId, + sku_ids: [skuId], + entitlement_ids: [entitlementId], + renewal_sku_ids: null, + current_period_start: "2026-01-01T00:00:00.000000+00:00", + current_period_end: "2026-02-01T00:00:00.000000+00:00", + status: SubscriptionStatus.Active, + canceled_at: null, + ...extra, + }; +} + +function createClient(options: Partial[0]> = {}) { + return new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId, + intents: 0, + ...options, + }); +} + +async function dispatch(client: GatewayClient, t: GatewayDispatchEvents, d: unknown) { + const payload = { op: GatewayOpcodes.Dispatch, s: 1, t, d } as GatewayDispatchPayload; + client.gateway.emit(WebSocketShardEvents.Dispatch, payload, 0); + await client.idle(); +} + +function record(client: GatewayClient, ...events: GatewayEventName[]) { + const calls: [string, ...unknown[]][] = []; + for (const event of events) { + client.on(event, ((...args: unknown[]) => void calls.push([event, ...args])) as never); + } + return calls; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("Entitlement", () => { + test("GIVEN a user entitlement THEN it exposes the raw fields and the dates", () => { + const entitlement = new Entitlement(entitlementData()); + + expect(entitlement.id).toBe(entitlementId); + expect(entitlement.skuId).toBe(skuId); + expect(entitlement.userId).toBe(userId); + expect(entitlement.guildId).toBeUndefined(); + expect(entitlement.startsTimestamp).toBe(Date.parse("2026-01-01T00:00:00.000Z")); + expect(entitlement.startsAt).toEqual(new Date("2026-01-01T00:00:00.000Z")); + expect(entitlement.endsAt).toEqual(new Date("2999-01-01T00:00:00.000Z")); + expect(entitlement.createdAt).toBeInstanceOf(Date); + expect(entitlement.isUserSubscription()).toBe(true); + expect(entitlement.isGuildSubscription()).toBe(false); + expect(entitlement.isTest()).toBe(false); + expect(entitlement.isActive()).toBe(true); + }); + + test("GIVEN a guild test entitlement THEN it is a guild subscription, a test, and active until deleted", () => { + const entitlement = new Entitlement( + entitlementData({ + user_id: undefined, + guild_id: guildId, + starts_at: null, + ends_at: null, + type: EntitlementType.TestModePurchase, + }), + ); + + expect(entitlement.isGuildSubscription()).toBe(true); + expect(entitlement.isUserSubscription()).toBe(false); + expect(entitlement.isTest()).toBe(true); + expect(entitlement.startsAt).toBeNull(); + expect(entitlement.endsAt).toBeNull(); + expect(entitlement.isActive()).toBe(true); + expect(new Entitlement({ ...entitlement.toJSON(), deleted: true }).isActive()).toBe(false); + }); + + test("GIVEN an end date in the past THEN the entitlement is not active", () => { + const ended = new Entitlement(entitlementData({ ends_at: "2000-01-01T00:00:00.000000+00:00" })); + expect(ended.isActive()).toBe(false); + + vi.useFakeTimers(); + vi.setSystemTime(Date.parse("2026-06-01T00:00:00.000Z")); + expect( + new Entitlement(entitlementData({ ends_at: "2026-07-01T00:00:00.000000+00:00" })).isActive(), + ).toBe(true); + vi.setSystemTime(Date.parse("2026-08-01T00:00:00.000Z")); + expect( + new Entitlement(entitlementData({ ends_at: "2026-07-01T00:00:00.000000+00:00" })).isActive(), + ).toBe(false); + }); + + test("GIVEN a cached guild and user THEN the relations read them from the cache", async () => { + const client = createClient(); + await client.guilds._add({ id: guildId, name: "Pack" } as never); + await client.users._add(user); + const entitlement = await client.application.entitlements._add( + entitlementData({ guild_id: guildId }), + ); + + expect(entitlement.guild?.id).toBe(guildId); + expect(entitlement.user?.id).toBe(userId); + expect(new Entitlement(entitlementData()).guild).toBeNull(); + }); + + test("GIVEN equals THEN it compares the data, and is false for foreign values", () => { + const entitlement = new Entitlement(entitlementData()); + + expect(entitlement.equals(new Entitlement(entitlementData()))).toBe(true); + expect(entitlement.equals(entitlementData())).toBe(true); + expect(entitlement.equals(entitlementData({ deleted: true }))).toBe(false); + expect(entitlement.equals(new Entitlement(entitlementData({ ends_at: null })))).toBe(false); + expect(entitlement.equals(null)).toBe(false); + expect(entitlement.equals("500000000000000050")).toBe(false); + expect(entitlement.equals({ id: entitlementId })).toBe(false); + expect(entitlement.equals(new Subscription(subscriptionData()))).toBe(false); + }); + + test("GIVEN fetchUser THEN it fetches the user, and rejects without one", async () => { + const client = createClient(); + const getUser = vi.spyOn(client.api.users, "get").mockResolvedValue(user as never); + const entitlement = await client.application.entitlements._add(entitlementData()); + + expect((await entitlement.fetchUser()).id).toBe(userId); + expect(getUser).toHaveBeenCalledWith(userId); + + const guildOnly = new Entitlement(entitlementData({ user_id: undefined, guild_id: guildId })); + await expect(guildOnly.fetchUser()).rejects.toMatchObject({ code: "IdUnresolvable" }); + }); +}); + +describe("SKU", () => { + test("GIVEN a SKU THEN it has the fields and the flags of discord.js", () => { + const sku = new SKU(skuData()); + + expect(sku.id).toBe(skuId); + expect(sku.name).toBe("Howl+"); + expect(sku.slug).toBe("howl-plus"); + expect(sku.type).toBe(SKUType.Subscription); + expect(sku.applicationId).toBe(clientId); + expect(sku.flags).toBeInstanceOf(SKUFlagsBitField); + expect(sku.flags.has("Available")).toBe(true); + expect(sku.flags.has("GuildSubscription")).toBe(false); + expect(sku.createdAt).toEqual(new Date(sku.createdTimestamp)); + expect(sku.toString()).toBe("Howl+"); + }); + + test("GIVEN equals THEN it is false for foreign values", () => { + const sku = new SKU(skuData()); + + expect(sku.equals(new SKU(skuData()))).toBe(true); + expect(sku.equals(skuData())).toBe(true); + expect(sku.equals(skuData({ name: "Other" }))).toBe(false); + expect(sku.equals(undefined)).toBe(false); + expect(sku.equals({ id: skuId })).toBe(false); + expect(sku.equals(new Entitlement(entitlementData()))).toBe(false); + }); +}); + +describe("Subscription", () => { + test("GIVEN a subscription THEN it exposes its fields and dates", () => { + const subscription = new Subscription( + subscriptionData({ canceled_at: "2026-01-15T00:00:00.000000+00:00", country: "IT" }), + ); + + expect(subscription.id).toBe(subscriptionId); + expect(subscription.userId).toBe(userId); + expect(subscription.skuIds).toEqual([skuId]); + expect(subscription.entitlementIds).toEqual([entitlementId]); + expect(subscription.renewalSkuIds).toBeNull(); + expect(subscription.status).toBe(SubscriptionStatus.Active); + expect(subscription.country).toBe("IT"); + expect(subscription.currentPeriodStartAt).toEqual(new Date("2026-01-01T00:00:00.000Z")); + expect(subscription.currentPeriodEndAt).toEqual(new Date("2026-02-01T00:00:00.000Z")); + expect(subscription.canceledAt).toEqual(new Date("2026-01-15T00:00:00.000Z")); + expect(subscription.createdAt).toBeInstanceOf(Date); + expect(new Subscription(subscriptionData()).canceledAt).toBeNull(); + }); + + test("GIVEN equals THEN it is false for foreign values", () => { + const subscription = new Subscription(subscriptionData()); + + expect(subscription.equals(new Subscription(subscriptionData()))).toBe(true); + expect(subscription.equals(subscriptionData())).toBe(true); + expect(subscription.equals(subscriptionData({ status: SubscriptionStatus.Ending }))).toBe( + false, + ); + expect(subscription.equals(null)).toBe(false); + expect(subscription.equals({ id: subscriptionId })).toBe(false); + expect(subscription.equals(new SKU(skuData()))).toBe(false); + }); +}); + +describe("client.application.entitlements", () => { + test("GIVEN the application THEN it holds one entitlement manager", () => { + const client = createClient(); + + expect(client.application).toBeInstanceOf(ClientApplication); + expect(client.application.entitlements).toBeInstanceOf(EntitlementManager); + expect(client.application.entitlements).toBe(client.application.entitlements); + expect(client.application.entitlements.client).toBe(client); + }); + + test("GIVEN an ID THEN fetch reads the cache first, and force or a miss calls the API", async () => { + const client = createClient(); + const getEntitlement = vi + .spyOn(client.api.monetization, "getEntitlement") + .mockResolvedValue(entitlementData()); + const { entitlements } = client.application; + + const fetched = await entitlements.fetch(entitlementId); + expect(getEntitlement).toHaveBeenCalledWith(clientId, entitlementId); + expect(fetched).toBeInstanceOf(Entitlement); + expect(fetched.client).toBe(client); + expect(await entitlements.cache.get(entitlementId)).toBe(fetched); + + expect(await entitlements.fetch(entitlementId)).toBe(fetched); + expect(await entitlements.fetch(fetched)).toBe(fetched); + expect(getEntitlement).toHaveBeenCalledTimes(1); + + await entitlements.fetch(entitlementId, { force: true }); + await entitlements.refresh(entitlementId); + expect(getEntitlement).toHaveBeenCalledTimes(3); + }); + + test("GIVEN cache false THEN a fetched entitlement is not stored", async () => { + const client = createClient(); + vi.spyOn(client.api.monetization, "getEntitlement").mockResolvedValue(entitlementData()); + const { entitlements } = client.application; + + const fetched = await entitlements.fetch(entitlementId, { cache: false }); + + expect(fetched.id).toBe(entitlementId); + expect(await entitlements.cache.get(entitlementId)).toBeUndefined(); + }); + + test("GIVEN no options THEN fetch lists the entitlements as a Collection, and caches them", async () => { + const client = createClient(); + const other = entitlementData({ id: "500000000000000051" }); + const getEntitlements = vi + .spyOn(client.api.monetization, "getEntitlements") + .mockResolvedValue([entitlementData(), other]); + const { entitlements } = client.application; + + const listed = await entitlements.fetch(); + + expect(getEntitlements).toHaveBeenCalledWith(clientId, { + limit: undefined, + guild_id: undefined, + user_id: undefined, + sku_ids: undefined, + exclude_ended: undefined, + exclude_deleted: undefined, + before: undefined, + after: undefined, + }); + expect(listed).toBeInstanceOf(Collection); + expect([...listed.keys()]).toEqual([entitlementId, "500000000000000051"]); + expect(listed.get(entitlementId)).toBeInstanceOf(Entitlement); + expect(await entitlements.cache.get("500000000000000051")).toBe( + listed.get("500000000000000051"), + ); + }); + + test("GIVEN filters THEN fetch sends them as the query, resolving the guild, user, and SKUs", async () => { + const client = createClient(); + const getEntitlements = vi + .spyOn(client.api.monetization, "getEntitlements") + .mockResolvedValue([entitlementData()]); + const { entitlements } = client.application; + const guild = await client.guilds._add({ id: guildId, name: "Pack" } as never); + const sku = new SKU(skuData()); + + const listed = await entitlements.fetch({ + limit: 50, + guild, + user: userId, + skus: [sku, "530000000000000053", { ...skuData(), id: "540000000000000054" }], + excludeEnded: true, + excludeDeleted: false, + before: "9", + after: "1", + cache: false, + }); + + expect(getEntitlements).toHaveBeenCalledWith(clientId, { + limit: 50, + guild_id: guildId, + user_id: userId, + sku_ids: `${skuId},530000000000000053,540000000000000054`, + exclude_ended: true, + exclude_deleted: false, + before: "9", + after: "1", + }); + expect(listed.size).toBe(1); + expect(await entitlements.cache.get(entitlementId)).toBeUndefined(); + }); + + test("GIVEN an unresolvable filter THEN fetch rejects instead of listing everything", async () => { + const client = createClient(); + const getEntitlements = vi.spyOn(client.api.monetization, "getEntitlements"); + const { entitlements } = client.application; + + await expect(entitlements.fetch({ user: {} as never })).rejects.toMatchObject({ + code: "InvalidType", + }); + await expect(entitlements.fetch({ guild: {} as never })).rejects.toMatchObject({ + code: "InvalidType", + }); + await expect(entitlements.fetch({ skus: [{} as never] })).rejects.toMatchObject({ + code: "InvalidElement", + }); + expect(getEntitlements).not.toHaveBeenCalled(); + }); + + test("GIVEN createTest THEN it posts the owner of the SKU, and builds an uncached entitlement", async () => { + const client = createClient(); + const createTestEntitlement = vi + .spyOn(client.api.monetization, "createTestEntitlement") + .mockResolvedValue({ + id: entitlementId, + sku_id: skuId, + application_id: clientId, + user_id: userId, + type: EntitlementType.TestModePurchase, + deleted: false, + }); + const { entitlements } = client.application; + + const byUser = await entitlements.createTest({ sku: skuId, user: userId }); + expect(createTestEntitlement).toHaveBeenLastCalledWith(clientId, { + sku_id: skuId, + owner_id: userId, + owner_type: EntitlementOwnerType.User, + }); + expect(byUser).toBeInstanceOf(Entitlement); + expect(byUser.isTest()).toBe(true); + expect(await entitlements.cache.get(entitlementId)).toBeUndefined(); + + await entitlements.createTest({ sku: new SKU(skuData()), guild: guildId }); + expect(createTestEntitlement).toHaveBeenLastCalledWith(clientId, { + sku_id: skuId, + owner_id: guildId, + owner_type: EntitlementOwnerType.Guild, + }); + }); + + test("GIVEN a bad createTest THEN it rejects without a request", async () => { + const client = createClient(); + const createTestEntitlement = vi.spyOn(client.api.monetization, "createTestEntitlement"); + const { entitlements } = client.application; + + await expect(entitlements.createTest({ sku: {} as never, user: userId })).rejects.toMatchObject( + { code: "InvalidType" }, + ); + await expect(entitlements.createTest({ sku: skuId })).rejects.toMatchObject({ + code: "EntitlementCreateInvalidOwner", + }); + await expect( + entitlements.createTest({ sku: skuId, user: userId, guild: guildId }), + ).rejects.toMatchObject({ code: "EntitlementCreateInvalidOwner" }); + await expect(entitlements.createTest({ sku: skuId, user: {} as never })).rejects.toMatchObject({ + code: "InvalidType", + }); + await expect(entitlements.createTest({ sku: skuId, guild: {} as never })).rejects.toMatchObject( + { code: "InvalidType" }, + ); + expect(createTestEntitlement).not.toHaveBeenCalled(); + }); + + test("GIVEN deleteTest THEN it deletes the entitlement and drops it from the cache", async () => { + const client = createClient(); + const deleteTestEntitlement = vi + .spyOn(client.api.monetization, "deleteTestEntitlement") + .mockResolvedValue(undefined); + const { entitlements } = client.application; + const cached = await entitlements._add(entitlementData()); + + await entitlements.deleteTest(cached); + + expect(deleteTestEntitlement).toHaveBeenCalledWith(clientId, entitlementId); + expect(await entitlements.cache.get(entitlementId)).toBeUndefined(); + await expect(entitlements.deleteTest({} as never)).rejects.toMatchObject({ + code: "InvalidType", + }); + }); + + test("GIVEN consume THEN it consumes the entitlement and marks the cached one", async () => { + const client = createClient(); + const consumeEntitlement = vi + .spyOn(client.api.monetization, "consumeEntitlement") + .mockResolvedValue(undefined); + const { entitlements } = client.application; + const cached = await entitlements._add(entitlementData()); + expect(cached.consumed).toBeUndefined(); + + await entitlements.consume(entitlementId); + + expect(consumeEntitlement).toHaveBeenCalledWith(clientId, entitlementId); + expect((await entitlements.cache.get(entitlementId))?.consumed).toBe(true); + await expect(entitlements.consume({} as never)).rejects.toMatchObject({ code: "InvalidType" }); + }); + + test("GIVEN entitlement.consume THEN it consumes through the manager and patches itself", async () => { + const client = createClient(); + const consumeEntitlement = vi + .spyOn(client.api.monetization, "consumeEntitlement") + .mockResolvedValue(undefined); + const detached = await client.application.entitlements._add(entitlementData(), false); + + await detached.consume(); + + expect(consumeEntitlement).toHaveBeenCalledWith(clientId, entitlementId); + expect(detached.consumed).toBe(true); + }); +}); + +describe.each(Object.keys(cacheModes) as CacheMode[])( + "entitlement events with %s cache", + (mode) => { + const events = ["entitlementCreate", "entitlementUpdate", "entitlementDelete"] as const; + + test("GIVEN the dispatches THEN the cache follows them and the events carry the structures", async () => { + const client = createClient(cacheModes[mode]()); + const calls = record(client, ...events); + const { entitlements } = client.application; + + await dispatch(client, GatewayDispatchEvents.EntitlementCreate, entitlementData()); + await dispatch( + client, + GatewayDispatchEvents.EntitlementUpdate, + entitlementData({ ends_at: "2999-06-01T00:00:00.000000+00:00" }), + ); + const cachedAfterUpdate = await entitlements.cache.get(entitlementId); + await dispatch(client, GatewayDispatchEvents.EntitlementDelete, entitlementData()); + + expect(calls.map(([event]) => event)).toEqual([ + "entitlementCreate", + "entitlementUpdate", + "entitlementDelete", + ]); + const [[, created], [, old, updated], [, deleted]] = calls as [ + [string, Entitlement], + [string, Entitlement | null, Entitlement], + [string, Entitlement], + ]; + expect(created).toBeInstanceOf(Entitlement); + expect(created.id).toBe(entitlementId); + // Without a cache there is nothing to compare an update with. + if (mode === "none") { + expect(old).toBeNull(); + expect(cachedAfterUpdate).toBeUndefined(); + } else if (mode === "partial") { + // The partial cache holds users and guilds only. + expect(old).toBeNull(); + expect(cachedAfterUpdate).toBeUndefined(); + } else { + expect(old?.endsAt).toEqual(new Date("2999-01-01T00:00:00.000Z")); + expect(cachedAfterUpdate?.endsAt).toEqual(new Date("2999-06-01T00:00:00.000Z")); + } + expect(updated.endsAt).toEqual(new Date("2999-06-01T00:00:00.000Z")); + expect(deleted.id).toBe(entitlementId); + expect(await entitlements.cache.get(entitlementId)).toBeUndefined(); + }); + }, +); + +describe("entitlements in an asynchronous store", () => { + test("GIVEN a Redis cache THEN dispatches and fetches go through the shared store", async () => { + const redis = new FakeRedis(); + const client = createClient({ cache: createRedisCache({ redis }) }); + const peer = createClient({ cache: createRedisCache({ redis }) }); + const calls = record(client, "entitlementUpdate"); + + await dispatch(client, GatewayDispatchEvents.EntitlementCreate, entitlementData()); + await dispatch( + client, + GatewayDispatchEvents.EntitlementUpdate, + entitlementData({ deleted: true }), + ); + + // Another process sharing the store reads what the first one cached, without calling the API. + const getEntitlement = vi.spyOn(peer.api.monetization, "getEntitlement"); + const shared = await peer.application.entitlements.fetch(entitlementId); + expect(getEntitlement).not.toHaveBeenCalled(); + expect(shared.deleted).toBe(true); + expect(shared.startsAt).toEqual(new Date("2026-01-01T00:00:00.000Z")); + + const [[, old, updated]] = calls as [[string, Entitlement, Entitlement]]; + expect(old.deleted).toBe(false); + expect(updated.deleted).toBe(true); + }); +}); + +describe("SubscriptionManager", () => { + test("GIVEN a SKU THEN subscriptions is a manager of that SKU, built on each access", () => { + const client = createClient(); + const sku = bindClient(new SKU(skuData()), client); + + expect(sku.subscriptions).toBeInstanceOf(SubscriptionManager); + expect(sku.subscriptions.skuId).toBe(skuId); + expect(sku.subscriptions.client).toBe(client); + }); + + test("GIVEN an ID or a subscription THEN fetch gets that subscription of the SKU", async () => { + const client = createClient(); + const getSKUSubscription = vi + .spyOn(client.api.monetization, "getSKUSubscription") + .mockResolvedValue(subscriptionData()); + const subscriptions = client.application.subscriptions(skuId); + + const fetched = await subscriptions.fetch(subscriptionId); + expect(getSKUSubscription).toHaveBeenLastCalledWith(skuId, subscriptionId); + expect(fetched).toBeInstanceOf(Subscription); + expect(fetched.client).toBe(client); + + await subscriptions.fetch(fetched); + await subscriptions.fetch(subscriptionData()); + expect(getSKUSubscription).toHaveBeenCalledTimes(3); + }); + + test("GIVEN options THEN fetch lists the subscriptions of the SKU as a Collection, without caching", async () => { + const client = createClient(); + const getSKUSubscriptions = vi + .spyOn(client.api.monetization, "getSKUSubscriptions") + .mockResolvedValue([subscriptionData(), subscriptionData({ id: "520000000000000053" })]); + const subscriptions = client.application.subscriptions(new SKU(skuData())); + + const listed = await subscriptions.fetch({ user: userId, limit: 10, before: "9", after: "1" }); + + expect(getSKUSubscriptions).toHaveBeenLastCalledWith(skuId, { + limit: 10, + user_id: userId, + before: "9", + after: "1", + }); + expect(listed).toBeInstanceOf(Collection); + expect([...listed.keys()]).toEqual([subscriptionId, "520000000000000053"]); + + await subscriptions.fetch(); + expect(getSKUSubscriptions).toHaveBeenLastCalledWith(skuId, { + limit: undefined, + user_id: undefined, + before: undefined, + after: undefined, + }); + expect("cache" in subscriptions).toBe(false); + }); + + test("GIVEN an unresolvable user or SKU THEN it rejects without a request", async () => { + const client = createClient(); + const getSKUSubscriptions = vi.spyOn(client.api.monetization, "getSKUSubscriptions"); + + await expect( + client.application.subscriptions(skuId).fetch({ user: {} as never }), + ).rejects.toMatchObject({ code: "InvalidType" }); + expect(() => client.application.subscriptions({} as never)).toThrow( + expect.objectContaining({ code: "InvalidType" }), + ); + expect(getSKUSubscriptions).not.toHaveBeenCalled(); + }); +}); + +describe("client.application.fetchSKUs", () => { + test("GIVEN the SKUs of the application THEN they are SKUs bound to the client, by ID", async () => { + const client = createClient(); + const getSKUs = vi + .spyOn(client.api.monetization, "getSKUs") + .mockResolvedValue([skuData(), skuData({ id: "510000000000000052", name: "Howl++" })]); + + const skus = await client.application.fetchSKUs(); + + expect(getSKUs).toHaveBeenCalledWith(clientId); + expect(skus).toBeInstanceOf(Collection); + expect([...skus.keys()]).toEqual([skuId, "510000000000000052"]); + const sku = skus.get(skuId)!; + expect(sku).toBeInstanceOf(SKU); + expect(sku.client).toBe(client); + expect(sku.subscriptions.client).toBe(client); + expect(sku.subscriptions.skuId).toBe(skuId); + }); +}); diff --git a/packages/plugin-gateway/tests/types/monetization.ts b/packages/plugin-gateway/tests/types/monetization.ts new file mode 100644 index 00000000..84b9ea7d --- /dev/null +++ b/packages/plugin-gateway/tests/types/monetization.ts @@ -0,0 +1,76 @@ +// Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: the monetization +// managers return the types of discord.js's `EntitlementManager` and `SubscriptionManager`. +import type { Collection } from "@discordjs/collection"; +import type { + ClientApplication, + Entitlement, + EntitlementManager, + GatewayClient, + Guild, + SKU, + SKUFlagsBitField, + Subscription, + SubscriptionManager, + User, +} from "../../src/index.js"; + +declare const client: GatewayClient; +declare const application: ClientApplication; +declare const entitlement: Entitlement; +declare const sku: SKU; +declare const guild: Guild; +declare const user: User; + +export const entitlements: EntitlementManager = client.application.entitlements; + +// One entitlement by ID or structure, with the cache options; many with the filters. +export const one: Promise = entitlements.fetch("1"); +export const oneForced: Promise = entitlements.fetch(entitlement, { force: true }); +export const many: Promise> = entitlements.fetch(); +export const filtered: Promise> = entitlements.fetch({ + guild, + user, + skus: [sku, "1"], + excludeEnded: true, + excludeDeleted: true, + limit: 10, + before: "1", + cache: false, +}); +export const refreshed: Promise = entitlements.refresh("1"); + +export const created: Promise = entitlements.createTest({ sku, user }); +export const createdForGuild: Promise = entitlements.createTest({ + sku: "1", + guild: "2", +}); +export const deleted: Promise = entitlements.deleteTest(entitlement); +export const consumed: Promise = entitlements.consume("1"); +export const consumedBy: Promise = entitlement.consume(); + +export const cached: Promise | Entitlement | undefined = + entitlements.cache.get("1"); + +// Subscriptions: one by ID, or the list of a SKU. +export const subscriptions: SubscriptionManager = sku.subscriptions; +export const subscription: Promise = subscriptions.fetch("1"); +export const subscriptionList: Promise> = subscriptions.fetch({ + user, + limit: 10, + after: "1", +}); +export const allSubscriptions: Promise> = subscriptions.fetch(); +export const ofApplication: SubscriptionManager = application.subscriptions(sku); + +export const skus: Promise> = application.fetchSKUs(); +export const flags: Readonly = sku.flags; + +export const startsAt: Date | null = entitlement.startsAt; +export const entitlementGuild: Guild | null = entitlement.guild; + +// @ts-expect-error a test entitlement needs a guild or a user, and a SKU +void entitlements.createTest({ user }); +// @ts-expect-error a subscription is fetched by ID, not by a number +void subscriptions.fetch(1); +// @ts-expect-error the entitlements of the application are read-only +client.application.entitlements = entitlements;