miinventario-v2/apps/backend/src/modules/integrations/pos/services/pos-webhook.service.ts
rckrdmrd c24f889f70
Some checks failed
Build / Build Backend (push) Has been cancelled
Build / Build Mobile (TypeScript Check) (push) Has been cancelled
Lint / Lint Backend (push) Has been cancelled
Lint / Lint Mobile (push) Has been cancelled
Test / Backend E2E Tests (push) Has been cancelled
Test / Mobile Unit Tests (push) Has been cancelled
Build / Build Docker Image (push) Has been cancelled
[MIINVENTARIO] feat: Add exports, reports, integrations modules and CI/CD pipeline
- Add exports module with PDF/CSV/Excel generation
- Add reports module for inventory analytics
- Add POS integrations module
- Add database migrations for exports, movements and integrations
- Add GitHub Actions CI/CD workflow with Docker support
- Add mobile export and reports screens with tests
- Update epic documentation with traceability
- Add deployment and security guides

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 06:06:34 -06:00

264 lines
6.9 KiB
TypeScript

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<PosIntegration>,
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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}`,
);
}
}