diff --git a/backend/package-lock.json b/backend/package-lock.json index c6f3aa32..2c5dc97f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -52,6 +52,7 @@ "@types/pdfkit": "^0.17.5", "@types/pg": "^8.16.0", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -97,7 +98,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2254,7 +2254,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2439,6 +2438,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2498,7 +2504,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2681,7 +2686,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3354,7 +3358,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4478,7 +4481,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6014,7 +6016,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7723,7 +7724,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -9395,7 +9395,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9656,7 +9655,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/package.json b/backend/package.json index 89a98a62..ef2ac54f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,6 +61,7 @@ "@types/pdfkit": "^0.17.5", "@types/pg": "^8.16.0", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", diff --git a/backend/src/controllers/paymentController.ts b/backend/src/controllers/paymentController.ts index 7cf9c40a..51f93ddd 100644 --- a/backend/src/controllers/paymentController.ts +++ b/backend/src/controllers/paymentController.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { AnchorService } from '../services/anchorService.js'; import { Keypair } from '@stellar/stellar-sdk'; +import { findConversionPaths, type PathfindRequest } from '../services/crossAssetPaymentService.js'; export class PaymentController { /** @@ -44,6 +45,29 @@ export class PaymentController { } } + /** + * POST /api/payments/pathfind + */ + static async findPaths(req: Request, res: Response) { + const { fromAsset, toAsset, amount } = req.body as PathfindRequest; + + if (!fromAsset || !toAsset || !amount || amount <= 0) { + return res + .status(400) + .json({ + error: 'Invalid pathfind request: fromAsset, toAsset, and positive amount required', + }); + } + + try { + const paths = await findConversionPaths({ fromAsset, toAsset, amount }); + res.json({ paths }); + } catch (error: any) { + console.error('Pathfinding error:', error); + res.status(500).json({ error: error.message }); + } + } + /** * GET /api/payments/sep31/status/:domain/:id */ diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index 6efea553..3ead01d2 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { WebhookService } from '../services/webhook.service.js'; +import { WebhookService, WEBHOOK_EVENTS } from '../services/webhook.service.js'; import { z } from 'zod'; const subscribeSchema = z.object({ @@ -8,11 +8,33 @@ const subscribeSchema = z.object({ events: z.array(z.string()).default(['*']), }); +const updateSchema = z.object({ + url: z.string().url().optional(), + secret: z.string().min(16).optional(), + events: z.array(z.string()).optional(), + is_active: z.boolean().optional(), +}); + +function getIdParam(params: Record): string { + const id = params.id; + if (Array.isArray(id)) { + return id[0] || ''; + } + return id || ''; +} + export class WebhookController { static async subscribe(req: Request, res: Response) { try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + const validatedData = subscribeSchema.parse(req.body); const subscription = await WebhookService.subscribe( + organization_id, validatedData.url, validatedData.secret, validatedData.events @@ -23,32 +45,169 @@ export class WebhookController { res.status(400).json({ error: error.issues }); return; } + console.error('Webhook subscription error:', error); res.status(500).json({ error: 'Internal Server Error' }); } } - static listSubscriptions(req: Request, res: Response) { - const subscriptions = WebhookService.listSubscriptions(); - res.json(subscriptions); + static async update(req: Request, res: Response) { + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const id = getIdParam(req.params); + if (!id) { + res.status(400).json({ error: 'Subscription ID is required' }); + return; + } + + const validatedData = updateSchema.parse(req.body); + + const subscription = await WebhookService.updateSubscription( + id, + organization_id, + validatedData + ); + if (!subscription) { + res.status(404).json({ error: 'Subscription not found' }); + return; + } + res.json(subscription); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: error.issues }); + return; + } + console.error('Webhook update error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } } - static deleteSubscription(req: Request, res: Response) { - const { id } = req.params; - const success = WebhookService.deleteSubscription(id as string); - if (success) { - res.status(204).send(); - return; + static async listSubscriptions(req: Request, res: Response) { + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const subscriptions = await WebhookService.listSubscriptions(organization_id); + res.json(subscriptions); + } catch (error) { + console.error('List subscriptions error:', error); + res.status(500).json({ error: 'Internal Server Error' }); } - res.status(404).json({ error: 'Subscription not found' }); } - // Debug endpoint to trigger a mock event + static async getSubscription(req: Request, res: Response) { + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const id = getIdParam(req.params); + if (!id) { + res.status(400).json({ error: 'Subscription ID is required' }); + return; + } + + const subscription = await WebhookService.getSubscriptionById(id, organization_id); + if (!subscription) { + res.status(404).json({ error: 'Subscription not found' }); + return; + } + res.json(subscription); + } catch (error) { + console.error('Get subscription error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } + } + + static async deleteSubscription(req: Request, res: Response) { + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const id = getIdParam(req.params); + if (!id) { + res.status(400).json({ error: 'Subscription ID is required' }); + return; + } + + const success = await WebhookService.deleteSubscription(id, organization_id); + if (success) { + res.status(204).send(); + return; + } + res.status(404).json({ error: 'Subscription not found' }); + } catch (error) { + console.error('Delete subscription error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } + } + + static async getDeliveryLogs(req: Request, res: Response) { + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const id = getIdParam(req.params); + if (!id) { + res.status(400).json({ error: 'Subscription ID is required' }); + return; + } + + const subscription = await WebhookService.getSubscriptionById(id, organization_id); + if (!subscription) { + res.status(404).json({ error: 'Subscription not found' }); + return; + } + + const limit = req.query.limit ? parseInt(req.query.limit as string) : 20; + const logs = await WebhookService.getDeliveryLogs(id, limit); + res.json(logs); + } catch (error) { + console.error('Get delivery logs error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } + } + + static async getEvents(req: Request, res: Response) { + res.json({ + events: Object.values(WEBHOOK_EVENTS), + description: 'Available webhook event types for subscription', + }); + } + static async triggerMockEvent(req: Request, res: Response) { - const { event, payload } = req.body; - await WebhookService.dispatch( - (event as string) || 'payment.completed', - payload || { id: 'test_tx_123', amount: 100 } - ); - res.json({ message: 'Mock event dispatched' }); + try { + const organization_id = (req.user as any)?.organizationId; + if (!organization_id) { + res.status(401).json({ error: 'Organization not identified' }); + return; + } + + const { event, payload } = req.body; + await WebhookService.dispatch( + (event as string) || 'payment.completed', + organization_id, + payload || { id: 'test_tx_123', amount: 100 } + ); + res.json({ message: 'Mock event dispatched' }); + } catch (error) { + console.error('Trigger mock event error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } } } diff --git a/backend/src/db/migrations/018_create_webhook_subscriptions.sql b/backend/src/db/migrations/018_create_webhook_subscriptions.sql new file mode 100644 index 00000000..ac0904d3 --- /dev/null +++ b/backend/src/db/migrations/018_create_webhook_subscriptions.sql @@ -0,0 +1,29 @@ +-- Migration to create webhook_subscriptions table for multi-tenant webhook management +CREATE TABLE IF NOT EXISTS webhook_subscriptions ( + id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT[] NOT NULL DEFAULT ARRAY['*'], + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_org ON webhook_subscriptions(organization_id); +CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_active ON webhook_subscriptions(organization_id, is_active); + +-- Migration to create webhook_delivery_logs table for retry tracking +CREATE TABLE IF NOT EXISTS webhook_delivery_logs ( + id SERIAL PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + response_status INTEGER, + response_body TEXT, + error_message TEXT, + attempt_number INTEGER NOT NULL DEFAULT 1, + delivered_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_logs_sub ON webhook_delivery_logs(subscription_id, delivered_at DESC); diff --git a/backend/src/routes/paymentRoutes.ts b/backend/src/routes/paymentRoutes.ts index 8472f650..09bc14a3 100644 --- a/backend/src/routes/paymentRoutes.ts +++ b/backend/src/routes/paymentRoutes.ts @@ -11,5 +11,6 @@ router.use(authenticateJWT); router.get('/anchor-info', PaymentController.getAnchorInfo); router.post('/sep31/initiate', isolateOrganization, require2FA, PaymentController.initiateSEP31); router.get('/sep31/status/:domain/:id', PaymentController.getStatus); +router.post('/pathfind', PaymentController.findPaths); export default router; diff --git a/backend/src/routes/webhook.routes.ts b/backend/src/routes/webhook.routes.ts index 8bf3eefb..8f5121c8 100644 --- a/backend/src/routes/webhook.routes.ts +++ b/backend/src/routes/webhook.routes.ts @@ -1,11 +1,21 @@ import { Router } from 'express'; import { WebhookController } from '../controllers/webhook.controller.js'; +import authenticateJWT from '../middlewares/auth.js'; +import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; const router = Router(); +router.use(authenticateJWT); +router.use(authorizeRoles('EMPLOYER')); +router.use(isolateOrganization); + router.post('/subscribe', WebhookController.subscribe); +router.put('/subscriptions/:id', WebhookController.update); router.get('/subscriptions', WebhookController.listSubscriptions); +router.get('/subscriptions/:id', WebhookController.getSubscription); router.delete('/subscriptions/:id', WebhookController.deleteSubscription); +router.get('/subscriptions/:id/delivery-logs', WebhookController.getDeliveryLogs); +router.get('/events', WebhookController.getEvents); router.post('/test-trigger', WebhookController.triggerMockEvent); export default router; diff --git a/backend/src/services/claimableBalanceService.ts b/backend/src/services/claimableBalanceService.ts index 065f7629..966593ea 100644 --- a/backend/src/services/claimableBalanceService.ts +++ b/backend/src/services/claimableBalanceService.ts @@ -1,6 +1,7 @@ import { Keypair, Operation, TransactionBuilder, Asset, Claimant } from '@stellar/stellar-sdk'; import { StellarService } from './stellarService.js'; import { pool } from '../config/database.js'; +import { WebhookService, WEBHOOK_EVENTS } from './webhook.service.js'; export interface ClaimableBalanceRecord { id: number; @@ -115,9 +116,30 @@ export class ClaimableBalanceService { const dbResult = await pool.query(insertQuery, values); const record = dbResult.rows[0] as ClaimableBalanceRecord; + this.dispatchWebhook(input.organization_id, WEBHOOK_EVENTS.CLAIMABLE_BALANCE_CREATED, { + id: record.id, + balance_id: balanceId, + amount: input.amount, + asset_code: input.asset_code, + employee_id: input.employee_id, + payroll_run_id: input.payroll_run_id, + }).catch((err) => console.error('Failed to dispatch claimable_balance.created webhook:', err)); + return { record, balance_id: balanceId }; } + private static async dispatchWebhook( + organization_id: number, + eventType: string, + payload: any + ): Promise { + try { + await WebhookService.dispatch(eventType, organization_id, payload); + } catch (error) { + console.error(`Webhook dispatch failed for ${eventType}:`, error); + } + } + private static extractBalanceIdFromTransaction(hash: string, resultXdr: string): string { return `00000000-0000-0000-0000-0000000000000000000000000000000000000000000000000000000000${hash.substring(0, 8)}`; } @@ -210,7 +232,22 @@ export class ClaimableBalanceService { RETURNING *`, [balanceId] ); - return result.rows[0] || null; + const record = result.rows[0] || null; + + if (record) { + this.dispatchWebhook(record.organization_id, WEBHOOK_EVENTS.CLAIMABLE_BALANCE_CLAIMED, { + id: record.id, + balance_id: balanceId, + amount: record.amount, + asset_code: record.asset_code, + employee_id: record.employee_id, + claimed_at: record.claimed_at, + }).catch((err) => + console.error('Failed to dispatch claimable_balance.claimed webhook:', err) + ); + } + + return record; } static async markNotificationSent(balanceId: string): Promise { diff --git a/backend/src/services/crossAssetPaymentService.ts b/backend/src/services/crossAssetPaymentService.ts new file mode 100644 index 00000000..78b3901f --- /dev/null +++ b/backend/src/services/crossAssetPaymentService.ts @@ -0,0 +1,141 @@ +import { Horizon, Networks, Asset } from '@stellar/stellar-sdk'; + +export interface ConversionPath { + id: string; + sourceAsset: string; + destinationAsset: string; + rate: number; + fee: number; + slippage: number; + estimatedDestinationAmount: number; + hops: string[]; +} + +export interface PathfindRequest { + fromAsset: string; + toAsset: string; + amount: number; +} + +const HORIZON_URL = process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; + +const ASSET_CODE_MAPPING: Record = { + USDC: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3KLQEH2Y6DFOUHD7I2DMSK7P' }, + EURT: { code: 'EURT', issuer: 'GBL5IYFWZVGF2V6D2OKP2JO3H3GZJ6B2TQ4G5S5O2NJX4JH7Q3K5ZT6OE' }, + XLM: { code: 'XLM' }, + NGN: { code: 'NGN' }, + BRL: { code: 'BRL' }, + ARS: { code: 'ARS' }, + KES: { code: 'KES' }, +}; + +function getHorizonServer(): Horizon.Server { + return new Horizon.Server(HORIZON_URL); +} + +function normalizeAssetCode(assetCode: string): string { + const upperCode = assetCode.toUpperCase(); + return ASSET_CODE_MAPPING[upperCode]?.code || upperCode; +} + +function createHorizonAsset(assetCode: string): Asset { + const mapping = ASSET_CODE_MAPPING[assetCode.toUpperCase()]; + if (!mapping || assetCode.toUpperCase() === 'XLM') { + return Asset.native(); + } + return new Asset(mapping.code, mapping.issuer); +} + +export async function findConversionPaths(request: PathfindRequest): Promise { + try { + const horizonServer = getHorizonServer(); + const sourceAsset = createHorizonAsset(request.fromAsset); + const destAsset = createHorizonAsset(request.toAsset); + + const response = await fetch( + `${HORIZON_URL}/paths?source_asset_type=${sourceAsset.isNative() ? 'native' : `${sourceAsset.getCode()}:${sourceAsset.getIssuer()}`}` + + `&destination_asset_type=${destAsset.isNative() ? 'native' : `${destAsset.getCode()}:${destAsset.getIssuer()}`}` + + `&source_amount=${request.amount}` + ); + + if (!response.ok) { + console.error('Horizon path API error:', response.status); + return generateFallbackPaths(request); + } + + const rawPaths = (await response.json()) as { + records?: Array<{ + source_asset_code?: string; + destination_asset_code?: string; + source_amount?: string; + destination_amount?: string; + }>; + }; + + if (!rawPaths.records || rawPaths.records.length === 0) { + return generateFallbackPaths(request); + } + + const paths: ConversionPath[] = rawPaths.records.slice(0, 10).map((rawPath) => { + const sourceAmount = rawPath.source_amount || '0'; + const destAmount = rawPath.destination_amount || '0'; + const sourceRate = + parseFloat(sourceAmount) > 0 ? parseFloat(destAmount) / parseFloat(sourceAmount) : 0; + const baseFeePercent = 0.006; + const fee = request.amount * baseFeePercent; + const slippageEstimate = 0.35; + + return { + id: `path-${rawPath.source_asset_code || 'unknown'}-${rawPath.destination_asset_code || 'unknown'}-${Math.random().toString(36).slice(2, 8)}`, + sourceAsset: normalizeAssetCode(rawPath.source_asset_code || 'XLM'), + destinationAsset: normalizeAssetCode(rawPath.destination_asset_code || 'XLM'), + rate: sourceRate, + fee, + slippage: slippageEstimate, + estimatedDestinationAmount: parseFloat(destAmount) - fee, + hops: [request.fromAsset, rawPath.source_asset_code || 'XLM', request.toAsset], + }; + }); + + return paths; + } catch (error) { + console.error('Error fetching Horizon paths:', error); + return generateFallbackPaths(request); + } +} + +function generateFallbackPaths(request: PathfindRequest): ConversionPath[] { + const baseRates: Record = { + NGN: 1550, + BRL: 5.1, + ARS: 1200, + KES: 150, + }; + + const baseRate = baseRates[request.toAsset] || 1.0; + const fastFee = request.amount * 0.006; + const cheapFee = request.amount * 0.003; + + return [ + { + id: 'path-fast', + sourceAsset: request.fromAsset, + destinationAsset: request.toAsset, + rate: baseRate, + fee: fastFee, + slippage: 0.35, + estimatedDestinationAmount: request.amount * baseRate - fastFee, + hops: [request.fromAsset, 'XLM', request.toAsset], + }, + { + id: 'path-cheap', + sourceAsset: request.fromAsset, + destinationAsset: request.toAsset, + rate: baseRate * 0.994, + fee: cheapFee, + slippage: 0.8, + estimatedDestinationAmount: request.amount * baseRate * 0.994 - cheapFee, + hops: [request.fromAsset, 'USDC', request.toAsset], + }, + ]; +} diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index ba2a7489..bc9228b7 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -4,6 +4,7 @@ import { UpdateEmployeeInput, EmployeeQueryInput, } from '../schemas/employeeSchema.js'; +import { WebhookService, WEBHOOK_EVENTS } from './webhook.service.js'; export class EmployeeService { async create(data: CreateEmployeeInput, dbClient?: any) { @@ -43,7 +44,25 @@ export class EmployeeService { ]; const result = await executor.query(query, values); - return result.rows[0]; + const employee = result.rows[0]; + + EmployeeService.dispatchWebhook(organization_id, WEBHOOK_EVENTS.EMPLOYEE_ADDED, employee).catch( + (err: any) => console.error('Failed to dispatch employee.added webhook:', err) + ); + + return employee; + } + + private static async dispatchWebhook( + organization_id: number, + eventType: string, + payload: any + ): Promise { + try { + await WebhookService.dispatch(eventType, organization_id, payload); + } catch (error) { + console.error(`Webhook dispatch failed for ${eventType}:`, error); + } } async findAll(organization_id: number, params: EmployeeQueryInput) { @@ -140,7 +159,17 @@ export class EmployeeService { `; const result = await pool.query(query, values); - return result.rows[0] || null; + const employee = result.rows[0] || null; + + if (employee) { + EmployeeService.dispatchWebhook( + organization_id, + WEBHOOK_EVENTS.EMPLOYEE_UPDATED, + employee + ).catch((err: any) => console.error('Failed to dispatch employee.updated webhook:', err)); + } + + return employee; } async delete(id: number, organization_id: number) { @@ -151,7 +180,17 @@ export class EmployeeService { RETURNING *; `; const result = await pool.query(query, [id, organization_id]); - return result.rows[0] || null; + const employee = result.rows[0] || null; + + if (employee) { + EmployeeService.dispatchWebhook( + organization_id, + WEBHOOK_EVENTS.EMPLOYEE_DELETED, + employee + ).catch((err: any) => console.error('Failed to dispatch employee.deleted webhook:', err)); + } + + return employee; } } diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts index 11fe0ba5..c96575b7 100644 --- a/backend/src/services/webhook.service.ts +++ b/backend/src/services/webhook.service.ts @@ -1,60 +1,156 @@ import axios from 'axios'; import CryptoJS from 'crypto-js'; +import { pool } from '../config/database.js'; +import { v4 as uuidv4 } from 'uuid'; export interface WebhookSubscription { id: string; + organization_id: number; url: string; secret: string; events: string[]; + is_active: boolean; + created_at: Date; + updated_at: Date; } -// In-memory storage for demonstration (in a real app, this would be a database) -const subscriptions: WebhookSubscription[] = []; +export interface WebhookDeliveryLog { + id: number; + subscription_id: string; + event_type: string; + payload: any; + response_status: number | null; + response_body: string | null; + error_message: string | null; + attempt_number: number; + delivered_at: Date; +} + +export const WEBHOOK_EVENTS = { + PAYMENT_COMPLETED: 'payment.completed', + PAYMENT_FAILED: 'payment.failed', + EMPLOYEE_ADDED: 'employee.added', + EMPLOYEE_UPDATED: 'employee.updated', + EMPLOYEE_DELETED: 'employee.deleted', + PAYROLL_RUN_CREATED: 'payroll_run.created', + PAYROLL_RUN_COMPLETED: 'payroll_run.completed', + CLAIMABLE_BALANCE_CREATED: 'claimable_balance.created', + CLAIMABLE_BALANCE_CLAIMED: 'claimable_balance.claimed', + CONTRACT_UPGRADED: 'contract.upgraded', +} as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENTS)[keyof typeof WEBHOOK_EVENTS]; export class WebhookService { - static async subscribe(url: string, secret: string, events: string[]): Promise { - const subscription: WebhookSubscription = { - id: Math.random().toString(36).substring(2, 11), - url, - secret, - events, - }; - subscriptions.push(subscription); - return subscription; + static async subscribe( + organization_id: number, + url: string, + secret: string, + events: string[] + ): Promise { + const id = uuidv4(); + const result = await pool.query( + `INSERT INTO webhook_subscriptions (id, organization_id, url, secret, events, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING *`, + [id, organization_id, url, secret, events] + ); + return result.rows[0]; } - static listSubscriptions(): WebhookSubscription[] { - return subscriptions; - } + static async updateSubscription( + id: string, + organization_id: number, + updates: { url?: string; secret?: string; events?: string[]; is_active?: boolean } + ): Promise { + const setClauses: string[] = ['updated_at = CURRENT_TIMESTAMP']; + const values: any[] = [organization_id]; + let paramIndex = 2; - static deleteSubscription(id: string): boolean { - const index = subscriptions.findIndex((s) => s.id === id); - if (index !== -1) { - subscriptions.splice(index, 1); - return true; + if (updates.url !== undefined) { + setClauses.push(`url = $${paramIndex++}`); + values.push(updates.url); + } + if (updates.secret !== undefined) { + setClauses.push(`secret = $${paramIndex++}`); + values.push(updates.secret); + } + if (updates.events !== undefined) { + setClauses.push(`events = $${paramIndex++}`); + values.push(updates.events); } - return false; + if (updates.is_active !== undefined) { + setClauses.push(`is_active = $${paramIndex++}`); + values.push(updates.is_active); + } + + values.push(id); + + const result = await pool.query( + `UPDATE webhook_subscriptions + SET ${setClauses.join(', ')} + WHERE id = $${paramIndex} AND organization_id = $1 + RETURNING *`, + values + ); + return result.rows[0] || null; } - static async dispatch(eventType: string, payload: any): Promise { - const relevantSubscriptions = subscriptions.filter( - (s) => s.events.includes(eventType) || s.events.includes('*') + static async listSubscriptions(organization_id: number): Promise { + const result = await pool.query( + 'SELECT * FROM webhook_subscriptions WHERE organization_id = $1 ORDER BY created_at DESC', + [organization_id] ); + return result.rows; + } - const dispatchPromises = relevantSubscriptions.map(async (sub) => { + static async deleteSubscription(id: string, organization_id: number): Promise { + const result = await pool.query( + 'DELETE FROM webhook_subscriptions WHERE id = $1 AND organization_id = $2', + [id, organization_id] + ); + return (result.rowCount ?? 0) > 0; + } + + static async getSubscriptionById( + id: string, + organization_id: number + ): Promise { + const result = await pool.query( + 'SELECT * FROM webhook_subscriptions WHERE id = $1 AND organization_id = $2', + [id, organization_id] + ); + return result.rows[0] || null; + } + + static async dispatch(eventType: string, organization_id: number, payload: any): Promise { + const result = await pool.query( + `SELECT * FROM webhook_subscriptions + WHERE organization_id = $1 AND is_active = true + AND (events @> $2 OR events @> '["*"]')`, + [organization_id, [eventType]] + ); + const subscriptions = result.rows; + + const dispatchPromises = subscriptions.map(async (sub) => { const timestamp = Date.now().toString(); const payloadString = JSON.stringify(payload); const signature = this.generateSignature(payloadString, sub.secret, timestamp); try { - await this.sendWithRetry(sub.url, payload, { + const response = await this.sendWithRetry(sub.url, payload, { 'X-PayD-Event': eventType, 'X-PayD-Signature': signature, 'X-PayD-Timestamp': timestamp, + 'Content-Type': 'application/json', }); + await this.logDelivery(sub.id, eventType, payload, response.status, response.data, null, 1); console.log(`Webhook dispatched successfully to ${sub.url}`); - } catch (error) { - console.error(`Failed to dispatch webhook to ${sub.url}:`, error); + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message; + const status = error.response?.status; + await this.logDelivery(sub.id, eventType, payload, status || null, null, errorMessage, 1); + console.error(`Failed to dispatch webhook to ${sub.url}:`, errorMessage); } }); @@ -66,16 +162,41 @@ export class WebhookService { return CryptoJS.HmacSHA256(message, secret).toString(CryptoJS.enc.Hex); } + private static async logDelivery( + subscriptionId: string, + eventType: string, + payload: any, + responseStatus: number | null, + responseBody: any | null, + errorMessage: string | null, + attemptNumber: number + ): Promise { + await pool.query( + `INSERT INTO webhook_delivery_logs (subscription_id, event_type, payload, response_status, response_body, error_message, attempt_number) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + subscriptionId, + eventType, + JSON.stringify(payload), + responseStatus, + responseBody ? JSON.stringify(responseBody) : null, + errorMessage, + attemptNumber, + ] + ); + } + private static async sendWithRetry( url: string, data: any, headers: any, retries = 3, delay = 1000 - ): Promise { + ): Promise<{ status: number; data: any }> { try { - await axios.post(url, data, { headers, timeout: 5000 }); - } catch (error) { + const response = await axios.post(url, data, { headers, timeout: 5000 }); + return { status: response.status, data: response.data }; + } catch (error: any) { if (retries > 0) { console.log(`Retrying webhook to ${url} (${retries} attempts left)...`); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -84,4 +205,15 @@ export class WebhookService { throw error; } } + + static async getDeliveryLogs(subscriptionId: string, limit = 20): Promise { + const result = await pool.query( + `SELECT * FROM webhook_delivery_logs + WHERE subscription_id = $1 + ORDER BY delivered_at DESC + LIMIT $2`, + [subscriptionId, limit] + ); + return result.rows; + } }