关于 spree-taxes
spree-taxes 是用于配置 Spree 6 税务类别、税率、税务地址及基于提供方计算的技能。它面向在 Spree 中实现税务处理的团队,说明内部税率和外部提供方如何为购物车与订单生成税额记录。
主要功能
- 01按商店划分的税务类别与税率
- 02按国家和州匹配税务辖区
- 03按市场选择税务提供方
- 04计算含税与附加税额
- 05税务标识符与免税证明
适用场景
- 01设置国家和州级税率
- 02为不同市场选择税务提供方
- 03配置含税商品定价
- 04应用企业税务标识符与免税资格
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-taxes安装后在 Claude Code 中输入/spree-taxes确认已加载。
详细介绍
Spree 税费
Spree 6 中的税费由三个独立决策决定:
- 销售什么 → 产品/变体上的
Spree::TaxCategory(复制到行项目)。 - 适用地点 → 购买记录的税费地址(默认是收货地址)及其市场。
- 由谁计算 → 市场的税费提供方,若没有则回退到
Spree.default_tax_provider(默认为Spree::TaxProvider::Internal,读取Spree::TaxRate记录)。
无论由谁计算,输出始终相同:购物车/订单上的 Spree::TaxLine 记录,由 Spree::Carts::RecalculateTotals 汇总到 included_tax_total / additional_tax_total 中(参见 spree-order-totals)。
如果你从 5.x 的区域和
Spree::Calculator::DefaultTax迁移而来:税费区域和税费调整项已移除——请参见spree-upgrade-5-to-6。
税费类别
Spree::TaxCategory — 按商店划分;包含 name、is_default(每个商店有一个默认类别;没有类别的商品使用它)和 tax_code(外部引擎识别该类别所用的代码,例如 Avalara 税码)。产品/变体引用一个类别;行项目会快照保存 tax_category_id。配送方式可以指定一个类别,用于计算运费税;未指定类别的费用使用商店默认类别。
税率(Internal 提供方)
Spree::TaxRate(tax_…,按商店划分,软删除)不设区域——它通过 ISO 代码指定管辖范围:
| 字段 | 含义 |
|---|---|
amount | 小数形式的比例——0.2 = 20%(amount_percentage 读取/写入 20) |
country_code / state_code | 适用范围。国家为 nil = 所有地区;州为 nil = 整个国家 |
tax_category | 适用对象 |
included_in_price | true = 类似 VAT,已包含在价格中;false = 在价格基础上另行收取 |
name / show_rate_in_label | 快照保存到税费行中的标签("VAT 20%") |
store = Spree::Current.store
standard = Spree::TaxCategory.default(store)
Spree::TaxRate.create!(name: 'VAT', amount: 0.20, country_code: 'DE', tax_category: standard, included_in_price: true)
Spree::TaxRate.create!(name: 'CA sales tax', amount: 0.0725, country_code: 'US', state_code: 'CA', tax_category: standard)
Admin API:/api/v3/admin/tax_categories、/api/v3/admin/tax_rates(adminClient.taxRates.create({ …, country_code: 'GB', tax_category_id: 'taxcat_…' }))。
匹配方式:TaxRate.for_store(store).for_jurisdiction(country, state) — 商品所属类别的所有税率中,国家/州匹配(或为 nil)的税率都会生效,因此全国适用的 US 税率和适用于 US/CA 的税率都会对加利福尼亚地址征税。Internal 提供方声明了 unsupported_capabilities:us_local_tax、reverse_charge、oss_thresholds、proportional_delivery_tax——如需超出简单国家/州税率的功能,请使用外部引擎。
税费地址
当商店偏好设置 tax_using_ship_address 为 true(默认值)时,purchase.tax_address 是收货地址;否则为账单地址。purchase.tax_country 会回退到市场默认国家,再回退到商店默认国家——因此在尚无地址时,采用含税定价的市场也能显示实际价格。
已含税与另行加税
- **
included_in_price(按税率设置)**决定计算方式:从含税总额中扣除已含税费(商品上会保存pre_tax_amount),并计入included_tax_total;另行收取的税费计入additional_tax_total,并增加total。 Spree::Market#tax_inclusive表示该市场声明其报价已含税——此属性会在 Store API 市场信息中公开,供店面标注价格(“含 VAT”)。请确保它与为该市场所在国家配置的税率保持一致。- 跨境 VAT 重新计算:向其他国家的客户展示含 VAT 的价格时,
Spree::VatPriceCalculation会移除本国 VAT 并加上目的地国家的 VAT——仅适用于 Internal 提供方(外部引擎不保留税率记录;请改用按地理范围划分的价格表,参见spree-pricing)。 - 汇总时,切勿将
included_tax_total加到其他金额中——它已经包含在价格里。tax_total= 已含税费 + 另行收取的税费,是安全的单行总额。
税务提供方
cart.tax_provider # => market&.tax_provider_instance || Spree.default_tax_provider.new
Spree::Market#tax_provider是类名字符串,并会根据Spree.tax_providers进行验证(Admin API:PATCH /api/v3/admin/markets/:id { tax_provider: 'MyTax::Provider' };GET /api/v3/admin/tax_providers会列出已安装的提供方及其可用性和不支持的功能)。Spree.default_tax_provider返回一个类(接受类或字符串):Spree.default_tax_provider = 'MyTax::Provider'。- 提供方是无状态的,每次调用都会重新实例化,且不接受构造参数。凭据存储在提供方运行时读取的
Spree::Integration中;可重写self.available_for_store?(store)来要求必须配置该集成。
提供方契约(Spree::TaxProvider::Base)
| 方法 | 时机 | 要求 |
|---|---|---|
estimate(owner, items = nil, tax_date:, tax_identifier:, exemptions:, context:) | 每次未冻结的重新计算(购物车、草稿订单);已下单订单不会调用 | 逐项全量替换:删除这些项现有的税务行,并写入全新的税务行——每种税务处理对应一行,金额为零的也要包含。items: nil = owner.taxable_items(行项目、履约记录、非关税费用)。无法计算时必须抛出异常。 |
commit(order) | 下单后,在任何事务之外(Carts::Complete finalize) | 必须幂等——完成流程可能重放;以订单作为申报的标识 |
void(order) | 取消订单时 | 撤销已提交的税务文件 |
refund(order, return_items, amount:, tax_date:) | 退货退款时 | 抵扣的税额不得超过实际退款的 amount |
service_tax_rate(address:, store:) | 开票收取市场佣金时 | 返回一个比例或 nil(表示不提供意见) |
self.unsupported_capabilities | Admin 市场配对时 | 声明无法支持的功能(符号) |
self.display_name, self.available_for_store?(store) | Admin | 用于展示 / 控制可用性 |
编写一个提供方
# server/app/models/my_tax/provider.rb
module MyTax
class Provider < Spree::TaxProvider::Base
def self.display_name = 'MyTax'
def self.unsupported_capabilities = %i[reverse_charge]
def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], context: {})
items ||= owner.taxable_items
return if items.empty?
quote = MyTax::Client.quote(owner, items, tax_date:, vat_id: tax_identifier&.value, **context)
items.group_by(&:class).each do |klass, group|
fk = { Spree::LineItem => :line_item_id, Spree::Fulfillment => :fulfillment_id, Spree::Fee => :fee_id }.fetch(klass)
Spree::TaxLine.where(fk => group.map(&:id)).delete_all # replace-all
end
owner_key = owner.is_a?(Spree::Order) ? :order : :cart
quote.lines.each do |line|
Spree::TaxLine.create!(
owner_key => owner, line.item_fk => line.item_id,
amount: line.tax, rate: line.rate, label: line.label,
included: false, provider_id: 'mytax',
taxability_reason: line.reason, # e.g. 'standard_rated', 'reverse_charge'
country_code: line.country, state_code: line.state,
data: { jurisdictions: line.breakdown } # provider payload, free-form
)
end
end
def commit(order)
MyTax::Client.commit(document_code: order.number) # idempotent by order number
end
def void(order) = MyTax::Client.void(document_code: order.number)
end
end
# server/config/initializers/spree.rb
Spree.default_tax_provider = 'MyTax::Provider' # optional fallback for markets naming none (string: no autoload at boot)
Rails.application.config.after_initialize do
Spree.tax_providers << MyTax::Provider # selectable per market; app/ classes can't be referenced during boot
end
TaxLine.taxability_reasons 是一个类属性(standard_rated、reduced_rated、zero_rated、reverse_charge、intra_community_supply、export、customer_exempt、product_exempt、not_collecting、not_subject_to_tax);可通过 Spree::TaxLine.taxability_reasons += ['my_reason'] 添加自定义原因。
网络 I/O 应快速并能妥善处理失败:estimate 会在购物车写入期间和完成锁期间运行。抛出异常会中止重新计算。
提供额外数据:set_tax_line_context
提供方需要但类型化输入中未包含的任何数据(客户用途代码、渠道、报价 ID),都来自上下文处理器。每个处理器返回的哈希都会合并,并作为 context: 传入。
module MyApp
class AvalaraEntityUseCode
def call(workflow)
code = workflow.cart.customer&.metadata&.dig('entity_use_code')
code ? { entity_use_code: code } : {}
end
end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyApp::AvalaraEntityUseCode')
# Admin draft-order edits use the twin key: 'orders.recalculate_totals.set_tax_line_context'
不要使用 context 存放豁免信息或买家的 VAT 编号——它们是类型化输入(见下文)。
TaxLine 行
Spree::TaxLine(tl_):所有者(购物车或订单)、line_item / fulfillment / fee 三者中恰好一个、amount、rate、label、included、tax_rate(外部税务提供方时为 nil)、provider_id、taxability_reason、country_code / state_code、data(JSON)。这些都是快照——之后编辑或删除税率不会改变已下单订单。
- 仅由提供方写入。Admin API
GET /api/v3/admin/orders/:id/tax_lines为只读。 - 完成时从购物车原样复制到订单(不重新估算);已下单订单的金额会被冻结。
- 配送税 → 记录在履约记录上;费用税 → 记录在费用上(
fee_id);关税不包含在默认应税项集合中。
B2B:税务识别号和免税
税务识别号(Spree::TaxIdentifier、txi_、kind + value,例如 eu_vat / NL123456789B01)可以属于客户、公司(仅限法人实体)或购物车(结账时覆盖)。解析顺序:cart.resolved_tax_identifier → 购物车覆盖项 → 公司(最近的法人实体祖先——绝不从父级借用)→ 客户;在各自范围内,已验证的优先于最近添加的。订单完成时,解析出的税务识别号会复制到订单上(source:override/company/customer),并变为只读。没有识别号 = 消费者销售(安全默认值)。
- Store API:
GET/PUT/DELETE /api/v3/store/carts/:cart_id/tax_identifier(PUT 需要同时提供kind和value;会重新计算购物车费用)。目前还没有 SDK 辅助方法——请使用client.request('PUT', \/carts/${id}/tax_identifier`, { body: { kind, value }, spreeToken })`。 - Admin:
/api/v3/admin/companies/:id/tax_identifiers(以及validate)、/api/v3/admin/customers/:id/tax_identifiers。 - 按类型配置验证器:
Spree.tax_identifier_validators['au_abn'] = 'MyApp::AbnValidator'(继承Spree::TaxIdentifiers::Validator::Base:保存时调用self.valid_format?,在Spree::TaxIdentifiers::ValidateJob中进行登记查询时调用call(tax_identifier:))。内置的eu_vat验证器仅检查格式——如果需要验证,请接入 VIES/登记验证器。状态:pending/verified/unverified/unavailable/unsupported。 - 根据 VAT ID 进行反向征税属于提供方能力——Internal 提供方会忽略
tax_identifier。
免税:Spree::TaxExemptionCertificate 属于公司的法人实体,并按 country_code/state_code 限定范围;仅在有效时(已验证且未过期)计入。Spree::Tax::ResolveExemptions(Spree.tax_resolve_exemptions_service)会将其转换为 Spree::TaxExemption 值对象(reason_code、certificate_number、管辖区、item_overrides),并作为 exemptions: 传入。免税商品会生成金额为零的明细行,并带有 taxability_reason: 'customer_exempt'。可替换此服务,以便从其他来源读取证书(例如 Avalara 的证书库)。参见 spree-b2b。
Avalara 和其他引擎
税务引擎以提供方类的形式接入,注册在 Spree.tax_providers 中,并按市场选择。有关 Avalara,请参阅 node_modules/@spree/docs/dist/integrations/tax/avalara.md——按照管理界面步骤操作前,请先确认其中描述的基于提供方的配置适用于你的 Spree 版本。将 TaxCategory#tax_code 映射到引擎的商品代码,并为配送方式的类别设置运费代码。
注意事项
- 税额为零 → 没有匹配的
TaxRate:店铺错误、类别不匹配(商品使用的类别没有税率;未指定类别的商品使用店铺的is_default类别),或新增市场尚无税务地址。检查Spree::TaxRate.for_store(cart.store).for_jurisdiction(cart.tax_country&.iso, cart.tax_address&.state_code)。 - 管理员编辑已下单订单后,税额没有更新——这是正确行为:已下单订单不会重新估算。
- 手动创建/编辑
TaxLine——下次估算时会被覆盖。请修改税率、类别或提供方。 - 市场的
tax_provider必须是已注册的类名;必须将 gem 的提供方追加到Spree.tax_providers,否则市场验证会失败。 commit不具备幂等性 → 完成流程重放时会生成重复单据。- 在作业/控制台中设置
Spree::Current.store——税率和默认类别均按店铺区分。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/taxes.md、companies.md、markets.md、order-totals.mdnode_modules/@spree/docs/dist/developer/customization/workflows.md——上下文钩子- 相关技能:
spree-order-totals、spree-b2b、spree-providers、spree-pricing、spree-checkout - 源码:
spree/core/app/models/spree/tax_provider/{base,internal}.rb、spree/core/app/models/concerns/spree/purchase/taxation.rb