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:
6
.changeset/rich-shrimps-learn.md
Normal file
6
.changeset/rich-shrimps-learn.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
value: 160000,
|
||||
balance: 160000,
|
||||
tax_rate: 25,
|
||||
})
|
||||
|
||||
// Add variant 1 to cart
|
||||
|
||||
@@ -8,6 +8,7 @@ export type GiftCardFactoryData = {
|
||||
region_id: string
|
||||
value: number
|
||||
balance: number
|
||||
tax_rate?: number
|
||||
}
|
||||
|
||||
export const simpleGiftCardFactory = async (
|
||||
@@ -27,6 +28,7 @@ export const simpleGiftCardFactory = async (
|
||||
region_id: data.region_id,
|
||||
value: data.value,
|
||||
balance: data.balance,
|
||||
tax_rate: data.tax_rate,
|
||||
})
|
||||
|
||||
return await manager.save(toSave)
|
||||
|
||||
@@ -23,6 +23,7 @@ export type LineItemFactoryData = {
|
||||
thumbnail?: string
|
||||
should_merge?: boolean
|
||||
allow_discounts?: boolean
|
||||
is_giftcard?: boolean
|
||||
unit_price?: number
|
||||
quantity?: number
|
||||
fulfilled_quantity?: boolean
|
||||
@@ -74,6 +75,7 @@ export const simpleLineItemFactory = async (
|
||||
adjustments: data.adjustments,
|
||||
includes_tax: data.includes_tax,
|
||||
order_edit_id: data.order_edit_id,
|
||||
is_giftcard: data.is_giftcard || false
|
||||
})
|
||||
|
||||
const line = await manager.save(toSave)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defaultAdminGiftCardFields, defaultAdminGiftCardRelations } from "."
|
||||
|
||||
import { GiftCardService } from "../../../../services"
|
||||
import { Type } from "class-transformer"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
/**
|
||||
@@ -87,16 +86,13 @@ import { EntityManager } from "typeorm"
|
||||
* $ref: "#/components/responses/500_error"
|
||||
*/
|
||||
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 manager: EntityManager = req.scope.resolve("manager")
|
||||
const newly = await manager.transaction(async (transactionManager) => {
|
||||
return await giftCardService.withTransaction(transactionManager).create({
|
||||
...validated,
|
||||
balance: validated.value,
|
||||
})
|
||||
return await giftCardService.withTransaction(transactionManager).create(validatedBody)
|
||||
})
|
||||
|
||||
const giftCard = await giftCardService.retrieve(newly.id, {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Router } from "express"
|
||||
import "reflect-metadata"
|
||||
import { GiftCard } from "../../../.."
|
||||
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
|
||||
import middlewares, { transformQuery } from "../../../middlewares"
|
||||
import middlewares, { transformQuery, transformBody } from "../../../middlewares"
|
||||
import { AdminGetGiftCardsParams } from "./list-gift-cards"
|
||||
import { AdminPostGiftCardsReq } from './create-gift-card'
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -20,7 +21,11 @@ export default (app) => {
|
||||
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))
|
||||
|
||||
@@ -39,6 +44,7 @@ export const defaultAdminGiftCardFields: (keyof GiftCard)[] = [
|
||||
"region_id",
|
||||
"is_disabled",
|
||||
"ends_at",
|
||||
"tax_rate",
|
||||
"created_at",
|
||||
"updated_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
|
||||
|
||||
@Column({ type: "real", nullable: true })
|
||||
tax_rate: number | null
|
||||
|
||||
@DbAwareColumn({ type: "jsonb", nullable: true })
|
||||
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."
|
||||
* type: string
|
||||
* 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:
|
||||
* type: string
|
||||
* description: "The date with timezone at which the resource was created."
|
||||
|
||||
10
packages/medusa/src/scripts/db-config.ts
Normal file
10
packages/medusa/src/scripts/db-config.ts
Normal file
@@ -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 { DiscountRule } from "../models/discount-rule"
|
||||
import { DiscountConditionRepository } from "../repositories/discount-condition"
|
||||
dotenv.config()
|
||||
import { typeormConfig } from "./db-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,
|
||||
}
|
||||
dotenv.config()
|
||||
|
||||
const migrate = async function ({ typeormConfig }): Promise<void> {
|
||||
const connection = await createConnection(typeormConfig)
|
||||
|
||||
101
packages/medusa/src/scripts/gift-card-tax-rate-migration.ts
Normal file
101
packages/medusa/src/scripts/gift-card-tax-rate-migration.ts
Normal file
@@ -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 { LineItem } from "../models/line-item"
|
||||
import { LineItemAdjustment } from "../models/line-item-adjustment"
|
||||
dotenv.config()
|
||||
import { typeormConfig } from "./db-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,
|
||||
}
|
||||
dotenv.config()
|
||||
|
||||
const migrate = async function({ typeormConfig }) {
|
||||
const connection = await createConnection(typeormConfig)
|
||||
|
||||
@@ -3,20 +3,10 @@ import { createConnection } from "typeorm"
|
||||
import Logger from "../loaders/logger"
|
||||
import { Product } from "../models/product"
|
||||
import { Store } from "../models/store"
|
||||
import { typeormConfig } from "./db-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 connection = await createConnection(typeormConfig)
|
||||
|
||||
|
||||
@@ -70,3 +70,13 @@ export const giftCards = [
|
||||
balance: 10000,
|
||||
},
|
||||
] 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: () => {
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId("region-id"),
|
||||
tax_rate: 19,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -57,6 +58,7 @@ describe("GiftCardService", () => {
|
||||
order_id: IdMap.getId("order-id"),
|
||||
is_disabled: true,
|
||||
code: expect.any(String),
|
||||
tax_rate: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asClass, asValue, createContainer } from "awilix"
|
||||
import {
|
||||
defaultContainerMock,
|
||||
giftCards,
|
||||
giftCardsWithTaxRate,
|
||||
lineItems,
|
||||
shippingMethods,
|
||||
} 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 testGiftCard = giftCards[0]
|
||||
const testGiftCard = giftCardsWithTaxRate[0]
|
||||
|
||||
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,
|
||||
tax_rate: 20,
|
||||
tax_rate: 0,
|
||||
} as Region
|
||||
|
||||
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 () => {
|
||||
const maxAmount = 1000
|
||||
|
||||
const testGiftCard = giftCards[0]
|
||||
const giftCardTransactions = [
|
||||
{
|
||||
tax_rate: 20,
|
||||
is_taxable: false,
|
||||
amount: 1000,
|
||||
gift_card: testGiftCard
|
||||
},
|
||||
]
|
||||
|
||||
@@ -572,7 +577,7 @@ describe("New totals service", () => {
|
||||
const gitCardTotals = await newTotalsService.getGiftCardTotals(
|
||||
maxAmount,
|
||||
{
|
||||
giftCardTransactions: giftCardTransactions,
|
||||
giftCardTransactions,
|
||||
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 () => {
|
||||
const maxAmount = 1000
|
||||
|
||||
const testGiftCard = giftCards[0]
|
||||
const giftCardTransactions = [
|
||||
{
|
||||
tax_rate: 20,
|
||||
is_taxable: null,
|
||||
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 = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createTransaction: jest.fn(),
|
||||
withTransaction: function () {
|
||||
@@ -128,6 +129,7 @@ describe("OrderService", () => {
|
||||
total: 100,
|
||||
})
|
||||
}),
|
||||
update: jest.fn(() => Promise.resolve()),
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
@@ -191,10 +193,7 @@ describe("OrderService", () => {
|
||||
discount_total: 0,
|
||||
}
|
||||
|
||||
orderService.cartService_.retrieveWithTotals = jest.fn(() =>
|
||||
Promise.resolve(cart)
|
||||
)
|
||||
orderService.cartService_.update = jest.fn(() => Promise.resolve())
|
||||
orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cart))
|
||||
|
||||
await orderService.createFromCart("cart_id")
|
||||
const order = {
|
||||
@@ -214,7 +213,7 @@ describe("OrderService", () => {
|
||||
|
||||
expect(cartService.retrieveWithTotals).toHaveBeenCalledTimes(1)
|
||||
expect(cartService.retrieveWithTotals).toHaveBeenCalledWith("cart_id", {
|
||||
relations: ["region", "payment"],
|
||||
relations: ["region", "payment", "items"],
|
||||
})
|
||||
|
||||
expect(paymentProviderService.updatePayment).toHaveBeenCalledTimes(1)
|
||||
@@ -248,6 +247,85 @@ describe("OrderService", () => {
|
||||
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 () => {
|
||||
const cart = {
|
||||
id: "cart_id",
|
||||
@@ -273,6 +351,7 @@ describe("OrderService", () => {
|
||||
id: "gid",
|
||||
code: "GC",
|
||||
balance: 80,
|
||||
tax_rate: 25,
|
||||
},
|
||||
],
|
||||
discounts: [],
|
||||
@@ -289,7 +368,6 @@ describe("OrderService", () => {
|
||||
orderService.cartService_.retrieveWithTotals = () => {
|
||||
return Promise.resolve(cart)
|
||||
}
|
||||
orderService.cartService_.update = () => Promise.resolve()
|
||||
|
||||
await orderService.createFromCart("cart_id")
|
||||
const order = {
|
||||
@@ -308,6 +386,7 @@ describe("OrderService", () => {
|
||||
id: "gid",
|
||||
code: "GC",
|
||||
balance: 80,
|
||||
tax_rate: 25,
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
@@ -438,7 +517,6 @@ describe("OrderService", () => {
|
||||
total: 100,
|
||||
}
|
||||
orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart)
|
||||
orderService.cartService_.update = () => Promise.resolve()
|
||||
const res = orderService.createFromCart(cart)
|
||||
await expect(res).rejects.toThrow(
|
||||
"Variant with id: variant-1 does not have the required inventory"
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("TotalsService", () => {
|
||||
const featureFlagRouter = new FlagRouter({
|
||||
[TaxInclusivePricingFeatureFlag.key]: false,
|
||||
})
|
||||
|
||||
const container = {
|
||||
taxProviderService: {
|
||||
withTransaction: function () {
|
||||
@@ -130,6 +129,14 @@ describe("TotalsService", () => {
|
||||
},
|
||||
getTaxLines: getTaxLinesMock,
|
||||
},
|
||||
newTotalsService: {
|
||||
getGiftCardTotals: jest.fn(() => {
|
||||
return {
|
||||
total: 0,
|
||||
tax_total: 0,
|
||||
}
|
||||
}),
|
||||
},
|
||||
taxCalculationStrategy: {},
|
||||
featureFlagRouter,
|
||||
}
|
||||
@@ -889,6 +896,14 @@ describe("TotalsService", () => {
|
||||
taxCalculationStrategy: {
|
||||
calculate: calculateMock,
|
||||
},
|
||||
newTotalsService: {
|
||||
getGiftCardTotals: jest.fn(() => {
|
||||
return {
|
||||
total: 0,
|
||||
tax_total: 0,
|
||||
}
|
||||
}),
|
||||
},
|
||||
featureFlagRouter,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import randomize from "randomatic"
|
||||
import { EntityManager } from "typeorm"
|
||||
import { EventBusService } from "."
|
||||
import { TransactionBaseService } from "../interfaces"
|
||||
import { GiftCard } from "../models"
|
||||
import { GiftCard, Region } from "../models"
|
||||
import { GiftCardRepository } from "../repositories/gift-card"
|
||||
import { GiftCardTransactionRepository } from "../repositories/gift-card-transaction"
|
||||
import {
|
||||
@@ -160,11 +160,12 @@ class GiftCardService extends TransactionBaseService {
|
||||
.retrieve(giftCard.region_id)
|
||||
|
||||
const code = GiftCardService.generateCode()
|
||||
|
||||
const taxRate = GiftCardService.resolveTaxRate(giftCard.tax_rate || null, region)
|
||||
const toCreate = {
|
||||
code,
|
||||
...giftCard,
|
||||
region_id: region.id,
|
||||
tax_rate: taxRate,
|
||||
}
|
||||
|
||||
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_(
|
||||
selector: Selector<GiftCard>,
|
||||
config: FindConfig<GiftCard> = {}
|
||||
|
||||
@@ -33,6 +33,13 @@ type LineItemTotals = {
|
||||
discount_total: number
|
||||
}
|
||||
|
||||
type GiftCardTransaction = {
|
||||
tax_rate: number | null
|
||||
is_taxable: boolean | null
|
||||
amount: number
|
||||
gift_card: GiftCard
|
||||
}
|
||||
|
||||
type ShippingMethodTotals = {
|
||||
price: number
|
||||
tax_total: number
|
||||
@@ -214,6 +221,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
totals.tax_lines,
|
||||
calculationContext
|
||||
)
|
||||
|
||||
const noDiscountContext = {
|
||||
...calculationContext,
|
||||
allocation_map: {}, // Don't account for discounts
|
||||
@@ -451,11 +459,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
giftCards,
|
||||
}: {
|
||||
region: Region
|
||||
giftCardTransactions?: {
|
||||
tax_rate: number | null
|
||||
is_taxable: boolean | null
|
||||
amount: number
|
||||
}[]
|
||||
giftCardTransactions?: GiftCardTransaction[]
|
||||
giftCards?: GiftCard[]
|
||||
}
|
||||
): Promise<{
|
||||
@@ -469,7 +473,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
)
|
||||
}
|
||||
|
||||
if (giftCardTransactions) {
|
||||
if (giftCardTransactions?.length) {
|
||||
return this.getGiftCardTransactionsTotals({
|
||||
giftCardTransactions,
|
||||
region,
|
||||
@@ -485,13 +489,27 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
return result
|
||||
}
|
||||
|
||||
const giftAmount = giftCards.reduce((acc, next) => acc + next.balance, 0)
|
||||
result.total = Math.min(giftCardableAmount, giftAmount)
|
||||
// If a gift card is not taxable, the tax_rate for the giftcard will be null
|
||||
const { totalGiftCardBalance, totalTaxFromGiftCards } = giftCards.reduce((acc, giftCard) => {
|
||||
let taxableAmount = 0
|
||||
|
||||
if (region?.gift_cards_taxable) {
|
||||
result.tax_total = Math.round(result.total * (region.tax_rate / 100))
|
||||
return result
|
||||
}
|
||||
acc.totalGiftCardBalance += giftCard.balance
|
||||
|
||||
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
|
||||
}
|
||||
@@ -505,11 +523,7 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
giftCardTransactions,
|
||||
region,
|
||||
}: {
|
||||
giftCardTransactions: {
|
||||
tax_rate: number | null
|
||||
is_taxable: boolean | null
|
||||
amount: number
|
||||
}[]
|
||||
giftCardTransactions: GiftCardTransaction[]
|
||||
region: { gift_cards_taxable: boolean; tax_rate: number }
|
||||
}): { total: number; tax_total: number } {
|
||||
return giftCardTransactions.reduce(
|
||||
@@ -522,13 +536,18 @@ export default class NewTotalsService extends TransactionBaseService {
|
||||
//
|
||||
// This is a backwards compatability fix for orders that were created
|
||||
// before we added the gift card tax rate.
|
||||
if (next.is_taxable === null && region?.gift_cards_taxable) {
|
||||
taxMultiplier = region.tax_rate / 100
|
||||
// We prioritize the giftCard.tax_rate as we create a snapshot of the tax
|
||||
// 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 {
|
||||
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,
|
||||
Swap,
|
||||
TrackingLink,
|
||||
GiftCard,
|
||||
} from "../models"
|
||||
import { AddressRepository } from "../repositories/address"
|
||||
import { OrderRepository } from "../repositories/order"
|
||||
@@ -302,6 +303,7 @@ class OrderService extends TransactionBaseService {
|
||||
relationSet.add("discounts.rule")
|
||||
relationSet.add("gift_cards")
|
||||
relationSet.add("gift_card_transactions")
|
||||
relationSet.add("gift_card_transactions.gift_card")
|
||||
relationSet.add("refunds")
|
||||
relationSet.add("shipping_methods")
|
||||
relationSet.add("shipping_methods.tax_lines")
|
||||
@@ -552,7 +554,7 @@ class OrderService extends TransactionBaseService {
|
||||
|
||||
const cart = isString(cartOrId)
|
||||
? await cartServiceTx.retrieveWithTotals(cartOrId, {
|
||||
relations: ["region", "payment"],
|
||||
relations: ["region", "payment", "items"],
|
||||
})
|
||||
: cartOrId
|
||||
|
||||
@@ -566,10 +568,10 @@ class OrderService extends TransactionBaseService {
|
||||
const { payment, region, total } = cart
|
||||
|
||||
await Promise.all(
|
||||
cart.items.map(async (item) => {
|
||||
cart.items.map(async (lineItem) => {
|
||||
return await inventoryServiceTx.confirmInventory(
|
||||
item.variant_id,
|
||||
item.quantity
|
||||
lineItem.variant_id,
|
||||
lineItem.quantity
|
||||
)
|
||||
})
|
||||
).catch(async (err) => {
|
||||
@@ -663,31 +665,32 @@ class OrderService extends TransactionBaseService {
|
||||
)
|
||||
}
|
||||
|
||||
let gcBalance =
|
||||
const giftCardableAmount =
|
||||
(cart.region?.gift_cards_taxable
|
||||
? cart.subtotal! - cart.discount_total!
|
||||
: cart.total! + cart.gift_card_total!) || 0
|
||||
const gcService = this.giftCardService_.withTransaction(manager)
|
||||
: 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
|
||||
|
||||
for (const g of cart.gift_cards) {
|
||||
const newBalance = Math.max(0, g.balance - gcBalance)
|
||||
const usage = g.balance - newBalance
|
||||
await gcService.update(g.id, {
|
||||
balance: newBalance,
|
||||
is_disabled: newBalance === 0,
|
||||
let giftCardableAmountBalance = giftCardableAmount
|
||||
const giftCardService = this.giftCardService_.withTransaction(manager)
|
||||
|
||||
for (const giftCard of cart.gift_cards) {
|
||||
const newGiftCardBalance = Math.max(0, giftCard.balance - giftCardableAmountBalance)
|
||||
const giftCardBalanceUsed = giftCard.balance - newGiftCardBalance
|
||||
|
||||
await giftCardService.update(giftCard.id, {
|
||||
balance: newGiftCardBalance,
|
||||
is_disabled: newGiftCardBalance === 0,
|
||||
})
|
||||
|
||||
await gcService.createTransaction({
|
||||
gift_card_id: g.id,
|
||||
await giftCardService.createTransaction({
|
||||
gift_card_id: giftCard.id,
|
||||
order_id: order.id,
|
||||
amount: usage,
|
||||
is_taxable: cart.region.gift_cards_taxable,
|
||||
tax_rate: cart.region.gift_cards_taxable
|
||||
? cart.region.tax_rate
|
||||
: null,
|
||||
amount: giftCardBalanceUsed,
|
||||
is_taxable: !!giftCard.tax_rate,
|
||||
tax_rate: giftCard.tax_rate
|
||||
})
|
||||
|
||||
gcBalance = gcBalance - usage
|
||||
giftCardableAmountBalance = giftCardableAmountBalance - giftCardBalanceUsed
|
||||
}
|
||||
|
||||
const shippingOptionServiceTx =
|
||||
@@ -696,14 +699,20 @@ class OrderService extends TransactionBaseService {
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
cart.items.map((item) => {
|
||||
return [
|
||||
lineItemServiceTx.update(item.id, { order_id: order.id }),
|
||||
cart.items.map((lineItem) => {
|
||||
const lineItemPromises: unknown[] = [
|
||||
lineItemServiceTx.update(lineItem.id, { order_id: order.id }),
|
||||
inventoryServiceTx.adjustInventory(
|
||||
item.variant_id,
|
||||
-item.quantity
|
||||
lineItem.variant_id,
|
||||
-lineItem.quantity
|
||||
),
|
||||
]
|
||||
|
||||
if (lineItem.is_giftcard) {
|
||||
lineItemPromises.push(this.createGiftCardsFromLineItem_(order, lineItem, manager))
|
||||
}
|
||||
|
||||
return lineItemPromises
|
||||
}),
|
||||
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.
|
||||
@@ -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
|
||||
* warehouse. Will ask the fulfillment provider for any documents that may
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
LineDiscountAmount,
|
||||
SubtotalOptions,
|
||||
} from "../types/totals"
|
||||
import TaxProviderService from "./tax-provider"
|
||||
import {
|
||||
TaxProviderService,
|
||||
NewTotalsService,
|
||||
} from "./index"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
import { calculatePriceTaxAmount } from "../utils"
|
||||
@@ -74,6 +77,7 @@ type GetLineItemTotalOptions = {
|
||||
|
||||
type TotalsServiceProps = {
|
||||
taxProviderService: TaxProviderService
|
||||
newTotalsService: NewTotalsService
|
||||
taxCalculationStrategy: ITaxCalculationStrategy
|
||||
manager: EntityManager
|
||||
featureFlagRouter: FlagRouter
|
||||
@@ -105,12 +109,14 @@ class TotalsService extends TransactionBaseService {
|
||||
protected transactionManager_: EntityManager
|
||||
|
||||
protected readonly taxProviderService_: TaxProviderService
|
||||
protected readonly newTotalsService_: NewTotalsService
|
||||
protected readonly taxCalculationStrategy_: ITaxCalculationStrategy
|
||||
protected readonly featureFlagRouter_: FlagRouter
|
||||
|
||||
constructor({
|
||||
manager,
|
||||
taxProviderService,
|
||||
newTotalsService,
|
||||
taxCalculationStrategy,
|
||||
featureFlagRouter,
|
||||
}: TotalsServiceProps) {
|
||||
@@ -118,6 +124,7 @@ class TotalsService extends TransactionBaseService {
|
||||
|
||||
this.manager_ = manager
|
||||
this.taxProviderService_ = taxProviderService
|
||||
this.newTotalsService_ = newTotalsService
|
||||
this.taxCalculationStrategy_ = taxCalculationStrategy
|
||||
|
||||
this.manager_ = manager
|
||||
@@ -962,73 +969,24 @@ class TotalsService extends TransactionBaseService {
|
||||
tax_total: number
|
||||
}> {
|
||||
let giftCardable: number
|
||||
|
||||
if (typeof opts.gift_cardable !== "undefined") {
|
||||
giftCardable = opts.gift_cardable
|
||||
} else {
|
||||
const subtotal = await this.getSubtotal(cartOrOrder)
|
||||
const discountTotal = await this.getDiscountTotal(cartOrOrder)
|
||||
|
||||
giftCardable = subtotal - discountTotal
|
||||
}
|
||||
|
||||
if ("gift_card_transactions" in cartOrOrder) {
|
||||
// gift_card_transactions only exist on orders so we can
|
||||
// safely calculate the total based on the gift card transactions
|
||||
|
||||
return cartOrOrder.gift_card_transactions.reduce(
|
||||
(acc, next) => {
|
||||
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,
|
||||
return await this.newTotalsService_.getGiftCardTotals(
|
||||
giftCardable,
|
||||
{
|
||||
region: cartOrOrder.region,
|
||||
giftCards: cartOrOrder.gift_cards || [],
|
||||
giftCardTransactions: cartOrOrder['gift_card_transactions'] || []
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const order = await this.orderService_.retrieve(data.id, {
|
||||
select: ["subtotal"],
|
||||
relations: ["discounts", "discounts.rule", "items", "gift_cards"],
|
||||
const order = await this.orderService_.retrieveWithTotals(data.id, {
|
||||
relations: ["discounts", "discounts.rule"],
|
||||
})
|
||||
|
||||
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(
|
||||
order.discounts.map(async (d) => {
|
||||
const usageCount = d?.usage_count || 0
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type CreateGiftCardInput = {
|
||||
order_id?: string
|
||||
value?: number
|
||||
balance?: number
|
||||
ends_at?: Date
|
||||
is_disabled?: boolean
|
||||
region_id: string
|
||||
metadata?: Record<string, unknown>
|
||||
tax_rate?: number | null
|
||||
}
|
||||
|
||||
export type UpdateGiftCardInput = {
|
||||
|
||||
Reference in New Issue
Block a user