mcpField
S
spree-taxes

spree-taxes

github.com/spree开发10103 次安装4 周前更新
代码仓库

关于 spree-taxes

spree-taxes 是用于配置 Spree 6 税务类别、税率、税务地址及基于提供方计算的技能。它面向在 Spree 中实现税务处理的团队,说明内部税率和外部提供方如何为购物车与订单生成税额记录。

主要功能

  1. 01按商店划分的税务类别与税率
  2. 02按国家和州匹配税务辖区
  3. 03按市场选择税务提供方
  4. 04计算含税与附加税额
  5. 05税务标识符与免税证明

适用场景

  1. 01设置国家和州级税率
  2. 02为不同市场选择税务提供方
  3. 03配置含税商品定价
  4. 04应用企业税务标识符与免税资格

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-taxes

安装后在 Claude Code 中输入/spree-taxes确认已加载。

详细介绍

Spree 税费

Spree 6 中的税费由三个独立决策决定:

  1. 销售什么 → 产品/变体上的 Spree::TaxCategory(复制到行项目)。
  2. 适用地点 → 购买记录的税费地址(默认是收货地址)及其市场。
  3. 由谁计算 → 市场的税费提供方,若没有则回退到 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_pricetrue = 类似 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_capabilitiesAdmin 市场配对时声明无法支持的功能(符号)
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.md
  • node_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

评论

支持 Markdown