309 lines
7.9 KiB
TypeScript
309 lines
7.9 KiB
TypeScript
import * as plugins from './bunq.plugins';
|
|
import { BunqAccount } from './bunq.classes.account';
|
|
import { BunqNotification, BunqWebhookHandler } from './bunq.classes.notification';
|
|
import { BunqCrypto } from './bunq.classes.crypto';
|
|
|
|
/**
|
|
* Webhook server for receiving bunq notifications
|
|
*/
|
|
export class BunqWebhookServer {
|
|
private bunqAccount: BunqAccount;
|
|
private notification: BunqNotification;
|
|
private handler: BunqWebhookHandler;
|
|
private server?: any; // HTTP server instance
|
|
private port: number;
|
|
private path: string;
|
|
private publicUrl: string;
|
|
|
|
constructor(
|
|
bunqAccount: BunqAccount,
|
|
options: {
|
|
port?: number;
|
|
path?: string;
|
|
publicUrl: string;
|
|
}
|
|
) {
|
|
this.bunqAccount = bunqAccount;
|
|
this.notification = new BunqNotification(bunqAccount);
|
|
this.handler = new BunqWebhookHandler();
|
|
this.port = options.port || 3000;
|
|
this.path = options.path || '/webhook';
|
|
this.publicUrl = options.publicUrl;
|
|
}
|
|
|
|
/**
|
|
* Start the webhook server
|
|
*/
|
|
public async start(): Promise<void> {
|
|
// Create HTTP server
|
|
const http = await import('http');
|
|
|
|
this.server = http.createServer(async (req, res) => {
|
|
if (req.method === 'POST' && req.url === this.path) {
|
|
let body = '';
|
|
|
|
req.on('data', chunk => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on('end', async () => {
|
|
try {
|
|
// Get signature from headers
|
|
const signature = req.headers['x-bunq-server-signature'] as string;
|
|
|
|
if (!signature) {
|
|
res.statusCode = 401;
|
|
res.end('Missing signature');
|
|
return;
|
|
}
|
|
|
|
// Verify signature
|
|
const isValid = this.notification.verifyWebhookSignature(body, signature);
|
|
|
|
if (!isValid) {
|
|
res.statusCode = 401;
|
|
res.end('Invalid signature');
|
|
return;
|
|
}
|
|
|
|
// Parse and process notification
|
|
const notification = JSON.parse(body);
|
|
await this.handler.process(notification);
|
|
|
|
res.statusCode = 200;
|
|
res.end('OK');
|
|
} catch (error) {
|
|
console.error('Webhook processing error:', error);
|
|
res.statusCode = 500;
|
|
res.end('Internal server error');
|
|
}
|
|
});
|
|
} else {
|
|
res.statusCode = 404;
|
|
res.end('Not found');
|
|
}
|
|
});
|
|
|
|
this.server.listen(this.port, () => {
|
|
console.log(`Webhook server listening on port ${this.port}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop the webhook server
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
if (this.server) {
|
|
await new Promise<void>((resolve) => {
|
|
this.server.close(() => {
|
|
resolve();
|
|
});
|
|
});
|
|
this.server = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the webhook handler
|
|
*/
|
|
public getHandler(): BunqWebhookHandler {
|
|
return this.handler;
|
|
}
|
|
|
|
/**
|
|
* Register webhook with bunq
|
|
*/
|
|
public async register(categories?: string[]): Promise<void> {
|
|
const webhookUrl = `${this.publicUrl}${this.path}`;
|
|
|
|
if (categories && categories.length > 0) {
|
|
// Register specific categories
|
|
const filters = categories.map(category => ({
|
|
category,
|
|
notificationTarget: webhookUrl
|
|
}));
|
|
|
|
await this.notification.createMultipleUrlFilters(filters);
|
|
} else {
|
|
// Register all payment and account events
|
|
await this.notification.setupPaymentWebhook(webhookUrl);
|
|
await this.notification.setupAccountWebhook(webhookUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregister all webhooks
|
|
*/
|
|
public async unregister(): Promise<void> {
|
|
await this.notification.clearAllUrlFilters();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Webhook client for sending test notifications
|
|
*/
|
|
export class BunqWebhookClient {
|
|
private crypto: BunqCrypto;
|
|
private privateKey: string;
|
|
|
|
constructor(privateKey: string) {
|
|
this.crypto = new BunqCrypto();
|
|
this.privateKey = privateKey;
|
|
}
|
|
|
|
/**
|
|
* Send a test notification to a webhook endpoint
|
|
*/
|
|
public async sendTestNotification(
|
|
webhookUrl: string,
|
|
notification: any
|
|
): Promise<void> {
|
|
const body = JSON.stringify(notification);
|
|
|
|
// Create signature
|
|
const sign = plugins.crypto.createSign('SHA256');
|
|
sign.update(body);
|
|
sign.end();
|
|
const signature = sign.sign(this.privateKey, 'base64');
|
|
|
|
// Send request
|
|
const response = await plugins.smartrequest.request(webhookUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Bunq-Server-Signature': signature
|
|
},
|
|
requestBody: body
|
|
});
|
|
|
|
if (response.statusCode !== 200) {
|
|
throw new Error(`Webhook request failed with status ${response.statusCode}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a test payment notification
|
|
*/
|
|
public createTestPaymentNotification(paymentData: any): any {
|
|
return {
|
|
NotificationUrl: {
|
|
target_url: 'https://example.com/webhook',
|
|
category: 'PAYMENT',
|
|
event_type: 'PAYMENT_CREATED',
|
|
object: {
|
|
Payment: {
|
|
id: 1234,
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
monetary_account_id: 1,
|
|
amount: {
|
|
currency: 'EUR',
|
|
value: '10.00'
|
|
},
|
|
description: 'Test payment',
|
|
type: 'IDEAL',
|
|
...paymentData
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a test account notification
|
|
*/
|
|
public createTestAccountNotification(accountData: any): any {
|
|
return {
|
|
NotificationUrl: {
|
|
target_url: 'https://example.com/webhook',
|
|
category: 'MONETARY_ACCOUNT',
|
|
event_type: 'MONETARY_ACCOUNT_UPDATED',
|
|
object: {
|
|
MonetaryAccountBank: {
|
|
id: 1234,
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
balance: {
|
|
currency: 'EUR',
|
|
value: '100.00'
|
|
},
|
|
...accountData
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Webhook event types
|
|
*/
|
|
export enum BunqWebhookEventType {
|
|
// Payment events
|
|
PAYMENT_CREATED = 'PAYMENT_CREATED',
|
|
PAYMENT_UPDATED = 'PAYMENT_UPDATED',
|
|
PAYMENT_CANCELLED = 'PAYMENT_CANCELLED',
|
|
|
|
// Account events
|
|
MONETARY_ACCOUNT_CREATED = 'MONETARY_ACCOUNT_CREATED',
|
|
MONETARY_ACCOUNT_UPDATED = 'MONETARY_ACCOUNT_UPDATED',
|
|
MONETARY_ACCOUNT_CLOSED = 'MONETARY_ACCOUNT_CLOSED',
|
|
|
|
// Card events
|
|
CARD_CREATED = 'CARD_CREATED',
|
|
CARD_UPDATED = 'CARD_UPDATED',
|
|
CARD_CANCELLED = 'CARD_CANCELLED',
|
|
CARD_TRANSACTION = 'CARD_TRANSACTION',
|
|
|
|
// Request events
|
|
REQUEST_INQUIRY_CREATED = 'REQUEST_INQUIRY_CREATED',
|
|
REQUEST_INQUIRY_UPDATED = 'REQUEST_INQUIRY_UPDATED',
|
|
REQUEST_INQUIRY_ACCEPTED = 'REQUEST_INQUIRY_ACCEPTED',
|
|
REQUEST_INQUIRY_REJECTED = 'REQUEST_INQUIRY_REJECTED',
|
|
|
|
// Other events
|
|
SCHEDULE_RESULT = 'SCHEDULE_RESULT',
|
|
TAB_RESULT = 'TAB_RESULT',
|
|
DRAFT_PAYMENT_CREATED = 'DRAFT_PAYMENT_CREATED',
|
|
DRAFT_PAYMENT_UPDATED = 'DRAFT_PAYMENT_UPDATED'
|
|
}
|
|
|
|
/**
|
|
* Webhook middleware for Express.js
|
|
*/
|
|
export function bunqWebhookMiddleware(
|
|
bunqAccount: BunqAccount,
|
|
handler: BunqWebhookHandler
|
|
) {
|
|
const notification = new BunqNotification(bunqAccount);
|
|
|
|
return async (req: any, res: any, next: any) => {
|
|
try {
|
|
// Get signature from headers
|
|
const signature = req.headers['x-bunq-server-signature'];
|
|
|
|
if (!signature) {
|
|
res.status(401).send('Missing signature');
|
|
return;
|
|
}
|
|
|
|
// Get raw body
|
|
const body = JSON.stringify(req.body);
|
|
|
|
// Verify signature
|
|
const isValid = notification.verifyWebhookSignature(body, signature);
|
|
|
|
if (!isValid) {
|
|
res.status(401).send('Invalid signature');
|
|
return;
|
|
}
|
|
|
|
// Process notification
|
|
await handler.process(req.body);
|
|
|
|
res.status(200).send('OK');
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
} |