diff --git a/server/emails/src/emails/notification_new_sale.tsx b/server/emails/src/emails/notification_new_sale.tsx
index 692bb4adaf..4713b04faf 100644
--- a/server/emails/src/emails/notification_new_sale.tsx
+++ b/server/emails/src/emails/notification_new_sale.tsx
@@ -1,36 +1,144 @@
-import { Preview } from '@react-email/components'
+import { Hr, Img, Preview, Section, Text } from '@react-email/components'
+import Button from '../components/Button'
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 NotificationNewSale({
+ customer_email,
customer_name,
+ billing_address_city,
+ billing_address_line1,
formatted_price_amount,
+ formatted_billing_reason,
+ formatted_address_country,
product_name,
- product_price_amount,
- organization_name,
+ product_image_url,
+ order_date,
+ order_url,
}: schemas['MaintainerNewProductSaleNotificationPayload']) {
+ const displayName = customer_name || customer_email || 'A customer'
+
+ const formattedDate = order_date
+ ? new Date(order_date).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ })
+ : null
+
+ const addressParts = [billing_address_line1, billing_address_city].filter(
+ Boolean,
+ )
+ const formattedAddress =
+ addressParts.length > 0 ? addressParts.join(', ') : null
+
return (
- New {product_name} sale
+
+ {displayName} placed an order for {product_name}
+
-
- {customer_name} purchased {product_name} for{' '}
- {formatted_price_amount}.
-
+
+
+
+ {displayName} placed an order{formattedDate ? ` on ${formattedDate}` : ''}!
+
+
+
+ {order_url && (
+
+ )}
+
+
+
+
+
+ Order Summary
+
+
+
+
+ {product_image_url && (
+
+
+ |
+ )}
+
+
+ {product_name}
+
+
+ {formatted_price_amount}
+
+ |
+
+
+
+
+
+
+
+ {formatted_billing_reason && (
+
+
+ Order Type
+
+
+ {formatted_billing_reason}
+
+
+ )}
+
+
+
+ Customer
+
+ {displayName}
+ {customer_email && (
+ {customer_email}
+ )}
+ {formattedAddress && (
+ {formattedAddress}
+ )}
+ {formatted_address_country && (
+
+ {formatted_address_country}
+
+ )}
+
+
)
}
NotificationNewSale.PreviewProps = {
- customer_name: 'John Doe',
+ customer_email: 'bob@ross.com',
+ customer_name: 'Bob Ross',
+ billing_address_country: 'US',
+ billing_address_city: 'San Francisco',
+ billing_address_line1: '123 Main St',
formatted_price_amount: '$45.95',
- product_name: 'Ultimate Magento webshop template',
+ formatted_billing_reason: 'One-time purchase',
+ formatted_address_country: 'United States',
+ product_name: 'Beginners guide to painting',
product_price_amount: 4595,
+ product_image_url: 'https://placehold.co/64x64',
+ order_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ order_date: '2024-11-05T20:41:00Z',
+ order_url:
+ 'https://polar.sh/dashboard/acme-inc/sales/a1b2c3d4-e5f6-7890-abcd-ef1234567890',
organization_name: 'Acme Inc.',
+ organization_slug: 'acme-inc',
+ billing_reason: 'purchase',
}
export default NotificationNewSale
diff --git a/server/emails/src/types/openapi.ts b/server/emails/src/types/openapi.ts
index 85c2f02482..29c2ac4cda 100644
--- a/server/emails/src/types/openapi.ts
+++ b/server/emails/src/types/openapi.ts
@@ -831,16 +831,79 @@ export interface components {
}
/** MaintainerNewProductSaleNotificationPayload */
MaintainerNewProductSaleNotificationPayload: {
- /** Customer Name */
- customer_name: string
/** Product Name */
product_name: string
/** Product Price Amount */
product_price_amount: number
- /** Organization Name */
+ /**
+ * Customer Name
+ * @default A customer
+ */
+ customer_name: string
+ /**
+ * Organization Name
+ * @default
+ */
organization_name: string
+ /**
+ * Customer Email
+ * @default null
+ */
+ customer_email: string | null
+ /**
+ * Billing Address Country
+ * @default null
+ */
+ billing_address_country: string | null
+ /**
+ * Billing Address City
+ * @default null
+ */
+ billing_address_city: string | null
+ /**
+ * Billing Address Line1
+ * @default null
+ */
+ billing_address_line1: string | null
+ /**
+ * Product Image Url
+ * @default null
+ */
+ product_image_url: string | null
+ /**
+ * Order Id
+ * @default null
+ */
+ order_id: string | null
+ /**
+ * Order Date
+ * @default null
+ */
+ order_date: string | null
+ /**
+ * Organization Slug
+ * @default null
+ */
+ organization_slug: string | null
+ /**
+ * Billing Reason
+ * @default null
+ */
+ billing_reason: components['schemas']['OrderBillingReasonInternal'] | null
/** Formatted Price Amount */
readonly formatted_price_amount: string
+ /**
+ * Formatted Billing Reason
+ * @default null
+ */
+ readonly formatted_billing_reason: string | null
+ /** Formatted Address Country */
+ readonly formatted_address_country: string | null
+ /**
+ * Order Url
+ * @default null
+ */
+ readonly order_url: string | null
}
/** NotificationCreateAccountEmail */
NotificationCreateAccountEmail: {
@@ -925,6 +988,17 @@ export interface components {
| 'subscription_create'
| 'subscription_cycle'
| 'subscription_update'
+ /**
+ * OrderBillingReasonInternal
+ * @description Internal billing reasons with additional granularity.
+ * @enum {string}
+ */
+ OrderBillingReasonInternal:
+ | 'purchase'
+ | 'subscription_create'
+ | 'subscription_cycle'
+ | 'subscription_cycle_after_trial'
+ | 'subscription_update'
/** OrderConfirmationEmail */
OrderConfirmationEmail: {
/**
@@ -1224,6 +1298,25 @@ export interface components {
/** Url */
url: string
}
+ /** OrganizationAccountUnlinkEmail */
+ OrganizationAccountUnlinkEmail: {
+ /**
+ * Template
+ * @default organization_account_unlink
+ * @constant
+ */
+ template: 'organization_account_unlink'
+ props: components['schemas']['OrganizationAccountUnlinkProps']
+ }
+ /** OrganizationAccountUnlinkProps */
+ OrganizationAccountUnlinkProps: {
+ /** Email */
+ email: string
+ /** Organization Kept Name */
+ organization_kept_name: string
+ /** Organizations Unlinked */
+ organizations_unlinked: string[]
+ }
/** OrganizationCustomerEmailSettings */
OrganizationCustomerEmailSettings: {
/** Order Confirmation */
@@ -1269,6 +1362,12 @@ export interface components {
* @default false
*/
wallets_enabled: boolean
+ /**
+ * Member Model Enabled
+ * @description If this organization has the Member model enabled
+ * @default false
+ */
+ member_model_enabled: boolean
}
/** OrganizationInviteEmail */
OrganizationInviteEmail: {
@@ -1358,6 +1457,8 @@ export interface components {
proration_behavior: components['schemas']['SubscriptionProrationBehavior']
/** Benefit Revocation Grace Period */
benefit_revocation_grace_period: number
+ /** Prevent Trial Abuse */
+ prevent_trial_abuse: boolean
}
/** OrganizationUnderReviewEmail */
OrganizationUnderReviewEmail: {
diff --git a/server/polar/notifications/notification.py b/server/polar/notifications/notification.py
index 21a88ee4d8..e13532a245 100644
--- a/server/polar/notifications/notification.py
+++ b/server/polar/notifications/notification.py
@@ -3,11 +3,14 @@
from enum import StrEnum
from typing import Annotated, Literal
+import pycountry
from babel.numbers import format_currency
from pydantic import UUID4, BaseModel, Discriminator, computed_field
+from polar.config import settings
from polar.email.react import render_email_template
from polar.kit.schemas import Schema
+from polar.models.order import OrderBillingReasonInternal
class NotificationType(StrEnum):
@@ -89,15 +92,54 @@ class MaintainerNewPaidSubscriptionNotification(NotificationBase):
class MaintainerNewProductSaleNotificationPayload(NotificationPayloadBase):
- customer_name: str
product_name: str
product_price_amount: int
- organization_name: str
+ customer_name: str = ""
+ organization_name: str = ""
+
+ customer_email: str | None = None
+ billing_address_country: str | None = None
+ billing_address_city: str | None = None
+ billing_address_line1: str | None = None
+ product_image_url: str | None = None
+ order_id: str | None = None
+ order_date: str | None = None
+ organization_slug: str | None = None
+ billing_reason: OrderBillingReasonInternal | None = None
@computed_field
def formatted_price_amount(self) -> str:
return format_currency(self.product_price_amount / 100, "USD", locale="en_US")
+ @computed_field
+ def formatted_billing_reason(self) -> str | None:
+ if self.billing_reason is None:
+ return None
+ match self.billing_reason:
+ case OrderBillingReasonInternal.purchase:
+ return "One-time purchase"
+ case OrderBillingReasonInternal.subscription_create:
+ return "New subscription"
+ case OrderBillingReasonInternal.subscription_cycle:
+ return "Subscription renewal"
+ case OrderBillingReasonInternal.subscription_cycle_after_trial:
+ return "Subscription started after trial"
+ case OrderBillingReasonInternal.subscription_update:
+ return "Subscription update"
+
+ @computed_field
+ def formatted_address_country(self) -> str | None:
+ if not self.billing_address_country:
+ return None
+ country = pycountry.countries.get(alpha_2=self.billing_address_country)
+ return country.name if country else self.billing_address_country
+
+ @computed_field
+ def order_url(self) -> str | None:
+ if not self.organization_slug or not self.order_id:
+ return None
+ return f"{settings.FRONTEND_BASE_URL}/dashboard/{self.organization_slug}/sales/{self.order_id}"
+
def subject(self) -> str:
return f"You've made a new sale ({self.formatted_price_amount})!"
diff --git a/server/polar/order/service.py b/server/polar/order/service.py
index aa2c1e95e7..e82ff9ee15 100644
--- a/server/polar/order/service.py
+++ b/server/polar/order/service.py
@@ -34,6 +34,7 @@
from polar.event.system import OrderPaidMetadata, SystemEvent, build_system_event
from polar.eventstream.service import publish as eventstream_publish
from polar.exceptions import PolarError
+from polar.file.s3 import S3_SERVICES
from polar.held_balance.service import held_balance as held_balance_service
from polar.integrations.stripe.schemas import ProductType
from polar.integrations.stripe.service import stripe as stripe_service
@@ -1542,16 +1543,46 @@ async def send_admin_notification(
return
if organization.notification_settings["new_order"]:
+ product_image_url: str | None = None
+ try:
+ if product.product_medias and len(product.product_medias) > 0:
+ first_media = product.product_medias[0].file
+ product_image_url = S3_SERVICES[first_media.service].get_public_url(
+ first_media.path
+ )
+ except Exception:
+ pass
+
+ billing_address = order.billing_address
+ customer = order.customer
+
await notifications_service.send_to_org_members(
session,
org_id=organization.id,
notif=PartialNotification(
type=NotificationType.maintainer_new_product_sale,
payload=MaintainerNewProductSaleNotificationPayload(
- customer_name=order.customer.email,
+ customer_email=customer.email,
+ customer_name=customer.name
+ or order.billing_name
+ or customer.email,
+ billing_address_country=billing_address.country
+ if billing_address
+ else None,
+ billing_address_city=billing_address.city
+ if billing_address
+ else None,
+ billing_address_line1=billing_address.line1
+ if billing_address
+ else None,
product_name=product.name,
product_price_amount=order.net_amount,
- organization_name=organization.slug,
+ product_image_url=product_image_url,
+ order_id=str(order.id),
+ order_date=order.created_at.isoformat(),
+ organization_name=organization.name,
+ organization_slug=organization.slug,
+ billing_reason=order.billing_reason,
),
),
)
diff --git a/server/tests/notifications/test_email.py b/server/tests/notifications/test_email.py
index 75ddb7adfa..7a9bca85fc 100644
--- a/server/tests/notifications/test_email.py
+++ b/server/tests/notifications/test_email.py
@@ -4,6 +4,7 @@
import pytest
+from polar.models.order import OrderBillingReasonInternal
from polar.notifications.notification import (
MaintainerCreateAccountNotificationPayload,
MaintainerNewPaidSubscriptionNotificationPayload,
@@ -50,10 +51,19 @@ async def test_MaintainerNewPaidSubscriptionNotification() -> None:
@pytest.mark.asyncio
async def test_MaintainerNewProductSaleNotification() -> None:
n = MaintainerNewProductSaleNotificationPayload(
- customer_name="birk@polar.sh",
+ customer_email="birk@polar.sh",
+ customer_name="Birk",
+ billing_address_country="US",
+ billing_address_city="San Francisco",
+ billing_address_line1="123 Main St",
product_name="My Awesome Digital Product",
product_price_amount=500,
+ product_image_url=None,
+ order_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ order_date="2024-11-05T20:41:00Z",
organization_name="myorg",
+ organization_slug="myorg",
+ billing_reason=OrderBillingReasonInternal.purchase,
)
await check_diff(n.render())
@@ -74,10 +84,15 @@ async def test_MaintainerCreateAccountNotificationPayload() -> None:
"payload",
[
MaintainerNewProductSaleNotificationPayload(
+ customer_email="{{ 123456 * 9 }}",
customer_name="{{ 123456 * 9 }}",
product_name="{{ 123456 * 9 }}",
product_price_amount=500,
+ order_id="{{ 123456 * 9 }}",
+ order_date="2024-11-05T20:41:00Z",
organization_name="{{ 123456 * 9 }}",
+ organization_slug="{{ 123456 * 9 }}",
+ billing_reason=OrderBillingReasonInternal.purchase,
),
MaintainerCreateAccountNotificationPayload(
organization_name="{{ 123456 * 9 }}",
@@ -100,6 +115,31 @@ async def test_injection_payloads(payload: NotificationPayloadBase) -> None:
assert "{{ 123456 * 9 }}" in body
+@pytest.mark.asyncio
+async def test_MaintainerNewProductSaleNotification_backwards_compatibility() -> None:
+ old_notification_data = {
+ "product_name": "Old Product",
+ "product_price_amount": 1000,
+ }
+
+ n = MaintainerNewProductSaleNotificationPayload.model_validate(
+ old_notification_data
+ )
+
+ assert n.product_name == "Old Product"
+ assert n.product_price_amount == 1000
+ assert n.customer_name == ""
+ assert n.organization_name == ""
+ assert n.customer_email is None
+ assert n.order_id is None
+ assert n.order_date is None
+ assert n.billing_reason is None
+
+ subject, body = n.render()
+ assert "Old Product" in body
+ assert "$10.00" in subject
+
+
@pytest.mark.asyncio
async def test_all_notification_types() -> None:
n: NotificationPayload
@@ -111,10 +151,15 @@ async def test_all_notification_types() -> None:
)
elif notification_type == NotificationType.maintainer_new_product_sale:
n = MaintainerNewProductSaleNotificationPayload(
+ customer_email="john@example.com",
customer_name="John Doe",
product_name="Ice cream sandwich",
product_price_amount=500,
+ order_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ order_date="2024-11-05T20:41:00Z",
organization_name="Ice Cream Van",
+ organization_slug="ice-cream-van",
+ billing_reason=OrderBillingReasonInternal.purchase,
)
elif notification_type == NotificationType.maintainer_new_paid_subscription:
n = MaintainerNewPaidSubscriptionNotificationPayload(
diff --git a/server/tests/notifications/testdata/test_MaintainerNewProductSaleNotification.html b/server/tests/notifications/testdata/test_MaintainerNewProductSaleNotification.html
index d67bc10375..8ec73cf8ca 100644
--- a/server/tests/notifications/testdata/test_MaintainerNewProductSaleNotification.html
+++ b/server/tests/notifications/testdata/test_MaintainerNewProductSaleNotification.html
@@ -12,4 +12,4 @@
* {
font-family: 'Inter', sans-serif;
}
-
New My Awesome Digital Product sale Congratulations! birk@polar.sh purchased My Awesome Digital Product for $5.00. |
|