From f6380cf1428b81b4b41e82a05f1fa55df6eb2a46 Mon Sep 17 00:00:00 2001 From: Dolfies Date: Sat, 4 Jan 2025 03:05:13 -0500 Subject: [PATCH] New billing features --- discord/client.py | 76 +++++++++++++++- discord/enums.py | 5 +- discord/http.py | 27 ++++++ discord/payments.py | 8 ++ discord/promotions.py | 162 +++++++++++++++++++++++++++++++++ discord/subscriptions.py | 82 ++++++++++++----- discord/types/billing.py | 4 + discord/types/payments.py | 7 +- discord/types/promotions.py | 15 +++ discord/types/subscriptions.py | 23 ++++- docs/api.rst | 16 ++++ 11 files changed, 394 insertions(+), 31 deletions(-) diff --git a/discord/client.py b/discord/client.py index 01d9e7751..dab71d27e 100644 --- a/discord/client.py +++ b/discord/client.py @@ -82,7 +82,7 @@ from .team import Team from .billing import PaymentSource, PremiumUsage from .subscriptions import Subscription, SubscriptionItem, SubscriptionInvoice from .payments import Payment -from .promotions import PricingPromotion, Promotion, TrialOffer +from .promotions import PricingPromotion, Promotion, TrialOffer, UserOffer from .entitlements import Entitlement, Gift from .store import SKU, StoreListing, SubscriptionPlan from .guild_premium import * @@ -4129,6 +4129,37 @@ class Client: ) return [Promotion(state=state, data=d) for d in data] + async def user_offer(self, *, payment_gateway: Optional[PaymentGateway] = None) -> UserOffer: + """|coro| + + Retrieves the current user offer for your account. + This includes the trial offer and discount offer. + + .. versionadded:: 2.1 + + Parameters + ----------- + payment_gateway: Optional[:class:`.PaymentGateway`] + The payment gateway to fetch the user offer for. + Used to fetch user offers for :attr:`.PaymentGateway.apple` + and :attr:`.PaymentGateway.google` mobile platforms. + + Raises + ------- + NotFound + You do not have a user offer. + HTTPException + Retrieving the user offer failed. + + Returns + ------- + :class:`.UserOffer` + The user offer for your account. + """ + state = self._connection + data = await state.http.get_user_offer(payment_gateway=int(payment_gateway) if payment_gateway else None) + return UserOffer(data=data, state=state) + async def trial_offer(self) -> TrialOffer: """|coro| @@ -4955,6 +4986,27 @@ class Client: data = await self._connection.http.get_premium_usage() return PremiumUsage(data=data) + async def checkout_recovery(self) -> bool: + """|coro| + + Checks whether the client should prompt the user to + continue their premium purchase. + + .. versionadded:: 2.1 + + Raises + ------ + HTTPException + Retrieving the checkout recovery eligibility failed. + + Returns + ------- + :class:`bool` + Whether the client should prompt the user for checkout recovery. + """ + data = await self._connection.http.checkout_recovery_eligibility() + return data.get('is_eligible', False) + async def recent_mentions( self, *, @@ -5137,6 +5189,28 @@ class Client: data = await state.http.get_channel_affinities() return [ChannelAffinity(data=d, state=state) for d in data['channel_affinities']] + async def premium_affinities(self) -> List[User]: + """|coro| + + Retrieves a list of friends who have a premium subscription, + used to incentivize the user to purchase one as well. + + .. versionadded:: 2.1 + + Raises + ------ + HTTPException + Retrieving the premium affinities failed. + + Returns + ------- + List[:class:`.User`] + The users who share a premium subscription with the current user. + """ + state = self._connection + data = await state.http.get_premium_affinity() + return [state.store_user(d) for d in data] + async def join_active_developer_program(self, *, application: Snowflake, channel: Snowflake) -> int: """|coro| diff --git a/discord/enums.py b/discord/enums.py index 1ceaf3e0c..a015cfad2 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1234,8 +1234,11 @@ class SubscriptionStatus(Enum): canceled = 3 cancelled = 3 ended = 4 - inactive = 5 + inactive = 5 # Guess, probably incorrect account_hold = 6 + billing_retry = 7 + paused = 8 + pause_pending = 9 def __int__(self) -> int: return self.value diff --git a/discord/http.py b/discord/http.py index 31673f38e..0543204b6 100644 --- a/discord/http.py +++ b/discord/http.py @@ -4253,12 +4253,36 @@ class HTTPClient: def ack_trial_offer(self, trial_id: Snowflake) -> Response[promotions.TrialOffer]: return self.request(Route('POST', '/users/@me/billing/user-trial-offer/{trial_id}/ack', trial_id=trial_id)) + def get_user_offer(self, payment_gateway: Optional[int] = None) -> Response[promotions.UserOffer]: + payload = {} + if payment_gateway: + payload['payment_gateway'] = payment_gateway + return self.request(Route('POST', '/users/@me/billing/user-offer'), json=payload) + + def ack_user_offer( + self, trial_offer_id: Optional[Snowflake] = None, discount_offer_id: Optional[Snowflake] = None + ) -> Response[Optional[promotions.UserOffer]]: + payload = {} + if trial_offer_id: + payload['user_trial_offer_id'] = trial_offer_id + if discount_offer_id: + payload['user_discount_offer_id'] = discount_offer_id + return self.request(Route('POST', '/users/@me/billing/user-offer/ack'), json=payload) + + def redeem_user_offer(self, discount_offer_id: Snowflake) -> Response[None]: # TODO: Unknown responses + return self.request( + Route('POST', '/users/@me/billing/user-offer/redeem'), json={'user_discount_offer_id': discount_offer_id} + ) + def get_pricing_promotion(self) -> Response[promotions.WrappedPricingPromotion]: return self.request(Route('GET', '/users/@me/billing/localized-pricing-promo')) def get_premium_usage(self) -> Response[billing.PremiumUsage]: return self.request(Route('GET', '/users/@me/premium-usage')) + def checkout_recovery_eligibility(self) -> Response[billing.CheckoutRecovery]: + return self.request(Route('GET', '/users/@me/billing/checkout-recovery')) + # OAuth2 def get_oauth2_tokens(self) -> Response[List[oauth2.OAuth2Token]]: @@ -4573,6 +4597,9 @@ class HTTPClient: def get_channel_affinities(self) -> Response[user.ChannelAffinities]: return self.request(Route('GET', '/users/@me/affinities/channels')) + def get_premium_affinity(self) -> Response[List[user.PartialUser]]: + return self.request(Route('GET', '/users/@me/billing/nitro-affinity')) + def get_country_code(self) -> Response[subscriptions.CountryCode]: return self.request(Route('GET', '/users/@me/billing/country-code')) diff --git a/discord/payments.py b/discord/payments.py index 505429b8f..52267c73c 100644 --- a/discord/payments.py +++ b/discord/payments.py @@ -121,6 +121,10 @@ class Payment(Hashable): A list of URLs to download VAT credit notices for refunds on this payment, if available. refund_disqualification_reasons: List[:class:`RefundDisqualificationReason`] A list of reasons why the payment cannot be refunded, if any. + error_code: Optional[:class:`int`] + The JSON error code that occurred during the payment, if any. + + .. versionadded:: 2.1 """ __slots__ = ( @@ -144,6 +148,7 @@ class Payment(Hashable): 'invoice_url', 'refund_invoices_urls', 'refund_disqualification_reasons', + 'error_code', '_refundable', '_flags', '_state', @@ -191,6 +196,9 @@ class Payment(Hashable): Subscription(data=data['subscription'], state=state) if 'subscription' in data else None ) + metadata = data.get('metadata') or {} + self.error_code: Optional[int] = metadata.get('billing_error_code') + def __repr__(self) -> str: return f'' diff --git a/discord/promotions.py b/discord/promotions.py index 3da48315d..6b28fd21e 100644 --- a/discord/promotions.py +++ b/discord/promotions.py @@ -39,11 +39,14 @@ if TYPE_CHECKING: ClaimedPromotion as ClaimedPromotionPayload, Promotion as PromotionPayload, TrialOffer as TrialOfferPayload, + DiscountOffer as DiscountOfferPayload, + UserOffer as UserOfferPayload, PricingPromotion as PricingPromotionPayload, ) __all__ = ( 'Promotion', + 'UserOffer', 'TrialOffer', 'PricingPromotion', ) @@ -201,6 +204,72 @@ class Promotion(Hashable): return data['code'] +class UserOffer: + """Represents a Discord user offer. + + .. versionadded:: 2.1 + + Attributes + ---------- + trial_offer: Optional[:class:`TrialOffer`] + The trial offer. + discount_offer: Optional[:class:`DiscountOffer`] + The discount offer. + discount: Optional[:class:`DiscountOffer`] + The discount applied. + """ + + __slots__ = ( + 'trial_offer', + 'discount_offer', + 'discount', + '_state', + ) + + def __init__(self, *, data: UserOfferPayload, state: ConnectionState) -> None: + self._state = state + self._update(data) + + def _update(self, data: UserOfferPayload) -> None: + state = self._state + + self.trial_offer: Optional[TrialOffer] = None + trial_offer = data.get('user_trial_offer') + if trial_offer is not None: + self.trial_offer = TrialOffer(data=trial_offer, state=state) + + self.discount_offer: Optional[DiscountOffer] = None + discount_offer = data.get('user_discount_offer') + if discount_offer is not None: + self.discount_offer = DiscountOffer(data=discount_offer, state=state) + + self.discount: Optional[DiscountOffer] = None + discount = data.get('user_discount') + if discount is not None: + self.discount = DiscountOffer(data=discount, state=state) + + async def ack(self) -> None: + """|coro| + + Acknowledges both the trial and discount offers. + + Raises + ------ + NotFound + The offers were not found. + HTTPException + Acknowledging the offers failed. + """ + # The client sets all offers to null if this 404s + # Unsure if I want to do that here + data = await self._state.http.ack_user_offer( + trial_offer_id=self.trial_offer.id if self.trial_offer else None, + discount_offer_id=self.discount_offer.id if self.discount_offer else None, + ) + if data: + self._update(data) + + class TrialOffer(Hashable): """Represents a Discord user trial offer. @@ -267,6 +336,8 @@ class TrialOffer(Hashable): Raises ------ + NotFound + The trial offer was not found. HTTPException Acknowledging the trial offer failed. """ @@ -274,6 +345,97 @@ class TrialOffer(Hashable): self._update(data) +class DiscountOffer(Hashable): + """Represents a Discord user discount offer. + + .. container:: operations + + .. describe:: x == y + + Checks if two discount offers are equal. + + .. describe:: x != y + + Checks if two discount offers are not equal. + + .. describe:: hash(x) + + Returns the discount offer's hash. + + .. versionadded:: 2.1 + + Attributes + ---------- + id: :class:`int` + The ID of the discount offer. + expires_at: Optional[:class:`datetime.datetime`] + When the discount offer expires, if it has been acknowledged. + applied_at: Optional[:class:`datetime.datetime`] + When the discount offer was applied. + discount_id: :class:`int` + The ID of the discount. + """ + + __slots__ = ( + 'id', + 'expires_at', + 'applied_at', + 'discount_id', + '_state', + ) + + def __init__(self, *, data: DiscountOfferPayload, state: ConnectionState) -> None: + self._state = state + self._update(data) + + def _update(self, data: DiscountOfferPayload) -> None: + self.id: int = int(data['id']) + self.expires_at: Optional[datetime] = parse_time(data.get('expires_at')) + self.applied_at: Optional[datetime] = parse_time(data.get('applied_at')) + self.discount_id: int = int(data['discount_id']) + + def __repr__(self) -> str: + return f'' + + def is_acked(self) -> bool: + """:class:`bool`: Checks if the discount offer has been acknowledged.""" + return self.expires_at is not None + + async def ack(self) -> None: + """|coro| + + Acknowledges the discount offer. + + Raises + ------ + HTTPException + Acknowledging the discount offer failed. + """ + data = await self._state.http.ack_user_offer(discount_offer_id=self.id) + if not data: + return + + # The type checker has no idea what is going on here for some reason + if data.get('user_discount_offer') and int(data['user_discount_offer']['id']) == self.id: # type: ignore + self._update(data['user_discount_offer']) # type: ignore + elif data.get('user_discount') and int(data['user_discount']['id']) == self.id: # type: ignore + self._update(data['user_discount']) # type: ignore + + async def redeem(self) -> None: + """|coro| + + Applies the discount on the user's existing subscription. + + Raises + ------ + NotFound + The discount offer was not found. + HTTPException + Redeeming the discount offer failed. + """ + await self._state.http.redeem_user_offer(self.id) + + class PricingPromotion: """Represents a Discord localized pricing promotion. diff --git a/discord/subscriptions.py b/discord/subscriptions.py index 4245f4d78..9547e1a37 100644 --- a/discord/subscriptions.py +++ b/discord/subscriptions.py @@ -207,13 +207,14 @@ class SubscriptionInvoiceItem(Hashable): .. versionadded:: 2.1 """ - __slots__ = ('id', 'quantity', 'amount', 'proration', 'plan_id', 'plan_price', 'discounts', 'metadata') + __slots__ = ('id', 'quantity', 'amount', 'proration', 'sku_id', 'plan_id', 'plan_price', 'discounts', 'metadata') def __init__(self, data: SubscriptionInvoiceItemPayload) -> None: self.id: int = int(data['id']) self.quantity: int = data['quantity'] self.amount: int = data['amount'] self.proration: bool = data.get('proration', False) + self.sku_id: Optional[int] = _get_as_snowflake(data, 'sku_id') # Seems to always be null 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']] @@ -282,10 +283,6 @@ 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__ = ( @@ -301,8 +298,6 @@ class SubscriptionInvoice(Hashable): 'items', 'current_period_start', 'current_period_end', - 'applied_discount_ids', - 'applied_user_discounts', ) def __init__( @@ -327,12 +322,6 @@ 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'' @@ -489,6 +478,14 @@ class Subscription(Hashable): The mutations to the subscription that will occur after renewal. trial_id: Optional[:class:`int`] The ID of the trial the subscription is from, if applicable. + discount_id: Optional[:class:`int`] + The ID of the discount the subscription has active. + + .. versionadded:: 2.1 + discount_expires_at: Optional[:class:`datetime.datetime`] + When the discount expires, if applicable. + + .. versionadded:: 2.1 payment_source_id: Optional[:class:`int`] The ID of the payment source the subscription is paid with, if applicable. payment_gateway_plan_id: Optional[:class:`str`] @@ -546,9 +543,11 @@ class Subscription(Hashable): 'current_period_end', 'trial_ends_at', 'streak_started_at', - 'ended_at', 'use_storekit_resubscribe', 'metadata', + 'ended_at', + 'discount_id', + 'discount_expires_at', 'latest_invoice', ) @@ -596,9 +595,12 @@ class Subscription(Hashable): self.streak_started_at: Optional[datetime] = parse_time(data.get('streak_started_at')) self.use_storekit_resubscribe: bool = data.get('use_storekit_resubscribe', False) + # Some metadata is exposed; I don't love this implementation metadata = data.get('metadata') or {} - self.ended_at: Optional[datetime] = parse_time(metadata.get('ended_at', None)) self.metadata: Metadata = Metadata(metadata) + self.ended_at: Optional[datetime] = parse_time(metadata.get('ended_at', None)) + self.discount_id = _get_as_snowflake(metadata, 'active_discount_id') + self.discount_expires_at = parse_time(metadata.get('active_discount_expires_at', None)) self.latest_invoice: Optional[SubscriptionInvoice] = ( SubscriptionInvoice(self, data=data['latest_invoice'], state=self._state) if 'latest_invoice' in data else None # type: ignore # ??? @@ -616,22 +618,35 @@ class Subscription(Hashable): @property def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: The guild the subscription's entitlements apply to, if applicable.""" - return self._state._get_guild(self.metadata.guild_id) + # I think guild_id is deprecated + return self._state._get_guild(self.metadata.guild_id or self.metadata.application_subscription_guild_id) @property - def grace_period(self) -> timedelta: - """:class:`datetime.timedelta`: How many days past the renewal date the user has available to pay outstanding invoices. + def grace_period(self) -> Optional[timedelta]: + """Optional[:class:`datetime.timedelta`]: How many days past the renewal date the user has available to pay outstanding invoices. .. note:: - This is a static value and does not change based on the subscription's status. - For that, see :attr:`remaining`. + This is only available for past-due subscriptions. .. versionchanged:: 2.1 - This is now a :class:`datetime.timedelta` instead of an :class:`int`. + This is now a Optional[:class:`datetime.timedelta`] instead of an :class:`int`. + """ + if self.grace_period_expires_at: + return self.grace_period_expires_at - utcnow() + + @property + def grace_period_expires_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When the grace period expires. + + .. versionadded:: 2.1 """ - return timedelta(days=7 if self.payment_source_id else 3) + if self.payment_gateway == PaymentGateway.apple: + return self.metadata.apple_grace_period_expires_date or self.metadata.grace_period_expires_date + if self.payment_gateway == PaymentGateway.google: + return self.metadata.google_grace_period_expires_date or self.metadata.grace_period_expires_date + return self.metadata.grace_period_expires_date @property def remaining(self) -> timedelta: @@ -639,12 +654,14 @@ class Subscription(Hashable): if self.status in (SubscriptionStatus.active, SubscriptionStatus.cancelled): return self.current_period_end - utcnow() elif self.status == SubscriptionStatus.past_due: - if self.payment_gateway == PaymentGateway.google and self.metadata.google_grace_period_expires_date: - return self.metadata.google_grace_period_expires_date - utcnow() - return (self.current_period_start + self.grace_period) - utcnow() + # Grace expiry is now always provided + return self.grace_period_expires_at - utcnow() # type: ignore elif self.status == SubscriptionStatus.account_hold: # Max hold time is 30 days return (self.current_period_start + timedelta(days=30)) - utcnow() + elif self.status == SubscriptionStatus.billing_retry: + # Max retry time is 7 days + return (self.current_period_start + timedelta(days=7)) - utcnow() return timedelta() @property @@ -657,6 +674,16 @@ class Subscription(Hashable): return self.remaining return self.trial_ends_at - utcnow() + @property + def discount_remaining(self) -> timedelta: + """:class:`datetime.timedelta`: The remaining time until the active discount on the subscription ends.""" + if not self.discount_id: + return timedelta() + if not self.discount_expires_at: + # Infinite discount? + return self.remaining + return self.discount_expires_at - utcnow() + def is_active(self) -> bool: """:class:`bool`: Indicates if the subscription is currently active and providing perks.""" return self.remaining > timedelta() @@ -750,6 +777,7 @@ class Subscription(Hashable): currency: str = MISSING, apply_entitlements: bool = MISSING, renewal: bool = MISSING, + discount_offer: Snowflake = MISSING, ) -> SubscriptionInvoice: """|coro| @@ -769,6 +797,8 @@ class Subscription(Hashable): Whether to apply entitlements (credits) to the previewed invoice. renewal: :class:`bool` Whether the previewed invoice should be a renewal. + discount_offer: :class:`.DiscountOffer` + The discount offer to apply to the previewed invoice. Raises ------ @@ -791,6 +821,8 @@ class Subscription(Hashable): payload['apply_entitlements'] = apply_entitlements if renewal is not MISSING: payload['renewal'] = renewal + if discount_offer is not MISSING: + payload['user_discount_offer_id'] = discount_offer.id if payload: data = await self._state.http.preview_subscription_update(self.id, **payload) diff --git a/discord/types/billing.py b/discord/types/billing.py index 3eb673678..5c6675a55 100644 --- a/discord/types/billing.py +++ b/discord/types/billing.py @@ -76,3 +76,7 @@ class PremiumUsage(TypedDict): total_large_uploads: PremiumUsageValue total_hd_streams: PremiumUsageValue hd_hours_streamed: PremiumUsageValue + + +class CheckoutRecovery(TypedDict): + is_eligible: bool diff --git a/discord/types/payments.py b/discord/types/payments.py index 5e3628046..aac9a5afa 100644 --- a/discord/types/payments.py +++ b/discord/types/payments.py @@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import List, Literal, TypedDict +from typing import List, Literal, Optional, TypedDict from typing_extensions import NotRequired from .billing import PartialPaymentSource @@ -59,3 +59,8 @@ class Payment(PartialPayment): sku: NotRequired[PublicSKU] payment_source: NotRequired[PartialPaymentSource] subscription: NotRequired[PartialSubscription] + metadata: PaymentMetadata + + +class PaymentMetadata(TypedDict): + billing_error_code: Optional[int] diff --git a/discord/types/promotions.py b/discord/types/promotions.py index bdcb89f6a..1dadffe2c 100644 --- a/discord/types/promotions.py +++ b/discord/types/promotions.py @@ -55,11 +55,26 @@ class ClaimedPromotion(TypedDict): claimed_at: str +class UserOffer(TypedDict): + user_trial_offer: Optional[TrialOffer] + user_discount_offer: Optional[DiscountOffer] + user_discount: Optional[DiscountOffer] + + class TrialOffer(TypedDict): id: Snowflake expires_at: Optional[str] trial_id: Snowflake subscription_trial: SubscriptionTrial + user_id: Snowflake + + +class DiscountOffer(TypedDict): + id: Snowflake + expires_at: Optional[str] + applied_at: Optional[str] + discount_id: Snowflake + user_id: Snowflake class PromotionalPrice(TypedDict): diff --git a/discord/types/subscriptions.py b/discord/types/subscriptions.py index fb27521d4..febf43368 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] + sku_id: Optional[Snowflake] tenant_metadata: NotRequired[Dict[str, Any]] @@ -87,8 +88,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]]] + # applied_discount_ids: NotRequired[List[Snowflake]] + # applied_user_discounts: NotRequired[Dict[Snowflake, Optional[Any]]] class SubscriptionRenewalMutations(TypedDict, total=False): @@ -118,12 +119,28 @@ class Subscription(PartialSubscription): canceled_at: NotRequired[str] country_code: Optional[str] trial_ends_at: NotRequired[str] - metadata: NotRequired[Dict[str, Any]] + metadata: NotRequired[SubscriptionMetadata] latest_invoice: NotRequired[SubscriptionInvoice] use_storekit_resubscribe: bool price: Optional[int] +class SubscriptionMetadata(TypedDict, total=False): + is_egs: bool + is_holiday_promotion_2021: bool + ended_at: str + guild_id: Snowflake + application_subscription_guild_id: Snowflake + grace_period_expires_date: str + apple_grace_period_expires_date: str + google_grace_period_expires_date: str + google_original_expires_date: str + user_trial_offer_id: Snowflake + user_discount_offer_id: Snowflake # guess + active_discount_id: Snowflake + active_discount_expires_at: str + + class SubscriptionTrial(TypedDict): id: Snowflake interval: Literal[1, 2, 3] diff --git a/docs/api.rst b/docs/api.rst index e00c7a289..63ee3a802 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4166,6 +4166,12 @@ of :class:`enum.Enum`. The subscription is on account hold. + .. attribute:: billing_retry + + The subscription failed to bill and will retry. + + .. versionadded:: 2.1 + .. class:: SubscriptionInvoiceStatus Represents the status of a subscription invoice. @@ -7334,11 +7340,21 @@ Promotion .. autoclass:: PricingPromotion() :members: +.. attributetable:: UserOffer + +.. autoclass:: UserOffer() + :members: + .. attributetable:: TrialOffer .. autoclass:: TrialOffer() :members: +.. attributetable:: DiscountOffer + +.. autoclass:: DiscountOffer() + :members: + Subscription ~~~~~~~~~~~~