Files
psp_api/src/modules/tspProviders/sales-invoice-tsp.service.ts
T
ahasani 23bfe1ecbe Refactor: Remove stored procedures and replace with direct database operations
feat: Add economic code and guild information to sales invoice selection

fix: Update error messages for invoice access and creation to use "صورت‌حساب"

fix: Change error messages in SaleInvoicesService to use "صورت‌حساب" instead of "فاکتور"

fix: Update error messages in SalesInvoicesService for not found cases to use "صورت‌حساب"

fix: Modify TSP service to handle invoice updates and error messages consistently with "صورت‌حساب"

fix: Update common DTO descriptions to refer to "صورت‌حساب" instead of "فاکتور"

fix: Adjust utility functions to handle invoice references and error messages with "صورت‌حساب"
2026-06-11 16:13:17 +03:30

466 lines
15 KiB
TypeScript

import { SharedSaleInvoiceCreateService } from '@/common/services/saleInvoices/sale-invoice-create.service'
import { PrismaService } from '@/prisma/prisma.service'
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import {
CustomerType,
TspProviderRequestType,
TspProviderResponseStatus,
} from 'generated/prisma/client'
import {
TspProviderActionResponseDto,
TspProviderCorrectionInvoicePayloadDto,
} from './dto'
import { SalesInvoiceTspSwitchService } from './sales-invoice-tsp-switch.service'
import {
buildCorrectionPayload,
buildOriginalPayload,
buildRevokePayload,
getOriginalResendAttemptNumber,
getRelatedInvoiceForCorrection,
onResult,
trySend,
} from './utils/sales-invoice-tsp.utils'
type ItemTspRow = {
tsp_provider: string | null
tax_id: string | null
}
@Injectable()
export class SalesInvoiceTspService {
constructor(
private readonly prisma: PrismaService,
private readonly tspSwitchService: SalesInvoiceTspSwitchService,
private sharedSaleInvoiceCreateService: SharedSaleInvoiceCreateService,
) {}
async originalSend(
posId: string,
invoice_id: string,
): Promise<TspProviderActionResponseDto> {
const payload = await buildOriginalPayload(this.prisma, invoice_id, posId)
const attemptNumber = await getOriginalResendAttemptNumber(this.prisma, invoice_id)
const invoice = await this.prisma.salesInvoice.update({
where: {
id: invoice_id,
},
data: {
last_tsp_status: TspProviderResponseStatus.QUEUED,
last_attempt_no: attemptNumber,
tsp_attempts: {
create: {
attempt_no: attemptNumber,
provider_request_payload: {},
status: TspProviderResponseStatus.QUEUED,
raw_request_payload: JSON.parse(JSON.stringify(payload)),
sent_at: new Date().toISOString(),
message: 'در حال ارسال به سامانه مالیاتی...',
},
},
},
})
const result = await this.tspSwitchService.send(payload)
return await onResult(this.prisma, result, invoice.id)
}
async sendBulk(consumer_id: string, invoice_ids: string[]): Promise<void> {
// if (!invoice_ids.length) return
// const payloads: TspProviderOriginalSendPayloadDto[] = []
// for (const invoiceId of invoice_ids) {
// const posId = await this.getPosId(consumer_id, invoiceId)
// payloads.push(await this.buildPayload(invoiceId, posId))
// }
// const bulkResult = await this.tspSwitchService.sendBulk(payloads)
// const allItemResults = bulkResult.flatMap(result => result.items)
// await this.persistAttemptResults(allItemResults)
}
async get(
invoice_id: string,
pos_id: string,
consumer_id: string,
): Promise<TspProviderActionResponseDto> {
const [invoice, pos] = await this.prisma.$transaction(async tx => [
await tx.salesInvoice.findUnique({
where: {
id: invoice_id,
pos: {
id: pos_id,
},
},
select: {
last_tsp_status: true,
id: true,
},
}),
await tx.pos.findUnique({
where: {
id: pos_id,
complex: {
business_activity: {
consumer_id,
},
},
},
select: {
complex: {
select: {
business_activity: {
select: {
partner_token: true,
consumer: {
select: {
legal: {
select: {
partner: {
select: {
tsp_provider: true,
},
},
},
},
individual: {
select: {
partner: {
select: {
tsp_provider: true,
},
},
},
},
},
},
},
},
},
},
},
}),
])
if (!invoice) {
throw new NotFoundException('صورت‌حساب ارسالی صورت‌حساب شما به سامانه یافت نشد.')
}
if (!pos) {
throw new NotFoundException('مشکلی در ساختار اطلاعات ورودی شما وجود دارد.')
}
if (
!invoice.last_tsp_status ||
invoice.last_tsp_status === TspProviderResponseStatus.NOT_SEND
) {
throw new BadRequestException(
'صورت‌حساب شما هنوز به سامانه مالیاتی ارسال نشده است. لطفا چند لحظه دیگر مجددا تلاش کنید.',
)
}
if (invoice.last_tsp_status === TspProviderResponseStatus.QUEUED) {
throw new BadRequestException(
'صورت‌حساب شما در صف ارسال به سامانه مالیاتی قرار دارد. لطفا چند لحظه دیگر مجددا تلاش کنید.',
)
}
const { business_activity } = pos.complex
const { partner } = (business_activity.consumer.individual ||
business_activity.consumer.legal)!
const result = await this.tspSwitchService.get(
partner.tsp_provider,
invoice_id,
business_activity.partner_token,
)
return await onResult(this.prisma, result, invoice.id)
}
async correctionSend(
consumerAccountId: string,
posId: string,
complexId: string,
businessId: string,
ref_invoice_id: string,
dataToUpdate: TspProviderCorrectionInvoicePayloadDto,
): Promise<TspProviderActionResponseDto> {
const [newInvoice, attempt, correctionPayload] = await this.prisma.$transaction(
async tx => {
const relatedInvoice = await getRelatedInvoiceForCorrection(tx, ref_invoice_id)
const newInvoice = await this.sharedSaleInvoiceCreateService.create({
tx,
data: {
customer_type: relatedInvoice.customer?.type || CustomerType.UNKNOWN,
invoice_date: dataToUpdate.invoice_date,
payments: dataToUpdate.payments,
items: dataToUpdate.items,
total_amount: dataToUpdate.total_amount,
discount_amount: dataToUpdate.discount_amount,
tax_amount: dataToUpdate.tax_amount,
customer_id: relatedInvoice.customer_id || undefined,
settlement_type: relatedInvoice.settlement_type,
},
businessId,
complexId,
posId,
consumerAccountId,
main_invoice_id: relatedInvoice.main_id || relatedInvoice.id,
ref_invoice_id: relatedInvoice.id,
type: TspProviderRequestType.CORRECTION,
})
const correctionPayload = await buildCorrectionPayload(tx, newInvoice.id)
const attempt = await tx.saleInvoiceTspAttempts.create({
data: {
attempt_no: 1,
invoice_id: newInvoice.id,
message: 'در حال ارسال به سامانه مالیاتی...',
status: TspProviderResponseStatus.QUEUED,
sent_at: new Date().toISOString(),
raw_request_payload: JSON.parse(JSON.stringify(correctionPayload)),
provider_request_payload: {},
},
})
return [newInvoice, attempt, correctionPayload]
},
)
const result = await this.runProviderCall(() =>
trySend(this.tspSwitchService, correctionPayload),
)
if (result?.hasError) {
result.provider_request_payload =
result.provider_request_payload || JSON.parse(JSON.stringify(correctionPayload))
result.sent_at = result.sent_at || new Date().toISOString()
result.received_at = result.received_at || new Date().toISOString()
}
return onResult(this.prisma, result, attempt.id)
// const countByGoodId = (goodIds: string[]): Map<string, number> => {
// const counts = new Map<string, number>()
// for (const goodId of goodIds) {
// counts.set(goodId, (counts.get(goodId) ?? 0) + 1)
// }
// return counts
// }
// const updatedGoodIds = dataToUpdate.items.map(item => item.good_id!)
// const previousGoodIds = lastAttempt.invoice.items.map(item => item.good_id)
// const updatedCounts = countByGoodId(updatedGoodIds)
// const previousCounts = countByGoodId(previousGoodIds)
// let isBackFromSale = false
// if (updatedCounts.size !== previousCounts.size) {
// isBackFromSale = true
// } else {
// for (const [goodId, count] of updatedCounts) {
// if (previousCounts.get(goodId) !== count) {
// isBackFromSale = true
// break
// }
// }
// }
}
async revoke(
consumerAccountId: string,
posId: string,
complexId: string,
businessId: string,
ref_invoice_id: string,
): Promise<TspProviderActionResponseDto> {
const [newInvoice, attempt, revokePayload] = await this.prisma.$transaction(
async tx => {
const relatedInvoice = await tx.salesInvoice.findUnique({
where: {
id: ref_invoice_id,
},
include: {
customer: {
select: {
type: true,
},
},
tsp_attempts: {
orderBy: {
attempt_no: 'desc',
},
take: 1,
},
payments: {
include: {
terminal_info: true,
},
},
items: true,
},
})
if (!relatedInvoice) {
throw new NotFoundException('صورت‌حساب مورد نظر یافت نشد.')
}
if (relatedInvoice.type === TspProviderRequestType.REVOKE) {
throw new BadRequestException(
'صورت‌حساب ارسالی قبلا ابطال شده است و امکان ویرایش آن وجود ندارد.',
)
}
if (!relatedInvoice.tax_id) {
throw new BadRequestException(
'صورت‌حساب قبلی همچنان در حال بررسی است و امکان ویرایش آن وجود ندارد.',
)
}
const payments = relatedInvoice.payments.reduce(
(acc, payment) => {
const amount = Number(payment.amount)
switch (payment.payment_method) {
case 'CASH':
acc.cash = (acc.cash || 0) + amount
break
case 'SET_OFF':
acc.set_off = (acc.set_off || 0) + amount
break
case 'CARD':
acc.card = (acc.card || 0) + amount
break
case 'BANK':
acc.bank = (acc.bank || 0) + amount
break
case 'CHEQUE':
acc.check = (acc.check || 0) + amount
break
case 'OTHER':
acc.other = (acc.other || 0) + amount
break
case 'TERMINAL':
acc.terminals = payment.terminal_info
? {
amount,
terminalId: payment.terminal_info.terminal_id,
stan: payment.terminal_info.stan,
rrn: payment.terminal_info.rrn,
customer_card_no:
payment.terminal_info.customer_card_no || undefined,
transaction_date_time: payment.terminal_info.transaction_date_time,
description: payment.terminal_info.description || undefined,
}
: acc.terminals
break
default:
break
}
return acc
},
{} as {
terminals?: {
amount?: number
terminalId: string
stan: string
rrn: string
customer_card_no?: string
transaction_date_time: Date
description?: string
}
cash?: number
set_off?: number
card?: number
bank?: number
check?: number
other?: number
},
)
const newInvoice = await this.sharedSaleInvoiceCreateService.create({
data: {
customer_type: relatedInvoice.customer?.type || CustomerType.UNKNOWN,
invoice_date: new Date(),
payments,
settlement_type: relatedInvoice.settlement_type,
items: relatedInvoice.items.map(item => ({
unit_price: Number(item.unit_price),
quantity: Number(item.quantity),
total_amount: Number(item.total_amount),
discount_amount: Number(item.discount_amount || 0),
tax_amount: Number(item.tax_amount || 0),
invoice_id: '',
good_id: item.good_id,
service_id: item.service_id || undefined,
payload: item.payload as any,
notes: item.notes || '',
})),
total_amount: Number(relatedInvoice.total_amount),
discount_amount: Number(relatedInvoice.discount_amount),
tax_amount: Number(relatedInvoice.tax_amount),
customer_id: relatedInvoice.customer_id || undefined,
},
businessId,
complexId,
posId,
consumerAccountId,
main_invoice_id: relatedInvoice.main_id || relatedInvoice.id,
ref_invoice_id: relatedInvoice.id,
type: TspProviderRequestType.REVOKE,
})
const payload = await buildRevokePayload(this.prisma, newInvoice.id, posId)
const attempt = await tx.saleInvoiceTspAttempts.create({
data: {
attempt_no: 1,
invoice_id: newInvoice.id,
message: 'در حال ارسال به سامانه مالیاتی...',
status: TspProviderResponseStatus.QUEUED,
raw_request_payload: JSON.parse(JSON.stringify(payload)),
provider_request_payload: {},
},
})
return [newInvoice, attempt, payload]
},
)
const result = await this.runProviderCall(() =>
this.tspSwitchService.revoke(revokePayload),
)
if (result?.hasError) {
result.provider_request_payload =
result.provider_request_payload || JSON.parse(JSON.stringify(revokePayload))
result.sent_at = result.sent_at || new Date().toISOString()
result.received_at = result.received_at || new Date().toISOString()
}
return await onResult(this.prisma, result, attempt.id)
}
private mapProviderError(error: any) {
return {
hasError: true,
status: error.status || TspProviderResponseStatus.FAILURE,
message:
error.message?.toString() ||
'خطا در ارتباط با سامانه مالیاتی. لطفا مجددا تلاش کنید.',
provider_response_payload: error.provider_response_payload || {
error: error?.message || 'fetch failed',
cause: error?.cause || null,
},
sent_at: error.sent_at || new Date().toISOString(),
received_at: error.received_at || new Date().toISOString(),
}
}
private async runProviderCall(fn: () => Promise<any>) {
try {
return await fn()
} catch (error: any) {
return this.mapProviderError(error)
}
}
}