Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI.
This commit is contained in:
@@ -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 {}
|
||||
Reference in New Issue
Block a user