From 26085b54a92ca1c2312f792119684ee18960ab24 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 26 Sep 2026 17:59:16 +0330 Subject: [PATCH] Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI. --- .../migration.sql | 63 +++ apps/api/prisma/schema.prisma | 51 +++ apps/api/src/app.module.ts | 2 + .../woocommerce/connections.service.ts | 253 +++++++++++ apps/api/src/integrations/woocommerce/dto.ts | 73 +++ .../integrations/woocommerce/sync.service.ts | 424 ++++++++++++++++++ .../integrations/woocommerce/woo-client.ts | 117 +++++ .../woocommerce/woocommerce.controller.ts | 139 ++++++ .../woocommerce/woocommerce.module.ts | 18 + apps/api/src/main.ts | 2 +- apps/web/src/layouts/AppLayout.vue | 1 + apps/web/src/lib/nav-hubs.ts | 9 + apps/web/src/router/index.ts | 6 + .../web/src/views/WooCommerceSettingsView.vue | 353 +++++++++++++++ packages/shared/src/index.ts | 62 ++- 15 files changed, 1571 insertions(+), 2 deletions(-) create mode 100644 apps/api/prisma/migrations/20260926190000_woocommerce_connection/migration.sql create mode 100644 apps/api/src/integrations/woocommerce/connections.service.ts create mode 100644 apps/api/src/integrations/woocommerce/dto.ts create mode 100644 apps/api/src/integrations/woocommerce/sync.service.ts create mode 100644 apps/api/src/integrations/woocommerce/woo-client.ts create mode 100644 apps/api/src/integrations/woocommerce/woocommerce.controller.ts create mode 100644 apps/api/src/integrations/woocommerce/woocommerce.module.ts create mode 100644 apps/web/src/views/WooCommerceSettingsView.vue diff --git a/apps/api/prisma/migrations/20260926190000_woocommerce_connection/migration.sql b/apps/api/prisma/migrations/20260926190000_woocommerce_connection/migration.sql new file mode 100644 index 0000000..abcf8cb --- /dev/null +++ b/apps/api/prisma/migrations/20260926190000_woocommerce_connection/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "WooCommerceConnection" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "storeUrl" TEXT NOT NULL, + "consumerKeyCiphertext" TEXT NOT NULL, + "consumerKeyIv" TEXT NOT NULL, + "consumerKeyTag" TEXT NOT NULL, + "consumerKeyLast4" TEXT NOT NULL DEFAULT '', + "consumerSecretCiphertext" TEXT NOT NULL, + "consumerSecretIv" TEXT NOT NULL, + "consumerSecretTag" TEXT NOT NULL, + "webhookSecretCiphertext" TEXT, + "webhookSecretIv" TEXT, + "webhookSecretTag" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "lastSyncAt" TIMESTAMP(3), + "lastHealthAt" TIMESTAMP(3), + "lastHealthOk" BOOLEAN, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WooCommerceConnection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WooCommerceOrderLink" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "wooOrderId" TEXT NOT NULL, + "wooOrderNumber" TEXT, + "invoiceId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT '', + "total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'IRR', + "rawPayload" JSONB, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WooCommerceOrderLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WooCommerceConnection_organizationId_enabled_idx" ON "WooCommerceConnection"("organizationId", "enabled"); + +-- CreateIndex +CREATE INDEX "WooCommerceOrderLink_organizationId_syncedAt_idx" ON "WooCommerceOrderLink"("organizationId", "syncedAt"); + +-- CreateIndex +CREATE INDEX "WooCommerceOrderLink_invoiceId_idx" ON "WooCommerceOrderLink"("invoiceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WooCommerceOrderLink_connectionId_wooOrderId_key" ON "WooCommerceOrderLink"("connectionId", "wooOrderId"); + +-- AddForeignKey +ALTER TABLE "WooCommerceConnection" ADD CONSTRAINT "WooCommerceConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WooCommerceOrderLink" ADD CONSTRAINT "WooCommerceOrderLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "WooCommerceConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7e06905..03c8488 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -198,6 +198,7 @@ model Organization { apiKeys OrganizationApiKey[] webhookEndpoints WebhookEndpoint[] smsCampaigns SmsCampaign[] + wooCommerceConnections WooCommerceConnection[] } model User { @@ -1729,3 +1730,53 @@ model SmsCampaignRecipient { @@index([campaignId, status]) } + +/// اتصال فروشگاه ووکامرس +model WooCommerceConnection { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + label String + storeUrl String + consumerKeyCiphertext String + consumerKeyIv String + consumerKeyTag String + consumerKeyLast4 String @default("") + consumerSecretCiphertext String + consumerSecretIv String + consumerSecretTag String + webhookSecretCiphertext String? + webhookSecretIv String? + webhookSecretTag String? + enabled Boolean @default(true) + lastSyncAt DateTime? + lastHealthAt DateTime? + lastHealthOk Boolean? + lastError String? + orders WooCommerceOrderLink[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, enabled]) +} + +model WooCommerceOrderLink { + id String @id @default(cuid()) + organizationId String + connectionId String + connection WooCommerceConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + wooOrderId String + wooOrderNumber String? + invoiceId String + status String @default("") + total Decimal @default(0) @db.Decimal(18, 2) + currency String @default("IRR") + rawPayload Json? + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([connectionId, wooOrderId]) + @@index([organizationId, syncedAt]) + @@index([invoiceId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ce268a4..9c4664f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -39,6 +39,7 @@ import { SavedViewsModule } from './saved-views/saved-views.module'; import { PublicApiModule } from './public-api/public-api.module'; import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module'; import { TeamsModule } from './teams/teams.module'; +import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module'; @Module({ imports: [ @@ -85,6 +86,7 @@ import { TeamsModule } from './teams/teams.module'; PublicApiModule, SmsCampaignsModule, TeamsModule, + WooCommerceModule, ], }) export class AppModule {} diff --git a/apps/api/src/integrations/woocommerce/connections.service.ts b/apps/api/src/integrations/woocommerce/connections.service.ts new file mode 100644 index 0000000..dc7eb1c --- /dev/null +++ b/apps/api/src/integrations/woocommerce/connections.service.ts @@ -0,0 +1,253 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateWooCommerceConnectionRequest, + UpdateWooCommerceConnectionRequest, + WooCommerceConnectionDto, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; +import { normalizeStoreUrl, WooCommerceClient } from './woo-client'; + +@Injectable() +export class WooConnectionsService { + constructor( + private readonly prisma: PrismaService, + private readonly crypto: TahesabCryptoService, + ) {} + + async list(organizationId: string): Promise { + const rows = await this.prisma.wooCommerceConnection.findMany({ + where: { organizationId }, + include: { _count: { select: { orders: true } } }, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((r) => this.toDto(r, r._count.orders)); + } + + async getOrThrow(organizationId: string, id: string) { + const row = await this.prisma.wooCommerceConnection.findFirst({ + where: { id, organizationId }, + }); + if (!row) throw new NotFoundException('اتصال ووکامرس پیدا نشد'); + return row; + } + + async create( + organizationId: string, + dto: CreateWooCommerceConnectionRequest, + ): Promise { + const label = dto.label?.trim(); + if (!label) throw new BadRequestException('نام اتصال الزامی است'); + let storeUrl: string; + try { + storeUrl = normalizeStoreUrl(dto.storeUrl); + } catch (e) { + throw new BadRequestException( + e instanceof Error ? e.message : 'آدرس فروشگاه نامعتبر است', + ); + } + const key = dto.consumerKey?.trim(); + const secret = dto.consumerSecret?.trim(); + if (!key || !secret) { + throw new BadRequestException('Consumer Key و Secret الزامی است'); + } + + const keyEnc = this.crypto.encrypt(key); + const secretEnc = this.crypto.encrypt(secret); + const webhook = dto.webhookSecret?.trim(); + const webhookEnc = webhook ? this.crypto.encrypt(webhook) : null; + + const row = await this.prisma.wooCommerceConnection.create({ + data: { + organizationId, + label, + storeUrl, + consumerKeyCiphertext: keyEnc.ciphertext, + consumerKeyIv: keyEnc.iv, + consumerKeyTag: keyEnc.tag, + consumerKeyLast4: this.crypto.last4(key), + consumerSecretCiphertext: secretEnc.ciphertext, + consumerSecretIv: secretEnc.iv, + consumerSecretTag: secretEnc.tag, + webhookSecretCiphertext: webhookEnc?.ciphertext, + webhookSecretIv: webhookEnc?.iv, + webhookSecretTag: webhookEnc?.tag, + enabled: dto.enabled ?? true, + }, + }); + return this.toDto(row, 0); + } + + async update( + organizationId: string, + id: string, + dto: UpdateWooCommerceConnectionRequest, + ): Promise { + const existing = await this.getOrThrow(organizationId, id); + const data: Prisma.WooCommerceConnectionUpdateInput = {}; + + if (dto.label !== undefined) { + const label = dto.label.trim(); + if (!label) throw new BadRequestException('نام اتصال الزامی است'); + data.label = label; + } + if (dto.storeUrl !== undefined) { + try { + data.storeUrl = normalizeStoreUrl(dto.storeUrl); + } catch (e) { + throw new BadRequestException( + e instanceof Error ? e.message : 'آدرس فروشگاه نامعتبر است', + ); + } + } + if (dto.consumerKey?.trim()) { + const enc = this.crypto.encrypt(dto.consumerKey.trim()); + data.consumerKeyCiphertext = enc.ciphertext; + data.consumerKeyIv = enc.iv; + data.consumerKeyTag = enc.tag; + data.consumerKeyLast4 = this.crypto.last4(dto.consumerKey.trim()); + } + if (dto.consumerSecret?.trim()) { + const enc = this.crypto.encrypt(dto.consumerSecret.trim()); + data.consumerSecretCiphertext = enc.ciphertext; + data.consumerSecretIv = enc.iv; + data.consumerSecretTag = enc.tag; + } + if (dto.webhookSecret !== undefined) { + const wh = dto.webhookSecret?.trim(); + if (!wh) { + data.webhookSecretCiphertext = null; + data.webhookSecretIv = null; + data.webhookSecretTag = null; + } else { + const enc = this.crypto.encrypt(wh); + data.webhookSecretCiphertext = enc.ciphertext; + data.webhookSecretIv = enc.iv; + data.webhookSecretTag = enc.tag; + } + } + if (dto.enabled !== undefined) data.enabled = dto.enabled; + + const row = await this.prisma.wooCommerceConnection.update({ + where: { id: existing.id }, + data, + }); + const count = await this.prisma.wooCommerceOrderLink.count({ + where: { connectionId: id }, + }); + return this.toDto(row, count); + } + + async remove(organizationId: string, id: string): Promise { + await this.getOrThrow(organizationId, id); + await this.prisma.wooCommerceConnection.delete({ where: { id } }); + } + + async getClient(organizationId: string, id: string) { + const row = await this.getOrThrow(organizationId, id); + if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است'); + const consumerKey = this.crypto.decrypt({ + ciphertext: row.consumerKeyCiphertext, + iv: row.consumerKeyIv, + tag: row.consumerKeyTag, + }); + const consumerSecret = this.crypto.decrypt({ + ciphertext: row.consumerSecretCiphertext, + iv: row.consumerSecretIv, + tag: row.consumerSecretTag, + }); + return { + row, + client: new WooCommerceClient(row.storeUrl, consumerKey, consumerSecret), + }; + } + + async decryptWebhookSecret( + row: { + webhookSecretCiphertext: string | null; + webhookSecretIv: string | null; + webhookSecretTag: string | null; + }, + ): Promise { + if ( + !row.webhookSecretCiphertext || + !row.webhookSecretIv || + !row.webhookSecretTag + ) { + return null; + } + return this.crypto.decrypt({ + ciphertext: row.webhookSecretCiphertext, + iv: row.webhookSecretIv, + tag: row.webhookSecretTag, + }); + } + + async testConnection(organizationId: string, id: string) { + const { row, client } = await this.getClient(organizationId, id); + try { + await client.test(); + await this.prisma.wooCommerceConnection.update({ + where: { id: row.id }, + data: { + lastHealthAt: new Date(), + lastHealthOk: true, + lastError: null, + }, + }); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'خطای ناشناخته'; + await this.prisma.wooCommerceConnection.update({ + where: { id: row.id }, + data: { + lastHealthAt: new Date(), + lastHealthOk: false, + lastError: msg.slice(0, 500), + }, + }); + throw new BadRequestException(`اتصال ناموفق: ${msg}`); + } + } + + private toDto( + row: { + id: string; + label: string; + storeUrl: string; + consumerKeyLast4: string; + webhookSecretCiphertext: string | null; + enabled: boolean; + lastSyncAt: Date | null; + lastHealthAt: Date | null; + lastHealthOk: boolean | null; + lastError: string | null; + createdAt: Date; + updatedAt: Date; + }, + importedOrders: number, + ): WooCommerceConnectionDto { + return { + id: row.id, + label: row.label, + storeUrl: row.storeUrl, + consumerKeyMasked: row.consumerKeyLast4 + ? `••••••${row.consumerKeyLast4}` + : '••••', + webhookSecretSet: Boolean(row.webhookSecretCiphertext), + enabled: row.enabled, + lastSyncAt: row.lastSyncAt?.toISOString() ?? null, + lastHealthAt: row.lastHealthAt?.toISOString() ?? null, + lastHealthOk: row.lastHealthOk, + lastError: row.lastError, + importedOrders, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/integrations/woocommerce/dto.ts b/apps/api/src/integrations/woocommerce/dto.ts new file mode 100644 index 0000000..6b13c01 --- /dev/null +++ b/apps/api/src/integrations/woocommerce/dto.ts @@ -0,0 +1,73 @@ +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateWooConnectionDto { + @IsString() + @MinLength(2) + @MaxLength(80) + label!: string; + + @IsString() + @MinLength(8) + @MaxLength(300) + storeUrl!: string; + + @IsString() + @MinLength(8) + @MaxLength(200) + consumerKey!: string; + + @IsString() + @MinLength(8) + @MaxLength(200) + consumerSecret!: string; + + @IsOptional() + @IsString() + @MaxLength(200) + webhookSecret?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +export class UpdateWooConnectionDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(80) + label?: string; + + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(300) + storeUrl?: string; + + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(200) + consumerKey?: string; + + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(200) + consumerSecret?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + webhookSecret?: string | null; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} diff --git a/apps/api/src/integrations/woocommerce/sync.service.ts b/apps/api/src/integrations/woocommerce/sync.service.ts new file mode 100644 index 0000000..17d18ec --- /dev/null +++ b/apps/api/src/integrations/woocommerce/sync.service.ts @@ -0,0 +1,424 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Prisma } from '@prisma/client'; +import type { + WooCommerceOrderLinkDto, + WooCommerceSyncResultDto, +} from '@mento/shared'; +import { PrismaService } from '../../prisma/prisma.service'; +import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; +import { WooConnectionsService } from './connections.service'; +import type { WooOrder } from './woo-client'; + +function mapInvoiceStatus( + wooStatus: string | undefined, +): 'issued' | 'paid' | 'canceled' { + const s = (wooStatus ?? '').toLowerCase(); + if (s === 'completed' || s === 'processing') return 'paid'; + if (s === 'cancelled' || s === 'canceled' || s === 'refunded' || s === 'failed') { + return 'canceled'; + } + return 'issued'; +} + +function isPaidStatus(wooStatus: string | undefined): boolean { + const s = (wooStatus ?? '').toLowerCase(); + return s === 'completed' || s === 'processing'; +} + +@Injectable() +export class WooSyncService { + private readonly logger = new Logger(WooSyncService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly connections: WooConnectionsService, + ) {} + + async syncConnection( + organizationId: string, + connectionId: string, + ): Promise { + const { row, client } = await this.connections.getClient( + organizationId, + connectionId, + ); + + const result: WooCommerceSyncResultDto = { + fetched: 0, + created: 0, + updated: 0, + skipped: 0, + errors: [], + }; + + try { + const after = row.lastSyncAt + ? new Date(row.lastSyncAt.getTime() - 60_000).toISOString() + : undefined; + + let page = 1; + let batch: WooOrder[] = []; + do { + batch = await client.listOrders({ + page, + perPage: 50, + modifiedAfter: after, + }); + result.fetched += batch.length; + for (const order of batch) { + try { + const outcome = await this.upsertOrder( + organizationId, + connectionId, + order, + ); + if (outcome === 'created') result.created += 1; + else if (outcome === 'updated') result.updated += 1; + else result.skipped += 1; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.errors.push(`سفارش ${order.id}: ${msg}`); + this.logger.warn(`Woo sync order ${order.id}: ${msg}`); + } + } + page += 1; + } while (batch.length >= 50 && page <= 20); + + await this.prisma.wooCommerceConnection.update({ + where: { id: connectionId }, + data: { + lastSyncAt: new Date(), + lastHealthAt: new Date(), + lastHealthOk: true, + lastError: result.errors.length + ? result.errors.slice(0, 3).join(' | ').slice(0, 500) + : null, + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.errors.push(msg); + await this.prisma.wooCommerceConnection.update({ + where: { id: connectionId }, + data: { + lastHealthAt: new Date(), + lastHealthOk: false, + lastError: msg.slice(0, 500), + }, + }); + } + + return result; + } + + async ingestWebhookOrder( + connectionId: string, + order: WooOrder, + ): Promise<{ invoiceId: string; outcome: string }> { + const conn = await this.prisma.wooCommerceConnection.findFirst({ + where: { id: connectionId, enabled: true }, + }); + if (!conn) throw new NotFoundException('اتصال پیدا نشد'); + const outcome = await this.upsertOrder( + conn.organizationId, + connectionId, + order, + ); + const link = await this.prisma.wooCommerceOrderLink.findUnique({ + where: { + connectionId_wooOrderId: { + connectionId, + wooOrderId: String(order.id), + }, + }, + }); + await this.prisma.wooCommerceConnection.update({ + where: { id: connectionId }, + data: { lastSyncAt: new Date() }, + }); + return { invoiceId: link!.invoiceId, outcome }; + } + + async listRecentOrders( + organizationId: string, + connectionId?: string, + ): Promise { + const rows = await this.prisma.wooCommerceOrderLink.findMany({ + where: { + organizationId, + ...(connectionId ? { connectionId } : {}), + }, + orderBy: { syncedAt: 'desc' }, + take: 40, + }); + const invoiceIds = rows.map((r) => r.invoiceId); + const invoices = invoiceIds.length + ? await this.prisma.invoice.findMany({ + where: { id: { in: invoiceIds } }, + select: { id: true, number: true }, + }) + : []; + const byId = new Map(invoices.map((i) => [i.id, i.number])); + return rows.map((r) => ({ + id: r.id, + wooOrderId: r.wooOrderId, + wooOrderNumber: r.wooOrderNumber, + invoiceId: r.invoiceId, + invoiceNumber: byId.get(r.invoiceId) ?? '—', + status: r.status, + total: Number(r.total), + currency: r.currency, + syncedAt: r.syncedAt.toISOString(), + })); + } + + @Cron('*/15 * * * *') + async cronSyncAll(): Promise { + const connections = await this.prisma.wooCommerceConnection.findMany({ + where: { enabled: true }, + select: { id: true, organizationId: true }, + take: 30, + }); + for (const c of connections) { + try { + await this.syncConnection(c.organizationId, c.id); + } catch (e) { + this.logger.warn( + `Woo cron sync ${c.id}: ${e instanceof Error ? e.message : e}`, + ); + } + } + } + + private async upsertOrder( + organizationId: string, + connectionId: string, + order: WooOrder, + ): Promise<'created' | 'updated' | 'skipped'> { + if (!order?.id) return 'skipped'; + + const personId = await this.upsertPerson(organizationId, order); + const currency = (order.currency ?? 'IRR').trim() || 'IRR'; + const total = Number(order.total ?? 0) || 0; + const discount = Number(order.discount_total ?? 0) || 0; + const status = mapInvoiceStatus(order.status); + const title = `سفارش ووکامرس #${order.number ?? order.id}`; + const number = `WOO-${order.id}`; + const issuedAt = order.date_created + ? new Date(order.date_created) + : new Date(); + const notes = [ + order.payment_method_title + ? `روش پرداخت: ${order.payment_method_title}` + : null, + `وضعیت ووکامرس: ${order.status ?? '—'}`, + ] + .filter(Boolean) + .join('\n'); + + const lineItems = (order.line_items ?? []).map((item, index) => ({ + name: (item.name ?? 'قلم').trim() || 'قلم', + quantity: new Prisma.Decimal(item.quantity ?? 1), + unitPrice: new Prisma.Decimal( + item.price != null + ? item.price + : item.quantity + ? Number(item.total ?? 0) / item.quantity + : Number(item.total ?? 0), + ), + currency, + position: index, + })); + + const existing = await this.prisma.wooCommerceOrderLink.findUnique({ + where: { + connectionId_wooOrderId: { + connectionId, + wooOrderId: String(order.id), + }, + }, + }); + + if (existing) { + await this.prisma.invoice.update({ + where: { id: existing.invoiceId }, + data: { + title, + status, + currency, + notes, + discountAmount: new Prisma.Decimal(discount), + personId, + issuedAt, + lineItems: { + deleteMany: {}, + create: lineItems.length + ? lineItems + : [ + { + name: title, + quantity: new Prisma.Decimal(1), + unitPrice: new Prisma.Decimal(total), + currency, + position: 0, + }, + ], + }, + }, + }); + await this.ensurePayment( + existing.invoiceId, + order, + total, + currency, + ); + await this.prisma.wooCommerceOrderLink.update({ + where: { id: existing.id }, + data: { + wooOrderNumber: order.number ? String(order.number) : null, + status: order.status ?? '', + total: new Prisma.Decimal(total), + currency, + rawPayload: order as unknown as Prisma.InputJsonValue, + syncedAt: new Date(), + }, + }); + return 'updated'; + } + + const invoice = await this.prisma.invoice.create({ + data: { + organizationId, + number, + title, + status, + kind: 'invoice', + currency, + notes, + discountAmount: new Prisma.Decimal(discount), + taxPercent: new Prisma.Decimal(0), + issuedAt, + personId, + lineItems: { + create: lineItems.length + ? lineItems + : [ + { + name: title, + quantity: new Prisma.Decimal(1), + unitPrice: new Prisma.Decimal(total), + currency, + position: 0, + }, + ], + }, + }, + }); + + await this.ensurePayment(invoice.id, order, total, currency); + + await this.prisma.wooCommerceOrderLink.create({ + data: { + organizationId, + connectionId, + wooOrderId: String(order.id), + wooOrderNumber: order.number ? String(order.number) : null, + invoiceId: invoice.id, + status: order.status ?? '', + total: new Prisma.Decimal(total), + currency, + rawPayload: order as unknown as Prisma.InputJsonValue, + }, + }); + + return 'created'; + } + + private async ensurePayment( + invoiceId: string, + order: WooOrder, + total: number, + currency: string, + ) { + if (!isPaidStatus(order.status) || total <= 0) return; + const existing = await this.prisma.payment.findFirst({ + where: { invoiceId, notes: { contains: 'woocommerce' } }, + }); + if (existing) { + await this.prisma.payment.update({ + where: { id: existing.id }, + data: { + amount: new Prisma.Decimal(total), + currency, + paidAt: order.date_paid ? new Date(order.date_paid) : new Date(), + }, + }); + return; + } + await this.prisma.payment.create({ + data: { + invoiceId, + amount: new Prisma.Decimal(total), + currency, + method: 'card', + paidAt: order.date_paid ? new Date(order.date_paid) : new Date(), + notes: `woocommerce order #${order.id}`, + }, + }); + } + + private async upsertPerson( + organizationId: string, + order: WooOrder, + ): Promise { + const billing = order.billing ?? {}; + const email = normalizeEmail(billing.email ?? null); + const mobile = normalizePhone(billing.phone ?? null); + const firstName = (billing.first_name ?? '').trim() || 'مشتری'; + const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; + + if (!email && !mobile) { + return null; + } + + const existing = await this.prisma.person.findFirst({ + where: { + organizationId, + OR: [ + ...(email ? [{ email }] : []), + ...(mobile ? [{ mobile }, { phone: mobile }] : []), + ], + }, + select: { id: true }, + }); + if (existing) { + await this.prisma.person.update({ + where: { id: existing.id }, + data: { + ...(email ? { email } : {}), + ...(mobile ? { mobile } : {}), + firstName, + lastName, + }, + }); + return existing.id; + } + + const created = await this.prisma.person.create({ + data: { + organizationId, + firstName, + lastName, + email, + mobile, + phone: mobile, + notes: order.customer_id + ? `ووکامرس customer_id=${order.customer_id}` + : 'واردشده از ووکامرس', + tags: ['woocommerce'], + }, + }); + return created.id; + } +} diff --git a/apps/api/src/integrations/woocommerce/woo-client.ts b/apps/api/src/integrations/woocommerce/woo-client.ts new file mode 100644 index 0000000..5b77022 --- /dev/null +++ b/apps/api/src/integrations/woocommerce/woo-client.ts @@ -0,0 +1,117 @@ +import { createHmac } from 'crypto'; + +export type WooOrderLine = { + name?: string; + quantity?: number; + price?: number; + total?: string; + product_id?: number; +}; + +export type WooBilling = { + first_name?: string; + last_name?: string; + email?: string; + phone?: string; + company?: string; +}; + +export type WooOrder = { + id: number; + number?: string; + status?: string; + currency?: string; + total?: string; + discount_total?: string; + date_created?: string; + date_paid?: string | null; + customer_id?: number; + billing?: WooBilling; + line_items?: WooOrderLine[]; + payment_method_title?: string; +}; + +function normalizeStoreUrl(url: string): string { + let u = url.trim().replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(u)) { + throw new Error('آدرس فروشگاه باید با http یا https شروع شود'); + } + return u; +} + +export class WooCommerceClient { + private readonly base: string; + private readonly authHeader: string; + + constructor( + storeUrl: string, + consumerKey: string, + consumerSecret: string, + ) { + this.base = `${normalizeStoreUrl(storeUrl)}/wp-json/wc/v3`; + const token = Buffer.from( + `${consumerKey.trim()}:${consumerSecret.trim()}`, + 'utf8', + ).toString('base64'); + this.authHeader = `Basic ${token}`; + } + + async test(): Promise { + await this.request('GET', '/system_status?_fields=environment.home_url'); + } + + async listOrders(opts: { + after?: string; + page?: number; + perPage?: number; + modifiedAfter?: string; + } = {}): Promise { + const params = new URLSearchParams(); + params.set('page', String(opts.page ?? 1)); + params.set('per_page', String(opts.perPage ?? 50)); + params.set('orderby', 'modified'); + params.set('order', 'asc'); + if (opts.after) params.set('after', opts.after); + if (opts.modifiedAfter) params.set('modified_after', opts.modifiedAfter); + return this.request('GET', `/orders?${params.toString()}`); + } + + async getOrder(id: number | string): Promise { + return this.request('GET', `/orders/${id}`); + } + + static verifyWebhookSignature( + rawBody: string | Buffer, + signature: string | undefined, + secret: string, + ): boolean { + if (!signature?.trim() || !secret.trim()) return false; + const digest = createHmac('sha256', secret) + .update(typeof rawBody === 'string' ? rawBody : rawBody) + .digest('base64'); + return digest === signature.trim(); + } + + private async request(method: string, path: string): Promise { + const res = await fetch(`${this.base}${path}`, { + method, + headers: { + Authorization: this.authHeader, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MentoCRM-WooCommerce/1.0', + }, + signal: AbortSignal.timeout(30000), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `WooCommerce HTTP ${res.status}: ${text.slice(0, 200) || res.statusText}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } +} + +export { normalizeStoreUrl }; diff --git a/apps/api/src/integrations/woocommerce/woocommerce.controller.ts b/apps/api/src/integrations/woocommerce/woocommerce.controller.ts new file mode 100644 index 0000000..a59d754 --- /dev/null +++ b/apps/api/src/integrations/woocommerce/woocommerce.controller.ts @@ -0,0 +1,139 @@ +import { + Body, + Controller, + Delete, + Get, + Headers, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { CurrentUser } from '../../auth/current-user.decorator'; +import { Roles } from '../../auth/roles.decorator'; +import { RolesGuard } from '../../auth/roles.guard'; +import { OrgModuleGuard } from '../../billing/org-module.guard'; +import { RequireOrgModule } from '../../billing/org-module.decorator'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WooConnectionsService } from './connections.service'; +import { CreateWooConnectionDto, UpdateWooConnectionDto } from './dto'; +import { WooSyncService } from './sync.service'; +import { WooCommerceClient, type WooOrder } from './woo-client'; + +@Controller('integrations/woocommerce') +@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) +@RequireOrgModule('woocommerce') +@Roles('owner', 'admin') +export class WooCommerceController { + constructor( + private readonly connections: WooConnectionsService, + private readonly sync: WooSyncService, + ) {} + + @Get('connections') + list(@CurrentUser() user: { organizationId: string }) { + return this.connections.list(user.organizationId); + } + + @Post('connections') + create( + @CurrentUser() user: { organizationId: string }, + @Body() dto: CreateWooConnectionDto, + ) { + return this.connections.create(user.organizationId, dto); + } + + @Patch('connections/:id') + update( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + @Body() dto: UpdateWooConnectionDto, + ) { + return this.connections.update(user.organizationId, id, dto); + } + + @Delete('connections/:id') + async remove( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + ) { + await this.connections.remove(user.organizationId, id); + return { ok: true }; + } + + @Post('connections/:id/test') + test( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + ) { + return this.connections.testConnection(user.organizationId, id); + } + + @Post('connections/:id/sync') + syncNow( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + ) { + return this.sync.syncConnection(user.organizationId, id); + } + + @Get('orders') + recent( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId?: string, + ) { + return this.sync.listRecentOrders(user.organizationId, connectionId); + } +} + +@Controller('public/integrations/woocommerce') +export class WooCommerceWebhookController { + constructor( + private readonly prisma: PrismaService, + private readonly connections: WooConnectionsService, + private readonly sync: WooSyncService, + ) {} + + @Post(':connectionId/webhook') + async webhook( + @Param('connectionId') connectionId: string, + @Headers('x-wc-webhook-signature') signature: string | undefined, + @Headers('x-wc-webhook-topic') topic: string | undefined, + @Req() req: Request & { rawBody?: Buffer }, + @Body() body: WooOrder, + ) { + const row = await this.prisma.wooCommerceConnection.findFirst({ + where: { id: connectionId, enabled: true }, + }); + if (!row) throw new UnauthorizedException('اتصال نامعتبر است'); + + const secret = await this.connections.decryptWebhookSecret(row); + if (secret) { + const raw = + req.rawBody ?? + Buffer.from(JSON.stringify(body ?? {}), 'utf8'); + const ok = WooCommerceClient.verifyWebhookSignature( + raw, + signature, + secret, + ); + if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); + } + + const topicOk = + !topic || + topic.includes('order') || + topic === 'action.woocommerce_new_order'; + if (!topicOk) return { ok: true, skipped: true }; + + if (!body?.id) return { ok: true, skipped: true }; + + const result = await this.sync.ingestWebhookOrder(connectionId, body); + return { ok: true, ...result }; + } +} diff --git a/apps/api/src/integrations/woocommerce/woocommerce.module.ts b/apps/api/src/integrations/woocommerce/woocommerce.module.ts new file mode 100644 index 0000000..b8d73db --- /dev/null +++ b/apps/api/src/integrations/woocommerce/woocommerce.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { BillingModule } from '../../billing/billing.module'; +import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; +import { WooConnectionsService } from './connections.service'; +import { WooSyncService } from './sync.service'; +import { + WooCommerceController, + WooCommerceWebhookController, +} from './woocommerce.controller'; + +@Module({ + imports: [AuthModule, BillingModule], + controllers: [WooCommerceController, WooCommerceWebhookController], + providers: [TahesabCryptoService, WooConnectionsService, WooSyncService], + exports: [WooConnectionsService, WooSyncService], +}) +export class WooCommerceModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 75cc6de..4335d50 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,7 +5,7 @@ import { brand } from '@mento/shared'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); const config = app.get(ConfigService); app.setGlobalPrefix('api'); diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 4cf7703..c435252 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -68,6 +68,7 @@ const settingsActive = computed(() => '/settings/custom-fields', '/settings/currencies', '/settings/tahesab', + '/settings/woocommerce', '/settings/faq', ]), ); diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 0c18ea6..ce40834 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -401,6 +401,15 @@ export const NAV_HUBS: NavHubDef[] = [ tint: 'amber', module: 'tahesab', }, + { + to: '/settings/woocommerce', + title: 'ووکامرس', + desc: 'سفارش فروشگاه به فاکتور و مشتری', + icon: 'box', + tint: 'mint', + module: 'woocommerce', + admin: true, + }, { to: '/settings/faq', title: 'FAQ', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index d3278f2..3882705 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -306,6 +306,12 @@ export const router = createRouter({ component: () => import('@/views/TahesabSettingsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/woocommerce', + name: 'woocommerce-settings', + component: () => import('@/views/WooCommerceSettingsView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/faq', name: 'faq-settings', diff --git a/apps/web/src/views/WooCommerceSettingsView.vue b/apps/web/src/views/WooCommerceSettingsView.vue new file mode 100644 index 0000000..bc1f0e4 --- /dev/null +++ b/apps/web/src/views/WooCommerceSettingsView.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e07d710..d170d9c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -121,7 +121,8 @@ export type OrgModuleKey = | "saved_views" | "public_api" | "sms_campaigns" - | "teams"; + | "teams" + | "woocommerce"; export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "customers", @@ -153,6 +154,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "public_api", "sms_campaigns", "teams", + "woocommerce", ]; export const ORG_MODULE_LABELS: Record = { @@ -185,6 +187,7 @@ export const ORG_MODULE_LABELS: Record = { public_api: "API و وب‌هوک", sms_campaigns: "پیامک گروهی", teams: "تیم‌ها و دیدپذیری", + woocommerce: "ووکامرس", }; export const ORG_MODULE_GROUPS: Array<{ @@ -224,6 +227,7 @@ export const ORG_MODULE_GROUPS: Array<{ "loyalty", "reports", "saved_views", + "woocommerce", ], }, { @@ -3143,3 +3147,59 @@ export interface UpdateTeamRequest { export interface SetTeamMembersRequest { members: Array<{ userId: string; role: TeamMemberRole }>; } + +/* ——— ووکامرس ——— */ + +export interface WooCommerceConnectionDto { + id: string; + label: string; + storeUrl: string; + consumerKeyMasked: string; + webhookSecretSet: boolean; + enabled: boolean; + lastSyncAt: string | null; + lastHealthAt: string | null; + lastHealthOk: boolean | null; + lastError: string | null; + importedOrders: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateWooCommerceConnectionRequest { + label: string; + storeUrl: string; + consumerKey: string; + consumerSecret: string; + webhookSecret?: string; + enabled?: boolean; +} + +export interface UpdateWooCommerceConnectionRequest { + label?: string; + storeUrl?: string; + consumerKey?: string; + consumerSecret?: string; + webhookSecret?: string | null; + enabled?: boolean; +} + +export interface WooCommerceSyncResultDto { + fetched: number; + created: number; + updated: number; + skipped: number; + errors: string[]; +} + +export interface WooCommerceOrderLinkDto { + id: string; + wooOrderId: string; + wooOrderNumber: string | null; + invoiceId: string; + invoiceNumber: string; + status: string; + total: number; + currency: string; + syncedAt: string; +}