Coverage for polar/integrations/plain/service.py: 16%
231 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1# pyright: reportCallIssue=false
2import asyncio 1a
3import contextlib 1a
4import uuid 1a
5from collections.abc import AsyncIterator, Coroutine 1a
6from typing import Any 1a
8import httpx 1a
9import pycountry 1a
10import pycountry.db 1a
11import structlog 1a
12from babel.numbers import format_currency 1a
13from plain_client import ( 1a
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 1a
40from sqlalchemy.orm import contains_eager 1a
42from polar.config import settings 1a
43from polar.exceptions import PolarError 1a
44from polar.models import ( 1a
45 Customer,
46 Order,
47 Organization,
48 Product,
49 User,
50 UserOrganization,
51)
52from polar.models.organization import OrganizationStatus 1a
53from polar.models.organization_review import OrganizationReview 1a
54from polar.postgres import AsyncSession 1a
55from polar.user.repository import UserRepository 1a
57from .schemas import ( 1a
58 CustomerCard,
59 CustomerCardKey,
60 CustomerCardsRequest,
61 CustomerCardsResponse,
62)
64log = structlog.get_logger(__name__) 1a
67class PlainServiceError(PolarError): ... 1a
70class AccountAdminDoesNotExistError(PlainServiceError): 1a
71 def __init__(self, account_id: uuid.UUID) -> None: 1a
72 self.account_id = account_id
73 super().__init__(f"Account admin does not exist for account ID {account_id}")
76class AccountReviewThreadCreationError(PlainServiceError): 1a
77 def __init__(self, account_id: uuid.UUID, message: str) -> None: 1a
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): 1a
86 def __init__(self, organization_id: uuid.UUID) -> None: 1a
87 self.organization_id = organization_id
88 super().__init__(f"No user found for organization {organization_id}")
91_card_getter_semaphore = asyncio.Semaphore(3) 1a
94async def _card_getter_task( 1a
95 coroutine: Coroutine[Any, Any, CustomerCard | None],
96) -> CustomerCard | None:
97 async with _card_getter_semaphore:
98 return await coroutine
101class PlainService: 1a
102 enabled = settings.PLAIN_TOKEN is not None 1a
104 async def get_cards( 1a
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( 1a
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( 1a
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:
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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: 1a
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 1a
1426 async def _get_plain_client(self) -> AsyncIterator[Plain]: 1a
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( 1a
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() 1a