Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ProductFormType = Omit<
> &
ProductFullMediasMixin & {
metadata: { key: string; value: string | number | boolean }[]
trial_days?: number | null
}

interface ProductFormProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ErrorMessage } from '@hookform/error-message'
import { CloseOutlined } from '@mui/icons-material'
import { schemas } from '@polar-sh/client'
import Button from '@polar-sh/ui/components/atoms/Button'
import Input from '@polar-sh/ui/components/atoms/Input'
import MoneyInput from '@polar-sh/ui/components/atoms/MoneyInput'
import {
Select,
Expand Down Expand Up @@ -618,6 +619,37 @@ export const ProductPricingSection = ({
)
}}
/>
{recurringInterval !== null && (
<FormField
control={control}
name="trial_days"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Free Trial Period (days)</FormLabel>
<FormControl>
<Input
type="text"
{...field}
value={field.value || ''}
onChange={(e) => {
const value = e.target.value
field.onChange(
value === '' ? null : parseInt(value, 10),
)
}}
/>
</FormControl>
<FormDescription>
Optional free trial period for new subscriptions.
Customers will be charged after the trial ends.
</FormDescription>
<FormMessage />
</FormItem>
)
}}
/>
)}
{prices.map((price, index) => (
<ProductPriceItemWrapper
prices={prices as schemas['ProductPrice'][]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,27 +54,42 @@ export const SubscriptionStatus = ({
subscription: schemas['Subscription']
}) => {
const { status, ends_at } = subscription
const trial_ends_at = 'trial_ends_at' in subscription ? subscription.trial_ends_at as string | undefined : undefined
const isEnding = useMemo(() => ends_at !== null, [ends_at])
const isTrialing = useMemo(() => status === 'trialing', [status])

const color = useMemo(() => {
if (status === 'trialing') {
return 'border-blue-500'
}
if (status === 'active') {
return isEnding ? 'border-yellow-500' : 'border-emerald-500'
}
return 'border-red-500'
}, [status, isEnding])

const icon = useMemo(() => {
if (isTrialing && trial_ends_at) {
return <AccessTimeOutlined fontSize="inherit" />
}
if (!isEnding) {
return null
}
if (status === 'canceled') {
return <CancelOutlined fontSize="inherit" />
}
return <AccessTimeOutlined fontSize="inherit" />
}, [isEnding, status])
}, [isTrialing, trial_ends_at, isEnding, status])

const dateToShow = useMemo(() => {
if (isTrialing && trial_ends_at) {
return trial_ends_at
}
return ends_at
}, [isTrialing, trial_ends_at, ends_at])

return (
<StatusLabel color={color} dt={ends_at} icon={icon}>
<StatusLabel color={color} dt={dateToShow} icon={icon}>
{subscriptionStatusDisplayNames[subscription.status]}
</StatusLabel>
)
Expand Down
4 changes: 4 additions & 0 deletions clients/apps/web/src/components/Subscriptions/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const SubscriptionStatusLabel = ({
}) => {
const label = useMemo(() => {
switch (subscription.status) {
case 'trialing':
return 'Trialing'
case 'active':
return subscription.ends_at ? 'To be cancelled' : 'Active'
default:
Expand All @@ -36,6 +38,8 @@ export const SubscriptionStatusLabel = ({

const statusColor = useMemo(() => {
switch (subscription.status) {
case 'trialing':
return 'border-blue-500'
case 'active':
return subscription.cancel_at_period_end
? 'border-yellow-500'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import type { CheckoutProduct } from '@polar-sh/sdk/models/components/checkoutproduct.js'
import type { CheckoutPublic } from '@polar-sh/sdk/models/components/checkoutpublic'
import type { CheckoutUpdatePublic } from '@polar-sh/sdk/models/components/checkoutupdatepublic'
import { LegacyRecurringProductPrice } from '@polar-sh/sdk/models/components/legacyrecurringproductprice.js'
Expand Down Expand Up @@ -56,6 +57,7 @@ const CheckoutProductSwitcher = ({
}

const getDescription = (
product: CheckoutProduct,
price: ProductPrice | LegacyRecurringProductPrice,
) => {
let recurringLabel = ''
Expand All @@ -69,6 +71,10 @@ const CheckoutProductSwitcher = ({
recurringLabel = 'yearly'
}

if (product.trialDays) {
return `${product.trialDays} day trial — then billed ${recurringLabel}`
}

if (price.recurringInterval) {
return `Billed ${recurringLabel}`
}
Expand Down Expand Up @@ -109,7 +115,7 @@ const CheckoutProductSwitcher = ({
</div>
<div className="flex grow flex-row items-center justify-between p-4 text-sm">
<p className="dark:text-polar-500 text-gray-500">
{getDescription(price)}
{getDescription(product, price)}
</p>
</div>
</label>
Expand Down Expand Up @@ -142,7 +148,7 @@ const CheckoutProductSwitcher = ({
</div>
<div className="flex grow flex-row items-center justify-between p-4 text-sm">
<p className="dark:text-polar-500 text-gray-500">
{getDescription(product.prices[0])}
{getDescription(product, product.prices[0])}
</p>
</div>
</label>
Expand Down
57 changes: 43 additions & 14 deletions clients/packages/checkout/src/components/ProductPriceLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,57 @@ const ProductPriceLabel: React.FC<ProductPriceLabelProps> = ({
product,
price,
}) => {
const showTrial =
product.trialDays && product.trialDays > 0 && product.isRecurring

if (price.amountType === 'fixed') {
return (
<AmountLabel
amount={price.priceAmount}
currency={price.priceCurrency}
interval={
isLegacyRecurringPrice(price)
? price.recurringInterval
: product.recurringInterval
}
/>
<div className="flex flex-col">
<AmountLabel
amount={price.priceAmount}
currency={price.priceCurrency}
interval={
isLegacyRecurringPrice(price)
? price.recurringInterval
: product.recurringInterval
}
/>
{false && showTrial && (
<span className="text-sm text-gray-500">
after {product.trialDays} day{product.trialDays > 1 ? 's' : ''} free
trial
</span>
)}
</div>
)
} else if (price.amountType === 'custom') {
return <div className="text-[min(1em,24px)]">Pay what you want</div>
return (
<div className="flex flex-col">
<div className="text-[min(1em,24px)]">Pay what you want</div>
{showTrial && (
<span className="text-sm text-gray-500">
after {product.trialDays} day{product.trialDays > 1 ? 's' : ''} free
trial
</span>
)}
</div>
)
} else if (price.amountType === 'free') {
return <div className="text-[min(1em,24px)]">Free</div>
} else if (price.amountType === 'metered_unit') {
return (
<div className="flex flex-row gap-1 text-[min(1em,24px)]">
{price.meter.name}
{' — '}
<MeteredPriceLabel price={price} />
<div className="flex flex-col">
<div className="flex flex-row gap-1 text-[min(1em,24px)]">
{price.meter.name}
{' — '}
<MeteredPriceLabel price={price} />
</div>
{showTrial && (
<span className="text-sm text-gray-500">
after {product.trialDays} day{product.trialDays > 1 ? 's' : ''} free
trial
</span>
)}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Add trial support fields to products and subscriptions

Revision ID: 679bf09dbcbf
Revises: c26960e60bda
Create Date: 2025-09-08 15:02:32.341930

"""

import sqlalchemy as sa
from alembic import op

# Polar Custom Imports

# revision identifiers, used by Alembic.
revision = "679bf09dbcbf"
down_revision = "c26960e60bda"
branch_labels: tuple[str] | None = None
depends_on: tuple[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("products", sa.Column("trial_days", sa.Integer(), nullable=True))
op.add_column(
"subscriptions",
sa.Column("trial_ends_at", sa.TIMESTAMP(timezone=True), nullable=True),
)
op.create_index(
op.f("ix_subscriptions_trial_ends_at"),
"subscriptions",
["trial_ends_at"],
unique=False,
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_subscriptions_trial_ends_at"), table_name="subscriptions")
op.drop_column("subscriptions", "trial_ends_at")
op.drop_column("products", "trial_days")
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions server/polar/models/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Boolean,
ColumnElement,
ForeignKey,
Integer,
String,
Text,
Uuid,
Expand Down Expand Up @@ -58,6 +59,7 @@ class Product(MetadataMixin, RecordModel):
index=True,
default=None,
)
trial_days: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

stripe_product_id: Mapped[str | None] = mapped_column(
String, nullable=True, index=True
Expand Down
3 changes: 3 additions & 0 deletions server/polar/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class Subscription(CustomFieldDataMixin, MetadataMixin, RecordModel):
current_period_end: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True), nullable=True, default=None
)
trial_ends_at: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True), nullable=True, default=None, index=True
)
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, nullable=False)
canceled_at: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True), nullable=True, default=None
Expand Down
18 changes: 18 additions & 0 deletions server/polar/product/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ class ProductCreate(MetadataInputMixin, Schema):
"Note that the `day` and `week` values are for internal Polar staff use only."
),
)
trial_days: int | None = Field(
default=None,
ge=0,
le=365,
description="Number of days for the free trial period. Only applicable to recurring products.",
)
prices: ProductPriceCreateList = Field(
...,
description="List of available prices for this product. "
Expand Down Expand Up @@ -271,6 +277,12 @@ class ProductUpdate(MetadataInputMixin, Schema):
"Once set, it can't be changed.**"
),
)
trial_days: int | None = Field(
default=None,
ge=0,
le=365,
description="Number of days for the free trial period. Only applicable to recurring products.",
)
is_archived: bool | None = Field(
default=None,
description=(
Expand Down Expand Up @@ -505,6 +517,12 @@ class ProductBase(IDSchema, TimestampedSchema):
""
"Note that the `day` and `week` values are for internal Polar staff use only."
)
trial_days: int | None = Field(
default=None,
ge=0,
le=365,
description="Number of days for the free trial period. Only applicable to recurring products.",
)
is_recurring: bool = Field(description="Whether the product is a subscription.")
is_archived: bool = Field(
description="Whether the product is archived and no longer available."
Expand Down
4 changes: 4 additions & 0 deletions server/polar/subscription/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class SubscriptionBase(IDSchema, TimestampedSchema):
current_period_end: datetime | None = Field(
description="The end timestamp of the current billing period."
)
trial_ends_at: datetime | None = Field(
default=None,
description="The end timestamp of the trial period. Only set for subscriptions with a trial.",
)
cancel_at_period_end: bool = Field(
description=(
"Whether the subscription will be canceled "
Expand Down
Loading
Loading