-
Notifications
You must be signed in to change notification settings - Fork 540
Organization and customer based invoice numbers #7460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # PostgreSQL functions that are applied, that needs to be both migrated and recreated | ||
| # in our tests. | ||
|
|
||
| SEQUENCE_CREATE_CUSTOMER_SHORT_ID = "CREATE SEQUENCE customer_short_id_seq START 1;" | ||
|
|
||
| # ID generation algorithm based on https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c | ||
| FUNCTION_GENERATE_CUSTOMER_SHORT_ID = """ | ||
| CREATE OR REPLACE FUNCTION generate_customer_short_id(creation_timestamp TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp()) | ||
| RETURNS bigint AS $$ | ||
| DECLARE | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is based on the Instagram ID generation algorithm. We get fairly small, ordered but not leaking user volume, IDs that we can base26-encode to get an alphabetical identifier we can use for invoice numbers.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Instagram ID generation algorithm also has a portion of the ID as a machine shard. We could put that in from the start, as it will become a bit annoying to do a migration in the future, if we believe we will need it.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would that look like in our current setup? Just an arbitrary fixed ID?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exactly, we'd set it to 0 or 1 for now, and then a component of the id would just be fixed. It would allow us to introduce it at some point without migrating the IDs or having two realities.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, let's add it then π
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking this over (implications on the size of the ID and size of the base26-encoded), and thought maybe it would make sense to go in the opposite direction. Switching over to seconds instead of milliesconds, and not adding the shard gives us a capacity of 1024 customers/second, and alphabetical IDs that are ~8 characters. Keeping milliseconds and adding shard IDs we would be on ~11 characters. Not sure where we should be aiming right now, but it does feel a bit overkill.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your call :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I increased it to millisecond precision again, in the event of a spike I don't want to be guilty of it not working. I'll merge this now! |
||
| our_epoch bigint := 1672531200000; -- 2023-01-01 in milliseconds | ||
| seq_id bigint; | ||
| now_millis bigint; | ||
| result bigint; | ||
| BEGIN | ||
| -- Get sequence number modulo 1024 (10 bits) | ||
| SELECT nextval('customer_short_id_seq') % 1024 INTO seq_id; | ||
|
|
||
| -- Use provided timestamp (defaults to clock_timestamp()) | ||
| SELECT FLOOR(EXTRACT(EPOCH FROM creation_timestamp) * 1000) INTO now_millis; | ||
|
|
||
| -- 42 bits timestamp (milliseconds) | 10 bits sequence = 52 bits total | ||
| -- Capacity: 1,024 IDs per millisecond (over 1 million per second) | ||
| -- Combine: (timestamp - epoch) << 10 | sequence | ||
| result := (now_millis - our_epoch) << 10; | ||
| result := result | seq_id; | ||
|
|
||
| RETURN result; | ||
| END; | ||
| $$ LANGUAGE plpgsql; | ||
| """ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """add order_settings to organizations with invoice_numbering | ||
|
|
||
| Revision ID: 9b0f38cdd25d | ||
| Revises: 1ac3957fd2cf | ||
| Create Date: 2025-10-23 15:43:43.869159 | ||
|
|
||
| """ | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
| from sqlalchemy.dialects import postgresql | ||
|
|
||
| # Polar Custom Imports | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "9b0f38cdd25d" | ||
| down_revision = "1ac3957fd2cf" | ||
| branch_labels: tuple[str] | None = None | ||
| depends_on: tuple[str] | None = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.add_column( | ||
| "organizations", | ||
| sa.Column( | ||
| "order_settings", | ||
| postgresql.JSONB(astext_type=sa.Text()), | ||
| nullable=True, | ||
| ), | ||
| ) | ||
|
|
||
| op.execute( | ||
| """ | ||
| UPDATE organizations | ||
| SET order_settings = '{"invoice_numbering": "organization"}'::jsonb | ||
| """ | ||
| ) | ||
|
|
||
| op.alter_column("organizations", "order_settings", nullable=False) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_column("organizations", "order_settings") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| """add invoice_next_number to customers | ||
|
|
||
| Revision ID: 8c9ef8060e95 | ||
| Revises: 9b0f38cdd25d | ||
| Create Date: 2025-10-24 09:15:38.708186 | ||
|
|
||
| """ | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| # Polar Custom Imports | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "8c9ef8060e95" | ||
| down_revision = "9b0f38cdd25d" | ||
| branch_labels: tuple[str] | None = None | ||
| depends_on: tuple[str] | None = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.add_column( | ||
| "customers", | ||
| sa.Column("invoice_next_number", sa.Integer(), nullable=True), | ||
| ) | ||
| op.execute( | ||
| "UPDATE customers SET invoice_next_number = 1 WHERE invoice_next_number IS NULL" | ||
| ) | ||
| op.alter_column("customers", "invoice_next_number", nullable=False) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_column("customers", "invoice_next_number") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| """add short_id to customers | ||
|
|
||
| Revision ID: aee75623c4bb | ||
| Revises: 8c9ef8060e95 | ||
| Create Date: 2025-10-24 11:39:54.946342 | ||
|
|
||
| """ | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| # Polar Custom Imports | ||
| from migrations.functions import ( | ||
| FUNCTION_GENERATE_CUSTOMER_SHORT_ID, | ||
| SEQUENCE_CREATE_CUSTOMER_SHORT_ID, | ||
| ) | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "aee75623c4bb" | ||
| down_revision = "8c9ef8060e95" | ||
| branch_labels: tuple[str] | None = None | ||
| depends_on: tuple[str] | None = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute(SEQUENCE_CREATE_CUSTOMER_SHORT_ID) | ||
|
|
||
| # Create Instagram-style ID generator | ||
| # Combines timestamp (milliseconds since epoch) + sequence number | ||
| op.execute(FUNCTION_GENERATE_CUSTOMER_SHORT_ID) | ||
|
|
||
| op.add_column( | ||
| "customers", | ||
| sa.Column("short_id", sa.BigInteger(), nullable=True), | ||
| ) | ||
|
|
||
| op.execute( | ||
| """ | ||
| UPDATE customers | ||
| SET short_id = generate_customer_short_id(created_at); | ||
| """ | ||
| ) | ||
|
|
||
| op.alter_column("customers", "short_id", nullable=False) | ||
|
|
||
| op.execute( | ||
| """ | ||
| ALTER TABLE customers | ||
| ALTER COLUMN short_id SET DEFAULT generate_customer_short_id(); | ||
| """ | ||
| ) | ||
|
|
||
| op.create_unique_constraint( | ||
| op.f("customers_organization_id_short_id_key"), | ||
| "customers", | ||
| ["organization_id", "short_id"], | ||
| ) | ||
| op.create_index( | ||
| op.f("ix_customers_short_id"), "customers", ["short_id"], unique=False | ||
| ) | ||
| # ### end Alembic commands ### | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| # ### commands auto generated by Alembic - please adjust! ### | ||
| op.drop_index(op.f("ix_customers_short_id"), table_name="customers") | ||
| op.drop_constraint( | ||
| op.f("customers_organization_id_short_id_key"), "customers", type_="unique" | ||
| ) | ||
| op.drop_column("customers", "short_id") | ||
| op.execute("DROP FUNCTION IF EXISTS generate_customer_short_id();") | ||
| op.execute("DROP SEQUENCE IF EXISTS customer_short_id_seq;") | ||
| # ### end Alembic commands ### |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ | |
|
|
||
| from polar.config import settings | ||
| from polar.email.sender import DEFAULT_REPLY_TO_EMAIL_ADDRESS, EmailFromReply | ||
| from polar.enums import SubscriptionProrationBehavior | ||
| from polar.enums import InvoiceNumbering, SubscriptionProrationBehavior | ||
| from polar.kit.db.models import RateLimitGroupMixin, RecordModel | ||
| from polar.kit.extensions.sqlalchemy import StringEnum | ||
|
|
||
|
|
@@ -74,6 +74,15 @@ class OrganizationSubscriptionSettings(TypedDict): | |
| } | ||
|
|
||
|
|
||
| class OrganizationOrderSettings(TypedDict): | ||
| invoice_numbering: InvoiceNumbering | ||
|
|
||
|
|
||
| _default_order_settings: OrganizationOrderSettings = { | ||
| "invoice_numbering": InvoiceNumbering.organization, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this on organization for now, so we can roll this out and change the defaults after this PR. |
||
| } | ||
|
|
||
|
|
||
| class OrganizationCustomerEmailSettings(TypedDict): | ||
| order_confirmation: bool | ||
| subscription_cancellation: bool | ||
|
|
@@ -179,6 +188,10 @@ def account(cls) -> Mapped[Account | None]: | |
| JSONB, nullable=False, default=_default_subscription_settings | ||
| ) | ||
|
|
||
| order_settings: Mapped[OrganizationOrderSettings] = mapped_column( | ||
| JSONB, nullable=False, default=_default_order_settings | ||
| ) | ||
|
|
||
| notification_settings: Mapped[OrganizationNotificationSettings] = mapped_column( | ||
| JSONB, nullable=False, default=_default_notification_settings | ||
| ) | ||
|
|
@@ -259,6 +272,10 @@ def proration_behavior(self) -> SubscriptionProrationBehavior: | |
| def benefit_revocation_grace_period(self) -> int: | ||
| return self.subscription_settings["benefit_revocation_grace_period"] | ||
|
|
||
| @property | ||
| def invoice_numbering(self) -> InvoiceNumbering: | ||
| return InvoiceNumbering(self.order_settings["invoice_numbering"]) | ||
|
|
||
| @declared_attr | ||
| def all_products(cls) -> Mapped[list["Product"]]: | ||
| return relationship("Product", lazy="raise", back_populates="organization") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't entirely sure how to play this. I wanted to do the ID generation in postgres to avoid having to retry saving customers if we got unlucky in the random ID generation. This is not really following any existing conventions. I put them here so that we can load them into the test runner as well, and it becomes fairly clear for the future how that works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was a bit reluctant to have a Postgres function at first but... Can't deny it, that's pretty neat and efficient π I like it!