Skip to content

Commit a89f7da

Browse files
committed
feat(invoice): Store customer-specific invoice numbering on customer
When switching between org and customer invoice numbering, we need to keep track of the number and use the correct one.
1 parent 1894309 commit a89f7da

File tree

6 files changed

+257
-20
lines changed

6 files changed

+257
-20
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""add invoice_next_number to customers
2+
3+
Revision ID: 8c9ef8060e95
4+
Revises: 9b0f38cdd25d
5+
Create Date: 2025-10-24 09:15:38.708186
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# Polar Custom Imports
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "8c9ef8060e95"
16+
down_revision = "9b0f38cdd25d"
17+
branch_labels: tuple[str] | None = None
18+
depends_on: tuple[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
op.add_column(
23+
"customers",
24+
sa.Column("invoice_next_number", sa.Integer(), nullable=True),
25+
)
26+
op.execute(
27+
"UPDATE customers SET invoice_next_number = 1 WHERE invoice_next_number IS NULL"
28+
)
29+
op.alter_column("customers", "invoice_next_number", nullable=False)
30+
31+
32+
def downgrade() -> None:
33+
op.drop_column("customers", "invoice_next_number")

server/polar/models/customer.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ColumnElement,
1414
ForeignKey,
1515
Index,
16+
Integer,
1617
String,
1718
UniqueConstraint,
1819
Uuid,
@@ -136,6 +137,8 @@ class Customer(MetadataMixin, RecordModel):
136137
TIMESTAMP(timezone=True), nullable=True, default=None, index=True
137138
)
138139

