diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eba8a049..1887bd54 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.21.0" + ".": "3.22.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 8d440bd0..1265d475 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 114 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/orb%2Forb-faced218bf2dee89c8449bdb209e7090452d26c0646f0b998f84fe5bd3c4b7cb.yml -openapi_spec_hash: b3a957e9c012fad5093545f65614ea42 -config_hash: e63f2d098e5d12f63ae4cd8270aa5c3c +configured_endpoints: 115 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/orb%2Forb-d513be954ba2cb57489bd048f93790cabb13849f0f04d328a5a15694c99550df.yml +openapi_spec_hash: 0d42694f412abf65defc3f88646a809c +config_hash: 1e2186b09e57d7d27b6ab5c8e6410b31 diff --git a/CHANGELOG.md b/CHANGELOG.md index c332d1d5..c4d57af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 3.22.0 (2025-06-02) + +Full Changelog: [v3.21.0...v3.22.0](https://github.com/orbcorp/orb-python/compare/v3.21.0...v3.22.0) + +### Features + +* **api:** api update ([3deb3ab](https://github.com/orbcorp/orb-python/commit/3deb3ab6bf04d89551aa40deba99afcd29ee8099)) +* **client:** add follow_redirects request option ([5185843](https://github.com/orbcorp/orb-python/commit/51858434884f222b3c9974529aaf1958e5fba808)) + + +### Chores + +* **docs:** remove reference to rye shell ([c0cdb68](https://github.com/orbcorp/orb-python/commit/c0cdb684679416fb188bc77f8e822f8bcb345b50)) + ## 3.21.0 (2025-05-30) Full Changelog: [v3.20.0...v3.21.0](https://github.com/orbcorp/orb-python/compare/v3.20.0...v3.21.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abbbea65..ea9f566f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix diff --git a/api.md b/api.md index 4a380b71..b7905aed 100644 --- a/api.md +++ b/api.md @@ -365,6 +365,7 @@ from orb.types import ( SubscriptionFetchCostsResponse, SubscriptionFetchScheduleResponse, SubscriptionPriceIntervalsResponse, + SubscriptionRedeemCouponResponse, SubscriptionSchedulePlanChangeResponse, SubscriptionTriggerPhaseResponse, SubscriptionUnscheduleCancellationResponse, @@ -386,6 +387,7 @@ Methods: - client.subscriptions.fetch_schedule(subscription_id, \*\*params) -> SyncPage[SubscriptionFetchScheduleResponse] - client.subscriptions.fetch_usage(subscription_id, \*\*params) -> SubscriptionUsage - client.subscriptions.price_intervals(subscription_id, \*\*params) -> SubscriptionPriceIntervalsResponse +- client.subscriptions.redeem_coupon(subscription_id, \*\*params) -> SubscriptionRedeemCouponResponse - client.subscriptions.schedule_plan_change(subscription_id, \*\*params) -> SubscriptionSchedulePlanChangeResponse - client.subscriptions.trigger_phase(subscription_id, \*\*params) -> SubscriptionTriggerPhaseResponse - client.subscriptions.unschedule_cancellation(subscription_id) -> SubscriptionUnscheduleCancellationResponse diff --git a/pyproject.toml b/pyproject.toml index 0cc737d9..9349e3c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "orb-billing" -version = "3.21.0" +version = "3.22.0" description = "The official Python library for the orb API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/orb/_base_client.py b/src/orb/_base_client.py index f82fa056..056620f0 100644 --- a/src/orb/_base_client.py +++ b/src/orb/_base_client.py @@ -961,6 +961,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1475,6 +1478,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None diff --git a/src/orb/_models.py b/src/orb/_models.py index 798956f1..4f214980 100644 --- a/src/orb/_models.py +++ b/src/orb/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/orb/_types.py b/src/orb/_types.py index a7f6c8ac..545d7960 100644 --- a/src/orb/_types.py +++ b/src/orb/_types.py @@ -101,6 +101,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -217,3 +218,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/src/orb/_version.py b/src/orb/_version.py index 14eb9911..36b0ea85 100644 --- a/src/orb/_version.py +++ b/src/orb/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "orb" -__version__ = "3.21.0" # x-release-please-version +__version__ = "3.22.0" # x-release-please-version diff --git a/src/orb/resources/subscriptions.py b/src/orb/resources/subscriptions.py index 8a6bde4e..7d1edbb5 100644 --- a/src/orb/resources/subscriptions.py +++ b/src/orb/resources/subscriptions.py @@ -17,6 +17,7 @@ subscription_fetch_costs_params, subscription_fetch_usage_params, subscription_update_trial_params, + subscription_redeem_coupon_params, subscription_trigger_phase_params, subscription_fetch_schedule_params, subscription_price_intervals_params, @@ -37,6 +38,7 @@ from ..types.subscription_create_response import SubscriptionCreateResponse from ..types.subscription_fetch_costs_response import SubscriptionFetchCostsResponse from ..types.subscription_update_trial_response import SubscriptionUpdateTrialResponse +from ..types.subscription_redeem_coupon_response import SubscriptionRedeemCouponResponse from ..types.subscription_trigger_phase_response import SubscriptionTriggerPhaseResponse from ..types.subscription_fetch_schedule_response import SubscriptionFetchScheduleResponse from ..types.subscription_price_intervals_response import SubscriptionPriceIntervalsResponse @@ -1361,6 +1363,68 @@ def price_intervals( cast_to=SubscriptionPriceIntervalsResponse, ) + def redeem_coupon( + self, + subscription_id: str, + *, + change_option: Literal["requested_date", "end_of_subscription_term", "immediate"], + coupon_id: str, + allow_invoice_credit_or_void: Optional[bool] | NotGiven = NOT_GIVEN, + change_date: Union[str, datetime, None] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> SubscriptionRedeemCouponResponse: + """ + Redeem a coupon effective at a given time. + + Args: + coupon_id: Coupon ID to be redeemed for this subscription. + + allow_invoice_credit_or_void: If false, this request will fail if it would void an issued invoice or create a + credit note. Consider using this as a safety mechanism if you do not expect + existing invoices to be changed. + + change_date: The date that the coupon discount should take effect. This parameter can only be + passed if the `change_option` is `requested_date`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + if not subscription_id: + raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") + return self._post( + f"/subscriptions/{subscription_id}/redeem_coupon", + body=maybe_transform( + { + "change_option": change_option, + "coupon_id": coupon_id, + "allow_invoice_credit_or_void": allow_invoice_credit_or_void, + "change_date": change_date, + }, + subscription_redeem_coupon_params.SubscriptionRedeemCouponParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=SubscriptionRedeemCouponResponse, + ) + def schedule_plan_change( self, subscription_id: str, @@ -3385,6 +3449,68 @@ async def price_intervals( cast_to=SubscriptionPriceIntervalsResponse, ) + async def redeem_coupon( + self, + subscription_id: str, + *, + change_option: Literal["requested_date", "end_of_subscription_term", "immediate"], + coupon_id: str, + allow_invoice_credit_or_void: Optional[bool] | NotGiven = NOT_GIVEN, + change_date: Union[str, datetime, None] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + idempotency_key: str | None = None, + ) -> SubscriptionRedeemCouponResponse: + """ + Redeem a coupon effective at a given time. + + Args: + coupon_id: Coupon ID to be redeemed for this subscription. + + allow_invoice_credit_or_void: If false, this request will fail if it would void an issued invoice or create a + credit note. Consider using this as a safety mechanism if you do not expect + existing invoices to be changed. + + change_date: The date that the coupon discount should take effect. This parameter can only be + passed if the `change_option` is `requested_date`. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + + idempotency_key: Specify a custom idempotency key for this request + """ + if not subscription_id: + raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}") + return await self._post( + f"/subscriptions/{subscription_id}/redeem_coupon", + body=await async_maybe_transform( + { + "change_option": change_option, + "coupon_id": coupon_id, + "allow_invoice_credit_or_void": allow_invoice_credit_or_void, + "change_date": change_date, + }, + subscription_redeem_coupon_params.SubscriptionRedeemCouponParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=SubscriptionRedeemCouponResponse, + ) + async def schedule_plan_change( self, subscription_id: str, @@ -4132,6 +4258,9 @@ def __init__(self, subscriptions: Subscriptions) -> None: self.price_intervals = _legacy_response.to_raw_response_wrapper( subscriptions.price_intervals, ) + self.redeem_coupon = _legacy_response.to_raw_response_wrapper( + subscriptions.redeem_coupon, + ) self.schedule_plan_change = _legacy_response.to_raw_response_wrapper( subscriptions.schedule_plan_change, ) @@ -4186,6 +4315,9 @@ def __init__(self, subscriptions: AsyncSubscriptions) -> None: self.price_intervals = _legacy_response.async_to_raw_response_wrapper( subscriptions.price_intervals, ) + self.redeem_coupon = _legacy_response.async_to_raw_response_wrapper( + subscriptions.redeem_coupon, + ) self.schedule_plan_change = _legacy_response.async_to_raw_response_wrapper( subscriptions.schedule_plan_change, ) @@ -4240,6 +4372,9 @@ def __init__(self, subscriptions: Subscriptions) -> None: self.price_intervals = to_streamed_response_wrapper( subscriptions.price_intervals, ) + self.redeem_coupon = to_streamed_response_wrapper( + subscriptions.redeem_coupon, + ) self.schedule_plan_change = to_streamed_response_wrapper( subscriptions.schedule_plan_change, ) @@ -4294,6 +4429,9 @@ def __init__(self, subscriptions: AsyncSubscriptions) -> None: self.price_intervals = async_to_streamed_response_wrapper( subscriptions.price_intervals, ) + self.redeem_coupon = async_to_streamed_response_wrapper( + subscriptions.redeem_coupon, + ) self.schedule_plan_change = async_to_streamed_response_wrapper( subscriptions.schedule_plan_change, ) diff --git a/src/orb/types/__init__.py b/src/orb/types/__init__.py index 76860045..3105fa6c 100644 --- a/src/orb/types/__init__.py +++ b/src/orb/types/__init__.py @@ -86,6 +86,7 @@ from .subscription_update_trial_params import SubscriptionUpdateTrialParams as SubscriptionUpdateTrialParams from .invoice_line_item_create_response import InvoiceLineItemCreateResponse as InvoiceLineItemCreateResponse from .subscription_fetch_costs_response import SubscriptionFetchCostsResponse as SubscriptionFetchCostsResponse +from .subscription_redeem_coupon_params import SubscriptionRedeemCouponParams as SubscriptionRedeemCouponParams from .subscription_trigger_phase_params import SubscriptionTriggerPhaseParams as SubscriptionTriggerPhaseParams from .subscription_change_apply_response import SubscriptionChangeApplyResponse as SubscriptionChangeApplyResponse from .subscription_fetch_schedule_params import SubscriptionFetchScheduleParams as SubscriptionFetchScheduleParams @@ -93,6 +94,7 @@ from .dimensional_price_group_list_params import DimensionalPriceGroupListParams as DimensionalPriceGroupListParams from .subscription_change_cancel_response import SubscriptionChangeCancelResponse as SubscriptionChangeCancelResponse from .subscription_price_intervals_params import SubscriptionPriceIntervalsParams as SubscriptionPriceIntervalsParams +from .subscription_redeem_coupon_response import SubscriptionRedeemCouponResponse as SubscriptionRedeemCouponResponse from .subscription_trigger_phase_response import SubscriptionTriggerPhaseResponse as SubscriptionTriggerPhaseResponse from .alert_create_for_subscription_params import AlertCreateForSubscriptionParams as AlertCreateForSubscriptionParams from .beta_set_default_plan_version_params import BetaSetDefaultPlanVersionParams as BetaSetDefaultPlanVersionParams diff --git a/src/orb/types/invoice.py b/src/orb/types/invoice.py index 7d8e108b..05d91a75 100644 --- a/src/orb/types/invoice.py +++ b/src/orb/types/invoice.py @@ -760,7 +760,7 @@ class LineItem(BaseModel): partially_invoiced_amount: str """Any amount applied from a partial invoice""" - price: Optional[Price] = None + price: Price """ The Price resource represents a price that can be billed on a subscription, resulting in a charge on an invoice in the form of an invoice line item. Prices diff --git a/src/orb/types/invoice_fetch_upcoming_response.py b/src/orb/types/invoice_fetch_upcoming_response.py index 5d9d2d6d..d2b4fa6a 100644 --- a/src/orb/types/invoice_fetch_upcoming_response.py +++ b/src/orb/types/invoice_fetch_upcoming_response.py @@ -760,7 +760,7 @@ class LineItem(BaseModel): partially_invoiced_amount: str """Any amount applied from a partial invoice""" - price: Optional[Price] = None + price: Price """ The Price resource represents a price that can be billed on a subscription, resulting in a charge on an invoice in the form of an invoice line item. Prices diff --git a/src/orb/types/invoice_line_item_create_response.py b/src/orb/types/invoice_line_item_create_response.py index d8e6209d..3523a409 100644 --- a/src/orb/types/invoice_line_item_create_response.py +++ b/src/orb/types/invoice_line_item_create_response.py @@ -462,7 +462,7 @@ class InvoiceLineItemCreateResponse(BaseModel): partially_invoiced_amount: str """Any amount applied from a partial invoice""" - price: Optional[Price] = None + price: Price """ The Price resource represents a price that can be billed on a subscription, resulting in a charge on an invoice in the form of an invoice line item. Prices diff --git a/src/orb/types/subscription_redeem_coupon_params.py b/src/orb/types/subscription_redeem_coupon_params.py new file mode 100644 index 00000000..385a6e26 --- /dev/null +++ b/src/orb/types/subscription_redeem_coupon_params.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SubscriptionRedeemCouponParams"] + + +class SubscriptionRedeemCouponParams(TypedDict, total=False): + change_option: Required[Literal["requested_date", "end_of_subscription_term", "immediate"]] + + coupon_id: Required[str] + """Coupon ID to be redeemed for this subscription.""" + + allow_invoice_credit_or_void: Optional[bool] + """ + If false, this request will fail if it would void an issued invoice or create a + credit note. Consider using this as a safety mechanism if you do not expect + existing invoices to be changed. + """ + + change_date: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """The date that the coupon discount should take effect. + + This parameter can only be passed if the `change_option` is `requested_date`. + """ diff --git a/src/orb/types/subscription_redeem_coupon_response.py b/src/orb/types/subscription_redeem_coupon_response.py new file mode 100644 index 00000000..e0c0d5bd --- /dev/null +++ b/src/orb/types/subscription_redeem_coupon_response.py @@ -0,0 +1,728 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypeAlias + +from .plan import Plan +from .price import Price +from .._utils import PropertyInfo +from .invoice import Invoice +from .._models import BaseModel +from .customer import Customer +from .credit_note import CreditNote + +__all__ = [ + "SubscriptionRedeemCouponResponse", + "AdjustmentInterval", + "AdjustmentIntervalAdjustment", + "AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustment", + "AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustmentFilter", + "AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustment", + "AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustmentFilter", + "AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustment", + "AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustmentFilter", + "AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustment", + "AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustmentFilter", + "AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustment", + "AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustmentFilter", + "BillingCycleAnchorConfiguration", + "DiscountInterval", + "DiscountIntervalAmountDiscountInterval", + "DiscountIntervalAmountDiscountIntervalFilter", + "DiscountIntervalPercentageDiscountInterval", + "DiscountIntervalPercentageDiscountIntervalFilter", + "DiscountIntervalUsageDiscountInterval", + "DiscountIntervalUsageDiscountIntervalFilter", + "FixedFeeQuantitySchedule", + "MaximumInterval", + "MaximumIntervalFilter", + "MinimumInterval", + "MinimumIntervalFilter", + "PendingSubscriptionChange", + "PriceInterval", + "PriceIntervalFixedFeeQuantityTransition", + "RedeemedCoupon", + "TrialInfo", + "ChangedResources", +] + + +class AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustmentFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustment(BaseModel): + id: str + + adjustment_type: Literal["usage_discount"] + + applies_to_price_ids: List[str] + """The price IDs that this adjustment applies to.""" + + filters: List[AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustmentFilter] + """The filters that determine which prices to apply this adjustment to.""" + + is_invoice_level: bool + """ + True for adjustments that apply to an entire invocice, false for adjustments + that apply to only one price. + """ + + plan_phase_order: Optional[int] = None + """The plan phase in which this adjustment is active.""" + + reason: Optional[str] = None + """The reason for the adjustment.""" + + usage_discount: float + """ + The number of usage units by which to discount the price this adjustment applies + to in a given billing period. + """ + + +class AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustmentFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustment(BaseModel): + id: str + + adjustment_type: Literal["amount_discount"] + + amount_discount: str + """ + The amount by which to discount the prices this adjustment applies to in a given + billing period. + """ + + applies_to_price_ids: List[str] + """The price IDs that this adjustment applies to.""" + + filters: List[AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustmentFilter] + """The filters that determine which prices to apply this adjustment to.""" + + is_invoice_level: bool + """ + True for adjustments that apply to an entire invocice, false for adjustments + that apply to only one price. + """ + + plan_phase_order: Optional[int] = None + """The plan phase in which this adjustment is active.""" + + reason: Optional[str] = None + """The reason for the adjustment.""" + + +class AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustmentFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustment(BaseModel): + id: str + + adjustment_type: Literal["percentage_discount"] + + applies_to_price_ids: List[str] + """The price IDs that this adjustment applies to.""" + + filters: List[AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustmentFilter] + """The filters that determine which prices to apply this adjustment to.""" + + is_invoice_level: bool + """ + True for adjustments that apply to an entire invocice, false for adjustments + that apply to only one price. + """ + + percentage_discount: float + """ + The percentage (as a value between 0 and 1) by which to discount the price + intervals this adjustment applies to in a given billing period. + """ + + plan_phase_order: Optional[int] = None + """The plan phase in which this adjustment is active.""" + + reason: Optional[str] = None + """The reason for the adjustment.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustmentFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustment(BaseModel): + id: str + + adjustment_type: Literal["minimum"] + + applies_to_price_ids: List[str] + """The price IDs that this adjustment applies to.""" + + filters: List[AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustmentFilter] + """The filters that determine which prices to apply this adjustment to.""" + + is_invoice_level: bool + """ + True for adjustments that apply to an entire invocice, false for adjustments + that apply to only one price. + """ + + item_id: str + """The item ID that revenue from this minimum will be attributed to.""" + + minimum_amount: str + """ + The minimum amount to charge in a given billing period for the prices this + adjustment applies to. + """ + + plan_phase_order: Optional[int] = None + """The plan phase in which this adjustment is active.""" + + reason: Optional[str] = None + """The reason for the adjustment.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustmentFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustment(BaseModel): + id: str + + adjustment_type: Literal["maximum"] + + applies_to_price_ids: List[str] + """The price IDs that this adjustment applies to.""" + + filters: List[AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustmentFilter] + """The filters that determine which prices to apply this adjustment to.""" + + is_invoice_level: bool + """ + True for adjustments that apply to an entire invocice, false for adjustments + that apply to only one price. + """ + + maximum_amount: str + """ + The maximum amount to charge in a given billing period for the prices this + adjustment applies to. + """ + + plan_phase_order: Optional[int] = None + """The plan phase in which this adjustment is active.""" + + reason: Optional[str] = None + """The reason for the adjustment.""" + + +AdjustmentIntervalAdjustment: TypeAlias = Annotated[ + Union[ + AdjustmentIntervalAdjustmentPlanPhaseUsageDiscountAdjustment, + AdjustmentIntervalAdjustmentPlanPhaseAmountDiscountAdjustment, + AdjustmentIntervalAdjustmentPlanPhasePercentageDiscountAdjustment, + AdjustmentIntervalAdjustmentPlanPhaseMinimumAdjustment, + AdjustmentIntervalAdjustmentPlanPhaseMaximumAdjustment, + ], + PropertyInfo(discriminator="adjustment_type"), +] + + +class AdjustmentInterval(BaseModel): + id: str + + adjustment: AdjustmentIntervalAdjustment + + applies_to_price_interval_ids: List[str] + """The price interval IDs that this adjustment applies to.""" + + end_date: Optional[datetime] = None + """The end date of the adjustment interval.""" + + start_date: datetime + """The start date of the adjustment interval.""" + + +class BillingCycleAnchorConfiguration(BaseModel): + day: int + """The day of the month on which the billing cycle is anchored. + + If the maximum number of days in a month is greater than this value, the last + day of the month is the billing cycle day (e.g. billing_cycle_day=31 for April + means the billing period begins on the 30th. + """ + + month: Optional[int] = None + """The month on which the billing cycle is anchored (e.g. + + a quarterly price anchored in February would have cycles starting February, May, + August, and November). + """ + + year: Optional[int] = None + """The year on which the billing cycle is anchored (e.g. + + a 2 year billing cycle anchored on 2021 would have cycles starting on 2021, + 2023, 2025, etc.). + """ + + +class DiscountIntervalAmountDiscountIntervalFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class DiscountIntervalAmountDiscountInterval(BaseModel): + amount_discount: str + """Only available if discount_type is `amount`.""" + + applies_to_price_interval_ids: List[str] + """The price interval ids that this discount interval applies to.""" + + discount_type: Literal["amount"] + + end_date: Optional[datetime] = None + """The end date of the discount interval.""" + + filters: List[DiscountIntervalAmountDiscountIntervalFilter] + """The filters that determine which prices this discount interval applies to.""" + + start_date: datetime + """The start date of the discount interval.""" + + +class DiscountIntervalPercentageDiscountIntervalFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class DiscountIntervalPercentageDiscountInterval(BaseModel): + applies_to_price_interval_ids: List[str] + """The price interval ids that this discount interval applies to.""" + + discount_type: Literal["percentage"] + + end_date: Optional[datetime] = None + """The end date of the discount interval.""" + + filters: List[DiscountIntervalPercentageDiscountIntervalFilter] + """The filters that determine which prices this discount interval applies to.""" + + percentage_discount: float + """ + Only available if discount_type is `percentage`.This is a number between 0 + and 1. + """ + + start_date: datetime + """The start date of the discount interval.""" + + +class DiscountIntervalUsageDiscountIntervalFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class DiscountIntervalUsageDiscountInterval(BaseModel): + applies_to_price_interval_ids: List[str] + """The price interval ids that this discount interval applies to.""" + + discount_type: Literal["usage"] + + end_date: Optional[datetime] = None + """The end date of the discount interval.""" + + filters: List[DiscountIntervalUsageDiscountIntervalFilter] + """The filters that determine which prices this discount interval applies to.""" + + start_date: datetime + """The start date of the discount interval.""" + + usage_discount: float + """Only available if discount_type is `usage`. + + Number of usage units that this discount is for + """ + + +DiscountInterval: TypeAlias = Annotated[ + Union[ + DiscountIntervalAmountDiscountInterval, + DiscountIntervalPercentageDiscountInterval, + DiscountIntervalUsageDiscountInterval, + ], + PropertyInfo(discriminator="discount_type"), +] + + +class FixedFeeQuantitySchedule(BaseModel): + end_date: Optional[datetime] = None + + price_id: str + + quantity: float + + start_date: datetime + + +class MaximumIntervalFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class MaximumInterval(BaseModel): + applies_to_price_interval_ids: List[str] + """The price interval ids that this maximum interval applies to.""" + + end_date: Optional[datetime] = None + """The end date of the maximum interval.""" + + filters: List[MaximumIntervalFilter] + """The filters that determine which prices this maximum interval applies to.""" + + maximum_amount: str + """ + The maximum amount to charge in a given billing period for the price intervals + this transform applies to. + """ + + start_date: datetime + """The start date of the maximum interval.""" + + +class MinimumIntervalFilter(BaseModel): + field: Literal["price_id", "item_id", "price_type", "currency", "pricing_unit_id"] + """The property of the price to filter on.""" + + operator: Literal["includes", "excludes"] + """Should prices that match the filter be included or excluded.""" + + values: List[str] + """The IDs or values that match this filter.""" + + +class MinimumInterval(BaseModel): + applies_to_price_interval_ids: List[str] + """The price interval ids that this minimum interval applies to.""" + + end_date: Optional[datetime] = None + """The end date of the minimum interval.""" + + filters: List[MinimumIntervalFilter] + """The filters that determine which prices this minimum interval applies to.""" + + minimum_amount: str + """ + The minimum amount to charge in a given billing period for the price intervals + this minimum applies to. + """ + + start_date: datetime + """The start date of the minimum interval.""" + + +class PendingSubscriptionChange(BaseModel): + id: str + + +class PriceIntervalFixedFeeQuantityTransition(BaseModel): + effective_date: datetime + + price_id: str + + quantity: int + + +class PriceInterval(BaseModel): + id: str + + billing_cycle_day: int + """The day of the month that Orb bills for this price""" + + current_billing_period_end_date: Optional[datetime] = None + """The end of the current billing period. + + This is an exclusive timestamp, such that the instant returned is exactly the + end of the billing period. Set to null if this price interval is not currently + active. + """ + + current_billing_period_start_date: Optional[datetime] = None + """The start date of the current billing period. + + This is an inclusive timestamp; the instant returned is exactly the beginning of + the billing period. Set to null if this price interval is not currently active. + """ + + end_date: Optional[datetime] = None + """The end date of the price interval. + + This is the date that Orb stops billing for this price. + """ + + filter: Optional[str] = None + """An additional filter to apply to usage queries.""" + + fixed_fee_quantity_transitions: Optional[List[PriceIntervalFixedFeeQuantityTransition]] = None + """The fixed fee quantity transitions for this price interval. + + This is only relevant for fixed fees. + """ + + price: Price + """ + The Price resource represents a price that can be billed on a subscription, + resulting in a charge on an invoice in the form of an invoice line item. Prices + take a quantity and determine an amount to bill. + + Orb supports a few different pricing models out of the box. Each of these models + is serialized differently in a given Price object. The model_type field + determines the key for the configuration object that is present. + + For more on the types of prices, see + [the core concepts documentation](/core-concepts#plan-and-price) + """ + + start_date: datetime + """The start date of the price interval. + + This is the date that Orb starts billing for this price. + """ + + usage_customer_ids: Optional[List[str]] = None + """ + A list of customer IDs whose usage events will be aggregated and billed under + this price interval. + """ + + +class RedeemedCoupon(BaseModel): + coupon_id: str + + end_date: Optional[datetime] = None + + start_date: datetime + + +class TrialInfo(BaseModel): + end_date: Optional[datetime] = None + + +class ChangedResources(BaseModel): + created_credit_notes: List[CreditNote] + """The credit notes that were created as part of this operation.""" + + created_invoices: List[Invoice] + """The invoices that were created as part of this operation.""" + + voided_credit_notes: List[CreditNote] + """The credit notes that were voided as part of this operation.""" + + voided_invoices: List[Invoice] + """The invoices that were voided as part of this operation.""" + + +class SubscriptionRedeemCouponResponse(BaseModel): + id: str + + active_plan_phase_order: Optional[int] = None + """ + The current plan phase that is active, only if the subscription's plan has + phases. + """ + + adjustment_intervals: List[AdjustmentInterval] + """ + The adjustment intervals for this subscription sorted by the start_date of the + adjustment interval. + """ + + auto_collection: Optional[bool] = None + """ + Determines whether issued invoices for this subscription will automatically be + charged with the saved payment method on the due date. This property defaults to + the plan's behavior. If null, defaults to the customer's setting. + """ + + billing_cycle_anchor_configuration: BillingCycleAnchorConfiguration + + billing_cycle_day: int + """The day of the month on which the billing cycle is anchored. + + If the maximum number of days in a month is greater than this value, the last + day of the month is the billing cycle day (e.g. billing_cycle_day=31 for April + means the billing period begins on the 30th. + """ + + created_at: datetime + + current_billing_period_end_date: Optional[datetime] = None + """The end of the current billing period. + + This is an exclusive timestamp, such that the instant returned is not part of + the billing period. Set to null for subscriptions that are not currently active. + """ + + current_billing_period_start_date: Optional[datetime] = None + """The start date of the current billing period. + + This is an inclusive timestamp; the instant returned is exactly the beginning of + the billing period. Set to null if the subscription is not currently active. + """ + + customer: Customer + """ + A customer is a buyer of your products, and the other party to the billing + relationship. + + In Orb, customers are assigned system generated identifiers automatically, but + it's often desirable to have these match existing identifiers in your system. To + avoid having to denormalize Orb ID information, you can pass in an + `external_customer_id` with your own identifier. See + [Customer ID Aliases](/events-and-metrics/customer-aliases) for further + information about how these aliases work in Orb. + + In addition to having an identifier in your system, a customer may exist in a + payment provider solution like Stripe. Use the `payment_provider_id` and the + `payment_provider` enum field to express this mapping. + + A customer also has a timezone (from the standard + [IANA timezone database](https://www.iana.org/time-zones)), which defaults to + your account's timezone. See [Timezone localization](/essentials/timezones) for + information on what this timezone parameter influences within Orb. + """ + + default_invoice_memo: Optional[str] = None + """Determines the default memo on this subscriptions' invoices. + + Note that if this is not provided, it is determined by the plan configuration. + """ + + discount_intervals: List[DiscountInterval] + """The discount intervals for this subscription sorted by the start_date.""" + + end_date: Optional[datetime] = None + """The date Orb stops billing for this subscription.""" + + fixed_fee_quantity_schedule: List[FixedFeeQuantitySchedule] + + invoicing_threshold: Optional[str] = None + + maximum_intervals: List[MaximumInterval] + """The maximum intervals for this subscription sorted by the start_date.""" + + metadata: Dict[str, str] + """User specified key-value pairs for the resource. + + If not present, this defaults to an empty dictionary. Individual keys can be + removed by setting the value to `null`, and the entire metadata mapping can be + cleared by setting `metadata` to `null`. + """ + + minimum_intervals: List[MinimumInterval] + """The minimum intervals for this subscription sorted by the start_date.""" + + name: str + """The name of the subscription.""" + + net_terms: int + """ + Determines the difference between the invoice issue date for subscription + invoices as the date that they are due. A value of `0` here represents that the + invoice is due on issue, whereas a value of `30` represents that the customer + has a month to pay the invoice. + """ + + pending_subscription_change: Optional[PendingSubscriptionChange] = None + """A pending subscription change if one exists on this subscription.""" + + plan: Optional[Plan] = None + """ + The [Plan](/core-concepts#plan-and-price) resource represents a plan that can be + subscribed to by a customer. Plans define the billing behavior of the + subscription. You can see more about how to configure prices in the + [Price resource](/reference/price). + """ + + price_intervals: List[PriceInterval] + """The price intervals for this subscription.""" + + redeemed_coupon: Optional[RedeemedCoupon] = None + + start_date: datetime + """The date Orb starts billing for this subscription.""" + + status: Literal["active", "ended", "upcoming"] + + trial_info: TrialInfo + + changed_resources: Optional[ChangedResources] = None + """The resources that were changed as part of this operation. + + Only present when fetched through the subscription changes API or if the + `include_changed_resources` parameter was passed in the request. + """ diff --git a/tests/api_resources/test_subscriptions.py b/tests/api_resources/test_subscriptions.py index d6d46997..08ad18e5 100644 --- a/tests/api_resources/test_subscriptions.py +++ b/tests/api_resources/test_subscriptions.py @@ -15,6 +15,7 @@ SubscriptionCreateResponse, SubscriptionFetchCostsResponse, SubscriptionUpdateTrialResponse, + SubscriptionRedeemCouponResponse, SubscriptionTriggerPhaseResponse, SubscriptionFetchScheduleResponse, SubscriptionPriceIntervalsResponse, @@ -730,6 +731,63 @@ def test_path_params_price_intervals(self, client: Orb) -> None: subscription_id="", ) + @parametrize + def test_method_redeem_coupon(self, client: Orb) -> None: + subscription = client.subscriptions.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + def test_method_redeem_coupon_with_all_params(self, client: Orb) -> None: + subscription = client.subscriptions.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + allow_invoice_credit_or_void=True, + change_date=parse_datetime("2017-07-21T17:32:28Z"), + ) + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + def test_raw_response_redeem_coupon(self, client: Orb) -> None: + response = client.subscriptions.with_raw_response.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + subscription = response.parse() + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + def test_streaming_response_redeem_coupon(self, client: Orb) -> None: + with client.subscriptions.with_streaming_response.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + subscription = response.parse() + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_redeem_coupon(self, client: Orb) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `subscription_id` but received ''"): + client.subscriptions.with_raw_response.redeem_coupon( + subscription_id="", + change_option="requested_date", + coupon_id="coupon_id", + ) + @parametrize def test_method_schedule_plan_change(self, client: Orb) -> None: subscription = client.subscriptions.schedule_plan_change( @@ -1923,6 +1981,63 @@ async def test_path_params_price_intervals(self, async_client: AsyncOrb) -> None subscription_id="", ) + @parametrize + async def test_method_redeem_coupon(self, async_client: AsyncOrb) -> None: + subscription = await async_client.subscriptions.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + async def test_method_redeem_coupon_with_all_params(self, async_client: AsyncOrb) -> None: + subscription = await async_client.subscriptions.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + allow_invoice_credit_or_void=True, + change_date=parse_datetime("2017-07-21T17:32:28Z"), + ) + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + async def test_raw_response_redeem_coupon(self, async_client: AsyncOrb) -> None: + response = await async_client.subscriptions.with_raw_response.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + subscription = response.parse() + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + @parametrize + async def test_streaming_response_redeem_coupon(self, async_client: AsyncOrb) -> None: + async with async_client.subscriptions.with_streaming_response.redeem_coupon( + subscription_id="subscription_id", + change_option="requested_date", + coupon_id="coupon_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + subscription = await response.parse() + assert_matches_type(SubscriptionRedeemCouponResponse, subscription, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_redeem_coupon(self, async_client: AsyncOrb) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `subscription_id` but received ''"): + await async_client.subscriptions.with_raw_response.redeem_coupon( + subscription_id="", + change_option="requested_date", + coupon_id="coupon_id", + ) + @parametrize async def test_method_schedule_plan_change(self, async_client: AsyncOrb) -> None: subscription = await async_client.subscriptions.schedule_plan_change( diff --git a/tests/test_client.py b/tests/test_client.py index 41b2ec55..88928ef0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -869,6 +869,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncOrb: client = AsyncOrb(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1751,3 +1778,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"