Skip to content

Commit 3c40929

Browse files
committed
Wire up Paddle checkout, fix license server
- Add Paddle.js integration to pricing page with env-based config - Fix webhook: fetch customer email via Paddle API (not in webhook payload) - Support quantity > 1: generate multiple license keys per purchase - Update email template to show numbered keys for bulk purchases - Add cursor-pointer to buy buttons - Update licensing docs with full Paddle setup instructions - Fix perpetual price $149 → $199 in docs
1 parent 7eb66ac commit 3c40929

8 files changed

Lines changed: 405 additions & 186 deletions

File tree

apps/license-server/src/email.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { LicenseType } from './license'
44
interface EmailParams {
55
to: string
66
customerName: string
7-
licenseKey: string
7+
licenseKeys: string[]
88
productName: string
99
supportEmail: string
1010
resendApiKey: string
@@ -35,10 +35,37 @@ export async function sendLicenseEmail(params: EmailParams): Promise<void> {
3535
const orgLine = params.organizationName ? `<p><strong>Licensed to:</strong> ${params.organizationName}</p>` : ''
3636
const orgLineText = params.organizationName ? `Licensed to: ${params.organizationName}\n` : ''
3737

38+
const count = params.licenseKeys.length
39+
const isMultiple = count > 1
40+
const keyWord = isMultiple ? 'keys' : 'key'
41+
const subject = `Your ${params.productName} license ${keyWord} 🎉`
42+
43+
// HTML: render keys as numbered boxes if multiple, single box otherwise
44+
const licenseBoxesHtml = isMultiple
45+
? params.licenseKeys
46+
.map(
47+
(key, i) => `
48+
<div class="license-box">
49+
<div class="license-number">License ${i + 1} of ${count}</div>
50+
${key}
51+
</div>`,
52+
)
53+
.join('\n')
54+
: `<div class="license-box">${params.licenseKeys[0]}</div>`
55+
56+
// Plain text: render keys with headers if multiple
57+
const licenseKeysText = isMultiple
58+
? params.licenseKeys.map((key, i) => `License ${i + 1} of ${count}:\n${key}`).join('\n\n')
59+
: params.licenseKeys[0]
60+
61+
const introText = isMultiple
62+
? `Thanks for purchasing ${count} licenses for ${params.productName}! Here are your license keys:`
63+
: `Thanks for purchasing ${params.productName}! Here's your license key:`
64+
3865
await resend.emails.send({
3966
from: `${params.productName} <noreply@getcmdr.com>`,
4067
to: params.to,
41-
subject: `Your ${params.productName} license key 🎉`,
68+
subject,
4269
html: `
4370
<!DOCTYPE html>
4471
<html lang="en">
@@ -47,36 +74,35 @@ export async function sendLicenseEmail(params: EmailParams): Promise<void> {
4774
<style>
4875
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
4976
.license-box { background: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; font-family: monospace; font-size: 18px; text-align: center; letter-spacing: 2px; }
77+
.license-number { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #666; margin-bottom: 8px; letter-spacing: normal; }
5078
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 14px; color: #666; }
5179
.note { background: #e8f4f8; border-left: 4px solid #0ea5e9; padding: 12px 16px; margin: 20px 0; }
5280
</style>
5381
</head>
5482
<body>
5583
<h1>Welcome to ${params.productName}! 🚀</h1>
56-
84+
5785
<p>Hey ${params.customerName},</p>
58-
59-
<p>Thanks for purchasing ${params.productName}! Here's your license key:</p>
60-
61-
<div class="license-box">
62-
${params.licenseKey}
63-
</div>
64-
86+
87+
<p>${introText}</p>
88+
89+
${licenseBoxesHtml}
90+
6591
${orgLine}
66-
92+
6793
<h3>How to activate:</h3>
6894
<ol>
6995
<li>Open ${params.productName}</li>
70-
<li>Go to <strong>Menu → Enter License Key</strong></li>
71-
<li>Paste the key above and click Activate</li>
96+
<li>Go to <strong>Menu → Enter license key</strong></li>
97+
<li>Paste a key and click Activate</li>
7298
</ol>
73-
99+
74100
<p>${licenseDescription}</p>
75-
101+
76102
<div class="note">
77-
<strong>Multiple machines?</strong> Your license lets you run ${params.productName} on multiple machines — like a laptop and desktop for remote debugging — as long as you're the only one using it.
103+
<strong>Multiple machines?</strong> Each license lets you run ${params.productName} on multiple machines — like a laptop and desktop for remote debugging — as long as you're the only one using that license.
78104
</div>
79-
105+
80106
<div class="footer">
81107
<p>Questions? Just reply to this email or contact <a href="mailto:${params.supportEmail}">${params.supportEmail}</a></p>
82108
<p>Happy file managing! ⌘</p>
@@ -89,19 +115,19 @@ Welcome to ${params.productName}!
89115
90116
Hey ${params.customerName},
91117
92-
Thanks for purchasing ${params.productName}! Here's your license key:
118+
${introText}
93119
94-
${params.licenseKey}
120+
${licenseKeysText}
95121
96122
${orgLineText}
97123
How to activate:
98124
1. Open ${params.productName}
99-
2. Go to Menu → Enter License Key
100-
3. Paste the key above and click Activate
125+
2. Go to Menu → Enter license key
126+
3. Paste a key and click Activate
101127
102128
${licenseDescription}
103129
104-
Multiple machines? Your license lets you run ${params.productName} on multiple machines — like a laptop and desktop for remote debugging — as long as you're the one using it.
130+
Multiple machines? Each license lets you run ${params.productName} on multiple machines — like a laptop and desktop for remote debugging — as long as you're the one using that license.
105131
106132
Questions? Contact ${params.supportEmail}
107133

apps/license-server/src/index.ts

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { verifyPaddleWebhookMulti } from './paddle'
55
import {
66
getSubscriptionStatus,
77
getLicenseTypeFromPriceId,
8+
getCustomerDetails,
89
type ValidationResponse,
910
type PriceIdMapping,
1011
} from './paddle-api'
@@ -33,14 +34,12 @@ interface PaddleWebhookPayload {
3334
event_type: string
3435
data?: {
3536
id?: string
36-
customer?: {
37-
email?: string
38-
name?: string
39-
}
37+
customer_id?: string
4038
items?: Array<{
4139
price?: {
4240
id?: string
4341
}
42+
quantity?: number
4443
}>
4544
custom_data?: {
4645
organization_name?: string
@@ -128,22 +127,43 @@ app.post('/webhook/paddle', async (c) => {
128127
c.env.PADDLE_WEBHOOK_SECRET_SANDBOX,
129128
])
130129
if (!isValid) {
130+
console.error('Webhook signature verification failed')
131131
return c.json({ error: 'Invalid signature' }, 401)
132132
}
133133

134134
const payload = JSON.parse(body) as PaddleWebhookPayload
135+
console.log('Received webhook:', payload.event_type)
135136

136137
// Only handle completed purchases
137138
if (payload.event_type !== 'transaction.completed') {
138139
return c.json({ status: 'ignored', event: payload.event_type })
139140
}
140141

141-
// Extract and validate purchase data
142+
// Extract purchase data from webhook
142143
const purchaseData = extractPurchaseData(payload)
143144
if (!purchaseData) {
144-
return c.json({ error: 'Missing customer email or transaction ID' }, 400)
145+
console.error('Missing customer_id or transaction ID in webhook payload')
146+
return c.json({ error: 'Missing customer_id or transaction ID' }, 400)
145147
}
146148

149+
console.log('Processing transaction:', purchaseData.transactionId, 'for customer:', purchaseData.customerId)
150+
151+
// Determine Paddle API config (sandbox vs live based on transaction ID)
152+
const paddleConfig = getPaddleConfig(purchaseData.transactionId, c.env)
153+
if (!paddleConfig) {
154+
console.error('No Paddle API key configured')
155+
return c.json({ error: 'Server configuration error' }, 500)
156+
}
157+
158+
// Fetch customer details from Paddle API
159+
const customer = await getCustomerDetails(purchaseData.customerId, paddleConfig)
160+
if (!customer) {
161+
console.error('Failed to fetch customer details for:', purchaseData.customerId)
162+
return c.json({ error: 'Failed to fetch customer details' }, 500)
163+
}
164+
165+
console.log('Customer email:', customer.email)
166+
147167
// Determine license type from price ID
148168
const priceIds: PriceIdMapping = {
149169
supporter: c.env.PRICE_ID_SUPPORTER,
@@ -154,11 +174,12 @@ app.post('/webhook/paddle', async (c) => {
154174
? getLicenseTypeFromPriceId(purchaseData.priceId, priceIds)
155175
: 'commercial_subscription'
156176

157-
// Generate and send license
158-
const result = await generateAndSendLicense({
159-
customerEmail: purchaseData.customerEmail,
160-
customerName: purchaseData.customerName,
177+
// Generate and send license(s) - one per quantity
178+
const result = await generateAndSendLicenses({
179+
customerEmail: customer.email,
180+
customerName: customer.name ?? 'there',
161181
transactionId: purchaseData.transactionId,
182+
quantity: purchaseData.quantity,
162183
licenseType: licenseType ?? 'commercial_subscription',
163184
organizationName: licenseType !== 'supporter' ? purchaseData.organizationName : undefined,
164185
privateKey: c.env.ED25519_PRIVATE_KEY,
@@ -167,65 +188,72 @@ app.post('/webhook/paddle', async (c) => {
167188
resendApiKey: c.env.RESEND_API_KEY,
168189
})
169190

170-
return c.json({ status: 'ok', email: purchaseData.customerEmail, licenseType: result.licenseType })
191+
console.log('Licenses sent to:', customer.email, 'type:', result.licenseType, 'quantity:', result.quantity)
192+
return c.json({ status: 'ok', email: customer.email, licenseType: result.licenseType, quantity: result.quantity })
171193
})
172194

173-
/** Extract and validate purchase data from webhook payload */
195+
/** Extract purchase data from webhook payload (customer fetched separately via API) */
174196
function extractPurchaseData(payload: PaddleWebhookPayload): {
175-
customerEmail: string
176-
customerName: string
197+
customerId: string
177198
transactionId: string
178199
priceId: string | undefined
200+
quantity: number
179201
organizationName: string | undefined
180202
} | null {
181-
const customerEmail = payload.data?.customer?.email
203+
const customerId = payload.data?.customer_id
182204
const transactionId = payload.data?.id
183205

184-
if (!customerEmail || !transactionId) return null
206+
if (!customerId || !transactionId) return null
185207

186208
return {
187-
customerEmail,
188-
customerName: payload.data?.customer?.name ?? 'there',
209+
customerId,
189210
transactionId,
190211
priceId: payload.data?.items?.[0]?.price?.id,
212+
quantity: payload.data?.items?.[0]?.quantity ?? 1,
191213
organizationName: payload.data?.custom_data?.organization_name,
192214
}
193215
}
194216

195-
/** Helper to generate license and send email */
196-
async function generateAndSendLicense(params: {
217+
/** Helper to generate license(s) and send email */
218+
async function generateAndSendLicenses(params: {
197219
customerEmail: string
198220
customerName: string
199221
transactionId: string
222+
quantity: number
200223
licenseType: LicenseType
201224
organizationName: string | undefined
202225
privateKey: string
203226
productName: string
204227
supportEmail: string
205228
resendApiKey: string
206-
}): Promise<{ licenseType: LicenseType }> {
207-
const licenseData = {
208-
email: params.customerEmail,
209-
transactionId: params.transactionId,
210-
issuedAt: new Date().toISOString(),
211-
type: params.licenseType,
212-
}
229+
}): Promise<{ licenseType: LicenseType; quantity: number }> {
230+
const licenseKeys: string[] = []
231+
232+
for (let i = 0; i < params.quantity; i++) {
233+
const licenseData = {
234+
email: params.customerEmail,
235+
// Each license gets a unique transaction ID suffix for quantity > 1
236+
transactionId: params.quantity > 1 ? `${params.transactionId}-${i + 1}` : params.transactionId,
237+
issuedAt: new Date().toISOString(),
238+
type: params.licenseType,
239+
}
213240

214-
const licenseKey = await generateLicenseKey(licenseData, params.privateKey)
215-
const formattedKey = formatLicenseKey(licenseKey)
241+
const licenseKey = await generateLicenseKey(licenseData, params.privateKey)
242+
licenseKeys.push(formatLicenseKey(licenseKey))
243+
}
216244

217245
await sendLicenseEmail({
218246
to: params.customerEmail,
219247
customerName: params.customerName,
220-
licenseKey: formattedKey,
248+
licenseKeys,
221249
productName: params.productName,
222250
supportEmail: params.supportEmail,
223251
resendApiKey: params.resendApiKey,
224252
organizationName: params.organizationName,
225253
licenseType: params.licenseType,
226254
})
227255

228-
return { licenseType: params.licenseType }
256+
return { licenseType: params.licenseType, quantity: params.quantity }
229257
}
230258

231259
// Manual license generation (for testing or customer service)

apps/license-server/src/paddle-api.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,50 @@ export interface PriceIdMapping {
185185
commercialSubscription?: string
186186
commercialPerpetual?: string
187187
}
188+
189+
/** Customer details from Paddle API */
190+
export interface CustomerDetails {
191+
email: string
192+
name: string | null
193+
}
194+
195+
/**
196+
* Fetch customer details from Paddle API using customer ID.
197+
* Returns null if customer not found or API error.
198+
*/
199+
export async function getCustomerDetails(customerId: string, config: PaddleConfig): Promise<CustomerDetails | null> {
200+
const baseUrl = config.environment === 'sandbox' ? 'https://sandbox-api.paddle.com' : 'https://api.paddle.com'
201+
202+
try {
203+
const response = await fetch(`${baseUrl}/customers/${customerId}`, {
204+
headers: { Authorization: `Bearer ${config.apiKey}` },
205+
})
206+
207+
if (!response.ok) {
208+
console.error('Failed to fetch customer:', response.status)
209+
return null
210+
}
211+
212+
const json: unknown = await response.json()
213+
return extractCustomerData(json)
214+
} catch (error) {
215+
console.error('Paddle API error fetching customer:', error)
216+
return null
217+
}
218+
}
219+
220+
/** Extract customer data from Paddle API response */
221+
function extractCustomerData(json: unknown): CustomerDetails | null {
222+
if (!json || typeof json !== 'object') return null
223+
const obj = json as Record<string, unknown>
224+
225+
if (!obj.data || typeof obj.data !== 'object') return null
226+
const data = obj.data as Record<string, unknown>
227+
228+
const email = typeof data.email === 'string' ? data.email : null
229+
if (!email) return null
230+
231+
const name = typeof data.name === 'string' ? data.name : null
232+
233+
return { email, name }
234+
}

apps/license-server/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ SUPPORT_EMAIL = "veszelovszki@gmail.com"
1616
# - RESEND_API_KEY (from resend.com)
1717
# - PRICE_ID_SUPPORTER (Price ID for $10 supporter product)
1818
# - PRICE_ID_COMMERCIAL_SUBSCRIPTION (Price ID for $59/yr subscription)
19-
# - PRICE_ID_COMMERCIAL_PERPETUAL (Price ID for $149 perpetual)
19+
# - PRICE_ID_COMMERCIAL_PERPETUAL (Price ID for $199 perpetual)

apps/website/.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Paddle checkout configuration
2+
# Get these from https://sandbox-vendors.paddle.com (sandbox) or https://vendors.paddle.com (live)
3+
4+
# Client-side token for Paddle.js (starts with "test_" for sandbox, "live_" for production)
5+
PUBLIC_PADDLE_CLIENT_TOKEN=test_xxx
6+
7+
# Price IDs for each product tier
8+
PUBLIC_PADDLE_PRICE_ID_SUPPORTER=pri_xxx
9+
PUBLIC_PADDLE_PRICE_ID_COMMERCIAL_SUBSCRIPTION=pri_xxx
10+
PUBLIC_PADDLE_PRICE_ID_COMMERCIAL_PERPETUAL=pri_xxx
11+
12+
# Set to "live" for production, "sandbox" for testing
13+
PUBLIC_PADDLE_ENVIRONMENT=sandbox

0 commit comments

Comments
 (0)