fix(medusa): gift card values & taxes are calculated correctly (#2777)
* chore: tax_rate is added to giftcards * chore: minor * chore: update gift card tax calculations to look at giftCard.tax_rate * chore: gift card transactions use tax rate from gift card for legacy * fix: gift cart total check for transaction should check the length * chore: use tax exclusive cost + use giftcard tax rate for gctransactions + refactor * chore: fix integration test * chore: address issues brought up in comments * chore: move gift card creation as a part of order service on order placed * chore: add type handling for gift card creation * chore: fix specs * chore: use taxLines to calculate tax of a gift card * chore: specs for line items containing gift cards and without * chore: add integration specs + fix tax rate bug * chore: round totaling + add GC application specs * chore: cleanup trialables * chore: write migration script to backfill gift cards with null tax_rate * chore: update legacy totals service for gift cards * chore: add changeset * chore: address PR review changes * chore: fix tests based on new totals calc * chore: address review comments Co-authored-by: adrien2p <adrien.deperetti@gmail.com> Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
co-authored by
adrien2p
Oliver Windall Juhl
parent
3113d8024f
commit
8a60a73389
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": minor
|
||||||
|
---
|
||||||
|
fix: Gift cart tax claculation wrongly calculated
|
||||||
|
|
||||||
|
Adds tax_rate column to gift_card table to calculate tax accurately for a gift card. This change includes a backfill migration to update gift cards that were already created.
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
const startServerWithEnvironment =
|
||||||
|
require("../../../../../helpers/start-server-with-environment").default
|
||||||
|
const path = require("path")
|
||||||
|
const { useApi } = require("../../../../../helpers/use-api")
|
||||||
|
const { useDb } = require("../../../../../helpers/use-db")
|
||||||
|
const { GiftCard, TaxRate } = require("@medusajs/medusa")
|
||||||
|
|
||||||
|
const {
|
||||||
|
simpleRegionFactory,
|
||||||
|
simpleProductFactory,
|
||||||
|
simpleCartFactory,
|
||||||
|
simpleCustomerFactory,
|
||||||
|
simpleGiftCardFactory,
|
||||||
|
} = require("../../../../factories")
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] Gift Card - Tax calculations", () => {
|
||||||
|
let medusaProcess
|
||||||
|
let dbConnection
|
||||||
|
let customerData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const cwd = path.resolve(path.join(__dirname, "..", "..", "..", ".."))
|
||||||
|
const [process, connection] = await startServerWithEnvironment({
|
||||||
|
cwd,
|
||||||
|
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
|
||||||
|
})
|
||||||
|
dbConnection = connection
|
||||||
|
medusaProcess = process
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.shutdown()
|
||||||
|
|
||||||
|
medusaProcess.kill()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /store/carts/:id", () => {
|
||||||
|
let product
|
||||||
|
let customer
|
||||||
|
let region
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
region = await simpleRegionFactory(dbConnection, {
|
||||||
|
id: "tax-region",
|
||||||
|
currency_code: "usd",
|
||||||
|
countries: ["us"],
|
||||||
|
tax_rate: 19,
|
||||||
|
name: "region test",
|
||||||
|
includes_tax: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
customer = await simpleCustomerFactory(dbConnection, { password: 'medusatest' })
|
||||||
|
customerData = {
|
||||||
|
email: customer.email,
|
||||||
|
password: "medusatest",
|
||||||
|
first_name: customer.first_name,
|
||||||
|
last_name: customer.last_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
product = await simpleProductFactory(dbConnection, {
|
||||||
|
is_giftcard: true,
|
||||||
|
discountable: false,
|
||||||
|
options: [{ id: "denom", title: "Denomination" }],
|
||||||
|
variants: [{
|
||||||
|
title: "Gift Card",
|
||||||
|
prices: [{
|
||||||
|
amount: 30000,
|
||||||
|
currency: "usd",
|
||||||
|
region_id: region.id,
|
||||||
|
}],
|
||||||
|
options: [{ option_id: "denom", value: "Denomination" }],
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adding a gift card purchase to cart treats it like buying a product", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const customerRes = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createCartRes = await api.post("/store/carts", {
|
||||||
|
region_id: region.id,
|
||||||
|
items: [{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createCartRes.status).toEqual(200)
|
||||||
|
|
||||||
|
const cartWithGiftcard = createCartRes.data.cart
|
||||||
|
await api.post(`/store/carts/${cartWithGiftcard.id}`, {
|
||||||
|
customer_id: customerRes.data.customer.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(cartWithGiftcard.items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
is_giftcard: true,
|
||||||
|
unit_price: 30000,
|
||||||
|
quantity: 1,
|
||||||
|
subtotal: 25210,
|
||||||
|
tax_total: 4790,
|
||||||
|
original_tax_total: 4790,
|
||||||
|
original_total: 30000,
|
||||||
|
total: 30000,
|
||||||
|
variant: expect.objectContaining({
|
||||||
|
id: product.variants[0].id,
|
||||||
|
product: expect.objectContaining({
|
||||||
|
is_giftcard: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("purchasing a gift card via an order creates a gift card entity", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const customerRes = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cartFactory = await simpleCartFactory(dbConnection, {
|
||||||
|
customer,
|
||||||
|
region,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.post(
|
||||||
|
`/store/carts/${cartFactory.id}/line-items`,
|
||||||
|
{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCartResponse = await api.get(`/store/carts/${cartFactory.id}`)
|
||||||
|
const cart = getCartResponse.data.cart
|
||||||
|
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||||
|
const createdOrder = await api.post(`/store/carts/${cart.id}/complete-cart`)
|
||||||
|
|
||||||
|
const createdGiftCards = await dbConnection.manager.find(GiftCard, {
|
||||||
|
where: { order_id: createdOrder.data.data.id }
|
||||||
|
})
|
||||||
|
const createdGiftCard = createdGiftCards[0]
|
||||||
|
|
||||||
|
expect(createdOrder.data.type).toEqual("order")
|
||||||
|
expect(createdOrder.status).toEqual(200)
|
||||||
|
expect(createdGiftCards.length).toEqual(1)
|
||||||
|
expect(createdGiftCard.tax_rate).toEqual(19)
|
||||||
|
expect(createdGiftCard.value).toEqual(25210)
|
||||||
|
expect(createdGiftCard.balance).toEqual(25210)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applying a gift card shows correct total values", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const giftCard = await simpleGiftCardFactory(dbConnection, {
|
||||||
|
region_id: region.id,
|
||||||
|
value: 25210,
|
||||||
|
balance: 25210,
|
||||||
|
tax_rate: region.tax_rate,
|
||||||
|
})
|
||||||
|
const expensiveProduct = await simpleProductFactory(dbConnection, {
|
||||||
|
variants: [{
|
||||||
|
title: "Product cost higher than gift card balance",
|
||||||
|
prices: [{
|
||||||
|
amount: 50000,
|
||||||
|
currency: "usd",
|
||||||
|
region_id: region.id,
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
const customerRes = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cartFactory = await simpleCartFactory(dbConnection, {
|
||||||
|
customer,
|
||||||
|
region,
|
||||||
|
line_items: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.post(
|
||||||
|
`/store/carts/${cartFactory.id}/line-items`,
|
||||||
|
{
|
||||||
|
variant_id: expensiveProduct.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add gift card to cart
|
||||||
|
await api.post(`/store/carts/${cartFactory.id}`, {
|
||||||
|
gift_cards: [{ code: giftCard.code }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCartResponse = await api.get(`/store/carts/${cartFactory.id}`)
|
||||||
|
const cart = getCartResponse.data.cart
|
||||||
|
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||||
|
const createdOrder = await api.post(`/store/carts/${cart.id}/complete-cart`)
|
||||||
|
|
||||||
|
expect(createdOrder.data.data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
subtotal: 42017,
|
||||||
|
discount_total: 0,
|
||||||
|
shipping_total: 0,
|
||||||
|
refunded_total: 0,
|
||||||
|
paid_total: 20000,
|
||||||
|
refundable_amount: 20000,
|
||||||
|
gift_card_total: 25210,
|
||||||
|
gift_card_tax_total: 4790,
|
||||||
|
tax_total: 3193,
|
||||||
|
total: 20000,
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
includes_tax: true,
|
||||||
|
unit_price: 50000,
|
||||||
|
is_giftcard: false,
|
||||||
|
quantity: 1,
|
||||||
|
subtotal: 42017,
|
||||||
|
discount_total: 0,
|
||||||
|
total: 50000,
|
||||||
|
original_total: 50000,
|
||||||
|
original_tax_total: 7983,
|
||||||
|
tax_total: 7983,
|
||||||
|
refundable: 50000,
|
||||||
|
tax_lines: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
rate: 19
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
const startServerWithEnvironment =
|
||||||
|
require("../../../../../helpers/start-server-with-environment").default
|
||||||
|
const path = require("path")
|
||||||
|
const { useApi } = require("../../../../../helpers/use-api")
|
||||||
|
const { useDb } = require("../../../../../helpers/use-db")
|
||||||
|
const { GiftCard } = require("@medusajs/medusa")
|
||||||
|
|
||||||
|
const {
|
||||||
|
simpleRegionFactory,
|
||||||
|
simpleProductFactory,
|
||||||
|
simpleCartFactory,
|
||||||
|
simpleCustomerFactory,
|
||||||
|
simpleGiftCardFactory,
|
||||||
|
} = require("../../../../factories")
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
describe("Gift Card - Tax calculations", () => {
|
||||||
|
let medusaProcess
|
||||||
|
let dbConnection
|
||||||
|
let customerData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const cwd = path.resolve(path.join(__dirname, "..", "..", "..", ".."))
|
||||||
|
const [process, connection] = await startServerWithEnvironment({
|
||||||
|
cwd,
|
||||||
|
env: {}
|
||||||
|
})
|
||||||
|
dbConnection = connection
|
||||||
|
medusaProcess = process
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.shutdown()
|
||||||
|
|
||||||
|
medusaProcess.kill()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /store/carts/:id", () => {
|
||||||
|
let product
|
||||||
|
let customer
|
||||||
|
let region
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
region = await simpleRegionFactory(dbConnection, {
|
||||||
|
id: "tax-region-1",
|
||||||
|
currency_code: "usd",
|
||||||
|
countries: ["us"],
|
||||||
|
tax_rate: 19,
|
||||||
|
name: "region test",
|
||||||
|
})
|
||||||
|
|
||||||
|
customer = await simpleCustomerFactory(dbConnection, { password: 'medusatest' })
|
||||||
|
|
||||||
|
customerData = {
|
||||||
|
email: customer.email,
|
||||||
|
password: "medusatest",
|
||||||
|
first_name: customer.first_name,
|
||||||
|
last_name: customer.last_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
product = await simpleProductFactory(dbConnection, {
|
||||||
|
is_giftcard: true,
|
||||||
|
discountable: false,
|
||||||
|
options: [{ id: "denom", title: "Denomination" }],
|
||||||
|
variants: [{
|
||||||
|
title: "Gift Card",
|
||||||
|
prices: [{ currency: "usd", amount: 30000, region_id: region.id }],
|
||||||
|
options: [{ option_id: "denom", value: "Denomination" }],
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adding a gift card purchase to cart treats it like buying a product", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const customerResponse = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createCartResponse = await api.post("/store/carts", {
|
||||||
|
region_id: region.id,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createCartResponse.status).toEqual(200)
|
||||||
|
|
||||||
|
const cartWithGiftcard = createCartResponse.data.cart
|
||||||
|
await api.post(`/store/carts/${cartWithGiftcard.id}`, {
|
||||||
|
customer_id: customerResponse.data.customer.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(cartWithGiftcard.items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
is_giftcard: true,
|
||||||
|
unit_price: 30000,
|
||||||
|
quantity: 1,
|
||||||
|
subtotal: 30000,
|
||||||
|
tax_total: 5700,
|
||||||
|
original_tax_total: 5700,
|
||||||
|
original_total: 35700,
|
||||||
|
total: 35700,
|
||||||
|
variant: expect.objectContaining({
|
||||||
|
id: product.variants[0].id,
|
||||||
|
product: expect.objectContaining({
|
||||||
|
is_giftcard: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("purchasing a gift card via an order creates a gift card entity", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const customerResponse = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cartFactory = await simpleCartFactory(dbConnection, { customer, region })
|
||||||
|
|
||||||
|
const response = await api.post(
|
||||||
|
`/store/carts/${cartFactory.id}/line-items`,
|
||||||
|
{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const cartResponse = await api.get(`/store/carts/${cartFactory.id}`)
|
||||||
|
|
||||||
|
const cart = cartResponse.data.cart
|
||||||
|
|
||||||
|
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||||
|
|
||||||
|
const createdOrderResponse = await api.post(`/store/carts/${cart.id}/complete-cart`)
|
||||||
|
const createdGiftCards = await dbConnection.manager.find(GiftCard, {
|
||||||
|
where: { order_id: createdOrderResponse.data.data.id }
|
||||||
|
})
|
||||||
|
const createdGiftCard = createdGiftCards[0]
|
||||||
|
|
||||||
|
expect(createdOrderResponse.data.type).toEqual("order")
|
||||||
|
expect(createdOrderResponse.status).toEqual(200)
|
||||||
|
expect(createdGiftCards.length).toEqual(1)
|
||||||
|
expect(createdGiftCard.tax_rate).toEqual(19)
|
||||||
|
expect(createdGiftCard.value).toEqual(30000)
|
||||||
|
expect(createdGiftCard.balance).toEqual(30000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applying a gift card shows correct total values", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const giftCard = await simpleGiftCardFactory(dbConnection, {
|
||||||
|
region_id: region.id,
|
||||||
|
value: 30000,
|
||||||
|
balance: 30000,
|
||||||
|
tax_rate: region.tax_rate,
|
||||||
|
})
|
||||||
|
const expensiveProduct = await simpleProductFactory(dbConnection, {
|
||||||
|
variants: [{
|
||||||
|
title: "Product cost higher than gift card balance",
|
||||||
|
prices: [{
|
||||||
|
amount: 50000,
|
||||||
|
currency: "usd",
|
||||||
|
region_id: region.id,
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
const customerRes = await api.post("/store/customers", customerData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cartFactory = await simpleCartFactory(dbConnection, {
|
||||||
|
customer,
|
||||||
|
region,
|
||||||
|
line_items: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.post(
|
||||||
|
`/store/carts/${cartFactory.id}/line-items`,
|
||||||
|
{
|
||||||
|
variant_id: expensiveProduct.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add gift card to cart
|
||||||
|
await api.post(`/store/carts/${cartFactory.id}`, {
|
||||||
|
gift_cards: [{ code: giftCard.code }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCartResponse = await api.get(`/store/carts/${cartFactory.id}`)
|
||||||
|
const cart = getCartResponse.data.cart
|
||||||
|
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||||
|
const createdOrder = await api.post(`/store/carts/${cart.id}/complete-cart`)
|
||||||
|
|
||||||
|
expect(createdOrder.data.data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
subtotal: 50000,
|
||||||
|
discount_total: 0,
|
||||||
|
shipping_total: 0,
|
||||||
|
refunded_total: 0,
|
||||||
|
paid_total: 23800,
|
||||||
|
refundable_amount: 23800,
|
||||||
|
gift_card_total: 30000,
|
||||||
|
gift_card_tax_total: 5700,
|
||||||
|
tax_total: 3800,
|
||||||
|
total: 23800,
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
unit_price: 50000,
|
||||||
|
is_giftcard: false,
|
||||||
|
quantity: 1,
|
||||||
|
subtotal: 50000,
|
||||||
|
discount_total: 0,
|
||||||
|
total: 59500,
|
||||||
|
original_total: 59500,
|
||||||
|
original_tax_total: 9500,
|
||||||
|
tax_total: 9500,
|
||||||
|
refundable: 59500,
|
||||||
|
tax_lines: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
rate: 19
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -139,6 +139,7 @@ describe("Order Totals", () => {
|
|||||||
region_id: region.id,
|
region_id: region.id,
|
||||||
value: 160000,
|
value: 160000,
|
||||||
balance: 160000,
|
balance: 160000,
|
||||||
|
tax_rate: 25,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add variant 1 to cart
|
// Add variant 1 to cart
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type GiftCardFactoryData = {
|
|||||||
region_id: string
|
region_id: string
|
||||||
value: number
|
value: number
|
||||||
balance: number
|
balance: number
|
||||||
|
tax_rate?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const simpleGiftCardFactory = async (
|
export const simpleGiftCardFactory = async (
|
||||||
@@ -27,6 +28,7 @@ export const simpleGiftCardFactory = async (
|
|||||||
region_id: data.region_id,
|
region_id: data.region_id,
|
||||||
value: data.value,
|
value: data.value,
|
||||||
balance: data.balance,
|
balance: data.balance,
|
||||||
|
tax_rate: data.tax_rate,
|
||||||
})
|
})
|
||||||
|
|
||||||
return await manager.save(toSave)
|
return await manager.save(toSave)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type LineItemFactoryData = {
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
should_merge?: boolean
|
should_merge?: boolean
|
||||||
allow_discounts?: boolean
|
allow_discounts?: boolean
|
||||||
|
is_giftcard?: boolean
|
||||||
unit_price?: number
|
unit_price?: number
|
||||||
quantity?: number
|
quantity?: number
|
||||||
fulfilled_quantity?: boolean
|
fulfilled_quantity?: boolean
|
||||||
@@ -74,6 +75,7 @@ export const simpleLineItemFactory = async (
|
|||||||
adjustments: data.adjustments,
|
adjustments: data.adjustments,
|
||||||
includes_tax: data.includes_tax,
|
includes_tax: data.includes_tax,
|
||||||
order_edit_id: data.order_edit_id,
|
order_edit_id: data.order_edit_id,
|
||||||
|
is_giftcard: data.is_giftcard || false
|
||||||
})
|
})
|
||||||
|
|
||||||
const line = await manager.save(toSave)
|
const line = await manager.save(toSave)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { defaultAdminGiftCardFields, defaultAdminGiftCardRelations } from "."
|
|||||||
|
|
||||||
import { GiftCardService } from "../../../../services"
|
import { GiftCardService } from "../../../../services"
|
||||||
import { Type } from "class-transformer"
|
import { Type } from "class-transformer"
|
||||||
import { validator } from "../../../../utils/validator"
|
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,16 +86,13 @@ import { EntityManager } from "typeorm"
|
|||||||
* $ref: "#/components/responses/500_error"
|
* $ref: "#/components/responses/500_error"
|
||||||
*/
|
*/
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const validated = await validator(AdminPostGiftCardsReq, req.body)
|
const validatedBody: AdminPostGiftCardsReq & { balance?: number } = req.validatedBody
|
||||||
|
validatedBody.balance = validatedBody.value
|
||||||
|
|
||||||
const giftCardService: GiftCardService = req.scope.resolve("giftCardService")
|
const giftCardService: GiftCardService = req.scope.resolve("giftCardService")
|
||||||
|
|
||||||
const manager: EntityManager = req.scope.resolve("manager")
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
const newly = await manager.transaction(async (transactionManager) => {
|
const newly = await manager.transaction(async (transactionManager) => {
|
||||||
return await giftCardService.withTransaction(transactionManager).create({
|
return await giftCardService.withTransaction(transactionManager).create(validatedBody)
|
||||||
...validated,
|
|
||||||
balance: validated.value,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const giftCard = await giftCardService.retrieve(newly.id, {
|
const giftCard = await giftCardService.retrieve(newly.id, {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Router } from "express"
|
|||||||
import "reflect-metadata"
|
import "reflect-metadata"
|
||||||
import { GiftCard } from "../../../.."
|
import { GiftCard } from "../../../.."
|
||||||
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
|
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
|
||||||
import middlewares, { transformQuery } from "../../../middlewares"
|
import middlewares, { transformQuery, transformBody } from "../../../middlewares"
|
||||||
import { AdminGetGiftCardsParams } from "./list-gift-cards"
|
import { AdminGetGiftCardsParams } from "./list-gift-cards"
|
||||||
|
import { AdminPostGiftCardsReq } from './create-gift-card'
|
||||||
|
|
||||||
const route = Router()
|
const route = Router()
|
||||||
|
|
||||||
@@ -20,7 +21,11 @@ export default (app) => {
|
|||||||
middlewares.wrap(require("./list-gift-cards").default)
|
middlewares.wrap(require("./list-gift-cards").default)
|
||||||
)
|
)
|
||||||
|
|
||||||
route.post("/", middlewares.wrap(require("./create-gift-card").default))
|
route.post(
|
||||||
|
"/",
|
||||||
|
transformBody(AdminPostGiftCardsReq),
|
||||||
|
middlewares.wrap(require("./create-gift-card").default)
|
||||||
|
)
|
||||||
|
|
||||||
route.get("/:id", middlewares.wrap(require("./get-gift-card").default))
|
route.get("/:id", middlewares.wrap(require("./get-gift-card").default))
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ export const defaultAdminGiftCardFields: (keyof GiftCard)[] = [
|
|||||||
"region_id",
|
"region_id",
|
||||||
"is_disabled",
|
"is_disabled",
|
||||||
"ends_at",
|
"ends_at",
|
||||||
|
"tax_rate",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"deleted_at",
|
"deleted_at",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||||
|
|
||||||
|
export class addTaxRateToGiftCards1670855241304 implements MigrationInterface {
|
||||||
|
name = "addTaxRateToGiftCards1670855241304"
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "gift_card" ADD COLUMN IF NOT EXISTS tax_rate REAL`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "gift_card" DROP COLUMN IF EXISTS "tax_rate"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ export class GiftCard extends SoftDeletableEntity {
|
|||||||
})
|
})
|
||||||
ends_at: Date
|
ends_at: Date
|
||||||
|
|
||||||
|
@Column({ type: "real", nullable: true })
|
||||||
|
tax_rate: number | null
|
||||||
|
|
||||||
@DbAwareColumn({ type: "jsonb", nullable: true })
|
@DbAwareColumn({ type: "jsonb", nullable: true })
|
||||||
metadata: Record<string, unknown>
|
metadata: Record<string, unknown>
|
||||||
|
|
||||||
@@ -108,6 +111,10 @@ export class GiftCard extends SoftDeletableEntity {
|
|||||||
* description: "The time at which the Gift Card can no longer be used."
|
* description: "The time at which the Gift Card can no longer be used."
|
||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
|
* tax_rate:
|
||||||
|
* description: The gift cards's tax rate that will be applied on calculating totals
|
||||||
|
* type: number
|
||||||
|
* example: 0
|
||||||
* created_at:
|
* created_at:
|
||||||
* type: string
|
* type: string
|
||||||
* description: "The date with timezone at which the resource was created."
|
* description: "The date with timezone at which the resource was created."
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const typeormConfig = {
|
||||||
|
type: process.env.TYPEORM_CONNECTION,
|
||||||
|
url: process.env.TYPEORM_URL,
|
||||||
|
username: process.env.TYPEORM_USERNAME,
|
||||||
|
password: process.env.TYPEORM_PASSWORD,
|
||||||
|
database: process.env.TYPEORM_DATABASE,
|
||||||
|
migrations: [process.env.TYPEORM_MIGRATIONS as string],
|
||||||
|
entities: [process.env.TYPEORM_ENTITIES],
|
||||||
|
logging: true,
|
||||||
|
}
|
||||||
@@ -11,18 +11,9 @@ import {
|
|||||||
import { DiscountConditionProduct } from "../models/discount-condition-product"
|
import { DiscountConditionProduct } from "../models/discount-condition-product"
|
||||||
import { DiscountRule } from "../models/discount-rule"
|
import { DiscountRule } from "../models/discount-rule"
|
||||||
import { DiscountConditionRepository } from "../repositories/discount-condition"
|
import { DiscountConditionRepository } from "../repositories/discount-condition"
|
||||||
dotenv.config()
|
import { typeormConfig } from "./db-config"
|
||||||
|
|
||||||
const typeormConfig = {
|
dotenv.config()
|
||||||
type: process.env.TYPEORM_CONNECTION,
|
|
||||||
url: process.env.TYPEORM_URL,
|
|
||||||
username: process.env.TYPEORM_USERNAME,
|
|
||||||
password: process.env.TYPEORM_PASSWORD,
|
|
||||||
database: process.env.TYPEORM_DATABASE,
|
|
||||||
migrations: [process.env.TYPEORM_MIGRATIONS as string],
|
|
||||||
entities: [process.env.TYPEORM_ENTITIES],
|
|
||||||
logging: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrate = async function ({ typeormConfig }): Promise<void> {
|
const migrate = async function ({ typeormConfig }): Promise<void> {
|
||||||
const connection = await createConnection(typeormConfig)
|
const connection = await createConnection(typeormConfig)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import dotenv from "dotenv"
|
||||||
|
import { createConnection, IsNull } from "typeorm"
|
||||||
|
import Logger from "../loaders/logger"
|
||||||
|
import { GiftCard } from "../models/gift-card"
|
||||||
|
import { typeormConfig } from "./db-config"
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const BATCH_SIZE = 1000
|
||||||
|
const migrationName = 'gift-card-tax-rate-migration'
|
||||||
|
|
||||||
|
Logger.info(`typeormConfig: ${JSON.stringify(typeormConfig)}`)
|
||||||
|
|
||||||
|
const migrate = async function ({ typeormConfig }): Promise<void> {
|
||||||
|
const connection = await createConnection(typeormConfig)
|
||||||
|
|
||||||
|
await connection.transaction(async (manager) => {
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
// Get all the GiftCards where the gift_card.tax_rate is null
|
||||||
|
const giftCardsCount = await manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.withDeleted()
|
||||||
|
.from(GiftCard, "gc")
|
||||||
|
.select("gc.id")
|
||||||
|
.where("gc.tax_rate IS NULL")
|
||||||
|
.getCount()
|
||||||
|
|
||||||
|
const totalBatches = Math.ceil(giftCardsCount / BATCH_SIZE)
|
||||||
|
|
||||||
|
if (totalBatches == 0) {
|
||||||
|
Logger.info(`${migrationName}: No records to update, skipping migration!`)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`${migrationName}: Running migration for ${giftCardsCount} GiftCards`)
|
||||||
|
Logger.info(`${migrationName}: Running migration in ${totalBatches} batch(es) of ${BATCH_SIZE}`)
|
||||||
|
|
||||||
|
for (let batch = 1; batch <= totalBatches; batch++) {
|
||||||
|
Logger.info(`${migrationName}: Starting batch ${batch} of ${totalBatches}`)
|
||||||
|
|
||||||
|
// Get all the GiftCards and its region where the gift_card.tax_rate is null
|
||||||
|
const giftCardRegionRecords = await manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.withDeleted()
|
||||||
|
.from(GiftCard, "gc")
|
||||||
|
.select("gc.id, gc.region_id, gc.tax_rate, r.tax_rate as region_tax_rate")
|
||||||
|
.innerJoin("region", "r", "gc.region_id = r.id")
|
||||||
|
.where("gc.tax_rate IS NULL")
|
||||||
|
.limit(BATCH_SIZE)
|
||||||
|
.offset(offset)
|
||||||
|
.getRawMany()
|
||||||
|
|
||||||
|
// Loop through each gift card record and update the value of gift_card.tax_rate
|
||||||
|
// with region.tax_rate value
|
||||||
|
giftCardRegionRecords.forEach(async (gcr) => {
|
||||||
|
await manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(GiftCard)
|
||||||
|
.set({ tax_rate: gcr.region_tax_rate })
|
||||||
|
.where("id = :id", { id: gcr.id })
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += BATCH_SIZE
|
||||||
|
|
||||||
|
Logger.info(`${migrationName}: Finished batch ${batch} of ${totalBatches}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordsFailedToBackfill = await manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.withDeleted()
|
||||||
|
.from(GiftCard, "gc")
|
||||||
|
.select("gc.id")
|
||||||
|
.where("gc.tax_rate IS NULL")
|
||||||
|
.getCount()
|
||||||
|
|
||||||
|
if (recordsFailedToBackfill == 0) {
|
||||||
|
Logger.info(`${migrationName}: successfully ran for ${giftCardsCount} GiftCards`)
|
||||||
|
} else {
|
||||||
|
Logger.info(`${migrationName}: ${recordsFailedToBackfill} GiftCards have no tax_rate set`)
|
||||||
|
Logger.info(`${migrationName}: 1. Check if all GiftCards have a region associated with it`)
|
||||||
|
Logger.info(`${migrationName}: If not, they need to be associated with a region & re-run migration`)
|
||||||
|
Logger.info(`${migrationName}: 2. Check if regions have a tax_rate added to it`)
|
||||||
|
Logger.info(`${migrationName}: If regions intentionally have no tax_rate, this can be ignored`)
|
||||||
|
Logger.info(`${migrationName}: If not, add a tax_rate to region & re-run migration`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate({ typeormConfig })
|
||||||
|
.then(() => {
|
||||||
|
Logger.info("Database migration completed")
|
||||||
|
process.exit()
|
||||||
|
}).catch((err) => {
|
||||||
|
Logger.error(`Database migration failed - ${JSON.stringify(err)}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default migrate
|
||||||
@@ -3,18 +3,9 @@ import { createConnection, SelectQueryBuilder } from "typeorm"
|
|||||||
import Logger from "../loaders/logger"
|
import Logger from "../loaders/logger"
|
||||||
import { LineItem } from "../models/line-item"
|
import { LineItem } from "../models/line-item"
|
||||||
import { LineItemAdjustment } from "../models/line-item-adjustment"
|
import { LineItemAdjustment } from "../models/line-item-adjustment"
|
||||||
dotenv.config()
|
import { typeormConfig } from "./db-config"
|
||||||
|
|
||||||
const typeormConfig = {
|
dotenv.config()
|
||||||
type: process.env.TYPEORM_CONNECTION,
|
|
||||||
url: process.env.TYPEORM_URL,
|
|
||||||
username: process.env.TYPEORM_USERNAME,
|
|
||||||
password: process.env.TYPEORM_PASSWORD,
|
|
||||||
database: process.env.TYPEORM_DATABASE,
|
|
||||||
migrations: [process.env.TYPEORM_MIGRATIONS as string],
|
|
||||||
entities: [process.env.TYPEORM_ENTITIES],
|
|
||||||
logging: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrate = async function({ typeormConfig }) {
|
const migrate = async function({ typeormConfig }) {
|
||||||
const connection = await createConnection(typeormConfig)
|
const connection = await createConnection(typeormConfig)
|
||||||
|
|||||||
@@ -3,20 +3,10 @@ import { createConnection } from "typeorm"
|
|||||||
import Logger from "../loaders/logger"
|
import Logger from "../loaders/logger"
|
||||||
import { Product } from "../models/product"
|
import { Product } from "../models/product"
|
||||||
import { Store } from "../models/store"
|
import { Store } from "../models/store"
|
||||||
|
import { typeormConfig } from "./db-config"
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
const typeormConfig = {
|
|
||||||
type: process.env.TYPEORM_CONNECTION,
|
|
||||||
url: process.env.TYPEORM_URL,
|
|
||||||
username: process.env.TYPEORM_USERNAME,
|
|
||||||
password: process.env.TYPEORM_PASSWORD,
|
|
||||||
database: process.env.TYPEORM_DATABASE,
|
|
||||||
migrations: [process.env.TYPEORM_MIGRATIONS as string],
|
|
||||||
entities: [process.env.TYPEORM_ENTITIES],
|
|
||||||
logging: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrate = async function ({ typeormConfig }): Promise<void> {
|
const migrate = async function ({ typeormConfig }): Promise<void> {
|
||||||
const connection = await createConnection(typeormConfig)
|
const connection = await createConnection(typeormConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -70,3 +70,13 @@ export const giftCards = [
|
|||||||
balance: 10000,
|
balance: 10000,
|
||||||
},
|
},
|
||||||
] as GiftCard[]
|
] as GiftCard[]
|
||||||
|
|
||||||
|
export const giftCardsWithTaxRate = [
|
||||||
|
{
|
||||||
|
id: IdMap.getId("gift_card_1"),
|
||||||
|
code: "CODE",
|
||||||
|
value: 10000,
|
||||||
|
balance: 10000,
|
||||||
|
tax_rate: 20,
|
||||||
|
},
|
||||||
|
] as GiftCard[]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe("GiftCardService", () => {
|
|||||||
retrieve: () => {
|
retrieve: () => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: IdMap.getId("region-id"),
|
id: IdMap.getId("region-id"),
|
||||||
|
tax_rate: 19,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,7 @@ describe("GiftCardService", () => {
|
|||||||
order_id: IdMap.getId("order-id"),
|
order_id: IdMap.getId("order-id"),
|
||||||
is_disabled: true,
|
is_disabled: true,
|
||||||
code: expect.any(String),
|
code: expect.any(String),
|
||||||
|
tax_rate: null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { asClass, asValue, createContainer } from "awilix"
|
|||||||
import {
|
import {
|
||||||
defaultContainerMock,
|
defaultContainerMock,
|
||||||
giftCards,
|
giftCards,
|
||||||
|
giftCardsWithTaxRate,
|
||||||
lineItems,
|
lineItems,
|
||||||
shippingMethods,
|
shippingMethods,
|
||||||
} from "../__fixtures__/new-totals"
|
} from "../__fixtures__/new-totals"
|
||||||
@@ -528,14 +529,17 @@ describe("New totals service", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should compute the gift cards totals amount in a taxable region", async () => {
|
it("should compute the gift cards totals amount using the gift card tax rate", async () => {
|
||||||
const maxAmount = 1000
|
const maxAmount = 1000
|
||||||
|
|
||||||
const testGiftCard = giftCards[0]
|
const testGiftCard = giftCardsWithTaxRate[0]
|
||||||
|
|
||||||
const region = {
|
const region = {
|
||||||
|
// These values aren't involved in calculating tax rates for a gift card
|
||||||
|
// GiftCard.tax_rate will be the source of truth for tax calculations
|
||||||
|
// This is needed for giftCardTransactions backwards compatability reasons
|
||||||
gift_cards_taxable: true,
|
gift_cards_taxable: true,
|
||||||
tax_rate: 20,
|
tax_rate: 0,
|
||||||
} as Region
|
} as Region
|
||||||
|
|
||||||
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
||||||
@@ -556,12 +560,13 @@ describe("New totals service", () => {
|
|||||||
|
|
||||||
it("should compute the gift cards totals amount in non taxable region using gift card transactions", async () => {
|
it("should compute the gift cards totals amount in non taxable region using gift card transactions", async () => {
|
||||||
const maxAmount = 1000
|
const maxAmount = 1000
|
||||||
|
const testGiftCard = giftCards[0]
|
||||||
const giftCardTransactions = [
|
const giftCardTransactions = [
|
||||||
{
|
{
|
||||||
tax_rate: 20,
|
tax_rate: 20,
|
||||||
is_taxable: false,
|
is_taxable: false,
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
|
gift_card: testGiftCard
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -572,7 +577,7 @@ describe("New totals service", () => {
|
|||||||
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
||||||
maxAmount,
|
maxAmount,
|
||||||
{
|
{
|
||||||
giftCardTransactions: giftCardTransactions,
|
giftCardTransactions,
|
||||||
region,
|
region,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -587,12 +592,13 @@ describe("New totals service", () => {
|
|||||||
|
|
||||||
it("should compute the gift cards totals amount in a taxable region using gift card transactions", async () => {
|
it("should compute the gift cards totals amount in a taxable region using gift card transactions", async () => {
|
||||||
const maxAmount = 1000
|
const maxAmount = 1000
|
||||||
|
const testGiftCard = giftCards[0]
|
||||||
const giftCardTransactions = [
|
const giftCardTransactions = [
|
||||||
{
|
{
|
||||||
tax_rate: 20,
|
tax_rate: 20,
|
||||||
is_taxable: null,
|
is_taxable: null,
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
|
gift_card: testGiftCard
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -616,6 +622,42 @@ describe("New totals service", () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should compute the gift cards totals amount using gift card transactions for gift card with tax_rate", async () => {
|
||||||
|
const maxAmount = 1000
|
||||||
|
const testGiftCard = giftCardsWithTaxRate[0]
|
||||||
|
const giftCardTransactions = [
|
||||||
|
{
|
||||||
|
tax_rate: 20,
|
||||||
|
is_taxable: null,
|
||||||
|
amount: 1000,
|
||||||
|
gift_card: testGiftCard
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const region = {
|
||||||
|
// These values aren't involved in calculating tax rates for a gift card
|
||||||
|
// GiftCard.tax_rate will be the source of truth for tax calculations
|
||||||
|
// This is needed for giftCardTransactions backwards compatability reasons
|
||||||
|
gift_cards_taxable: false,
|
||||||
|
tax_rate: 99,
|
||||||
|
} as Region
|
||||||
|
|
||||||
|
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
||||||
|
maxAmount,
|
||||||
|
{
|
||||||
|
giftCardTransactions: giftCardTransactions,
|
||||||
|
region,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(gitCardTotals).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
total: 1000,
|
||||||
|
tax_total: 200,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe("OrderService", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
const giftCardService = {
|
const giftCardService = {
|
||||||
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
createTransaction: jest.fn(),
|
createTransaction: jest.fn(),
|
||||||
withTransaction: function () {
|
withTransaction: function () {
|
||||||
@@ -128,6 +129,7 @@ describe("OrderService", () => {
|
|||||||
total: 100,
|
total: 100,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
update: jest.fn(() => Promise.resolve()),
|
||||||
withTransaction: function () {
|
withTransaction: function () {
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
@@ -191,10 +193,7 @@ describe("OrderService", () => {
|
|||||||
discount_total: 0,
|
discount_total: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
orderService.cartService_.retrieveWithTotals = jest.fn(() =>
|
orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cart))
|
||||||
Promise.resolve(cart)
|
|
||||||
)
|
|
||||||
orderService.cartService_.update = jest.fn(() => Promise.resolve())
|
|
||||||
|
|
||||||
await orderService.createFromCart("cart_id")
|
await orderService.createFromCart("cart_id")
|
||||||
const order = {
|
const order = {
|
||||||
@@ -214,7 +213,7 @@ describe("OrderService", () => {
|
|||||||
|
|
||||||
expect(cartService.retrieveWithTotals).toHaveBeenCalledTimes(1)
|
expect(cartService.retrieveWithTotals).toHaveBeenCalledTimes(1)
|
||||||
expect(cartService.retrieveWithTotals).toHaveBeenCalledWith("cart_id", {
|
expect(cartService.retrieveWithTotals).toHaveBeenCalledWith("cart_id", {
|
||||||
relations: ["region", "payment"],
|
relations: ["region", "payment", "items"],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(paymentProviderService.updatePayment).toHaveBeenCalledTimes(1)
|
expect(paymentProviderService.updatePayment).toHaveBeenCalledTimes(1)
|
||||||
@@ -248,6 +247,85 @@ describe("OrderService", () => {
|
|||||||
expect(orderRepo.save).toHaveBeenCalledWith(order)
|
expect(orderRepo.save).toHaveBeenCalledWith(order)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("gift card creation", () => {
|
||||||
|
const taxLineRateOne = 20
|
||||||
|
const taxLineRateTwo = 10
|
||||||
|
const giftCardValue = 100
|
||||||
|
const totalGiftCardsPurchased = 2
|
||||||
|
const expectedGiftCardTaxRate = taxLineRateOne + taxLineRateTwo
|
||||||
|
const lineItemWithGiftCard = {
|
||||||
|
id: "item_1",
|
||||||
|
variant_id: "variant-1",
|
||||||
|
quantity: 2,
|
||||||
|
is_giftcard: true,
|
||||||
|
subtotal: giftCardValue * totalGiftCardsPurchased,
|
||||||
|
quantity: totalGiftCardsPurchased,
|
||||||
|
metadata: {},
|
||||||
|
tax_lines: [{
|
||||||
|
rate: taxLineRateOne
|
||||||
|
}, {
|
||||||
|
rate: taxLineRateTwo
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItemWithoutGiftCard = {
|
||||||
|
...lineItemWithGiftCard,
|
||||||
|
is_giftcard: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartWithGiftcard = {
|
||||||
|
id: "id",
|
||||||
|
email: "test@test.com",
|
||||||
|
customer_id: "cus_1234",
|
||||||
|
payment: {},
|
||||||
|
region_id: "test",
|
||||||
|
region: {
|
||||||
|
id: "test",
|
||||||
|
currency_code: "eur",
|
||||||
|
name: "test",
|
||||||
|
tax_rate: 25,
|
||||||
|
},
|
||||||
|
shipping_address_id: "1234",
|
||||||
|
billing_address_id: "1234",
|
||||||
|
gift_cards: [],
|
||||||
|
discounts: [],
|
||||||
|
shipping_methods: [{ id: "method_1" }],
|
||||||
|
items: [lineItemWithGiftCard],
|
||||||
|
total: 100,
|
||||||
|
subtotal: 100,
|
||||||
|
discount_total: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartWithoutGiftcard = {
|
||||||
|
...cartWithGiftcard,
|
||||||
|
items: [lineItemWithoutGiftCard],
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates gift cards when a lineItem contains a gift card variant", async () => {
|
||||||
|
orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cartWithGiftcard))
|
||||||
|
|
||||||
|
await orderService.createFromCart("id")
|
||||||
|
|
||||||
|
expect(giftCardService.create).toHaveBeenCalledTimes(totalGiftCardsPurchased)
|
||||||
|
expect(giftCardService.create).toHaveBeenCalledWith({
|
||||||
|
order_id: "id",
|
||||||
|
region_id: "test",
|
||||||
|
value: giftCardValue,
|
||||||
|
balance: giftCardValue,
|
||||||
|
metadata: {},
|
||||||
|
tax_rate: expectedGiftCardTaxRate
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not create gift cards when a lineItem doesn't contains a gift card variant", async () => {
|
||||||
|
orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cartWithoutGiftcard))
|
||||||
|
|
||||||
|
await orderService.createFromCart("id")
|
||||||
|
|
||||||
|
expect(giftCardService.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("creates gift card transactions", async () => {
|
it("creates gift card transactions", async () => {
|
||||||
const cart = {
|
const cart = {
|
||||||
id: "cart_id",
|
id: "cart_id",
|
||||||
@@ -273,6 +351,7 @@ describe("OrderService", () => {
|
|||||||
id: "gid",
|
id: "gid",
|
||||||
code: "GC",
|
code: "GC",
|
||||||
balance: 80,
|
balance: 80,
|
||||||
|
tax_rate: 25,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
discounts: [],
|
discounts: [],
|
||||||
@@ -289,7 +368,6 @@ describe("OrderService", () => {
|
|||||||
orderService.cartService_.retrieveWithTotals = () => {
|
orderService.cartService_.retrieveWithTotals = () => {
|
||||||
return Promise.resolve(cart)
|
return Promise.resolve(cart)
|
||||||
}
|
}
|
||||||
orderService.cartService_.update = () => Promise.resolve()
|
|
||||||
|
|
||||||
await orderService.createFromCart("cart_id")
|
await orderService.createFromCart("cart_id")
|
||||||
const order = {
|
const order = {
|
||||||
@@ -308,6 +386,7 @@ describe("OrderService", () => {
|
|||||||
id: "gid",
|
id: "gid",
|
||||||
code: "GC",
|
code: "GC",
|
||||||
balance: 80,
|
balance: 80,
|
||||||
|
tax_rate: 25,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
@@ -438,7 +517,6 @@ describe("OrderService", () => {
|
|||||||
total: 100,
|
total: 100,
|
||||||
}
|
}
|
||||||
orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
|
orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
|
||||||
orderService.cartService_.update = () => Promise.resolve()
|
|
||||||
const res = orderService.createFromCart(cart)
|
const res = orderService.createFromCart(cart)
|
||||||
await expect(res).rejects.toThrow(
|
await expect(res).rejects.toThrow(
|
||||||
"Variant with id: variant-1 does not have the required inventory"
|
"Variant with id: variant-1 does not have the required inventory"
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ describe("TotalsService", () => {
|
|||||||
const featureFlagRouter = new FlagRouter({
|
const featureFlagRouter = new FlagRouter({
|
||||||
[TaxInclusivePricingFeatureFlag.key]: false,
|
[TaxInclusivePricingFeatureFlag.key]: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const container = {
|
const container = {
|
||||||
taxProviderService: {
|
taxProviderService: {
|
||||||
withTransaction: function () {
|
withTransaction: function () {
|
||||||
@@ -130,6 +129,14 @@ describe("TotalsService", () => {
|
|||||||
},
|
},
|
||||||
getTaxLines: getTaxLinesMock,
|
getTaxLines: getTaxLinesMock,
|
||||||
},
|
},
|
||||||
|
newTotalsService: {
|
||||||
|
getGiftCardTotals: jest.fn(() => {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
tax_total: 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
taxCalculationStrategy: {},
|
taxCalculationStrategy: {},
|
||||||
featureFlagRouter,
|
featureFlagRouter,
|
||||||
}
|
}
|
||||||
@@ -889,6 +896,14 @@ describe("TotalsService", () => {
|
|||||||
taxCalculationStrategy: {
|
taxCalculationStrategy: {
|
||||||
calculate: calculateMock,
|
calculate: calculateMock,
|
||||||
},
|
},
|
||||||
|
newTotalsService: {
|
||||||
|
getGiftCardTotals: jest.fn(() => {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
tax_total: 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
featureFlagRouter,
|
featureFlagRouter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import randomize from "randomatic"
|
|||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
import { EventBusService } from "."
|
import { EventBusService } from "."
|
||||||
import { TransactionBaseService } from "../interfaces"
|
import { TransactionBaseService } from "../interfaces"
|
||||||
import { GiftCard } from "../models"
|
import { GiftCard, Region } from "../models"
|
||||||
import { GiftCardRepository } from "../repositories/gift-card"
|
import { GiftCardRepository } from "../repositories/gift-card"
|
||||||
import { GiftCardTransactionRepository } from "../repositories/gift-card-transaction"
|
import { GiftCardTransactionRepository } from "../repositories/gift-card-transaction"
|
||||||
import {
|
import {
|
||||||
@@ -160,11 +160,12 @@ class GiftCardService extends TransactionBaseService {
|
|||||||
.retrieve(giftCard.region_id)
|
.retrieve(giftCard.region_id)
|
||||||
|
|
||||||
const code = GiftCardService.generateCode()
|
const code = GiftCardService.generateCode()
|
||||||
|
const taxRate = GiftCardService.resolveTaxRate(giftCard.tax_rate || null, region)
|
||||||
const toCreate = {
|
const toCreate = {
|
||||||
code,
|
code,
|
||||||
...giftCard,
|
...giftCard,
|
||||||
region_id: region.id,
|
region_id: region.id,
|
||||||
|
tax_rate: taxRate,
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = giftCardRepo.create(toCreate)
|
const created = giftCardRepo.create(toCreate)
|
||||||
@@ -180,6 +181,30 @@ class GiftCardService extends TransactionBaseService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tax_rate of the giftcard can depend on whether regions tax gift cards, an input
|
||||||
|
* provided by the user or the tax rate. Based on these conditions, tax_rate changes.
|
||||||
|
* @return the tax rate for the gift card
|
||||||
|
*/
|
||||||
|
protected static resolveTaxRate(
|
||||||
|
giftCardTaxRate: number | null,
|
||||||
|
region: Region
|
||||||
|
): number | null {
|
||||||
|
// A gift card is always associated with a region. If the region doesn't tax gift cards,
|
||||||
|
// return null
|
||||||
|
if (!region.gift_cards_taxable) return null
|
||||||
|
|
||||||
|
// If a tax rate has been provided as an input from an external input, use that
|
||||||
|
// This would handle cases where gift cards are created as a part of an order where taxes better defined
|
||||||
|
// or to handle usecases outside of the opinions of the core.
|
||||||
|
if (giftCardTaxRate) {
|
||||||
|
return giftCardTaxRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the context of the taxRate input, it picks up the tax rate directly from the region
|
||||||
|
return region.tax_rate || null
|
||||||
|
}
|
||||||
|
|
||||||
protected async retrieve_(
|
protected async retrieve_(
|
||||||
selector: Selector<GiftCard>,
|
selector: Selector<GiftCard>,
|
||||||
config: FindConfig<GiftCard> = {}
|
config: FindConfig<GiftCard> = {}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ type LineItemTotals = {
|
|||||||
discount_total: number
|
discount_total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GiftCardTransaction = {
|
||||||
|
tax_rate: number | null
|
||||||
|
is_taxable: boolean | null
|
||||||
|
amount: number
|
||||||
|
gift_card: GiftCard
|
||||||
|
}
|
||||||
|
|
||||||
type ShippingMethodTotals = {
|
type ShippingMethodTotals = {
|
||||||
price: number
|
price: number
|
||||||
tax_total: number
|
tax_total: number
|
||||||
@@ -214,6 +221,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
totals.tax_lines,
|
totals.tax_lines,
|
||||||
calculationContext
|
calculationContext
|
||||||
)
|
)
|
||||||
|
|
||||||
const noDiscountContext = {
|
const noDiscountContext = {
|
||||||
...calculationContext,
|
...calculationContext,
|
||||||
allocation_map: {}, // Don't account for discounts
|
allocation_map: {}, // Don't account for discounts
|
||||||
@@ -451,11 +459,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
giftCards,
|
giftCards,
|
||||||
}: {
|
}: {
|
||||||
region: Region
|
region: Region
|
||||||
giftCardTransactions?: {
|
giftCardTransactions?: GiftCardTransaction[]
|
||||||
tax_rate: number | null
|
|
||||||
is_taxable: boolean | null
|
|
||||||
amount: number
|
|
||||||
}[]
|
|
||||||
giftCards?: GiftCard[]
|
giftCards?: GiftCard[]
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -469,7 +473,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (giftCardTransactions) {
|
if (giftCardTransactions?.length) {
|
||||||
return this.getGiftCardTransactionsTotals({
|
return this.getGiftCardTransactionsTotals({
|
||||||
giftCardTransactions,
|
giftCardTransactions,
|
||||||
region,
|
region,
|
||||||
@@ -485,13 +489,27 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const giftAmount = giftCards.reduce((acc, next) => acc + next.balance, 0)
|
// If a gift card is not taxable, the tax_rate for the giftcard will be null
|
||||||
result.total = Math.min(giftCardableAmount, giftAmount)
|
const { totalGiftCardBalance, totalTaxFromGiftCards } = giftCards.reduce((acc, giftCard) => {
|
||||||
|
let taxableAmount = 0
|
||||||
|
|
||||||
if (region?.gift_cards_taxable) {
|
acc.totalGiftCardBalance += giftCard.balance
|
||||||
result.tax_total = Math.round(result.total * (region.tax_rate / 100))
|
|
||||||
return result
|
taxableAmount = Math.min(acc.giftCardableBalance, giftCard.balance)
|
||||||
}
|
// skip tax, if the taxable amount is not a positive number or tax rate is not set
|
||||||
|
if (taxableAmount <= 0 || !giftCard.tax_rate) return acc
|
||||||
|
|
||||||
|
let taxAmountFromGiftCard = Math.round(taxableAmount * (giftCard.tax_rate / 100))
|
||||||
|
|
||||||
|
acc.totalTaxFromGiftCards += taxAmountFromGiftCard
|
||||||
|
// Update the balance, pass it over to the next gift card (if any) for calculating tax on balance.
|
||||||
|
acc.giftCardableBalance -= taxableAmount
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, { totalGiftCardBalance: 0, totalTaxFromGiftCards: 0, giftCardableBalance: giftCardableAmount })
|
||||||
|
|
||||||
|
result.tax_total = Math.round(totalTaxFromGiftCards)
|
||||||
|
result.total = Math.min(giftCardableAmount, totalGiftCardBalance)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -505,11 +523,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
giftCardTransactions,
|
giftCardTransactions,
|
||||||
region,
|
region,
|
||||||
}: {
|
}: {
|
||||||
giftCardTransactions: {
|
giftCardTransactions: GiftCardTransaction[]
|
||||||
tax_rate: number | null
|
|
||||||
is_taxable: boolean | null
|
|
||||||
amount: number
|
|
||||||
}[]
|
|
||||||
region: { gift_cards_taxable: boolean; tax_rate: number }
|
region: { gift_cards_taxable: boolean; tax_rate: number }
|
||||||
}): { total: number; tax_total: number } {
|
}): { total: number; tax_total: number } {
|
||||||
return giftCardTransactions.reduce(
|
return giftCardTransactions.reduce(
|
||||||
@@ -522,13 +536,18 @@ export default class NewTotalsService extends TransactionBaseService {
|
|||||||
//
|
//
|
||||||
// This is a backwards compatability fix for orders that were created
|
// This is a backwards compatability fix for orders that were created
|
||||||
// before we added the gift card tax rate.
|
// before we added the gift card tax rate.
|
||||||
if (next.is_taxable === null && region?.gift_cards_taxable) {
|
// We prioritize the giftCard.tax_rate as we create a snapshot of the tax
|
||||||
taxMultiplier = region.tax_rate / 100
|
// on order creation to create gift cards on the gift card itself.
|
||||||
|
// If its created outside of the order, we refer to the region tax
|
||||||
|
if (next.is_taxable === null) {
|
||||||
|
if (region?.gift_cards_taxable || next.gift_card?.tax_rate) {
|
||||||
|
taxMultiplier = (next.gift_card?.tax_rate ?? region.tax_rate) / 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: acc.total + next.amount,
|
total: acc.total + next.amount,
|
||||||
tax_total: acc.tax_total + next.amount * taxMultiplier,
|
tax_total: Math.round(acc.tax_total + next.amount * taxMultiplier),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Return,
|
Return,
|
||||||
Swap,
|
Swap,
|
||||||
TrackingLink,
|
TrackingLink,
|
||||||
|
GiftCard,
|
||||||
} from "../models"
|
} from "../models"
|
||||||
import { AddressRepository } from "../repositories/address"
|
import { AddressRepository } from "../repositories/address"
|
||||||
import { OrderRepository } from "../repositories/order"
|
import { OrderRepository } from "../repositories/order"
|
||||||
@@ -302,6 +303,7 @@ class OrderService extends TransactionBaseService {
|
|||||||
relationSet.add("discounts.rule")
|
relationSet.add("discounts.rule")
|
||||||
relationSet.add("gift_cards")
|
relationSet.add("gift_cards")
|
||||||
relationSet.add("gift_card_transactions")
|
relationSet.add("gift_card_transactions")
|
||||||
|
relationSet.add("gift_card_transactions.gift_card")
|
||||||
relationSet.add("refunds")
|
relationSet.add("refunds")
|
||||||
relationSet.add("shipping_methods")
|
relationSet.add("shipping_methods")
|
||||||
relationSet.add("shipping_methods.tax_lines")
|
relationSet.add("shipping_methods.tax_lines")
|
||||||
@@ -552,7 +554,7 @@ class OrderService extends TransactionBaseService {
|
|||||||
|
|
||||||
const cart = isString(cartOrId)
|
const cart = isString(cartOrId)
|
||||||
? await cartServiceTx.retrieveWithTotals(cartOrId, {
|
? await cartServiceTx.retrieveWithTotals(cartOrId, {
|
||||||
relations: ["region", "payment"],
|
relations: ["region", "payment", "items"],
|
||||||
})
|
})
|
||||||
: cartOrId
|
: cartOrId
|
||||||
|
|
||||||
@@ -566,10 +568,10 @@ class OrderService extends TransactionBaseService {
|
|||||||
const { payment, region, total } = cart
|
const { payment, region, total } = cart
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
cart.items.map(async (item) => {
|
cart.items.map(async (lineItem) => {
|
||||||
return await inventoryServiceTx.confirmInventory(
|
return await inventoryServiceTx.confirmInventory(
|
||||||
item.variant_id,
|
lineItem.variant_id,
|
||||||
item.quantity
|
lineItem.quantity
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
).catch(async (err) => {
|
).catch(async (err) => {
|
||||||
@@ -663,31 +665,32 @@ class OrderService extends TransactionBaseService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let gcBalance =
|
const giftCardableAmount =
|
||||||
(cart.region?.gift_cards_taxable
|
(cart.region?.gift_cards_taxable
|
||||||
? cart.subtotal! - cart.discount_total!
|
? cart.subtotal! - cart.discount_total!
|
||||||
: cart.total! + cart.gift_card_total!) || 0
|
: cart.total! + cart.gift_card_total!) || 0 // we re add the gift card total to compensate the fact that the decorate total already removed this amount from the total
|
||||||
const gcService = this.giftCardService_.withTransaction(manager)
|
|
||||||
|
|
||||||
for (const g of cart.gift_cards) {
|
let giftCardableAmountBalance = giftCardableAmount
|
||||||
const newBalance = Math.max(0, g.balance - gcBalance)
|
const giftCardService = this.giftCardService_.withTransaction(manager)
|
||||||
const usage = g.balance - newBalance
|
|
||||||
await gcService.update(g.id, {
|
for (const giftCard of cart.gift_cards) {
|
||||||
balance: newBalance,
|
const newGiftCardBalance = Math.max(0, giftCard.balance - giftCardableAmountBalance)
|
||||||
is_disabled: newBalance === 0,
|
const giftCardBalanceUsed = giftCard.balance - newGiftCardBalance
|
||||||
|
|
||||||
|
await giftCardService.update(giftCard.id, {
|
||||||
|
balance: newGiftCardBalance,
|
||||||
|
is_disabled: newGiftCardBalance === 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
await gcService.createTransaction({
|
await giftCardService.createTransaction({
|
||||||
gift_card_id: g.id,
|
gift_card_id: giftCard.id,
|
||||||
order_id: order.id,
|
order_id: order.id,
|
||||||
amount: usage,
|
amount: giftCardBalanceUsed,
|
||||||
is_taxable: cart.region.gift_cards_taxable,
|
is_taxable: !!giftCard.tax_rate,
|
||||||
tax_rate: cart.region.gift_cards_taxable
|
tax_rate: giftCard.tax_rate
|
||||||
? cart.region.tax_rate
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
gcBalance = gcBalance - usage
|
giftCardableAmountBalance = giftCardableAmountBalance - giftCardBalanceUsed
|
||||||
}
|
}
|
||||||
|
|
||||||
const shippingOptionServiceTx =
|
const shippingOptionServiceTx =
|
||||||
@@ -696,14 +699,20 @@ class OrderService extends TransactionBaseService {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[
|
[
|
||||||
cart.items.map((item) => {
|
cart.items.map((lineItem) => {
|
||||||
return [
|
const lineItemPromises: unknown[] = [
|
||||||
lineItemServiceTx.update(item.id, { order_id: order.id }),
|
lineItemServiceTx.update(lineItem.id, { order_id: order.id }),
|
||||||
inventoryServiceTx.adjustInventory(
|
inventoryServiceTx.adjustInventory(
|
||||||
item.variant_id,
|
lineItem.variant_id,
|
||||||
-item.quantity
|
-lineItem.quantity
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (lineItem.is_giftcard) {
|
||||||
|
lineItemPromises.push(this.createGiftCardsFromLineItem_(order, lineItem, manager))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lineItemPromises
|
||||||
}),
|
}),
|
||||||
cart.shipping_methods.map(async (method) => {
|
cart.shipping_methods.map(async (method) => {
|
||||||
// TODO: Due to cascade insert we have to remove the tax_lines that have been added by the cart decorate totals.
|
// TODO: Due to cascade insert we have to remove the tax_lines that have been added by the cart decorate totals.
|
||||||
@@ -730,6 +739,46 @@ class OrderService extends TransactionBaseService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected createGiftCardsFromLineItem_(
|
||||||
|
order: Order,
|
||||||
|
lineItem: LineItem,
|
||||||
|
manager: EntityManager
|
||||||
|
): Promise<GiftCard>[] {
|
||||||
|
const createGiftCardPromises: Promise<GiftCard>[] = []
|
||||||
|
|
||||||
|
// LineItem type doesn't promise either the subtotal or quantity. Adding a check here provides
|
||||||
|
// additional type safety/strictness
|
||||||
|
if (!lineItem.subtotal || !lineItem.quantity) return createGiftCardPromises
|
||||||
|
|
||||||
|
// Subtotal is the pure value of the product/variant excluding tax, discounts, etc.
|
||||||
|
// We divide here by quantity to get the value of the product/variant as a lineItem
|
||||||
|
// contains quantity. The subtotal is multiplicative of pure price per product and quantity
|
||||||
|
const taxExclusivePrice = lineItem.subtotal / lineItem.quantity
|
||||||
|
// The tax_lines contains all the taxes that is applicable on the purchase of the gift card
|
||||||
|
// On utilizing the gift card, the same set of taxRate will apply to gift card
|
||||||
|
// We calculate the summation of all taxes and add that as a snapshot in the giftcard.tax_rate column
|
||||||
|
const giftCardTaxRate = lineItem.tax_lines.reduce(
|
||||||
|
(sum, taxLine) => sum + taxLine.rate, 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const giftCardTxnService = this.giftCardService_.withTransaction(manager)
|
||||||
|
|
||||||
|
for (let qty = 0; qty < lineItem.quantity; qty++) {
|
||||||
|
const createGiftCardPromise = giftCardTxnService.create({
|
||||||
|
region_id: order.region_id,
|
||||||
|
order_id: order.id,
|
||||||
|
value: taxExclusivePrice,
|
||||||
|
balance: taxExclusivePrice,
|
||||||
|
metadata: lineItem.metadata,
|
||||||
|
tax_rate: giftCardTaxRate || null
|
||||||
|
})
|
||||||
|
|
||||||
|
createGiftCardPromises.push(createGiftCardPromise)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createGiftCardPromises
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a shipment to the order to indicate that an order has left the
|
* Adds a shipment to the order to indicate that an order has left the
|
||||||
* warehouse. Will ask the fulfillment provider for any documents that may
|
* warehouse. Will ask the fulfillment provider for any documents that may
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
LineDiscountAmount,
|
LineDiscountAmount,
|
||||||
SubtotalOptions,
|
SubtotalOptions,
|
||||||
} from "../types/totals"
|
} from "../types/totals"
|
||||||
import TaxProviderService from "./tax-provider"
|
import {
|
||||||
|
TaxProviderService,
|
||||||
|
NewTotalsService,
|
||||||
|
} from "./index"
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
|
|
||||||
import { calculatePriceTaxAmount } from "../utils"
|
import { calculatePriceTaxAmount } from "../utils"
|
||||||
@@ -74,6 +77,7 @@ type GetLineItemTotalOptions = {
|
|||||||
|
|
||||||
type TotalsServiceProps = {
|
type TotalsServiceProps = {
|
||||||
taxProviderService: TaxProviderService
|
taxProviderService: TaxProviderService
|
||||||
|
newTotalsService: NewTotalsService
|
||||||
taxCalculationStrategy: ITaxCalculationStrategy
|
taxCalculationStrategy: ITaxCalculationStrategy
|
||||||
manager: EntityManager
|
manager: EntityManager
|
||||||
featureFlagRouter: FlagRouter
|
featureFlagRouter: FlagRouter
|
||||||
@@ -105,12 +109,14 @@ class TotalsService extends TransactionBaseService {
|
|||||||
protected transactionManager_: EntityManager
|
protected transactionManager_: EntityManager
|
||||||
|
|
||||||
protected readonly taxProviderService_: TaxProviderService
|
protected readonly taxProviderService_: TaxProviderService
|
||||||
|
protected readonly newTotalsService_: NewTotalsService
|
||||||
protected readonly taxCalculationStrategy_: ITaxCalculationStrategy
|
protected readonly taxCalculationStrategy_: ITaxCalculationStrategy
|
||||||
protected readonly featureFlagRouter_: FlagRouter
|
protected readonly featureFlagRouter_: FlagRouter
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
manager,
|
manager,
|
||||||
taxProviderService,
|
taxProviderService,
|
||||||
|
newTotalsService,
|
||||||
taxCalculationStrategy,
|
taxCalculationStrategy,
|
||||||
featureFlagRouter,
|
featureFlagRouter,
|
||||||
}: TotalsServiceProps) {
|
}: TotalsServiceProps) {
|
||||||
@@ -118,6 +124,7 @@ class TotalsService extends TransactionBaseService {
|
|||||||
|
|
||||||
this.manager_ = manager
|
this.manager_ = manager
|
||||||
this.taxProviderService_ = taxProviderService
|
this.taxProviderService_ = taxProviderService
|
||||||
|
this.newTotalsService_ = newTotalsService
|
||||||
this.taxCalculationStrategy_ = taxCalculationStrategy
|
this.taxCalculationStrategy_ = taxCalculationStrategy
|
||||||
|
|
||||||
this.manager_ = manager
|
this.manager_ = manager
|
||||||
@@ -962,73 +969,24 @@ class TotalsService extends TransactionBaseService {
|
|||||||
tax_total: number
|
tax_total: number
|
||||||
}> {
|
}> {
|
||||||
let giftCardable: number
|
let giftCardable: number
|
||||||
|
|
||||||
if (typeof opts.gift_cardable !== "undefined") {
|
if (typeof opts.gift_cardable !== "undefined") {
|
||||||
giftCardable = opts.gift_cardable
|
giftCardable = opts.gift_cardable
|
||||||
} else {
|
} else {
|
||||||
const subtotal = await this.getSubtotal(cartOrOrder)
|
const subtotal = await this.getSubtotal(cartOrOrder)
|
||||||
const discountTotal = await this.getDiscountTotal(cartOrOrder)
|
const discountTotal = await this.getDiscountTotal(cartOrOrder)
|
||||||
|
|
||||||
giftCardable = subtotal - discountTotal
|
giftCardable = subtotal - discountTotal
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("gift_card_transactions" in cartOrOrder) {
|
return await this.newTotalsService_.getGiftCardTotals(
|
||||||
// gift_card_transactions only exist on orders so we can
|
giftCardable,
|
||||||
// safely calculate the total based on the gift card transactions
|
{
|
||||||
|
region: cartOrOrder.region,
|
||||||
return cartOrOrder.gift_card_transactions.reduce(
|
giftCards: cartOrOrder.gift_cards || [],
|
||||||
(acc, next) => {
|
giftCardTransactions: cartOrOrder['gift_card_transactions'] || []
|
||||||
let taxMultiplier = (next.tax_rate || 0) / 100
|
|
||||||
|
|
||||||
// Previously we did not record whether a gift card was taxable or not.
|
|
||||||
// All gift cards where is_taxable === null are from the old system,
|
|
||||||
// where we defaulted to taxable gift cards.
|
|
||||||
//
|
|
||||||
// This is a backwards compatability fix for orders that were created
|
|
||||||
// before we added the gift card tax rate.
|
|
||||||
if (
|
|
||||||
next.is_taxable === null &&
|
|
||||||
cartOrOrder.region?.gift_cards_taxable
|
|
||||||
) {
|
|
||||||
taxMultiplier = cartOrOrder.region.tax_rate / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: acc.total + next.amount,
|
|
||||||
tax_total: acc.tax_total + next.amount * taxMultiplier,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
total: 0,
|
|
||||||
tax_total: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cartOrOrder.gift_cards || !cartOrOrder.gift_cards.length) {
|
|
||||||
return {
|
|
||||||
total: 0,
|
|
||||||
tax_total: 0,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const toReturn = cartOrOrder.gift_cards.reduce(
|
|
||||||
(acc, next) => acc + next.balance,
|
|
||||||
0
|
|
||||||
)
|
)
|
||||||
const orderGiftCardAmount = Math.min(giftCardable, toReturn)
|
|
||||||
|
|
||||||
if (cartOrOrder.region?.gift_cards_taxable) {
|
|
||||||
return {
|
|
||||||
total: orderGiftCardAmount,
|
|
||||||
tax_total: Math.round(
|
|
||||||
(orderGiftCardAmount * cartOrOrder.region.tax_rate) / 100
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: orderGiftCardAmount,
|
|
||||||
tax_total: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,27 +30,10 @@ class OrderSubscriber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleOrderPlaced = async (data) => {
|
handleOrderPlaced = async (data) => {
|
||||||
const order = await this.orderService_.retrieve(data.id, {
|
const order = await this.orderService_.retrieveWithTotals(data.id, {
|
||||||
select: ["subtotal"],
|
relations: ["discounts", "discounts.rule"],
|
||||||
relations: ["discounts", "discounts.rule", "items", "gift_cards"],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
order.items.map(async (i) => {
|
|
||||||
if (i.is_giftcard) {
|
|
||||||
for (let qty = 0; qty < i.quantity; qty++) {
|
|
||||||
await this.giftCardService_.create({
|
|
||||||
region_id: order.region_id,
|
|
||||||
order_id: order.id,
|
|
||||||
value: i.unit_price,
|
|
||||||
balance: i.unit_price,
|
|
||||||
metadata: i.metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
order.discounts.map(async (d) => {
|
order.discounts.map(async (d) => {
|
||||||
const usageCount = d?.usage_count || 0
|
const usageCount = d?.usage_count || 0
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export type CreateGiftCardInput = {
|
export type CreateGiftCardInput = {
|
||||||
|
order_id?: string
|
||||||
value?: number
|
value?: number
|
||||||
balance?: number
|
balance?: number
|
||||||
ends_at?: Date
|
ends_at?: Date
|
||||||
is_disabled?: boolean
|
is_disabled?: boolean
|
||||||
region_id: string
|
region_id: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
tax_rate?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateGiftCardInput = {
|
export type UpdateGiftCardInput = {
|
||||||
|
|||||||
Reference in New Issue
Block a user