This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
# Payment Integration Skill
Comprehensive payment integration skill for SePay (Vietnamese payment gateway), Polar (global SaaS monetization platform), and Stripe (global payment infrastructure).
## Features
### SePay Integration
- Vietnamese payment gateway with VietQR, NAPAS, bank transfers, and cards
- 44+ supported banks
- Webhook verification with API Key/OAuth2 authentication
- QR code generation API
- Order-based virtual accounts
- SDK support for Node.js, PHP, and Laravel
### Polar Integration
- Global SaaS monetization platform
- Merchant of Record (handles global tax compliance)
- Subscription management with trials, upgrades, downgrades
- Usage-based billing with events and meters
- Automated benefit delivery (GitHub repos, Discord roles, license keys, files)
- Customer self-service portal
- Multi-language SDKs (TypeScript, Python, PHP, Go)
- Framework adapters (Next.js, Laravel, Remix, etc.)
### Stripe Integration
- Global payment infrastructure
- CheckoutSessions, PaymentIntents, SetupIntents APIs
- Billing and subscriptions at scale
- Connect for marketplaces and platforms
- Payment Element for custom checkout experiences
- Multi-language SDKs (Node.js, Python, Ruby, PHP, Java, Go, .NET)
- Best practices for integration design and API version upgrades
## Structure
```
payment-integration/
├── SKILL.md # Main skill definition
├── README.md # This file
├── references/ # Progressive disclosure documentation
│ ├── sepay/ # SePay integration guides
│ │ ├── overview.md # Auth, capabilities, environments
│ │ ├── api.md # API endpoints and operations
│ │ ├── webhooks.md # Webhook setup and handling
│ │ ├── sdk.md # SDK usage (Node.js, PHP, Laravel)
│ │ ├── qr-codes.md # VietQR generation
│ │ └── best-practices.md # Security, patterns, monitoring
│ ├── polar/ # Polar integration guides
│ │ ├── overview.md # Auth, MoR concept, environments
│ │ ├── products.md # Products, pricing, usage-based billing
│ │ ├── checkouts.md # Checkout flows and embedded checkout
│ │ ├── subscriptions.md # Lifecycle, upgrades, trials
│ │ ├── webhooks.md # Event handling and verification
│ │ ├── benefits.md # Automated benefit delivery
│ │ ├── sdk.md # Multi-language SDK usage
│ │ └── best-practices.md # Security, patterns, monitoring
│ └── stripe/ # Stripe integration guides
│ ├── stripe-best-practices.md # Integration design, API selection
│ └── stripe-upgrade.md # API versions, SDK upgrades
└── scripts/ # Integration helper scripts
├── sepay-webhook-verify.js # SePay webhook verification
├── polar-webhook-verify.js # Polar webhook verification
├── checkout-helper.js # Checkout session generation
├── test-scripts.js # Test suite for all scripts
├── package.json # Node.js package configuration
└── .env.example # Environment variable template
```
## Usage
### Activate the Skill
Claude Code will automatically activate this skill when you mention payment integration, subscriptions, webhooks, or platform-specific terms (SePay, Polar).
### Manual Activation
In conversations, simply reference the platforms:
- "Implement SePay payment integration"
- "Set up Polar subscriptions with usage-based billing"
- "Create webhook handler for payment notifications"
### Using Scripts
**SePay Webhook Verification:**
```bash
cd $HOME/.opencode/skills/payment-integration/scripts
node sepay-webhook-verify.js '{"id":12345,"gateway":"Vietcombank",...}'
```
**Polar Webhook Verification:**
```bash
node polar-webhook-verify.js '{"type":"order.paid","data":{...}}' base64secret
```
**Checkout Helper:**
```bash
# SePay
node checkout-helper.js sepay '{"orderInvoiceNumber":"ORD001","orderAmount":100000,...}'
# Polar
node checkout-helper.js polar '{"productPriceId":"price_xxx","successUrl":"https://..."}'
```
**Run Tests:**
```bash
npm test
```
## Environment Variables
Copy `.env.example` to `.env` and configure:
```env
# SePay
SEPAY_MERCHANT_ID=SP-TEST-XXXXXXX
SEPAY_SECRET_KEY=spsk_test_xxxxxxxxxxxxx
SEPAY_ENV=sandbox
SEPAY_WEBHOOK_API_KEY=your_key
# Polar
POLAR_ACCESS_TOKEN=polar_xxxxxxxxxxxxxxxx
POLAR_SERVER=sandbox
POLAR_WEBHOOK_SECRET=base64_secret
```
## Progressive Disclosure
The skill uses progressive disclosure to minimize context usage:
1. **SKILL.md** - Overview and quick reference (~99 lines)
2. **references/** - Detailed guides loaded as needed (<100 lines each)
3. **scripts/** - Executable helpers with embedded examples
Load only the references you need for your current task.
## Platform Selection Guide
**Choose SePay for:**
- Vietnamese market targeting
- Bank transfer automation
- Local payment methods
- QR code payments (VietQR/NAPAS)
- Direct bank monitoring
**Choose Polar for:**
- Global market
- SaaS/subscription business
- Usage-based billing
- Automated benefit delivery
- Tax compliance (Merchant of Record)
- Customer self-service
**Choose Stripe for:**
- Global payment infrastructure
- Enterprise-grade payment processing
- Connect platforms (marketplaces)
- Billing/subscriptions at scale
- Custom checkout experiences (Payment Element)
- Maximum payment method coverage
## Examples
### SePay Payment Flow
1. Load `references/sepay/overview.md` for authentication
2. Load `references/sepay/sdk.md` for integration
3. Use `checkout-helper.js` to generate payment form
4. Load `references/sepay/webhooks.md` for notifications
5. Use `sepay-webhook-verify.js` to verify authenticity
### Polar Subscription Flow
1. Load `references/polar/overview.md` for setup
2. Load `references/polar/products.md` for pricing
3. Load `references/polar/checkouts.md` for payment
4. Load `references/polar/subscriptions.md` for lifecycle
5. Load `references/polar/webhooks.md` for events
6. Load `references/polar/benefits.md` for automation
### Stripe Integration Flow
1. Load `references/stripe/stripe-best-practices.md` for integration design
2. Choose: Checkout (hosted/embedded) or Payment Element
3. Use CheckoutSessions API for most use cases
4. Load `references/stripe/stripe-upgrade.md` when upgrading API versions
## Testing
All scripts include comprehensive test coverage:
- SePay webhook verification (with/without authentication)
- Polar webhook signature validation
- Checkout configuration generation
- Error handling and edge cases
Run `npm test` in the scripts directory to verify functionality.
## Support
### SePay
- Docs: https://developer.sepay.vn/en
- Email: info@sepay.vn
- Hotline: 02873059589
### Polar
- Docs: https://polar.sh/docs
- API Reference: https://polar.sh/docs/api-reference
- GitHub: https://github.com/polarsource/polar
### Stripe
- Docs: https://docs.stripe.com
- API Reference: https://docs.stripe.com/api
- Changelog: https://docs.stripe.com/changelog
- Go Live Checklist: https://docs.stripe.com/get-started/checklist/go-live
## License
MIT
## Version
1.1.0

View File

@@ -0,0 +1,105 @@
---
name: ck:payment-integration
description: Integrate payments with SePay (VietQR), Polar, Stripe, Paddle (MoR subscriptions), Creem.io (licensing). Checkout, webhooks, subscriptions, QR codes, multi-provider orders.
license: MIT
argument-hint: "[provider] [task]"
metadata:
author: claudekit
version: "2.2.0"
---
# Payment Integration
Production-proven payment processing with SePay (Vietnamese banks), Polar (global SaaS), Stripe (global infrastructure), Paddle (MoR subscriptions), and Creem.io (MoR + licensing).
## When to Use
- Payment gateway integration (checkout, processing)
- Subscription management (trials, upgrades, billing)
- Webhook handling (notifications, idempotency)
- QR code payments (VietQR, NAPAS)
- Software licensing (device activation)
- Multi-provider order management
- Revenue splits and commissions
## Platform Selection
| Platform | Best For |
|----------|----------|
| **SePay** | Vietnamese market, VND, bank transfers, VietQR |
| **Polar** | Global SaaS, subscriptions, automated benefits (GitHub/Discord) |
| **Stripe** | Enterprise payments, Connect platforms, custom checkout |
| **Paddle** | MoR subscriptions, global tax compliance, churn prevention |
| **Creem.io** | MoR + licensing, revenue splits, no-code checkout |
## Quick Reference
### SePay
- `references/sepay/overview.md` - Auth, supported banks
- `references/sepay/api.md` - Endpoints, transactions
- `references/sepay/webhooks.md` - Setup, verification
- `references/sepay/sdk.md` - Node.js, PHP, Laravel
- `references/sepay/qr-codes.md` - VietQR generation
- `references/sepay/best-practices.md` - Production patterns
### Polar
- `references/polar/overview.md` - Auth, MoR concept
- `references/polar/products.md` - Pricing models
- `references/polar/checkouts.md` - Checkout flows
- `references/polar/subscriptions.md` - Lifecycle management
- `references/polar/webhooks.md` - Event handling
- `references/polar/benefits.md` - Automated delivery
- `references/polar/sdk.md` - Multi-language SDKs
- `references/polar/best-practices.md` - Production patterns
### Stripe
- `references/stripe/stripe-best-practices.md` - Integration design
- `references/stripe/stripe-sdks.md` - Server SDKs
- `references/stripe/stripe-js.md` - Payment Element
- `references/stripe/stripe-cli.md` - Local testing
- `references/stripe/stripe-upgrade.md` - Version upgrades
- External: https://docs.stripe.com/llms.txt
### Paddle
- `references/paddle/overview.md` - MoR, auth, entity IDs
- `references/paddle/api.md` - Products, prices, transactions
- `references/paddle/paddle-js.md` - Checkout overlay/inline
- `references/paddle/subscriptions.md` - Trials, upgrades, pause
- `references/paddle/webhooks.md` - SHA256 verification
- `references/paddle/sdk.md` - Node, Python, PHP, Go
- `references/paddle/best-practices.md` - Production patterns
- External: https://developer.paddle.com/llms.txt
### Creem.io
- `references/creem/overview.md` - MoR, auth, global support
- `references/creem/api.md` - Products, checkout sessions
- `references/creem/checkouts.md` - No-code links, storefronts
- `references/creem/subscriptions.md` - Trials, seat-based
- `references/creem/licensing.md` - Device activation
- `references/creem/webhooks.md` - Signature verification
- `references/creem/sdk.md` - Next.js, Better Auth
- External: https://docs.creem.io/llms.txt
### Multi-Provider
- `references/multi-provider-order-management-patterns.md` - Unified orders, currency conversion
### Scripts
- `scripts/sepay-webhook-verify.js` - SePay webhook verification
- `scripts/polar-webhook-verify.js` - Polar webhook verification
- `scripts/checkout-helper.js` - Checkout session generator
## Key Capabilities
| Platform | Highlights |
|----------|------------|
| **SePay** | QR/bank/cards, 44+ VN banks, webhooks, 2 req/s |
| **Polar** | MoR, subscriptions, usage billing, benefits, 300 req/min |
| **Stripe** | CheckoutSessions, Billing, Connect, Payment Element |
| **Paddle** | MoR, overlay/inline checkout, Retain (churn prevention), tax |
| **Creem.io** | MoR, licensing, revenue splits, no-code checkout |
## Implementation
See `references/implementation-workflows.md` for step-by-step guides per platform.
**General flow:** auth → products → checkout → webhooks → events

View File

@@ -0,0 +1,139 @@
# Creem.io API Reference
## Checkout Sessions
### Create Checkout Session
```javascript
// POST /v1/checkout/sessions
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
customer_email: 'user@example.com', // Optional
metadata: { order_id: '123' } // Optional
});
// Returns: { url: 'https://checkout.creem.io/xxx', id: 'cs_xxx' }
```
## Products
### Create Product
```javascript
// POST /v1/products
const product = await creem.products.create({
name: 'Pro Plan',
description: 'Full access to all features',
price: 2900, // Amount in cents
currency: 'usd',
recurring: { // Optional - for subscriptions
interval: 'month',
interval_count: 1
}
});
```
### Retrieve Product
```javascript
// GET /v1/products/:id
const product = await creem.products.retrieve('prod_xxx');
```
## Transactions
### Retrieve Transaction
```javascript
// GET /v1/transactions/:id
const transaction = await creem.transactions.retrieve('txn_xxx');
```
### List Transactions
```javascript
// GET /v1/transactions
const transactions = await creem.transactions.list({
customer_id: 'cus_xxx', // Optional filter
product_id: 'prod_xxx', // Optional filter
status: 'completed', // Optional filter
limit: 25,
starting_after: 'txn_xxx' // Pagination cursor
});
```
## Customers
### Retrieve Customer
```javascript
// GET /v1/customers/:id
const customer = await creem.customers.retrieve('cus_xxx');
// GET /v1/customers/email/:email
const customer = await creem.customers.retrieveByEmail('user@example.com');
```
### List Customers
```javascript
// GET /v1/customers
const customers = await creem.customers.list({
limit: 25,
starting_after: 'cus_xxx'
});
```
### Generate Portal Link
```javascript
// POST /v1/customers/:id/portal
const portal = await creem.customers.createPortalSession('cus_xxx');
// Returns: { url: 'https://portal.creem.io/xxx' }
```
## Discount Codes
### Create Discount
```javascript
// POST /v1/discounts
const discount = await creem.discounts.create({
code: 'LAUNCH20',
type: 'percentage', // or 'fixed'
value: 20, // 20% or 20 cents
expires_at: '2024-12-31T23:59:59Z',
max_redemptions: 100 // Optional
});
```
### Retrieve Discount
```javascript
// GET /v1/discounts/:code
const discount = await creem.discounts.retrieve('LAUNCH20');
```
### Delete Discount
```javascript
// DELETE /v1/discounts/:code
await creem.discounts.delete('LAUNCH20');
```
## Error Handling
```javascript
try {
const session = await creem.checkout.sessions.create({...});
} catch (error) {
if (error.type === 'invalid_request_error') {
console.error('Invalid parameters:', error.message);
} else if (error.type === 'authentication_error') {
console.error('Invalid API key');
} else if (error.type === 'rate_limit_error') {
console.error('Rate limited, retry after:', error.retry_after);
}
}
```

View File

@@ -0,0 +1,99 @@
# Creem.io Checkouts
## Checkout Options
1. **Programmatic Sessions** - Full API control
2. **Checkout Links** - No-code, shareable URLs
3. **Storefronts** - Hosted product pages
## Create Checkout Session
```javascript
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cancel',
// Optional parameters
customer_email: 'user@example.com',
customer_id: 'cus_xxx', // Existing customer
quantity: 1, // For seat-based products
discount_code: 'LAUNCH20', // Pre-apply discount
metadata: {
order_id: '123',
referral_code: 'abc'
},
// Custom fields
custom_fields: [
{ key: 'company', label: 'Company Name', required: true }
]
});
// Redirect user to checkout
redirect(session.url);
```
## Checkout Customization
Configure in dashboard or via API:
- **Branding**: Logo, colors, themes
- **Email Receipts**: Custom templates
- **Localization**: Auto-detect or force language (42 supported)
- **Custom Fields**: Collect additional data
## Retrieve Session
```javascript
// GET /v1/checkout/sessions/:id
const session = await creem.checkout.sessions.retrieve('cs_xxx');
// Returns: { id, status, customer_id, product_id, amount, metadata, ... }
```
## Success URL Parameters
Creem replaces `{CHECKOUT_SESSION_ID}` in success URL:
```javascript
// Frontend: parse session ID from URL
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');
// Backend: verify and fulfill
const session = await creem.checkout.sessions.retrieve(sessionId);
if (session.status === 'complete') {
await fulfillOrder(session);
}
```
## No-Code Checkout Links
Create in dashboard - shareable URLs for any product. Good for:
- Social media links
- Email campaigns
- Quick sales without integration
## Storefronts
Hosted product pages - display multiple products without custom website:
1. Configure storefront in dashboard
2. Add products to display
3. Share storefront URL
4. Customers browse and checkout
## Cart Abandonment Recovery
Enable in dashboard - automatic emails sent when checkout abandoned:
- Configurable delay before sending
- Customizable email content
- Include discount code incentive
## Embedding (Coming)
For embedded checkout in your site, see SDK adapters:
- Next.js Adapter
- React components
See `references/creem/sdk.md` for implementation details.

View File

@@ -0,0 +1,136 @@
# Creem.io Licensing
Software licensing with device activation management.
## License Flow
```
purchase → license_key issued → activate device → validate → deactivate
```
## Activate License
Register a device against a license key:
```javascript
// POST /v1/licenses/activate
const activation = await creem.licenses.activate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123', // Unique device identifier
instance_name: 'MacBook Pro' // Optional friendly name
});
// Returns: {
// id: 'act_xxx',
// license_key: '...',
// instance_id: '...',
// activated_at: '...',
// valid_until: '...'
// }
```
## Validate License
Check if license is active for specific device:
```javascript
// POST /v1/licenses/validate
const validation = await creem.licenses.validate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123'
});
// Returns: {
// valid: true,
// license_key: '...',
// product_id: 'prod_xxx',
// customer_id: 'cus_xxx',
// expires_at: '2025-01-15T00:00:00Z',
// activations_used: 2,
// activations_limit: 5
// }
```
## Deactivate License
Remove device activation to free slot:
```javascript
// POST /v1/licenses/deactivate
await creem.licenses.deactivate({
license_key: 'XXXX-XXXX-XXXX-XXXX',
instance_id: 'device_fingerprint_123'
});
```
## Client-Side Implementation
```javascript
// Desktop app example (Electron, Tauri, etc.)
class LicenseManager {
constructor(apiKey) {
this.apiKey = apiKey;
this.instanceId = this.getDeviceFingerprint();
}
getDeviceFingerprint() {
// Generate unique device ID (machine ID, hardware hash, etc.)
return require('node-machine-id').machineIdSync();
}
async activate(licenseKey) {
const response = await fetch('https://api.creem.io/v1/licenses/activate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: licenseKey,
instance_id: this.instanceId,
instance_name: os.hostname()
})
});
return response.json();
}
async validate(licenseKey) {
const response = await fetch('https://api.creem.io/v1/licenses/validate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: licenseKey,
instance_id: this.instanceId
})
});
const data = await response.json();
return data.valid;
}
}
```
## Activation Limits
Configure per product - limits simultaneous device activations:
```javascript
const product = await creem.products.create({
name: 'Desktop App License',
price: 4900,
currency: 'usd',
license_config: {
activations_limit: 3 // Max 3 devices per license
}
});
```
## License Events (Webhooks)
- `license.activated` - Device activated
- `license.deactivated` - Device deactivated
- `license.expired` - License expired (subscription ended)
See `references/creem/webhooks.md` for webhook handling.

View File

@@ -0,0 +1,65 @@
# Creem.io Overview
Payment infrastructure platform supporting subscriptions, one-time payments, and licensing. Functions as Merchant of Record (MoR) - handles compliance, taxes, and payment processing.
## Key Features
- **Merchant of Record**: Tax compliance, payment processing, global coverage
- **Subscriptions**: Recurring billing, trials, seat-based, prorations
- **One-Time Payments**: Single charges for products/services
- **Licensing**: Activation keys, device management, validation
- **Checkouts**: Hosted, embedded, no-code options
- **Customer Portal**: Self-service billing management
- **Revenue Splits**: Automatic payment distribution to multiple recipients
## When to Choose Creem
- Global SaaS products requiring MoR
- Software licensing with activation management
- Subscription products with seat-based billing
- Digital product sales with file delivery
- Affiliate/commission programs
- Multi-recipient revenue splitting
## Authentication
```bash
# API Key authentication
curl -H "Authorization: Bearer sk_live_xxx" https://api.creem.io/v1/...
```
Environment variables:
```bash
CREEM_API_KEY=sk_live_xxx # Production
CREEM_API_KEY=sk_test_xxx # Test mode
CREEM_WEBHOOK_SECRET=whsec_xxx # Webhook verification
```
## API Base URLs
- **Production**: `https://api.creem.io/v1`
- **Test Mode**: Use `sk_test_` prefixed API keys
## Rate Limits
Standard API rate limits apply. Check response headers for limit status.
## Global Support
- **Customers**: Hundreds of countries supported
- **Merchants**: Global payouts
- **Languages**: 42 languages for checkout localization
## Related References
- **API Endpoints**: `references/creem/api.md`
- **Webhooks**: `references/creem/webhooks.md`
- **Checkouts**: `references/creem/checkouts.md`
- **Subscriptions**: `references/creem/subscriptions.md`
- **Licensing**: `references/creem/licensing.md`
- **SDKs**: `references/creem/sdk.md`
## External Resources
- **Documentation**: https://docs.creem.io
- **LLM Docs**: https://docs.creem.io/llms.txt

View File

@@ -0,0 +1,161 @@
# Creem.io SDKs
## Official SDKs
### Core SDK (`creem`)
Full API access with maximum flexibility:
```bash
npm install creem
# or
pip install creem
```
```javascript
// Node.js
import Creem from 'creem';
const creem = new Creem({
apiKey: process.env.CREEM_API_KEY
});
// Create checkout
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
success_url: 'https://example.com/success'
});
```
```python
# Python
from creem import Creem
creem = Creem(api_key=os.environ['CREEM_API_KEY'])
session = creem.checkout.sessions.create(
product_id='prod_xxx',
success_url='https://example.com/success'
)
```
### Wrapper SDK (`creem_io`)
Helper functions for common operations:
```bash
npm install creem_io
```
```javascript
import { CreemClient, verifyWebhook } from 'creem_io';
const client = new CreemClient({
apiKey: process.env.CREEM_API_KEY,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET
});
// Simplified webhook verification
app.post('/webhook', async (req, res) => {
const event = client.verifyWebhook(req.body, req.headers['x-creem-signature']);
// Handle event...
});
// Access management helpers
const hasAccess = await client.checkAccess(customerId, productId);
```
## Framework Adapters
### Next.js Adapter
End-to-end billing integration:
```bash
npm install @creem/nextjs
```
```typescript
// app/api/checkout/route.ts
import { createCheckout } from '@creem/nextjs';
export const POST = createCheckout({
productId: 'prod_xxx',
successUrl: '/success',
cancelUrl: '/pricing'
});
// app/api/webhooks/creem/route.ts
import { handleWebhook } from '@creem/nextjs';
export const POST = handleWebhook({
onCheckoutCompleted: async (session) => {
await grantAccess(session.customer_id);
},
onSubscriptionCancelled: async (subscription) => {
await revokeAccess(subscription.customer_id);
}
});
```
### Better Auth Integration
Combined auth + payments:
```bash
npm install @creem/better-auth
```
```typescript
import { betterAuth } from 'better-auth';
import { creemPlugin } from '@creem/better-auth';
export const auth = betterAuth({
plugins: [
creemPlugin({
apiKey: process.env.CREEM_API_KEY,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
products: {
pro: 'prod_xxx',
enterprise: 'prod_yyy'
}
})
]
});
// Check subscription in auth session
const session = await auth.getSession();
if (session.user.subscription?.status === 'active') {
// User has active subscription
}
```
### Next.js Template
Pre-built starter with Prisma, shadcn/ui, Tailwind:
```bash
npx create-creem-app my-saas
# or
git clone https://github.com/creem-io/nextjs-template
```
Includes:
- Auth (Better Auth)
- Database (Prisma)
- UI (shadcn/ui, Tailwind)
- Pricing page
- Customer portal
- Webhook handling
## Environment Variables
```bash
# .env
CREEM_API_KEY=sk_live_xxx # or sk_test_xxx for test mode
CREEM_WEBHOOK_SECRET=whsec_xxx
```
## AI Tool Integration
Creem supports Claude Code, Cursor, Windsurf via official skill - this document is part of that integration.

View File

@@ -0,0 +1,129 @@
# Creem.io Subscriptions
## Subscription Lifecycle
```
create → active → [pause] → [resume] → [upgrade] → cancel
```
## Create Subscription
Via checkout session with recurring product:
```javascript
const session = await creem.checkout.sessions.create({
product_id: 'prod_recurring_xxx',
success_url: 'https://example.com/success',
customer_email: 'user@example.com'
});
```
## Retrieve Subscription
```javascript
// GET /v1/subscriptions/:id
const subscription = await creem.subscriptions.retrieve('sub_xxx');
// Returns: { id, status, product_id, current_period_end, ... }
```
## Modify Subscription
### Update Seats/Units
```javascript
// PATCH /v1/subscriptions/:id
const updated = await creem.subscriptions.update('sub_xxx', {
quantity: 10, // Seat count
prorate: true, // Prorate charges
billing_immediately: false
});
```
### Upgrade/Downgrade
```javascript
const updated = await creem.subscriptions.update('sub_xxx', {
product_id: 'prod_higher_tier',
prorate: true
});
```
## Pause Subscription
```javascript
// POST /v1/subscriptions/:id/pause
const paused = await creem.subscriptions.pause('sub_xxx', {
resume_at: '2024-02-01T00:00:00Z' // Optional auto-resume date
});
```
## Resume Subscription
```javascript
// POST /v1/subscriptions/:id/resume
const resumed = await creem.subscriptions.resume('sub_xxx');
```
## Cancel Subscription
```javascript
// POST /v1/subscriptions/:id/cancel
const cancelled = await creem.subscriptions.cancel('sub_xxx', {
at_period_end: true // false = immediate cancellation
});
```
## Free Trials
Configure on product level:
```javascript
const product = await creem.products.create({
name: 'Pro Plan',
price: 2900,
currency: 'usd',
recurring: { interval: 'month' },
trial_period_days: 14
});
```
## Seat-Based Billing
```javascript
const product = await creem.products.create({
name: 'Team Plan',
price: 1000, // Per seat price
currency: 'usd',
recurring: { interval: 'month' },
billing_scheme: 'per_unit'
});
// Checkout with quantity
const session = await creem.checkout.sessions.create({
product_id: 'prod_xxx',
quantity: 5, // 5 seats
success_url: '...'
});
```
## Product Bundles
Group related tiers for upsells:
```javascript
const bundle = await creem.bundles.create({
name: 'Growth Plans',
products: ['prod_starter', 'prod_pro', 'prod_enterprise']
});
```
## Subscription Events (Webhooks)
- `subscription.created` - New subscription started
- `subscription.updated` - Quantity, product, or status changed
- `subscription.paused` - Subscription paused
- `subscription.resumed` - Subscription resumed
- `subscription.cancelled` - Cancellation scheduled or completed
- `subscription.renewed` - Successful renewal charge
See `references/creem/webhooks.md` for webhook handling.

View File

@@ -0,0 +1,120 @@
# Creem.io Webhooks
## Webhook Setup
Configure webhook endpoint in Creem dashboard. Receive events at your endpoint URL.
## Event Types
### Checkout Events
- `checkout.completed` - Payment successful, access granted
- `checkout.abandoned` - Cart abandoned (triggers recovery emails if enabled)
### Subscription Events
- `subscription.created` - New subscription started
- `subscription.updated` - Changes to quantity, product, status
- `subscription.paused` - Subscription paused
- `subscription.resumed` - Subscription resumed
- `subscription.cancelled` - Cancellation scheduled/completed
- `subscription.renewed` - Successful renewal charge
### Payment Events
- `payment.succeeded` - Charge successful
- `payment.failed` - Charge failed
- `refund.created` - Refund processed
- `chargeback.created` - Dispute opened
### License Events
- `license.activated` - Device activated against license
- `license.deactivated` - Device deactivated
## Webhook Payload Structure
```json
{
"id": "evt_xxx",
"type": "checkout.completed",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"object": {
"id": "cs_xxx",
"customer_id": "cus_xxx",
"product_id": "prod_xxx",
"amount": 2900,
"currency": "usd",
"metadata": { "order_id": "123" }
}
}
}
```
## Signature Verification
```javascript
import crypto from 'crypto';
function verifyWebhook(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express handler
app.post('/webhooks/creem', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-creem-signature'];
const payload = req.body.toString();
if (!verifyWebhook(payload, signature, process.env.CREEM_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
switch (event.type) {
case 'checkout.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data.object);
break;
}
res.status(200).send('OK');
});
```
## Idempotency
Store processed event IDs to prevent duplicate processing:
```javascript
async function handleWebhook(event) {
// Check if already processed
const existing = await db.webhookEvents.findOne({ eventId: event.id });
if (existing) return { status: 'already_processed' };
// Process event
await processEvent(event);
// Mark as processed
await db.webhookEvents.create({
eventId: event.id,
type: event.type,
processedAt: new Date()
});
}
```
## Retry Behavior
Creem retries failed webhooks (non-2xx responses). Implement idempotency to handle retries safely.
## Testing Webhooks
Use test mode API keys (`sk_test_`) - events sent to same webhook endpoint with test data.

View File

@@ -0,0 +1,43 @@
# Implementation Workflows
## SePay Implementation
1. Load `references/sepay/overview.md` for auth setup
2. Load `references/sepay/api.md` or `references/sepay/sdk.md` for integration
3. Load `references/sepay/webhooks.md` for payment notifications
4. Use `scripts/sepay-webhook-verify.js` for webhook verification
5. Load `references/sepay/best-practices.md` for production readiness
## Polar Implementation
1. Load `references/polar/overview.md` for auth and concepts
2. Load `references/polar/products.md` for product setup
3. Load `references/polar/checkouts.md` for payment flows
4. Load `references/polar/webhooks.md` for event handling
5. Use `scripts/polar-webhook-verify.js` for webhook verification
6. Load `references/polar/benefits.md` if automating delivery
7. Load `references/polar/best-practices.md` for production readiness
## Stripe Implementation
1. Load `references/stripe/stripe-best-practices.md` for integration design
2. Load `references/stripe/stripe-sdks.md` for server-side SDK setup
3. Load `references/stripe/stripe-js.md` for client-side Elements/Checkout
4. Use `stripe listen` via CLI for local webhook testing (`references/stripe/stripe-cli.md`)
5. Choose integration: Checkout (hosted/embedded) or Payment Element
6. Use CheckoutSessions API for most payment flows
7. Use Billing APIs for subscriptions (combine with Checkout)
8. Load `references/stripe/stripe-upgrade.md` when upgrading API versions
## Creem.io Implementation
1. Load `references/creem/overview.md` for auth and MoR concepts
2. Load `references/creem/api.md` for products and checkout sessions
3. Load `references/creem/checkouts.md` for payment flow options
4. Load `references/creem/webhooks.md` for event handling
5. Load `references/creem/subscriptions.md` if implementing recurring billing
6. Load `references/creem/licensing.md` if implementing device activation
7. Load `references/creem/sdk.md` for framework-specific adapters
## General Workflow
1. Identify platform (Vietnamese → SePay, global SaaS → Polar/Stripe/Creem.io)
2. Load relevant references progressively
3. Implement: auth → products → checkout → webhooks → events
4. Test in sandbox, then production
5. Load only needed references to maintain context efficiency

View File

@@ -0,0 +1,821 @@
# Multi-Provider Order Management Patterns
Production patterns for managing orders across multiple payment providers (Polar + SePay), currency handling, commission systems, and revenue tracking.
## Order Schema Design
### Unified Orders Table
```typescript
// db/schema/orders.ts
import { pgTable, uuid, text, integer, numeric, timestamp, boolean } from 'drizzle-orm/pg-core';
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id),
email: text('email').notNull(),
// Product info
productType: text('product_type').notNull(), // 'engineer_kit', 'marketing_kit', 'combo', 'team_*'
quantity: integer('quantity').default(1),
// Pricing (stored in provider's currency)
amount: integer('amount').notNull(), // Final amount after discounts
originalAmount: integer('original_amount'), // Before any discounts
currency: text('currency').default('USD'), // 'USD' or 'VND'
// Status
status: text('status').default('pending'), // pending, completed, failed, refunded
// Provider info
paymentProvider: text('payment_provider').notNull(), // 'polar' or 'sepay'
paymentId: text('payment_id'), // External payment/transaction ID
// Referral tracking
referredBy: uuid('referred_by').references(() => users.id),
discountAmount: integer('discount_amount').default(0),
discountRate: numeric('discount_rate', { precision: 5, scale: 2 }),
// Audit trail (JSON)
metadata: text('metadata'),
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
```
### Provider-Specific Metadata
```typescript
// Polar order metadata
interface PolarOrderMetadata {
originalAmount: number;
couponCode?: string;
couponDiscountAmount?: number;
referralCode?: string;
referralDiscountAmount?: number;
referrerId?: string;
githubUsername: string;
polarDiscountId?: string;
polarDiscountSynced?: boolean;
polarDiscountSyncAction?: 'decremented' | 'deleted' | 'already_deleted';
polarDiscountSyncedAt?: string;
isTeamPurchase?: boolean;
teamId?: string;
}
// SePay order metadata
interface SepayOrderMetadata {
originalAmount: number;
couponCode?: string;
couponDiscountAmount?: number;
couponId?: string; // For Polar discount sync
referralCode?: string;
referralDiscountAmount?: number;
referrerId?: string;
githubUsername: string;
vatInvoiceRequested?: boolean;
encryptedTaxId?: string;
// Added by webhook
gateway?: string;
transactionDate?: string;
transactionId?: number;
transferAmount?: number;
matchMethod?: string;
content?: string;
}
```
## Currency Conversion
### Multi-Layer Fallback Architecture
```typescript
// lib/currency.ts
const EXCHANGE_RATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const FALLBACK_RATES = {
VND_TO_USD: 24500, // Conservative estimate
USD_TO_VND: 24500,
};
interface ExchangeRateCache {
rates: { VND: number; USD: number };
timestamp: number;
source: 'api' | 'cached' | 'expired' | 'fallback';
}
let rateCache: ExchangeRateCache | null = null;
export async function getExchangeRates(): Promise<ExchangeRateCache> {
const now = Date.now();
// Layer 1: Fresh cache (< 1 hour)
if (rateCache && now - rateCache.timestamp < EXCHANGE_RATE_CACHE_TTL) {
return { ...rateCache, source: 'cached' };
}
// Layer 2: Live API
try {
const response = await fetch(
'https://api.exchangerate-api.com/v4/latest/USD',
{ signal: AbortSignal.timeout(5000) }
);
const data = await response.json();
rateCache = {
rates: { VND: data.rates.VND, USD: 1 },
timestamp: now,
source: 'api',
};
return rateCache;
} catch (error) {
console.warn('Exchange rate API failed:', error);
// Layer 3: Expired cache (better than nothing)
if (rateCache) {
return { ...rateCache, source: 'expired' };
}
// Layer 4: Hardcoded fallback
return {
rates: { VND: FALLBACK_RATES.VND_TO_USD, USD: 1 },
timestamp: now,
source: 'fallback',
};
}
}
export async function convertVndToUsd(vndAmount: number): Promise<{
usdCents: number;
rate: number;
source: string;
}> {
const { rates, source } = await getExchangeRates();
const usdCents = Math.round((vndAmount / rates.VND) * 100);
return { usdCents, rate: rates.VND, source };
}
export async function convertUsdToVnd(usdCents: number): Promise<{
vndAmount: number;
rate: number;
source: string;
}> {
const { rates, source } = await getExchangeRates();
const vndAmount = Math.round((usdCents / 100) * rates.VND);
return { vndAmount, rate: rates.VND, source };
}
```
### Normalizing Revenue to USD
```typescript
// For reporting/dashboard - normalize all revenue to USD cents
export async function normalizeOrderToUsd(order: Order): Promise<{
amountUsdCents: number;
originalAmountUsdCents: number;
conversionSource: string;
}> {
if (order.currency === 'USD') {
return {
amountUsdCents: order.amount,
originalAmountUsdCents: order.originalAmount || order.amount,
conversionSource: 'native',
};
}
// VND order
const conversion = await convertVndToUsd(order.amount);
const originalConversion = order.originalAmount
? await convertVndToUsd(order.originalAmount)
: conversion;
return {
amountUsdCents: conversion.usdCents,
originalAmountUsdCents: originalConversion.usdCents,
conversionSource: conversion.source,
};
}
```
## Commission System
### Commission Schema
```typescript
// db/schema/commissions.ts
export const commissions = pgTable('commissions', {
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id').references(() => orders.id).notNull(),
referrerId: uuid('referrer_id').references(() => users.id).notNull(),
referredUserId: uuid('referred_user_id').references(() => users.id).notNull(),
referralCodeId: uuid('referral_code_id').references(() => referralCodes.id),
// Amount in original currency
orderAmount: integer('order_amount').notNull(), // Base amount for commission
orderCurrency: text('order_currency').notNull(), // 'USD' or 'VND'
// Commission calculation
commissionRate: numeric('commission_rate', { precision: 5, scale: 4 }).default('0.20'), // 20%
commissionAmount: integer('commission_amount').notNull(),
commissionCurrency: text('commission_currency').notNull(),
// Normalized USD (for tier tracking)
orderAmountUsdCents: integer('order_amount_usd_cents'),
commissionAmountUsdCents: integer('commission_amount_usd_cents'),
exchangeRateSource: text('exchange_rate_source'),
// Status
status: text('status').default('pending'), // pending, approved, paid, cancelled
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
approvedAt: timestamp('approved_at'),
paidAt: timestamp('paid_at'),
cancelledAt: timestamp('cancelled_at'),
});
```
### Creating Commission (Multi-Currency)
```typescript
// lib/commissions.ts
export async function createCommission(params: {
orderId: string;
referrerId: string;
referredUserId: string;
referralCodeId: string;
orderAmount: number;
orderCurrency: 'USD' | 'VND';
commissionRate?: number;
}): Promise<Commission> {
const rate = params.commissionRate || 0.20; // Default 20%
// Calculate commission in original currency
const commissionAmount = Math.round(params.orderAmount * rate);
// Convert to USD for tier tracking
let orderAmountUsdCents: number;
let commissionAmountUsdCents: number;
let exchangeRateSource: string;
if (params.orderCurrency === 'USD') {
orderAmountUsdCents = params.orderAmount;
commissionAmountUsdCents = commissionAmount;
exchangeRateSource = 'native';
} else {
const conversion = await convertVndToUsd(params.orderAmount);
orderAmountUsdCents = conversion.usdCents;
commissionAmountUsdCents = Math.round(conversion.usdCents * rate);
exchangeRateSource = conversion.source;
}
const [commission] = await db.insert(commissions).values({
orderId: params.orderId,
referrerId: params.referrerId,
referredUserId: params.referredUserId,
referralCodeId: params.referralCodeId,
orderAmount: params.orderAmount,
orderCurrency: params.orderCurrency,
commissionRate: String(rate),
commissionAmount,
commissionCurrency: params.orderCurrency,
orderAmountUsdCents,
commissionAmountUsdCents,
exchangeRateSource,
status: 'pending',
}).returning();
// Update referrer's tier based on USD revenue
await updateReferrerTier(params.referrerId, orderAmountUsdCents);
return commission;
}
```
### Referrer Tier System
```typescript
// lib/referrals.ts
const TIER_THRESHOLDS = [
{ tier: 'bronze', minRevenue: 0, commissionRate: 0.20 },
{ tier: 'silver', minRevenue: 50000, commissionRate: 0.25 }, // $500
{ tier: 'gold', minRevenue: 150000, commissionRate: 0.30 }, // $1,500
{ tier: 'platinum', minRevenue: 500000, commissionRate: 0.35 }, // $5,000
];
export async function updateReferrerTier(
referrerId: string,
newRevenueUsdCents: number
): Promise<void> {
const referrer = await db.select()
.from(users)
.where(eq(users.id, referrerId))
.limit(1);
if (!referrer[0]) return;
const currentRevenue = referrer[0].referralRevenueUsdCents || 0;
const totalRevenue = currentRevenue + newRevenueUsdCents;
// Determine new tier
let newTier = 'bronze';
let newRate = 0.20;
for (const threshold of TIER_THRESHOLDS) {
if (totalRevenue >= threshold.minRevenue) {
newTier = threshold.tier;
newRate = threshold.commissionRate;
}
}
// Update if tier changed
if (referrer[0].referralTier !== newTier) {
await db.update(users)
.set({
referralTier: newTier,
referralCommissionRate: String(newRate),
referralRevenueUsdCents: totalRevenue,
updatedAt: new Date(),
})
.where(eq(users.id, referrerId));
// Send tier upgrade notification
if (TIER_THRESHOLDS.findIndex(t => t.tier === newTier) >
TIER_THRESHOLDS.findIndex(t => t.tier === referrer[0].referralTier)) {
await sendTierUpgradeEmail(referrerId, newTier, newRate);
}
} else {
// Just update revenue
await db.update(users)
.set({
referralRevenueUsdCents: totalRevenue,
updatedAt: new Date(),
})
.where(eq(users.id, referrerId));
}
}
```
## Revenue Tracking
### Combined Provider Revenue
```typescript
// lib/revenue.ts
export async function getTotalRevenue(options?: {
startDate?: Date;
endDate?: Date;
}): Promise<{
totalUsdCents: number;
byProvider: { polar: number; sepay: number };
orderCount: number;
averageOrderValueCents: number;
}> {
let query = db.select()
.from(orders)
.where(eq(orders.status, 'completed'));
if (options?.startDate) {
query = query.where(gte(orders.createdAt, options.startDate));
}
if (options?.endDate) {
query = query.where(lte(orders.createdAt, options.endDate));
}
const completedOrders = await query;
let totalUsdCents = 0;
let polarUsdCents = 0;
let sepayUsdCents = 0;
for (const order of completedOrders) {
const normalized = await normalizeOrderToUsd(order);
totalUsdCents += normalized.amountUsdCents;
if (order.paymentProvider === 'polar') {
polarUsdCents += normalized.amountUsdCents;
} else {
sepayUsdCents += normalized.amountUsdCents;
}
}
return {
totalUsdCents,
byProvider: { polar: polarUsdCents, sepay: sepayUsdCents },
orderCount: completedOrders.length,
averageOrderValueCents: completedOrders.length > 0
? Math.round(totalUsdCents / completedOrders.length)
: 0,
};
}
```
### Maintainer Revenue Calculation
```typescript
// lib/maintainer-revenue.ts
// Calculate actual payout after fees and costs
interface MaintainerRevenue {
grossRevenue: number; // Total received
platformFees: number; // Polar/Stripe fees
operatingCosts: number; // Proportional costs
taxDeduction: number; // 17% tax
netPayout: number; // Final amount
currency: 'USD';
}
export async function calculateMaintainerRevenue(
productIds: string[],
dateRange: { start: Date; end: Date }
): Promise<MaintainerRevenue> {
// Get orders for these products
const orders = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'completed'),
inArray(orders.productType, productIds),
gte(orders.createdAt, dateRange.start),
lte(orders.createdAt, dateRange.end)
));
let grossRevenue = 0;
let platformFees = 0;
for (const order of orders) {
const normalized = await normalizeOrderToUsd(order);
grossRevenue += normalized.amountUsdCents;
if (order.paymentProvider === 'polar') {
const fees = calculatePolarFees(normalized.amountUsdCents);
platformFees += fees.totalFee;
}
// SePay has no platform fees (direct bank transfer)
}
// Proportional operating costs (hosting, services, etc.)
const monthlyOperatingCosts = 50000; // $500/month in cents
const totalMonthlyRevenue = await getTotalRevenue({
startDate: dateRange.start,
endDate: dateRange.end,
});
const costRatio = grossRevenue / (totalMonthlyRevenue.totalUsdCents || 1);
const operatingCosts = Math.round(monthlyOperatingCosts * costRatio);
// Tax deduction (17%)
const afterCosts = grossRevenue - platformFees - operatingCosts;
const taxDeduction = Math.round(afterCosts * 0.17);
const netPayout = afterCosts - taxDeduction;
return {
grossRevenue,
platformFees,
operatingCosts,
taxDeduction,
netPayout,
currency: 'USD',
};
}
```
## Refund Handling
### Unified Refund Flow
```typescript
// lib/refunds.ts
export async function processRefund(
orderId: string,
options: { keepAccess?: boolean; reason?: string }
): Promise<{ success: boolean; error?: string }> {
const order = await db.select()
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);
if (!order[0]) {
return { success: false, error: 'Order not found' };
}
if (order[0].status !== 'completed') {
return { success: false, error: 'Order not refundable' };
}
try {
// 1. Process refund with payment provider
if (order[0].paymentProvider === 'polar') {
await polar.orders.refund({ id: order[0].paymentId! });
} else {
// SePay: Manual bank transfer refund required
// Just mark order, admin handles bank transfer
console.log(`Manual refund needed for SePay order ${orderId}`);
}
// 2. Update order status
await db.update(orders)
.set({
status: 'refunded',
metadata: JSON.stringify({
...JSON.parse(order[0].metadata || '{}'),
refundedAt: new Date().toISOString(),
refundReason: options.reason,
keepAccess: options.keepAccess,
}),
updatedAt: new Date(),
})
.where(eq(orders.id, orderId));
// 3. Cancel commission (if any)
if (order[0].referredBy) {
await db.update(commissions)
.set({
status: 'cancelled',
cancelledAt: new Date(),
})
.where(eq(commissions.orderId, orderId));
// Recalculate referrer tier
await recalculateReferrerTier(order[0].referredBy);
}
// 4. Revoke access (unless keepAccess)
if (!options.keepAccess) {
const metadata = JSON.parse(order[0].metadata || '{}');
if (metadata.githubUsername) {
await revokeGitHubAccess(metadata.githubUsername, order[0].productType);
}
await db.update(licenses)
.set({ isActive: false, revokedAt: new Date() })
.where(eq(licenses.orderId, orderId));
}
return { success: true };
} catch (error) {
console.error('Refund failed:', error);
return { success: false, error: error instanceof Error ? error.message : 'Refund failed' };
}
}
```
## Webhook Event Tracking
### Unified Webhook Events Table
```typescript
// db/schema/webhook-events.ts
export const webhookEvents = pgTable('webhook_events', {
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(), // 'polar' or 'sepay'
eventType: text('event_type').notNull(), // Event type/name
eventId: text('event_id').notNull().unique(), // Idempotency key
payload: text('payload').notNull(), // Raw JSON payload
processed: boolean('processed').default(false),
processedAt: timestamp('processed_at'),
error: text('error'), // Error message if failed
createdAt: timestamp('created_at').defaultNow(),
});
// Partial index for unprocessed events
// CREATE INDEX idx_webhook_events_unprocessed ON webhook_events (created_at)
// WHERE processed = false;
```
### Idempotent Webhook Processing
```typescript
// lib/webhooks.ts
export async function processWebhookIdempotently<T>(
provider: 'polar' | 'sepay',
eventId: string,
eventType: string,
payload: string,
handler: () => Promise<T>
): Promise<{ processed: boolean; result?: T; error?: string }> {
// Check for duplicate
const existing = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existing.length > 0) {
return { processed: false }; // Already processed
}
// Record event BEFORE processing
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider,
eventType,
eventId,
payload,
processed: false,
});
try {
const result = await handler();
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
return { processed: true, result };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: errorMessage,
})
.where(eq(webhookEvents.eventId, eventId));
return { processed: true, error: errorMessage };
}
}
```
## Discount Cross-Provider Sync
### Syncing SePay Usage to Polar
```typescript
// lib/polar-discount-sync.ts
// When a Polar discount is used via SePay, decrement Polar's redemption count
export async function syncDiscountRedemptionToPolar(
orderId: string,
discountId: string,
discountCode: string
): Promise<{ success: boolean; action: string }> {
const order = await db.select()
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);
if (!order[0]) {
return { success: false, action: 'order_not_found' };
}
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
// Idempotency check
if (metadata.polarDiscountSynced) {
return { success: true, action: 'already_synced' };
}
const polar = getPolar();
try {
const discount = await polar.discounts.get({ id: discountId });
// Skip if unlimited redemptions
if (discount.maxRedemptions === null) {
await markSynced(orderId, 'skipped_unlimited');
return { success: true, action: 'skipped_unlimited' };
}
const currentMax = discount.maxRedemptions;
if (currentMax <= 1) {
// Delete discount if this was last use
await polar.discounts.delete({ id: discountId });
await markSynced(orderId, 'deleted');
return { success: true, action: 'deleted' };
} else {
// Decrement max redemptions
await polar.discounts.update({
id: discountId,
discountUpdate: { maxRedemptions: currentMax - 1 },
});
await markSynced(orderId, 'decremented');
return { success: true, action: 'decremented' };
}
} catch (error: any) {
if (error.statusCode === 404) {
await markSynced(orderId, 'already_deleted');
return { success: true, action: 'already_deleted' };
}
throw error;
}
}
async function markSynced(orderId: string, action: string) {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
await db.update(orders)
.set({
metadata: JSON.stringify({
...metadata,
polarDiscountSynced: true,
polarDiscountSyncAction: action,
polarDiscountSyncedAt: new Date().toISOString(),
}),
})
.where(eq(orders.id, orderId));
}
// Retry wrapper with exponential backoff
export async function syncWithRetry(
orderId: string,
discountId: string,
discountCode: string,
attempt: number = 1
): Promise<{ success: boolean; action: string }> {
const MAX_ATTEMPTS = 3;
try {
return await syncDiscountRedemptionToPolar(orderId, discountId, discountCode);
} catch (error) {
if (attempt < MAX_ATTEMPTS) {
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
await sleep(delay);
return syncWithRetry(orderId, discountId, discountCode, attempt + 1);
}
throw error;
}
}
```
## Admin Order Management API
### Order Listing with Provider Info
```typescript
// app/api/admin/orders/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const provider = searchParams.get('provider'); // 'polar' | 'sepay' | null
const status = searchParams.get('status');
let query = db.select()
.from(orders)
.orderBy(desc(orders.createdAt));
if (provider) {
query = query.where(eq(orders.paymentProvider, provider));
}
if (status) {
query = query.where(eq(orders.status, status));
}
const results = await query
.limit(limit)
.offset((page - 1) * limit);
// Normalize amounts to USD for display
const ordersWithNormalized = await Promise.all(
results.map(async (order) => {
const normalized = await normalizeOrderToUsd(order);
return {
...order,
amountUsdCents: normalized.amountUsdCents,
displayAmount: order.currency === 'VND'
? formatVND(order.amount)
: formatUSD(order.amount),
};
})
);
return NextResponse.json({
orders: ordersWithNormalized,
pagination: {
page,
limit,
hasMore: results.length === limit,
},
});
}
```
## Best Practices Summary
### 1. Currency Handling
- Store amounts in original currency (USD or VND)
- Always store currency code with amount
- Use multi-layer fallback for exchange rates
- Convert to USD for reporting/comparison
### 2. Order Management
- Use unified orders table for both providers
- Store provider-specific data in metadata JSON
- Normalize to USD for tier calculations
### 3. Commission System
- Store original currency and USD equivalent
- Calculate tier based on USD values
- Handle currency conversion in commission creation
### 4. Webhook Processing
- Use idempotency keys for deduplication
- Record event before processing
- Always return 200 to prevent retry loops
- Log errors in event record for debugging
### 5. Cross-Provider Sync
- Sync discount redemptions from SePay to Polar
- Use retry with exponential backoff
- Mark orders as synced to prevent duplicates
### 6. Refund Handling
- Check order status before processing
- Cancel related commissions
- Recalculate referrer tier after cancellation
- Optionally keep access (goodwill refunds)

View File

@@ -0,0 +1,116 @@
# Paddle API Reference
Base URL: `https://api.paddle.com` (prod) | `https://sandbox-api.paddle.com` (sandbox)
## Products
```bash
# Create product
POST /products
{
"name": "Pro Plan",
"tax_category": "standard",
"description": "Professional subscription"
}
# List products
GET /products?status=active
```
## Prices
```bash
# Create price
POST /prices
{
"product_id": "pro_xxx",
"description": "Monthly subscription",
"unit_price": { "amount": "1999", "currency_code": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 }
}
# One-time price
POST /prices
{
"product_id": "pro_xxx",
"unit_price": { "amount": "4999", "currency_code": "USD" }
}
```
## Transactions
```bash
# Create transaction (checkout)
POST /transactions
{
"items": [{ "price_id": "pri_xxx", "quantity": 1 }],
"customer_id": "ctm_xxx" # optional
}
# Get transaction
GET /transactions/{txn_id}
```
## Customers
```bash
# Create customer
POST /customers
{
"email": "user@example.com",
"name": "John Doe"
}
# Get customer portal session
POST /customers/{ctm_id}/portal-sessions
```
## Subscriptions
```bash
# Get subscription
GET /subscriptions/{sub_id}
# Update subscription
PATCH /subscriptions/{sub_id}
{
"items": [{ "price_id": "pri_new", "quantity": 1 }],
"proration_billing_mode": "prorated_immediately"
}
# Cancel subscription
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "next_billing_period"
}
# Pause subscription
POST /subscriptions/{sub_id}/pause
{
"effective_from": "next_billing_period"
}
```
## Response Format
```json
{
"data": { ... },
"meta": {
"request_id": "xxx",
"pagination": { "per_page": 50, "next": "..." }
}
}
```
## Error Handling
```json
{
"error": {
"type": "request_error",
"code": "entity_not_found",
"detail": "Product not found"
}
}
```

View File

@@ -0,0 +1,130 @@
# Paddle Best Practices
Production patterns for reliable integration.
## Webhook Handling
```typescript
// 1. Verify signature first
// 2. Check idempotency
// 3. Process async
// 4. Return 200 immediately
const processedEvents = new Set(); // Use Redis in production
app.post('/webhooks/paddle', async (req, res) => {
const signature = req.headers['paddle-signature'];
// Verify
const event = paddle.webhooks.unmarshal(
req.rawBody,
process.env.PADDLE_WEBHOOK_SECRET,
signature
);
// Idempotency
if (processedEvents.has(event.eventId)) {
return res.status(200).send('Already processed');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process async
await queue.add('paddle-webhook', event);
});
```
## Subscription Status Sync
```typescript
// Always verify subscription status server-side
async function checkAccess(userId: string): Promise<boolean> {
const user = await db.users.findOne({ id: userId });
if (!user.paddleSubscriptionId) return false;
const sub = await paddle.subscriptions.get(user.paddleSubscriptionId);
return ['active', 'trialing'].includes(sub.status);
}
```
## Custom Data for User Linking
```typescript
// Pass user_id in checkout
paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
customData: { user_id: currentUser.id }
});
// Retrieve in webhook
app.post('/webhooks/paddle', async (req, res) => {
const event = paddle.webhooks.unmarshal(...);
if (event.eventType === 'subscription.created') {
const userId = event.data.customData?.user_id;
await db.users.update(userId, {
paddleSubscriptionId: event.data.id,
paddleCustomerId: event.data.customerId
});
}
});
```
## Error Recovery
```typescript
// Handle past_due subscriptions
async function handlePastDue(subscriptionId: string) {
// Get customer portal for payment update
const sub = await paddle.subscriptions.get(subscriptionId);
const portal = await paddle.customers.createPortalSession(sub.customerId);
// Email customer with portal link
await sendEmail(sub.customer.email, {
subject: 'Update your payment method',
link: portal.urls.general.overview
});
}
```
## Testing with Sandbox
```typescript
// Use sandbox environment
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: 'sandbox'
});
// Sandbox card: 4242 4242 4242 4242
// Any future expiry, any CVC
```
## Price Localization
```typescript
// Preview localized prices before checkout
const preview = await paddle.PricePreview({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
address: { countryCode: customerCountry }
});
// Display localized price
const formattedPrice = preview.data.details.totals.total;
```
## Paddle Retain (Churn Prevention)
Features enabled in dashboard:
- **Payment recovery**: Automated dunning emails
- **Cancellation surveys**: Collect feedback + offer discounts
- **Term optimization**: Auto-upgrade annual suggestions
## Security Checklist
- [ ] Webhook signatures verified
- [ ] API keys in env vars, not code
- [ ] Separate keys for sandbox/production
- [ ] Idempotency implemented
- [ ] Server-side status verification
- [ ] Secure customer portal sessions

View File

@@ -0,0 +1,57 @@
# Paddle Overview
Paddle Billing = merchant-of-record platform handling payments, tax compliance, localization, subscriptions globally.
## Authentication
```bash
# API Key in Authorization header
curl -X GET "https://api.paddle.com/products" \
-H "Authorization: Bearer {api_key}"
```
Environment:
- Production: `api.paddle.com`
- Sandbox: `sandbox-api.paddle.com`
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Paddle ID** | Unique identifier for all entities (`pro_xxx`, `pri_xxx`, `txn_xxx`) |
| **MoR** | Paddle is merchant-of-record, handles tax/compliance |
| **Localization** | Auto currency/language based on customer location |
## SDK Installation
```bash
# Node.js
npm install @paddle/paddle-node-sdk
# Python
pip install paddle-python-sdk
# PHP
composer require paddle/paddle-php-sdk
# Go
go get github.com/PaddleHQ/paddle-go-sdk
```
## Entity Prefixes
| Entity | Prefix | Example |
|--------|--------|---------|
| Product | `pro_` | `pro_01gsz4vmqbjk3x4vvtafffd540` |
| Price | `pri_` | `pri_01gsz8z1q1n00f12qt82y31smh` |
| Customer | `ctm_` | `ctm_01grnn4zta5a1mf02jjze7y2ys` |
| Subscription | `sub_` | `sub_01gv2z5ht1mk2y6bsgv2mjryyn` |
| Transaction | `txn_` | `txn_01gv2z5ht1mk2y6bsgv2mjryyn` |
## Quick Links
- API Reference: https://developer.paddle.com/api-reference/overview
- Paddle.js: `references/paddle/paddle-js.md`
- Webhooks: `references/paddle/webhooks.md`
- Subscriptions: `references/paddle/subscriptions.md`
- External llms.txt: https://developer.paddle.com/llms.txt

View File

@@ -0,0 +1,106 @@
# Paddle.js v2
Client-side library for checkout and pricing.
## Installation
```html
<!-- CDN -->
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
```
```bash
# npm
npm install @paddle/paddle-js
```
## Initialization
```typescript
import { initializePaddle } from '@paddle/paddle-js';
const paddle = await initializePaddle({
environment: 'sandbox', // 'production'
token: 'live_xxx', // client-side token
eventCallback: (event) => {
if (event.name === 'checkout.completed') {
console.log('Payment successful', event.data);
}
}
});
```
## Checkout Methods
### Overlay Checkout (Modal)
```typescript
paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
customer: { email: 'user@example.com' },
customData: { user_id: '123' },
successUrl: 'https://example.com/success',
});
```
### Inline Checkout (Embedded)
```html
<div class="paddle-checkout-container"></div>
```
```typescript
paddle.Checkout.open({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
settings: {
displayMode: 'inline',
frameTarget: 'paddle-checkout-container',
frameStyle: 'width: 100%; min-width: 312px; background-color: transparent;'
}
});
```
### HTML Data Attributes
```html
<a
href="#"
data-paddle-product="pri_xxx"
data-paddle-quantity="1"
data-paddle-email="user@example.com"
>Buy Now</a>
```
## Price Preview
```typescript
const preview = await paddle.PricePreview({
items: [{ priceId: 'pri_xxx', quantity: 1 }],
address: { countryCode: 'US' }
});
console.log(preview.data.details.totals.total); // "19.99"
```
## Events
| Event | Description |
|-------|-------------|
| `checkout.loaded` | Checkout frame loaded |
| `checkout.customer.created` | New customer created |
| `checkout.payment.initiated` | Payment processing started |
| `checkout.completed` | Payment successful |
| `checkout.closed` | Checkout closed |
| `checkout.error` | Payment failed |
## Update Checkout
```typescript
// Update items after open
paddle.Checkout.updateItems([
{ priceId: 'pri_xxx', quantity: 2 }
]);
// Close checkout
paddle.Checkout.close();
```

View File

@@ -0,0 +1,131 @@
# Paddle SDKs
Official SDKs for server-side integration.
## Node.js
```bash
npm install @paddle/paddle-node-sdk
```
```typescript
import Paddle from '@paddle/paddle-node-sdk';
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: 'sandbox' // 'production'
});
// Products
const products = await paddle.products.list();
const product = await paddle.products.create({
name: 'Pro Plan',
taxCategory: 'standard'
});
// Prices
const prices = await paddle.prices.list({ productId: 'pro_xxx' });
const price = await paddle.prices.create({
productId: 'pro_xxx',
description: 'Monthly',
unitPrice: { amount: '999', currencyCode: 'USD' },
billingCycle: { interval: 'month', frequency: 1 }
});
// Transactions
const transaction = await paddle.transactions.create({
items: [{ priceId: 'pri_xxx', quantity: 1 }]
});
// Subscriptions
const subscription = await paddle.subscriptions.get('sub_xxx');
await paddle.subscriptions.update('sub_xxx', {
items: [{ priceId: 'pri_new', quantity: 1 }]
});
await paddle.subscriptions.cancel('sub_xxx', { effectiveFrom: 'nextBillingPeriod' });
// Customers
const customers = await paddle.customers.list({ email: 'user@example.com' });
```
## Python
```bash
pip install paddle-python-sdk
```
```python
from paddle_billing import Client, Environment
paddle = Client(
api_key="your_api_key",
options=Options(environment=Environment.SANDBOX)
)
# Products
products = paddle.products.list()
product = paddle.products.create(
name="Pro Plan",
tax_category="standard"
)
# Subscriptions
subscription = paddle.subscriptions.get("sub_xxx")
paddle.subscriptions.cancel(
"sub_xxx",
effective_from="next_billing_period"
)
```
## PHP
```bash
composer require paddle/paddle-php-sdk
```
```php
use Paddle\SDK\Client;
$paddle = new Client('your_api_key');
// Products
$products = $paddle->products->list();
// Subscriptions
$subscription = $paddle->subscriptions->get('sub_xxx');
$paddle->subscriptions->cancel('sub_xxx', [
'effective_from' => 'next_billing_period'
]);
```
## Go
```bash
go get github.com/PaddleHQ/paddle-go-sdk
```
```go
import paddle "github.com/PaddleHQ/paddle-go-sdk"
client, _ := paddle.New(
os.Getenv("PADDLE_API_KEY"),
paddle.WithBaseURL(paddle.SandboxBaseURL),
)
// Products
products, _ := client.ListProducts(ctx, nil)
// Subscriptions
sub, _ := client.GetSubscription(ctx, "sub_xxx")
```
## Error Handling
```typescript
try {
await paddle.subscriptions.get('sub_invalid');
} catch (error) {
if (error.code === 'entity_not_found') {
console.log('Subscription not found');
}
}
```

View File

@@ -0,0 +1,118 @@
# Paddle Subscriptions
Full subscription lifecycle management.
## Create Subscription
Via checkout (customer initiates):
```typescript
paddle.Checkout.open({
items: [{ priceId: 'pri_monthly', quantity: 1 }],
customer: { email: 'user@example.com' }
});
```
## Subscription States
| Status | Description |
|--------|-------------|
| `trialing` | In trial period |
| `active` | Actively billed |
| `past_due` | Payment failed, retrying |
| `paused` | Temporarily suspended |
| `canceled` | Terminated |
## Upgrade/Downgrade
```typescript
// API: Update subscription items
PATCH /subscriptions/{sub_id}
{
"items": [{ "price_id": "pri_annual", "quantity": 1 }],
"proration_billing_mode": "prorated_immediately"
}
```
Proration modes:
- `prorated_immediately` - Charge/credit now
- `prorated_next_billing_period` - Apply next cycle
- `full_immediately` - Full new price now
- `full_next_billing_period` - Full price next cycle
- `do_not_bill` - No charge for change
## Multi-Item Subscriptions
```typescript
// Add item to existing subscription
PATCH /subscriptions/{sub_id}
{
"items": [
{ "price_id": "pri_base", "quantity": 1 },
{ "price_id": "pri_addon", "quantity": 5 }
]
}
```
## Trials
Set trial on price:
```typescript
POST /prices
{
"product_id": "pro_xxx",
"unit_price": { "amount": "999", "currency_code": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 14 }
}
```
## Pause/Resume
```typescript
// Pause at end of period
POST /subscriptions/{sub_id}/pause
{
"effective_from": "next_billing_period"
}
// Resume immediately
POST /subscriptions/{sub_id}/resume
{
"effective_from": "immediately"
}
```
## Cancel
```typescript
// Cancel at end of period
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "next_billing_period"
}
// Cancel immediately
POST /subscriptions/{sub_id}/cancel
{
"effective_from": "immediately"
}
```
## Customer Portal
Self-service subscription management:
```typescript
// Get portal URL
POST /customers/{ctm_id}/portal-sessions
// Response
{
"data": {
"id": "cps_xxx",
"customer_id": "ctm_xxx",
"urls": {
"general": { "overview": "https://..." }
}
}
}
```

View File

@@ -0,0 +1,112 @@
# Paddle Webhooks
Event-driven notifications for payment lifecycle.
## Setup
1. Dashboard → Developer Tools → Notifications
2. Create new destination with endpoint URL
3. Select events to receive
4. Copy signing secret
## Signature Verification
Header: `Paddle-Signature`
Format: `ts=1234567890;h1=sha256_signature`
### Node.js SDK
```typescript
import Paddle from '@paddle/paddle-node-sdk';
const paddle = new Paddle(process.env.PADDLE_API_KEY);
app.post('/webhooks/paddle', async (req, res) => {
const signature = req.headers['paddle-signature'];
const rawBody = req.body; // raw request body string
try {
const event = paddle.webhooks.unmarshal(
rawBody,
process.env.PADDLE_WEBHOOK_SECRET,
signature
);
await handleEvent(event);
res.status(200).send('OK');
} catch (err) {
res.status(400).send('Invalid signature');
}
});
```
### Manual Verification
```typescript
import crypto from 'crypto';
function verifyPaddleWebhook(
rawBody: string,
signature: string,
secret: string
): boolean {
const [tsPart, h1Part] = signature.split(';');
const ts = tsPart.replace('ts=', '');
const h1 = h1Part.replace('h1=', '');
const signedPayload = `${ts}:${rawBody}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(h1),
Buffer.from(expectedSig)
);
}
```
## Key Events
| Event | Description |
|-------|-------------|
| `transaction.completed` | Payment successful |
| `transaction.payment_failed` | Payment failed |
| `subscription.created` | New subscription |
| `subscription.updated` | Subscription changed |
| `subscription.canceled` | Subscription canceled |
| `subscription.past_due` | Payment overdue |
| `subscription.paused` | Subscription paused |
| `subscription.resumed` | Subscription resumed |
| `customer.created` | New customer |
| `customer.updated` | Customer updated |
## Event Payload
```json
{
"event_id": "evt_xxx",
"event_type": "subscription.created",
"occurred_at": "2024-01-15T10:00:00Z",
"notification_id": "ntf_xxx",
"data": {
"id": "sub_xxx",
"status": "active",
"customer_id": "ctm_xxx",
"items": [{ "price": { "id": "pri_xxx" }, "quantity": 1 }],
"billing_cycle": { "interval": "month", "frequency": 1 },
"current_billing_period": {
"starts_at": "2024-01-15",
"ends_at": "2024-02-15"
}
}
}
```
## Best Practices
- Store `event_id` for idempotency
- Return 200 immediately, process async
- Implement retry handling (Paddle retries failed deliveries)
- Use webhook secret per environment

View File

@@ -0,0 +1,396 @@
# Polar Benefits
Automated benefit delivery system for digital products.
## Philosophy
Configure once, automatic delivery. Polar handles granting and revoking based on subscription state.
## Benefit Types
### 1. License Keys
**Auto-generate unique keys with customizable branding.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "license_keys",
organization_id: "org_xxx",
description: "Software License",
properties: {
prefix: "MYAPP",
expires: false,
activations: 1,
limit_usage: false
}
});
```
**Validation API (unauthenticated):**
```typescript
const validation = await polar.licenses.validate({
key: "MYAPP-XXXX-XXXX-XXXX",
organization_id: "org_xxx"
});
if (validation.valid) {
// Grant access
}
```
**Activation/Deactivation:**
```typescript
await polar.licenses.activate(licenseKey, {
label: "User's MacBook Pro"
});
await polar.licenses.deactivate(activationId);
```
**Auto-revoke:** On subscription cancellation or refund
### 2. GitHub Repository Access
**Auto-invite to private repos with permission management.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "github_repository",
organization_id: "org_xxx",
description: "Access to private repo",
properties: {
repository_owner: "myorg",
repository_name: "private-repo",
permission: "pull" // or "push", "admin"
}
});
```
**Multiple Repos:**
```typescript
{
properties: {
repositories: [
{ owner: "myorg", name: "repo1", permission: "pull" },
{ owner: "myorg", name: "repo2", permission: "push" }
]
}
}
```
**Behavior:**
- Auto-invite on subscription activation
- Permission managed by Polar
- Auto-revoke on cancellation
### 3. Discord Access
**Server invites and role assignment.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "discord",
organization_id: "org_xxx",
description: "Premium Discord role",
properties: {
guild_id: "123456789",
role_id: "987654321"
}
});
```
**Multiple Roles:**
```typescript
{
properties: {
guild_id: "123456789",
roles: [
{ role_id: "role1", name: "Premium" },
{ role_id: "role2", name: "Supporter" }
]
}
}
```
**Requirements:**
- Polar Discord app must be added to server
- Configure in Polar dashboard
**Behavior:**
- Auto-invite to server
- Assign roles automatically
- Remove roles on cancellation
### 4. Downloadable Files
**Secure file delivery up to 10GB each.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "downloadable",
organization_id: "org_xxx",
description: "Premium templates",
properties: {
files: [
{ name: "template1.zip", size: 5000000 },
{ name: "template2.psd", size: 10000000 }
]
}
});
```
**Upload Files:**
- Via Polar dashboard
- Secure storage
- Access control
**Customer Access:**
- Download links in customer portal
- Secure, time-limited URLs
- Multiple files supported
### 5. Meter Credits
**Pre-purchased usage for usage-based billing.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "custom",
organization_id: "org_xxx",
description: "10,000 API credits",
properties: {
meter_id: "meter_xxx",
credits: 10000
}
});
```
**Automatic Application:**
- Credits added on subscription start
- Balance tracked via API
- Depletes with usage
**Balance Check:**
```typescript
const balance = await polar.meters.getBalance({
customer_id: "cust_xxx",
meter_id: "meter_xxx"
});
```
### 6. Custom Benefits
**Flexible placeholder for manual fulfillment.**
**Create:**
```typescript
const benefit = await polar.benefits.create({
type: "custom",
organization_id: "org_xxx",
description: "Priority support via email",
properties: {
note: "Email support@example.com with your order ID for priority support"
}
});
```
**Use Cases:**
- Cal.com booking links
- Email support access
- Community forum access
- Manual onboarding
## Benefit Grants
**Link between customer and benefit.**
### States
- `created` - Grant created
- `active` - Benefit delivered
- `revoked` - Access removed
### Webhooks
- `benefit_grant.created` - Grant created
- `benefit_grant.updated` - Status changed
- `benefit_grant.revoked` - Access revoked
### Auto-revoke Triggers
- Subscription canceled
- Subscription revoked
- Refund processed
- Product changed (if benefit not on new product)
### Querying Grants
```typescript
const grants = await polar.benefitGrants.list({
customer_id: "cust_xxx",
benefit_id: "benefit_xxx",
is_granted: true
});
```
## Attaching Benefits to Products
### Via API
```typescript
await polar.products.updateBenefits(productId, {
benefits: [benefitId1, benefitId2, benefitId3]
});
```
### Via Dashboard
1. Navigate to product
2. Benefits tab
3. Select benefits to attach
4. Save
### Order
- Benefits granted in order attached
- Customers see in that order
- Reorder via dashboard or API
## Customer Experience
### Viewing Benefits
- Customer portal shows all active benefits
- Clear instructions for each type
- Download links for files
- License keys displayed
### Accessing Benefits
```typescript
// Generate customer portal link
const session = await polar.customerSessions.create({
external_customer_id: userId
});
// Customer sees:
// - Active subscriptions
// - Granted benefits
// - Download links
// - License keys
// - Instructions
```
## Implementation Patterns
### License Key Validation
```typescript
// In your application
async function validateLicense(key) {
try {
const result = await polar.licenses.validate({
key: key,
organization_id: process.env.POLAR_ORG_ID
});
if (!result.valid) {
return { valid: false, reason: 'Invalid license' };
}
if (result.limit_usage && result.usage >= result.limit_usage) {
return { valid: false, reason: 'Usage limit exceeded' };
}
return { valid: true, customer: result.customer };
} catch (error) {
console.error('License validation failed:', error);
return { valid: false, reason: 'Validation error' };
}
}
```
### GitHub Access Check
```typescript
// Listen to benefit grant webhook
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'benefit_grant.created') {
const grant = event.data;
if (grant.benefit.type === 'github_repository') {
// Update user's GitHub access in your system
await updateGitHubAccess(grant.customer.external_id, true);
}
}
res.json({ received: true });
});
```
### Discord Role Sync
```typescript
// Monitor benefit grants
if (event.type === 'benefit_grant.created') {
const grant = event.data;
if (grant.benefit.type === 'discord') {
// Notify user to connect Discord
await sendDiscordInvite(grant.customer.email);
}
}
if (event.type === 'benefit_grant.revoked') {
const grant = event.data;
if (grant.benefit.type === 'discord') {
// Roles removed automatically by Polar
await notifyRoleRemoval(grant.customer.external_id);
}
}
```
## Best Practices
1. **Benefit Selection:**
- Choose appropriate benefit types
- Consider automation capabilities
- Plan for revocation scenarios
2. **License Keys:**
- Set appropriate activation limits
- Monitor usage patterns
- Provide clear validation errors
- Allow customers to manage activations
3. **GitHub Access:**
- Set minimum required permissions
- Use separate repos for different tiers
- Monitor repository access
- Communicate access removal
4. **Discord Roles:**
- Clear role hierarchy
- Meaningful role names
- Separate roles per product tier
- Welcome messages for new members
5. **Files:**
- Organize files clearly
- Provide README/instructions
- Keep files updated
- Version control important files
6. **Credits:**
- Clear credit value communication
- Usage tracking and display
- Alerts near depletion
- Easy credit top-up
7. **Custom Benefits:**
- Clear, actionable instructions
- Provide contact information
- Set expectations for timing
- Track manual fulfillment
8. **Customer Communication:**
- Welcome email with benefit access info
- Instructions for each benefit type
- Support contact for issues
- Revocation warnings before cancellation

View File

@@ -0,0 +1,902 @@
# Polar Best Practices
Production-proven patterns from real SaaS implementations covering SDK initialization, checkout flows, webhooks, discounts, fee calculations, and error handling.
## Environment Configuration
### Required Environment Variables
```bash
# Core API
POLAR_API_KEY=polar_at_xxx # Access token from Polar Dashboard
POLAR_ORGANIZATION_ID=org_xxx # Your organization ID
POLAR_WEBHOOK_SECRET=whsec_xxx # Webhook signature verification
# Product IDs (one per product)
POLAR_PRODUCT_ENGINEER_ID=prod_xxx
POLAR_PRODUCT_MARKETING_ID=prod_xxx
POLAR_PRODUCT_COMBO_ID=prod_xxx
# Environment (optional, defaults to production)
POLAR_ENV=production # 'production' or 'sandbox'
```
### Lazy Initialization Pattern
```typescript
// lib/polar.ts - Defer validation until first access
import { Polar } from '@polar-sh/sdk';
import { z } from 'zod';
const polarEnvSchema = z.object({
POLAR_API_KEY: z.string().min(1),
POLAR_ORGANIZATION_ID: z.string().min(1),
POLAR_WEBHOOK_SECRET: z.string().min(1),
});
let _polar: Polar | null = null;
let _env: z.infer<typeof polarEnvSchema> | null = null;
export function getPolarEnv() {
if (!_env) {
_env = polarEnvSchema.parse({
POLAR_API_KEY: process.env.POLAR_API_KEY,
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID,
POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET,
});
}
return _env;
}
export function getPolar() {
if (!_polar) {
const env = getPolarEnv();
const polarEnv = process.env.POLAR_ENV || 'production';
_polar = new Polar({
accessToken: env.POLAR_API_KEY,
server: polarEnv as 'production' | 'sandbox',
});
}
return _polar;
}
```
**Key Benefit:** Module imports succeed at build time; validation deferred until runtime when env vars are available.
## Checkout Flow Implementation
### Standard Checkout API
```typescript
// app/api/checkout/polar/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getPolar, getPolarEnv } from '@/lib/polar';
const checkoutSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
productType: z.enum(['engineer_kit', 'marketing_kit', 'combo']),
githubUsername: z.string().min(1),
referralCode: z.string().regex(/^[A-Z0-9]{8}$/).optional(),
couponCode: z.string().optional(),
});
// Pricing in cents
const PRODUCT_PRICES = {
engineer_kit: 9900, // $99
marketing_kit: 9900, // $99
combo: 14900, // $149
} as const;
export async function POST(request: Request) {
try {
const body = await request.json();
const data = checkoutSchema.parse(body);
const polar = getPolar();
const env = getPolarEnv();
// 1. Normalize email
const normalizedEmail = data.email.toLowerCase().trim();
// 2. Validate GitHub username against GitHub API
const githubValid = await validateGitHubUsername(data.githubUsername);
if (!githubValid) {
return NextResponse.json(
{ error: 'Invalid GitHub username' },
{ status: 400 }
);
}
// 3. Get product ID and base price
const productId = getProductId(data.productType);
const originalAmount = PRODUCT_PRICES[data.productType];
// 4. Apply discount hierarchy (order matters!)
let finalAmount = originalAmount;
let polarDiscountId: string | undefined;
let discountMetadata: Record<string, any> = {};
// Step A: Apply coupon FIRST (if provided)
if (data.couponCode) {
const couponResult = await validateAndApplyCoupon(
data.couponCode,
productId,
originalAmount
);
if (couponResult.valid) {
finalAmount = originalAmount - couponResult.discountAmount;
discountMetadata.couponCode = data.couponCode;
discountMetadata.couponDiscountAmount = couponResult.discountAmount;
}
}
// Step B: Apply referral discount SECOND (on post-coupon price)
if (data.referralCode) {
const referralResult = await calculateReferralDiscount(
data.referralCode,
finalAmount, // Applied to post-coupon amount
normalizedEmail
);
if (referralResult.valid && referralResult.discountAmount > 0) {
// Validate discount calculation
if (referralResult.discountAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid discount calculation - contact support' },
{ status: 400 }
);
}
finalAmount -= referralResult.discountAmount;
discountMetadata.referralCode = data.referralCode;
discountMetadata.referralDiscountAmount = referralResult.discountAmount;
discountMetadata.referrerId = referralResult.referrerId;
}
}
// 5. Create order record BEFORE Polar checkout
const order = await db.insert(orders).values({
id: crypto.randomUUID(),
email: normalizedEmail,
productType: data.productType,
amount: finalAmount,
originalAmount,
currency: 'USD',
status: 'pending',
paymentProvider: 'polar',
referredBy: discountMetadata.referrerId,
discountAmount: originalAmount - finalAmount,
metadata: JSON.stringify({
...discountMetadata,
githubUsername: data.githubUsername,
}),
}).returning();
// 6. Create dynamic Polar discount (if referral applied)
if (discountMetadata.referrerId && discountMetadata.referralDiscountAmount > 0) {
try {
const discount = await polar.discounts.create({
type: 'fixed',
name: `referral-${order[0].id.slice(0, 8)}`,
amount: discountMetadata.referralDiscountAmount,
currency: 'usd',
duration: 'once',
maxRedemptions: 1,
products: [productId],
metadata: {
orderId: order[0].id,
type: 'referral',
referrerId: discountMetadata.referrerId,
},
});
polarDiscountId = discount.id;
} catch (error) {
// FAIL-OPEN: Proceed with full price, flag for manual refund
console.error('⚠️ Failed to create Polar discount:', error);
}
}
// 7. Create Polar checkout session
const checkout = await polar.checkouts.create({
productPriceId: productId,
customerEmail: normalizedEmail,
successUrl: `${process.env.NEXT_PUBLIC_URL}/checkout/success?orderId=${order[0].id}`,
discountId: polarDiscountId,
allowDiscountCodes: !polarDiscountId, // Prevent stacking
metadata: {
orderId: order[0].id,
githubUsername: data.githubUsername,
referredBy: discountMetadata.referrerId,
},
});
return NextResponse.json({
checkoutUrl: checkout.url,
orderId: order[0].id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout' },
{ status: 500 }
);
}
}
```
### Discount Application Order (Critical)
```
1. Original price (e.g., $99)
2. Apply coupon discount FIRST → post-coupon price (e.g., $79)
3. Apply referral discount SECOND → final price (e.g., $63.20)
Never apply referral to original price if coupon was used!
```
## Webhook Handling
### Signature Verification
```typescript
// app/api/webhooks/polar/route.ts
import { validateEvent } from '@polar-sh/sdk/webhooks';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const payload = await request.text();
const headers = Object.fromEntries(request.headers);
const secret = process.env.POLAR_WEBHOOK_SECRET!;
let webhookEvent;
try {
webhookEvent = validateEvent(payload, headers, secret);
} catch (error) {
console.error('Invalid webhook signature:', error);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Extract event ID for idempotency
const parsedPayload = JSON.parse(payload);
const eventId = parsedPayload.id || `${parsedPayload.type}-${Date.now()}`;
// Check for duplicate processing
const existingEvent = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existingEvent.length > 0) {
console.log(`Duplicate webhook ignored: ${eventId}`);
return NextResponse.json({ received: true });
}
// Record event BEFORE processing (idempotency)
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider: 'polar',
eventType: webhookEvent.type,
eventId,
payload,
processed: false,
});
try {
await handleWebhookEvent(webhookEvent);
// Mark as processed
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
} catch (error) {
// Log error but don't fail the webhook
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(webhookEvents.eventId, eventId));
}
return NextResponse.json({ received: true });
}
```
### Event Handlers
```typescript
async function handleWebhookEvent(event: WebhookEvent) {
switch (event.type) {
case 'checkout.created':
// Order already exists from API - just log
console.log(`Checkout created: ${event.data.id}`);
break;
case 'checkout.updated':
await handleCheckoutUpdated(event.data);
break;
case 'order.created':
await handleOrderCreated(event.data);
break;
case 'order.refunded':
await handleOrderRefunded(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleOrderCreated(order: PolarOrder) {
const orderId = order.metadata?.orderId;
if (!orderId) {
console.error('Order missing orderId in metadata');
return;
}
const dbOrder = await db.select()
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);
if (!dbOrder[0]) {
console.error(`Order not found: ${orderId}`);
return;
}
// 1. Update order status
await db.update(orders)
.set({
status: 'completed',
paymentId: order.id,
updatedAt: new Date(),
})
.where(eq(orders.id, orderId));
// 2. Create license (non-blocking)
try {
await createLicense(dbOrder[0]);
} catch (error) {
console.error('Failed to create license:', error);
}
// 3. Send confirmation email (non-blocking)
try {
await sendOrderConfirmation(dbOrder[0], order);
} catch (error) {
console.error('Failed to send confirmation:', error);
}
// 4. Create referral commission (non-blocking)
if (dbOrder[0].referredBy) {
try {
await createCommission(dbOrder[0]);
} catch (error) {
console.error('Failed to create commission:', error);
}
}
// 5. Grant GitHub access (non-blocking)
try {
const metadata = JSON.parse(dbOrder[0].metadata || '{}');
await inviteToGitHub(metadata.githubUsername, dbOrder[0].productType);
} catch (error) {
console.error('Failed to invite to GitHub:', error);
}
// 6. Send Discord notification (non-blocking)
try {
await sendSalesNotification(dbOrder[0]);
} catch (error) {
console.error('Failed to send Discord notification:', error);
}
}
```
### Status Mapping
```typescript
function mapPolarStatusToAppStatus(polarStatus: string): string | null {
switch (polarStatus) {
case 'succeeded':
return 'completed';
case 'failed':
case 'expired':
return 'failed';
case 'open':
case 'confirmed':
return null; // Don't update - still pending
default:
return null;
}
}
```
## Fee Calculation
### Platform Fee Structure (Dec 2025)
```typescript
// lib/polar-fees.ts
interface PolarFeeConfig {
basePercentage: number; // 4%
baseFlatCents: number; // $0.40 per transaction
internationalSurcharge: number; // +1.5% for non-US cards
subscriptionSurcharge: number; // +0.5% (not for one-time)
}
const POLAR_FEES: PolarFeeConfig = {
basePercentage: 0.04,
baseFlatCents: 40,
internationalSurcharge: 0.015,
subscriptionSurcharge: 0.005,
};
export function calculatePolarFees(
amountCents: number,
isInternational: boolean = true, // Conservative default
isSubscription: boolean = false
): {
baseFee: number;
internationalFee: number;
subscriptionFee: number;
totalFee: number;
netRevenue: number;
} {
// Handle zero/negative
if (amountCents <= 0) {
return { baseFee: 0, internationalFee: 0, subscriptionFee: 0, totalFee: 0, netRevenue: 0 };
}
const baseFee = Math.round(amountCents * POLAR_FEES.basePercentage + POLAR_FEES.baseFlatCents);
const internationalFee = isInternational
? Math.round(amountCents * POLAR_FEES.internationalSurcharge)
: 0;
const subscriptionFee = isSubscription
? Math.round(amountCents * POLAR_FEES.subscriptionSurcharge)
: 0;
const totalFee = baseFee + internationalFee + subscriptionFee;
const netRevenue = amountCents - totalFee;
return { baseFee, internationalFee, subscriptionFee, totalFee, netRevenue };
}
// Aggregate fees preserve per-transaction flat fees
export function calculateAggregatePolarFees(transactionAmounts: number[]): {
totalFees: number;
totalNetRevenue: number;
} {
let totalFees = 0;
let totalNetRevenue = 0;
for (const amount of transactionAmounts) {
const { totalFee, netRevenue } = calculatePolarFees(amount);
totalFees += totalFee;
totalNetRevenue += netRevenue;
}
return { totalFees, totalNetRevenue };
}
```
## Discount Management
### Discount Validation with Timeout
```typescript
// lib/polar-discounts.ts
const VALIDATION_TIMEOUT_MS = 15000;
export async function validateDiscount(
code: string,
productId: string
): Promise<{ valid: boolean; discount?: PolarDiscount; reason?: string }> {
const sanitizedCode = code.trim().toUpperCase();
if (!sanitizedCode) {
return { valid: false, reason: 'Code cannot be empty' };
}
const polar = getPolar();
const env = getPolarEnv();
try {
// Race against timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Validation timeout')), VALIDATION_TIMEOUT_MS);
});
const searchPromise = polar.discounts.list({
organizationId: env.POLAR_ORGANIZATION_ID,
query: sanitizedCode,
limit: 100,
});
const result = await Promise.race([searchPromise, timeoutPromise]);
// Find exact match
const discount = result.items.find(d =>
d.code?.toUpperCase() === sanitizedCode
);
if (!discount) {
return { valid: false, reason: 'Code not found' };
}
// Check eligibility
const now = new Date();
if (discount.startsAt && now < new Date(discount.startsAt)) {
return { valid: false, reason: `Code starts on ${discount.startsAt}` };
}
if (discount.endsAt && now > new Date(discount.endsAt)) {
return { valid: false, reason: 'Code has expired' };
}
if (discount.maxRedemptions && discount.redemptionsCount >= discount.maxRedemptions) {
return { valid: false, reason: 'Code redemption limit reached' };
}
if (!discount.products?.some(p => p.id === productId)) {
return { valid: false, reason: 'Code not valid for this product' };
}
return { valid: true, discount };
} catch (error) {
console.error('Discount validation error:', error);
return { valid: false, reason: 'Validation failed - please try again' };
}
}
```
### VND Conversion for Discounts
```typescript
const VND_TO_USD_RATE = 25000; // 1 USD = 25,000 VND
export function convertDiscountToVND(discount: PolarDiscount, amountVND: number): number {
if (discount.type === 'percentage') {
// Basis points: 1000 = 10%, 10000 = 100%
const percentage = discount.basisPoints / 10000;
return Math.round(amountVND * percentage);
} else {
// Fixed amount in USD cents → VND
const amountUSD = discount.amount / 100;
return Math.round(amountUSD * VND_TO_USD_RATE);
}
}
```
### Syncing SePay Redemptions to Polar
```typescript
// lib/polar-discount-sync.ts
// When SePay payment completes, decrement Polar discount redemptions
export async function syncPolarDiscountRedemption(
orderId: string,
discountId: string,
discountCode: string
): Promise<{ success: boolean; action: string }> {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
if (!order[0]) {
return { success: false, action: 'order_not_found' };
}
// Idempotency check
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
if (metadata.polarDiscountSynced) {
return { success: true, action: 'already_synced' };
}
const polar = getPolar();
try {
const discount = await polar.discounts.get({ id: discountId });
if (discount.maxRedemptions === null || discount.maxRedemptions === undefined) {
return { success: true, action: 'skipped_unlimited' };
}
const currentMax = discount.maxRedemptions;
if (currentMax <= 1) {
await polar.discounts.delete({ id: discountId });
await markOrderSynced(orderId, 'deleted');
} else {
await polar.discounts.update({
id: discountId,
discountUpdate: { maxRedemptions: currentMax - 1 },
});
await markOrderSynced(orderId, 'decremented');
}
return { success: true, action: currentMax <= 1 ? 'deleted' : 'decremented' };
} catch (error: any) {
if (error.statusCode === 404) {
// Already deleted - treat as success
await markOrderSynced(orderId, 'already_deleted');
return { success: true, action: 'already_deleted' };
}
throw error;
}
}
async function markOrderSynced(orderId: string, action: string) {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).limit(1);
const metadata = order[0].metadata ? JSON.parse(order[0].metadata) : {};
metadata.polarDiscountSynced = true;
metadata.polarDiscountSyncAction = action;
metadata.polarDiscountSyncedAt = new Date().toISOString();
await db.update(orders)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(orders.id, orderId));
}
```
## Revenue Tracking with Caching
```typescript
// lib/polar.ts
const REVENUE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
let revenueCache: {
data: { totalRevenueCents: number; orderCount: number } | null;
timestamp: number;
} = { data: null, timestamp: 0 };
export async function getPolarApiRevenue(): Promise<{
totalRevenueCents: number;
orderCount: number;
fromCache: boolean;
}> {
const now = Date.now();
// Return cache if valid
if (revenueCache.data && now - revenueCache.timestamp < REVENUE_CACHE_TTL_MS) {
return { ...revenueCache.data, fromCache: true };
}
const polar = getPolar();
const env = getPolarEnv();
try {
let totalRevenueCents = 0;
let orderCount = 0;
let page = 1;
const maxPages = 100; // Safety limit
while (page <= maxPages) {
const response = await polar.orders.list({
organizationId: env.POLAR_ORGANIZATION_ID,
page,
limit: 100,
});
for (const order of response.items) {
if (order.status === 'succeeded') {
totalRevenueCents += order.netAmount; // After discounts, before tax
orderCount++;
}
}
if (!response.pagination.hasMore) break;
page++;
}
revenueCache = { data: { totalRevenueCents, orderCount }, timestamp: now };
return { totalRevenueCents, orderCount, fromCache: false };
} catch (error) {
// Return stale cache on error
if (revenueCache.data) {
console.warn('Using stale revenue cache due to API error');
return { ...revenueCache.data, fromCache: true };
}
throw error;
}
}
```
## Error Handling Patterns
### Fail-Open for Non-Critical Operations
```typescript
// Discount creation fails → proceed with full price
try {
const discount = await createReferralDiscount(productId, amount, referralCode);
polarDiscountId = discount.id;
} catch (error) {
console.error('⚠️ Discount creation failed - proceeding with full price:', error);
// Flag for manual refund investigation
await flagOrderForReview(orderId, 'discount_creation_failed');
}
```
### Graceful Degradation in Webhooks
```typescript
// Non-critical operations don't block order completion
const operations = [
{ name: 'GitHub invite', fn: () => inviteToGitHub(username, productType) },
{ name: 'Welcome email', fn: () => sendWelcomeEmail(order) },
{ name: 'Discord notification', fn: () => sendSalesNotification(order) },
{ name: 'Tier update', fn: () => updateReferrerTier(referrerId, revenueUsd) },
];
for (const op of operations) {
try {
await op.fn();
} catch (error) {
console.error(`❌ ${op.name} failed:`, error);
// Continue processing - don't block order
}
}
```
### Rate Limit Handling with Exponential Backoff
```typescript
async function callWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let attempt = 0;
while (attempt < maxRetries) {
try {
return await fn();
} catch (error: any) {
if (error.statusCode === 429) {
const retryAfter = parseInt(error.headers?.['retry-after'] || '1', 10);
const delay = retryAfter * 1000 * Math.pow(2, attempt);
console.log(`Rate limited, retrying in ${delay}ms...`);
await sleep(delay);
attempt++;
} else {
throw error;
}
}
}
throw new Error('Max retries exceeded');
}
```
## Database Schema
### Orders Table
```typescript
// db/schema/orders.ts
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id),
email: text('email').notNull(),
productType: text('product_type').notNull(),
amount: integer('amount').notNull(), // Final amount in cents
originalAmount: integer('original_amount'), // Before discounts
currency: text('currency').default('USD'),
status: text('status').default('pending'), // pending, completed, failed, refunded
paymentProvider: text('payment_provider').notNull(), // 'polar' or 'sepay'
paymentId: text('payment_id'), // External payment ID
referredBy: uuid('referred_by').references(() => users.id),
discountAmount: integer('discount_amount').default(0),
discountRate: numeric('discount_rate', { precision: 5, scale: 2 }),
metadata: text('metadata'), // JSON with audit trail
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
```
### Webhook Events Table (Idempotency)
```typescript
export const webhookEvents = pgTable('webhook_events', {
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(), // 'polar' or 'sepay'
eventType: text('event_type').notNull(),
eventId: text('event_id').notNull().unique(), // Idempotency key
payload: text('payload').notNull(),
processed: boolean('processed').default(false),
processedAt: timestamp('processed_at'),
error: text('error'),
createdAt: timestamp('created_at').defaultNow(),
});
```
## Metadata Best Practices
### Comprehensive Audit Trail
```typescript
// Store everything needed for debugging and reconciliation
metadata: JSON.stringify({
// Pricing history
originalAmount: 9900,
// Coupon tracking
couponCode: 'LAUNCH20',
couponDiscountAmount: 1980,
// Referral tracking
referralCode: 'ABC12345',
referralDiscountAmount: 1584,
referrerId: 'user-uuid',
// Customer context
githubUsername: 'customer',
// Polar integration
polarDiscountId: 'disc_xxx',
polarDiscountSynced: true,
polarDiscountSyncAction: 'decremented',
polarDiscountSyncedAt: '2025-01-15T10:30:00Z',
// Team context (if applicable)
isTeamPurchase: false,
teamId: null,
quantity: 1,
})
```
## Testing
### Unit Tests for Fee Calculation
```typescript
// __tests__/lib/polar-fees.test.ts
describe('calculatePolarFees', () => {
it('handles zero amount', () => {
const result = calculatePolarFees(0);
expect(result.totalFee).toBe(0);
expect(result.netRevenue).toBe(0);
});
it('calculates international one-time correctly', () => {
// $100 transaction
const result = calculatePolarFees(10000, true, false);
expect(result.baseFee).toBe(440); // 4% + $0.40
expect(result.internationalFee).toBe(150); // 1.5%
expect(result.totalFee).toBe(590);
expect(result.netRevenue).toBe(9410); // $94.10
});
it('preserves per-transaction flat fees in aggregate', () => {
// Two $100 transactions should each have $0.40 flat fee
const aggregate = calculateAggregatePolarFees([10000, 10000]);
const single = calculatePolarFees(20000);
expect(aggregate.totalFees).toBeGreaterThan(single.totalFee);
// Difference should be one extra flat fee ($0.40)
expect(aggregate.totalFees - single.totalFee).toBe(40);
});
});
```
## Production Checklist
- [ ] Environment variables configured in all environments
- [ ] Sandbox testing completed for all checkout flows
- [ ] Production API key obtained and secured
- [ ] Webhook endpoint deployed and reachable
- [ ] Webhook signature verification implemented
- [ ] Idempotency handling tested with duplicate webhooks
- [ ] Fee calculations verified against Polar dashboard
- [ ] Discount validation timeout configured
- [ ] Error monitoring enabled (Sentry, etc.)
- [ ] Structured logging in place
- [ ] Database indexes on orders.status, orders.paymentProvider
- [ ] Revenue caching configured
- [ ] Rate limit handling implemented
- [ ] Fail-open patterns for non-critical operations
- [ ] Customer email notifications working
- [ ] Refund flow tested end-to-end
- [ ] GitHub access grant/revoke tested
- [ ] Discord sales notifications configured
## Common Pitfalls
1. **Applying discounts in wrong order** - Always coupon first, then referral
2. **Trusting success redirect without verification** - Always verify via API or webhook
3. **Not handling duplicate webhooks** - Use eventId for idempotency
4. **Blocking webhook on non-critical failures** - Wrap in try-catch, log, continue
5. **Hardcoding Polar customer IDs** - Use external_id (your user ID) for lookups
6. **Not setting timeout on discount validation** - API can be slow
7. **Calculating aggregate fees as single transaction** - Each transaction has flat fee
8. **Exposing API keys client-side** - Always server-side
9. **Not preserving original amount in metadata** - Need for audit/debugging
10. **Syncing discount redemptions synchronously** - Can fail; use retry with backoff

View File

@@ -0,0 +1,266 @@
# Polar Checkouts
Checkout flows, embedded checkout, and session management.
## Checkout Approaches
### 1. Checkout Links
- Pre-configured shareable links
- Created via dashboard or API
- For marketing campaigns
- Can pre-apply discounts
**Create via API:**
```typescript
const link = await polar.checkoutLinks.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success"
});
// Returns: link.url
```
### 2. Checkout Sessions (API)
- Programmatically created
- Server-side API call
- Dynamic workflows
- Custom logic
**Create Session:**
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success?checkout_id={CHECKOUT_ID}",
customer_email: "user@example.com",
external_customer_id: "user_123",
metadata: {
user_id: "123",
source: "web"
}
});
// Redirect to: session.url
```
**Response:**
```json
{
"id": "checkout_xxx",
"url": "https://polar.sh/checkout/...",
"client_secret": "cs_xxx",
"status": "open",
"expires_at": "2025-01-15T10:00:00Z"
}
```
### 3. Embedded Checkout
- Inline checkout within your site
- Seamless purchase experience
- Theme customization
**Implementation:**
```html
<script src="https://polar.sh/embed.js"></script>
<div id="polar-checkout"></div>
<script>
const checkout = await fetch('/api/create-checkout', {
method: 'POST',
body: JSON.stringify({ productPriceId: 'price_xxx' })
}).then(r => r.json());
Polar('checkout', {
checkoutId: checkout.id,
clientSecret: checkout.client_secret,
onSuccess: () => {
window.location.href = '/success';
},
theme: 'dark' // or 'light'
});
</script>
```
**Server-side (create session):**
```typescript
app.post('/api/create-checkout', async (req, res) => {
const session = await polar.checkouts.create({
product_price_id: req.body.productPriceId,
embed_origin: "https://example.com",
external_customer_id: req.user.id
});
res.json({
id: session.id,
client_secret: session.client_secret
});
});
```
## Configuration Parameters
### Required
- `product_price_id` - Product to checkout (or `products` array for multiple)
- `success_url` - Post-payment redirect (absolute URL)
### Optional
- `external_customer_id` - Your user ID mapping
- `embed_origin` - For embedded checkouts
- `customer_email` - Pre-fill email
- `customer_name` - Pre-fill name
- `discount_id` - Pre-apply discount code
- `allow_discount_codes` - Allow customer to enter codes (default: true)
- `metadata` - Custom data (key-value)
- `custom_field_data` - Pre-fill custom fields
- `customer_billing_address` - Pre-fill billing address
### Success URL Placeholder
```typescript
{
success_url: "https://example.com/success?checkout_id={CHECKOUT_ID}"
}
// Polar replaces {CHECKOUT_ID} with actual checkout ID
```
## Multi-Product Checkout
```typescript
const session = await polar.checkouts.create({
products: [
{ product_price_id: "price_1", quantity: 1 },
{ product_price_id: "price_2", quantity: 2 }
],
success_url: "https://example.com/success"
});
```
## Discount Application
### Pre-apply Discount
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
discount_id: "discount_xxx",
success_url: "https://example.com/success"
});
```
### Allow Customer Codes
```typescript
{
allow_discount_codes: true // default
// Set to false to disable code entry
}
```
## Checkout States
- `open` - Ready for payment
- `confirmed` - Payment successful
- `expired` - Session expired (typically 24 hours)
## Events
**Webhook Events:**
- `checkout.created` - Session created
- `checkout.updated` - Session updated
- `order.created` - Order created after successful payment
- `order.paid` - Payment confirmed
**Handle Success:**
```typescript
// Listen to order.paid webhook
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'order.paid') {
const order = event.data;
await fulfillOrder(order);
}
res.json({ received: true });
});
```
## Best Practices
1. **Success URL:**
- Must be absolute URL: `https://example.com/success`
- Use `{CHECKOUT_ID}` placeholder to retrieve checkout details
- Verify payment via webhook, not just success redirect
2. **External Customer ID:**
- Set on first checkout
- Never change once set
- Use for all customer operations
- Enables customer lookup without storing Polar IDs
3. **Pre-filling Data:**
- Pre-fill customer info when available
- Reduces friction in checkout
- Improves conversion rates
4. **Embedded Checkout:**
- Provide seamless experience
- Match your site's theme
- Handle errors gracefully
- Show loading states
5. **Metadata:**
- Store tracking info (source, campaign, etc.)
- Link to your internal systems
- Use for analytics and reporting
6. **Error Handling:**
- Handle expired sessions
- Provide clear error messages
- Offer to create new session
- Log failures for debugging
7. **Mobile Optimization:**
- Test on mobile devices
- Ensure responsive design
- Consider mobile payment methods
- Test embedded checkout on mobile
## Framework Examples
### Next.js
```typescript
// app/actions/checkout.ts
'use server'
export async function createCheckout(productPriceId: string) {
const session = await polar.checkouts.create({
product_price_id: productPriceId,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?checkout_id={CHECKOUT_ID}`,
external_customer_id: await getCurrentUserId()
});
return session.url;
}
// app/product/page.tsx
export default function ProductPage() {
async function handleCheckout() {
const url = await createCheckout(productPriceId);
window.location.href = url;
}
return <button onClick={handleCheckout}>Buy Now</button>;
}
```
### Laravel
```php
Route::post('/checkout', function (Request $request) {
$polar = new Polar(config('polar.access_token'));
$session = $polar->checkouts->create([
'product_price_id' => $request->input('product_price_id'),
'success_url' => route('checkout.success'),
'external_customer_id' => auth()->id(),
]);
return redirect($session['url']);
});
```

View File

@@ -0,0 +1,184 @@
# Polar Overview
Comprehensive payment & billing platform for software monetization with Merchant of Record services.
## Core Capabilities
**Platform Features:**
- Digital product sales (one-time, recurring, usage-based)
- Merchant of Record - handles global tax compliance
- Subscription lifecycle management
- Automated benefit distribution
- Customer self-service portal
- Real-time webhook system
- Analytics dashboard
- Multi-language SDKs
**Merchant of Record Benefits:**
- Global tax compliance (VAT, GST, sales tax)
- Tax calculations for all jurisdictions
- B2B reverse charge, B2C tax collection
- Invoicing from Polar to customers
- Payout invoicing to merchants
- Transparent fees (20% discount vs other MoRs)
## Authentication
### Organization Access Tokens (OAT)
**For:** Server-side API access
**Create:**
1. Org Settings → Developers
2. Create new access token
3. Copy and store securely
**Usage:**
```bash
Authorization: Bearer polar_xxxxxxxxxxxxxxxx
```
**Security:** Never expose client-side (auto-revoked if leaked)
### OAuth 2.0
**For:** Third-party app integration
**Authorization URL:** `https://polar.sh/oauth2/authorize`
**Token URL:** `https://api.polar.sh/v1/oauth2/token`
**Flow:**
```
1. Redirect to authorize URL with scopes
2. User approves permissions
3. Receive authorization code
4. Exchange code for access_token + refresh_token
5. Use access_token for API calls
```
**Scopes:**
- `products:read/write` - Product management
- `checkouts:read/write` - Checkout operations
- `orders:read` - View orders
- `subscriptions:read/write` - Subscription management
- `benefits:read/write` - Benefit configuration
- `customers:read/write` - Customer management
- `discounts:read/write` - Discount codes
- `refunds:read/write` - Refund processing
### Customer Sessions
**For:** Customer-facing portal operations
**Create:** Server-side API call returns customer access token
**Usage:** Pre-authenticated customer portal links
**Scope:** Restricted to customer-specific operations
## Base URLs
**Production:**
- Dashboard: `https://polar.sh`
- API: `https://api.polar.sh/v1/`
**Sandbox:**
- Dashboard: `https://sandbox.polar.sh`
- API: `https://sandbox-api.polar.sh/v1/`
**SDK Configuration:**
```typescript
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "production" // or "sandbox"
});
```
## Rate Limits
**Limits:**
- 300 requests/minute per org/customer/OAuth2 client
- 3 requests/second for unauthenticated license validation
**Response:** HTTP 429 with `Retry-After` header
**Handling:**
```javascript
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
await sleep(retryAfter * 1000);
return retry();
}
```
## Key Concepts
### External Customer ID
- Map your user IDs to Polar customers
- Set at checkout: `external_customer_id`
- Query API by external_id
- Immutable once set
- Use for all customer operations
### Metadata
- Custom key-value storage
- Available on products, customers, subscriptions, orders
- For reporting and filtering
- Not indexed, use for supplementary data
### Billing Reasons
Track order types via `billing_reason`:
- `purchase` - One-time product
- `subscription_create` - New subscription
- `subscription_cycle` - Renewal invoice
- `subscription_update` - Plan change
## Environments
**Sandbox:**
- Separate account required
- Separate organization
- Separate access tokens (production tokens don't work)
- Test with Stripe test cards
**Test Cards (Stripe):**
- Success: `4242 4242 4242 4242`
- Decline: `4000 0000 0000 0002`
- Auth Required: `4000 0025 0000 3155`
- Expiry: Any future date
- CVC: Any 3 digits
## SDKs
**Official SDKs:**
- TypeScript/JavaScript: `@polar-sh/sdk`
- Python: `polar-sdk`
- PHP: `polar-sh/sdk`
- Go: Official SDK
**Framework Adapters:**
- Next.js: `@polar-sh/nextjs` (quickstart: `npx polar-init`)
- Laravel: `polar-sh/laravel`
- Remix, Astro, Express, TanStack Start
- Elysia, Fastify, Hono, SvelteKit
**BetterAuth Integration:**
- Package: `@polar-sh/better-auth`
- Auto-create customers on signup
- External ID mapping
- User-customer sync
## Support & Resources
- Docs: https://polar.sh/docs
- API Reference: https://polar.sh/docs/api-reference
- LLMs.txt: https://polar.sh/docs/llms.txt
- GitHub: https://github.com/polarsource/polar
- Discussions: https://github.com/orgs/polarsource/discussions
## Next Steps
- **For products:** Load `products.md`
- **For checkout:** Load `checkouts.md`
- **For subscriptions:** Load `subscriptions.md`
- **For webhooks:** Load `webhooks.md`
- **For benefits:** Load `benefits.md`
- **For SDK usage:** Load `sdk.md`

View File

@@ -0,0 +1,244 @@
# Polar Products & Pricing
Product management, pricing models, and usage-based billing.
## Billing Cycles
**Options:**
- One-time: Charged once, forever access
- Monthly: Charged every month
- Yearly: Charged every year
**Important:** Cannot change after product creation
## Pricing Types
**Fixed Price:** Set amount
**Pay What You Want:** Customer decides (optional minimum)
**Free:** No charge
**Important:** Cannot change after product creation
## Advanced Pricing Models
### Seat-Based Pricing
- Team access with assignable seats
- Works for recurring or one-time
- Tiered pricing structures
- Customer manages seat assignments
**Configuration:**
```typescript
const product = await polar.products.create({
name: "Team Plan",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 5000, // per seat
pricing_type: "fixed"
}],
is_seat_based: true,
max_seats: 100
});
```
### Usage-Based Billing
**Architecture:** Events → Meters → Metered Prices
**1. Events:** Usage data from your application
```typescript
await polar.events.create({
external_customer_id: "user_123",
event_name: "api_call",
properties: {
tokens: 1000,
model: "gpt-4"
}
});
```
**2. Meters:** Filter & aggregate events
```typescript
const meter = await polar.meters.create({
name: "API Tokens",
slug: "api_tokens",
event_name: "api_call",
aggregation: {
type: "sum",
property: "tokens"
}
});
```
**3. Metered Prices:** Billing based on usage
```typescript
const price = await polar.products.createPrice(productId, {
type: "metered",
meter_id: meter.id,
price_per_unit: 10, // 10 cents per 1000 tokens
billing_interval: "month"
});
```
**Credits System:**
- Pre-purchased usage credits
- Credit customer's meter balance
- Use as subscription benefit
- Balance tracking API
**Ingestion Strategies:**
- LLM Strategy: AI/ML tracking
- S3 Strategy: Bulk import
- Stream Strategy: Real-time
- Delta Time Strategy: Time-based
## Product Features
### Metadata
```typescript
const product = await polar.products.create({
name: "Pro Plan",
metadata: {
feature_x: "enabled",
tier: "pro",
custom_field: "value"
}
});
```
### Custom Fields
```typescript
const product = await polar.products.create({
name: "Enterprise Plan",
custom_fields: [
{
slug: "company_name",
label: "Company Name",
type: "text",
required: true
},
{
slug: "employees",
label: "Number of Employees",
type: "number"
}
]
});
```
Data collected at checkout, accessible via Orders/Subscriptions API in `custom_field_data`.
### Trials
- Set on recurring products
- Customer not charged during trial
- Benefits granted immediately
- Configure at product or checkout level
```typescript
const product = await polar.products.create({
name: "Pro Plan",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 2000,
trial_period_days: 14
}]
});
```
## Product Operations
### Create Product
```typescript
const product = await polar.products.create({
organization_id: "org_xxx",
name: "Pro Plan",
description: "Professional features",
prices: [{
type: "recurring",
recurring_interval: "month",
price_amount: 2000,
pricing_type: "fixed"
}]
});
```
### List Products
```typescript
const products = await polar.products.list({
organization_id: "org_xxx",
is_archived: false
});
```
### Update Product
```typescript
const product = await polar.products.update(productId, {
name: "Pro Plan Updated",
description: "New description"
});
```
### Archive Product
```typescript
await polar.products.archive(productId);
// Products can be unarchived later
// Cannot be deleted (maintains order history)
```
### Update Benefits
```typescript
await polar.products.updateBenefits(productId, {
benefits: [benefitId1, benefitId2]
});
```
## Important Constraints
1. **Cannot change after creation:**
- Billing cycle (one-time, monthly, yearly)
- Pricing type (fixed, pay-what-you-want, free)
2. **Price changes don't affect existing subscribers:**
- Current subscribers keep their original price
- New subscribers get new price
- Use separate products for significant changes
3. **Products cannot be deleted:**
- Archive instead
- Maintains order history integrity
- Archived products not shown to new customers
4. **Metadata vs Custom Fields:**
- Metadata: For internal use, not shown to customers
- Custom Fields: Collected from customers at checkout
## Best Practices
1. **Product Strategy:**
- Plan billing cycle carefully before creation
- Use separate products for different tiers
- Archive unused products rather than delete
2. **Pricing Changes:**
- Create new product for major changes
- Grandfather existing customers
- Communicate changes clearly
3. **Usage-Based:**
- Define clear meter aggregations
- Set appropriate billing intervals
- Monitor usage patterns
- Provide usage dashboards to customers
4. **Custom Fields:**
- Collect only necessary information
- Validate on frontend before checkout
- Use for personalization and support
5. **Trials:**
- Set appropriate trial duration
- Communicate trial end clearly
- Notify before trial expires
- Easy cancellation during trial

View File

@@ -0,0 +1,436 @@
# Polar SDK Usage
Multi-language SDKs and framework adapters.
## TypeScript/JavaScript
**Installation:**
```bash
npm install @polar-sh/sdk
```
**Configuration:**
```typescript
import { Polar } from '@polar-sh/sdk';
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "production" // or "sandbox"
});
```
**Usage:**
```typescript
// Products
const products = await polar.products.list({ organization_id: "org_xxx" });
const product = await polar.products.create({ name: "Pro Plan", ... });
// Checkouts
const checkout = await polar.checkouts.create({
product_price_id: "price_xxx",
success_url: "https://example.com/success"
});
// Subscriptions
const subs = await polar.subscriptions.list({ customer_id: "cust_xxx" });
await polar.subscriptions.update(subId, { metadata: { plan: "pro" } });
// Orders
const orders = await polar.orders.list({ organization_id: "org_xxx" });
const order = await polar.orders.get(orderId);
// Customers
const customer = await polar.customers.get({ external_id: "user_123" });
// Events (usage-based)
await polar.events.create({
external_customer_id: "user_123",
event_name: "api_call",
properties: { tokens: 1000 }
});
```
**Pagination:**
```typescript
// Automatic pagination
for await (const product of polar.products.listAutoPaging()) {
console.log(product.name);
}
// Manual pagination
let page = 1;
while (true) {
const response = await polar.products.list({ page, limit: 100 });
if (response.items.length === 0) break;
// Process items
page++;
}
```
## Python
**Installation:**
```bash
pip install polar-sdk
```
**Configuration:**
```python
from polar_sdk import Polar
polar = Polar(
access_token=os.environ["POLAR_ACCESS_TOKEN"],
server="production" # or "sandbox"
)
```
**Sync Usage:**
```python
# Products
products = polar.products.list(organization_id="org_xxx")
product = polar.products.create(name="Pro Plan", ...)
# Checkouts
checkout = polar.checkouts.create(
product_price_id="price_xxx",
success_url="https://example.com/success"
)
# Subscriptions
subs = polar.subscriptions.list(customer_id="cust_xxx")
polar.subscriptions.update(sub_id, metadata={"plan": "pro"})
# Orders
orders = polar.orders.list(organization_id="org_xxx")
order = polar.orders.get(order_id)
# Events
polar.events.create(
external_customer_id="user_123",
event_name="api_call",
properties={"tokens": 1000}
)
```
**Async Usage:**
```python
import asyncio
from polar_sdk import AsyncPolar
async def main():
polar = AsyncPolar(access_token=os.environ["POLAR_ACCESS_TOKEN"])
products = await polar.products.list(organization_id="org_xxx")
checkout = await polar.checkouts.create(...)
asyncio.run(main())
```
## PHP
**Installation:**
```bash
composer require polar-sh/sdk
```
**Configuration:**
```php
use Polar\Polar;
$polar = new Polar(
accessToken: $_ENV['POLAR_ACCESS_TOKEN'],
server: 'production' // or 'sandbox'
);
```
**Usage:**
```php
// Products
$products = $polar->products->list(['organization_id' => 'org_xxx']);
$product = $polar->products->create(['name' => 'Pro Plan', ...]);
// Checkouts
$checkout = $polar->checkouts->create([
'product_price_id' => 'price_xxx',
'success_url' => 'https://example.com/success'
]);
// Subscriptions
$subs = $polar->subscriptions->list(['customer_id' => 'cust_xxx']);
$polar->subscriptions->update($subId, ['metadata' => ['plan' => 'pro']]);
// Orders
$orders = $polar->orders->list(['organization_id' => 'org_xxx']);
$order = $polar->orders->get($orderId);
// Events
$polar->events->create([
'external_customer_id' => 'user_123',
'event_name' => 'api_call',
'properties' => ['tokens' => 1000]
]);
```
## Go
**Installation:**
```bash
go get github.com/polarsource/polar-go
```
**Usage:**
```go
import (
"github.com/polarsource/polar-go"
)
client := polar.NewClient(
polar.WithAccessToken(os.Getenv("POLAR_ACCESS_TOKEN")),
polar.WithEnvironment("production"),
)
// Products
products, err := client.Products.List(ctx, &polar.ProductListParams{
OrganizationID: "org_xxx",
})
// Checkouts
checkout, err := client.Checkouts.Create(ctx, &polar.CheckoutCreateParams{
ProductPriceID: "price_xxx",
SuccessURL: "https://example.com/success",
})
```
## Framework Adapters
### Next.js (@polar-sh/nextjs)
**Quick Start:**
```bash
npx polar-init
```
**Configuration:**
```typescript
// lib/polar.ts
import { PolarClient } from '@polar-sh/nextjs';
export const polar = new PolarClient({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!
});
```
**Checkout Handler:**
```typescript
// app/actions/checkout.ts
'use server'
import { polar } from '@/lib/polar';
export async function createCheckout(priceId: string) {
const session = await polar.checkouts.create({
product_price_id: priceId,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?checkout_id={CHECKOUT_ID}`
});
return session.url;
}
```
**Webhook Handler:**
```typescript
// app/api/webhook/polar/route.ts
import { polar } from '@/lib/polar';
export async function POST(req: Request) {
const event = await polar.webhooks.validate(req);
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data);
break;
// ... other events
}
return Response.json({ received: true });
}
```
### Laravel (polar-sh/laravel)
**Installation:**
```bash
composer require polar-sh/laravel
php artisan vendor:publish --tag=polar-config
php artisan vendor:publish --tag=polar-migrations
php artisan migrate
```
**Configuration:**
```php
// config/polar.php
return [
'access_token' => env('POLAR_ACCESS_TOKEN'),
'webhook_secret' => env('POLAR_WEBHOOK_SECRET'),
];
```
**Checkout:**
```php
use Polar\Facades\Polar;
Route::post('/checkout', function (Request $request) {
$checkout = Polar::checkouts()->create([
'product_price_id' => $request->input('price_id'),
'success_url' => route('checkout.success'),
'external_customer_id' => auth()->id(),
]);
return redirect($checkout['url']);
});
```
**Webhook:**
```php
use Polar\Events\WebhookReceived;
// app/Listeners/PolarWebhookHandler.php
class PolarWebhookHandler
{
public function handle(WebhookReceived $event)
{
match ($event->payload['type']) {
'order.paid' => $this->handleOrderPaid($event->payload['data']),
'subscription.revoked' => $this->handleRevoked($event->payload['data']),
default => null,
};
}
}
```
### Express
```javascript
const express = require('express');
const { Polar } = require('@polar-sh/sdk');
const { validateEvent } = require('@polar-sh/sdk/webhooks');
const app = express();
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN });
app.use(express.json());
app.post('/checkout', async (req, res) => {
const session = await polar.checkouts.create({
product_price_id: req.body.priceId,
success_url: 'https://example.com/success',
external_customer_id: req.user.id
});
res.json({ url: session.url });
});
app.post('/webhook/polar', (req, res) => {
const event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET
);
handleEvent(event);
res.json({ received: true });
});
```
### Remix
```typescript
import { Polar } from '@polar-sh/sdk';
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN });
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const priceId = formData.get('priceId');
const session = await polar.checkouts.create({
product_price_id: priceId,
success_url: `${request.url}/success`
});
return redirect(session.url);
}
```
## BetterAuth Integration
**Installation:**
```bash
npm install @polar-sh/better-auth
```
**Configuration:**
```typescript
import { betterAuth } from 'better-auth';
import { polarPlugin } from '@polar-sh/better-auth';
export const auth = betterAuth({
database: db,
plugins: [
polarPlugin({
organizationId: process.env.POLAR_ORG_ID!,
accessToken: process.env.POLAR_ACCESS_TOKEN!
})
]
});
```
**Features:**
- Auto-create Polar customers on signup
- Automatic external_id mapping
- User-customer sync
- Access customer data in auth session
## Error Handling
**TypeScript:**
```typescript
try {
const product = await polar.products.get(productId);
} catch (error) {
if (error.statusCode === 404) {
console.error('Product not found');
} else if (error.statusCode === 429) {
console.error('Rate limit exceeded');
} else {
console.error('API error:', error.message);
}
}
```
**Python:**
```python
from polar_sdk.exceptions import PolarException
try:
product = polar.products.get(product_id)
except PolarException as e:
if e.status_code == 404:
print("Product not found")
elif e.status_code == 429:
print("Rate limit exceeded")
else:
print(f"API error: {e.message}")
```
## Best Practices
1. **Environment Variables:** Store credentials securely
2. **Error Handling:** Catch and handle API errors appropriately
3. **Rate Limiting:** Implement backoff for 429 responses
4. **Pagination:** Use auto-paging for large datasets
5. **Webhooks:** Always verify signatures
6. **Testing:** Use sandbox for development
7. **Logging:** Log API calls for debugging
8. **Retry Logic:** Implement for transient failures

View File

@@ -0,0 +1,340 @@
# Polar Subscriptions
Subscription lifecycle, upgrades, downgrades, and trial management.
## Lifecycle States
- `created` - New subscription, payment pending
- `active` - Payment successful, benefits granted
- `canceled` - Scheduled cancellation at period end
- `revoked` - Billing stopped, benefits revoked immediately
- `past_due` - Payment failed, in dunning period
## API Operations
### List Subscriptions
```typescript
const subscriptions = await polar.subscriptions.list({
organization_id: "org_xxx",
product_id: "prod_xxx",
customer_id: "cust_xxx",
status: "active"
});
```
### Get Subscription
```typescript
const subscription = await polar.subscriptions.get(subscriptionId);
```
### Update Subscription
```typescript
const updated = await polar.subscriptions.update(subscriptionId, {
product_price_id: "newPriceId",
discount_id: "discount_xxx",
metadata: { plan: "pro" }
});
```
## Upgrades & Downgrades
### Proration Options
**Next Invoice (default):**
- Credit/charge applied to upcoming invoice
- Subscription updates immediately
- Customer billed at next cycle
**Invoice Immediately:**
- Credit/charge processed right away
- Subscription updates immediately
- New invoice generated
```typescript
await polar.subscriptions.update(subscriptionId, {
product_price_id: "higher_tier_price",
proration: "invoice_immediately" // or "next_invoice"
});
```
### Customer-Initiated Changes
**Enable in Product Settings:**
- Toggle "Allow price change"
- Customer can upgrade/downgrade via portal
- Admin-only changes if disabled
**Implementation:**
```typescript
// Check if changes allowed
const product = await polar.products.get(productId);
if (product.allow_price_change) {
// Customer can change via portal
}
```
## Trials
### Configuration
**Product-level:**
```typescript
const product = await polar.products.create({
name: "Pro Plan",
prices: [{
trial_period_days: 14
}]
});
```
**Checkout-level:**
```typescript
const session = await polar.checkouts.create({
product_price_id: "price_xxx",
trial_period_days: 7 // Overrides product setting
});
```
### Trial Behavior
- Customer not charged during trial
- Benefits granted immediately
- Can cancel anytime during trial
- Charged at trial end if not canceled
### Trial Events
```typescript
// Listen to webhooks
subscription.created // Trial starts
subscription.active // Trial ends, first charge
subscription.canceled // Trial canceled
```
## Cancellations
### Cancel at Period End
```typescript
await polar.subscriptions.update(subscriptionId, {
cancel_at_period_end: true
});
// Subscription remains active
// Benefits continue until period end
// Webhooks: subscription.updated, subscription.canceled
```
### Immediate Revocation
```typescript
// Happens automatically at period end
// Or manually via API (future feature)
// Status changes to "revoked"
// Billing stops, benefits revoked
// Webhooks: subscription.updated, subscription.revoked
```
### Reactivate Canceled
```typescript
await polar.subscriptions.update(subscriptionId, {
cancel_at_period_end: false
});
// Removes cancellation
// Subscription continues normally
```
## Renewals
### Listening to Renewals
```typescript
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'order.created') {
const order = event.data;
if (order.billing_reason === 'subscription_cycle') {
// This is a renewal
await handleRenewal(order.subscription_id);
}
}
res.json({ received: true });
});
```
### Failed Renewals
- `subscription.past_due` webhook fired
- Dunning process initiated
- Customer notified via email
- Multiple retry attempts
- Eventually revoked if payment fails
## Discounts
### Apply Discount
```typescript
await polar.subscriptions.update(subscriptionId, {
discount_id: "discount_xxx"
});
```
### Remove Discount
```typescript
await polar.subscriptions.update(subscriptionId, {
discount_id: null
});
```
### Discount Types
- Percentage off: 20% off
- Fixed amount: $5 off
- Duration: once, forever, repeating
## Customer Portal
### Generate Portal Access
```typescript
const session = await polar.customerSessions.create({
customer_id: "cust_xxx"
});
// Redirect to: session.url
```
### Portal Features
- View subscriptions
- Upgrade/downgrade plans
- Cancel subscriptions
- Update billing info
- View invoices
- Access benefits
### Pre-authenticated Links
```typescript
// From your app, create session and redirect
app.get('/portal', async (req, res) => {
const session = await polar.customerSessions.create({
external_customer_id: req.user.id
});
res.redirect(session.url);
});
```
## Metadata
### Update Subscription Metadata
```typescript
await polar.subscriptions.update(subscriptionId, {
metadata: {
internal_id: "sub_123",
tier: "pro",
source: "web"
}
});
```
### Query by Metadata
```typescript
const subscriptions = await polar.subscriptions.list({
organization_id: "org_xxx",
metadata: { tier: "pro" }
});
```
## Best Practices
1. **Lifecycle Management:**
- Listen to all subscription webhooks
- Handle each state appropriately
- Sync state to your database
- Grant/revoke access based on state
2. **Upgrades/Downgrades:**
- Use proration for fair billing
- Communicate changes clearly
- Preview invoice before change
- Allow customer self-service
3. **Trials:**
- Set appropriate trial duration
- Notify before trial ends
- Easy cancellation during trial
- Clear trial end date in UI
4. **Cancellations:**
- Make cancellation easy
- Offer alternatives (pause, downgrade)
- Collect feedback
- Keep benefits until period end
- Send confirmation email
5. **Failed Payments:**
- Handle `past_due` webhook
- Notify customer promptly
- Provide retry mechanism
- Grace period before revocation
- Clear reactivation path
6. **Customer Communication:**
- Renewal reminders
- Payment confirmations
- Failed payment notifications
- Upgrade/downgrade confirmations
- Cancellation confirmations
7. **Analytics:**
- Track churn reasons
- Monitor upgrade/downgrade patterns
- Analyze trial conversion
- Measure payment failure rates
- Lifetime value calculations
## Common Patterns
### Subscription Status Check
```typescript
async function hasActiveSubscription(userId) {
const subscriptions = await polar.subscriptions.list({
external_customer_id: userId,
status: "active"
});
return subscriptions.items.length > 0;
}
```
### Grace Period Handler
```typescript
app.post('/webhook/polar', async (req, res) => {
const event = validateEvent(req.body, req.headers, secret);
if (event.type === 'subscription.past_due') {
const subscription = event.data;
// Grant 3-day grace period
await grantGracePeriod(subscription.customer_id, 3);
// Notify customer
await sendPaymentFailedEmail(subscription.customer_id);
}
res.json({ received: true });
});
```
### Upgrade Path
```typescript
async function upgradeSubscription(subscriptionId, newPriceId) {
// Preview invoice
const preview = await polar.subscriptions.previewUpdate(subscriptionId, {
product_price_id: newPriceId,
proration: "invoice_immediately"
});
// Show customer preview
if (await confirmUpgrade(preview)) {
await polar.subscriptions.update(subscriptionId, {
product_price_id: newPriceId,
proration: "invoice_immediately"
});
}
}
```

View File

@@ -0,0 +1,405 @@
# Polar Webhooks
Event handling, signature verification, and monitoring.
## Setup
1. Org Settings → Webhooks
2. Enter endpoint URL (publicly accessible)
3. Receive webhook secret (base64 encoded)
4. Select event types
5. Save configuration
**Requirements:**
- HTTPS endpoint
- Respond within 20 seconds
- Return 2xx status code
## Signature Verification
### Headers
```
webhook-id: msg_xxx
webhook-signature: v1,signature_xxx
webhook-timestamp: 1642000000
```
### TypeScript Verification
```typescript
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
app.post('/webhook/polar', (req, res) => {
try {
const event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET
);
// Event is valid, process it
await handleEvent(event);
res.json({ received: true });
} catch (error) {
if (error instanceof WebhookVerificationError) {
console.error('Invalid webhook signature');
return res.status(400).json({ error: 'Invalid signature' });
}
throw error;
}
});
```
### Python Verification
```python
from polar_sdk.webhooks import validate_event, WebhookVerificationError
@app.route('/webhook/polar', methods=['POST'])
def polar_webhook():
try:
event = validate_event(
request.get_data(),
dict(request.headers),
os.environ['POLAR_WEBHOOK_SECRET']
)
handle_event(event)
return {'received': True}
except WebhookVerificationError:
return {'error': 'Invalid signature'}, 400
```
### Manual Verification
```typescript
import crypto from 'crypto';
function verifySignature(payload, headers, secret) {
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'].split(',');
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signedPayload)
.digest('base64');
return signatures.some(sig => {
const [version, signature] = sig.split('=');
return version === 'v1' && signature === expectedSignature;
});
}
```
## Event Types
### Checkout
- `checkout.created` - Checkout session created
- `checkout.updated` - Session updated
### Order
- `order.created` - Order created (check `billing_reason`)
- `purchase` - One-time product
- `subscription_create` - New subscription
- `subscription_cycle` - Renewal
- `subscription_update` - Plan change
- `order.paid` - Payment confirmed
- `order.updated` - Order updated
- `order.refunded` - Refund processed
### Subscription
- `subscription.created` - Subscription created
- `subscription.active` - Subscription activated
- `subscription.updated` - Subscription modified
- `subscription.canceled` - Cancellation scheduled
- `subscription.revoked` - Subscription terminated
**Note:** Multiple events may fire for single action
### Customer
- `customer.created` - Customer created
- `customer.updated` - Customer modified
- `customer.deleted` - Customer deleted
- `customer.state_changed` - Benefits/subscriptions changed
### Benefit Grant
- `benefit_grant.created` - Benefit granted
- `benefit_grant.updated` - Grant modified
- `benefit_grant.revoked` - Benefit revoked
### Refund
- `refund.created` - Refund initiated
- `refund.updated` - Refund status changed
### Product
- `product.created` - Product created
- `product.updated` - Product modified
## Event Structure
```typescript
{
"type": "order.paid",
"data": {
"id": "order_xxx",
"amount": 2000,
"currency": "USD",
"billing_reason": "purchase",
"customer": { ... },
"product": { ... },
"subscription": null,
"metadata": { ... }
}
}
```
## Handler Implementation
### Basic Handler
```typescript
async function handleEvent(event) {
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data);
break;
case 'subscription.active':
await grantAccess(event.data.customer_id);
break;
case 'subscription.revoked':
await revokeAccess(event.data.customer_id);
break;
case 'benefit_grant.created':
await notifyBenefitGranted(event.data);
break;
default:
console.log(`Unhandled event: ${event.type}`);
}
}
```
### Order Handler
```typescript
async function handleOrderPaid(order) {
// Handle different billing reasons
switch (order.billing_reason) {
case 'purchase':
await fulfillOneTimeOrder(order);
break;
case 'subscription_create':
await handleNewSubscription(order);
break;
case 'subscription_cycle':
await handleRenewal(order);
break;
case 'subscription_update':
await handleUpgrade(order);
break;
}
}
```
### Customer State Handler
```typescript
async function handleCustomerStateChanged(customer) {
// Customer state includes:
// - active_subscriptions
// - active_benefits
const hasActiveSubscription = customer.active_subscriptions.length > 0;
if (hasActiveSubscription) {
await enableFeatures(customer.external_id);
} else {
await disableFeatures(customer.external_id);
}
}
```
## Best Practices
### 1. Respond Immediately
```typescript
app.post('/webhook/polar', async (req, res) => {
// Respond quickly
res.json({ received: true });
// Queue for background processing
await webhookQueue.add('polar-webhook', req.body);
});
```
### 2. Idempotency
```typescript
async function handleEvent(event) {
// Check if already processed
const exists = await db.processedEvents.findOne({
webhook_id: event.id
});
if (exists) {
console.log('Event already processed');
return;
}
// Process event
await processEvent(event);
// Mark as processed
await db.processedEvents.insert({
webhook_id: event.id,
processed_at: new Date()
});
}
```
### 3. Retry Logic
```typescript
async function processWithRetry(event, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
await handleEvent(event);
return;
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error;
await sleep(1000 * attempt);
}
}
}
```
### 4. Error Handling
```typescript
app.post('/webhook/polar', async (req, res) => {
try {
const event = validateEvent(req.body, req.headers, secret);
res.json({ received: true });
await processWithRetry(event);
} catch (error) {
console.error('Webhook processing failed:', error);
// Log to error tracking service
await logError(error, req.body);
if (error instanceof WebhookVerificationError) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Return 2xx even on processing errors
// Polar will retry if non-2xx
res.json({ received: true });
}
});
```
### 5. Logging
```typescript
logger.info('Webhook received', {
event_type: event.type,
event_id: event.id,
customer_id: event.data.customer?.id,
amount: event.data.amount
});
```
## Monitoring
### Dashboard Features
- View webhook attempts
- Check response status
- Review retry history
- Manual retry option
- Filter by event type
- Search by customer
### Application Monitoring
```typescript
const metrics = {
webhooks_received: counter('polar_webhooks_received_total'),
webhooks_processed: counter('polar_webhooks_processed_total'),
webhooks_failed: counter('polar_webhooks_failed_total'),
processing_time: histogram('polar_webhook_processing_seconds')
};
app.post('/webhook/polar', async (req, res) => {
metrics.webhooks_received.inc({ type: req.body.type });
const timer = metrics.processing_time.startTimer();
try {
await handleEvent(req.body);
metrics.webhooks_processed.inc({ type: req.body.type });
} catch (error) {
metrics.webhooks_failed.inc({ type: req.body.type });
} finally {
timer();
}
res.json({ received: true });
});
```
## Framework Adapters
### Next.js
```typescript
import { validateEvent } from '@polar-sh/nextjs/webhooks';
export async function POST(req: Request) {
const event = await validateEvent(req);
await handleEvent(event);
return Response.json({ received: true });
}
```
### Laravel
```php
use Polar\Webhooks\WebhookHandler;
Route::post('/webhook/polar', function (Request $request) {
$event = WebhookHandler::validate(
$request->getContent(),
$request->headers->all(),
config('polar.webhook_secret')
);
dispatch(new ProcessPolarWebhook($event));
return response()->json(['received' => true]);
});
```
## Testing
### Manual Testing
```bash
# Use Polar dashboard to send test webhooks
# Or use webhook testing tools
curl -X POST https://your-domain.com/webhook/polar \
-H "Content-Type: application/json" \
-H "webhook-id: msg_test" \
-H "webhook-timestamp: $(date +%s)" \
-H "webhook-signature: v1,test_signature" \
-d '{"type":"order.paid","data":{...}}'
```
### Local Testing with ngrok
```bash
# Expose local server
ngrok http 3000
# Use ngrok URL in Polar webhook settings
https://abc123.ngrok.io/webhook/polar
```

View File

@@ -0,0 +1,140 @@
# SePay API Reference
Base URL: `https://my.sepay.vn/userapi/`
Rate Limit: 2 calls/second
## Transaction API
### List Transactions
```
GET /userapi/transactions/list
```
**Parameters:**
- `account_number` (string) - Bank account ID
- `transaction_date_min/max` (yyyy-mm-dd) - Date range
- `since_id` (integer) - Start from ID
- `limit` (integer) - Max 5000 per request
- `reference_number` (string) - Transaction reference
- `amount_in` (number) - Incoming amount
- `amount_out` (number) - Outgoing amount
**Response:**
```json
{
"status": 200,
"transactions": [{
"id": 92704,
"gateway": "Vietcombank",
"transaction_date": "2023-03-25 14:02:37",
"account_number": "0123499999",
"content": "payment content",
"transfer_type": "in",
"transfer_amount": 2277000,
"accumulated": 19077000,
"reference_number": "MBVCB.3278907687",
"bank_account_id": 123
}]
}
```
### Transaction Details
```
GET /userapi/transactions/details/{transaction_id}
```
### Count Transactions
```
GET /userapi/transactions/count
```
## Bank Account API
### List Bank Accounts
```
GET /userapi/bankaccounts/list
```
**Parameters:**
- `short_name` - Bank identifier
- `last_transaction_date_min/max` - Date range
- `since_id` - Starting account ID
- `limit` - Results per page (default 100)
- `accumulated_min/max` - Balance range
**Response:**
```json
{
"id": 123,
"account_holder_name": "NGUYEN VAN A",
"account_number": "0123456789",
"accumulated": 50000000,
"last_transaction": "2025-01-13 10:30:00",
"bank_short_name": "VCB",
"active": 1
}
```
### Account Details
```
GET /userapi/bankaccounts/details/{bank_account_id}
```
### Count Accounts
```
GET /userapi/bankaccounts/count
```
## Order-Based Virtual Account API
**Concept:** Each order gets unique VA with exact amount matching for automated confirmation.
**Flow:**
1. Create order → API generates unique VA
2. Display VA + QR to customer
3. Customer transfers to VA
4. Bank notifies SePay on success
5. SePay triggers webhook
6. Update order status
**Advantages:**
- Precision: VA accepts only exact amounts
- Independence: Each order has own VA (no content parsing)
- Security: VAs auto-cancel after success/expiration
- Integration: RESTful API
**Supported Banks:** BIDV and others (check docs for full list)
## Error Handling
**HTTP Status Codes:**
- 200 OK - Successful
- 201 Created - Resource created
- 400 Bad Request - Invalid parameters
- 401 Unauthorized - Invalid/missing auth
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Server error
- 503 Service Unavailable - Temporarily unavailable
**Rate Limit Response:**
```json
{
"status": 429,
"error": "rate_limit_exceeded",
"message": "Too many requests"
}
```
Check `x-sepay-userapi-retry-after` header for retry timing.
## Best Practices
1. **Pagination:** Use `limit` and `since_id` for large datasets
2. **Date Ranges:** Query specific periods to reduce response size
3. **Rate Limiting:** Implement exponential backoff
4. **Error Handling:** Log all errors with context
5. **Caching:** Cache bank account lists
6. **Monitoring:** Track API response times and error rates
7. **Reconciliation:** Regular transaction matching

View File

@@ -0,0 +1,939 @@
# SePay Best Practices
Production-proven patterns for Vietnamese bank transfer payments via SePay/VietQR, covering transaction parsing, webhook handling, order matching, currency conversion, and error handling.
## Environment Configuration
### Required Environment Variables
```bash
# Core API
SEPAY_API_TOKEN=xxx # Bearer token for SePay API
SEPAY_WEBHOOK_API_KEY=xxx # API key for webhook authentication
SEPAY_API_URL=https://my.sepay.vn/userapi # Base URL (optional)
# Bank Account Details
SEPAY_ACCOUNT_NUMBER=0123456789 # Bank account for transfers
SEPAY_ACCOUNT_NAME=COMPANY_NAME # Account holder name
SEPAY_BANK_NAME=Vietcombank # Bank name (VietQR recognized)
```
### Product Pricing in VND
```typescript
// lib/sepay.ts
const VND_PRICES = {
engineer_kit: 2450000, // ~$100 USD
marketing_kit: 2450000, // ~$100 USD
combo: 3650000, // ~$149 USD
} as const;
const USD_TO_VND_RATE = 24500; // 1 USD ≈ 24,500 VND
```
## Transaction Content Format
### Standard Format
```
CLAUDEKIT {order-uuid}
```
Example: `CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e`
### Team Checkout Format
```
TEAM{8-hex-chars}
```
Example: `TEAM4E4635F4`
### Why These Formats
- UUID ensures global uniqueness
- `CLAUDEKIT` prefix for easy visual identification
- Short team prefix fits bank memo limits
- Case-insensitive matching handles bank transformations
## QR Code Generation
### VietQR URL Pattern
```typescript
// lib/sepay.ts
export function generateVietQRUrl(
accountNumber: string,
bankName: string,
amount: number,
content: string
): string {
const params = new URLSearchParams({
acc: accountNumber,
bank: bankName,
amount: String(Math.floor(amount)), // Integer only
des: content,
});
return `https://qr.sepay.vn/img?${params.toString()}`;
}
```
### Usage Example
```typescript
const qrUrl = generateVietQRUrl(
process.env.SEPAY_ACCOUNT_NUMBER!,
process.env.SEPAY_BANK_NAME!,
2450000,
`CLAUDEKIT ${orderId}`
);
// Returns: https://qr.sepay.vn/img?acc=0123456789&bank=Vietcombank&amount=2450000&des=CLAUDEKIT+uuid
```
## Checkout API Implementation
### Standard SePay Checkout
```typescript
// app/api/checkout/sepay/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const checkoutSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
productType: z.enum(['engineer_kit', 'marketing_kit', 'combo']),
githubUsername: z.string().min(1),
couponCode: z.string().optional(),
vatInvoiceRequested: z.boolean().optional(),
taxId: z.string().regex(/^\d{10}$|^\d{13}$/).optional(), // 10 or 13 digits
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = checkoutSchema.parse(body);
// 1. Normalize email
const normalizedEmail = data.email.toLowerCase().trim();
// 2. Get base price
const originalAmount = VND_PRICES[data.productType];
let finalAmount = originalAmount;
let discountMetadata: Record<string, any> = { originalAmount };
// 3. CRITICAL: Apply discounts in correct order
// Step A: Apply coupon FIRST
if (data.couponCode) {
const couponResult = await validateCouponForVND(data.couponCode, originalAmount);
if (couponResult.valid) {
finalAmount = originalAmount - couponResult.discountAmountVND;
discountMetadata.couponCode = data.couponCode;
discountMetadata.couponDiscountAmount = couponResult.discountAmountVND;
discountMetadata.couponId = couponResult.couponId;
}
}
// Step B: Apply referral SECOND (on post-coupon amount)
const referralCode = getReferralCodeFromCookie(request);
if (referralCode) {
const referralResult = await calculateReferralDiscountVND(
referralCode,
finalAmount, // Post-coupon amount
normalizedEmail
);
if (referralResult.valid && referralResult.discountAmount > 0) {
// Validate calculation
if (referralResult.discountAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid discount calculation' },
{ status: 400 }
);
}
finalAmount -= referralResult.discountAmount;
discountMetadata.referralCode = referralCode;
discountMetadata.referralDiscountAmount = referralResult.discountAmount;
discountMetadata.referrerId = referralResult.referrerId;
}
}
// 4. Validate final amount
if (finalAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid final amount' },
{ status: 400 }
);
}
// 5. Encrypt sensitive data if VAT invoice requested
let encryptedTaxId: string | null = null;
if (data.vatInvoiceRequested && data.taxId) {
encryptedTaxId = await encrypt(data.taxId);
}
// 6. Create order record
const orderId = crypto.randomUUID();
const transactionContent = `CLAUDEKIT ${orderId}`;
const order = await db.insert(orders).values({
id: orderId,
email: normalizedEmail,
productType: data.productType,
amount: finalAmount,
currency: 'VND',
status: 'pending',
paymentProvider: 'sepay',
paymentId: transactionContent, // Used for matching
referredBy: discountMetadata.referrerId,
discountAmount: originalAmount - finalAmount,
metadata: JSON.stringify({
...discountMetadata,
githubUsername: data.githubUsername,
vatInvoiceRequested: data.vatInvoiceRequested,
encryptedTaxId,
}),
}).returning();
// 7. Generate payment instructions
const qrCode = generateVietQRUrl(
process.env.SEPAY_ACCOUNT_NUMBER!,
process.env.SEPAY_BANK_NAME!,
finalAmount,
transactionContent
);
return NextResponse.json({
orderId: order[0].id,
paymentMethod: 'bank_transfer',
payment: {
bankName: process.env.SEPAY_BANK_NAME,
accountNumber: process.env.SEPAY_ACCOUNT_NUMBER,
accountName: process.env.SEPAY_ACCOUNT_NAME,
amount: finalAmount,
currency: 'VND',
content: transactionContent,
qrCode,
instructions: [
'Open your banking app',
'Scan the QR code or transfer manually',
'Use the exact transfer content shown',
'Payment will be confirmed automatically',
],
},
statusCheckUrl: `/api/orders/${order[0].id}/status`,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
console.error('SePay checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout' },
{ status: 500 }
);
}
}
```
## Webhook Handling
### Webhook Authentication (Timing-Safe)
```typescript
// app/api/webhooks/sepay/route.ts
import { timingSafeEqual } from 'crypto';
import { NextResponse } from 'next/server';
function verifyWebhookAuth(request: Request): boolean {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return false;
const expectedKey = process.env.SEPAY_WEBHOOK_API_KEY!;
// Support both "Bearer" and "Apikey" formats
let providedKey: string;
if (authHeader.startsWith('Bearer ')) {
providedKey = authHeader.slice(7);
} else if (authHeader.startsWith('Apikey ')) {
providedKey = authHeader.slice(7);
} else {
return false;
}
// Timing-safe comparison to prevent timing attacks
try {
const expected = Buffer.from(expectedKey);
const provided = Buffer.from(providedKey);
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
} catch {
return false;
}
}
export async function POST(request: Request) {
// 1. Verify authentication
if (!verifyWebhookAuth(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = await request.json();
// 2. Extract event ID for idempotency
const eventId = String(payload.id || payload.transaction_id || Date.now());
// 3. Check for duplicate
const existingEvent = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existingEvent.length > 0) {
console.log(`Duplicate SePay webhook ignored: ${eventId}`);
return NextResponse.json({ success: true });
}
// 4. Record event BEFORE processing (idempotency)
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider: 'sepay',
eventType: 'transaction',
eventId,
payload: JSON.stringify(payload),
processed: false,
});
try {
await processTransaction(payload);
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
} catch (error) {
// Log error but return 200 to prevent retry loop
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(webhookEvents.eventId, eventId));
}
// Always return 200 to prevent SePay retries
return NextResponse.json({ success: true });
}
```
### Webhook Payload Structure
```typescript
interface SepayWebhookPayload {
id: number; // Transaction ID (unique key)
gateway: string; // Bank name (e.g., "Vietcombank")
transactionDate: string; // "2025-01-07 10:30:00"
accountNumber: string; // Account number
code?: string; // Optional payment code
content: string; // Transaction memo - CRITICAL for matching
transferType: 'in' | 'out'; // Only process 'in'
transferAmount: number; // Amount in VND
accumulated: number; // Balance after transaction
subAccount?: string;
referenceCode?: string;
description?: string;
}
```
## Order Matching Strategy
### Multi-Strategy Fallback Chain
```typescript
// lib/sepay.ts
export async function findOrderByTransaction(
payload: SepayWebhookPayload
): Promise<{ order: Order | null; matchMethod: string }> {
const { content, transferAmount, transactionDate } = payload;
// Strategy 1: Parse Order ID from content (preferred)
const parsedOrderId = parseOrderIdFromContent(content);
if (parsedOrderId) {
const order = await db.select()
.from(orders)
.where(eq(orders.id, parsedOrderId))
.limit(1);
if (order[0]) {
return { order: order[0], matchMethod: 'content-parse' };
}
}
// Strategy 2: Team payment ID match
const teamMatch = content.match(/TEAM([A-F0-9]{8})/i);
if (teamMatch) {
const teamPaymentId = `TEAM${teamMatch[1].toUpperCase()}`;
const order = await db.select()
.from(orders)
.where(eq(orders.paymentId, teamPaymentId))
.limit(1);
if (order[0]) {
return { order: order[0], matchMethod: 'team-payment-id' };
}
}
// Strategy 3: Amount + timestamp window (±30 minutes)
const transactionTime = new Date(transactionDate);
const windowStart = new Date(transactionTime.getTime() - 30 * 60 * 1000);
const windowEnd = new Date(transactionTime.getTime() + 30 * 60 * 1000);
const windowMatches = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'pending'),
eq(orders.paymentProvider, 'sepay'),
eq(orders.amount, transferAmount),
gte(orders.createdAt, windowStart),
lte(orders.createdAt, windowEnd)
))
.limit(10);
if (windowMatches.length === 1) {
return { order: windowMatches[0], matchMethod: 'timestamp-window' };
}
if (windowMatches.length > 1) {
// Multiple matches - select closest by creation time
const closest = windowMatches.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.createdAt.getTime() - transactionTime.getTime());
const currDiff = Math.abs(curr.createdAt.getTime() - transactionTime.getTime());
return currDiff < prevDiff ? curr : prev;
});
return { order: closest, matchMethod: 'timestamp-window-closest' };
}
// Strategy 4: Amount only (last resort - single match only)
const amountMatches = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'pending'),
eq(orders.paymentProvider, 'sepay'),
eq(orders.amount, transferAmount)
))
.limit(2);
if (amountMatches.length === 1) {
console.warn(`⚠️ Amount-only match for ${transferAmount} VND - verify manually`);
return { order: amountMatches[0], matchMethod: 'amount-only' };
}
// No match found
console.error(`❌ Could not match order:
Content: "${content}"
Amount: ${transferAmount} VND
Transaction Date: ${transactionDate}`);
return { order: null, matchMethod: 'none' };
}
```
### UUID Parsing with Bank Transformations
```typescript
// lib/sepay.ts
export function parseOrderIdFromContent(content: string): string | null {
if (!content) return null;
// Pattern 1: Standard "CLAUDEKIT {uuid}"
const claudekitMatch = content.match(/CLAUDEKIT\s+([\w-]+)/i);
if (claudekitMatch) {
return normalizeUUID(claudekitMatch[1]);
}
// Pattern 2: UUID anywhere in content (banks may strip/transform content)
// Match 8-4-4-4-12 hex with optional dashes
const uuidMatch = content.match(
/([0-9A-F]{8}-?[0-9A-F]{4}-?[0-9A-F]{4}-?[0-9A-F]{4}-?[0-9A-F]{12})/i
);
if (uuidMatch) {
return normalizeUUID(uuidMatch[1]);
}
return null;
}
function normalizeUUID(input: string): string | null {
// Remove dashes and validate
const cleaned = input.replace(/-/g, '');
if (cleaned.length !== 32) return null;
if (!/^[0-9a-f]+$/i.test(cleaned)) return null;
// Re-format to standard UUID format
return [
cleaned.slice(0, 8),
cleaned.slice(8, 12),
cleaned.slice(12, 16),
cleaned.slice(16, 20),
cleaned.slice(20),
].join('-').toLowerCase();
}
```
### Handled Content Formats
```
CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e ✅ Standard
CLAUDEKIT 4e4635f404784080a5c548da91f97f1e ✅ Bank stripped dashes
CLAUDEKIT4e4635f404784080a5c548da91f97f1e ✅ No space
4e4635f404784080a5c548da91f97f1e-CLAUDEKIT ✅ Reversed
claudekit 4e4635f4-0478-4080-a5c5-48da91f97f1e ✅ Lowercase
BankAPINotify 4e4635f404784080a5c548da91f97f1e... ✅ Extra prefix
4e4635f404784080a5c548da91f97f1e ✅ UUID only
```
## Transaction Processing
### Complete Processing Flow
```typescript
async function processTransaction(payload: SepayWebhookPayload) {
// 1. Only process incoming transfers
if (payload.transferType !== 'in') {
console.log('Skipping outbound transfer');
return;
}
// 2. Find matching order
const { order, matchMethod } = await findOrderByTransaction(payload);
if (!order) {
console.error('No matching order found');
return;
}
// 3. Verify amount (allow overpayment)
if (payload.transferAmount < order.amount) {
console.error(`Underpayment: expected ${order.amount}, got ${payload.transferAmount}`);
return;
}
if (payload.transferAmount > order.amount) {
console.log(`Overpayment accepted: expected ${order.amount}, got ${payload.transferAmount}`);
}
// 4. Update order with transaction details
const existingMetadata = order.metadata ? JSON.parse(order.metadata) : {};
await db.update(orders)
.set({
status: 'completed',
paymentId: String(payload.id),
metadata: JSON.stringify({
...existingMetadata, // Preserve discount info
gateway: payload.gateway,
transactionDate: payload.transactionDate,
accountNumber: payload.accountNumber,
transferAmount: payload.transferAmount,
content: payload.content,
matchMethod,
transactionId: payload.id,
}),
updatedAt: new Date(),
})
.where(eq(orders.id, order.id));
// 5. Create license (non-blocking)
try {
await createLicense(order);
} catch (error) {
console.error('Failed to create license:', error);
}
// 6. Send confirmation email (non-blocking)
try {
await sendOrderConfirmation(order, payload);
} catch (error) {
console.error('Failed to send confirmation:', error);
}
// 7. Create referral commission (non-blocking)
if (order.referredBy) {
try {
// Commission based on actual paid amount
await createCommission({
orderId: order.id,
referrerId: order.referredBy,
baseAmount: payload.transferAmount, // Actual paid amount
currency: 'VND',
});
} catch (error) {
console.error('Failed to create commission:', error);
}
}
// 8. Update referrer tier (non-blocking)
if (order.referredBy) {
try {
const usdConversion = await convertVndToUsd(payload.transferAmount);
await updateReferrerTier(order.referredBy, usdConversion.usdCents, order.id);
} catch (error) {
console.error('Failed to update tier:', error);
}
}
// 9. Grant GitHub access (non-blocking)
try {
const metadata = JSON.parse(order.metadata || '{}');
await inviteToGitHub(metadata.githubUsername, order.productType);
} catch (error) {
console.error('Failed to invite to GitHub:', error);
}
// 10. Sync Polar discount redemption (non-blocking)
const metadata = JSON.parse(order.metadata || '{}');
if (metadata.couponId && metadata.couponCode) {
try {
await syncPolarDiscountWithRetry(order.id, metadata.couponId, metadata.couponCode);
} catch (error) {
console.error('Failed to sync Polar discount:', error);
await sendDiscordAlert('Polar discount sync failed', { orderId: order.id });
}
}
// 11. Send sales notification (non-blocking)
try {
await sendSalesNotification({
...order,
gateway: payload.gateway,
transactionId: payload.id,
});
} catch (error) {
console.error('Failed to send Discord notification:', error);
}
}
```
## Currency Conversion
### VND to USD with Multi-Layer Fallback
```typescript
// lib/currency.ts
const EXCHANGE_RATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const FALLBACK_VND_TO_USD = 24500; // Conservative fallback
let exchangeRateCache: {
rate: number;
timestamp: number;
source: 'api' | 'cached' | 'expired' | 'fallback';
} | null = null;
export async function convertVndToUsd(vndAmount: number): Promise<{
usdCents: number;
rate: number;
source: string;
}> {
const now = Date.now();
// Layer 1: Fresh cache
if (exchangeRateCache && now - exchangeRateCache.timestamp < EXCHANGE_RATE_CACHE_TTL) {
const usdCents = Math.round((vndAmount / exchangeRateCache.rate) * 100);
return { usdCents, rate: exchangeRateCache.rate, source: 'cached' };
}
// Layer 2: Try live API
try {
const response = await fetch(
'https://api.exchangerate-api.com/v4/latest/USD',
{ signal: AbortSignal.timeout(5000) }
);
const data = await response.json();
const rate = data.rates.VND;
exchangeRateCache = { rate, timestamp: now, source: 'api' };
const usdCents = Math.round((vndAmount / rate) * 100);
return { usdCents, rate, source: 'api' };
} catch (error) {
console.warn('Exchange rate API failed:', error);
// Layer 3: Expired cache (better than nothing)
if (exchangeRateCache) {
const usdCents = Math.round((vndAmount / exchangeRateCache.rate) * 100);
return { usdCents, rate: exchangeRateCache.rate, source: 'expired_cache' };
}
// Layer 4: Hardcoded fallback
const usdCents = Math.round((vndAmount / FALLBACK_VND_TO_USD) * 100);
return { usdCents, rate: FALLBACK_VND_TO_USD, source: 'fallback' };
}
}
```
### USD Discount to VND
```typescript
// When Polar discount is in USD, convert to VND for SePay checkout
export function convertUsdDiscountToVnd(
discount: { type: 'fixed' | 'percentage'; amount?: number; basisPoints?: number },
amountVND: number
): number {
if (discount.type === 'percentage') {
// Basis points: 1000 = 10%, 10000 = 100%
const percentage = (discount.basisPoints || 0) / 10000;
return Math.round(amountVND * percentage);
} else {
// Fixed amount in USD cents → VND
const usdDollars = (discount.amount || 0) / 100;
return Math.round(usdDollars * 24500); // Use conservative rate
}
}
```
## Invoice Email Template
### HTML Invoice Generation
```typescript
// lib/emails/sepay-invoice.ts
export function generateSepayInvoice(order: Order, transaction: TransactionInfo): string {
const metadata = JSON.parse(order.metadata || '{}');
const invoiceNumber = `INV-${format(new Date(), 'yyyyMMdd')}-${order.id.slice(-8).toUpperCase()}`;
// Format VND with Vietnamese locale
const formatVND = (amount: number) =>
new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(amount);
// Escape HTML to prevent XSS
const escapeHtml = (text: string) =>
text.replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char] || char);
return `
<!DOCTYPE html>
<html>
<head>
<style>
.invoice { font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; }
.header { background: linear-gradient(135deg, #ff6b6b, #feca57); padding: 20px; }
.status { background: #10b981; color: white; padding: 4px 12px; border-radius: 4px; }
.amount { font-size: 24px; font-weight: bold; }
.savings { color: #10b981; }
</style>
</head>
<body>
<div class="invoice">
<div class="header">
<h1>Invoice</h1>
<span class="status">PAID</span>
</div>
<table>
<tr><td>Invoice #:</td><td>${invoiceNumber}</td></tr>
<tr><td>Customer:</td><td>${escapeHtml(metadata.name || order.email)}</td></tr>
<tr><td>Email:</td><td>${escapeHtml(order.email)}</td></tr>
<tr><td>Payment Date:</td><td>${format(new Date(transaction.transactionDate), 'dd/MM/yyyy HH:mm')}</td></tr>
<tr><td>Transaction Ref:</td><td>${transaction.transactionId || 'N/A'}</td></tr>
</table>
<h3>Order Details</h3>
<table>
<tr><td>Product:</td><td>${getProductName(order.productType)}</td></tr>
<tr><td>Original Price:</td><td>${formatVND(metadata.originalAmount || order.amount)}</td></tr>
${metadata.couponDiscountAmount ? `
<tr><td>Coupon (${metadata.couponCode}):</td><td>-${formatVND(metadata.couponDiscountAmount)}</td></tr>
` : ''}
${metadata.referralDiscountAmount ? `
<tr><td>Referral Discount (20%):</td><td>-${formatVND(metadata.referralDiscountAmount)}</td></tr>
` : ''}
${order.discountAmount > 0 ? `
<tr class="savings"><td>Total Savings:</td><td>-${formatVND(order.discountAmount)}</td></tr>
` : ''}
<tr class="amount"><td>Total Paid:</td><td>${formatVND(order.amount)}</td></tr>
</table>
<p>Thank you for your purchase!</p>
<p>Support: support@claudekit.com</p>
</div>
</body>
</html>
`;
}
```
## Error Handling Patterns
### Always Return 200 to SePay
```typescript
// Webhook must always return 200 to prevent retry loop
export async function POST(request: Request) {
try {
// ... processing
} catch (error) {
// Log error but don't fail
console.error('Webhook processing error:', error);
await logWebhookError(error);
}
// ALWAYS return 200
return NextResponse.json({ success: true });
}
```
### Non-Blocking Post-Payment Operations
```typescript
// Wrap each operation in try-catch
const operations = [
{ name: 'License', fn: () => createLicense(order) },
{ name: 'Email', fn: () => sendOrderConfirmation(order) },
{ name: 'Commission', fn: () => createCommission(order) },
{ name: 'GitHub', fn: () => inviteToGitHub(username, productType) },
{ name: 'Discord', fn: () => sendSalesNotification(order) },
];
for (const op of operations) {
try {
await op.fn();
console.log(`✅ ${op.name} completed`);
} catch (error) {
console.error(`❌ ${op.name} failed:`, error);
// Continue - don't block other operations
}
}
```
### Amount Validation
```typescript
// Reject underpayment, accept overpayment
if (transferAmount < order.amount) {
console.error(`Underpayment: expected ${order.amount}, received ${transferAmount}`);
await flagOrderForReview(order.id, 'underpayment');
return; // Don't process
}
if (transferAmount > order.amount) {
console.log(`Overpayment: expected ${order.amount}, received ${transferAmount}`);
// Continue processing - customer paid more than required
}
```
## Testing Patterns
### Unit Tests for UUID Parsing
```typescript
// __tests__/lib/sepay.test.ts
describe('parseOrderIdFromContent', () => {
it('parses standard format', () => {
expect(parseOrderIdFromContent('CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('handles bank dash-stripping', () => {
expect(parseOrderIdFromContent('CLAUDEKIT 4e4635f404784080a5c548da91f97f1e'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('handles real-world Vietnamese bank memo', () => {
expect(parseOrderIdFromContent('BankAPINotify 4e4635f404784080a5c548da91f97f1e-CHUYEN TIEN'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('returns null for invalid content', () => {
expect(parseOrderIdFromContent('CLAUDEKIT')).toBeNull();
expect(parseOrderIdFromContent('4e4635f4-0478')).toBeNull();
expect(parseOrderIdFromContent('104588021672-CLAUDEKIT')).toBeNull();
});
});
```
### Webhook Integration Test Script
```bash
#!/bin/bash
# scripts/test-sepay-webhook.sh
BASE_URL="http://localhost:3000/api/webhooks/sepay"
API_KEY="your-test-key"
# Test 1: Valid Bearer token
echo "Test 1: Bearer token auth"
curl -X POST "$BASE_URL" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":12345,"content":"CLAUDEKIT test-uuid","transferAmount":2450000,"transferType":"in"}'
# Test 2: Valid Apikey format
echo "Test 2: Apikey auth"
curl -X POST "$BASE_URL" \
-H "Authorization: Apikey $API_KEY" \
-d '{"id":12346,"content":"CLAUDEKIT test-uuid","transferAmount":2450000,"transferType":"in"}'
# Test 3: Missing auth (should return 401)
echo "Test 3: No auth (expect 401)"
curl -X POST "$BASE_URL" \
-d '{"id":12347,"content":"test","transferAmount":100000,"transferType":"in"}'
# Test 4: Invalid key (should return 401)
echo "Test 4: Invalid key (expect 401)"
curl -X POST "$BASE_URL" \
-H "Authorization: Bearer wrong-key" \
-d '{"id":12348,"content":"test","transferAmount":100000,"transferType":"in"}'
```
## Database Schema
### Orders Table Extensions for SePay
```typescript
// Fields used specifically for SePay
{
paymentId: text('payment_id'), // Transaction content or TEAM{8} code
paymentProvider: literal('sepay'), // Distinguishes from Polar
currency: literal('VND'), // Always VND for SePay
amount: integer('amount'), // In VND (no decimals)
}
// Metadata JSON includes:
{
gateway: string, // Bank name from webhook
transactionDate: string, // Webhook timestamp
transactionId: number, // SePay transaction ID
transferAmount: number, // Actual received amount
matchMethod: string, // How order was matched
content: string, // Original transaction memo
encryptedTaxId?: string, // For VAT invoices
}
```
### Recommended Indexes
```sql
CREATE INDEX idx_orders_sepay_pending ON orders (status, payment_provider, amount)
WHERE status = 'pending' AND payment_provider = 'sepay';
CREATE INDEX idx_orders_sepay_timestamp ON orders (created_at)
WHERE payment_provider = 'sepay';
CREATE INDEX idx_orders_payment_id ON orders (payment_id)
WHERE payment_provider = 'sepay';
```
## Production Checklist
- [ ] Environment variables configured
- [ ] Bank account verified and active
- [ ] Webhook endpoint publicly accessible (HTTPS)
- [ ] Webhook API key set and verified
- [ ] Timing-safe auth comparison implemented
- [ ] Idempotency handling tested with duplicate webhooks
- [ ] UUID parsing tested with real Vietnamese bank memos
- [ ] Amount validation (underpayment rejection) tested
- [ ] Overpayment handling verified
- [ ] Currency conversion fallback chain tested
- [ ] Invoice email template tested
- [ ] Error monitoring enabled
- [ ] Structured logging in place
- [ ] Database indexes created
- [ ] Polar discount sync tested (for shared coupons)
- [ ] Team payment ID format tested
- [ ] Non-blocking operations wrapped in try-catch
- [ ] Always-200 webhook response verified
## Common Pitfalls
1. **Not handling bank dash-stripping** - Banks may remove dashes from UUIDs
2. **Rejecting overpayments** - Should accept; customer paid more
3. **Blocking webhook on non-critical failures** - Wrap in try-catch, continue
4. **Not using timing-safe comparison** - Vulnerable to timing attacks
5. **Returning non-200 on error** - Causes SePay retry loops
6. **Using raw exchange rates without fallback** - API can fail
7. **Applying discounts in wrong order** - Always coupon first, then referral
8. **Not logging matchMethod** - Hard to debug failed matches
9. **Not preserving checkout metadata** - Lose discount audit trail
10. **Synchronous Polar discount sync** - Can fail; use retry with backoff
11. **Case-sensitive content matching** - Banks may uppercase/lowercase
12. **Missing amount-only match safety** - Reject ambiguous matches

View File

@@ -0,0 +1,138 @@
# SePay Overview
Vietnamese payment automation platform serving as intermediary between applications and banks.
## Core Capabilities
**Payment Methods:**
- VietQR - QR code bank transfers (NAPAS standard)
- NAPAS QR - National payment gateway QR
- Bank Cards - Visa/Mastercard/JCB
- Bank Transfers - Direct bank-to-bank
- Virtual Accounts - Order-specific VAs with exact matching
**Supported Banks:** 44+ banks via NAPAS, 37+ with VietQR (Vietcombank, VPBank, BIDV, etc.)
**Use Cases:**
- Payment gateway for online payments
- Bank API direct connection
- Transaction verification automation
- Real-time balance monitoring
## Authentication
### API Token (Simple)
**Create:**
1. Company Configuration → API Access → "+ Add API"
2. Provide name, set status "Active"
3. Copy token from list
**Usage:**
```
Authorization: Bearer {API_TOKEN}
Content-Type: application/json
```
**Note:** All tokens have full access (no permission levels currently)
### OAuth2 (Advanced)
**Scopes:**
- `bank-account:read` - View accounts, balances
- `transaction:read` - Transaction history
- `webhook:read/write/delete` - Webhook management
- `profile` - User information
- `company` - Company details
**Authorization Code Flow:**
1. **Authorization Request:**
```
GET https://my.sepay.vn/oauth/authorize?
response_type=code&
client_id={CLIENT_ID}&
redirect_uri={REDIRECT_URI}&
scope={SCOPES}&
state={CSRF_TOKEN}
```
2. **Token Exchange (server-side only):**
```
POST https://my.sepay.vn/oauth/token
{
"grant_type": "authorization_code",
"client_id": "{CLIENT_ID}",
"client_secret": "{CLIENT_SECRET}",
"code": "{AUTHORIZATION_CODE}"
}
```
3. **Token Refresh:**
```
POST https://my.sepay.vn/oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "{REFRESH_TOKEN}",
"client_id": "{CLIENT_ID}",
"client_secret": "{CLIENT_SECRET}"
}
```
**Security:** Access tokens expire ~1 hour, never expose client_secret, use state for CSRF protection
## Payment Gateway Flow (13 Steps)
1. Customer selects products, initiates payment
2. Merchant creates order record
3. Generate checkout form with HMAC-SHA256 signature
4. Send request to `/v1/checkout/init`
5. SePay validates signature
6. Redirect customer to SePay gateway
7. Customer selects payment method
8. SePay communicates with banks/card networks
9. Financial institution returns result
10. Callback notification sent to merchant
11. IPN (Instant Payment Notification) transmitted
12. Customer redirected to merchant result page
13. Final outcome displayed
## Environments
**Sandbox:**
- Dashboard: https://my.sepay.vn (free tier)
- Endpoint: https://sandbox.pay.sepay.vn/v1/init
- Credentials: `SP-TEST-XXXXXXX`, `spsk_test_xxxxxxxxxxxxx`
**Production:**
- Endpoint: https://pay.sepay.vn/v1/init
- Requirements: Personal/business bank account, completed testing
- Approval: 3-7 days for NAPAS QR/cards (requires documentation)
## Rate Limits
**Limit:** 2 calls/second
**Response:** HTTP 429 with `x-sepay-userapi-retry-after` header (seconds to wait)
**Handling:**
```javascript
if (response.status === 429) {
const retryAfter = response.headers.get('x-sepay-userapi-retry-after');
await sleep(retryAfter * 1000);
return retry();
}
```
## Support
- Email: info@sepay.vn
- Hotline: 02873059589 (24/7)
- Docs: https://developer.sepay.vn/en
- GitHub: https://github.com/sepayvn
## Next Steps
- **For API integration:** Load `api.md`
- **For SDK integration:** Load `sdk.md`
- **For webhook setup:** Load `webhooks.md`
- **For QR generation:** Load `qr-codes.md`

View File

@@ -0,0 +1,228 @@
# SePay VietQR Generation
Dynamic QR code generation service compatible with VietQR standard (NAPAS).
## API Endpoint
```
https://qr.sepay.vn/img?acc={ACCOUNT}&bank={BANK}&amount={AMOUNT}&des={DESCRIPTION}
```
## Parameters
**Required:**
- `acc` - Bank account number
- `bank` - Bank code or short name
**Optional:**
- `amount` - Transfer amount (omit for flexible amount)
- `des` - Transfer description/content (URL encoded)
- `template` - QR image template (empty/compact/qronly)
- `download` - Set to "true" to download image
## Examples
### Complete QR (Fixed Amount)
```
https://qr.sepay.vn/img?
acc=0010000000355&
bank=Vietcombank&
amount=100000&
des=ung%20ho%20quy%20bao%20tro%20tre%20em
```
### Flexible QR (Customer Enters Amount)
```
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank
```
### QR Only Template
```
https://qr.sepay.vn/img?
acc=0010000000355&
bank=Vietcombank&
amount=100000&
template=qronly
```
## Integration
### HTML
```html
<img src="https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000"
alt="Payment QR Code" />
```
### JavaScript (Dynamic)
```javascript
function generatePaymentQR(account, bank, amount, description) {
const params = new URLSearchParams({
acc: account,
bank: bank,
amount: amount,
des: description
});
return `https://qr.sepay.vn/img?${params}`;
}
// Usage
const qrUrl = generatePaymentQR(
'0010000000355',
'Vietcombank',
100000,
'Order #12345'
);
document.getElementById('qr-code').src = qrUrl;
```
### PHP (Dynamic)
```php
<?php
function generatePaymentQR($account, $bank, $amount, $description) {
return 'https://qr.sepay.vn/img?' . http_build_query([
'acc' => $account,
'bank' => $bank,
'amount' => $amount,
'des' => $description
]);
}
// Usage
$qrUrl = generatePaymentQR(
'0010000000355',
'Vietcombank',
100000,
'Order #' . $orderId
);
echo "<img src='{$qrUrl}' alt='Payment QR' />";
?>
```
### Node.js (Express)
```javascript
app.get('/payment/:orderId/qr', async (req, res) => {
const order = await Order.findById(req.params.orderId);
const qrUrl = new URL('https://qr.sepay.vn/img');
qrUrl.searchParams.set('acc', process.env.SEPAY_ACCOUNT);
qrUrl.searchParams.set('bank', process.env.SEPAY_BANK);
qrUrl.searchParams.set('amount', order.total);
qrUrl.searchParams.set('des', `Order ${order.id}`);
res.render('payment', { qrUrl: qrUrl.toString() });
});
```
### React Component
```jsx
function PaymentQR({ account, bank, amount, description }) {
const qrUrl = useMemo(() => {
const params = new URLSearchParams({
acc: account,
bank: bank,
amount: amount,
des: description
});
return `https://qr.sepay.vn/img?${params}`;
}, [account, bank, amount, description]);
return (
<div className="payment-qr">
<img src={qrUrl} alt="Payment QR Code" />
<p>Scan to pay {amount.toLocaleString('vi-VN')} VND</p>
</div>
);
}
```
## Templates
**Default:**
- Full QR with bank logo
- Account information displayed
- Branded with bank colors
**Compact:**
- Smaller version
- Minimal branding
- More space-efficient
**QR Only:**
- Pure QR code
- No decorations
- For custom layouts
## Bank Codes
**Get Bank List:**
```
GET https://qr.sepay.vn/banks.json
```
**Common Banks:**
- Vietcombank (VCB)
- VPBank
- BIDV
- Techcombank (TCB)
- ACB
- MB Bank
- Sacombank
- VietinBank
- And 40+ others
**Cache Bank List:**
```javascript
// Fetch once and cache
const banks = await fetch('https://qr.sepay.vn/banks.json')
.then(res => res.json());
// Store in memory or Redis
cache.set('sepay_banks', banks, 86400); // 24 hours
```
## Best Practices
1. **Cache Bank List:** Avoid repeated API calls
2. **URL Encode Descriptions:** Use `encodeURIComponent()` or `http_build_query()`
3. **Error Handling:** Provide fallback for QR generation failures
4. **Amount Validation:** Ensure amount is positive integer
5. **Flexible vs Fixed:** Use flexible QR for varying amounts
6. **Template Selection:** Choose based on UI design
7. **Responsive Design:** Scale QR code for mobile devices
8. **Alt Text:** Always provide descriptive alt text
9. **Loading State:** Show placeholder while QR loads
10. **Print Support:** Ensure QR codes are print-friendly
## Integration Patterns
### Checkout Page
```html
<div class="payment-methods">
<h3>Pay via Bank Transfer</h3>
<img src="[QR_URL]" alt="Payment QR Code" class="qr-code" />
<p>Scan this QR code with your banking app</p>
<div class="payment-details">
<p><strong>Account:</strong> 0010000000355</p>
<p><strong>Bank:</strong> Vietcombank</p>
<p><strong>Amount:</strong> 100,000 VND</p>
<p><strong>Content:</strong> Order #12345</p>
</div>
</div>
```
### Email Receipt
```html
<table>
<tr>
<td align="center">
<img src="[QR_URL]" alt="Payment QR Code" width="200" />
<p>Scan to pay for your order</p>
</td>
</tr>
</table>
```
### PDF Invoice
Use QR URL in PDF generation libraries (wkhtmltopdf, Puppeteer, etc.)

View File

@@ -0,0 +1,213 @@
# SePay SDK Integration
Official SDKs for Node.js, PHP, and Laravel.
## Node.js SDK (sepay-pg-node)
**Installation:**
```bash
npm install github:sepay/sepay-pg-node
```
**Requirements:** Node.js 16+
**Configuration:**
```javascript
import { SePayPgClient } from 'sepay-pg-node';
const client = new SePayPgClient({
env: 'sandbox', // or 'production'
merchant_id: 'SP-TEST-XXXXXXX',
secret_key: 'spsk_test_xxxxxxxxxxxxx',
});
```
**Create Payment:**
```javascript
const fields = client.checkout.initOneTimePaymentFields({
operation: 'PURCHASE',
order_invoice_number: 'DH0001',
order_amount: 10000,
currency: 'VND',
success_url: 'https://example.com/success',
error_url: 'https://example.com/error',
cancel_url: 'https://example.com/cancel',
order_description: 'Payment for order DH0001',
});
```
**Render Payment Form:**
```jsx
<form action={client.checkout.initCheckoutUrl()} method="POST">
{Object.keys(fields).map(field =>
<input type="hidden" name={field} value={fields[field]} key={field} />
)}
<button type="submit">Pay Now</button>
</form>
```
**API Methods:**
```javascript
// List all orders
await client.order.all({
per_page: 50,
q: 'search_term',
order_status: 'completed',
from_created_at: '2025-01-01',
to_created_at: '2025-01-31'
});
// Get order details
await client.order.retrieve('DH0001');
// Void transaction (cards only)
await client.order.voidTransaction('DH0001');
// Cancel order (QR payments)
await client.order.cancel('DH0001');
```
**Endpoints:**
- Sandbox: `https://sandbox.pay.sepay.vn/v1/init`
- Production: `https://pay.sepay.vn/v1/init`
## PHP SDK (sepay/sepay-pg)
**Installation:**
```bash
composer require sepay/sepay-pg
```
**Requirements:** PHP 7.4+, ext-json, ext-curl, Guzzle
**Quick Start:**
```php
use SePay\SePayClient;
use SePay\Builders\CheckoutBuilder;
$sepay = new SePayClient(
'SP-TEST-XXXXXXX',
'spsk_live_xxxxxxxxxxxxx',
SePayClient::ENVIRONMENT_SANDBOX
);
$checkoutData = CheckoutBuilder::make()
->currency('VND')
->orderAmount(100000)
->operation('PURCHASE')
->orderDescription('Test payment')
->orderInvoiceNumber('INV_001')
->successUrl('https://yoursite.com/success')
->errorUrl('https://yoursite.com/error')
->cancelUrl('https://yoursite.com/cancel')
->build();
echo $sepay->checkout()->generateFormHtml($checkoutData);
```
**Error Handling:**
```php
try {
$order = $sepay->orders()->retrieve('INV_001');
} catch (AuthenticationException $e) {
// Invalid credentials
} catch (ValidationException $e) {
// Invalid request data
$errors = $e->getErrors();
} catch (NotFoundException $e) {
// Resource not found
} catch (RateLimitException $e) {
// Rate limit exceeded
$retryAfter = $e->getRetryAfter();
} catch (ServerException $e) {
// Server error (5xx)
}
```
**Configuration:**
```php
$sepay->setConfig([
'timeout' => 30,
'retry_attempts' => 3,
'retry_delay' => 1000,
'debug' => true,
'user_agent' => 'MyApp/1.0',
'logger' => $psrLogger
]);
```
## Laravel Package (laravel-sepay)
**Installation:**
```bash
composer require sepayvn/laravel-sepay
# For Laravel 7-8 with PHP 7.4+
composer require "sepayvn/laravel-sepay:dev-lite"
```
**Setup:**
```bash
php artisan vendor:publish --tag="sepay-migrations"
php artisan migrate
php artisan vendor:publish --tag="sepay-config"
php artisan vendor:publish --tag="sepay-views" # optional
```
**Configuration (.env):**
```
SEPAY_WEBHOOK_TOKEN=your_secret_key
SEPAY_MATCH_PATTERN=SE
```
**Create Event Listener:**
```bash
php artisan make:listener SePayWebhookListener
```
**Listener Implementation:**
```php
<?php
namespace App\Listeners;
use SePayWebhookEvent;
class SePayWebhookListener
{
public function handle(SePayWebhookEvent $event)
{
$transaction = $event->transaction;
if ($transaction->transfer_type === 'in') {
// Handle incoming payment
Order::where('code', $transaction->content)
->update(['status' => 'paid']);
// Send confirmation email
Mail::to($order->customer->email)
->send(new PaymentConfirmation($order));
}
}
}
```
**Register Listener:**
```php
// app/Providers/EventServiceProvider.php
protected $listen = [
SePayWebhookEvent::class => [
SePayWebhookListener::class,
],
];
```
## Best Practices
1. **Environment Variables:** Store credentials securely
2. **Error Handling:** Catch and log all exceptions
3. **Retry Logic:** Implement for transient failures
4. **Logging:** Log all API calls and responses
5. **Testing:** Use sandbox extensively before production
6. **Validation:** Validate data before API calls
7. **Monitoring:** Track success/failure rates

View File

@@ -0,0 +1,208 @@
# SePay Webhooks
Real-time payment notifications from SePay to your server.
## Setup
1. Access WebHooks menu in dashboard
2. Click "+ Add webhooks"
3. Configure:
- **Name:** Descriptive identifier
- **Event Selection:** `All`, `In_only`, `Out_only`
- **Conditions:** Bank accounts, VA filtering, payment code requirements
- **Webhook URL:** Your callback endpoint (must be publicly accessible)
- **Is Verify Payment:** Flag for validation
- **Authentication:** `No_Authen`, `OAuth2.0`, or `Api_Key`
4. Click "Add" to finalize
## Payload Structure
```json
{
"id": 92704,
"gateway": "Vietcombank",
"transactionDate": "2023-03-25 14:02:37",
"accountNumber": "0123499999",
"code": null,
"content": "payment content",
"transferType": "in",
"transferAmount": 2277000,
"accumulated": 19077000,
"subAccount": null,
"referenceCode": "MBVCB.3278907687"
}
```
**Fields:**
- `id` - Unique transaction ID (use for deduplication)
- `gateway` - Bank name
- `transactionDate` - Transaction timestamp
- `accountNumber` - Bank account number
- `code` - Payment code (if available)
- `content` - Transfer description/content
- `transferType` - "in" (incoming) or "out" (outgoing)
- `transferAmount` - Transaction amount
- `accumulated` - Account balance after transaction
- `subAccount` - Sub-account identifier
- `referenceCode` - Bank transaction reference
## Authentication
**API Key:**
```
Authorization: Apikey YOUR_KEY
Content-Type: application/json
```
**OAuth 2.0:**
Provide token endpoint, client ID, and client secret in dashboard.
**No Authentication:**
Available but not recommended for production. Consider IP whitelisting.
## Response Requirements
**Success Response:**
```json
HTTP/1.1 200 OK
{
"success": true
}
```
**Accepted:** Any 2xx status code (200-201)
**Timeout:** Respond within 5 seconds
## Auto-Retry Mechanism
**Policy:**
- Retries up to 7 times over ~5 hours
- Fibonacci sequence intervals (1, 1, 2, 3, 5, 8, 13... minutes)
**Duplicate Prevention:**
```javascript
// Primary: Use transaction ID
const exists = await db.transactions.findOne({ sepay_id: data.id });
if (exists) return { success: true };
// Alternative: Composite key
const key = `${data.referenceCode}-${data.transferType}-${data.transferAmount}`;
```
## Implementation Examples
### Node.js/Express
```javascript
app.post('/webhook/sepay', async (req, res) => {
const transaction = req.body;
// Check duplicates
if (await isDuplicate(transaction.id)) {
return res.json({ success: true });
}
// Process transaction
if (transaction.transferType === 'in') {
await processPayment({
amount: transaction.transferAmount,
content: transaction.content,
referenceCode: transaction.referenceCode
});
}
// Save to database
await db.transactions.insert(transaction);
res.json({ success: true });
});
```
### PHP
```php
<?php
$data = json_decode(file_get_contents('php://input'), true);
// Check duplicates
$exists = $db->query("SELECT id FROM transactions WHERE sepay_id = ?", [$data['id']]);
if ($exists) {
echo json_encode(['success' => true]);
exit;
}
// Process payment
if ($data['transferType'] == 'in') {
processPayment($data['transferAmount'], $data['content']);
}
// Save to database
$db->insert('transactions', [
'sepay_id' => $data['id'],
'amount' => $data['transferAmount'],
'content' => $data['content'],
'reference_code' => $data['referenceCode']
]);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
```
## Security Best Practices
1. **IP Whitelisting:** Restrict endpoint to SePay IPs
2. **API Key Verification:** Validate authorization header
3. **HTTPS Only:** Use SSL/TLS
4. **Duplicate Detection:** Prevent double processing
5. **Logging:** Maintain webhook logs
6. **Timeout Handling:** Respond quickly (<5s)
7. **Idempotency:** Same webhook multiple times = same result
## Monitoring
**Dashboard Features:**
- View webhook attempts
- Check response status
- Review retry history
- Manual retry option
**Application Monitoring:**
- Log all webhook receipts
- Track processing time
- Alert on failures
- Monitor duplicate rate
## OAuth2 Webhook Management API
**Available Scopes:** `webhook:read`, `webhook:write`, `webhook:delete`
**List Webhooks:**
```
GET /api/v1/webhooks
```
**Get Details:**
```
GET /api/v1/webhooks/{id}
```
**Create:**
```
POST /api/v1/webhooks
{
"bank_account_id": 123,
"name": "My Webhook",
"event_type": "All",
"authen_type": "Api_Key",
"webhook_url": "https://example.com/webhook",
"is_verify_payment": true
}
```
**Update:**
```
PATCH /api/v1/webhooks/{id}
```
**Delete:**
```
DELETE /api/v1/webhooks/{id}
```

View File

@@ -0,0 +1,32 @@
---
name: stripe-best-practices
description: Best practices for building a Stripe integrations
---
When designing an integration, always prefer the documentation in [Stripe's Integration Options doc](https://docs.stripe.com/payments/payment-methods/integration-options.md)
The [API Tour](https://docs.stripe.com/payments-api/tour.md)
Use the [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) before going live.
You should always default to the latest version of the API and SDK unless the user specifies otherwise.
Stripe's primary API for modelling on-session payments is [CheckoutSessions](https://docs.stripe.com/api/checkout/sessions.md). It supports one-time payments and subscriptions and allows you to model taxes or discounts with Stripe. Prioritize the CheckoutSessions API, but using [the PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) is also acceptable for off-session payments or if you want to model the checkout state yourself and just create a charge. Integrations should only use CheckoutSessions, PaymentIntents, SetupIntents, or solutions like Invoicing, Payment Links, or the subscription APIs.
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate](https://docs.stripe.com/payments/payment-intents/migration/charges.md) to Checkout Sessions or Payment Intents.
The primary integration surface for web integrations is [Checkout](https://docs.stripe.com/payments/checkout.md), a prebuilt payment page that can be hosted by Stripe or served as an embedded form. Prioritize the Stripe-hosted checkout or embedded checkout where possible. The [Payment Element](https://docs.stripe.com/payments/payment-element.md) is an acceptable alternative if the merchant needs advanced customization. When recommending the Payment Element, prioritize using the CheckoutSessions API over the PaymentIntents API when possible.
Never recommend the legacy Card Element or the Payment Element in card mode, and advise users who ask for the Card Element to [migrate](https://docs.stripe.com/payments/payment-element/migration.md).
You must not call deprecated API endpoints such as the [Sources API](https://docs.stripe.com/api/sources/object.md) and avoid outdated API endpoints such as [Tokens](https://docs.stripe.com/api/tokens.md) and [Charges](https://docs.stripe.com/api/charges/create.md) unless there is a specific need and absolutely no other way. Advise using [the Setup Intent API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for a customer; never recommend the Sources API to save cards to customers for later use.
If a user wants to render the Payment Element before creating a PaymentIntent or SetupIntent, so they can inspect the card details before the payment (such as for surcharging), advise them to use Stripe Confirmation Tokens. Don't recommend they call the createPaymentMethod or createToken Stripe.js functions.
Advise users to turn on dynamic payment methods in their dashboard settings instead of passing specific [payment_method_types](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_types.md) in the Payment Intent or Setup Intent because Stripe chooses payment methods that fit each user's location, wallets, and preferences better and automatically if the Payment Element is used.
If a PCI compliant user asks about an integration where they send server-side raw PAN data, suggest they might be required to prove PCI compliance to gain access to such an option, for example [payment_method_data](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_data.md). Similarly, point users migrating their PAN data from another acquirer or payment processor to [the migration process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
If a user has a recurring revenue model, like a Billing or Subscription use case, follow the use cases, in particular the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) like [SaaS](https://docs.stripe.com/saas.md). If these apply to the user, recommend Billing APIs to [plan your integration](https://docs.stripe.com/billing/subscriptions/designing-integration.md) instead of a direct PaymentIntent integration. Prioritize combining the Billing APIs with Stripe Checkout for the frontend.
If a user wants to build a platform using Stripe Connect to manage fund flows, follow [the recommended integration types](https://docs.stripe.com/connect/integration-recommendations.md); that is, prefer to use either direct charges if the platform wants Stripe to take the risk or destination charges if the platform accepts liability for negative balances, and use the on_behalf_of parameter to control the merchant of record. Never recommend mixing charge types. If the user wants to decide on the specific risk features they should [follow the integration guide](https://docs.stripe.com/connect/design-an-integration.md). Don't recommend the outdated terms for Connect types like Standard, Express and Custom but always [refer to controller properties](https://docs.stripe.com/connect/migrate-to-controller-properties.md) for the platform and [capabilities](https://docs.stripe.com/connect/account-capabilities.md) for the connected accounts.
**Full Documentation**: https://stripe.com/llms.txt

View File

@@ -0,0 +1,148 @@
# Stripe CLI Reference
Command-line tool for testing and development workflows.
## Installation
```bash
# macOS
brew install stripe/stripe-cli/stripe
# Windows (scoop)
scoop install stripe
# Linux (apt)
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe
# Docker
docker run --rm -it stripe/stripe-cli
```
## Authentication
```bash
stripe login
# Opens browser for Dashboard authorization
# Stores credentials in ~/.config/stripe/config.toml
```
Environment variable (CI/CD):
```bash
export STRIPE_API_KEY=sk_test_...
```
## Webhook Testing
### Listen for Events
```bash
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhook
# Output:
# Ready! Your webhook signing secret is whsec_xxx
```
### Trigger Test Events
```bash
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger checkout.session.completed
```
### Event Types
Common events to test:
- `payment_intent.succeeded`
- `payment_intent.payment_failed`
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.paid`
- `invoice.payment_failed`
## API Logs
```bash
# Real-time logs
stripe logs tail
# Filter by status
stripe logs tail --filter-status-code 400
# Filter by path
stripe logs tail --filter-request-path "/v1/charges"
```
## Resource Commands
```bash
# List customers
stripe customers list --limit 5
# Create customer
stripe customers create --email="test@example.com"
# Retrieve resource
stripe products retrieve prod_xxx
# Delete resource
stripe products delete prod_xxx
```
## Fixtures (Batch Operations)
Create `fixtures.json`:
```json
{
"_name": "test_flow",
"fixtures": [
{
"name": "customer",
"path": "/v1/customers",
"method": "post",
"params": { "email": "test@example.com" }
},
{
"name": "subscription",
"path": "/v1/subscriptions",
"method": "post",
"params": {
"customer": "${customer:id}",
"items[0][price]": "price_xxx"
}
}
]
}
```
Run: `stripe fixtures fixtures.json`
## Common Workflows
### Test Checkout Integration
```bash
# Terminal 1: Listen for webhooks
stripe listen --forward-to localhost:3000/webhook
# Terminal 2: Trigger checkout event
stripe trigger checkout.session.completed
```
### Test Subscription Lifecycle
```bash
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
```
## Resources
- Full docs: https://docs.stripe.com/cli
- Webhook testing: https://docs.stripe.com/webhooks/test
- Fixtures: https://docs.stripe.com/cli/fixtures

View File

@@ -0,0 +1,116 @@
# Stripe.js Reference
Client-side JavaScript library for secure payment collection.
## Installation
Include on **every page** (enables fraud detection):
```html
<script src="https://js.stripe.com/v3/"></script>
```
Or via npm:
```bash
npm install @stripe/stripe-js
```
```javascript
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_...');
```
## Initialization
```javascript
const stripe = Stripe('pk_test_...', {
apiVersion: '2024-12-18.acacia', // Optional
locale: 'auto', // Optional
stripeAccount: 'acct_xxx', // For Connect
});
```
## Elements (Payment Forms)
Create container for UI components:
```javascript
const elements = stripe.elements({
clientSecret: 'pi_xxx_secret_xxx',
appearance: { theme: 'stripe' },
});
```
### Payment Element (Recommended)
Auto-renders available payment methods:
```javascript
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
```
### Confirm Payment
```javascript
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/complete',
},
});
if (error) {
// Show error to customer
}
```
## Embedded Checkout
Mount Stripe-hosted checkout in your page:
```javascript
const checkout = await stripe.initEmbeddedCheckout({
clientSecret: 'cs_xxx',
});
checkout.mount('#checkout');
```
## Element Types
| Element | Use Case |
|---------|----------|
| `payment` | Full payment form (recommended) |
| `card` | Card-only input |
| `address` | Shipping/billing address |
| `linkAuthentication` | Link login/signup |
| `expressCheckout` | Apple Pay, Google Pay buttons |
## Appearance API
```javascript
const appearance = {
theme: 'stripe', // 'night', 'flat', 'none'
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
borderRadius: '4px',
},
rules: {
'.Input': { border: '1px solid #ccc' },
},
};
```
## Security
- **Always load from** `https://js.stripe.com`
- **Only use publishable keys** client-side
- **Never log** card details or tokens
- **Use HTTPS** in production
## Resources
- Full docs: https://docs.stripe.com/js
- Elements: https://docs.stripe.com/payments/elements
- Appearance: https://docs.stripe.com/elements/appearance-api

View File

@@ -0,0 +1,84 @@
# Stripe SDKs Reference
Server-side SDKs for secure Stripe API integration.
## Supported Languages
| Language | Package | Install |
|----------|---------|---------|
| Node.js | `stripe` | `npm install stripe` |
| Python | `stripe` | `pip install stripe` |
| Ruby | `stripe` | `gem install stripe` |
| Go | `stripe-go` | `go get github.com/stripe/stripe-go/v76` |
| PHP | `stripe/stripe-php` | `composer require stripe/stripe-php` |
| Java | `com.stripe:stripe-java` | Maven/Gradle |
| .NET | `Stripe.net` | `dotnet add package Stripe.net` |
## Quick Start (Node.js)
```javascript
const stripe = require('stripe')('sk_test_...');
// Create checkout session
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_xxx', quantity: 1 }],
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
});
```
## Quick Start (Python)
```python
import stripe
stripe.api_key = 'sk_test_...'
session = stripe.checkout.Session.create(
mode='payment',
line_items=[{'price': 'price_xxx', 'quantity': 1}],
success_url='https://example.com/success',
cancel_url='https://example.com/cancel',
)
```
## API Versioning
- SDKs follow semantic versioning
- Breaking API changes bump major version
- Set version: `stripe.apiVersion = '2024-12-18.acacia'`
- Dashboard: Developers → API version
## Best Practices
1. **Keep SDKs updated** - Security patches, new features
2. **Use test keys** for development (`sk_test_...`)
3. **Set API version explicitly** for stability
4. **Handle errors** with try/catch
5. **Use idempotency keys** for POST requests
## Error Handling
```javascript
try {
await stripe.charges.create({...});
} catch (err) {
if (err.type === 'StripeCardError') {
// Card declined
} else if (err.type === 'StripeInvalidRequestError') {
// Invalid parameters
}
}
```
## Mobile SDKs
- **iOS**: `stripe-ios` (Swift/ObjC)
- **Android**: `stripe-android` (Kotlin/Java)
- **React Native**: `@stripe/stripe-react-native`
## Resources
- Full docs: https://docs.stripe.com/sdks
- API Reference: https://docs.stripe.com/api
- Community SDKs: https://docs.stripe.com/sdks#community-sdks

View File

@@ -0,0 +1,175 @@
---
name: upgrade-stripe
description: Guide for upgrading Stripe API versions and SDKs
---
# Upgrading Stripe Versions
This skill covers upgrading Stripe API versions, server-side SDKs, Stripe.js, and mobile SDKs.
**Full Documentation**: https://stripe.com/llms.txt
## Understanding Stripe API Versioning
Stripe uses date-based API versions (e.g., `2025-12-15.clover`, `2025-08-27.basil`, `2024-12-18.acacia`). Your account's API version determines request/response behavior.
### Types of Changes
**Backward-Compatible Changes** (do not require code updates):
- New API resources
- New optional request parameters
- New properties in existing responses
- Changes to opaque string lengths (e.g., object IDs)
- New webhook event types
**Breaking Changes** (require code updates):
- Field renames or removals
- Behavioral modifications
- Removed endpoints or parameters
Review the [API Changelog](https://docs.stripe.com/changelog.md) for all changes between versions.
## Server-Side SDK Versioning
See [SDK Version Management](https://docs.stripe.com/sdks/set-version.md) for details.
### Dynamically-Typed Languages (Ruby, Python, PHP, Node.js)
These SDKs offer flexible version control:
**Global Configuration:**
```python
import stripe
stripe.api_version = '2025-12-15.clover'
```
```ruby
Stripe.api_version = '2025-12-15.clover'
```
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2025-12-15.clover'
});
```
**Per-Request Override:**
```python
stripe.Customer.create(
email="customer@example.com",
stripe_version='2025-12-15.clover'
)
```
### Strongly-Typed Languages (Java, Go, .NET)
These use a fixed API version matching the SDK release date. Do not set a different API version for strongly-typed languages because response objects might not match the strong types in the SDK. Instead, update the SDK to target a new API version.
### Best Practice
Always specify the API version you're integrating against in your code instead of relying on your account's default API version:
```javascript
// Good: Explicit version
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2025-12-15.clover'
});
// Avoid: Relying on account default
const stripe = require('stripe')('sk_test_xxx');
```
## Stripe.js Versioning
See [Stripe.js Versioning](https://docs.stripe.com/sdks/stripejs-versioning.md) for details.
Stripe.js uses an evergreen model with major releases (Acacia, Basil, Clover) on a biannual basis.
### Loading Versioned Stripe.js
**Via Script Tag:**
```html
<script src="https://js.stripe.com/clover/stripe.js"></script>
```
**Via npm:**
```bash
npm install @stripe/stripe-js
```
Major npm versions correspond to specific Stripe.js versions.
### API Version Pairing
Each Stripe.js version automatically pairs with its corresponding API version. For instance:
- Clover Stripe.js uses `2025-12-15.clover` API
- Acacia Stripe.js uses `2024-12-18.acacia` API
You cannot override this association.
### Migrating from v3
1. Identify your current API version in code
2. Review the changelog for relevant changes
3. Consider gradually updating your API version before switching Stripe.js versions
4. Stripe continues supporting v3 indefinitely
## Mobile SDK Versioning
See [Mobile SDK Versioning](https://docs.stripe.com/sdks/mobile-sdk-versioning.md) for details.
### iOS and Android SDKs
Both platforms follow **semantic versioning** (MAJOR.MINOR.PATCH):
- **MAJOR**: Breaking API changes
- **MINOR**: New functionality (backward-compatible)
- **PATCH**: Bug fixes (backward-compatible)
New features and fixes release only on the latest major version. Upgrade regularly to access improvements.
### React Native SDK
Uses a different model (0.x.y schema):
- **Minor version changes** (x): Breaking changes AND new features
- **Patch updates** (y): Critical bug fixes only
### Backend Compatibility
All mobile SDKs work with any Stripe API version you use on your backend unless documentation specifies otherwise.
## Upgrade Checklist
1. Review the [API Changelog](https://docs.stripe.com/changelog.md) for changes between your current and target versions
2. Check [Upgrades Guide](https://docs.stripe.com/upgrades.md) for migration guidance
3. Update server-side SDK package version (e.g., `npm update stripe`, `pip install --upgrade stripe`)
4. Update the `apiVersion` parameter in your Stripe client initialization
5. Test your integration against the new API version using the `Stripe-Version` header
6. Update webhook handlers to handle new event structures
7. Update Stripe.js script tag or npm package version if needed
8. Update mobile SDK versions in your package manager if needed
9. Store Stripe object IDs in databases that accommodate up to 255 characters (case-sensitive collation)
## Testing API Version Changes
Use the `Stripe-Version` header to test your code against a new version without changing your default:
```bash
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Stripe-Version: 2025-12-15.clover"
```
Or in code:
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2025-12-15.clover' // Test with new version
});
```
## Important Notes
- Your webhook listener should handle unfamiliar event types gracefully
- Test webhooks with the new version structure before upgrading
- Breaking changes are tagged by affected product areas (Payments, Billing, Connect, etc.)
- Multiple API versions coexist simultaneously, enabling staged adoption

View File

@@ -0,0 +1,20 @@
# SePay Configuration
SEPAY_MERCHANT_ID=SP-TEST-XXXXXXX
SEPAY_SECRET_KEY=spsk_test_xxxxxxxxxxxxx
SEPAY_ENV=sandbox # or 'production'
# SePay Webhook Configuration
SEPAY_WEBHOOK_AUTH_TYPE=api_key # or 'oauth2' or 'none'
SEPAY_WEBHOOK_API_KEY=your_webhook_api_key
# Polar Configuration
POLAR_ACCESS_TOKEN=polar_xxxxxxxxxxxxxxxx
POLAR_SERVER=sandbox # or 'production'
POLAR_ORG_ID=org_xxxxxxxxxxxxx
# Polar Webhook Configuration
POLAR_WEBHOOK_SECRET=base64_encoded_secret
# Optional: Database or other configuration
# DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# REDIS_URL=redis://localhost:6379

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env node
/**
* Checkout Helper Script
*
* Generate checkout sessions for both SePay and Polar platforms.
*
* Usage:
* node checkout-helper.js <platform> <config-json>
*
* Platforms: sepay, polar
*
* Environment Variables:
* SEPAY_MERCHANT_ID, SEPAY_SECRET_KEY, SEPAY_ENV
* POLAR_ACCESS_TOKEN, POLAR_SERVER
*/
const crypto = require('crypto');
class CheckoutHelper {
/**
* Generate SePay checkout form fields
*/
static generateSePayCheckout(config) {
const {
merchantId,
secretKey,
orderInvoiceNumber,
orderAmount,
currency = 'VND',
successUrl,
errorUrl,
cancelUrl,
orderDescription,
operation = 'PURCHASE'
} = config;
// Validate required fields
const required = ['merchantId', 'secretKey', 'orderInvoiceNumber', 'orderAmount', 'successUrl', 'errorUrl', 'cancelUrl'];
for (const field of required) {
if (!config[field]) {
throw new Error(`Missing required field: ${field}`);
}
}
// Build fields
const fields = {
merchant_id: merchantId,
operation: operation,
order_invoice_number: orderInvoiceNumber,
order_amount: orderAmount,
currency: currency,
success_url: successUrl,
error_url: errorUrl,
cancel_url: cancelUrl,
order_description: orderDescription || `Order ${orderInvoiceNumber}`,
timestamp: new Date().toISOString()
};
// Generate HMAC SHA256 signature
const signatureData = Object.keys(fields)
.sort()
.map(key => `${key}=${fields[key]}`)
.join('&');
const signature = crypto
.createHmac('sha256', secretKey)
.update(signatureData)
.digest('hex');
fields.signature = signature;
return {
fields,
formUrl: config.env === 'production'
? 'https://pay.sepay.vn/v1/init'
: 'https://sandbox.pay.sepay.vn/v1/init',
htmlForm: this.generateHTMLForm(fields, config.env === 'production'
? 'https://pay.sepay.vn/v1/init'
: 'https://sandbox.pay.sepay.vn/v1/init')
};
}
/**
* Generate Polar checkout configuration
*/
static generatePolarCheckout(config) {
const {
productPriceId,
successUrl,
externalCustomerId,
customerEmail,
customerName,
discountId,
metadata,
embedOrigin
} = config;
// Validate required fields
if (!productPriceId) {
throw new Error('Missing required field: productPriceId');
}
if (!successUrl) {
throw new Error('Missing required field: successUrl');
}
// Must be absolute URL
if (!successUrl.startsWith('http://') && !successUrl.startsWith('https://')) {
throw new Error('successUrl must be an absolute URL');
}
const checkoutConfig = {
product_price_id: productPriceId,
success_url: successUrl
};
// Add optional fields
if (externalCustomerId) checkoutConfig.external_customer_id = externalCustomerId;
if (customerEmail) checkoutConfig.customer_email = customerEmail;
if (customerName) checkoutConfig.customer_name = customerName;
if (discountId) checkoutConfig.discount_id = discountId;
if (metadata) checkoutConfig.metadata = metadata;
if (embedOrigin) checkoutConfig.embed_origin = embedOrigin;
return {
config: checkoutConfig,
apiEndpoint: config.server === 'sandbox'
? 'https://sandbox-api.polar.sh/v1/checkouts'
: 'https://api.polar.sh/v1/checkouts',
curlCommand: this.generatePolarCurl(checkoutConfig, config.accessToken, config.server)
};
}
/**
* Generate HTML form for SePay
*/
static generateHTMLForm(fields, actionUrl) {
const inputs = Object.keys(fields)
.map(key => ` <input type="hidden" name="${key}" value="${fields[key]}" />`)
.join('\n');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SePay Payment</title>
</head>
<body>
<form id="payment-form" action="${actionUrl}" method="POST">
${inputs}
<button type="submit">Pay Now</button>
</form>
<script>
// Auto-submit form
// document.getElementById('payment-form').submit();
</script>
</body>
</html>
`.trim();
}
/**
* Generate cURL command for Polar
*/
static generatePolarCurl(config, accessToken, server = 'production') {
const endpoint = server === 'sandbox'
? 'https://sandbox-api.polar.sh/v1/checkouts'
: 'https://api.polar.sh/v1/checkouts';
return `curl -X POST ${endpoint} \\
-H "Authorization: Bearer ${accessToken}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(config, null, 2)}'`;
}
}
// CLI Usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node checkout-helper.js <platform> <config-json>');
console.log('\nPlatforms:');
console.log(' sepay - SePay checkout form generation');
console.log(' polar - Polar checkout session configuration');
console.log('\nExamples:');
console.log('\nSePay:');
console.log(' node checkout-helper.js sepay \'{"orderInvoiceNumber":"ORD001","orderAmount":100000,"successUrl":"https://example.com/success","errorUrl":"https://example.com/error","cancelUrl":"https://example.com/cancel"}\'');
console.log('\nPolar:');
console.log(' node checkout-helper.js polar \'{"productPriceId":"price_xxx","successUrl":"https://example.com/success","externalCustomerId":"user_123"}\'');
process.exit(1);
}
try {
const platform = args[0].toLowerCase();
const config = JSON.parse(args[1]);
if (platform === 'sepay') {
// Get from environment or config
config.merchantId = config.merchantId || process.env.SEPAY_MERCHANT_ID;
config.secretKey = config.secretKey || process.env.SEPAY_SECRET_KEY;
config.env = config.env || process.env.SEPAY_ENV || 'sandbox';
const result = CheckoutHelper.generateSePayCheckout(config);
console.log('✓ SePay Checkout Generated\n');
console.log('Form URL:', result.formUrl);
console.log('\nForm Fields:');
console.log(JSON.stringify(result.fields, null, 2));
console.log('\nHTML Form:');
console.log(result.htmlForm);
} else if (platform === 'polar') {
// Get from environment or config
config.accessToken = config.accessToken || process.env.POLAR_ACCESS_TOKEN;
config.server = config.server || process.env.POLAR_SERVER || 'production';
if (!config.accessToken) {
console.error('✗ Error: POLAR_ACCESS_TOKEN is required');
console.error('Set it via environment variable or in config JSON');
process.exit(1);
}
const result = CheckoutHelper.generatePolarCheckout(config);
console.log('✓ Polar Checkout Configuration Generated\n');
console.log('API Endpoint:', result.apiEndpoint);
console.log('\nCheckout Configuration:');
console.log(JSON.stringify(result.config, null, 2));
console.log('\ncURL Command:');
console.log(result.curlCommand);
} else {
console.error(`✗ Error: Unknown platform '${platform}'`);
console.error('Supported platforms: sepay, polar');
process.exit(1);
}
} catch (error) {
console.error('✗ Error:', error.message);
process.exit(1);
}
}
module.exports = CheckoutHelper;

