From 33a27679bf76ace3aa88091e24368a4d4a38c55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Thu, 16 May 2024 12:57:01 +0200 Subject: [PATCH] server: migrate direct subscription management to Sale, esp. in Transaction --- .../2024-05-16-1358_migrate_heldbalance.py | 117 ++++++++++ server/polar/held_balance/service.py | 6 +- server/polar/integrations/stripe/tasks.py | 9 +- server/polar/models/held_balance.py | 30 +-- server/polar/product/service/product.py | 9 - server/polar/sale/__init__.py | 0 server/polar/sale/service.py | 215 ++++++++++++++++++ .../subscription/service/subscription.py | 102 +-------- server/polar/transaction/service/balance.py | 31 +-- server/polar/transaction/service/dispute.py | 12 +- server/polar/transaction/service/payment.py | 30 +-- server/polar/transaction/service/payout.py | 19 +- .../polar/transaction/service/platform_fee.py | 29 ++- server/polar/transaction/service/refund.py | 3 +- .../polar/transaction/service/transaction.py | 33 ++- server/scripts/pledge_invoice_payment_fix.py | 2 +- server/tests/fixtures/random_objects.py | 36 ++- server/tests/sale/__init__.py | 0 server/tests/sale/test_service.py | 181 +++++++++++++++ .../subscription/service/test_subscription.py | 151 ++---------- server/tests/transaction/conftest.py | 29 ++- .../tests/transaction/service/test_balance.py | 15 +- .../tests/transaction/service/test_payment.py | 69 +----- .../transaction/service/test_platform_fee.py | 16 +- .../transaction/service/test_processor_fee.py | 14 +- .../transaction/service/test_transaction.py | 8 +- server/tests/transaction/test_endpoints.py | 3 +- 27 files changed, 680 insertions(+), 489 deletions(-) create mode 100644 server/migrations/versions/2024-05-16-1358_migrate_heldbalance.py create mode 100644 server/polar/sale/__init__.py create mode 100644 server/polar/sale/service.py create mode 100644 server/tests/sale/__init__.py create mode 100644 server/tests/sale/test_service.py diff --git a/server/migrations/versions/2024-05-16-1358_migrate_heldbalance.py b/server/migrations/versions/2024-05-16-1358_migrate_heldbalance.py new file mode 100644 index 0000000000..bbfee6026b --- /dev/null +++ b/server/migrations/versions/2024-05-16-1358_migrate_heldbalance.py @@ -0,0 +1,117 @@ +"""Migrate HeldBalance + +Revision ID: f850759b02d5 +Revises: e553ec9cf929 +Create Date: 2024-05-16 13:58:23.584174 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports +from polar.kit.extensions.sqlalchemy import PostgresUUID + +# revision identifiers, used by Alembic. +revision = "f850759b02d5" +down_revision = "e553ec9cf929" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("held_balances", sa.Column("sale_id", sa.UUID(), nullable=True)) + op.create_foreign_key( + op.f("held_balances_sale_id_fkey"), + "held_balances", + "sales", + ["sale_id"], + ["id"], + ondelete="set null", + ) + + op.execute( + """ + UPDATE held_balances + SET sale_id = sales.id + FROM sales + JOIN subscriptions ON sales.subscription_id = subscriptions.id + WHERE held_balances.subscription_id = subscriptions.id + """ + ) + + op.drop_index("ix_held_balances_product_price_id", table_name="held_balances") + op.drop_index("ix_held_balances_subscription_id", table_name="held_balances") + op.create_index( + op.f("ix_held_balances_sale_id"), "held_balances", ["sale_id"], unique=False + ) + op.drop_constraint( + "held_balances_subscription_id_fkey", "held_balances", type_="foreignkey" + ) + op.drop_constraint( + "held_balances_subscription_tier_price_id_fkey", + "held_balances", + type_="foreignkey", + ) + + op.drop_column("held_balances", "product_price_id") + op.drop_column("held_balances", "subscription_id") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "held_balances", + sa.Column("subscription_id", sa.UUID(), autoincrement=False, nullable=True), + ) + op.add_column( + "held_balances", + sa.Column("product_price_id", sa.UUID(), autoincrement=False, nullable=True), + ) + op.create_foreign_key( + "held_balances_subscription_tier_price_id_fkey", + "held_balances", + "product_prices", + ["product_price_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "held_balances_subscription_id_fkey", + "held_balances", + "subscriptions", + ["subscription_id"], + ["id"], + ondelete="SET NULL", + ) + + op.execute( + """ + UPDATE held_balances + SET subscription_id = sales.subscription_id, + product_price_id = sales.product_price_id + FROM sales + WHERE sales.id = held_balances.sale_id + """ + ) + + op.drop_constraint( + op.f("held_balances_sale_id_fkey"), "held_balances", type_="foreignkey" + ) + op.drop_index(op.f("ix_held_balances_sale_id"), table_name="held_balances") + op.create_index( + "ix_held_balances_subscription_id", + "held_balances", + ["subscription_id"], + unique=False, + ) + op.create_index( + "ix_held_balances_product_price_id", + "held_balances", + ["product_price_id"], + unique=False, + ) + op.drop_column("held_balances", "sale_id") + # ### end Alembic commands ### diff --git a/server/polar/held_balance/service.py b/server/polar/held_balance/service.py index 7cb1afb3a1..9d135f4274 100644 --- a/server/polar/held_balance/service.py +++ b/server/polar/held_balance/service.py @@ -53,8 +53,7 @@ async def release_account( .options( joinedload(HeldBalance.payment_transaction), joinedload(HeldBalance.pledge), - joinedload(HeldBalance.subscription), - joinedload(HeldBalance.product_price), + joinedload(HeldBalance.sale), joinedload(HeldBalance.issue_reward), joinedload(HeldBalance.donation), ) @@ -70,8 +69,7 @@ async def release_account( payment_transaction=held_balance.payment_transaction, amount=held_balance.amount, pledge=held_balance.pledge, - subscription=held_balance.subscription, - product_price=held_balance.product_price, + sale=held_balance.sale, issue_reward=held_balance.issue_reward, donation=held_balance.donation, ) diff --git a/server/polar/integrations/stripe/tasks.py b/server/polar/integrations/stripe/tasks.py index df73bb1b64..e435a0ade8 100644 --- a/server/polar/integrations/stripe/tasks.py +++ b/server/polar/integrations/stripe/tasks.py @@ -13,6 +13,7 @@ ProductType, ) from polar.pledge.service import pledge as pledge_service +from polar.sale.service import sale as sale_service from polar.subscription.service.subscription import SubscriptionDoesNotExist from polar.subscription.service.subscription import subscription as subscription_service from polar.transaction.service.balance import PaymentTransactionForChargeDoesNotExist @@ -28,9 +29,6 @@ from polar.transaction.service.payment import ( PledgeDoesNotExist as PaymentTransactionPledgeDoesNotExist, ) -from polar.transaction.service.payment import ( - SubscriptionDoesNotExist as PaymentTransactionSubscriptionDoesNotExist, -) from polar.transaction.service.payment import ( payment_transaction as payment_transaction_service, ) @@ -139,7 +137,6 @@ async def charge_succeeded( ) except ( PaymentTransactionPledgeDoesNotExist, - PaymentTransactionSubscriptionDoesNotExist, PaymentTransactionDonationDoesNotExist, ) as e: # Retry because we might not have been able to handle other events @@ -278,9 +275,7 @@ async def invoice_paid( async with AsyncSessionMaker(ctx) as session: invoice = stripe.Invoice.construct_from(event["data"]["object"], None) try: - await subscription_service.transfer_subscription_paid_invoice( - session, invoice=invoice - ) + await sale_service.create_sale_from_stripe(session, invoice=invoice) except ( SubscriptionDoesNotExist, PaymentTransactionForChargeDoesNotExist, diff --git a/server/polar/models/held_balance.py b/server/polar/models/held_balance.py index 8af9664edb..fe8938e8cf 100644 --- a/server/polar/models/held_balance.py +++ b/server/polar/models/held_balance.py @@ -14,8 +14,7 @@ IssueReward, Organization, Pledge, - ProductPrice, - Subscription, + Sale, Transaction, ) @@ -89,34 +88,17 @@ def payment_transaction(cls) -> Mapped["Transaction"]: def pledge(cls) -> Mapped["Pledge | None"]: return relationship("Pledge", lazy="raise") - subscription_id: Mapped[UUID | None] = mapped_column( + sale_id: Mapped[UUID | None] = mapped_column( PostgresUUID, - ForeignKey("subscriptions.id", ondelete="set null"), + ForeignKey("sales.id", ondelete="set null"), nullable=True, index=True, ) - """ID of the `Subscription` related to this balance.""" + """ID of the `Sale` related to this balance.""" @declared_attr - def subscription(cls) -> Mapped["Subscription | None"]: - return relationship("Subscription", lazy="raise") - - product_price_id: Mapped[UUID | None] = mapped_column( - PostgresUUID, - ForeignKey("product_prices.id", ondelete="set null"), - nullable=True, - index=True, - ) - """ - ID of the `ProductPrice` related to this balance. - - Useful to keep track of the price at the time of the balance creation, - which might change if the product is updated. - """ - - @declared_attr - def product_price(cls) -> Mapped["ProductPrice | None"]: - return relationship("ProductPrice", lazy="raise") + def sale(cls) -> Mapped["Sale | None"]: + return relationship("Sale", lazy="raise") issue_reward_id: Mapped[UUID | None] = mapped_column( PostgresUUID, diff --git a/server/polar/product/service/product.py b/server/polar/product/service/product.py index 6cc610042d..70b816b523 100644 --- a/server/polar/product/service/product.py +++ b/server/polar/product/service/product.py @@ -7,7 +7,6 @@ from sqlalchemy.exc import InvalidRequestError from sqlalchemy.orm import contains_eager, joinedload -from polar.account.service import account as account_service from polar.auth.models import AuthSubject, Subject, is_organization, is_user from polar.authz.service import AccessType, Authz from polar.benefit.service.benefit import benefit as benefit_service @@ -17,7 +16,6 @@ from polar.kit.pagination import PaginationParams, paginate from polar.kit.services import ResourceService from polar.models import ( - Account, Benefit, Organization, Product, @@ -511,13 +509,6 @@ def _get_readable_product_statement( return statement - async def get_managing_organization_account( - self, session: AsyncSession, product: Product - ) -> Account | None: - return await account_service.get_by_organization_id( - session, product.organization_id - ) - async def _disable_other_highlights( self, session: AsyncSession, diff --git a/server/polar/sale/__init__.py b/server/polar/sale/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/sale/service.py b/server/polar/sale/service.py new file mode 100644 index 0000000000..12084d4f7c --- /dev/null +++ b/server/polar/sale/service.py @@ -0,0 +1,215 @@ +import stripe as stripe_lib + +from polar.account.service import account as account_service +from polar.enums import UserSignupType +from polar.exceptions import PolarError +from polar.held_balance.service import held_balance as held_balance_service +from polar.integrations.loops.service import loops as loops_service +from polar.integrations.stripe.utils import get_expandable_id +from polar.kit.db.postgres import AsyncSession +from polar.kit.services import ResourceServiceReader +from polar.models import HeldBalance, Sale, Subscription, User +from polar.models.transaction import TransactionType +from polar.notifications.notification import ( + MaintainerCreateAccountNotificationPayload, + NotificationType, +) +from polar.notifications.service import PartialNotification +from polar.notifications.service import notifications as notifications_service +from polar.organization.service import organization as organization_service +from polar.product.service.product_price import product_price as product_price_service +from polar.subscription.service.subscription import subscription as subscription_service +from polar.transaction.service.balance import PaymentTransactionForChargeDoesNotExist +from polar.transaction.service.balance import ( + balance_transaction as balance_transaction_service, +) +from polar.transaction.service.platform_fee import ( + platform_fee_transaction as platform_fee_transaction_service, +) +from polar.user.service import user as user_service + + +class SaleError(PolarError): ... + + +class NotASaleInvoice(SaleError): + def __init__(self, invoice_id: str) -> None: + self.invoice_id = invoice_id + message = ( + f"Received invoice {invoice_id} from Stripe, but it is not a sale." + " Check if it's an issue pledge." + ) + super().__init__(message) + + +class InvoiceWithNoOrMultipleLines(SaleError): + def __init__(self, invoice_id: str) -> None: + self.invoice_id = invoice_id + message = ( + f"Received invoice {invoice_id} from Stripe " f"with no or multiple lines." + ) + super().__init__(message) + + +class ProductPriceDoesNotExist(SaleError): + def __init__(self, invoice_id: str, stripe_price_id: str) -> None: + self.invoice_id = invoice_id + self.stripe_price_id = stripe_price_id + message = ( + f"Received invoice {invoice_id} from Stripe with price {stripe_price_id}, " + f"but no associated ProductPrice exists." + ) + super().__init__(message) + + +class SubscriptionDoesNotExist(SaleError): + def __init__(self, invoice_id: str, stripe_subscription_id: str) -> None: + self.invoice_id = invoice_id + self.stripe_subscription_id = stripe_subscription_id + message = ( + f"Received invoice {invoice_id} from Stripe " + f"for subscription {stripe_subscription_id}, " + f"but no associated Subscription exists." + ) + super().__init__(message) + + +class SaleService(ResourceServiceReader[Sale]): + async def create_sale_from_stripe( + self, session: AsyncSession, *, invoice: stripe_lib.Invoice + ) -> Sale: + assert invoice.charge is not None + assert invoice.id is not None + + if invoice.subscription is None: + raise NotASaleInvoice(invoice.id) + + # Get price and product + if len(invoice.lines.data) != 1: + raise InvoiceWithNoOrMultipleLines(invoice.id) + line = invoice.lines.data[0] + assert line.price is not None + stripe_price_id = line.price.id + product_price = await product_price_service.get_by_stripe_price_id( + session, stripe_price_id + ) + if product_price is None: + raise ProductPriceDoesNotExist(invoice.id, stripe_price_id) + product = product_price.product + + user: User | None = None + + # Get subscription if applicable + subscription: Subscription | None = None + if invoice.subscription is not None: + stripe_subscription_id = get_expandable_id(invoice.subscription) + subscription = await subscription_service.get_by_stripe_subscription_id( + session, stripe_subscription_id + ) + if subscription is None: + raise SubscriptionDoesNotExist(invoice.id, stripe_subscription_id) + user = await user_service.get(session, subscription.user_id) + + # Get or create customer user + assert invoice.customer is not None + stripe_customer_id = get_expandable_id(invoice.customer) + if user is None: + user = await user_service.get_by_stripe_customer_id( + session, stripe_customer_id + ) + if user is None: + assert invoice.customer_email is not None + user = await user_service.get_by_email_or_signup( + session, invoice.customer_email, signup_type=UserSignupType.backer + ) + + # Take the chance to update Stripe customer ID and email marketing + user.stripe_customer_id = stripe_customer_id + await loops_service.user_update(user, isBacker=True) + session.add(user) + + # Create Sale + tax = invoice.tax or 0 + sale = Sale( + amount=invoice.total - tax, + tax_amount=tax, + currency=invoice.currency, + stripe_invoice_id=invoice.id, + user=user, + product=product, + product_price=product_price, + subscription=subscription, + ) + session.add(sale) + + await self._create_sale_balance( + session, sale, charge_id=get_expandable_id(invoice.charge) + ) + + return sale + + async def _create_sale_balance( + self, session: AsyncSession, sale: Sale, charge_id: str + ) -> None: + product = sale.product + account = await account_service.get_by_organization_id( + session, product.organization_id + ) + + transfer_amount = sale.amount + + # Retrieve the payment transaction and link it to the sale + payment_transaction = await balance_transaction_service.get_by( + session, type=TransactionType.payment, charge_id=charge_id + ) + if payment_transaction is None: + raise PaymentTransactionForChargeDoesNotExist(charge_id) + payment_transaction.sale = sale + session.add(payment_transaction) + + # Prepare an held balance + # It'll be used if the account is not created yet + held_balance = HeldBalance( + amount=transfer_amount, sale=sale, payment_transaction=payment_transaction + ) + + # No account, create the held balance + if account is None: + managing_organization = await organization_service.get( + session, product.organization_id + ) + assert managing_organization is not None + held_balance.organization_id = managing_organization.id + await held_balance_service.create(session, held_balance=held_balance) + + await notifications_service.send_to_org_admins( + session=session, + org_id=managing_organization.id, + notif=PartialNotification( + type=NotificationType.maintainer_create_account, + payload=MaintainerCreateAccountNotificationPayload( + organization_name=managing_organization.name, + url=managing_organization.account_url, + ), + ), + ) + + return + + # Account created, create the balance immediately + balance_transactions = ( + await balance_transaction_service.create_balance_from_charge( + session, + source_account=None, + destination_account=account, + charge_id=charge_id, + amount=transfer_amount, + sale=sale, + ) + ) + await platform_fee_transaction_service.create_fees_reversal_balances( + session, balance_transactions=balance_transactions + ) + + +sale = SaleService(Sale) diff --git a/server/polar/subscription/service/subscription.py b/server/polar/subscription/service/subscription.py index 4d7ea084c7..b62665354b 100644 --- a/server/polar/subscription/service/subscription.py +++ b/server/polar/subscription/service/subscription.py @@ -34,7 +34,6 @@ from polar.config import settings from polar.enums import UserSignupType from polar.exceptions import NotPermitted, PolarError, ResourceNotFound -from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.loops.service import loops as loops_service from polar.integrations.stripe.service import stripe as stripe_service from polar.integrations.stripe.utils import get_expandable_id @@ -46,12 +45,12 @@ from polar.models import ( Benefit, BenefitGrant, - HeldBalance, OAuthAccount, Organization, Product, ProductBenefit, ProductPrice, + Sale, Subscription, Transaction, User, @@ -64,22 +63,13 @@ from polar.models.user import OAuthPlatform from polar.models.webhook_endpoint import WebhookEventType from polar.notifications.notification import ( - MaintainerCreateAccountNotificationPayload, MaintainerNewPaidSubscriptionNotificationPayload, NotificationType, ) from polar.notifications.service import PartialNotification -from polar.notifications.service import notifications as notification_service from polar.notifications.service import notifications as notifications_service from polar.organization.service import organization as organization_service from polar.posthog import posthog -from polar.transaction.service.balance import PaymentTransactionForChargeDoesNotExist -from polar.transaction.service.balance import ( - balance_transaction as balance_transaction_service, -) -from polar.transaction.service.platform_fee import ( - platform_fee_transaction as platform_fee_transaction_service, -) from polar.user.service import user as user_service from polar.user_organization.service import ( user_organization as user_organization_service, @@ -773,86 +763,6 @@ async def update_subscription_from_stripe( return subscription - async def transfer_subscription_paid_invoice( - self, - session: AsyncSession, - *, - invoice: stripe_lib.Invoice, - ) -> None: - assert invoice.charge is not None - - if invoice.subscription is None: - return - - stripe_subscription_id = get_expandable_id(invoice.subscription) - subscription = await self.get_by_stripe_subscription_id( - session, stripe_subscription_id - ) - if subscription is None: - raise SubscriptionDoesNotExist(stripe_subscription_id) - - await session.refresh(subscription, {"product", "price"}) - account = await product_service.get_managing_organization_account( - session, subscription.product - ) - - tax = invoice.tax or 0 - transfer_amount = invoice.total - tax - - charge_id = get_expandable_id(invoice.charge) - - # Prepare an held balance - # It'll be used if the account is not created yet - payment_transaction = await balance_transaction_service.get_by( - session, type=TransactionType.payment, charge_id=charge_id - ) - if payment_transaction is None: - raise PaymentTransactionForChargeDoesNotExist(charge_id) - held_balance = HeldBalance( - amount=transfer_amount, - subscription=subscription, - product_price=subscription.price, - payment_transaction=payment_transaction, - ) - - # No account, create the held balance - if account is None: - managing_organization = await organization_service.get( - session, subscription.product.organization_id - ) - assert managing_organization is not None - held_balance.organization_id = managing_organization.id - await held_balance_service.create(session, held_balance=held_balance) - - await notification_service.send_to_org_admins( - session=session, - org_id=managing_organization.id, - notif=PartialNotification( - type=NotificationType.maintainer_create_account, - payload=MaintainerCreateAccountNotificationPayload( - organization_name=managing_organization.name, - url=managing_organization.account_url, - ), - ), - ) - - return - - # Account created, create the balance immediately - balance_transactions = ( - await balance_transaction_service.create_balance_from_charge( - session, - source_account=None, - destination_account=account, - charge_id=charge_id, - amount=transfer_amount, - subscription=subscription, - ) - ) - await platform_fee_transaction_service.create_fees_reversal_balances( - session, balance_transactions=balance_transactions - ) - async def enqueue_benefits_grants( self, session: AsyncSession, subscription: Subscription ) -> None: @@ -1097,8 +1007,14 @@ async def get_statistics_periods( onclause=and_( Transaction.type == TransactionType.balance, Transaction.account_id.is_not(None), - Transaction.subscription_id.in_( - subscriptions_statement.with_only_columns(Subscription.id) + Transaction.sale_id.in_( + select(Sale.id).where( + Sale.subscription_id.in_( + subscriptions_statement.with_only_columns( + Subscription.id + ) + ) + ) ), ), isouter=True, diff --git a/server/polar/transaction/service/balance.py b/server/polar/transaction/service/balance.py index 9187da98d2..9f5c907c30 100644 --- a/server/polar/transaction/service/balance.py +++ b/server/polar/transaction/service/balance.py @@ -7,14 +7,7 @@ from polar.integrations.stripe.utils import get_expandable_id from polar.kit.utils import generate_uuid from polar.logging import Logger -from polar.models import ( - Account, - IssueReward, - Pledge, - ProductPrice, - Subscription, - Transaction, -) +from polar.models import Account, IssueReward, Pledge, Sale, Transaction from polar.models.donation import Donation from polar.models.transaction import PlatformFeeType, TransactionType from polar.postgres import AsyncSession @@ -44,8 +37,7 @@ async def create_balance( amount: int, payment_transaction: Transaction | None = None, pledge: Pledge | None = None, - subscription: Subscription | None = None, - product_price: ProductPrice | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, platform_fee_type: PlatformFeeType | None = None, donation: Donation | None = None, @@ -66,8 +58,7 @@ async def create_balance( balance_correlation_key=balance_correlation_key, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, - product_price=product_price, + sale=sale, payment_transaction=payment_transaction, platform_fee_type=platform_fee_type, donation=donation, @@ -84,8 +75,7 @@ async def create_balance( balance_correlation_key=balance_correlation_key, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, - product_price=product_price, + sale=sale, payment_transaction=payment_transaction, platform_fee_type=platform_fee_type, donation=donation, @@ -109,7 +99,7 @@ async def create_balance_from_charge( charge_id: str, amount: int, pledge: Pledge | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, donation: Donation | None = None, ) -> tuple[Transaction, Transaction]: @@ -126,8 +116,7 @@ async def create_balance_from_charge( payment_transaction=payment_transaction, amount=amount, pledge=pledge, - subscription=subscription, - product_price=subscription.price if subscription is not None else None, + sale=sale, issue_reward=issue_reward, donation=donation, ) @@ -141,7 +130,7 @@ async def create_balance_from_payment_intent( payment_intent_id: str, amount: int, pledge: Pledge | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, donation: Donation | None = None, ) -> tuple[Transaction, Transaction]: @@ -156,7 +145,7 @@ async def create_balance_from_payment_intent( charge_id=charge_id, amount=amount, pledge=pledge, - subscription=subscription, + sale=sale, issue_reward=issue_reward, donation=donation, ) @@ -194,7 +183,7 @@ async def create_reversal_balance( platform_fee_type=platform_fee_type, pledge_id=outgoing.pledge_id, issue_reward_id=outgoing.issue_reward_id, - subscription_id=outgoing.subscription_id, + sale_id=outgoing.sale_id, balance_reversal_transaction=incoming, incurred_by_transaction=outgoing_incurred_by, ) @@ -211,7 +200,7 @@ async def create_reversal_balance( platform_fee_type=platform_fee_type, pledge_id=outgoing.pledge_id, issue_reward_id=outgoing.issue_reward_id, - subscription_id=outgoing.subscription_id, + sale_id=outgoing.sale_id, balance_reversal_transaction=outgoing, incurred_by_transaction=incoming_incurred_by, ) diff --git a/server/polar/transaction/service/dispute.py b/server/polar/transaction/service/dispute.py index 1a65baa136..7cbd48374e 100644 --- a/server/polar/transaction/service/dispute.py +++ b/server/polar/transaction/service/dispute.py @@ -68,8 +68,7 @@ async def create_dispute( payment_organization_id=payment_transaction.payment_organization_id, pledge_id=payment_transaction.pledge_id, issue_reward_id=payment_transaction.issue_reward_id, - subscription_id=payment_transaction.subscription_id, - product_price_id=payment_transaction.product_price_id, + sale_id=payment_transaction.sale_id, ) # Compute and link fees @@ -136,8 +135,7 @@ async def create_dispute_reversal( payment_organization_id=payment_transaction.payment_organization_id, pledge_id=payment_transaction.pledge_id, issue_reward_id=payment_transaction.issue_reward_id, - subscription_id=payment_transaction.subscription_id, - product_price_id=payment_transaction.product_price_id, + sale_id=payment_transaction.sale_id, ) # Compute and link fees @@ -166,8 +164,7 @@ async def create_dispute_reversal( payment_transaction=payment_transaction, amount=abs(outgoing.amount), pledge=outgoing.pledge, - subscription=outgoing.subscription, - product_price=outgoing.product_price, + sale=outgoing.sale, issue_reward=outgoing.issue_reward, ) @@ -197,8 +194,7 @@ async def _get_reverse_balance_transactions_for_payment( .options( joinedload(Transaction.account), joinedload(Transaction.pledge), - joinedload(Transaction.subscription), - joinedload(Transaction.product_price), + joinedload(Transaction.sale), joinedload(Transaction.issue_reward), ) ) diff --git a/server/polar/transaction/service/payment.py b/server/polar/transaction/service/payment.py index 963b7f3ebc..4e743728aa 100644 --- a/server/polar/transaction/service/payment.py +++ b/server/polar/transaction/service/payment.py @@ -7,13 +7,12 @@ from polar.integrations.stripe.schemas import ProductType from polar.integrations.stripe.service import stripe as stripe_service from polar.integrations.stripe.utils import get_expandable_id -from polar.models import Pledge, Subscription, Transaction +from polar.models import Pledge, Transaction from polar.models.donation import Donation from polar.models.transaction import PaymentProcessor, TransactionType from polar.organization.service import organization as organization_service from polar.pledge.service import pledge as pledge_service from polar.postgres import AsyncSession -from polar.subscription.service.subscription import subscription as subscription_service from polar.user.service import user as user_service from .base import BaseTransactionService, BaseTransactionServiceError @@ -25,17 +24,6 @@ class PaymentTransactionError(BaseTransactionServiceError): ... -class SubscriptionDoesNotExist(PaymentTransactionError): - def __init__(self, charge_id: str, stripe_subscription_id: str) -> None: - self.charge_id = charge_id - self.stripe_subscription_id = stripe_subscription_id - message = ( - f"Received the charge {charge_id} from Stripe related to subscription " - f"{stripe_subscription_id}, but no associated Subscription exists." - ) - super().__init__(message) - - class PledgeDoesNotExist(PaymentTransactionError): def __init__(self, charge_id: str, payment_intent_id: str) -> None: self.charge_id = charge_id @@ -62,7 +50,6 @@ class PaymentTransactionService(BaseTransactionService): async def create_payment( self, session: AsyncSession, *, charge: stripe_lib.Charge ) -> Transaction: - subscription: Subscription | None = None pledge: Pledge | None = None donation: Donation | None = None @@ -100,18 +87,6 @@ async def create_payment( tax_country = tax_rate.country tax_state = tax_rate.state - # Try to link with a Subscription - if stripe_invoice.subscription: - stripe_subscription_id = get_expandable_id(stripe_invoice.subscription) - subscription = await subscription_service.get_by_stripe_subscription_id( - session, stripe_subscription_id - ) - # Give a chance to retry this later in case we didn't yet handle - # the `customer.subscription.created` event. - if subscription is None: - raise SubscriptionDoesNotExist(charge.id, stripe_subscription_id) - await session.refresh(subscription, {"price"}) - if ( stripe_invoice.metadata and stripe_invoice.metadata.get("type") == ProductType.pledge @@ -157,9 +132,8 @@ async def create_payment( payment_organization=payment_organization, charge_id=charge.id, pledge=pledge, - subscription=subscription, + sale=None, donation=donation, - product_price=subscription.price if subscription else None, ) # Compute and link fees diff --git a/server/polar/transaction/service/payout.py b/server/polar/transaction/service/payout.py index 8325bdeffb..ef0ce68b28 100644 --- a/server/polar/transaction/service/payout.py +++ b/server/polar/transaction/service/payout.py @@ -16,7 +16,7 @@ from polar.kit.db.postgres import AsyncSessionMaker from polar.kit.utils import generate_uuid, utc_now from polar.logging import Logger -from polar.models import Account, Issue, Pledge, Subscription, Transaction +from polar.models import Account, Issue, Pledge, Sale, Transaction from polar.models.donation import Donation from polar.models.transaction import PaymentProcessor, TransactionType from polar.postgres import AsyncSession @@ -152,8 +152,7 @@ async def create_payout( account=account, pledge=None, issue_reward=None, - subscription=None, - product_price=None, + sale=None, donation=None, paid_transactions=[], incurred_transactions=[], @@ -320,8 +319,8 @@ async def get_payout_csv( ) .order_by(Transaction.created_at) .options( - # Subscription - selectinload(Transaction.subscription).joinedload(Subscription.product), + # Sale + selectinload(Transaction.sale).joinedload(Sale.product), # Pledge selectinload(Transaction.pledge) .joinedload(Pledge.issue) @@ -368,10 +367,12 @@ async def get_payout_csv( ) elif transaction.pledge is not None: description = f"Pledge to {transaction.pledge.issue.reference_key}" - elif transaction.subscription is not None: - description = ( - f"Subscription to {transaction.subscription.product.name}" - ) + elif transaction.sale is not None: + product = transaction.sale.product + if transaction.sale.subscription_id is not None: + description = f"Subscription to {product.name}" + else: + description = f"Sale of {product.name}" elif transaction.donation is not None: description = ( f"Donation to {transaction.donation.to_organization.name}" diff --git a/server/polar/transaction/service/platform_fee.py b/server/polar/transaction/service/platform_fee.py index 403d6d68e9..5d3b858979 100644 --- a/server/polar/transaction/service/platform_fee.py +++ b/server/polar/transaction/service/platform_fee.py @@ -137,7 +137,7 @@ async def _create_platform_fee( if incoming.pledge_id is not None and incoming.issue_reward_id is not None: fee_percent = account.platform_pledge_fee_percent fee_amount = math.floor(incoming.amount * (fee_percent / 100)) - elif incoming.subscription_id is not None: + elif incoming.sale_id is not None: fee_percent = account.platform_subscription_fee_percent fee_amount = math.floor(incoming.amount * (fee_percent / 100)) elif incoming.donation_id is not None: @@ -205,17 +205,22 @@ async def _create_payment_processor_fees( payment_processor_fees_balances.append(fee_balances) # Subscription fee - if incoming.subscription_id is not None: - subscription_fee_amount = get_stripe_subscription_fee(incoming.amount) - fee_balances = await balance_transaction_service.create_reversal_balance( - session, - balance_transactions=balance_transactions, - amount=subscription_fee_amount, - platform_fee_type=PlatformFeeType.subscription, - outgoing_incurred_by=incoming, - incoming_incurred_by=outgoing, - ) - payment_processor_fees_balances.append(fee_balances) + if incoming.sale_id is not None: + await session.refresh(incoming, {"sale"}) + assert incoming.sale is not None + if incoming.sale.subscription_id is not None: + subscription_fee_amount = get_stripe_subscription_fee(incoming.amount) + fee_balances = ( + await balance_transaction_service.create_reversal_balance( + session, + balance_transactions=balance_transactions, + amount=subscription_fee_amount, + platform_fee_type=PlatformFeeType.subscription, + outgoing_incurred_by=incoming, + incoming_incurred_by=outgoing, + ) + ) + payment_processor_fees_balances.append(fee_balances) return payment_processor_fees_balances diff --git a/server/polar/transaction/service/refund.py b/server/polar/transaction/service/refund.py index 7c1a767d5f..4b9a88d97e 100644 --- a/server/polar/transaction/service/refund.py +++ b/server/polar/transaction/service/refund.py @@ -77,8 +77,7 @@ async def create_refunds( payment_organization_id=payment_transaction.payment_organization_id, pledge_id=payment_transaction.pledge_id, issue_reward_id=payment_transaction.issue_reward_id, - subscription_id=payment_transaction.subscription_id, - product_price_id=payment_transaction.product_price_id, + sale_id=payment_transaction.sale_id, ) # Compute and link fees diff --git a/server/polar/transaction/service/transaction.py b/server/polar/transaction/service/transaction.py index 263827611c..1dba24925c 100644 --- a/server/polar/transaction/service/transaction.py +++ b/server/polar/transaction/service/transaction.py @@ -16,7 +16,7 @@ Issue, Pledge, Product, - Subscription, + Sale, Transaction, User, ) @@ -69,14 +69,11 @@ async def search( ), # IssueReward subqueryload(Transaction.issue_reward), - # Subscription - subqueryload(Transaction.subscription).options( - joinedload(Subscription.product).options( - joinedload(Product.organization) - ), + # Sale + subqueryload(Transaction.sale).options( + joinedload(Sale.product).options(joinedload(Product.organization)), + joinedload(Sale.product_price), ), - # SubscriptionTierPrice - subqueryload(Transaction.product_price), # Donation subqueryload(Transaction.donation).options( joinedload(Donation.to_organization), @@ -130,9 +127,10 @@ async def lookup( ), # IssueReward subqueryload(Transaction.issue_reward), - # Subscription - subqueryload(Transaction.subscription).options( - joinedload(Subscription.product), + # Sale + subqueryload(Transaction.sale).options( + joinedload(Sale.product).options(joinedload(Product.organization)), + joinedload(Sale.product_price), ), # Donation subqueryload(Transaction.donation).options( @@ -141,8 +139,6 @@ async def lookup( joinedload(Donation.by_organization), joinedload(Donation.on_behalf_of_organization), ), - # SubscriptionTierPrice - subqueryload(Transaction.product_price), # Paid transactions (joining on itself) subqueryload(Transaction.paid_transactions) .subqueryload(Transaction.pledge) @@ -155,16 +151,17 @@ async def lookup( Transaction.issue_reward ), subqueryload(Transaction.paid_transactions) - .subqueryload(Transaction.subscription) + .subqueryload(Transaction.sale) .options( - joinedload(Subscription.product), + joinedload(Sale.product), + joinedload(Sale.product_price), ), subqueryload(Transaction.paid_transactions).subqueryload( Transaction.account_incurred_transactions ), - subqueryload(Transaction.paid_transactions).subqueryload( - Transaction.product_price - ), + subqueryload(Transaction.paid_transactions) + .subqueryload(Transaction.sale) + .options(joinedload(Sale.product_price)), subqueryload(Transaction.paid_transactions) .subqueryload(Transaction.donation) .options( diff --git a/server/scripts/pledge_invoice_payment_fix.py b/server/scripts/pledge_invoice_payment_fix.py index 43789f408c..2363d0e115 100644 --- a/server/scripts/pledge_invoice_payment_fix.py +++ b/server/scripts/pledge_invoice_payment_fix.py @@ -64,7 +64,7 @@ async def pledge_invoice_payment_fix( .where( Transaction.type == TransactionType.payment, Transaction.pledge_id.is_(None), - Transaction.subscription_id.is_(None), + Transaction.sale_id.is_(None), ) .order_by(Transaction.created_at.asc()) ) diff --git a/server/tests/fixtures/random_objects.py b/server/tests/fixtures/random_objects.py index 85659c529f..ed0b5c7454 100644 --- a/server/tests/fixtures/random_objects.py +++ b/server/tests/fixtures/random_objects.py @@ -17,6 +17,7 @@ ProductBenefit, ProductPrice, Repository, + Sale, Subscription, User, UserOrganization, @@ -520,6 +521,35 @@ async def create_product_price( return price +async def create_sale( + save_fixture: SaveFixture, + *, + product: Product, + user: User, + product_price: ProductPrice | None = None, + subscription: Subscription | None = None, + amount: int = 1000, + tax_amount: int = 0, + stripe_invoice_id: str = "INVOICE_ID", +) -> Sale: + sale = Sale( + amount=amount, + tax_amount=tax_amount, + currency="usd", + stripe_invoice_id=stripe_invoice_id, + user=user, + product=product, + product_price=product_price + if product_price is not None + else product.prices[0] + if product.prices + else None, + subscription=subscription, + ) + await save_fixture(sale) + return sale + + async def create_benefit( save_fixture: SaveFixture, *, @@ -584,9 +614,9 @@ async def create_subscription( cancel_at_period_end=False, started_at=started_at, ended_at=ended_at, - user_id=user.id, - organization_id=organization.id if organization is not None else None, - product_id=product.id, + user=user, + organization=organization, + product=product, price=price if price is not None else product.prices[0] diff --git a/server/tests/sale/__init__.py b/server/tests/sale/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/tests/sale/test_service.py b/server/tests/sale/test_service.py new file mode 100644 index 0000000000..c919cb6714 --- /dev/null +++ b/server/tests/sale/test_service.py @@ -0,0 +1,181 @@ +import pytest +import stripe as stripe_lib +from pytest_mock import MockerFixture + +from polar.held_balance.service import held_balance as held_balance_service +from polar.kit.db.postgres import AsyncSession +from polar.models import Account, Product, Subscription, Transaction +from polar.models.transaction import TransactionType +from polar.sale.service import ( + InvoiceWithNoOrMultipleLines, + NotASaleInvoice, + ProductPriceDoesNotExist, +) +from polar.sale.service import sale as sale_service +from polar.transaction.service.balance import BalanceTransactionService +from polar.transaction.service.payment import ( + payment_transaction as payment_transaction_service, +) +from polar.transaction.service.platform_fee import PlatformFeeTransactionService +from tests.fixtures.database import SaveFixture +from tests.transaction.conftest import create_transaction + + +def construct_stripe_invoice( + *, + id: str = "INVOICE_ID", + total: int = 12000, + tax: int = 2000, + charge_id: str = "CHARGE_ID", + subscription_id: str | None = "SUBSCRIPTION_ID", + customer_id: str = "CUSTOMER_ID", + lines: list[str] = ["PRICE_ID"], + metadata: dict[str, str] = {}, +) -> stripe_lib.Invoice: + return stripe_lib.Invoice.construct_from( + { + "id": id, + "total": total, + "tax": tax, + "currency": "usd", + "charge": charge_id, + "subscription": subscription_id, + "customer": customer_id, + "lines": {"data": [{"price": {"id": price_id}} for price_id in lines]}, + "metadata": metadata, + }, + None, + ) + + +@pytest.mark.asyncio +@pytest.mark.skip_db_asserts +class TestCreateSaleFromStripe: + async def test_not_a_sale_invoice(self, session: AsyncSession) -> None: + invoice = construct_stripe_invoice(subscription_id=None) + with pytest.raises(NotASaleInvoice): + await sale_service.create_sale_from_stripe(session, invoice=invoice) + + @pytest.mark.parametrize("lines", ([], ["PRICE_1", "PRICE_2"])) + async def test_invalid_lines(self, lines: list[str], session: AsyncSession) -> None: + invoice = construct_stripe_invoice(lines=lines) + with pytest.raises(InvoiceWithNoOrMultipleLines): + await sale_service.create_sale_from_stripe(session, invoice=invoice) + + async def test_not_existing_product_price(self, session: AsyncSession) -> None: + invoice = construct_stripe_invoice() + with pytest.raises(ProductPriceDoesNotExist): + await sale_service.create_sale_from_stripe(session, invoice=invoice) + + async def test_no_account( + self, + session: AsyncSession, + save_fixture: SaveFixture, + subscription: Subscription, + product: Product, + ) -> None: + invoice = construct_stripe_invoice( + subscription_id=subscription.stripe_subscription_id, + lines=[product.prices[0].stripe_price_id], + ) + + payment_transaction = await create_transaction( + save_fixture, type=TransactionType.payment + ) + payment_transaction.charge_id = "CHARGE_ID" + await save_fixture(payment_transaction) + + sale = await sale_service.create_sale_from_stripe(session, invoice=invoice) + + assert sale.amount == invoice.total - (invoice.tax or 0) + assert sale.user.id == subscription.user_id + assert sale.product == product + assert sale.product_price == product.prices[0] + assert sale.subscription == subscription + assert sale.user.stripe_customer_id == invoice.customer + + held_balance = await held_balance_service.get_by( + session, organization_id=product.organization_id + ) + assert held_balance is not None + assert held_balance.sale_id == sale.id + + updated_payment_transaction = await payment_transaction_service.get( + session, id=payment_transaction.id + ) + assert updated_payment_transaction is not None + assert updated_payment_transaction.sale_id == sale.id + + async def test_with_account( + self, + mocker: MockerFixture, + session: AsyncSession, + save_fixture: SaveFixture, + subscription: Subscription, + product: Product, + organization_account: Account, + ) -> None: + invoice = construct_stripe_invoice( + subscription_id=subscription.stripe_subscription_id, + lines=[product.prices[0].stripe_price_id], + ) + invoice_total = invoice.total - (invoice.tax or 0) + + payment_transaction = await create_transaction( + save_fixture, type=TransactionType.payment + ) + payment_transaction.charge_id = "CHARGE_ID" + await save_fixture(payment_transaction) + + transaction_service_mock = mocker.patch( + "polar.sale.service.balance_transaction_service", + spec=BalanceTransactionService, + ) + transaction_service_mock.get_by.return_value = payment_transaction + transaction_service_mock.create_balance_from_charge.return_value = ( + Transaction(type=TransactionType.balance, amount=-invoice_total), + Transaction( + type=TransactionType.balance, + amount=invoice_total, + account_id=organization_account.id, + ), + ) + platform_fee_transaction_service_mock = mocker.patch( + "polar.sale.service.platform_fee_transaction_service", + spec=PlatformFeeTransactionService, + ) + + sale = await sale_service.create_sale_from_stripe(session, invoice=invoice) + + assert sale.amount == invoice_total + assert sale.user.id == subscription.user_id + assert sale.product == product + assert sale.product_price == product.prices[0] + assert sale.subscription == subscription + assert sale.user.stripe_customer_id == invoice.customer + + transaction_service_mock.create_balance_from_charge.assert_called_once() + assert ( + transaction_service_mock.create_balance_from_charge.call_args[1][ + "destination_account" + ].id + == organization_account.id + ) + assert ( + transaction_service_mock.create_balance_from_charge.call_args[1][ + "charge_id" + ] + == invoice.charge + ) + assert ( + transaction_service_mock.create_balance_from_charge.call_args[1]["amount"] + == invoice_total + ) + + platform_fee_transaction_service_mock.create_fees_reversal_balances.assert_called_once() + + updated_payment_transaction = await payment_transaction_service.get( + session, id=payment_transaction.id + ) + assert updated_payment_transaction is not None + assert updated_payment_transaction.sale_id == sale.id diff --git a/server/tests/subscription/service/test_subscription.py b/server/tests/subscription/service/test_subscription.py index 0c3d6c5cd7..85ae1a71a3 100644 --- a/server/tests/subscription/service/test_subscription.py +++ b/server/tests/subscription/service/test_subscription.py @@ -10,7 +10,6 @@ from polar.authz.service import Authz from polar.config import settings from polar.exceptions import NotPermitted, ResourceNotFound -from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.stripe.service import StripeService from polar.kit.pagination import PaginationParams from polar.models import ( @@ -42,12 +41,6 @@ SubscriptionDoesNotExist, ) from polar.subscription.service.subscription import subscription as subscription_service -from polar.transaction.service.balance import ( - BalanceTransactionService, -) -from polar.transaction.service.platform_fee import ( - PlatformFeeTransactionService, -) from polar.user.service import user as user_service from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture @@ -55,10 +48,10 @@ add_product_benefits, create_active_subscription, create_product_price, + create_sale, create_subscription, create_user, ) -from tests.transaction.conftest import create_transaction def construct_stripe_subscription( @@ -662,122 +655,6 @@ async def test_valid( ) -@pytest.mark.asyncio -class TestTransferSubscriptionPaidInvoice: - async def test_not_existing_subscription(self, session: AsyncSession) -> None: - stripe_invoice = construct_stripe_invoice() - - # then - session.expunge_all() - - with pytest.raises(SubscriptionDoesNotExist): - await subscription_service.transfer_subscription_paid_invoice( - session, invoice=stripe_invoice - ) - - async def test_no_account( - self, - mocker: MockerFixture, - session: AsyncSession, - save_fixture: SaveFixture, - subscription: Subscription, - product: Product, - ) -> None: - stripe_invoice = construct_stripe_invoice( - subscription_id=subscription.stripe_subscription_id - ) - - stripe_service_mock = mocker.patch( - "polar.subscription.service.subscription.stripe_service", spec=StripeService - ) - - payment_transaction = await create_transaction( - save_fixture, type=TransactionType.payment, subscription=subscription - ) - payment_transaction.charge_id = "CHARGE_ID" - await save_fixture(payment_transaction) - - # then - session.expunge_all() - - await subscription_service.transfer_subscription_paid_invoice( - session, invoice=stripe_invoice - ) - - stripe_service_mock.update_invoice.assert_not_called() - - held_balance = await held_balance_service.get_by( - session, - organization_id=product.organization_id, - ) - assert held_balance is not None - - assert held_balance.subscription_id == subscription.id - assert held_balance.product_price_id == subscription.price_id - - async def test_valid( - self, - mocker: MockerFixture, - session: AsyncSession, - subscription: Subscription, - organization_account: Account, - ) -> None: - stripe_invoice = construct_stripe_invoice( - subscription_id=subscription.stripe_subscription_id - ) - invoice_total = stripe_invoice.total - (stripe_invoice.tax or 0) - - transaction_service_mock = mocker.patch( - "polar.subscription.service.subscription.balance_transaction_service", - spec=BalanceTransactionService, - ) - transaction_service_mock.create_balance_from_charge.return_value = ( - Transaction( - type=TransactionType.balance, - amount=-invoice_total, - subscription_id=subscription.id, - ), - Transaction( - type=TransactionType.balance, - amount=invoice_total, - account_id=organization_account.id, - subscription_id=subscription.id, - ), - ) - - platform_fee_transaction_service_mock = mocker.patch( - "polar.subscription.service.subscription.platform_fee_transaction_service", - spec=PlatformFeeTransactionService, - ) - - # then - session.expunge_all() - - await subscription_service.transfer_subscription_paid_invoice( - session, invoice=stripe_invoice - ) - - transaction_service_mock.create_balance_from_charge.assert_called_once() - assert ( - transaction_service_mock.create_balance_from_charge.call_args[1][ - "destination_account" - ].id - == organization_account.id - ) - assert ( - transaction_service_mock.create_balance_from_charge.call_args[1][ - "charge_id" - ] - == stripe_invoice.charge - ) - assert ( - transaction_service_mock.create_balance_from_charge.call_args[1]["amount"] - == invoice_total - ) - - platform_fee_transaction_service_mock.create_fees_reversal_balances.assert_called_once() - - @pytest.mark.asyncio class TestEnqueueBenefitsGrants: @pytest.mark.parametrize( @@ -1600,7 +1477,7 @@ def get_balances_sum(balances: list[Transaction]) -> int: return sum(balance.amount for balance in balances) -async def create_subscription_balances( +async def create_sale_subscription_balances( save_fixture: SaveFixture, *, gross_amount: int, @@ -1613,6 +1490,12 @@ async def create_subscription_balances( net_amount = get_net_amount(gross_amount) transactions: list[Transaction] = [] for month in range(start_month, end_month + 1): + sale = await create_sale( + save_fixture, + user=subscription.user, + product=subscription.product, + subscription=subscription, + ) transaction = Transaction( created_at=datetime(year, month, 1, 0, 0, 0, 0, UTC), type=TransactionType.balance, @@ -1623,7 +1506,7 @@ async def create_subscription_balances( account_amount=int(net_amount * 0.9), tax_amount=0, account=organization_account, - subscription=subscription, + sale=sale, ) await save_fixture(transaction) transactions.append(transaction) @@ -1689,7 +1572,7 @@ async def test_user_organization_member( ended_at=datetime(2023, 6, 15), ) price = product.prices[0].price_amount - balances = await create_subscription_balances( + balances = await create_sale_subscription_balances( save_fixture, gross_amount=price, start_month=1, @@ -1738,7 +1621,7 @@ async def test_organization( ended_at=datetime(2023, 6, 15), ) price = product.prices[0].price_amount - balances = await create_subscription_balances( + balances = await create_sale_subscription_balances( save_fixture, gross_amount=price, start_month=1, @@ -1803,7 +1686,7 @@ async def test_user_multiple_users_organization( ended_at=datetime(2023, 6, 15), ) price = product.prices[0].price_amount - balances = await create_subscription_balances( + balances = await create_sale_subscription_balances( save_fixture, gross_amount=price, start_month=1, @@ -1853,7 +1736,7 @@ async def test_filter_type( ended_at=datetime(2023, 6, 15), ) price = product.prices[0].price_amount - await create_subscription_balances( + await create_sale_subscription_balances( save_fixture, gross_amount=price, start_month=1, @@ -1900,7 +1783,7 @@ async def test_filter_subscription_tier_id( ended_at=datetime(2023, 6, 15), ) price_organization = product.prices[0].price_amount - balances_organization = await create_subscription_balances( + balances_organization = await create_sale_subscription_balances( save_fixture, gross_amount=price_organization, start_month=1, @@ -1916,7 +1799,7 @@ async def test_filter_subscription_tier_id( ended_at=datetime(2023, 6, 15), ) price_organization_second = product_second.prices[0].price_amount - balances_organization_second = await create_subscription_balances( + balances_organization_second = await create_sale_subscription_balances( save_fixture, gross_amount=price_organization_second, start_month=1, @@ -1967,7 +1850,7 @@ async def test_free_subscription( started_at=datetime(2023, 1, 1), ) price = product.prices[0].price_amount - balances = await create_subscription_balances( + balances = await create_sale_subscription_balances( save_fixture, gross_amount=price, start_month=1, @@ -2075,7 +1958,7 @@ async def test_yearly_subscription( user=user_second, started_at=datetime(2023, 1, 1, tzinfo=UTC), ) - balances = await create_subscription_balances( + balances = await create_sale_subscription_balances( save_fixture, gross_amount=price.price_amount, start_month=1, diff --git a/server/tests/transaction/conftest.py b/server/tests/transaction/conftest.py index eb18ce0876..f5bc542761 100644 --- a/server/tests/transaction/conftest.py +++ b/server/tests/transaction/conftest.py @@ -8,19 +8,19 @@ Organization, Pledge, Repository, - Subscription, + Sale, Transaction, User, ) from polar.models.donation import Donation from polar.models.pledge import PledgeType -from polar.models.product_price import ProductPrice from polar.models.transaction import PaymentProcessor, TransactionType from tests.fixtures.database import SaveFixture from tests.fixtures.random_objects import ( create_donation, create_pledge, create_product, + create_sale, create_subscription, ) @@ -36,8 +36,7 @@ async def create_transaction( account_currency: str = "eur", pledge: Pledge | None = None, issue_reward: IssueReward | None = None, - subscription: Subscription | None = None, - produce_price: ProductPrice | None = None, + sale: Sale | None = None, payout_transaction: Transaction | None = None, donation: Donation | None = None, ) -> Transaction: @@ -54,12 +53,7 @@ async def create_transaction( payment_organization=payment_organization, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, - product_price=produce_price - if produce_price is not None - else subscription.price - if subscription is not None - else None, + sale=sale, donation=donation, payout_transaction=payout_transaction, ) @@ -131,11 +125,14 @@ async def transaction_issue_reward( @pytest_asyncio.fixture -async def transaction_subscription( +async def transaction_sale_subscription( save_fixture: SaveFixture, organization: Organization, user: User -) -> Subscription: - subscription_tier = await create_product(save_fixture, organization=organization) - return await create_subscription(save_fixture, product=subscription_tier, user=user) +) -> Sale: + product = await create_product(save_fixture, organization=organization) + subscription = await create_subscription(save_fixture, product=product, user=user) + return await create_sale( + save_fixture, product=product, user=user, subscription=subscription + ) @pytest_asyncio.fixture @@ -177,7 +174,7 @@ async def account_transactions( account: Account, transaction_pledge: Pledge, transaction_issue_reward: IssueReward, - transaction_subscription: Subscription, + transaction_sale_subscription: Sale, transaction_donation_by_user: Donation, transaction_donation_by_organization: Donation, transaction_donation_on_behalf_of_organization: Donation, @@ -196,7 +193,7 @@ async def account_transactions( type=TransactionType.balance, account_currency="usd", account=account, - subscription=transaction_subscription, + sale=transaction_sale_subscription, ), await create_transaction( save_fixture, diff --git a/server/tests/transaction/service/test_balance.py b/server/tests/transaction/service/test_balance.py index 90d649790e..e08eb3b7c2 100644 --- a/server/tests/transaction/service/test_balance.py +++ b/server/tests/transaction/service/test_balance.py @@ -7,8 +7,7 @@ from polar.enums import AccountType from polar.integrations.stripe.service import StripeService -from polar.models import Account, IssueReward, Pledge, Subscription, Transaction, User -from polar.models.product_price import ProductPrice +from polar.models import Account, IssueReward, Pledge, Sale, Transaction, User from polar.models.transaction import PaymentProcessor, TransactionType from polar.postgres import AsyncSession from polar.transaction.service.balance import PaymentTransactionForChargeDoesNotExist @@ -33,8 +32,7 @@ async def create_payment_transaction( amount: int = 1000, charge_id: str = "STRIPE_CHARGE_ID", pledge: Pledge | None = None, - subscription: Subscription | None = None, - product_price: ProductPrice | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, ) -> Transaction: transaction = Transaction( @@ -47,12 +45,7 @@ async def create_payment_transaction( tax_amount=0, charge_id=charge_id, pledge=pledge, - subscription=subscription, - product_price=product_price - if product_price - else subscription.price - if subscription - else None, + sale=sale, issue_reward=issue_reward, ) await save_fixture(transaction) @@ -276,7 +269,7 @@ async def load_balance_transactions( joinedload(Transaction.account), joinedload(Transaction.pledge), joinedload(Transaction.issue_reward), - joinedload(Transaction.subscription), + joinedload(Transaction.sale), ) loaded_outgoing = await session.get(Transaction, outgoing.id, options=load_options) diff --git a/server/tests/transaction/service/test_payment.py b/server/tests/transaction/service/test_payment.py index 64ab93983d..e87238a47a 100644 --- a/server/tests/transaction/service/test_payment.py +++ b/server/tests/transaction/service/test_payment.py @@ -13,7 +13,6 @@ from polar.postgres import AsyncSession from polar.transaction.service.payment import ( # type: ignore[attr-defined] PledgeDoesNotExist, - SubscriptionDoesNotExist, processor_fee_transaction_service, ) from polar.transaction.service.payment import ( @@ -21,7 +20,6 @@ ) from polar.transaction.service.processor_fee import ProcessorFeeTransactionService from tests.fixtures.database import SaveFixture -from tests.fixtures.random_objects import create_product, create_subscription def build_stripe_balance_transaction( @@ -199,71 +197,6 @@ async def test_customer_organization( assert transaction.payment_user is None assert transaction.payment_organization == organization - async def test_not_existing_subscription( - self, session: AsyncSession, stripe_service_mock: MagicMock - ) -> None: - stripe_invoice = build_stripe_invoice(subscription="NOT_EXISTING_SUBSCRIPTION") - stripe_charge = build_stripe_charge(invoice=stripe_invoice.id) - - stripe_service_mock.get_invoice.return_value = stripe_invoice - - # then - session.expunge_all() - - with pytest.raises(SubscriptionDoesNotExist): - await payment_transaction_service.create_payment( - session, charge=stripe_charge - ) - - async def test_subscription( - self, - session: AsyncSession, - save_fixture: SaveFixture, - organization: Organization, - user: User, - stripe_service_mock: MagicMock, - create_payment_fees_mock: AsyncMock, - ) -> None: - subscription_tier = await create_product( - save_fixture, organization=organization - ) - subscription = await create_subscription( - save_fixture, product=subscription_tier, user=user - ) - stripe_invoice = build_stripe_invoice( - subscription=subscription.stripe_subscription_id - ) - stripe_balance_transaction = build_stripe_balance_transaction() - stripe_charge = build_stripe_charge( - invoice=stripe_invoice.id, balance_transaction=stripe_balance_transaction.id - ) - - stripe_service_mock.get_invoice.return_value = stripe_invoice - stripe_service_mock.get_balance_transaction.return_value = ( - stripe_balance_transaction - ) - - # then - session.expunge_all() - - transaction = await payment_transaction_service.create_payment( - session, charge=stripe_charge - ) - - assert transaction.type == TransactionType.payment - assert transaction.processor == PaymentProcessor.stripe - assert transaction.currency == stripe_charge.currency - assert transaction.amount == stripe_charge.amount - (stripe_invoice.tax or 0) - assert transaction.tax_amount == stripe_invoice.tax - assert transaction.tax_country == "US" - assert transaction.tax_state == "NY" - assert transaction.charge_id == stripe_charge.id - assert transaction.subscription == subscription - assert transaction.product_price == subscription.price - assert transaction.pledge is None - - create_payment_fees_mock.assert_awaited_once() - async def test_not_existing_pledge( self, session: AsyncSession, pledge: Pledge, stripe_service_mock: MagicMock ) -> None: @@ -321,7 +254,7 @@ async def test_pledge( assert transaction.amount == stripe_charge.amount assert transaction.charge_id == stripe_charge.id assert transaction.pledge == pledge - assert transaction.subscription is None + assert transaction.sale is None create_payment_fees_mock.assert_awaited_once() diff --git a/server/tests/transaction/service/test_platform_fee.py b/server/tests/transaction/service/test_platform_fee.py index e573b34d5a..1a1a741cc8 100644 --- a/server/tests/transaction/service/test_platform_fee.py +++ b/server/tests/transaction/service/test_platform_fee.py @@ -10,7 +10,7 @@ IssueReward, Organization, Pledge, - Subscription, + Sale, Transaction, User, ) @@ -33,7 +33,7 @@ async def create_balance_transactions( account: Account, pledge: Pledge | None = None, issue_reward: IssueReward | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, ) -> tuple[Transaction, Transaction]: payment_transaction = Transaction( type=TransactionType.payment, @@ -45,7 +45,7 @@ async def create_balance_transactions( tax_amount=0, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, + sale=sale, ) await save_fixture(payment_transaction) @@ -71,7 +71,7 @@ async def create_balance_transactions( tax_amount=0, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, + sale=sale, balance_correlation_key="BALANCE_1", payment_transaction=payment_transaction, ) @@ -86,7 +86,7 @@ async def create_balance_transactions( tax_amount=0, pledge=pledge, issue_reward=issue_reward, - subscription=subscription, + sale=sale, balance_correlation_key="BALANCE_1", payment_transaction=payment_transaction, ) @@ -107,7 +107,7 @@ async def load_balance_transactions( joinedload(Transaction.account), joinedload(Transaction.pledge), joinedload(Transaction.issue_reward), - joinedload(Transaction.subscription), + joinedload(Transaction.sale), ) loaded_outgoing = await session.get(Transaction, outgoing.id, options=load_options) @@ -227,12 +227,12 @@ async def test_subscription( session: AsyncSession, save_fixture: SaveFixture, account_processor_fees: Account, - transaction_subscription: Subscription, + transaction_sale_subscription: Sale, ) -> None: balance_transactions = await create_balance_transactions( save_fixture, account=account_processor_fees, - subscription=transaction_subscription, + sale=transaction_sale_subscription, ) # then diff --git a/server/tests/transaction/service/test_processor_fee.py b/server/tests/transaction/service/test_processor_fee.py index fb41c8362d..fc56eb59f1 100644 --- a/server/tests/transaction/service/test_processor_fee.py +++ b/server/tests/transaction/service/test_processor_fee.py @@ -7,7 +7,7 @@ from pytest_mock import MockerFixture from polar.integrations.stripe.service import StripeService -from polar.models import IssueReward, Pledge, Subscription, Transaction +from polar.models import IssueReward, Pledge, Sale, Transaction from polar.models.transaction import PaymentProcessor, ProcessorFeeType, TransactionType from polar.postgres import AsyncSession from polar.transaction.service.processor_fee import ( @@ -31,7 +31,7 @@ async def create_payment_transaction( amount: int = 1000, charge_id: str | None = "STRIPE_CHARGE_ID", pledge: Pledge | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, ) -> Transaction: transaction = Transaction( @@ -44,7 +44,7 @@ async def create_payment_transaction( tax_amount=0, charge_id=charge_id, pledge=pledge, - subscription=subscription, + sale=sale, issue_reward=issue_reward, ) await save_fixture(transaction) @@ -60,7 +60,7 @@ async def create_refund_transaction( charge_id: str | None = "STRIPE_CHARGE_ID", refund_id: str | None = "STRIPE_REFUND_ID", pledge: Pledge | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, ) -> Transaction: transaction = Transaction( @@ -74,7 +74,7 @@ async def create_refund_transaction( charge_id=charge_id, refund_id=refund_id, pledge=pledge, - subscription=subscription, + sale=sale, issue_reward=issue_reward, ) await save_fixture(transaction) @@ -90,7 +90,7 @@ async def create_dispute_transaction( charge_id: str | None = "STRIPE_CHARGE_ID", dispute_id: str | None = "STRIPE_DISPUTE_ID", pledge: Pledge | None = None, - subscription: Subscription | None = None, + sale: Sale | None = None, issue_reward: IssueReward | None = None, ) -> Transaction: transaction = Transaction( @@ -104,7 +104,7 @@ async def create_dispute_transaction( charge_id=charge_id, dispute_id=dispute_id, pledge=pledge, - subscription=subscription, + sale=sale, issue_reward=issue_reward, ) await save_fixture(transaction) diff --git a/server/tests/transaction/service/test_transaction.py b/server/tests/transaction/service/test_transaction.py index 51c110413a..374d97620c 100644 --- a/server/tests/transaction/service/test_transaction.py +++ b/server/tests/transaction/service/test_transaction.py @@ -61,9 +61,9 @@ async def test_no_filter( if result.pledge is not None: result.pledge.issue result.issue_reward - result.subscription - if result.subscription is not None: - result.subscription.product + result.sale + if result.sale is not None: + result.sale.product async def test_filter_type( self, @@ -279,4 +279,4 @@ async def test_valid( # Check that relationships are eagerly loaded transaction.pledge transaction.issue_reward - transaction.subscription + transaction.sale diff --git a/server/tests/transaction/test_endpoints.py b/server/tests/transaction/test_endpoints.py index 2a5eb0a6c2..a7d49164e4 100644 --- a/server/tests/transaction/test_endpoints.py +++ b/server/tests/transaction/test_endpoints.py @@ -189,9 +189,8 @@ async def test_valid( account=account, pledge=None, issue_reward=None, - subscription=None, + sale=None, donation=None, - product_price=None, account_incurred_transactions=[], ) await save_fixture(payout)