Coverage for polar/refund/service.py: 19%
286 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
1from collections.abc import Sequence 1a
2from typing import Any, Literal, TypeAlias 1a
3from uuid import UUID 1a
5import stripe as stripe_lib 1a
6import structlog 1a
7from sqlalchemy import Select, UnaryExpression, asc, desc, select 1a
8from sqlalchemy.dialects import postgresql 1a
10from polar.auth.models import AuthSubject, is_organization, is_user 1a
11from polar.benefit.grant.service import benefit_grant as benefit_grant_service 1a
12from polar.customer.repository import CustomerRepository 1a
13from polar.event.service import event as event_service 1a
14from polar.event.system import OrderRefundedMetadata, SystemEvent, build_system_event 1a
15from polar.exceptions import PolarError, PolarRequestValidationError, ResourceNotFound 1a
16from polar.integrations.stripe.service import stripe as stripe_service 1a
17from polar.kit.db.postgres import AsyncSession 1a
18from polar.kit.pagination import PaginationParams, paginate 1a
19from polar.kit.services import ResourceServiceReader 1a
20from polar.kit.sorting import Sorting 1a
21from polar.kit.utils import utc_now 1a
22from polar.logging import Logger 1a
23from polar.models import ( 1a
24 Order,
25 Organization,
26 Pledge,
27 Transaction,
28 User,
29 UserOrganization,
30)
31from polar.models.refund import Refund, RefundReason, RefundStatus 1a
32from polar.models.webhook_endpoint import WebhookEventType 1a
33from polar.order.repository import OrderRepository 1a
34from polar.order.service import order as order_service 1a
35from polar.organization.repository import OrganizationRepository 1a
36from polar.pledge.service import pledge as pledge_service 1a
37from polar.transaction.service.payment import ( 1a
38 payment_transaction as payment_transaction_service,
39)
40from polar.transaction.service.refund import ( 1a
41 RefundTransactionAlreadyExistsError,
42 RefundTransactionDoesNotExistError,
43)
44from polar.transaction.service.refund import ( 1a
45 refund_transaction as refund_transaction_service,
46)
47from polar.wallet.service import wallet as wallet_service 1a
48from polar.webhook.service import webhook as webhook_service 1a
50from .schemas import InternalRefundCreate, RefundCreate 1a
51from .sorting import RefundSortProperty 1a
53log: Logger = structlog.get_logger() 1a
55ChargeID: TypeAlias = str 1a
56RefundTransaction: TypeAlias = Transaction 1a
57RefundedResources: TypeAlias = tuple[ 1a
58 ChargeID, RefundTransaction, Order | None, Pledge | None
59]
60Created: TypeAlias = bool 1a
61RefundAmount: TypeAlias = int 1a
62RefundTaxAmount: TypeAlias = int 1a
63FullRefund: TypeAlias = bool 1a
66class RefundError(PolarError): ... 1a
69class RefundUnknownPayment(ResourceNotFound): 1a
70 def __init__( 1a
71 self, id: str | UUID, payment_type: Literal["charge", "order", "pledge"]
72 ) -> None:
73 self.id = id
74 message = f"Refund issued for unknown {payment_type}: {id}"
75 super().__init__(message, 404)
78class RefundedAlready(RefundError): 1a
79 def __init__(self, order: Order) -> None: 1a
80 self.order = order
81 message = f"Order is already fully refunded: {order.id}"
82 super().__init__(message, 403)
85class RefundAmountTooHigh(RefundError): 1a
86 def __init__(self, order: Order) -> None: 1a
87 self.order = order
88 message = (
89 f"Refund amount exceeds remaining order balance: {order.refundable_amount}"
90 )
91 super().__init__(message, 400)
94class RevokeSubscriptionBenefitsProhibited(RefundError): 1a
95 def __init__(self) -> None: 1a
96 message = "Subscription benefits can only be revoked upon cancellation"
97 super().__init__(message, 400)
100class RefundService(ResourceServiceReader[Refund]): 1a
101 async def get_list( 1a
102 self,
103 session: AsyncSession,
104 auth_subject: AuthSubject[User | Organization],
105 *,
106 id: Sequence[UUID] | None = None,
107 organization_id: Sequence[UUID] | None = None,
108 order_id: Sequence[UUID] | None = None,
109 subscription_id: Sequence[UUID] | None = None,
110 customer_id: Sequence[UUID] | None = None,
111 succeeded: bool | None = None,
112 pagination: PaginationParams,
113 sorting: list[Sorting[RefundSortProperty]] = [
114 (RefundSortProperty.created_at, True)
115 ],
116 ) -> tuple[Sequence[Refund], int]:
117 statement = self._get_readable_refund_statement(auth_subject)
118 if id is not None:
119 statement = statement.where(Refund.id.in_(id))
121 if organization_id is not None:
122 statement = statement.where(Refund.organization_id.in_(organization_id))
124 if order_id is not None:
125 statement = statement.where(Refund.order_id.in_(order_id))
127 if subscription_id is not None:
128 statement = statement.where(Refund.subscription_id.in_(subscription_id))
130 if customer_id is not None:
131 statement = statement.where(Refund.customer_id.in_(customer_id))
133 if succeeded:
134 statement = statement.where(Refund.status == RefundStatus.succeeded)
136 order_by_clauses: list[UnaryExpression[Any]] = []
137 for criterion, is_desc in sorting:
138 clause_function = desc if is_desc else asc
139 if criterion == RefundSortProperty.created_at:
140 order_by_clauses.append(clause_function(Refund.created_at))
141 elif criterion == RefundSortProperty.amount:
142 order_by_clauses.append(clause_function(Refund.amount))
144 statement = statement.order_by(*order_by_clauses)
145 return await paginate(session, statement, pagination=pagination)
147 async def user_create( 1a
148 self,
149 session: AsyncSession,
150 auth_subject: AuthSubject[User | Organization],
151 create_schema: RefundCreate,
152 ) -> Refund:
153 order_repository = OrderRepository.from_session(session)
154 order = await order_repository.get_one_or_none(
155 order_repository.get_readable_statement(auth_subject)
156 .where(Order.id == create_schema.order_id)
157 .options(*order_repository.get_eager_options())
158 )
159 if not order:
160 raise PolarRequestValidationError(
161 [
162 {
163 "type": "value_error",
164 "loc": ("body", "order_id"),
165 "msg": "Order not found",
166 "input": create_schema.order_id,
167 }
168 ]
169 )
171 return await self.create(session, order, create_schema=create_schema)
173 async def create( 1a
174 self,
175 session: AsyncSession,
176 order: Order,
177 create_schema: RefundCreate,
178 ) -> Refund:
179 if order.refunded:
180 raise RefundedAlready(order)
182 is_subscription = order.subscription_id is not None
183 if create_schema.revoke_benefits and is_subscription:
184 raise RevokeSubscriptionBenefitsProhibited()
186 refund_amount = create_schema.amount
187 refund_tax_amount = self.calculate_tax(order, create_schema.amount)
188 payment = await payment_transaction_service.get_by_order_id(session, order.id)
189 if not (payment and payment.charge_id):
190 raise RefundUnknownPayment(order.id, payment_type="order")
192 refund_total = refund_amount + refund_tax_amount
193 stripe_metadata = dict(
194 order_id=str(order.id),
195 charge_id=str(payment.charge_id),
196 amount=str(create_schema.amount),
197 refund_amount=str(refund_amount),
198 refund_tax_amount=str(refund_tax_amount),
199 revoke_benefits="1" if create_schema.revoke_benefits else "0",
200 )
202 try:
203 stripe_refund = await stripe_service.create_refund(
204 charge_id=payment.charge_id,
205 amount=refund_total,
206 reason=RefundReason.to_stripe(create_schema.reason),
207 metadata=stripe_metadata,
208 )
209 except stripe_lib.InvalidRequestError as e:
210 if e.code == "charge_already_refunded":
211 log.warning("refund.attempted_already_refunded", order_id=order.id)
212 raise RefundedAlready(order)
213 else:
214 raise e
216 internal_create_schema = self.build_create_schema_from_stripe(
217 stripe_refund,
218 order=order,
219 )
220 internal_create_schema.reason = create_schema.reason
221 internal_create_schema.comment = create_schema.comment
222 internal_create_schema.revoke_benefits = create_schema.revoke_benefits
223 internal_create_schema.metadata = create_schema.metadata
224 refund = await self._create(
225 session,
226 internal_create_schema,
227 charge_id=payment.charge_id,
228 payment=payment,
229 order=order,
230 )
232 if refund.revoke_benefits:
233 await self.enqueue_benefits_revokation(session, order)
235 return refund
237 async def upsert_from_stripe( 1a
238 self, session: AsyncSession, stripe_refund: stripe_lib.Refund
239 ) -> Refund:
240 refund = await self.get_by(session, processor_id=stripe_refund.id)
241 if refund:
242 return await self.update_from_stripe(session, refund, stripe_refund)
243 return await self.create_from_stripe(session, stripe_refund)
245 async def create_from_stripe( 1a
246 self,
247 session: AsyncSession,
248 stripe_refund: stripe_lib.Refund,
249 ) -> Refund:
250 resources = await self._get_resources(session, stripe_refund)
251 charge_id, payment, order, pledge = resources
253 internal_create_schema = self.build_create_schema_from_stripe(
254 stripe_refund,
255 order=order,
256 pledge=pledge,
257 )
258 return await self._create(
259 session,
260 internal_create_schema,
261 charge_id=charge_id,
262 payment=payment,
263 order=order,
264 pledge=pledge,
265 )
267 async def update_from_stripe( 1a
268 self,
269 session: AsyncSession,
270 refund: Refund,
271 stripe_refund: stripe_lib.Refund,
272 ) -> Refund:
273 resources = await self._get_resources(session, stripe_refund)
274 charge_id, payment, order, pledge = resources
275 updated = self.build_create_schema_from_stripe(
276 stripe_refund,
277 order=order,
278 pledge=pledge,
279 )
281 had_succeeded = refund.succeeded
283 # Reference: https://docs.stripe.com/refunds#see-also
284 # Only `metadata` and `destination_details` should update according to
285 # docs, but a pending refund can surely become `succeeded`, `canceled` or `failed`
286 refund.status = updated.status
287 refund.failure_reason = updated.failure_reason
288 refund.destination_details = updated.destination_details
289 refund.processor_receipt_number = updated.processor_receipt_number
290 session.add(refund)
292 transitioned_to_succeeded = refund.succeeded and not had_succeeded
294 if transitioned_to_succeeded:
295 refund_transaction = await self._create_refund_transaction(
296 session,
297 charge_id=charge_id,
298 refund=refund,
299 payment=payment,
300 order=order,
301 pledge=pledge,
302 )
303 # Double check transition by ensuring ledger entry was made
304 transitioned_to_succeeded = refund_transaction is not None
306 reverted = had_succeeded and refund.status in {
307 RefundStatus.canceled,
308 RefundStatus.failed,
309 }
310 if reverted:
311 await self._revert_refund_transaction(
312 session,
313 charge_id=charge_id,
314 refund=refund,
315 payment=payment,
316 order=order,
317 pledge=pledge,
318 )
320 await session.flush()
321 log.info(
322 "refund.updated",
323 id=refund.id,
324 amount=refund.amount,
325 tax_amount=refund.tax_amount,
326 order_id=refund.order_id,
327 reason=refund.reason,
328 processor=refund.processor,
329 processor_id=refund.processor_id,
330 )
331 if order is None:
332 return refund
334 organization_repository = OrganizationRepository.from_session(session)
335 organization = await organization_repository.get_by_id(order.organization.id)
336 if organization is None:
337 return refund
339 await self._on_updated(session, organization, refund)
340 if transitioned_to_succeeded:
341 await self._on_succeeded(session, organization, order)
342 return refund
344 async def enqueue_benefits_revokation( 1a
345 self, session: AsyncSession, order: Order
346 ) -> None:
347 customer_repository = CustomerRepository.from_session(session)
348 customer = await customer_repository.get_by_id(
349 order.customer_id, include_deleted=True
350 )
351 if customer is None:
352 return
354 if not order.product:
355 return
357 await benefit_grant_service.enqueue_benefits_grants(
358 session,
359 task="revoke",
360 customer=customer,
361 product=order.product,
362 order=order,
363 )
365 def calculate_tax( 1a
366 self,
367 order: Order,
368 refund_amount: int,
369 ) -> int:
370 if refund_amount > order.refundable_amount:
371 raise RefundAmountTooHigh(order)
373 # Trigger full refund of remaining balance
374 if refund_amount == order.refundable_amount:
375 return order.refundable_tax_amount
377 ratio = order.tax_amount / order.net_amount
378 tax_amount = round(refund_amount * ratio)
379 return tax_amount
381 def calculate_tax_from_stripe( 1a
382 self,
383 order: Order,
384 stripe_amount: int,
385 ) -> tuple[RefundAmount, RefundTaxAmount]:
386 if stripe_amount == order.remaining_balance:
387 return order.refundable_amount, order.refundable_tax_amount
389 if not order.taxed:
390 return stripe_amount, 0
392 # Reverse engineer taxes from Stripe amount (always inclusive)
393 refunded_tax_amount = abs(
394 round((order.tax_amount * stripe_amount) / order.total_amount)
395 )
396 refunded_amount = stripe_amount - refunded_tax_amount
397 return refunded_amount, refunded_tax_amount
399 def build_create_schema_from_stripe( 1a
400 self,
401 stripe_refund: stripe_lib.Refund,
402 *,
403 order: Order | None = None,
404 pledge: Pledge | None = None,
405 ) -> InternalRefundCreate:
406 order_id = None
407 subscription_id = None
408 customer_id = None
409 organization_id = None
410 refunded_amount = stripe_refund.amount
411 refunded_tax_amount = 0 # Default since pledges don't have VAT
412 pledge_id = pledge.id if pledge else None
414 if order:
415 order_id = order.id
416 subscription_id = order.subscription_id
417 customer_id = order.customer_id
418 organization_id = order.organization.id
419 refunded_amount, refunded_tax_amount = self.calculate_tax_from_stripe(
420 order,
421 stripe_amount=stripe_refund.amount,
422 )
424 schema = InternalRefundCreate.from_stripe(
425 stripe_refund,
426 refunded_amount=refunded_amount,
427 refunded_tax_amount=refunded_tax_amount,
428 order_id=order_id,
429 subscription_id=subscription_id,
430 customer_id=customer_id,
431 organization_id=organization_id,
432 pledge_id=pledge_id,
433 )
434 return schema
436 def build_instance_from_stripe( 1a
437 self,
438 stripe_refund: stripe_lib.Refund,
439 *,
440 order: Order | None = None,
441 pledge: Pledge | None = None,
442 ) -> Refund:
443 internal_create_schema = self.build_create_schema_from_stripe(
444 stripe_refund,
445 order=order,
446 pledge=pledge,
447 )
448 instance = Refund(**internal_create_schema.model_dump())
449 return instance
451 ###############################################################################
452 # INTERNALS
453 ###############################################################################
455 async def _create( 1a
456 self,
457 session: AsyncSession,
458 internal_create_schema: InternalRefundCreate,
459 *,
460 charge_id: str,
461 payment: Transaction,
462 order: Order | None = None,
463 pledge: Pledge | None = None,
464 ) -> Refund:
465 # Upsert to handle race condition from Stripe `refund.created`.
466 # Could be fired standalone from manual support operations in Stripe dashboard.
467 statement = (
468 postgresql.insert(Refund)
469 .values(**internal_create_schema.model_dump(by_alias=True))
470 .on_conflict_do_update(
471 index_elements=[Refund.processor_id],
472 # Only update `modified_at` as race conditions from API &
473 # webhook creation should only contain the same data.
474 set_=dict(
475 modified_at=utc_now(),
476 ),
477 )
478 .returning(Refund)
479 .execution_options(populate_existing=True)
480 )
481 res = await session.execute(statement)
482 instance = res.scalars().one()
483 # Avoid processing creation twice, i.e updated vs. inserted
484 if instance.modified_at:
485 return instance
487 if instance.succeeded:
488 await self._create_refund_transaction(
489 session,
490 charge_id=charge_id,
491 refund=instance,
492 payment=payment,
493 order=order,
494 pledge=pledge,
495 )
497 await session.flush()
498 log.info(
499 "refund.create",
500 id=instance.id,
501 amount=instance.amount,
502 tax_amount=instance.tax_amount,
503 order_id=instance.order_id,
504 pledge_id=instance.pledge_id,
505 reason=instance.reason,
506 processor=instance.processor,
507 processor_id=instance.processor_id,
508 )
509 if order is None:
510 return instance
512 organization = order.organization
514 await self._on_created(session, organization, instance)
515 if instance.succeeded:
516 await self._on_succeeded(session, organization, order)
517 return instance
519 async def _create_refund_transaction( 1a
520 self,
521 session: AsyncSession,
522 *,
523 charge_id: str,
524 refund: Refund,
525 payment: Transaction,
526 order: Order | None = None,
527 pledge: Pledge | None = None,
528 ) -> Transaction | None:
529 try:
530 transaction = await refund_transaction_service.create(
531 session,
532 charge_id=charge_id,
533 payment_transaction=payment,
534 refund=refund,
535 )
536 except RefundTransactionAlreadyExistsError:
537 return None
539 if order:
540 await order_service.update_refunds(
541 session,
542 order,
543 refunded_amount=refund.amount,
544 refunded_tax_amount=refund.tax_amount,
545 )
547 # Send order.updated webhook
548 await order_service.send_webhook(
549 session, order, WebhookEventType.order_updated
550 )
552 # Revert the tax transaction in the tax processor ledger
553 if order.tax_transaction_processor_id and order.tax_amount > 0:
554 if refund.total_amount == order.total_amount:
555 tax_transaction_processor = (
556 await stripe_service.revert_tax_transaction(
557 order.tax_transaction_processor_id,
558 mode="full",
559 reference=str(refund.id),
560 )
561 )
562 else:
563 tax_transaction_processor = (
564 await stripe_service.revert_tax_transaction(
565 order.tax_transaction_processor_id,
566 mode="partial",
567 reference=str(refund.id),
568 amount=-refund.total_amount,
569 )
570 )
571 refund.tax_transaction_processor_id = tax_transaction_processor.id
572 session.add(refund)
574 elif pledge and pledge.payment_id and payment.charge_id:
575 await pledge_service.refund_by_payment_id(
576 session=session,
577 payment_id=pledge.payment_id,
578 amount=refund.amount,
579 transaction_id=payment.charge_id,
580 )
582 return transaction
584 async def _revert_refund_transaction( 1a
585 self,
586 session: AsyncSession,
587 *,
588 charge_id: str,
589 refund: Refund,
590 payment: Transaction,
591 order: Order | None = None,
592 pledge: Pledge | None = None,
593 ) -> None:
594 try:
595 transaction = await refund_transaction_service.revert(
596 session,
597 charge_id=charge_id,
598 payment_transaction=payment,
599 refund=refund,
600 )
601 except RefundTransactionDoesNotExistError:
602 return None
604 if order:
605 await order_service.update_refunds(
606 session,
607 order,
608 refunded_amount=-refund.amount,
609 refunded_tax_amount=-refund.tax_amount,
610 )
612 # Send order.updated webhook
613 await order_service.send_webhook(
614 session, order, WebhookEventType.order_updated
615 )
617 async def _on_created( 1a
618 self,
619 session: AsyncSession,
620 organization: Organization,
621 refund: Refund,
622 ) -> None:
623 await webhook_service.send(
624 session, organization, WebhookEventType.refund_created, refund
625 )
627 async def _on_succeeded( 1a
628 self,
629 session: AsyncSession,
630 organization: Organization,
631 order: Order,
632 ) -> None:
633 # Reduce positive customer balance
634 customer_balance = await wallet_service.get_billing_wallet_balance(
635 session, order.customer, order.currency
636 )
637 if customer_balance > 0:
638 reduction_amount = min(
639 customer_balance, order.refunded_amount + order.refunded_tax_amount
640 )
641 await wallet_service.create_balance_transaction(
642 session, order.customer, -reduction_amount, order.currency, order=order
643 )
645 await event_service.create_event(
646 session,
647 build_system_event(
648 SystemEvent.order_refunded,
649 customer=order.customer,
650 organization=order.organization,
651 metadata=OrderRefundedMetadata(
652 order_id=str(order.id),
653 refunded_amount=order.refunded_amount,
654 currency=order.currency,
655 ),
656 ),
657 )
659 # Send order.refunded
660 await webhook_service.send(
661 session, organization, WebhookEventType.order_refunded, order
662 )
664 async def _on_updated( 1a
665 self,
666 session: AsyncSession,
667 organization: Organization,
668 refund: Refund,
669 ) -> None:
670 await webhook_service.send(
671 session, organization, WebhookEventType.refund_updated, refund
672 )
674 async def _get_resources( 1a
675 self,
676 session: AsyncSession,
677 refund: stripe_lib.Refund,
678 ) -> RefundedResources:
679 if not refund.charge:
680 raise RefundUnknownPayment(refund.id, payment_type="charge")
682 charge_id = str(refund.charge)
683 payment_intent = str(refund.payment_intent) if refund.payment_intent else None
684 payment = await payment_transaction_service.get_by_charge_id(session, charge_id)
685 if payment is None:
686 raise RefundUnknownPayment(charge_id, payment_type="charge")
688 if payment.order_id:
689 order_repository = OrderRepository.from_session(session)
690 order = await order_repository.get_by_id(
691 payment.order_id, options=order_repository.get_eager_options()
692 )
693 if not order:
694 raise RefundUnknownPayment(payment.order_id, payment_type="order")
696 return (charge_id, payment, order, None)
698 if not (payment.pledge_id and payment_intent):
699 raise RefundUnknownPayment(payment.id, payment_type="charge")
701 pledge = await pledge_service.get_by_payment_id(
702 session,
703 payment_id=payment_intent,
704 )
705 if pledge is None:
706 raise RefundUnknownPayment(payment.pledge_id, payment_type="pledge")
708 return (charge_id, payment, None, pledge)
710 def _get_readable_refund_statement( 1a
711 self, auth_subject: AuthSubject[User | Organization]
712 ) -> Select[tuple[Refund]]:
713 statement = select(Refund).where(
714 Refund.deleted_at.is_(None),
715 # We only care about order refunds in our API
716 Refund.order_id.is_not(None),
717 )
719 if is_user(auth_subject):
720 user = auth_subject.subject
721 statement = statement.where(
722 Refund.organization_id.in_(
723 select(UserOrganization.organization_id).where(
724 UserOrganization.user_id == user.id,
725 UserOrganization.deleted_at.is_(None),
726 )
727 )
728 )
729 elif is_organization(auth_subject):
730 statement = statement.where(
731 Refund.organization_id == auth_subject.subject.id
732 )
734 return statement
737refund = RefundService(Refund) 1a