Problem/Motivation

Sites selling across borders must calculate correct tax per customer location (e.g. EU/UK VAT for B2C, reverse charge for validated B2B), retain location evidence, and issue compliant invoices. Subscription Manager should never implement tax logic itself (global rate rules are a maintenance liability), and it has no invoicing. But without a defined seam, every site bolts this on differently, and charge-based connectors (#3618739: Add a charge-based connector mode with a local billing cycle engine) have no processor-side invoicing to fall back on at all: self-scheduling services typically generate invoices remotely, while charge-based processors only move money.

Proposed resolution

  • Define a TaxInvoiceServiceInterface with two responsibilities: calculate() (given customer identity/location evidence and line items, return tax lines and treatment, supporting both tax-inclusive and tax-exclusive modes per currency/plan) and recordCharge() (given a completed charge, register the transaction and return an invoice reference/URL).
  • Invocation points: the cycle engine calls calculate() before computing a period charge and recordCharge() on success; a parallel hand-off is documented for self-scheduling connectors so remote-generated charges are also registered (e.g. from webhook handlers).
  • Store the returned invoice reference on the per-period charge record and surface invoice history in the membership portal.
  • Ship a null implementation (no tax, no invoices) as the default. Concrete integrations are separate bridge projects, exactly as connectors are: the first planned bridge targets Quaderno, whose API covers calculation, VIES validation, location evidence, and invoice issuance. The bridge project will be filed separately once this interface settles.
  • Refunds/credits flow through the same interface (a negative recordCharge() or dedicated method; decide in review) so credit notes reach tax reporting.

Remaining tasks

Interface design review (particularly evidence structure and inclusive/exclusive semantics); patch with the null implementation and a test double; change record; then file the Quaderno bridge as its own project.

User interface changes

Invoice list in the membership portal (link-out to hosted invoices).

API changes

New TaxInvoiceServiceInterface and its invocation points; no connector interface changes.

Data model changes

Invoice reference storage on the per-period charge record introduced by #3618739: Add a charge-based connector mode with a local billing cycle engine.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

colan created an issue. See original summary.

colan’s picture

Issue summary: View changes
colan’s picture

Design for this, resolving the open questions in the summary:

Interface: three explicit methods

Evidence: deliberately not modeled upstream

The summary asks for the evidence structure; the answer is that upstream should not have one. A bridge (Quaderno first) gathers and retains location evidence itself from what the site knows — billing address, IP, VIES validation — because evidence requirements are jurisdiction- and provider-specific: exactly the maintenance liability the issue's problem statement keeps out of this module. Upstream passes the subscription (hence the account) and the money; everything else is the bridge's domain.

Inclusive/exclusive semantics

TaxResult carries a mode and a list of TaxLines (label, rate, amount). With exclusive treatment the engine adds the tax total to the period charge; with inclusive treatment the amount is unchanged and the lines are informational (the tax is inside the price). Either way the lines and mode are recorded in the charge record's data map alongside the rated lines, so the full breakdown is auditable per charge.

Wiring: one service slot, null by default

A single service (subscription_manager.tax_invoice) rather than a collector — a site has exactly one tax authority. The shipped default is a NullTaxInvoiceService (no tax, no invoices, NULL references); a bridge module replaces the class via a ServiceProvider. The cycle engine calls calculate() when computing the period amount and on upgrade proration (tax applies to prorations too), recordCharge() in the single charge-success path, and recordRefund() on refund success. For self-scheduling connectors the hand-off stays documentation on the interface: their webhook handlers call recordCharge() when the remote service settles a charge, so remote-generated invoices register identically.

Storage and scope

New invoice_reference string field on the charge entity (queryable, exposed to Views) with an update hook; credit-note references and any richer bridge payloads go in the data map. The member-facing invoice list in the portal is deferred to its own follow-up issue — it is UI on top of the stored references and should not gate the Phase 4 work that builds on this seam. Tests: a state-controlled test double exercising both modes, recorded references, refund recording, and the null default leaving behavior byte-identical.

colan’s picture

Status: Active » Needs review

Implemented in the MR, per the design in #3, in two commits.

The seam. TaxInvoiceServiceInterface — a single service slot (subscription_manager.tax_invoice), shipped as NullTaxInvoiceService, replaced by a bridge via ServiceProvider (the test module's own provider demonstrates the mechanism). Three methods: calculate() (pure; the basket is the base fee as a normalized line plus any rated usage lines from #3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode, the answer a TaxResult with inclusive/exclusive treatment and as many TaxLines as the jurisdiction stacks — Canadian GST + PST land as two lines), recordCharge() (returns the invoice reference, stored in the new invoice_reference charge field, installed by update 10020 and exposed to Views), and recordRefund() (the dedicated credit-note method decided in #3; references append to data['credit_notes']). Location evidence is deliberately not modeled, per #3.

Engine wiring. calculate() runs while computing each period amount and on upgrade prorations — exclusive tax is added on top (and reaches the connector in the charged amount), inclusive tax is recorded without changing the amount; lines and mode land in the charge data map next to the rated lines. recordCharge() fires everywhere a charge reaches succeeded: the synchronous path, webhook resolution of pending charges, and the settled-locally zero path (whether a zero period deserves an invoice is the implementation's call). recordRefund() fires on refund success. The self-scheduling hand-off is documented on the interface: webhook handlers call recordCharge() when the remote service settles a charge.

Also fixed in passing: both charge-failure paths used to overwrite the charge's data map with the failure message, clobbering the rated (and now tax) lines; the message now merges in, with regression tests for both paths.

Tests. New TaxInvoiceTest (ten scenarios) driven by a state-controlled TestTaxInvoiceService that defaults to null-service behavior, so the rest of the suite doubles as the no-tax regression check. Suite is at 100 kernel tests / 1257 assertions; update 10020 verified on a live site.

Change record drafted. As scoped in #3, the member-facing invoice list is a separate follow-up issue, and the Quaderno bridge gets filed as its own project once this lands.

  • colan committed 4122d2cd on 1.0.x
    Issue #3618741: Cover the tax seam with a state-controlled test service...

  • colan committed cf03bc1b on 1.0.x
    Issue #3618741: Add the tax/invoice seam to the billing cycle...
colan’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.