Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/emails/src/emails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { OAuth2LeakedClient } from './oauth2_leaked_client'
import { OAuth2LeakedToken } from './oauth2_leaked_token'
import { OrderConfirmation } from './order_confirmation'
import { OrganizationAccessTokenLeaked } from './organization_access_token_leaked'
import { OrganizationAccountUnlink } from './organization_account_unlink'
import { OrganizationInvite } from './organization_invite'
import { PersonalAccessTokenLeaked } from './personal_access_token_leaked'
import { SeatInvitation } from './seat_invitation'
Expand All @@ -29,6 +30,7 @@ const TEMPLATES: Record<string, React.FC<any>> = {
oauth2_leaked_token: OAuth2LeakedToken,
order_confirmation: OrderConfirmation,
organization_access_token_leaked: OrganizationAccessTokenLeaked,
organization_account_unlink: OrganizationAccountUnlink,
organization_invite: OrganizationInvite,
personal_access_token_leaked: PersonalAccessTokenLeaked,
seat_invitation: SeatInvitation,
Expand Down
100 changes: 100 additions & 0 deletions server/emails/src/emails/organization_account_unlink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Preview, Section, Text } from '@react-email/components'
import BodyText from '../components/BodyText'
import Footer from '../components/Footer'
import IntroWithHi from '../components/IntroWithHi'
import PolarHeader from '../components/PolarHeader'
import Wrapper from '../components/Wrapper'
import type { schemas } from '../types'

export function OrganizationAccountUnlink({
email,
organization_kept_name,
organizations_unlinked,
}: schemas['OrganizationAccountUnlinkProps']) {
return (
<Wrapper>
<Preview>
Important: Organization Account Update for {organization_kept_name}
</Preview>
<PolarHeader />
<IntroWithHi>
We're writing to inform that we needed to unlink some of your
organizations from Stripe. The problem is that multiple organizations
shared the same Stripe account, and for security and compliance reasons
we had to unlink them.
</IntroWithHi>
<Section>
<BodyText>
Your organization{' '}
<span className="font-bold">{organization_kept_name}</span> has
retained the connected account, and{' '}
<span className="font-bold">no data has been lost</span>.
</BodyText>
</Section>
<Section className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<Text className="mb-2 text-[16px] font-bold text-blue-900">
What This Means
</Text>
<ul className="ml-4 list-disc text-[14px] text-blue-900">
<li className="mb-2">
<span className="font-bold">{organization_kept_name}</span> keeps
the existing account setup with no changes required.
</li>
<li className="mb-2">
The following organizations will need to complete the Stripe setup
again:
<ul className="ml-4 mt-2 list-disc">
{organizations_unlinked.map((org) => (
<li key={org} className="mb-1">
{org}
</li>
))}
</ul>
</li>
</ul>
</Section>
<Section className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<Text className="mb-2 text-[16px] font-bold text-yellow-900">
Important Information
</Text>
<ul className="ml-4 list-disc text-[14px] text-yellow-900">
<li className="mb-2">
<span className="font-bold">Payments:</span> Not blocked - customers
can continue making payments
</li>
<li className="mb-2">
<span className="font-bold">Withdrawals:</span> Blocked until Stripe
setup is completed for the affected organizations
</li>
<li className="mb-2">
<span className="font-bold">Payout history:</span> All payout
history is still available on{' '}
<span className="font-bold">{organization_kept_name}</span>.
</li>
</ul>
</Section>
<Section>
<BodyText>
To restore full functionality for the organizations that need Stripe
setup, please visit your organization settings and complete the Stripe
connection process.
</BodyText>
</Section>
<Section>
<BodyText>
If you have any questions or concerns, please don't hesitate to reach
out to our support team.
</BodyText>
</Section>
<Footer email={email} />
</Wrapper>
)
}

OrganizationAccountUnlink.PreviewProps = {
email: '[email protected]',
organization_kept_name: 'Acme Inc.',
organizations_unlinked: ['Beta Corp', 'Gamma LLC'],
}

export default OrganizationAccountUnlink
14 changes: 14 additions & 0 deletions server/polar/email/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class EmailTemplate(StrEnum):
order_confirmation = "order_confirmation"
organization_access_token_leaked = "organization_access_token_leaked"
organization_invite = "organization_invite"
organization_account_unlink = "organization_account_unlink"
personal_access_token_leaked = "personal_access_token_leaked"
seat_invitation = "seat_invitation"
subscription_cancellation = "subscription_cancellation"
Expand Down Expand Up @@ -301,6 +302,18 @@ class NotificationCreateAccountEmail(BaseModel):
props: MaintainerCreateAccountNotificationPayload


class OrganizationAccountUnlinkProps(EmailProps):
organization_kept_name: str
organizations_unlinked: list[str]


class OrganizationAccountUnlinkEmail(BaseModel):
template: Literal[EmailTemplate.organization_account_unlink] = (
EmailTemplate.organization_account_unlink
)
props: OrganizationAccountUnlinkProps


Email = Annotated[
LoginCodeEmail
| CustomerSessionCodeEmail
Expand All @@ -310,6 +323,7 @@ class NotificationCreateAccountEmail(BaseModel):
| OrderConfirmationEmail
| OrganizationAccessTokenLeakedEmail
| OrganizationInviteEmail
| OrganizationAccountUnlinkEmail
| PersonalAccessTokenLeakedEmail
| SeatInvitationEmail
| SubscriptionCancellationEmail
Expand Down
42 changes: 42 additions & 0 deletions server/scripts/unlink_organization_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@

Actually perform the unlink:
uv run python -m scripts.unlink_organization_account ACCOUNT_UUID --no-dry-run

Send email notification to admin:
uv run python -m scripts.unlink_organization_account ACCOUNT_UUID --no-dry-run --send-email
"""

import asyncio
Expand All @@ -35,6 +38,12 @@
from sqlalchemy.orm import joinedload

from polar.account.repository import AccountRepository
from polar.email.react import render_email_template
from polar.email.schemas import (
OrganizationAccountUnlinkEmail,
OrganizationAccountUnlinkProps,
)
from polar.email.sender import email_sender
from polar.kit.db.postgres import create_async_sessionmaker
from polar.models import Account, Customer, Order, Organization
from polar.organization.repository import OrganizationRepository
Expand Down Expand Up @@ -113,6 +122,9 @@ async def unlink_organizations(
dry_run: bool = typer.Option(
True, help="If True, only show what would be done without making changes"
),
send_email: bool = typer.Option(
False, help="If True, send email notification to the admin"
),
) -> None:
account_uuid = UUID(account_id)

Expand Down Expand Up @@ -229,6 +241,36 @@ async def unlink_organizations(
typer.echo()
typer.echo(f"✅ Successfully unlinked {len(orgs_to_unlink)} organization(s)")

if send_email:
typer.echo()
typer.echo("📧 Sending email notification to admin...")

try:
orgs_unlinked_names = [org.slug for org in orgs_to_unlink]

email_data = OrganizationAccountUnlinkEmail(
props=OrganizationAccountUnlinkProps(
email=account.admin.email,
organization_kept_name=org_to_keep.slug,
organizations_unlinked=orgs_unlinked_names,
)
)

email_html = render_email_template(email_data)

await email_sender.send(
to_email_addr=account.admin.email,
subject="Important: Organization Account Update",
html_content=email_html,
)

typer.echo(f" ✓ Email sent to {account.admin.email}")
except Exception as e:
typer.echo(f" ❌ Failed to send email: {str(e)}")
typer.echo(
" ⚠️ Organizations were unlinked successfully, but email notification failed"
)


if __name__ == "__main__":
cli()
Loading