Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI.

This commit is contained in:
2026-09-26 17:59:16 +03:30
parent 1b160c8cd3
commit 26085b54a9
15 changed files with 1571 additions and 2 deletions
@@ -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;
+51
View File
@@ -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])
}
+2
View File
@@ -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 {}
@@ -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<WooCommerceConnectionDto[]> {
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<WooCommerceConnectionDto> {
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<WooCommerceConnectionDto> {
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<void> {
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<string | null> {
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(),
};
}
}
@@ -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;
}
@@ -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<WooCommerceSyncResultDto> {
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<WooCommerceOrderLinkDto[]> {
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<void> {
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<string | null> {
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;
}
}
@@ -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<void> {
await this.request('GET', '/system_status?_fields=environment.home_url');
}
async listOrders(opts: {
after?: string;
page?: number;
perPage?: number;
modifiedAfter?: string;
} = {}): Promise<WooOrder[]> {
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<WooOrder[]>('GET', `/orders?${params.toString()}`);
}
async getOrder(id: number | string): Promise<WooOrder> {
return this.request<WooOrder>('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<T>(method: string, path: string): Promise<T> {
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 };
@@ -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 };
}
}
@@ -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 {}
+1 -1
View File
@@ -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');
+1
View File
@@ -68,6 +68,7 @@ const settingsActive = computed(() =>
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
'/settings/woocommerce',
'/settings/faq',
]),
);
+9
View File
@@ -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',
+6
View File
@@ -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',
@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateWooCommerceConnectionRequest,
WooCommerceConnectionDto,
WooCommerceOrderLinkDto,
WooCommerceSyncResultDto,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const syncing = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<WooCommerceConnectionDto[]>([]);
const orders = ref<WooCommerceOrderLinkDto[]>([]);
const lastSync = ref<WooCommerceSyncResultDto | null>(null);
const form = reactive({
label: 'فروشگاه اصلی',
storeUrl: '',
consumerKey: '',
consumerSecret: '',
webhookSecret: '',
enabled: true,
});
const webhookUrl = computed(() => {
if (!items.value[0]) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/api/public/integrations/woocommerce/${items.value[0].id}/webhook`;
});
async function load() {
loading.value = true;
error.value = '';
try {
const [connRes, orderRes] = await Promise.all([
api.get<WooCommerceConnectionDto[]>('/integrations/woocommerce/connections'),
api.get<WooCommerceOrderLinkDto[]>('/integrations/woocommerce/orders'),
]);
items.value = connRes.data;
orders.value = orderRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول ووکامرس برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function create() {
if (!form.storeUrl.trim() || !form.consumerKey.trim() || !form.consumerSecret.trim()) {
error.value = 'آدرس فروشگاه و کلیدهای API را کامل کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
const payload: CreateWooCommerceConnectionRequest = {
label: form.label.trim() || 'فروشگاه',
storeUrl: form.storeUrl.trim(),
consumerKey: form.consumerKey.trim(),
consumerSecret: form.consumerSecret.trim(),
webhookSecret: form.webhookSecret.trim() || undefined,
enabled: form.enabled,
};
await api.post('/integrations/woocommerce/connections', payload);
form.consumerKey = '';
form.consumerSecret = '';
form.webhookSecret = '';
ok.value = 'اتصال ذخیره شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
saving.value = false;
}
}
async function test(id: string) {
try {
await api.post(`/integrations/woocommerce/connections/${id}/test`);
ok.value = 'اتصال به فروشگاه موفق بود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تست ناموفق بود.')
: 'تست ناموفق بود.';
}
}
async function sync(id: string) {
syncing.value = true;
error.value = '';
try {
const { data } = await api.post<WooCommerceSyncResultDto>(
`/integrations/woocommerce/connections/${id}/sync`,
);
lastSync.value = data;
ok.value = `همگام‌سازی: ${toFaDigits(data.created)} جدید، ${toFaDigits(data.updated)} به‌روز.`;
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'همگام‌سازی ناموفق بود.')
: 'همگام‌سازی ناموفق بود.';
} finally {
syncing.value = false;
}
}
async function remove(id: string) {
if (!confirm('این اتصال حذف شود؟ لینک سفارش‌های قبلی می‌ماند ولی همگام‌سازی قطع می‌شود.')) {
return;
}
try {
await api.delete(`/integrations/woocommerce/connections/${id}`);
await load();
} catch {
error.value = 'حذف ناموفق بود.';
}
}
async function copyWebhook() {
if (!webhookUrl.value) return;
try {
await navigator.clipboard.writeText(webhookUrl.value);
ok.value = 'آدرس وب‌هوک کپی شد.';
} catch {
error.value = 'کپی ممکن نشد.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="ووکامرس"
description="همگام‌سازی سفارش‌های فروشگاه با فاکتور و مشتری در CRM"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="اتصال جدید">
<p class="hint">
در ووکامرس: ووکامرس ← تنظیمات ← پیشرفته ← REST API یک کلید بسازید.
برای وب‌هوک: ووکامرس ← تنظیمات ← پیشرفته ← وب‌هوک‌ها، موضوع «Order created/updated».
</p>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="form.label" placeholder="فروشگاه اصلی" />
</UiField>
<UiField label="آدرس فروشگاه">
<UiInput v-model="form.storeUrl" placeholder="https://shop.example.com" />
</UiField>
<UiField label="Consumer Key">
<UiInput v-model="form.consumerKey" placeholder="ck_…" />
</UiField>
<UiField label="Consumer Secret">
<UiInput v-model="form.consumerSecret" placeholder="cs_…" />
</UiField>
<UiField label="Webhook Secret (اختیاری)">
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
</UiField>
</div>
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
</UiCard>
<UiCard title="اتصال‌های ذخیره‌شده">
<UiEmptyState
v-if="!loading && !items.length"
title="اتصالی نیست"
description="کلید REST API فروشگاه را وارد کنید."
/>
<div v-else class="list">
<article v-for="c in items" :key="c.id" class="item">
<div>
<strong>{{ c.label }}</strong>
<p class="muted">{{ c.storeUrl }}</p>
<p class="muted">
کلید {{ c.consumerKeyMasked }}
· سفارش‌های واردشده {{ toFaDigits(c.importedOrders) }}
·
{{
c.lastSyncAt
? `آخرین همگام‌سازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
: 'هنوز همگام نشده'
}}
</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<div class="actions">
<UiButton variant="secondary" @click="test(c.id)">تست</UiButton>
<UiButton :loading="syncing" @click="sync(c.id)">همگام‌سازی</UiButton>
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
</div>
</article>
</div>
<div v-if="webhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک این اتصال:</p>
<code>{{ webhookUrl }}</code>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
</div>
</UiCard>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
description="همگام‌سازی را بزنید یا وب‌هوک را وصل کنید."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>سفارش ووکامرس</th>
<th>فاکتور</th>
<th>وضعیت</th>
<th>مبلغ</th>
<th>زمان</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>#{{ o.wooOrderNumber || o.wooOrderId }}</td>
<td>{{ o.invoiceNumber }}</td>
<td>{{ o.status }}</td>
<td>{{ toFaDigits(o.total) }} {{ o.currency }}</td>
<td>{{ formatJalaliNumeric(new Date(o.syncedAt)) }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: start;
}
.webhook {
margin-top: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: var(--radius);
display: grid;
gap: 0.5rem;
}
.webhook code {
word-break: break-all;
font-size: 0.85rem;
}
.warn {
color: #b45309;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.form-grid,
.item {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>
+61 -1
View File
@@ -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<OrgModuleKey, string> = {
@@ -185,6 +187,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
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;
}