Strategy Pattern¶
Every business decision point in Vectis is an abstract strategy interface. The core engine calls strategies; extensions and modules provide implementations.
Strategy ABCs¶
| Strategy | Purpose | Resolution |
|---|---|---|
PriceCalculation |
Tiered / contract pricing | resolve |
TaxCalculation |
Tax rates and remittance | resolve_all |
ShippingCalculator |
Rate calculation | resolve_all |
ShippingEligibility |
Method eligibility rules | resolve_all |
InventoryAllocation |
Stock reservation | resolve |
PaymentProcess |
Authorize, capture, charge, void, refund | resolve |
PaymentEligibility |
Payment method filtering | resolve_all |
OrderProcess |
Order state machine hooks | resolve |
SearchIndexing |
Full-text index sync | resolve |
FraudScoring |
Risk evaluation | resolve |
EmailDelivery |
Transactional email | resolve |
SmsDelivery |
Transactional SMS | resolve (via SmsDeliveryStrategy in notification/sms_strategies.py) |
CommissionCalculation |
Sales rep commissions | resolve |
ReturnProcess |
RMA / return workflow | resolve |
CatalogVisibility |
Account-level product access | resolve_all |
PaymentProcessStrategy¶
The payment strategy is the most feature-rich strategy ABC. It defines the full payment lifecycle:
class PaymentProcessStrategy(ABC):
@property
@abstractmethod
def gateway_name(self) -> str: ...
@abstractmethod
async def authorize(self, amount, currency, payment_data) -> PaymentResult: ...
@abstractmethod
async def capture(self, transaction_id, amount) -> PaymentResult: ...
@abstractmethod
async def refund(self, transaction_id, amount) -> PaymentResult: ...
async def charge(self, amount, currency, payment_data) -> PaymentResult:
"""Auth + capture in one call. Default: authorize then capture."""
...
async def void(self, transaction_id) -> PaymentResult:
"""Void an authorized but uncaptured transaction."""
...
async def approve_held_transaction(self, transaction_id) -> PaymentResult:
"""Release a transaction held by fraud filters."""
...
The PaymentResult dataclass includes a held_for_review flag for fraud filter detection:
@dataclass
class PaymentResult:
success: bool
transaction_id: str | None = None
error_message: str | None = None
gateway_response: dict | None = None
held_for_review: bool = False
Creating a Strategy¶
Define an ABC, then register implementations with strategy_resolver.
from abc import ABC, abstractmethod
from decimal import Decimal
class TaxCalculationStrategy(ABC):
@abstractmethod
async def calculate(self, line_items: list[dict], address: dict) -> Decimal: ...
Register a default at startup:
from vectis.core.strategy import strategy_resolver
class SimpleSalesTax(TaxCalculationStrategy):
async def calculate(self, line_items, address):
subtotal = sum(i["price"] * i["qty"] for i in line_items)
return subtotal * Decimal("0.08")
strategy_resolver.register(TaxCalculationStrategy, SimpleSalesTax(), name="sales_tax")
Single vs Multi Resolution¶
resolve — returns the last-registered implementation (or a named one).
Use for single-active-strategy decision points (see
Registration Ownership for what happens
when two registrations collide):
price_strategy = strategy_resolver.resolve(PriceCalculationStrategy)
price = await price_strategy.calculate(variant_id, account_id)
resolve_all — returns every implementation. Use when multiple strategies
combine results (e.g., tax jurisdictions):
tax_strategies = strategy_resolver.resolve_all(TaxCalculationStrategy)
total_tax = sum([await s.calculate(items, addr) for s in tax_strategies])
Stage-Sorted Engines¶
resolve_all returns strategies in registration order — that's fine for independent providers (UPS + USPS shipping). For tax and promotions the order matters: sales tax must complete before excise reads the tax-adjusted subtotal; line-item promos must run before order-subtotal promos so allocation distributes correctly.
The engine (not the resolver) handles ordering. Strategies declare ordering through different mechanisms depending on the engine:
Tax engine — stage class attribute (vectis/modules/tax/engine.py)¶
Tax strategies expose stage: int as an attribute on the strategy class (default 100). group_by_stage() buckets strategies, runs each bucket, then quantizes the emitted LineTax rows to currency decimals (HALF_UP) before the next bucket sees the totals via TaxContext.prior_stage_tax_by_line. So:
class ExciseEngineTaxStrategy(TaxCalculationStrategy):
stage = 200 # runs after sales-tax tier
name = "excise_engine"
...
| Tier | Stage | Strategies |
|---|---|---|
| Sales tax | 100 (default) |
ext_taxjar, in-process SimpleSalesTax |
| Excise | 200 |
ext_excise_engine |
| VAT | 300 |
(reserved) |
Promotion engine — PHASE_* constants (vectis/modules/promotion/engine.py)¶
Promotions don't use stage; the engine has fixed phases the rule's phase field maps into:
| Phase | Constant | Order |
|---|---|---|
| BOGO | PHASE_BOGO |
-10 |
| Line-item | PHASE_LINE |
0 |
| Order-subtotal | PHASE_ORDER |
10 |
| Shipping | PHASE_SHIPPING |
20 |
Within each phase, stack_group makes siblings mutually exclusive (first non-null wins per group; null groups stack additively). The $0 floor prevents any line from going negative. PHASE_ORDER allocations get distributed across lines after the phase resolves.
strategy_resolver.register() does not accept a stage or phase kwarg — those are concerns of the engine consuming resolve_all(), not the resolver itself.
Worker Readiness Gate¶
On boot, the worker iterates every payment method enabled in the DB and calls strategy_resolver.resolve(PaymentProcessStrategy, name=method.gateway_code). In strict mode (default), the worker exits non-zero if any enabled method has no registered strategy. This catches the cluster-state bug where you remove an extension package but forget to disable its payment methods.
Set VECTIS_WORKER_STRICT_STRATEGY_CHECK=false (also accepts 0, no, empty string) to downgrade to warnings — useful for emergency reseeds. The gate lives in vectis/core/extension_readiness.py.
Channel-Scoped Filtering¶
Extensions are activated per-channel via ChannelExtension. Pass
channel_enabled to filter strategies at resolution time:
from vectis.core.strategy import _load_channel_extensions
enabled = await _load_channel_extensions(channel_id)
strategies = strategy_resolver.resolve_all(TaxCalculationStrategy, channel_enabled=enabled)
Core defaults (no extension_name) always pass the filter.
Note
The channel extension cache is invalidated when an admin toggles an
extension. Call invalidate_channel_extension_cache(channel_id) if you
modify ChannelExtension rows programmatically.
Registration Ownership¶
Registrations made from an extension's on_activate / hot_reload are attributed to that extension by the registration ledger (a contextvar — no need to pass extension_name explicitly, though the kwarg still works and takes precedence). Ownership drives the semantics:
class ExciseEngineTax(TaxCalculationStrategy):
stage = 200 # tax engine runs sales-tier (100) before excise-tier (200)
...
# inside on_activate — the ledger attributes this to "excise_engine"
strategy_resolver.register(
TaxCalculationStrategy,
ExciseEngineTax(),
name="excise_engine",
)
- Cross-extension duplicate — a
(strategy type, name)key already owned by a different extension raisesDuplicateRegistrationErrorbefore anything is mutated: activation of the second extension fails, the first registration is untouched. - Same-extension re-registration (e.g. from
hot_reload) — clean overwrite. - Core / unattributed registrations (no owner) — keep the old last-registered-wins behavior, so an extension can still shadow a core default by registering under the same name.
- Ledgered registrations are automatically unregistered when the owning extension deactivates or uninstalls.
Warning
Two extensions can no longer silently fight over one strategy name — the
second activation hard-fails. Register under per-extension names (see the
fraud providers: ipqs, maxmind, signifyd, riskified) and use
conflicts_with in ExtensionManifest when two extensions are mutually
exclusive by design.