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
TaxInvoiceServiceInterfacewith 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) andrecordCharge()(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 andrecordCharge()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.
Issue fork subscription_manager-3618741
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
Comment #2
colanComment #3
colanDesign for this, resolving the open questions in the summary:
Interface: three explicit methods
calculate($subscription, array $lines, $period_start, $period_end): TaxResult— pure and side-effect free; safe to call repeatedly. The lines are the period'sRatedLines (#3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode), with the engine representing the base fee as a line too, so the tax engine always sees the whole basket.recordCharge($charge): ?string— side-effecting: registers a succeeded charge with the tax system and returns the invoice reference, or NULL when nothing was issued (the null implementation always; a bridge may also decline, e.g. for a zero-amount period).recordRefund($charge, string $amount): ?string— the refunds question from the summary, decided: a dedicated method rather than a negative-amount convention. Explicit beats sign conventions, and the #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land reversal work needs a credit-note path with its own semantics anyway. Returns the credit-note reference.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
TaxResultcarries a mode and a list ofTaxLines (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 aNullTaxInvoiceService(no tax, no invoices, NULL references); a bridge module replaces the class via a ServiceProvider. The cycle engine callscalculate()when computing the period amount and on upgrade proration (tax applies to prorations too),recordCharge()in the single charge-success path, andrecordRefund()on refund success. For self-scheduling connectors the hand-off stays documentation on the interface: their webhook handlers callrecordCharge()when the remote service settles a charge, so remote-generated invoices register identically.Storage and scope
New
invoice_referencestring 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.Comment #5
colanImplemented in the MR, per the design in #3, in two commits.
The seam.
TaxInvoiceServiceInterface— a single service slot (subscription_manager.tax_invoice), shipped asNullTaxInvoiceService, 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 aTaxResultwith inclusive/exclusive treatment and as manyTaxLines as the jurisdiction stacks — Canadian GST + PST land as two lines),recordCharge()(returns the invoice reference, stored in the newinvoice_referencecharge field, installed by update 10020 and exposed to Views), andrecordRefund()(the dedicated credit-note method decided in #3; references append todata['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 callrecordCharge()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-controlledTestTaxInvoiceServicethat 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.
Comment #8
colan