From 763a3ce280f39fe888d49992e0dde0ed81d1fe0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Tue, 21 Oct 2025 15:57:49 +0200 Subject: [PATCH] feat(invoice-number): Switch to a Polar global invoice sequence By utilizing postgres SEQUENCE we can ensure that we will get a forever incrementing sequence for invoice numbers, that can be used by all organizations. During a rollback the number will not get reused, but as long as each organization has a strictly increasing number sequence it should be legally OK. --- ...-10-21-1518_add_global_invoice_sequence.py | 45 +++++++++++++++++++ server/polar/organization/service.py | 11 +++-- server/tests/fixtures/database.py | 4 ++ server/tests/organization/test_service.py | 24 +++++++++- 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 server/migrations/versions/2025-10-21-1518_add_global_invoice_sequence.py diff --git a/server/migrations/versions/2025-10-21-1518_add_global_invoice_sequence.py b/server/migrations/versions/2025-10-21-1518_add_global_invoice_sequence.py new file mode 100644 index 0000000000..3ee5879047 --- /dev/null +++ b/server/migrations/versions/2025-10-21-1518_add_global_invoice_sequence.py @@ -0,0 +1,45 @@ +"""add_global_invoice_sequence + +Revision ID: 1c01dfc86203 +Revises: 902dd5c6c2b7 +Create Date: 2025-10-21 15:18:00.469806 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "1c01dfc86203" +down_revision = "902dd5c6c2b7" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # Buffer to add to max existing invoice number + # This ensures clean separation between old per-org and new global numbering + BUFFER_SIZE = 10000 + + # Create the global invoice number sequence + op.execute("CREATE SEQUENCE invoice_number_seq START WITH 1") + + # Initialize sequence to max existing invoice number + buffer + # Extract numeric part from "PREFIX-0001" format and find the maximum + op.execute( + f""" + SELECT setval('invoice_number_seq', + COALESCE( + (SELECT MAX(CAST(SPLIT_PART(invoice_number, '-', 2) AS INTEGER)) + FROM orders), + 0 + ) + {BUFFER_SIZE} + ) + """ + ) + + +def downgrade() -> None: + op.execute("DROP SEQUENCE IF EXISTS invoice_number_seq") diff --git a/server/polar/organization/service.py b/server/polar/organization/service.py index bba71ec3d8..3a63543a6d 100644 --- a/server/polar/organization/service.py +++ b/server/polar/organization/service.py @@ -7,6 +7,7 @@ import structlog from pydantic import BaseModel, Field +from sqlalchemy import text from sqlalchemy import update as sqlalchemy_update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload @@ -362,16 +363,20 @@ async def get_next_invoice_number( session: AsyncSession, organization: Organization, ) -> str: - invoice_number = f"{organization.customer_invoice_prefix}-{organization.customer_invoice_next_number:04d}" + result = await session.execute(text("SELECT nextval('invoice_number_seq')")) + number = result.scalar_one() + + # Backward compatability repository = OrganizationRepository.from_session(session) - organization = await repository.update( + await repository.update( organization, update_dict={ "customer_invoice_next_number": organization.customer_invoice_next_number + 1 }, ) - return invoice_number + + return f"{organization.customer_invoice_prefix}-{number:04d}" async def _after_update( self, diff --git a/server/tests/fixtures/database.py b/server/tests/fixtures/database.py index d231a975c5..2ddb283ba1 100644 --- a/server/tests/fixtures/database.py +++ b/server/tests/fixtures/database.py @@ -46,6 +46,10 @@ async def initialize_test_database(worker_id: str) -> AsyncIterator[None]: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS citext")) await conn.execute(text('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')) await conn.run_sync(Model.metadata.create_all) + # Create global invoice number sequence (from migration) + await conn.execute( + text("CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START WITH 1") + ) await engine.dispose() yield diff --git a/server/tests/organization/test_service.py b/server/tests/organization/test_service.py index 0285ca64f0..9a17fb626b 100644 --- a/server/tests/organization/test_service.py +++ b/server/tests/organization/test_service.py @@ -166,15 +166,35 @@ async def test_valid_with_none_subscription_settings( async def test_get_next_invoice_number( session: AsyncSession, organization: Organization, + organization_second: Organization, ) -> None: assert organization.customer_invoice_next_number == 1 + assert organization_second.customer_invoice_next_number == 1 - next_invoice_number = await organization_service.get_next_invoice_number( + # Get invoice from first organization + invoice_org1 = await organization_service.get_next_invoice_number( session, organization ) - assert next_invoice_number == f"{organization.customer_invoice_prefix}-0001" + # Get invoice from second organization + invoice_org2 = await organization_service.get_next_invoice_number( + session, organization_second + ) + + # Extract numbers from format "PREFIX-0001" + number_1 = int(invoice_org1.split("-")[1]) + number_2 = int(invoice_org2.split("-")[1]) + + # Verify sequential global numbering across organizations + assert number_2 == number_1 + 1 + + # Verify each uses their own prefix + assert invoice_org1.startswith(organization.customer_invoice_prefix) + assert invoice_org2.startswith(organization_second.customer_invoice_prefix) + + # Verify per-org counter still increments (behind the scenes) assert organization.customer_invoice_next_number == 2 + assert organization_second.customer_invoice_next_number == 2 @pytest.mark.asyncio