Subscription Manager now has a pluggable tax/invoice seam (#3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core). The module itself implements no tax logic and issues no invoices — global rate rules are a maintenance liability — but every charge now flows through a single service where a real tax engine can plug in.
The service slot
subscription_manager.tax_invoice ships as a null implementation (no tax, no invoices — sites without tax obligations need no changes). A bridge module replaces it via a ServiceProvider:
class MyBridgeServiceProvider extends ServiceProviderBase {
public function alter(ContainerBuilder $container): void {
$container->getDefinition('subscription_manager.tax_invoice')
->setClass(MyTaxInvoiceService::class)
->setArguments([new Reference('my_bridge.client')]);
}
}
The interface
\Drupal\subscription_manager\TaxInvoiceServiceInterface has three methods:
calculate($subscription, array $lines, $period_start, $period_end): TaxResult— pure and side-effect free. The basket is the base fee (as a line) plus any rated usage lines (#3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode). ReturnTaxResult::exclusive($tax_lines)to have the engine add the tax on top of the charge,TaxResult::inclusive($tax_lines)when the prices already contain it (lines recorded, amount unchanged), orTaxResult::none(). A result carries as manyTaxLines (label, display rate, amount) as apply — e.g. Canadian GST and PST as two lines.recordCharge($charge): ?string— register a succeeded charge with the tax system and return the invoice reference (an identifier or URL), or NULL when nothing was issued. Called for every charge that reaches the succeeded state, zero-amount settled-locally periods included.recordRefund($charge, $amount): ?string— register a refund and return the credit-note reference; stored underdata['credit_notes']on the charge.
Location evidence (billing address, IP, VAT-ID validation) is deliberately not modeled by this module: the bridge gathers and retains whatever its jurisdictions require.
Storage
Charge records gain an invoice_reference field (update 10020), holding what recordCharge() returned; it is queryable and available to Views. Tax lines and treatment are recorded in the charge's data map (tax_lines, tax_mode) alongside the rated usage lines.
Self-scheduling connectors
Services that own their own billing cycle (e.g. Stripe Billing) settle charges out of band; their webhook handlers should call recordCharge() when a remote charge settles, so remote-generated transactions register with the tax system identically.