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:
Sebastian Rindom
2024-02-18 14:31:50 +01:00
committed by GitHub
parent 1ba35b02dd
commit 5977a38ef4
8 changed files with 346 additions and 15 deletions

View File

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

View File

@@ -1 +1,2 @@
export { default as TaxRate } from "./tax-rate"
export { default as TaxRegion } from "./tax-region"

View File

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

View 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")
}
}

View File

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

View File

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

View File

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

View File

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