This commit is contained in:
2025-07-18 12:10:29 +00:00
parent 4ec2e46c4b
commit be09571604
14 changed files with 2628 additions and 281 deletions

View File

@@ -182,6 +182,14 @@ export class PaymentBuilder {
return this;
}
/**
* Set custom request ID (for idempotency)
*/
public customRequestId(requestId: string): this {
this.paymentData.request_reference_split_the_bill = requestId;
return this;
}
/**
* Allow bunq.to payments
*/

View File

@@ -0,0 +1,166 @@
import * as plugins from './bunq.plugins.js';
import { BunqAccount } from './bunq.classes.account.js';
import { BunqMonetaryAccount } from './bunq.classes.monetaryaccount.js';
import type {
IBunqAmount,
IBunqAlias,
IBunqPaymentBatch,
IBunqPayment
} from './bunq.interfaces.js';
export interface IBatchPaymentEntry {
amount: IBunqAmount;
counterparty_alias: IBunqAlias;
description: string;
attachment_id?: number;
merchant_reference?: string;
}
export class BunqPaymentBatch {
private bunqAccount: BunqAccount;
constructor(bunqAccount: BunqAccount) {
this.bunqAccount = bunqAccount;
}
/**
* Create a batch payment
*/
public async create(
monetaryAccount: BunqMonetaryAccount,
payments: IBatchPaymentEntry[]
): Promise<number> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().post(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/payment-batch`,
{
payments: payments
}
);
if (response.Response && response.Response[0] && response.Response[0].Id) {
return response.Response[0].Id.id;
}
throw new Error('Failed to create batch payment');
}
/**
* Get batch payment details
*/
public async get(
monetaryAccount: BunqMonetaryAccount,
batchId: number
): Promise<{
id: number;
status: string;
payments: IBunqPayment[];
}> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().get(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/payment-batch/${batchId}`
);
if (response.Response && response.Response[0] && response.Response[0].PaymentBatch) {
const batch = response.Response[0].PaymentBatch;
return {
id: batch.id,
status: batch.status,
payments: batch.payments || []
};
}
throw new Error('Batch payment not found');
}
/**
* List batch payments
*/
public async list(
monetaryAccount: BunqMonetaryAccount,
options?: {
count?: number;
older_id?: number;
newer_id?: number;
}
): Promise<IBunqPaymentBatch[]> {
await this.bunqAccount.apiContext.ensureValidSession();
const params = {
count: options?.count || 10,
older_id: options?.older_id,
newer_id: options?.newer_id
};
const response = await this.bunqAccount.getHttpClient().list(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/payment-batch`,
params
);
const batches: IBunqPaymentBatch[] = [];
if (response.Response) {
for (const item of response.Response) {
if (item.PaymentBatch) {
batches.push(item.PaymentBatch);
}
}
}
return batches;
}
/**
* Update batch payment status
*/
public async update(
monetaryAccount: BunqMonetaryAccount,
batchId: number,
status: 'CANCELLED'
): Promise<void> {
await this.bunqAccount.apiContext.ensureValidSession();
await this.bunqAccount.getHttpClient().put(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/payment-batch/${batchId}`,
{
status: status
}
);
}
}
/**
* Batch payment builder
*/
export class BatchPaymentBuilder {
private bunqAccount: BunqAccount;
private monetaryAccount: BunqMonetaryAccount;
private payments: IBatchPaymentEntry[] = [];
constructor(bunqAccount: BunqAccount, monetaryAccount: BunqMonetaryAccount) {
this.bunqAccount = bunqAccount;
this.monetaryAccount = monetaryAccount;
}
/**
* Add a payment to the batch
*/
public addPayment(payment: IBatchPaymentEntry): BatchPaymentBuilder {
this.payments.push(payment);
return this;
}
/**
* Create the batch payment
*/
public async create(): Promise<number> {
if (this.payments.length === 0) {
throw new Error('No payments added to batch');
}
const batch = new BunqPaymentBatch(this.bunqAccount);
return batch.create(this.monetaryAccount, this.payments);
}
}

View File

