@porulle/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +1 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +5 -0
- package/dist/config/types.d.ts +17 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +16 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/staff.js +172 -0
- package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
- package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/analytics.js +24 -0
- package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/carts.js +39 -2
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -1
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +17 -0
- package/dist/interfaces/rest/routes/documents.d.ts +10 -0
- package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/documents.js +40 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +98 -1
- package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/pricing.js +36 -1
- package/dist/interfaces/rest/routes/settings.d.ts +5 -0
- package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/settings.js +50 -0
- package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
- package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/shipping.js +68 -0
- package/dist/interfaces/rest/routes/tax.d.ts +5 -0
- package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/tax.js +68 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/analytics.js +33 -0
- package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/carts.js +38 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +48 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/checkout.js +4 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
- package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/documents.js +65 -0
- package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +233 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/pricing.js +70 -0
- package/dist/interfaces/rest/schemas/responses.d.ts +156 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
- package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/settings.js +79 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/shipping.js +146 -0
- package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
- package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/tax.js +132 -0
- package/dist/kernel/database/schema.d.ts +4 -0
- package/dist/kernel/database/schema.d.ts.map +1 -1
- package/dist/kernel/database/schema.js +8 -0
- package/dist/kernel/hooks/types.d.ts +1 -1
- package/dist/kernel/hooks/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +23 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +17 -2
- package/dist/modules/analytics/module.d.ts.map +1 -1
- package/dist/modules/analytics/module.js +6 -0
- package/dist/modules/analytics/reports.d.ts +58 -0
- package/dist/modules/analytics/reports.d.ts.map +1 -0
- package/dist/modules/analytics/reports.js +249 -0
- package/dist/modules/analytics/service.d.ts +10 -0
- package/dist/modules/analytics/service.d.ts.map +1 -1
- package/dist/modules/analytics/service.js +14 -0
- package/dist/modules/cart/repository/index.d.ts +7 -0
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +25 -1
- package/dist/modules/cart/schema.d.ts +17 -0
- package/dist/modules/cart/schema.d.ts.map +1 -1
- package/dist/modules/cart/schema.js +2 -0
- package/dist/modules/cart/schemas.d.ts +1 -0
- package/dist/modules/cart/schemas.d.ts.map +1 -1
- package/dist/modules/cart/schemas.js +1 -0
- package/dist/modules/cart/service.d.ts +32 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +58 -0
- package/dist/modules/catalog/entity-service.d.ts +21 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +132 -3
- package/dist/modules/catalog/schema.d.ts +34 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +5 -0
- package/dist/modules/catalog/schemas.d.ts +3 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +3 -0
- package/dist/modules/catalog/service.d.ts +36 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +6 -0
- package/dist/modules/documents/module.d.ts +7 -0
- package/dist/modules/documents/module.d.ts.map +1 -0
- package/dist/modules/documents/module.js +12 -0
- package/dist/modules/documents/pdf.d.ts +25 -0
- package/dist/modules/documents/pdf.d.ts.map +1 -0
- package/dist/modules/documents/pdf.js +80 -0
- package/dist/modules/documents/render.d.ts +32 -0
- package/dist/modules/documents/render.d.ts.map +1 -0
- package/dist/modules/documents/render.js +190 -0
- package/dist/modules/documents/repository/index.d.ts +25 -0
- package/dist/modules/documents/repository/index.d.ts.map +1 -0
- package/dist/modules/documents/repository/index.js +47 -0
- package/dist/modules/documents/schema.d.ts +214 -0
- package/dist/modules/documents/schema.d.ts.map +1 -0
- package/dist/modules/documents/schema.js +42 -0
- package/dist/modules/documents/service.d.ts +51 -0
- package/dist/modules/documents/service.d.ts.map +1 -0
- package/dist/modules/documents/service.js +119 -0
- package/dist/modules/fulfillment/service.d.ts +18 -0
- package/dist/modules/fulfillment/service.d.ts.map +1 -1
- package/dist/modules/fulfillment/service.js +85 -1
- package/dist/modules/orders/repository/index.d.ts +18 -1
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +86 -1
- package/dist/modules/orders/schema.d.ts +374 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +52 -1
- package/dist/modules/orders/service.d.ts +86 -1
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +447 -20
- package/dist/modules/pricing/repository/index.d.ts +5 -0
- package/dist/modules/pricing/repository/index.d.ts.map +1 -1
- package/dist/modules/pricing/repository/index.js +19 -0
- package/dist/modules/pricing/service.d.ts +18 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +38 -0
- package/dist/modules/settings/module.d.ts +6 -0
- package/dist/modules/settings/module.d.ts.map +1 -0
- package/dist/modules/settings/module.js +11 -0
- package/dist/modules/settings/repository/index.d.ts +16 -0
- package/dist/modules/settings/repository/index.d.ts.map +1 -0
- package/dist/modules/settings/repository/index.js +41 -0
- package/dist/modules/settings/schema.d.ts +121 -0
- package/dist/modules/settings/schema.d.ts.map +1 -0
- package/dist/modules/settings/schema.js +24 -0
- package/dist/modules/settings/service.d.ts +26 -0
- package/dist/modules/settings/service.d.ts.map +1 -0
- package/dist/modules/settings/service.js +51 -0
- package/dist/modules/shipping/calculator.d.ts +5 -0
- package/dist/modules/shipping/calculator.d.ts.map +1 -1
- package/dist/modules/shipping/calculator.js +10 -0
- package/dist/modules/shipping/module.d.ts +5 -1
- package/dist/modules/shipping/module.d.ts.map +1 -1
- package/dist/modules/shipping/module.js +4 -1
- package/dist/modules/shipping/repository/index.d.ts +27 -2
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.js +105 -1
- package/dist/modules/shipping/schema.d.ts +419 -0
- package/dist/modules/shipping/schema.d.ts.map +1 -0
- package/dist/modules/shipping/schema.js +56 -0
- package/dist/modules/shipping/service.d.ts +54 -0
- package/dist/modules/shipping/service.d.ts.map +1 -1
- package/dist/modules/shipping/service.js +161 -2
- package/dist/modules/tax/adapter.d.ts +13 -0
- package/dist/modules/tax/adapter.d.ts.map +1 -1
- package/dist/modules/tax/module.d.ts +5 -1
- package/dist/modules/tax/module.d.ts.map +1 -1
- package/dist/modules/tax/module.js +4 -1
- package/dist/modules/tax/repository/index.d.ts +38 -2
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.js +138 -1
- package/dist/modules/tax/schema.d.ts +355 -0
- package/dist/modules/tax/schema.d.ts.map +1 -0
- package/dist/modules/tax/schema.js +55 -0
- package/dist/modules/tax/service.d.ts +60 -1
- package/dist/modules/tax/service.d.ts.map +1 -1
- package/dist/modules/tax/service.js +227 -2
- package/dist/runtime/kernel-modules.d.ts +15 -2
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/dist/runtime/kernel-modules.js +4 -0
- package/dist/runtime/kernel-types.d.ts +5 -1
- package/dist/runtime/kernel-types.d.ts.map +1 -1
- package/dist/runtime/kernel-types.js +2 -0
- package/dist/runtime/server.js +1 -1
- package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +15 -3
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +6 -0
- package/package.json +3 -3
- package/src/auth/setup.ts +1 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/types.ts +17 -1
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/hooks/checkout.ts +25 -3
- package/src/index.ts +1 -0
- package/src/interfaces/rest/index.ts +12 -0
- package/src/interfaces/rest/routes/admin/staff.ts +218 -0
- package/src/interfaces/rest/routes/analytics.ts +33 -0
- package/src/interfaces/rest/routes/carts.ts +52 -2
- package/src/interfaces/rest/routes/catalog.ts +34 -0
- package/src/interfaces/rest/routes/checkout.ts +22 -0
- package/src/interfaces/rest/routes/documents.ts +61 -0
- package/src/interfaces/rest/routes/orders.ts +137 -1
- package/src/interfaces/rest/routes/pricing.ts +44 -1
- package/src/interfaces/rest/routes/settings.ts +61 -0
- package/src/interfaces/rest/routes/shipping.ts +92 -0
- package/src/interfaces/rest/routes/tax.ts +90 -0
- package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
- package/src/interfaces/rest/schemas/analytics.ts +39 -0
- package/src/interfaces/rest/schemas/carts.ts +40 -0
- package/src/interfaces/rest/schemas/catalog.ts +53 -0
- package/src/interfaces/rest/schemas/checkout.ts +4 -0
- package/src/interfaces/rest/schemas/documents.ts +72 -0
- package/src/interfaces/rest/schemas/orders.ts +257 -0
- package/src/interfaces/rest/schemas/pricing.ts +75 -0
- package/src/interfaces/rest/schemas/settings.ts +93 -0
- package/src/interfaces/rest/schemas/shipping.ts +164 -0
- package/src/interfaces/rest/schemas/tax.ts +148 -0
- package/src/kernel/database/schema.ts +12 -0
- package/src/kernel/hooks/types.ts +1 -0
- package/src/kernel/plugin/manifest.ts +38 -2
- package/src/modules/analytics/module.ts +10 -0
- package/src/modules/analytics/reports.ts +311 -0
- package/src/modules/analytics/service.ts +23 -0
- package/src/modules/cart/repository/index.ts +30 -1
- package/src/modules/cart/schema.ts +2 -0
- package/src/modules/cart/schemas.ts +1 -0
- package/src/modules/cart/service.ts +101 -0
- package/src/modules/catalog/entity-service.ts +168 -3
- package/src/modules/catalog/schema.ts +5 -0
- package/src/modules/catalog/schemas.ts +3 -0
- package/src/modules/catalog/service.ts +30 -0
- package/src/modules/documents/module.ts +19 -0
- package/src/modules/documents/pdf.ts +101 -0
- package/src/modules/documents/render.ts +231 -0
- package/src/modules/documents/repository/index.ts +68 -0
- package/src/modules/documents/schema.ts +44 -0
- package/src/modules/documents/service.ts +176 -0
- package/src/modules/fulfillment/service.ts +139 -1
- package/src/modules/orders/repository/index.ts +127 -1
- package/src/modules/orders/schema.ts +54 -1
- package/src/modules/orders/service.ts +654 -22
- package/src/modules/pricing/repository/index.ts +34 -0
- package/src/modules/pricing/service.ts +71 -0
- package/src/modules/settings/module.ts +18 -0
- package/src/modules/settings/repository/index.ts +59 -0
- package/src/modules/settings/schema.ts +25 -0
- package/src/modules/settings/service.ts +82 -0
- package/src/modules/shipping/calculator.ts +27 -0
- package/src/modules/shipping/module.ts +6 -2
- package/src/modules/shipping/repository/index.ts +136 -2
- package/src/modules/shipping/schema.ts +58 -0
- package/src/modules/shipping/service.ts +260 -1
- package/src/modules/tax/adapter.ts +9 -0
- package/src/modules/tax/module.ts +6 -2
- package/src/modules/tax/repository/index.ts +169 -2
- package/src/modules/tax/schema.ts +57 -0
- package/src/modules/tax/service.ts +313 -2
- package/src/runtime/kernel-modules.ts +4 -0
- package/src/runtime/kernel-types.ts +6 -0
- package/src/runtime/server.ts +1 -1
- package/src/test-utils/create-plugin-test-app.ts +17 -4
- package/src/test-utils/rest-api-test-utils.ts +6 -0
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import type { CommerceConfig } from "../../config/types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveOrgId } from "../../auth/org.js";
|
|
3
|
+
import type { Actor } from "../../auth/types.js";
|
|
4
|
+
import { CommerceNotFoundError, CommerceValidationError } from "../../kernel/errors.js";
|
|
5
|
+
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
3
6
|
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
4
7
|
import type { CatalogRepository } from "../catalog/repository/index.js";
|
|
5
8
|
import {
|
|
6
9
|
calculateShippingCost,
|
|
10
|
+
computeShippableWeightGrams,
|
|
7
11
|
type ShippingAddress,
|
|
8
12
|
type ShippingLineItem,
|
|
9
13
|
} from "./calculator.js";
|
|
14
|
+
import type {
|
|
15
|
+
ShippingConfigRepository,
|
|
16
|
+
ShippingZone,
|
|
17
|
+
ShippingRate,
|
|
18
|
+
} from "./repository/index.js";
|
|
10
19
|
|
|
11
20
|
interface ShippingServiceDeps {
|
|
12
21
|
config: CommerceConfig;
|
|
13
22
|
catalogRepository: CatalogRepository;
|
|
23
|
+
repository: ShippingConfigRepository;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
export interface CalculateShippingInput {
|
|
@@ -19,6 +29,8 @@ export interface CalculateShippingInput {
|
|
|
19
29
|
currency: string;
|
|
20
30
|
address?: ShippingAddress;
|
|
21
31
|
isFreeShipping?: boolean;
|
|
32
|
+
/** When set, runtime shipping zones for this org take precedence over code config. */
|
|
33
|
+
orgId?: string;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export class ShippingService {
|
|
@@ -34,6 +46,16 @@ export class ShippingService {
|
|
|
34
46
|
): Promise<
|
|
35
47
|
Result<{ amount: number; strategy: string; weightGrams: number }>
|
|
36
48
|
> {
|
|
49
|
+
// Runtime zones (issue #45) take precedence when they exist and the
|
|
50
|
+
// destination is known; the code-config strategy remains the fallback.
|
|
51
|
+
if (!input.isFreeShipping && input.orgId && input.address) {
|
|
52
|
+
const zoneResult = await this.calculateFromZones(
|
|
53
|
+
input as CalculateShippingInput & { orgId: string; address: ShippingAddress },
|
|
54
|
+
ctx,
|
|
55
|
+
);
|
|
56
|
+
if (zoneResult) return Ok(zoneResult);
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
const result = await calculateShippingCost(
|
|
38
60
|
this.deps.config,
|
|
39
61
|
this.catalogRepo,
|
|
@@ -48,4 +70,241 @@ export class ShippingService {
|
|
|
48
70
|
);
|
|
49
71
|
return Ok(result);
|
|
50
72
|
}
|
|
73
|
+
|
|
74
|
+
private zoneMatches(zone: ShippingZone, address: ShippingAddress): boolean {
|
|
75
|
+
const country = address.country?.toUpperCase();
|
|
76
|
+
const countries = zone.countries.map((c) => c.toUpperCase());
|
|
77
|
+
if (!countries.includes("*") && (!country || !countries.includes(country))) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (zone.states.length > 0) {
|
|
81
|
+
const state = address.state?.toUpperCase();
|
|
82
|
+
if (!state || !zone.states.map((s) => s.toUpperCase()).includes(state)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async calculateFromZones(
|
|
90
|
+
input: CalculateShippingInput & { orgId: string; address: ShippingAddress },
|
|
91
|
+
ctx?: TxContext,
|
|
92
|
+
): Promise<{ amount: number; strategy: string; weightGrams: number } | null> {
|
|
93
|
+
const zones = await this.deps.repository.findActiveZones(input.orgId, ctx);
|
|
94
|
+
if (zones.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
let weightGrams: number | null = null;
|
|
97
|
+
for (const zone of zones) {
|
|
98
|
+
if (!this.zoneMatches(zone, input.address)) continue;
|
|
99
|
+
const rates = (
|
|
100
|
+
await this.deps.repository.findActiveRatesByZoneId(zone.id, ctx)
|
|
101
|
+
).filter((rate) => rate.currency === input.currency);
|
|
102
|
+
|
|
103
|
+
for (const rate of rates) {
|
|
104
|
+
const sub = input.subtotalAfterDiscount;
|
|
105
|
+
if (rate.minSubtotal != null && sub < rate.minSubtotal) continue;
|
|
106
|
+
if (rate.maxSubtotal != null && sub > rate.maxSubtotal) continue;
|
|
107
|
+
if (rate.minWeightGrams != null || rate.maxWeightGrams != null) {
|
|
108
|
+
weightGrams ??= await computeShippableWeightGrams(
|
|
109
|
+
this.deps.config,
|
|
110
|
+
this.catalogRepo,
|
|
111
|
+
input.lineItems,
|
|
112
|
+
ctx,
|
|
113
|
+
);
|
|
114
|
+
if (rate.minWeightGrams != null && weightGrams < rate.minWeightGrams) continue;
|
|
115
|
+
if (rate.maxWeightGrams != null && weightGrams > rate.maxWeightGrams) continue;
|
|
116
|
+
}
|
|
117
|
+
if (rate.freeShippingThreshold != null && sub >= rate.freeShippingThreshold) {
|
|
118
|
+
return {
|
|
119
|
+
amount: 0,
|
|
120
|
+
strategy: `zone:${zone.name}:free_shipping`,
|
|
121
|
+
weightGrams: weightGrams ?? 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
amount: Math.max(0, Math.round(rate.amount)),
|
|
126
|
+
strategy: `zone:${zone.name}:${rate.name}`,
|
|
127
|
+
weightGrams: weightGrams ?? 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Runtime zone & rate management (issue #45) ─────────────────────────
|
|
135
|
+
|
|
136
|
+
async createZone(
|
|
137
|
+
input: {
|
|
138
|
+
name: string;
|
|
139
|
+
countries: string[];
|
|
140
|
+
states?: string[] | undefined;
|
|
141
|
+
priority?: number | undefined;
|
|
142
|
+
isActive?: boolean | undefined;
|
|
143
|
+
},
|
|
144
|
+
actor?: Actor | null,
|
|
145
|
+
ctx?: TxContext,
|
|
146
|
+
): Promise<Result<ShippingZone>> {
|
|
147
|
+
if (input.countries.length === 0) {
|
|
148
|
+
return Err(new CommerceValidationError("A shipping zone requires at least one country (\"*\" matches any)."));
|
|
149
|
+
}
|
|
150
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
151
|
+
const zone = await this.deps.repository.createZone(
|
|
152
|
+
{
|
|
153
|
+
organizationId: orgId,
|
|
154
|
+
name: input.name,
|
|
155
|
+
countries: input.countries.map((c) => c.toUpperCase()),
|
|
156
|
+
states: (input.states ?? []).map((s) => s.toUpperCase()),
|
|
157
|
+
priority: input.priority ?? 100,
|
|
158
|
+
isActive: input.isActive ?? true,
|
|
159
|
+
},
|
|
160
|
+
ctx,
|
|
161
|
+
);
|
|
162
|
+
return Ok(zone);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async listZones(actor?: Actor | null, ctx?: TxContext): Promise<Result<ShippingZone[]>> {
|
|
166
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
167
|
+
return Ok(await this.deps.repository.findZones(orgId, ctx));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async updateZone(
|
|
171
|
+
id: string,
|
|
172
|
+
patch: {
|
|
173
|
+
name?: string | undefined;
|
|
174
|
+
countries?: string[] | undefined;
|
|
175
|
+
states?: string[] | undefined;
|
|
176
|
+
priority?: number | undefined;
|
|
177
|
+
isActive?: boolean | undefined;
|
|
178
|
+
},
|
|
179
|
+
actor?: Actor | null,
|
|
180
|
+
ctx?: TxContext,
|
|
181
|
+
): Promise<Result<ShippingZone>> {
|
|
182
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
183
|
+
const existing = await this.deps.repository.findZoneById(orgId, id, ctx);
|
|
184
|
+
if (!existing) return Err(new CommerceNotFoundError("Shipping zone not found."));
|
|
185
|
+
const updated = await this.deps.repository.updateZone(
|
|
186
|
+
id,
|
|
187
|
+
{
|
|
188
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
189
|
+
...(patch.countries !== undefined
|
|
190
|
+
? { countries: patch.countries.map((c) => c.toUpperCase()) }
|
|
191
|
+
: {}),
|
|
192
|
+
...(patch.states !== undefined
|
|
193
|
+
? { states: patch.states.map((s) => s.toUpperCase()) }
|
|
194
|
+
: {}),
|
|
195
|
+
...(patch.priority !== undefined ? { priority: patch.priority } : {}),
|
|
196
|
+
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
|
|
197
|
+
},
|
|
198
|
+
ctx,
|
|
199
|
+
);
|
|
200
|
+
return Ok(updated!);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async deleteZone(id: string, actor?: Actor | null, ctx?: TxContext): Promise<Result<{ deleted: true }>> {
|
|
204
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
205
|
+
const existing = await this.deps.repository.findZoneById(orgId, id, ctx);
|
|
206
|
+
if (!existing) return Err(new CommerceNotFoundError("Shipping zone not found."));
|
|
207
|
+
await this.deps.repository.deleteZone(id, ctx);
|
|
208
|
+
return Ok({ deleted: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async createRate(
|
|
212
|
+
input: {
|
|
213
|
+
zoneId: string;
|
|
214
|
+
name: string;
|
|
215
|
+
amount: number;
|
|
216
|
+
currency?: string | undefined;
|
|
217
|
+
minSubtotal?: number | undefined;
|
|
218
|
+
maxSubtotal?: number | undefined;
|
|
219
|
+
minWeightGrams?: number | undefined;
|
|
220
|
+
maxWeightGrams?: number | undefined;
|
|
221
|
+
freeShippingThreshold?: number | undefined;
|
|
222
|
+
isActive?: boolean | undefined;
|
|
223
|
+
},
|
|
224
|
+
actor?: Actor | null,
|
|
225
|
+
ctx?: TxContext,
|
|
226
|
+
): Promise<Result<ShippingRate>> {
|
|
227
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
228
|
+
const zone = await this.deps.repository.findZoneById(orgId, input.zoneId, ctx);
|
|
229
|
+
if (!zone) return Err(new CommerceNotFoundError("Shipping zone not found."));
|
|
230
|
+
const rate = await this.deps.repository.createRate(
|
|
231
|
+
{
|
|
232
|
+
organizationId: orgId,
|
|
233
|
+
zoneId: input.zoneId,
|
|
234
|
+
name: input.name,
|
|
235
|
+
amount: input.amount,
|
|
236
|
+
currency: input.currency ?? "USD",
|
|
237
|
+
minSubtotal: input.minSubtotal ?? null,
|
|
238
|
+
maxSubtotal: input.maxSubtotal ?? null,
|
|
239
|
+
minWeightGrams: input.minWeightGrams ?? null,
|
|
240
|
+
maxWeightGrams: input.maxWeightGrams ?? null,
|
|
241
|
+
freeShippingThreshold: input.freeShippingThreshold ?? null,
|
|
242
|
+
isActive: input.isActive ?? true,
|
|
243
|
+
},
|
|
244
|
+
ctx,
|
|
245
|
+
);
|
|
246
|
+
return Ok(rate);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async listRates(
|
|
250
|
+
filter?: { zoneId?: string },
|
|
251
|
+
actor?: Actor | null,
|
|
252
|
+
ctx?: TxContext,
|
|
253
|
+
): Promise<Result<ShippingRate[]>> {
|
|
254
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
255
|
+
return Ok(
|
|
256
|
+
await this.deps.repository.findRates(
|
|
257
|
+
orgId,
|
|
258
|
+
filter?.zoneId !== undefined ? { zoneId: filter.zoneId } : undefined,
|
|
259
|
+
ctx,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async updateRate(
|
|
265
|
+
id: string,
|
|
266
|
+
patch: {
|
|
267
|
+
name?: string | undefined;
|
|
268
|
+
amount?: number | undefined;
|
|
269
|
+
currency?: string | undefined;
|
|
270
|
+
minSubtotal?: number | null | undefined;
|
|
271
|
+
maxSubtotal?: number | null | undefined;
|
|
272
|
+
minWeightGrams?: number | null | undefined;
|
|
273
|
+
maxWeightGrams?: number | null | undefined;
|
|
274
|
+
freeShippingThreshold?: number | null | undefined;
|
|
275
|
+
isActive?: boolean | undefined;
|
|
276
|
+
},
|
|
277
|
+
actor?: Actor | null,
|
|
278
|
+
ctx?: TxContext,
|
|
279
|
+
): Promise<Result<ShippingRate>> {
|
|
280
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
281
|
+
const existing = await this.deps.repository.findRateById(orgId, id, ctx);
|
|
282
|
+
if (!existing) return Err(new CommerceNotFoundError("Shipping rate not found."));
|
|
283
|
+
const updated = await this.deps.repository.updateRate(
|
|
284
|
+
id,
|
|
285
|
+
{
|
|
286
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
287
|
+
...(patch.amount !== undefined ? { amount: patch.amount } : {}),
|
|
288
|
+
...(patch.currency !== undefined ? { currency: patch.currency } : {}),
|
|
289
|
+
...(patch.minSubtotal !== undefined ? { minSubtotal: patch.minSubtotal } : {}),
|
|
290
|
+
...(patch.maxSubtotal !== undefined ? { maxSubtotal: patch.maxSubtotal } : {}),
|
|
291
|
+
...(patch.minWeightGrams !== undefined ? { minWeightGrams: patch.minWeightGrams } : {}),
|
|
292
|
+
...(patch.maxWeightGrams !== undefined ? { maxWeightGrams: patch.maxWeightGrams } : {}),
|
|
293
|
+
...(patch.freeShippingThreshold !== undefined
|
|
294
|
+
? { freeShippingThreshold: patch.freeShippingThreshold }
|
|
295
|
+
: {}),
|
|
296
|
+
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
|
|
297
|
+
},
|
|
298
|
+
ctx,
|
|
299
|
+
);
|
|
300
|
+
return Ok(updated!);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async deleteRate(id: string, actor?: Actor | null, ctx?: TxContext): Promise<Result<{ deleted: true }>> {
|
|
304
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
305
|
+
const existing = await this.deps.repository.findRateById(orgId, id, ctx);
|
|
306
|
+
if (!existing) return Err(new CommerceNotFoundError("Shipping rate not found."));
|
|
307
|
+
await this.deps.repository.deleteRate(id, ctx);
|
|
308
|
+
return Ok({ deleted: true });
|
|
309
|
+
}
|
|
51
310
|
}
|
|
@@ -11,6 +11,8 @@ export interface TaxAddress {
|
|
|
11
11
|
export interface TaxLineItem {
|
|
12
12
|
id: string;
|
|
13
13
|
entityId: string;
|
|
14
|
+
/** Variant reference — a variant-level taxClass overrides the entity's (issue #57). */
|
|
15
|
+
variantId?: string;
|
|
14
16
|
description: string;
|
|
15
17
|
quantity: number;
|
|
16
18
|
unitPrice: number;
|
|
@@ -25,6 +27,11 @@ export interface TaxCalculationParams {
|
|
|
25
27
|
fromAddress?: TaxAddress;
|
|
26
28
|
toAddress?: TaxAddress;
|
|
27
29
|
shippingAmount: number;
|
|
30
|
+
/**
|
|
31
|
+
* Cart-level discount not already reflected in per-line `discount` —
|
|
32
|
+
* pro-rated across lines before class-based tax (issue #57).
|
|
33
|
+
*/
|
|
34
|
+
orderDiscount?: number;
|
|
28
35
|
lineItems: TaxLineItem[];
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -33,6 +40,8 @@ export interface TaxCalculationResult {
|
|
|
33
40
|
taxableAmount: number;
|
|
34
41
|
rate: number;
|
|
35
42
|
breakdown?: Record<string, unknown>;
|
|
43
|
+
/** Per-line results when product tax classes computed the tax (issue #57). */
|
|
44
|
+
lines?: Array<{ id: string; taxClass: string | null; taxAmount: number }>;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export interface TaxReportParams {
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { CommerceConfig } from "../../config/types.js";
|
|
2
|
+
import type { DrizzleDatabase } from "../../kernel/database/drizzle-db.js";
|
|
2
3
|
import { defineModule } from "../../kernel/module/index.js";
|
|
4
|
+
import { TaxRatesRepository } from "./repository/index.js";
|
|
5
|
+
import { taxClasses, taxRates } from "./schema.js";
|
|
3
6
|
import { TaxService } from "./service.js";
|
|
4
7
|
|
|
5
8
|
export const taxModule = defineModule<
|
|
6
|
-
|
|
9
|
+
{ taxRates: typeof taxRates; taxClasses: typeof taxClasses },
|
|
7
10
|
TaxService,
|
|
8
11
|
Record<string, never>
|
|
9
12
|
>({
|
|
10
13
|
id: "tax",
|
|
11
|
-
schema: () => ({}),
|
|
14
|
+
schema: () => ({ taxRates, taxClasses }),
|
|
12
15
|
service: (deps) =>
|
|
13
16
|
new TaxService({
|
|
14
17
|
adapter: (deps.config as CommerceConfig).tax?.adapter,
|
|
18
|
+
repository: new TaxRatesRepository(deps.db.db as DrizzleDatabase),
|
|
15
19
|
}),
|
|
16
20
|
});
|
|
@@ -1,5 +1,172 @@
|
|
|
1
|
+
import { eq, and, asc, inArray } from "drizzle-orm";
|
|
1
2
|
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
|
+
import type {
|
|
4
|
+
DrizzleDatabase,
|
|
5
|
+
DbOrTx,
|
|
6
|
+
} from "../../../kernel/database/drizzle-db.js";
|
|
7
|
+
import { sellableEntities, variants } from "../../catalog/schema.js";
|
|
8
|
+
import { taxClasses, taxRates } from "../schema.js";
|
|
2
9
|
|
|
3
|
-
export
|
|
4
|
-
|
|
10
|
+
export type TaxRate = typeof taxRates.$inferSelect;
|
|
11
|
+
export type TaxRateInsert = typeof taxRates.$inferInsert;
|
|
12
|
+
export type TaxClass = typeof taxClasses.$inferSelect;
|
|
13
|
+
export type TaxClassInsert = typeof taxClasses.$inferInsert;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Persistence for runtime tax rates (issue #45).
|
|
17
|
+
*/
|
|
18
|
+
export class TaxRatesRepository {
|
|
19
|
+
constructor(private readonly db: DrizzleDatabase) {}
|
|
20
|
+
|
|
21
|
+
private getDb(ctx?: TxContext): DbOrTx {
|
|
22
|
+
return (ctx?.tx as DbOrTx | undefined) ?? this.db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findById(orgId: string, id: string, ctx?: TxContext): Promise<TaxRate | undefined> {
|
|
26
|
+
const db = this.getDb(ctx);
|
|
27
|
+
const rows = await db
|
|
28
|
+
.select()
|
|
29
|
+
.from(taxRates)
|
|
30
|
+
.where(and(eq(taxRates.organizationId, orgId), eq(taxRates.id, id)));
|
|
31
|
+
return rows[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findAll(orgId: string, ctx?: TxContext): Promise<TaxRate[]> {
|
|
35
|
+
const db = this.getDb(ctx);
|
|
36
|
+
return db
|
|
37
|
+
.select()
|
|
38
|
+
.from(taxRates)
|
|
39
|
+
.where(eq(taxRates.organizationId, orgId))
|
|
40
|
+
.orderBy(asc(taxRates.priority));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findActive(orgId: string, ctx?: TxContext): Promise<TaxRate[]> {
|
|
44
|
+
const db = this.getDb(ctx);
|
|
45
|
+
return db
|
|
46
|
+
.select()
|
|
47
|
+
.from(taxRates)
|
|
48
|
+
.where(and(eq(taxRates.organizationId, orgId), eq(taxRates.isActive, true)))
|
|
49
|
+
.orderBy(asc(taxRates.priority));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async create(data: TaxRateInsert, ctx?: TxContext): Promise<TaxRate> {
|
|
53
|
+
const db = this.getDb(ctx);
|
|
54
|
+
const rows = await db.insert(taxRates).values(data).returning();
|
|
55
|
+
return rows[0]!;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async update(
|
|
59
|
+
id: string,
|
|
60
|
+
data: Partial<Omit<TaxRateInsert, "id" | "organizationId">>,
|
|
61
|
+
ctx?: TxContext,
|
|
62
|
+
): Promise<TaxRate | undefined> {
|
|
63
|
+
const db = this.getDb(ctx);
|
|
64
|
+
const rows = await db
|
|
65
|
+
.update(taxRates)
|
|
66
|
+
.set({ ...data, updatedAt: new Date() })
|
|
67
|
+
.where(eq(taxRates.id, id))
|
|
68
|
+
.returning();
|
|
69
|
+
return rows[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async delete(id: string, ctx?: TxContext): Promise<boolean> {
|
|
73
|
+
const db = this.getDb(ctx);
|
|
74
|
+
const rows = await db.delete(taxRates).where(eq(taxRates.id, id)).returning();
|
|
75
|
+
return rows.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Product tax classes (issue #57) ─────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async findActiveClasses(orgId: string, ctx?: TxContext): Promise<TaxClass[]> {
|
|
81
|
+
const db = this.getDb(ctx);
|
|
82
|
+
return db
|
|
83
|
+
.select()
|
|
84
|
+
.from(taxClasses)
|
|
85
|
+
.where(and(eq(taxClasses.organizationId, orgId), eq(taxClasses.isActive, true)))
|
|
86
|
+
.orderBy(asc(taxClasses.name));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async findAllClasses(orgId: string, ctx?: TxContext): Promise<TaxClass[]> {
|
|
90
|
+
const db = this.getDb(ctx);
|
|
91
|
+
return db
|
|
92
|
+
.select()
|
|
93
|
+
.from(taxClasses)
|
|
94
|
+
.where(eq(taxClasses.organizationId, orgId))
|
|
95
|
+
.orderBy(asc(taxClasses.name));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async findClassById(orgId: string, id: string, ctx?: TxContext): Promise<TaxClass | undefined> {
|
|
99
|
+
const db = this.getDb(ctx);
|
|
100
|
+
const rows = await db
|
|
101
|
+
.select()
|
|
102
|
+
.from(taxClasses)
|
|
103
|
+
.where(and(eq(taxClasses.organizationId, orgId), eq(taxClasses.id, id)));
|
|
104
|
+
return rows[0];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async createClass(data: TaxClassInsert, ctx?: TxContext): Promise<TaxClass> {
|
|
108
|
+
const db = this.getDb(ctx);
|
|
109
|
+
const rows = await db.insert(taxClasses).values(data).returning();
|
|
110
|
+
return rows[0]!;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async updateClass(
|
|
114
|
+
id: string,
|
|
115
|
+
data: Partial<Omit<TaxClassInsert, "id" | "organizationId">>,
|
|
116
|
+
ctx?: TxContext,
|
|
117
|
+
): Promise<TaxClass | undefined> {
|
|
118
|
+
const db = this.getDb(ctx);
|
|
119
|
+
const rows = await db
|
|
120
|
+
.update(taxClasses)
|
|
121
|
+
.set({ ...data, updatedAt: new Date() })
|
|
122
|
+
.where(eq(taxClasses.id, id))
|
|
123
|
+
.returning();
|
|
124
|
+
return rows[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async deleteClass(id: string, ctx?: TxContext): Promise<boolean> {
|
|
128
|
+
const db = this.getDb(ctx);
|
|
129
|
+
const rows = await db.delete(taxClasses).where(eq(taxClasses.id, id)).returning();
|
|
130
|
+
return rows.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async clearDefaultClass(orgId: string, ctx?: TxContext): Promise<void> {
|
|
134
|
+
const db = this.getDb(ctx);
|
|
135
|
+
await db
|
|
136
|
+
.update(taxClasses)
|
|
137
|
+
.set({ isDefault: false, updatedAt: new Date() })
|
|
138
|
+
.where(and(eq(taxClasses.organizationId, orgId), eq(taxClasses.isDefault, true)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolves the taxClass for catalog references: variant.taxClass overrides
|
|
143
|
+
* entity.taxClass. Returns maps keyed by id so callers can pick
|
|
144
|
+
* variant-first.
|
|
145
|
+
*/
|
|
146
|
+
async resolveCatalogTaxClasses(
|
|
147
|
+
refs: Array<{ entityId: string; variantId?: string | undefined }>,
|
|
148
|
+
ctx?: TxContext,
|
|
149
|
+
): Promise<{ byEntity: Map<string, string | null>; byVariant: Map<string, string | null> }> {
|
|
150
|
+
const db = this.getDb(ctx);
|
|
151
|
+
const entityIds = [...new Set(refs.map((r) => r.entityId))];
|
|
152
|
+
const variantIds = [...new Set(refs.flatMap((r) => (r.variantId ? [r.variantId] : [])))];
|
|
153
|
+
|
|
154
|
+
const byEntity = new Map<string, string | null>();
|
|
155
|
+
const byVariant = new Map<string, string | null>();
|
|
156
|
+
if (entityIds.length > 0) {
|
|
157
|
+
const rows = await db
|
|
158
|
+
.select({ id: sellableEntities.id, taxClass: sellableEntities.taxClass })
|
|
159
|
+
.from(sellableEntities)
|
|
160
|
+
.where(inArray(sellableEntities.id, entityIds));
|
|
161
|
+
for (const row of rows) byEntity.set(row.id, row.taxClass);
|
|
162
|
+
}
|
|
163
|
+
if (variantIds.length > 0) {
|
|
164
|
+
const rows = await db
|
|
165
|
+
.select({ id: variants.id, taxClass: variants.taxClass })
|
|
166
|
+
.from(variants)
|
|
167
|
+
.where(inArray(variants.id, variantIds));
|
|
168
|
+
for (const row of rows) byVariant.set(row.id, row.taxClass);
|
|
169
|
+
}
|
|
170
|
+
return { byEntity, byVariant };
|
|
171
|
+
}
|
|
5
172
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
|
+
import { organization } from "../../auth/auth-schema.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Product tax classes (issue #57) — standard / reduced / zero-rated etc.
|
|
6
|
+
* Sellable entities and variants reference a class by name (`taxClass`
|
|
7
|
+
* column); checkout computes per-line tax from the class rate, with cart
|
|
8
|
+
* discounts pro-rated across lines first. Lines with no class use the org's
|
|
9
|
+
* default class (isDefault). When classes exist for an org they take
|
|
10
|
+
* precedence over region rates and the adapter for line taxation.
|
|
11
|
+
*/
|
|
12
|
+
export const taxClasses = pgTable("tax_classes", {
|
|
13
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
14
|
+
organizationId: text("organization_id")
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
17
|
+
name: text("name").notNull(),
|
|
18
|
+
// Basis points: 1000 = 10%
|
|
19
|
+
rateBps: integer("rate_bps").notNull(),
|
|
20
|
+
isDefault: boolean("is_default").notNull().default(false),
|
|
21
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
22
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
23
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
24
|
+
}, (table) => ({
|
|
25
|
+
orgNameUnique: uniqueIndex("tax_classes_org_name_unique").on(table.organizationId, table.name),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runtime tax rates (issue #45).
|
|
30
|
+
*
|
|
31
|
+
* Per-region rates in basis points (500 = 5%). When rates exist for an
|
|
32
|
+
* organization and the calculation has a destination address, they take
|
|
33
|
+
* precedence over the configured tax adapter, which remains the fallback.
|
|
34
|
+
* State-specific matches beat country-level matches; multiple matches at
|
|
35
|
+
* the same specificity are summed (e.g. GST + PST).
|
|
36
|
+
*/
|
|
37
|
+
export const taxRates = pgTable("tax_rates", {
|
|
38
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
39
|
+
organizationId: text("organization_id")
|
|
40
|
+
.notNull()
|
|
41
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
42
|
+
name: text("name").notNull(),
|
|
43
|
+
// ISO 3166-1 alpha-2, uppercase. "*" matches any country.
|
|
44
|
+
country: text("country").notNull(),
|
|
45
|
+
// Optional state/province code; null = the whole country.
|
|
46
|
+
state: text("state"),
|
|
47
|
+
// Basis points: 500 = 5%, 875 = 8.75%
|
|
48
|
+
rateBps: integer("rate_bps").notNull(),
|
|
49
|
+
appliesToShipping: boolean("applies_to_shipping").notNull().default(true),
|
|
50
|
+
priority: integer("priority").notNull().default(100),
|
|
51
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
52
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
53
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
54
|
+
}, (table) => ({
|
|
55
|
+
orgIdx: index("idx_tax_rates_org").on(table.organizationId),
|
|
56
|
+
countryIdx: index("idx_tax_rates_country").on(table.country, table.state),
|
|
57
|
+
}));
|