diff --git a/.changeset/rich-shrimps-learn.md b/.changeset/rich-shrimps-learn.md new file mode 100644 index 0000000000..3e32fcce7f --- /dev/null +++ b/.changeset/rich-shrimps-learn.md @@ -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. diff --git a/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation-ff-tax-inclusive.js b/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation-ff-tax-inclusive.js new file mode 100644 index 0000000000..c7112228ce --- /dev/null +++ b/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation-ff-tax-inclusive.js @@ -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 + }) + ]), + }) + ]), + }), + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation.js b/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation.js new file mode 100644 index 0000000000..427cfd2d2b --- /dev/null +++ b/integration-tests/api/__tests__/store/cart/gift-cards/tax-calculation.js @@ -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 + }) + ]), + }) + ]), + }), + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/totals/orders.js b/integration-tests/api/__tests__/totals/orders.js index 72620d858f..ff250f1fb9 100644 --- a/integration-tests/api/__tests__/totals/orders.js +++ b/integration-tests/api/__tests__/totals/orders.js @@ -139,6 +139,7 @@ describe("Order Totals", () => { region_id: region.id, value: 160000, balance: 160000, + tax_rate: 25, }) // Add variant 1 to cart diff --git a/integration-tests/api/factories/simple-gift-card-factory.ts b/integration-tests/api/factories/simple-gift-card-factory.ts index 4e80a83f3c..0baaf2b1c5 100644 --- a/integration-tests/api/factories/simple-gift-card-factory.ts +++ b/integration-tests/api/factories/simple-gift-card-factory.ts @@ -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) diff --git a/integration-tests/api/factories/simple-line-item-factory.ts b/integration-tests/api/factories/simple-line-item-factory.ts index aac65d870b..fbae1a2f15 100644 --- a/integration-tests/api/factories/simple-line-item-factory.ts +++ b/integration-tests/api/factories/simple-line-item-factory.ts @@ -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) diff --git a/packages/medusa/src/api/routes/admin/gift-cards/create-gift-card.ts b/packages/medusa/src/api/routes/admin/gift-cards/create-gift-card.ts index fc4a53ea6a..affd3eda9a 100644 --- a/packages/medusa/src/api/routes/admin/gift-cards/create-gift-card.ts +++ b/packages/medusa/src/api/routes/admin/gift-cards/create-gift-card.ts @@ -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, { diff --git a/packages/medusa/src/api/routes/admin/gift-cards/index.ts b/packages/medusa/src/api/routes/admin/gift-cards/index.ts index b08e7c0c6e..10b284d576 100644 --- a/packages/medusa/src/api/routes/admin/gift-cards/index.ts +++ b/packages/medusa/src/api/routes/admin/gift-cards/index.ts @@ -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", diff --git a/packages/medusa/src/migrations/1670855241304-add-tax-rate-to-gift-cards.ts b/packages/medusa/src/migrations/1670855241304-add-tax-rate-to-gift-cards.ts new file mode 100644 index 0000000000..cf7f8c063c --- /dev/null +++ b/packages/medusa/src/migrations/1670855241304-add-tax-rate-to-gift-cards.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addTaxRateToGiftCards1670855241304 implements MigrationInterface { + name = "addTaxRateToGiftCards1670855241304" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "gift_card" ADD COLUMN IF NOT EXISTS tax_rate REAL` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "gift_card" DROP COLUMN IF EXISTS "tax_rate"` + ) + } +} diff --git a/packages/medusa/src/models/gift-card.ts b/packages/medusa/src/models/gift-card.ts index 21bed5ed65..e2cd68a150 100644 --- a/packages/medusa/src/models/gift-card.ts +++ b/packages/medusa/src/models/gift-card.ts @@ -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 @@ -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." diff --git a/packages/medusa/src/scripts/db-config.ts b/packages/medusa/src/scripts/db-config.ts new file mode 100644 index 0000000000..6cab6b01ea --- /dev/null +++ b/packages/medusa/src/scripts/db-config.ts @@ -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, +} diff --git a/packages/medusa/src/scripts/discount-rule-migration.ts b/packages/medusa/src/scripts/discount-rule-migration.ts index 2268edfe0c..f77a0e96a2 100644 --- a/packages/medusa/src/scripts/discount-rule-migration.ts +++ b/packages/medusa/src/scripts/discount-rule-migration.ts @@ -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 { const connection = await createConnection(typeormConfig) diff --git a/packages/medusa/src/scripts/gift-card-tax-rate-migration.ts b/packages/medusa/src/scripts/gift-card-tax-rate-migration.ts new file mode 100644 index 0000000000..e8b9f1d0a7 --- /dev/null +++ b/packages/medusa/src/scripts/gift-card-tax-rate-migration.ts @@ -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 { + 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 diff --git a/packages/medusa/src/scripts/line-item-adjustment-migration.ts b/packages/medusa/src/scripts/line-item-adjustment-migration.ts index 4b9b669b1c..39be902262 100644 --- a/packages/medusa/src/scripts/line-item-adjustment-migration.ts +++ b/packages/medusa/src/scripts/line-item-adjustment-migration.ts @@ -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) diff --git a/packages/medusa/src/scripts/sales-channels-migration.ts b/packages/medusa/src/scripts/sales-channels-migration.ts index f74660be1c..a3a48681f3 100644 --- a/packages/medusa/src/scripts/sales-channels-migration.ts +++ b/packages/medusa/src/scripts/sales-channels-migration.ts @@ -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 { const connection = await createConnection(typeormConfig) diff --git a/packages/medusa/src/services/__fixtures__/new-totals.ts b/packages/medusa/src/services/__fixtures__/new-totals.ts index 0784e218c8..912b37c488 100644 --- a/packages/medusa/src/services/__fixtures__/new-totals.ts +++ b/packages/medusa/src/services/__fixtures__/new-totals.ts @@ -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[] diff --git a/packages/medusa/src/services/__tests__/gift-card.js b/packages/medusa/src/services/__tests__/gift-card.js index e144fd4bac..1603815f86 100644 --- a/packages/medusa/src/services/__tests__/gift-card.js +++ b/packages/medusa/src/services/__tests__/gift-card.js @@ -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 }) }) }) diff --git a/packages/medusa/src/services/__tests__/new-totals.ts b/packages/medusa/src/services/__tests__/new-totals.ts index dcf798e6c7..0bff38ae2c 100644 --- a/packages/medusa/src/services/__tests__/new-totals.ts +++ b/packages/medusa/src/services/__tests__/new-totals.ts @@ -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, + }) + ) + }) }) }) diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index f3aa48f1db..7e349c7daa 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -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" diff --git a/packages/medusa/src/services/__tests__/totals.js b/packages/medusa/src/services/__tests__/totals.js index 70ebbcb191..57d613ad2c 100644 --- a/packages/medusa/src/services/__tests__/totals.js +++ b/packages/medusa/src/services/__tests__/totals.js @@ -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, } diff --git a/packages/medusa/src/services/gift-card.ts b/packages/medusa/src/services/gift-card.ts index f69f33824a..ed2edf70fe 100644 --- a/packages/medusa/src/services/gift-card.ts +++ b/packages/medusa/src/services/gift-card.ts @@ -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, config: FindConfig = {} diff --git a/packages/medusa/src/services/new-totals.ts b/packages/medusa/src/services/new-totals.ts index 17a3aeec8c..602add9a68 100644 --- a/packages/medusa/src/services/new-totals.ts +++ b/packages/medusa/src/services/new-totals.ts @@ -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), } }, { diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index ff65646fb8..89ad1169d0 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -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[] { + const createGiftCardPromises: Promise[] = [] + + // 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 diff --git a/packages/medusa/src/services/totals.ts b/packages/medusa/src/services/totals.ts index 2433404ffd..5b9169b4ad 100644 --- a/packages/medusa/src/services/totals.ts +++ b/packages/medusa/src/services/totals.ts @@ -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, - } } /** diff --git a/packages/medusa/src/subscribers/order.js b/packages/medusa/src/subscribers/order.js index aaf00cccbd..e9b797baf6 100644 --- a/packages/medusa/src/subscribers/order.js +++ b/packages/medusa/src/subscribers/order.js @@ -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 diff --git a/packages/medusa/src/types/gift-card.ts b/packages/medusa/src/types/gift-card.ts index 4b88c56dff..596608f6c5 100644 --- a/packages/medusa/src/types/gift-card.ts +++ b/packages/medusa/src/types/gift-card.ts @@ -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 + tax_rate?: number | null } export type UpdateGiftCardInput = {