Skip to content
Draft
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
26 changes: 18 additions & 8 deletions server/polar/billing_entry/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
MeteredPrice,
StaticPrice,
is_metered_price,
is_seat_price,
)
from polar.product.repository import ProductPriceRepository, ProductRepository

Expand Down Expand Up @@ -90,7 +91,7 @@ async def compute_pending_subscription_line_items(
):
static_price = cast(StaticPrice, entry.product_price)
static_line_item = await self._get_static_price_line_item(
session, static_price, entry
session, static_price, entry, seats=subscription.seats
)
yield static_line_item, [entry.id]

Expand Down Expand Up @@ -182,7 +183,12 @@ async def compute_pending_subscription_line_items(
yield metered_line_item, pending_entries_ids

async def _get_static_price_line_item(
self, session: AsyncSession, price: StaticPrice, entry: BillingEntry
self,
session: AsyncSession,
price: StaticPrice,
entry: BillingEntry,
*,
seats: int | None,
) -> StaticLineItem:
assert entry.amount is not None
assert entry.currency is not None
Expand All @@ -195,12 +201,16 @@ async def _get_static_price_line_item(
end = format_date(entry.end_timestamp.date(), locale="en_US")
amount = entry.amount

if entry.direction == BillingEntryDirection.credit:
label = f"Remaining time on {product.name} — From {start} to {end}"
amount = -amount
elif entry.direction == BillingEntryDirection.debit:
label = f"{product.name} — From {start} to {end}"
amount = amount
match entry.direction:
case BillingEntryDirection.credit:
label = f"Remaining time on {product.name} — From {start} to {end}"
amount = -amount
case BillingEntryDirection.debit:
label = f"{product.name} — From {start} to {end}"

if is_seat_price(price) and seats is not None:
_, seat_label = price.get_amount_and_label(seats)
label += f"\n{seat_label}"

return StaticLineItem(
price=price,
Expand Down
38 changes: 36 additions & 2 deletions server/polar/invoice/generator.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import textwrap
from datetime import date, datetime
from pathlib import Path
from typing import ClassVar, Self
Expand Down Expand Up @@ -410,7 +409,14 @@ def generate(self) -> None:
# Body
for item in self.data.items:
row = table.row()
row.cell(textwrap.shorten(item.description, width=90, placeholder="…"))
row.cell(
"\n".join(
# Fit description within the cell width
self._truncate_text(line, max_width=90)
# Max 10 lines of description to prevent overflow
for line in item.description.splitlines()[:10]
)
)
row.cell(format_number(item.quantity))
row.cell(format_currency(item.unit_amount, self.data.currency))
row.cell(format_currency(item.amount, self.data.currency))
Expand Down Expand Up @@ -451,5 +457,33 @@ def set_metadata(self) -> None:
self.set_author(settings.INVOICES_NAME)
self.set_creation_date(utc_now())

def _truncate_text(
self, text: str, max_width: float, placeholder: str = "…"
) -> str:
"""
Truncate text to fit within max_width

Args:
text: The text to truncate
max_width: The maximum width in user units
placeholder: The string to append if truncation is needed (default: "…")

Returns:
The truncated text with placeholder if truncation was needed
"""
if self.get_string_width(text) <= max_width:
return text

lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if self.get_string_width(text[:mid] + placeholder) <= max_width:
lo = mid
else:
hi = mid - 1
text = text[:lo]

return text + placeholder


__all__ = ["Invoice", "InvoiceGenerator", "InvoiceItem"]
6 changes: 4 additions & 2 deletions server/polar/models/order_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def from_price(
amount: int | None = None,
seats: int | None = None,
) -> Self:
label = price.product.name
if isinstance(price, ProductPriceFixed | LegacyRecurringProductPriceFixed):
amount = price.price_amount
elif isinstance(price, ProductPriceCustom | LegacyRecurringProductPriceCustom):
Expand All @@ -76,9 +77,10 @@ def from_price(
amount = 0
elif isinstance(price, ProductPriceSeatUnit):
assert seats is not None, "seats must be provided for seat-based prices"
amount = price.calculate_amount(seats)
amount, seats_label = price.get_amount_and_label(seats)
label += f"\n{seats_label}"
return cls(
label=price.product.name,
label=label,
amount=amount,
tax_amount=tax_amount,
net_amount=amount,
Expand Down
49 changes: 49 additions & 0 deletions server/polar/models/product_price.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,55 @@ def _calculate_graduated(self, seats: int) -> int:
remaining -= seats_in_tier
return total

def get_amount_and_label(self, seats: int) -> tuple[int, str]:
seat_tier_type = self.seat_tiers.get("seat_tier_type", SeatTierType.volume)
tiers = sorted(self.seat_tiers.get("tiers", []), key=lambda t: t["min_seats"])
show_tier_range = len(tiers) > 1

def format_tier_range(tier: SeatTier) -> str:
min_seats = tier["min_seats"]
max_seats = tier.get("max_seats")
if max_seats is None:
return f"{min_seats}+ seats"
return f"{min_seats}–{max_seats} seats"

def seat_line(n: int, tier: SeatTier) -> str:
seat_word = "seat" if n == 1 else "seats"
price_label = format_currency(tier["price_per_seat"], self.price_currency)
line = f"{n} {seat_word} × {price_label}/seat"
if show_tier_range:
line += f" ({format_tier_range(tier)} tier)"
return line

amount = self.calculate_amount(seats)

match seat_tier_type:
case SeatTierType.volume:
tier = self.get_tier_for_seats(seats)
label = seat_line(seats, tier)

case SeatTierType.graduated:
lines: list[str] = []
remaining = seats
for tier in tiers:
if remaining <= 0:
break
min_seats = tier["min_seats"]
max_seats = tier.get("max_seats")
tier_capacity = (
(max_seats - min_seats + 1)
if max_seats is not None
else remaining
)
seats_in_tier = min(remaining, tier_capacity)
lines.append(seat_line(seats_in_tier, tier))
remaining -= seats_in_tier
label = "\n+ ".join(lines)

seat_word = "seat" if seats == 1 else "seats"
header = f"{self.product.name} ({seats} {seat_word})"
return amount, f"{header}\n{label}"

def get_minimum_seats(self) -> int:
"""Get the minimum number of seats allowed, derived from first tier's min_seats."""
tiers = self.seat_tiers.get("tiers", [])
Expand Down
26 changes: 26 additions & 0 deletions server/tests/invoice/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ def invoice() -> Invoice:
},
"long_item_description",
),
(
{
"items": [
InvoiceItem(
description=(
"Bacon ipsum dolor amet flank venison swine\n"
"tenderloin ham hock turducken short loin bacon.\n"
"Pork chop cupim turkey short ribs bacon rump picanha ham hock jerky salami\n"
"ground round ham shoulder swine brisket. Ham hock pork chop chislic cow hamburger\n"
"tongue beef. Jerky pastrami biltong pancetta. Ground round chuck meatloaf jowl.\n"
"Tongue short ribs boudin jowl, frankfurter sausage meatloaf short loin tail\n"
"Bacon ipsum dolor amet flank venison swine\n"
"tenderloin ham hock turducken short loin bacon.\n"
"Pork chop cupim turkey short ribs bacon rump picanha ham hock jerky salami\n"
"ground round ham shoulder swine brisket. Ham hock pork chop chislic cow hamburger\n"
"tongue beef. Jerky pastrami biltong pancetta. Ground round chuck meatloaf jowl.\n"
"Tongue short ribs boudin jowl, frankfurter sausage meatloaf short loin tail\n"
),
quantity=1,
unit_amount=50_00,
amount=50_00,
),
],
},
"long_item_description_with_lines",
),
(
{
"customer_name": "Văn bản thử nghiệm tiếng Việt",
Expand Down
119 changes: 119 additions & 0 deletions server/tests/models/test_product_price.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ async def test_get_amount_and_label(
def _make_seat_price(
tiers: list[dict[str, Any]],
seat_tier_type: SeatTierType = SeatTierType.volume,
product_name: str = "My Product",
) -> ProductPriceSeatUnit:
return ProductPriceSeatUnit(
seat_tiers={"seat_tier_type": seat_tier_type, "tiers": tiers},
price_currency="usd",
product=Product(name=product_name),
)


Expand Down Expand Up @@ -197,3 +199,120 @@ def test_free_first_tier_then_paid(self) -> None:
assert price.calculate_amount(5) == 0
assert price.calculate_amount(8) == 3 * 1000
assert price.calculate_amount(15) == 10 * 1000


class TestVolumePricingLabel:
def test_single_tier_no_range_shown(self) -> None:
price = _make_seat_price(
[{"min_seats": 1, "max_seats": None, "price_per_seat": 500}],
SeatTierType.volume,
)
amount, label = price.get_amount_and_label(5)
assert amount == 2500
assert label == "My Product (5 seats)\n5 seats × $5.00/seat"

def test_singular_seat(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
amount, label = price.get_amount_and_label(1)
assert amount == 1000
assert label == "My Product (1 seat)\n1 seat × $10.00/seat (1–10 seats tier)"

def test_first_tier(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
amount, label = price.get_amount_and_label(5)
assert amount == 5000
assert label == "My Product (5 seats)\n5 seats × $10.00/seat (1–10 seats tier)"

def test_second_tier(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
amount, label = price.get_amount_and_label(25)
assert amount == 20_000
assert (
label == "My Product (25 seats)\n25 seats × $8.00/seat (11–50 seats tier)"
)

def test_open_ended_last_tier(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
amount, label = price.get_amount_and_label(60)
assert amount == 36_000
assert label == "My Product (60 seats)\n60 seats × $6.00/seat (51+ seats tier)"

def test_free_tier(self) -> None:
price = _make_seat_price(
[{"min_seats": 1, "max_seats": None, "price_per_seat": 0}],
SeatTierType.volume,
)
amount, label = price.get_amount_and_label(10)
assert amount == 0
assert label == "My Product (10 seats)\n10 seats × $0.00/seat"


class TestGraduatedPricingLabel:
def test_single_tier_no_range_shown(self) -> None:
price = _make_seat_price(
[{"min_seats": 1, "max_seats": None, "price_per_seat": 500}],
SeatTierType.graduated,
)
amount, label = price.get_amount_and_label(5)
assert amount == 2500
assert label == "My Product (5 seats)\n5 seats × $5.00/seat"

def test_singular_seat(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
amount, label = price.get_amount_and_label(1)
assert amount == 1000
assert label == "My Product (1 seat)\n1 seat × $10.00/seat (1–10 seats tier)"

def test_within_first_tier(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
amount, label = price.get_amount_and_label(5)
assert amount == 5000
assert label == "My Product (5 seats)\n5 seats × $10.00/seat (1–10 seats tier)"

def test_spans_two_tiers(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
amount, label = price.get_amount_and_label(15)
assert amount == 10 * 1000 + 5 * 800
assert label == (
"My Product (15 seats)\n"
"10 seats × $10.00/seat (1–10 seats tier)\n"
"+ 5 seats × $8.00/seat (11–50 seats tier)"
)

def test_spans_all_tiers(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
amount, label = price.get_amount_and_label(100)
assert amount == 10 * 1000 + 40 * 800 + 50 * 600
assert label == (
"My Product (100 seats)\n"
"10 seats × $10.00/seat (1–10 seats tier)\n"
"+ 40 seats × $8.00/seat (11–50 seats tier)\n"
"+ 50 seats × $6.00/seat (51+ seats tier)"
)

def test_one_seat_into_last_tier(self) -> None:
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
amount, label = price.get_amount_and_label(51)
assert amount == 10 * 1000 + 40 * 800 + 1 * 600
assert label == (
"My Product (51 seats)\n"
"10 seats × $10.00/seat (1–10 seats tier)\n"
"+ 40 seats × $8.00/seat (11–50 seats tier)\n"
"+ 1 seat × $6.00/seat (51+ seats tier)"
)

def test_free_first_tier_then_paid(self) -> None:
price = _make_seat_price(
[
{"min_seats": 1, "max_seats": 5, "price_per_seat": 0},
{"min_seats": 6, "max_seats": None, "price_per_seat": 1000},
],
SeatTierType.graduated,
)
amount, label = price.get_amount_and_label(8)
assert amount == 3 * 1000
assert label == (
"My Product (8 seats)\n"
"5 seats × $0.00/seat (1–5 seats tier)\n"
"+ 3 seats × $10.00/seat (6+ seats tier)"
)
Loading