feat: implement correction and original send functionality for Nama provider

- Added new DTOs for correction requests and responses in `nama-provider.dto.ts`.
- Updated `nama-provider.adapter.ts` to include `originalSend` and `correctionSend` methods.
- Enhanced `nama-provider.util.ts` with mapping functions for correction requests.
- Created operational guidelines for agents in `AGENT.md`.
- Updated Prisma migrations to support new invoice types and relationships.
- Introduced new service and DTO for creating sales invoices in `sale-invoice-create.service.ts` and `sale-invoice-create.dto.ts`.
- Added utility for handling Prisma errors in `prisma-error.util.ts`.
This commit is contained in:
2026-05-05 22:42:09 +03:30
parent 4af07fe3e8
commit 658496320b
43 changed files with 2808 additions and 1354 deletions
+2 -2
View File
@@ -143,8 +143,8 @@ export default {
[TspProviderResponseStatus.QUEUED]: 'در صف ارسال',
},
TspProviderRequestType: {
MAIN: 'اصلی',
UPDATE: 'اصلاح',
ORIGINAL: 'اصلی',
CORRECTION: 'اصلاح',
REVOKE: 'ابطال',
REMOVE: 'حذف',
},
+2 -2
View File
@@ -5,8 +5,8 @@ export enum GoldKarat {
}
export enum TspProviderRequestType {
MAIN = 'MAIN',
UPDATE = 'UPDATE',
ORIGINAL = 'ORIGINAL',
CORRECTION = 'CORRECTION',
REVOKE = 'REVOKE',
REMOVE = 'REMOVE',
}
+1
View File
@@ -22,6 +22,7 @@ export const infoSelect: ConsumerSelect = {
first_name: true,
last_name: true,
mobile_number: true,
national_code: true,
partner: {
select: {
id: true,
@@ -0,0 +1,209 @@
import type { SaleInvoiceType } from '@/common/interfaces/sale-invoice-payload'
import { ApiProperty } from '@nestjs/swagger'
import { Type } from 'class-transformer'
import {
ArrayMinSize,
IsBoolean,
IsDate,
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator'
import type { CustomerIndividual, CustomerLegal } from 'generated/prisma/client'
import { CustomerType } from 'generated/prisma/enums'
export class SharedCreateSalesInvoiceItemDto {
@IsNumber()
@ApiProperty({ required: true })
unit_price: number
@IsNumber()
@ApiProperty({ required: true, default: 1 })
quantity: number
@IsNumber()
@ApiProperty({ required: true })
total_amount: number
@IsNumber()
@ApiProperty({ required: true })
discount_amount: number
@IsString()
@ApiProperty({ required: true })
invoice_id: string
@IsOptional()
@IsString()
@ApiProperty({ required: false })
good_id?: string
@IsOptional()
@IsString()
@ApiProperty({ required: false })
service_id?: string
// @IsEnum(SalesInvoiceItemPricingModel)
// @ApiProperty({ enum: Object.values(SalesInvoiceItemPricingModel) })
// @IsOptional()
// pricingModel: SalesInvoiceItemPricingModel
@IsOptional()
@IsObject()
@ApiProperty({ required: false, default: {} })
payload: SaleInvoiceType
@IsOptional()
@IsString()
@ApiProperty({ required: false, default: '' })
notes: string
}
export class SharedCreateTerminalPayment {
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
amount?: number
@IsString()
@IsNotEmpty()
@ApiProperty({ required: true })
terminalId: string
@IsString()
@IsNotEmpty()
@ApiProperty({ required: true })
stan: string
@IsString()
@IsNotEmpty()
@ApiProperty({ required: true })
rrn: string
@IsString()
@IsOptional()
@ApiProperty({ required: true })
response_code?: string
@IsString()
@IsOptional()
@ApiProperty({ required: true })
customer_card_no?: string
@Type(() => Date)
@IsDate()
@ApiProperty({ required: true })
transaction_date_time: Date
@IsOptional()
@IsString()
@ApiProperty({ required: false })
description?: string
}
export class SharedCreateSalesInvoicePaymentsDto {
@IsOptional()
@ValidateNested()
@Type(() => SharedCreateTerminalPayment)
@ApiProperty({ required: false, type: () => SharedCreateTerminalPayment })
terminals?: SharedCreateTerminalPayment
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
cash?: number
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
set_off?: number
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
card?: number
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
bank?: number
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
check?: number
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({ required: false, default: 0 })
other?: number
}
export class SharedCreateSalesInvoiceDto {
// @TODO: totalAmount must calculated instead of get from api
@IsNumber()
@ApiProperty({ required: true, default: 0 })
total_amount: number
@ApiProperty({ required: true })
@IsDateString(
{ strict: true },
{ message: 'invoice_date must be a valid ISO-8601 string' },
)
invoice_date: Date
@ApiProperty({ required: true })
@IsObject()
@ValidateNested({ each: true })
@Type(() => SharedCreateSalesInvoicePaymentsDto)
payments: SharedCreateSalesInvoicePaymentsDto
@ApiProperty()
@ArrayMinSize(1)
@ValidateNested({ each: true })
items: SharedCreateSalesInvoiceItemDto[]
@ApiProperty({ required: false })
@IsOptional()
@IsString()
notes?: string
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
send_to_tsp?: boolean
@ApiProperty({ required: true, default: CustomerType.UNKNOWN })
@IsEnum(CustomerType)
customer_type: CustomerType
@IsOptional()
@IsString()
@ApiProperty({ required: false })
customer_id?: string
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
customer?: {
customer_individual?: Omit<CustomerIndividual, 'business_activity_id' | 'customer_id'>
customer_legal?: Omit<CustomerLegal, 'business_activity_id' | 'customer_id'>
customer_unknown?: {
first_name: string
last_name: string
}
}
}
@@ -0,0 +1,529 @@
import { SalesInvoiceCreateInput } from '@/generated/prisma/models'
import { PrismaService } from '@/prisma/prisma.service'
import { BadRequestException, Injectable } from '@nestjs/common'
import { Prisma } from 'generated/prisma/client'
import {
CustomerType,
PaymentMethodType,
TspProviderRequestType,
} from 'generated/prisma/enums'
import { SharedCreateSalesInvoiceDto } from './sale-invoice-create.dto'
interface TerminalPaymentInfo {
terminalId: string
stan: string
rrn: string
transactionDateTime: string | Date
customerCardNO?: string
description?: string
amount?: number
}
interface NormalizedPayment {
method: PaymentMethodType
amount: number
}
interface CreateSharedSaleInvoiceInput {
tx?: Prisma.TransactionClient
data: SharedCreateSalesInvoiceDto
businessId: string
complexId: string
posId: string
consumerAccountId: string
type: TspProviderRequestType
main_invoice_id?: string
ref_invoice_id?: string
}
@Injectable()
export class SharedSaleInvoiceCreateService {
private readonly createInvoiceRetries = 3
constructor(private prisma: PrismaService) {}
async create(input: CreateSharedSaleInvoiceInput) {
const {
tx = this.prisma,
data,
businessId,
complexId,
posId,
consumerAccountId,
type,
main_invoice_id,
ref_invoice_id,
} = input
const normalizedInvoiceDate = this.normalizeInvoiceDate(data.invoice_date)
const { payments, terminalInfo } = this.buildPaymentsData(
data.payments,
data.total_amount,
)
for (let attempt = 1; attempt <= this.createInvoiceRetries; attempt++) {
try {
return await tx.$transaction(async $tx => {
const invoiceNumber = await this.getNextInvoiceNumber($tx, businessId)
const customerId = await this.resolveCustomerId($tx, data, businessId)
const goodsById = await this.getGoodsById($tx, data)
const salesInvoiceData = this.buildSalesInvoiceData({
data,
normalizedInvoiceDate,
invoiceNumber,
consumerAccountId,
businessId,
complexId,
posId,
goodsById,
customerId,
type,
main_invoice_id,
ref_invoice_id,
})
const salesInvoice = await $tx.salesInvoice.create({
data: salesInvoiceData,
})
await this.createPayments(
$tx,
salesInvoice.id,
payments,
terminalInfo,
normalizedInvoiceDate,
)
return salesInvoice
})
} catch (error) {
if (
this.isRetryableInvoiceConflict(error) &&
attempt < this.createInvoiceRetries
) {
continue
}
throw error
}
}
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: SharedCreateSalesInvoiceDto['payments'],
totalAmount: number,
) {
const paymentMethodMap: Record<string, PaymentMethodType> = {
cash: PaymentMethodType.CASH,
set_off: PaymentMethodType.SET_OFF,
card: PaymentMethodType.CARD,
payment_gateway: PaymentMethodType.PAYMENT_GATEWAY,
bank: PaymentMethodType.BANK,
check: PaymentMethodType.CHEQUE,
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: SharedCreateSalesInvoiceDto,
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: SharedCreateSalesInvoiceDto,
) {
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: {
select: {
id: true,
name: true,
code: true,
VAT: true,
},
},
local_sku: true,
barcode: true,
pricing_model: true,
measure_unit: {
select: {
id: true,
name: true,
code: 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: SharedCreateSalesInvoiceDto
normalizedInvoiceDate: Date
invoiceNumber: number
consumerAccountId: string
businessId: string
complexId: string
posId: string
goodsById: Map<string, any>
customerId: string | null
type: TspProviderRequestType
main_invoice_id?: string
ref_invoice_id?: string
}) {
const {
data,
normalizedInvoiceDate,
invoiceNumber,
consumerAccountId,
posId,
goodsById,
customerId,
businessId,
complexId,
type,
main_invoice_id,
ref_invoice_id,
} = params
const { customer_id, customer_type, customer, payments, ...invoiceData } = data
if (
type !== TspProviderRequestType.ORIGINAL &&
!(main_invoice_id || ref_invoice_id)
) {
throw new BadRequestException('متاسفانه مشکلی در اطلاعات فاکتور وجود دارد.')
}
const salesInvoiceData: SalesInvoiceCreateInput = {
...invoiceData,
invoice_date: normalizedInvoiceDate,
invoice_number: invoiceNumber,
total_amount: data.total_amount,
code: this.generateInvoiceCode(businessId, complexId, posId, invoiceNumber),
type,
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,
measure_unit_text: goodsById.get(item.good_id!)?.measure_unit.name || null,
measure_unit_code: goodsById.get(item.good_id!)?.measure_unit.code || null,
sku_code: goodsById.get(item.good_id!)?.sku.code || null,
sku_vat: goodsById.get(item.good_id!)?.sku.VAT || null,
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,
})),
},
},
unknown_customer: {},
consumer_account: {
connect: {
id: consumerAccountId,
},
},
pos: {
connect: {
id: posId,
},
},
}
if (customerId) {
salesInvoiceData.customer = {
connect: {
id: customerId,
},
}
}
if (type !== TspProviderRequestType.ORIGINAL) {
salesInvoiceData.main_id = main_invoice_id
salesInvoiceData.reference_invoice = {
connect: {
id: ref_invoice_id,
},
}
}
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,
pos: {
select: {
complex: {
select: {
business_activity: {
select: {
invoice_number_sequence: true,
},
},
},
},
},
},
},
})
const latestSequence =
latestInvoice?.pos?.complex?.business_activity?.invoice_number_sequence.toNumber() ||
0
return Math.max(latestInvoice?.invoice_number || 0, latestSequence) + 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,
},
})
}
}
}
private generateInvoiceCode(
businessId: string,
complexId: string,
posId: string,
invoiceNumber: number,
) {
return `${businessId.substring(businessId.length - 2, businessId.length)}-${complexId.substring(complexId.length - 2, complexId.length)}-${posId.substring(posId.length - 2, posId.length)}-${invoiceNumber.toString()}`
}
}
@@ -15,7 +15,7 @@ export default (consumer: any) => {
status: translateEnumValue('ConsumerStatus', consumer.status),
legal: legal ? { ...legal } : null,
individual: individual
? { ...individual, fullname: `${rest?.first_name} ${rest?.last_name}` }
? { ...individual, fullname: `${individual?.first_name} ${individual?.last_name}` }
: null,
name: legal ? legal.name : `${individual?.first_name} ${individual?.last_name}`,
business_counts,
+54
View File
@@ -0,0 +1,54 @@
import { BadRequestException, ConflictException } from '@nestjs/common'
import { Prisma } from 'generated/prisma/client'
type UniqueMessageMap = Record<string, string>
export class PrismaErrorUtil {
static isKnownError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
return error instanceof Prisma.PrismaClientKnownRequestError
}
static isCode(error: unknown, code: string): boolean {
return this.isKnownError(error) && error.code === code
}
static hasTarget(error: unknown, targetKey: string): boolean {
if (!this.isKnownError(error)) {
return false
}
const target = error.meta?.target as string[] | string | undefined
if (Array.isArray(target)) {
return target.includes(targetKey)
}
if (typeof target === 'string') {
return target.includes(targetKey)
}
return false
}
static throwIfKnown(error: unknown, uniqueMap: UniqueMessageMap = {}) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
throw error
}
if (error.code === 'P2002') {
const target = (error.meta?.target as string[]) || []
for (const key of Object.keys(uniqueMap)) {
if (target.includes(key)) {
throw new BadRequestException(uniqueMap[key])
}
}
throw new ConflictException('رکورد تکراری است.')
}
if (error.code === 'P2003') {
throw new BadRequestException('ارتباط داده‌ای نامعتبر است.')
}
if (error.code === 'P2025') {
throw new BadRequestException('رکورد مورد نظر یافت نشد.')
}
throw error
}
}
@@ -1,4 +1,5 @@
import { randomBytes } from 'crypto'
import { PrismaErrorUtil } from './prisma-error.util'
export function generateTrackingCode(prefix: string, suffixLength: number) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -13,23 +14,8 @@ export function generateTrackingCode(prefix: string, suffixLength: number) {
}
export function isTrackingCodeUniqueViolation(error: unknown) {
const prismaError = error as {
code?: string
meta?: { target?: string[] | string }
}
if (prismaError?.code !== 'P2002') {
if (!PrismaErrorUtil.isCode(error, 'P2002')) {
return false
}
const target = prismaError.meta?.target
if (Array.isArray(target)) {
return target.includes('tracking_code')
}
if (typeof target === 'string') {
return target.includes('tracking_code')
}
return true
return PrismaErrorUtil.hasTarget(error, 'tracking_code')
}