generated from coulomb/repo-seed
Implement Stripe publication layer and close WP-0007
This commit is contained in:
696
adaptive_pricing_core/provider_publication.py
Normal file
696
adaptive_pricing_core/provider_publication.py
Normal file
@@ -0,0 +1,696 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from .pricing_models import PricingModel
|
||||
|
||||
PublicationArtifactKind = Literal["product", "meter", "price", "commitment", "configuration"]
|
||||
ArtifactMappingStatus = Literal["exact", "approximate", "unsupported"]
|
||||
PublicationOperationKind = Literal["create", "update", "noop", "retire", "rollback"]
|
||||
DriftSeverity = Literal["info", "warn", "error"]
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {
|
||||
key: _serialize_value(getattr(value, key))
|
||||
for key in value.__dataclass_fields__
|
||||
}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize_value(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _payload_signature(payload: Any) -> str:
|
||||
return json.dumps(_serialize_value(payload), sort_keys=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogProduct:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
currency: str
|
||||
lifecycle_phase: str
|
||||
active_pricing_model_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableProduct:
|
||||
key: str
|
||||
product_id: str
|
||||
name: str
|
||||
description: str
|
||||
currency: str
|
||||
lifecycle_phase: str
|
||||
active: bool
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableMeter:
|
||||
key: str
|
||||
meter_id: str
|
||||
name: str
|
||||
event_name: str
|
||||
unit: str
|
||||
aggregation: str = "sum"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishablePrice:
|
||||
key: str
|
||||
price_id: str
|
||||
product_key: str
|
||||
component_id: str
|
||||
component_kind: str
|
||||
label: str
|
||||
currency: str
|
||||
billing_treatment: str
|
||||
cadence: str | None = None
|
||||
amount: Decimal | None = None
|
||||
unit_price: Decimal | None = None
|
||||
included_units: Decimal | None = None
|
||||
meter_key: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableCommitment:
|
||||
key: str
|
||||
commitment_id: str
|
||||
kind: str
|
||||
value: str
|
||||
unit: str | None = None
|
||||
description: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableConfiguration:
|
||||
key: str
|
||||
configuration_id: str
|
||||
product_key: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
segment: str | None
|
||||
price_keys: tuple[str, ...]
|
||||
commitment_keys: tuple[str, ...]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationBundle:
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
product: PublishableProduct
|
||||
meters: tuple[PublishableMeter, ...]
|
||||
prices: tuple[PublishablePrice, ...]
|
||||
commitments: tuple[PublishableCommitment, ...]
|
||||
configurations: tuple[PublishableConfiguration, ...]
|
||||
provider_hints: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderMappedArtifact:
|
||||
provider: str
|
||||
source_key: str
|
||||
source_kind: PublicationArtifactKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
mapping_status: ArtifactMappingStatus
|
||||
payload: dict[str, Any]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPublicationPackage:
|
||||
provider: str
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
artifacts: tuple[ProviderMappedArtifact, ...]
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DriftFinding:
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
severity: DriftSeverity
|
||||
summary: str
|
||||
expected: dict[str, Any] = field(default_factory=dict)
|
||||
actual: dict[str, Any] = field(default_factory=dict)
|
||||
suggested_action: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationOperation:
|
||||
kind: PublicationOperationKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
source_key: str | None
|
||||
source_kind: PublicationArtifactKind | None
|
||||
mapping_status: ArtifactMappingStatus | None
|
||||
summary: str
|
||||
desired_payload: dict[str, Any] = field(default_factory=dict)
|
||||
current_payload: dict[str, Any] = field(default_factory=dict)
|
||||
desired_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
current_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
desired_notes: tuple[str, ...] = ()
|
||||
current_notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishedProviderArtifact:
|
||||
provider: str
|
||||
source_key: str
|
||||
source_kind: PublicationArtifactKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
mapping_status: ArtifactMappingStatus
|
||||
payload: dict[str, Any]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationRevision:
|
||||
revision_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
summary: str
|
||||
operations: tuple[PublicationOperation, ...]
|
||||
snapshot: tuple[PublishedProviderArtifact, ...]
|
||||
replaced_revision_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPublicationState:
|
||||
provider: str
|
||||
active_revision_id: str | None = None
|
||||
active_model_id: str | None = None
|
||||
artifacts: tuple[PublishedProviderArtifact, ...] = ()
|
||||
revisions: tuple[PublicationRevision, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationPlan:
|
||||
provider: str
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
operations: tuple[PublicationOperation, ...]
|
||||
drift: tuple[DriftFinding, ...]
|
||||
unsupported_artifacts: tuple[ProviderMappedArtifact, ...]
|
||||
summary: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationApplyResult:
|
||||
plan: PublicationPlan
|
||||
revision: PublicationRevision
|
||||
state: ProviderPublicationState
|
||||
summary: str
|
||||
|
||||
|
||||
def build_publication_bundle(
|
||||
product: CatalogProduct,
|
||||
model: PricingModel,
|
||||
*,
|
||||
configuration_id: str | None = None,
|
||||
segment: str | None = None,
|
||||
) -> PublicationBundle:
|
||||
product_key = f"product:{product.id}"
|
||||
publishable_product = PublishableProduct(
|
||||
key=product_key,
|
||||
product_id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
currency=product.currency,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
active=model.status != "retired",
|
||||
metadata={
|
||||
**product.metadata,
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"adaptive_pricing_model_status": model.status,
|
||||
},
|
||||
)
|
||||
|
||||
meters: list[PublishableMeter] = []
|
||||
prices: list[PublishablePrice] = []
|
||||
for component in model.charge_components:
|
||||
meter_key: str | None = None
|
||||
if component.kind == "usage" and component.meter:
|
||||
meter_key = f"meter:{model.id}:{component.id}"
|
||||
meters.append(
|
||||
PublishableMeter(
|
||||
key=meter_key,
|
||||
meter_id=component.meter,
|
||||
name=component.label or component.meter,
|
||||
event_name=component.meter,
|
||||
unit=component.unit or "usage_unit",
|
||||
metadata={
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"source_component_id": component.id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
prices.append(
|
||||
PublishablePrice(
|
||||
key=f"price:{model.id}:{component.id}",
|
||||
price_id=f"{model.id}:{component.id}",
|
||||
product_key=product_key,
|
||||
component_id=component.id,
|
||||
component_kind=component.kind,
|
||||
label=component.label or component.id,
|
||||
currency=model.currency,
|
||||
billing_treatment=component.billing_treatment or "recurring",
|
||||
cadence=component.cadence or (
|
||||
model.access_fee_cadence if component.kind in {"access", "support", "discount", "risk_adjustment"} else None
|
||||
),
|
||||
amount=component.amount,
|
||||
unit_price=component.unit_price,
|
||||
included_units=component.included_units,
|
||||
meter_key=meter_key,
|
||||
metadata={
|
||||
**component.metadata,
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"source_component_id": component.id,
|
||||
"source_component_kind": component.kind,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
commitments = tuple(
|
||||
PublishableCommitment(
|
||||
key=f"commitment:{model.id}:{commitment.id}",
|
||||
commitment_id=commitment.id,
|
||||
kind=commitment.kind,
|
||||
value=commitment.value,
|
||||
unit=commitment.unit,
|
||||
description=commitment.description,
|
||||
metadata={"adaptive_pricing_model_id": model.id},
|
||||
)
|
||||
for commitment in model.commitments
|
||||
)
|
||||
|
||||
configuration = PublishableConfiguration(
|
||||
key=f"configuration:{configuration_id or model.id}",
|
||||
configuration_id=configuration_id or model.id,
|
||||
product_key=product_key,
|
||||
model_id=model.id,
|
||||
model_name=model.name,
|
||||
segment=segment,
|
||||
price_keys=tuple(price.key for price in prices),
|
||||
commitment_keys=tuple(commitment.key for commitment in commitments),
|
||||
metadata={
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"lifecycle_phase": model.lifecycle_phase,
|
||||
},
|
||||
)
|
||||
|
||||
notes = (
|
||||
"Publication bundles preserve the internal pricing model as the source of truth.",
|
||||
"Provider mappings may mark artifacts exact, approximate, or unsupported without mutating the bundle.",
|
||||
)
|
||||
|
||||
return PublicationBundle(
|
||||
bundle_id=f"bundle:{model.id}",
|
||||
model_id=model.id,
|
||||
model_name=model.name,
|
||||
product=publishable_product,
|
||||
meters=tuple(meters),
|
||||
prices=tuple(prices),
|
||||
commitments=commitments,
|
||||
configurations=(configuration,),
|
||||
provider_hints=model.provider_hints,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _published_artifact(mapped: ProviderMappedArtifact) -> PublishedProviderArtifact:
|
||||
return PublishedProviderArtifact(
|
||||
provider=mapped.provider,
|
||||
source_key=mapped.source_key,
|
||||
source_kind=mapped.source_kind,
|
||||
provider_id=mapped.provider_id,
|
||||
provider_object_type=mapped.provider_object_type,
|
||||
mapping_status=mapped.mapping_status,
|
||||
payload=mapped.payload,
|
||||
metadata=mapped.metadata,
|
||||
notes=mapped.notes,
|
||||
)
|
||||
|
||||
|
||||
def _artifact_changed(
|
||||
desired: ProviderMappedArtifact,
|
||||
current: PublishedProviderArtifact,
|
||||
) -> bool:
|
||||
return any(
|
||||
[
|
||||
desired.mapping_status != current.mapping_status,
|
||||
desired.provider_object_type != current.provider_object_type,
|
||||
_payload_signature(desired.payload) != _payload_signature(current.payload),
|
||||
_payload_signature(desired.metadata) != _payload_signature(current.metadata),
|
||||
desired.notes != current.notes,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _diff_summary(
|
||||
desired: ProviderMappedArtifact,
|
||||
current: PublishedProviderArtifact,
|
||||
) -> DriftFinding:
|
||||
return DriftFinding(
|
||||
provider_id=desired.provider_id,
|
||||
provider_object_type=desired.provider_object_type,
|
||||
severity="warn",
|
||||
summary="Provider shadow state differs from desired pricing definition.",
|
||||
expected={
|
||||
"payload": desired.payload,
|
||||
"metadata": desired.metadata,
|
||||
"mapping_status": desired.mapping_status,
|
||||
"notes": list(desired.notes),
|
||||
},
|
||||
actual={
|
||||
"payload": current.payload,
|
||||
"metadata": current.metadata,
|
||||
"mapping_status": current.mapping_status,
|
||||
"notes": list(current.notes),
|
||||
},
|
||||
suggested_action="Publish the desired definition again or reconcile the provider-side drift.",
|
||||
)
|
||||
|
||||
|
||||
def plan_publication(
|
||||
package: ProviderPublicationPackage,
|
||||
current_state: ProviderPublicationState | None = None,
|
||||
) -> PublicationPlan:
|
||||
current_state = current_state or ProviderPublicationState(provider=package.provider)
|
||||
current_index = {artifact.provider_id: artifact for artifact in current_state.artifacts}
|
||||
desired_publishable = tuple(
|
||||
artifact for artifact in package.artifacts if artifact.mapping_status != "unsupported"
|
||||
)
|
||||
unsupported = tuple(
|
||||
artifact for artifact in package.artifacts if artifact.mapping_status == "unsupported"
|
||||
)
|
||||
|
||||
operations: list[PublicationOperation] = []
|
||||
drift: list[DriftFinding] = []
|
||||
desired_ids = {artifact.provider_id for artifact in desired_publishable}
|
||||
|
||||
for artifact in desired_publishable:
|
||||
current = current_index.get(artifact.provider_id)
|
||||
if current is None:
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="create",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Create provider artifact from canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if _artifact_changed(artifact, current):
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="update",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Update provider artifact to match canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
current_payload=current.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
current_metadata=current.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
current_notes=current.notes,
|
||||
)
|
||||
)
|
||||
drift.append(_diff_summary(artifact, current))
|
||||
continue
|
||||
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="noop",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Provider artifact already matches canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
current_payload=current.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
current_metadata=current.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
current_notes=current.notes,
|
||||
)
|
||||
)
|
||||
|
||||
for artifact in current_state.artifacts:
|
||||
if artifact.provider_id in desired_ids:
|
||||
continue
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="retire",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Retire managed provider artifact no longer present in the desired pricing definition.",
|
||||
current_payload=artifact.payload,
|
||||
current_metadata=artifact.metadata,
|
||||
current_notes=artifact.notes,
|
||||
)
|
||||
)
|
||||
drift.append(
|
||||
DriftFinding(
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
severity="warn",
|
||||
summary="Managed provider artifact exists in shadow state but not in the desired pricing definition.",
|
||||
actual={
|
||||
"payload": artifact.payload,
|
||||
"metadata": artifact.metadata,
|
||||
"mapping_status": artifact.mapping_status,
|
||||
"notes": list(artifact.notes),
|
||||
},
|
||||
suggested_action="Retire the artifact or republish the desired model.",
|
||||
)
|
||||
)
|
||||
|
||||
summary = (
|
||||
f"{package.provider}: {sum(op.kind == 'create' for op in operations)} create, "
|
||||
f"{sum(op.kind == 'update' for op in operations)} update, "
|
||||
f"{sum(op.kind == 'retire' for op in operations)} retire, "
|
||||
f"{sum(op.kind == 'noop' for op in operations)} noop, "
|
||||
f"{len(unsupported)} unsupported."
|
||||
)
|
||||
|
||||
return PublicationPlan(
|
||||
provider=package.provider,
|
||||
bundle_id=package.bundle_id,
|
||||
model_id=package.model_id,
|
||||
model_name=package.model_name,
|
||||
operations=tuple(operations),
|
||||
drift=tuple(drift),
|
||||
unsupported_artifacts=unsupported,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def _next_revision_id(state: ProviderPublicationState) -> str:
|
||||
return f"{state.provider}-rev-{len(state.revisions) + 1:04d}"
|
||||
|
||||
|
||||
def _next_active_model_id(artifacts: list[PublishedProviderArtifact]) -> str | None:
|
||||
product = next((artifact for artifact in artifacts if artifact.source_kind == "product"), None)
|
||||
if product is None:
|
||||
return None
|
||||
if product.metadata.get("model_id"):
|
||||
return str(product.metadata["model_id"])
|
||||
payload_metadata = product.payload.get("metadata", {})
|
||||
if payload_metadata.get("adaptive_pricing_model_id"):
|
||||
return str(payload_metadata["adaptive_pricing_model_id"])
|
||||
return None
|
||||
|
||||
|
||||
def apply_publication(
|
||||
package: ProviderPublicationPackage,
|
||||
current_state: ProviderPublicationState | None = None,
|
||||
) -> PublicationApplyResult:
|
||||
current_state = current_state or ProviderPublicationState(provider=package.provider)
|
||||
plan = plan_publication(package, current_state)
|
||||
desired_index = {
|
||||
artifact.provider_id: artifact
|
||||
for artifact in package.artifacts
|
||||
if artifact.mapping_status != "unsupported"
|
||||
}
|
||||
artifact_index = {artifact.provider_id: artifact for artifact in current_state.artifacts}
|
||||
|
||||
for operation in plan.operations:
|
||||
if operation.kind in {"create", "update", "noop"}:
|
||||
artifact_index[operation.provider_id] = _published_artifact(
|
||||
desired_index[operation.provider_id]
|
||||
)
|
||||
elif operation.kind == "retire":
|
||||
artifact_index.pop(operation.provider_id, None)
|
||||
|
||||
snapshot = tuple(sorted(artifact_index.values(), key=lambda artifact: artifact.provider_id))
|
||||
revision = PublicationRevision(
|
||||
revision_id=_next_revision_id(current_state),
|
||||
model_id=package.model_id,
|
||||
model_name=package.model_name,
|
||||
summary=plan.summary,
|
||||
operations=plan.operations,
|
||||
snapshot=snapshot,
|
||||
replaced_revision_id=current_state.active_revision_id,
|
||||
)
|
||||
state = ProviderPublicationState(
|
||||
provider=package.provider,
|
||||
active_revision_id=revision.revision_id,
|
||||
active_model_id=_next_active_model_id(list(snapshot)),
|
||||
artifacts=snapshot,
|
||||
revisions=current_state.revisions + (revision,),
|
||||
)
|
||||
return PublicationApplyResult(
|
||||
plan=plan,
|
||||
revision=revision,
|
||||
state=state,
|
||||
summary=plan.summary,
|
||||
)
|
||||
|
||||
|
||||
def rollback_publication(
|
||||
state: ProviderPublicationState,
|
||||
revision_id: str,
|
||||
) -> PublicationApplyResult:
|
||||
revision = next((item for item in state.revisions if item.revision_id == revision_id), None)
|
||||
if revision is None:
|
||||
raise ValueError(f"unknown revision_id '{revision_id}'")
|
||||
|
||||
rollback_operation = PublicationOperation(
|
||||
kind="rollback",
|
||||
provider_id=revision.revision_id,
|
||||
provider_object_type="revision",
|
||||
source_key=None,
|
||||
source_kind=None,
|
||||
mapping_status=None,
|
||||
summary=f"Rollback provider shadow state to {revision.revision_id}.",
|
||||
)
|
||||
new_revision = PublicationRevision(
|
||||
revision_id=_next_revision_id(state),
|
||||
model_id=revision.model_id,
|
||||
model_name=revision.model_name,
|
||||
summary=f"Rolled back provider shadow state to {revision.revision_id}.",
|
||||
operations=(rollback_operation,),
|
||||
snapshot=revision.snapshot,
|
||||
replaced_revision_id=state.active_revision_id,
|
||||
)
|
||||
new_state = ProviderPublicationState(
|
||||
provider=state.provider,
|
||||
active_revision_id=new_revision.revision_id,
|
||||
active_model_id=revision.model_id,
|
||||
artifacts=revision.snapshot,
|
||||
revisions=state.revisions + (new_revision,),
|
||||
)
|
||||
return PublicationApplyResult(
|
||||
plan=PublicationPlan(
|
||||
provider=state.provider,
|
||||
bundle_id=f"rollback:{revision.revision_id}",
|
||||
model_id=revision.model_id,
|
||||
model_name=revision.model_name,
|
||||
operations=(rollback_operation,),
|
||||
drift=(),
|
||||
unsupported_artifacts=(),
|
||||
summary=new_revision.summary,
|
||||
),
|
||||
revision=new_revision,
|
||||
state=new_state,
|
||||
summary=new_revision.summary,
|
||||
)
|
||||
|
||||
|
||||
def provider_state_to_dict(state: ProviderPublicationState) -> dict[str, Any]:
|
||||
return _serialize_value(state)
|
||||
|
||||
|
||||
def _operation_from_dict(raw: dict[str, Any]) -> PublicationOperation:
|
||||
return PublicationOperation(
|
||||
kind=raw["kind"],
|
||||
provider_id=raw["provider_id"],
|
||||
provider_object_type=raw["provider_object_type"],
|
||||
source_key=raw.get("source_key"),
|
||||
source_kind=raw.get("source_kind"),
|
||||
mapping_status=raw.get("mapping_status"),
|
||||
summary=raw["summary"],
|
||||
desired_payload=dict(raw.get("desired_payload", {})),
|
||||
current_payload=dict(raw.get("current_payload", {})),
|
||||
desired_metadata=dict(raw.get("desired_metadata", {})),
|
||||
current_metadata=dict(raw.get("current_metadata", {})),
|
||||
desired_notes=tuple(raw.get("desired_notes", [])),
|
||||
current_notes=tuple(raw.get("current_notes", [])),
|
||||
)
|
||||
|
||||
|
||||
def _artifact_from_dict(raw: dict[str, Any]) -> PublishedProviderArtifact:
|
||||
return PublishedProviderArtifact(
|
||||
provider=raw["provider"],
|
||||
source_key=raw["source_key"],
|
||||
source_kind=raw["source_kind"],
|
||||
provider_id=raw["provider_id"],
|
||||
provider_object_type=raw["provider_object_type"],
|
||||
mapping_status=raw["mapping_status"],
|
||||
payload=dict(raw.get("payload", {})),
|
||||
metadata=dict(raw.get("metadata", {})),
|
||||
notes=tuple(raw.get("notes", [])),
|
||||
)
|
||||
|
||||
|
||||
def _revision_from_dict(raw: dict[str, Any]) -> PublicationRevision:
|
||||
return PublicationRevision(
|
||||
revision_id=raw["revision_id"],
|
||||
model_id=raw["model_id"],
|
||||
model_name=raw["model_name"],
|
||||
summary=raw["summary"],
|
||||
operations=tuple(_operation_from_dict(item) for item in raw.get("operations", [])),
|
||||
snapshot=tuple(_artifact_from_dict(item) for item in raw.get("snapshot", [])),
|
||||
replaced_revision_id=raw.get("replaced_revision_id"),
|
||||
)
|
||||
|
||||
|
||||
def provider_state_from_dict(raw: dict[str, Any]) -> ProviderPublicationState:
|
||||
return ProviderPublicationState(
|
||||
provider=raw["provider"],
|
||||
active_revision_id=raw.get("active_revision_id"),
|
||||
active_model_id=raw.get("active_model_id"),
|
||||
artifacts=tuple(_artifact_from_dict(item) for item in raw.get("artifacts", [])),
|
||||
revisions=tuple(_revision_from_dict(item) for item in raw.get("revisions", [])),
|
||||
)
|
||||
330
adaptive_pricing_core/stripe_provider.py
Normal file
330
adaptive_pricing_core/stripe_provider.py
Normal file
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .provider_publication import (
|
||||
ProviderMappedArtifact,
|
||||
ProviderPublicationPackage,
|
||||
PublicationBundle,
|
||||
PublishableCommitment,
|
||||
PublishableConfiguration,
|
||||
PublishableMeter,
|
||||
PublishablePrice,
|
||||
)
|
||||
|
||||
|
||||
def _lookup_key(*parts: str) -> str:
|
||||
return "--".join(
|
||||
part.replace(":", "-").replace("_", "-")
|
||||
for part in parts
|
||||
if part
|
||||
)
|
||||
|
||||
|
||||
def _stripe_interval(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.lower()
|
||||
if normalized in {"monthly", "month"}:
|
||||
return "month"
|
||||
if normalized in {"yearly", "annual", "year"}:
|
||||
return "year"
|
||||
return normalized
|
||||
|
||||
|
||||
def _stripe_hints(bundle: PublicationBundle) -> dict[str, Any]:
|
||||
return dict(bundle.provider_hints.get("stripe", {}))
|
||||
|
||||
|
||||
def _product_provider_id(bundle: PublicationBundle) -> str:
|
||||
hints = _stripe_hints(bundle)
|
||||
return hints.get("product_lookup_key") or _lookup_key("product", bundle.product.product_id)
|
||||
|
||||
|
||||
def _meter_provider_id(bundle: PublicationBundle, meter: PublishableMeter) -> str:
|
||||
hints = _stripe_hints(bundle)
|
||||
if hints.get("meter_name") and len(bundle.meters) == 1:
|
||||
return str(hints["meter_name"])
|
||||
return _lookup_key("meter", bundle.model_id, meter.meter_id)
|
||||
|
||||
|
||||
def _product_artifact(bundle: PublicationBundle) -> ProviderMappedArtifact:
|
||||
hints = _stripe_hints(bundle)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=bundle.product.key,
|
||||
source_kind="product",
|
||||
provider_id=_product_provider_id(bundle),
|
||||
provider_object_type="product",
|
||||
mapping_status="exact",
|
||||
payload={
|
||||
"lookup_key": _product_provider_id(bundle),
|
||||
"name": bundle.product.name,
|
||||
"description": bundle.product.description,
|
||||
"active": bundle.product.active,
|
||||
"metadata": {
|
||||
**bundle.product.metadata,
|
||||
"collection_method": hints.get("collection_method", "charge_automatically"),
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe product mapping is direct for catalog identity and metadata.",),
|
||||
)
|
||||
|
||||
|
||||
def _meter_artifact(bundle: PublicationBundle, meter: PublishableMeter) -> ProviderMappedArtifact:
|
||||
provider_id = _meter_provider_id(bundle, meter)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=meter.key,
|
||||
source_kind="meter",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="billing_meter",
|
||||
mapping_status="exact",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"display_name": meter.name,
|
||||
"event_name": meter.event_name,
|
||||
"default_aggregation": {"formula": meter.aggregation},
|
||||
"unit_label": meter.unit,
|
||||
"metadata": {
|
||||
**meter.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe meter mapping is direct for metered usage identifiers.",),
|
||||
)
|
||||
|
||||
|
||||
def _fixed_price_payload(bundle: PublicationBundle, price: PublishablePrice) -> dict[str, Any]:
|
||||
payload = {
|
||||
"lookup_key": _lookup_key("price", bundle.model_id, price.component_id),
|
||||
"product": _product_provider_id(bundle),
|
||||
"currency": price.currency.lower(),
|
||||
"nickname": price.label,
|
||||
"unit_amount_decimal": str(price.amount or "0"),
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
}
|
||||
if price.billing_treatment != "one_time" and price.cadence:
|
||||
payload["recurring"] = {"interval": _stripe_interval(price.cadence)}
|
||||
return payload
|
||||
|
||||
|
||||
def _discount_artifact(bundle: PublicationBundle, price: PublishablePrice) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("coupon", bundle.model_id, price.component_id)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="coupon",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"amount_off_decimal": str(abs(price.amount or 0)),
|
||||
"currency": price.currency.lower(),
|
||||
"name": price.label,
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe coupons approximate discount components because attachment to subscriptions and eligibility rules lives outside the price object.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _usage_price_artifact(
|
||||
bundle: PublicationBundle,
|
||||
price: PublishablePrice,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("price", bundle.model_id, price.component_id)
|
||||
if price.meter_key is None or price.unit_price is None:
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status="unsupported",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"reason": "usage component lacks a billable per-unit price or mapped meter",
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe publication cannot create a metered price without both a meter and a per-unit charge.",
|
||||
),
|
||||
)
|
||||
|
||||
meter_provider_id = _lookup_key("meter", bundle.model_id, price.component_id)
|
||||
if len(bundle.meters) == 1:
|
||||
meter_provider_id = _meter_provider_id(bundle, bundle.meters[0])
|
||||
status = "approximate" if price.included_units not in (None, 0) else "exact"
|
||||
notes = [
|
||||
"Stripe metered price mapping is direct for per-unit overage billing.",
|
||||
]
|
||||
if status == "approximate":
|
||||
notes.append(
|
||||
"Included usage allowance requires supplemental credits, invoice adjustments, or custom entitlement logic outside the Stripe price object."
|
||||
)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status=status,
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"product": _product_provider_id(bundle),
|
||||
"currency": price.currency.lower(),
|
||||
"nickname": price.label,
|
||||
"billing_scheme": "per_unit",
|
||||
"unit_amount_decimal": str(price.unit_price),
|
||||
"recurring": {
|
||||
"interval": _stripe_interval(price.cadence) or "month",
|
||||
"usage_type": "metered",
|
||||
},
|
||||
"meter": meter_provider_id,
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"included_units": str(price.included_units) if price.included_units is not None else None,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=tuple(notes),
|
||||
)
|
||||
|
||||
|
||||
def _price_artifact(bundle: PublicationBundle, price: PublishablePrice) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("price", bundle.model_id, price.component_id)
|
||||
if price.component_kind == "usage":
|
||||
return _usage_price_artifact(bundle, price)
|
||||
if price.component_kind == "discount":
|
||||
return _discount_artifact(bundle, price)
|
||||
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status="exact",
|
||||
payload=_fixed_price_payload(bundle, price),
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe price mapping is direct for fixed recurring or one-time charges.",),
|
||||
)
|
||||
|
||||
|
||||
def _commitment_artifact(
|
||||
bundle: PublicationBundle,
|
||||
commitment: PublishableCommitment,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("commitment", bundle.model_id, commitment.commitment_id)
|
||||
if commitment.kind == "contract_duration":
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=commitment.key,
|
||||
source_kind="commitment",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"metadata": {
|
||||
**commitment.metadata,
|
||||
"contract_duration_value": commitment.value,
|
||||
"contract_duration_unit": commitment.unit,
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe can store contract duration metadata, but enforcement still relies on subscription schedule policy or external contract workflow.",
|
||||
),
|
||||
)
|
||||
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=commitment.key,
|
||||
source_kind="commitment",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="unsupported",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"kind": commitment.kind,
|
||||
"value": commitment.value,
|
||||
"unit": commitment.unit,
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe metadata alone cannot enforce this commitment semantics; it remains an internal pricing and contract artifact.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configuration_artifact(
|
||||
bundle: PublicationBundle,
|
||||
configuration: PublishableConfiguration,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("configuration", configuration.configuration_id)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=configuration.key,
|
||||
source_kind="configuration",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"metadata": {
|
||||
**configuration.metadata,
|
||||
"configuration_id": configuration.configuration_id,
|
||||
"segment": configuration.segment,
|
||||
"price_keys": list(configuration.price_keys),
|
||||
"commitment_keys": list(configuration.commitment_keys),
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Customer or default pricing configurations can be recorded in Stripe metadata, but they are not first-class Stripe catalog objects.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def map_bundle_to_stripe(bundle: PublicationBundle) -> ProviderPublicationPackage:
|
||||
artifacts: list[ProviderMappedArtifact] = [_product_artifact(bundle)]
|
||||
artifacts.extend(_meter_artifact(bundle, meter) for meter in bundle.meters)
|
||||
artifacts.extend(_price_artifact(bundle, price) for price in bundle.prices)
|
||||
artifacts.extend(_commitment_artifact(bundle, commitment) for commitment in bundle.commitments)
|
||||
artifacts.extend(
|
||||
_configuration_artifact(bundle, configuration)
|
||||
for configuration in bundle.configurations
|
||||
)
|
||||
|
||||
notes = [
|
||||
"Stripe remains an execution backend; adaptive-pricing stays the source of truth.",
|
||||
"Exact mappings create publishable Stripe shadow artifacts; approximate mappings require supplemental operational logic.",
|
||||
]
|
||||
if _stripe_hints(bundle).get("metered_usage_strategy") == "future_adapter":
|
||||
notes.append(
|
||||
"The pricing model declares a future metered-usage strategy hint, so Stripe publication keeps the usage allowance semantics descriptive rather than executable."
|
||||
)
|
||||
|
||||
return ProviderPublicationPackage(
|
||||
provider="stripe",
|
||||
bundle_id=bundle.bundle_id,
|
||||
model_id=bundle.model_id,
|
||||
model_name=bundle.model_name,
|
||||
artifacts=tuple(artifacts),
|
||||
notes=tuple(notes),
|
||||
)
|
||||
Reference in New Issue
Block a user