Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
36 changes: 36 additions & 0 deletions server/polar/backoffice/organizations_v2/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
7 changes: 7 additions & 0 deletions server/polar/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
6 changes: 6 additions & 0 deletions server/polar/refund/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
120 changes: 120 additions & 0 deletions server/tests/refund/test_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock

Expand Down Expand Up @@ -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
Loading