From 826041b07a6dde51a948c23d2b8d82cc2470191c Mon Sep 17 00:00:00 2001 From: ahasani194 Date: Wed, 17 Jun 2026 08:50:35 +0330 Subject: [PATCH] update return from sale invoices jurney and set nama return from sale --- src/common/queryConstants/saleInvoice.ts | 6 + .../sale-invoice-actions.service.ts | 485 +++++++++++++++++- .../saleInvoices/sale-invoice-create.dto.ts | 15 +- .../sale-invoice-create.service.ts | 41 +- .../dto/create-sales-invoice.dto.ts | 45 ++ .../sales-invoices.controller.ts | 11 +- .../sales-invoices/sales-invoices.service.ts | 8 +- .../tspProviders/dto/correction.dto.ts | 3 +- src/modules/tspProviders/dto/index.ts | 1 + src/modules/tspProviders/dto/return.dto.ts | 48 ++ .../sales-invoice-tsp-switch.service.ts | 10 + .../tspProviders/sales-invoice-tsp.service.ts | 109 ++-- .../tspProviders/switch/nama/dto/index.ts | 1 + .../switch/nama/dto/returnFromSale.dto.ts | 60 +++ .../switch/nama/nama-provider.adapter.ts | 53 ++ .../switch/nama/nama-provider.util.ts | 53 ++ .../utils/sales-invoice-tsp.utils.ts | 216 +++++++- 17 files changed, 1085 insertions(+), 80 deletions(-) create mode 100644 src/modules/tspProviders/dto/return.dto.ts create mode 100644 src/modules/tspProviders/switch/nama/dto/returnFromSale.dto.ts diff --git a/src/common/queryConstants/saleInvoice.ts b/src/common/queryConstants/saleInvoice.ts index 06b8ec1..4f4b6b5 100644 --- a/src/common/queryConstants/saleInvoice.ts +++ b/src/common/queryConstants/saleInvoice.ts @@ -44,6 +44,12 @@ export const summarySelect: SalesInvoiceSelect = { invoice_number: true, }, }, + referenced_by: { + select: { + id: true, + invoice_number: true, + }, + }, } export const select: SalesInvoiceSelect = { diff --git a/src/common/services/saleInvoices/sale-invoice-actions.service.ts b/src/common/services/saleInvoices/sale-invoice-actions.service.ts index 9dc2891..aa41b74 100644 --- a/src/common/services/saleInvoices/sale-invoice-actions.service.ts +++ b/src/common/services/saleInvoices/sale-invoice-actions.service.ts @@ -1,13 +1,25 @@ import { SharedSaleInvoiceAccessService } from '@/common/services/saleInvoices/sale-invoice-access.service' -import { PosCorrectionSalesInvoiceDto } from '@/modules/pos/sales-invoices/dto/create-sales-invoice.dto' +import { + PosCorrectionSalesInvoiceDto, + PosReturnSalesInvoiceDto, +} from '@/modules/pos/sales-invoices/dto/create-sales-invoice.dto' import { SalesInvoiceTspService } from '@/modules/tspProviders/sales-invoice-tsp.service' -import { Injectable } from '@nestjs/common' +import { PrismaService } from '@/prisma/prisma.service' +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' +import { + CustomerType, + TspProviderRequestType, + TspProviderResponseStatus, +} from '@prisma/client' +import { SharedSaleInvoiceCreateService } from './sale-invoice-create.service' @Injectable() export class SharedSaleInvoiceActionsService { constructor( + private prisma: PrismaService, private readonly salesInvoiceTspService: SalesInvoiceTspService, private readonly saleInvoiceAccessService: SharedSaleInvoiceAccessService, + private sharedSaleInvoiceCreateService: SharedSaleInvoiceCreateService, ) {} async send(consumerAccountId: string, posId: string, invoiceId: string) { @@ -58,14 +70,464 @@ export class SharedSaleInvoiceActionsService { consumerAccountId, posId, ) - return this.salesInvoiceTspService.correctionSend( + + const newInvoice = await this.prisma.$transaction(async tx => { + const relatedInvoice = await tx.salesInvoice.findUnique({ + where: { + id: invoiceId, + pos_id: posId, + }, + select: { + id: true, + invoice_date: true, + settlement_type: true, + customer_id: true, + main_id: true, + last_tsp_status: true, + referenced_by: { + select: { + id: true, + }, + }, + customer: { + select: { + type: true, + }, + }, + total_amount: true, + items: { + select: { + id: true, + good_id: true, + service_id: true, + quantity: true, + unit_price: true, + total_amount: true, + discount_amount: true, + tax_amount: true, + payload: true, + notes: true, + }, + }, + }, + }) + + if (!relatedInvoice) { + throw new NotFoundException('صورت‌حساب مورد نظر شما یافت نشد.') + } + + if (relatedInvoice.last_tsp_status !== TspProviderResponseStatus.SUCCESS) { + throw new BadRequestException('امکان اصلاح این صورت‌حساب وجود ندارد.') + } + + if (relatedInvoice.referenced_by) { + throw new BadRequestException( + 'این صورت‌حساب قبلا با یک صورت‌حساب دیگر جایگزین شده است.', + ) + } + + const normalizedInvoiceDate = this.normalizeInvoiceDate(data.invoice_date) + const originalInvoiceDate = this.normalizeInvoiceDate(relatedInvoice.invoice_date) + + const originalItemsByKey = new Map( + relatedInvoice.items.map(item => [this.getItemKey(item), item]), + ) + + const usedItemKeys = new Set() + const normalizedItems = data.items.map(item => { + const itemKey = this.getItemKey(item) + + if (!itemKey) { + throw new BadRequestException('هر قلم باید شناسه کالا یا خدمت داشته باشد.') + } + + usedItemKeys.add(itemKey) + + const originalItem = originalItemsByKey.get(itemKey) + + if (!originalItem) { + throw new BadRequestException( + 'فقط اقلام موجود در صورت‌حساب اصلی قابل اصلاح هستند.', + ) + } + + const originalQuantity = Number(originalItem.quantity) + const requestedQuantity = Number(item.quantity) + const originalUnitPrice = Number(originalItem.unit_price) + const requestedUnitPrice = Number(item.unit_price) + const originalTotalAmount = this.roundAmount(Number(originalItem.total_amount)) + const requestedTotalAmount = this.roundAmount(Number(item.total_amount)) + + if (requestedQuantity <= 0) { + throw new BadRequestException( + 'تعداد/مقدار اقلام اصلاحی باید بیشتر از صفر باشد.', + ) + } + + // if (requestedUnitPrice < originalUnitPrice) { + // throw new BadRequestException( + // 'مبلغ واحد اقلام اصلاحی نمی‌تواند کمتر از صورت‌حساب اصلی باشد.', + // ) + // } + + return { + invoice_id: originalItem.id, + good_id: originalItem.good_id, + service_id: originalItem.service_id || undefined, + quantity: requestedQuantity, + unit_price: requestedUnitPrice, + total_amount: requestedTotalAmount, + discount_amount: this.roundAmount(Number(item.discount_amount || 0)), + tax_amount: this.roundAmount(Number(item.tax_amount || 0)), + payload: item.payload ? JSON.parse(JSON.stringify(item.payload)) : undefined, + notes: item.notes || undefined, + } + }) + + if (usedItemKeys.size !== relatedInvoice.items.length) { + throw new BadRequestException( + 'فقط اقلام موجود در صورت‌حساب اصلی قابل اصلاح هستند.', + ) + } + + const hasChangedQuantity = normalizedItems.some(item => { + const originalItem = relatedInvoice.items.find( + relatedItem => this.getItemKey(relatedItem) === this.getItemKey(item), + ) + + if (!originalItem) { + return false + } + + return Number(originalItem.quantity) !== Number(item.quantity) + }) + const hasChangedAmount = normalizedItems.some(item => { + const originalItem = relatedInvoice.items.find( + relatedItem => this.getItemKey(relatedItem) === this.getItemKey(item), + ) + + if (!originalItem) { + return false + } + + return ( + this.roundAmount(Number(originalItem.total_amount)) !== + this.roundAmount(Number(item.total_amount)) + ) + }) + const hasChangedInvoiceDate = normalizedInvoiceDate !== originalInvoiceDate + + if (!hasChangedQuantity && !hasChangedAmount && !hasChangedInvoiceDate) { + throw new BadRequestException('مقداری تغییر نکرده است.') + } + + const requestedTotalAmount = this.roundAmount(Number(data.total_amount)) + const originalTotalAmount = this.roundAmount(Number(relatedInvoice.total_amount)) + const totalDiff = this.roundAmount(requestedTotalAmount - originalTotalAmount) + const paymentsAmount = this.roundAmount(this.getPaymentsAmount(data.payments)) + + if (totalDiff > 0 && paymentsAmount !== totalDiff) { + throw new BadRequestException( + 'جمع پرداختی باید برابر با اختلاف مبلغ صورت‌حساب اصلاحی و صورت‌حساب مرجع باشد.', + ) + } + + if (totalDiff === 0 && paymentsAmount !== 0) { + throw new BadRequestException('در صورت عدم افزایش مبلغ، پرداختی نباید ثبت شود.') + } + + const newInvoice = await this.sharedSaleInvoiceCreateService.create({ + tx, + data: { + invoice_date: new Date(normalizedInvoiceDate), + items: normalizedItems as any, + total_amount: requestedTotalAmount, + discount_amount: this.roundAmount(Number(data.discount_amount)), + tax_amount: this.roundAmount(Number(data.tax_amount)), + customer_id: relatedInvoice.customer_id || undefined, + customer_type: relatedInvoice.customer?.type || CustomerType.UNKNOWN, + settlement_type: relatedInvoice.settlement_type, + payments: data.payments, + } as any, + businessId, + complexId, + posId, + consumerAccountId, + main_invoice_id: relatedInvoice.main_id || relatedInvoice.id, + ref_invoice_id: relatedInvoice.id, + type: TspProviderRequestType.CORRECTION, + }) + + await tx.salesInvoice.update({ + where: { + id: relatedInvoice.id, + }, + data: { + referenced_by: { + connect: { + id: newInvoice.id, + }, + }, + }, + }) + + return newInvoice + }) + + return this.salesInvoiceTspService.correctionSend(posId, newInvoice.id) + } + + async return( + data: PosReturnSalesInvoiceDto, + consumerAccountId: string, + pos_id: string, + complex_id: string, + business_id: string, + invoiceId: string, + ) { + await this.saleInvoiceAccessService.getConsumerIdWithPosAccess( consumerAccountId, - posId, - complexId, - businessId, - invoiceId, - data, + pos_id, ) + + const newInvoice = await this.prisma.$transaction(async tx => { + const relatedInvoice = await tx.salesInvoice.findUnique({ + where: { + id: invoiceId, + pos_id, + }, + select: { + id: true, + type: true, + invoice_date: true, + settlement_type: true, + customer_id: true, + main_id: true, + last_tsp_status: true, + referenced_by: { + select: { + id: true, + }, + }, + customer: { + select: { + type: true, + }, + }, + items: { + select: { + id: true, + good_id: true, + service_id: true, + quantity: true, + unit_price: true, + total_amount: true, + discount_amount: true, + tax_amount: true, + payload: true, + notes: true, + }, + }, + }, + }) + + if (!relatedInvoice) { + throw new NotFoundException('صورت‌حساب مورد نظر شما یافت نشد.') + } + + if (relatedInvoice.last_tsp_status !== TspProviderResponseStatus.SUCCESS) { + throw new BadRequestException('امکان برگشت از خرید روی این صورت‌حساب وجود ندارد.') + } + + if (relatedInvoice.referenced_by) { + throw new BadRequestException( + 'این صورت‌حساب قبلا با یک صورت‌حساب دیگر جایگزین شده است.', + ) + } + + const normalizedInvoiceDate = this.normalizeInvoiceDate(data.invoice_date) + const originalInvoiceDate = this.normalizeInvoiceDate(relatedInvoice.invoice_date) + + const originalItemsByKey = new Map( + relatedInvoice.items.map(item => [this.getReturnItemKey(item), item]), + ) + + const usedItemKeys = new Set() + const normalizedItems = data.items.map(item => { + const itemKey = this.getReturnItemKey(item) + + if (!itemKey) { + throw new BadRequestException('هر قلم باید شناسه کالا یا خدمت داشته باشد.') + } + + // if (usedItemKeys.has(itemKey)) { + // throw new BadRequestException('اقلام تکراری در صورت‌حساب بازگشتی مجاز نیستند.') + // } + + usedItemKeys.add(itemKey) + + const originalItem = originalItemsByKey.get(itemKey) + + if (!originalItem) { + throw new BadRequestException( + 'فقط اقلام موجود در صورت‌حساب اصلی قابل بازگشت هستند.', + ) + } + + const originalQuantity = Number(originalItem.quantity) + const requestedQuantity = Number(item.quantity) + + if (requestedQuantity <= 0) { + throw new BadRequestException( + 'تعداد/مقدار اقلام بازگشتی باید بیشتر از صفر باشد.', + ) + } + + if (requestedQuantity > originalQuantity) { + throw new BadRequestException( + 'تعداد/مقدار اقلام بازگشتی نمی‌تواند از مقدار صورت‌حساب اصلی بیشتر باشد.', + ) + } + + const quantityRatio = requestedQuantity / originalQuantity + + return { + invoice_id: originalItem.id, + good_id: originalItem.good_id, + service_id: originalItem.service_id || undefined, + quantity: requestedQuantity, + unit_price: Number(originalItem.unit_price), + total_amount: this.roundAmount( + Number(originalItem.total_amount) * quantityRatio, + ), + discount_amount: this.roundAmount( + Number(originalItem.discount_amount || 0) * quantityRatio, + ), + tax_amount: this.roundAmount( + Number(originalItem.tax_amount || 0) * quantityRatio, + ), + payload: originalItem.payload + ? JSON.parse(JSON.stringify(originalItem.payload)) + : undefined, + notes: originalItem.notes || undefined, + } + }) + + if (!normalizedItems.length) { + throw new BadRequestException( + 'حداقل یک قلم باید در صورت‌حساب بازگشتی باقی بماند.', + ) + } + + const hasRemovedItem = relatedInvoice.items.some( + item => !usedItemKeys.has(this.getReturnItemKey(item)), + ) + const hasChangedQuantity = normalizedItems.some(item => { + const originalItem = relatedInvoice.items.find( + relatedItem => + this.getReturnItemKey(relatedItem) === this.getReturnItemKey(item), + ) + + if (!originalItem) { + return false + } + + return Number(originalItem.quantity) !== Number(item.quantity) + }) + const hasChangedInvoiceDate = normalizedInvoiceDate !== originalInvoiceDate + + if (!hasRemovedItem && !hasChangedQuantity && !hasChangedInvoiceDate) { + throw new BadRequestException('مقداری تغییر نکرده است.') + } + + const totalAmount = normalizedItems.reduce( + (sum, item) => sum + Number(item.total_amount), + 0, + ) + const discountAmount = normalizedItems.reduce( + (sum, item) => sum + Number(item.discount_amount || 0), + 0, + ) + const taxAmount = normalizedItems.reduce( + (sum, item) => sum + Number(item.tax_amount || 0), + 0, + ) + + const newInvoice = await this.sharedSaleInvoiceCreateService.create({ + tx, + data: { + invoice_date: new Date(normalizedInvoiceDate), + items: normalizedItems as any, + total_amount: totalAmount, + discount_amount: discountAmount, + tax_amount: taxAmount, + customer_id: relatedInvoice.customer_id || undefined, + customer_type: relatedInvoice.customer?.type || CustomerType.UNKNOWN, + settlement_type: relatedInvoice.settlement_type, + payments: undefined, + } as any, + businessId: business_id, + complexId: complex_id, + posId: pos_id, + consumerAccountId, + main_invoice_id: relatedInvoice.main_id || relatedInvoice.id, + ref_invoice_id: relatedInvoice.id, + type: TspProviderRequestType.RETURN, + }) + + await tx.salesInvoice.update({ + where: { + id: relatedInvoice.id, + }, + data: { + referenced_by: { + connect: { + id: newInvoice.id, + }, + }, + }, + }) + + return newInvoice + }) + + return this.salesInvoiceTspService.returnFromSaleSend( + pos_id, + business_id, + newInvoice.id, + ) + } + + private normalizeInvoiceDate(invoiceDate: Date | string) { + return new Date(invoiceDate).toISOString() + } + + private getItemKey(item: { good_id?: string | null; service_id?: string | null }) { + return item.good_id || item.service_id || '' + } + + private getPaymentsAmount(payments?: { + terminals?: { amount?: number } + cash?: number + set_off?: number + card?: number + bank?: number + check?: number + other?: number + }) { + return ( + Number(payments?.terminals?.amount || 0) + + Number(payments?.cash || 0) + + Number(payments?.set_off || 0) + + Number(payments?.card || 0) + + Number(payments?.bank || 0) + + Number(payments?.check || 0) + + Number(payments?.other || 0) + ) + } + + private roundAmount(amount: number) { + return Number(amount.toFixed(2)) } async inquiry(consumerAccountId: string, posId: string, invoiceId: string) { @@ -75,4 +537,11 @@ export class SharedSaleInvoiceActionsService { ) return this.salesInvoiceTspService.get(invoiceId, posId, consumerId) } + + private getReturnItemKey(item: { + good_id?: string | null + service_id?: string | null + }) { + return item.good_id || item.service_id || '' + } } diff --git a/src/common/services/saleInvoices/sale-invoice-create.dto.ts b/src/common/services/saleInvoices/sale-invoice-create.dto.ts index 23fae7d..f1ebb78 100644 --- a/src/common/services/saleInvoices/sale-invoice-create.dto.ts +++ b/src/common/services/saleInvoices/sale-invoice-create.dto.ts @@ -196,6 +196,11 @@ export class SharedCreateSalesInvoiceDto { @ValidateNested({ each: true }) items: SharedCreateSalesInvoiceItemDto[] + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + ref_invoice_id?: string + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -230,5 +235,13 @@ export class SharedCreateSalesInvoiceDto { export class SharedCorrectionSalesInvoiceDto extends OmitType( SharedCreateSalesInvoiceDto, - ['customer', 'customer_id', 'send_to_tsp', 'customer_type', 'settlement_type'], + ['customer', 'customer_id', 'customer_type', 'settlement_type'], ) {} + +export class SharedReturnSalesInvoiceDto extends OmitType(SharedCreateSalesInvoiceDto, [ + 'customer', + 'customer_id', + 'customer_type', + 'settlement_type', + 'payments', +]) {} diff --git a/src/common/services/saleInvoices/sale-invoice-create.service.ts b/src/common/services/saleInvoices/sale-invoice-create.service.ts index 64f17ab..3011ba1 100644 --- a/src/common/services/saleInvoices/sale-invoice-create.service.ts +++ b/src/common/services/saleInvoices/sale-invoice-create.service.ts @@ -58,7 +58,10 @@ export class SharedSaleInvoiceCreateService { } = input const normalizedInvoiceDate = this.normalizeInvoiceDate(data.invoice_date) - const payments = this.buildPaymentsData(data.payments, data.total_amount) + const payments = + type === TspProviderRequestType.ORIGINAL || data.payments + ? this.buildPaymentsData(data.payments, data.total_amount) + : [] for (let attempt = 1; attempt <= this.createInvoiceRetries; attempt++) { try { @@ -86,7 +89,14 @@ export class SharedSaleInvoiceCreateService { select: { ...QUERY_CONSTANTS.SALE_INVOICE.select }, }) - await this.createPayments($tx, salesInvoice.id, payments, normalizedInvoiceDate) + if (payments.length) { + await this.createPayments( + $tx, + salesInvoice.id, + payments, + normalizedInvoiceDate, + ) + } return salesInvoice }) @@ -468,7 +478,7 @@ export class SharedSaleInvoiceCreateService { return salesInvoiceData } - private async getNextInvoiceNumber(tx: Prisma.TransactionClient, businessId: string) { + async getNextInvoiceNumber(tx: Prisma.TransactionClient, businessId: string) { const latestInvoice = await tx.salesInvoice.findFirst({ where: { pos: { @@ -525,17 +535,26 @@ export class SharedSaleInvoiceCreateService { }) if (payment.method === PaymentMethodType.TERMINAL && payment.terminalInfo) { + const { + terminal_id, + stan, + rrn, + transaction_date_time, + customer_card_no, + description, + } = payment.terminalInfo await tx.salesInvoicePaymentTerminalInfo.create({ data: { payment_id: createdPayment.id, - terminal_id: payment.terminalInfo.terminal_id, - stan: payment.terminalInfo.stan, - rrn: payment.terminalInfo.rrn, - transaction_date_time: new Date( - payment.terminalInfo.transaction_date_time || '', - ), - customer_card_no: payment.terminalInfo.customer_card_no || null, - description: payment.terminalInfo.description || null, + terminal_id, + stan: stan, + rrn: rrn, + transaction_date_time: transaction_date_time + ? new Date(transaction_date_time) + : new Date(), + customer_card_no: '1234567890123456', + // customer_card_no: customer_card_no || null, + description: description || null, }, }) } diff --git a/src/modules/pos/sales-invoices/dto/create-sales-invoice.dto.ts b/src/modules/pos/sales-invoices/dto/create-sales-invoice.dto.ts index ca826a2..2f205d2 100644 --- a/src/modules/pos/sales-invoices/dto/create-sales-invoice.dto.ts +++ b/src/modules/pos/sales-invoices/dto/create-sales-invoice.dto.ts @@ -2,6 +2,51 @@ import { SharedCorrectionSalesInvoiceDto, SharedCreateSalesInvoiceDto, } from '@/common/services/saleInvoices/sale-invoice-create.dto' +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator' export class PosCreateSalesInvoiceDto extends SharedCreateSalesInvoiceDto {} export class PosCorrectionSalesInvoiceDto extends SharedCorrectionSalesInvoiceDto {} + +export class PosReturnSalesInvoiceItemDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + good_id?: string + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + service_id?: string + + @ApiProperty({ required: true, default: 1 }) + @IsNumber() + @Min(1) + quantity: number +} + +export class PosReturnSalesInvoiceDto { + @ApiProperty({ required: true }) + @IsDateString( + { strict: true }, + { message: 'invoice_date must be a valid ISO-8601 string' }, + ) + invoice_date: Date + + @ApiProperty({ required: true, type: [PosReturnSalesInvoiceItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PosReturnSalesInvoiceItemDto) + items: PosReturnSalesInvoiceItemDto[] +} diff --git a/src/modules/pos/sales-invoices/sales-invoices.controller.ts b/src/modules/pos/sales-invoices/sales-invoices.controller.ts index 20ddba3..c2d498b 100644 --- a/src/modules/pos/sales-invoices/sales-invoices.controller.ts +++ b/src/modules/pos/sales-invoices/sales-invoices.controller.ts @@ -5,6 +5,7 @@ import type { IPosPayload } from '@/common/models/posPayload.model' import { PosCorrectionSalesInvoiceDto, PosCreateSalesInvoiceDto, + PosReturnSalesInvoiceDto, } from './dto/create-sales-invoice.dto' import { SalesInvoicesFilterDto } from './dto/sales-invoices-filter.dto' import { SalesInvoicesService } from './sales-invoices.service' @@ -60,9 +61,13 @@ export class SalesInvoicesController { ) { return this.salesInvoicesService.correction(id, posInfo, data) } - @Post(':id/return') - return(@Param('id') id: string, @PosInfo() posInfo: IPosPayload) { - return this.salesInvoicesService.return(id, posInfo) + @Post(':id/return_from_sale') + return( + @Param('id') id: string, + @PosInfo() posInfo: IPosPayload, + @Body() data: PosReturnSalesInvoiceDto, + ) { + return this.salesInvoicesService.return(id, posInfo, data) } // @Post('send/bulk') diff --git a/src/modules/pos/sales-invoices/sales-invoices.service.ts b/src/modules/pos/sales-invoices/sales-invoices.service.ts index bedb7a7..015d7e9 100644 --- a/src/modules/pos/sales-invoices/sales-invoices.service.ts +++ b/src/modules/pos/sales-invoices/sales-invoices.service.ts @@ -13,6 +13,7 @@ import { SalesInvoiceTspService } from '../../tspProviders/sales-invoice-tsp.ser import { PosCorrectionSalesInvoiceDto, PosCreateSalesInvoiceDto, + PosReturnSalesInvoiceDto, } from './dto/create-sales-invoice.dto' import { SalesInvoicesFilterDto } from './dto/sales-invoices-filter.dto' @@ -172,10 +173,11 @@ export class SalesInvoicesService { return ResponseMapper.single(invoice) } - async return(invoiceId: string, posInfo: IPosPayload) { + async return(invoiceId: string, posInfo: IPosPayload, data: PosReturnSalesInvoiceDto) { const { consumer_account_id, pos_id, business_id, complex_id } = posInfo - const invoice = await this.sharedSaleInvoiceActionsService.revoke( + const invoice = await this.sharedSaleInvoiceActionsService.return( + data, consumer_account_id, pos_id, complex_id, @@ -183,7 +185,7 @@ export class SalesInvoicesService { invoiceId, ) - return ResponseMapper.single(invoice) + return ResponseMapper.create(invoice) } async inquiry(consumer_account_id: string, pos_id: string, invoiceId: string) { diff --git a/src/modules/tspProviders/dto/correction.dto.ts b/src/modules/tspProviders/dto/correction.dto.ts index 38e8185..d87a983 100644 --- a/src/modules/tspProviders/dto/correction.dto.ts +++ b/src/modules/tspProviders/dto/correction.dto.ts @@ -46,8 +46,9 @@ export class TspProviderCorrectionInvoicePayloadDto { @IsObject() @ValidateNested({ each: true }) @Type(() => SharedCreateSalesInvoicePaymentsDto) - payments: SharedCreateSalesInvoicePaymentsDto + payments?: SharedCreateSalesInvoicePaymentsDto } + export class TspProviderCorrectionSendPayloadDto extends TspProviderOriginalSendPayloadDto { @ApiProperty({ required: true }) @IsString() diff --git a/src/modules/tspProviders/dto/index.ts b/src/modules/tspProviders/dto/index.ts index 6894dba..cd15d81 100644 --- a/src/modules/tspProviders/dto/index.ts +++ b/src/modules/tspProviders/dto/index.ts @@ -3,4 +3,5 @@ export * from './correction.dto' export * from './get.dto' export * from './original.dto' export * from './provider-switch.dto' +export * from './return.dto' export * from './revoke.dto' diff --git a/src/modules/tspProviders/dto/return.dto.ts b/src/modules/tspProviders/dto/return.dto.ts new file mode 100644 index 0000000..35292f9 --- /dev/null +++ b/src/modules/tspProviders/dto/return.dto.ts @@ -0,0 +1,48 @@ +import { SharedCreateSalesInvoiceItemDto } from '@/common/services/saleInvoices/sale-invoice-create.dto' +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsString, + ValidateNested, +} from 'class-validator' +import { + TspProviderOriginalResponseDto, + TspProviderOriginalSendPayloadDto, +} from './original.dto' + +export class TspProviderReturnInvoicePayloadDto { + @ApiProperty({ required: true, nullable: true }) + @IsDateString() + invoice_date: Date + + @ApiProperty({ type: [SharedCreateSalesInvoiceItemDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SharedCreateSalesInvoiceItemDto) + @ArrayMinSize(1) + items: SharedCreateSalesInvoiceItemDto[] + + @ApiProperty() + @IsNumber() + total_amount: number + + @ApiProperty() + @IsNumber() + discount_amount: number + + @ApiProperty() + @IsNumber() + tax_amount: number +} + +export class TspProviderReturnSendPayloadDto extends TspProviderOriginalSendPayloadDto { + @ApiProperty({ required: true }) + @IsString() + last_tax_id: string +} + +export class TspProviderReturnSendResponseDto extends TspProviderOriginalResponseDto {} diff --git a/src/modules/tspProviders/sales-invoice-tsp-switch.service.ts b/src/modules/tspProviders/sales-invoice-tsp-switch.service.ts index de92d74..d407364 100644 --- a/src/modules/tspProviders/sales-invoice-tsp-switch.service.ts +++ b/src/modules/tspProviders/sales-invoice-tsp-switch.service.ts @@ -6,6 +6,8 @@ import { TspProviderGetResponseDto, TspProviderOriginalResponseDto, TspProviderOriginalSendPayloadDto, + TspProviderReturnSendPayloadDto, + TspProviderReturnSendResponseDto, TspProviderRevokePayloadDto, TspProviderRevokeResponseDto, } from './dto' @@ -48,6 +50,14 @@ export class SalesInvoiceTspSwitchService { return adapter.correctionSend(payload) } + async returnFromSale( + payload: TspProviderReturnSendPayloadDto, + ): Promise { + const adapter = this.resolveSwitch(payload.tsp_provider) + + return adapter.returnSend(payload) + } + async sendBulk( payloads: TspProviderOriginalSendPayloadDto[], ): Promise { diff --git a/src/modules/tspProviders/sales-invoice-tsp.service.ts b/src/modules/tspProviders/sales-invoice-tsp.service.ts index 77a3940..ac4f06a 100644 --- a/src/modules/tspProviders/sales-invoice-tsp.service.ts +++ b/src/modules/tspProviders/sales-invoice-tsp.service.ts @@ -6,17 +6,14 @@ import { TspProviderRequestType, TspProviderResponseStatus, } from 'generated/prisma/client' -import { - TspProviderActionResponseDto, - TspProviderCorrectionInvoicePayloadDto, -} from './dto' +import { TspProviderActionResponseDto } from './dto' import { SalesInvoiceTspSwitchService } from './sales-invoice-tsp-switch.service' import { buildCorrectionPayload, buildOriginalPayload, + buildReturnFromSalePayload, buildRevokePayload, getOriginalResendAttemptNumber, - getRelatedInvoiceForCorrection, onResult, } from './utils/sales-invoice-tsp.utils' @@ -176,60 +173,68 @@ export class SalesInvoiceTspService { } async correctionSend( - consumerAccountId: string, posId: string, - complexId: string, - businessId: string, - ref_invoice_id: string, - dataToUpdate: TspProviderCorrectionInvoicePayloadDto, + invoice_id: string, ): Promise { - const [newInvoice, attempt, correctionPayload] = await this.prisma.$transaction( - async tx => { - const relatedInvoice = await getRelatedInvoiceForCorrection(tx, ref_invoice_id) + const payload = await buildCorrectionPayload(this.prisma, invoice_id, posId) - 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 attemptNumber = 1 - 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 invoice = await this.prisma.salesInvoice.update({ + where: { + id: invoice_id, }, - ) + data: { + last_tsp_status: TspProviderResponseStatus.QUEUED, + last_attempt_no: attemptNumber, + type: TspProviderRequestType.CORRECTION, + 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.correction(correctionPayload) + const result = await this.tspSwitchService.send(payload) + return await onResult(this.prisma, result, invoice.id) + } - return await onResult(this.prisma, result, newInvoice.id) + async returnFromSaleSend( + pos_id: string, + business_id: string, + invoice_id: string, + ): Promise { + const payload = await buildReturnFromSalePayload(this.prisma, invoice_id, pos_id) + + const attemptNumber = 1 + + 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.returnFromSale(payload) + return await onResult(this.prisma, result, invoice.id) } async revoke( diff --git a/src/modules/tspProviders/switch/nama/dto/index.ts b/src/modules/tspProviders/switch/nama/dto/index.ts index 21ed553..ad5f870 100644 --- a/src/modules/tspProviders/switch/nama/dto/index.ts +++ b/src/modules/tspProviders/switch/nama/dto/index.ts @@ -2,4 +2,5 @@ export * from './common.dto' export * from './correction.dto' export * from './get.dto' export * from './original.dto' +export * from './returnFromSale.dto' export * from './revoke.dto' diff --git a/src/modules/tspProviders/switch/nama/dto/returnFromSale.dto.ts b/src/modules/tspProviders/switch/nama/dto/returnFromSale.dto.ts new file mode 100644 index 0000000..4fdabc8 --- /dev/null +++ b/src/modules/tspProviders/switch/nama/dto/returnFromSale.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsArray, IsString, Length, ValidateNested } from 'class-validator' +import { + NamaProviderCommonBodyDto, + NamaProviderCommonHeaderDto, + NamaProviderPaymentInfoDto, + NamaProviderResponseDto, +} from './common.dto' + +export class NamaProviderReturnHeaderDto extends NamaProviderCommonHeaderDto { + @ApiProperty({ required: true, description: 'موضوع صورتحساب (3- برگشت از فروش)' }) + @IsString() + ins: '3' + + @ApiProperty({ + required: true, + description: 'شناسه مالیاتی صورت‌حساب مرجع', + minLength: 22, + maxLength: 22, + }) + @IsString() + @Length(22, 22) + irtaxid: string +} + +export class NamaProviderReturnBodyItemDto extends NamaProviderCommonBodyDto {} + +export class NamaProviderReturnRequestDto { + @ApiProperty({ type: [NamaProviderReturnBodyItemDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => NamaProviderReturnBodyItemDto) + body: NamaProviderReturnBodyItemDto[] + + @ApiProperty({ type: [NamaProviderPaymentInfoDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => NamaProviderPaymentInfoDto) + payment: NamaProviderPaymentInfoDto[] + + @ApiProperty({ type: NamaProviderReturnHeaderDto }) + @ValidateNested({ each: true }) + @Type(() => NamaProviderReturnHeaderDto) + header: NamaProviderReturnHeaderDto + + @ApiProperty() + @IsString() + uuid: string + + @ApiProperty() + @IsString() + economic_code: string + + @ApiProperty() + @IsString() + fiscal_id: string +} + +export class NamaProviderReturnResponseDto extends NamaProviderResponseDto {} diff --git a/src/modules/tspProviders/switch/nama/nama-provider.adapter.ts b/src/modules/tspProviders/switch/nama/nama-provider.adapter.ts index b17766a..685e0e6 100644 --- a/src/modules/tspProviders/switch/nama/nama-provider.adapter.ts +++ b/src/modules/tspProviders/switch/nama/nama-provider.adapter.ts @@ -12,6 +12,8 @@ import { TspProviderGetResponseDto, TspProviderOriginalResponseDto, TspProviderOriginalSendPayloadDto, + TspProviderReturnSendPayloadDto, + TspProviderReturnSendResponseDto, TspProviderRevokePayloadDto, TspProviderRevokeResponseDto, } from '../../dto' @@ -27,6 +29,7 @@ import { NamaProviderRevokeRequestDto, NamaProviderRevokeResponseDto, } from './dto' +import { NamaProviderReturnResponseDto } from './dto/returnFromSale.dto' import { NamaProviderUtils } from './nama-provider.util' @Injectable() @@ -40,6 +43,7 @@ export class NamaProviderSwitchAdapter implements IProviderSwitchAdapter { private readonly sendPath = '/api/v1/invoices/single-send' private readonly correctionPath = this.sendPath + private readonly returnFromSalePath = this.sendPath private readonly sendBulkPath = '/api/v1/invoices' private readonly checkBulkStatus = '/api/v1/invoices/check-by-uuids' private readonly getPath = '/api/v1/invoices' @@ -63,6 +67,10 @@ export class NamaProviderSwitchAdapter implements IProviderSwitchAdapter { return `${this.baseUrl}${this.correctionPath}` } + private buildReturnFromSaleUrl() { + return `${this.baseUrl}${this.returnFromSalePath}` + } + private buildGetUrl(invoiceId: string) { return `${this.baseUrl}${this.getPath}/${invoiceId}` } @@ -176,6 +184,51 @@ export class NamaProviderSwitchAdapter implements IProviderSwitchAdapter { } } + async returnSend( + payload: TspProviderReturnSendPayloadDto, + ): Promise { + const mappedRequest = this.namaProviderUtils.mapToNamaReturnDto(payload) + + try { + const response = await this.httpClient.request( + this.buildReturnFromSaleUrl(), + { + method: 'POST', + body: JSON.stringify(mappedRequest), + }, + this.createRequestInterceptors(payload.token), + ) + const providerResponse: NamaProviderReturnResponseDto = await response.json() + this.logger.debug('NAMA provider return response', providerResponse) + + const result: TspProviderReturnSendResponseDto = { + invoice_id: payload.id, + provider_request_payload: JSON.parse(JSON.stringify(mappedRequest)), + hasError: !response.ok, + message: providerResponse.message, + provider_response: JSON.parse(JSON.stringify(providerResponse)), + received_at: new Date().toISOString(), + tax_id: providerResponse.tax_id, + status: this.namaProviderUtils.mapResponseStatus( + providerResponse.status as NamaProviderResponseStatus, + ), + } + + return result + } catch (err) { + this.logger.error('NAMA return from sale send failed', err) + const failure: TspProviderReturnSendResponseDto = { + invoice_id: payload.id, + provider_request_payload: JSON.parse(JSON.stringify(mappedRequest)), + hasError: true, + message: (err as Error).message, + received_at: new Date().toISOString(), + status: TspProviderResponseStatus.SEND_FAILURE, + } + return failure + } + } + async sendBulk( payloads: TspProviderOriginalSendPayloadDto[], ): Promise { diff --git a/src/modules/tspProviders/switch/nama/nama-provider.util.ts b/src/modules/tspProviders/switch/nama/nama-provider.util.ts index 49832c9..836656d 100644 --- a/src/modules/tspProviders/switch/nama/nama-provider.util.ts +++ b/src/modules/tspProviders/switch/nama/nama-provider.util.ts @@ -12,6 +12,7 @@ import { PaymentInfoDto, TspProviderCorrectionSendPayloadDto, TspProviderOriginalSendPayloadDto, + TspProviderReturnSendPayloadDto, TspProviderRevokePayloadDto, } from '../../dto' import { @@ -22,6 +23,7 @@ import { NamaProviderResponseStatus, NamaProviderRevokeRequestDto, } from './dto' +import { NamaProviderReturnRequestDto } from './dto/returnFromSale.dto' export class NamaProviderUtils { mapResponseStatus(status: NamaProviderResponseStatus): TspProviderResponseStatus { @@ -143,6 +145,57 @@ export class NamaProviderUtils { } } + mapToNamaReturnDto( + payload: TspProviderReturnSendPayloadDto, + ): NamaProviderReturnRequestDto { + return { + uuid: payload.id, + economic_code: payload.economic_code, + fiscal_id: payload.fiscal_id, + payment: this.mapPayments(payload.payments), + header: { + ins: this.mapRequestType(TspProviderRequestType.RETURN), + inp: this.mapInvoiceTemplate(payload.template), + inty: this.mapTspProviderCustomerType(payload.customer?.type), + inno: payload.invoice_number.toString(), + tins: '', + indatim: payload.invoice_date.getTime() + '', + irtaxid: payload.last_tax_id, + ...this.mapSettlementBased( + payload.settlement_type, + payload.payments, + payload.total_amount, + ), + ...this.mapCustomerInfo(payload.customer), + }, + body: payload.items.map(item => { + let data: NamaProviderOriginalBodyItemDto = { + sstid: item.sku, + vra: item.sku_vat, + fee: String(item.unit_price), + dis: String(parseInt(item.discount_amount + '')), + mu: item.measure_unit, + am: String(item.quantity), + consfee: '0', + bros: '0', + spro: '0', + } + + if (item.gold_type_payload) { + const { profit = '0', commission = '0', wages = '0' } = item.gold_type_payload + data = { + ...data, + consfee: wages, + bros: commission, + spro: profit, + tcpbs: (Number(profit) + Number(commission) + Number(wages)).toString(), + } + } + return data + }), + } + } + mapToNamaRevokeRequestDto( payload: TspProviderRevokePayloadDto, ): NamaProviderRevokeRequestDto { diff --git a/src/modules/tspProviders/utils/sales-invoice-tsp.utils.ts b/src/modules/tspProviders/utils/sales-invoice-tsp.utils.ts index d249f69..b5cf5d3 100644 --- a/src/modules/tspProviders/utils/sales-invoice-tsp.utils.ts +++ b/src/modules/tspProviders/utils/sales-invoice-tsp.utils.ts @@ -9,12 +9,13 @@ import { } from 'generated/prisma/client' import { goldTypePayload, - TspProviderCorrectionSendPayloadDto, TspProviderGetResponseDto, TspProviderOriginalResponseDto, TspProviderOriginalSendPayloadDto, + TspProviderReturnSendPayloadDto, TspProviderRevokePayloadDto, } from '../dto' +import { TspProviderCorrectionSendPayloadDto } from '../dto/correction.dto' import { TspProviderActionResponseDto } from '../dto/provider-switch.dto' export async function getOriginalResendAttemptNumber( @@ -160,10 +161,12 @@ export async function buildRevokePayload( export async function buildCorrectionPayload( tx: Prisma.TransactionClient, invoice_id: string, + pos_id: string, ): Promise { const invoice = await tx.salesInvoice.findUnique({ where: { id: invoice_id, + pos_id, }, select: { id: true, @@ -323,6 +326,217 @@ export async function buildCorrectionPayload( } } +export async function buildReturnFromSalePayload( + prisma: PrismaService, + invoiceId: string, + posId: string, +): Promise { + const invoice = await prisma.salesInvoice.findUnique({ + where: { + id: invoiceId, + pos_id: posId, + }, + select: { + id: true, + code: true, + total_amount: true, + invoice_date: true, + invoice_number: true, + settlement_type: true, + discount_amount: true, + tax_amount: true, + tax_id: true, + items: { + select: { + id: true, + quantity: true, + unit_price: true, + total_amount: true, + measure_unit_code: true, + measure_unit_text: true, + sku_code: true, + sku_vat: true, + good_id: true, + service_id: true, + discount_amount: true, + tax_amount: true, + payload: true, + good_snapshot: true, + }, + }, + pos: { + select: { + complex: { + select: { + business_activity: { + select: { + fiscal_id: true, + economic_code: true, + partner_token: true, + guild: { + select: { + invoice_template: true, + }, + }, + consumer: { + select: { + legal: { + select: { + partner: { + select: { + tsp_provider: true, + }, + }, + }, + }, + individual: { + select: { + partner: { + select: { + tsp_provider: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + unknown_customer: true, + customer: { + select: { + type: true, + individual: true, + legal: true, + }, + }, + payments: { + select: { + amount: true, + payment_method: true, + paid_at: true, + terminal_info: { + select: { + stan: true, + rrn: true, + transaction_date_time: true, + customer_card_no: true, + terminal_id: true, + }, + }, + }, + }, + reference_invoice: { + select: { + tax_id: true, + }, + }, + }, + }) + + if (!invoice) { + throw new NotFoundException('صورت‌حساب مورد نظر یافت نشد.') + } + if (!invoice.reference_invoice) { + throw new NotFoundException('صورت‌حساب مرجع برای برگشت از فروش یافت نشد.') + } + + const { + pos, + id, + invoice_number, + invoice_date, + total_amount, + discount_amount, + tax_amount, + settlement_type, + } = invoice + const { business_activity: ba } = pos.complex + + const unknown_customer = (invoice.unknown_customer || {}) as Record + + const { partner } = (ba.consumer.legal || ba.consumer.individual)! + + return { + id, + invoice_number, + invoice_date, + settlement_type, + total_amount: Number(total_amount), + tax_amount: Number(tax_amount), + discount_amount: Number(discount_amount), + economic_code: ba.economic_code, + fiscal_id: ba.fiscal_id, + template: ba.guild.invoice_template, + token: ba.partner_token, + tsp_provider: partner.tsp_provider!, + last_tax_id: invoice.reference_invoice.tax_id!, + + payments: invoice.payments.map(payment => ({ + amount: Number(payment.amount), + payment_method: payment.payment_method, + paid_at: payment.paid_at, + terminal_info: { + card_number: payment.terminal_info?.customer_card_no + ? payment.terminal_info.customer_card_no + : undefined, + tracking_code: payment.terminal_info ? payment.terminal_info.rrn : undefined, + }, + })), + customer: + invoice.customer && invoice.customer.type !== CustomerType.UNKNOWN + ? { + type: invoice.customer.type, + legal_info: invoice.customer.legal + ? { + name: invoice.customer.legal.name ?? undefined, + registration_number: + invoice.customer.legal.registration_number ?? undefined, + postal_code: invoice.customer.legal.postal_code ?? undefined, + economic_code: invoice.customer.legal.economic_code ?? undefined, + } + : undefined, + individual_info: invoice.customer.individual + ? { + first_name: invoice.customer.individual.first_name ?? undefined, + last_name: invoice.customer.individual.last_name ?? undefined, + national_id: invoice.customer.individual.national_id ?? undefined, + mobile_number: invoice.customer.individual.mobile_number ?? undefined, + postal_code: invoice.customer.individual.postal_code ?? undefined, + economic_code: invoice.customer.individual.economic_code ?? undefined, + } + : undefined, + } + : { + type: CustomerType.UNKNOWN, + unknown_info: { + name: unknown_customer?.name || '', + economic_code: unknown_customer?.economic_code || '', + }, + }, + items: invoice.items.map(item => ({ + invoice_item_id: item.id, + quantity: Number(item.quantity), + unit_price: Number(item.unit_price), + total_amount: Number(item.total_amount), + tax_amount: Number(tax_amount), + discount_amount: Number(discount_amount), + measure_unit: item.measure_unit_code, + sku: item.sku_code, + sku_vat: String(item.sku_vat), + discount: String((item.payload as Record)?.discount || '0'), + good_id: item.good_id, + service_id: item.service_id, + good_snapshot: item.good_snapshot, + gold_type_payload: isGoldTypePayload(item.payload) ? item.payload : undefined, + })), + } +} + export async function buildOriginalPayload( prisma: PrismaService, invoiceId: string,