import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as crypto from 'crypto'; import { PosIntegration, PosProvider, } from '../../entities/pos-integration.entity'; import { InventorySyncService } from './inventory-sync.service'; import { IPosWebhookHandler, PosWebhookEventType, SaleWebhookData, InventoryWebhookData, ProductWebhookData, } from '../interfaces/pos-webhook.interface'; @Injectable() export class PosWebhookService implements IPosWebhookHandler { private readonly logger = new Logger(PosWebhookService.name); constructor( @InjectRepository(PosIntegration) private integrationRepository: Repository, private inventorySyncService: InventorySyncService, ) {} private verifyWebhookSignature( payload: string, signature: string, secret: string, ): boolean { try { const expectedSignature = crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature), ); } catch { return false; } } async handleWebhook( storeId: string, provider: PosProvider, rawPayload: string, signature: string, ): Promise<{ success: boolean; message: string }> { this.logger.log( `Received webhook from ${provider} for store ${storeId}`, ); // Find integration const integration = await this.integrationRepository.findOne({ where: { storeId, provider, isActive: true }, }); if (!integration) { throw new NotFoundException( `No active integration found for provider ${provider}`, ); } // Verify signature if (integration.webhookSecret && signature) { const isValid = this.verifyWebhookSignature( rawPayload, signature, integration.webhookSecret, ); if (!isValid) { this.logger.warn( `Invalid webhook signature for integration ${integration.id}`, ); return { success: false, message: 'Invalid signature' }; } } try { const payload = JSON.parse(rawPayload); await this.processWebhookPayload(integration, payload); return { success: true, message: 'Webhook processed successfully' }; } catch (error) { this.logger.error(`Failed to process webhook: ${error.message}`); return { success: false, message: error.message }; } } private async processWebhookPayload( integration: PosIntegration, payload: { eventType: PosWebhookEventType; eventId?: string; data: unknown; }, ): Promise { const { eventType, data } = payload; switch (eventType) { case PosWebhookEventType.SALE_CREATED: case PosWebhookEventType.SALE_UPDATED: await this.processSaleEvent( integration.storeId, integration.id, data as SaleWebhookData, ); break; case PosWebhookEventType.SALE_REFUNDED: // Handle refunds - increase inventory await this.processSaleRefund( integration.storeId, integration.id, data as SaleWebhookData, ); break; case PosWebhookEventType.INVENTORY_UPDATED: await this.processInventoryEvent( integration.storeId, integration.id, data as InventoryWebhookData, ); break; case PosWebhookEventType.PRODUCT_CREATED: case PosWebhookEventType.PRODUCT_UPDATED: case PosWebhookEventType.PRODUCT_DELETED: await this.processProductEvent( integration.storeId, integration.id, eventType, data as ProductWebhookData, ); break; default: this.logger.warn(`Unknown event type: ${eventType}`); } } async processSaleEvent( storeId: string, integrationId: string, data: SaleWebhookData, ): Promise { const integration = await this.integrationRepository.findOneOrFail({ where: { id: integrationId }, }); if (!integration.syncConfig?.syncOnSale) { this.logger.log('Sale sync disabled for this integration, skipping'); return; } const saleItems = data.items.map((item) => ({ productId: item.productId, quantity: item.quantity, })); await this.inventorySyncService.processSale( integration, saleItems, data.saleId, ); this.logger.log( `Processed sale ${data.saleId} with ${saleItems.length} items`, ); } private async processSaleRefund( storeId: string, integrationId: string, data: SaleWebhookData, ): Promise { // For refunds, we add the quantity back const integration = await this.integrationRepository.findOneOrFail({ where: { id: integrationId }, }); // Convert refund to inventory updates (positive quantities) const products = data.items.map((item) => ({ externalId: item.productId, name: item.productName || `Product ${item.productId}`, quantity: item.quantity, // This will be added back })); await this.inventorySyncService.syncFromPos(integration, products); this.logger.log( `Processed refund for sale ${data.saleId} with ${products.length} items`, ); } async processInventoryEvent( storeId: string, integrationId: string, data: InventoryWebhookData, ): Promise { const integration = await this.integrationRepository.findOneOrFail({ where: { id: integrationId }, }); const products = [ { externalId: data.productId, name: data.productName || `Product ${data.productId}`, quantity: data.newQuantity, }, ]; await this.inventorySyncService.syncFromPos(integration, products); this.logger.log( `Processed inventory update for product ${data.productId}: ${data.newQuantity}`, ); } async processProductEvent( storeId: string, integrationId: string, eventType: PosWebhookEventType, data: ProductWebhookData, ): Promise { const integration = await this.integrationRepository.findOneOrFail({ where: { id: integrationId }, }); if (eventType === PosWebhookEventType.PRODUCT_DELETED) { // We don't delete items from our inventory when deleted from POS // Just log it this.logger.log(`Product ${data.productId} deleted in POS, skipping`); return; } const products = [ { externalId: data.productId, name: data.name, sku: data.sku, barcode: data.barcode, category: data.category, quantity: data.quantity || 0, price: data.price, cost: data.cost, }, ]; await this.inventorySyncService.syncFromPos(integration, products); this.logger.log( `Processed product ${eventType} for ${data.productId}: ${data.name}`, ); } }