feat(tax): Add TaxRegion (#6421)
**What**
- Adds a TaxRegion entity.
**For context: High-level design of the Tax module**
- A TaxRegion scopes tax rates to a geographical place.
- You can define tax regions on two levels: country-level, province-level (this corresponds to state in US contexts)
- Each Tax Region can have a default Tax Rate.
- [not yet done] - Each Tax Region can also have granularly defined tax rates for different products and shipping rates. For example, California can have a base rate for default products, but a reduced rate for groceries.
- Tax Rates specify if they can be combined with other rates - it's always the lowest level rate that wins.
The above allows a merchant to define their tax settings along the lines of this:
- Denmark (Region)
- Default rate: 25% (TaxRate)
- Germany (Region)
- Default rate: 19% (TaxRate)
- Reduced rate (books): 9% (TaxRate w. rule)
- United States (Region)
- Default rate: 0% (TaxRate)
- California: (Region)
- Default rate: 7.25% (TaxRate)
- Arkansas: (Region)
- Default rate: 6.5%
- Reduced rate (groceries): 0.125% (TaxRate w. rule)
The TaxModule can then receive a list of products and the shipping address to determine what tax rates apply to the line items.
This commit is contained in:
@@ -1,5 +1,95 @@
|
||||
describe("noop", function () {
|
||||
it("should run", function () {
|
||||
expect(true).toBe(true)
|
||||
import { initModules } from "medusa-test-utils"
|
||||
import { ITaxModuleService } from "@medusajs/types"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
|
||||
import { MikroOrmWrapper } from "../utils"
|
||||
import { getInitModuleConfig } from "../utils/get-init-module-config"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("TaxModuleService", function () {
|
||||
let service: ITaxModuleService
|
||||
let shutdownFunc: () => Promise<void>
|
||||
|
||||
beforeAll(async () => {
|
||||
const initModulesConfig = getInitModuleConfig()
|
||||
|
||||
const { medusaApp, shutdown } = await initModules(initModulesConfig)
|
||||
|
||||
service = medusaApp.modules[Modules.TAX]
|
||||
|
||||
shutdownFunc = shutdown
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownFunc()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await MikroOrmWrapper.setupDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await MikroOrmWrapper.clearDatabase()
|
||||
})
|
||||
|
||||
it("should create a tax region", async () => {
|
||||
const [region] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: {
|
||||
name: "Test Rate",
|
||||
rate: 0.2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [provinceRegion] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
default_tax_rate: {
|
||||
name: "CA Rate",
|
||||
rate: 8.25,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const listedRegions = await service.listTaxRegions()
|
||||
expect(listedRegions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: region.id,
|
||||
country_code: "US",
|
||||
province_code: null,
|
||||
parent_id: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: provinceRegion.id,
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const rates = await service.list()
|
||||
expect(rates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tax_region_id: region.id,
|
||||
rate: 0.2,
|
||||
name: "Test Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tax_region_id: provinceRegion.id,
|
||||
rate: 8.25,
|
||||
name: "CA Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as TaxRate } from "./tax-rate"
|
||||
export { default as TaxRegion } from "./tax-region"
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { DAL } from "@medusajs/types"
|
||||
import { generateEntityId } from "@medusajs/utils"
|
||||
import {
|
||||
createPsqlIndexStatementHelper,
|
||||
generateEntityId,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
BeforeCreate,
|
||||
Cascade,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OnInit,
|
||||
OptionalProps,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from "@mikro-orm/core"
|
||||
import TaxRegion from "./tax-region"
|
||||
|
||||
type OptionalTaxRateProps = DAL.EntityDateColumns
|
||||
|
||||
@Entity({ tableName: "tax_rate" })
|
||||
const TABLE_NAME = "tax_rate"
|
||||
|
||||
const taxRegionIdIndexName = "IDX_tax_rate_tax_region_id"
|
||||
|
||||
const singleDefaultRegionIndexName = "IDX_single_default_region"
|
||||
const singleDefaultRegionIndexStatement = createPsqlIndexStatementHelper({
|
||||
name: singleDefaultRegionIndexName,
|
||||
tableName: TABLE_NAME,
|
||||
columns: "tax_region_id",
|
||||
unique: true,
|
||||
where: "is_default = true",
|
||||
})
|
||||
|
||||
@singleDefaultRegionIndexStatement.MikroORMIndex()
|
||||
@Entity({ tableName: TABLE_NAME })
|
||||
export default class TaxRate {
|
||||
[OptionalProps]: OptionalTaxRateProps
|
||||
|
||||
@@ -27,6 +47,22 @@ export default class TaxRate {
|
||||
@Property({ columnType: "text" })
|
||||
name: string
|
||||
|
||||
@Property({ columnType: "bool", default: false })
|
||||
is_default = false
|
||||
|
||||
@Property({ columnType: "bool", default: false })
|
||||
is_combinable = false
|
||||
|
||||
@Property({ columnType: "text" })
|
||||
tax_region_id: string
|
||||
|
||||
@ManyToOne(() => TaxRegion, {
|
||||
fieldName: "tax_region_id",
|
||||
index: taxRegionIdIndexName,
|
||||
cascade: [Cascade.REMOVE, Cascade.PERSIST],
|
||||
})
|
||||
tax_region: TaxRegion
|
||||
|
||||
@Property({ columnType: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown> | null = null
|
||||
|
||||
|
||||
96
packages/tax/src/models/tax-region.ts
Normal file
96
packages/tax/src/models/tax-region.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { DAL } from "@medusajs/types"
|
||||
import {
|
||||
createPsqlIndexStatementHelper,
|
||||
generateEntityId,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
BeforeCreate,
|
||||
Collection,
|
||||
Entity,
|
||||
OnInit,
|
||||
OptionalProps,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
Check,
|
||||
Cascade,
|
||||
} from "@mikro-orm/core"
|
||||
import TaxRate from "./tax-rate"
|
||||
|
||||
type OptionalTaxRegionProps = DAL.EntityDateColumns
|
||||
|
||||
const TABLE_NAME = "tax_region"
|
||||
|
||||
const countryCodeProvinceIndexName = "IDX_tax_region_unique_country_province"
|
||||
const countryCodeProvinceIndexStatement = createPsqlIndexStatementHelper({
|
||||
name: countryCodeProvinceIndexName,
|
||||
tableName: TABLE_NAME,
|
||||
columns: ["country_code", "province_code"],
|
||||
unique: true,
|
||||
})
|
||||
|
||||
const taxRegionCountryTopLevelCheckName = "CK_tax_region_country_top_level"
|
||||
@Check({
|
||||
name: taxRegionCountryTopLevelCheckName,
|
||||
expression: `parent_id IS NULL OR province_code IS NOT NULL`,
|
||||
})
|
||||
@countryCodeProvinceIndexStatement.MikroORMIndex()
|
||||
@Entity({ tableName: TABLE_NAME })
|
||||
export default class TaxRegion {
|
||||
[OptionalProps]: OptionalTaxRegionProps
|
||||
|
||||
@PrimaryKey({ columnType: "text" })
|
||||
id!: string
|
||||
|
||||
@Property({ columnType: "text" })
|
||||
country_code: string
|
||||
|
||||
@Property({ columnType: "text", nullable: true })
|
||||
province_code: string | null = null
|
||||
|
||||
@Property({ columnType: "text", nullable: true })
|
||||
parent_id: string | null = null
|
||||
|
||||
@ManyToOne(() => TaxRegion, {
|
||||
index: "IDX_tax_region_parent_id",
|
||||
cascade: [Cascade.PERSIST],
|
||||
onDelete: "set null",
|
||||
nullable: true,
|
||||
})
|
||||
parent: TaxRegion
|
||||
|
||||
@OneToMany(() => TaxRate, (label) => label.tax_region)
|
||||
tax_rates = new Collection<TaxRate>(this)
|
||||
|
||||
@Property({ columnType: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown> | null = null
|
||||
|
||||
@Property({
|
||||
onCreate: () => new Date(),
|
||||
columnType: "timestamptz",
|
||||
defaultRaw: "now()",
|
||||
})
|
||||
created_at: Date
|
||||
|
||||
@Property({
|
||||
onCreate: () => new Date(),
|
||||
onUpdate: () => new Date(),
|
||||
columnType: "timestamptz",
|
||||
defaultRaw: "now()",
|
||||
})
|
||||
updated_at: Date
|
||||
|
||||
@Property({ columnType: "text", nullable: true })
|
||||
created_by: string | null = null
|
||||
|
||||
@BeforeCreate()
|
||||
onCreate() {
|
||||
this.id = generateEntityId(this.id, "txreg")
|
||||
}
|
||||
|
||||
@OnInit()
|
||||
onInit() {
|
||||
this.id = generateEntityId(this.id, "txreg")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Context,
|
||||
DAL,
|
||||
ITaxRateModuleService,
|
||||
ITaxModuleService,
|
||||
InternalModuleDeclaration,
|
||||
ModuleJoinerConfig,
|
||||
ModulesSdkTypes,
|
||||
@@ -13,27 +13,32 @@ import {
|
||||
MedusaContext,
|
||||
ModulesSdkUtils,
|
||||
} from "@medusajs/utils"
|
||||
import { TaxRate } from "@models"
|
||||
import { TaxRate, TaxRegion } from "@models"
|
||||
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
||||
|
||||
type InjectedDependencies = {
|
||||
baseRepository: DAL.RepositoryService
|
||||
taxRateService: ModulesSdkTypes.InternalModuleService<any>
|
||||
taxRegionService: ModulesSdkTypes.InternalModuleService<any>
|
||||
}
|
||||
|
||||
export default class TaxModuleService<TTaxRate extends TaxRate = TaxRate>
|
||||
export default class TaxModuleService<
|
||||
TTaxRate extends TaxRate = TaxRate,
|
||||
TTaxRegion extends TaxRegion = TaxRegion
|
||||
>
|
||||
extends ModulesSdkUtils.abstractModuleServiceFactory<
|
||||
InjectedDependencies,
|
||||
TaxTypes.TaxRateDTO,
|
||||
{}
|
||||
>(TaxRate, [], entityNameToLinkableKeysMap)
|
||||
implements ITaxRateModuleService
|
||||
{ TaxRegion: { dto: TaxTypes.TaxRegionDTO } }
|
||||
>(TaxRate, [TaxRegion], entityNameToLinkableKeysMap)
|
||||
implements ITaxModuleService
|
||||
{
|
||||
protected baseRepository_: DAL.RepositoryService
|
||||
protected taxRateService_: ModulesSdkTypes.InternalModuleService<TTaxRate>
|
||||
protected taxRegionService_: ModulesSdkTypes.InternalModuleService<TTaxRegion>
|
||||
|
||||
constructor(
|
||||
{ baseRepository, taxRateService }: InjectedDependencies,
|
||||
{ baseRepository, taxRateService, taxRegionService }: InjectedDependencies,
|
||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||
) {
|
||||
// @ts-ignore
|
||||
@@ -41,6 +46,7 @@ export default class TaxModuleService<TTaxRate extends TaxRate = TaxRate>
|
||||
|
||||
this.baseRepository_ = baseRepository
|
||||
this.taxRateService_ = taxRateService
|
||||
this.taxRegionService_ = taxRegionService
|
||||
}
|
||||
|
||||
__joinerConfig(): ModuleJoinerConfig {
|
||||
@@ -78,4 +84,48 @@ export default class TaxModuleService<TTaxRate extends TaxRate = TaxRate>
|
||||
) {
|
||||
return await this.taxRateService_.create(data, sharedContext)
|
||||
}
|
||||
|
||||
async createTaxRegions(
|
||||
data: TaxTypes.CreateTaxRegionDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TaxTypes.TaxRegionDTO[]> {
|
||||
// TODO: check that country_code === parent.country_code
|
||||
const [defaultRates, regionData] = data.reduce(
|
||||
(acc, region) => {
|
||||
const { default_tax_rate, ...rest } = region
|
||||
acc[0].push({
|
||||
...default_tax_rate,
|
||||
is_default: true,
|
||||
created_by: region.created_by,
|
||||
})
|
||||
acc[1].push(rest)
|
||||
return acc
|
||||
},
|
||||
[[], []] as [
|
||||
Omit<TaxTypes.CreateTaxRateDTO, "tax_region_id">[],
|
||||
Partial<TaxTypes.CreateTaxRegionDTO>[]
|
||||
]
|
||||
)
|
||||
|
||||
const regions = await this.taxRegionService_.create(
|
||||
regionData,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const rates = regions.map((region, i) => {
|
||||
return {
|
||||
...defaultRates[i],
|
||||
tax_region_id: region.id,
|
||||
}
|
||||
})
|
||||
|
||||
await this.create(rates, sharedContext)
|
||||
|
||||
return await this.baseRepository_.serialize<TaxTypes.TaxRegionDTO[]>(
|
||||
regions,
|
||||
{
|
||||
populate: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,29 @@ export interface FilterableTaxRateProps
|
||||
updated_at?: OperatorMap<string>
|
||||
created_by?: string | string[] | OperatorMap<string>
|
||||
}
|
||||
|
||||
export interface TaxRegionDTO {
|
||||
id: string
|
||||
country_code: string
|
||||
province_code: string | null
|
||||
parent_id: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string | Date
|
||||
updated_at: string | Date
|
||||
created_by: string | null
|
||||
}
|
||||
|
||||
export interface FilterableTaxRegionProps
|
||||
extends BaseFilterable<FilterableTaxRegionProps> {
|
||||
id?: string | string[]
|
||||
country_code?: string | string[] | OperatorMap<string>
|
||||
province_code?: string | string[] | OperatorMap<string>
|
||||
parent_id?: string | string[] | OperatorMap<string>
|
||||
metadata?:
|
||||
| Record<string, unknown>
|
||||
| Record<string, unknown>[]
|
||||
| OperatorMap<Record<string, unknown>>
|
||||
created_at?: OperatorMap<string>
|
||||
updated_at?: OperatorMap<string>
|
||||
created_by?: string | string[] | OperatorMap<string>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
export interface CreateTaxRateDTO {
|
||||
tax_region_id: string
|
||||
rate?: number | null
|
||||
code?: string | null
|
||||
name: string
|
||||
is_default?: boolean
|
||||
created_by?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface CreateTaxRegionDTO {
|
||||
country_code: string
|
||||
province_code?: string | null
|
||||
parent_id?: string | null
|
||||
metadata?: Record<string, unknown>
|
||||
created_by?: string
|
||||
default_tax_rate: {
|
||||
rate?: number | null
|
||||
code?: string | null
|
||||
name: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { FindConfig } from "../common"
|
||||
import { IModuleService } from "../modules-sdk"
|
||||
import { Context } from "../shared-context"
|
||||
import { FilterableTaxRateProps, TaxRateDTO } from "./common"
|
||||
import { CreateTaxRateDTO } from "./mutations"
|
||||
import {
|
||||
FilterableTaxRegionProps,
|
||||
FilterableTaxRateProps,
|
||||
TaxRateDTO,
|
||||
TaxRegionDTO,
|
||||
} from "./common"
|
||||
import { CreateTaxRateDTO, CreateTaxRegionDTO } from "./mutations"
|
||||
|
||||
export interface ITaxRateModuleService extends IModuleService {
|
||||
export interface ITaxModuleService extends IModuleService {
|
||||
retrieve(
|
||||
taxRateId: string,
|
||||
config?: FindConfig<TaxRateDTO>,
|
||||
@@ -31,4 +36,15 @@ export interface ITaxRateModuleService extends IModuleService {
|
||||
|
||||
delete(taxRateIds: string[], sharedContext?: Context): Promise<void>
|
||||
delete(taxRateId: string, sharedContext?: Context): Promise<void>
|
||||
|
||||
createTaxRegions(
|
||||
data: CreateTaxRegionDTO[],
|
||||
sharedContext?: Context
|
||||
): Promise<TaxRegionDTO[]>
|
||||
|
||||
listTaxRegions(
|
||||
filters?: FilterableTaxRegionProps,
|
||||
config?: FindConfig<TaxRegionDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<TaxRegionDTO[]>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user