Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# Changelog

## Unreleased
## 0.1.0a3 — 2026-11-13

- Link initial orders to their subscriptions through
`lines[].relatedResources.subscriptionId` in `order.paid`/`checkout.paid` reactions
and `reconcile_checkout`.
- `reconcile_checkout` reports signup lines with a still-null `subscriptionId` as
`unresolved_line_ids`; `legacy_plan_ids` now lists only plan lines without
`relatedResources` (historical orders).
- Require `vatly-api-python==0.1.0a3` from PyPI; drop the vendored SDK wheel.
- Refresh installation, European positioning and published-package documentation.

## 0.1.0a2 — 2026-11-12
Expand Down
10 changes: 5 additions & 5 deletions PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ customer-binding fallback. `EntityRef(kind, id)` supports teams/users/organizati
billing identities and cannot establish entitlement ownership. Drivers enforce
immutable links and scope every lookup by tenant, storefront and API mode.

The original port targeted an earlier design of core
[PR #2910](https://github.com/sandervanhooft/vatlify/pull/2910). As of 2026-11-12,
that unmerged proposal instead adds nullable `relatedResources.subscriptionId`
and preserves `productType`/`productId`. This alpha does not consume the new field;
initial subscription linkage needs a separately tested compatibility update.
Core [PR #2910](https://github.com/sandervanhooft/vatlify/pull/2910) adds nullable
`lines[].relatedResources.subscriptionId` and preserves `productType`/`productId`.
Linkage reads that field (and `subscription` renewal product IDs) to link a checkout's
order to its subscriptions. A null `subscriptionId` on a signup line means core has
not resolved it yet; frozen webhook snapshots keep the original value.
`subscription_plan` lines are preserved but never treated as subscription IDs.
Quantity does not multiply subscription IDs; two checkout plan lines can produce
distinct subscription IDs for the same plan. `reconcile_checkout` explicitly reads
Expand Down
16 changes: 7 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ transactions and jobs. Python 3.10+, fully typed, **async-first**.
pip install vatly-fluent-python==0.1.0a2 vatly-api-python==0.1.0a2
```

For development, run `.agents/setup` or `uv sync --locked`. The retained versioned
wheel under `vendor/` pins the SDK for uv; normal package metadata depends on the
exact SDK version from PyPI. It is not copied into Fluent's wheel.
See [provenance](vendor/README.md).
For development, run `.agents/setup` or `uv sync --locked`. Both development and
package metadata use the exact SDK version from PyPI.

## First checkout

Expand Down Expand Up @@ -69,11 +67,11 @@ falls back to `customerId`, which is only billing identity and may be shared or
Metadata is correlation data, not authorization. A signed webhook authenticates
Vatly's snapshot; it does not authorize a browser-provided entity ID.

Current linkage follows `productType="subscription"` lines and their subscription
IDs; `subscription_plan` lines remain plan references. The revised core PR #2910
proposes additive `relatedResources.subscriptionId` instead of changing initial
product references. This alpha does not consume that new field yet: initial
subscription ownership remains unresolved. See the [compatibility notes](docs/Subscriptions.md).
Linkage follows each order line's `relatedResources.subscriptionId`, which names
the signup's subscription on the initial order, and `productType="subscription"`
renewal lines. `subscription_plan` product IDs remain plan references. A signed
`order.paid` snapshot can carry a null `subscriptionId`; reconcile the checkout to
read the current order. See [Subscriptions](docs/Subscriptions.md).
Fluent never guesses a subscription from a customer, plan or quantity.

## Learn the integration
Expand Down
13 changes: 4 additions & 9 deletions docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,10 @@ imports and `py.typed`. These commands do not publish anything.

## Pinned SDK dependency

Development uses the SDK wheel in `vendor/`. Published Fluent metadata declares
the normal, exact PyPI dependency instead. Verify the pinned file before updating:

```bash
sha256sum --check vendor/SHA256SUMS
```

Keep published wheels immutable. Use a new alpha version when dependency behavior
changes; do not silently replace a version's wheel.
Fluent pins one exact `vatly-api-python` version from PyPI in `pyproject.toml`;
`uv.lock` records its hashes. To move to a new SDK version, change the pin, run
`uv lock`, and run the full checks. Use a new Fluent alpha version when dependency
behavior changes.

## Documentation sync

Expand Down
11 changes: 7 additions & 4 deletions docs/Subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ async def example(vatly: Vatly):
```

Authorize the checkout and wrap local writes in your driver's transaction. Reads
follow checkout → order → distinct subscription IDs; repeated 404s remain pending.
This alpha reports plan-valued lines as `legacy_plan_ids` and does not consume the
proposed `relatedResources.subscriptionId` addition. A plan/customer match is
never an ownership fallback. Trial flows can lack `order.paid`, so reconciliation
follow checkout → order → distinct subscription IDs from each line's
`relatedResources.subscriptionId` and from `subscription` renewal lines; repeated
404s remain pending. A signup line whose `subscriptionId` is still null is
reported in `unresolved_line_ids`: core has not resolved that subscription yet, so
reconcile again later; it may never resolve. Plan lines on historical orders
without `relatedResources` are reported as `legacy_plan_ids`. A plan/customer
match is never an ownership fallback. Trial flows can lack `order.paid`, so reconciliation
must be explicit. Unknown linkage must not grant access.

[Subscription API reference](https://docs.vatly.com/api-reference/subscriptions)
9 changes: 3 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "hatchling.build"

[project]
name = "vatly-fluent-python"
version = "0.1.0a2"
version = "0.1.0a3"
description = "Framework-independent fluent billing integrations built on the Vatly Python SDK"
keywords = ["vatly", "merchant-of-record", "billing", "payments", "subscriptions", "vat", "ai"]
requires-python = ">=3.10"
readme = "README.md"
license = "MIT"
dependencies = ["vatly-api-python==0.1.0a2"]
dependencies = ["vatly-api-python==0.1.0a3"]
classifiers = ["Development Status :: 3 - Alpha", "Typing :: Typed", "Programming Language :: Python :: 3"]

[project.urls]
Expand All @@ -31,14 +31,11 @@ dev = [
[tool.uv]
required-version = ">=0.12.9"

[tool.uv.sources]
vatly-api-python = {path = "vendor/vatly_api_python-0.1.0a2-py3-none-any.whl"}

[tool.hatch.build.targets.wheel]
packages = ["src/vatly_fluent"]

[tool.hatch.build.targets.sdist]
include = ["src", "tests", "examples", "scripts", "vendor", "docs", "README.md", "PORTING.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
include = ["src", "tests", "examples", "scripts", "docs", "README.md", "PORTING.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
Expand Down
5 changes: 2 additions & 3 deletions scripts/check_package.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
"""Isolated wheel installation including the unpublished SDK bootstrap."""
"""Isolated wheel installation; the pinned SDK resolves from PyPI."""

import pathlib
import subprocess
import tempfile

root = pathlib.Path(__file__).resolve().parents[1]
wheel = next((root / "dist").glob("*.whl"))
sdk = next((root / "vendor").glob("*.whl"))
with tempfile.TemporaryDirectory() as tmp:
env = pathlib.Path(tmp) / "venv"
subprocess.run(["uv", "venv", str(env)], check=True)
python = str(env / "bin/python")
subprocess.run(["uv", "pip", "install", "--python", python, str(sdk), str(wheel)], check=True)
subprocess.run(["uv", "pip", "install", "--python", python, str(wheel)], check=True)
subprocess.run(
[
python,
Expand Down
2 changes: 1 addition & 1 deletion src/vatly_fluent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from .handles import OrderHandle, SubscriptionHandle
from .webhooks import WebhookProcessor

__version__ = "0.1.0a2"
__version__ = "0.1.0a3"

__all__ = [
"Vatly",
Expand Down
24 changes: 15 additions & 9 deletions src/vatly_fluent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from .contracts import CustomerProfile, FluentError, LocalSubscription, Wiring, required
from .customers import CustomerHandle, CustomerService
from .handles import OrderHandle, SubscriptionHandle
from .linkage import link_order
from .linkage import link_order, subscription_ids
from .webhooks import WebhookProcessor


Expand All @@ -22,6 +22,7 @@ class CheckoutResolution:
subscriptions: tuple[Subscription, ...] = ()
pending_subscription_ids: tuple[str, ...] = ()
legacy_plan_ids: tuple[str, ...] = ()
unresolved_line_ids: tuple[str, ...] = ()


class TestHelpers:
Expand Down Expand Up @@ -126,6 +127,9 @@ async def reconcile_checkout(self, id: str, *, attempts: int = 3) -> CheckoutRes
Caller authorizes the checkout and runs own-record writes in a transaction.
No customerId fallback and no plan->subscription guessing. A pending ID can
remain absent forever when the core deliberately skips subscription start.
``unresolved_line_ids`` are signup lines whose subscription core has not
resolved yet; they may resolve on a later call, or never.
``legacy_plan_ids`` are plan lines on historical orders without a reference.
"""
if isinstance(attempts, bool) or not isinstance(attempts, int) or not 1 <= attempts <= 10:
raise FluentError("attempts must be an integer between 1 and 10")
Expand All @@ -142,12 +146,7 @@ async def reconcile_checkout(self, id: str, *, attempts: int = 3) -> CheckoutRes
await link_order(self.wiring, record, order)
found = []
pending = []
ids = dict.fromkeys(
line.product_id
for line in order.lines
if line.product_type == "subscription" and line.product_id
)
for sub_id in ids:
for sub_id in subscription_ids(order):
for attempt in range(attempts):
try:
subscription = await self.api.subscriptions.get(sub_id)
Expand All @@ -164,7 +163,14 @@ async def reconcile_checkout(self, id: str, *, attempts: int = 3) -> CheckoutRes
dict.fromkeys(
line.product_id
for line in order.lines
if line.product_type == "subscription_plan" and line.product_id
if line.product_type == "subscription_plan"
and line.product_id
and line.related_resources is None
)
)
return CheckoutResolution(order, tuple(found), tuple(pending), legacy)
unresolved = tuple(
line.id
for line in order.lines
if line.related_resources is not None and not line.related_resources.subscription_id
)
return CheckoutResolution(order, tuple(found), tuple(pending), legacy, unresolved)
28 changes: 21 additions & 7 deletions src/vatly_fluent/linkage.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,28 @@ async def link_order(wiring: Wiring, record: CheckoutRecord, order: Order) -> No
await wiring.orders.update(
order.id, {"entity": preserve_entity(existing.entity, record.entity)}
)
for line in order.lines:
# Never mistake historical subscription_plan IDs for subscription IDs.
if line.product_type != "subscription" or not line.product_id:
continue
await records.link(record.checkout_id, "subscription", line.product_id)
for sub_id in subscription_ids(order):
await records.link(record.checkout_id, "subscription", sub_id)
if wiring.subscriptions:
sub = await wiring.subscriptions.find_by_vatly_id(line.product_id)
sub = await wiring.subscriptions.find_by_vatly_id(sub_id)
if sub:
await wiring.subscriptions.update(
line.product_id, {"entity": preserve_entity(sub.entity, record.entity)}
sub_id, {"entity": preserve_entity(sub.entity, record.entity)}
)


def subscription_ids(order: Order) -> tuple[str, ...]:
"""Distinct subscription IDs the order's lines reference, in line order.

Signup lines carry their subscription in ``relatedResources.subscriptionId``
(null until core resolves it); renewal lines also reference it as their
product. A ``subscription_plan`` product ID is a plan, never a subscription.
"""
ids: dict[str, None] = {}
for line in order.lines:
related = line.related_resources
if related is not None and related.subscription_id:
ids[related.subscription_id] = None
if line.product_type == "subscription" and line.product_id:
ids[line.product_id] = None
return tuple(ids)
38 changes: 28 additions & 10 deletions tests/test_fluent.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,14 @@ async def test_out_of_order_own_record_linkage_not_customer_id(harness, order_of
await db.checkouts.record(CheckoutRecord("checkout_a", TEAM, True))
await db.bindings.bind("customer_shared", "WRONG_ENTITY")
order = resource("Order", id="order_a", customerId="customer_shared", status="paid")
order["lines"][0].update(productType="subscription", productId="subscription_a", quantity=3)
# Signup lines reference the plan; relatedResources names each line's subscription.
signup = {**order["lines"][0], "productType": "subscription_plan", "quantity": 3}
signup.update(
productId="subscription_plan_pro", relatedResources={"subscriptionId": "subscription_a"}
)
# Two plan lines may be the same plan, but own distinct subscription IDs.
order["lines"].append({**order["lines"][0], "id": "line_b", "productId": "subscription_b"})
second = {**signup, "id": "line_b", "relatedResources": {"subscriptionId": "subscription_b"}}
order["lines"] = [signup, second]
checkout = resource(
"Checkout", id="checkout_a", orderId="order_a", customerId="customer_shared", status="paid"
)
Expand All @@ -194,6 +199,7 @@ async def test_out_of_order_own_record_linkage_not_customer_id(harness, order_of
assert (await db.orders.find_by_vatly_id("order_a")).entity == TEAM
assert (await db.subscriptions.find_by_vatly_id("subscription_a")).entity == TEAM
assert (await db.checkouts.find_for_resource("subscription", "subscription_b")).entity == TEAM
assert await db.checkouts.find_for_resource("subscription", "subscription_plan_pro") is None
assert db.db.execute("SELECT COUNT(*) FROM webhook_calls").fetchone()[0] == 3
assert db.db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 5
assert not calls
Expand Down Expand Up @@ -456,7 +462,7 @@ async def test_handles_use_remote_response_for_scheduled_swap_and_decimal_money(


@pytest.mark.parametrize("delay_first", [False, True])
async def test_reconcile_initial_subscription_ids_bounded_404_and_legacy(
async def test_reconcile_related_subscription_ids_bounded_404_unresolved_and_legacy(
harness, monkeypatch, delay_first
):
vatly, db, calls, responses = harness
Expand All @@ -468,20 +474,26 @@ async def no_sleep(t):
await db.checkouts.record(CheckoutRecord("checkout_a", TEAM, True))
order = resource("Order", id="order_a")
line = order["lines"][0]
plan_line = {**line, "productType": "subscription_plan", "productId": "subscription_plan_pro"}
order["lines"] = [
{**line, "productType": "subscription", "productId": "subscription_a", "quantity": 7},
{**plan_line, "quantity": 7, "relatedResources": {"subscriptionId": "subscription_a"}},
{
**line,
"id": "line_b",
"productType": "subscription",
"productId": "subscription_missing",
"relatedResources": {"subscriptionId": "subscription_missing"},
},
# Historical plan lines: relatedResources omitted or null.
{**plan_line, "id": "line_c", "productId": "subscription_plan_legacy"},
{
**line,
"id": "line_c",
"productType": "subscription_plan",
"productId": "subscription_plan_legacy",
**plan_line,
"id": "line_d",
"productId": "subscription_plan_old",
"relatedResources": None,
},
# A signup whose subscription core has not resolved yet.
{**plan_line, "id": "line_e", "relatedResources": {"subscriptionId": None}},
]
responses.extend([resource("Checkout", id="checkout_a", orderId="order_a"), order])
if delay_first:
Expand All @@ -492,9 +504,15 @@ async def no_sleep(t):
result = await vatly.reconcile_checkout("checkout_a", attempts=2)
assert [sub.id for sub in result.subscriptions] == ["subscription_a"]
assert result.pending_subscription_ids == ("subscription_missing",)
assert result.legacy_plan_ids == ("subscription_plan_legacy",)
assert not any("subscription_plan_legacy" in str(call.url) for call in calls)
assert result.legacy_plan_ids == ("subscription_plan_legacy", "subscription_plan_old")
assert result.unresolved_line_ids == ("line_e",)
assert not any("subscription_plan" in str(call.url) for call in calls)
assert not responses
assert (await db.checkouts.find_for_resource("subscription", "subscription_a")).entity == TEAM
assert (
await db.checkouts.find_for_resource("subscription", "subscription_missing")
).entity == TEAM
assert await db.checkouts.find_for_resource("subscription", "subscription_plan_pro") is None


def test_state_boundaries():
Expand Down
18 changes: 6 additions & 12 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading