diff --git a/discord/enums.py b/discord/enums.py index fdd491789..6df1675b3 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -107,6 +107,7 @@ __all__ = ( 'ApplicationAssetType', 'SKUType', 'SKUAccessLevel', + 'SKUProductLine', 'SKUFeature', 'SKUGenre', 'OperatingSystem', @@ -1188,6 +1189,7 @@ class PaymentSourceType(Enum): bancontact = 14 eps = 15 ideal = 16 + cash_app = 17 payment_request = 99 @@ -1290,6 +1292,19 @@ class SKUAccessLevel(Enum, comparable=True): return self.value +class SKUProductLine(Enum): + premium = 1 + guild_premium = 2 + iap = 3 + guild_role = 4 + guild_product = 5 + application = 6 + collectible = 7 + + def __int__(self) -> int: + return self.value + + class SKUFeature(Enum): single_player = 1 online_multiplayer = 2 diff --git a/discord/flags.py b/discord/flags.py index 601cd20d4..edff9c3e3 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -1591,6 +1591,23 @@ class SKUFlags(BaseFlags): """:class:`bool`: Returns ``True`` if the SKU is a application subscription. These are subscriptions made to applications for premium perks bound to a user.""" return 1 << 8 + @flag_value + def creator_monetization(self): + """:class:`bool`: Returns ``True`` if the SKU is a creator monetization product (e.g. guild role subscription, guild product). + + .. versionadded:: 2.1 + """ + # For some reason this is only actually present on products... + return 1 << 9 + + @flag_value + def guild_product(self): + """:class:`bool`: Returns ``True`` if the SKU is a guild product. These are one-time purchases made by guilds for premium perks. + + .. versionadded:: 2.1 + """ + return 1 << 10 + @fill_with_flags() class PaymentFlags(BaseFlags): diff --git a/discord/http.py b/discord/http.py index a07b8bb7f..ac6dabd3b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3090,7 +3090,7 @@ class HTTPClient: payment_source_id: Optional[Snowflake] = None, localize: bool = True, with_bundled_skus: bool = True, - ) -> Response[List[store.SKU]]: + ) -> Response[List[store.PrivateSKU]]: params = {} if country_code: params['country_code'] = country_code @@ -3105,7 +3105,7 @@ class HTTPClient: Route('GET', '/applications/{app_id}/skus', app_id=app_id), params=params, super_properties_to_track=True ) - def create_sku(self, payload: dict) -> Response[store.SKU]: + def create_sku(self, payload: dict) -> Response[store.PrivateSKU]: return self.request(Route('POST', '/store/skus'), json=payload, super_properties_to_track=True) def get_app_discoverability(self, app_id: Snowflake) -> Response[application.ApplicationDiscoverability]: @@ -3507,7 +3507,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None, localize: bool = True, - ) -> Response[store.StoreListing]: + ) -> Response[store.PrivateStoreListing]: params = {} if country_code: params['country_code'] = country_code @@ -3525,7 +3525,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None, localize: bool = True, - ) -> Response[store.StoreListing]: + ) -> Response[store.PublicStoreListing]: params = {} if country_code: params['country_code'] = country_code @@ -3543,7 +3543,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[int] = None, localize: bool = True, - ) -> Response[List[store.StoreListing]]: + ) -> Response[List[store.PrivateStoreListing]]: params = {} if country_code: params['country_code'] = country_code @@ -3603,7 +3603,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[int] = None, localize: bool = True, - ) -> Response[List[store.StoreListing]]: + ) -> Response[List[store.PublicStoreListing]]: params = {'application_id': app_id} if country_code: params['country_code'] = country_code @@ -3621,7 +3621,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[int] = None, localize: bool = True, - ) -> Response[store.StoreListing]: + ) -> Response[store.PublicStoreListing]: params = {} if country_code: params['country_code'] = country_code @@ -3641,7 +3641,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None, localize: bool = True, - ) -> Response[List[store.StoreListing]]: + ) -> Response[List[store.PublicStoreListing]]: params: Dict[str, Any] = {'application_ids': app_ids} if country_code: params['country_code'] = country_code @@ -3654,14 +3654,14 @@ class HTTPClient: def create_store_listing( self, application_id: Snowflake, sku_id: Snowflake, payload: dict - ) -> Response[store.StoreListing]: + ) -> Response[store.PrivateStoreListing]: return self.request( Route('POST', '/store/listings'), json={**payload, 'application_id': application_id, 'sku_id': sku_id}, super_properties_to_track=True, ) - def edit_store_listing(self, listing_id: Snowflake, payload: dict) -> Response[store.StoreListing]: + def edit_store_listing(self, listing_id: Snowflake, payload: dict) -> Response[store.PrivateStoreListing]: return self.request( Route('PATCH', '/store/listings/{listing_id}', listing_id=listing_id), json=payload, @@ -3675,7 +3675,7 @@ class HTTPClient: country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None, localize: bool = True, - ) -> Response[store.SKU]: + ) -> Response[store.PrivateSKU]: params = {} if country_code: params['country_code'] = country_code @@ -3686,7 +3686,7 @@ class HTTPClient: return self.request(Route('GET', '/store/skus/{sku_id}', sku_id=sku_id), params=params) - def edit_sku(self, sku_id: Snowflake, payload: dict) -> Response[store.SKU]: + def edit_sku(self, sku_id: Snowflake, payload: dict) -> Response[store.PrivateSKU]: return self.request( Route('PATCH', '/store/skus/{sku_id}', sku_id=sku_id), json=payload, super_properties_to_track=True ) diff --git a/discord/store.py b/discord/store.py index 9602fde8c..a4d593d70 100644 --- a/discord/store.py +++ b/discord/store.py @@ -41,6 +41,7 @@ from .enums import ( SKUAccessLevel, SKUFeature, SKUGenre, + SKUProductLine, SKUType, SubscriptionInterval, SubscriptionPlanPurchaseType, @@ -76,6 +77,7 @@ if TYPE_CHECKING: SKU as SKUPayload, CarouselItem as CarouselItemPayload, ContentRating as ContentRatingPayload, + PremiumPrice as PremiumPricePayload, SKUPrice as SKUPricePayload, StoreListing as StoreListingPayload, StoreNote as StoreNotePayload, @@ -330,6 +332,8 @@ class SystemRequirements: Any extra notes on recommended requirements. """ + # I hate this class so much + if TYPE_CHECKING: os: OperatingSystem minimum_ram: Optional[int] @@ -607,6 +611,19 @@ class StoreListing(Hashable): The guild tied to this listing, if any. published: :class:`bool` Whether the listing is published and publicly visible. + published_at: Optional[:class:`datetime.datetime`] + When the listing was published, if available. + + .. note:: + + This data is not available for all listings. + + .. versionadded:: 2.1 + unpublished_at: Optional[:class:`datetime.datetime`] + When the listing was last unpublished, if available. + If this is a future date, the listing will be unpublished at that time. + + .. versionadded:: 2.1 staff_note: Optional[:class:`StoreNote`] The staff note attached to this listing. assets: List[:class:`StoreAsset`] @@ -645,6 +662,8 @@ class StoreListing(Hashable): 'entitlement_branch_id', 'guild', 'published', + 'published_at', + 'unpublished_at', 'staff_note', 'assets', 'carousel_items', @@ -693,6 +712,8 @@ class StoreListing(Hashable): self.entitlement_branch_id: Optional[int] = _get_as_snowflake(data, 'entitlement_branch_id') self.guild: Optional[Guild] = state.create_guild(data['guild']) if 'guild' in data else None self.published: bool = data.get('published', True) + self.published_at: Optional[datetime] = parse_time(data['published_at']) if 'published_at' in data else None + self.unpublished_at: Optional[datetime] = parse_time(data['unpublished_at']) if 'unpublished_at' in data else None self.staff_note: Optional[StoreNote] = ( StoreNote(data=data['staff_notes'], state=state) if 'staff_notes' in data else None ) @@ -902,22 +923,35 @@ class SKUPrice: The price of the SKU with discounts applied, if any. sale_percentage: :class:`int` The percentage of the price discounted, if any. + exponent: :class:`int` + The offset of the currency's decimal point. + For example, if the price is 1000 and the exponent is 2, the price is $10.00. + + .. versionadded:: 2.1 + premium: Dict[:class:`PremiumType`, :class:`SKUPrice`] + Special SKU prices for premium (Nitro) users. + + .. versionadded:: 2.1 """ - __slots__ = ('currency', 'amount', 'sale_amount', 'sale_percentage', 'premium', 'exponent') + __slots__ = ('currency', 'amount', 'sale_amount', 'sale_percentage', 'exponent', 'premium') def __init__(self, data: Union[SKUPricePayload, SubscriptionPricePayload]) -> None: self.currency: str = data.get('currency', 'usd') self.amount: int = data.get('amount', 0) self.sale_amount: Optional[int] = data.get('sale_amount') self.sale_percentage: int = data.get('sale_percentage', 0) - self.premium = data.get('premium') - self.exponent: Optional[int] = data.get('exponent') + self.exponent: int = data.get('exponent', data.get('currency_exponent', 0)) + self.premium: Dict[PremiumType, SKUPrice] = { + try_enum(PremiumType, premium_type): SKUPrice.from_premium(self, premium_data) + for premium_type, premium_data in data.get('premium', {}).items() + } @classmethod def from_private(cls, data: SKUPayload) -> SKUPrice: payload: SKUPricePayload = { 'currency': 'usd', + 'currency_exponent': 2, 'amount': data.get('price_tier') or 0, 'sale_amount': data.get('sale_price_tier'), } @@ -925,6 +959,17 @@ class SKUPrice: payload['sale_percentage'] = int((1 - (payload['sale_amount'] / payload['amount'])) * 100) return cls(payload) + @classmethod + def from_premium(cls, parent: SKUPrice, data: PremiumPricePayload) -> SKUPrice: + payload: SKUPricePayload = { + 'currency': parent.currency, + 'currency_exponent': parent.exponent, + 'amount': parent.amount, + 'sale_amount': data.get('amount'), + 'sale_percentage': data.get('percentage'), + } + return cls(payload) + def __repr__(self) -> str: return f'' @@ -939,8 +984,13 @@ class SKUPrice: return self.sale_percentage > 0 def is_free(self) -> bool: - """:class:`bool`: Checks whether the SKU is free.""" - return self.amount == 0 + """:class:`bool`: Checks whether the SKU is free. + + .. versionchanged:: 2.1 + + This now also checks the :attr:`sale_amount` to see if the SKU is free with discounts applied. + """ + return self.sale_amount == 0 or self.amount == 0 @property def discounts(self) -> int: @@ -1047,6 +1097,10 @@ class SKU(Hashable): The legal notice of the SKU localized to different languages. type: :class:`SKUType` The type of the SKU. + product_line: Optional[:class:`SKUProductLine`] + The product line of the SKU, if any. + + .. versionadded:: 2.1 slug: :class:`str` The URL slug of the SKU. dependent_sku_id: Optional[:class:`int`] @@ -1098,6 +1152,10 @@ class SKU(Hashable): Whether this SKU is restricted. exclusive: :class:`bool` Whether this SKU is exclusive to Discord. + deleted: :class:`bool` + Whether this SKU has been soft-deleted. + + .. versionadded:: 2.1 show_age_gate: :class:`bool` Whether the client should prompt the user to verify their age. bundled_skus: List[:class:`SKU`] @@ -1116,6 +1174,7 @@ class SKU(Hashable): 'legal_notice', 'legal_notice_localizations', 'type', + 'product_line', 'slug', 'price_tier', 'price_overrides', @@ -1139,6 +1198,7 @@ class SKU(Hashable): 'premium', 'restricted', 'exclusive', + 'deleted', 'show_age_gate', 'bundled_skus', 'manifests', @@ -1179,6 +1239,9 @@ class SKU(Hashable): self.id: int = int(data['id']) self.type: SKUType = try_enum(SKUType, data['type']) + self.product_line: Optional[SKUProductLine] = ( + try_enum(SKUProductLine, data['product_line']) if data.get('product_line') else None + ) self.slug: str = data['slug'] self.dependent_sku_id: Optional[int] = _get_as_snowflake(data, 'dependent_sku_id') self.application_id: int = int(data['application_id']) @@ -1233,6 +1296,7 @@ class SKU(Hashable): self.premium: bool = data.get('premium', False) self.restricted: bool = data.get('restricted', False) self.exclusive: bool = data.get('exclusive', False) + self.deleted: bool = data.get('deleted', False) self.show_age_gate: bool = data.get('show_age_gate', False) self.bundled_skus: List[SKU] = [ SKU(data=sku, state=state, application=self.application) for sku in data.get('bundled_skus', []) diff --git a/discord/subscriptions.py b/discord/subscriptions.py index a994bf460..4245f4d78 100644 --- a/discord/subscriptions.py +++ b/discord/subscriptions.py @@ -201,9 +201,13 @@ class SubscriptionInvoiceItem(Hashable): The price of the subscription plan the item represents. This does not include discounts. discounts: List[:class:`SubscriptionDiscount`] A list of discounts applied to the item. + metadata: :class:`Metadata` + Extra metadata about the invoice item. + + .. versionadded:: 2.1 """ - __slots__ = ('id', 'quantity', 'amount', 'proration', 'plan_id', 'plan_price', 'discounts') + __slots__ = ('id', 'quantity', 'amount', 'proration', 'plan_id', 'plan_price', 'discounts', 'metadata') def __init__(self, data: SubscriptionInvoiceItemPayload) -> None: self.id: int = int(data['id']) @@ -213,6 +217,7 @@ class SubscriptionInvoiceItem(Hashable): self.plan_id: int = int(data['subscription_plan_id']) self.plan_price: int = data['subscription_plan_price'] self.discounts: List[SubscriptionDiscount] = [SubscriptionDiscount(d) for d in data['discounts']] + self.metadata: Metadata = Metadata(data.get('tenant_metadata', {})) def __repr__(self) -> str: return f'' @@ -277,6 +282,10 @@ class SubscriptionInvoice(Hashable): When the current billing period started. current_period_end: :class:`datetime.datetime` When the current billing period ends. + applied_discount_ids: List[:class:`int`] + The IDs of the discounts applied to the invoice. + + .. versionadded:: 2.1 """ __slots__ = ( @@ -292,6 +301,8 @@ class SubscriptionInvoice(Hashable): 'items', 'current_period_start', 'current_period_end', + 'applied_discount_ids', + 'applied_user_discounts', ) def __init__( @@ -316,6 +327,12 @@ class SubscriptionInvoice(Hashable): self.current_period_start: datetime = parse_time(data['subscription_period_start']) # type: ignore # Should always be a datetime self.current_period_end: datetime = parse_time(data['subscription_period_end']) # type: ignore # Should always be a datetime + # These fields are unknown + self.applied_discount_ids: List[int] = [int(id) for id in data.get('applied_discount_ids', [])] + self.applied_user_discounts: Dict[int, Optional[Any]] = { + int(k): v for k, v in data.get('applied_user_discounts', {}).items() + } + def __repr__(self) -> str: return f'' diff --git a/discord/types/entitlements.py b/discord/types/entitlements.py index 2f07071a3..44b706df7 100644 --- a/discord/types/entitlements.py +++ b/discord/types/entitlements.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired from .payments import PartialPayment from .promotions import Promotion from .snowflake import Snowflake -from .store import SKU, StoreListing +from .store import PublicSKU, PublicStoreListing from .subscriptions import PartialSubscriptionPlan, SubscriptionPlan, SubscriptionTrial from .user import PartialUser @@ -55,7 +55,7 @@ class Entitlement(TypedDict): ends_at: NotRequired[str] subscription_id: NotRequired[Snowflake] subscription_plan: NotRequired[PartialSubscriptionPlan] - sku: NotRequired[SKU] + sku: NotRequired[PublicSKU] payment: NotRequired[PartialPayment] @@ -78,7 +78,7 @@ class Gift(GatewayGift): uses: int redeemed: bool revoked: NotRequired[bool] - store_listing: NotRequired[StoreListing] + store_listing: NotRequired[PublicStoreListing] promotion: NotRequired[Promotion] subscription_trial: NotRequired[SubscriptionTrial] subscription_plan: NotRequired[SubscriptionPlan] diff --git a/discord/types/payments.py b/discord/types/payments.py index 450767864..5e3628046 100644 --- a/discord/types/payments.py +++ b/discord/types/payments.py @@ -29,7 +29,7 @@ from typing_extensions import NotRequired from .billing import PartialPaymentSource from .snowflake import Snowflake -from .store import SKU +from .store import PublicSKU from .subscriptions import PartialSubscription @@ -56,6 +56,6 @@ class Payment(PartialPayment): downloadable_refund_invoices: NotRequired[List[str]] refund_disqualification_reasons: NotRequired[List[str]] flags: int - sku: NotRequired[SKU] + sku: NotRequired[PublicSKU] payment_source: NotRequired[PartialPaymentSource] subscription: NotRequired[PartialSubscription] diff --git a/discord/types/store.py b/discord/types/store.py index 233452e23..36794581a 100644 --- a/discord/types/store.py +++ b/discord/types/store.py @@ -64,16 +64,14 @@ class CarouselItem(TypedDict, total=False): youtube_video_id: str -class StoreListing(TypedDict): +class BaseStoreListing(TypedDict): id: Snowflake summary: NotRequired[LOCALIZED_STR] description: NotRequired[LOCALIZED_STR] tagline: NotRequired[LOCALIZED_STR] flavor_text: NotRequired[str] - published: NotRequired[bool] entitlement_branch_id: NotRequired[Snowflake] staff_notes: NotRequired[StoreNote] - guild: NotRequired[PartialGuild] assets: NotRequired[List[StoreAsset]] carousel_items: NotRequired[List[CarouselItem]] preview_video: NotRequired[StoreAsset] @@ -84,17 +82,36 @@ class StoreListing(TypedDict): thumbnail: NotRequired[StoreAsset] header_logo_light_theme: NotRequired[StoreAsset] header_logo_dark_theme: NotRequired[StoreAsset] - sku: SKU - child_skus: NotRequired[List[SKU]] - alternative_skus: NotRequired[List[SKU]] + sku: PublicSKU + child_skus: NotRequired[List[PublicSKU]] + alternative_skus: NotRequired[List[PublicSKU]] + published_at: NotRequired[str] + unpublished_at: NotRequired[str] + + +class PublicStoreListing(BaseStoreListing): + guild: NotRequired[PartialGuild] + + +class PrivateStoreListing(BaseStoreListing): + published: bool + + +StoreListing = Union[PublicStoreListing, PrivateStoreListing] + + +class PremiumPrice(TypedDict): + amount: int + percentage: int class SKUPrice(TypedDict): currency: str + currency_exponent: int amount: int sale_amount: NotRequired[Optional[int]] sale_percentage: NotRequired[int] - premium: NotRequired[bool] + premium: NotRequired[Dict[Literal[1, 2, 3], PremiumPrice]] class ContentRating(TypedDict): @@ -102,17 +119,21 @@ class ContentRating(TypedDict): descriptors: List[int] +SKUType = Literal[1, 2, 3, 4, 5, 6] + + class PartialSKU(TypedDict): id: Snowflake - type: Literal[1, 2, 3, 4, 5, 6] + type: SKUType premium: bool preorder_release_date: Optional[str] preorder_released_at: Optional[str] -class SKU(PartialSKU): +class BaseSKU(PartialSKU): id: Snowflake - type: Literal[1, 2, 3, 4, 5, 6] + type: SKUType + product_line: Optional[Literal[1, 2, 3, 4, 5, 6, 7]] name: LOCALIZED_STR summary: NotRequired[LOCALIZED_STR] legal_notice: NotRequired[LOCALIZED_STR] @@ -121,18 +142,11 @@ class SKU(PartialSKU): application_id: Snowflake application: NotRequired[PartialApplication] flags: int - price_tier: NotRequired[int] - price: NotRequired[Union[SKUPrice, Dict[str, int]]] - sale_price_tier: NotRequired[int] - sale_price: NotRequired[Dict[str, int]] access_level: Literal[1, 2, 3] features: List[int] locales: NotRequired[List[str]] genres: NotRequired[List[int]] available_regions: NotRequired[List[str]] - content_rating_agency: NotRequired[Literal[1, 2]] - content_rating: NotRequired[ContentRating] - content_ratings: NotRequired[Dict[Literal[1, 2], ContentRating]] system_requirements: NotRequired[Dict[Literal[1, 2, 3], SystemRequirements]] release_date: Optional[str] preorder_release_date: NotRequired[Optional[str]] @@ -141,11 +155,30 @@ class SKU(PartialSKU): premium: NotRequired[bool] restricted: NotRequired[bool] exclusive: NotRequired[bool] + deleted: NotRequired[bool] show_age_gate: bool - bundled_skus: NotRequired[List[SKU]] manifest_labels: Optional[List[Snowflake]] +class PublicSKU(BaseSKU): + bundled_skus: NotRequired[List[PublicSKU]] + price: SKUPrice + content_rating_agency: NotRequired[Literal[1, 2]] + content_rating: NotRequired[ContentRating] + + +class PrivateSKU(BaseSKU): + bundled_skus: NotRequired[List[PrivateSKU]] + price_tier: int + price: Dict[str, int] + sale_price_tier: int + sale_price: Dict[str, int] + content_ratings: Dict[Literal[1, 2], ContentRating] + + +SKU = Union[PublicSKU, PrivateSKU] + + class SKUPurchase(TypedDict): entitlements: List[Entitlement] library_applications: NotRequired[List[LibraryApplication]] diff --git a/discord/types/subscriptions.py b/discord/types/subscriptions.py index 0981c3a75..fb27521d4 100644 --- a/discord/types/subscriptions.py +++ b/discord/types/subscriptions.py @@ -73,6 +73,7 @@ class SubscriptionInvoiceItem(TypedDict): subscription_plan_id: Snowflake subscription_plan_price: int discounts: List[SubscriptionDiscount] + tenant_metadata: NotRequired[Dict[str, Any]] class SubscriptionInvoice(TypedDict): @@ -86,6 +87,8 @@ class SubscriptionInvoice(TypedDict): items: List[SubscriptionInvoiceItem] current_period_start: str current_period_end: str + applied_discount_ids: NotRequired[List[Snowflake]] + applied_user_discounts: NotRequired[Dict[Snowflake, Optional[Any]]] class SubscriptionRenewalMutations(TypedDict, total=False): diff --git a/docs/api.rst b/docs/api.rst index c8b035a02..4bbaf25bc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3960,6 +3960,12 @@ of :class:`enum.Enum`. The payment source is an iDEAL account. + .. attribute:: cash_app + + The payment source is a Cash App account. + + .. versionadded:: 2.1 + .. class:: PaymentGateway Represents the payment gateway used for a payment source.