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 Rindom
2022-02-24 20:14:09 +01:00
committed by GitHub
co-authored by Oliver Windall Juhl
parent 694e2df20f
commit c56660fca9
223 changed files with 28546 additions and 2229 deletions
+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