diff --git a/src/database/__tests__/tax.test.js b/src/database/__tests__/tax.test.js new file mode 100644 index 0000000..a0a7980 --- /dev/null +++ b/src/database/__tests__/tax.test.js @@ -0,0 +1,37 @@ +import { amountWithTax, effectiveMarginPrice } from '../tax.js'; + +describe('amountWithTax', () => { + it('returns 0 for zero amount', () => { + expect(amountWithTax(0, { rateType: 'percentage', rate: 20 })).toBe(0); + }); + + it('applies percentage tax', () => { + expect(amountWithTax(100, { rateType: 'percentage', rate: 20 })).toBe(120); + }); + + it('applies fixed amount tax', () => { + expect(amountWithTax(100, { rateType: 'amount', rate: 5 })).toBe(105); + }); + + it('applies fixed rate type from API schema', () => { + expect(amountWithTax(100, { rateType: 'fixed', rate: 5 })).toBe(105); + }); + + it('returns base amount when tax rate is missing', () => { + expect(amountWithTax(100, null)).toBe(100); + }); +}); + +describe('effectiveMarginPrice', () => { + it('computes price from margin mode', () => { + expect( + effectiveMarginPrice({ priceMode: 'margin', price: 50, cost: 100, margin: 20 }) + ).toBe(120); + }); + + it('returns explicit price for amount mode', () => { + expect( + effectiveMarginPrice({ priceMode: 'amount', price: 150, cost: 100, margin: 20 }) + ).toBe(150); + }); +}); diff --git a/src/database/schemas/finance/invoice.schema.js b/src/database/schemas/finance/invoice.schema.js index bd9eea4..954d09a 100644 --- a/src/database/schemas/finance/invoice.schema.js +++ b/src/database/schemas/finance/invoice.schema.js @@ -1,7 +1,9 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; const { Schema } = mongoose; -import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; +import { aggregateRollups, aggregateRollupsHistory, editObject, getObject } from '../../database.js'; +import { taxRateModel } from '../management/taxrate.schema.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const invoiceOrderItemSchema = new Schema( { @@ -140,23 +142,41 @@ invoiceSchema.statics.recalculate = async function (invoice, user) { return; } + const invoiceOrderItems = []; + for (const item of invoice.invoiceOrderItems || []) { + const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel); + invoiceOrderItems.push({ + ...item, + invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate), + }); + } + + const invoiceShipments = []; + for (const item of invoice.invoiceShipments || []) { + const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel); + invoiceShipments.push({ + ...item, + invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate), + }); + } + // Calculate totals from invoiceOrderItems let totalAmount = 0; - for (const item of invoice.invoiceOrderItems || []) { + for (const item of invoiceOrderItems) { totalAmount += Number.parseFloat(item.invoiceAmount) || 0; } let totalAmountWithTax = 0; - for (const item of invoice.invoiceOrderItems || []) { + for (const item of invoiceOrderItems) { totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0; } // Calculate shipping totals from invoiceShipments let shippingAmount = 0; - for (const item of invoice.invoiceShipments || []) { + for (const item of invoiceShipments) { shippingAmount += Number.parseFloat(item.invoiceAmount) || 0; } let shippingAmountWithTax = 0; - for (const item of invoice.invoiceShipments || []) { + for (const item of invoiceShipments) { shippingAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0; } @@ -168,6 +188,8 @@ invoiceSchema.statics.recalculate = async function (invoice, user) { (parseFloat(shippingAmountWithTax) - parseFloat(shippingAmount)); const updateData = { + invoiceOrderItems, + invoiceShipments, totalAmount: parseFloat(totalAmount).toFixed(2), totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2), shippingAmount: parseFloat(shippingAmount).toFixed(2), diff --git a/src/database/schemas/inventory/orderitem.schema.js b/src/database/schemas/inventory/orderitem.schema.js index 7286d1f..f93d0da 100644 --- a/src/database/schemas/inventory/orderitem.schema.js +++ b/src/database/schemas/inventory/orderitem.schema.js @@ -15,6 +15,7 @@ import { getObject, } from '../../database.js'; import { generateId } from '../../utils.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; const skuModelsByItemType = { @@ -194,17 +195,10 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) { } } - let taxRate = orderItem.taxRate; - if (orderItem.taxRate?._id && Object.keys(orderItem.taxRate).length === 1) { - taxRate = await getObject({ - model: taxRateModel, - id: orderItem.taxRate._id, - cached: true, - }); - } + const taxRate = await resolveTaxRate(orderItem.taxRate, getObject, taxRateModel); const orderTotalAmount = effectiveItemAmount * orderItem.quantity; - const orderTotalAmountWithTax = orderTotalAmount * (1 + (taxRate?.rate || 0) / 100); + const orderTotalAmountWithTax = amountWithTax(orderTotalAmount, taxRate); const orderItemUpdateData = { totalAmount: orderTotalAmount, diff --git a/src/database/schemas/inventory/shipment.schema.js b/src/database/schemas/inventory/shipment.schema.js index 95f9beb..8f63b69 100644 --- a/src/database/schemas/inventory/shipment.schema.js +++ b/src/database/schemas/inventory/shipment.schema.js @@ -10,6 +10,7 @@ import { editObject, getObject, } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const shipmentSchema = new Schema( { @@ -102,26 +103,17 @@ shipmentSchema.statics.recalculate = async function (shipment, user) { return; } - var taxRate = shipment.taxRate; + const taxRate = await resolveTaxRate(shipment.taxRate, getObject, taxRateModel); - if (shipment.taxRate?._id && Object.keys(shipment.taxRate).length == 1) { - taxRate = await getObject({ - model: taxRateModel, - id: shipment.taxRate._id, - cached: true, - }); - } - - const amountWithTax = parseFloat( - (shipment.amount || 0) * (1 + (taxRate?.rate || 0) / 100) - ).toFixed(2); + const amountWithTaxValue = amountWithTax(shipment.amount || 0, taxRate); await editObject({ model: shipmentModel, id: shipment._id, updateData: { - amountWithTax: amountWithTax, + amountWithTax: amountWithTaxValue, invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0), - invoicedAmountWithTaxRemaining: amountWithTax - (shipment.invoicedAmountWithTax || 0), + invoicedAmountWithTaxRemaining: + amountWithTaxValue - (shipment.invoicedAmountWithTax || 0), }, user, recalculate: false, diff --git a/src/database/schemas/management/courierservice.schema.js b/src/database/schemas/management/courierservice.schema.js index 73e990a..800406d 100644 --- a/src/database/schemas/management/courierservice.schema.js +++ b/src/database/schemas/management/courierservice.schema.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { taxRateModel } from './taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; const marketplaceMappingSchema = new mongoose.Schema( @@ -39,4 +42,29 @@ courierServiceSchema.virtual('id').get(function () { courierServiceSchema.set('toJSON', { virtuals: true }); +courierServiceSchema.statics.recalculate = async function (courierService, user) { + const costTaxRate = await resolveTaxRate(courierService.costTaxRate, getObject, taxRateModel); + const updateData = {}; + + if (courierService.cost != null) { + updateData.costWithTax = amountWithTax(courierService.cost, costTaxRate); + } + if (courierService.additionalCost != null) { + updateData.additionalCostWithTax = amountWithTax( + courierService.additionalCost, + costTaxRate + ); + } + + if (Object.keys(updateData).length > 0) { + await editObject({ + model: this, + id: courierService._id, + updateData, + user, + recalculate: false, + }); + } +}; + export const courierServiceModel = mongoose.model('courierService', courierServiceSchema); diff --git a/src/database/schemas/management/filament.schema.js b/src/database/schemas/management/filament.schema.js index ea53d0b..8a66537 100644 --- a/src/database/schemas/management/filament.schema.js +++ b/src/database/schemas/management/filament.schema.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { taxRateModel } from '../management/taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; // Filament base - cost and tax; color and cost override at FilamentSKU @@ -28,6 +31,24 @@ filamentSchema.virtual('id').get(function () { filamentSchema.set('toJSON', { virtuals: true }); filamentSchema.statics.recalculate = async function (filament, user) { + const costTaxRate = await resolveTaxRate(filament.costTaxRate, getObject, taxRateModel); + const taxUpdateData = {}; + + if (filament.cost != null) { + taxUpdateData.costWithTax = amountWithTax(filament.cost, costTaxRate); + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: filament._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(filament, taxUpdateData); + } + const filamentSkuModel = mongoose.model('filamentSku'); const skus = await filamentSkuModel.find({ filament: filament._id }).select('_id').lean(); for (const sku of skus) { diff --git a/src/database/schemas/management/filamentsku.schema.js b/src/database/schemas/management/filamentsku.schema.js index 97e995d..60646c1 100644 --- a/src/database/schemas/management/filamentsku.schema.js +++ b/src/database/schemas/management/filamentsku.schema.js @@ -1,5 +1,9 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { filamentModel } from './filament.schema.js'; +import { taxRateModel } from './taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; // Define the main filament SKU schema - color and cost live at SKU level @@ -30,6 +34,34 @@ filamentSkuSchema.virtual('id').get(function () { filamentSkuSchema.set('toJSON', { virtuals: true }); filamentSkuSchema.statics.recalculate = async function (filamentSku, user) { + const parent = await getObject({ + model: filamentModel, + id: filamentSku.filament?._id || filamentSku.filament, + cached: true, + }); + + const taxUpdateData = {}; + + if (filamentSku.overrideCost) { + const costTaxRate = await resolveTaxRate(filamentSku.costTaxRate, getObject, taxRateModel); + if (filamentSku.cost != null) { + taxUpdateData.costWithTax = amountWithTax(filamentSku.cost, costTaxRate); + } + } else if (parent?.costWithTax != null) { + taxUpdateData.costWithTax = parent.costWithTax; + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: filamentSku._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(filamentSku, taxUpdateData); + } + const orderItemModel = mongoose.model('orderItem'); const skuId = filamentSku._id; const draftOrderItems = await orderItemModel diff --git a/src/database/schemas/management/part.schema.js b/src/database/schemas/management/part.schema.js index 682f6e7..1456228 100644 --- a/src/database/schemas/management/part.schema.js +++ b/src/database/schemas/management/part.schema.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { taxRateModel } from '../management/taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; // Define the main part schema - cost/price and tax; override at PartSku @@ -32,6 +35,28 @@ partSchema.virtual('id').get(function () { partSchema.set('toJSON', { virtuals: true }); partSchema.statics.recalculate = async function (part, user) { + const costTaxRate = await resolveTaxRate(part.costTaxRate, getObject, taxRateModel); + const priceTaxRate = await resolveTaxRate(part.priceTaxRate, getObject, taxRateModel); + const taxUpdateData = {}; + + if (part.cost != null) { + taxUpdateData.costWithTax = amountWithTax(part.cost, costTaxRate); + } + if (part.price != null) { + taxUpdateData.priceWithTax = amountWithTax(part.price, priceTaxRate); + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: part._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(part, taxUpdateData); + } + const partSkuModel = mongoose.model('partSku'); const skus = await partSkuModel.find({ part: part._id }).select('_id').lean(); for (const sku of skus) { diff --git a/src/database/schemas/management/partsku.schema.js b/src/database/schemas/management/partsku.schema.js index 02c443e..ed7a1fd 100644 --- a/src/database/schemas/management/partsku.schema.js +++ b/src/database/schemas/management/partsku.schema.js @@ -1,5 +1,13 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { partModel } from './part.schema.js'; +import { taxRateModel } from './taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { + amountWithTax, + effectiveMarginPrice, + resolveTaxRate, +} from '../../tax.js'; const { Schema } = mongoose; // Define the main part SKU schema - pricing lives at SKU level @@ -36,6 +44,54 @@ partSkuSchema.virtual('id').get(function () { partSkuSchema.set('toJSON', { virtuals: true }); partSkuSchema.statics.recalculate = async function (partSku, user) { + const parent = await getObject({ + model: partModel, + id: partSku.part?._id || partSku.part, + cached: true, + }); + + const taxUpdateData = {}; + + if (partSku.overrideCost) { + const costTaxRate = await resolveTaxRate(partSku.costTaxRate, getObject, taxRateModel); + if (partSku.cost != null) { + taxUpdateData.costWithTax = amountWithTax(partSku.cost, costTaxRate); + } + } else if (parent?.costWithTax != null) { + taxUpdateData.costWithTax = parent.costWithTax; + } + + if (partSku.overridePrice) { + const priceTaxRate = await resolveTaxRate( + partSku.priceTaxRate ?? parent?.priceTaxRate, + getObject, + taxRateModel + ); + const cost = partSku.overrideCost ? partSku.cost : parent?.cost; + const price = effectiveMarginPrice({ + priceMode: partSku.priceMode ?? parent?.priceMode, + price: partSku.price, + cost, + margin: partSku.margin ?? parent?.margin, + }); + if (price != null) { + taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate); + } + } else if (parent?.priceWithTax != null) { + taxUpdateData.priceWithTax = parent.priceWithTax; + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: partSku._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(partSku, taxUpdateData); + } + const orderItemModel = mongoose.model('orderItem'); const skuId = partSku._id; const draftOrderItems = await orderItemModel diff --git a/src/database/schemas/management/product.schema.js b/src/database/schemas/management/product.schema.js index 09e9813..9e1cb9d 100644 --- a/src/database/schemas/management/product.schema.js +++ b/src/database/schemas/management/product.schema.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { taxRateModel } from '../management/taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { amountWithTax, resolveTaxRate } from '../../tax.js'; const { Schema } = mongoose; // Define the main product schema @@ -35,6 +38,34 @@ productSchema.virtual('id').get(function () { productSchema.set('toJSON', { virtuals: true }); productSchema.statics.recalculate = async function (product, user) { + const costTaxRate = await resolveTaxRate(product.costTaxRate, getObject, taxRateModel); + const priceTaxRate = await resolveTaxRate(product.priceTaxRate, getObject, taxRateModel); + const taxUpdateData = {}; + + if (product.cost != null) { + taxUpdateData.costWithTax = amountWithTax(product.cost, costTaxRate); + } + if (product.price != null) { + taxUpdateData.priceWithTax = amountWithTax(product.price, priceTaxRate); + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: product._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(product, taxUpdateData); + } + + const productSkuModel = mongoose.model('productSku'); + const skus = await productSkuModel.find({ product: product._id }).select('_id').lean(); + for (const sku of skus) { + await productSkuModel.recalculate(sku, user); + } + const orderItemModel = mongoose.model('orderItem'); const itemId = product._id; const draftOrderItems = await orderItemModel diff --git a/src/database/schemas/management/productsku.schema.js b/src/database/schemas/management/productsku.schema.js index 59ac71e..49c2696 100644 --- a/src/database/schemas/management/productsku.schema.js +++ b/src/database/schemas/management/productsku.schema.js @@ -1,5 +1,13 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { productModel } from './product.schema.js'; +import { taxRateModel } from './taxrate.schema.js'; +import { editObject, getObject } from '../../database.js'; +import { + amountWithTax, + effectiveMarginPrice, + resolveTaxRate, +} from '../../tax.js'; const { Schema } = mongoose; const partSkuUsageSchema = new Schema({ @@ -43,6 +51,54 @@ productSkuSchema.virtual('id').get(function () { productSkuSchema.set('toJSON', { virtuals: true }); productSkuSchema.statics.recalculate = async function (productSku, user) { + const parent = await getObject({ + model: productModel, + id: productSku.product?._id || productSku.product, + cached: true, + }); + + const taxUpdateData = {}; + + if (productSku.overrideCost) { + const costTaxRate = await resolveTaxRate(productSku.costTaxRate, getObject, taxRateModel); + if (productSku.cost != null) { + taxUpdateData.costWithTax = amountWithTax(productSku.cost, costTaxRate); + } + } else if (parent?.costWithTax != null) { + taxUpdateData.costWithTax = parent.costWithTax; + } + + if (productSku.overridePrice) { + const priceTaxRate = await resolveTaxRate( + productSku.priceTaxRate ?? parent?.priceTaxRate, + getObject, + taxRateModel + ); + const cost = productSku.overrideCost ? productSku.cost : parent?.cost; + const price = effectiveMarginPrice({ + priceMode: productSku.priceMode ?? parent?.priceMode, + price: productSku.price, + cost, + margin: productSku.margin ?? parent?.margin, + }); + if (price != null) { + taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate); + } + } else if (parent?.priceWithTax != null) { + taxUpdateData.priceWithTax = parent.priceWithTax; + } + + if (Object.keys(taxUpdateData).length > 0) { + await editObject({ + model: this, + id: productSku._id, + updateData: taxUpdateData, + user, + recalculate: false, + }); + Object.assign(productSku, taxUpdateData); + } + const orderItemModel = mongoose.model('orderItem'); const skuId = productSku._id; const draftOrderItems = await orderItemModel diff --git a/src/database/tax.js b/src/database/tax.js new file mode 100644 index 0000000..078094b --- /dev/null +++ b/src/database/tax.js @@ -0,0 +1,54 @@ +/** + * Tax calculation helpers mirroring farmcontrol-ui model value functions. + */ + +export function isPopulatedTaxRate(taxRate) { + return taxRate != null && taxRate.rateType != null; +} + +export async function resolveTaxRate(taxRateRef, getObject, taxRateModel) { + if (!taxRateRef) { + return null; + } + if (isPopulatedTaxRate(taxRateRef)) { + return taxRateRef; + } + const id = taxRateRef._id ?? taxRateRef; + if (!id) { + return null; + } + if ( + typeof taxRateRef === 'object' && + taxRateRef._id && + Object.keys(taxRateRef).length === 1 + ) { + return await getObject({ model: taxRateModel, id, cached: true }); + } + return await getObject({ model: taxRateModel, id, cached: true }); +} + +export function amountWithTax(amount, taxRate) { + const base = Number.parseFloat(amount) || 0; + if (!base) { + return 0; + } + if (!taxRate) { + return Number.parseFloat(base.toFixed(2)); + } + + const rate = Number.parseFloat(taxRate.rate) || 0; + if (taxRate.rateType === 'percentage') { + return Number.parseFloat((base * (1 + rate / 100)).toFixed(2)); + } + if (taxRate.rateType === 'amount' || taxRate.rateType === 'fixed') { + return Number.parseFloat((base + rate).toFixed(2)); + } + return Number.parseFloat(base.toFixed(2)); +} + +export function effectiveMarginPrice({ priceMode, price, cost, margin }) { + if (priceMode === 'margin' && margin != null && cost != null) { + return cost * (1 + margin / 100); + } + return price; +}