Browse Source

New billing features

pull/10109/head
Dolfies 4 months ago
parent
commit
f6380cf142
  1. 76
      discord/client.py
  2. 5
      discord/enums.py
  3. 27
      discord/http.py
  4. 8
      discord/payments.py
  5. 162
      discord/promotions.py
  6. 82
      discord/subscriptions.py
  7. 4
      discord/types/billing.py
  8. 7
      discord/types/payments.py
  9. 15
      discord/types/promotions.py
  10. 23
      discord/types/subscriptions.py
  11. 16
      docs/api.rst

76
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|

5
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

27
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'))

8
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'<Payment id={self.id} amount={self.amount} currency={self.currency} status={self.status}>'

162
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'<DiscountOffer id={self.id} discount_id={self.discount_id}>'
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.

82
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'<SubscriptionInvoice id={self.id} status={self.status!r} total={self.total}>'
@ -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)

4
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

7
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]

15
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):

23
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]

16
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
~~~~~~~~~~~~

Loading…
Cancel
Save