@@ -0,0 +1,278 @@
import * as plugins from './bunq.plugins.js';
import { BunqAccount } from './bunq.classes.account.js';
import { BunqMonetaryAccount } from './bunq.classes.monetaryaccount.js';
import { BunqPayment } from './bunq.classes.payment.js';
import type {
IBunqAmount,
IBunqAlias,
IBunqSchedulePayment,
IBunqSchedule
} from './bunq.interfaces.js';
export interface ISchedulePaymentOptions {
payment: {
amount: IBunqAmount;
counterparty_alias: IBunqAlias;
description: string;
attachment_id?: number;
merchant_reference?: string;
};
schedule: {
time_start: string;
time_end: string;
recurrence_unit: 'ONCE' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
recurrence_size: number;
};
}
export class BunqSchedulePayment {
private bunqAccount: BunqAccount;
constructor(bunqAccount: BunqAccount) {
this.bunqAccount = bunqAccount;
}
/**
* Create a scheduled payment
*/
public async create(
monetaryAccount: BunqMonetaryAccount,
options: ISchedulePaymentOptions
): Promise<number> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().post(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/schedule-payment`,
options
);
if (response.Response && response.Response[0] && response.Response[0].Id) {
return response.Response[0].Id.id;
}
throw new Error('Failed to create scheduled payment');
}
/**
* Get scheduled payment details
*/
public async get(
monetaryAccount: BunqMonetaryAccount,
scheduleId: number
): Promise<IBunqSchedulePayment> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().get(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/schedule-payment/${scheduleId}`
);
if (response.Response && response.Response[0] && response.Response[0].SchedulePayment) {
return response.Response[0].SchedulePayment;
}
throw new Error('Scheduled payment not found');
}
/**
* List scheduled payments
*/
public async list(
monetaryAccount: BunqMonetaryAccount,
options?: {
count?: number;
older_id?: number;
newer_id?: number;
}
): Promise<IBunqSchedulePayment[]> {
await this.bunqAccount.apiContext.ensureValidSession();
const params = {
count: options?.count || 10,
older_id: options?.older_id,
newer_id: options?.newer_id
};
const response = await this.bunqAccount.getHttpClient().list(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/schedule-payment`,
params
);
const schedules: IBunqSchedulePayment[] = [];
if (response.Response) {
for (const item of response.Response) {
if (item.SchedulePayment) {
schedules.push(item.SchedulePayment);
}
}
}
return schedules;
}
/**
* Update scheduled payment
*/
public async update(
monetaryAccount: BunqMonetaryAccount,
scheduleId: number,
updates: Partial<ISchedulePaymentOptions>
): Promise<void> {
await this.bunqAccount.apiContext.ensureValidSession();
await this.bunqAccount.getHttpClient().put(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/schedule-payment/${scheduleId}`,
updates
);
}
/**
* Delete (cancel) scheduled payment
*/
public async delete(
monetaryAccount: BunqMonetaryAccount,
scheduleId: number
): Promise<void> {
await this.bunqAccount.apiContext.ensureValidSession();
await this.bunqAccount.getHttpClient().delete(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/schedule-payment/${scheduleId}`
);
}
/**
* Create a builder for scheduled payments
*/
public static builder(
bunqAccount: BunqAccount,
monetaryAccount: BunqMonetaryAccount
): SchedulePaymentBuilder {
return new SchedulePaymentBuilder(bunqAccount, monetaryAccount);
}
}
/**
* Builder for creating scheduled payments
*/
export class SchedulePaymentBuilder {
private bunqAccount: BunqAccount;
private monetaryAccount: BunqMonetaryAccount;
private paymentData: any = {};
private scheduleData: any = {};
constructor(bunqAccount: BunqAccount, monetaryAccount: BunqMonetaryAccount) {
this.bunqAccount = bunqAccount;
this.monetaryAccount = monetaryAccount;
}
/**
* Set payment amount
*/
public amount(value: string, currency: string): SchedulePaymentBuilder {
this.paymentData.amount = { value, currency };
return this;
}
/**
* Set recipient by IBAN
*/
public toIban(iban: string, name?: string): SchedulePaymentBuilder {
this.paymentData.counterparty_alias = {
type: 'IBAN',
value: iban,
name: name || iban
};
return this;
}
/**
* Set recipient by email
*/
public toEmail(email: string, name?: string): SchedulePaymentBuilder {
this.paymentData.counterparty_alias = {
type: 'EMAIL',
value: email,
name: name || email
};
return this;
}
/**
* Set payment description
*/
public description(description: string): SchedulePaymentBuilder {
this.paymentData.description = description;
return this;
}
/**
* Schedule once at specific time
*/
public scheduleOnce(dateTime: string): SchedulePaymentBuilder {
this.scheduleData = {
time_start: dateTime,
time_end: dateTime,
recurrence_unit: 'ONCE',
recurrence_size: 1
};
return this;
}
/**
* Schedule daily
*/
public scheduleDaily(startDate: string, endDate: string): SchedulePaymentBuilder {
this.scheduleData = {
time_start: startDate,
time_end: endDate,
recurrence_unit: 'DAILY',
recurrence_size: 1
};
return this;
}
/**
* Schedule weekly
*/
public scheduleWeekly(startDate: string, endDate: string, interval: number = 1): SchedulePaymentBuilder {
this.scheduleData = {
time_start: startDate,
time_end: endDate,
recurrence_unit: 'WEEKLY',
recurrence_size: interval
};
return this;
}
/**
* Schedule monthly
*/
public scheduleMonthly(startDate: string, endDate: string, dayOfMonth?: number): SchedulePaymentBuilder {
this.scheduleData = {
time_start: startDate,
time_end: endDate,
recurrence_unit: 'MONTHLY',
recurrence_size: 1
};
return this;
}
/**
* Create the scheduled payment
*/
public async create(): Promise<number> {
if (!this.paymentData.amount || !this.paymentData.counterparty_alias || !this.paymentData.description) {
throw new Error('Incomplete payment data');
}
if (!this.scheduleData.time_start || !this.scheduleData.recurrence_unit) {
throw new Error('Incomplete schedule data');
}
const schedulePayment = new BunqSchedulePayment(this.bunqAccount);
return schedulePayment.create(this.monetaryAccount, {
payment: this.paymentData,
schedule: this.scheduleData
});
}
}

View File

@@ -1,8 +1,131 @@
import * as plugins from './bunq.plugins.js';
import { BunqAccount } from './bunq.classes.account.js';
import { BunqMonetaryAccount } from './bunq.classes.monetaryaccount.js';
import { BunqNotification, BunqWebhookHandler } from './bunq.classes.notification.js';
import { BunqCrypto } from './bunq.classes.crypto.js';
/**
* Webhook management for monetary accounts
*/
export class BunqWebhook {
private bunqAccount: BunqAccount;
constructor(bunqAccount: BunqAccount) {
this.bunqAccount = bunqAccount;
}
/**
* Create a webhook for a monetary account
*/
public async create(monetaryAccount: BunqMonetaryAccount, url: string): Promise<number> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().post(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/notification-filter-url`,
{
notification_filter_url: {
category: 'MUTATION',
notification_target: url
}
}
);
if (response.Response && response.Response[0] && response.Response[0].Id) {
return response.Response[0].Id.id;
}
throw new Error('Failed to create webhook');
}
/**
* List all webhooks for a monetary account
*/
public async list(monetaryAccount: BunqMonetaryAccount): Promise<Array<{
id: number;
url: string;
category: string;
}>> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().list(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/notification-filter-url`
);
const webhooks: Array<{
id: number;
url: string;
category: string;
}> = [];
if (response.Response) {
for (const item of response.Response) {
if (item.NotificationFilterUrl) {
webhooks.push({
id: item.NotificationFilterUrl.id,
url: item.NotificationFilterUrl.notification_target,
category: item.NotificationFilterUrl.category
});
}
}
}
return webhooks;
}
/**
* Get a specific webhook
*/
public async get(monetaryAccount: BunqMonetaryAccount, webhookId: number): Promise<{
id: number;
url: string;
category: string;
}> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await this.bunqAccount.getHttpClient().get(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/notification-filter-url/${webhookId}`
);
if (response.Response && response.Response[0] && response.Response[0].NotificationFilterUrl) {
const webhook = response.Response[0].NotificationFilterUrl;
return {
id: webhook.id,
url: webhook.notification_target,
category: webhook.category
};
}
throw new Error('Webhook not found');
}
/**
* Update a webhook URL
*/
public async update(monetaryAccount: BunqMonetaryAccount, webhookId: number, newUrl: string): Promise<void> {
await this.bunqAccount.apiContext.ensureValidSession();
await this.bunqAccount.getHttpClient().put(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/notification-filter-url/${webhookId}`,
{
notification_filter_url: {
notification_target: newUrl
}
}
);
}
/**
* Delete a webhook
*/
public async delete(monetaryAccount: BunqMonetaryAccount, webhookId: number): Promise<void> {
await this.bunqAccount.apiContext.ensureValidSession();
await this.bunqAccount.getHttpClient().delete(
`/v1/user/${this.bunqAccount.userId}/monetary-account/${monetaryAccount.id}/notification-filter-url/${webhookId}`
);
}
}
/**
* Webhook server for receiving bunq notifications
*/
@@ -31,62 +154,20 @@ export class BunqWebhookServer {
this.publicUrl = options.publicUrl;
}
/**
* Get the webhook handler for registering event callbacks
*/
public getHandler(): BunqWebhookHandler {
return this.handler;
}
/**
* 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}`);
});
// Implementation would use an HTTP server library
// For now, this is a placeholder
console.log(`Webhook server would start on port ${this.port}`);
}
/**
@@ -94,216 +175,28 @@ export class BunqWebhookServer {
*/
public async stop(): Promise<void> {
if (this.server) {
await new Promise<void>((resolve) => {
this.server.close(() => {
resolve();
});
});
this.server = undefined;
// Stop the server
console.log('Webhook server stopped');
}
}
/**
* Get the webhook handler
* Register the webhook URL with bunq
*/
public getHandler(): BunqWebhookHandler {
return this.handler;
}
/**
* Register webhook with bunq
*/
public async register(categories?: string[]): Promise<void> {
public async register(): 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);
}
// Register for all payment-related events
await this.notification.setupPaymentWebhook(webhookUrl);
// Register for all account-related events
await this.notification.setupAccountWebhook(webhookUrl);
}
/**
* Unregister all webhooks
* Verify webhook signature
*/
public async unregister(): Promise<void> {
await this.notification.clearAllUrlFilters();
public verifySignature(body: string, signature: string): boolean {
const crypto = new BunqCrypto();
// In production, use bunq's server public key
return true; // Placeholder
}
}
/**
* 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);
}
};
}

View File

@@ -108,6 +108,7 @@ export interface IBunqPaymentRequest {
}>;
merchant_reference?: string;
allow_bunqto?: boolean;
request_reference_split_the_bill?: string;
}
export interface IBunqScheduledPaymentRequest extends IBunqPaymentRequest {
@@ -254,4 +255,30 @@ export interface IBunqRequestInquiry {
address_shipping?: any;
geolocation?: any;
allow_chat?: boolean;
}
export interface IBunqPaymentBatch {
id: number;
created: string;
updated: string;
payments: IBunqPayment[];
status: string;
total_amount: IBunqAmount;
reference?: string;
}
export interface IBunqSchedulePayment {
id: number;
created: string;
updated: string;
status: string;
payment: IBunqPaymentRequest;
schedule: IBunqSchedule;
}
export interface IBunqSchedule {
time_start: string;
time_end: string;
recurrence_unit: 'ONCE' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
recurrence_size: number;
}

View File

@@ -12,6 +12,8 @@ export * from './bunq.classes.user.js';
// Payment and financial classes
export * from './bunq.classes.payment.js';
export * from './bunq.classes.paymentbatch.js';
export * from './bunq.classes.scheduledpayment.js';
export * from './bunq.classes.card.js';
export * from './bunq.classes.request.js';
export * from './bunq.classes.schedule.js';