Coverage for polar/integrations/plain/service.py: 16%

231 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 17:15 +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

7 

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

41 

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

56 

57from .schemas import ( 1a

58 CustomerCard, 

59 CustomerCardKey, 

60 CustomerCardsRequest, 

61 CustomerCardsResponse, 

62) 

63 

64log = structlog.get_logger(__name__) 1a

65 

66 

67class PlainServiceError(PolarError): ... 1a

68 

69 

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}") 

74 

75 

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 ) 

83 

84 

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}") 

89 

90 

91_card_getter_semaphore = asyncio.Semaphore(3) 1a

92 

93 

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 

99 

100 

101class PlainService: 1a

102 enabled = settings.PLAIN_TOKEN is not None 1a

103 

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 ) 

139 

140 cards = [card for task in tasks if (card := task.result()) is not None] 

141 return CustomerCardsResponse(cards=cards) 

142 

143 async def create_organization_review_thread( 1a

144 self, session: AsyncSession, organization: Organization 

145 ) -> None: 

146 if not self.enabled: 

147 return 

148 

149 user_repository = UserRepository.from_session(session) 

150 if organization.account is None: 

151 from polar.organization.tasks import OrganizationAccountNotSet 

152 

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) 

157 

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 ) 

180 

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") 

188 

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 ) 

222 

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 

232 

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] 

238 

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 ) 

258 

259 if customer_result.error is not None: 

260 raise AccountReviewThreadCreationError( 

261 user.id, customer_result.error.message 

262 ) 

263 

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 ) 

335 

336 if thread_result.error is not None: 

337 raise AccountReviewThreadCreationError( 

338 user.id, thread_result.error.message 

339 ) 

340 

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 

351 

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 ) 

378 

379 reasons_text = ", ".join(blocked_reasons) if blocked_reasons else "unknown" 

380 

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 ) 

464 

465 async def _get_user_card( 1a

466 self, session: AsyncSession, request: CustomerCardsRequest 

467 ) -> CustomerCard | None: 

468 email = request.customer.email 

469 

470 user_repository = UserRepository.from_session(session) 

471 user = await user_repository.get_by_email(email) 

472 

473 if user is None: 

474 return None 

475 

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 ] 

581 

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 ) 

590 

591 async def _get_organization_card( 1a

592 self, session: AsyncSession, request: CustomerCardsRequest 

593 ) -> CustomerCard | None: 

594 email = request.customer.email 

595 

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() 

608 

609 if len(organizations) == 0: 

610 return None 

611 

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 ) 

629 

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 ) 

638 

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 ) 

783 

784 async def get_customer_card( 1a

785 self, session: AsyncSession, request: CustomerCardsRequest 

786 ) -> CustomerCard | None: 

787 email = request.customer.email 

788 

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() 

793 

794 if len(customers) == 0: 

795 return None 

796 

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 ) 

927 

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 ) 

941 

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 ) 

950 

951 async def _get_order_card( 1a

952 self, session: AsyncSession, request: CustomerCardsRequest 

953 ) -> CustomerCard | None: 

954 email = request.customer.email 

955 

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() 

972 

973 if len(orders) == 0: 

974 return None 

975 

976 def _get_order_container(order: Order) -> ComponentContainerInput: 

977 organization = order.customer.organization 

978 

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 ) 

1209 

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 ) 

1223 

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 ) 

1232 

1233 async def _get_snippets_card( 1a

1234 self, session: AsyncSession, request: CustomerCardsRequest 

1235 ) -> CustomerCard | None: 

1236 email = request.customer.email 

1237 

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() 

1245 

1246 if len(organizations) == 0: 

1247 return None 

1248 

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 ] 

1294 

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 ) 

1349 

1350 return ComponentContainerInput(container_content=snippets_rows) 

1351 

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 ) 

1365 

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 ) 

1374 

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. 

1382 

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 ) 

1390 

1391 if customer_result.customer.external_id: 

1392 return CustomerIdentifierInput( 

1393 external_id=customer_result.customer.external_id 

1394 ) 

1395 

1396 return CustomerIdentifierInput(email_address=email) 

1397 

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 

1406 

1407 log.info("Checking thread existence", customer_email=customer_email) 

1408 

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 

1424 

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 

1441 

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 "" 

1452 

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 ) 

1475 

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 ) 

1565 

1566 if thread_result.error is not None: 

1567 raise AccountReviewThreadCreationError( 

1568 organization.id, thread_result.error.message 

1569 ) 

1570 

1571 if thread_result.thread is None: 

1572 raise AccountReviewThreadCreationError( 

1573 organization.id, "Failed to create thread: no thread returned" 

1574 ) 

1575 

1576 return thread_result.thread.id 

1577 

1578 

1579plain = PlainService() 1a