Coverage for polar/checkout/schemas.py: 95%
188 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1from datetime import datetime 1a
2from typing import Annotated, Any, Literal 1a
4from annotated_types import Ge, Le 1a
5from pydantic import ( 1a
6 UUID4,
7 AliasChoices,
8 Discriminator,
9 Field,
10 HttpUrl,
11 IPvAnyAddress,
12 Tag,
13 computed_field,
14)
15from pydantic.json_schema import SkipJsonSchema 1a
17from polar.custom_field.data import ( 1a
18 CustomFieldDataInputMixin,
19 CustomFieldDataOutputMixin,
20)
21from polar.custom_field.schemas import AttachedCustomField 1a
22from polar.customer.schemas.customer import CustomerNameInput 1a
23from polar.discount.schemas import ( 1a
24 DiscountFixedBase,
25 DiscountOnceForeverDurationBase,
26 DiscountPercentageBase,
27 DiscountRepeatDurationBase,
28)
29from polar.enums import PaymentProcessor 1a
30from polar.kit.address import Address, AddressInput 1a
31from polar.kit.email import EmailStrDNS 1a
32from polar.kit.metadata import ( 1a
33 METADATA_DESCRIPTION,
34 MetadataField,
35 MetadataInputMixin,
36 MetadataOutputMixin,
37)
38from polar.kit.schemas import ( 1a
39 EmptyStrToNoneValidator,
40 HttpUrlToStr,
41 IDSchema,
42 Schema,
43 SetSchemaReference,
44 TimestampedSchema,
45)
46from polar.kit.trial import ( 1a
47 TrialConfigurationInputMixin,
48 TrialConfigurationOutputMixin,
49 TrialInterval,
50)
51from polar.models.checkout import ( 1a
52 CheckoutBillingAddressFields,
53 CheckoutCustomerBillingAddressFields,
54 CheckoutStatus,
55)
56from polar.models.discount import DiscountDuration, DiscountType 1a
57from polar.organization.schemas import OrganizationPublicBase 1a
58from polar.product.schemas import ( 1a
59 BenefitPublicList,
60 ProductBase,
61 ProductMediaList,
62 ProductPrice,
63 ProductPriceCreateList,
64 ProductPriceList,
65)
67# Ref: https://stripe.com/docs/api/payment_intents/object#payment_intent_object-amount
68MAXIMUM_PRICE_AMOUNT = 99999999 1a
69MINIMUM_PRICE_AMOUNT = 50 1a
71Amount = Annotated[ 1a
72 int,
73 Field(
74 description=(
75 "Amount in cents, before discounts and taxes. "
76 "Only useful for custom prices, it'll be ignored for fixed and free prices."
77 )
78 ),
79 Ge(MINIMUM_PRICE_AMOUNT),
80 Le(MAXIMUM_PRICE_AMOUNT),
81]
82CustomerEmail = Annotated[ 1a
83 EmailStrDNS,
84 Field(description="Email address of the customer."),
85]
86CustomerIPAddress = Annotated[ 1a
87 IPvAnyAddress,
88 Field(
89 description="IP address of the customer. Used to detect tax location.",
90 ),
91]
92CustomerBillingAddressInput = Annotated[ 1a
93 AddressInput, Field(description="Billing address of the customer.")
94]
95CustomerBillingAddress = Annotated[ 1a
96 Address, Field(description="Billing address of the customer.")
97]
98SuccessURL = Annotated[ 1a
99 HttpUrl | None,
100 Field(
101 description=(
102 "URL where the customer will be redirected after a successful payment."
103 "You can add the `checkout_id={CHECKOUT_ID}` query parameter "
104 "to retrieve the checkout session id."
105 )
106 ),
107]
108ReturnURL = Annotated[ 1a
109 HttpUrlToStr | None,
110 Field(
111 description=(
112 "When set, a back button will be shown in the checkout "
113 "to return to this URL."
114 )
115 ),
116]
117EmbedOrigin = Annotated[ 1a
118 str | None,
119 Field(
120 description=(
121 "If you plan to embed the checkout session, "
122 "set this to the Origin of the embedding page. "
123 "It'll allow the Polar iframe to communicate with the parent page."
124 ),
125 ),
126]
128_external_customer_id_description = ( 1a
129 "ID of the customer in your system. "
130 "If a matching customer exists on Polar, the resulting order "
131 "will be linked to this customer. "
132 "Otherwise, a new customer will be created with this external ID set."
133)
134_allow_discount_codes_description = ( 1a
135 "Whether to allow the customer to apply discount codes. "
136 "If you apply a discount through `discount_id`, it'll still be applied, "
137 "but the customer won't be able to change it."
138)
139_require_billing_address_description = ( 1a
140 "Whether to require the customer to fill their full billing address, instead of "
141 "just the country. "
142 "Customers in the US will always be required to fill their full address, "
143 "regardless of this setting. "
144 "If you preset the billing address, this setting will be automatically set to "
145 "`true`."
146)
147_allow_trial_description = ( 1a
148 "Whether to enable the trial period for the checkout session. "
149 "If `false`, the trial period will be disabled, even if the selected product "
150 "has a trial configured."
151)
152_is_business_customer_description = ( 1a
153 "Whether the customer is a business or an individual. "
154 "If `true`, the customer will be required to fill their full billing address "
155 "and billing name."
156)
157_customer_metadata_description = METADATA_DESCRIPTION.format( 1a
158 heading=(
159 "Key-value object allowing you to store additional information "
160 "that'll be copied to the created customer."
161 )
162)
165class CheckoutCreateBase( 1a
166 CustomFieldDataInputMixin, MetadataInputMixin, TrialConfigurationInputMixin, Schema
167):
168 """
169 Create a new checkout session.
171 Metadata set on the checkout will be copied
172 to the resulting order and/or subscription.
173 """
175 discount_id: UUID4 | None = Field( 1a
176 default=None, description="ID of the discount to apply to the checkout."
177 )
178 allow_discount_codes: bool = Field( 1a
179 default=True, description=_allow_discount_codes_description
180 )
181 require_billing_address: bool = Field( 1a
182 default=False, description=_require_billing_address_description
183 )
184 amount: Amount | None = None 1a
185 seats: int | None = Field( 1a
186 default=None,
187 ge=1,
188 le=1000,
189 description="Number of seats for seat-based pricing. Required for seat-based products.",
190 )
191 allow_trial: bool = Field(default=True, description=_allow_trial_description) 1a
192 customer_id: UUID4 | None = Field( 1a
193 default=None,
194 description=(
195 "ID of an existing customer in the organization. "
196 "The customer data will be pre-filled in the checkout form. "
197 "The resulting order will be linked to this customer."
198 ),
199 )
200 is_business_customer: bool = Field( 1a
201 default=False, description=_is_business_customer_description
202 )
203 external_customer_id: str | None = Field( 1a
204 default=None,
205 description=_external_customer_id_description,
206 validation_alias=AliasChoices("external_customer_id", "customer_external_id"),
207 )
208 customer_name: CustomerNameInput | None = None 1a
209 customer_email: CustomerEmail | None = None 1a
210 customer_ip_address: CustomerIPAddress | None = None 1a
211 customer_billing_name: Annotated[str | None, EmptyStrToNoneValidator] = None 1a
212 customer_billing_address: CustomerBillingAddressInput | None = None 1a
213 customer_tax_id: Annotated[str | None, EmptyStrToNoneValidator] = None 1a
214 customer_metadata: MetadataField = Field( 1a
215 default_factory=dict, description=_customer_metadata_description
216 )
217 subscription_id: UUID4 | None = Field( 1a
218 default=None,
219 description=(
220 "ID of a subscription to upgrade. It must be on a free pricing. "
221 "If checkout is successful, metadata set on this checkout "
222 "will be copied to the subscription, and existing keys will be overwritten."
223 ),
224 )
225 success_url: SuccessURL = None 1a
226 return_url: ReturnURL = None 1a
227 embed_origin: EmbedOrigin = None 1a
230class CheckoutPriceCreate(CheckoutCreateBase): 1a
231 """
232 Create a new checkout session from a product price.
234 **Deprecated**: Use `CheckoutProductsCreate` instead.
236 Metadata set on the checkout will be copied
237 to the resulting order and/or subscription.
238 """
240 product_price_id: UUID4 = Field(description="ID of the product price to checkout.") 1a
243class CheckoutProductCreate(CheckoutCreateBase): 1a
244 """
245 Create a new checkout session from a product.
247 **Deprecated**: Use `CheckoutProductsCreate` instead.
249 Metadata set on the checkout will be copied
250 to the resulting order and/or subscription.
251 """
253 product_id: UUID4 = Field( 1a
254 description=(
255 "ID of the product to checkout. First available price will be selected."
256 )
257 )
260class CheckoutProductsCreate(CheckoutCreateBase): 1a
261 """
262 Create a new checkout session from a list of products.
263 Customers will be able to switch between those products.
265 Metadata set on the checkout will be copied
266 to the resulting order and/or subscription.
267 """
269 products: list[UUID4] = Field( 1a
270 description=(
271 "List of product IDs available to select at that checkout. "
272 "The first one will be selected by default."
273 ),
274 min_length=1,
275 )
276 prices: dict[UUID4, ProductPriceCreateList] | None = Field( 1a
277 default=None,
278 description=(
279 "Optional mapping of product IDs to a list of ad-hoc prices to create for that product. "
280 "If not set, catalog prices of the product will be used."
281 ),
282 )
285CheckoutCreate = Annotated[ 1a
286 CheckoutProductsCreate
287 | SkipJsonSchema[CheckoutProductCreate]
288 | SkipJsonSchema[CheckoutPriceCreate],
289 SetSchemaReference("CheckoutCreate"),
290]
293class CheckoutCreatePublic(Schema): 1a
294 """Create a new checkout session from a client."""
296 product_id: UUID4 = Field(description="ID of the product to checkout.") 1a
297 seats: int | None = Field( 1a
298 default=None,
299 ge=1,
300 le=1000,
301 description="Number of seats for seat-based pricing.",
302 )
303 customer_email: CustomerEmail | None = None 1a
304 subscription_id: UUID4 | None = Field( 1a
305 default=None,
306 description=(
307 "ID of a subscription to upgrade. It must be on a free pricing. "
308 "If checkout is successful, metadata set on this checkout "
309 "will be copied to the subscription, and existing keys will be overwritten."
310 ),
311 )
314class CheckoutUpdateBase(CustomFieldDataInputMixin, Schema): 1a
315 product_id: UUID4 | None = Field( 1a
316 default=None,
317 description=(
318 "ID of the product to checkout. "
319 "Must be present in the checkout's product list."
320 ),
321 )
322 product_price_id: UUID4 | None = Field( 1a
323 default=None,
324 description=(
325 "ID of the product price to checkout. "
326 "Must correspond to a price present in the checkout's product list."
327 ),
328 deprecated=(
329 "Use `product_id` unless you have a product with legacy pricing "
330 "including several recurring intervals."
331 ),
332 )
333 amount: Amount | None = None 1a
334 seats: int | None = Field( 1a
335 default=None,
336 ge=1,
337 le=1000,
338 description="Number of seats for seat-based pricing.",
339 )
340 is_business_customer: bool | None = None 1a
341 customer_name: CustomerNameInput | None = None 1a
342 customer_email: CustomerEmail | None = None 1a
343 customer_billing_name: Annotated[str | None, EmptyStrToNoneValidator] = None 1a
344 customer_billing_address: CustomerBillingAddressInput | None = None 1a
345 customer_tax_id: Annotated[str | None, EmptyStrToNoneValidator] = None 1a
348class CheckoutUpdate( 1a
349 MetadataInputMixin, TrialConfigurationInputMixin, CheckoutUpdateBase
350):
351 """Update an existing checkout session using an access token."""
353 discount_id: UUID4 | None = Field( 1a
354 default=None, description="ID of the discount to apply to the checkout."
355 )
356 allow_discount_codes: bool | None = Field( 1a
357 default=None, description=_allow_discount_codes_description
358 )
359 require_billing_address: bool | None = Field( 1a
360 default=None, description=_require_billing_address_description
361 )
362 allow_trial: bool | None = Field(default=None, description=_allow_trial_description) 1a
363 customer_ip_address: CustomerIPAddress | None = None 1a
364 customer_metadata: MetadataField | None = Field( 1a
365 default=None, description=_customer_metadata_description
366 )
367 success_url: SuccessURL = None 1a
368 return_url: ReturnURL = None 1a
369 embed_origin: EmbedOrigin = None 1a
372class CheckoutUpdatePublic(CheckoutUpdateBase): 1a
373 """Update an existing checkout session using the client secret."""
375 discount_code: str | None = Field( 1a
376 default=None, description="Discount code to apply to the checkout."
377 )
378 allow_trial: Literal[False] | None = Field( 1a
379 default=None,
380 description=(
381 "Disable the trial period for the checkout session. "
382 "It's mainly useful when the trial is blocked because the customer "
383 "already redeemed one."
384 ),
385 )
388class CheckoutConfirmBase(CheckoutUpdatePublic): ... 1a
391class CheckoutConfirmStripe(CheckoutConfirmBase): 1a
392 """Confirm a checkout session using a Stripe confirmation token."""
394 confirmation_token_id: str | None = Field( 1a
395 None,
396 description=(
397 "ID of the Stripe confirmation token. "
398 "Required for fixed prices and custom prices."
399 ),
400 )
403CheckoutConfirm = CheckoutConfirmStripe 1a
406class CheckoutBase(CustomFieldDataOutputMixin, TimestampedSchema, IDSchema): 1a
407 payment_processor: PaymentProcessor = Field(description="Payment processor used.") 1a
408 status: CheckoutStatus = Field( 1a
409 description="""
410 Status of the checkout session.
412 - Open: the checkout session was opened.
413 - Expired: the checkout session was expired and is no more accessible.
414 - Confirmed: the user on the checkout session clicked Pay. This is not indicative of the payment's success status.
415 - Failed: the checkout definitely failed for technical reasons and cannot be retried. In most cases, this state is never reached.
416 - Succeeded: the payment on the checkout was performed successfully.
417 """
418 )
419 client_secret: str = Field( 1a
420 description=(
421 "Client secret used to update and complete "
422 "the checkout session from the client."
423 )
424 )
425 url: str = Field( 1a
426 description="URL where the customer can access the checkout session."
427 )
428 expires_at: datetime = Field( 1a
429 description="Expiration date and time of the checkout session."
430 )
431 success_url: str = Field( 1a
432 description=(
433 "URL where the customer will be redirected after a successful payment."
434 )
435 )
436 return_url: str | None = Field( 1a
437 description=(
438 "When set, a back button will be shown in the checkout "
439 "to return to this URL."
440 )
441 )
442 embed_origin: str | None = Field( 1a
443 description=(
444 "When checkout is embedded, "
445 "represents the Origin of the page embedding the checkout. "
446 "Used as a security measure to send messages only to the embedding page."
447 )
448 )
449 amount: int = Field(description="Amount in cents, before discounts and taxes.") 1a
450 seats: int | None = Field( 1a
451 default=None, description="Number of seats for seat-based pricing."
452 )
453 price_per_seat: int | None = Field( 1a
454 default=None,
455 description=(
456 "Price per seat in cents for the current seat count, "
457 "based on the applicable tier. Only relevant for seat-based pricing."
458 ),
459 )
460 discount_amount: int = Field(description="Discount amount in cents.") 1a
461 net_amount: int = Field( 1a
462 description="Amount in cents, after discounts but before taxes."
463 )
464 tax_amount: int | None = Field( 1a
465 description=(
466 "Sales tax amount in cents. "
467 "If `null`, it means there is no enough information yet to calculate it."
468 )
469 )
470 total_amount: int = Field(description="Amount in cents, after discounts and taxes.") 1a
471 currency: str = Field(description="Currency code of the checkout session.") 1a
473 allow_trial: bool | None = Field(description=_allow_trial_description) 1a
474 active_trial_interval: TrialInterval | None = Field( 1a
475 description=(
476 "Interval unit of the trial period, if any. "
477 "This value is either set from the checkout, if `trial_interval` is set, "
478 "or from the selected product."
479 )
480 )
481 active_trial_interval_count: int | None = Field( 1a
482 description=(
483 "Number of interval units of the trial period, if any. "
484 "This value is either set from the checkout, if `trial_interval_count` "
485 "is set, or from the selected product."
486 )
487 )
488 trial_end: datetime | None = Field( 1a
489 description="End date and time of the trial period, if any."
490 )
492 organization_id: UUID4 = Field( 1a
493 description="ID of the organization owning the checkout session."
494 )
495 product_id: UUID4 | None = Field(description="ID of the product to checkout.") 1a
496 product_price_id: UUID4 | None = Field( 1a
497 description="ID of the product price to checkout.", deprecated=True
498 )
499 discount_id: UUID4 | None = Field( 1a
500 description="ID of the discount applied to the checkout."
501 )
502 allow_discount_codes: bool = Field(description=_allow_discount_codes_description) 1a
503 require_billing_address: bool = Field( 1a
504 description=_require_billing_address_description
505 )
506 is_discount_applicable: bool = Field( 1a
507 description=(
508 "Whether the discount is applicable to the checkout. "
509 "Typically, free and custom prices are not discountable."
510 )
511 )
512 is_free_product_price: bool = Field( 1a
513 description="Whether the product price is free, regardless of discounts."
514 )
515 is_payment_required: bool = Field( 1a
516 description=(
517 "Whether the checkout requires payment, e.g. in case of free products "
518 "or discounts that cover the total amount."
519 )
520 )
521 is_payment_setup_required: bool = Field( 1a
522 description=(
523 "Whether the checkout requires setting up a payment method, "
524 "regardless of the amount, e.g. subscriptions that have first free cycles."
525 )
526 )
527 is_payment_form_required: bool = Field( 1a
528 description=(
529 "Whether the checkout requires a payment form, "
530 "whether because of a payment or payment method setup."
531 )
532 )
534 customer_id: UUID4 | None 1a
535 is_business_customer: bool = Field(description=_is_business_customer_description) 1a
536 customer_name: str | None = Field(description="Name of the customer.") 1a
537 customer_email: str | None = Field(description="Email address of the customer.") 1a
538 customer_ip_address: CustomerIPAddress | None 1a
539 customer_billing_name: str | None 1a
540 customer_billing_address: CustomerBillingAddress | None 1a
541 customer_tax_id: str | None = Field( 1a
542 validation_alias=AliasChoices("customer_tax_id_number", "customer_tax_id")
543 )
545 payment_processor_metadata: dict[str, str] 1a
547 customer_billing_address_fields: SkipJsonSchema[ 1a
548 CheckoutCustomerBillingAddressFields
549 ] = Field(
550 deprecated="Use `billing_address_fields` instead.",
551 )
552 billing_address_fields: CheckoutBillingAddressFields = Field( 1a
553 description=(
554 "Determine which billing address fields "
555 "should be disabled, optional or required in the checkout form."
556 )
557 )
559 @computed_field(deprecated="Use `net_amount`.") 1a
560 def subtotal_amount(self) -> SkipJsonSchema[int | None]: 1a
561 return self.net_amount
564class CheckoutOrganization(OrganizationPublicBase): ... 1a
567class CheckoutProduct(ProductBase): 1a
568 """Product data for a checkout session."""
570 prices: ProductPriceList 1a
571 benefits: BenefitPublicList 1a
572 medias: ProductMediaList 1a
575class CheckoutDiscountBase(IDSchema): 1a
576 name: str 1a
577 type: DiscountType 1a
578 duration: DiscountDuration 1a
579 code: str | None 1a
582class CheckoutDiscountFixedOnceForeverDuration( 1a
583 CheckoutDiscountBase, DiscountFixedBase, DiscountOnceForeverDurationBase
584):
585 """Schema for a fixed amount discount that is applied once or forever."""
588class CheckoutDiscountFixedRepeatDuration( 1a
589 CheckoutDiscountBase, DiscountFixedBase, DiscountRepeatDurationBase
590):
591 """
592 Schema for a fixed amount discount that is applied on every invoice
593 for a certain number of months.
594 """
597class CheckoutDiscountPercentageOnceForeverDuration( 1a
598 CheckoutDiscountBase, DiscountPercentageBase, DiscountOnceForeverDurationBase
599):
600 """Schema for a percentage discount that is applied once or forever."""
603class CheckoutDiscountPercentageRepeatDuration( 1a
604 CheckoutDiscountBase, DiscountPercentageBase, DiscountRepeatDurationBase
605):
606 """
607 Schema for a percentage discount that is applied on every invoice
608 for a certain number of months.
609 """
612def get_discount_discriminator_value(v: Any) -> str: 1a
613 if isinstance(v, dict):
614 type = v["type"]
615 duration = v["duration"]
616 else:
617 type = getattr(v, "type")
618 duration = getattr(v, "duration")
619 duration_tag = (
620 "once_forever"
621 if duration in {DiscountDuration.once, DiscountDuration.forever}
622 else "repeat"
623 )
624 return f"{type}.{duration_tag}"
627CheckoutDiscount = Annotated[ 1a
628 Annotated[CheckoutDiscountFixedOnceForeverDuration, Tag("fixed.once_forever")]
629 | Annotated[CheckoutDiscountFixedRepeatDuration, Tag("fixed.repeat")]
630 | Annotated[
631 CheckoutDiscountPercentageOnceForeverDuration, Tag("percentage.once_forever")
632 ]
633 | Annotated[CheckoutDiscountPercentageRepeatDuration, Tag("percentage.repeat")],
634 Discriminator(get_discount_discriminator_value),
635]
638class Checkout(MetadataOutputMixin, TrialConfigurationOutputMixin, CheckoutBase): 1a
639 """Checkout session data retrieved using an access token."""
641 external_customer_id: str | None = Field( 1a
642 description=_external_customer_id_description,
643 validation_alias=AliasChoices("external_customer_id", "customer_external_id"),
644 )
645 customer_external_id: str | None = Field( 1a
646 validation_alias=AliasChoices("external_customer_id", "customer_external_id"),
647 deprecated="Use `external_customer_id` instead.",
648 )
649 products: list[CheckoutProduct] = Field( 1a
650 description="List of products available to select."
651 )
652 product: CheckoutProduct | None = Field(description="Product selected to checkout.") 1a
653 product_price: ProductPrice | None = Field( 1a
654 description="Price of the selected product.", deprecated=True
655 )
656 prices: dict[UUID4, ProductPriceList] | None = Field( 1a
657 description=("Mapping of product IDs to their list of prices.")
658 )
659 discount: CheckoutDiscount | None 1a
660 subscription_id: UUID4 | None 1a
661 attached_custom_fields: list[AttachedCustomField] | None 1a
662 customer_metadata: dict[str, str | int | bool] 1a
665class CheckoutPublic(CheckoutBase): 1a
666 """Checkout session data retrieved using the client secret."""
668 products: list[CheckoutProduct] = Field( 1a
669 description="List of products available to select."
670 )
671 product: CheckoutProduct | None = Field(description="Product selected to checkout.") 1a
672 product_price: ProductPrice | None = Field( 1a
673 description="Price of the selected product.", deprecated=True
674 )
675 prices: dict[UUID4, ProductPriceList] | None = Field( 1a
676 description=("Mapping of product IDs to their list of prices.")
677 )
678 discount: CheckoutDiscount | None 1a
679 organization: CheckoutOrganization 1a
680 attached_custom_fields: list[AttachedCustomField] | None 1a
683class CheckoutPublicConfirmed(CheckoutPublic): 1a
684 """
685 Checkout session data retrieved using the client secret after confirmation.
687 It contains a customer session token to retrieve order information
688 right after the checkout.
689 """
691 status: Literal[CheckoutStatus.confirmed] 1a
692 customer_session_token: str 1a