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
- 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>
264 lines
6.9 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
}
|