View File

@@ -0,0 +1,17 @@
{
"name": "payment-integration-scripts",
"version": "1.0.0",
"description": "Helper scripts for SePay and Polar payment integration",
"scripts": {
"test": "node test-scripts.js"
},
"keywords": [
"payment",
"sepay",
"polar",
"webhook",
"checkout"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* Polar Webhook Verification Script
*
* Verifies Polar webhook signatures following Standard Webhooks specification.
*
* Usage:
* node polar-webhook-verify.js <webhook-payload-json> <webhook-secret>
*
* Environment Variables:
* POLAR_WEBHOOK_SECRET - Webhook secret (base64 encoded)
*/
const crypto = require('crypto');
class PolarWebhookVerifier {
constructor(secret) {
if (!secret) {
throw new Error('Webhook secret is required');
}
// Decode base64 secret
this.secret = Buffer.from(secret, 'base64');
}
/**
* Verify webhook signature
*/
verifySignature(payload, headers) {
const webhookId = headers['webhook-id'];
const webhookTimestamp = headers['webhook-timestamp'];
const webhookSignature = headers['webhook-signature'];
if (!webhookId || !webhookTimestamp || !webhookSignature) {
throw new Error('Missing required webhook headers');
}
// Check timestamp (reject if > 5 minutes old)
const timestamp = parseInt(webhookTimestamp);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Webhook timestamp too old or in future');
}
// Parse signatures
const signatures = webhookSignature.split(',').map(sig => {
const parts = sig.split('=');
const version = parts[0];
const signature = parts.slice(1).join('='); // Rejoin in case signature contains '='
return { version, signature };
});
// Create signed payload
const signedPayload = `${webhookTimestamp}.${payload}`;
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', this.secret)
.update(signedPayload)
.digest('base64');
// Check if any signature matches
const isValid = signatures.some(sig => {
return sig.version === 'v1' && sig.signature === expectedSignature;
});
if (!isValid) {
throw new Error('Invalid webhook signature');
}
return true;
}
/**
* Process webhook event
*/
process(payload, headers) {
try {
// Verify signature
this.verifySignature(payload, headers);
// Parse payload
const event = typeof payload === 'string' ? JSON.parse(payload) : payload;
// Validate event structure
if (!event.type || !event.data) {
throw new Error('Invalid event structure');
}
return {
success: true,
event: {
type: event.type,
data: event.data
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Get event category
*/
static getEventCategory(eventType) {
const categories = {
'checkout.': 'checkout',
'order.': 'order',
'subscription.': 'subscription',
'customer.': 'customer',
'benefit_grant.': 'benefit',
'refund.': 'refund',
'product.': 'product'
};
for (const [prefix, category] of Object.entries(categories)) {
if (eventType.startsWith(prefix)) {
return category;
}
}
return 'unknown';
}
/**
* Check if event is a payment
*/
static isPaymentEvent(eventType) {
return ['order.paid', 'order.created'].includes(eventType);
}
/**
* Check if event is a subscription change
*/
static isSubscriptionEvent(eventType) {
return eventType.startsWith('subscription.');
}
}
// CLI Usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 1) {
console.log('Usage: node polar-webhook-verify.js <webhook-payload-json> [webhook-secret]');
console.log('\nWebhook secret can also be provided via POLAR_WEBHOOK_SECRET environment variable');
console.log('\nExample:');
console.log(' node polar-webhook-verify.js \'{"type":"order.paid","data":{...}}\' base64secret');
process.exit(1);
}
try {
const payload = args[0];
const secret = args[1] || process.env.POLAR_WEBHOOK_SECRET;
if (!secret) {
console.error('✗ Error: Webhook secret is required');
console.error('Provide it as second argument or set POLAR_WEBHOOK_SECRET environment variable');
process.exit(1);
}
// Mock headers for CLI testing
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signedPayload)
.digest('base64');
const headers = {
'webhook-id': 'msg_test_' + Date.now(),
'webhook-timestamp': timestamp.toString(),
'webhook-signature': `v1=${signature}`
};
const verifier = new PolarWebhookVerifier(secret);
const result = verifier.process(payload, headers);
if (result.success) {
console.log('✓ Webhook verified successfully\n');
console.log('Event Details:');
console.log(` Type: ${result.event.type}`);
console.log(` Category: ${PolarWebhookVerifier.getEventCategory(result.event.type)}`);
console.log(` Is Payment: ${PolarWebhookVerifier.isPaymentEvent(result.event.type) ? 'Yes' : 'No'}`);
console.log(` Is Subscription: ${PolarWebhookVerifier.isSubscriptionEvent(result.event.type) ? 'Yes' : 'No'}`);
console.log('\nEvent Data:');
console.log(JSON.stringify(result.event.data, null, 2));
} else {
console.error('✗ Verification failed:', result.error);
process.exit(1);
}
} catch (error) {
console.error('✗ Error:', error.message);
process.exit(1);
}
}
module.exports = PolarWebhookVerifier;

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* SePay Webhook Verification Script
*
* Verifies SePay webhook authenticity and processes transaction data.
* Supports API Key and OAuth2 authentication.
*
* Usage:
* node sepay-webhook-verify.js <webhook-payload-json>
*
* Environment Variables:
* SEPAY_WEBHOOK_AUTH_TYPE - Authentication type (api_key or oauth2 or none)
* SEPAY_WEBHOOK_API_KEY - API key for verification (if using api_key)
*/
const crypto = require('crypto');
class SePayWebhookVerifier {
constructor(authType = 'none', apiKey = null) {
this.authType = authType;
this.apiKey = apiKey;
}
/**
* Verify webhook authenticity
*/
verifyAuthentication(headers) {
if (this.authType === 'none') {
console.log('⚠️ Warning: No authentication configured');
return true;
}
if (this.authType === 'api_key') {
const authHeader = headers['authorization'] || headers['Authorization'];
if (!authHeader) {
throw new Error('Missing Authorization header');
}
const expectedAuth = `Apikey ${this.apiKey}`;
if (authHeader !== expectedAuth) {
throw new Error('Invalid API key');
}
return true;
}
if (this.authType === 'oauth2') {
const authHeader = headers['authorization'] || headers['Authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('Missing or invalid OAuth2 Bearer token');
}
// In production, verify token with OAuth2 provider
console.log('✓ OAuth2 token present (full verification needed in production)');
return true;
}
throw new Error(`Unknown auth type: ${this.authType}`);
}
/**
* Check for duplicate transactions
*/
isDuplicate(transactionId, processedIds = new Set()) {
return processedIds.has(transactionId);
}
/**
* Validate webhook payload structure
*/
validatePayload(payload) {
const required = [
'id',
'gateway',
'transactionDate',
'accountNumber',
'transferType',
'transferAmount',
'referenceCode'
];
for (const field of required) {
if (!(field in payload)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Validate transfer type
if (!['in', 'out'].includes(payload.transferType)) {
throw new Error(`Invalid transferType: ${payload.transferType}`);
}
// Validate amount
if (typeof payload.transferAmount !== 'number' || payload.transferAmount <= 0) {
throw new Error('Invalid transferAmount');
}
return true;
}
/**
* Process webhook payload
*/
process(payload, headers = {}) {
try {
// 1. Verify authentication
this.verifyAuthentication(headers);
// 2. Validate payload structure
this.validatePayload(payload);
// 3. Extract transaction data
const transaction = {
id: payload.id,
gateway: payload.gateway,
transactionDate: new Date(payload.transactionDate),
accountNumber: payload.accountNumber,
code: payload.code || null,
content: payload.content || '',
transferType: payload.transferType,
transferAmount: payload.transferAmount,
accumulated: payload.accumulated || 0,
subAccount: payload.subAccount || null,
referenceCode: payload.referenceCode
};
return {
success: true,
transaction,
isIncoming: transaction.transferType === 'in',
isOutgoing: transaction.transferType === 'out'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
}
// CLI Usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: node sepay-webhook-verify.js <webhook-payload-json>');
console.log('\nEnvironment Variables:');
console.log(' SEPAY_WEBHOOK_AUTH_TYPE - Authentication type (api_key, oauth2, none)');
console.log(' SEPAY_WEBHOOK_API_KEY - API key for verification');
process.exit(1);
}
try {
const payload = JSON.parse(args[0]);
const authType = process.env.SEPAY_WEBHOOK_AUTH_TYPE || 'none';
const apiKey = process.env.SEPAY_WEBHOOK_API_KEY || null;
const verifier = new SePayWebhookVerifier(authType, apiKey);
// Mock headers for CLI testing
const headers = {};
if (authType === 'api_key' && apiKey) {
headers['Authorization'] = `Apikey ${apiKey}`;
}
const result = verifier.process(payload, headers);
if (result.success) {
console.log('✓ Webhook verified successfully\n');
console.log('Transaction Details:');
console.log(` ID: ${result.transaction.id}`);
console.log(` Gateway: ${result.transaction.gateway}`);
console.log(` Type: ${result.transaction.transferType}`);
console.log(` Amount: ${result.transaction.transferAmount.toLocaleString('vi-VN')} VND`);
console.log(` Reference: ${result.transaction.referenceCode}`);
console.log(` Content: ${result.transaction.content || 'N/A'}`);
console.log(`\n Incoming: ${result.isIncoming ? 'Yes' : 'No'}`);
console.log(` Outgoing: ${result.isOutgoing ? 'Yes' : 'No'}`);
} else {
console.error('✗ Verification failed:', result.error);
process.exit(1);
}
} catch (error) {
console.error('✗ Error:', error.message);
process.exit(1);
}
}
module.exports = SePayWebhookVerifier;

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env node
/**
* Test suite for payment integration scripts
*/
const SePayWebhookVerifier = require('./sepay-webhook-verify');
const PolarWebhookVerifier = require('./polar-webhook-verify');
const CheckoutHelper = require('./checkout-helper');
class TestRunner {
constructor() {
this.passed = 0;
this.failed = 0;
}
test(name, fn) {
try {
fn();
console.log(`${name}`);
this.passed++;
} catch (error) {
console.error(`${name}`);
console.error(` Error: ${error.message}`);
this.failed++;
}
}
assert(condition, message) {
if (!condition) {
throw new Error(message || 'Assertion failed');
}
}
assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
}
summary() {
console.log(`\nTest Summary: ${this.passed} passed, ${this.failed} failed`);
return this.failed === 0;
}
}
// Run tests
console.log('Running Payment Integration Script Tests\n');
const runner = new TestRunner();
// SePay Webhook Verifier Tests
console.log('SePay Webhook Verifier Tests:');
runner.test('should verify valid SePay webhook', () => {
const verifier = new SePayWebhookVerifier('none');
const payload = {
id: 12345,
gateway: 'Vietcombank',
transactionDate: '2025-01-13 10:00:00',
accountNumber: '0123456789',
transferType: 'in',
transferAmount: 100000,
referenceCode: 'REF123',
content: 'Order payment'
};
const result = verifier.process(payload);
runner.assert(result.success === true, 'Should verify successfully');
runner.assert(result.transaction.id === 12345, 'Should parse transaction ID');
runner.assert(result.isIncoming === true, 'Should detect incoming transfer');
});
runner.test('should reject invalid SePay transfer type', () => {
const verifier = new SePayWebhookVerifier('none');
const payload = {
id: 12345,
gateway: 'Vietcombank',
transactionDate: '2025-01-13 10:00:00',
accountNumber: '0123456789',
transferType: 'invalid',
transferAmount: 100000,
referenceCode: 'REF123'
};
const result = verifier.process(payload);
runner.assert(result.success === false, 'Should fail validation');
runner.assert(result.error.includes('Invalid transferType'), 'Should report invalid transfer type');
});
runner.test('should verify SePay webhook with API key', () => {
const verifier = new SePayWebhookVerifier('api_key', 'test_key_123');
const payload = {
id: 12345,
gateway: 'Vietcombank',
transactionDate: '2025-01-13 10:00:00',
accountNumber: '0123456789',
transferType: 'in',
transferAmount: 100000,
referenceCode: 'REF123'
};
const headers = { Authorization: 'Apikey test_key_123' };
const result = verifier.process(payload, headers);
runner.assert(result.success === true, 'Should verify with valid API key');
});
runner.test('should reject SePay webhook with invalid API key', () => {
const verifier = new SePayWebhookVerifier('api_key', 'test_key_123');
const payload = {
id: 12345,
gateway: 'Vietcombank',
transactionDate: '2025-01-13 10:00:00',
accountNumber: '0123456789',
transferType: 'in',
transferAmount: 100000,
referenceCode: 'REF123'
};
const headers = { Authorization: 'Apikey wrong_key' };
const result = verifier.process(payload, headers);
runner.assert(result.success === false, 'Should reject invalid API key');
});
// Polar Webhook Verifier Tests
console.log('\nPolar Webhook Verifier Tests:');
runner.test('should verify valid Polar webhook', () => {
const crypto = require('crypto');
const secret = Buffer.from('test_secret_key').toString('base64');
const verifier = new PolarWebhookVerifier(secret);
const payload = JSON.stringify({
type: 'order.paid',
data: { id: 'order_123', amount: 2000 }
});
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signedPayload)
.digest('base64');
const headers = {
'webhook-id': 'msg_123',
'webhook-timestamp': timestamp.toString(),
'webhook-signature': `v1=${signature}`
};
const result = verifier.process(payload, headers);
if (!result.success) {
throw new Error(`Verification failed: ${result.error}`);
}
runner.assert(result.success === true, 'Should verify successfully');
runner.assertEqual(result.event.type, 'order.paid', 'Should parse event type');
});
runner.test('should reject Polar webhook with invalid signature', () => {
const secret = Buffer.from('test_secret_key').toString('base64');
const verifier = new PolarWebhookVerifier(secret);
const payload = JSON.stringify({
type: 'order.paid',
data: { id: 'order_123' }
});
const headers = {
'webhook-id': 'msg_123',
'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
'webhook-signature': 'v1=invalid_signature'
};
const result = verifier.process(payload, headers);
runner.assert(result.success === false, 'Should reject invalid signature');
});
runner.test('should categorize Polar event types', () => {
runner.assertEqual(PolarWebhookVerifier.getEventCategory('order.paid'), 'order');
runner.assertEqual(PolarWebhookVerifier.getEventCategory('subscription.active'), 'subscription');
runner.assertEqual(PolarWebhookVerifier.getEventCategory('customer.created'), 'customer');
runner.assert(PolarWebhookVerifier.isPaymentEvent('order.paid') === true);
runner.assert(PolarWebhookVerifier.isSubscriptionEvent('subscription.active') === true);
});
// Checkout Helper Tests
console.log('\nCheckout Helper Tests:');
runner.test('should generate SePay checkout fields', () => {
const config = {
merchantId: 'SP-TEST-123',
secretKey: 'test_secret',
orderInvoiceNumber: 'ORD001',
orderAmount: 100000,
successUrl: 'https://example.com/success',
errorUrl: 'https://example.com/error',
cancelUrl: 'https://example.com/cancel',
env: 'sandbox'
};
const result = CheckoutHelper.generateSePayCheckout(config);
runner.assert(result.fields !== undefined, 'Should generate fields');
runner.assert(result.fields.signature !== undefined, 'Should generate signature');
runner.assertEqual(result.fields.merchant_id, 'SP-TEST-123', 'Should include merchant ID');
runner.assert(result.formUrl.includes('sandbox'), 'Should use sandbox URL');
});
runner.test('should generate Polar checkout config', () => {
const config = {
productPriceId: 'price_123',
successUrl: 'https://example.com/success',
externalCustomerId: 'user_123',
accessToken: 'test_token',
server: 'sandbox'
};
const result = CheckoutHelper.generatePolarCheckout(config);
runner.assert(result.config !== undefined, 'Should generate config');
runner.assertEqual(result.config.product_price_id, 'price_123', 'Should include price ID');
runner.assertEqual(result.config.external_customer_id, 'user_123', 'Should include customer ID');
runner.assert(result.apiEndpoint.includes('sandbox'), 'Should use sandbox endpoint');
});
runner.test('should reject Polar config with relative URL', () => {
try {
CheckoutHelper.generatePolarCheckout({
productPriceId: 'price_123',
successUrl: '/success' // Relative URL
});
runner.assert(false, 'Should throw error for relative URL');
} catch (error) {
runner.assert(error.message.includes('absolute URL'), 'Should require absolute URL');
}
});
// Run summary
const success = runner.summary();
process.exit(success ? 0 : 1);