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:
Riqwan Thamir
2022-12-20 22:24:25 +01:00
committed by GitHub
parent 3113d8024f
commit 8a60a73389
26 changed files with 968 additions and 181 deletions

View 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.

View File

@@ -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
})
]),
})
]),
}),
)
})
})
})

View File

@@ -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
})
]),
})
]),
}),
)
})
})
})

View File

@@ -139,6 +139,7 @@ describe("Order Totals", () => {
region_id: region.id,
value: 160000,
balance: 160000,
tax_rate: 25,
})
// Add variant 1 to cart

View File

@@ -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)

View File

@@ -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)

View File

@@ -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, {

View File

@@ -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",

View File

@@ -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"`
)
}
}

View File

@@ -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."

View 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,
}

View File

@@ -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)

View 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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[]

View File

@@ -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
})
})
})

View File

@@ -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,
})
)
})
})
})

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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> = {}

View File

@@ -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),
}
},
{

View File

@@ -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

View File

@@ -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,
}
}
/**

View File

@@ -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

View File

@@ -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 = {