init
This commit is contained in:
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