From c3c4f49fc2126f950e69e291ca939ca88a15afd3 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 12 Mar 2024 15:36:22 +0100 Subject: [PATCH] feat(core-flows,medusa,types): add automatic-taxes to region + generate tax lines endpoint (#6667) what: - endpoint to generate tax lines - update workflows to force calculate tax lines with a flag - added automatic_taxes to region --- .changeset/metal-tomatoes-cheat.md | 7 + .../__tests__/cart/store/carts.spec.ts | 191 +++++++++++++++++- .../cart/steps/get-item-tax-lines.ts | 28 ++- .../definition/cart/steps/update-tax-lines.ts | 1 + .../cart/workflows/update-tax-lines.ts | 4 + .../api-v2/store/carts/[id]/taxes/route.ts | 43 ++++ .../src/api-v2/store/carts/query-config.ts | 1 + .../src/api-v2/store/carts/validators.ts | 2 + .../__tests__/region-module.spec.ts | 5 + .../RegionModuleSetup20240205173216.ts | 3 +- packages/region/src/models/region.ts | 4 +- packages/types/src/cart/workflows.ts | 2 + packages/types/src/region/common.ts | 5 + packages/types/src/region/mutations.ts | 12 ++ 14 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 .changeset/metal-tomatoes-cheat.md create mode 100644 packages/medusa/src/api-v2/store/carts/[id]/taxes/route.ts diff --git a/.changeset/metal-tomatoes-cheat.md b/.changeset/metal-tomatoes-cheat.md new file mode 100644 index 0000000000..ac854c5610 --- /dev/null +++ b/.changeset/metal-tomatoes-cheat.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(core-flows,medusa,types): add automatic-taxes to region + generate tax lines endpoint diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 0102f101eb..ad708fee7a 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -20,7 +20,7 @@ import adminSeeder from "../../../../helpers/admin-seeder" import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer" import { setupTaxStructure } from "../../fixtures" -jest.setTimeout(50000) +jest.setTimeout(100000) const env = { MEDUSA_FF_MEDUSA_V2: true } @@ -327,6 +327,11 @@ medusaIntegrationTestRunner({ it("should update a cart with promo codes with a replace action", async () => { await setupTaxStructure(taxModule) + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + }) + const targetRules = [ { attribute: "product_id", @@ -365,6 +370,7 @@ medusaIntegrationTestRunner({ const cart = await cartModule.create({ currency_code: "usd", email: "tony@stark.com", + region_id: region.id, shipping_address: { address_1: "test address 1", address_2: "test address 2", @@ -393,10 +399,12 @@ medusaIntegrationTestRunner({ }, ]) - await remoteLink.create({ - [Modules.CART]: { cart_id: cart.id }, - [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, - }) + await remoteLink.create([ + { + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }, + ]) // Should remove earlier adjustments from other promocodes let updated = await api.post(`/store/carts/${cart.id}`, { @@ -455,6 +463,78 @@ medusaIntegrationTestRunner({ ) }) + it("should not generate tax lines if region is not present or automatic taxes is false", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + automatic_taxes: false, + }) + + const cart = await cartModule.create({ + currency_code: "usd", + email: "tony@stark.com", + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "NY", + country_code: "US", + province: "NY", + postal_code: "94016", + }, + items: [ + { + id: "item-1", + unit_price: 2000, + quantity: 1, + title: "Test item", + product_id: "prod_tshirt", + } as any, + ], + }) + + let updated = await api.post(`/store/carts/${cart.id}`, { + email: "another@tax.com", + }) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + items: [ + expect.objectContaining({ + id: "item-1", + tax_lines: [], + adjustments: [], + }), + ], + }) + ) + + await cartModule.update(cart.id, { + region_id: region.id, + }) + + updated = await api.post(`/store/carts/${cart.id}`, { + email: "another@tax.com", + }) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + items: [ + expect.objectContaining({ + id: "item-1", + adjustments: [], + tax_lines: [], + }), + ], + }) + ) + }) + it("should update a cart's region, sales channel, customer data and tax lines", async () => { await setupTaxStructure(taxModule) @@ -598,7 +678,7 @@ medusaIntegrationTestRunner({ }) }) - describe("GET /store/carts/:id", () => { + describe("POST /store/carts/:id", () => { it("should create and update a cart", async () => { const region = await regionModule.create({ name: "US", @@ -700,6 +780,11 @@ medusaIntegrationTestRunner({ it("should add item to cart", async () => { await setupTaxStructure(taxModule) + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + }) + const customer = await customerModule.create({ email: "tony@stark-industries.com", }) @@ -723,6 +808,7 @@ medusaIntegrationTestRunner({ const cart = await cartModule.create({ currency_code: "usd", customer_id: customer.id, + region_id: region.id, shipping_address: { customer_id: customer.id, address_1: "test address 1", @@ -905,6 +991,99 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("POST /store/carts/:id/taxes", () => { + it("should update a carts tax lines when region.automatic_taxes is false", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + automatic_taxes: false, + }) + + const cart = await cartModule.create({ + currency_code: "usd", + region_id: region.id, + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "NY", + country_code: "US", + province: "NY", + postal_code: "94016", + }, + items: [ + { + id: "item-1", + unit_price: 2000, + quantity: 1, + title: "Test item", + product_id: "prod_tshirt", + } as any, + ], + }) + + let updated = await api.post(`/store/carts/${cart.id}/taxes`, {}) + + expect(updated.status).toEqual(200) + + // TODO: validate tax totals when calculations are ready + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + items: [ + expect.objectContaining({ + id: "item-1", + tax_lines: [ + expect.objectContaining({ + description: "NY Default Rate", + code: "NYDEFAULT", + rate: 6, + provider_id: "system", + }), + ], + adjustments: [], + }), + ], + }) + ) + }) + + it("should throw error when shipping is not present", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + automatic_taxes: false, + }) + + const cart = await cartModule.create({ + currency_code: "usd", + region_id: region.id, + items: [ + { + id: "item-1", + unit_price: 2000, + quantity: 1, + title: "Test item", + product_id: "prod_tshirt", + } as any, + ], + }) + + let error = await api + .post(`/store/carts/${cart.id}/taxes`, {}) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + type: "invalid_data", + message: "country code is required to calculate taxes", + }) + }) + }) }) }, }) diff --git a/packages/core-flows/src/definition/cart/steps/get-item-tax-lines.ts b/packages/core-flows/src/definition/cart/steps/get-item-tax-lines.ts index 2c377a64a8..9b73965873 100644 --- a/packages/core-flows/src/definition/cart/steps/get-item-tax-lines.ts +++ b/packages/core-flows/src/definition/cart/steps/get-item-tax-lines.ts @@ -9,6 +9,7 @@ import { TaxableItemDTO, TaxableShippingDTO, } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { ModuleRegistrationName } from "../../../../../modules-sdk/dist" @@ -16,14 +17,28 @@ interface StepInput { cart: CartWorkflowDTO items: CartLineItemDTO[] shipping_methods: CartShippingMethodDTO[] + force_tax_calculation?: boolean } function normalizeTaxModuleContext( - cart: CartWorkflowDTO + cart: CartWorkflowDTO, + forceTaxCalculation: boolean ): TaxCalculationContext | null { const address = cart.shipping_address + const shouldCalculateTax = forceTaxCalculation || cart.region?.automatic_taxes - if (!address || !address.country_code) { + if (!shouldCalculateTax) { + return null + } + + if (forceTaxCalculation && !address?.country_code) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `country code is required to calculate taxes` + ) + } + + if (!address?.country_code) { return null } @@ -83,12 +98,17 @@ export const getItemTaxLinesStepId = "get-item-tax-lines" export const getItemTaxLinesStep = createStep( getItemTaxLinesStepId, async (data: StepInput, { container }) => { - const { cart, items, shipping_methods: shippingMethods } = data + const { + cart, + items, + shipping_methods: shippingMethods, + force_tax_calculation: forceTaxCalculation = false, + } = data const taxService = container.resolve( ModuleRegistrationName.TAX ) - const taxContext = normalizeTaxModuleContext(cart) + const taxContext = normalizeTaxModuleContext(cart, forceTaxCalculation) if (!taxContext) { return new StepResponse({ diff --git a/packages/core-flows/src/definition/cart/steps/update-tax-lines.ts b/packages/core-flows/src/definition/cart/steps/update-tax-lines.ts index 2eaf7189e9..399251f8c1 100644 --- a/packages/core-flows/src/definition/cart/steps/update-tax-lines.ts +++ b/packages/core-flows/src/definition/cart/steps/update-tax-lines.ts @@ -10,6 +10,7 @@ interface StepInput { cart_or_cart_id: CartWorkflowDTO | string items?: CartLineItemDTO[] shipping_methods?: CartShippingMethodDTO[] + force_tax_calculation?: boolean } export const updateTaxLinesStepId = "update-tax-lines-step" diff --git a/packages/core-flows/src/definition/cart/workflows/update-tax-lines.ts b/packages/core-flows/src/definition/cart/workflows/update-tax-lines.ts index 5a81c1caf8..b8f2b624bd 100644 --- a/packages/core-flows/src/definition/cart/workflows/update-tax-lines.ts +++ b/packages/core-flows/src/definition/cart/workflows/update-tax-lines.ts @@ -18,6 +18,8 @@ const cartFields = [ "id", "currency_code", "email", + "region.id", + "region.automatic_taxes", "items.id", "items.variant_id", "items.product_id", @@ -62,6 +64,7 @@ type WorkflowInput = { cart_or_cart_id: string | CartWorkflowDTO items?: CartLineItemDTO[] shipping_methods?: CartShippingMethodDTO[] + force_tax_calculation?: boolean } export const updateTaxLinesWorkflowId = "update-tax-lines" @@ -79,6 +82,7 @@ export const updateTaxLinesWorkflow = createWorkflow( items: data.input.items || data.cart.items, shipping_methods: data.input.shipping_methods || data.cart.shipping_methods, + force_tax_calculation: data.input.force_tax_calculation, })) ) diff --git a/packages/medusa/src/api-v2/store/carts/[id]/taxes/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/taxes/route.ts new file mode 100644 index 0000000000..4a8cf8e376 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/taxes/route.ts @@ -0,0 +1,43 @@ +import { updateTaxLinesWorkflow } from "@medusajs/core-flows" +import { Modules } from "@medusajs/modules-sdk" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { defaultStoreCartFields } from "../../query-config" +import { StorePostCartsCartTaxesReq } from "../../validators" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const workflow = updateTaxLinesWorkflow(req.scope) + + const { errors } = await workflow.run({ + input: { + cart_or_cart_id: req.params.id, + force_tax_calculation: true, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const query = remoteQueryObjectFromString({ + entryPoint: Modules.CART, + fields: defaultStoreCartFields, + }) + + const [cart] = await remoteQuery(query, { + cart: { id: req.params.id }, + }) + + // TODO: wrap result with totals when totals calculation is ready + + res.status(200).json({ cart }) +} diff --git a/packages/medusa/src/api-v2/store/carts/query-config.ts b/packages/medusa/src/api-v2/store/carts/query-config.ts index 9a518d19aa..295a28556a 100644 --- a/packages/medusa/src/api-v2/store/carts/query-config.ts +++ b/packages/medusa/src/api-v2/store/carts/query-config.ts @@ -66,6 +66,7 @@ export const defaultStoreCartFields = [ "region.id", "region.name", "region.currency_code", + "region.automatic_taxes", "sales_channel_id", // TODO: To be updated when payment sessions are introduces in the Rest API diff --git a/packages/medusa/src/api-v2/store/carts/validators.ts b/packages/medusa/src/api-v2/store/carts/validators.ts index 796d225e79..2cdfc9def5 100644 --- a/packages/medusa/src/api-v2/store/carts/validators.ts +++ b/packages/medusa/src/api-v2/store/carts/validators.ts @@ -114,3 +114,5 @@ export class StorePostCartsCartReq { // @Type(() => Discount) // discounts?: Discount[] } + +export class StorePostCartsCartTaxesReq {} diff --git a/packages/region/integration-tests/__tests__/region-module.spec.ts b/packages/region/integration-tests/__tests__/region-module.spec.ts index e65aa62e59..ca3f654f3e 100644 --- a/packages/region/integration-tests/__tests__/region-module.spec.ts +++ b/packages/region/integration-tests/__tests__/region-module.spec.ts @@ -34,6 +34,7 @@ describe("Region Module Service", () => { const createdRegion = await service.create({ name: "Europe", currency_code: "EUR", + automatic_taxes: false, }) expect(createdRegion).toEqual( @@ -42,6 +43,7 @@ describe("Region Module Service", () => { name: "Europe", currency_code: "eur", countries: [], + automatic_taxes: false, }) ) @@ -75,6 +77,7 @@ describe("Region Module Service", () => { id: region.id, name: "North America", currency_code: "usd", + automatic_taxes: true, countries: [ expect.objectContaining({ display_name: "Canada", @@ -148,6 +151,7 @@ describe("Region Module Service", () => { name: "Americas", currency_code: "MXN", countries: ["us", "mx"], + automatic_taxes: false, }) const latestRegion = await service.retrieve(createdRegion.id, { @@ -158,6 +162,7 @@ describe("Region Module Service", () => { id: createdRegion.id, name: "Americas", currency_code: "mxn", + automatic_taxes: false, }) expect(latestRegion.countries.map((c) => c.iso_2)).toEqual(["mx", "us"]) }) diff --git a/packages/region/src/migrations/RegionModuleSetup20240205173216.ts b/packages/region/src/migrations/RegionModuleSetup20240205173216.ts index f350b25095..81390f93ac 100644 --- a/packages/region/src/migrations/RegionModuleSetup20240205173216.ts +++ b/packages/region/src/migrations/RegionModuleSetup20240205173216.ts @@ -20,9 +20,10 @@ ALTER TABLE "region" DROP CONSTRAINT IF EXISTS "FK_3bdd5896ec93be2f1c62a3309a5"; ALTER TABLE "region" DROP CONSTRAINT IF EXISTS "FK_91f88052197680f9790272aaf5b"; ${generatePostgresAlterColummnIfExistStatement( "region", - ["tax_rate", "gift_cards_taxable", "automatic_taxes", "includes_tax"], + ["tax_rate", "gift_cards_taxable", "includes_tax"], "DROP NOT NULL" )} +ALTER TABLE "region" ADD COLUMN IF NOT EXISTS "automatic_taxes" BOOLEAN NOT NULL DEFAULT TRUE; CREATE INDEX IF NOT EXISTS "IDX_region_deleted_at" ON "region" ("deleted_at") WHERE "deleted_at" IS NOT NULL; -- Create or update "region_country" table CREATE TABLE IF NOT EXISTS "region_country" ( diff --git a/packages/region/src/models/region.ts b/packages/region/src/models/region.ts index abe0f1e5a6..5ac1fdb773 100644 --- a/packages/region/src/models/region.ts +++ b/packages/region/src/models/region.ts @@ -6,7 +6,6 @@ import { Entity, Filter, Index, - ManyToOne, OnInit, OneToMany, OptionalProps, @@ -31,6 +30,9 @@ export default class Region { @Property({ columnType: "text" }) currency_code: string + @Property({ columnType: "boolean" }) + automatic_taxes = true + @OneToMany(() => Country, (country) => country.region) countries = new Collection(this) diff --git a/packages/types/src/cart/workflows.ts b/packages/types/src/cart/workflows.ts index 0d7573386c..2a5c4733f1 100644 --- a/packages/types/src/cart/workflows.ts +++ b/packages/types/src/cart/workflows.ts @@ -1,5 +1,6 @@ import { CustomerDTO } from "../customer" import { ProductDTO } from "../product" +import { RegionDTO } from "../region" import { CartDTO, CartLineItemDTO } from "./common" import { UpdateLineItemDTO } from "./mutations" @@ -98,4 +99,5 @@ export interface CreatePaymentCollectionForCartWorkflowInputDTO { export interface CartWorkflowDTO extends CartDTO { customer?: CustomerDTO product?: ProductDTO + region?: RegionDTO } diff --git a/packages/types/src/region/common.ts b/packages/types/src/region/common.ts index 907e26f661..40669b918d 100644 --- a/packages/types/src/region/common.ts +++ b/packages/types/src/region/common.ts @@ -19,6 +19,11 @@ export interface RegionDTO { */ currency_code: string + /** + * Setting to indicate whether taxes need to be applied automatically + */ + automatic_taxes: boolean + /** * The countries of the region. */ diff --git a/packages/types/src/region/mutations.ts b/packages/types/src/region/mutations.ts index 139178d99d..642c0bc458 100644 --- a/packages/types/src/region/mutations.ts +++ b/packages/types/src/region/mutations.ts @@ -10,6 +10,10 @@ export interface CreateRegionDTO { * The currency code of the region. */ currency_code: string + /** + * Setting to indicate whether taxes need to be applied automatically + */ + automatic_taxes?: boolean /** * The region's countries. */ @@ -33,6 +37,10 @@ export interface UpsertRegionDTO { * The currency code of the region. */ currency_code?: string + /** + * Setting to indicate whether taxes need to be applied automatically + */ + automatic_taxes?: boolean /** * The region's countries. */ @@ -52,6 +60,10 @@ export interface UpdateRegionDTO { * The currency code of the region. */ currency_code?: string + /** + * Setting to indicate whether taxes need to be applied automatically + */ + automatic_taxes?: boolean /** * The region's countries. */