Coverage for polar/integrations/plain/service.py: 16%
231 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1# pyright: reportCallIssue=false
2import asyncio
3import contextlib
4import uuid
5from collections.abc import AsyncIterator, Coroutine
6from typing import Any
8import httpx
9import pycountry
10import pycountry.db
11import structlog
12from babel.numbers import format_currency
13from plain_client import (
14 ComponentContainerContentInput,
15 ComponentContainerInput,
16 ComponentCopyButtonInput,
17 ComponentDividerInput,
18 ComponentDividerSpacingSize,
19 ComponentInput,
20 ComponentLinkButtonInput,
21 ComponentRowContentInput,
22 ComponentRowInput,
23 ComponentSpacerInput,
24 ComponentSpacerSize,
25 ComponentTextColor,
26 ComponentTextInput,
27 ComponentTextSize,
28 CreateThreadInput,
29 CustomerIdentifierInput,
30 EmailAddressInput,
31 Plain,
32 ThreadsFilter,
33 UpsertCustomerIdentifierInput,
34 UpsertCustomerInput,
35 UpsertCustomerOnCreateInput,
36 UpsertCustomerOnUpdateInput,
37 UpsertCustomerUpsertCustomer,
38)
39from sqlalchemy import func, select
40from sqlalchemy.orm import contains_eager
42from polar.config import settings
43from polar.exceptions import PolarError
44from polar.models import (
45 Customer,
46 Order,
47 Organization,
48 Product,
49 User,
50 UserOrganization,
51)
52from polar.models.organization import OrganizationStatus
53from polar.models.organization_review import OrganizationReview
54from polar.postgres import AsyncSession
55from polar.user.repository import UserRepository
57from .schemas import (
58 CustomerCard,
59 CustomerCardKey,
60 CustomerCardsRequest,
61 CustomerCardsResponse,
62)
64log = structlog.get_logger(__name__)
67class PlainServiceError(PolarError): ... 67 ↛ 70line 67 didn't jump to line 70 because
70class AccountAdminDoesNotExistError(PlainServiceError):
71 def __init__(self, account_id: uuid.UUID) -> None:
72 self.account_id = account_id
73 super().__init__(f"Account admin does not exist for account ID {account_id}")
76class AccountReviewThreadCreationError(PlainServiceError):
77 def __init__(self, account_id: uuid.UUID, message: str) -> None:
78 self.account_id = account_id
79 self.message = message
80 super().__init__(
81 f"Error creating thread for account ID {account_id}: {message}"
82 )
85class NoUserFoundError(PlainServiceError):
86 def __init__(self, organization_id: uuid.UUID) -> None:
87 self.organization_id = organization_id
88 super().__init__(f"No user found for organization {organization_id}")
91_card_getter_semaphore = asyncio.Semaphore(3)
94async def _card_getter_task(
95 coroutine: Coroutine[Any, Any, CustomerCard | None],
96) -> CustomerCard | None:
97 async with _card_getter_semaphore:
98 return await coroutine
101class PlainService:
102 enabled = settings.PLAIN_TOKEN is not None
104 async def get_cards(
105 self, session: AsyncSession, request: CustomerCardsRequest
106 ) -> CustomerCardsResponse:
107 tasks: list[asyncio.Task[CustomerCard | None]] = []
108 async with asyncio.TaskGroup() as tg:
109 if CustomerCardKey.user in request.cardKeys:
110 tasks.append(
111 tg.create_task(
112 _card_getter_task(self._get_user_card(session, request))
113 )
114 )
115 if CustomerCardKey.organization in request.cardKeys:
116 tasks.append(
117 tg.create_task(
118 _card_getter_task(self._get_organization_card(session, request))
119 )
120 )
121 if CustomerCardKey.customer in request.cardKeys:
122 tasks.append(
123 tg.create_task(
124 _card_getter_task(self.get_customer_card(session, request))
125 )
126 )
127 if CustomerCardKey.order in request.cardKeys:
128 tasks.append(
129 tg.create_task(
130 _card_getter_task(self._get_order_card(session, request))
131 )
132 )
133 if CustomerCardKey.snippets in request.cardKeys:
134 tasks.append(
135 tg.create_task(
136 _card_getter_task(self._get_snippets_card(session, request))
137 )
138 )
140 cards = [card for task in tasks if (card := task.result()) is not None]
141 return CustomerCardsResponse(cards=cards)
143 async def create_organization_review_thread(
144 self, session: AsyncSession, organization: Organization
145 ) -> None:
146 if not self.enabled:
147 return
149 user_repository = UserRepository.from_session(session)
150 if organization.account is None:
151 from polar.organization.tasks import OrganizationAccountNotSet
153 raise OrganizationAccountNotSet(organization.id)
154 admin = await user_repository.get_by_id(organization.account.admin_id)
155 if admin is None:
156 raise AccountAdminDoesNotExistError(organization.account.admin_id)
158 async with self._get_plain_client() as plain:
159 customer_result = await plain.upsert_customer(
160 UpsertCustomerInput(
161 identifier=UpsertCustomerIdentifierInput(email_address=admin.email),
162 on_create=UpsertCustomerOnCreateInput(
163 external_id=str(admin.id),
164 full_name=admin.email,
165 email=EmailAddressInput(
166 email=admin.email, is_verified=admin.email_verified
167 ),
168 ),
169 on_update=UpsertCustomerOnUpdateInput(
170 email=EmailAddressInput(
171 email=admin.email, is_verified=admin.email_verified
172 ),
173 ),
174 )
175 )
176 if customer_result.error is not None:
177 raise AccountReviewThreadCreationError(
178 organization.account.id, customer_result.error.message
179 )
181 match organization.status:
182 case OrganizationStatus.INITIAL_REVIEW:
183 title = "Initial Account Review"
184 case OrganizationStatus.ONGOING_REVIEW:
185 title = "Ongoing Account Review"
186 case _:
187 raise ValueError("Organization is not under review")
189 thread_result = await plain.create_thread(
190 CreateThreadInput(
191 customer_identifier=self._get_customer_identifier(
192 customer_result, admin.email
193 ),
194 title=title,
195 label_type_ids=["lt_01JFG7F4N67FN3MAWK06FJ8FPG"],
196 components=[
197 ComponentInput(
198 component_text=ComponentTextInput(
199 text=f"The organization `{organization.slug}` should be reviewed, as it hit a threshold."
200 )
201 ),
202 ComponentInput(
203 component_spacer=ComponentSpacerInput(
204 spacer_size=ComponentSpacerSize.M
205 )
206 ),
207 ComponentInput(
208 component_link_button=ComponentLinkButtonInput(
209 link_button_url=settings.generate_backoffice_url(
210 f"/organizations/{organization.id}"
211 ),
212 link_button_label="Review organization ↗",
213 )
214 ),
215 ],
216 )
217 )
218 if thread_result.error is not None:
219 raise AccountReviewThreadCreationError(
220 organization.account.id, thread_result.error.message
221 )
223 async def create_appeal_review_thread(
224 self,
225 session: AsyncSession,
226 organization: Organization,
227 review: OrganizationReview,
228 ) -> None:
229 """Create Plain ticket for organization appeal review."""
230 if not self.enabled: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true
231 return
233 user_repository = UserRepository.from_session(session)
234 users = await user_repository.get_all_by_organization(organization.id)
235 if len(users) == 0:
236 raise NoUserFoundError(organization.id)
237 user = users[0]
239 # Create Plain ticket for appeal review
240 async with self._get_plain_client() as plain:
241 customer_result = await plain.upsert_customer(
242 UpsertCustomerInput(
243 identifier=UpsertCustomerIdentifierInput(email_address=user.email),
244 on_create=UpsertCustomerOnCreateInput(
245 external_id=str(user.id),
246 full_name=user.email,
247 email=EmailAddressInput(
248 email=user.email, is_verified=user.email_verified
249 ),
250 ),
251 on_update=UpsertCustomerOnUpdateInput(
252 email=EmailAddressInput(
253 email=user.email, is_verified=user.email_verified
254 ),
255 ),
256 )
257 )
259 if customer_result.error is not None:
260 raise AccountReviewThreadCreationError(
261 user.id, customer_result.error.message
262 )
264 # Create the thread with detailed appeal information
265 thread_result = await plain.create_thread(
266 CreateThreadInput(
267 customer_identifier=self._get_customer_identifier(
268 customer_result, user.email
269 ),
270 title=f"Organization Appeal - {organization.slug}",
271 label_type_ids=["lt_01K3QWYTDV7RSS7MM2RC584X41"],
272 components=[
273 ComponentInput(
274 component_text=ComponentTextInput(
275 text=f"The organization `{organization.slug}` has submitted an appeal for review after AI validation {review.verdict}."
276 )
277 ),
278 ComponentInput(
279 component_container=ComponentContainerInput(
280 container_content=[
281 ComponentContainerContentInput(
282 component_text=ComponentTextInput(
283 text=organization.name or organization.slug
284 )
285 ),
286 ComponentContainerContentInput(
287 component_divider=ComponentDividerInput(
288 divider_spacing_size=ComponentDividerSpacingSize.M
289 )
290 ),
291 # Organization ID
292 ComponentContainerContentInput(
293 component_row=ComponentRowInput(
294 row_main_content=[
295 ComponentRowContentInput(
296 component_text=ComponentTextInput(
297 text="Organization ID",
298 text_size=ComponentTextSize.S,
299 text_color=ComponentTextColor.MUTED,
300 )
301 ),
302 ComponentRowContentInput(
303 component_text=ComponentTextInput(
304 text=str(organization.id)
305 )
306 ),
307 ],
308 row_aside_content=[
309 ComponentRowContentInput(
310 component_copy_button=ComponentCopyButtonInput(
311 copy_button_value=str(
312 organization.id
313 ),
314 copy_button_tooltip_label="Copy Organization ID",
315 )
316 )
317 ],
318 )
319 ),
320 # Backoffice Link
321 ComponentContainerContentInput(
322 component_link_button=ComponentLinkButtonInput(
323 link_button_url=settings.generate_backoffice_url(
324 f"/organizations/{organization.id}"
325 ),
326 link_button_label="View in Backoffice",
327 )
328 ),
329 ]
330 )
331 ),
332 ],
333 )
334 )
336 if thread_result.error is not None:
337 raise AccountReviewThreadCreationError(
338 user.id, thread_result.error.message
339 )
341 async def create_organization_deletion_thread(
342 self,
343 session: AsyncSession,
344 organization: Organization,
345 requesting_user: User,
346 blocked_reasons: list[str],
347 ) -> None:
348 """Create Plain ticket for organization deletion request."""
349 if not self.enabled:
350 return
352 async with self._get_plain_client() as plain:
353 customer_result = await plain.upsert_customer(
354 UpsertCustomerInput(
355 identifier=UpsertCustomerIdentifierInput(
356 email_address=requesting_user.email
357 ),
358 on_create=UpsertCustomerOnCreateInput(
359 external_id=str(requesting_user.id),
360 full_name=requesting_user.email,
361 email=EmailAddressInput(
362 email=requesting_user.email,
363 is_verified=requesting_user.email_verified,
364 ),
365 ),
366 on_update=UpsertCustomerOnUpdateInput(
367 email=EmailAddressInput(
368 email=requesting_user.email,
369 is_verified=requesting_user.email_verified,
370 ),
371 ),
372 )
373 )
374 if customer_result.error is not None:
375 raise AccountReviewThreadCreationError(
376 organization.id, customer_result.error.message
377 )
379 reasons_text = ", ".join(blocked_reasons) if blocked_reasons else "unknown"
381 thread_result = await plain.create_thread(
382 CreateThreadInput(
383 customer_identifier=self._get_customer_identifier(
384 customer_result, requesting_user.email
385 ),
386 title=f"Organization Deletion Request - {organization.slug}",
387 label_type_ids=["lt_01JKD9ASBPVX09YYXGHSXZRWSA"],
388 components=[
389 ComponentInput(
390 component_text=ComponentTextInput(
391 text=f"User has requested deletion of organization `{organization.slug}`."
392 )
393 ),
394 ComponentInput(
395 component_text=ComponentTextInput(
396 text=f"Blocked reasons: {reasons_text}",
397 text_color=ComponentTextColor.MUTED,
398 )
399 ),
400 ComponentInput(
401 component_spacer=ComponentSpacerInput(
402 spacer_size=ComponentSpacerSize.M
403 )
404 ),
405 ComponentInput(
406 component_container=ComponentContainerInput(
407 container_content=[
408 ComponentContainerContentInput(
409 component_text=ComponentTextInput(
410 text=organization.name or organization.slug
411 )
412 ),
413 ComponentContainerContentInput(
414 component_divider=ComponentDividerInput(
415 divider_spacing_size=ComponentDividerSpacingSize.M
416 )
417 ),
418 ComponentContainerContentInput(
419 component_row=ComponentRowInput(
420 row_main_content=[
421 ComponentRowContentInput(
422 component_text=ComponentTextInput(
423 text="Organization ID",
424 text_size=ComponentTextSize.S,
425 text_color=ComponentTextColor.MUTED,
426 )
427 ),
428 ComponentRowContentInput(
429 component_text=ComponentTextInput(
430 text=str(organization.id)
431 )
432 ),
433 ],
434 row_aside_content=[
435 ComponentRowContentInput(
436 component_copy_button=ComponentCopyButtonInput(
437 copy_button_value=str(
438 organization.id
439 ),
440 copy_button_tooltip_label="Copy Organization ID",
441 )
442 )
443 ],
444 )
445 ),
446 ComponentContainerContentInput(
447 component_link_button=ComponentLinkButtonInput(
448 link_button_url=settings.generate_backoffice_url(
449 f"/organizations/{organization.id}"
450 ),
451 link_button_label="View in Backoffice",
452 )
453 ),
454 ]
455 )
456 ),
457 ],
458 )
459 )
460 if thread_result.error is not None:
461 raise AccountReviewThreadCreationError(
462 organization.id, thread_result.error.message
463 )
465 async def _get_user_card(
466 self, session: AsyncSession, request: CustomerCardsRequest
467 ) -> CustomerCard | None:
468 email = request.customer.email
470 user_repository = UserRepository.from_session(session)
471 user = await user_repository.get_by_email(email)
473 if user is None:
474 return None
476 components: list[ComponentInput] = [
477 ComponentInput(
478 component_container=ComponentContainerInput(
479 container_content=[
480 ComponentContainerContentInput(
481 component_row=ComponentRowInput(
482 row_main_content=[
483 ComponentRowContentInput(
484 component_text=ComponentTextInput(
485 text=user.email
486 )
487 ),
488 ],
489 row_aside_content=[
490 ComponentRowContentInput(
491 component_link_button=ComponentLinkButtonInput(
492 link_button_label="Backoffice ↗",
493 link_button_url=settings.generate_backoffice_url(
494 f"/users/{user.id}"
495 ),
496 )
497 )
498 ],
499 )
500 ),
501 ComponentContainerContentInput(
502 component_divider=ComponentDividerInput(
503 divider_spacing_size=ComponentDividerSpacingSize.M
504 )
505 ),
506 ComponentContainerContentInput(
507 component_row=ComponentRowInput(
508 row_main_content=[
509 ComponentRowContentInput(
510 component_text=ComponentTextInput(
511 text="ID",
512 text_size=ComponentTextSize.S,
513 text_color=ComponentTextColor.MUTED,
514 )
515 ),
516 ComponentRowContentInput(
517 component_text=ComponentTextInput(
518 text=str(user.id)
519 )
520 ),
521 ],
522 row_aside_content=[
523 ComponentRowContentInput(
524 component_copy_button=ComponentCopyButtonInput(
525 copy_button_value=str(user.id),
526 copy_button_tooltip_label="Copy User ID",
527 )
528 )
529 ],
530 )
531 ),
532 ComponentContainerContentInput(
533 component_spacer=ComponentSpacerInput(
534 spacer_size=ComponentSpacerSize.M
535 )
536 ),
537 ComponentContainerContentInput(
538 component_text=ComponentTextInput(
539 text="Created At",
540 text_size=ComponentTextSize.S,
541 text_color=ComponentTextColor.MUTED,
542 )
543 ),
544 ComponentContainerContentInput(
545 component_text=ComponentTextInput(
546 text=user.created_at.date().isoformat()
547 )
548 ),
549 ComponentContainerContentInput(
550 component_row=ComponentRowInput(
551 row_main_content=[
552 ComponentRowContentInput(
553 component_text=ComponentTextInput(
554 text="Identity Verification",
555 text_size=ComponentTextSize.S,
556 text_color=ComponentTextColor.MUTED,
557 )
558 ),
559 ComponentRowContentInput(
560 component_text=ComponentTextInput(
561 text=user.identity_verification_status
562 )
563 ),
564 ],
565 row_aside_content=[
566 ComponentRowContentInput(
567 component_link_button=ComponentLinkButtonInput(
568 link_button_label="Stripe ↗",
569 link_button_url=f"https://dashboard.stripe.com/identity/verification-sessions/{user.identity_verification_id}",
570 )
571 )
572 ]
573 if user.identity_verification_id
574 else [],
575 )
576 ),
577 ]
578 )
579 )
580 ]
582 return CustomerCard(
583 key=CustomerCardKey.user,
584 timeToLiveSeconds=86400,
585 components=[
586 component.model_dump(by_alias=True, exclude_none=True)
587 for component in components
588 ],
589 )
591 async def _get_organization_card(
592 self, session: AsyncSession, request: CustomerCardsRequest
593 ) -> CustomerCard | None:
594 email = request.customer.email
596 statement = (
597 select(Organization)
598 .join(
599 UserOrganization,
600 Organization.id == UserOrganization.organization_id,
601 isouter=True,
602 )
603 .join(User, User.id == UserOrganization.user_id)
604 .where(func.lower(User.email) == email.lower())
605 )
606 result = await session.execute(statement)
607 organizations = result.unique().scalars().all()
609 if len(organizations) == 0:
610 return None
612 components: list[ComponentInput] = []
613 for i, organization in enumerate(organizations):
614 components.append(
615 ComponentInput(
616 component_container=self._get_organization_component_container(
617 organization
618 )
619 )
620 )
621 if i < len(organizations) - 1:
622 components.append(
623 ComponentInput(
624 component_divider=ComponentDividerInput(
625 divider_spacing_size=ComponentDividerSpacingSize.M
626 )
627 )
628 )
630 return CustomerCard(
631 key=CustomerCardKey.organization,
632 timeToLiveSeconds=86400,
633 components=[
634 component.model_dump(by_alias=True, exclude_none=True)
635 for component in components
636 ],
637 )
639 def _get_organization_component_container(
640 self, organization: Organization
641 ) -> ComponentContainerInput:
642 return ComponentContainerInput(
643 container_content=[
644 ComponentContainerContentInput(
645 component_row=ComponentRowInput(
646 row_main_content=[
647 ComponentRowContentInput(
648 component_text=ComponentTextInput(
649 text=organization.name
650 )
651 ),
652 ComponentRowContentInput(
653 component_text=ComponentTextInput(
654 text=organization.slug,
655 text_color=ComponentTextColor.MUTED,
656 )
657 ),
658 ],
659 row_aside_content=[
660 ComponentRowContentInput(
661 component_link_button=ComponentLinkButtonInput(
662 link_button_label="Backoffice ↗",
663 link_button_url=settings.generate_backoffice_url(
664 f"/organizations/{organization.id}"
665 ),
666 )
667 )
668 ],
669 )
670 ),
671 ComponentContainerContentInput(
672 component_divider=ComponentDividerInput(
673 divider_spacing_size=ComponentDividerSpacingSize.M
674 )
675 ),
676 ComponentContainerContentInput(
677 component_row=ComponentRowInput(
678 row_main_content=[
679 ComponentRowContentInput(
680 component_text=ComponentTextInput(
681 text="ID",
682 text_size=ComponentTextSize.S,
683 text_color=ComponentTextColor.MUTED,
684 )
685 ),
686 ComponentRowContentInput(
687 component_text=ComponentTextInput(
688 text=str(organization.id)
689 )
690 ),
691 ],
692 row_aside_content=[
693 ComponentRowContentInput(
694 component_copy_button=ComponentCopyButtonInput(
695 copy_button_value=str(organization.id),
696 copy_button_tooltip_label="Copy Organization ID",
697 )
698 )
699 ],
700 )
701 ),
702 ComponentContainerContentInput(
703 component_row=ComponentRowInput(
704 row_main_content=[
705 ComponentRowContentInput(
706 component_text=ComponentTextInput(
707 text="Customer Portal",
708 text_size=ComponentTextSize.S,
709 text_color=ComponentTextColor.MUTED,
710 )
711 ),
712 ComponentRowContentInput(
713 component_text=ComponentTextInput(
714 text=settings.generate_frontend_url(
715 f"/{organization.slug}/portal"
716 )
717 )
718 ),
719 ],
720 row_aside_content=[
721 ComponentRowContentInput(
722 component_copy_button=ComponentCopyButtonInput(
723 copy_button_value=settings.generate_frontend_url(
724 f"/{organization.slug}/portal"
725 ),
726 copy_button_tooltip_label="Copy URL",
727 )
728 )
729 ],
730 )
731 ),
732 *(
733 [
734 ComponentContainerContentInput(
735 component_row=ComponentRowInput(
736 row_main_content=[
737 ComponentRowContentInput(
738 component_text=ComponentTextInput(
739 text="Support Email",
740 text_size=ComponentTextSize.S,
741 text_color=ComponentTextColor.MUTED,
742 )
743 ),
744 ComponentRowContentInput(
745 component_text=ComponentTextInput(
746 text=organization.email
747 )
748 ),
749 ],
750 row_aside_content=[
751 ComponentRowContentInput(
752 component_copy_button=ComponentCopyButtonInput(
753 copy_button_value=organization.email,
754 copy_button_tooltip_label="Copy Support Email",
755 )
756 )
757 ],
758 )
759 ),
760 ]
761 if organization.email
762 else []
763 ),
764 ComponentContainerContentInput(
765 component_spacer=ComponentSpacerInput(
766 spacer_size=ComponentSpacerSize.M
767 )
768 ),
769 ComponentContainerContentInput(
770 component_text=ComponentTextInput(
771 text="Created At",
772 text_size=ComponentTextSize.S,
773 text_color=ComponentTextColor.MUTED,
774 )
775 ),
776 ComponentContainerContentInput(
777 component_text=ComponentTextInput(
778 text=organization.created_at.date().isoformat()
779 )
780 ),
781 ]
782 )
784 async def get_customer_card(
785 self, session: AsyncSession, request: CustomerCardsRequest
786 ) -> CustomerCard | None:
787 email = request.customer.email
789 # No need to filter out soft deleted. We want to see them in support.
790 statement = select(Customer).where(func.lower(Customer.email) == email.lower())
791 result = await session.execute(statement)
792 customers = result.unique().scalars().all()
794 if len(customers) == 0:
795 return None
797 def _get_customer_container(customer: Customer) -> ComponentContainerInput:
798 country: pycountry.db.Country | None = None
799 if customer.billing_address and customer.billing_address.country:
800 country = pycountry.countries.get(
801 alpha_2=customer.billing_address.country
802 )
803 return ComponentContainerInput(
804 container_content=[
805 ComponentContainerContentInput(
806 component_text=ComponentTextInput(
807 text=customer.name or customer.email or "Unknown"
808 )
809 ),
810 ComponentContainerContentInput(
811 component_divider=ComponentDividerInput(
812 divider_spacing_size=ComponentDividerSpacingSize.M
813 )
814 ),
815 ComponentContainerContentInput(
816 component_row=ComponentRowInput(
817 row_main_content=[
818 ComponentRowContentInput(
819 component_text=ComponentTextInput(
820 text="ID",
821 text_size=ComponentTextSize.S,
822 text_color=ComponentTextColor.MUTED,
823 )
824 ),
825 ComponentRowContentInput(
826 component_text=ComponentTextInput(
827 text=str(customer.id)
828 )
829 ),
830 ],
831 row_aside_content=[
832 ComponentRowContentInput(
833 component_copy_button=ComponentCopyButtonInput(
834 copy_button_value=str(customer.id),
835 copy_button_tooltip_label="Copy Customer ID",
836 )
837 )
838 ],
839 )
840 ),
841 ComponentContainerContentInput(
842 component_spacer=ComponentSpacerInput(
843 spacer_size=ComponentSpacerSize.M
844 )
845 ),
846 ComponentContainerContentInput(
847 component_text=ComponentTextInput(
848 text="Created At",
849 text_size=ComponentTextSize.S,
850 text_color=ComponentTextColor.MUTED,
851 )
852 ),
853 ComponentContainerContentInput(
854 component_text=ComponentTextInput(
855 text=customer.created_at.date().isoformat()
856 )
857 ),
858 *(
859 [
860 ComponentContainerContentInput(
861 component_spacer=ComponentSpacerInput(
862 spacer_size=ComponentSpacerSize.M
863 )
864 ),
865 ComponentContainerContentInput(
866 component_row=ComponentRowInput(
867 row_main_content=[
868 ComponentRowContentInput(
869 component_text=ComponentTextInput(
870 text="Country",
871 text_size=ComponentTextSize.S,
872 text_color=ComponentTextColor.MUTED,
873 )
874 ),
875 ComponentRowContentInput(
876 component_text=ComponentTextInput(
877 text=country.name,
878 )
879 ),
880 ],
881 row_aside_content=[
882 ComponentRowContentInput(
883 component_text=ComponentTextInput(
884 text=country.flag
885 )
886 )
887 ],
888 )
889 ),
890 ]
891 if country
892 else []
893 ),
894 ComponentContainerContentInput(
895 component_spacer=ComponentSpacerInput(
896 spacer_size=ComponentSpacerSize.M
897 )
898 ),
899 ComponentContainerContentInput(
900 component_row=ComponentRowInput(
901 row_main_content=[
902 ComponentRowContentInput(
903 component_text=ComponentTextInput(
904 text="Stripe Customer ID",
905 text_size=ComponentTextSize.S,
906 text_color=ComponentTextColor.MUTED,
907 )
908 ),
909 ComponentRowContentInput(
910 component_text=ComponentTextInput(
911 text=customer.stripe_customer_id or "N/A",
912 )
913 ),
914 ],
915 row_aside_content=[
916 ComponentRowContentInput(
917 component_link_button=ComponentLinkButtonInput(
918 link_button_label="Stripe ↗",
919 link_button_url=f"https://dashboard.stripe.com/customers/{customer.stripe_customer_id}",
920 )
921 )
922 ],
923 )
924 ),
925 ]
926 )
928 components: list[ComponentInput] = []
929 for i, customer in enumerate(customers):
930 components.append(
931 ComponentInput(component_container=_get_customer_container(customer))
932 )
933 if i < len(customers) - 1:
934 components.append(
935 ComponentInput(
936 component_divider=ComponentDividerInput(
937 divider_spacing_size=ComponentDividerSpacingSize.M
938 )
939 )
940 )
942 return CustomerCard(
943 key=CustomerCardKey.customer,
944 timeToLiveSeconds=86400,
945 components=[
946 component.model_dump(by_alias=True, exclude_none=True)
947 for component in components
948 ],
949 )
951 async def _get_order_card(
952 self, session: AsyncSession, request: CustomerCardsRequest
953 ) -> CustomerCard | None:
954 email = request.customer.email
956 statement = (
957 (
958 select(Order)
959 .join(Customer, onclause=Customer.id == Order.customer_id)
960 .join(Product, onclause=Product.id == Order.product_id, isouter=True)
961 .where(func.lower(Customer.email) == email.lower())
962 )
963 .order_by(Order.created_at.desc())
964 .limit(3)
965 .options(
966 contains_eager(Order.product),
967 contains_eager(Order.customer).joinedload(Customer.organization),
968 )
969 )
970 result = await session.execute(statement)
971 orders = result.unique().scalars().all()
973 if len(orders) == 0:
974 return None
976 def _get_order_container(order: Order) -> ComponentContainerInput:
977 organization = order.customer.organization
979 return ComponentContainerInput(
980 container_content=[
981 ComponentContainerContentInput(
982 component_row=ComponentRowInput(
983 row_main_content=[
984 ComponentRowContentInput(
985 component_text=ComponentTextInput(
986 text=order.description
987 )
988 ),
989 ],
990 row_aside_content=[
991 ComponentRowContentInput(
992 component_link_button=ComponentLinkButtonInput(
993 link_button_label="Backoffice ↗",
994 link_button_url=settings.generate_backoffice_url(
995 f"/orders/{order.id}"
996 ),
997 )
998 )
999 ],
1000 )
1001 ),
1002 ComponentContainerContentInput(
1003 component_divider=ComponentDividerInput(
1004 divider_spacing_size=ComponentDividerSpacingSize.M
1005 )
1006 ),
1007 ComponentContainerContentInput(
1008 component_row=ComponentRowInput(
1009 row_main_content=[
1010 ComponentRowContentInput(
1011 component_text=ComponentTextInput(
1012 text="Organization",
1013 text_size=ComponentTextSize.S,
1014 text_color=ComponentTextColor.MUTED,
1015 )
1016 ),
1017 ComponentRowContentInput(
1018 component_text=ComponentTextInput(
1019 text=organization.name
1020 )
1021 ),
1022 ],
1023 row_aside_content=[
1024 ComponentRowContentInput(
1025 component_link_button=ComponentLinkButtonInput(
1026 link_button_label="Backoffice ↗",
1027 link_button_url=settings.generate_backoffice_url(
1028 f"/organizations/{organization.id}"
1029 ),
1030 )
1031 )
1032 ],
1033 )
1034 ),
1035 ComponentContainerContentInput(
1036 component_spacer=ComponentSpacerInput(
1037 spacer_size=ComponentSpacerSize.M
1038 )
1039 ),
1040 ComponentContainerContentInput(
1041 component_row=ComponentRowInput(
1042 row_main_content=[
1043 ComponentRowContentInput(
1044 component_text=ComponentTextInput(
1045 text="Customer Portal",
1046 text_size=ComponentTextSize.S,
1047 text_color=ComponentTextColor.MUTED,
1048 )
1049 ),
1050 ComponentRowContentInput(
1051 component_text=ComponentTextInput(
1052 text=settings.generate_frontend_url(
1053 f"/{organization.slug}/portal"
1054 )
1055 )
1056 ),
1057 ],
1058 row_aside_content=[
1059 ComponentRowContentInput(
1060 component_copy_button=ComponentCopyButtonInput(
1061 copy_button_value=settings.generate_frontend_url(
1062 f"/{organization.slug}/portal"
1063 ),
1064 copy_button_tooltip_label="Copy URL",
1065 )
1066 )
1067 ],
1068 )
1069 ),
1070 *(
1071 [
1072 ComponentContainerContentInput(
1073 component_row=ComponentRowInput(
1074 row_main_content=[
1075 ComponentRowContentInput(
1076 component_text=ComponentTextInput(
1077 text="Support Email",
1078 text_size=ComponentTextSize.S,
1079 text_color=ComponentTextColor.MUTED,
1080 )
1081 ),
1082 ComponentRowContentInput(
1083 component_text=ComponentTextInput(
1084 text=organization.email
1085 )
1086 ),
1087 ],
1088 row_aside_content=[
1089 ComponentRowContentInput(
1090 component_copy_button=ComponentCopyButtonInput(
1091 copy_button_value=organization.email,
1092 copy_button_tooltip_label="Copy Support Email",
1093 )
1094 )
1095 ],
1096 )
1097 ),
1098 ]
1099 if organization.email
1100 else []
1101 ),
1102 ComponentContainerContentInput(
1103 component_spacer=ComponentSpacerInput(
1104 spacer_size=ComponentSpacerSize.M
1105 )
1106 ),
1107 ComponentContainerContentInput(
1108 component_row=ComponentRowInput(
1109 row_main_content=[
1110 ComponentRowContentInput(
1111 component_text=ComponentTextInput(
1112 text="ID",
1113 text_size=ComponentTextSize.S,
1114 text_color=ComponentTextColor.MUTED,
1115 )
1116 ),
1117 ComponentRowContentInput(
1118 component_text=ComponentTextInput(
1119 text=str(order.id)
1120 )
1121 ),
1122 ],
1123 row_aside_content=[
1124 ComponentRowContentInput(
1125 component_copy_button=ComponentCopyButtonInput(
1126 copy_button_value=str(order.id),
1127 copy_button_tooltip_label="Copy Order ID",
1128 )
1129 )
1130 ],
1131 )
1132 ),
1133 ComponentContainerContentInput(
1134 component_spacer=ComponentSpacerInput(
1135 spacer_size=ComponentSpacerSize.M
1136 )
1137 ),
1138 ComponentContainerContentInput(
1139 component_text=ComponentTextInput(
1140 text="Date",
1141 text_size=ComponentTextSize.S,
1142 text_color=ComponentTextColor.MUTED,
1143 )
1144 ),
1145 ComponentContainerContentInput(
1146 component_text=ComponentTextInput(
1147 text=order.created_at.date().isoformat()
1148 )
1149 ),
1150 ComponentContainerContentInput(
1151 component_spacer=ComponentSpacerInput(
1152 spacer_size=ComponentSpacerSize.M
1153 )
1154 ),
1155 ComponentContainerContentInput(
1156 component_text=ComponentTextInput(
1157 text="Billing Reason",
1158 text_size=ComponentTextSize.S,
1159 text_color=ComponentTextColor.MUTED,
1160 )
1161 ),
1162 ComponentContainerContentInput(
1163 component_text=ComponentTextInput(text=order.billing_reason)
1164 ),
1165 ComponentContainerContentInput(
1166 component_divider=ComponentDividerInput(
1167 divider_spacing_size=ComponentDividerSpacingSize.M
1168 )
1169 ),
1170 ComponentContainerContentInput(
1171 component_spacer=ComponentSpacerInput(
1172 spacer_size=ComponentSpacerSize.M
1173 )
1174 ),
1175 ComponentContainerContentInput(
1176 component_text=ComponentTextInput(
1177 text="Amount",
1178 text_size=ComponentTextSize.S,
1179 text_color=ComponentTextColor.MUTED,
1180 )
1181 ),
1182 ComponentContainerContentInput(
1183 component_text=ComponentTextInput(
1184 text=format_currency(
1185 order.net_amount / 100,
1186 order.currency.upper(),
1187 locale="en_US",
1188 )
1189 )
1190 ),
1191 ComponentContainerContentInput(
1192 component_text=ComponentTextInput(
1193 text="Tax Amount",
1194 text_size=ComponentTextSize.S,
1195 text_color=ComponentTextColor.MUTED,
1196 )
1197 ),
1198 ComponentContainerContentInput(
1199 component_text=ComponentTextInput(
1200 text=format_currency(
1201 order.tax_amount / 100,
1202 order.currency.upper(),
1203 locale="en_US",
1204 )
1205 )
1206 ),
1207 ]
1208 )
1210 components: list[ComponentInput] = []
1211 for i, order in enumerate(orders):
1212 components.append(
1213 ComponentInput(component_container=_get_order_container(order))
1214 )
1215 if i < len(orders) - 1:
1216 components.append(
1217 ComponentInput(
1218 component_divider=ComponentDividerInput(
1219 divider_spacing_size=ComponentDividerSpacingSize.M
1220 )
1221 )
1222 )
1224 return CustomerCard(
1225 key=CustomerCardKey.order,
1226 timeToLiveSeconds=86400,
1227 components=[
1228 component.model_dump(by_alias=True, exclude_none=True)
1229 for component in components
1230 ],
1231 )
1233 async def _get_snippets_card(
1234 self, session: AsyncSession, request: CustomerCardsRequest
1235 ) -> CustomerCard | None:
1236 email = request.customer.email
1238 statement = (
1239 select(Organization)
1240 .join(Customer, Customer.organization_id == Organization.id)
1241 .where(func.lower(Customer.email) == email.lower())
1242 )
1243 result = await session.execute(statement)
1244 organizations = result.unique().scalars().all()
1246 if len(organizations) == 0:
1247 return None
1249 snippets: list[tuple[str, str]] = [
1250 (
1251 "Looping In",
1252 (
1253 "I'm looping in the {organization_name} team to the conversation so that they can help you."
1254 ),
1255 ),
1256 (
1257 "Looping In with guidelines",
1258 (
1259 "I'm looping in the {organization_name} team to the conversation so that they can help you. "
1260 "Please allow them up to 48 hours to get back to you ([guidelines for merchants on Polar](https://polar.sh/docs/merchant-of-record/account-reviews#operational-guidelines))."
1261 ),
1262 ),
1263 (
1264 "Cancellation Portal",
1265 (
1266 "You can perform the cancellation on the following URL: https://polar.sh/{organization_slug}/portal\n"
1267 ),
1268 ),
1269 (
1270 "Invoice Generation",
1271 (
1272 "You can generate the invoice on the following URL: https://polar.sh/{organization_slug}/portal\n"
1273 ),
1274 ),
1275 (
1276 "Follow-up 48 hours",
1277 (
1278 "I'm looping in the {organization_name} team again to the conversation. "
1279 "Please allow them another 48 hours to get back to you before we [proceed with the documented resolution](https://polar.sh/docs/merchant-of-record/account-reviews#expected-responsiveness)."
1280 ),
1281 ),
1282 (
1283 "Follow-up Reply All",
1284 (
1285 "I'm looping in the {organization_name} team again to the conversation. "
1286 'Please use "Reply All" so as to keep everyone involved in the conversation.'
1287 ),
1288 ),
1289 (
1290 "Subscription Cancellation",
1291 ("I have cancelled the subscription immediately."),
1292 ),
1293 ]
1295 def _get_snippet_container(
1296 organization: Organization,
1297 ) -> ComponentContainerInput:
1298 snippets_rows: list[ComponentContainerContentInput] = [
1299 ComponentContainerContentInput(
1300 component_text=ComponentTextInput(
1301 text=f"Snippets for {organization.name}",
1302 )
1303 ),
1304 ComponentContainerContentInput(
1305 component_divider=ComponentDividerInput(
1306 divider_spacing_size=ComponentDividerSpacingSize.M
1307 )
1308 ),
1309 ]
1310 for i, (snippet_name, snippet_text) in enumerate(snippets):
1311 text = snippet_text.format(
1312 organization_name=organization.name,
1313 organization_slug=organization.slug,
1314 )
1315 snippets_rows.append(
1316 ComponentContainerContentInput(
1317 component_row=ComponentRowInput(
1318 row_main_content=[
1319 ComponentRowContentInput(
1320 component_text=ComponentTextInput(
1321 text_size=ComponentTextSize.S,
1322 text_color=ComponentTextColor.MUTED,
1323 text=snippet_name,
1324 ),
1325 ),
1326 ComponentRowContentInput(
1327 component_text=ComponentTextInput(text=text)
1328 ),
1329 ],
1330 row_aside_content=[
1331 ComponentRowContentInput(
1332 component_copy_button=ComponentCopyButtonInput(
1333 copy_button_value=text,
1334 copy_button_tooltip_label="Copy Snippet",
1335 )
1336 )
1337 ],
1338 )
1339 )
1340 )
1341 if i < len(snippets) - 1:
1342 snippets_rows.append(
1343 ComponentContainerContentInput(
1344 component_spacer=ComponentSpacerInput(
1345 spacer_size=ComponentSpacerSize.M
1346 )
1347 )
1348 )
1350 return ComponentContainerInput(container_content=snippets_rows)
1352 components: list[ComponentInput] = []
1353 for i, organization in enumerate(organizations):
1354 components.append(
1355 ComponentInput(component_container=_get_snippet_container(organization))
1356 )
1357 if i < len(organizations) - 1:
1358 components.append(
1359 ComponentInput(
1360 component_divider=ComponentDividerInput(
1361 divider_spacing_size=ComponentDividerSpacingSize.M
1362 )
1363 )
1364 )
1366 return CustomerCard(
1367 key=CustomerCardKey.snippets,
1368 timeToLiveSeconds=86400,
1369 components=[
1370 component.model_dump(by_alias=True, exclude_none=True)
1371 for component in components
1372 ],
1373 )
1375 def _get_customer_identifier(
1376 self,
1377 customer_result: UpsertCustomerUpsertCustomer,
1378 email: str,
1379 ) -> CustomerIdentifierInput:
1380 """
1381 Get customer identifier for Plain thread creation.
1383 Prefers external_id if set on the customer result, otherwise falls back to email.
1384 This handles cases where external_id might not be set on the Plain customer.
1385 """
1386 if customer_result.customer is None:
1387 raise ValueError(
1388 "Customer not found when creating thread", customer_result, email
1389 )
1391 if customer_result.customer.external_id:
1392 return CustomerIdentifierInput(
1393 external_id=customer_result.customer.external_id
1394 )
1396 return CustomerIdentifierInput(email_address=email)
1398 async def check_thread_exists(self, customer_email: str, thread_title: str) -> bool:
1399 """
1400 Check if a thread with the given title exists for a customer.
1401 Only considers threads that are not done/closed.
1402 """
1403 if not self.enabled:
1404 log.warning("Plain integration is disabled, assuming no thread exists")
1405 return False
1407 log.info("Checking thread existence", customer_email=customer_email)
1409 async with self._get_plain_client() as plain:
1410 user = await plain.customer_by_email(email=customer_email)
1411 log.info("User found", user_id=user)
1412 if not user:
1413 log.warning("User not found", email=customer_email)
1414 return False
1415 filters = ThreadsFilter(customer_ids=[user.id])
1416 threads = await plain.threads(filters=filters)
1417 nr_threads = 0
1418 for edge in threads.edges:
1419 thread = edge.node
1420 if thread.title == thread_title:
1421 nr_threads += 1
1422 log.info(f"There are {nr_threads} threads for user {customer_email}")
1423 return nr_threads > 0
1425 @contextlib.asynccontextmanager
1426 async def _get_plain_client(self) -> AsyncIterator[Plain]:
1427 async with httpx.AsyncClient(
1428 headers={"Authorization": f"Bearer {settings.PLAIN_TOKEN}"},
1429 # Set a MockTransport if not enabled
1430 # Basically, we disable Plain requests.
1431 transport=(
1432 httpx.MockTransport(lambda _: httpx.Response(200))
1433 if not self.enabled
1434 else None
1435 ),
1436 ) as client:
1437 async with Plain(
1438 "https://core-api.uk.plain.com/graphql/v1", http_client=client
1439 ) as plain:
1440 yield plain
1442 async def create_manual_organization_thread(
1443 self,
1444 session: AsyncSession,
1445 organization: Organization,
1446 admin: User,
1447 title: str,
1448 ) -> str:
1449 """Create a manual thread for an organization with the admin user."""
1450 if not self.enabled:
1451 return ""
1453 async with self._get_plain_client() as plain:
1454 customer_result = await plain.upsert_customer(
1455 UpsertCustomerInput(
1456 identifier=UpsertCustomerIdentifierInput(email_address=admin.email),
1457 on_create=UpsertCustomerOnCreateInput(
1458 external_id=str(admin.id),
1459 full_name=admin.email,
1460 email=EmailAddressInput(
1461 email=admin.email, is_verified=admin.email_verified
1462 ),
1463 ),
1464 on_update=UpsertCustomerOnUpdateInput(
1465 email=EmailAddressInput(
1466 email=admin.email, is_verified=admin.email_verified
1467 ),
1468 ),
1469 )
1470 )
1471 if customer_result.error is not None:
1472 raise AccountReviewThreadCreationError(
1473 organization.id, customer_result.error.message
1474 )
1476 thread_result = await plain.create_thread(
1477 CreateThreadInput(
1478 customer_identifier=self._get_customer_identifier(
1479 customer_result, admin.email
1480 ),
1481 title=title,
1482 components=[
1483 ComponentInput(
1484 component_container=ComponentContainerInput(
1485 container_content=[
1486 ComponentContainerContentInput(
1487 component_text=ComponentTextInput(
1488 text=organization.name or organization.slug
1489 )
1490 ),
1491 ComponentContainerContentInput(
1492 component_divider=ComponentDividerInput(
1493 divider_spacing_size=ComponentDividerSpacingSize.M
1494 )
1495 ),
1496 # Organization ID
1497 ComponentContainerContentInput(
1498 component_row=ComponentRowInput(
1499 row_main_content=[
1500 ComponentRowContentInput(
1501 component_text=ComponentTextInput(
1502 text="Organization ID",
1503 text_size=ComponentTextSize.S,
1504 text_color=ComponentTextColor.MUTED,
1505 )
1506 ),
1507 ComponentRowContentInput(
1508 component_text=ComponentTextInput(
1509 text=str(organization.id)
1510 )
1511 ),
1512 ],
1513 row_aside_content=[
1514 ComponentRowContentInput(
1515 component_copy_button=ComponentCopyButtonInput(
1516 copy_button_value=str(
1517 organization.id
1518 ),
1519 copy_button_tooltip_label="Copy Organization ID",
1520 )
1521 )
1522 ],
1523 )
1524 ),
1525 # Next Review Threshold
1526 ComponentContainerContentInput(
1527 component_row=ComponentRowInput(
1528 row_main_content=[
1529 ComponentRowContentInput(
1530 component_text=ComponentTextInput(
1531 text="Next Review Threshold",
1532 text_size=ComponentTextSize.S,
1533 text_color=ComponentTextColor.MUTED,
1534 )
1535 ),
1536 ComponentRowContentInput(
1537 component_text=ComponentTextInput(
1538 text=format_currency(
1539 organization.next_review_threshold
1540 / 100,
1541 "USD",
1542 locale="en_US",
1543 )
1544 )
1545 ),
1546 ],
1547 row_aside_content=[],
1548 )
1549 ),
1550 # Backoffice Link
1551 ComponentContainerContentInput(
1552 component_link_button=ComponentLinkButtonInput(
1553 link_button_url=settings.generate_backoffice_url(
1554 f"/organizations/{organization.id}"
1555 ),
1556 link_button_label="View in Backoffice",
1557 )
1558 ),
1559 ]
1560 )
1561 ),
1562 ],
1563 )
1564 )
1566 if thread_result.error is not None:
1567 raise AccountReviewThreadCreationError(
1568 organization.id, thread_result.error.message
1569 )
1571 if thread_result.thread is None:
1572 raise AccountReviewThreadCreationError(
1573 organization.id, "Failed to create thread: no thread returned"
1574 )
1576 return thread_result.thread.id
1579plain = PlainService()