open-standards/openpay/crates/op-tax/src/calculator.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
OpenPay (3 crates, ~13k LOC, 175 tests)
  op-connect           sub-merchant onboarding + KYB + split-payments + payouts + 1099-K
  op-3ds2              3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions)
  op-tax               US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters

Smart Byte (3 crates, ~7k LOC, 57 tests)
  smart-byte-libp2p    libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp)
  smart-byte-crdt      LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop
  smart-byte-keri-witness  KERI key-rotation cycle + witness layer + watchers + recovery + delegation

EOC (3 crates, ~8k LOC, 108 tests)
  eoc-tools            function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox
  eoc-multimodal       vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router
  eoc-spec-decode      vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs

All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 04:00:04 -04:00

187 lines
7.1 KiB
Rust

//! [`TaxCalculator`] trait + the shared request/response types.
//!
//! Every backend — native or commercial — implements `TaxCalculator`.
//! Operators wire the backend they trust; the rest of OpenPay never
//! sees the difference.
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use op_core::Money;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use crate::category::ProductTaxCategory;
use crate::error::Result;
use crate::exemption::ExemptionCertificate;
use crate::jurisdiction::Jurisdiction;
/// Customer classification — drives reverse-charge eligibility,
/// exemption applicability, and per-jurisdiction B2B rate logic.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CustomerType {
/// Consumer (B2C). Typical retail flow; full tax collected at PoS.
Consumer,
/// Business with a registered tax identifier (B2B). Eligible for
/// EU reverse-charge across borders; may present resale or
/// exemption certificates.
Business {
/// VAT / GST / sales-tax registration number, as a string.
/// Format is jurisdiction-specific (`DE123456789`, `GB123456789`,
/// `12-3456789` for US EINs); we do not validate.
tax_id: String,
},
/// Non-profit / charity / government — usually fully exempt.
Exempt,
}
/// A single taxable line item.
///
/// Maps roughly one-to-one with Avalara's `LineItemModel`, Stripe Tax's
/// `line_item`, and Vertex's `LineItem`. The names are deliberately
/// adapter-friendly so the conversion code in each backend module is
/// boring.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxableLine {
/// Caller-supplied identifier — stable across retries so a
/// `TaxResult.per_line` lookup is unambiguous.
pub line_id: String,
/// The net (tax-exclusive) or gross (tax-inclusive — see the
/// jurisdiction's `TaxBase`) line amount.
pub amount: Money,
/// Product / service category. Drives rate overrides.
pub category: ProductTaxCategory,
/// Origin jurisdiction. Only required for origin-based US states
/// (a handful — AZ, IL, MO, NM, OH, PA, TN, TX, UT, VA, plus
/// California's complicated hybrid) and for cross-border duty
/// detection. `None` is fine for destination-based states.
pub ship_from: Option<Jurisdiction>,
/// Destination jurisdiction. Determines what authority is owed.
pub ship_to: Jurisdiction,
}
/// Contextual data that applies to every line in a calculate call.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxContext {
/// Date the transaction is deemed to occur. Drives rate selection
/// for jurisdictions whose rates have changed mid-year.
pub transaction_date: NaiveDate,
/// Classification of the buyer.
pub customer_type: CustomerType,
/// Resale / exemption certificates the buyer has produced.
pub exemption_certs: Vec<ExemptionCertificate>,
/// Jurisdictions in which the seller has economic or physical
/// nexus. Lines shipping to a jurisdiction NOT in this set are
/// zero-rated (no obligation to collect).
pub nexus_jurisdictions: HashSet<Jurisdiction>,
}
impl TaxContext {
/// Convenience constructor for the common consumer case.
#[must_use]
pub fn consumer(transaction_date: NaiveDate) -> Self {
Self {
transaction_date,
customer_type: CustomerType::Consumer,
exemption_certs: Vec::new(),
nexus_jurisdictions: HashSet::new(),
}
}
/// Builder: declare nexus in a jurisdiction.
#[must_use]
pub fn with_nexus(mut self, j: Jurisdiction) -> Self {
self.nexus_jurisdictions.insert(j);
self
}
/// Builder: declare nexus everywhere the calculator wants — the
/// "I'm shipping everywhere" case. The calculator will treat
/// every line's destination as nexused.
#[must_use]
pub const fn with_nexus_everywhere(self) -> Self {
// Sentinel: `nexus_jurisdictions.is_empty()` is the marker
// the native calculator interprets as "no nexus filtering".
// We document but don't store anything explicit here — the
// empty set means "skip the filter."
self
}
/// Builder: attach an exemption certificate.
#[must_use]
pub fn with_exemption(mut self, cert: ExemptionCertificate) -> Self {
self.exemption_certs.push(cert);
self
}
}
/// Per-line tax breakdown — every authority that taxed this line,
/// with its rate and amount.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LineTaxBreakdown {
/// Same identifier the caller supplied on [`TaxableLine`].
pub line_id: String,
/// Net amount the rate was applied against.
pub taxable_amount: Money,
/// Total tax collected from this line.
pub tax_amount: Money,
/// Effective compounded rate — sum of all applicable layers.
pub effective_rate: Decimal,
/// Per-jurisdiction breakdown, in iteration order (broadest first).
pub jurisdiction_layers: Vec<JurisdictionTax>,
/// If exemption fired, why.
pub exemption_reason: Option<String>,
}
/// Tax collected for a specific authority.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JurisdictionTax {
/// Authority that gets the money.
pub jurisdiction: Jurisdiction,
/// Rate applied at this layer.
pub rate: Decimal,
/// Money this authority collected.
pub amount: Money,
/// Tax kind for this layer.
pub kind: crate::rate_table::RateKind,
}
/// Result of a `calculate` call.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxResult {
/// Sum of every line's `tax_amount`. Currency must match all line
/// currencies (the calculator returns an error if not).
pub total_tax: Money,
/// Per-line detail, keyed by `line_id` for deterministic lookup.
pub per_line: BTreeMap<String, LineTaxBreakdown>,
/// Flat list of every `(jurisdiction, amount)` charged, summed
/// across all lines. Used by the remittance / filing layer.
pub jurisdictions_charged: Vec<JurisdictionTax>,
/// Backend that produced this result. e.g. `"native"`, `"avalara"`,
/// `"stripe_tax"`, `"vertex"`.
pub calculator: String,
/// When the calculation ran.
pub calculated_at: DateTime<Utc>,
}
/// The pluggable tax-calculation interface.
///
/// Async because every commercial backend is an HTTPS RPC; the native
/// calculator is async-by-trait too so callers can swap implementations
/// without touching signatures.
#[async_trait]
pub trait TaxCalculator: Send + Sync {
/// Compute tax for a batch of lines.
///
/// # Errors
/// - [`crate::error::Error::NoRate`] (native) when no rate exists.
/// - [`crate::error::Error::Transport`] / `Vendor` (adapter) for
/// network and backend errors.
/// - [`crate::error::Error::Money`] on currency mismatch across
/// the line set.
async fn calculate(&self, lines: &[TaxableLine], ctx: &TaxContext) -> Result<TaxResult>;
/// Stable identifier for telemetry and audit (`"native"`,
/// `"avalara"`, etc.). Shows up in `TaxResult.calculator`.
fn name(&self) -> &'static str;
}