feat: new tax api (#979)

* feat: add tax calculation strategy (#885)

* feat: add tax calculation strategy

* fix: adds strategy loader

* fix: eslint ignore

* chore: cleanup

* fix: allow plugin overwrites

* fix: allow plugin overwrites

* fix: fake region

* Update packages/medusa/src/loaders/strategies.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* feat: adds tax related db entities + tax provider (#896)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: pr comments

* fix: unit test

* feat: totals service to ts (#911)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: adds TotalsServiceProps

* feat: adds integration tests for automatic tax calculation + shipping tax rates (#945)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: integration test helpers

* fix: adds factories + tests automatic tax rates

* fix: remove verbose

* fix: adds TotalsServiceProps

* fix: add shipping tax lines

* fix: add migration for shipping taxes

* fix: integration tests for shipping taxes

* fix: integration tests for shipping taxes

* fix: jsdoc types

* Feat/manual taxes (#950)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: integration test helpers

* fix: adds factories + tests automatic tax rates

* fix: remove verbose

* fix: adds TotalsServiceProps

* fix: add shipping tax lines

* fix: add migration for shipping taxes

* fix: integration tests for shipping taxes

* fix: integration tests for shipping taxes

* fix: add integration tests for manual taxes

* fix: cart service - cleanup jsdoc

* feat: add /carts/id/taxes to manually calculate taxes

* feat: add integration tests for order tax calculations

* fix: unit tests

* fix: merge

* fix: rm verbose

* fix: unit tests

* fix: object -> cartOrOrder

* fix: rounding

* Feat/complete order w tax lines (#951)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: integration test helpers

* fix: adds factories + tests automatic tax rates

* fix: remove verbose

* fix: adds TotalsServiceProps

* fix: add shipping tax lines

* fix: add migration for shipping taxes

* fix: integration tests for shipping taxes

* fix: integration tests for shipping taxes

* fix: add integration tests for manual taxes

* fix: cart service - cleanup jsdoc

* feat: add /carts/id/taxes to manually calculate taxes

* feat: add integration tests for order tax calculations

* feat: adds cart completion strategy + create order w. tax lines

* fix: unit tests

* fix: merge

* fix: rm verbose

* fix: unit tests

* fix: unit tests

* fix: unit tests

* fix: ensure calculation for list orders

* fix: unit tests

* fix: integration tests

* fix: adds cart order type gaurds

* Docs/tax api (#954)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: integration test helpers

* fix: adds factories + tests automatic tax rates

* fix: remove verbose

* fix: adds TotalsServiceProps

* fix: add shipping tax lines

* fix: add migration for shipping taxes

* fix: integration tests for shipping taxes

* fix: integration tests for shipping taxes

* fix: add integration tests for manual taxes

* fix: cart service - cleanup jsdoc

* feat: add /carts/id/taxes to manually calculate taxes

* feat: add integration tests for order tax calculations

* feat: adds cart completion strategy + create order w. tax lines

* fix: unit tests

* fix: merge

* fix: rm verbose

* fix: unit tests

* fix: unit tests

* fix: unit tests

* fix: ensure calculation for list orders

* fix: unit tests

* fix: integration tests

* docs: documents tax related methods and types

* fix: require either item_id or shipping_method_id

* feat: product type tax rate (#969)

* feat: adds tax related db entities + tax provider

* fix: add tax provider tests

* fix: add tax service unit tests

* fix: tests + migrations

* feat: totals service to ts

* fix: remove totals.js

* fix: add shipping methods

* fix: add inherited tax lines

* chore: rm tax-line repo

* fix: test

* fix: tests

* fix: tests

* fix: unit test

* fix: integration test helpers

* fix: adds factories + tests automatic tax rates

* fix: remove verbose

* fix: adds TotalsServiceProps

* fix: add shipping tax lines

* fix: add migration for shipping taxes

* fix: integration tests for shipping taxes

* fix: integration tests for shipping taxes

* fix: add integration tests for manual taxes

* fix: cart service - cleanup jsdoc

* feat: add /carts/id/taxes to manually calculate taxes

* feat: add integration tests for order tax calculations

* feat: adds cart completion strategy + create order w. tax lines

* fix: unit tests

* fix: merge

* fix: rm verbose

* fix: unit tests

* fix: unit tests

* fix: unit tests

* fix: ensure calculation for list orders

* fix: unit tests

* fix: integration tests

* docs: documents tax related methods and types

* fix: require either item_id or shipping_method_id

* feat: adds returns tests for new tax system

* feat: adds return lines + integration tests for swaps

* feat: return integration tests

* feat: adds product type tax rates

* feat: add tax management endpoints

* fix: create single migration

* fix: adds tax rates to js client

* fix: strats

* Fix/plugin tests (#998)

* plugin testing setup

* fix: test sendgrid plugin

* fix: test sendgrid plugin

* chore: clean

* chore: clean

* fix: clean up tests

* fix: remove dirty import

* fix: sendgrid + brightpearl

* fix: plugin integration tests

* fix: klarna

* fix: shipping method tax

* fix: remove taxrates

* fix: unit tests

* fix: integration

* fix: integration

* fix: plugins tests

* fix: ignore plugins

* fix: tests

* fix: taxes (#1017)

* fix: taxes

* fix: taxes

* fix: faulty ref

* fix: create tax-lines with claim items

* fix: snapshot tax-liens

* fix: allows integration test teardown to force deleting tables

* fix: tests

* fix: merge

* fix: adds tax-rates to client

* fix: adds tax-rates to medusa-react

* fix: tests

* fix: tests

* fix: add product types

* fix: adds tax provider endpoint + cascaded deletes on tax rate relations

* fix: move errors to service layer

* fix: cleanup api

* fix: unit tests

* fix: error handler in base-service

* fix: Add order region to swap on createFulfillment (#1110)

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Sebastian RindomandOliver Windall Juhl authored and olivermrbl committed 2022-02-25 18:53:49 +01:00
1 parent d80eaa172d
commit 47588e7a8d
223 files changed
+28546 -2229

No files matched your search

+1
View File
@@ -21,6 +21,7 @@
"@babel/core": "^7.14.3",
"@babel/preset-typescript": "^7.13.0",
"@types/express": "^4.17.13",
"@types/jest": "^27.0.3",
"@types/jsonwebtoken": "^8.5.5",
"babel-preset-medusa-package": "^1.1.19",
"cross-env": "^5.2.1",
+2
View File
@@ -35,9 +35,11 @@ export * from "./routes/admin/return-reasons"
export * from "./routes/admin/swaps"
export * from "./routes/admin/uploads"
export * from "./routes/admin/returns"
export * from "./routes/admin/tax-rates"
export * from "./routes/admin/shipping-options"
export * from "./routes/admin/regions"
export * from "./routes/admin/product-tags"
export * from "./routes/admin/product-types"
// Store
export * from "./routes/store/auth"
@@ -25,6 +25,8 @@ import collectionRoutes from "./collections"
import productTagRoutes from "./product-tags"
import notificationRoutes from "./notifications"
import noteRoutes from "./notes"
import taxRateRoutes from "./tax-rates"
import productTypesRoutes from "./product-types"
import customerGroupRoutes from "./customer-groups"
const route = Router()
@@ -79,8 +81,10 @@ export default (app, container, config) => {
notificationRoutes(route)
returnReasonRoutes(route)
productTagRoutes(route)
productTypesRoutes(route)
noteRoutes(route)
inviteRoutes(route)
taxRateRoutes(route)
customerGroupRoutes(route)
return app
@@ -15,6 +15,8 @@ const defaultRelations = [
"fulfillments.tracking_links",
"fulfillments.items",
"returns",
"returns.shipping_method",
"returns.shipping_method.tax_lines",
"returns.items",
"returns.items.reason",
"gift_cards",
@@ -169,7 +169,15 @@ export default async (req, res) => {
const order = await orderService
.withTransaction(manager)
.retrieve(id, {
relations: ["items", "discounts", "discounts.rule"],
relations: [
"customer",
"shipping_address",
"region",
"items",
"items.tax_lines",
"discounts",
"discounts.rule",
],
})
await claimService.withTransaction(manager).create({
@@ -81,7 +81,7 @@ export class AdminPostOrdersOrderSwapsSwapShipmentsReq {
@IsArray()
@IsOptional()
@IsString({ each: true })
tracking_numbers?: string[]
tracking_numbers?: string[] = []
@IsBoolean()
@IsOptional()
@@ -150,7 +150,13 @@ export default async (req, res) => {
.withTransaction(manager)
.retrieve(id, {
select: ["refunded_total", "total"],
relations: ["items", "swaps", "swaps.additional_items"],
relations: [
"items",
"items.tax_lines",
"swaps",
"swaps.additional_items",
"swaps.additional_items.tax_lines",
],
})
const swap = await swapService
@@ -170,6 +176,7 @@ export default async (req, res) => {
await swapService
.withTransaction(manager)
.createCart(swap.id, validated.custom_shipping_options)
const returnOrder = await returnService
.withTransaction(manager)
.retrieveBySwap(swap.id)
@@ -143,14 +143,6 @@ export default (app) => {
middlewares.wrap(require("./cancel-swap").default)
)
/**
* Receives the inventory to return from a swap
*/
route.post(
"/:id/swaps/:swap_id/receive",
middlewares.wrap(require("./receive-swap").default)
)
/**
* Fulfills a swap.
*/
@@ -246,6 +238,8 @@ export const defaultAdminOrdersRelations = [
"fulfillments.tracking_links",
"fulfillments.items",
"returns",
"returns.shipping_method",
"returns.shipping_method.tax_lines",
"returns.items",
"returns.items.reason",
"gift_cards",
@@ -390,7 +384,6 @@ export * from "./fulfill-swap"
export * from "./get-order"
export * from "./list-orders"
export * from "./process-swap-payment"
export * from "./receive-swap"
export * from "./refund-payment"
export * from "./request-return"
export * from "./update-claim"
@@ -1,95 +0,0 @@
import { Type } from "class-transformer"
import {
IsArray,
IsInt,
IsNotEmpty,
IsString,
ValidateNested,
} from "class-validator"
import { EntityManager } from "typeorm"
import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "."
import { OrderService, SwapService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /orders/{id}/swaps/{swap_id}/receive
* operationId: "PostOrdersOrderSwapsSwapReceive"
* summary: "Receive a Swap"
* description: "Registers a Swap as received."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Order.
* - (path) swap_id=* {string} The id of the Swap.
* requestBody:
* content:
* application/json:
* schema:
* properties:
* items:
* description: The Line Items that have been received.
* type: array
* items:
* properties:
* item_id:
* description: The id of the Line Item.
* type: string
* quantity:
* description: The quantity of the Line Item.
* type: integer
* tags:
* - Order
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* order:
* $ref: "#/components/schemas/order"
*/
export default async (req, res) => {
const { id, swap_id } = req.params
const validated = await validator(
AdminPostOrdersOrderSwapsSwapReceiveReq,
req.body
)
const orderService: OrderService = req.scope.resolve("orderService")
const swapService: SwapService = req.scope.resolve("swapService")
const entityManager: EntityManager = req.scope.resolve("manager")
await entityManager.transaction(async (manager) => {
await swapService
.withTransaction(manager)
.receiveReturn(swap_id, validated.items)
await orderService
.withTransaction(manager)
.registerSwapReceived(id, swap_id)
})
const order = await orderService.retrieve(id, {
select: defaultAdminOrdersFields,
relations: defaultAdminOrdersRelations,
})
res.status(200).json({ order })
}
export class AdminPostOrdersOrderSwapsSwapReceiveReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => Item)
items: Item[]
}
class Item {
@IsString()
@IsNotEmpty()
item_id: string
@IsInt()
@IsNotEmpty()
quantity: number
}
@@ -0,0 +1,40 @@
import { Router } from "express"
import { ProductType } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import "reflect-metadata"
const route = Router()
export default (app) => {
app.use("/product-types", route)
route.get("/", middlewares.wrap(require("./list-product-types").default))
return app
}
export const allowedAdminProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultAdminProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultAdminProductTypeRelations = []
export type AdminProductTypesListRes = PaginatedResponse & {
product_types: ProductType[]
}
export type AdminProductTypesRes = {
product_type: ProductType
}
export * from "./list-product-types"
@@ -0,0 +1,125 @@
import { Type } from "class-transformer"
import { MedusaError } from "medusa-core-utils"
import { IsNumber, IsString, IsOptional, ValidateNested } from "class-validator"
import { omit, pickBy, identity } from "lodash"
import {
allowedAdminProductTypeFields,
defaultAdminProductTypeFields,
defaultAdminProductTypeRelations,
} from "."
import { ProductType } from "../../../../models/product-type"
import ProductTypeService from "../../../../services/product-type"
import {
StringComparisonOperator,
DateComparisonOperator,
FindConfig,
} from "../../../../types/common"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
/**
* @oas [get] /product-types
* operationId: "GetProductTypes"
* summary: "List Product Types"
* description: "Retrieve a list of Product Types."
* x-authenticated: true
* parameters:
* - (query) limit {string} The number of types to return.
* - (query) offset {string} The offset of types to return.
* - (query) value {string} The value of types to return.
* - (query) id {string} The id of types to return.
* - (query) created_at {DateComparisonOperator} Date comparison for when resulting tas was created, i.e. less than, greater than etc.
* - (query) updated_at {DateComparisonOperator} Date comparison for when resulting tas was updated, i.e. less than, greater than etc.
* tags:
* - Product Tag
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* properties:
* types:
* $ref: "#/components/schemas/product_tag"
*/
export default async (req, res) => {
const validated = await validator(AdminGetProductTypesParams, req.query)
const typeService: ProductTypeService =
req.scope.resolve("productTypeService")
const listConfig: FindConfig<ProductType> = {
select: defaultAdminProductTypeFields as (keyof ProductType)[],
relations: defaultAdminProductTypeRelations,
skip: validated.offset,
take: validated.limit,
}
if (typeof validated.order !== "undefined") {
let orderField = validated.order
if (validated.order.startsWith("-")) {
const [, field] = validated.order.split("-")
orderField = field
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validated.order]: "ASC" }
}
if (!allowedAdminProductTypeFields.includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order field must be a valid product type field"
)
}
}
const filterableFields = omit(validated, ["limit", "offset"])
const [types, count] = await typeService.listAndCount(
pickBy(filterableFields, identity),
listConfig
)
res.status(200).json({
product_types: types,
count,
offset: validated.offset,
limit: validated.limit,
})
}
export class AdminGetProductTypesPaginationParams {
@IsNumber()
@IsOptional()
@Type(() => Number)
limit? = 10
@IsNumber()
@IsOptional()
@Type(() => Number)
offset? = 0
}
export class AdminGetProductTypesParams extends AdminGetProductTypesPaginationParams {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
id?: string | string[] | StringComparisonOperator
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
value?: string | string[] | StringComparisonOperator
@IsType([DateComparisonOperator])
@IsOptional()
created_at?: DateComparisonOperator
@IsType([DateComparisonOperator])
@IsOptional()
updated_at?: DateComparisonOperator
@IsString()
@IsOptional()
order?: string
}
@@ -5,6 +5,9 @@ import { RegionServiceMock } from "../../../../../services/__mocks__/region"
const defaultFields = [
"id",
"name",
"automatic_taxes",
"gift_cards_taxable",
"tax_provider_id",
"currency_code",
"tax_rate",
"tax_code",
@@ -5,6 +5,9 @@ import { RegionServiceMock } from "../../../../../services/__mocks__/region"
const defaultFields = [
"id",
"name",
"automatic_taxes",
"gift_cards_taxable",
"tax_provider_id",
"currency_code",
"tax_rate",
"tax_code",
@@ -77,6 +77,9 @@ export default (app) => {
export const defaultAdminRegionFields = [
"id",
"name",
"automatic_taxes",
"gift_cards_taxable",
"tax_provider_id",
"currency_code",
"tax_rate",
"tax_code",
@@ -1,4 +1,10 @@
import { IsArray, IsNumber, IsOptional, IsString } from "class-validator"
import {
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
} from "class-validator"
import { defaultAdminRegionRelations, defaultAdminRegionFields } from "."
import { validator } from "../../../../utils/validator"
import RegionService from "../../../../services/region"
@@ -22,6 +28,15 @@ import RegionService from "../../../../services/region"
* currency_code:
* description: "The 3 character ISO currency code to use for the Region."
* type: string
* automatic_taxes:
* description: "If true Medusa will automatically calculate taxes for carts in this region. If false you have to manually call POST /carts/:id/taxes."
* type: boolean
* gift_cards_taxable:
* description: "Whether gift cards in this region should be applied sales tax when purchasing a gift card"
* type: boolean
* tax_provider_id:
* description: "The id of the tax provider to use; if null the system tax provider is used"
* type: string
* tax_code:
* description: "An optional tax code the Region."
* type: string
@@ -86,6 +101,18 @@ export class AdminPostRegionsRegionReq {
@IsOptional()
tax_rate?: number
@IsBoolean()
@IsOptional()
gift_cards_taxable?: boolean
@IsBoolean()
@IsOptional()
automatic_taxes?: boolean
@IsString()
@IsOptional()
tax_provider_id?: string | null
@IsArray()
@IsString({ each: true })
@IsOptional()
@@ -40,8 +40,8 @@ describe("GET /admin/shipping-options", () => {
})
it("calls service retrieve", () => {
expect(ShippingOptionServiceMock.list).toHaveBeenCalledTimes(1)
expect(ShippingOptionServiceMock.list).toHaveBeenCalledWith(
expect(ShippingOptionServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(ShippingOptionServiceMock.listAndCount).toHaveBeenCalledWith(
{},
{
select: defaultFields,
@@ -1,6 +1,6 @@
import { Router } from "express"
import { ShippingOption } from "../../../.."
import { DeleteResponse } from "../../../../types/common"
import { PaginatedResponse, DeleteResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
const route = Router()
@@ -46,7 +46,7 @@ export const defaultFields = [
export const defaultRelations = ["region", "profile", "requirements"]
export type AdminShippingOptionsListRes = {
export type AdminShippingOptionsListRes = PaginatedResponse & {
shipping_options: ShippingOption[]
}
@@ -50,12 +50,12 @@ export default async (req, res) => {
)
const optionService = req.scope.resolve("shippingOptionService")
const data = await optionService.list(validatedParams, {
const [data, count] = await optionService.listAndCount(validatedParams, {
select: defaultFields,
relations: defaultRelations,
})
res.status(200).json({ shipping_options: data })
res.status(200).json({ shipping_options: data, count })
}
export class AdminGetShippingOptionsParams {
@@ -1,5 +1,5 @@
import { Router } from "express"
import { Store, PaymentProvider } from "./../../../../"
import { Store, PaymentProvider, TaxProvider } from "./../../../../"
import middlewares from "../../../middlewares"
const route = Router()
@@ -12,6 +12,10 @@ export default (app) => {
"/payment-providers",
middlewares.wrap(require("./list-payment-providers").default)
)
route.get(
"/tax-providers",
middlewares.wrap(require("./list-tax-providers").default)
)
route.post("/", middlewares.wrap(require("./update-store").default))
route.post(
"/currencies/:currency_code",
@@ -29,6 +33,10 @@ export type AdminStoresRes = {
store: Store
}
export type AdminTaxProvidersList = {
tax_providers: TaxProvider[]
}
export type AdminPaymentProvidersList = {
payment_providers: PaymentProvider[]
}
@@ -0,0 +1,28 @@
import { TaxProviderService } from "../../../../services"
/**
* @oas [get] /store/tax-providers
* operationId: "GetStoreTaxProviders"
* summary: "Retrieve configured Tax Providers"
* description: "Retrieves the configured Tax Providers"
* x-authenticated: true
* tags:
* - Store
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_providers:
* type: array
* items:
* $ref: "#/components/schemas/store"
*/
export default async (req, res) => {
const taxProviderService: TaxProviderService =
req.scope.resolve("taxProviderService")
const taxProviders = await taxProviderService.list()
res.status(200).json({ tax_providers: taxProviders })
}
@@ -11,12 +11,6 @@ const defaultRelations = [
"shipping_address",
"shipping_methods",
"cart",
"cart.items",
"cart.region",
"cart.shipping_methods",
"cart.gift_cards",
"cart.discounts",
"cart.payment",
]
const defaultFields = [
@@ -30,12 +30,6 @@ export const defaultAdminSwapRelations = [
"shipping_address",
"shipping_methods",
"cart",
"cart.items",
"cart.region",
"cart.shipping_methods",
"cart.gift_cards",
"cart.discounts",
"cart.payment",
]
export const defaultAdminSwapFields = [
@@ -0,0 +1,66 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /tax-rates/:id/product-types/batch
* operationId: "PostTaxRatesTaxRateProductTypes"
* summary: "Add Tax Rate to Product Types"
* description: "Associates a Tax Rate with a list of Product Types"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(
AdminPostTaxRatesTaxRateProductTypesReq,
req.body
)
const query = await validator(
AdminPostTaxRatesTaxRateProductTypesParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.addToProductType(req.params.id, value.product_types)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminPostTaxRatesTaxRateProductTypesReq {
@IsArray()
product_types: string[]
}
export class AdminPostTaxRatesTaxRateProductTypesParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,63 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /tax-rates/:id/products/batch
* operationId: "PostTaxRatesTaxRateProducts"
* summary: "Add Tax Rate to Products"
* description: "Associates a Tax Rate with a list of Products"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(AdminPostTaxRatesTaxRateProductsReq, req.body)
const query = await validator(
AdminPostTaxRatesTaxRateProductsParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.addToProduct(req.params.id, value.products)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminPostTaxRatesTaxRateProductsReq {
@IsArray()
products: string[]
}
export class AdminPostTaxRatesTaxRateProductsParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,65 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /tax-rates/:id/shipping-options/batch
* operationId: "PostTaxRatesTaxRateShippingOptions"
* summary: "Add Tax Rate to Product Types"
* description: "Associates a Tax Rate with a list of Product Types"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(
AdminPostTaxRatesTaxRateShippingOptionsReq,
req.body
)
const query = await validator(
AdminPostTaxRatesTaxRateShippingOptionsParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.addToShippingOption(req.params.id, value.shipping_options)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminPostTaxRatesTaxRateShippingOptionsReq {
@IsArray()
shipping_options: string[]
}
export class AdminPostTaxRatesTaxRateShippingOptionsParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,114 @@
import { EntityManager } from "typeorm"
import { IsString, IsArray, IsOptional } from "class-validator"
import { omit } from "lodash"
import { MedusaError } from "medusa-core-utils"
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
/**
* @oas [post] /tax-rates
* operationId: "PostTaxRates"
* summary: "Create a Tax Rate"
* description: "Creates a Tax Rate"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(AdminPostTaxRatesReq, req.body)
const query = await validator(AdminPostTaxRatesParams, req.query)
const manager: EntityManager = req.scope.resolve("manager")
const rateService: TaxRateService = req.scope.resolve("taxRateService")
let id: string | undefined
await manager.transaction(async (tx) => {
const txRateService = rateService.withTransaction(tx)
const created = await txRateService.create(
omit(value, ["products", "product_types", "shipping_options"])
)
id = created.id
if (typeof value.products !== "undefined") {
await txRateService.addToProduct(id, value.products)
}
if (typeof value.product_types !== "undefined") {
await txRateService.addToProductType(id, value.product_types)
}
if (typeof value.shipping_options !== "undefined") {
await txRateService.addToShippingOption(id, value.shipping_options)
}
})
if (typeof id === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Rate was not created"
)
}
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminPostTaxRatesReq {
@IsString()
code: string
@IsString()
name: string
@IsString()
region_id: string
@IsOptional()
@IsType([Number, null])
rate?: number | null
@IsOptional()
@IsArray()
products?: string[]
@IsOptional()
@IsArray()
shipping_options?: string[]
@IsOptional()
@IsArray()
product_types?: string[]
}
export class AdminPostTaxRatesParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,40 @@
import { TaxRateService } from "../../../../services"
/**
* @oas [delete] /tax-rates/{id}
* operationId: "DeleteTaxRatesTaxRate"
* summary: "Delete a Tax Rate"
* description: "Deletes a Tax Rate"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Shipping Option.
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted Shipping Option.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id } = req.params
const taxRateService: TaxRateService = req.scope.resolve("taxRateService")
await taxRateService.delete(id)
res.json({
id: id,
object: "tax-rate",
deleted: true,
})
}
@@ -0,0 +1,50 @@
import { getRetrieveConfig, pickByConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /tax-rates/:id
* operationId: "GetTaxRatesTaxRate"
* summary: "Get Tax Rate"
* description: "Retrieves a TaxRate"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(AdminGetTaxRatesTaxRateParams, req.query)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
const config = getRetrieveConfig(
value.fields as (keyof TaxRate)[],
value.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig<TaxRate>(rate, config)
res.json({ tax_rate: data })
}
export class AdminGetTaxRatesTaxRateParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,119 @@
import { Router } from "express"
import { TaxRate } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
const route = Router()
export default (app) => {
app.use("/tax-rates", route)
/**
* List tax rates
*/
route.get("/", middlewares.wrap(require("./list-tax-rates").default))
/**
* Get a tax rate
*/
route.get("/:id", middlewares.wrap(require("./get-tax-rate").default))
/**
* Create a tax rate
*/
route.post("/", middlewares.wrap(require("./create-tax-rate").default))
/**
* Update a tax rate
*/
route.post("/:id", middlewares.wrap(require("./update-tax-rate").default))
/**
* Remove products from tax rate
*/
route.delete(
"/:id/products/batch",
middlewares.wrap(require("./remove-from-products").default)
)
/**
* Remove product types from tax rate
*/
route.delete(
"/:id/product-types/batch",
middlewares.wrap(require("./remove-from-product-types").default)
)
/**
* Remove shipping options from tax rate
*/
route.delete(
"/:id/shipping-options/batch",
middlewares.wrap(require("./remove-from-shipping-options").default)
)
/**
* Add products to tax rate
*/
route.post(
"/:id/products/batch",
middlewares.wrap(require("./add-to-products").default)
)
/**
* Add product types to tax rate
*/
route.post(
"/:id/product-types/batch",
middlewares.wrap(require("./add-to-product-types").default)
)
/**
* Add to shipping options
*/
route.post(
"/:id/shipping-options/batch",
middlewares.wrap(require("./add-to-shipping-options").default)
)
/**
* Delete a tax rate
*/
route.delete("/:id", middlewares.wrap(require("./delete-tax-rate").default))
return app
}
export const defaultAdminTaxRatesRelations = []
export const defaultAdminTaxRatesFields: (keyof TaxRate)[] = [
"id",
"rate",
"code",
"name",
"region_id",
"created_at",
"updated_at",
]
export type AdminTaxRatesDeleteRes = DeleteResponse
export type AdminTaxRatesListRes = PaginatedResponse & {
tax_rates: TaxRate[]
}
export type AdminTaxRatesRes = {
tax_rate: TaxRate
}
export * from "./list-tax-rates"
export * from "./get-tax-rate"
export * from "./remove-from-product-types"
export * from "./remove-from-products"
export * from "./remove-from-shipping-options"
export * from "./add-to-product-types"
export * from "./add-to-products"
export * from "./add-to-shipping-options"
export * from "./create-tax-rate"
export * from "./delete-tax-rate"
export * from "./update-tax-rate"
@@ -0,0 +1,111 @@
import { getListConfig, pickByConfig } from "./utils/get-query-config"
import {
IsArray,
ValidateNested,
IsNumber,
IsOptional,
IsString,
} from "class-validator"
import { Type } from "class-transformer"
import { omit, pickBy, pick, identity } from "lodash"
import { TaxRate } from "../../../.."
import {
NumericalComparisonOperator,
FindConfig,
} from "../../../../types/common"
import { TaxRateService } from "../../../../services"
import { IsType } from "../../../../utils/validators/is-type"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /tax-rates
* operationId: "GetTaxRates"
* summary: "List Tax Rates"
* description: "Retrieves a list of TaxRates"
* x-authenticated: true
* parameters:
* - (query) q {string} Query used for searching orders.
* - (query) id {string} Id of the order to search for.
* - (query) region_id {string} to search for.
* - (query) code {string} to search for.
* - (query) rate {string} to search for.
* - (query) created_at {DateComparisonOperator} Date comparison for when resulting orders was created, i.e. less than, greater than etc.
* - (query) updated_at {DateComparisonOperator} Date comparison for when resulting orders was updated, i.e. less than, greater than etc.
* - (query) offset {string} How many orders to skip in the result.
* - (query) limit {string} Limit the number of orders returned.
* - (query) fields {string} (Comma separated) Which fields should be included in each order of the result.
* tags:
* - Order
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* orders:
* type: array
* items:
* $ref: "#/components/schemas/order"
*/
export default async (req, res) => {
const value = await validator(AdminGetTaxRatesParams, req.query)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
const listConfig = getListConfig()
const filterableFields = omit(value, [
"limit",
"offset",
"expand",
"fields",
"order",
])
const [rates, count] = await rateService.listAndCount(
pickBy(filterableFields, identity),
listConfig
)
const data = pickByConfig<TaxRate>(rates, listConfig)
res.json({ tax_rates: data, count, offset: value.offset, limit: value.limit })
}
export class AdminGetTaxRatesParams {
@IsOptional()
@IsType([String, [String]])
region_id?: string | string[]
@IsString()
@IsOptional()
name?: string
@IsString()
@IsOptional()
code?: string
@IsType([NumericalComparisonOperator, Number])
@IsOptional()
rate?: number | NumericalComparisonOperator
@IsNumber()
@IsOptional()
@Type(() => Number)
offset? = 0
@IsNumber()
@IsOptional()
@Type(() => Number)
limit? = 50
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,66 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [delete] /tax-rates/:id/product-types/batch
* operationId: "DeleteTaxRatesTaxRateProductTypes"
* summary: "Remove Tax Rate from Product Types"
* description: "Removes a Tax Rate from a list of Product Types"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(
AdminDeleteTaxRatesTaxRateProductTypesReq,
req.body
)
const query = await validator(
AdminDeleteTaxRatesTaxRateProductTypesParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.removeFromProductType(req.params.id, value.product_types)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminDeleteTaxRatesTaxRateProductTypesReq {
@IsArray()
product_types: string[]
}
export class AdminDeleteTaxRatesTaxRateProductTypesParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,63 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [delete] /tax-rates/:id/products/batch
* operationId: "DeleteTaxRatesTaxRateProducts"
* summary: "Removes Tax Rate from Products"
* description: "Removes a Tax Rate from a list of Products"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(AdminDeleteTaxRatesTaxRateProductsReq, req.body)
const query = await validator(
AdminDeleteTaxRatesTaxRateProductsParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.removeFromProduct(req.params.id, value.products)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminDeleteTaxRatesTaxRateProductsReq {
@IsArray()
products: string[]
}
export class AdminDeleteTaxRatesTaxRateProductsParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,69 @@
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { IsArray, IsOptional } from "class-validator"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [delete] /tax-rates/:id/shipping-options/batch
* operationId: "DeleteTaxRatesTaxRateShippingOptions"
* summary: "Removes a Tax Rate from Product Types"
* description: "Removes a Tax Rate from a list of Product Types"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(
AdminDeleteTaxRatesTaxRateShippingOptionsReq,
req.body
)
const query = await validator(
AdminDeleteTaxRatesTaxRateShippingOptionsParams,
req.query
)
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await rateService.removeFromShippingOption(
req.params.id,
value.shipping_options
)
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminDeleteTaxRatesTaxRateShippingOptionsReq {
@IsArray()
shipping_options: string[]
}
export class AdminDeleteTaxRatesTaxRateShippingOptionsParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,116 @@
import { EntityManager } from "typeorm"
import { IsString, IsArray, IsOptional } from "class-validator"
import { omit } from "lodash"
import { pickByConfig, getRetrieveConfig } from "./utils/get-query-config"
import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
/**
* @oas [post] /tax-rates/:id
* operationId: "PostTaxRatesTaxRate"
* summary: "Update a Tax Rate"
* description: "Updates a Tax Rate"
* x-authenticated: true
* tags:
* - Tax Rates
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* tax_rate:
* type: array
* items:
* $ref: "#/components/schemas/tax_rate"
*/
export default async (req, res) => {
const value = await validator(AdminPostTaxRatesTaxRateReq, req.body)
const query = await validator(AdminPostTaxRatesTaxRateParams, req.query)
const manager: EntityManager = req.scope.resolve("manager")
const rateService: TaxRateService = req.scope.resolve("taxRateService")
await manager.transaction(async (tx) => {
const txRateService = rateService.withTransaction(tx)
await txRateService.update(
req.params.id,
omit(value, ["products", "product_types", "shipping_options"])
)
if (typeof value.products !== "undefined") {
await txRateService.addToProduct(req.params.id, value.products, true)
}
if (typeof value.product_types !== "undefined") {
await txRateService.addToProductType(
req.params.id,
value.product_types,
true
)
}
if (typeof value.shipping_options !== "undefined") {
await txRateService.addToShippingOption(
req.params.id,
value.shipping_options,
true
)
}
})
const config = getRetrieveConfig(
query.fields as (keyof TaxRate)[],
query.expand
)
const rate = await rateService.retrieve(req.params.id, config)
const data = pickByConfig(rate, config)
res.json({ tax_rate: data })
}
export class AdminPostTaxRatesTaxRateReq {
@IsOptional()
@IsString()
code?: string
@IsOptional()
@IsString()
name?: string
@IsOptional()
@IsString()
region_id?: string
@IsOptional()
@IsType([Number, null])
rate?: number | null
@IsOptional()
@IsArray()
products?: string[]
@IsOptional()
@IsArray()
shipping_options?: string[]
@IsOptional()
@IsArray()
product_types?: string[]
}
export class AdminPostTaxRatesTaxRateParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -0,0 +1,78 @@
import { defaultAdminTaxRatesFields, defaultAdminTaxRatesRelations } from "../"
import { pick } from "lodash"
import { FindConfig } from "../../../../../types/common"
import { TaxRate } from "../../../../.."
export function pickByConfig<T>(
obj: T | T[],
config: FindConfig<T>
): Partial<T> | Partial<T>[] {
const fields = [...(config.select ?? []), ...(config.relations ?? [])]
if (fields.length) {
if (Array.isArray(obj)) {
return obj.map((o) => pick(o, fields))
} else {
return pick(obj, fields)
}
}
return obj
}
export function getRetrieveConfig(
fields?: (keyof TaxRate)[],
expand?: string[]
): FindConfig<TaxRate> {
let includeFields: (keyof TaxRate)[] = []
if (typeof fields !== "undefined") {
const fieldSet = new Set(fields)
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TaxRate)[]
}
let expandFields: string[] = []
if (typeof expand !== "undefined") {
expandFields = expand
}
return {
select: includeFields.length ? includeFields : defaultAdminTaxRatesFields,
relations: expandFields.length
? expandFields
: defaultAdminTaxRatesRelations,
}
}
export function getListConfig(
fields?: (keyof TaxRate)[],
expand?: string[],
limit = 50,
offset = 0,
order?: { [k: symbol]: "DESC" | "ASC" }
): FindConfig<TaxRate> {
let includeFields: (keyof TaxRate)[] = []
if (typeof fields !== "undefined") {
const fieldSet = new Set(fields)
// Ensure created_at is included, since we are sorting on this
fieldSet.add("created_at")
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TaxRate)[]
}
let expandFields: string[] = []
if (typeof expand !== "undefined") {
expandFields = expand
}
const orderBy = order ?? { created_at: "DESC" }
return {
select: includeFields.length ? includeFields : defaultAdminTaxRatesFields,
relations: expandFields.length
? expandFields
: defaultAdminTaxRatesRelations,
skip: offset,
take: limit,
order: orderBy,
}
}
@@ -1,119 +1,56 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { CartServiceMock } from "../../../../../services/__mocks__/cart"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
import { SwapServiceMock } from "../../../../../services/__mocks__/swap"
import { CompletionStrategyMock } from "../../../../../strategies/__mocks__/cart-completion"
describe("POST /store/carts/:id", () => {
describe("successfully completes a normal cart", () => {
describe("POST /store/carts/:id/complete", () => {
describe("successfully calls completion strategy - Legacy Endpoint", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("test-cart")}/complete-cart`
)
subject = await request("POST", `/store/carts/test-cart/complete-cart`)
})
afterAll(() => {
jest.clearAllMocks()
})
it("Call CartService authorizePayment", () => {
expect(CartServiceMock.authorizePayment).toHaveBeenCalledTimes(1)
expect(CartServiceMock.authorizePayment).toHaveBeenCalledWith(
IdMap.getId("test-cart"),
{ idempotency_key: "testkey" }
it("calls completion strat", () => {
expect(CompletionStrategyMock.complete).toHaveBeenCalledTimes(1)
expect(CompletionStrategyMock.complete).toHaveBeenCalledWith(
"test-cart",
{ idempotency_key: "testkey", recovery_point: "started" },
undefined
)
})
it("Call OrderService createFromCart", () => {
expect(OrderServiceMock.createFromCart).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.createFromCart).toHaveBeenCalledWith(
IdMap.getId("test-cart")
)
})
it("returns 200", () => {
it("responds correctly", () => {
expect(subject.status).toEqual(200)
})
it("returns the created order", () => {
expect(subject.body.data.id).toEqual(IdMap.getId("test-order"))
expect(subject.body).toEqual({})
})
})
describe("successfully completes a swap cart", () => {
describe("successfully calls completion strategy", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("swap-cart")}/complete-cart`
)
subject = await request("POST", `/store/carts/test-cart/complete`)
})
afterAll(() => {
jest.clearAllMocks()
})
it("Call CartService authorizePayment", () => {
expect(CartServiceMock.authorizePayment).toHaveBeenCalledTimes(1)
expect(CartServiceMock.authorizePayment).toHaveBeenCalledWith(
IdMap.getId("swap-cart"),
{ idempotency_key: "testkey" }
it("calls completion strat", () => {
expect(CompletionStrategyMock.complete).toHaveBeenCalledTimes(1)
expect(CompletionStrategyMock.complete).toHaveBeenCalledWith(
"test-cart",
{ idempotency_key: "testkey", recovery_point: "started" },
undefined
)
})
it("Call SwapService registerCartCompletion", () => {
expect(SwapServiceMock.registerCartCompletion).toHaveBeenCalledTimes(1)
expect(SwapServiceMock.registerCartCompletion).toHaveBeenCalledWith(
"test-swap"
)
})
it("returns 200", () => {
it("responds correctly", () => {
expect(subject.status).toEqual(200)
})
it("returns the created order", () => {
expect(subject.body.data.id).toEqual("test-swap")
})
})
describe("returns early if payment requires more work", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/store/carts/${IdMap.getId("test-cart2")}/complete-cart`
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("Call CartService authorizePayment", () => {
expect(CartServiceMock.authorizePayment).toHaveBeenCalledTimes(1)
expect(CartServiceMock.authorizePayment).toHaveBeenCalledWith(
IdMap.getId("test-cart2"),
{ idempotency_key: "testkey" }
)
})
it("Call CartService retrieve 1 time", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns the created order", () => {
expect(subject.body.data.id).toEqual(IdMap.getId("test-cart2"))
expect(subject.body.data.payment_session.status).toEqual("requires_more")
expect(subject.body).toEqual({})
})
})
})
@@ -0,0 +1,118 @@
import { EntityManager } from "typeorm"
import { IdempotencyKey } from "../../../../models/idempotency-key"
import { CartService, IdempotencyKeyService } from "../../../../services"
/**
* @oas [post] /carts/{id}/taxes
* summary: "Calculate Cart Taxes"
* operationId: "PostCartsCartTaxes"
* description: "Calculates taxes for a cart. Depending on the cart's region
* this may involve making 3rd party API calls to a Tax Provider service."
* parameters:
* - (path) id=* {String} The Cart id.
* tags:
* - Cart
* responses:
* 200:
* description: "A cart object with the tax_total field populated"
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* properties:
* cart:
* $ref: "#/components/schemas/cart"
*/
export default async (req, res) => {
const { id } = req.params
const idempotencyKeyService: IdempotencyKeyService = req.scope.resolve(
"idempotencyKeyService"
)
const headerKey = req.get("Idempotency-Key") || ""
let idempotencyKey: IdempotencyKey
try {
idempotencyKey = await idempotencyKeyService.initializeRequest(
headerKey,
req.method,
req.params,
req.path
)
} catch (error) {
console.log(error)
res.status(409).send("Failed to create idempotency key")
return
}
res.setHeader("Access-Control-Expose-Headers", "Idempotency-Key")
res.setHeader("Idempotency-Key", idempotencyKey.idempotency_key)
const cartService: CartService = req.scope.resolve("cartService")
let inProgress = true
let err = false
while (inProgress) {
switch (idempotencyKey.recovery_point) {
case "started": {
const { key, error } = await idempotencyKeyService.workStage(
idempotencyKey.idempotency_key,
async (manager: EntityManager) => {
const cart = await cartService.withTransaction(manager).retrieve(
id,
{
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
},
{ force_taxes: true }
)
return {
response_code: 200,
response_body: { cart },
}
}
)
if (error) {
inProgress = false
err = error
} else {
idempotencyKey = key
}
break
}
case "finished": {
inProgress = false
break
}
default:
idempotencyKey = await idempotencyKeyService.update(
idempotencyKey.idempotency_key,
{
recovery_point: "finished",
response_code: 500,
response_body: { message: "Unknown recovery point" },
}
)
break
}
}
if (err) {
throw err
}
res.status(idempotencyKey.response_code).json(idempotencyKey.response_body)
}
@@ -1,12 +1,6 @@
import { MedusaError } from "medusa-core-utils"
import {
CartService,
IdempotencyKeyService,
OrderService,
SwapService,
} from "../../../../services"
import { Order } from "../../../../models/order"
import { ICartCompletionStrategy } from "../../../../interfaces"
import { IdempotencyKeyService } from "../../../../services"
import { IdempotencyKey } from "../../../../models/idempotency-key"
/**
* @oas [post] /carts/{id}/complete
@@ -54,7 +48,7 @@ export default async (req, res) => {
const headerKey = req.get("Idempotency-Key") || ""
let idempotencyKey
let idempotencyKey: IdempotencyKey
try {
idempotencyKey = await idempotencyKeyService.initializeRequest(
headerKey,
@@ -71,231 +65,15 @@ export default async (req, res) => {
res.setHeader("Access-Control-Expose-Headers", "Idempotency-Key")
res.setHeader("Idempotency-Key", idempotencyKey.idempotency_key)
const cartService: CartService = req.scope.resolve("cartService")
const orderService: OrderService = req.scope.resolve("orderService")
const swapService: SwapService = req.scope.resolve("swapService")
const completionStrat: ICartCompletionStrategy = req.scope.resolve(
"cartCompletionStrategy"
)
let inProgress = true
let err = false
const { response_code, response_body } = await completionStrat.complete(
id,
idempotencyKey,
req.request_context
)
while (inProgress) {
switch (idempotencyKey.recovery_point) {
case "started": {
const { key, error } = await idempotencyKeyService.workStage(
idempotencyKey.idempotency_key,
async (manager) => {
let cart = await cartService.withTransaction(manager).retrieve(id)
if (cart.completed_at) {
return {
response_code: 409,
response_body: {
code: MedusaError.Codes.CART_INCOMPATIBLE_STATE,
message: "Cart has already been completed",
type: MedusaError.Types.NOT_ALLOWED,
},
}
}
cart = await cartService
.withTransaction(manager)
.authorizePayment(id, {
...req.request_context,
idempotency_key: idempotencyKey.idempotency_key,
})
if (cart.payment_session) {
if (
cart.payment_session.status === "requires_more" ||
cart.payment_session.status === "pending"
) {
return {
response_code: 200,
response_body: {
data: cart,
payment_status: cart.payment_session.status,
type: "cart",
},
}
}
}
return {
recovery_point: "payment_authorized",
}
}
)
if (error) {
inProgress = false
err = error
} else {
idempotencyKey = key
}
break
}
case "payment_authorized": {
const { key, error } = await idempotencyKeyService.workStage(
idempotencyKey.idempotency_key,
async (manager) => {
const cart = await cartService
.withTransaction(manager)
.retrieve(id, {
select: ["total"],
relations: ["payment", "payment_sessions"],
})
let order: Order
// If cart is part of swap, we register swap as complete
switch (cart.type) {
case "swap": {
try {
const swapId = cart.metadata?.swap_id
let swap = await swapService
.withTransaction(manager)
.registerCartCompletion(swapId)
swap = await swapService
.withTransaction(manager)
.retrieve(swap.id, { relations: ["shipping_address"] })
return {
response_code: 200,
response_body: { data: swap, type: "swap" },
}
} catch (error) {
if (
error &&
error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY
) {
return {
response_code: 409,
response_body: {
message: error.message,
type: error.type,
code: error.code,
},
}
} else {
throw error
}
}
}
// case "payment_link":
default: {
if (typeof cart.total === "undefined") {
return {
response_code: 500,
response_body: {
message: "Unexpected state",
},
}
}
if (!cart.payment && cart.total > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cart payment not authorized`
)
}
try {
order = await orderService
.withTransaction(manager)
.createFromCart(cart.id)
} catch (error) {
if (
error &&
error.message === "Order from cart already exists"
) {
order = await orderService
.withTransaction(manager)
.retrieveByCartId(id, {
select: [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
relations: ["shipping_address", "items", "payments"],
})
return {
response_code: 200,
response_body: { data: order, type: "order" },
}
} else if (
error &&
error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY
) {
return {
response_code: 409,
response_body: {
message: error.message,
type: error.type,
code: error.code,
},
}
} else {
throw error
}
}
}
}
order = await orderService
.withTransaction(manager)
.retrieve(order.id, {
select: [
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
relations: ["shipping_address", "items", "payments"],
})
return {
response_code: 200,
response_body: { data: order, type: "order" },
}
}
)
if (error) {
inProgress = false
err = error
} else {
idempotencyKey = key
}
break
}
case "finished": {
inProgress = false
break
}
default:
idempotencyKey = await idempotencyKeyService.update(
idempotencyKey.idempotency_key,
{
recovery_point: "finished",
response_code: 500,
response_body: { message: "Unknown recovery point" },
}
)
break
}
}
if (err) {
throw err
}
res.status(idempotencyKey.response_code).json(idempotencyKey.response_body)
res.status(response_code).json(response_body)
}
@@ -93,6 +93,12 @@ export default (app, container) => {
middlewares.wrap(require("./add-shipping-method").default)
)
// Taxes
route.post(
"/:id/taxes",
middlewares.wrap(require("./calculate-taxes").default)
)
return app
}
@@ -128,7 +128,13 @@ export default async (req, res) => {
.withTransaction(manager)
.retrieve(swapDto.order_id, {
select: ["refunded_total", "total"],
relations: ["items", "swaps", "swaps.additional_items"],
relations: [
"items",
"items.tax_lines",
"swaps",
"swaps.additional_items",
"swaps.additional_items.tax_lines",
],
})
let returnShipping
@@ -8,6 +8,7 @@ import config from "../config"
import apiLoader from "../loaders/api"
import passportLoader from "../loaders/passport"
import servicesLoader from "../loaders/services"
import strategiesLoader from "../loaders/strategies"
const adminSessionOpts = {
cookieName: "session",
@@ -45,6 +46,7 @@ testApp.use((req, res, next) => {
})
servicesLoader({ container })
strategiesLoader({ container })
passportLoader({ app: testApp, container })
testApp.use((req, res, next) => {
+61 -51
View File
@@ -1,55 +1,65 @@
// API payloads
export * from "./api"
// Interfaces
export * from "./interfaces"
// Models
export { Address } from "./models/address"
export { Cart } from "./models/cart"
export { ClaimImage } from "./models/claim-image"
export { ClaimItem } from "./models/claim-item"
export { ClaimOrder } from "./models/claim-order"
export { ClaimTag } from "./models/claim-tag"
export { Country } from "./models/country"
export { Currency } from "./models/currency"
export { CustomShippingOption } from "./models/custom-shipping-option"
export { Customer } from "./models/customer"
export { CustomerGroup } from "./models/customer-group"
export { Discount } from "./models/discount"
export { DiscountRule } from "./models/discount-rule"
export { DraftOrder } from "./models/draft-order"
export { Fulfillment } from "./models/fulfillment"
export { FulfillmentItem } from "./models/fulfillment-item"
export { FulfillmentProvider } from "./models/fulfillment-provider"
export { GiftCard } from "./models/gift-card"
export { GiftCardTransaction } from "./models/gift-card-transaction"
export { IdempotencyKey } from "./models/idempotency-key"
export { Image } from "./models/image"
export { Invite } from "./models/invite"
export { LineItem } from "./models/line-item"
export { MoneyAmount } from "./models/money-amount"
export { Note } from "./models/note"
export { Notification } from "./models/notification"
export { Oauth } from "./models/oauth"
export { Order } from "./models/order"
export { Payment } from "./models/payment"
export { PaymentProvider } from "./models/payment-provider"
export { PaymentSession } from "./models/payment-session"
export { Product } from "./models/product"
export { ProductCollection } from "./models/product-collection"
export { ProductOption } from "./models/product-option"
export { ProductOptionValue } from "./models/product-option-value"
export { ProductTag } from "./models/product-tag"
export { ProductType } from "./models/product-type"
export { ProductVariant } from "./models/product-variant"
export { Refund } from "./models/refund"
export { Region } from "./models/region"
export { Return } from "./models/return"
export { ReturnItem } from "./models/return-item"
export { ReturnReason } from "./models/return-reason"
export { ShippingMethod } from "./models/shipping-method"
export { ShippingOption } from "./models/shipping-option"
export { ShippingOptionRequirement } from "./models/shipping-option-requirement"
export { ShippingProfile } from "./models/shipping-profile"
export { StagedJob } from "./models/staged-job"
export { Store } from "./models/store"
export { Swap } from "./models/swap"
export { User } from "./models/user"
export * from "./models/shipping-tax-rate"
export * from "./models/product-tax-rate"
export * from "./models/product-type-tax-rate"
export * from "./models/tax-rate"
export * from "./models/shipping-method-tax-line"
export * from "./models/line-item-tax-line"
export * from "./models/address"
export * from "./models/cart"
export * from "./models/claim-image"
export * from "./models/claim-item"
export * from "./models/claim-order"
export * from "./models/claim-tag"
export * from "./models/country"
export * from "./models/currency"
export * from "./models/custom-shipping-option"
export * from "./models/customer"
export * from "./models/customer-group"
export * from "./models/discount"
export * from "./models/discount-rule"
export * from "./models/draft-order"
export * from "./models/fulfillment"
export * from "./models/fulfillment-item"
export * from "./models/fulfillment-provider"
export * from "./models/gift-card"
export * from "./models/gift-card-transaction"
export * from "./models/idempotency-key"
export * from "./models/image"
export * from "./models/invite"
export * from "./models/line-item"
export * from "./models/money-amount"
export * from "./models/note"
export * from "./models/notification"
export * from "./models/oauth"
export * from "./models/order"
export * from "./models/payment"
export * from "./models/payment-provider"
export * from "./models/payment-session"
export * from "./models/product"
export * from "./models/product-collection"
export * from "./models/product-option"
export * from "./models/product-option-value"
export * from "./models/product-tag"
export * from "./models/product-type"
export * from "./models/product-variant"
export * from "./models/refund"
export * from "./models/region"
export * from "./models/return"
export * from "./models/return-item"
export * from "./models/return-reason"
export * from "./models/shipping-method"
export * from "./models/shipping-option"
export * from "./models/shipping-option-requirement"
export * from "./models/shipping-profile"
export * from "./models/staged-job"
export * from "./models/store"
export * from "./models/swap"
export * from "./models/user"
export * from "./models/tax-provider"
@@ -0,0 +1,33 @@
import { IdempotencyKey } from "../models/idempotency-key"
import { RequestContext } from "../types/request"
export type CartCompletionResponse = {
/** The response code for the completion request */
response_code: number
/** The response body for the completion request */
response_body: object
}
export interface ICartCompletionStrategy {
/**
* Takes a cart id and completes the cart. This for example takes place when
* creating an order or confirming a swap.
* @param cartId - the id of the Cart to complete.
* @param idempotencyKey - the idempotency key for the request
* @param context - the request context for the completion request
* @return the response for the completion request
*/
complete(
cartId: string,
idempotencyKey: IdempotencyKey,
context: RequestContext
): Promise<CartCompletionResponse>
}
export function isCartCompletionStrategy(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any
): object is ICartCompletionStrategy {
return typeof object.complete === "function"
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./tax-calculation-strategy"
export * from "./cart-completion-strategy"
export * from "./tax-service"
@@ -0,0 +1,27 @@
import { LineItem } from "../models/line-item"
import { TaxCalculationContext } from "./tax-service"
import { LineItemTaxLine } from "../models/line-item-tax-line"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
export interface ITaxCalculationStrategy {
/**
* Calculates the tax amount for a given set of line items under applicable
* tax conditions and calculation contexts.
* @param items - the line items to calculate the tax total for
* @param taxLines - the tax lines that applies to the calculation
* @param calculationContext - other details relevant for the calculation
* @return the tax total
*/
calculate(
items: LineItem[],
taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[],
calculationContext: TaxCalculationContext
): Promise<number>
}
export function isTaxCalculationStrategy(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any
): object is ITaxCalculationStrategy {
return typeof object.calculate === "function"
}
@@ -0,0 +1,59 @@
import { LineItem } from "../models/line-item"
import { Region } from "../models/region"
import { Address } from "../models/address"
import { ShippingMethod } from "../models/shipping-method"
import { Customer } from "../models/customer"
import { ProviderTaxLine, TaxServiceRate } from "../types/tax-service"
import { LineAllocationsMap } from "../types/totals"
/**
* A shipping method and the tax rates that have been configured to apply to the
* shipping method.
*/
export type ShippingTaxCalculationLine = {
shipping_method: ShippingMethod
rates: TaxServiceRate[]
}
/**
* A line item and the tax rates that have been configured to apply to the
* product contained in the line item.
*/
export type ItemTaxCalculationLine = {
item: LineItem
rates: TaxServiceRate[]
}
/**
* Information relevant to a tax calculation, such as the shipping address where
* the items are going.
*/
export type TaxCalculationContext = {
shipping_address: Address | null
customer: Customer
region: Region
is_return: boolean
shipping_methods: ShippingMethod[]
allocation_map: LineAllocationsMap
}
/**
* Interface to be implemented by tax provider plugins. The interface defines a
* single method `getTaxLines` that returns numerical rates to apply to line
* items and shipping methods.
*/
export interface ITaxService {
/**
* Retrieves the numerical tax lines for a calculation context.
* @param itemLines - the line item calculation lines
* @param itemLines - the shipping calculation lines
* @param context - other details relevant to the tax determination
* @return numerical tax rates that should apply to the provided calculation
* lines
*/
getTaxLines(
itemLines: ItemTaxCalculationLine[],
shippingLines: ShippingTaxCalculationLine[],
context: TaxCalculationContext
): Promise<ProviderTaxLine[]>
}
+17 -3
View File
@@ -16,9 +16,10 @@ import redisLoader from "./redis"
import repositoriesLoader from "./repositories"
import searchIndexLoader from "./search-index"
import servicesLoader from "./services"
import strategiesLoader from "./strategies"
import subscribersLoader from "./subscribers"
export default async ({ directory: rootDirectory, expressApp }) => {
export default async ({ directory: rootDirectory, expressApp, isTest }) => {
const { configModule } = getConfigFile(rootDirectory, `medusa-config`)
const container = createContainer()
@@ -58,7 +59,7 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const modelsActivity = Logger.activity("Initializing models")
track("MODELS_INIT_STARTED")
modelsLoader({ container, activityId: modelsActivity })
modelsLoader({ container, activityId: modelsActivity, isTest })
const mAct = Logger.success(modelsActivity, "Models initialized") || {}
track("MODELS_INIT_COMPLETED", { duration: mAct.duration })
@@ -74,7 +75,7 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const repoActivity = Logger.activity("Initializing repositories")
track("REPOSITORIES_INIT_STARTED")
repositoriesLoader({ container, activityId: repoActivity }) || {}
repositoriesLoader({ container, activityId: repoActivity, isTest }) || {}
const rAct = Logger.success(repoActivity, "Repositories initialized") || {}
track("REPOSITORIES_INIT_COMPLETED", { duration: rAct.duration })
@@ -84,6 +85,7 @@ export default async ({ directory: rootDirectory, expressApp }) => {
container,
configModule,
activityId: dbActivity,
isTest,
})
const dbAct = Logger.success(dbActivity, "Database initialized") || {}
track("DATABASE_INIT_COMPLETED", { duration: dbAct.duration })
@@ -92,12 +94,24 @@ export default async ({ directory: rootDirectory, expressApp }) => {
manager: asValue(dbConnection.manager),
})
const stratActivity = Logger.activity("Initializing strategies")
track("STRATEGIES_INIT_STARTED")
strategiesLoader({
container,
configModule,
activityId: stratActivity,
isTest,
})
const stratAct = Logger.success(stratActivity, "Strategies initialized") || {}
track("STRATEGIES_INIT_COMPLETED", { duration: stratAct.duration })
const servicesActivity = Logger.activity("Initializing services")
track("SERVICES_INIT_STARTED")
servicesLoader({
container,
configModule,
activityId: servicesActivity,
isTest,
})
const servAct = Logger.success(servicesActivity, "Services initialized") || {}
track("SERVICES_INIT_COMPLETED", { duration: servAct.duration })
+2 -2
View File
@@ -9,13 +9,13 @@ import formatRegistrationName from "../utils/format-registration-name"
* Registers all models in the model directory
*/
export default ({ container }, config = { register: true }) => {
let corePath = "../models/*.js"
const corePath = "../models/*.js"
const coreFull = path.join(__dirname, corePath)
const toReturn = []
const core = glob.sync(coreFull, { cwd: __dirname })
core.forEach(fn => {
core.forEach((fn) => {
const loaded = require(fn)
Object.entries(loaded).map(([key, val]) => {
+1 -1
View File
@@ -35,7 +35,7 @@ export default async ({ app, container }) => {
passport.use(
new JWTStrategy(
{
jwtFromRequest: req => req.session.jwt,
jwtFromRequest: (req) => req.session.jwt,
secretOrKey: config.jwtSecret,
},
async (jwtPayload, done) => {
+51 -23
View File
@@ -1,7 +1,6 @@
import glob from "glob"
import { EntitySchema } from "typeorm"
import {
BaseModel,
BaseService,
PaymentService,
FulfillmentService,
@@ -17,6 +16,7 @@ import fs from "fs"
import { asValue, asClass, asFunction, aliasTo } from "awilix"
import { sync as existsSync } from "fs-exists-cached"
import { isTaxCalculationStrategy } from "../interfaces/tax-calculation-strategy"
import formatRegistrationName from "../utils/format-registration-name"
/**
@@ -26,7 +26,7 @@ export default async ({ rootDirectory, container, app, activityId }) => {
const resolved = getResolvedPlugins(rootDirectory)
await Promise.all(
resolved.map(async pluginDetails => {
resolved.map(async (pluginDetails) => {
registerRepositories(pluginDetails, container)
await registerServices(pluginDetails, container)
registerMedusaApi(pluginDetails, container)
@@ -37,7 +37,7 @@ export default async ({ rootDirectory, container, app, activityId }) => {
)
await Promise.all(
resolved.map(async pluginDetails => runLoaders(pluginDetails, container))
resolved.map(async (pluginDetails) => runLoaders(pluginDetails, container))
)
}
@@ -50,7 +50,7 @@ function getResolvedPlugins(rootDirectory) {
const { plugins } = configModule
const resolved = plugins.map(plugin => {
const resolved = plugins.map((plugin) => {
if (_.isString(plugin)) {
return resolvePlugin(plugin)
}
@@ -75,7 +75,7 @@ function getResolvedPlugins(rootDirectory) {
export async function registerPluginModels({ rootDirectory, container }) {
const resolved = getResolvedPlugins(rootDirectory)
await Promise.all(
resolved.map(async pluginDetails => {
resolved.map(async (pluginDetails) => {
registerModels(pluginDetails, container)
})
)
@@ -87,7 +87,7 @@ async function runLoaders(pluginDetails, container) {
{}
)
await Promise.all(
loaderFiles.map(async loader => {
loaderFiles.map(async (loader) => {
try {
const module = require(loader).default
if (typeof module === "function") {
@@ -104,6 +104,34 @@ async function runLoaders(pluginDetails, container) {
function registerMedusaApi(pluginDetails, container) {
registerMedusaMiddleware(pluginDetails, container)
registerStrategies(pluginDetails, container)
}
function registerStrategies(pluginDetails, container) {
let module
try {
const path = `${pluginDetails.resolve}/strategies/tax-calculation`
if (existsSync(path)) {
module = require(path).default
} else {
return
}
} catch (err) {
return
}
if (isTaxCalculationStrategy(module.prototype)) {
container.register({
taxCalculationStrategy: asFunction(
(cradle) => new module(cradle, pluginDetails.options)
).singleton(),
})
} else {
const logger = container.resolve("logger")
logger.warn(
`${pluginDetails.resolve}/strategies/tax-calculation did not export a class that implements ITaxCalculationStrategy. Your Medusa server will still work, but if you have written custom tax calculation logic it will not be used. Make sure to implement the ITaxCalculationStrategy interface.`
)
}
}
function registerMedusaMiddleware(pluginDetails, container) {
@@ -140,7 +168,7 @@ function registerCoreRouters(pluginDetails, container) {
const adminFiles = glob.sync(`${resolve}/api/admin/[!__]*.js`, {})
const storeFiles = glob.sync(`${resolve}/api/store/[!__]*.js`, {})
adminFiles.forEach(fn => {
adminFiles.forEach((fn) => {
const descriptor = fn.split(".")[0]
const splat = descriptor.split("/")
const path = `${splat[splat.length - 2]}/${splat[splat.length - 1]}`
@@ -148,7 +176,7 @@ function registerCoreRouters(pluginDetails, container) {
middlewareService.addRouter(path, loaded())
})
storeFiles.forEach(fn => {
storeFiles.forEach((fn) => {
const descriptor = fn.split(".")[0]
const splat = descriptor.split("/")
const path = `${splat[splat.length - 2]}/${splat[splat.length - 1]}`
@@ -204,7 +232,7 @@ function registerApi(
async function registerServices(pluginDetails, container) {
const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*`, {})
await Promise.all(
files.map(async fn => {
files.map(async (fn) => {
const loaded = require(fn).default
const name = formatRegistrationName(fn)
@@ -219,14 +247,14 @@ async function registerServices(pluginDetails, container) {
// Register our payment providers to paymentProviders
container.registerAdd(
"paymentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
asFunction((cradle) => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
),
[`pp_${loaded.identifier}`]: aliasTo(name),
})
@@ -239,35 +267,35 @@ async function registerServices(pluginDetails, container) {
const name = appDetails.application_name
container.register({
[`${name}Oauth`]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
),
})
} else if (loaded.prototype instanceof FulfillmentService) {
// Register our payment providers to paymentProviders
container.registerAdd(
"fulfillmentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
asFunction((cradle) => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which fulfillment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
).singleton(),
[`fp_${loaded.identifier}`]: aliasTo(name),
})
} else if (loaded.prototype instanceof NotificationService) {
container.registerAdd(
"notificationProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
asFunction((cradle) => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which notification provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
).singleton(),
[`noti_${loaded.identifier}`]: aliasTo(name),
})
@@ -276,7 +304,7 @@ async function registerServices(pluginDetails, container) {
// resolution if we already know which file storage provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
),
[`fileService`]: aliasTo(name),
})
@@ -285,14 +313,14 @@ async function registerServices(pluginDetails, container) {
// resolution if we already know which search provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
),
[`searchService`]: aliasTo(name),
})
} else {
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
),
})
}
@@ -311,13 +339,13 @@ async function registerServices(pluginDetails, container) {
*/
function registerSubscribers(pluginDetails, container) {
const files = glob.sync(`${pluginDetails.resolve}/subscribers/*.js`, {})
files.forEach(fn => {
files.forEach((fn) => {
const loaded = require(fn).default
const name = formatRegistrationName(fn)
container.build(
asFunction(
cradle => new loaded(cradle, pluginDetails.options)
(cradle) => new loaded(cradle, pluginDetails.options)
).singleton()
)
})
@@ -334,7 +362,7 @@ function registerSubscribers(pluginDetails, container) {
*/
function registerRepositories(pluginDetails, container) {
const files = glob.sync(`${pluginDetails.resolve}/repositories/*.js`, {})
files.forEach(fn => {
files.forEach((fn) => {
const loaded = require(fn)
Object.entries(loaded).map(([key, val]) => {
@@ -361,7 +389,7 @@ function registerRepositories(pluginDetails, container) {
*/
function registerModels(pluginDetails, container) {
const files = glob.sync(`${pluginDetails.resolve}/models/*.js`, {})
files.forEach(fn => {
files.forEach((fn) => {
const loaded = require(fn)
Object.entries(loaded).map(([key, val]) => {
+6 -5
View File
@@ -7,20 +7,21 @@ import formatRegistrationName from "../utils/format-registration-name"
/**
* Registers all services in the services directory
*/
export default ({ container, configModule }) => {
const isTest = process.env.NODE_ENV === "test"
export default ({ container, configModule, isTest }) => {
const useMock =
typeof isTest !== "undefined" ? isTest : process.env.NODE_ENV === "test"
const corePath = isTest ? "../services/__mocks__/*.js" : "../services/*.js"
const corePath = useMock ? "../services/__mocks__/*.js" : "../services/*.js"
const coreFull = path.join(__dirname, corePath)
const core = glob.sync(coreFull, { cwd: __dirname })
core.forEach(fn => {
core.forEach((fn) => {
const loaded = require(fn).default
if (loaded) {
const name = formatRegistrationName(fn)
container.register({
[name]: asFunction(
cradle => new loaded(cradle, configModule),
(cradle) => new loaded(cradle, configModule)
).singleton(),
})
}
+37
View File
@@ -0,0 +1,37 @@
import glob from "glob"
import path from "path"
import { AwilixContainer, asFunction } from "awilix"
import formatRegistrationName from "../utils/format-registration-name"
type LoaderOptions = {
container: AwilixContainer
configModule: object
isTest: boolean
}
/**
* Registers all strategies in the strategies directory
* @returns void
*/
export default ({ container, configModule, isTest }: LoaderOptions): void => {
const useMock =
typeof isTest !== "undefined" ? isTest : process.env.NODE_ENV === "test"
const corePath = useMock
? "../strategies/__mocks__/*.js"
: "../strategies/*.js"
const coreFull = path.join(__dirname, corePath)
const core = glob.sync(coreFull, { cwd: __dirname })
core.forEach((fn) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const loaded = require(fn).default
const name = formatRegistrationName(fn)
container.register({
[name]: asFunction(
(cradle) => new loaded(cradle, configModule)
).singleton(),
})
})
}
@@ -0,0 +1,178 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class newTaxSystem1641636508055 implements MigrationInterface {
name = "newTaxSystem1641636508055"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "tax_rate" ("id" character varying NOT NULL, "rate" real, "code" character varying, "name" character varying NOT NULL, "region_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_23b71b53f650c0b39e99ccef4fd" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE TABLE "tax_provider" ("id" character varying NOT NULL, "is_installed" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_b198bf82ba6a317c11763d99b99" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE TABLE "line_item_tax_line" ("id" character varying NOT NULL, "rate" real NOT NULL, "name" character varying NOT NULL, "code" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, "item_id" character varying NOT NULL, CONSTRAINT "PK_4a0f4322fcd5ce4af85727f89a8" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_5077fa54b0d037e984385dfe8a" ON "line_item_tax_line" ("item_id") `
)
await queryRunner.query(
`CREATE TABLE "shipping_method_tax_line" ("id" character varying NOT NULL, "rate" real NOT NULL, "name" character varying NOT NULL, "code" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, "shipping_method_id" character varying NOT NULL, CONSTRAINT "PK_54c94f5908aacbd51cf0a73edb1" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_926ca9f29014af8091722dede0" ON "shipping_method_tax_line" ("shipping_method_id") `
)
await queryRunner.query(
`CREATE TABLE "product_tax_rate" ("product_id" character varying NOT NULL, "rate_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_326257ce468df46cd5c8c5922e9" PRIMARY KEY ("product_id", "rate_id"))`
)
await queryRunner.query(
`CREATE TABLE "product_type_tax_rate" ("product_type_id" character varying NOT NULL, "rate_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_ddc9242de1d99bc7674969289f0" PRIMARY KEY ("product_type_id", "rate_id"))`
)
await queryRunner.query(
`CREATE TABLE "shipping_tax_rate" ("shipping_option_id" character varying NOT NULL, "rate_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_bcd93b14d7e2695365d383f5eae" PRIMARY KEY ("shipping_option_id", "rate_id"))`
)
await queryRunner.query(
`ALTER TABLE "region" ADD "gift_cards_taxable" boolean NOT NULL DEFAULT true`
)
await queryRunner.query(
`ALTER TABLE "region" ADD "automatic_taxes" boolean NOT NULL DEFAULT true`
)
await queryRunner.query(
`ALTER TABLE "region" ADD "tax_provider_id" character varying`
)
await queryRunner.query(
`ALTER TABLE "line_item" ADD "is_return" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`UPDATE "line_item" SET "is_return" = true WHERE "id" IN (SELECT "id" from "line_item" WHERE "metadata"->>'is_return_line' = 'true')`
)
await queryRunner.query(
`ALTER TABLE "order" ALTER COLUMN "tax_rate" DROP NOT NULL`
)
await queryRunner.query(
`CREATE INDEX "IDX_2484cf14c437a04586b07e7ddd" ON "product_tax_rate" ("rate_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_1d04aebeabb6a89f87e536a124" ON "product_tax_rate" ("product_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_ece65a774192b34253abc4cd67" ON "product_type_tax_rate" ("rate_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_25a3138bb236f63d9bb6c8ff11" ON "product_type_tax_rate" ("product_type_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_346e0016cf045b998074774764" ON "shipping_tax_rate" ("rate_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_f672727ab020df6c50fb64c1a7" ON "shipping_tax_rate" ("shipping_option_id") `
)
await queryRunner.query(
`ALTER TABLE "tax_rate" ADD CONSTRAINT "FK_b95a1e03b051993d208366cb960" FOREIGN KEY ("region_id") REFERENCES "region"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "region" ADD CONSTRAINT "FK_91f88052197680f9790272aaf5b" FOREIGN KEY ("tax_provider_id") REFERENCES "tax_provider"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "line_item_tax_line" ADD CONSTRAINT "FK_5077fa54b0d037e984385dfe8ad" FOREIGN KEY ("item_id") REFERENCES "line_item"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "shipping_method_tax_line" ADD CONSTRAINT "FK_926ca9f29014af8091722dede08" FOREIGN KEY ("shipping_method_id") REFERENCES "shipping_method"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_tax_rate" ADD CONSTRAINT "FK_1d04aebeabb6a89f87e536a124d" FOREIGN KEY ("product_id") REFERENCES "product"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_tax_rate" ADD CONSTRAINT "FK_2484cf14c437a04586b07e7dddb" FOREIGN KEY ("rate_id") REFERENCES "tax_rate"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_type_tax_rate" ADD CONSTRAINT "FK_25a3138bb236f63d9bb6c8ff111" FOREIGN KEY ("product_type_id") REFERENCES "product_type"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_type_tax_rate" ADD CONSTRAINT "FK_ece65a774192b34253abc4cd672" FOREIGN KEY ("rate_id") REFERENCES "tax_rate"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "shipping_tax_rate" ADD CONSTRAINT "FK_f672727ab020df6c50fb64c1a70" FOREIGN KEY ("shipping_option_id") REFERENCES "shipping_option"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "shipping_tax_rate" ADD CONSTRAINT "FK_346e0016cf045b9980747747645" FOREIGN KEY ("rate_id") REFERENCES "tax_rate"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "shipping_tax_rate" DROP CONSTRAINT "FK_346e0016cf045b9980747747645"`
)
await queryRunner.query(
`ALTER TABLE "shipping_tax_rate" DROP CONSTRAINT "FK_f672727ab020df6c50fb64c1a70"`
)
await queryRunner.query(
`ALTER TABLE "product_type_tax_rate" DROP CONSTRAINT "FK_ece65a774192b34253abc4cd672"`
)
await queryRunner.query(
`ALTER TABLE "product_type_tax_rate" DROP CONSTRAINT "FK_25a3138bb236f63d9bb6c8ff111"`
)
await queryRunner.query(
`ALTER TABLE "product_tax_rate" DROP CONSTRAINT "FK_2484cf14c437a04586b07e7dddb"`
)
await queryRunner.query(
`ALTER TABLE "product_tax_rate" DROP CONSTRAINT "FK_1d04aebeabb6a89f87e536a124d"`
)
await queryRunner.query(
`ALTER TABLE "shipping_method_tax_line" DROP CONSTRAINT "FK_926ca9f29014af8091722dede08"`
)
await queryRunner.query(
`ALTER TABLE "line_item_tax_line" DROP CONSTRAINT "FK_5077fa54b0d037e984385dfe8ad"`
)
await queryRunner.query(
`ALTER TABLE "region" DROP CONSTRAINT "FK_91f88052197680f9790272aaf5b"`
)
await queryRunner.query(
`ALTER TABLE "tax_rate" DROP CONSTRAINT "FK_b95a1e03b051993d208366cb960"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_f672727ab020df6c50fb64c1a7"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_346e0016cf045b998074774764"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_25a3138bb236f63d9bb6c8ff11"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_ece65a774192b34253abc4cd67"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_1d04aebeabb6a89f87e536a124"`
)
await queryRunner.query(
`DROP INDEX "public"."IDX_2484cf14c437a04586b07e7ddd"`
)
await queryRunner.query(
`ALTER TABLE "order" ALTER COLUMN "tax_rate" SET NOT NULL`
)
await queryRunner.query(`ALTER TABLE "line_item" DROP COLUMN "is_return"`)
await queryRunner.query(
`ALTER TABLE "region" DROP COLUMN "tax_provider_id"`
)
await queryRunner.query(
`ALTER TABLE "region" DROP COLUMN "automatic_taxes"`
)
await queryRunner.query(
`ALTER TABLE "region" DROP COLUMN "gift_cards_taxable"`
)
await queryRunner.query(`DROP TABLE "shipping_tax_rate"`)
await queryRunner.query(`DROP TABLE "product_type_tax_rate"`)
await queryRunner.query(`DROP TABLE "product_tax_rate"`)
await queryRunner.query(
`DROP INDEX "public"."IDX_926ca9f29014af8091722dede0"`
)
await queryRunner.query(`DROP TABLE "shipping_method_tax_line"`)
await queryRunner.query(
`DROP INDEX "public"."IDX_5077fa54b0d037e984385dfe8a"`
)
await queryRunner.query(`DROP TABLE "line_item_tax_line"`)
await queryRunner.query(`DROP TABLE "tax_provider"`)
await queryRunner.query(`DROP TABLE "tax_rate"`)
}
}
+3 -1
View File
@@ -124,6 +124,8 @@ export class Cart {
@PrimaryColumn()
id: string
readonly object = "cart"
@Column({ nullable: true })
email: string
@@ -245,7 +247,7 @@ export class Cart {
shipping_total?: number
discount_total?: number
tax_total?: number
tax_total?: number | null
refunded_total?: number
total?: number
subtotal?: number
@@ -0,0 +1,30 @@
import {
Entity,
BeforeInsert,
Index,
Column,
ManyToOne,
JoinColumn,
} from "typeorm"
import { ulid } from "ulid"
import { TaxLine } from "./tax-line"
import { LineItem } from "./line-item"
@Entity()
export class LineItemTaxLine extends TaxLine {
@Index()
@Column()
item_id: string
@ManyToOne(() => LineItem, (li) => li.tax_lines)
@JoinColumn({ name: "item_id" })
item: LineItem
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `litl_${id}`
}
}
+12 -16
View File
@@ -1,5 +1,6 @@
import {
Entity,
OneToMany,
BeforeInsert,
CreateDateColumn,
UpdateDateColumn,
@@ -13,6 +14,7 @@ import {
import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { LineItemTaxLine } from "./line-item-tax-line"
import { Swap } from "./swap"
import { Cart } from "./cart"
import { Order } from "./order"
@@ -32,10 +34,7 @@ export class LineItem {
@Column({ nullable: true })
cart_id: string
@ManyToOne(
() => Cart,
cart => cart.items
)
@ManyToOne(() => Cart, (cart) => cart.items)
@JoinColumn({ name: "cart_id" })
cart: Cart
@@ -43,10 +42,7 @@ export class LineItem {
@Column({ nullable: true })
order_id: string
@ManyToOne(
() => Order,
order => order.items
)
@ManyToOne(() => Order, (order) => order.items)
@JoinColumn({ name: "order_id" })
order: Order
@@ -54,10 +50,7 @@ export class LineItem {
@Column({ nullable: true })
swap_id: string
@ManyToOne(
() => Swap,
swap => swap.additional_items
)
@ManyToOne(() => Swap, (swap) => swap.additional_items)
@JoinColumn({ name: "swap_id" })
swap: Swap
@@ -65,13 +58,13 @@ export class LineItem {
@Column({ nullable: true })
claim_order_id: string
@ManyToOne(
() => ClaimOrder,
co => co.additional_items
)
@ManyToOne(() => ClaimOrder, (co) => co.additional_items)
@JoinColumn({ name: "claim_order_id" })
claim_order: ClaimOrder
@OneToMany(() => LineItemTaxLine, (tl) => tl.item, { cascade: ["insert"] })
tax_lines: LineItemTaxLine[]
@Column()
title: string
@@ -81,6 +74,9 @@ export class LineItem {
@Column({ nullable: true })
thumbnail: string
@Column({ default: false })
is_return: boolean
@Column({ default: false })
is_giftcard: boolean
+19 -52
View File
@@ -74,6 +74,8 @@ export class Order {
@PrimaryColumn()
id: string
readonly object = "order"
@DbAwareColumn({ type: "enum", enum: OrderStatus, default: "pending" })
status: OrderStatus
@@ -142,8 +144,8 @@ export class Order {
@JoinColumn({ name: "currency_code", referencedColumnName: "code" })
currency: Currency
@Column({ type: "real" })
tax_rate: number
@Column({ type: "real", nullable: true })
tax_rate: number | null
@ManyToMany(() => Discount, { cascade: ["insert"] })
@JoinTable({
@@ -173,57 +175,29 @@ export class Order {
})
gift_cards: GiftCard[]
@OneToMany(
() => ShippingMethod,
(method) => method.order,
{
cascade: ["insert"],
}
)
@OneToMany(() => ShippingMethod, (method) => method.order, {
cascade: ["insert"],
})
shipping_methods: ShippingMethod[]
@OneToMany(
() => Payment,
(payment) => payment.order,
{ cascade: ["insert"] }
)
@OneToMany(() => Payment, (payment) => payment.order, { cascade: ["insert"] })
payments: Payment[]
@OneToMany(
() => Fulfillment,
(fulfillment) => fulfillment.order,
{
cascade: ["insert"],
}
)
@OneToMany(() => Fulfillment, (fulfillment) => fulfillment.order, {
cascade: ["insert"],
})
fulfillments: Fulfillment[]
@OneToMany(
() => Return,
(ret) => ret.order,
{ cascade: ["insert"] }
)
@OneToMany(() => Return, (ret) => ret.order, { cascade: ["insert"] })
returns: Return[]
@OneToMany(
() => ClaimOrder,
(co) => co.order,
{ cascade: ["insert"] }
)
@OneToMany(() => ClaimOrder, (co) => co.order, { cascade: ["insert"] })
claims: ClaimOrder[]
@OneToMany(
() => Refund,
(ref) => ref.order,
{ cascade: ["insert"] }
)
@OneToMany(() => Refund, (ref) => ref.order, { cascade: ["insert"] })
refunds: Refund[]
@OneToMany(
() => Swap,
(swap) => swap.order,
{ cascade: ["insert"] }
)
@OneToMany(() => Swap, (swap) => swap.order, { cascade: ["insert"] })
swaps: Swap[]
@Column({ nullable: true })
@@ -233,19 +207,12 @@ export class Order {
@JoinColumn({ name: "draft_order_id" })
draft_order: DraftOrder
@OneToMany(
() => LineItem,
(lineItem) => lineItem.order,
{
cascade: ["insert"],
}
)
@OneToMany(() => LineItem, (lineItem) => lineItem.order, {
cascade: ["insert"],
})
items: LineItem[]
@OneToMany(
() => GiftCardTransaction,
(gc) => gc.order
)
@OneToMany(() => GiftCardTransaction, (gc) => gc.order)
gift_card_transactions: GiftCardTransaction[]
@Column({ nullable: true, type: resolveDbType("timestamptz") })
@@ -0,0 +1,69 @@
import {
Entity,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
PrimaryColumn,
} from "typeorm"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { Product } from "./product"
import { TaxRate } from "./tax-rate"
@Entity()
export class ProductTaxRate {
@PrimaryColumn()
product_id: string
@PrimaryColumn()
rate_id: string
@ManyToOne(() => Product, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_id" })
product?: Product
// Note the onDelete config here
@ManyToOne(() => TaxRate, { onDelete: "CASCADE" })
@JoinColumn({ name: "rate_id" })
tax_rate?: TaxRate
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema product_tax_rate
* title: "Product Tax Rate"
* description: "Associates a tax rate with a product to indicate that the product is taxed in a certain way"
* x-resourceId: product_tax_rate
* properties:
* product_id:
* description: "The id of the Product"
* type: string
* rate_id:
* description: "The id of the Tax Rate"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,68 @@
import {
Entity,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
PrimaryColumn,
} from "typeorm"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { ProductType } from "./product-type"
import { TaxRate } from "./tax-rate"
@Entity()
export class ProductTypeTaxRate {
@PrimaryColumn()
product_type_id: string
@PrimaryColumn()
rate_id: string
@ManyToOne(() => ProductType, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_type_id" })
product_type?: ProductType
@ManyToOne(() => TaxRate, { onDelete: "CASCADE" })
@JoinColumn({ name: "rate_id" })
tax_rate?: TaxRate
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema product_type_tax_rate
* title: "Product Type Tax Rate"
* description: "Associates a tax rate with a product type to indicate that the product type is taxed in a certain way"
* x-resourceId: product_type_tax_rate
* properties:
* product_type_id:
* description: "The id of the Product type"
* type: string
* rate_id:
* description: "The id of the Tax Rate"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
+27 -6
View File
@@ -16,9 +16,11 @@ import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { Currency } from "./currency"
import { TaxRate } from "./tax-rate"
import { Country } from "./country"
import { PaymentProvider } from "./payment-provider"
import { FulfillmentProvider } from "./fulfillment-provider"
import { TaxProvider } from "./tax-provider"
@Entity()
export class Region {
@@ -38,16 +40,32 @@ export class Region {
@Column({ type: "real" })
tax_rate: number
@OneToMany(() => TaxRate, (tr) => tr.region)
tax_rates: TaxRate[] | null
@Column({ nullable: true })
tax_code: string
@OneToMany(
() => Country,
c => c.region
)
@Column({ default: true })
gift_cards_taxable: boolean
@Column({ default: true })
automatic_taxes: boolean
@OneToMany(() => Country, (c) => c.region)
countries: Country[]
@ManyToMany(() => PaymentProvider, { eager: true, cascade: ['insert', 'update'] })
@Column({ type: "text", nullable: true })
tax_provider_id: string | null
@ManyToOne(() => TaxProvider)
@JoinColumn({ name: "tax_provider_id" })
tax_provider: TaxProvider
@ManyToMany(() => PaymentProvider, {
eager: true,
cascade: ["insert", "update"],
})
@JoinTable({
name: "region_payment_providers",
joinColumn: {
@@ -61,7 +79,10 @@ export class Region {
})
payment_providers: PaymentProvider[]
@ManyToMany(() => FulfillmentProvider, { eager: true, cascade: ['insert', 'update'] })
@ManyToMany(() => FulfillmentProvider, {
eager: true,
cascade: ["insert", "update"],
})
@JoinTable({
name: "region_fulfillment_providers",
joinColumn: {
@@ -0,0 +1,30 @@
import {
Entity,
BeforeInsert,
Index,
Column,
ManyToOne,
JoinColumn,
} from "typeorm"
import { ulid } from "ulid"
import { TaxLine } from "./tax-line"
import { ShippingMethod } from "./shipping-method"
@Entity()
export class ShippingMethodTaxLine extends TaxLine {
@Index()
@Column()
shipping_method_id: string
@ManyToOne(() => ShippingMethod, (sm) => sm.tax_lines)
@JoinColumn({ name: "shipping_method_id" })
shipping_method: ShippingMethod
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `smtl_${id}`
}
}
@@ -6,6 +6,7 @@ import {
PrimaryColumn,
ManyToOne,
OneToOne,
OneToMany,
JoinColumn,
Index,
} from "typeorm"
@@ -18,6 +19,7 @@ import { Cart } from "./cart"
import { Swap } from "./swap"
import { Return } from "./return"
import { ShippingOption } from "./shipping-option"
import { ShippingMethodTaxLine } from "./shipping-method-tax-line"
@Check(
`"claim_order_id" IS NOT NULL OR "order_id" IS NOT NULL OR "cart_id" IS NOT NULL OR "swap_id" IS NOT NULL OR "return_id" IS NOT NULL`
@@ -68,10 +70,7 @@ export class ShippingMethod {
@Column({ nullable: true })
return_id: string
@OneToOne(
() => Return,
ret => ret.shipping_method
)
@OneToOne(() => Return, (ret) => ret.shipping_method)
@JoinColumn({ name: "return_id" })
return_order: Return
@@ -79,6 +78,11 @@ export class ShippingMethod {
@JoinColumn({ name: "shipping_option_id" })
shipping_option: ShippingOption
@OneToMany(() => ShippingMethodTaxLine, (tl) => tl.shipping_method, {
cascade: ["insert"],
})
tax_lines: ShippingMethodTaxLine[]
@Column({ type: "int" })
price: number
@@ -0,0 +1,68 @@
import {
Entity,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
PrimaryColumn,
} from "typeorm"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { ShippingOption } from "./shipping-option"
import { TaxRate } from "./tax-rate"
@Entity()
export class ShippingTaxRate {
@PrimaryColumn()
shipping_option_id: string
@PrimaryColumn()
rate_id: string
@ManyToOne(() => ShippingOption, { onDelete: "CASCADE" })
@JoinColumn({ name: "shipping_option_id" })
shipping_option?: ShippingOption
@ManyToOne(() => TaxRate, { onDelete: "CASCADE" })
@JoinColumn({ name: "rate_id" })
tax_rate?: TaxRate
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema shipping_tax_rate
* title: "Shipping Tax Rate"
* description: "Associates a tax rate with a shipping option to indicate that the shipping option is taxed in a certain way"
* x-resourceId: shipping_tax_rate
* properties:
* shipping_option_id:
* description: "The id of the Shipping Option"
* type: string
* rate_id:
* description: "The id of the Tax Rate"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
+7 -7
View File
@@ -63,35 +63,35 @@ export class Swap {
@ManyToOne(
() => Order,
o => o.swaps
(o) => o.swaps
)
@JoinColumn({ name: "order_id" })
order: Order
@OneToMany(
() => LineItem,
item => item.swap,
(item) => item.swap,
{ cascade: ["insert"] }
)
additional_items: LineItem
additional_items: LineItem[]
@OneToOne(
() => Return,
ret => ret.swap,
(ret) => ret.swap,
{ cascade: ["insert"] }
)
return_order: Return
@OneToMany(
() => Fulfillment,
fulfillment => fulfillment.swap,
(fulfillment) => fulfillment.swap,
{ cascade: ["insert"] }
)
fulfillments: Fulfillment[]
@OneToOne(
() => Payment,
p => p.swap,
(p) => p.swap,
{ cascade: ["insert"] }
)
payment: Payment
@@ -108,7 +108,7 @@ export class Swap {
@OneToMany(
() => ShippingMethod,
method => method.swap,
(method) => method.swap,
{ cascade: ["insert"] }
)
shipping_methods: ShippingMethod[]
+61
View File
@@ -0,0 +1,61 @@
import {
CreateDateColumn,
UpdateDateColumn,
Column,
PrimaryColumn,
} from "typeorm"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
export class TaxLine {
@PrimaryColumn()
id: string
@Column({ type: "real" })
rate: number
@Column()
name: string
@Column({ type: "varchar", nullable: true })
code: string | null
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema tax_line
* title: "Tax Line"
* description: "Line item that specifies an amount of tax to add to a line item."
* x-resourceId: tax_line
* properties:
* id:
* description: "The id of the Tax Line. This value will be prefixed by `tl_`."
* type: string
* code:
* description: "A code to identify the tax type by"
* type: string
* name:
* description: "A human friendly name for the tax"
* type: string
* rate:
* description: "The numeric rate to charge tax by"
* type: number
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,24 @@
import { Entity, Column, PrimaryColumn } from "typeorm"
@Entity()
export class TaxProvider {
@PrimaryColumn()
id: string
@Column({ default: true })
is_installed: boolean
}
/**
* @schema tax_provider
* title: "Tax Provider"
* description: "The tax service used to calculate taxes"
* x-resourceId: tax_provider
* properties:
* id:
* description: "The id of the tax provider as given by the plugin."
* type: string
* is_installed:
* description: "Whether the plugin is installed in the current version. Plugins that are no longer installed are not deleted by will have this field set to `false`."
* type: boolean
*/
+141
View File
@@ -0,0 +1,141 @@
import {
Entity,
BeforeInsert,
CreateDateColumn,
UpdateDateColumn,
Column,
PrimaryColumn,
ManyToOne,
ManyToMany,
JoinColumn,
JoinTable,
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { Region } from "./region"
import { Product } from "./product"
import { ProductType } from "./product-type"
import { ShippingOption } from "./shipping-option"
@Entity()
export class TaxRate {
@PrimaryColumn()
id: string
@Column({ type: "real", nullable: true })
rate: number | null
@Column({ type: "varchar", nullable: true })
code: string | null
@Column()
name: string
@Column()
region_id: string
@ManyToOne(() => Region)
@JoinColumn({ name: "region_id" })
region: Region
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
@ManyToMany(() => Product)
@JoinTable({
name: "product_tax_rate",
joinColumn: {
name: "rate_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_id",
referencedColumnName: "id",
},
})
products: Product[]
@ManyToMany(() => ProductType)
@JoinTable({
name: "product_type_tax_rate",
joinColumn: {
name: "rate_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_type_id",
referencedColumnName: "id",
},
})
product_types: ProductType[]
@ManyToMany(() => ShippingOption)
@JoinTable({
name: "shipping_tax_rate",
joinColumn: {
name: "rate_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "shipping_option_id",
referencedColumnName: "id",
},
})
shipping_options: ShippingOption[]
// TODO: consider custom DTO instead
product_count?: number
product_type_count?: number
shipping_option_count?: number
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `txr_${id}`
}
}
/**
* @schema tax_rate
* title: "Tax Rate"
* description: "A Tax Rate can be used to associate a certain rate to charge on products within a given Region"
* x-resourceId: line_item
* properties:
* id:
* description: "The id of the Tax Rate. This value will be prefixed by `txr_`."
* type: string
* rate:
* description: "The numeric rate to charge"
* type: number
* code:
* description: "A code to identify the tax type by"
* type: string
* name:
* description: "A human friendly name for the tax"
* type: string
* region_id:
* description: "The id of the Region that the rate belongs to"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
* refundable:
* description: "The amount that can be refunded from the given Line Item. Takes taxes and discounts into consideration."
* type: integer
*/
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { LineItemTaxLine } from "../models/line-item-tax-line"
@EntityRepository(LineItemTaxLine)
export class LineItemTaxLineRepository extends Repository<LineItemTaxLine> {}
+26 -1
View File
@@ -1,5 +1,30 @@
import { EntityRepository, Repository } from "typeorm"
import { LineItem } from "../models/line-item"
import { ReturnItem } from "../models/return-item"
@EntityRepository(LineItem)
export class LineItemRepository extends Repository<LineItem> {}
export class LineItemRepository extends Repository<LineItem> {
/**
* Finds line items that are to be returned as part of the Return represented
* by `returnId`. The function joins the associated ReturnItem and the
* LineItem's TaxLines.
* @param returnId - the id of the Return to get LineItems by
* @return the LineItems associated with the Return with its ReturnItem joined
* and mapped to `return_item`.
*/
async findByReturn(
returnId: string
): Promise<(LineItem & { return_item: ReturnItem })[]> {
const qb = this.createQueryBuilder("li")
.leftJoinAndSelect(`li.tax_lines`, "tax_lines")
.leftJoinAndMapOne(
`li.return_item`,
ReturnItem,
`ri`,
`ri.item_id = li.id`
)
.where(`ri.return_id = :returnId`, { returnId })
return (await qb.getMany()) as (LineItem & { return_item: ReturnItem })[]
}
}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { ProductTaxRate } from "../models/product-tax-rate"
@EntityRepository(ProductTaxRate)
export class ProductTaxRateRepository extends Repository<ProductTaxRate> {}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
@EntityRepository(ShippingMethodTaxLine)
export class ShippingMethodTaxLineRepository extends Repository<ShippingMethodTaxLine> {}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
@EntityRepository(ShippingTaxRate)
export class ShippingTaxRateRepository extends Repository<ShippingTaxRate> {}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { TaxProvider } from "../models/tax-provider"
@EntityRepository(TaxProvider)
export class TaxProviderRepository extends Repository<TaxProvider> {}
@@ -0,0 +1,274 @@
import { unionBy } from "lodash"
import {
In,
Not,
DeleteResult,
SelectQueryBuilder,
EntityRepository,
FindManyOptions,
FindOptionsUtils,
Repository,
} from "typeorm"
import { TaxRate } from "../models/tax-rate"
import { ProductTaxRate } from "../models/product-tax-rate"
import { ProductTypeTaxRate } from "../models/product-type-tax-rate"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
import { Product } from "../models/product"
import { ShippingMethod } from "../models/shipping-method"
import { TaxRateListByConfig } from "../types/tax-rate"
const resolveableFields = [
"product_count",
"product_type_count",
"shipping_option_count",
]
@EntityRepository(TaxRate)
export class TaxRateRepository extends Repository<TaxRate> {
getFindQueryBuilder(findOptions: FindManyOptions<TaxRate>) {
let qb = this.createQueryBuilder("tr")
const cleanOptions = findOptions
const resolverFields: string[] = []
if (typeof findOptions.select !== "undefined") {
let selectableCols: (keyof TaxRate)[] = []
for (const k of findOptions.select) {
if (!resolveableFields.includes(k)) {
selectableCols.push(k)
} else {
resolverFields.push(k)
}
}
cleanOptions.select = selectableCols
}
FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(
qb,
cleanOptions
)
if (resolverFields.length > 0) {
this.applyResolutionsToQueryBuilder(qb, resolverFields)
}
return qb
}
async findWithResolution(findOptions: FindManyOptions<TaxRate>) {
const qb = this.getFindQueryBuilder(findOptions)
return await qb.getMany()
}
async findOneWithResolution(findOptions: FindManyOptions<TaxRate>) {
const qb = this.getFindQueryBuilder(findOptions)
return await qb.getOne()
}
async findAndCountWithResolution(findOptions: FindManyOptions<TaxRate>) {
const qb = this.getFindQueryBuilder(findOptions)
return await qb.getManyAndCount()
}
applyResolutionsToQueryBuilder(
qb: SelectQueryBuilder<TaxRate>,
resolverFields: string[]
): SelectQueryBuilder<TaxRate> {
for (const k of resolverFields) {
switch (k) {
case "product_count":
qb.loadRelationCountAndMap(
`${qb.alias}.product_count`,
`${qb.alias}.products`
)
break
case "product_type_count":
qb.loadRelationCountAndMap(
`${qb.alias}.product_type_count`,
`${qb.alias}.product_types`
)
break
case "shipping_option_count":
qb.loadRelationCountAndMap(
`${qb.alias}.shipping_option_count`,
`${qb.alias}.shipping_options`
)
break
default:
break
}
}
return qb
}
async removeFromProduct(
id: string,
productIds: string[]
): Promise<DeleteResult> {
return await this.createQueryBuilder()
.delete()
.from(ProductTaxRate)
.where({ rate_id: id, product_id: In(productIds) })
.execute()
}
async addToProduct(
id: string,
productIds: string[],
overrideExisting: boolean = false
): Promise<ProductTaxRate[]> {
const toInsert = productIds.map((pId) => ({ rate_id: id, product_id: pId }))
const insertResult = await this.createQueryBuilder()
.insert()
.orIgnore(true)
.into(ProductTaxRate)
.values(toInsert)
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
.delete()
.from(ProductTaxRate)
.where({ rate_id: id, product_id: Not(In(productIds)) })
.execute()
}
return await this.manager
.createQueryBuilder(ProductTaxRate, "ptr")
.select()
.where(insertResult.identifiers)
.getMany()
}
async removeFromProductType(
id: string,
productTypeIds: string[]
): Promise<DeleteResult> {
return await this.createQueryBuilder()
.delete()
.from(ProductTypeTaxRate)
.where({ rate_id: id, product_type_id: In(productTypeIds) })
.execute()
}
async addToProductType(
id: string,
productTypeIds: string[],
overrideExisting: boolean = false
): Promise<ProductTypeTaxRate[]> {
const toInsert = productTypeIds.map((pId) => ({
rate_id: id,
product_type_id: pId,
}))
const insertResult = await this.createQueryBuilder()
.insert()
.orIgnore(true)
.into(ProductTypeTaxRate)
.values(toInsert)
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
.delete()
.from(ProductTypeTaxRate)
.where({ rate_id: id, product_type_id: Not(In(productTypeIds)) })
.execute()
}
return await this.manager
.createQueryBuilder(ProductTypeTaxRate, "ptr")
.select()
.where(insertResult.identifiers)
.getMany()
}
async removeFromShippingOption(
id: string,
optionIds: string[]
): Promise<DeleteResult> {
return await this.createQueryBuilder()
.delete()
.from(ShippingTaxRate)
.where({ rate_id: id, shipping_option_id: In(optionIds) })
.execute()
}
async addToShippingOption(
id: string,
optionIds: string[],
overrideExisting: boolean = false
): Promise<ShippingTaxRate[]> {
const toInsert = optionIds.map((pId) => ({
rate_id: id,
shipping_option_id: pId,
}))
const insertResult = await this.createQueryBuilder()
.insert()
.orIgnore(true)
.into(ShippingTaxRate)
.values(toInsert)
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
.delete()
.from(ShippingTaxRate)
.where({ rate_id: id, shipping_option_id: Not(In(optionIds)) })
.execute()
}
return await this.manager
.createQueryBuilder(ShippingTaxRate, "str")
.select()
.where(insertResult.identifiers)
.getMany()
}
async listByProduct(productId: string, config: TaxRateListByConfig) {
let productRates = this.createQueryBuilder("txr")
.leftJoin(ProductTaxRate, "ptr", "ptr.rate_id = txr.id")
.leftJoin(Product, "prod", "prod.id = ptr.product_id")
.where("prod.id = :productId", { productId })
let typeRates = this.createQueryBuilder("txr")
.leftJoin(ProductTypeTaxRate, "pttr", "pttr.rate_id = txr.id")
.leftJoin(Product, "prod", "prod.type_id = pttr.product_type_id")
.where("prod.id = :productId", { productId })
if (typeof config.region_id !== "undefined") {
productRates.andWhere("txr.region_id = :regionId", {
regionId: config.region_id,
})
typeRates.andWhere("txr.region_id = :regionId", {
regionId: config.region_id,
})
}
const results = await Promise.all([
productRates.getMany(),
typeRates.getMany(),
])
// Only return unique rates by joining the two arrays from typeRates and
// productRates matching based on the id
return unionBy(...results, (txr) => txr.id)
}
async listByShippingOption(optionId: string, config: TaxRateListByConfig) {
let rates = this.createQueryBuilder("txr")
.leftJoin(ShippingTaxRate, "ptr", "ptr.rate_id = txr.id")
.leftJoin(
ShippingMethod,
"sm",
"sm.shipping_option_id = ptr.shipping_option_id"
)
.where("sm.shipping_option_id = :optionId", { optionId })
if (typeof config.region_id !== "undefined") {
rates.andWhere("txr.region_id = :regionId", {
regionId: config.region_id,
})
}
return await rates.getMany()
}
}
@@ -67,7 +67,7 @@ export const shippingOptions = {
}
export const ShippingOptionServiceMock = {
retrieve: jest.fn().mockImplementation(optionId => {
retrieve: jest.fn().mockImplementation((optionId) => {
if (optionId === IdMap.getId("return-shipping")) {
return Promise.resolve(shippingOptions.returnShipping)
}
@@ -86,9 +86,21 @@ export const ShippingOptionServiceMock = {
return Promise.resolve(undefined)
}),
update: jest.fn().mockReturnValue(Promise.resolve()),
list: jest.fn().mockImplementation(data => {
listAndCount: jest.fn().mockImplementation((data) => {
if (data.region_id === IdMap.getId("region-france")) {
return Promise.resolve([[shippingOptions.franceShipping], 1])
}
if (data.region_id === IdMap.getId("testRegion")) {
return Promise.resolve([
[shippingOptions.freeShipping, shippingOptions.expensiveShipping],
2,
])
}
return Promise.resolve([[], 0])
}),
list: jest.fn().mockImplementation((data) => {
if (!data) {
return Promise.resolve()
return Promise.resolve([])
}
if (data.region_id === IdMap.getId("region-france")) {
return Promise.resolve([shippingOptions.franceShipping])
@@ -100,7 +112,7 @@ export const ShippingOptionServiceMock = {
])
}
}),
create: jest.fn().mockImplementation(data => {
create: jest.fn().mockImplementation((data) => {
return Promise.resolve(data)
}),
validateFulfillmentData: jest
@@ -655,6 +655,8 @@ describe("CartService", () => {
"discounts.rule",
"discounts.rule.valid_for",
"discounts.regions",
"items.tax_lines",
"region.tax_rates",
],
{
where: { id: "withpays" },
+27 -16
View File
@@ -5,13 +5,14 @@ import { InventoryServiceMock } from "../__mocks__/inventory"
const withTransactionMock = jest.fn()
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("eventBus")
return this
},
}
const totalsService = {
getCalculationContext: jest.fn(() => {}),
getRefundTotal: jest.fn(() => 1000),
}
@@ -54,12 +55,20 @@ describe("ClaimService", () => {
}
const claimRepo = MockRepository({
create: d => ({ id: "claim_134", ...d }),
create: (d) => ({ id: "claim_134", ...d }),
})
const taxProviderService = {
createTaxLines: jest.fn(),
withTransaction: function () {
withTransactionMock("return")
return this
},
}
const returnService = {
create: jest.fn(),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("return")
return this
},
@@ -68,7 +77,8 @@ describe("ClaimService", () => {
const lineItemService = {
generate: jest.fn((d, _, q) => ({ variant_id: d, quantity: q })),
retrieve: () => Promise.resolve({}),
withTransaction: function() {
list: () => Promise.resolve([{}]),
withTransaction: function () {
withTransactionMock("lineItem")
return this
},
@@ -76,7 +86,7 @@ describe("ClaimService", () => {
const inventoryService = {
...InventoryServiceMock,
withTransaction: function() {
withTransaction: function () {
withTransactionMock("inventory")
return this
},
@@ -84,7 +94,7 @@ describe("ClaimService", () => {
const claimItemService = {
create: jest.fn(),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("claimItem")
return this
},
@@ -93,6 +103,7 @@ describe("ClaimService", () => {
const claimService = new ClaimService({
manager: MockManager,
claimRepository: claimRepo,
taxProviderService,
totalsService,
returnService,
lineItemService,
@@ -303,7 +314,7 @@ describe("ClaimService", () => {
createFulfillment: jest.fn((_, items) =>
Promise.resolve([{ id: "ful", items }])
),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("fulfillment")
return this
},
@@ -339,7 +350,7 @@ describe("ClaimService", () => {
const lineItemService = {
update: jest.fn(),
retrieve: () => Promise.resolve({}),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("lineItem")
return this
},
@@ -440,11 +451,11 @@ describe("ClaimService", () => {
describe("cancelFulfillment", () => {
const claimRepo = MockRepository({
findOne: () => Promise.resolve({}),
save: f => Promise.resolve(f),
save: (f) => Promise.resolve(f),
})
const fulfillmentService = {
cancelFulfillment: jest.fn().mockImplementation(f => {
cancelFulfillment: jest.fn().mockImplementation((f) => {
switch (f) {
case IdMap.getId("no-claim"):
return Promise.resolve({})
@@ -454,7 +465,7 @@ describe("ClaimService", () => {
})
}
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -519,7 +530,7 @@ describe("ClaimService", () => {
],
})
}),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("fulfillment")
return this
},
@@ -528,7 +539,7 @@ describe("ClaimService", () => {
const lineItemService = {
update: jest.fn(),
retrieve: () => Promise.resolve({}),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("lineItem")
return this
},
@@ -623,7 +634,7 @@ describe("ClaimService", () => {
describe("cancel", () => {
const fulfillmentService = {
cancelFulfillment: jest.fn(),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("fulfillment")
return this
},
@@ -631,7 +642,7 @@ describe("ClaimService", () => {
const returnService = {
cancel: jest.fn(),
withTransaction: function() {
withTransaction: function () {
withTransactionMock("return")
return this
},
@@ -642,7 +653,7 @@ describe("ClaimService", () => {
const fulfillment = { id: "ful_21", canceled_at: now }
const claimRepo = MockRepository({
findOne: q => {
findOne: (q) => {
const claim = {
return_order: { ...ret_order },
fulfillments: [{ ...fulfillment }],
+43 -53
View File
@@ -5,35 +5,35 @@ import { InventoryServiceMock } from "../__mocks__/inventory"
describe("OrderService", () => {
const totalsService = {
getLineItemRefund: () => {},
getTotal: o => {
getTotal: (o) => {
return o.total || 0
},
getRefundedTotal: o => {
getRefundedTotal: (o) => {
return o.refunded_total || 0
},
getShippingTotal: o => {
getShippingTotal: (o) => {
return o.shipping_total || 0
},
getGiftCardTotal: o => {
getGiftCardTotal: (o) => {
return o.gift_card_total || 0
},
getDiscountTotal: o => {
getDiscountTotal: (o) => {
return o.discount_total || 0
},
getTaxTotal: o => {
getTaxTotal: (o) => {
return o.tax_total || 0
},
getSubtotal: o => {
getSubtotal: (o) => {
return o.subtotal || 0
},
getPaidTotal: o => {
getPaidTotal: (o) => {
return o.paid_total || 0
},
}
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -43,7 +43,7 @@ describe("OrderService", () => {
}
describe("create", () => {
const orderRepo = MockRepository({ create: f => f })
const orderRepo = MockRepository({ create: (f) => f })
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
@@ -73,37 +73,37 @@ describe("OrderService", () => {
describe("createFromCart", () => {
const orderRepo = MockRepository({
create: p => p,
save: p => ({ ...p, id: "id" }),
create: (p) => p,
save: (p) => ({ ...p, id: "id" }),
})
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const shippingOptionService = {
updateShippingMethod: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const giftCardService = {
update: jest.fn(),
createTransaction: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const paymentProviderService = {
getStatus: payment => {
getStatus: (payment) => {
return Promise.resolve(payment.status || "authorized")
},
updatePayment: jest.fn(),
cancelPayment: jest.fn().mockImplementation(payment => {
cancelPayment: jest.fn().mockImplementation((payment) => {
return Promise.resolve({ ...payment, status: "cancelled" })
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -113,7 +113,7 @@ describe("OrderService", () => {
total: 0,
}
const cartService = {
retrieve: jest.fn().mockImplementation(query => {
retrieve: jest.fn().mockImplementation((query) => {
if (query === "empty") {
return Promise.resolve(emptyCart)
}
@@ -142,7 +142,7 @@ describe("OrderService", () => {
total: 100,
})
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -211,14 +211,12 @@ describe("OrderService", () => {
email: cart.email,
customer_id: cart.customer_id,
shipping_methods: cart.shipping_methods,
customer_id: "cus_1234",
discounts: cart.discounts,
billing_address_id: cart.billing_address_id,
shipping_address_id: cart.shipping_address_id,
region_id: cart.region_id,
currency_code: "eur",
cart_id: "cart_id",
tax_rate: 25,
gift_cards: [],
metadata: {},
}
@@ -316,14 +314,12 @@ describe("OrderService", () => {
email: cart.email,
customer_id: cart.customer_id,
shipping_methods: cart.shipping_methods,
customer_id: "cus_1234",
discounts: cart.discounts,
billing_address_id: cart.billing_address_id,
shipping_address_id: cart.shipping_address_id,
region_id: cart.region_id,
currency_code: "eur",
cart_id: "cart_id",
tax_rate: 25,
gift_cards: [
{
id: "gid",
@@ -403,7 +399,6 @@ describe("OrderService", () => {
email: cart.email,
customer_id: cart.customer_id,
shipping_methods: cart.shipping_methods,
customer_id: "cus_1234",
discounts: cart.discounts,
billing_address_id: cart.billing_address_id,
shipping_address_id: cart.shipping_address_id,
@@ -411,7 +406,6 @@ describe("OrderService", () => {
region_id: cart.region_id,
currency_code: "eur",
cart_id: "cart_id",
tax_rate: 25,
metadata: {},
}
expect(orderRepo.create).toHaveBeenCalledTimes(1)
@@ -471,7 +465,7 @@ describe("OrderService", () => {
describe("retrieve", () => {
const orderRepo = MockRepository({
findOneWithRelations: q => {
findOneWithRelations: (q) => {
return Promise.resolve({})
},
})
@@ -496,7 +490,7 @@ describe("OrderService", () => {
describe("retrieveByCartId", () => {
const orderRepo = MockRepository({
findOne: q => {
findOne: (q) => {
return Promise.resolve({})
},
})
@@ -640,14 +634,14 @@ describe("OrderService", () => {
const fulfillmentService = {
cancelFulfillment: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
const paymentProviderService = {
cancelPayment: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -668,10 +662,7 @@ describe("OrderService", () => {
it("calls order model functions", async () => {
try {
const order = await orderService.retrieve(
IdMap.getId("not-fulfilled-order")
)
console.warn(order)
await orderService.retrieve(IdMap.getId("not-fulfilled-order"))
await orderService.cancel(IdMap.getId("not-fulfilled-order"))
} catch (e) {
console.warn(e)
@@ -696,6 +687,7 @@ describe("OrderService", () => {
expect(orderRepo.save).toHaveBeenCalledWith({
fulfillment_status: "canceled",
payment_status: "canceled",
canceled_at: expect.any(Date),
status: "canceled",
fulfillments: [{ id: "fulfillment_test", canceled_at: now }],
payments: [{ id: "payment_test" }],
@@ -741,12 +733,12 @@ describe("OrderService", () => {
const paymentProviderService = {
capturePayment: jest
.fn()
.mockImplementation(p =>
.mockImplementation((p) =>
p.id === "payment_fail"
? Promise.reject()
: Promise.resolve({ ...p, captured_at: "notnull" })
),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -851,7 +843,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -864,7 +856,7 @@ describe("OrderService", () => {
},
])
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1017,11 +1009,11 @@ describe("OrderService", () => {
describe("cancelFulfillment", () => {
const orderRepo = MockRepository({
findOneWithRelations: () => Promise.resolve({}),
save: f => Promise.resolve(f),
save: (f) => Promise.resolve(f),
})
const fulfillmentService = {
cancelFulfillment: jest.fn().mockImplementation(f => {
cancelFulfillment: jest.fn().mockImplementation((f) => {
switch (f) {
case IdMap.getId("no-order"):
return Promise.resolve({})
@@ -1031,7 +1023,7 @@ describe("OrderService", () => {
})
}
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1097,10 +1089,10 @@ describe("OrderService", () => {
const paymentProviderService = {
refundPayment: jest
.fn()
.mockImplementation(p =>
.mockImplementation((p) =>
p.id === "payment_fail" ? Promise.reject() : Promise.resolve()
),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1176,7 +1168,7 @@ describe("OrderService", () => {
return Promise.resolve({ id: IdMap.getId("order") })
}
},
save: jest.fn().mockImplementation(f => f),
save: jest.fn().mockImplementation((f) => f),
})
const eventBus = {
@@ -1228,7 +1220,7 @@ describe("OrderService", () => {
})
}
},
save: jest.fn().mockImplementation(f => f),
save: jest.fn().mockImplementation((f) => f),
})
const optionService = {
@@ -1241,7 +1233,7 @@ describe("OrderService", () => {
.fn()
.mockImplementation(() => Promise.resolve({})),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1375,7 +1367,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1398,7 +1390,7 @@ describe("OrderService", () => {
],
})
}),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -1425,9 +1417,7 @@ describe("OrderService", () => {
)
expect(fulfillmentService.createShipment).toHaveBeenCalledTimes(1)
expect(
fulfillmentService.createShipment
).toHaveBeenCalledWith(
expect(fulfillmentService.createShipment).toHaveBeenCalledWith(
IdMap.getId("fulfillment"),
[{ tracking_number: "1234" }, { tracking_number: "2345" }],
{ metadata: undefined, no_notification: true }
@@ -1518,8 +1508,8 @@ describe("OrderService", () => {
const paymentProviderService = {
refundPayment: jest
.fn()
.mockImplementation(p => Promise.resolve({ id: "ref" })),
withTransaction: function() {
.mockImplementation((p) => Promise.resolve({ id: "ref" })),
withTransaction: function () {
return this
},
}
+37 -133
View File
@@ -160,6 +160,11 @@ describe("SwapService", () => {
const cartService = {
create: jest.fn().mockReturnValue(Promise.resolve({ id: "cart" })),
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ id: "cart", items: [{ id: "test-item" }] })
),
update: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
return this
@@ -173,7 +178,7 @@ describe("SwapService", () => {
const customShippingOptionService = {
create: jest.fn().mockReturnValue(Promise.resolve({ id: "cso-test" })),
update: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -182,6 +187,7 @@ describe("SwapService", () => {
create: jest.fn().mockImplementation((d) => Promise.resolve(d)),
update: jest.fn().mockImplementation((d) => Promise.resolve(d)),
retrieve: () => Promise.resolve({}),
createReturnLines: jest.fn(() => Promise.resolve()),
withTransaction: function () {
return this
},
@@ -216,12 +222,19 @@ describe("SwapService", () => {
"return_order",
"return_order.items",
"return_order.shipping_method",
"return_order.shipping_method.tax_lines",
],
{
where: { id: IdMap.getId("swap-1") },
}
)
expect(lineItemService.createReturnLines).toHaveBeenCalledTimes(1)
expect(lineItemService.createReturnLines).toHaveBeenCalledWith(
expect.any(String),
"cart"
)
expect(cartService.create).toHaveBeenCalledTimes(1)
expect(cartService.create).toHaveBeenCalledWith({
email: testOrder.email,
@@ -404,126 +417,6 @@ describe("SwapService", () => {
})
})
describe("receiveReturn", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const returnService = {
receiveReturn: jest
.fn()
.mockReturnValue(Promise.resolve({ test: "received" })),
withTransaction: function () {
return this
},
}
const existing = {
order_id: IdMap.getId("test"),
return_id: IdMap.getId("test"),
return_order: {
id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
other: "data",
}
const swapRepo = MockRepository({
findOneWithRelations: () => Promise.resolve(existing),
})
const swapService = new SwapService({
manager: MockManager,
eventBusService,
swapRepository: swapRepo,
returnService,
})
it("calls register return and updates return value", async () => {
await swapService.receiveReturn(IdMap.getId("swap"), [
{ item_id: IdMap.getId("1234"), quantity: 1 },
])
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
IdMap.getId("return-swap"),
[{ item_id: IdMap.getId("1234"), quantity: 1 }],
undefined,
false
)
})
})
describe("failure", () => {
const returnService = {
receiveReturn: jest
.fn()
.mockReturnValue(Promise.resolve({ status: "requires_action" })),
withTransaction: function () {
return this
},
}
const existing = {
order_id: IdMap.getId("test"),
return_id: IdMap.getId("return-swap"),
return_order_id: IdMap.getId("return-swap"),
return_order: { id: IdMap.getId("return-swap") },
other: "data",
}
const swapRepo = MockRepository({
findOneWithRelations: (rels, q) => {
switch (q.where.id) {
case IdMap.getId("canceled"):
return Promise.resolve({ canceled_at: new Date() })
case IdMap.getId("empty"):
return Promise.resolve({})
default:
return Promise.resolve(existing)
}
},
})
const swapService = new SwapService({
manager: MockManager,
eventBusService,
swapRepository: swapRepo,
returnService,
})
it("fails if swap has no return request", async () => {
const res = swapService.receiveReturn(IdMap.getId("empty"), [])
await expect(res).rejects.toThrow("Swap has no return request")
})
it("sets requires action if return fails", async () => {
await swapService.receiveReturn(IdMap.getId("swap"), [
{ variant_id: IdMap.getId("1234"), quantity: 1 },
])
expect(swapRepo.save).toHaveBeenCalledWith({
...existing,
fulfillment_status: "requires_action",
})
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
IdMap.getId("return-swap"),
[{ variant_id: IdMap.getId("1234"), quantity: 1 }],
undefined,
false
)
})
it("fails to receive return when swap is canceled", async () => {
await expect(
swapService.receiveReturn(IdMap.getId("canceled"))
).rejects.toThrow("Canceled swap cannot be registered as received")
})
})
})
describe("createFulfillment", () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -757,6 +650,11 @@ describe("SwapService", () => {
const cartService = {
update: jest.fn(),
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ id: "cart", items: [{ id: "test-item" }] })
),
withTransaction: function () {
return this
},
@@ -771,7 +669,6 @@ describe("SwapService", () => {
eventBusService,
swapRepository: swapRepo,
lineItemService,
eventBusService,
fulfillmentService,
cartService,
})
@@ -854,6 +751,12 @@ describe("SwapService", () => {
}
const cartService = {
retrieve: jest.fn().mockReturnValue(
Promise.resolve({
id: "cart",
items: [{ id: "test-item", variant_id: "variant" }],
})
),
update: () => {
return Promise.resolve()
},
@@ -885,18 +788,21 @@ describe("SwapService", () => {
}
describe("success", () => {
const existing = {
cart: {
items: [{ id: "1", variant_id: "variant", quantity: 2 }],
shipping_methods: [{ id: "method_1" }],
payment: {
good: "yes",
},
shipping_address_id: 1234,
const cart = {
items: [{ id: "1", variant_id: "variant", quantity: 2 }],
shipping_methods: [{ id: "method_1" }],
payment: {
good: "yes",
},
shipping_address_id: 1234,
total: 1,
}
const existing = {
other: "data",
}
cartService.retrieve = () => cart
const swapRepo = MockRepository({
findOneWithRelations: () => Promise.resolve(existing),
})
@@ -908,7 +814,6 @@ describe("SwapService", () => {
totalsService,
cartService,
paymentProviderService,
eventBusService,
shippingOptionService,
inventoryService,
})
@@ -931,7 +836,7 @@ describe("SwapService", () => {
expect(swapRepo.save).toHaveBeenCalledWith({
...existing,
difference_due: 100,
difference_due: 1,
shipping_address_id: 1234,
confirmed_at: expect.anything(),
})
@@ -970,7 +875,6 @@ describe("SwapService", () => {
totalsService,
cartService,
paymentProviderService,
eventBusService,
shippingOptionService,
inventoryService,
})
@@ -0,0 +1,85 @@
import SystemTaxService from "../system-tax"
describe("SystemTaxService", () => {
describe("getTaxLines", () => {
const taxService = new SystemTaxService()
const calculationContext = {
region: {},
customer: {},
allocation_map: {},
shipping_address: {},
}
test("success", async () => {
const lines = await taxService.getTaxLines(
[
{
item: { id: "line_id" },
rates: [
{ rate: 20, name: "test", code: "test" },
{ rate: 5.5, name: "test_2", code: "test_2" },
],
},
],
[],
calculationContext
)
expect(lines).toEqual([
{
item_id: "line_id",
rate: 20,
name: "test",
code: "test",
},
{
item_id: "line_id",
rate: 5.5,
name: "test_2",
code: "test_2",
},
])
})
test("success w. shipping", async () => {
const lines = await taxService.getTaxLines(
[
{
item: { id: "line_id" },
rates: [
{ rate: 20, name: "test", code: "test" },
{ rate: 5.5, name: "test_2", code: "test_2" },
],
},
],
[
{
shipping_method: { id: "sm_id" },
rates: [{ rate: 20, name: "test", code: "test" }],
},
],
calculationContext
)
expect(lines).toEqual([
{
item_id: "line_id",
rate: 20,
name: "test",
code: "test",
},
{
item_id: "line_id",
rate: 5.5,
name: "test_2",
code: "test_2",
},
{
shipping_method_id: "sm_id",
rate: 20,
name: "test",
code: "test",
},
])
})
})
})
@@ -0,0 +1,281 @@
import { MockManager, MockRepository } from "medusa-test-utils"
import TaxProviderService from "../tax-provider"
describe("TaxProviderService", () => {
describe("retrieveProvider", () => {
const container = {
manager: MockManager,
taxRateService: {},
systemTaxService: "system",
tp_test: "good",
}
const providerService = new TaxProviderService(container)
it("successfully retrieves system provider", () => {
const provider = providerService.retrieveProvider({
tax_provider_id: null,
})
expect(provider).toEqual("system")
})
it("successfully retrieves external provider", () => {
const provider = providerService.retrieveProvider({
tax_provider_id: "test",
})
expect(provider).toEqual("good")
})
it("fails when payment provider not found", () => {
expect.assertions(1)
try {
providerService.retrieveProvider({ tax_provider_id: "unregistered" })
} catch (err) {
expect(err.message).toEqual(
"Could not find a tax provider with id: unregistered"
)
}
})
})
describe("getTaxLines", () => {
const mockCalculateLineItemTaxes = jest.fn(() => [
{
rate: 5,
name: "test vat",
code: "test_vat",
item_id: "item_1",
},
])
const container = {
manager: MockManager,
lineItemTaxLineRepository: MockRepository({ create: (d) => d }),
systemTaxService: {
getTaxLines: mockCalculateLineItemTaxes,
},
}
test("success", async () => {
const providerService = new TaxProviderService(container)
providerService.getRegionRatesForProduct = jest.fn(() => [])
const region = { id: "test", tax_provider_id: null }
const { cart, calculationContext: calcContext } = getTaxLineFactory({
items: [{ id: "item_1", product_id: "prod_1" }],
region,
})
const expected = [
{
item_id: "item_1",
rate: 5,
name: "test vat",
code: "test_vat",
metadata: undefined,
},
]
const rates = await providerService.getTaxLines(cart.items, calcContext)
expect(rates).toEqual(expected)
expect(container.lineItemTaxLineRepository.create).toHaveBeenCalledTimes(
1
)
expect(container.lineItemTaxLineRepository.create).toHaveBeenCalledWith(
expected[0]
)
expect(providerService.getRegionRatesForProduct).toHaveBeenCalledTimes(1)
expect(providerService.getRegionRatesForProduct).toHaveBeenCalledWith(
"prod_1",
region
)
expect(mockCalculateLineItemTaxes).toHaveBeenCalledTimes(1)
expect(mockCalculateLineItemTaxes).toHaveBeenCalledWith(
[
{
item: cart.items[0],
rates: [],
},
],
[],
calcContext
)
})
})
describe("getRegionRatesForProduct", () => {
const container = {
manager: MockManager,
taxRateService: {
listByProduct: jest.fn(),
},
}
test("success", async () => {
container.taxRateService = {
listByProduct: jest.fn(() => Promise.resolve([])),
}
const providerService = new TaxProviderService(container)
providerService.getCacheEntry = jest.fn(() => null)
providerService.setCache = jest.fn()
const rates = await providerService.getRegionRatesForProduct("prod_id", {
id: "reg_id",
tax_rates: [],
tax_rate: 12.5,
})
const expected = [
{
rate: 12.5,
name: "default",
code: "default",
},
]
expect(rates).toEqual(expected)
expect(providerService.getCacheEntry).toHaveBeenCalledTimes(1)
expect(providerService.getCacheEntry).toHaveBeenCalledWith(
"prod_id",
"reg_id"
)
expect(providerService.setCache).toHaveBeenCalledTimes(1)
expect(providerService.setCache).toHaveBeenCalledWith(
"prod_id",
"reg_id",
expected
)
})
test("success - without product rates", async () => {
container.taxRateService = {
listByProduct: jest.fn(() => Promise.resolve([])),
}
const providerService = new TaxProviderService(container)
providerService.getCacheEntry = jest.fn(() => null)
providerService.setCache = jest.fn()
const rates = await providerService.getRegionRatesForProduct("prod_id", {
id: "reg_id",
tax_rates: [{ id: "reg_rate", rate: 20, name: "PTR", code: "ptr" }],
tax_rate: 12.5,
})
const expected = [
{
rate: 12.5,
name: "default",
code: "default",
},
]
expect(container.taxRateService.listByProduct).toHaveBeenCalledTimes(1)
expect(container.taxRateService.listByProduct).toHaveBeenCalledWith(
"prod_id",
{ region_id: "reg_id" }
)
expect(providerService.setCache).toHaveBeenCalledTimes(1)
expect(providerService.setCache).toHaveBeenCalledWith(
"prod_id",
"reg_id",
expected
)
expect(rates).toEqual(expected)
})
test("success - with product rates", async () => {
container.taxRateService = {
listByProduct: jest.fn(() =>
Promise.resolve([
{
rate: 20,
name: "PTR",
code: "ptr",
},
])
),
}
const providerService = new TaxProviderService(container)
providerService.getCacheEntry = jest.fn(() => null)
providerService.setCache = jest.fn()
const rates = await providerService.getRegionRatesForProduct("prod_id", {
id: "reg_id",
tax_rates: [{ id: "reg_rate", rate: 20, name: "PTR", code: "ptr" }],
tax_rate: 12.5,
})
const expected = [
{
rate: 20,
name: "PTR",
code: "ptr",
},
]
expect(container.taxRateService.listByProduct).toHaveBeenCalledTimes(1)
expect(container.taxRateService.listByProduct).toHaveBeenCalledWith(
"prod_id",
{ region_id: "reg_id" }
)
expect(providerService.setCache).toHaveBeenCalledTimes(1)
expect(providerService.setCache).toHaveBeenCalledWith(
"prod_id",
"reg_id",
expected
)
expect(rates).toEqual(expected)
})
})
describe("getCacheKey", () => {
const container = {
manager: MockManager,
productTaxRateService: {},
systemTaxService: "system",
tp_test: "good",
}
const providerService = new TaxProviderService(container)
test("formats correctly", () => {
expect(providerService.getCacheKey("product", "region")).toEqual(
"txrtcache:product:region"
)
})
})
})
const getTaxLineFactory = ({ items, region }) => {
const cart = {
shipping_methods: [],
items: items.map((i) => {
return {
id: i.id,
variant: {
product_id: i.product_id,
},
}
}),
}
const calculationContext = {
region,
customer: {},
allocation_map: {},
shipping_address: {},
shipping_methods: [],
}
return { cart, calculationContext }
}
+133 -10
View File
@@ -60,9 +60,15 @@ const discounts = {
}
describe("TotalsService", () => {
const container = {
taxProviderService: {},
taxCalculationStrategy: {},
}
describe("getAllocationItemDiscounts", () => {
let res
const totalsService = new TotalsService()
const totalsService = new TotalsService(container)
beforeEach(() => {
jest.clearAllMocks()
@@ -187,7 +193,7 @@ describe("TotalsService", () => {
describe("getDiscountTotal", () => {
let res
const totalsService = new TotalsService()
const totalsService = new TotalsService(container)
const discountCart = {
id: "discount_cart",
@@ -275,7 +281,7 @@ describe("TotalsService", () => {
describe("getRefundTotal", () => {
let res
const totalsService = new TotalsService()
const totalsService = new TotalsService(container)
const orderToRefund = {
id: "refund-order",
tax_rate: 25,
@@ -441,7 +447,7 @@ describe("TotalsService", () => {
})
describe("getShippingTotal", () => {
let res
const totalsService = new TotalsService()
const totalsService = new TotalsService(container)
beforeEach(() => {
jest.clearAllMocks()
@@ -469,10 +475,85 @@ describe("TotalsService", () => {
})
describe("getTaxTotal", () => {
let res
const totalsService = new TotalsService()
let totalsService
const getTaxLinesMock = jest.fn(() => Promise.resolve([{ id: "line1" }]))
const calculateMock = jest.fn(() => Promise.resolve(20.3))
const getAllocationMapMock = jest.fn(() => ({}))
const cradle = {
taxProviderService: {
getTaxLines: getTaxLinesMock,
},
taxCalculationStrategy: {
calculate: calculateMock,
},
}
beforeEach(() => {
jest.clearAllMocks()
totalsService = new TotalsService(cradle)
totalsService.getAllocationMap = getAllocationMapMock
})
it("uses order tax lines", async () => {
const order = {
object: "order",
tax_rate: null,
region: {
tax_rate: 25,
},
customer: {
test: "test",
},
shipping_address: {
test: "test",
},
items: [
{
unit_price: 20,
quantity: 2,
tax_lines: [{ id: "orderline1" }],
},
],
shipping_methods: [
{
id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
tax_lines: [],
data: {
extra: "hi",
},
},
],
}
res = await totalsService.getTaxTotal(order)
expect(res).toEqual(20)
expect(getAllocationMapMock).toHaveBeenCalledTimes(1)
expect(getAllocationMapMock).toHaveBeenCalledWith(order, {})
expect(getTaxLinesMock).toHaveBeenCalledTimes(0)
expect(calculateMock).toHaveBeenCalledTimes(1)
expect(calculateMock).toHaveBeenCalledWith(
order.items,
[{ id: "orderline1" }],
{
shipping_address: order.shipping_address,
shipping_methods: order.shipping_methods,
is_return: false,
customer: order.customer,
region: order.region,
allocation_map: {},
}
)
})
it("calculates tax", async () => {
@@ -480,6 +561,12 @@ describe("TotalsService", () => {
region: {
tax_rate: 25,
},
customer: {
test: "test",
},
shipping_address: {
test: "test",
},
items: [
{
unit_price: 20,
@@ -488,7 +575,7 @@ describe("TotalsService", () => {
],
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
@@ -500,15 +587,45 @@ describe("TotalsService", () => {
],
}
res = totalsService.getTaxTotal(order)
res = await totalsService.getTaxTotal(order)
expect(res).toEqual(35)
expect(res).toEqual(20)
expect(getAllocationMapMock).toHaveBeenCalledTimes(1)
expect(getAllocationMapMock).toHaveBeenCalledWith(order, {})
expect(getTaxLinesMock).toHaveBeenCalledTimes(1)
expect(getTaxLinesMock).toHaveBeenCalledWith(
[{ quantity: 2, unit_price: 20 }],
{
shipping_address: order.shipping_address,
shipping_methods: order.shipping_methods,
is_return: false,
customer: order.customer,
region: order.region,
allocation_map: {},
}
)
expect(calculateMock).toHaveBeenCalledTimes(1)
expect(calculateMock).toHaveBeenCalledWith(
order.items,
[{ id: "line1" }],
{
shipping_address: order.shipping_address,
shipping_methods: order.shipping_methods,
is_return: false,
customer: order.customer,
region: order.region,
allocation_map: {},
}
)
})
})
describe("getTotal", () => {
let res
const totalsService = new TotalsService()
const totalsService = new TotalsService(container)
beforeEach(() => {
jest.clearAllMocks()
@@ -538,7 +655,13 @@ describe("TotalsService", () => {
},
],
}
res = totalsService.getTotal(order)
const getTaxTotalMock = jest.fn(() => Promise.resolve(35))
totalsService.getTaxTotal = getTaxTotalMock
res = await totalsService.getTotal(order)
expect(getTaxTotalMock).toHaveBeenCalledTimes(1)
expect(getTaxTotalMock).toHaveBeenCalledWith(order, undefined)
expect(res).toEqual(175)
})
})
+164 -182
View File
@@ -32,6 +32,7 @@ import LineItemService from "./line-item"
import PaymentProviderService from "./payment-provider"
import ShippingOptionService from "./shipping-option"
import CustomerService from "./customer"
import TaxProviderService from "./tax-provider"
import DiscountService from "./discount"
import GiftCardService from "./gift-card"
import TotalsService from "./totals"
@@ -45,6 +46,7 @@ type CartConstructorProps = {
addressRepository: typeof AddressRepository
paymentSessionRepository: typeof PaymentSessionRepository
eventBusService: EventBusService
taxProviderService: TaxProviderService
paymentProviderService: PaymentProviderService
productService: ProductService
productVariantService: ProductVariantService
@@ -59,6 +61,10 @@ type CartConstructorProps = {
customShippingOptionService: CustomShippingOptionService
}
type TotalsConfig = {
force_taxes?: boolean
}
/* Provides layer to manipulate carts.
* @implements BaseService
*/
@@ -82,6 +88,7 @@ class CartService extends BaseService {
private shippingOptionService_: ShippingOptionService
private discountService_: DiscountService
private giftCardService_: GiftCardService
private taxProviderService_: TaxProviderService
private totalsService_: TotalsService
private addressRepository_: typeof AddressRepository
private paymentSessionRepository_: typeof PaymentSessionRepository
@@ -96,6 +103,7 @@ class CartService extends BaseService {
paymentProviderService,
productService,
productVariantService,
taxProviderService,
regionService,
lineItemService,
shippingOptionService,
@@ -110,59 +118,25 @@ class CartService extends BaseService {
}: CartConstructorProps) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShippingMethodRepository} */
this.shippingMethodRepository_ = shippingMethodRepository
/** @private @const {CartRepository} */
this.cartRepository_ = cartRepository
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
/** @private @const {ProductService} */
this.productService_ = productService
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {CustomerService} */
this.customerService_ = customerService
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
/** @private @const {DiscountService} */
this.discountService_ = discountService
/** @private @const {GiftCardService} */
this.giftCardService_ = giftCardService
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
/** @private @const {AddressRepository} */
this.addressRepository_ = addressRepository
/** @private @const {PaymentSessionRepository} */
this.paymentSessionRepository_ = paymentSessionRepository
/** @private @const {InventoryService} */
this.inventoryService_ = inventoryService
/** @private @const {CustomShippingOptionService} */
this.customShippingOptionService_ = customShippingOptionService
this.taxProviderService_ = taxProviderService
}
withTransaction(transactionManager: EntityManager): CartService {
@@ -172,6 +146,7 @@ class CartService extends BaseService {
const cloned = new CartService({
manager: transactionManager,
taxProviderService: this.taxProviderService_,
cartRepository: this.cartRepository_,
eventBusService: this.eventBus_,
paymentProviderService: this.paymentProviderService_,
@@ -196,25 +171,6 @@ class CartService extends BaseService {
return cloned
}
/**
* Used to validate cart ids. Throws an error if the cast fails
* @param {string} rawId - the raw cart id to validate.
* @return {string} the validated id
*/
/**
* Contents of a line item
* @typedef {(object | array)} LineItemContent
* @property {number} unit_price - the price of the content
* @property {object} variant - the product variant of the content
* @property {object} product - the product of the content
* @property {number} quantity - the quantity of the content
*/
/**
* A collection of contents grouped in the same line item
* @typedef {LineItemContent[]} LineItemContentArray
*/
transformQueryForTotals_(
config: FindConfig<Cart>
): FindConfig<Cart> & { totalsToSelect: TotalField[] } {
@@ -243,6 +199,7 @@ class CartService extends BaseService {
if (totalsToSelect.length > 0) {
const relationSet = new Set(relations)
relationSet.add("items")
relationSet.add("items.tax_lines")
relationSet.add("gift_cards")
relationSet.add("discounts")
relationSet.add("discounts.rule")
@@ -252,6 +209,7 @@ class CartService extends BaseService {
// relationSet.add("discounts.parent_discount.regions")
relationSet.add("shipping_methods")
relationSet.add("region")
relationSet.add("region.tax_rates")
relations = Array.from(relationSet.values())
select = select.filter((v) => !totalFields.includes(v))
@@ -266,14 +224,17 @@ class CartService extends BaseService {
async decorateTotals_(
cart: Cart,
totalsToSelect: TotalField[]
totalsToSelect: TotalField[],
options: TotalsConfig = { force_taxes: false }
): Promise<Cart> {
const totals: { [K in TotalField]?: number } = {}
const totals: { [K in TotalField]?: number | null } = {}
for (const key of totalsToSelect) {
switch (key) {
case "total": {
totals.total = await this.totalsService_.getTotal(cart)
totals.total = await this.totalsService_.getTotal(cart, {
force_taxes: options.force_taxes,
})
break
}
case "shipping_total": {
@@ -284,7 +245,10 @@ class CartService extends BaseService {
totals.discount_total = this.totalsService_.getDiscountTotal(cart)
break
case "tax_total":
totals.tax_total = await this.totalsService_.getTaxTotal(cart)
totals.tax_total = await this.totalsService_.getTaxTotal(
cart,
options.force_taxes
)
break
case "gift_card_total":
totals.gift_card_total = this.totalsService_.getGiftCardTotal(cart)
@@ -301,9 +265,9 @@ class CartService extends BaseService {
}
/**
* @param {Object} selector - the query object for find
* @param {Object} config - config object
* @return {Promise} the result of the find operation
* @param selector - the query object for find
* @param config - config object
* @return the result of the find operation
*/
async list(
selector: FilterableCartProps,
@@ -317,13 +281,15 @@ class CartService extends BaseService {
/**
* Gets a cart by id.
* @param {string} cartId - the id of the cart to get.
* @param {Object} options - the options to get a cart
* @return {Promise<Cart>} the cart document.
* @param cartId - the id of the cart to get.
* @param options - the options to get a cart
* @param totalsConfig - configuration for retrieval of totals
* @return the cart document.
*/
async retrieve(
cartId: string,
options: FindConfig<Cart> = {}
options: FindConfig<Cart> = {},
totalsConfig: TotalsConfig = {}
): Promise<Cart> {
const cartRepo = this.manager_.getCustomRepository(this.cartRepository_)
const validatedId = this.validateId_(cartId)
@@ -358,13 +324,13 @@ class CartService extends BaseService {
)
}
return await this.decorateTotals_(raw, totalsToSelect)
return await this.decorateTotals_(raw, totalsToSelect, totalsConfig)
}
/**
* Creates a cart.
* @param {Object} data - the data to create the cart with
* @return {Promise} the result of the create operation
* @param data - the data to create the cart with
* @return the result of the create operation
*/
async create(data: CartCreateProps): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -435,7 +401,7 @@ class CartService extends BaseService {
]
for (const k of remainingFields) {
if (typeof data[k] !== "undefined") {
if (typeof data[k] !== "undefined" && k !== "object") {
toCreate[k] = data[k]
}
}
@@ -453,9 +419,9 @@ class CartService extends BaseService {
/**
* Removes a line item from the cart.
* @param {string} cartId - the id of the cart that we will remove from
* @param {LineItem} lineItemId - the line item to remove.
* @return {Promise} the result of the update operation
* @param cartId - the id of the cart that we will remove from
* @param lineItemId - the line item to remove.
* @return the result of the update operation
*/
async removeLineItem(cartId: string, lineItemId: string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -505,9 +471,9 @@ class CartService extends BaseService {
* Checks if a given line item has a shipping method that can fulfill it.
* Returns true if all products in the cart can be fulfilled with the current
* shipping methods.
* @param {ShippingMethod[]} shippingMethods - the set of shipping methods to check from
* @param {LineItem} lineItem - the line item
* @return {boolean}
* @param shippingMethods - the set of shipping methods to check from
* @param lineItem - the line item
* @return boolean representing wheter shipping method is validated
*/
validateLineItemShipping_(
shippingMethods: ShippingMethod[],
@@ -535,9 +501,9 @@ class CartService extends BaseService {
/**
* Adds a line item to the cart.
* @param {string} cartId - the id of the cart that we will add to
* @param {LineItem} lineItem - the line item to add.
* @return {Promise} the result of the update operation
* @param cartId - the id of the cart that we will add to
* @param lineItem - the line item to add.
* @return the result of the update operation
*/
async addLineItem(cartId: string, lineItem: LineItem): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -613,11 +579,10 @@ class CartService extends BaseService {
/**
* Updates a cart's existing line item.
* @param {string} cartId - the id of the cart to update
* @param {string} lineItemId - the id of the line item to update.
* @param {LineItemUpdate} lineItemUpdate - the line item to update. Must
* include an id field.
* @return {Promise} the result of the update operation
* @param cartId - the id of the cart to update
* @param lineItemId - the id of the line item to update.
* @param lineItemUpdate - the line item to update. Must include an id field.
* @return the result of the update operation
*/
async updateLineItem(
cartId: string,
@@ -669,8 +634,9 @@ class CartService extends BaseService {
* shipping discount
* If a free shipping is present, we set shipping methods price to 0
* if a free shipping was present, we set shipping methods to original amount
* @param {Cart} cart - the the cart to adjust free shipping for
* @param {boolean} shouldAdd - flag to indicate, if we should add or remove
* @param cart - the the cart to adjust free shipping for
* @param shouldAdd - flag to indicate, if we should add or remove
* @return void
*/
async adjustFreeShipping_(cart: Cart, shouldAdd: boolean): Promise<void> {
if (cart.shipping_methods?.length) {
@@ -851,9 +817,9 @@ class CartService extends BaseService {
/**
* Sets the customer id of a cart
* @param {Cart} cart - the cart to add email to
* @param {string} customerId - the customer to add to cart
* @return {Promise} the result of the update operation
* @param cart - the cart to add email to
* @param customerId - the customer to add to cart
* @return the result of the update operation
*/
async updateCustomerId_(cart: Cart, customerId: string): Promise<void> {
const customer = await this.customerService_
@@ -867,8 +833,8 @@ class CartService extends BaseService {
/**
* Creates or fetches a user based on an email.
* @param {string} email - the email to use
* @return {Promise} the resultign customer object
* @param email - the email to use
* @return the resultign customer object
*/
async createOrFetchUserFromEmail_(email: string): Promise<Customer> {
const schema = Validator.string().email().required()
@@ -896,12 +862,10 @@ class CartService extends BaseService {
/**
* Updates the cart's billing address.
* @param {Cart} cart - the cart to update
* @param {Address | string} addressOrId - the value to set the billing
* address to
* @param {AddressRepository} addrRepo - the repository to use for address
* updates
* @return {Promise} the result of the update operation
* @param cart - the cart to update
* @param addressOrId - the value to set the billing address to
* @param addrRepo - the repository to use for address updates
* @return the result of the update operation
*/
async updateBillingAddress_(
cart: Cart,
@@ -941,12 +905,10 @@ class CartService extends BaseService {
/**
* Updates the cart's shipping address.
* @param {Cart} cart - the cart to update
* @param {Address | string} addressOrId - the value to set the shipping
* address to
* @param {AddressRepository} addrRepo - the repository to use for address
* updates
* @return {Promise} the result of the update operation
* @param cart - the cart to update
* @param addressOrId - the value to set the shipping address to
* @param addrRepo - the repository to use for address updates
* @return the result of the update operation
*/
async updateShippingAddress_(
cart: Cart,
@@ -1030,9 +992,9 @@ class CartService extends BaseService {
* If discount besides free shipping is already applied, this
* will be overwritten
* Throws if discount regions does not include the cart region
* @param {Cart} cart - the cart to update
* @param {string} discountCode - the discount code
* @return {Promise} the result of the update operation
* @param cart - the cart to update
* @param discountCode - the discount code
* @return the result of the update operation
*/
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
const discount = await this.discountService_.retrieveByCode(discountCode, [
@@ -1129,9 +1091,9 @@ class CartService extends BaseService {
/**
* Removes a discount based on a discount code.
* @param {string} cartId - the id of the cart to remove from
* @param {string} discountCode - the discount code to remove
* @return {Promise<Cart>} the resulting cart
* @param cartId - the id of the cart to remove from
* @param discountCode - the discount code to remove
* @return the resulting cart
*/
async removeDiscount(cartId: string, discountCode: string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -1167,19 +1129,11 @@ class CartService extends BaseService {
})
}
/**
* A payment method represents a way for the customer to pay. The payment
* method will typically come from one of the payment sessions.
* @typedef {object} PaymentMethod
* @property {string} provider_id - the identifier of the payment method's
* provider
* @property {object} data - the data associated with the payment method
*/
/**
* Updates the currently selected payment session.
* @param {string} cartId - the id of the cart to update the payment session for
* @param {object} update - the data to update the payment session with
* @param cartId - the id of the cart to update the payment session for
* @param update - the data to update the payment session with
* @return the resulting cart
*/
async updatePaymentSession(cartId: string, update: object): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -1209,11 +1163,11 @@ class CartService extends BaseService {
* a payment object, that we will use to update our cart payment with.
* Additionally, if the payment does not require more or fails, we will
* set the payment on the cart.
* @param {string} cartId - the id of the cart to authorize payment for
* @param {object} context - object containing whatever is relevant for
* @param cartId - the id of the cart to authorize payment for
* @param context - object containing whatever is relevant for
* authorizing the payment with the payment provider. As an example,
* this could be IP address or similar for fraud handling.
* @return {Promise<Cart>} the resulting cart
* @return the resulting cart
*/
async authorizePayment(cartId: string, context: Record<string, any> = {}): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -1265,9 +1219,9 @@ class CartService extends BaseService {
/**
* Sets a payment method for a cart.
* @param {string} cartId - the id of the cart to add payment method to
* @param {string} providerId - the id of the provider to be set to the cart
* @return {Promise} result of update operation
* @param cartId - the id of the cart to add payment method to
* @param providerId - the id of the provider to be set to the cart
* @return result of update operation
*/
async setPaymentSession(cartId: string, providerId: string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -1334,9 +1288,8 @@ class CartService extends BaseService {
* provider. Additional calls will ensure that payment sessions have correct
* amounts, currencies, etc. as well as make sure to filter payment sessions
* that are not available for the cart's region.
* @param {Cart | string} cartOrCartId - the id of the cart to set payment
* session for
* @return {Promise} the result of the update operation.
* @param cartOrCartId - the id of the cart to set payment session for
* @return the result of the update operation.
*/
async setPaymentSessions(cartOrCartId: Cart | string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
@@ -1345,36 +1298,42 @@ class CartService extends BaseService {
const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
const cart = await this.retrieve(cartId, {
select: [
"gift_card_total",
"subtotal",
"tax_total",
"shipping_total",
"discount_total",
"total",
],
relations: [
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"gift_cards",
"billing_address",
"shipping_address",
"region",
"region.payment_providers",
"payment_sessions",
"customer",
],
})
const cart = await this.retrieve(
cartId,
{
select: [
"total",
"subtotal",
"tax_total",
"discount_total",
"shipping_total",
"gift_card_total",
],
relations: [
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"gift_cards",
"shipping_methods",
"billing_address",
"shipping_address",
"region",
"region.tax_rates",
"region.payment_providers",
"payment_sessions",
"customer",
],
},
{ force_taxes: true }
)
const region = cart.region
const { total, region } = cart
if (typeof cart.total === "undefined") {
if (typeof total === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"cart.total should be defined"
"cart.total must be defined"
)
}
@@ -1383,7 +1342,7 @@ class CartService extends BaseService {
if (cart.payment_sessions && cart.payment_sessions.length) {
for (const session of cart.payment_sessions) {
if (
cart.total <= 0 ||
total <= 0 ||
!region.payment_providers.find(
({ id }) => id === session.provider_id
)
@@ -1400,7 +1359,7 @@ class CartService extends BaseService {
}
}
if (cart.total > 0) {
if (total > 0) {
// If only one payment session exists, we preselect it
if (region.payment_providers.length === 1 && !cart.payment_session) {
const p = region.payment_providers[0]
@@ -1426,10 +1385,10 @@ class CartService extends BaseService {
/**
* Removes a payment session from the cart.
* @param {string} cartId - the id of the cart to remove from
* @param {string} providerId - the id of the provider whoose payment session
* @param cartId - the id of the cart to remove from
* @param providerId - the id of the provider whoose payment session
* should be removed.
* @return {Promise<Cart>} the resulting cart.
* @return the resulting cart.
*/
async deletePaymentSession(
cartId: string,
@@ -1470,8 +1429,8 @@ class CartService extends BaseService {
/**
* Refreshes a payment session on a cart
* @param {string} cartId - the id of the cart to remove from
* @param {string} providerId - the id of the provider whoose payment session
* @param cartId - the id of the cart to remove from
* @param providerId - the id of the provider whoose payment session
* should be removed.
* @return {Promise<Cart>} the resulting cart.
*/
@@ -1512,10 +1471,10 @@ class CartService extends BaseService {
* Shipping Option is a possible way to ship an order. Shipping Methods may
* also have additional details in the data field such as an id for a package
* shop.
* @param {string} cartId - the id of the cart to add shipping method to
* @param {string} optionId - id of shipping option to add as valid method
* @param {Object} data - the fulmillment data for the method
* @return {Promise} the result of the update operation
* @param cartId - the id of the cart to add shipping method to
* @param optionId - id of shipping option to add as valid method
* @param data - the fulmillment data for the method
* @return the result of the update operation
*/
async addShippingMethod(
cartId: string,
@@ -1610,9 +1569,9 @@ class CartService extends BaseService {
/**
* Finds the cart's custom shipping options based on the passed option id.
* throws if custom options is not empty and no shipping option corresponds to optionId
* @param {Object} cartCustomShippingOptions - the cart's custom shipping options
* @param {string} optionId - id of the normal or custom shipping option to find in the cartCustomShippingOptions
* @return {CustomShippingOption | undefined}
* @param cartCustomShippingOptions - the cart's custom shipping options
* @param optionId - id of the normal or custom shipping option to find in the cartCustomShippingOptions
* @return custom shipping option
*/
findCustomShippingOption(
cartCustomShippingOptions: CustomShippingOption[],
@@ -1635,10 +1594,10 @@ class CartService extends BaseService {
/**
* Set's the region of a cart.
* @param {Cart} cart - the cart to set region on
* @param {string} regionId - the id of the region to set the region to
* @param {string} countryCode - the country code to set the country to
* @return {Promise} the result of the update operation
* @param cart - the cart to set region on
* @param regionId - the id of the region to set the region to
* @param countryCode - the country code to set the country to
* @return the result of the update operation
*/
async setRegion_(
cart: Cart,
@@ -1793,9 +1752,8 @@ class CartService extends BaseService {
/**
* Deletes a cart from the database. Completed carts cannot be deleted.
* @param {string} cartId - the id of the cart to delete
* @return {Promise<string>} the deleted cart or undefined if the cart was
* not found.
* @param cartId - the id of the cart to delete
* @return the deleted cart or undefined if the cart was not found.
*/
async delete(cartId: string): Promise<string> {
return await this.atomicPhase_(async (manager: EntityManager) => {
@@ -1832,10 +1790,10 @@ class CartService extends BaseService {
* Dedicated method to set metadata for a cart.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} cartId - the cart to apply metadata to.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise} resolves to the updated result.
* @param cartId - the cart to apply metadata to.
* @param key - key for metadata field
* @param value - value for metadata field.
* @return resolves to the updated result.
*/
async setMetadata(
cartId: string,
@@ -1869,11 +1827,35 @@ class CartService extends BaseService {
})
}
async createTaxLines(id: string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
const cart = await this.retrieve(id, {
relations: [
"items",
"gift_cards",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"region",
"region.tax_rates",
],
})
const calculationContext = this.totalsService_.getCalculationContext(cart)
await this.taxProviderService_
.withTransaction(manager)
.createTaxLines(cart, calculationContext)
return cart
})
}
/**
* Dedicated method to delete metadata for a cart.
* @param {string} cartId - the cart to delete metadata from.
* @param {string} key - key for metadata field
* @return {Promise} resolves to the updated result.
* @param cartId - the cart to delete metadata from.
* @param key - key for metadata field
* @return resolves to the updated result.
*/
async deleteMetadata(cartId: string, key: string): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
+59 -63
View File
@@ -13,63 +13,40 @@ class ClaimService extends BaseService {
constructor({
manager,
claimRepository,
addressRepository,
claimItemService,
claimRepository,
eventBusService,
fulfillmentProviderService,
fulfillmentService,
inventoryService,
lineItemService,
totalsService,
paymentProviderService,
regionService,
returnService,
shippingOptionService,
claimItemService,
regionService,
inventoryService,
eventBusService,
taxProviderService,
totalsService,
}) {
super()
/** @private @constant {EntityManager} */
this.manager_ = manager
/** @private @constant {ClaimRepository} */
this.claimRepository_ = claimRepository
/** @private @constant {AddressRepository} */
this.addressRepo_ = addressRepository
/** @private @constant {FulfillmentProviderService} */
this.fulfillmentProviderService_ = fulfillmentProviderService
/** @private @constant {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @constant {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @constant {RegionService} */
this.regionService_ = regionService
/** @private @constant {ReturnService} */
this.returnService_ = returnService
/** @private @constant {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
/** @private @constant {ClaimItemService} */
this.claimItemService_ = claimItemService
/** @private @constant {TotalsService} */
this.totalsService_ = totalsService
/** @private @constant {InventoryService} */
this.inventoryService_ = inventoryService
/** @private @constant {EventBus} */
this.claimRepository_ = claimRepository
this.eventBus_ = eventBusService
/** @private @constant {ShippingOptionService} */
this.fulfillmentProviderService_ = fulfillmentProviderService
this.fulfillmentService_ = fulfillmentService
this.inventoryService_ = inventoryService
this.lineItemService_ = lineItemService
this.paymentProviderService_ = paymentProviderService
this.regionService_ = regionService
this.returnService_ = returnService
this.shippingOptionService_ = shippingOptionService
this.taxProviderService_ = taxProviderService
this.totalsService_ = totalsService
}
withTransaction(manager) {
@@ -79,19 +56,20 @@ class ClaimService extends BaseService {
const cloned = new ClaimService({
manager,
claimRepository: this.claimRepository_,
addressRepository: this.addressRepo_,
claimItemService: this.claimItemService_,
claimRepository: this.claimRepository_,
eventBusService: this.eventBus_,
fulfillmentProviderService: this.fulfillmentProviderService_,
fulfillmentService: this.fulfillmentService_,
paymentProviderService: this.paymentProviderService_,
inventoryService: this.inventoryService_,
lineItemService: this.lineItemService_,
paymentProviderService: this.paymentProviderService_,
regionService: this.regionService_,
returnService: this.returnService_,
claimItemService: this.claimItemService_,
eventBusService: this.eventBus_,
totalsService: this.totalsService_,
inventoryService: this.inventoryService_,
shippingOptionService: this.shippingOptionService_,
totalsService: this.totalsService_,
taxProviderService: this.taxProviderService_,
})
cloned.transactionManager_ = manager
@@ -198,7 +176,7 @@ class ClaimService extends BaseService {
for (const item of claim_items) {
const line = await this.lineItemService_.retrieve(item.item_id, {
relations: ["order", "swap", "claim_order"],
relations: ["order", "swap", "claim_order", "tax_lines"],
})
if (
@@ -261,25 +239,29 @@ class ClaimService extends BaseService {
toRefund = await this.totalsService_.getRefundTotal(order, lines)
}
for (const item of additional_items) {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
}
const newItems = await Promise.all(
additional_items.map((i) =>
this.lineItemService_
let newItems = []
if (typeof additional_items !== "undefined") {
for (const item of additional_items) {
await this.inventoryService_
.withTransaction(manager)
.generate(i.variant_id, order.region_id, i.quantity)
)
)
.confirmInventory(item.variant_id, item.quantity)
}
for (const newItem of newItems) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(newItem.variant_id, -newItem.quantity)
newItems = await Promise.all(
additional_items.map((i) =>
this.lineItemService_
.withTransaction(manager)
.generate(i.variant_id, order.region_id, i.quantity)
)
)
for (const newItem of newItems) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(newItem.variant_id, -newItem.quantity)
}
}
const evaluatedNoNotification =
no_notification !== undefined ? no_notification : order.no_notification
@@ -296,6 +278,18 @@ class ClaimService extends BaseService {
const result = await claimRepo.save(created)
if (result.additional_items && result.additional_items.length) {
const calcContext = this.totalsService_.getCalculationContext(order)
const lineItems = await this.lineItemService_
.withTransaction(manager)
.list({
id: result.additional_items.map((i) => i.id),
})
await this.taxProviderService_
.withTransaction(manager)
.createTaxLines(lineItems, calcContext)
}
if (shipping_methods) {
for (const method of shipping_methods) {
if (method.id) {
@@ -366,7 +360,9 @@ class ClaimService extends BaseService {
const claim = await this.retrieve(id, {
relations: [
"additional_items",
"additional_items.tax_lines",
"shipping_methods",
"shipping_methods.tax_lines",
"shipping_address",
"order",
"order.billing_address",
+1 -1
View File
@@ -299,7 +299,7 @@ class FulfillmentService extends BaseService {
const now = new Date()
fulfillment.shipped_at = now
fulfillment.tracking_links = trackingLinks.map((tl) =>
fulfillment.tracking_links = (trackingLinks || []).map((tl) =>
trackingLinkRepo.create(tl)
)
+3
View File
@@ -36,3 +36,6 @@ export { default as SystemPaymentProviderService } from "./system-payment-provid
export { default as TotalsService } from "./totals"
export { default as TransactionService } from "./transaction"
export { default as UserService } from "./user"
export { default as TaxRateService } from "./tax-rate"
export { default as TaxProviderService } from "./tax-provider"
export { default as ProductTypeService } from "./product-type"
+48
View File
@@ -9,6 +9,7 @@ class LineItemService extends BaseService {
constructor({
manager,
lineItemRepository,
lineItemTaxLineRepository,
productVariantService,
productService,
regionService,
@@ -22,6 +23,9 @@ class LineItemService extends BaseService {
/** @private @const {LineItemRepository} */
this.lineItemRepository_ = lineItemRepository
/** @private @const {typeof LineItemTaxLineRepository} */
this.itemTaxLineRepo_ = lineItemTaxLineRepository
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
@@ -43,6 +47,7 @@ class LineItemService extends BaseService {
const cloned = new LineItemService({
manager: transactionManager,
lineItemRepository: this.lineItemRepository_,
lineItemTaxLineRepository: this.itemTaxLineRepo_,
productVariantService: this.productVariantService_,
productService: this.productService_,
regionService: this.regionService_,
@@ -89,6 +94,49 @@ class LineItemService extends BaseService {
return lineItem
}
/**
* Creates return line items for a given cart based on the return items in a
* return.
* @param {string} returnId - the id to generate return items from.
* @param {string} cartId - the cart to assign the return line items to.
* @return {Promise<LineItem[]>} the created line items
*/
async createReturnLines(returnId, cartId) {
const lineItemRepo = this.manager_.getCustomRepository(
this.lineItemRepository_
)
const itemTaxLineRepo = this.manager_.getCustomRepository(
this.itemTaxLineRepo_
)
const items = await lineItemRepo.findByReturn(returnId)
const toCreate = items.map((i) =>
lineItemRepo.create({
cart_id: cartId,
thumbnail: i.thumbnail,
is_return: true,
title: i.title,
variant_id: i.variant_id,
unit_price: -1 * i.unit_price,
quantity: i.return_item.quantity,
allow_discounts: i.allow_discounts,
tax_lines: i.tax_lines.map((tl) => {
return itemTaxLineRepo.create({
name: tl.name,
code: tl.code,
rate: tl.rate,
metadata: tl.metadata,
})
}),
metadata: i.metadata,
})
)
return await lineItemRepo.save(toCreate)
}
async generate(variantId, regionId, quantity, config = {}) {
return this.atomicPhase_(async (manager) => {
const variant = await this.productVariantService_
+97 -78
View File
@@ -201,7 +201,9 @@ class OrderService extends BaseService {
const raw = await orderRepo.find(query)
return raw.map((r) => this.decorateTotals_(r, totalsToSelect))
return await Promise.all(
raw.map(async (r) => await this.decorateTotals_(r, totalsToSelect))
)
}
async listAndCount(
@@ -258,7 +260,9 @@ class OrderService extends BaseService {
const raw = await orderRepo.findWithRelations(rels, query)
const count = await orderRepo.count(query)
const orders = raw.map((r) => this.decorateTotals_(r, totalsToSelect))
const orders = await Promise.all(
raw.map(async (r) => await this.decorateTotals_(r, totalsToSelect))
)
return [orders, count]
}
@@ -293,10 +297,13 @@ class OrderService extends BaseService {
if (totalsToSelect.length > 0) {
const relationSet = new Set(relations)
relationSet.add("items")
relationSet.add("items.tax_lines")
relationSet.add("swaps")
relationSet.add("swaps.additional_items")
relationSet.add("swaps.additional_items.tax_lines")
relationSet.add("claims")
relationSet.add("claims.additional_items")
relationSet.add("claims.additional_items.tax_lines")
relationSet.add("discounts")
relationSet.add("discounts.rule")
relationSet.add("discounts.rule.valid_for")
@@ -304,15 +311,21 @@ class OrderService extends BaseService {
relationSet.add("gift_card_transactions")
relationSet.add("refunds")
relationSet.add("shipping_methods")
relationSet.add("shipping_methods.tax_lines")
relationSet.add("region")
relations = [...relationSet]
select = select.filter((v) => !totalFields.includes(v))
}
const toSelect = [...select]
if (toSelect.length > 0 && toSelect.indexOf("tax_rate") === -1) {
toSelect.push("tax_rate")
}
return {
relations,
select,
select: toSelect,
totalsToSelect,
}
}
@@ -352,8 +365,7 @@ class OrderService extends BaseService {
)
}
const order = this.decorateTotals_(raw, totalsToSelect)
return order
return await this.decorateTotals_(raw, totalsToSelect)
}
/**
@@ -389,7 +401,7 @@ class OrderService extends BaseService {
)
}
const order = this.decorateTotals_(raw, totalsToSelect)
const order = await this.decorateTotals_(raw, totalsToSelect)
return order
}
@@ -427,7 +439,7 @@ class OrderService extends BaseService {
)
}
const order = this.decorateTotals_(raw, totalsToSelect)
const order = await this.decorateTotals_(raw, totalsToSelect)
return order
}
@@ -574,7 +586,6 @@ class OrderService extends BaseService {
email: cart.email,
customer_id: cart.customer_id,
cart_id: cart.id,
tax_rate: region.tax_rate,
currency_code: region.currency_code,
metadata: cart.metadata || {},
}
@@ -1022,6 +1033,7 @@ class OrderService extends BaseService {
order.status = "canceled"
order.fulfillment_status = "canceled"
order.payment_status = "canceled"
order.canceled_at = new Date()
const orderRepo = manager.getCustomRepository(this.orderRepository_)
const result = await orderRepo.save(order)
@@ -1398,79 +1410,86 @@ class OrderService extends BaseService {
})
}
decorateTotals_(order, totalsFields = []) {
if (totalsFields.includes("shipping_total")) {
order.shipping_total = this.totalsService_.getShippingTotal(order)
}
if (totalsFields.includes("gift_card_total")) {
order.gift_card_total = this.totalsService_.getGiftCardTotal(order)
}
if (totalsFields.includes("discount_total")) {
order.discount_total = this.totalsService_.getDiscountTotal(order)
}
if (totalsFields.includes("tax_total")) {
order.tax_total = this.totalsService_.getTaxTotal(order)
}
if (totalsFields.includes("subtotal")) {
order.subtotal = this.totalsService_.getSubtotal(order)
}
if (totalsFields.includes("total")) {
order.total = this.totalsService_.getTotal(order)
}
if (totalsFields.includes("refunded_total")) {
order.refunded_total = this.totalsService_.getRefundedTotal(order)
}
if (totalsFields.includes("paid_total")) {
order.paid_total = this.totalsService_.getPaidTotal(order)
}
if (totalsFields.includes("refundable_amount")) {
const paid_total = this.totalsService_.getPaidTotal(order)
const refunded_total = this.totalsService_.getRefundedTotal(order)
order.refundable_amount = paid_total - refunded_total
}
if (totalsFields.includes("items.refundable")) {
order.items = order.items.map((i) => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
}
if (
totalsFields.includes("swaps.additional_items.refundable") &&
order.swaps &&
order.swaps.length
) {
for (const s of order.swaps) {
s.additional_items = s.additional_items.map((i) => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
async decorateTotals_(order, totalsFields = []) {
for (const totalField of totalsFields) {
switch (totalField) {
case "shipping_total": {
order.shipping_total = this.totalsService_.getShippingTotal(order)
break
}
case "gift_card_total": {
order.gift_card_total = this.totalsService_.getGiftCardTotal(order)
break
}
case "discount_total": {
order.discount_total = this.totalsService_.getDiscountTotal(order)
break
}
case "tax_total": {
order.tax_total = await this.totalsService_.getTaxTotal(order)
break
}
case "subtotal": {
order.subtotal = this.totalsService_.getSubtotal(order)
break
}
case "total": {
order.total = await this.totalsService_.getTotal(order)
break
}
case "refunded_total": {
order.refunded_total = this.totalsService_.getRefundedTotal(order)
break
}
case "paid_total": {
order.paid_total = this.totalsService_.getPaidTotal(order)
break
}
case "refundable_amount": {
const paid_total = this.totalsService_.getPaidTotal(order)
const refunded_total = this.totalsService_.getRefundedTotal(order)
order.refundable_amount = paid_total - refunded_total
break
}
case "items.refundable": {
order.items = order.items.map((i) => ({
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
break
}
case "swaps.additional_items.refundable": {
for (const s of order.swaps) {
s.additional_items = s.additional_items.map((i) => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
}
break
}
case "claims.additional_items.refundable": {
for (const c of order.claims) {
c.additional_items = c.additional_items.map((i) => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
}
break
}
default: {
break
}
}
}
if (
totalsFields.includes("claims.additional_items.refundable") &&
order.claims &&
order.claims.length
) {
for (const c of order.claims) {
c.additional_items = c.additional_items.map((i) => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
}
}
return order
}
@@ -0,0 +1,60 @@
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { ProductTaxRate } from "../models/product-tax-rate"
import { ProductTaxRateRepository } from "../repositories/product-tax-rate"
import { FindConfig } from "../types/common"
import { FilterableProductTaxRateProps } from "../types/product-tax-rate"
/**
* Provides layer to manipulate product variants.
* @extends BaseService
*/
class ProductTaxRateService extends BaseService {
private manager_: EntityManager
private productTaxRateRepository_: typeof ProductTaxRateRepository
constructor({ manager, productTaxRateRepository }) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductVariantModel} */
this.productTaxRateRepository_ = productTaxRateRepository
}
withTransaction(transactionManager: EntityManager): ProductTaxRateService {
if (!transactionManager) {
return this
}
const cloned = new ProductTaxRateService({
manager: transactionManager,
productTaxRateRepository: this.productTaxRateRepository_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* @param {FilterableProductVariantProps} selector - the query object for find
* @param {FindConfig<ProductVariant>} config - query config object for variant retrieval
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableProductTaxRateProps,
config: FindConfig<ProductTaxRate> = { relations: [], skip: 0, take: 20 }
): Promise<ProductTaxRate[]> {
const pTaxRateRepo = this.manager_.getCustomRepository(
this.productTaxRateRepository_
)
const query = this.buildQuery_(selector, config)
return await pTaxRateRepo.find(query)
}
}
export default ProductTaxRateService
@@ -0,0 +1,99 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { FindConfig } from "../types/common"
import { FilterableProductTypeProps } from "../types/product"
import { ProductType } from "../models/product-type"
import { ProductTypeRepository } from "../repositories/product-type"
/**
* Provides layer to manipulate products.
* @extends BaseService
*/
class ProductTypeService extends BaseService {
private manager_: EntityManager
private typeRepository_: typeof ProductTypeRepository
constructor({ manager, productTypeRepository }) {
super()
this.manager_ = manager
this.typeRepository_ = productTypeRepository
}
withTransaction(transactionManager: EntityManager): ProductTypeService {
if (!transactionManager) {
return this
}
const cloned = new ProductTypeService({
manager: transactionManager,
productTypeRepository: this.typeRepository_,
})
cloned.transactionManager_ = transactionManager
cloned.manager_ = transactionManager
return cloned
}
/**
* Gets a product by id.
* Throws in case of DB Error and if product was not found.
* @param id - id of the product to get.
* @param config - object that defines what should be included in the
* query response
* @return {Promise<Product>} the result of the find one operation.
*/
async retrieve(
id: string,
config: FindConfig<ProductType> = {}
): Promise<ProductType> {
const typeRepo = this.manager_.getCustomRepository(this.typeRepository_)
const query = this.buildQuery_({ id }, config)
const type = await typeRepo.findOne(query)
if (!type) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${id} was not found`
)
}
return type
}
/**
* Lists product types
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableProductTypeProps = {},
config: FindConfig<ProductType> = { skip: 0, take: 20 }
): Promise<ProductType[]> {
const typeRepo = this.manager_.getCustomRepository(this.typeRepository_)
const query = this.buildQuery_(selector, config)
return await typeRepo.find(query)
}
/**
* Lists product tags and adds count.
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: FilterableProductTypeProps = {},
config: FindConfig<ProductType> = { skip: 0, take: 20 }
): Promise<[ProductType[], number]> {
const typeRepo = this.manager_.getCustomRepository(this.typeRepository_)
const query = this.buildQuery_(selector, config)
return await typeRepo.findAndCount(query)
}
}
export default ProductTypeService
+20
View File
@@ -22,6 +22,7 @@ class RegionService extends BaseService {
currencyRepository,
paymentProviderRepository,
fulfillmentProviderRepository,
taxProviderRepository,
paymentProviderService,
fulfillmentProviderService,
}) {
@@ -54,6 +55,9 @@ class RegionService extends BaseService {
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {typeof TaxProviderService} */
this.taxProviderRepository_ = taxProviderRepository
/** @private @const {FulfillmentProviderService} */
this.fulfillmentProviderService_ = fulfillmentProviderService
}
@@ -72,6 +76,7 @@ class RegionService extends BaseService {
eventBusService: this.eventBus_,
paymentProviderRepository: this.paymentProviderRepository_,
paymentProviderService: this.paymentProviderService_,
taxProviderService: this.taxProviderService_,
fulfillmentProviderRepository: this.fulfillmentProviderRepository_,
fulfillmentProviderService: this.fulfillmentProviderService_,
})
@@ -211,6 +216,9 @@ class RegionService extends BaseService {
const fpRepository = this.manager_.getCustomRepository(
this.fulfillmentProviderRepository_
)
const tpRepository = this.manager_.getCustomRepository(
this.taxProviderRepository_
)
if (region.tax_rate) {
this.validateTaxRate_(region.tax_rate)
@@ -226,6 +234,18 @@ class RegionService extends BaseService {
})
}
if (region.tax_provider_id) {
const tp = await tpRepository.findOne({
where: { id: region.tax_provider_id },
})
if (!tp) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Tax provider not found"
)
}
}
if (region.payment_providers) {
region.payment_providers = await Promise.all(
region.payment_providers.map(async (pId) => {
+40 -21
View File
@@ -14,6 +14,7 @@ class ReturnService extends BaseService {
returnItemRepository,
shippingOptionService,
returnReasonService,
taxProviderService,
fulfillmentProviderService,
inventoryService,
orderService,
@@ -35,6 +36,8 @@ class ReturnService extends BaseService {
/** @private @const {ReturnItemRepository} */
this.lineItemService_ = lineItemService
this.taxProviderService_ = taxProviderService
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
@@ -59,6 +62,7 @@ class ReturnService extends BaseService {
totalsService: this.totalsService_,
lineItemService: this.lineItemService_,
returnRepository: this.returnRepository_,
taxProviderService: this.taxProviderService_,
returnItemRepository: this.returnItemRepository_,
shippingOptionService: this.shippingOptionService_,
fulfillmentProviderService: this.fulfillmentProviderService_,
@@ -335,9 +339,14 @@ class ReturnService extends BaseService {
relations: [
"swaps",
"swaps.additional_items",
"swaps.additional_items.tax_lines",
"claims",
"claims.additional_items",
"claims.additional_items.tax_lines",
"items",
"items.tax_lines",
"region",
"region.tax_rates",
],
})
@@ -347,18 +356,10 @@ class ReturnService extends BaseService {
this.validateReturnLineItem_
)
if (data.shipping_method) {
if (typeof data.shipping_method.price === "undefined") {
const opt = await this.shippingOptionService_.retrieve(
data.shipping_method.option_id
)
data.shipping_method.price = opt.amount
}
}
let toRefund = data.refund_amount
if (typeof toRefund !== "undefined") {
// refundable from order
// Merchant wants to do a custom refund amount; we check if amount is
// refundable
const refundable = order.refundable_amount
if (toRefund > refundable) {
@@ -368,14 +369,8 @@ class ReturnService extends BaseService {
)
}
} else {
// Merchant hasn't specified refund amount so we calculate it
toRefund = await this.totalsService_.getRefundTotal(order, returnLines)
if (data.shipping_method) {
toRefund = Math.max(
0,
toRefund - data.shipping_method.price * (1 + order.tax_rate / 100)
)
}
}
const method = data.shipping_method
@@ -416,7 +411,7 @@ class ReturnService extends BaseService {
const result = await returnRepository.save(created)
if (method) {
await this.shippingOptionService_
const shippingMethod = await this.shippingOptionService_
.withTransaction(manager)
.createShippingMethod(
method.option_id,
@@ -426,6 +421,26 @@ class ReturnService extends BaseService {
return_id: result.id,
}
)
const calculationContext =
this.totalsService_.getCalculationContext(order)
const taxLines = await this.taxProviderService_
.withTransaction(manager)
.createShippingTaxLines(shippingMethod, calculationContext)
const shippingTotal =
shippingMethod.price +
taxLines.reduce(
(acc, tl) =>
acc + Math.round(shippingMethod.price * (tl.rate / 100)),
0
)
if (typeof data.refund_amount === "undefined") {
result.refund_amount = toRefund - shippingTotal
return await returnRepository.save(result)
}
}
return result
@@ -438,6 +453,7 @@ class ReturnService extends BaseService {
relations: [
"items",
"shipping_method",
"shipping_method.tax_lines",
"shipping_method.shipping_option",
"swap",
"claim_order",
@@ -453,9 +469,12 @@ class ReturnService extends BaseService {
const returnData = { ...returnOrder }
const items = await this.lineItemService_.list({
id: returnOrder.items.map(({ item_id }) => item_id),
})
const items = await this.lineItemService_.list(
{
id: returnOrder.items.map(({ item_id }) => item_id),
},
{ relations: ["tax_lines"] }
)
returnData.items = returnOrder.items.map((item) => {
const found = items.find((i) => i.id === item.item_id)
@@ -13,7 +13,6 @@ class ShippingOptionService extends BaseService {
shippingMethodRepository,
fulfillmentProviderService,
regionService,
totalsService,
}) {
super()
@@ -34,9 +33,6 @@ class ShippingOptionService extends BaseService {
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
}
withTransaction(transactionManager) {
@@ -51,7 +47,6 @@ class ShippingOptionService extends BaseService {
shippingOptionRequirementRepository: this.requirementRepository_,
fulfillmentProviderService: this.providerService_,
regionService: this.regionService_,
totalsService: this.totalsService_,
})
cloned.transactionManager_ = transactionManager
@@ -132,6 +127,18 @@ class ShippingOptionService extends BaseService {
return optRepo.find(query)
}
/**
* @param {Object} selector - the query object for find
* @param {object} config - config object
* @return {Promise} the result of the find operation
*/
async listAndCount(selector, config = { skip: 0, take: 50 }) {
const optRepo = this.manager_.getCustomRepository(this.optionRepository_)
const query = this.buildQuery_(selector, config)
return await optRepo.findAndCount(query)
}
/**
* Gets a profile by id.
* Throws in case of DB Error and if profile was not found.
@@ -236,7 +243,7 @@ class ShippingOptionService extends BaseService {
)
let methodPrice
if ("price" in config) {
if (typeof config.price === "number") {
methodPrice = config.price
} else {
methodPrice = await this.getPrice_(option, validatedData, config.cart)
@@ -0,0 +1,61 @@
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
import { ShippingTaxRateRepository } from "../repositories/shipping-tax-rate"
import { FindConfig } from "../types/common"
import { FilterableShippingTaxRateProps } from "../types/shipping-tax-rate"
/**
* Provides layer to manipulate Shipping variants.
* @extends BaseService
*/
class ShippingTaxRateService extends BaseService {
private manager_: EntityManager
private shippingTaxRateRepository_: typeof ShippingTaxRateRepository
constructor({ manager, shippingTaxRateRepository }) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShippingVariantModel} */
this.shippingTaxRateRepository_ = shippingTaxRateRepository
}
withTransaction(transactionManager: EntityManager): ShippingTaxRateService {
if (!transactionManager) {
return this
}
const cloned = new ShippingTaxRateService({
manager: transactionManager,
shippingTaxRateRepository: this.ShippingTaxRateRepository_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Lists Shipping Tax Rates given a certain query.
* @param selector - the query object for find
* @param config - query config object for variant retrieval
* @return the result of the find operation
*/
async list(
selector: FilterableShippingTaxRateProps,
config: FindConfig<ShippingTaxRate> = { relations: [], skip: 0, take: 20 }
): Promise<ShippingTaxRate[]> {
const sTaxRateRepo = this.manager_.getCustomRepository(
this.shippingTaxRateRepository_
)
const query = this.buildQuery_(selector, config)
return await sTaxRateRepo.find(query)
}
}
export default ShippingTaxRateService
+100 -154
View File
@@ -27,6 +27,7 @@ class SwapService extends BaseService {
returnService,
lineItemService,
paymentProviderService,
shippingMethodTaxLineRepository,
shippingOptionService,
fulfillmentService,
orderService,
@@ -71,6 +72,9 @@ class SwapService extends BaseService {
/** @private @const {EventBusService} */
this.eventBus_ = eventBusService
/** @private @const {typeof ShippingMethodTaxLineRepository} */
this.shippingTaxLineRepo_ = shippingMethodTaxLineRepository
/** @private @const {CustomShippingOptionService} */
this.customShippingOptionService_ = customShippingOptionService
}
@@ -88,6 +92,7 @@ class SwapService extends BaseService {
totalsService: this.totalsService_,
returnService: this.returnService_,
lineItemService: this.lineItemService_,
shippingMethodTaxLineRepository: this.shippingTaxLineRepo_,
paymentProviderService: this.paymentProviderService_,
shippingOptionService: this.shippingOptionService_,
orderService: this.orderService_,
@@ -101,71 +106,67 @@ class SwapService extends BaseService {
return cloned
}
transformQueryForTotals_(config) {
transformQueryForCart_(config) {
let { select, relations } = config
if (!select) {
return {
...config,
totalsToSelect: [],
let cartSelects = null
let cartRelations = null
if (typeof relations !== "undefined" && relations.includes("cart")) {
const [swapRelations, cartRels] = relations.reduce(
(acc, next) => {
if (next === "cart") {
return acc
}
if (next.startsWith("cart.")) {
const [, ...rel] = next.split(".")
acc[1].push(rel.join("."))
} else {
acc[0].push(next)
}
return acc
},
[[], []]
)
relations = swapRelations
cartRelations = cartRels
let foundCartId = false
if (typeof select !== "undefined") {
const [swapSelects, cartSels] = select.reduce(
(acc, next) => {
if (next.startsWith("cart.")) {
const [, ...rel] = next.split(".")
acc[1].push(rel.join("."))
} else {
if (next === "cart_id") {
foundCartId = true
}
acc[0].push(next)
}
return acc
},
[[], []]
)
select = foundCartId ? swapSelects : [...swapSelects, "cart_id"]
cartSelects = cartSels
}
}
const totalFields = [
"cart.subtotal",
"cart.tax_total",
"cart.shipping_total",
"cart.discount_total",
"cart.gift_card_total",
"cart.total",
]
const totalsToSelect = select.filter((v) => totalFields.includes(v))
if (totalsToSelect.length > 0) {
const relationSet = new Set(relations)
relationSet.add("cart")
relationSet.add("cart.items")
relationSet.add("cart.gift_cards")
relationSet.add("cart.discounts")
relationSet.add("cart.discounts.rule")
relationSet.add("cart.discounts.rule.valid_for")
relationSet.add("cart.shipping_methods")
relationSet.add("cart.region")
relations = [...relationSet]
select = select.filter((v) => !totalFields.includes(v))
}
return {
...config,
relations,
select,
totalsToSelect,
cartSelects,
cartRelations,
}
}
async decorateTotals_(cart, totalsFields = []) {
if (totalsFields.includes("cart.shipping_total")) {
cart.shipping_total = await this.totalsService_.getShippingTotal(cart)
}
if (totalsFields.includes("cart.discount_total")) {
cart.discount_total = await this.totalsService_.getDiscountTotal(cart)
}
if (totalsFields.includes("cart.tax_total")) {
cart.tax_total = await this.totalsService_.getTaxTotal(cart)
}
if (totalsFields.includes("cart.gift_card_total")) {
cart.gift_card_total = await this.totalsService_.getGiftCardTotal(cart)
}
if (totalsFields.includes("cart.subtotal")) {
cart.subtotal = await this.totalsService_.getSubtotal(cart)
}
if (totalsFields.includes("cart.total")) {
cart.total = await this.totalsService_.getTotal(cart)
}
return cart
}
/**
* Retrieves a swap with the given id.
* @param {string} id - the id of the swap to retrieve
@@ -177,8 +178,8 @@ class SwapService extends BaseService {
const validatedId = this.validateId_(id)
const { totalsToSelect, ...newConfig } =
this.transformQueryForTotals_(config)
const { cartSelects, cartRelations, ...newConfig } =
this.transformQueryForCart_(config)
const query = this.buildQuery_({ id: validatedId }, newConfig)
@@ -190,8 +191,11 @@ class SwapService extends BaseService {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found")
}
if (rels && rels.includes("cart")) {
const cart = await this.decorateTotals_(swap.cart, totalsToSelect)
if (cartRelations || cartSelects) {
const cart = await this.cartService_.retrieve(swap.cart_id, {
select: cartSelects,
relations: cartRelations,
})
swap.cart = cart
}
@@ -548,6 +552,7 @@ class SwapService extends BaseService {
"return_order",
"return_order.items",
"return_order.shipping_method",
"return_order.shipping_method.tax_lines",
],
})
@@ -602,9 +607,12 @@ class SwapService extends BaseService {
})
}
// If the swap has a return shipping method the price has to be added to the
// cart.
// If the swap has a return shipping method the price has to be added to
// the cart.
if (swap.return_order && swap.return_order.shipping_method) {
const shippingTaxLineRepo = this.manager_.getCustomRepository(
this.shippingTaxLineRepo_
)
await this.lineItemService_.withTransaction(manager).create({
cart_id: cart.id,
title: "Return shipping",
@@ -612,51 +620,26 @@ class SwapService extends BaseService {
has_shipping: true,
allow_discounts: false,
unit_price: swap.return_order.shipping_method.price,
metadata: {
is_return_line: true,
},
is_return: true,
tax_lines: swap.return_order.shipping_method.tax_lines.map((tl) => {
return shippingTaxLineRepo.create({
name: tl.name,
code: tl.code,
rate: tl.rate,
metadata: tl.metadata,
})
}),
})
}
for (const r of swap.return_order.items) {
let allItems = [...order.items]
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
allItems = [...allItems, ...s.additional_items]
}
}
if (order.claims && order.claims.length) {
for (const c of order.claims) {
allItems = [...allItems, ...c.additional_items]
}
}
const lineItem = allItems.find((i) => i.id === r.item_id)
const toCreate = {
cart_id: cart.id,
thumbnail: lineItem.thumbnail,
title: lineItem.title,
variant_id: lineItem.variant_id,
unit_price: -1 * lineItem.unit_price,
quantity: r.quantity,
allow_discounts: lineItem.allow_discounts,
metadata: {
...lineItem.metadata,
is_return_line: true,
},
}
await this.lineItemService_.withTransaction(manager).create(toCreate)
}
await this.lineItemService_
.withTransaction(manager)
.createReturnLines(swap.return_order.id, cart.id)
swap.cart_id = cart.id
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
return result
return await swapRepo.save(swap)
})
}
@@ -664,18 +647,16 @@ class SwapService extends BaseService {
*@param {string} swapId - The id of the swap
*/
async registerCartCompletion(swapId) {
return this.atomicPhase_(async (manager) => {
return await this.atomicPhase_(async (manager) => {
const swap = await this.retrieve(swapId, {
relations: [
"cart",
"cart.region",
"cart.shipping_methods",
"cart.shipping_address",
"cart.items",
"cart.discounts",
"cart.discounts.rule",
"cart.payment",
"cart.gift_cards",
select: [
"id",
"order_id",
"no_notification",
"allow_backorder",
"canceled_at",
"confirmed_at",
"cart_id",
],
})
@@ -691,10 +672,14 @@ class SwapService extends BaseService {
)
}
const cart = swap.cart
const cart = await this.cartService_.retrieve(swap.cart_id, {
select: ["total"],
relations: ["payment", "shipping_methods", "items"],
})
const { payment } = cart
const items = swap.cart.items
const items = cart.items
if (!swap.allow_backorder) {
for (const item of items) {
@@ -716,7 +701,7 @@ class SwapService extends BaseService {
}
}
const total = await this.totalsService_.getTotal(cart)
const total = cart.total
if (total > 0) {
if (!payment) {
@@ -785,49 +770,6 @@ class SwapService extends BaseService {
})
}
/**
* Registers the return associated with a swap as received. If the return
* is received with mismatching return items the swap's status will be updated
* to requires_action.
* @param {string} swapId - the id of the swap to receive.
* @param {Array<ReturnItem>} returnItems - the return items that have been returned
* @return {Promise<Swap>} the resulting swap, with an updated return and
* status.
*/
async receiveReturn(swapId, returnItems) {
return this.atomicPhase_(async (manager) => {
const swap = await this.retrieve(swapId, { relations: ["return_order"] })
if (swap.canceled_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Canceled swap cannot be registered as received"
)
}
const returnId = swap.return_order && swap.return_order.id
if (!returnId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Swap has no return request"
)
}
const updatedRet = await this.returnService_
.withTransaction(manager)
.receiveReturn(returnId, returnItems, undefined, false)
if (updatedRet.status === "requires_action") {
const swapRepo = manager.getCustomRepository(this.swapRepository_)
swap.fulfillment_status = "requires_action"
const result = await swapRepo.save(swap)
return result
}
return this.retrieve(swapId)
})
}
/**
* Cancels a given swap if possible. A swap can only be canceled if all
* related returns, fulfillments, and payments have been canceled. If a swap
@@ -908,8 +850,11 @@ class SwapService extends BaseService {
"payment",
"shipping_address",
"additional_items",
"additional_items.tax_lines",
"shipping_methods",
"shipping_methods.tax_lines",
"order",
"order.region",
"order.billing_address",
"order.discounts",
"order.discounts.rule",
@@ -956,6 +901,7 @@ class SwapService extends BaseService {
currency_code: order.currency_code,
tax_rate: order.tax_rate,
region_id: order.region_id,
region: order.region,
display_id: order.display_id,
billing_address: order.billing_address,
items: swap.additional_items,
@@ -0,0 +1,47 @@
import { BaseService } from "medusa-interfaces"
import {
ITaxService,
ItemTaxCalculationLine,
ShippingTaxCalculationLine,
TaxCalculationContext,
} from "../interfaces/tax-service"
import { ProviderTaxLine } from "../types/tax-service"
class SystemTaxService extends BaseService implements ITaxService {
static identifier = "system"
constructor() {
super()
}
async getTaxLines(
itemLines: ItemTaxCalculationLine[],
shippingLines: ShippingTaxCalculationLine[],
context: TaxCalculationContext // eslint-disable-line
): Promise<ProviderTaxLine[]> {
let taxLines: ProviderTaxLine[] = itemLines.flatMap((l) => {
return l.rates.map((r) => ({
rate: r.rate || 0,
name: r.name,
code: r.code,
item_id: l.item.id,
}))
})
taxLines = taxLines.concat(
shippingLines.flatMap((l) => {
return l.rates.map((r) => ({
rate: r.rate || 0,
name: r.name,
code: r.code,
shipping_method_id: l.shipping_method.id,
}))
})
)
return taxLines
}
}
export default SystemTaxService
@@ -0,0 +1,433 @@
import { MedusaError } from "medusa-core-utils"
import { AwilixContainer } from "awilix"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import Redis from "ioredis"
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
import { ShippingMethodTaxLineRepository } from "../repositories/shipping-method-tax-line"
import { TaxProviderRepository } from "../repositories/tax-provider"
import { LineItemTaxLine } from "../models/line-item-tax-line"
import { TaxProvider } from "../models/tax-provider"
import { LineItem } from "../models/line-item"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
import { ShippingMethod } from "../models/shipping-method"
import { Region } from "../models/region"
import { Cart } from "../models/cart"
import { isCart } from "../types/cart"
import {
ITaxService,
ItemTaxCalculationLine,
TaxCalculationContext,
} from "../interfaces/tax-service"
import { TaxServiceRate } from "../types/tax-service"
import TaxRateService from "./tax-rate"
const CACHE_TIME = 30 // seconds
/**
* Finds tax providers and assists in tax related operations.
*/
class TaxProviderService extends BaseService {
private container_: AwilixContainer
private manager_: EntityManager
private transactionManager_: EntityManager
private taxRateService_: TaxRateService
private taxLineRepo_: typeof LineItemTaxLineRepository
private smTaxLineRepo_: typeof ShippingMethodTaxLineRepository
private taxProviderRepo_: typeof TaxProviderRepository
private redis_: Redis
constructor(container: AwilixContainer) {
super()
this.container_ = container
this.taxLineRepo_ = container["lineItemTaxLineRepository"]
this.smTaxLineRepo_ = container["shippingMethodTaxLineRepository"]
this.taxRateService_ = container["taxRateService"]
this.eventBus_ = container["eventBusService"]
this.taxProviderRepo_ = container["taxProviderRepository"]
this.manager_ = container["manager"]
this.redis_ = container["redisClient"]
}
withTransaction(transactionManager: EntityManager): TaxProviderService {
if (!transactionManager) {
return this
}
const cloned = new TaxProviderService(this.container_)
cloned.transactionManager_ = transactionManager
cloned.manager_ = transactionManager
return cloned
}
async list(): Promise<TaxProvider[]> {
const tpRepo = this.manager_.getCustomRepository(this.taxProviderRepo_)
return tpRepo.find({})
}
/**
* Retrieves the relevant tax provider for the given region.
* @param region - the region to get tax provider for.
* @return the region specific tax provider
*/
retrieveProvider(region: Region): ITaxService {
let provider: ITaxService
if (region.tax_provider_id) {
provider = this.container_[`tp_${region.tax_provider_id}`]
} else {
provider = this.container_["systemTaxService"]
}
if (!provider) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find a tax provider with id: ${region.tax_provider_id}`
)
}
return provider
}
/**
* Persists the tax lines relevant for an order to the database.
* @param cartOrLineItems - the cart or line items to create tax lines for
* @param calculationContext - the calculation context to get tax lines by
* @return the newly created tax lines
*/
async createTaxLines(
cartOrLineItems: Cart | LineItem[],
calculationContext: TaxCalculationContext
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
let taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[] = []
if (isCart(cartOrLineItems)) {
taxLines = await this.getTaxLines(
cartOrLineItems.items,
calculationContext
)
} else {
taxLines = await this.getTaxLines(cartOrLineItems, calculationContext)
}
return this.manager_.save(taxLines)
}
/**
* Persists the tax lines relevant for a shipping method to the database. Used
* for return shipping methods.
* @param shippingMethod - the shipping method to create tax lines for
* @param calculationContext - the calculation context to get tax lines by
* @return the newly created tax lines
*/
async createShippingTaxLines(
shippingMethod: ShippingMethod,
calculationContext: TaxCalculationContext
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
const taxLines = await this.getShippingTaxLines(
shippingMethod,
calculationContext
)
return this.manager_.save(taxLines)
}
/**
* Gets the relevant tax lines for a shipping method. Note: this method
* doesn't persist the tax lines. Use createShippingTaxLines if you wish to
* persist the tax lines to the DB layer.
* @param shippingMethod - the shipping method to get tax lines for
* @param calculationContext - the calculation context to get tax lines by
* @return the computed tax lines
*/
async getShippingTaxLines(
shippingMethod: ShippingMethod,
calculationContext: TaxCalculationContext
): Promise<ShippingMethodTaxLine[]> {
const calculationLines = [
{
shipping_method: shippingMethod,
rates: await this.getRegionRatesForShipping(
shippingMethod.shipping_option_id,
calculationContext.region
),
},
]
const taxProvider = this.retrieveProvider(calculationContext.region)
const providerLines = await taxProvider.getTaxLines(
[],
calculationLines,
calculationContext
)
const smTaxLineRepo = this.manager_.getCustomRepository(this.smTaxLineRepo_)
// .create only creates entities nothing is persisted in DB
return providerLines.map((pl) => {
if (!("shipping_method_id" in pl)) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Expected only shipping method tax lines"
)
}
return smTaxLineRepo.create({
shipping_method_id: pl.shipping_method_id,
rate: pl.rate,
name: pl.name,
code: pl.code,
metadata: pl.metadata,
})
})
}
/**
* Gets the relevant tax lines for an order or cart. If an order is provided
* the order's tax lines will be returned. If a cart is provided the tax lines
* will be computed from the tax rules and potentially a 3rd party tax plugin.
* Note: this method doesn't persist the tax lines. Use createTaxLines if you
* wish to persist the tax lines to the DB layer.
* @param lineItems - the cart or order to get tax lines for
* @param calculationContext - the calculation context to get tax lines by
* @return the computed tax lines
*/
async getTaxLines(
lineItems: LineItem[],
calculationContext: TaxCalculationContext
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
const calculationLines = await Promise.all(
lineItems.map(async (l) => {
if (l.is_return) {
return null
}
if (l.variant && l.variant.product_id) {
return {
item: l,
rates: await this.getRegionRatesForProduct(
l.variant.product_id,
calculationContext.region
),
}
}
/*
* If the line item is custom and therefore not associated with a
* product we assume no taxes - we should consider adding rate overrides
* to custom lines at some point
*/
return {
item: l,
rates: [],
}
})
)
const shippingCalculationLines = await Promise.all(
calculationContext.shipping_methods.map(async (sm) => {
return {
shipping_method: sm,
rates: await this.getRegionRatesForShipping(
sm.shipping_option_id,
calculationContext.region
),
}
})
)
const taxProvider = this.retrieveProvider(calculationContext.region)
const providerLines = await taxProvider.getTaxLines(
calculationLines.filter((v) => v !== null) as ItemTaxCalculationLine[],
shippingCalculationLines,
calculationContext
)
const liTaxLineRepo = this.manager_.getCustomRepository(this.taxLineRepo_)
const smTaxLineRepo = this.manager_.getCustomRepository(this.smTaxLineRepo_)
// .create only creates entities nothing is persisted in DB
return providerLines.map((pl) => {
if ("shipping_method_id" in pl) {
return smTaxLineRepo.create({
shipping_method_id: pl.shipping_method_id,
rate: pl.rate,
name: pl.name,
code: pl.code,
metadata: pl.metadata,
})
}
if (!("item_id" in pl)) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Provider returned invalid tax lines"
)
}
return liTaxLineRepo.create({
item_id: pl.item_id,
rate: pl.rate,
name: pl.name,
code: pl.code,
metadata: pl.metadata,
})
})
}
/**
* Gets the tax rates configured for a shipping option. The rates are cached
* between calls.
* @param optionId - the option id of the shipping method.
* @param region - the region to get configured rates for.
* @return the tax rates configured for the shipping option.
*/
async getRegionRatesForShipping(
optionId: string,
region: Region
): Promise<TaxServiceRate[]> {
const cacheHit = await this.getCacheEntry(optionId, region.id)
if (cacheHit) {
return cacheHit
}
let toReturn: TaxServiceRate[] = []
const optionRates = await this.taxRateService_.listByShippingOption(
optionId,
{ region_id: region.id }
)
if (optionRates.length > 0) {
toReturn = optionRates.map((pr) => {
return {
rate: pr.rate,
name: pr.name,
code: pr.code,
}
})
}
if (toReturn.length === 0) {
toReturn = [
{
rate: region.tax_rate,
name: "default",
code: "default",
},
]
}
await this.setCache(optionId, region.id, toReturn)
return toReturn
}
/**
* Gets the tax rates configured for a product. The rates are cached between
* calls.
* @param productId - the product id to get rates for
* @param region - the region to get configured rates for.
* @return the tax rates configured for the shipping option.
*/
async getRegionRatesForProduct(
productId: string,
region: Region
): Promise<TaxServiceRate[]> {
const cacheHit = await this.getCacheEntry(productId, region.id)
if (cacheHit) {
return cacheHit
}
let toReturn: TaxServiceRate[] = []
const productRates = await this.taxRateService_.listByProduct(productId, {
region_id: region.id,
})
if (productRates.length > 0) {
toReturn = productRates.map((pr) => {
return {
rate: pr.rate,
name: pr.name,
code: pr.code,
}
})
}
if (toReturn.length === 0) {
toReturn = [
{
rate: region.tax_rate,
name: "default",
code: "default",
},
]
}
await this.setCache(productId, region.id, toReturn)
return toReturn
}
/**
* The cache key to get cache hits by.
* @param productId - the product id to cache
* @param regionId - the region id to cache
* @return the cache key to use for the id set
*/
private getCacheKey(productId: string, regionId: string): string {
return `txrtcache:${productId}:${regionId}`
}
/**
* Sets the cache results for a set of ids
* @param productId - the product id to cache
* @param regionId - the region id to cache
* @param value - tax rates to cache
* @return promise that resolves after the cache has been set
*/
private async setCache(
productId: string,
regionId: string,
value: TaxServiceRate[]
): Promise<void> {
const cacheKey = this.getCacheKey(productId, regionId)
return await this.redis_.set(
cacheKey,
JSON.stringify(value),
"EX",
CACHE_TIME
)
}
/**
* Gets the cache results for a set of ids
* @param productId - the product id to cache
* @param regionId - the region id to cache
* @return the cached result or null
*/
private async getCacheEntry(
productId: string,
regionId: string
): Promise<TaxServiceRate[] | null> {
const cacheKey = this.getCacheKey(productId, regionId)
try {
const cacheHit = await this.redis_.get(cacheKey)
if (cacheHit) {
// TODO: Validate that cache has correct data
const parsedResults = JSON.parse(cacheHit) as TaxServiceRate[]
return parsedResults
}
} catch (_) {
// noop - cache parse failed
await this.redis_.del(cacheKey)
}
return null
}
}
export default TaxProviderService
+343
View File
@@ -0,0 +1,343 @@
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { TaxRate } from "../models/tax-rate"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
import { ProductTaxRate } from "../models/product-tax-rate"
import { ProductTypeTaxRate } from "../models/product-type-tax-rate"
import { TaxRateRepository } from "../repositories/tax-rate"
import ProductService from "../services/product"
import ProductTypeService from "../services/product-type"
import ShippingOptionService from "../services/shipping-option"
import { FindConfig } from "../types/common"
import {
CreateTaxRateInput,
UpdateTaxRateInput,
TaxRateListByConfig,
FilterableTaxRateProps,
} from "../types/tax-rate"
class TaxRateService extends BaseService {
private manager_: EntityManager
private productService_: ProductService
private productTypeService_: ProductTypeService
private shippingOptionService_: ShippingOptionService
private taxRateRepository_: typeof TaxRateRepository
constructor({
manager,
productService,
productTypeService,
shippingOptionService,
taxRateRepository,
}) {
super()
this.manager_ = manager
this.taxRateRepository_ = taxRateRepository
this.productService_ = productService
this.productTypeService_ = productTypeService
this.shippingOptionService_ = shippingOptionService
}
withTransaction(transactionManager: EntityManager): TaxRateService {
if (!transactionManager) {
return this
}
const cloned = new TaxRateService({
manager: transactionManager,
taxRateRepository: this.taxRateRepository_,
productService: this.productService_,
productTypeService: this.productTypeService_,
shippingOptionService: this.shippingOptionService_,
})
cloned.transactionManager_ = transactionManager
cloned.manager_ = transactionManager
return cloned
}
async list(
selector: FilterableTaxRateProps,
config: FindConfig<TaxRate> = {}
): Promise<TaxRate[]> {
const taxRateRepo = this.manager_.getCustomRepository(
this.taxRateRepository_
)
const query = this.buildQuery_(selector, config)
return await taxRateRepo.findWithResolution(query)
}
async listAndCount(
selector: FilterableTaxRateProps,
config: FindConfig<TaxRate> = {}
): Promise<[TaxRate[], number]> {
const taxRateRepo = this.manager_.getCustomRepository(
this.taxRateRepository_
)
const query = this.buildQuery_(selector, config)
return await taxRateRepo.findAndCountWithResolution(query)
}
async retrieve(
id: string,
config: FindConfig<TaxRate> = {}
): Promise<TaxRate> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
const query = this.buildQuery_({ id }, config)
const taxRate = await taxRateRepo.findOneWithResolution(query)
if (!taxRate) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`TaxRate with ${id} was not found`
)
}
return taxRate
})
}
async create(data: CreateTaxRateInput): Promise<TaxRate> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
if (typeof data.region_id === "undefined") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"TaxRates must belong to a Region"
)
}
const taxRate = taxRateRepo.create(data)
return await taxRateRepo.save(taxRate)
})
}
async update(id: string, data: UpdateTaxRateInput): Promise<TaxRate> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
const taxRate = await this.retrieve(id)
for (const [k, v] of Object.entries(data)) {
if (typeof v !== "undefined") {
taxRate[k] = v
}
}
return await taxRateRepo.save(taxRate)
})
}
async delete(id: string | string[]): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
const query = this.buildQuery_({ id })
await taxRateRepo.delete(query.where)
})
}
async removeFromProduct(
id: string,
productIds: string | string[]
): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
let ids: string[]
if (typeof productIds === "string") {
ids = [productIds]
} else {
ids = productIds
}
await taxRateRepo.removeFromProduct(id, ids)
})
}
async removeFromProductType(
id: string,
typeIds: string | string[]
): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
let ids: string[]
if (typeof typeIds === "string") {
ids = [typeIds]
} else {
ids = typeIds
}
await taxRateRepo.removeFromProductType(id, ids)
})
}
async removeFromShippingOption(
id: string,
optionIds: string | string[]
): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
let ids: string[]
if (typeof optionIds === "string") {
ids = [optionIds]
} else {
ids = optionIds
}
await taxRateRepo.removeFromShippingOption(id, ids)
})
}
async addToProduct(
id: string,
productIds: string | string[],
replace = false
): Promise<ProductTaxRate | ProductTaxRate[]> {
let ids: string[]
if (typeof productIds === "string") {
ids = [productIds]
} else {
ids = productIds
}
const result = await this.atomicPhase_(
async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
return await taxRateRepo.addToProduct(id, ids, replace)
},
// eslint-disable-next-line
async (err: any) => {
if (err.code === "23503") {
// A foreign key constraint failed meaning some thing doesn't exist
// either it is a product or the tax rate itself. Using Promise.all
// will try to retrieve all of the resources and will fail when
// something is not found.
await Promise.all([
this.retrieve(id, { select: ["id"] }),
...ids.map((pId) =>
this.productService_.retrieve(pId, { select: ["id"] })
),
])
}
}
)
return result
}
async addToProductType(
id: string,
productTypeIds: string | string[],
replace = false
): Promise<ProductTypeTaxRate[]> {
let ids: string[]
if (typeof productTypeIds === "string") {
ids = [productTypeIds]
} else {
ids = productTypeIds
}
return await this.atomicPhase_(
async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
return await taxRateRepo.addToProductType(id, ids, replace)
},
// eslint-disable-next-line
async (err: any) => {
if (err.code === "23503") {
// A foreign key constraint failed meaning some thing doesn't exist
// either it is a product or the tax rate itself. Using Promise.all
// will try to retrieve all of the resources and will fail when
// something is not found.
await Promise.all([
this.retrieve(id, {
select: ["id"],
}) as Promise<unknown>,
...ids.map(
(pId) =>
this.productTypeService_.retrieve(pId, {
select: ["id"],
}) as Promise<unknown>
),
])
}
}
)
}
async addToShippingOption(
id: string,
optionIds: string | string[],
replace = false
): Promise<ShippingTaxRate> {
let ids: string[]
if (typeof optionIds === "string") {
ids = [optionIds]
} else {
ids = optionIds
}
return await this.atomicPhase_(
async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
const taxRate = await this.retrieve(id, { select: ["id", "region_id"] })
const options = await this.shippingOptionService_.list(
{ id: ids },
{ select: ["id", "region_id"] }
)
for (const o of options) {
if (o.region_id !== taxRate.region_id) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Shipping Option and Tax Rate must belong to the same Region to be associated. Shipping Option with id: ${o.id} belongs to Region with id: ${o.region_id} and Tax Rate with id: ${taxRate.id} belongs to Region with id: ${taxRate.region_id}`
)
}
}
return await taxRateRepo.addToShippingOption(id, ids, replace)
},
// eslint-disable-next-line
async (err: any) => {
if (err.code === "23503") {
// A foreign key constraint failed meaning some thing doesn't exist
// either it is a product or the tax rate itself. Using Promise.all
// will try to retrieve all of the resources and will fail when
// something is not found.
await Promise.all([
this.retrieve(id, { select: ["id"] }),
...ids.map((sId) =>
this.shippingOptionService_.retrieve(sId, { select: ["id"] })
),
])
}
}
)
}
async listByProduct(
productId: string,
config: TaxRateListByConfig
): Promise<TaxRate[]> {
// Check both ProductTaxRate + ProductTypeTaxRate
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
return await taxRateRepo.listByProduct(productId, config)
})
}
async listByShippingOption(
shippingOptionId: string,
config: TaxRateListByConfig
): Promise<TaxRate[]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
return await taxRateRepo.listByShippingOption(shippingOptionId, config)
})
}
}
export default TaxRateService
-389
View File
@@ -1,389 +0,0 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* A service that calculates total and subtotals for orders, carts etc..
* @implements {BaseService}
*/
class TotalsService extends BaseService {
constructor() {
super()
}
/**
* Calculates subtotal of a given cart or order.
* @param {object} object - object to calculate total for
* @return {int} the calculated subtotal
*/
getTotal(object) {
const subtotal = this.getSubtotal(object)
const taxTotal = this.getTaxTotal(object)
const discountTotal = this.getDiscountTotal(object)
const giftCardTotal = this.getGiftCardTotal(object)
const shippingTotal = this.getShippingTotal(object)
return subtotal + taxTotal + shippingTotal - discountTotal - giftCardTotal
}
getPaidTotal(order) {
const total = order.payments?.reduce((acc, next) => {
acc += next.amount
return acc
}, 0)
return total
}
getSwapTotal(order) {
let swapTotal = 0
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
swapTotal = swapTotal + s.difference_due
}
}
return swapTotal
}
/**
* Calculates subtotal of a given cart or order.
* @param {(Cart|Order)} object - cart or order to calculate subtotal for
* @param {Object} opts - options
* @return {int} the calculated subtotal
*/
getSubtotal(object, opts = {}) {
let subtotal = 0
if (!object.items) {
return subtotal
}
object.items.map((item) => {
if (opts.excludeNonDiscounts) {
if (item.allow_discounts) {
subtotal += item.unit_price * item.quantity
}
} else {
subtotal += item.unit_price * item.quantity
}
})
return this.rounded(subtotal)
}
/**
* Calculates shipping total
* @param {Cart | Object} object - cart or order to calculate subtotal for
* @return {int} shipping total
*/
getShippingTotal(object) {
const { shipping_methods } = object
return shipping_methods.reduce((acc, next) => {
return acc + next.price
}, 0)
}
/**
* Calculates tax total
* Currently based on the Danish tax system
* @param {Cart | Object} object - cart or order to calculate subtotal for
* @return {int} tax total
*/
getTaxTotal(object) {
const subtotal = this.getSubtotal(object)
const shippingTotal = this.getShippingTotal(object)
const discountTotal = this.getDiscountTotal(object)
const giftCardTotal = this.getGiftCardTotal(object)
const tax_rate =
typeof object.tax_rate !== "undefined"
? object.tax_rate
: object.region.tax_rate
return this.rounded(
(subtotal - discountTotal - giftCardTotal + shippingTotal) *
(tax_rate / 100)
)
}
getRefundedTotal(object) {
if (!object.refunds) {
return 0
}
const total = object.refunds.reduce((acc, next) => acc + next.amount, 0)
return this.rounded(total)
}
getLineItemRefund(object, lineItem) {
const { discounts } = object
const tax_rate =
typeof object.tax_rate !== "undefined"
? object.tax_rate
: object.region.tax_rate
const taxRate = (tax_rate || 0) / 100
const discount = discounts.find(({ rule }) => rule.type !== "free_shipping")
if (!discount || !lineItem.allow_discounts) {
return lineItem.unit_price * lineItem.quantity * (1 + taxRate)
}
const lineDiscounts = this.getLineDiscounts(object, discount)
const discountedLine = lineDiscounts.find(
(line) => line.item.id === lineItem.id
)
const discountAmount =
(discountedLine.amount / discountedLine.item.quantity) * lineItem.quantity
return this.rounded(
(lineItem.unit_price * lineItem.quantity - discountAmount) * (1 + taxRate)
)
}
/**
* Calculates refund total of line items.
* If any of the items to return have been discounted, we need to
* apply the discount again before refunding them.
* @param {Order} order - cart or order to calculate subtotal for
* @param {[LineItem]} lineItems -
* @return {int} the calculated subtotal
*/
getRefundTotal(order, lineItems) {
let itemIds = order.items.map((i) => i.id)
// in case we swap a swap, we need to include swap items
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
const swapItemIds = s.additional_items.map((el) => el.id)
itemIds = [...itemIds, ...swapItemIds]
}
}
if (order.claims && order.claims.length) {
for (const c of order.claims) {
const claimItemIds = c.additional_items.map((el) => el.id)
itemIds = [...itemIds, ...claimItemIds]
}
}
const refunds = lineItems.map((i) => {
if (!itemIds.includes(i.id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Line item does not exist on order"
)
}
return this.getLineItemRefund(order, i)
})
return this.rounded(refunds.reduce((acc, next) => acc + next, 0))
}
/**
* Calculates either fixed or percentage discount of a variant
* @param {string} lineItem - id of line item
* @param {string} variant - id of variant in line item
* @param {int} variantPrice - price of the variant based on region
* @param {int} value - discount value
* @param {string} discountType - the type of discount (fixed or percentage)
* @return {{ string, string, int }} triples of lineitem, variant and
* applied discount
*/
calculateDiscount_(lineItem, variant, variantPrice, value, discountType) {
if (!lineItem.allow_discounts) {
return {
lineItem,
variant,
amount: 0,
}
}
if (discountType === "percentage") {
return {
lineItem,
variant,
amount: ((variantPrice * lineItem.quantity) / 100) * value,
}
} else {
return {
lineItem,
variant,
amount:
value >= variantPrice * lineItem.quantity
? variantPrice * lineItem.quantity
: value * lineItem.quantity,
}
}
}
/**
* If the rule of a discount has allocation="item", then we need
* to calculate discount on each item in the cart. Furthermore, we need to
* make sure to only apply the discount on valid variants. And finally we
* return ether an array of percentages discounts or fixed discounts
* alongside the variant on which the discount was applied.
* @param {Discount} discount - the discount to which we do the calculation
* @param {Cart} cart - the cart to calculate discounts for
* @return {[{ string, string, int }]} array of triples of lineitem, variant
* and applied discount
*/
getAllocationItemDiscounts(discount, cart) {
const discounts = []
for (const item of cart.items) {
if (discount.rule.valid_for?.length > 0) {
discount.rule.valid_for.map(({ id }) => {
if (item.variant.product_id === id) {
discounts.push(
this.calculateDiscount_(
item,
item.variant.id,
item.unit_price,
discount.rule.value,
discount.rule.type
)
)
}
})
}
}
return discounts
}
getLineDiscounts(cart, discount) {
const subtotal = this.getSubtotal(cart, { excludeNonDiscounts: true })
let merged = [...cart.items]
// merge items from order with items from order swaps
if (cart.swaps && cart.swaps.length) {
for (const s of cart.swaps) {
merged = [...merged, ...s.additional_items]
}
}
if (cart.claims && cart.claims.length) {
for (const c of cart.claims) {
merged = [...merged, ...c.additional_items]
}
}
const { type, allocation, value } = discount.rule
if (allocation === "total") {
let percentage = 0
if (type === "percentage") {
percentage = value / 100
} else if (type === "fixed") {
// If the fixed discount exceeds the subtotal we should
// calculate a 100% discount
const nominator = Math.min(value, subtotal)
percentage = nominator / subtotal
}
return merged.map((item) => {
const lineTotal = item.unit_price * item.quantity
return {
item,
amount: lineTotal * percentage,
}
})
} else if (allocation === "item") {
const allocationDiscounts = this.getAllocationItemDiscounts(
discount,
cart,
type
)
return merged.map((item) => {
const discounted = allocationDiscounts.find(
(a) => a.lineItem.id === item.id
)
return {
item,
amount: discounted ? discounted.amount : 0,
}
})
}
return merged.map((i) => ({ item: i, amount: 0 }))
}
getGiftCardTotal(cart) {
const giftCardable = this.getSubtotal(cart) - this.getDiscountTotal(cart)
if (cart.gift_card_transactions) {
return cart.gift_card_transactions.reduce(
(acc, next) => acc + next.amount,
0
)
}
if (!cart.gift_cards || !cart.gift_cards.length) {
return 0
}
const toReturn = cart.gift_cards.reduce(
(acc, next) => acc + next.balance,
0
)
return Math.min(giftCardable, toReturn)
}
/**
* Calculates the total discount amount for each of the different supported
* discount types. If discounts aren't present or invalid returns 0.
* @param {Cart} cart - the cart to calculate discounts for
* @return {int} the total discounts amount
*/
getDiscountTotal(cart) {
const subtotal = this.getSubtotal(cart, { excludeNonDiscounts: true })
if (!cart.discounts || !cart.discounts.length) {
return 0
}
// we only support having free shipping and one other discount, so first
// find the discount, which is not free shipping.
const discount = cart.discounts.find(
({ rule }) => rule.type !== "free_shipping"
)
if (!discount) {
return 0
}
const { type, allocation, value } = discount.rule
let toReturn = 0
if (type === "percentage" && allocation === "total") {
toReturn = (subtotal / 100) * value
} else if (type === "percentage" && allocation === "item") {
const itemPercentageDiscounts = this.getAllocationItemDiscounts(
discount,
cart,
"percentage"
)
toReturn = _.sumBy(itemPercentageDiscounts, (d) => d.amount)
} else if (type === "fixed" && allocation === "total") {
toReturn = value
} else if (type === "fixed" && allocation === "item") {
const itemFixedDiscounts = this.getAllocationItemDiscounts(
discount,
cart,
"fixed"
)
toReturn = _.sumBy(itemFixedDiscounts, (d) => d.amount)
}
if (subtotal < 0) {
return this.rounded(Math.max(subtotal, toReturn))
}
return this.rounded(Math.min(subtotal, toReturn))
}
rounded(value) {
return Math.round(value)
}
}
export default TotalsService
+957
View File
@@ -0,0 +1,957 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { LineItemTaxLine } from "../models/line-item-tax-line"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
import { Order } from "../models/order"
import { Cart } from "../models/cart"
import { ShippingMethod } from "../models/shipping-method"
import { LineItem } from "../models/line-item"
import { Discount } from "../models/discount"
import { DiscountRuleType } from "../models/discount-rule"
import TaxProviderService from "./tax-provider"
import { ITaxCalculationStrategy } from "../interfaces/tax-calculation-strategy"
import { TaxCalculationContext } from "../interfaces/tax-service"
import { isCart } from "../types/cart"
import { isOrder } from "../types/orders"
import {
SubtotalOptions,
LineDiscount,
LineAllocationsMap,
LineDiscountAmount,
} from "../types/totals"
type ShippingMethodTotals = {
price: number
tax_total: number
total: number
original_total: number
original_tax_total: number
tax_lines: ShippingMethodTaxLine[]
}
type GetShippingMethodTotalsOptions = {
include_tax?: boolean
use_tax_lines?: boolean
}
type LineItemTotals = {
unit_price: number
quantity: number
subtotal: number
tax_total: number
total: number
original_total: number
original_tax_total: number
tax_lines: LineItemTaxLine[]
discount_total: number
gift_card_total: number
}
type LineItemTotalsOptions = {
include_tax?: boolean
use_tax_lines?: boolean
}
type GetLineItemTotalOptions = {
include_tax?: boolean
exclude_gift_cards?: boolean
exclude_discounts?: boolean
}
type TotalsServiceProps = {
taxProviderService: TaxProviderService
taxCalculationStrategy: ITaxCalculationStrategy
}
type GetTotalsOptions = {
force_taxes?: boolean
}
type AllocationMapOptions = {
exclude_gift_cards?: boolean
exclude_discounts?: boolean
}
type CalculationContextOptions = {
is_return?: boolean
exclude_shipping?: boolean
exclude_gift_cards?: boolean
exclude_discounts?: boolean
}
/**
* A service that calculates total and subtotals for orders, carts etc..
* @implements {BaseService}
*/
class TotalsService extends BaseService {
private taxProviderService_: TaxProviderService
private taxCalculationStrategy_: ITaxCalculationStrategy
constructor({
taxProviderService,
taxCalculationStrategy,
}: TotalsServiceProps) {
super()
this.taxProviderService_ = taxProviderService
this.taxCalculationStrategy_ = taxCalculationStrategy
}
/**
* Calculates subtotal of a given cart or order.
* @param cartOrOrder - object to calculate total for
* @param options - options to calculate by
* @return the calculated subtotal
*/
async getTotal(
cartOrOrder: Cart | Order,
options: GetTotalsOptions = {}
): Promise<number> {
const subtotal = this.getSubtotal(cartOrOrder)
const taxTotal =
(await this.getTaxTotal(cartOrOrder, options.force_taxes)) || 0
const discountTotal = this.getDiscountTotal(cartOrOrder)
const giftCardTotal = this.getGiftCardTotal(cartOrOrder)
const shippingTotal = this.getShippingTotal(cartOrOrder)
return subtotal + taxTotal + shippingTotal - discountTotal - giftCardTotal
}
/**
* Gets the total payments made on an order
* @param order - the order to calculate paid amount for
* @return the total paid amount
*/
getPaidTotal(order: Order): number {
const total = order.payments?.reduce((acc, next) => {
acc += next.amount
return acc
}, 0)
return total
}
/**
* The total paid for swaps. May be negative in case of negative swap
* difference.
* @param order - the order to calculate swap total for
* @return the swap total
*/
getSwapTotal(order: Order): number {
let swapTotal = 0
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
swapTotal = swapTotal + s.difference_due
}
}
return swapTotal
}
/**
* Gets the totals breakdown for a shipping method. Fetches tax lines if not
* already provided.
* @param shippingMethod - the shipping method to get totals breakdown for.
* @param cartOrOrder - the cart or order to use as context for the breakdown
* @param opts - options for what should be included
* @returns An object that breaks down the totals for the shipping method
*/
async getShippingMethodTotals(
shippingMethod: ShippingMethod,
cartOrOrder: Cart | Order,
opts: GetShippingMethodTotalsOptions = {}
): Promise<ShippingMethodTotals> {
const calculationContext = this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
})
calculationContext.shipping_methods = [shippingMethod]
const totals = {
price: shippingMethod.price,
original_total: shippingMethod.price,
total: shippingMethod.price,
original_tax_total: 0,
tax_total: 0,
tax_lines: shippingMethod.tax_lines || [],
}
if (opts.include_tax) {
if (isOrder(cartOrOrder) && cartOrOrder.tax_rate !== null) {
totals.original_tax_total = Math.round(
totals.original_tax_total * (cartOrOrder.tax_rate / 100)
)
totals.tax_total = Math.round(
totals.original_tax_total * (cartOrOrder.tax_rate / 100)
)
} else {
let taxLines: ShippingMethodTaxLine[]
if (opts.use_tax_lines || isOrder(cartOrOrder)) {
if (typeof shippingMethod.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Lines must be joined on shipping method to calculate taxes"
)
}
taxLines = shippingMethod.tax_lines
} else {
const orderLines = await this.taxProviderService_.getTaxLines(
cartOrOrder.items,
calculationContext
)
console.log(orderLines)
taxLines = orderLines.filter((ol) => {
if ("shipping_method_id" in ol) {
return ol.shipping_method_id === shippingMethod.id
}
return false
}) as ShippingMethodTaxLine[]
}
totals.tax_lines = taxLines
}
if (totals.tax_lines.length > 0) {
totals.original_tax_total =
await this.taxCalculationStrategy_.calculate(
[],
totals.tax_lines,
calculationContext
)
totals.tax_total = totals.original_tax_total
totals.original_total += totals.original_tax_total
totals.total += totals.tax_total
}
}
if (cartOrOrder.discounts) {
if (cartOrOrder.discounts.some((d) => d.rule.type === "free_shipping")) {
totals.total = 0
totals.tax_total = 0
}
}
return totals
}
/**
* Calculates subtotal of a given cart or order.
* @param cartOrOrder - cart or order to calculate subtotal for
* @param opts - options
* @return the calculated subtotal
*/
getSubtotal(cartOrOrder: Cart | Order, opts: SubtotalOptions = {}): number {
let subtotal = 0
if (!cartOrOrder.items) {
return subtotal
}
cartOrOrder.items.map((item) => {
if (opts.excludeNonDiscounts) {
if (item.allow_discounts) {
subtotal += item.unit_price * item.quantity
}
} else {
subtotal += item.unit_price * item.quantity
}
})
return this.rounded(subtotal)
}
/**
* Calculates shipping total
* @param cartOrOrder - cart or order to calculate subtotal for
* @return shipping total
*/
getShippingTotal(cartOrOrder: Cart | Order): number {
const { shipping_methods } = cartOrOrder
return shipping_methods.reduce((acc, next) => {
return acc + next.price
}, 0)
}
/**
* Calculates tax total
* Currently based on the Danish tax system
* @param cartOrOrder - cart or order to calculate tax total for
* @param forceTaxes - whether taxes should be calculated regardless
* of region settings
* @return tax total
*/
async getTaxTotal(
cartOrOrder: Cart | Order,
forceTaxes = false
): Promise<number | null> {
if (
isCart(cartOrOrder) &&
!forceTaxes &&
!cartOrOrder.region.automatic_taxes
) {
return null
}
const calculationContext = this.getCalculationContext(cartOrOrder)
let taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[]
if (isOrder(cartOrOrder)) {
const taxLinesJoined = cartOrOrder.items.every(
(i) => typeof i.tax_lines !== "undefined"
)
if (!taxLinesJoined) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Order tax calculations must have tax lines joined on line items"
)
}
if (cartOrOrder.tax_rate === null) {
taxLines = cartOrOrder.items.flatMap((li) => li.tax_lines)
const shippingTaxLines = cartOrOrder.shipping_methods.flatMap(
(sm) => sm.tax_lines
)
taxLines = taxLines.concat(shippingTaxLines)
} else {
const subtotal = this.getSubtotal(cartOrOrder)
const shippingTotal = this.getShippingTotal(cartOrOrder)
const discountTotal = this.getDiscountTotal(cartOrOrder)
const giftCardTotal = this.getGiftCardTotal(cartOrOrder)
return this.rounded(
(subtotal - discountTotal - giftCardTotal + shippingTotal) *
(cartOrOrder.tax_rate / 100)
)
}
} else {
taxLines = await this.taxProviderService_.getTaxLines(
cartOrOrder.items,
calculationContext
)
if (cartOrOrder.type === "swap") {
const returnTaxLines = cartOrOrder.items.flatMap((i) => {
if (i.is_return) {
if (typeof i.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Return Line Items must join tax lines"
)
}
return i.tax_lines
}
return []
})
taxLines = taxLines.concat(returnTaxLines)
}
}
const toReturn = await this.taxCalculationStrategy_.calculate(
cartOrOrder.items,
taxLines,
calculationContext
)
return this.rounded(toReturn)
}
/**
* Gets a map of discounts and gift cards that apply to line items in an
* order. The function calculates the amount of a discount or gift card that
* applies to a specific line item.
* @param orderOrCart - the order or cart to get an allocation map for
* @param options - controls what should be included in allocation map
* @return the allocation map for the line items in the cart or order.
*/
getAllocationMap(
orderOrCart: Cart | Order,
options: AllocationMapOptions = {}
): LineAllocationsMap {
const allocationMap: LineAllocationsMap = {}
if (!options.exclude_discounts) {
let lineDiscounts: LineDiscountAmount[] = []
const discount = orderOrCart.discounts.find(
({ rule }) => rule.type !== "free_shipping"
)
if (discount) {
lineDiscounts = this.getLineDiscounts(orderOrCart, discount)
}
for (const ld of lineDiscounts) {
if (allocationMap[ld.item.id]) {
allocationMap[ld.item.id].discount = {
amount: ld.amount,
unit_amount: ld.amount / ld.item.quantity,
}
} else {
allocationMap[ld.item.id] = {
discount: {
amount: ld.amount,
unit_amount: ld.amount / ld.item.quantity,
},
}
}
}
}
if (!options.exclude_gift_cards) {
let lineGiftCards: LineDiscountAmount[] = []
if (orderOrCart.gift_cards && orderOrCart.gift_cards.length) {
const subtotal = this.getSubtotal(orderOrCart)
const giftCardTotal = this.getGiftCardTotal(orderOrCart)
// If the fixed discount exceeds the subtotal we should
// calculate a 100% discount
const nominator = Math.min(giftCardTotal, subtotal)
const percentage = nominator / subtotal
lineGiftCards = orderOrCart.items.map((l) => {
return {
item: l,
amount: l.unit_price * l.quantity * percentage,
}
})
}
for (const lgc of lineGiftCards) {
if (allocationMap[lgc.item.id]) {
allocationMap[lgc.item.id].gift_card = {
amount: lgc.amount,
unit_amount: lgc.amount / lgc.item.quantity,
}
} else {
allocationMap[lgc.item.id] = {
discount: {
amount: lgc.amount,
unit_amount: lgc.amount / lgc.item.quantity,
},
}
}
}
}
return allocationMap
}
/**
* Gets the total refund amount for an order.
* @param order - the order to get total refund amount for.
* @return the total refunded amount for an order.
*/
getRefundedTotal(order: Order): number {
if (!order.refunds) {
return 0
}
const total = order.refunds.reduce((acc, next) => acc + next.amount, 0)
return this.rounded(total)
}
/**
* The amount that can be refunded for a given line item.
* @param order - order to use as context for the calculation
* @param lineItem - the line item to calculate the refund amount for.
* @return the line item refund amount.
*/
getLineItemRefund(order: Order, lineItem: LineItem): number {
const allocationMap = this.getAllocationMap(order)
const discountAmount =
(allocationMap[lineItem.id]?.discount?.unit_amount || 0) *
lineItem.quantity
const lineSubtotal =
lineItem.unit_price * lineItem.quantity - discountAmount
/*
* Used for backcompat with old tax system
*/
if (order.tax_rate !== null) {
const taxRate = order.tax_rate / 100
return this.rounded(lineSubtotal * (1 + taxRate))
}
/*
* New tax system uses the tax lines registerd on the line items
*/
if (typeof lineItem.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax calculation did not receive tax_lines"
)
}
const taxTotal = lineItem.tax_lines.reduce((acc, next) => {
const taxRate = next.rate / 100
return acc + this.rounded(lineSubtotal * taxRate)
}, 0)
return lineSubtotal + taxTotal
}
/**
* Calculates refund total of line items.
* If any of the items to return have been discounted, we need to
* apply the discount again before refunding them.
* @param order - cart or order to calculate subtotal for
* @param lineItems - the line items to calculate refund total for
* @return the calculated subtotal
*/
getRefundTotal(order: Order, lineItems: LineItem[]): number {
let itemIds = order.items.map((i) => i.id)
// in case we swap a swap, we need to include swap items
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
const swapItemIds = s.additional_items.map((el) => el.id)
itemIds = [...itemIds, ...swapItemIds]
}
}
if (order.claims && order.claims.length) {
for (const c of order.claims) {
const claimItemIds = c.additional_items.map((el) => el.id)
itemIds = [...itemIds, ...claimItemIds]
}
}
const refunds = lineItems.map((i) => {
if (!itemIds.includes(i.id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Line item does not exist on order"
)
}
return this.getLineItemRefund(order, i)
})
return this.rounded(refunds.reduce((acc, next) => acc + next, 0))
}
/**
* Calculates either fixed or percentage discount of a variant
* @param lineItem - id of line item
* @param variant - id of variant in line item
* @param variantPrice - price of the variant based on region
* @param value - discount value
* @param discountType - the type of discount (fixed or percentage)
* @return triples of lineitem, variant and applied discount
*/
calculateDiscount_(
lineItem: LineItem,
variant: string,
variantPrice: number,
value: number,
discountType: DiscountRuleType
): LineDiscount {
if (!lineItem.allow_discounts) {
return {
lineItem,
variant,
amount: 0,
}
}
if (discountType === DiscountRuleType.PERCENTAGE) {
return {
lineItem,
variant,
amount: ((variantPrice * lineItem.quantity) / 100) * value,
}
} else {
return {
lineItem,
variant,
amount:
value >= variantPrice * lineItem.quantity
? variantPrice * lineItem.quantity
: value * lineItem.quantity,
}
}
}
/**
* If the rule of a discount has allocation="item", then we need
* to calculate discount on each item in the cart. Furthermore, we need to
* make sure to only apply the discount on valid variants. And finally we
* return ether an array of percentages discounts or fixed discounts
* alongside the variant on which the discount was applied.
* @param discount - the discount to which we do the calculation
* @param cart - the cart to calculate discounts for
* @return array of triples of lineitem, variant and applied discount
*/
getAllocationItemDiscounts(
discount: Discount,
cart: Cart | Order
): LineDiscount[] {
const discounts: LineDiscount[] = []
for (const item of cart.items) {
if (discount.rule.valid_for?.length > 0) {
discount.rule.valid_for.map(({ id }) => {
if (item.variant.product_id === id) {
discounts.push(
this.calculateDiscount_(
item,
item.variant.id,
item.unit_price,
discount.rule.value,
discount.rule.type
)
)
}
})
}
}
return discounts
}
/**
* Returns the discount amount allocated to the line items of an order.
* @param cartOrOrder - the cart or order to get line discount allocations for
* @param discount - the discount to use as context for the calculation
* @return the allocations that the discount has on the items in the cart or
* order
*/
getLineDiscounts(
cartOrOrder: Cart | Order,
discount: Discount
): LineDiscountAmount[] {
const subtotal = this.getSubtotal(cartOrOrder, {
excludeNonDiscounts: true,
})
let merged: LineItem[] = [...cartOrOrder.items]
// merge items from order with items from order swaps
if ("swaps" in cartOrOrder && cartOrOrder.swaps.length) {
for (const s of cartOrOrder.swaps) {
merged = [...merged, ...s.additional_items]
}
}
if ("claims" in cartOrOrder && cartOrOrder.claims.length) {
for (const c of cartOrOrder.claims) {
merged = [...merged, ...c.additional_items]
}
}
const { type, allocation, value } = discount.rule
if (allocation === "total") {
let percentage = 0
if (type === "percentage") {
percentage = value / 100
} else if (type === "fixed") {
// If the fixed discount exceeds the subtotal we should
// calculate a 100% discount
const nominator = Math.min(value, subtotal)
percentage = nominator / subtotal
}
return merged.map((item) => {
const lineTotal = item.unit_price * item.quantity
return {
item,
amount: item.allow_discounts ? lineTotal * percentage : 0,
}
})
} else if (allocation === "item") {
const allocationDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
)
return merged.map((item) => {
const discounted = allocationDiscounts.find(
(a) => a.lineItem.id === item.id
)
return {
item,
amount: discounted ? discounted.amount : 0,
}
})
}
return merged.map((i) => ({ item: i, amount: 0 }))
}
/**
* Breaks down the totals related to a line item; these are the subtotal, the
* amount of discount applied to the line item, the amount of a gift card
* applied to a line item and the amount of tax applied to a line item.
* @param lineItem - the line item to calculate totals for
* @param cartOrOrder - the cart or order to use as context for the calculation
* @param options - the options to evaluate the line item totals for
* @returns the breakdown of the line item totals
*/
async getLineItemTotals(
lineItem: LineItem,
cartOrOrder: Cart | Order,
options: LineItemTotalsOptions = {}
): Promise<LineItemTotals> {
const calculationContext = this.getCalculationContext(cartOrOrder, {
exclude_shipping: true,
})
const lineItemAllocation =
calculationContext.allocation_map[lineItem.id] || {}
const subtotal = lineItem.unit_price * lineItem.quantity
const gift_card_total = lineItemAllocation.gift_card?.amount || 0
const discount_total =
(lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity
const lineItemTotals: LineItemTotals = {
unit_price: lineItem.unit_price,
quantity: lineItem.quantity,
subtotal,
gift_card_total,
discount_total,
total: subtotal - discount_total,
original_total: subtotal,
original_tax_total: 0,
tax_total: 0,
tax_lines: lineItem.tax_lines || [],
}
// Tax Information
if (options.include_tax) {
// When we have an order with a null'ed tax rate we know that it is an
// order from the old tax system. The following is a backward compat
// calculation.
if (isOrder(cartOrOrder) && cartOrOrder.tax_rate !== null) {
lineItemTotals.original_tax_total =
subtotal * (cartOrOrder.tax_rate / 100)
lineItemTotals.tax_total =
(subtotal - discount_total) * (cartOrOrder.tax_rate / 100)
lineItemTotals.total += lineItemTotals.tax_total
lineItemTotals.original_total += lineItemTotals.original_tax_total
} else {
let taxLines: LineItemTaxLine[]
/*
* Line Items on orders will already have tax lines. But for cart line
* items we have to get the line items from the tax provider.
*/
if (options.use_tax_lines || isOrder(cartOrOrder)) {
if (typeof lineItem.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Lines must be joined on items to calculate taxes"
)
}
taxLines = lineItem.tax_lines
} else {
if (lineItem.is_return) {
if (typeof lineItem.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Return Line Items must join tax lines"
)
}
taxLines = lineItem.tax_lines
} else {
const orderLines = await this.taxProviderService_.getTaxLines(
cartOrOrder.items,
calculationContext
)
taxLines = orderLines.filter((ol) => {
if ("item_id" in ol) {
return ol.item_id === lineItem.id
}
return false
}) as LineItemTaxLine[]
}
}
lineItemTotals.tax_lines = taxLines
}
}
if (lineItemTotals.tax_lines.length > 0) {
lineItemTotals.tax_total = await this.taxCalculationStrategy_.calculate(
[lineItem],
lineItemTotals.tax_lines,
calculationContext
)
lineItemTotals.total += lineItemTotals.tax_total
calculationContext.allocation_map = {} // Don't account for discounts
lineItemTotals.original_tax_total =
await this.taxCalculationStrategy_.calculate(
[lineItem],
lineItemTotals.tax_lines,
calculationContext
)
lineItemTotals.original_total += lineItemTotals.original_tax_total
}
return lineItemTotals
}
/**
* Gets a total for a line item. The total can take gift cards, discounts and
* taxes into account. This can be controlled through the options.
* @param lineItem - the line item to calculate a total for
* @param cartOrOrder - the cart or order to use as context for the calculation
* @param options - the options to use for the calculation
* @returns the line item total
*/
async getLineItemTotal(
lineItem: LineItem,
cartOrOrder: Cart | Order,
options: GetLineItemTotalOptions = {}
): Promise<number> {
const lineItemTotals = await this.getLineItemTotals(lineItem, cartOrOrder, {
include_tax: options.include_tax,
})
let toReturn = lineItemTotals.subtotal
if (!options.exclude_discounts) {
toReturn += lineItemTotals.discount_total
}
if (!options.exclude_gift_cards) {
toReturn += lineItemTotals.gift_card_total
}
if (options.include_tax) {
toReturn += lineItemTotals.tax_total
}
return toReturn
}
/**
* Gets the gift card amount on a cart or order.
* @param cartOrOrder - the cart or order to get gift card amount for
* @return the gift card amount applied to the cart or order
*/
getGiftCardTotal(cartOrOrder: Cart | Order): number {
const giftCardable =
this.getSubtotal(cartOrOrder) - this.getDiscountTotal(cartOrOrder)
if ("gift_card_transactions" in cartOrOrder) {
return cartOrOrder.gift_card_transactions.reduce(
(acc, next) => acc + next.amount,
0
)
}
if (!cartOrOrder.gift_cards || !cartOrOrder.gift_cards.length) {
return 0
}
const toReturn = cartOrOrder.gift_cards.reduce(
(acc, next) => acc + next.balance,
0
)
return Math.min(giftCardable, toReturn)
}
/**
* Calculates the total discount amount for each of the different supported
* discount types. If discounts aren't present or invalid returns 0.
* @param cartOrOrder - the cart or order to calculate discounts for
* @return the total discounts amount
*/
getDiscountTotal(cartOrOrder: Cart | Order): number {
const subtotal = this.getSubtotal(cartOrOrder, {
excludeNonDiscounts: true,
})
if (!cartOrOrder.discounts || !cartOrOrder.discounts.length) {
return 0
}
// we only support having free shipping and one other discount, so first
// find the discount, which is not free shipping.
const discount = cartOrOrder.discounts.find(
({ rule }) => rule.type !== "free_shipping"
)
if (!discount) {
return 0
}
const { type, allocation, value } = discount.rule
let toReturn = 0
if (type === "percentage" && allocation === "total") {
toReturn = (subtotal / 100) * value
} else if (type === "percentage" && allocation === "item") {
const itemPercentageDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
)
toReturn = _.sumBy(itemPercentageDiscounts, (d) => d.amount)
} else if (type === "fixed" && allocation === "total") {
toReturn = value
} else if (type === "fixed" && allocation === "item") {
const itemFixedDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
)
toReturn = _.sumBy(itemFixedDiscounts, (d) => d.amount)
}
if (subtotal < 0) {
return this.rounded(Math.max(subtotal, toReturn))
}
return this.rounded(Math.min(subtotal, toReturn))
}
/**
* Prepares the calculation context for a tax total calculation.
* @param cartOrOrder - the cart or order to get the calculation context for
* @param options - options to gather context by
* @return the tax calculation context
*/
getCalculationContext(
cartOrOrder: Cart | Order,
options: CalculationContextOptions = {}
): TaxCalculationContext {
const allocationMap = this.getAllocationMap(cartOrOrder, {
exclude_gift_cards: options.exclude_gift_cards,
exclude_discounts: options.exclude_discounts,
})
let shippingMethods: ShippingMethod[] = []
// Default to include shipping methods
if (!options.exclude_shipping) {
shippingMethods = cartOrOrder.shipping_methods || []
}
return {
shipping_address: cartOrOrder.shipping_address,
shipping_methods: shippingMethods,
customer: cartOrOrder.customer,
region: cartOrOrder.region,
is_return: options.is_return ?? false,
allocation_map: allocationMap,
}
}
/**
* Rounds a number using Math.round.
* @param value - the value to round
* @return the rounded value
*/
rounded(value: number): number {
return Math.round(value)
}
}
export default TotalsService
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more