import prisma from "../db"; import config from "../config.json"; import { CustomerType, PayCondition } from "@prisma/client"; import { convertTemplate } from "../utils/string-template"; import { htmlToText } from "html-to-text"; import { JsonObject } from "@prisma/client/runtime/library"; import { precisionRound } from "../utils/arithmetic"; if (!process.env.FLOW_ACCOUNT_URL) throw new Error("Require FLOW_ACCOUNT_URL"); if (!process.env.FLOW_ACCOUNT_CLIENT_ID) throw new Error("Require FLOW_ACCOUNT_CLIENT_ID"); if (!process.env.FLOW_ACCOUNT_CLIENT_SECRET) throw new Error("Require FLOW_ACCOUNT_CLIENT_SECRET"); if (!process.env.FLOW_ACCOUNT_CLIENT_GRANT_TYPE) { throw new Error("Require FLOW_ACCOUNT_CLIENT_GRANT_TYPE"); } if (!process.env.FLOW_ACCOUNT_CLIENT_SCOPE) throw new Error("Require FLOW_ACCOUNT_CLIENT_SCOPE"); const api = process.env.FLOW_ACCOUNT_URL; const clientId = process.env.FLOW_ACCOUNT_CLIENT_ID; const clientSecret = process.env.FLOW_ACCOUNT_CLIENT_SECRET; const clientGrantType = process.env.FLOW_ACCOUNT_CLIENT_GRANT_TYPE; const clientScope = process.env.FLOW_ACCOUNT_CLIENT_SCOPE; let token: string = ""; let tokenExpire: number = Date.now(); // float 0.0 - 1.0 const VAT_DEFAULT = config.vat; enum ContactGroup { PERS = 1, CORP = 3, } enum CreditType { Cash = 3, CreditDay = 1, CreditNoLimit = 5, } enum DocumentDeductionType { Special = 1, Agent = 3, Process = 5, Round = 7, } enum SaleAndPurchaseChannel { Lazada = "Lazada", Shopee = "Shopee", } enum ProductAndServiceType { Service = 1, ProductNonInv = 3, ProductInv = 5, } enum PaymentDeductionType { SpecialDiscount = 1, Commission = 3, Process = 5, Round = 7, } type ProductAndService = { type?: ProductAndServiceType; name: string; description?: string; quantity: number; unitName?: string; pricePerUnit: number; total: number; discountAmount?: number; vatRate?: number; sellChartOfAccountCode?: string; buyChartOfAccountcode?: string; }; const flowAccountAPI = { auth: async () => { // Get new token if it is expiring soon (30s). if (token && Date.now() < tokenExpire + 30 * 1000) return { token, tokenExpire }; const payload = { ["client_id"]: clientId, ["client_secret"]: clientSecret, ["grant_type"]: clientGrantType, ["scope"]: clientScope, }; const res = await fetch(api + "/token", { method: "POST", headers: { ["Content-Type"]: "application/x-www-form-urlencoded", }, body: new URLSearchParams(payload), }); if (!res.ok) throw new Error("Cannot connect to flowaccount: " + (await res.text())); const body: { access_token: string; expires_in: number; } = await res.json(); token = body.access_token; tokenExpire = Date.now() + body.expires_in * 1000; return { token, tokenExpire }; }, async createReceipt( data: { recordId?: string; contactCode?: string; contactName: string; contactAddress: string; contactTaxId?: string; contactBranch?: string; contactPerson?: string; contactEmail?: string; contactNumber?: string; contactZipCode?: string; contactGroup?: ContactGroup; /** This must be in yyyy-MM-dd format if pass as string */ publishedOn?: Date | string; creditType?: CreditType; /** This is integer */ creditDays?: number; /** This must be in yyyy-MM-dd format if pass as string */ dueDate?: Date | string; salesName?: string; projectName?: string; reference?: string; isVatInclusive: boolean; useReceiptDeduction?: boolean; subTotal: number; discounPercentage?: number; discountAmount?: number; totalAfterDiscount: number; isVat?: boolean; vatAmount?: number; /** VAT included */ grandTotal?: number; /** แสดงหรือไม่แสดง หัก ณ ที่จ่ายท้ายเอกสาร */ documentShowWithholdingTax?: boolean; /** ภาษีหัก ณ ที่จ่าย (%) */ documentWithholdingTaxPercentage?: number; /** ภาษีหัก ณ ที่จ่าย */ documentWithholdingTaxAmount?: number; documentDeductionType?: DocumentDeductionType; documentDeductionAmount?: number; remarks?: string; internalNotes?: string; showSignatureOrStamp?: boolean; documentStructureType?: "SimpleDocument" | null; saleAndPurchaseChannel?: SaleAndPurchaseChannel; items: ProductAndService[]; /** This must be in yyyy-MM-dd format if pass as string */ // paymentDate: Date | string; // paymentDeductionType?: PaymentDeductionType; // collected: number; }, withPayment?: boolean, ) { const { token } = await flowAccountAPI.auth(); if (data.publishedOn instanceof Date) { let date = data.publishedOn.getDate(); let month = data.publishedOn.getMonth() + 1; let year = data.publishedOn.getFullYear(); data.publishedOn = `${year}-${String(month).padStart(2, "0")}-${String(date).padStart(2, "0")}`; } if (data.dueDate instanceof Date) { let date = data.dueDate.getDate(); let month = data.dueDate.getMonth() + 1; let year = data.dueDate.getFullYear(); data.dueDate = `${year}-${String(month).padStart(2, "0")}-${String(date).padStart(2, "0")}`; } /* if (data.paymentDate instanceof Date) { let date = data.paymentDate.getDate(); let month = data.paymentDate.getMonth() + 1; let year = data.paymentDate.getFullYear(); data.paymentDate = `${year}-${String(month).padStart(2, "0")}-${String(date).padStart(2, "0")}`; } */ const res = await fetch( api + "/upgrade/receipts/inline" + (withPayment ? "/with-payment" : ""), { method: "POST", headers: { ["Content-Type"]: `application/json`, ["Authorization"]: `Bearer ${token}`, }, body: JSON.stringify({ ...data, referenceDocument: [] }), }, ); return { ok: res.ok, status: res.status, body: await res.json(), }; }, async getInvoiceDocument(recordId: string) { const { token } = await flowAccountAPI.auth(); const res = await fetch(api + "/receipts/sharedocument", { method: "POST", headers: { ["Content-Type"]: `application/json`, ["Authorization"]: `Bearer ${token}`, }, body: JSON.stringify({ documentId: +recordId, culture: "th", }), }); return { ok: res.ok, status: res.status, body: await res.json(), }; }, }; const flowAccount = { issueInvoice: async (invoiceId: string) => { const data = await prisma.invoice.findFirst({ where: { id: invoiceId }, include: { installments: true, quotation: { include: { paySplit: true, worker: { select: { employee: { select: { employeePassport: { select: { number: true, }, orderBy: { expireDate: "desc", }, take: 1, }, namePrefix: true, firstName: true, lastName: true, firstNameEN: true, lastNameEN: true, }, }, }, }, registeredBranch: { include: { province: true, district: true, subDistrict: true, }, }, productServiceList: { include: { worker: true, service: true, work: true, product: true, }, }, customerBranch: { include: { customer: true, province: true, district: true, subDistrict: true }, }, createdBy: true, updatedBy: true, }, }, payment: true, }, }); if (!data) return null; const quotation = data.quotation; const customer = quotation.customerBranch; const summary = { subTotal: 0, discountAmount: 0, vatableAmount: 0, exemptAmount: 0, vatAmount: 0, grandTotal: 0, }; const products = ( quotation.payCondition === PayCondition.BillFull || quotation.payCondition === PayCondition.Full ? quotation.productServiceList : quotation.productServiceList.filter((lhs) => data.installments.some((rhs) => rhs.no === lhs.installmentNo), ) ).map((v) => { // TODO: Use product's VAT field (not implemented) instead. const VAT_RATE = VAT_DEFAULT; summary.subTotal += precisionRound(v.pricePerUnit * (1 + (v.vat > 0 ? VAT_RATE : 0))) * v.amount; summary.discountAmount += v.discount; const total = precisionRound(v.pricePerUnit * (1 + (v.vat > 0 ? VAT_RATE : 0))) * v.amount - (v.discount ?? 0); if (v.vat > 0) { summary.vatableAmount += precisionRound(total / (1 + VAT_RATE)); summary.vatAmount += v.vat; } else { summary.exemptAmount += total; } summary.grandTotal += total; return { type: ProductAndServiceType.ProductNonInv, name: v.product.name, pricePerUnit: precisionRound(v.pricePerUnit), quantity: v.amount, discountAmount: v.discount, vatRate: v.vat === 0 ? 0 : Math.round(VAT_RATE * 100), total, }; }); const payload = { contactCode: customer.code, contactName: customer.contactName || "-", contactAddress: [ customer.address, !!customer.moo ? "หมู่ " + customer.moo : null, !!customer.soi ? "ซอย " + customer.soi : null, !!customer.street ? "ถนน " + customer.street : null, (customer.province?.id === "10" ? "แขวง" : "อำเภอ") + customer.subDistrict?.name, (customer.province?.id === "10" ? "เขต" : "ตำบล") + customer.district?.name, "จังหวัด" + customer.province?.name, ] .filter(Boolean) .join(" "), contactTaxId: customer.citizenId || customer.legalPersonNo || "-", contactBranch: (customer.customer.customerType === CustomerType.PERS ? [customer.firstName, customer.lastName].join(" ").trim() : customer.registerName) || "-", contactPerson: customer.contactName ?? undefined, contactEmail: customer.email, contactNumber: customer.telephoneNo, contactZipCode: customer.subDistrict?.zipCode, contactGroup: customer.customer.customerType === "PERS" ? ContactGroup.PERS : ContactGroup.CORP, dueDate: quotation.dueDate, salesName: [quotation.createdBy?.firstName, quotation.createdBy?.lastName].join(" "), isVatInclusive: true, isVat: true, useReceiptDeduction: false, useInlineVat: true, discounPercentage: 0, discountAmount: quotation.totalDiscount, subTotal: summary.subTotal, totalAfterDiscount: summary.subTotal - summary.discountAmount, vatableAmount: summary.vatableAmount, exemptAmount: summary.exemptAmount, vatAmount: summary.vatAmount, grandTotal: summary.grandTotal, remarks: htmlToText( convertTemplate(quotation.remark ?? "", { "quotation-payment": { paymentType: quotation?.payCondition || "Full", amount: quotation.finalPrice, installments: quotation?.paySplit, }, "quotation-labor": { name: quotation.worker.map( (v, i) => `${i + 1}. ` + `${v.employee.employeePassport.length !== 0 ? v.employee.employeePassport[0].number + "_" : ""}${v.employee.namePrefix}. ${v.employee.firstNameEN ? `${v.employee.firstNameEN} ${v.employee.lastNameEN}` : `${v.employee.firstName} ${v.employee.lastName}`} `.toUpperCase(), ), }, }), ), items: products, }; return await flowAccountAPI.createReceipt(payload, false); }, getInvoiceDocument: async (recordId: string) => { const ret = await flowAccountAPI.getInvoiceDocument(recordId); if (ret && ret.ok) { return ret.body; } return null; }, // flowAccount GET Product list async getProducts() { const { token } = await flowAccountAPI.auth(); const res = await fetch(api + "/products", { method: "GET", headers: { ["Content-Type"]: `application/json`, ["Authorization"]: `Bearer ${token}`, }, }); return { ok: res.ok, status: res.status, body: await res.json(), }; }, // flowAccount GET Product by id async getProductsById(recordId: string) { const { token } = await flowAccountAPI.auth(); const res = await fetch(api + `/products/${recordId}`, { method: "GET", headers: { ["Content-Type"]: `application/json`, ["Authorization"]: `Bearer ${token}`, }, }); const data = await res.json(); return { ok: res.ok, status: res.status, list: data.data.list, total: data.data.total, }; }, // flowAccount POST create Product async createProducts(code: string, body: JsonObject) { const { token } = await flowAccountAPI.auth(); const commonBody = { productStructureType: null, type: 3, name: body.name, sellDescription: body.detail, sellVatType: 3, buyPrice: body.serviceCharge, buyVatType: body.serviceChargeVatIncluded ? 1 : 3, buyDescription: body.detail, }; const createProduct = async (name: string, price: any, vatIncluded: boolean) => { try { const res = await fetch(`${api}/products`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ ...commonBody, name, sellPrice: price, sellVatType: vatIncluded ? 1 : 3, }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to create product`); const json = await res.json().catch(() => { throw new Error("Invalid JSON response from FlowAccount API"); }); return json?.data?.list?.[0]?.id ?? null; } catch (err) { console.error("createProduct error:", err); return null; } }; const deleteProduct = async (id: string) => { try { await fetch(`${api}/products/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); } catch (err) { console.error("Rollback delete failed:", err); } }; const [sellResult, agentResult] = await Promise.allSettled([ createProduct(`${code} ${body.name}`, body.price, /true/.test(`${body.vatIncluded}`)), createProduct( `${code} ${body.name} (ราคาตัวแทน)`, body.agentPrice, /true/.test(`${body.agentPriceVatIncluded}`), ), ]); const sellId = sellResult.status === "fulfilled" ? sellResult.value : null; const agentId = agentResult.status === "fulfilled" ? agentResult.value : null; // --- validation --- if (!sellId && !agentId) { throw new Error("FlowAccountProductError.BOTH_CREATION_FAILED"); } if (!sellId && agentId) { await deleteProduct(agentId); throw new Error("FlowAccountProductError.SELL_PRICE_CREATION_FAILED"); } if (sellId && !agentId) { await deleteProduct(sellId); throw new Error("FlowAccountProductError.AGENT_PRICE_CREATION_FAILED"); } return { ok: true, status: 200, data: { productIdSellPrice: sellId, productIdAgentPrice: agentId, }, }; }, // flowAccount PUT edit Product async editProducts(sellPriceId: String, agentPriceId: String, body: JsonObject) { const { token } = await flowAccountAPI.auth(); const commonBody = { productStructureType: null, type: 3, name: body.name, sellDescription: body.detail, sellVatType: 3, buyPrice: body.serviceCharge, buyVatType: body.serviceChargeVatIncluded ? 1 : 3, buyDescription: body.detail, }; const editProduct = async (id: String, name: String, price: any, vatIncluded: boolean) => { try { const res = await fetch(api + `/products/${id}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ ...commonBody, name: name, sellPrice: price, sellVatType: vatIncluded ? 1 : 3, }), }); if (!res.ok) { throw new Error(`Request failed with status ${res.status} ${res}`); } let json: any = null; try { json = await res.json(); } catch { throw new Error("Response is not valid JSON"); } return json?.data?.list?.[0]?.id ?? null; } catch (err) { console.error("createProduct error:", err); return null; } }; await Promise.all([ editProduct( sellPriceId, `${body.code} ${body.name}`, body.price, /true/.test(`${body.vatIncluded}`), ), editProduct( agentPriceId, `${body.code} ${body.name} (ราคาตัวแทน)`, body.agentPrice, /true/.test(`${body.agentPriceVatIncluded}`), ), ]); }, // flowAccount DELETE Product async deleteProduct(recordId: string) { const { token } = await flowAccountAPI.auth(); const res = await fetch(api + `/products/${recordId}`, { method: "DELETE", headers: { ["Authorization"]: `Bearer ${token}`, }, }); return { ok: res.ok, status: res.status, }; }, }; export default flowAccount;