-
-
Notifications
You must be signed in to change notification settings - Fork 169
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
server/integrations/plain: add endpoint to return Plain customer cards
- Loading branch information
1 parent
80e1946
commit 60a8b17
Showing
6 changed files
with
348 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import hashlib | ||
import hmac | ||
|
||
from fastapi import Depends, Header, HTTPException, Request | ||
|
||
from polar.config import settings | ||
from polar.postgres import AsyncSession, get_db_session | ||
from polar.routing import APIRouter | ||
|
||
from .schemas import CustomerCardsRequest, CustomerCardsResponse | ||
from .service import plain as plain_service | ||
|
||
router = APIRouter( | ||
prefix="/integrations/plain", tags=["integrations_plain"], include_in_schema=False | ||
) | ||
|
||
|
||
@router.post("/cards") | ||
async def get_cards( | ||
request: Request, | ||
customer_cards_request: CustomerCardsRequest, | ||
plain_request_signature: str = Header(...), | ||
session: AsyncSession = Depends(get_db_session), | ||
) -> CustomerCardsResponse: | ||
secret = settings.PLAIN_REQUEST_SIGNING_SECRET | ||
if secret is None: | ||
raise HTTPException(status_code=404) | ||
|
||
raw_body = await request.body() | ||
signature = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() | ||
if not hmac.compare_digest(signature, plain_request_signature): | ||
raise HTTPException(status_code=403) | ||
|
||
return await plain_service.get_cards(session, customer_cards_request) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
from enum import StrEnum | ||
from typing import Any | ||
|
||
from pydantic import BaseModel | ||
|
||
|
||
class CustomerCardKey(StrEnum): | ||
organization = "organization" | ||
customer = "customer" | ||
latest_order = "latest_order" | ||
|
||
|
||
class CustomerCardCustomer(BaseModel): | ||
id: str | ||
email: str | ||
externalId: str | None | ||
|
||
|
||
class CustomerCardThread(BaseModel): | ||
id: str | ||
externalId: str | None | ||
|
||
|
||
class CustomerCardsRequest(BaseModel): | ||
cardKeys: list[CustomerCardKey] | ||
customer: CustomerCardCustomer | ||
thread: CustomerCardThread | None | ||
|
||
|
||
class CustomerCard(BaseModel): | ||
key: CustomerCardKey | ||
timeToLiveSeconds: int | ||
components: list[dict[str, Any]] | None | ||
|
||
|
||
class CustomerCardsResponse(BaseModel): | ||
cards: list[CustomerCard] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,271 @@ | ||
import asyncio | ||
|
||
import pycountry | ||
import pycountry.db | ||
from sqlalchemy import func, or_, select | ||
|
||
from polar.models import Customer, Organization, User, UserOrganization | ||
from polar.postgres import AsyncSession | ||
|
||
from .schemas import ( | ||
CustomerCard, | ||
CustomerCardKey, | ||
CustomerCardsRequest, | ||
CustomerCardsResponse, | ||
) | ||
|
||
|
||
class PlainService: | ||
async def get_cards( | ||
self, session: AsyncSession, request: CustomerCardsRequest | ||
) -> CustomerCardsResponse: | ||
tasks: list[asyncio.Task[CustomerCard | None]] = [] | ||
async with asyncio.TaskGroup() as tg: | ||
if CustomerCardKey.organization in request.cardKeys: | ||
tasks.append( | ||
tg.create_task(self._get_organization_card(session, request)) | ||
) | ||
if CustomerCardKey.customer in request.cardKeys: | ||
tasks.append(tg.create_task(self.get_customer_card(session, request))) | ||
|
||
cards = [card for task in tasks if (card := task.result()) is not None] | ||
return CustomerCardsResponse(cards=cards) | ||
|
||
async def _get_organization_card( | ||
self, session: AsyncSession, request: CustomerCardsRequest | ||
) -> CustomerCard | None: | ||
email = request.customer.email | ||
|
||
statement = ( | ||
select(Organization) | ||
.join( | ||
UserOrganization, | ||
Organization.id == UserOrganization.organization_id, | ||
isouter=True, | ||
) | ||
.join(User, User.id == UserOrganization.user_id) | ||
.join(Customer, Customer.organization_id == Organization.id, isouter=True) | ||
.where( | ||
or_( | ||
func.lower(Customer.email) == email.lower(), | ||
func.lower(User.email) == email.lower(), | ||
) | ||
) | ||
) | ||
result = await session.execute(statement) | ||
organization = result.unique().scalar_one_or_none() | ||
|
||
if organization is None: | ||
return None | ||
|
||
return CustomerCard( | ||
key=CustomerCardKey.organization, | ||
timeToLiveSeconds=86400, | ||
components=[ | ||
{ | ||
"componentContainer": { | ||
"containerContent": [ | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{"componentText": {"text": organization.name}}, | ||
{ | ||
"componentText": { | ||
"text": organization.slug, | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentLinkButton": { | ||
"linkButtonLabel": "Omni ↗", | ||
"linkButtonUrl": "https://example.com", | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
{"componentDivider": {"dividerSpacingSize": "M"}}, | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{ | ||
"componentText": { | ||
"text": "ID", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{"componentText": {"text": organization.id}}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentCopyButton": { | ||
"copyButtonValue": organization.id, | ||
"copyButtonTooltipLabel": "Copy Organization ID", | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
{"componentSpacer": {"spacerSize": "M"}}, | ||
{ | ||
"componentText": { | ||
"text": "Created At", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{ | ||
"componentText": { | ||
"text": organization.created_at.date().isoformat() | ||
} | ||
}, | ||
] | ||
} | ||
} | ||
], | ||
) | ||
|
||
async def get_customer_card( | ||
self, session: AsyncSession, request: CustomerCardsRequest | ||
) -> CustomerCard | None: | ||
email = request.customer.email | ||
|
||
statement = select(Customer).where(func.lower(Customer.email) == email.lower()) | ||
result = await session.execute(statement) | ||
customer = result.unique().scalar_one_or_none() | ||
|
||
if customer is None: | ||
return None | ||
|
||
country: pycountry.db.Country | None = None | ||
if customer.billing_address and customer.billing_address.country: | ||
country = pycountry.countries.get(alpha_2=customer.billing_address.country) | ||
|
||
return CustomerCard( | ||
key=CustomerCardKey.customer, | ||
timeToLiveSeconds=86400, | ||
components=[ | ||
{ | ||
"componentContainer": { | ||
"containerContent": [ | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{"componentText": {"text": customer.name}}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentLinkButton": { | ||
"linkButtonLabel": "Omni ↗", | ||
"linkButtonUrl": "https://example.com", | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
{"componentDivider": {"dividerSpacingSize": "M"}}, | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{ | ||
"componentText": { | ||
"text": "ID", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{"componentText": {"text": customer.id}}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentCopyButton": { | ||
"copyButtonValue": customer.id, | ||
"copyButtonTooltipLabel": "Copy Customer ID", | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
{"componentSpacer": {"spacerSize": "M"}}, | ||
{ | ||
"componentText": { | ||
"text": "Created At", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{ | ||
"componentText": { | ||
"text": customer.created_at.date().isoformat() | ||
} | ||
}, | ||
*( | ||
[ | ||
{"componentSpacer": {"spacerSize": "M"}}, | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{ | ||
"componentText": { | ||
"text": "Country", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{ | ||
"componentText": { | ||
"text": country.name, | ||
} | ||
}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentText": { | ||
"text": country.flag | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
] | ||
if country | ||
else [] | ||
), | ||
{"componentSpacer": {"spacerSize": "M"}}, | ||
{ | ||
"componentRow": { | ||
"rowMainContent": [ | ||
{ | ||
"componentText": { | ||
"text": "Stripe Customer ID", | ||
"textSize": "S", | ||
"textColor": "MUTED", | ||
} | ||
}, | ||
{ | ||
"componentText": { | ||
"text": customer.stripe_customer_id, | ||
} | ||
}, | ||
], | ||
"rowAsideContent": [ | ||
{ | ||
"componentLinkButton": { | ||
"linkButtonLabel": "Stripe ↗", | ||
"linkButtonUrl": f"https://dashboard.stripe.com/customers/{customer.stripe_customer_id}", | ||
} | ||
} | ||
], | ||
} | ||
}, | ||
] | ||
} | ||
} | ||
], | ||
) | ||
|
||
|
||
plain = PlainService() |