Skip to content

Commit dfdcdd7

Browse files
authored
feat(types,core-flows,order): add metadata to tax lines returned by providers (#15840)
## Summary **What** — Add separate `data` and `metadata` fields to all four tax line models (cart and order), wire them through the cart and order tax line steps, and add the missing `metadata` column to the order module's `LineItemTaxLine` and `ShippingMethodTaxLine` models. **Why** — Custom tax providers (e.g. Stripe Tax, TaxJar) return per-jurisdiction breakdown data (state, county, city rates) that cannot be captured in the single aggregated `rate` field. Without a place to store this, the breakdown is discarded after calculation and cannot be shown on invoices or order confirmations. The `data` field captures the provider snapshot; `metadata` remains available for user-defined key/value pairs, consistent with other Medusa models (e.g. payment sessions, fulfillments). **How** — A `data` field is added to the base `TaxLineDTO` interface (the provider-facing return type) and to the relevant create/update mutation DTOs. The normalize functions in the cart and order set/upsert tax line steps map both `data` and `metadata` from the provider return to the persistence layer. Migrations add a `data` column to all four tax line tables and a `metadata` column to the two order module tables that were missing it. **Testing** — Integration tests in the cart and order module test suites verify that both `data` and `metadata` are persisted and returned correctly for line item and shipping method tax lines. An HTTP integration test verifies end-to-end that a custom tax provider's `data` payload is stored and returned by the store cart API. --- ## Examples ```ts // In a custom tax provider's getTaxLines method: return [{ line_item_id: item.line_item.id, rate: 8.9, code: "US-GA", provider_id: this.getIdentifier(), name: "Sales Tax", data: { state_rate: 4.0, county_rate: 3.0, city_rate: 1.9, }, }] ``` --- ## Checklist - [x] I have added a changeset for this PR - [x] The changes are covered by relevant tests - [ ] I have verified the code works as intended locally - [ ] I have linked the related issue(s) if applicable --- ## Additional Context Discussed with [nicolas](https://github.com/NicolasGorga) in the Medusa Slack. Nicolas suggested this approach to support US multi-jurisdiction tax breakdown (state/county/city) without introducing a new relation model.
1 parent 812687d commit dfdcdd7

25 files changed

Lines changed: 736 additions & 0 deletions

File tree

.changeset/bright-nights-drop.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@medusajs/core-flows": patch
3+
"@medusajs/order": patch
4+
"@medusajs/cart": patch
5+
"@medusajs/types": patch
6+
"@medusajs/medusa": patch
7+
---
8+
9+
feat(core-flows,order,cart,types,medusa): Add data and metadata fields to tax line models
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
2+
import { Modules, ProductStatus } from "@medusajs/utils"
3+
import {
4+
createAdminUser,
5+
generatePublishableKey,
6+
generateStoreHeaders,
7+
} from "../../../../helpers/create-admin-user"
8+
9+
jest.setTimeout(100000)
10+
11+
const env = {}
12+
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
13+
14+
medusaIntegrationTestRunner({
15+
env,
16+
testSuite: ({ dbConnection, getContainer, api }) => {
17+
let appContainer
18+
19+
beforeAll(async () => {
20+
appContainer = getContainer()
21+
})
22+
23+
describe("Tax line data field", () => {
24+
let storeHeaders
25+
let region
26+
let product
27+
let salesChannel
28+
let shippingProfile
29+
30+
beforeAll(async () => {
31+
await createAdminUser(dbConnection, adminHeaders, appContainer)
32+
const publishableKey = await generatePublishableKey(appContainer)
33+
storeHeaders = generateStoreHeaders({ publishableKey })
34+
35+
shippingProfile = (
36+
await api.post(
37+
"/admin/shipping-profiles",
38+
{ name: "default", type: "default" },
39+
adminHeaders
40+
)
41+
).data.shipping_profile
42+
43+
const taxService = appContainer.resolve(Modules.TAX)
44+
await taxService.createTaxRegions([
45+
{
46+
country_code: "GB",
47+
provider_id: "tp_tax-data-provider_data-provider",
48+
default_tax_rate: {
49+
name: "GB Standard Rate",
50+
rate: 20,
51+
code: "GB_STD",
52+
},
53+
},
54+
])
55+
56+
region = (
57+
await api.post(
58+
"/admin/regions",
59+
{ name: "GB", currency_code: "gbp", countries: ["gb"] },
60+
adminHeaders
61+
)
62+
).data.region
63+
64+
product = (
65+
await api.post(
66+
"/admin/products",
67+
{
68+
title: "Test Product",
69+
status: ProductStatus.PUBLISHED,
70+
shipping_profile_id: shippingProfile.id,
71+
options: [{ title: "Size", values: ["S"] }],
72+
variants: [
73+
{
74+
title: "S",
75+
manage_inventory: false,
76+
options: { Size: "S" },
77+
prices: [{ amount: 1000, currency_code: "gbp" }],
78+
},
79+
],
80+
},
81+
adminHeaders
82+
)
83+
).data.product
84+
85+
salesChannel = (
86+
await api.post(
87+
"/admin/sales-channels",
88+
{ name: "GB Webshop", description: "channel" },
89+
adminHeaders
90+
)
91+
).data.sales_channel
92+
})
93+
94+
it("should persist provider data on cart line item tax lines", async () => {
95+
const cart = (
96+
await api.post(
97+
"/store/carts",
98+
{
99+
currency_code: "gbp",
100+
region_id: region.id,
101+
sales_channel_id: salesChannel.id,
102+
shipping_address: {
103+
address_1: "1 Oxford Street",
104+
city: "London",
105+
country_code: "GB",
106+
postal_code: "W1D 1AN",
107+
},
108+
items: [
109+
{ variant_id: product.variants[0].id, quantity: 1 },
110+
],
111+
},
112+
storeHeaders
113+
)
114+
).data.cart
115+
116+
const response = await api.post(
117+
`/store/carts/${cart.id}/taxes`,
118+
{},
119+
storeHeaders
120+
)
121+
122+
expect(response.status).toEqual(200)
123+
expect(response.data.cart.items[0].tax_lines).toEqual(
124+
expect.arrayContaining([
125+
expect.objectContaining({
126+
code: "GB_STD",
127+
rate: 20,
128+
provider_id: "tax-data-provider",
129+
data: {
130+
state_rate: 4.0,
131+
county_rate: 3.0,
132+
city_rate: 1.9,
133+
},
134+
}),
135+
])
136+
)
137+
})
138+
})
139+
},
140+
})

integration-tests/http/__tests__/cart/store/cart.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,8 +832,10 @@ medusaIntegrationTestRunner({
832832
tax_lines: [
833833
{
834834
code: "CADEFAULT",
835+
data: null,
835836
description: "CA Default Rate",
836837
id: expect.any(String),
838+
metadata: null,
837839
provider_id: "system",
838840
rate: 5,
839841
},
@@ -954,8 +956,10 @@ medusaIntegrationTestRunner({
954956
tax_lines: [
955957
{
956958
code: "CADEFAULT",
959+
data: null,
957960
description: "CA Default Rate",
958961
id: expect.any(String),
962+
metadata: null,
959963
provider_id: "system",
960964
rate: 5,
961965
},
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
2+
import { Modules, ProductStatus } from "@medusajs/utils"
3+
import {
4+
adminHeaders,
5+
createAdminUser,
6+
} from "../../../helpers/create-admin-user"
7+
8+
jest.setTimeout(300000)
9+
10+
medusaIntegrationTestRunner({
11+
testSuite: ({ dbConnection, getContainer, api, dbUtils }) => {
12+
let container
13+
let region
14+
let product
15+
let productExtra
16+
let salesChannel
17+
18+
beforeAll(async () => {
19+
container = getContainer()
20+
await createAdminUser(dbConnection, adminHeaders, container)
21+
22+
const shippingProfile = (
23+
await api.post(
24+
"/admin/shipping-profiles",
25+
{ name: "default", type: "default" },
26+
adminHeaders
27+
)
28+
).data.shipping_profile
29+
30+
const taxService = container.resolve(Modules.TAX)
31+
await taxService.createTaxRegions([
32+
{
33+
country_code: "GB",
34+
provider_id: "tp_tax-data-provider_data-provider",
35+
default_tax_rate: {
36+
name: "GB Standard Rate",
37+
rate: 20,
38+
code: "GB_STD",
39+
},
40+
},
41+
])
42+
43+
region = (
44+
await api.post(
45+
"/admin/regions",
46+
{ name: "GB", currency_code: "gbp", countries: ["gb"] },
47+
adminHeaders
48+
)
49+
).data.region
50+
51+
product = (
52+
await api.post(
53+
"/admin/products",
54+
{
55+
title: "Base Product",
56+
status: ProductStatus.PUBLISHED,
57+
shipping_profile_id: shippingProfile.id,
58+
options: [{ title: "Size", values: ["S"] }],
59+
variants: [
60+
{
61+
title: "S",
62+
manage_inventory: false,
63+
options: { Size: "S" },
64+
prices: [{ amount: 1000, currency_code: "gbp" }],
65+
},
66+
],
67+
},
68+
adminHeaders
69+
)
70+
).data.product
71+
72+
productExtra = (
73+
await api.post(
74+
"/admin/products",
75+
{
76+
title: "Extra Product",
77+
status: ProductStatus.PUBLISHED,
78+
shipping_profile_id: shippingProfile.id,
79+
options: [{ title: "Size", values: ["S"] }],
80+
variants: [
81+
{
82+
title: "S",
83+
manage_inventory: false,
84+
options: { Size: "S" },
85+
prices: [{ amount: 500, currency_code: "gbp" }],
86+
},
87+
],
88+
},
89+
adminHeaders
90+
)
91+
).data.product
92+
93+
salesChannel = (
94+
await api.post(
95+
"/admin/sales-channels",
96+
{ name: "GB Webshop", description: "channel" },
97+
adminHeaders
98+
)
99+
).data.sales_channel
100+
101+
await dbUtils.snapshot()
102+
})
103+
104+
describe("Order edit tax line data field", () => {
105+
it("should persist provider data on tax lines after order edit retax", async () => {
106+
const orderModule = container.resolve(Modules.ORDER)
107+
108+
const order = await orderModule.createOrders({
109+
region_id: region.id,
110+
email: "customer@test.com",
111+
items: [
112+
{
113+
title: "Base item",
114+
variant_id: product.variants[0].id,
115+
quantity: 1,
116+
unit_price: 1000,
117+
},
118+
],
119+
sales_channel_id: salesChannel.id,
120+
shipping_address: {
121+
first_name: "Test",
122+
last_name: "User",
123+
address_1: "1 Oxford Street",
124+
city: "London",
125+
country_code: "GB",
126+
postal_code: "W1D 1AN",
127+
},
128+
billing_address: {
129+
first_name: "Test",
130+
last_name: "User",
131+
address_1: "1 Oxford Street",
132+
city: "London",
133+
country_code: "GB",
134+
postal_code: "W1D 1AN",
135+
},
136+
shipping_methods: [
137+
{
138+
name: "Standard shipping",
139+
amount: 500,
140+
},
141+
],
142+
currency_code: "gbp",
143+
})
144+
145+
await api.post(
146+
"/admin/order-edits",
147+
{ order_id: order.id, description: "Add item" },
148+
adminHeaders
149+
)
150+
151+
await api.post(
152+
`/admin/order-edits/${order.id}/items`,
153+
{
154+
items: [
155+
{
156+
variant_id: productExtra.variants[0].id,
157+
quantity: 1,
158+
},
159+
],
160+
},
161+
adminHeaders
162+
)
163+
164+
// request triggers setOrderTaxLinesForItemsStep (retax via data provider)
165+
const preview = (
166+
await api.post(
167+
`/admin/order-edits/${order.id}/request`,
168+
{},
169+
adminHeaders
170+
)
171+
).data.order_preview
172+
173+
const previewItem = preview.items.find(
174+
(i) => i.variant_id === productExtra.variants[0].id
175+
)
176+
177+
expect(previewItem).toBeDefined()
178+
expect(previewItem.tax_lines).toEqual(
179+
expect.arrayContaining([
180+
expect.objectContaining({
181+
code: "GB_STD",
182+
rate: 20,
183+
provider_id: "tax-data-provider",
184+
data: { state_rate: 4.0, county_rate: 3.0, city_rate: 1.9 },
185+
}),
186+
])
187+
)
188+
189+
await api.post(
190+
`/admin/order-edits/${order.id}/confirm`,
191+
{},
192+
adminHeaders
193+
)
194+
195+
const confirmedOrder = (
196+
await api.get(`/admin/orders/${order.id}`, adminHeaders)
197+
).data.order
198+
199+
const confirmedItem = confirmedOrder.items.find(
200+
(i) => i.variant_id === productExtra.variants[0].id
201+
)
202+
203+
expect(confirmedItem).toBeDefined()
204+
expect(confirmedItem.tax_lines).toEqual(
205+
expect.arrayContaining([
206+
expect.objectContaining({
207+
code: "GB_STD",
208+
rate: 20,
209+
provider_id: "tax-data-provider",
210+
data: { state_rate: 4.0, county_rate: 3.0, city_rate: 1.9 },
211+
}),
212+
])
213+
)
214+
})
215+
})
216+
},
217+
})

0 commit comments

Comments
 (0)