Skip to content

Commit 9f484f5

Browse files
committed
wip
1 parent 82e8471 commit 9f484f5

4 files changed

Lines changed: 190 additions & 10 deletions

File tree

server/polar/billing_entry/service.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
MeteredPrice,
2222
StaticPrice,
2323
is_metered_price,
24+
is_seat_price,
2425
)
2526
from polar.product.repository import ProductPriceRepository, ProductRepository
2627

@@ -90,7 +91,7 @@ async def compute_pending_subscription_line_items(
9091
):
9192
static_price = cast(StaticPrice, entry.product_price)
9293
static_line_item = await self._get_static_price_line_item(
93-
session, static_price, entry
94+
session, static_price, entry, seats=subscription.seats
9495
)
9596
yield static_line_item, [entry.id]
9697

@@ -182,7 +183,12 @@ async def compute_pending_subscription_line_items(
182183
yield metered_line_item, pending_entries_ids
183184

184185
async def _get_static_price_line_item(
185-
self, session: AsyncSession, price: StaticPrice, entry: BillingEntry
186+
self,
187+
session: AsyncSession,
188+
price: StaticPrice,
189+
entry: BillingEntry,
190+
*,
191+
seats: int | None,
186192
) -> StaticLineItem:
187193
assert entry.amount is not None
188194
assert entry.currency is not None
@@ -195,12 +201,16 @@ async def _get_static_price_line_item(
195201
end = format_date(entry.end_timestamp.date(), locale="en_US")
196202
amount = entry.amount
197203

198-
if entry.direction == BillingEntryDirection.credit:
199-
label = f"Remaining time on {product.name} — From {start} to {end}"
200-
amount = -amount
201-
elif entry.direction == BillingEntryDirection.debit:
202-
label = f"{product.name} — From {start} to {end}"
203-
amount = amount
204+
match entry.direction:
205+
case BillingEntryDirection.credit:
206+
label = f"Remaining time on {product.name} — From {start} to {end}"
207+
amount = -amount
208+
case BillingEntryDirection.debit:
209+
label = f"{product.name} — From {start} to {end}"
210+
211+
if is_seat_price(price) and seats is not None:
212+
_, seat_label = price.get_amount_and_label(seats)
213+
label += f"\n{seat_label}"
204214

205215
return StaticLineItem(
206216
price=price,

server/polar/models/order_item.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def from_price(
6868
amount: int | None = None,
6969
seats: int | None = None,
7070
) -> Self:
71+
label = price.product.name
7172
if isinstance(price, ProductPriceFixed | LegacyRecurringProductPriceFixed):
7273
amount = price.price_amount
7374
elif isinstance(price, ProductPriceCustom | LegacyRecurringProductPriceCustom):
@@ -76,9 +77,10 @@ def from_price(
7677
amount = 0
7778
elif isinstance(price, ProductPriceSeatUnit):
7879
assert seats is not None, "seats must be provided for seat-based prices"
79-
amount = price.calculate_amount(seats)
80+
amount, seats_label = price.get_amount_and_label(seats)
81+
label += f"\n{seats_label}"
8082
return cls(
81-
label=price.product.name,
83+
label=label,
8284
amount=amount,
8385
tax_amount=tax_amount,
8486
net_amount=amount,

server/polar/models/product_price.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,55 @@ def _calculate_graduated(self, seats: int) -> int:
415415
remaining -= seats_in_tier
416416
return total
417417

418+
def get_amount_and_label(self, seats: int) -> tuple[int, str]:
419+
seat_tier_type = self.seat_tiers.get("seat_tier_type", SeatTierType.volume)
420+
tiers = sorted(self.seat_tiers.get("tiers", []), key=lambda t: t["min_seats"])
421+
show_tier_range = len(tiers) > 1
422+
423+
def format_tier_range(tier: SeatTier) -> str:
424+
min_seats = tier["min_seats"]
425+
max_seats = tier.get("max_seats")
426+
if max_seats is None:
427+
return f"{min_seats}+ seats"
428+
return f"{min_seats}{max_seats} seats"
429+
430+
def seat_line(n: int, tier: SeatTier) -> str:
431+
seat_word = "seat" if n == 1 else "seats"
432+
price_label = format_currency(tier["price_per_seat"], self.price_currency)
433+
line = f"{n} {seat_word} × {price_label}/seat"
434+
if show_tier_range:
435+
line += f" ({format_tier_range(tier)} tier)"
436+
return line
437+
438+
amount = self.calculate_amount(seats)
439+
440+
match seat_tier_type:
441+
case SeatTierType.volume:
442+
tier = self.get_tier_for_seats(seats)
443+
label = seat_line(seats, tier)
444+
445+
case SeatTierType.graduated:
446+
lines: list[str] = []
447+
remaining = seats
448+
for tier in tiers:
449+
if remaining <= 0:
450+
break
451+
min_seats = tier["min_seats"]
452+
max_seats = tier.get("max_seats")
453+
tier_capacity = (
454+
(max_seats - min_seats + 1)
455+
if max_seats is not None
456+
else remaining
457+
)
458+
seats_in_tier = min(remaining, tier_capacity)
459+
lines.append(seat_line(seats_in_tier, tier))
460+
remaining -= seats_in_tier
461+
label = "\n+ ".join(lines)
462+
463+
seat_word = "seat" if seats == 1 else "seats"
464+
header = f"{self.product.name} ({seats} {seat_word})"
465+
return amount, f"{header}\n{label}"
466+
418467
def get_minimum_seats(self) -> int:
419468
"""Get the minimum number of seats allowed, derived from first tier's min_seats."""
420469
tiers = self.seat_tiers.get("tiers", [])

server/tests/models/test_product_price.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,12 @@ async def test_get_amount_and_label(
8383
def _make_seat_price(
8484
tiers: list[dict[str, Any]],
8585
seat_tier_type: SeatTierType = SeatTierType.volume,
86+
product_name: str = "My Product",
8687
) -> ProductPriceSeatUnit:
8788
return ProductPriceSeatUnit(
8889
seat_tiers={"seat_tier_type": seat_tier_type, "tiers": tiers},
8990
price_currency="usd",
91+
product=Product(name=product_name),
9092
)
9193

9294

@@ -197,3 +199,120 @@ def test_free_first_tier_then_paid(self) -> None:
197199
assert price.calculate_amount(5) == 0
198200
assert price.calculate_amount(8) == 3 * 1000
199201
assert price.calculate_amount(15) == 10 * 1000
202+
203+
204+
class TestVolumePricingLabel:
205+
def test_single_tier_no_range_shown(self) -> None:
206+
price = _make_seat_price(
207+
[{"min_seats": 1, "max_seats": None, "price_per_seat": 500}],
208+
SeatTierType.volume,
209+
)
210+
amount, label = price.get_amount_and_label(5)
211+
assert amount == 2500
212+
assert label == "My Product (5 seats)\n5 seats × $5.00/seat"
213+
214+
def test_singular_seat(self) -> None:
215+
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
216+
amount, label = price.get_amount_and_label(1)
217+
assert amount == 1000
218+
assert label == "My Product (1 seat)\n1 seat × $10.00/seat (1–10 seats tier)"
219+
220+
def test_first_tier(self) -> None:
221+
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
222+
amount, label = price.get_amount_and_label(5)
223+
assert amount == 5000
224+
assert label == "My Product (5 seats)\n5 seats × $10.00/seat (1–10 seats tier)"
225+
226+
def test_second_tier(self) -> None:
227+
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
228+
amount, label = price.get_amount_and_label(25)
229+
assert amount == 20_000
230+
assert (
231+
label == "My Product (25 seats)\n25 seats × $8.00/seat (11–50 seats tier)"
232+
)
233+
234+
def test_open_ended_last_tier(self) -> None:
235+
price = _make_seat_price(MULTI_TIER, SeatTierType.volume)
236+
amount, label = price.get_amount_and_label(60)
237+
assert amount == 36_000
238+
assert label == "My Product (60 seats)\n60 seats × $6.00/seat (51+ seats tier)"
239+
240+
def test_free_tier(self) -> None:
241+
price = _make_seat_price(
242+
[{"min_seats": 1, "max_seats": None, "price_per_seat": 0}],
243+
SeatTierType.volume,
244+
)
245+
amount, label = price.get_amount_and_label(10)
246+
assert amount == 0
247+
assert label == "My Product (10 seats)\n10 seats × $0.00/seat"
248+
249+
250+
class TestGraduatedPricingLabel:
251+
def test_single_tier_no_range_shown(self) -> None:
252+
price = _make_seat_price(
253+
[{"min_seats": 1, "max_seats": None, "price_per_seat": 500}],
254+
SeatTierType.graduated,
255+
)
256+
amount, label = price.get_amount_and_label(5)
257+
assert amount == 2500
258+
assert label == "My Product (5 seats)\n5 seats × $5.00/seat"
259+
260+
def test_singular_seat(self) -> None:
261+
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
262+
amount, label = price.get_amount_and_label(1)
263+
assert amount == 1000
264+
assert label == "My Product (1 seat)\n1 seat × $10.00/seat (1–10 seats tier)"
265+
266+
def test_within_first_tier(self) -> None:
267+
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
268+
amount, label = price.get_amount_and_label(5)
269+
assert amount == 5000
270+
assert label == "My Product (5 seats)\n5 seats × $10.00/seat (1–10 seats tier)"
271+
272+
def test_spans_two_tiers(self) -> None:
273+
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
274+
amount, label = price.get_amount_and_label(15)
275+
assert amount == 10 * 1000 + 5 * 800
276+
assert label == (
277+
"My Product (15 seats)\n"
278+
"10 seats × $10.00/seat (1–10 seats tier)\n"
279+
"+ 5 seats × $8.00/seat (11–50 seats tier)"
280+
)
281+
282+
def test_spans_all_tiers(self) -> None:
283+
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
284+
amount, label = price.get_amount_and_label(100)
285+
assert amount == 10 * 1000 + 40 * 800 + 50 * 600
286+
assert label == (
287+
"My Product (100 seats)\n"
288+
"10 seats × $10.00/seat (1–10 seats tier)\n"
289+
"+ 40 seats × $8.00/seat (11–50 seats tier)\n"
290+
"+ 50 seats × $6.00/seat (51+ seats tier)"
291+
)
292+
293+
def test_one_seat_into_last_tier(self) -> None:
294+
price = _make_seat_price(MULTI_TIER, SeatTierType.graduated)
295+
amount, label = price.get_amount_and_label(51)
296+
assert amount == 10 * 1000 + 40 * 800 + 1 * 600
297+
assert label == (
298+
"My Product (51 seats)\n"
299+
"10 seats × $10.00/seat (1–10 seats tier)\n"
300+
"+ 40 seats × $8.00/seat (11–50 seats tier)\n"
301+
"+ 1 seat × $6.00/seat (51+ seats tier)"
302+
)
303+
304+
def test_free_first_tier_then_paid(self) -> None:
305+
price = _make_seat_price(
306+
[
307+
{"min_seats": 1, "max_seats": 5, "price_per_seat": 0},
308+
{"min_seats": 6, "max_seats": None, "price_per_seat": 1000},
309+
],
310+
SeatTierType.graduated,
311+
)
312+
amount, label = price.get_amount_and_label(8)
313+
assert amount == 3 * 1000
314+
assert label == (
315+
"My Product (8 seats)\n"
316+
"5 seats × $0.00/seat (1–5 seats tier)\n"
317+
"+ 3 seats × $10.00/seat (6+ seats tier)"
318+
)

0 commit comments

Comments
 (0)