init
This commit is contained in:
217
.opencode/skills/payment-integration/README.md
Normal file
217
.opencode/skills/payment-integration/README.md
Normal 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
|
||||
105
.opencode/skills/payment-integration/SKILL.md
Normal file
105
.opencode/skills/payment-integration/SKILL.md
Normal 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
|
||||
139
.opencode/skills/payment-integration/references/creem/api.md
Normal file
139
.opencode/skills/payment-integration/references/creem/api.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
161
.opencode/skills/payment-integration/references/creem/sdk.md
Normal file
161
.opencode/skills/payment-integration/references/creem/sdk.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
116
.opencode/skills/payment-integration/references/paddle/api.md
Normal file
116
.opencode/skills/payment-integration/references/paddle/api.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
```
|
||||
131
.opencode/skills/payment-integration/references/paddle/sdk.md
Normal file
131
.opencode/skills/payment-integration/references/paddle/sdk.md
Normal 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');
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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://..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']);
|
||||
});
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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
|
||||
436
.opencode/skills/payment-integration/references/polar/sdk.md
Normal file
436
.opencode/skills/payment-integration/references/polar/sdk.md
Normal 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
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
140
.opencode/skills/payment-integration/references/sepay/api.md
Normal file
140
.opencode/skills/payment-integration/references/sepay/api.md
Normal 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
|
||||
@@ -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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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
|
||||
@@ -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`
|
||||
@@ -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.)
|
||||
213
.opencode/skills/payment-integration/references/sepay/sdk.md
Normal file
213
.opencode/skills/payment-integration/references/sepay/sdk.md
Normal 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
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
20
.opencode/skills/payment-integration/scripts/.env.example
Normal file
20
.opencode/skills/payment-integration/scripts/.env.example
Normal 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
|
||||
244
.opencode/skills/payment-integration/scripts/checkout-helper.js
Executable file
244
.opencode/skills/payment-integration/scripts/checkout-helper.js
Executable 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;
|
||||
17
.opencode/skills/payment-integration/scripts/package.json
Normal file
17
.opencode/skills/payment-integration/scripts/package.json
Normal 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"
|
||||
}
|
||||
202
.opencode/skills/payment-integration/scripts/polar-webhook-verify.js
Executable file
202
.opencode/skills/payment-integration/scripts/polar-webhook-verify.js
Executable 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;
|
||||
193
.opencode/skills/payment-integration/scripts/sepay-webhook-verify.js
Executable file
193
.opencode/skills/payment-integration/scripts/sepay-webhook-verify.js
Executable 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;
|
||||
237
.opencode/skills/payment-integration/scripts/test-scripts.js
Executable file
237
.opencode/skills/payment-integration/scripts/test-scripts.js
Executable 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);
|
||||
Reference in New Issue
Block a user