diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e79da..96a9edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PORTING.md b/PORTING.md index cb2e986..c6fdfd1 100644 --- a/PORTING.md +++ b/PORTING.md @@ -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 diff --git a/README.md b/README.md index a1d6dad..eddcbc1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/Development.md b/docs/Development.md index cf2e6cc..aafc6ca 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -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 diff --git a/docs/Subscriptions.md b/docs/Subscriptions.md index 29f6297..3d178e2 100644 --- a/docs/Subscriptions.md +++ b/docs/Subscriptions.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 637b75b..0bf321b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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" diff --git a/scripts/check_package.py b/scripts/check_package.py index 46da629..dde2a8e 100644 --- a/scripts/check_package.py +++ b/scripts/check_package.py @@ -1,4 +1,4 @@ -"""Isolated wheel installation including the unpublished SDK bootstrap.""" +"""Isolated wheel installation; the pinned SDK resolves from PyPI.""" import pathlib import subprocess @@ -6,12 +6,11 @@ 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, diff --git a/src/vatly_fluent/__init__.py b/src/vatly_fluent/__init__.py index b308af8..17b79ec 100644 --- a/src/vatly_fluent/__init__.py +++ b/src/vatly_fluent/__init__.py @@ -27,7 +27,7 @@ from .handles import OrderHandle, SubscriptionHandle from .webhooks import WebhookProcessor -__version__ = "0.1.0a2" +__version__ = "0.1.0a3" __all__ = [ "Vatly", diff --git a/src/vatly_fluent/client.py b/src/vatly_fluent/client.py index c4c1924..65e417d 100644 --- a/src/vatly_fluent/client.py +++ b/src/vatly_fluent/client.py @@ -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 @@ -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: @@ -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") @@ -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) @@ -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) diff --git a/src/vatly_fluent/linkage.py b/src/vatly_fluent/linkage.py index 8c29aac..93c380e 100644 --- a/src/vatly_fluent/linkage.py +++ b/src/vatly_fluent/linkage.py @@ -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) diff --git a/tests/test_fluent.py b/tests/test_fluent.py index 0a935e6..e215026 100644 --- a/tests/test_fluent.py +++ b/tests/test_fluent.py @@ -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" ) @@ -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 @@ -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 @@ -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: @@ -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(): diff --git a/uv.lock b/uv.lock index 06ba692..5e0b44b 100644 --- a/uv.lock +++ b/uv.lock @@ -1571,27 +1571,21 @@ wheels = [ [[package]] name = "vatly-api-python" -version = "0.1.0a2" -source = { path = "vendor/vatly_api_python-0.1.0a2-py3-none-any.whl" } +version = "0.1.0a3" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/24/06/3fe5113356153b6619552c6e0c7f33c6c7771d27fda2085172276edc26d8/vatly_api_python-0.1.0a3.tar.gz", hash = "sha256:aa8af3f52d6570a869aceb7a7c6ee64ddf85f124ed1bf504eabc28228b3ad07d", size = 112463, upload-time = "2026-11-13T16:59:55.29Z" } wheels = [ - { filename = "vatly_api_python-0.1.0a2-py3-none-any.whl", hash = "sha256:c85d3d4fdc6573820b0916010c8f968c2201bccdb50fc791d88274794b8fd774" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27,<1" }, - { name = "pydantic", specifier = ">=2.11,<3" }, - { name = "typing-extensions", specifier = ">=4.12,<5" }, + { url = "https://files.pythonhosted.org/packages/32/d3/510c5b139fc8a180d82d0ae0dd7b6ccc46a732493bd4cdab198c47d5a5b9/vatly_api_python-0.1.0a3-py3-none-any.whl", hash = "sha256:436f4d2ec62a96fdb71fd8431996a69bb0358fd690d792df643a344ea961029b", size = 39347, upload-time = "2026-11-13T16:59:54.028Z" }, ] [[package]] name = "vatly-fluent-python" -version = "0.1.0a2" +version = "0.1.0a3" source = { editable = "." } dependencies = [ { name = "vatly-api-python" }, @@ -1608,7 +1602,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "vatly-api-python", path = "vendor/vatly_api_python-0.1.0a2-py3-none-any.whl" }] +requires-dist = [{ name = "vatly-api-python", specifier = "==0.1.0a3" }] [package.metadata.requires-dev] dev = [ diff --git a/vendor/README.md b/vendor/README.md deleted file mode 100644 index df8de19..0000000 --- a/vendor/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Pinned SDK development wheel - -`vatly_api_python-0.1.0a2-py3-none-any.whl` is built from the MIT-licensed -`vatly/vatly-api-python` 0.1.0a2 source at commit -`26adc74d8553238bcc94da2f650619de93e9cbd6`. See `SHA256SUMS` for exact wheel bytes. - -It lets standalone CI and fresh orbs use the pinned SDK -without a machine-relative sibling checkout. uv's -development source override uses this file; Fluent's wheel only contains -`vatly_fluent` and declares the ordinary `vatly-api-python==0.1.0a2` dependency. -The SDK wheel carries its own MIT license. Source is in the repository above. - -The SDK is now [published on PyPI](https://pypi.org/project/vatly-api-python/0.1.0a2/). -This retained development override is not used by ordinary PyPI installations. -To switch development to PyPI, remove the wheel and `[tool.uv.sources]` override, -run `uv lock`, and test index-based installation. -Do not rebuild or replace this version's wheel silently: bump the alpha version -when SDK behavior changes. Keep release tags and their provenance immutable. diff --git a/vendor/SHA256SUMS b/vendor/SHA256SUMS deleted file mode 100644 index 2e60b55..0000000 --- a/vendor/SHA256SUMS +++ /dev/null @@ -1 +0,0 @@ -c85d3d4fdc6573820b0916010c8f968c2201bccdb50fc791d88274794b8fd774 vendor/vatly_api_python-0.1.0a2-py3-none-any.whl diff --git a/vendor/vatly_api_python-0.1.0a2-py3-none-any.whl b/vendor/vatly_api_python-0.1.0a2-py3-none-any.whl deleted file mode 100644 index 038969a..0000000 Binary files a/vendor/vatly_api_python-0.1.0a2-py3-none-any.whl and /dev/null differ