diff --git a/server/migrations/versions/2026-03-10-0001_add_refunds_blocked_until_to_organization.py b/server/migrations/versions/2026-03-10-0001_add_refunds_blocked_until_to_organization.py new file mode 100644 index 0000000000..eea2158168 --- /dev/null +++ b/server/migrations/versions/2026-03-10-0001_add_refunds_blocked_until_to_organization.py @@ -0,0 +1,33 @@ +"""Add refunds_blocked_until to Organization + +Revision ID: 8a2e1f3b5c7d +Revises: df00cf8b34e1 +Create Date: 2026-03-10 00:01:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "8a2e1f3b5c7d" +down_revision = "df00cf8b34e1" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + op.add_column( + "organizations", + sa.Column( + "refunds_blocked_until", + sa.TIMESTAMP(timezone=True), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("organizations", "refunds_blocked_until") diff --git a/server/polar/backoffice/organizations_v2/endpoints.py b/server/polar/backoffice/organizations_v2/endpoints.py index de4938452b..37cba53c9f 100644 --- a/server/polar/backoffice/organizations_v2/endpoints.py +++ b/server/polar/backoffice/organizations_v2/endpoints.py @@ -3274,4 +3274,40 @@ async def set_refunds_blocked( ) +@router.post( + "/{organization_id}/refunds-blocked-until", + name="organizations:set_refunds_blocked_until", + dependencies=[Depends(get_admin)], +) +async def set_refunds_blocked_until( + request: Request, + organization_id: UUID4, + blocked: bool, + session: AsyncSession = Depends(get_db_session), +) -> Any: + repository = OrganizationRepository.from_session(session) + organization = await repository.get_by_id(organization_id) + + if organization is None: + raise HTTPException(status_code=404) + + value = datetime.now(UTC) if blocked else None + organization = await repository.update( + organization, update_dict={"refunds_blocked_until": value} + ) + + action = "set" if blocked else "cleared" + await add_toast( + request, + f"Past order refund block has been {action} for this organization.", + "success", + ) + return HXRedirectResponse( + request, + str(request.url_for("organizations:detail", organization_id=organization_id)) + + "?section=settings", + 303, + ) + + __all__ = ["router"] diff --git a/server/polar/backoffice/organizations_v2/views/sections/settings_section.py b/server/polar/backoffice/organizations_v2/views/sections/settings_section.py index eab557f593..8f6fb11b7c 100644 --- a/server/polar/backoffice/organizations_v2/views/sections/settings_section.py +++ b/server/polar/backoffice/organizations_v2/views/sections/settings_section.py @@ -347,6 +347,42 @@ def render(self, request: Request) -> Generator[None]: else: text("Block Refunds") + # Block/Clear Past Order Refunds + with tag.div(classes="flex items-center justify-between"): + with tag.div(): + with tag.div(classes="font-semibold text-sm"): + text("Block Past Order Refunds") + with tag.div(classes="text-xs text-base-content/60"): + if self.org.refunds_blocked_until is not None: + text( + f"Refunds blocked for orders created before {self.org.refunds_blocked_until.strftime('%Y-%m-%d %H:%M UTC')}" + ) + else: + text( + "Prevent refunds for all orders created before this moment" + ) + + with tag.form( + method="POST", + action=str( + request.url_for( + "organizations:set_refunds_blocked_until", + organization_id=self.org.id, + ) + ) + + f"?blocked={'false' if self.org.refunds_blocked_until is not None else 'true'}", + ): + with button( + type="submit", + variant="error", + size="sm", + outline=True, + ): + if self.org.refunds_blocked_until is not None: + text("Clear Past Refund Block") + else: + text("Block Past Refunds") + # Delete Organization with tag.div(classes="flex items-center justify-between"): with tag.div(): diff --git a/server/polar/models/organization.py b/server/polar/models/organization.py index 387fec68d8..fc99828e60 100644 --- a/server/polar/models/organization.py +++ b/server/polar/models/organization.py @@ -268,6 +268,13 @@ def account(cls) -> Mapped[Account | None]: default=False, ) + # Timestamp cutoff: block refunds for orders created at or before this time + refunds_blocked_until: Mapped[datetime | None] = mapped_column( + TIMESTAMP(timezone=True), + nullable=True, + default=None, + ) + profile_settings: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, default=dict ) diff --git a/server/polar/refund/service.py b/server/polar/refund/service.py index 53390ce596..7889f37442 100644 --- a/server/polar/refund/service.py +++ b/server/polar/refund/service.py @@ -210,6 +210,12 @@ async def create( if order.refunds_blocked or order.organization.refunds_blocked: raise RefundsBlocked(order) + if ( + order.organization.refunds_blocked_until is not None + and order.created_at <= order.organization.refunds_blocked_until + ): + raise RefundsBlocked(order) + if order.refunded: raise RefundedAlready(order) diff --git a/server/tests/refund/test_service.py b/server/tests/refund/test_service.py index a1b81f8b51..2534e9bdcb 100644 --- a/server/tests/refund/test_service.py +++ b/server/tests/refund/test_service.py @@ -1,3 +1,4 @@ +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock @@ -967,3 +968,122 @@ async def test_create_refund_allowed_when_organization_not_blocked( # Verify refund was created assert refund is not None assert refund.order_id == order.id + + +@pytest.mark.asyncio +class TestOrganizationRefundsBlockedUntil: + async def test_refund_blocked_for_order_before_cutoff( + self, + session: AsyncSession, + save_fixture: SaveFixture, + organization: Organization, + product: Product, + customer: Customer, + ) -> None: + """Test that refunds are blocked for orders created before the cutoff.""" + from polar.organization.repository import OrganizationRepository + + cutoff = datetime.now(UTC) + org_repository = OrganizationRepository.from_session(session) + organization = await org_repository.update( + organization, update_dict={"refunds_blocked_until": cutoff} + ) + + # Create an order with created_at before the cutoff + order = await create_order( + save_fixture, + product=product, + customer=customer, + status=OrderStatus.paid, + created_at=cutoff - timedelta(hours=1), + ) + + create_schema = RefundCreate( + order_id=order.id, + amount=100, + reason=RefundReason.customer_request, + ) + + from polar.refund.service import RefundsBlocked + + with pytest.raises(RefundsBlocked) as exc_info: + await refund_service.create(session, order, create_schema) + + assert exc_info.value.order.id == order.id + + async def test_refund_allowed_for_order_after_cutoff( + self, + session: AsyncSession, + save_fixture: SaveFixture, + stripe_service_mock: MagicMock, + organization: Organization, + product: Product, + customer: Customer, + ) -> None: + """Test that refunds are allowed for orders created after the cutoff.""" + from polar.organization.repository import OrganizationRepository + + cutoff = datetime.now(UTC) - timedelta(hours=2) + org_repository = OrganizationRepository.from_session(session) + organization = await org_repository.update( + organization, update_dict={"refunds_blocked_until": cutoff} + ) + + # Create an order with payment (created_at defaults to now, which is after cutoff) + order, payment, _transaction = await create_order_and_payment( + save_fixture, + product=product, + customer=customer, + subtotal_amount=100, + tax_amount=0, + ) + + stripe_service_mock.create_refund.return_value = build_stripe_refund( + amount=100, + charge_id=payment.processor_id, + ) + + create_schema = RefundCreate( + order_id=order.id, + amount=100, + reason=RefundReason.customer_request, + ) + + refund = await refund_service.create(session, order, create_schema) + + assert refund is not None + assert refund.order_id == order.id + + async def test_refund_not_blocked_when_cutoff_is_none( + self, + session: AsyncSession, + save_fixture: SaveFixture, + stripe_service_mock: MagicMock, + product: Product, + customer: Customer, + ) -> None: + """Test that refunds are not blocked when refunds_blocked_until is None.""" + # Organization has refunds_blocked_until=None by default + order, payment, _transaction = await create_order_and_payment( + save_fixture, + product=product, + customer=customer, + subtotal_amount=100, + tax_amount=0, + ) + + stripe_service_mock.create_refund.return_value = build_stripe_refund( + amount=100, + charge_id=payment.processor_id, + ) + + create_schema = RefundCreate( + order_id=order.id, + amount=100, + reason=RefundReason.customer_request, + ) + + refund = await refund_service.create(session, order, create_schema) + + assert refund is not None + assert refund.order_id == order.id