feat: implement tax switch functionality with Nama adapter

- Add DTOs for tax switch operations including payloads and results.
- Create SalesInvoiceFiscalSwitchService to handle sending and retrieving tax data.
- Implement SalesInvoiceFiscalService for managing invoice tax submissions and results persistence.
- Develop NamaTaxSwitchAdapter for interfacing with the external tax service.
- Introduce NamaTaxRequestDto and related classes for structured tax requests.
This commit is contained in:
2026-04-30 16:27:46 +03:30
parent 58a7c359d8
commit a68a7f594d
38 changed files with 2678 additions and 287 deletions
@@ -3,8 +3,9 @@ import { PrismaService } from '@/prisma/prisma.service'
import { BadRequestException, Injectable } from '@nestjs/common'
import { ResponseMapper } from 'common/response/response-mapper'
import type { CustomerIndividual, CustomerLegal } from 'generated/prisma/client'
import { Prisma } from 'generated/prisma/client'
import { CustomerType, PaymentMethodType } from 'generated/prisma/enums'
import { SalesInvoiceTaxService } from '../../consumer/saleInvoices/tax/sales-invoice-tax.service'
import { SalesInvoiceFiscalService } from '../../consumer/saleInvoices/fiscal/sales-invoice-fiscal.service'
import { CreateSalesInvoiceDto } from './dto/create-sales-invoice.dto'
// Define type guard for CustomerIndividual
@@ -25,11 +26,28 @@ function isCustomerLegal(customer: any): customer is CustomerLegal {
)
}
interface TerminalPaymentInfo {
terminalId: string
stan: string
rrn: string
transactionDateTime: string | Date
customerCardNO?: string
description?: string
amount?: number
}
interface NormalizedPayment {
method: PaymentMethodType
amount: number
}
@Injectable()
export class SalesInvoicesService {
private readonly createInvoiceRetries = 3
constructor(
private prisma: PrismaService,
private salesInvoiceTaxService: SalesInvoiceTaxService,
private salesInvoiceTaxService: SalesInvoiceFiscalService,
) {}
findAll() {
@@ -65,202 +83,404 @@ export class SalesInvoicesService {
// }
async create(data: CreateSalesInvoiceDto, posInfo: IPosPayload) {
data.invoice_date = new Date(data.invoice_date).toISOString() as any
const { business_id, pos_id, consumer_account_id } = posInfo
const normalizedInvoiceDate = this.normalizeInvoiceDate(data.invoice_date)
const { payments, terminalInfo } = this.buildPaymentsData(
data.payments,
data.total_amount,
)
const salesInvoice = await this.prisma.$transaction(async $tx => {
const payments = Object.entries(data.payments)
.filter(([_, value]) => value > 0 && PaymentMethodType[_.toUpperCase()])
.map(([key, value]) => {
return {
amount: value,
payment_method: key.toLocaleUpperCase() as PaymentMethodType,
paid_at: data.invoice_date,
}
})
const totalPayments = payments.reduce((sum, payment) => sum + payment.amount, 0)
if (totalPayments !== data.total_amount) {
throw new Error('مبلغ پرداختی باید برابر با مبلغ کل فاکتور باشد.')
}
const { customer_id, customer_type, customer, ...invoiceData } = data
let newCustomerId: string | null = customer_id || null
if (customer_id) {
} else if (
customer_type === CustomerType.INDIVIDUAL &&
customer?.customer_individual
) {
const customer_individual = customer.customer_individual
const customerIndividual = await $tx.customerIndividual.upsert({
where: {
business_activity_id_national_id: {
business_activity_id: business_id,
national_id: customer_individual.national_id,
},
},
create: {
...customer_individual,
customer: {
create: {
type: CustomerType.INDIVIDUAL,
},
},
business_activity: {
connect: {
id: business_id,
},
},
},
update: {},
select: {
customer_id: true,
},
})
if (!customerIndividual) {
throw new BadRequestException('متاسفانه مشکلی در اطلاعات مشتری وجود دارد.')
}
newCustomerId = customerIndividual.customer_id
} else if (data.customer_type === CustomerType.LEGAL && customer?.customer_legal) {
const customer_legal = customer.customer_legal
const customerLegal = await $tx.customerLegal.upsert({
where: {
business_activity_id_economic_code: {
business_activity_id: business_id,
economic_code: customer_legal.economic_code,
},
},
create: {
...customer_legal,
customer: {
create: {
type: CustomerType.LEGAL,
},
},
business_activity: {
connect: {
id: business_id,
},
},
},
update: {},
select: {
customer_id: true,
},
})
if (!customerLegal) {
throw new BadRequestException('متاسفانه مشکلی در اطلاعات مشتری وجود دارد.')
}
newCustomerId = customerLegal.customer_id
} else if (data.customer_type === CustomerType.UNKNOWN) {
}
const itemGoodIds = data.items
.map(item => item.good_id)
.filter((goodId): goodId is string => Boolean(goodId))
const goods = itemGoodIds.length
? await $tx.good.findMany({
where: {
id: {
in: itemGoodIds,
},
},
select: {
id: true,
name: true,
sku: true,
local_sku: true,
barcode: true,
pricing_model: true,
unit_type: true,
base_sale_price: true,
image_url: true,
category: {
select: {
id: true,
name: true,
},
},
},
for (let attempt = 1; attempt <= this.createInvoiceRetries; attempt++) {
try {
const salesInvoice = await this.prisma.$transaction(async $tx => {
const invoiceNumber = await this.getNextInvoiceNumber($tx, business_id)
const newCustomerId = await this.resolveCustomerId($tx, data, business_id)
const goodsById = await this.getGoodsById($tx, data)
const salesInvoiceData = this.buildSalesInvoiceData({
data,
normalizedInvoiceDate,
invoiceNumber,
consumer_account_id,
pos_id,
goodsById,
customerId: newCustomerId,
})
: []
const goodsById = new Map(goods.map(good => [good.id, good]))
const salesInvoice = await $tx.salesInvoice.create({
data: salesInvoiceData,
})
const salesInvoiceData: any = {
...invoiceData,
total_amount: data.total_amount,
code: 'INV-' + Date.now(),
await this.createPayments(
$tx,
salesInvoice.id,
payments,
terminalInfo,
normalizedInvoiceDate,
)
items: {
createMany: {
data: data.items.map(item => ({
good_id: item.good_id,
quantity: item.quantity,
unit_price: item.unit_price,
total_amount: item.total_amount,
payload: item.payload
? JSON.parse(JSON.stringify(item.payload))
: undefined,
good_snapshot: item.good_id
? JSON.parse(
JSON.stringify({
good: goodsById.get(item.good_id) || null,
}),
)
: undefined,
unit_type: item.unit_type,
// pricing_model: item.pricingModel,
})),
},
},
unknown_customer: {},
payments: {
createMany: {
data: payments,
},
},
// customer: {
// connect: {
// id: newCustomerId || undefined,
// },
// },
consumer_account: {
connect: {
id: consumer_account_id,
},
},
pos: {
connect: {
id: pos_id,
},
},
}
return salesInvoice
})
if (newCustomerId) {
salesInvoiceData.customer = {
connect: {
id: newCustomerId || undefined,
},
if (data.send_to_tax) {
await this.salesInvoiceTaxService.send(salesInvoice.id, pos_id)
}
return ResponseMapper.create(salesInvoice)
} catch (error) {
if (
this.isRetryableInvoiceConflict(error) &&
attempt < this.createInvoiceRetries
) {
continue
}
throw error
}
const salesInvoice = await $tx.salesInvoice.create({
data: salesInvoiceData,
})
return salesInvoice
})
if (data.send_to_tax) {
await this.salesInvoiceTaxService.send(salesInvoice.id, pos_id)
}
return ResponseMapper.create(salesInvoice)
throw new BadRequestException('ایجاد فاکتور با خطا مواجه شد.')
}
private isRetryableInvoiceConflict(error: unknown) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
return false
}
if (error.code !== 'P2002') {
return false
}
const target = (error.meta?.target as string[]) || []
return (
target.includes('invoice_number') ||
target.includes('sales_invoices_invoice_number_pos_id_key')
)
}
private normalizeInvoiceDate(invoiceDate: Date | string) {
return new Date(invoiceDate).toISOString() as any
}
private buildPaymentsData(
paymentsData: CreateSalesInvoiceDto['payments'],
totalAmount: number,
) {
const paymentMethodMap: Record<string, PaymentMethodType> = {
cash: PaymentMethodType.CASH,
set_off: PaymentMethodType.SET_OFF,
card: PaymentMethodType.CARD,
bank: PaymentMethodType.BANK,
check: PaymentMethodType.CHECK,
other: PaymentMethodType.OTHER,
terminal: PaymentMethodType.TERMINAL,
}
const rawPayments = (paymentsData || {}) as Record<string, unknown>
const terminalInfo = rawPayments.terminals as TerminalPaymentInfo | undefined
const payments: NormalizedPayment[] = Object.entries(rawPayments)
.filter(([key]) => key !== 'terminals')
.map(([key, value]) => ({
method: paymentMethodMap[key.toLowerCase()],
amount: typeof value === 'number' ? value : Number(value || 0),
}))
.filter(payment => payment.method && payment.amount > 0) as NormalizedPayment[]
const hasTerminalPayment = payments.some(
payment => payment.method === PaymentMethodType.TERMINAL,
)
const nonTerminalTotal = payments
.filter(payment => payment.method !== PaymentMethodType.TERMINAL)
.reduce((sum, payment) => sum + payment.amount, 0)
if (!hasTerminalPayment && terminalInfo) {
const terminalAmount =
typeof terminalInfo.amount === 'number'
? terminalInfo.amount
: Math.max(0, Number(totalAmount) - nonTerminalTotal)
if (terminalAmount > 0) {
payments.push({
method: PaymentMethodType.TERMINAL,
amount: terminalAmount,
})
}
}
this.validatePayments(payments, totalAmount, terminalInfo)
return {
payments,
terminalInfo,
}
}
private validatePayments(
payments: NormalizedPayment[],
totalAmount: number,
terminalInfo?: TerminalPaymentInfo,
) {
const totalPayments = payments.reduce((sum, payment) => sum + payment.amount, 0)
const roundedTotalPayments = Number(totalPayments.toFixed(2))
const roundedTotalAmount = Number(Number(totalAmount).toFixed(2))
if (roundedTotalPayments !== roundedTotalAmount) {
throw new BadRequestException('مبلغ پرداختی باید برابر با مبلغ کل فاکتور باشد.')
}
const terminalPayments = payments.filter(
payment => payment.method === PaymentMethodType.TERMINAL,
)
if (terminalPayments.length > 0 && !terminalInfo) {
throw new BadRequestException('برای پرداخت ترمینال اطلاعات ترمینال الزامی است.')
}
}
private async resolveCustomerId(
tx: Prisma.TransactionClient,
data: CreateSalesInvoiceDto,
businessId: string,
) {
if (data.customer_id) {
return data.customer_id
}
if (
data.customer_type === CustomerType.INDIVIDUAL &&
data.customer?.customer_individual
) {
const { national_id, mobile_number, ...rest } = data.customer.customer_individual
const customerIndividual = await tx.customerIndividual.upsert({
where: {
business_activity_id_national_id: {
business_activity_id: businessId,
national_id: data.customer.customer_individual.national_id,
},
},
create: {
...data.customer.customer_individual,
customer: {
create: {
type: CustomerType.INDIVIDUAL,
},
},
business_activity: {
connect: {
id: businessId,
},
},
},
update: { ...rest },
select: {
customer_id: true,
},
})
if (!customerIndividual) {
throw new BadRequestException('متاسفانه مشکلی در اطلاعات مشتری وجود دارد.')
}
return customerIndividual.customer_id
}
if (data.customer_type === CustomerType.LEGAL && data.customer?.customer_legal) {
const { registration_number, ...rest } = data.customer.customer_legal
const customerLegal = await tx.customerLegal.upsert({
where: {
business_activity_id_economic_code: {
business_activity_id: businessId,
economic_code: data.customer.customer_legal.economic_code,
},
},
create: {
...data.customer.customer_legal,
customer: {
create: {
type: CustomerType.LEGAL,
},
},
business_activity: {
connect: {
id: businessId,
},
},
},
update: {
...rest,
},
select: {
customer_id: true,
},
})
if (!customerLegal) {
throw new BadRequestException('متاسفانه مشکلی در اطلاعات مشتری وجود دارد.')
}
return customerLegal.customer_id
}
return null
}
private async getGoodsById(tx: Prisma.TransactionClient, data: CreateSalesInvoiceDto) {
const itemGoodIds = data.items
.map(item => item.good_id)
.filter((goodId): goodId is string => Boolean(goodId))
const goods = itemGoodIds.length
? await tx.good.findMany({
where: {
id: {
in: itemGoodIds,
},
},
select: {
id: true,
name: true,
sku: true,
local_sku: true,
barcode: true,
pricing_model: true,
unit_type: true,
base_sale_price: true,
image_url: true,
category: {
select: {
id: true,
name: true,
},
},
},
})
: []
return new Map(goods.map(good => [good.id, good]))
}
private buildSalesInvoiceData(params: {
data: CreateSalesInvoiceDto
normalizedInvoiceDate: Date
invoiceNumber: number
consumer_account_id: string
pos_id: string
goodsById: Map<string, any>
customerId: string | null
}) {
const {
data,
normalizedInvoiceDate,
invoiceNumber,
consumer_account_id,
pos_id,
goodsById,
customerId,
} = params
const { customer_id, customer_type, customer, payments, ...invoiceData } = data
const salesInvoiceData: any = {
...invoiceData,
invoice_date: normalizedInvoiceDate,
invoice_number: invoiceNumber,
total_amount: data.total_amount,
code: 'INV-' + Date.now(),
items: {
createMany: {
data: data.items.map(item => ({
good_id: item.good_id,
quantity: item.quantity,
unit_price: item.unit_price,
total_amount: item.total_amount,
payload: item.payload ? JSON.parse(JSON.stringify(item.payload)) : undefined,
good_snapshot: item.good_id
? JSON.parse(
JSON.stringify({
good: goodsById.get(item.good_id) || null,
}),
)
: undefined,
unit_type: item.unit_type,
})),
},
},
unknown_customer: {},
consumer_account: {
connect: {
id: consumer_account_id,
},
},
pos: {
connect: {
id: pos_id,
},
},
}
if (customerId) {
salesInvoiceData.customer = {
connect: {
id: customerId,
},
}
}
return salesInvoiceData
}
private async getNextInvoiceNumber(tx: Prisma.TransactionClient, businessId: string) {
const latestInvoice = await tx.salesInvoice.findFirst({
where: {
pos: {
complex: {
business_activity_id: businessId,
},
},
},
orderBy: {
invoice_number: 'desc',
},
select: {
invoice_number: true,
},
})
return (latestInvoice?.invoice_number || 0) + 1
}
private async createPayments(
tx: Prisma.TransactionClient,
invoiceId: string,
payments: NormalizedPayment[],
terminalInfo: TerminalPaymentInfo | undefined,
paidAt: Date,
) {
for (const payment of payments) {
if (payment.amount <= 0) {
continue
}
const createdPayment = await tx.salesInvoicePayment.create({
data: {
invoice_id: invoiceId,
amount: payment.amount,
payment_method: payment.method,
paid_at: paidAt,
},
})
if (payment.method === PaymentMethodType.TERMINAL && terminalInfo) {
await tx.salesInvoicePaymentTerminalInfo.create({
data: {
payment_id: createdPayment.id,
terminal_id: terminalInfo.terminalId,
stan: terminalInfo.stan,
rrn: terminalInfo.rrn,
transaction_date_time: new Date(terminalInfo.transactionDateTime),
customer_card_no: terminalInfo.customerCardNO || null,
description: terminalInfo.description || null,
},
})
}
}
}
}