From fc5ba6be704ee8e0967381af37d914fbe524dfaa Mon Sep 17 00:00:00 2001 From: Asad <105254706+AsadHumayun@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:56:06 +0000 Subject: [PATCH] feat(structures): add Subscription structure (#11399) * feat(structure): update barrel exports for new structure * chore(structure): add new symbols for the Subscription structure * feat(structure): add Subscription structure * docs(structure): correct typos * chore(structure): add default attributes on class params * fix(structures): correctly expose [..]Ids properties and update docs * fix(structures): add canceled_at to DataTemplate * docs(structures): update doc clarity on canceledAt getter - @almeidx This was a suggestion by Almedia. Co-authored-by: Almeida * style: fix --------- Co-authored-by: Almeida Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/structures/src/index.ts | 1 + .../src/subscriptions/Subscription.ts | 177 ++++++++++++++++++ .../structures/src/subscriptions/index.ts | 1 + packages/structures/src/utils/symbols.ts | 4 + 4 files changed, 183 insertions(+) create mode 100644 packages/structures/src/subscriptions/Subscription.ts create mode 100644 packages/structures/src/subscriptions/index.ts diff --git a/packages/structures/src/index.ts b/packages/structures/src/index.ts index d294ceeea..6fa8d797f 100644 --- a/packages/structures/src/index.ts +++ b/packages/structures/src/index.ts @@ -10,6 +10,7 @@ export * from './polls/index.js'; export * from './stickers/index.js'; export * from './users/index.js'; export * from './Structure.js'; +export * from './subscriptions/index.js'; export * from './Mixin.js'; export * from './utils/optimization.js'; export type * from './utils/types.js'; diff --git a/packages/structures/src/subscriptions/Subscription.ts b/packages/structures/src/subscriptions/Subscription.ts new file mode 100644 index 000000000..d2d5cc259 --- /dev/null +++ b/packages/structures/src/subscriptions/Subscription.ts @@ -0,0 +1,177 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import type { APISubscription, SubscriptionStatus } from 'discord-api-types/v10'; +import { Structure } from '../Structure.js'; +import { + kData, + kCurrentPeriodStartTimestamp, + kCurrentPeriodEndTimestamp, + kCanceledTimestamp, +} from '../utils/symbols.js'; +import { isIdSet } from '../utils/type-guards.js'; +import type { Partialize } from '../utils/types.js'; + +/** + * Represents any subscription on Discord. + * + * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate` + */ +export class Subscription< + Omitted extends keyof APISubscription | '' = 'canceled_at' | 'current_period_end' | 'current_period_start', +> extends Structure { + /** + * The template used for removing data from the raw data stored for each subscription + */ + public static override readonly DataTemplate: Partial = { + set current_period_start(_: string) {}, + set current_period_end(_: string) {}, + set canceled_at(_: string) {}, + }; + + protected [kCurrentPeriodStartTimestamp]: number | null = null; + + protected [kCurrentPeriodEndTimestamp]: number | null = null; + + protected [kCanceledTimestamp]: number | null = null; + + /** + * @param data - The raw data received from the API for the subscription + */ + public constructor(data: Partialize) { + super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.optimizeData} + */ + protected override optimizeData(data: Partial) { + const currentPeriodStartTimestamp = data.current_period_start; + const currentPeriodEndTimestamp = data.current_period_end; + const canceledTimestamp = data.canceled_at; + + if (currentPeriodStartTimestamp) { + this[kCurrentPeriodStartTimestamp] = Date.parse(currentPeriodStartTimestamp); + } + + if (currentPeriodEndTimestamp) { + this[kCurrentPeriodEndTimestamp] = Date.parse(currentPeriodEndTimestamp); + } + + if (canceledTimestamp) { + this[kCanceledTimestamp] = Date.parse(canceledTimestamp); + } + } + + /** + * The subscription's id + * + * @remarks The start of a subscription is determined by its id. When the subscription renews, its current period is updated. + */ + public get id() { + return this[kData].id; + } + + /** + * Id of the user who is subscribed + */ + public get userId() { + return this[kData].user_id; + } + + /** + * List of SKUs subscribed to + */ + public get skuIds() { + return this[kData].sku_ids; + } + + /** + * List of entitlements granted for this subscription + */ + public get entitlementIds() { + return this[kData].entitlement_ids; + } + + /** + * List of SKUs that this user will be subscribed to at renewal + */ + public get renewalSkuIds() { + return this[kData].renewal_sku_ids; + } + + /** + * Timestamp of start of the current subscription period + */ + public get currentPeriodStartTimestamp() { + return this[kCurrentPeriodStartTimestamp]; + } + + /** + * The time at which the current subscription period will start + */ + public get currentPeriodStartAt() { + const startTimestamp = this.currentPeriodStartTimestamp; + return startTimestamp ? new Date(startTimestamp) : null; + } + + /** + * Timestamp of end of the current subscription period + */ + public get currentPeriodEndTimestamp() { + return this[kCurrentPeriodEndTimestamp]; + } + + /** + * The time at which the current subscription period will end + */ + public get currentPeriodEndsAt() { + const endTimestamp = this.currentPeriodEndTimestamp; + return endTimestamp ? new Date(endTimestamp) : null; + } + + /** + * The {@link SubscriptionStatus} of the current subscription + */ + public get status() { + return this[kData].status; + } + + /** + * Timestamp when the subscription was canceled + */ + public get canceledTimestamp() { + return this[kCanceledTimestamp]; + } + + /** + * The time when the subscription was canceled + * + * @remarks This is populated when the {@link Subscription#status} transitions to {@link SubscriptionStatus.Ending}. + */ + public get canceledAt() { + const canceledTimestamp = this.canceledTimestamp; + return canceledTimestamp ? new Date(canceledTimestamp) : null; + } + + /** + * ISO3166-1 alpha-2 country code of the payment source used to purchase the subscription. Missing unless queried with a private OAuth scope. + */ + public get country() { + return this[kData].country; + } + + /** + * The timestamp the subscription was created at + */ + public get createdTimestamp() { + return isIdSet(this.id) ? DiscordSnowflake.timestampFrom(this.id) : null; + } + + /** + * The time the subscription was created at + */ + public get createdAt() { + const createdTimestamp = this.createdTimestamp; + return createdTimestamp ? new Date(createdTimestamp) : null; + } +} diff --git a/packages/structures/src/subscriptions/index.ts b/packages/structures/src/subscriptions/index.ts new file mode 100644 index 000000000..ecc4b4193 --- /dev/null +++ b/packages/structures/src/subscriptions/index.ts @@ -0,0 +1 @@ +export * from './Subscription.js'; diff --git a/packages/structures/src/utils/symbols.ts b/packages/structures/src/utils/symbols.ts index c9c9d72dc..28fa474a6 100644 --- a/packages/structures/src/utils/symbols.ts +++ b/packages/structures/src/utils/symbols.ts @@ -10,6 +10,10 @@ export const kArchiveTimestamp = Symbol.for('djs.structures.archiveTimestamp'); export const kStartsTimestamp = Symbol.for('djs.structures.startsTimestamp'); export const kEndsTimestamp = Symbol.for('djs.structures.endsTimestamp'); +export const kCurrentPeriodStartTimestamp = Symbol.for('djs.structures.currentPeriodStartTimestamp'); +export const kCurrentPeriodEndTimestamp = Symbol.for('djs.structures.currentPeriodEndTimestamp'); +export const kCanceledTimestamp = Symbol.for('djs.structures.canceledTimestamp'); + export const kAllow = Symbol.for('djs.structures.allow'); export const kDeny = Symbol.for('djs.structures.deny');