140+
invoice_next_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
141+
139142
organization_id: Mapped[UUID] = mapped_column(
140143
Uuid,
141144
ForeignKey("organizations.id", ondelete="cascade"),

server/polar/order/service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ async def _create_order_from_checkout(
578578

579579
organization = checkout.organization
580580
invoice_number = await organization_service.get_next_invoice_number(
581-
session, organization
581+
session, organization, customer
582582
)
583583

584584
repository = OrderRepository.from_session(session)
@@ -699,7 +699,7 @@ async def create_subscription_order(
699699
tax_rate = tax_calculation["tax_rate"]
700700

701701
invoice_number = await organization_service.get_next_invoice_number(
702-
session, subscription.organization
702+
session, subscription.organization, customer
703703
)
704704

705705
total_amount = subtotal_amount - discount_amount + tax_amount
@@ -791,7 +791,7 @@ async def create_trial_order(
791791

792792
organization = subscription.organization
793793
invoice_number = await organization_service.get_next_invoice_number(
794-
session, organization
794+
session, organization, customer
795795
)
796796

797797
repository = OrderRepository.from_session(session)
@@ -1344,7 +1344,7 @@ async def create_order_from_stripe(
13441344
)
13451345

13461346
invoice_number = await organization_service.get_next_invoice_number(
1347-
session, subscription.organization
1347+
session, subscription.organization, customer
13481348
)
13491349

13501350
repository = OrderRepository.from_session(session)

server/polar/organization/service.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from collections.abc import Sequence
33
from datetime import UTC, datetime
44
from enum import StrEnum
5-
from typing import Any
5+
from typing import TYPE_CHECKING, Any
66
from uuid import UUID
77

88
import structlog
@@ -16,6 +16,8 @@
1616
from polar.auth.models import AuthSubject
1717
from polar.checkout_link.repository import CheckoutLinkRepository
1818
from polar.config import Environment, settings
19+
from polar.customer.repository import CustomerRepository
20+
from polar.enums import InvoiceNumbering
1921
from polar.exceptions import PolarError, PolarRequestValidationError
2022
from polar.integrations.loops.service import loops as loops_service
2123
from polar.integrations.plain.service import plain as plain_service
@@ -44,6 +46,9 @@
4446
)
4547
from .sorting import OrganizationSortProperty
4648

49+
if TYPE_CHECKING:
50+
from polar.models import Customer
51+
4752
log = structlog.get_logger()
4853

4954

@@ -361,17 +366,32 @@ async def get_next_invoice_number(
361366
self,
362367
session: AsyncSession,
363368
organization: Organization,
369+
customer: "Customer",
364370
) -> str:
365-
invoice_number = f"{organization.customer_invoice_prefix}-{organization.customer_invoice_next_number:04d}"
366-
repository = OrganizationRepository.from_session(session)
367-
organization = await repository.update(
368-
organization,
369-
update_dict={
370-
"customer_invoice_next_number": organization.customer_invoice_next_number
371-
+ 1
372-
},
373-
)
374-
return invoice_number
371+
match organization.invoice_numbering:
372+
case InvoiceNumbering.customer:
373+
customer_suffix = str(customer.id).split("-")[0].upper()
374+
invoice_number = f"{organization.customer_invoice_prefix}-{customer_suffix}-{customer.invoice_next_number:04d}"
375+
customer_repository = CustomerRepository.from_session(session)
376+
await customer_repository.update(
377+
customer,
378+
update_dict={
379+
"invoice_next_number": customer.invoice_next_number + 1
380+
},
381+
)
382+
return invoice_number
383+
384+
case InvoiceNumbering.organization:
385+
invoice_number = f"{organization.customer_invoice_prefix}-{organization.customer_invoice_next_number:04d}"
386+
repository = OrganizationRepository.from_session(session)
387+
organization = await repository.update(
388+
organization,
389+
update_dict={
390+
"customer_invoice_next_number": organization.customer_invoice_next_number
391+
+ 1
392+
},
393+
)
394+
return invoice_number
375395

376396
async def _after_update(
377397
self,

server/tests/order/test_service.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414
from polar.auth.models import AuthSubject
1515
from polar.checkout.eventstream import CheckoutEvent
16-
from polar.enums import PaymentProcessor, SubscriptionRecurringInterval
16+
from polar.enums import (
17+
InvoiceNumbering,
18+
PaymentProcessor,
19+
SubscriptionRecurringInterval,
20+
)
1721
from polar.held_balance.service import held_balance as held_balance_service
1822
from polar.integrations.stripe.schemas import ProductType
1923
from polar.integrations.stripe.service import StripeService
@@ -3871,3 +3875,88 @@ async def test_customer_balance(
38713875
await order_service.customer_balance(session, customer)
38723876
== setup.expected_balance
38733877
)
3878+
3879+
3880+
@pytest.mark.asyncio
3881+
class TestCustomerBasedInvoiceNumbering:
3882+
async def test_different_customers_different_invoice_numbers(
3883+
self,
3884+
save_fixture: SaveFixture,
3885+
session: AsyncSession,
3886+
organization: Organization,
3887+
product_one_time: Product,
3888+
) -> None:
3889+
# Set organization to use customer-based invoice numbering
3890+
organization.order_settings = {
3891+
**organization.order_settings,
3892+
"invoice_numbering": InvoiceNumbering.customer,
3893+
}
3894+
await save_fixture(organization)
3895+
3896+
# Create two different customers
3897+
customer_1 = await create_customer(
3898+
save_fixture,
3899+
organization=organization,
3900+
3901+
name="Customer 1",
3902+
stripe_customer_id="STRIPE_CUSTOMER_1",
3903+
)
3904+
customer_2 = await create_customer(
3905+
save_fixture,
3906+
organization=organization,
3907+
3908+
name="Customer 2",
3909+
stripe_customer_id="STRIPE_CUSTOMER_2",
3910+
)
3911+
3912+
# Create checkout and order for customer 1
3913+
checkout_1 = await create_checkout(
3914+
save_fixture,
3915+
products=[product_one_time],
3916+
status=CheckoutStatus.confirmed,
3917+
customer=customer_1,
3918+
)
3919+
order_1 = await order_service.create_from_checkout_one_time(session, checkout_1)
3920+
3921+
# Create checkout and order for customer 2
3922+
checkout_2 = await create_checkout(
3923+
save_fixture,
3924+
products=[product_one_time],
3925+
status=CheckoutStatus.confirmed,
3926+
customer=customer_2,
3927+
)
3928+
order_2 = await order_service.create_from_checkout_one_time(session, checkout_2)
3929+
3930+
# Refresh to get the invoice numbers
3931+
await session.refresh(order_1)
3932+
await session.refresh(order_2)
3933+
3934+
# Both customers should have different invoice number sequences
3935+
# They should both start with the organization prefix but have customer-specific numbering
3936+
assert order_1.invoice_number is not None
3937+
assert order_2.invoice_number is not None
3938+
assert order_1.invoice_number != order_2.invoice_number
3939+
3940+
# Verify they both start with organization prefix
3941+
assert order_1.invoice_number.startswith(organization.customer_invoice_prefix)
3942+
assert order_2.invoice_number.startswith(organization.customer_invoice_prefix)
3943+
3944+
# Each customer should start at 0001
3945+
assert order_1.invoice_number.endswith("-0001")
3946+
assert order_2.invoice_number.endswith("-0001")
3947+
3948+
# Verify invoice numbers contain customer-specific suffix
3949+
customer_1_suffix = str(customer_1.id).split("-")[0].upper()
3950+
customer_2_suffix = str(customer_2.id).split("-")[0].upper()
3951+
assert customer_1_suffix in order_1.invoice_number
3952+
assert customer_2_suffix in order_2.invoice_number
3953+
3954+
# Verify format: PREFIX-CUSTOMER_SUFFIX-0001
3955+
assert (
3956+
order_1.invoice_number
3957+
== f"{organization.customer_invoice_prefix}-{customer_1_suffix}-0001"
3958+
)
3959+
assert (
3960+
order_2.invoice_number
3961+
== f"{organization.customer_invoice_prefix}-{customer_2_suffix}-0001"
3962+
)

server/tests/organization/test_service.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010

1111
from polar.auth.models import AuthSubject
1212
from polar.config import Environment, settings
13-
from polar.enums import AccountType
13+
from polar.enums import AccountType, InvoiceNumbering
1414
from polar.exceptions import PolarRequestValidationError
15-
from polar.models import Organization, Product, User
15+
from polar.models import Customer, Organization, Product, User
1616
from polar.models.account import Account
1717
from polar.models.organization import OrganizationNotificationSettings
1818
from polar.models.organization_review import OrganizationReview
@@ -163,19 +163,111 @@ async def test_valid_with_none_subscription_settings(
163163

164164

165165
@pytest.mark.asyncio
166-
async def test_get_next_invoice_number(
166+
async def test_get_next_invoice_number_organization(
167167
session: AsyncSession,
168168
organization: Organization,
169+
customer: Customer,
169170
) -> None:
171+
organization.order_settings = {
172+
**organization.order_settings,
173+
"invoice_numbering": InvoiceNumbering.organization,
174+
}
170175
assert organization.customer_invoice_next_number == 1
171176

172177
next_invoice_number = await organization_service.get_next_invoice_number(
173-
session, organization
178+
session, organization, customer
174179
)
175180

176181
assert next_invoice_number == f"{organization.customer_invoice_prefix}-0001"
177182
assert organization.customer_invoice_next_number == 2
178183

184+
await session.refresh(customer)
185+
assert customer.invoice_next_number == 1
186+
187+
188+
@pytest.mark.asyncio
189+
async def test_get_next_invoice_number_customer(
190+
session: AsyncSession,
191+
save_fixture: SaveFixture,
192+
organization: Organization,
193+
customer: Customer,
194+
) -> None:
195+
await save_fixture(organization)
196+
197+
initial_org_counter = organization.customer_invoice_next_number
198+
assert customer.invoice_next_number == 1
199+
200+
next_invoice_number = await organization_service.get_next_invoice_number(
201+
session, organization, customer
202+
)
203+
204+
customer_suffix = str(customer.id).split("-")[0].upper()
205+
assert (
206+
next_invoice_number
207+
== f"{organization.customer_invoice_prefix}-{customer_suffix}-0001"
208+
)
209+
await session.commit()
210+
await session.refresh(customer)
211+
assert customer.invoice_next_number == 2
212+
213+
await session.refresh(organization)
214+
assert organization.customer_invoice_next_number == initial_org_counter
215+
216+
await session.refresh(customer)
217+
218+
next_invoice_number = await organization_service.get_next_invoice_number(
219+
session, organization, customer
220+
)
221+
222+
assert (
223+
next_invoice_number
224+
== f"{organization.customer_invoice_prefix}-{customer_suffix}-0002"
225+
)
226+
await session.commit()
227+
await session.refresh(customer)
228+
assert customer.invoice_next_number == 3
229+
230+
231+
@pytest.mark.asyncio
232+
async def test_get_next_invoice_number_multiple_customers(
233+
session: AsyncSession,
234+
save_fixture: SaveFixture,
235+
organization: Organization,
236+
customer: Customer,
237+
) -> None:
238+
await save_fixture(organization)
239+
240+
customer2 = Customer(
241+
242+
organization=organization,
243+
)
244+
session.add(customer2)
245+
246+
invoice1 = await organization_service.get_next_invoice_number(
247+
session, organization, customer
248+
)
249+
customer_suffix = str(customer.id).split("-")[0].upper()
250+
await session.commit()
251+
assert invoice1 == f"{organization.customer_invoice_prefix}-{customer_suffix}-0001"
252+
253+
invoice2 = await organization_service.get_next_invoice_number(
254+
session, organization, customer2
255+
)
256+
customer2_suffix = str(customer2.id).split("-")[0].upper()
257+
await session.commit()
258+
assert invoice2 == f"{organization.customer_invoice_prefix}-{customer2_suffix}-0001"
259+
260+
invoice3 = await organization_service.get_next_invoice_number(
261+
session, organization, customer
262+
)
263+
await session.commit()
264+
assert invoice3 == f"{organization.customer_invoice_prefix}-{customer_suffix}-0002"
265+
266+
await session.refresh(customer)
267+
await session.refresh(customer2)
268+
assert customer.invoice_next_number == 3
269+
assert customer2.invoice_next_number == 2
270+
179271

180272
@pytest.mark.asyncio
181273
class TestCheckReviewThreshold:

0 commit comments

Comments
 (0)