From 7f56b9691f148b90cef4f11fffe58d7df4d75a9b Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Tue, 4 Aug 2026 20:07:35 -0400 Subject: [PATCH 01/15] [WIP] Implement addLedgerEntries --- CHANGELOG.md | 14 + README.md | 101 ++++ fragment/codegen/helpers.py | 1 + .../codegen/plugins/generate_typed_entries.py | 131 +++++ fragment/codegen/typed_entries.py | 452 ++++++++++++++++++ fragment/sdk/__init__.py | 27 ++ fragment/sdk/add_ledger_entries.py | 72 +++ fragment/sdk/client.py | 56 ++- fragment/sdk/typed_entries.py | 88 ++++ fragment/std_queries/queries.graphql | 35 ++ fragment/sync_sdk/__init__.py | 27 ++ fragment/sync_sdk/add_ledger_entries.py | 72 +++ fragment/sync_sdk/client.py | 56 ++- fragment/sync_sdk/typed_entries.py | 88 ++++ poetry.toml | 2 + pyproject.toml | 9 + .../001-marketing-schema/sdk/__init__.py | 48 ++ .../sdk/add_ledger_entries.py | 72 +++ .../001-marketing-schema/sdk/client.py | 56 ++- .../001-marketing-schema/sdk/typed_entries.py | 299 ++++++++++++ tests/test_add_ledger_entries.py | 168 +++++++ 21 files changed, 1871 insertions(+), 3 deletions(-) create mode 100644 fragment/codegen/plugins/generate_typed_entries.py create mode 100644 fragment/codegen/typed_entries.py create mode 100644 fragment/sdk/add_ledger_entries.py create mode 100644 fragment/sdk/typed_entries.py create mode 100644 fragment/sync_sdk/add_ledger_entries.py create mode 100644 fragment/sync_sdk/typed_entries.py create mode 100644 poetry.toml create mode 100644 tests/snapshots/001-marketing-schema/sdk/add_ledger_entries.py create mode 100644 tests/snapshots/001-marketing-schema/sdk/typed_entries.py create mode 100644 tests/test_add_ledger_entries.py diff --git a/CHANGELOG.md b/CHANGELOG.md index de42ed0..3c07a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/). Releases prior to `1.0.0` were published before this changelog was added and are not documented here. +## [Unreleased] + +### Added + +- `AddLedgerEntries` commits a batch of Ledger Entries in one atomic, + strongly-consistent transaction. +- Strongly-typed batch payloads. Codegen now emits a `typed_entries` module with + one model per Ledger Entry type, derived from the per-entry-type + `addLedgerEntry` operations in the codegen input directory. Because a batch + mutation takes one list of one input type, GraphQL cannot type each entry's + `parameters` field individually; these models do. They can be passed to + `add_ledger_entries` directly, mixed with raw `AddLedgerEntryInput` values. + Model names always carry the entry type version, defaulting to `V1`. + ## [1.0.0] ### Changed diff --git a/README.md b/README.md index 130c621..da40942 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,107 @@ await graphql_client.add_ledger_entry( ) ``` +### Post a batch of Ledger Entries + +`add_ledger_entries` commits every entry in one atomic, strongly-consistent +transaction — either all of them are committed, or none are. It takes a list of +`AddLedgerEntryInput`: + +```python +from fragment.sdk.input_types import ( + AddLedgerEntryInput, + LedgerEntryInput, + LedgerMatchInput, +) + +await graphql_client.add_ledger_entries( + entries=[ + AddLedgerEntryInput( + ik="some-ik", + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik="your-ledger-ik"), + type="user_funds_account", + parameters=dict(user_id="user-1", funding_amount="20000"), + ), + ), + ], +) +``` + +Because `parameters` is an untyped JSON field, nothing checks those parameter +names or values. See below for typed payloads that do. + +### Strongly-typed batch payloads + +A batch mutation takes one list of one input type, so GraphQL cannot type each +entry's `parameters` individually. The SDK closes that gap at codegen time: if +your codegen input directory contains the per-entry-type `addLedgerEntry` +operations for your Schema, a `typed_entries` module is generated alongside the +client with one model per entry type. + +Given an operation like this in `queries/`: + +```graphql +mutation PostAuthCapture( + $ik: SafeString! + $ledgerIk: SafeString! + $user_id: String! + $capture_amount: String! +) { + addLedgerEntry( + ik: $ik + entry: { + ledger: { ik: $ledgerIk } + type: "auth_capture" + parameters: { user_id: $user_id, capture_amount: $capture_amount } + } + ) { + __typename + } +} +``` + +you get an `AuthCaptureV1` model named for the entry type and version, and can build batch +payloads with real field names and types: + +```python +from .libs.fragment.custom_queries_package.typed_entries import ( + AuthCaptureV1, + PlatformFundsAccountV1, +) + +await graphql_client.add_ledger_entries( + entries=[ + AuthCaptureV1( + ik="ik-1", + ledger_ik="your-ledger-ik", + user_id="user-1", + capture_amount="100", + ), + PlatformFundsAccountV1( + ik="ik-2", + ledger_ik="your-ledger-ik", + funding_amount="20000", + ), + ], +) +``` + +Typed entries can be mixed with raw `AddLedgerEntryInput` values in the same +call. Every model also accepts the optional `posted`, `tags`, `groups`, and +`conditions` fields, and exposes `to_input()` if you want the raw +`AddLedgerEntryInput` — for example to inspect or adjust a payload before +sending it. `to_entry_inputs()` converts a whole list at once. + +Parameter names are preserved on the wire even when the Python field has to be +escaped: a parameter named `type`, `class`, or `json` becomes `type_`, `class_`, +or `json_` in Python but is still sent under its original Schema name. + +Model names always carry the entry type's version, defaulting to `V1` when the +operation pins no `typeVersion`. This means adding a new version to your Schema +never renames an existing model, so it cannot break call sites. The default is +naming only — an unpinned `typeVersion` is still omitted from the request. + ### Read a Ledger Account's Balance To read a Ledger Account's [balance](https://fragment.dev/docs#read-balances-latest): diff --git a/fragment/codegen/helpers.py b/fragment/codegen/helpers.py index 101f651..86df7b3 100644 --- a/fragment/codegen/helpers.py +++ b/fragment/codegen/helpers.py @@ -42,6 +42,7 @@ def get_codegen_config( plugins=[ "fragment.codegen.plugins.get_file_comment.GenerateFileComment", "fragment.codegen.plugins.generate_client_method.RewriteUnsetTypeMethodArguments", + "fragment.codegen.plugins.generate_typed_entries.GenerateTypedLedgerEntries", ], ), }, diff --git a/fragment/codegen/plugins/generate_typed_entries.py b/fragment/codegen/plugins/generate_typed_entries.py new file mode 100644 index 0000000..b0bdcb0 --- /dev/null +++ b/fragment/codegen/plugins/generate_typed_entries.py @@ -0,0 +1,131 @@ +import ast +from pathlib import Path +from typing import Dict, List, Union + +from ariadne_codegen.plugins.base import Plugin +from graphql import OperationDefinitionNode + +from fragment.codegen.typed_entries import ( + MODULE_NAME, + EntrySpec, + collect_annotations, + extract_entry_spec, + render_module, +) + +ADD_LEDGER_ENTRIES_OPERATION = "addLedgerEntries" +ENTRIES_ARGUMENT = "entries" + + +class GenerateTypedLedgerEntries(Plugin): + """Emit strongly-typed `addLedgerEntries` payload models. + + `addLedgerEntries` accepts a list of a single input type whose `parameters` + field is an opaque `JSON` scalar, so GraphQL alone cannot type an individual + entry in a batch. The per-entry-type `addLedgerEntry` operations already in + the input queries do carry that information, so this plugin recovers it and + renders one pydantic model per entry type into a `typed_entries` module. + + Specs are collected in `generate_client_method`, which ariadne calls for + every operation. The module is written in `generate_init_code`, the last + hook to run, by which point every operation has been seen. + """ + + def __init__(self, schema, config_dict: dict) -> None: + super().__init__(schema, config_dict) + settings = config_dict.get("tool", {}).get("ariadne-codegen", {}) + self.package_path = Path( + settings.get("target_package_path", Path.cwd()) + ) / settings.get("target_package_name", "graphql_client") + self.specs: List[EntrySpec] = [] + self.widened_entries_argument = False + + def generate_client_method( + self, + method_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], + operation_definition: OperationDefinitionNode, + ) -> Union[ast.FunctionDef, ast.AsyncFunctionDef]: + annotations: Dict[str, str] = collect_annotations( + method_def, operation_definition + ) + spec = extract_entry_spec(operation_definition, annotations) + if spec is not None: + self.specs.append(spec) + if ( + operation_definition.name + and operation_definition.name.value == ADD_LEDGER_ENTRIES_OPERATION + ): + self._widen_entries_argument(method_def) + return method_def + + def _widen_entries_argument( + self, method_def: Union[ast.FunctionDef, ast.AsyncFunctionDef] + ) -> None: + """Let `add_ledger_entries` take typed entries as well as raw inputs. + + ariadne annotates the argument `list[AddLedgerEntryInput]`, which typed + entries satisfy at runtime but not under a type checker. Widening to a + `Sequence` of either keeps raw inputs working while accepting typed + models directly -- `Sequence` because `list` is invariant, so + `list[AuthCapture]` would otherwise be rejected. + """ + for arg in method_def.args.args: + if arg.arg != ENTRIES_ARGUMENT: + continue + arg.annotation = ast.Name( + id="Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]" + ) + self.widened_entries_argument = True + + def generate_client_code(self, generated_code: str) -> str: + if not self.widened_entries_argument: + return generated_code + return self._insert_imports( + generated_code, + [ + "from typing import Sequence", + f"from .{MODULE_NAME} import TypedLedgerEntry", + ], + ) + + @staticmethod + def _insert_imports(code: str, imports: List[str]) -> str: + """Insert imports after the module's existing top-level import block.""" + lines = code.splitlines() + last_import_line = 0 + for node in ast.parse(code).body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + last_import_line = max(last_import_line, node.end_lineno or 0) + lines[last_import_line:last_import_line] = imports + return "\n".join(lines) + "\n" + + def generate_init_code(self, generated_code: str) -> str: + module_path = self.package_path / f"{MODULE_NAME}.py" + module_path.write_text(self._add_comment(render_module(self.specs))) + return generated_code + self._init_additions() + + def _add_comment(self, code: str) -> str: + # This module is written directly rather than through ariadne's module + # pipeline, so it does not pass through the GenerateFileComment hook. + queries_path = ( + self.config_dict.get("tool", {}) + .get("ariadne-codegen", {}) + .get("queries_path", "") + ) + comment = "# Generated by fragment (with the help of ariadne-codegen)" + if queries_path: + comment += f"\n# Source: {queries_path}" + return f"{comment}\n\n{code}" + + def _init_additions(self) -> str: + """Re-export the typed models and extend `__all__`.""" + names = ["TypedLedgerEntry", "to_entry_inputs"] + [ + spec.class_name for spec in self.specs + ] + names.sort() + imported = ",\n ".join(names) + exported = "\n".join(f' "{name}",' for name in names) + return ( + f"\nfrom .{MODULE_NAME} import (\n {imported},\n)\n" + f"\n__all__ += [\n{exported}\n]\n" + ) diff --git a/fragment/codegen/typed_entries.py b/fragment/codegen/typed_entries.py new file mode 100644 index 0000000..411e1ba --- /dev/null +++ b/fragment/codegen/typed_entries.py @@ -0,0 +1,452 @@ +"""Derive strongly-typed batch payloads from single-entry ``addLedgerEntry`` operations. + +``addLedgerEntries`` takes ``[AddLedgerEntryInput!]!``, and every entry's +``parameters`` field is an opaque ``JSON`` scalar. GraphQL therefore cannot type +the parameters of an individual entry in a batch: one list means one input type. + +The per-entry-type ``addLedgerEntry`` operations already generated for a Schema +carry exactly the missing information, though -- the entry type as a string +literal, and each parameter bound to a typed operation variable: + + mutation PostAuthCapture($ik: SafeString!, ..., $capture_amount: String!) { + addLedgerEntry( + ik: $ik + entry: {ledger: {ik: $ledgerIk}, type: "auth_capture", + parameters: {capture_amount: $capture_amount}} + ) { ... } + } + +This module recovers that information into an :class:`EntrySpec` and renders one +pydantic model per entry type, so callers can build batch payloads with real +field names and types instead of untyped dicts. +""" + +import ast +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Dict, FrozenSet, List, Optional + +from ariadne_codegen.utils import process_name, str_to_pascal_case, str_to_snake_case +from graphql import ( + FieldNode, + ObjectValueNode, + OperationDefinitionNode, + OperationType, + StringValueNode, + VariableNode, +) + +ADD_LEDGER_ENTRY_FIELD = "addLedgerEntry" +MODULE_NAME = "typed_entries" + +# Used for the model *name* when an operation pins no typeVersion. Naming only: +# the wire payload still omits typeVersion in that case. +DEFAULT_TYPE_VERSION = 1 + + +@dataclass +class EntryParameter: + """A single templated parameter of a typed Ledger Entry.""" + + # The parameter name as the Schema knows it, e.g. "capture_amount". This is + # the JSON key sent in `parameters`, so it is preserved verbatim. + name: str + # The Python field name. Usually identical to `name`, but snake_cased for + # Schemas that declare camelCase parameters. + field_name: str + # Rendered Python annotation, harvested from the client method ariadne + # generated for this same operation. + annotation: str + required: bool + + +@dataclass +class EntrySpec: + """Everything needed to render one typed batch-payload model.""" + + entry_type: str + class_name: str + operation_name: str + # Kept in the order the parameters appear in the source operation's + # `parameters: {...}` literal. That order is the cross-SDK canonical key + # order for the `parameters` payload, so it must not be re-sorted: pydantic + # emits fields in declaration order, and the Go/Node/Ruby SDKs key off the + # same source order. Reordering here would silently diverge the wire bytes. + parameters: List[EntryParameter] = field(default_factory=list) + # Present only when the source operation pins a type version. + type_version: Optional[int] = None + + +def _safe_field_name(name: str) -> str: + """Snake_case a Schema parameter into a field name safe to declare. + + `process_name` is ariadne's own helper, so it escapes Python keywords, + shadowed builtins, and pydantic's reserved field names (derived from + `dir(BaseModel)`, so it tracks pydantic rather than drifting) exactly the way + the rest of the generated SDK does. Only `TypedLedgerEntry`'s own attributes + are ours to handle, and those are read off the base class below. + + PARAMETER_FIELDS keeps the original Schema name, so escaping a field never + changes the wire format. + """ + processed = process_name( + name, + convert_to_snake_case=True, + handle_pydantic_resrved_field_names=True, + ) + if processed in base_class_attribute_names(): + processed += "_" + return processed + + +def _unwrap_type(type_node) -> tuple: + """Return ``(named_type_name, required)`` for a variable's type node.""" + required = type_node.kind == "non_null_type" + node = type_node + while hasattr(node, "type"): + node = node.type + return node.name.value, required + + +def _get_object_field(node: ObjectValueNode, name: str): + for f in node.fields: + if f.name.value == name: + return f.value + return None + + +def _get_entry_argument( + operation_definition: OperationDefinitionNode, +) -> Optional[ObjectValueNode]: + """Return the inline `entry:` object of a single-field `addLedgerEntry` post. + + ``None`` for anything else: a query, a multi-field selection, a root fragment + spread, or an `entry` passed as a variable rather than written inline. + """ + if operation_definition.operation != OperationType.MUTATION: + return None + + selections = operation_definition.selection_set.selections + if len(selections) != 1: + return None + root = selections[0] + if not isinstance(root, FieldNode): + return None + if root.name.value != ADD_LEDGER_ENTRY_FIELD: + return None + + for arg in root.arguments: + if arg.name.value == "entry" and isinstance(arg.value, ObjectValueNode): + return arg.value + return None + + +def _extract_parameters( + parameters_node, + operation_definition: OperationDefinitionNode, + annotations: Dict[str, str], +) -> List[EntryParameter]: + """Recover the typed parameters bound to the entry's `parameters` object.""" + if not isinstance(parameters_node, ObjectValueNode): + return [] + + variable_types = { + vd.variable.name.value: _unwrap_type(vd.type) + for vd in operation_definition.variable_definitions + } + + parameters: List[EntryParameter] = [] + for param in parameters_node.fields: + # Only variable-bound parameters are typeable. A parameter hardcoded in + # the operation is already fixed and must not become a field. + if not isinstance(param.value, VariableNode): + continue + variable_name = param.value.name.value + _, required = variable_types.get(variable_name, (None, False)) + # Keyed off the variable, not the parameter: a Schema is free to bind + # `{captureAmount: $capture_amount}`, and the variable carries the type. + # Falls back to the loosest annotation rather than dropping a parameter. + annotation = annotations.get(str_to_snake_case(variable_name)) or "Any" + parameters.append( + EntryParameter( + name=param.name.value, + field_name=_safe_field_name(param.name.value), + annotation=annotation, + required=required, + ) + ) + return parameters + + +def extract_entry_spec( + operation_definition: OperationDefinitionNode, + annotations: Dict[str, str], +) -> Optional[EntrySpec]: + """Recover an :class:`EntrySpec` from a typed ``addLedgerEntry`` operation. + + Returns ``None`` for any operation that is not a single-entry post with a + literal entry type -- including the SDK's own ``addLedgerEntry`` and + ``addLedgerEntryRuntime``, whose type comes from a variable and whose + parameters are an opaque ``JSON`` blob. Those cannot be typed, and are + silently skipped rather than treated as an error. + + ``annotations`` maps snake_cased variable names to the Python annotations + ariadne generated for this operation's client method. + """ + if not operation_definition.name: + return None + entry_arg = _get_entry_argument(operation_definition) + if entry_arg is None: + return None + + # A literal `type` is what makes an operation entry-type-specific. Without + # it there is nothing to key a typed model on. + type_node = _get_object_field(entry_arg, "type") + if not isinstance(type_node, StringValueNode): + return None + entry_type = type_node.value + + parameters = _extract_parameters( + _get_object_field(entry_arg, "parameters"), + operation_definition, + annotations, + ) + + type_version = None + version_node = _get_object_field(entry_arg, "typeVersion") + if version_node is not None and version_node.kind == "int_value": + type_version = int(version_node.value) + + return EntrySpec( + entry_type=entry_type, + # Named for the entry type rather than the operation, so the model is + # recognisable from the Schema regardless of how the operation that + # produced it happened to be named. + class_name=str_to_pascal_case(str_to_snake_case(entry_type)), + operation_name=operation_definition.name.value, + parameters=parameters, + type_version=type_version, + ) + + +def collect_annotations( + method_def, + operation_definition: OperationDefinitionNode, +) -> Dict[str, str]: + """Map snake_cased variable names to the annotations ariadne generated. + + Reusing ariadne's own annotations keeps a typed model's fields identical to + the equivalent client method's arguments, including custom scalar handling. + ariadne reorders arguments to put required ones first, so this matches by + name rather than by position. + """ + generated: Dict[str, str] = {} + for arg in method_def.args.args: + if arg.arg == "self" or arg.annotation is None: + continue + generated[arg.arg] = ast.unparse(arg.annotation) + + annotations: Dict[str, str] = {} + for vd in operation_definition.variable_definitions: + name = vd.variable.name.value + snake = str_to_snake_case(name) + for candidate in (snake, f"{snake}_"): + if candidate in generated: + annotations[snake] = generated[candidate] + break + return annotations + + +BASE_CLASS_SOURCE = '''class TypedLedgerEntry(BaseModel): + """Base class for a strongly-typed `addLedgerEntries` payload. + + Subclasses declare one field per Schema parameter. The serializer below + reshapes those flat fields into the nested `AddLedgerEntryInput` the API + expects, so instances can be passed to `add_ledger_entries` directly. + """ + + ENTRY_TYPE: ClassVar[str] = "" + TYPE_VERSION: ClassVar[Optional[int]] = None + # Maps Schema parameter name -> Python field name. These differ only when a + # parameter is camelCased or collides with a field on this class. + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} + + ik: Any + ledger_ik: Any + posted: Optional[Any] = None + tags: Optional[List[LedgerEntryTagInput]] = None + groups: Optional[List[LedgerEntryGroupInput]] = None + conditions: Optional[List[LedgerEntryConditionInput]] = None + + def entry_parameters(self) -> Dict[str, Any]: + """The `parameters` payload, keyed by Schema parameter name.""" + return { + parameter_name: getattr(self, field_name) + for parameter_name, field_name in self.PARAMETER_FIELDS.items() + if getattr(self, field_name) is not None + } + + def to_input(self) -> AddLedgerEntryInput: + """Convert to the `AddLedgerEntryInput` that `addLedgerEntries` takes.""" + return AddLedgerEntryInput( + ik=self.ik, + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik=self.ledger_ik), + type=self.ENTRY_TYPE, + typeVersion=self.TYPE_VERSION, + parameters=self.entry_parameters(), + posted=self.posted, + tags=self.tags, + groups=self.groups, + conditions=self.conditions, + ), + ) + + @model_serializer + def serialize(self) -> Dict[str, Any]: + """Serialize as `AddLedgerEntryInput`, not as this flat model. + + The base client dumps variables with `model_dump(by_alias=True)`, so + this is what puts the correct shape on the wire. + """ + return self.to_input().model_dump(by_alias=True, exclude_none=True) + + +def to_entry_inputs( + entries: Sequence[TypedLedgerEntry], +) -> List[AddLedgerEntryInput]: + """Convert typed entries to raw inputs, preserving order. + + `add_ledger_entries` accepts typed entries directly. This is for callers who + want to inspect or adjust the raw payload first. + """ + return [entry.to_input() for entry in entries]''' + + +@lru_cache(maxsize=1) +def base_class_attribute_names() -> FrozenSet[str]: + """The attribute names TypedLedgerEntry itself declares. + + Read out of the rendered source rather than hand-listed, so adding a field or + method to the base class cannot silently start shadowing a Schema parameter. + """ + module = ast.parse(BASE_CLASS_SOURCE) + class_def = next( + node + for node in module.body + if isinstance(node, ast.ClassDef) and node.name == "TypedLedgerEntry" + ) + names = set() + for node in class_def.body: + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + names.add(node.target.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + names.add(node.name) + return frozenset(names) + + +MODULE_HEADER = """from typing import Any, ClassVar, Dict, List, Optional, Sequence + +from pydantic import model_serializer + +from .base_model import BaseModel +from .input_types import ( + AddLedgerEntryInput, + LedgerEntryConditionInput, + LedgerEntryGroupInput, + LedgerEntryInput, + LedgerEntryTagInput, + LedgerMatchInput, +)""" + + +EMPTY_MODULE_NOTE = """ +# No typed Ledger Entry operations were found in the input queries, so no +# per-entry-type payload models were generated. Add the per-entry-type +# `addLedgerEntry` operations for your Schema to the codegen input directory to +# generate them.""" + + +def assign_class_names(specs: List[EntrySpec]) -> List[EntrySpec]: + """Give every distinct entry type and version its own uniquely named model. + + A model's identity is the (entry type, type version) pair, not the entry type + alone: the same type at two versions is two different parameter sets, and + collapsing them would silently drop one and post the wrong version. + + Every name carries a version, defaulting to V1 when the operation pins none. + A name therefore depends only on that payload's own identity, never on which + other operations are in the input: suffixing only on collision would mean + adding a second version later renames the first and breaks every existing + call site (spec §2.6). + + The default is naming-only. `TYPE_VERSION` stays `None` when unpinned, so + `typeVersion` is still omitted from the wire -- "unspecified" and "explicitly + 1" are not assumed to be equivalent to the API. + + Remaining collisions (distinct entry types that pascal-case alike, e.g. + `auth_hold` and `authHold`) fall back to the source operation name, then to a + counter, so a model is never dropped. + """ + unique: Dict[tuple, EntrySpec] = {} + for spec in specs: + # The same (type, version) from two operations really is one model. + unique.setdefault((spec.entry_type, spec.type_version), spec) + + named: List[EntrySpec] = [] + seen: set = set() + for spec in unique.values(): + version = ( + DEFAULT_TYPE_VERSION if spec.type_version is None else spec.type_version + ) + base = f"{spec.class_name}V{version}" + name = base + if name in seen: + name = f"{base}{str_to_pascal_case(spec.operation_name)}" + counter = 2 + while name in seen: + name = f"{base}{counter}" + counter += 1 + spec.class_name = name + seen.add(name) + named.append(spec) + return named + + +def _render_class(spec: EntrySpec) -> List[str]: + lines = [ + f"class {spec.class_name}(TypedLedgerEntry):", + f' """Typed `addLedgerEntries` payload for the ' + f'"{spec.entry_type}" Ledger Entry.', + "", + f" Derived from the `{spec.operation_name}` operation.", + ' """', + "", + f' ENTRY_TYPE: ClassVar[str] = "{spec.entry_type}"', + ] + if spec.type_version is not None: + lines.append(f" TYPE_VERSION: ClassVar[Optional[int]] = {spec.type_version}") + + parameters = spec.parameters + if parameters: + lines.append(" PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {") + for parameter in parameters: + lines.append(f' "{parameter.name}": "{parameter.field_name}",') + lines.append(" }") + lines.append("") + for parameter in parameters: + suffix = "" if parameter.required else " = None" + lines.append(f" {parameter.field_name}: {parameter.annotation}{suffix}") + else: + lines.append(" PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {}") + return lines + + +def render_module(specs: List[EntrySpec]) -> str: + """Render the `typed_entries` module source for the given entry specs.""" + blocks = [MODULE_HEADER, BASE_CLASS_SOURCE] + for spec in sorted(assign_class_names(specs), key=lambda s: s.class_name): + blocks.append("\n".join(_render_class(spec))) + if not specs: + blocks.append(EMPTY_MODULE_NOTE.strip("\n")) + return "\n\n\n".join(blocks) + "\n" diff --git a/fragment/sdk/__init__.py b/fragment/sdk/__init__.py index cb235d1..ad37c25 100644 --- a/fragment/sdk/__init__.py +++ b/fragment/sdk/__init__.py @@ -1,5 +1,16 @@ # Generated by fragment (with the help of ariadne-codegen) +from .add_ledger_entries import ( + AddLedgerEntries, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount, + AddLedgerEntriesAddLedgerEntriesBadRequestError, + AddLedgerEntriesAddLedgerEntriesInternalError, +) from .add_ledger_entry import ( AddLedgerEntry, AddLedgerEntryAddLedgerEntryAddLedgerEntryResult, @@ -388,6 +399,15 @@ ) __all__ = [ + "AddLedgerEntries", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", "AddLedgerEntry", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResult", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResultEntry", @@ -718,3 +738,10 @@ "UpdateLedgerUpdateLedgerUpdateLedgerResultLedger", "Upload", ] + +from .typed_entries import TypedLedgerEntry, to_entry_inputs + +__all__ += [ + "TypedLedgerEntry", + "to_entry_inputs", +] diff --git a/fragment/sdk/add_ledger_entries.py b/fragment/sdk/add_ledger_entries.py new file mode 100644 index 0000000..b13e53b --- /dev/null +++ b/fragment/sdk/add_ledger_entries.py @@ -0,0 +1,72 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: queries/ + +from typing import Any, Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel + + +class AddLedgerEntries(BaseModel): + add_ledger_entries: Union[ + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", + ] = Field(alias="addLedgerEntries", discriminator="typename__") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError(BaseModel): + typename__: Literal["AddLedgerEntriesError"] = Field(alias="__typename") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult(BaseModel): + typename__: Literal["AddLedgerEntriesResult"] = Field(alias="__typename") + results: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults(BaseModel): + is_ik_replay: bool = Field(alias="isIkReplay") + entry: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry" + lines: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry(BaseModel): + type_: Optional[Any] = Field(alias="type") + id: str + ik: str + posted: Any + created: Any + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines(BaseModel): + id: str + amount: Any + account: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount" + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount( + BaseModel +): + path: str + + +class AddLedgerEntriesAddLedgerEntriesBadRequestError(BaseModel): + typename__: Literal["BadRequestError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +class AddLedgerEntriesAddLedgerEntriesInternalError(BaseModel): + typename__: Literal["InternalError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +AddLedgerEntries.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines.model_rebuild() diff --git a/fragment/sdk/client.py b/fragment/sdk/client.py index 5896823..813e061 100644 --- a/fragment/sdk/client.py +++ b/fragment/sdk/client.py @@ -1,8 +1,9 @@ # Generated by fragment (with the help of ariadne-codegen) # Source: queries/ -from typing import Any, Optional +from typing import Any, Optional, Sequence, Union +from .add_ledger_entries import AddLedgerEntries from .add_ledger_entry import AddLedgerEntry from .add_ledger_entry_runtime import AddLedgerEntryRuntime from .async_client import AsyncFragmentClient @@ -28,6 +29,7 @@ from .get_schema import GetSchema from .get_workspace import GetWorkspace from .input_types import ( + AddLedgerEntryInput, CreateLedgerInput, CurrencyMatchInput, CustomAccountInput, @@ -61,6 +63,7 @@ from .store_schema import StoreSchema from .sync_custom_accounts import SyncCustomAccounts from .sync_custom_txs import SyncCustomTxs +from .typed_entries import TypedLedgerEntry from .update_ledger import UpdateLedger from .update_ledger_entry import UpdateLedgerEntry @@ -273,6 +276,57 @@ async def add_ledger_entry( data = self.get_data(response) return AddLedgerEntry.model_validate(data) + async def add_ledger_entries( + self, + entries: Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]], + **kwargs: Any + ) -> AddLedgerEntries: + query = gql(""" + mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) { + addLedgerEntries(entries: $entries) { + __typename + ... on AddLedgerEntriesResult { + results { + isIkReplay + entry { + type + id + ik + posted + created + } + lines { + id + amount + account { + path + } + } + } + } + ... on BadRequestError { + code + message + retryable + } + ... on InternalError { + code + message + retryable + } + } + } + """) + variables: dict[str, object] = {"entries": entries} + response = await self.execute( + query=query, + operation_name="addLedgerEntries", + variables=variables, + **kwargs + ) + data = self.get_data(response) + return AddLedgerEntries.model_validate(data) + async def reverse_ledger_entry(self, id: str, **kwargs: Any) -> ReverseLedgerEntry: query = gql(""" mutation reverseLedgerEntry($id: ID!) { diff --git a/fragment/sdk/typed_entries.py b/fragment/sdk/typed_entries.py new file mode 100644 index 0000000..6d1b515 --- /dev/null +++ b/fragment/sdk/typed_entries.py @@ -0,0 +1,88 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: queries/ + +from typing import Any, ClassVar, Dict, List, Optional, Sequence + +from pydantic import model_serializer + +from .base_model import BaseModel +from .input_types import ( + AddLedgerEntryInput, + LedgerEntryConditionInput, + LedgerEntryGroupInput, + LedgerEntryInput, + LedgerEntryTagInput, + LedgerMatchInput, +) + + +class TypedLedgerEntry(BaseModel): + """Base class for a strongly-typed `addLedgerEntries` payload. + + Subclasses declare one field per Schema parameter. The serializer below + reshapes those flat fields into the nested `AddLedgerEntryInput` the API + expects, so instances can be passed to `add_ledger_entries` directly. + """ + + ENTRY_TYPE: ClassVar[str] = "" + TYPE_VERSION: ClassVar[Optional[int]] = None + # Maps Schema parameter name -> Python field name. These differ only when a + # parameter is camelCased or collides with a field on this class. + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} + + ik: Any + ledger_ik: Any + posted: Optional[Any] = None + tags: Optional[List[LedgerEntryTagInput]] = None + groups: Optional[List[LedgerEntryGroupInput]] = None + conditions: Optional[List[LedgerEntryConditionInput]] = None + + def entry_parameters(self) -> Dict[str, Any]: + """The `parameters` payload, keyed by Schema parameter name.""" + return { + parameter_name: getattr(self, field_name) + for parameter_name, field_name in self.PARAMETER_FIELDS.items() + if getattr(self, field_name) is not None + } + + def to_input(self) -> AddLedgerEntryInput: + """Convert to the `AddLedgerEntryInput` that `addLedgerEntries` takes.""" + return AddLedgerEntryInput( + ik=self.ik, + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik=self.ledger_ik), + type=self.ENTRY_TYPE, + typeVersion=self.TYPE_VERSION, + parameters=self.entry_parameters(), + posted=self.posted, + tags=self.tags, + groups=self.groups, + conditions=self.conditions, + ), + ) + + @model_serializer + def serialize(self) -> Dict[str, Any]: + """Serialize as `AddLedgerEntryInput`, not as this flat model. + + The base client dumps variables with `model_dump(by_alias=True)`, so + this is what puts the correct shape on the wire. + """ + return self.to_input().model_dump(by_alias=True, exclude_none=True) + + +def to_entry_inputs( + entries: Sequence[TypedLedgerEntry], +) -> List[AddLedgerEntryInput]: + """Convert typed entries to raw inputs, preserving order. + + `add_ledger_entries` accepts typed entries directly. This is for callers who + want to inspect or adjust the raw payload first. + """ + return [entry.to_input() for entry in entries] + + +# No typed Ledger Entry operations were found in the input queries, so no +# per-entry-type payload models were generated. Add the per-entry-type +# `addLedgerEntry` operations for your Schema to the codegen input directory to +# generate them. diff --git a/fragment/std_queries/queries.graphql b/fragment/std_queries/queries.graphql index 12118e7..d34407c 100644 --- a/fragment/std_queries/queries.graphql +++ b/fragment/std_queries/queries.graphql @@ -147,6 +147,41 @@ mutation addLedgerEntry( } } +mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) { + addLedgerEntries(entries: $entries) { + __typename + ... on AddLedgerEntriesResult { + results { + isIkReplay + entry { + type + id + ik + posted + created + } + lines { + id + amount + account { + path + } + } + } + } + ... on BadRequestError { + code + message + retryable + } + ... on InternalError { + code + message + retryable + } + } +} + mutation reverseLedgerEntry($id: ID!) { reverseLedgerEntry(id: $id) { __typename diff --git a/fragment/sync_sdk/__init__.py b/fragment/sync_sdk/__init__.py index 6ffdf15..0070e60 100644 --- a/fragment/sync_sdk/__init__.py +++ b/fragment/sync_sdk/__init__.py @@ -1,5 +1,16 @@ # Generated by fragment (with the help of ariadne-codegen) +from .add_ledger_entries import ( + AddLedgerEntries, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount, + AddLedgerEntriesAddLedgerEntriesBadRequestError, + AddLedgerEntriesAddLedgerEntriesInternalError, +) from .add_ledger_entry import ( AddLedgerEntry, AddLedgerEntryAddLedgerEntryAddLedgerEntryResult, @@ -388,6 +399,15 @@ ) __all__ = [ + "AddLedgerEntries", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", "AddLedgerEntry", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResult", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResultEntry", @@ -718,3 +738,10 @@ "UpdateLedgerUpdateLedgerUpdateLedgerResultLedger", "Upload", ] + +from .typed_entries import TypedLedgerEntry, to_entry_inputs + +__all__ += [ + "TypedLedgerEntry", + "to_entry_inputs", +] diff --git a/fragment/sync_sdk/add_ledger_entries.py b/fragment/sync_sdk/add_ledger_entries.py new file mode 100644 index 0000000..b13e53b --- /dev/null +++ b/fragment/sync_sdk/add_ledger_entries.py @@ -0,0 +1,72 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: queries/ + +from typing import Any, Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel + + +class AddLedgerEntries(BaseModel): + add_ledger_entries: Union[ + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", + ] = Field(alias="addLedgerEntries", discriminator="typename__") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError(BaseModel): + typename__: Literal["AddLedgerEntriesError"] = Field(alias="__typename") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult(BaseModel): + typename__: Literal["AddLedgerEntriesResult"] = Field(alias="__typename") + results: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults(BaseModel): + is_ik_replay: bool = Field(alias="isIkReplay") + entry: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry" + lines: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry(BaseModel): + type_: Optional[Any] = Field(alias="type") + id: str + ik: str + posted: Any + created: Any + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines(BaseModel): + id: str + amount: Any + account: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount" + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount( + BaseModel +): + path: str + + +class AddLedgerEntriesAddLedgerEntriesBadRequestError(BaseModel): + typename__: Literal["BadRequestError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +class AddLedgerEntriesAddLedgerEntriesInternalError(BaseModel): + typename__: Literal["InternalError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +AddLedgerEntries.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines.model_rebuild() diff --git a/fragment/sync_sdk/client.py b/fragment/sync_sdk/client.py index 545b20c..d61f9c5 100644 --- a/fragment/sync_sdk/client.py +++ b/fragment/sync_sdk/client.py @@ -1,8 +1,9 @@ # Generated by fragment (with the help of ariadne-codegen) # Source: queries/ -from typing import Any, Optional +from typing import Any, Optional, Sequence, Union +from .add_ledger_entries import AddLedgerEntries from .add_ledger_entry import AddLedgerEntry from .add_ledger_entry_runtime import AddLedgerEntryRuntime from .create_custom_currency import CreateCustomCurrency @@ -27,6 +28,7 @@ from .get_schema import GetSchema from .get_workspace import GetWorkspace from .input_types import ( + AddLedgerEntryInput, CreateLedgerInput, CurrencyMatchInput, CustomAccountInput, @@ -61,6 +63,7 @@ from .sync_client import SyncFragmentClient from .sync_custom_accounts import SyncCustomAccounts from .sync_custom_txs import SyncCustomTxs +from .typed_entries import TypedLedgerEntry from .update_ledger import UpdateLedger from .update_ledger_entry import UpdateLedgerEntry @@ -269,6 +272,57 @@ def add_ledger_entry( data = self.get_data(response) return AddLedgerEntry.model_validate(data) + def add_ledger_entries( + self, + entries: Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]], + **kwargs: Any + ) -> AddLedgerEntries: + query = gql(""" + mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) { + addLedgerEntries(entries: $entries) { + __typename + ... on AddLedgerEntriesResult { + results { + isIkReplay + entry { + type + id + ik + posted + created + } + lines { + id + amount + account { + path + } + } + } + } + ... on BadRequestError { + code + message + retryable + } + ... on InternalError { + code + message + retryable + } + } + } + """) + variables: dict[str, object] = {"entries": entries} + response = self.execute( + query=query, + operation_name="addLedgerEntries", + variables=variables, + **kwargs + ) + data = self.get_data(response) + return AddLedgerEntries.model_validate(data) + def reverse_ledger_entry(self, id: str, **kwargs: Any) -> ReverseLedgerEntry: query = gql(""" mutation reverseLedgerEntry($id: ID!) { diff --git a/fragment/sync_sdk/typed_entries.py b/fragment/sync_sdk/typed_entries.py new file mode 100644 index 0000000..6d1b515 --- /dev/null +++ b/fragment/sync_sdk/typed_entries.py @@ -0,0 +1,88 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: queries/ + +from typing import Any, ClassVar, Dict, List, Optional, Sequence + +from pydantic import model_serializer + +from .base_model import BaseModel +from .input_types import ( + AddLedgerEntryInput, + LedgerEntryConditionInput, + LedgerEntryGroupInput, + LedgerEntryInput, + LedgerEntryTagInput, + LedgerMatchInput, +) + + +class TypedLedgerEntry(BaseModel): + """Base class for a strongly-typed `addLedgerEntries` payload. + + Subclasses declare one field per Schema parameter. The serializer below + reshapes those flat fields into the nested `AddLedgerEntryInput` the API + expects, so instances can be passed to `add_ledger_entries` directly. + """ + + ENTRY_TYPE: ClassVar[str] = "" + TYPE_VERSION: ClassVar[Optional[int]] = None + # Maps Schema parameter name -> Python field name. These differ only when a + # parameter is camelCased or collides with a field on this class. + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} + + ik: Any + ledger_ik: Any + posted: Optional[Any] = None + tags: Optional[List[LedgerEntryTagInput]] = None + groups: Optional[List[LedgerEntryGroupInput]] = None + conditions: Optional[List[LedgerEntryConditionInput]] = None + + def entry_parameters(self) -> Dict[str, Any]: + """The `parameters` payload, keyed by Schema parameter name.""" + return { + parameter_name: getattr(self, field_name) + for parameter_name, field_name in self.PARAMETER_FIELDS.items() + if getattr(self, field_name) is not None + } + + def to_input(self) -> AddLedgerEntryInput: + """Convert to the `AddLedgerEntryInput` that `addLedgerEntries` takes.""" + return AddLedgerEntryInput( + ik=self.ik, + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik=self.ledger_ik), + type=self.ENTRY_TYPE, + typeVersion=self.TYPE_VERSION, + parameters=self.entry_parameters(), + posted=self.posted, + tags=self.tags, + groups=self.groups, + conditions=self.conditions, + ), + ) + + @model_serializer + def serialize(self) -> Dict[str, Any]: + """Serialize as `AddLedgerEntryInput`, not as this flat model. + + The base client dumps variables with `model_dump(by_alias=True)`, so + this is what puts the correct shape on the wire. + """ + return self.to_input().model_dump(by_alias=True, exclude_none=True) + + +def to_entry_inputs( + entries: Sequence[TypedLedgerEntry], +) -> List[AddLedgerEntryInput]: + """Convert typed entries to raw inputs, preserving order. + + `add_ledger_entries` accepts typed entries directly. This is for callers who + want to inspect or adjust the raw payload first. + """ + return [entry.to_input() for entry in entries] + + +# No typed Ledger Entry operations were found in the input queries, so no +# per-entry-type payload models were generated. Add the per-entry-type +# `addLedgerEntry` operations for your Schema to the codegen input directory to +# generate them. diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml index 55fcf7f..08b54ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,15 @@ fragment-python-client-codegen = 'fragment.codegen.main:run' [tool.pytest.ini_options] testpaths = ["tests"] +# Makes the snapshotted client importable as `sdk`, so tests exercise the +# generated code rather than a hand-written approximation of it. Regenerate with +# `make snapshots`. +pythonpath = ["tests/snapshots/001-marketing-schema"] + +[tool.mypy] +# Same path pytest uses, so `mypy tests/` can resolve the snapshotted `sdk` +# package and actually typecheck calls against the generated typed payloads. +mypy_path = "tests/snapshots/001-marketing-schema" [tool.pylint.messages_control] max-line-length = 88 diff --git a/tests/snapshots/001-marketing-schema/sdk/__init__.py b/tests/snapshots/001-marketing-schema/sdk/__init__.py index 43ca152..c48bfef 100644 --- a/tests/snapshots/001-marketing-schema/sdk/__init__.py +++ b/tests/snapshots/001-marketing-schema/sdk/__init__.py @@ -1,5 +1,16 @@ # Generated by fragment (with the help of ariadne-codegen) +from .add_ledger_entries import ( + AddLedgerEntries, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines, + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount, + AddLedgerEntriesAddLedgerEntriesBadRequestError, + AddLedgerEntriesAddLedgerEntriesInternalError, +) from .add_ledger_entry import ( AddLedgerEntry, AddLedgerEntryAddLedgerEntryAddLedgerEntryResult, @@ -505,6 +516,15 @@ ) __all__ = [ + "AddLedgerEntries", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", "AddLedgerEntry", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResult", "AddLedgerEntryAddLedgerEntryAddLedgerEntryResultEntry", @@ -934,3 +954,31 @@ "UpdateLedgerUpdateLedgerUpdateLedgerResultLedger", "Upload", ] + +from .typed_entries import ( + CardSettleV1, + DisputePayoutInitiateV1, + DisputePayoutSettleV1, + DriverPayoutInitiateV1, + DriverPayoutSettleV1, + OrderPlacedV1, + OrderPlacedV2, + RestaurantPayoutInitiateV1, + RestaurantPayoutSettleV1, + TypedLedgerEntry, + to_entry_inputs, +) + +__all__ += [ + "CardSettleV1", + "DisputePayoutInitiateV1", + "DisputePayoutSettleV1", + "DriverPayoutInitiateV1", + "DriverPayoutSettleV1", + "OrderPlacedV1", + "OrderPlacedV2", + "RestaurantPayoutInitiateV1", + "RestaurantPayoutSettleV1", + "TypedLedgerEntry", + "to_entry_inputs", +] diff --git a/tests/snapshots/001-marketing-schema/sdk/add_ledger_entries.py b/tests/snapshots/001-marketing-schema/sdk/add_ledger_entries.py new file mode 100644 index 0000000..2c341de --- /dev/null +++ b/tests/snapshots/001-marketing-schema/sdk/add_ledger_entries.py @@ -0,0 +1,72 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: tests/snapshots/001-marketing-schema/ + +from typing import Any, Literal, Optional, Union + +from pydantic import Field + +from .base_model import BaseModel + + +class AddLedgerEntries(BaseModel): + add_ledger_entries: Union[ + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError", + "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult", + "AddLedgerEntriesAddLedgerEntriesBadRequestError", + "AddLedgerEntriesAddLedgerEntriesInternalError", + ] = Field(alias="addLedgerEntries", discriminator="typename__") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError(BaseModel): + typename__: Literal["AddLedgerEntriesError"] = Field(alias="__typename") + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult(BaseModel): + typename__: Literal["AddLedgerEntriesResult"] = Field(alias="__typename") + results: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults(BaseModel): + is_ik_replay: bool = Field(alias="isIkReplay") + entry: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry" + lines: list["AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines"] + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsEntry(BaseModel): + type_: Optional[Any] = Field(alias="type") + id: str + ik: str + posted: Any + created: Any + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines(BaseModel): + id: str + amount: Any + account: "AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount" + + +class AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLinesAccount( + BaseModel +): + path: str + + +class AddLedgerEntriesAddLedgerEntriesBadRequestError(BaseModel): + typename__: Literal["BadRequestError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +class AddLedgerEntriesAddLedgerEntriesInternalError(BaseModel): + typename__: Literal["InternalError"] = Field(alias="__typename") + code: str + message: str + retryable: bool + + +AddLedgerEntries.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResults.model_rebuild() +AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResultResultsLines.model_rebuild() diff --git a/tests/snapshots/001-marketing-schema/sdk/client.py b/tests/snapshots/001-marketing-schema/sdk/client.py index 199a693..4f2a530 100644 --- a/tests/snapshots/001-marketing-schema/sdk/client.py +++ b/tests/snapshots/001-marketing-schema/sdk/client.py @@ -1,8 +1,9 @@ # Generated by fragment (with the help of ariadne-codegen) # Source: tests/snapshots/001-marketing-schema/ -from typing import Any, Optional +from typing import Any, Optional, Sequence, Union +from .add_ledger_entries import AddLedgerEntries from .add_ledger_entry import AddLedgerEntry from .add_ledger_entry_runtime import AddLedgerEntryRuntime from .async_client import AsyncFragmentClient @@ -28,6 +29,7 @@ from .get_schema import GetSchema from .get_workspace import GetWorkspace from .input_types import ( + AddLedgerEntryInput, CreateLedgerInput, CurrencyMatchInput, CustomAccountInput, @@ -70,6 +72,7 @@ from .store_schema import StoreSchema from .sync_custom_accounts import SyncCustomAccounts from .sync_custom_txs import SyncCustomTxs +from .typed_entries import TypedLedgerEntry from .update_ledger import UpdateLedger from .update_ledger_entry import UpdateLedgerEntry @@ -1042,6 +1045,57 @@ async def add_ledger_entry( data = self.get_data(response) return AddLedgerEntry.model_validate(data) + async def add_ledger_entries( + self, + entries: Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]], + **kwargs: Any + ) -> AddLedgerEntries: + query = gql(""" + mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) { + addLedgerEntries(entries: $entries) { + __typename + ... on AddLedgerEntriesResult { + results { + isIkReplay + entry { + type + id + ik + posted + created + } + lines { + id + amount + account { + path + } + } + } + } + ... on BadRequestError { + code + message + retryable + } + ... on InternalError { + code + message + retryable + } + } + } + """) + variables: dict[str, object] = {"entries": entries} + response = await self.execute( + query=query, + operation_name="addLedgerEntries", + variables=variables, + **kwargs + ) + data = self.get_data(response) + return AddLedgerEntries.model_validate(data) + async def reverse_ledger_entry(self, id: str, **kwargs: Any) -> ReverseLedgerEntry: query = gql(""" mutation reverseLedgerEntry($id: ID!) { diff --git a/tests/snapshots/001-marketing-schema/sdk/typed_entries.py b/tests/snapshots/001-marketing-schema/sdk/typed_entries.py new file mode 100644 index 0000000..699f531 --- /dev/null +++ b/tests/snapshots/001-marketing-schema/sdk/typed_entries.py @@ -0,0 +1,299 @@ +# Generated by fragment (with the help of ariadne-codegen) +# Source: tests/snapshots/001-marketing-schema/ + +from typing import Any, ClassVar, Dict, List, Optional, Sequence + +from pydantic import model_serializer + +from .base_model import BaseModel +from .input_types import ( + AddLedgerEntryInput, + LedgerEntryConditionInput, + LedgerEntryGroupInput, + LedgerEntryInput, + LedgerEntryTagInput, + LedgerMatchInput, +) + + +class TypedLedgerEntry(BaseModel): + """Base class for a strongly-typed `addLedgerEntries` payload. + + Subclasses declare one field per Schema parameter. The serializer below + reshapes those flat fields into the nested `AddLedgerEntryInput` the API + expects, so instances can be passed to `add_ledger_entries` directly. + """ + + ENTRY_TYPE: ClassVar[str] = "" + TYPE_VERSION: ClassVar[Optional[int]] = None + # Maps Schema parameter name -> Python field name. These differ only when a + # parameter is camelCased or collides with a field on this class. + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} + + ik: Any + ledger_ik: Any + posted: Optional[Any] = None + tags: Optional[List[LedgerEntryTagInput]] = None + groups: Optional[List[LedgerEntryGroupInput]] = None + conditions: Optional[List[LedgerEntryConditionInput]] = None + + def entry_parameters(self) -> Dict[str, Any]: + """The `parameters` payload, keyed by Schema parameter name.""" + return { + parameter_name: getattr(self, field_name) + for parameter_name, field_name in self.PARAMETER_FIELDS.items() + if getattr(self, field_name) is not None + } + + def to_input(self) -> AddLedgerEntryInput: + """Convert to the `AddLedgerEntryInput` that `addLedgerEntries` takes.""" + return AddLedgerEntryInput( + ik=self.ik, + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik=self.ledger_ik), + type=self.ENTRY_TYPE, + typeVersion=self.TYPE_VERSION, + parameters=self.entry_parameters(), + posted=self.posted, + tags=self.tags, + groups=self.groups, + conditions=self.conditions, + ), + ) + + @model_serializer + def serialize(self) -> Dict[str, Any]: + """Serialize as `AddLedgerEntryInput`, not as this flat model. + + The base client dumps variables with `model_dump(by_alias=True)`, so + this is what puts the correct shape on the wire. + """ + return self.to_input().model_dump(by_alias=True, exclude_none=True) + + +def to_entry_inputs( + entries: Sequence[TypedLedgerEntry], +) -> List[AddLedgerEntryInput]: + """Convert typed entries to raw inputs, preserving order. + + `add_ledger_entries` accepts typed entries directly. This is for callers who + want to inspect or adjust the raw payload first. + """ + return [entry.to_input() for entry in entries] + + +class CardSettleV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "card_settle" Ledger Entry. + + Derived from the `PostCardSettle` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "card_settle" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "user_id": "user_id", + "order_id": "order_id", + "currency": "currency", + "amount": "amount", + } + + user_id: str + order_id: str + currency: str + amount: str + + +class DisputePayoutInitiateV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "dispute_payout_initiate" Ledger Entry. + + Derived from the `PostDisputePayoutInitiate` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "dispute_payout_initiate" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "user_id": "user_id", + "disputes_id": "disputes_id", + "amount": "amount", + "currency": "currency", + "payout_id": "payout_id", + "order_id": "order_id", + } + + user_id: str + disputes_id: str + amount: str + currency: str + payout_id: str + order_id: str + + +class DisputePayoutSettleV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "dispute_payout_settle" Ledger Entry. + + Derived from the `PostDisputePayoutSettle` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "dispute_payout_settle" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "user_id": "user_id", + "disputes_id": "disputes_id", + "amount": "amount", + "currency": "currency", + "order_id": "order_id", + } + + user_id: str + disputes_id: str + amount: str + currency: str + order_id: str + + +class DriverPayoutInitiateV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "driver_payout_initiate" Ledger Entry. + + Derived from the `PostDriverPayoutInitiate` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "driver_payout_initiate" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "driver_id": "driver_id", + "order_id": "order_id", + "currency": "currency", + "amount": "amount", + "payout_id": "payout_id", + } + + driver_id: str + order_id: str + currency: str + amount: str + payout_id: str + + +class DriverPayoutSettleV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "driver_payout_settle" Ledger Entry. + + Derived from the `PostDriverPayoutSettle` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "driver_payout_settle" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "driver_id": "driver_id", + "payout_id": "payout_id", + "currency": "currency", + "amount": "amount", + } + + driver_id: str + payout_id: str + currency: str + amount: str + + +class OrderPlacedV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "order_placed" Ledger Entry. + + Derived from the `PostOrderPlaced` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "order_placed" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "user_id": "user_id", + "order_id": "order_id", + "order_cost": "order_cost", + "currency": "currency", + "platform_fee": "platform_fee", + "driver_fee": "driver_fee", + "restaurant_id": "restaurant_id", + "driver_id": "driver_id", + } + + user_id: str + order_id: str + order_cost: str + currency: str + platform_fee: str + driver_fee: str + restaurant_id: str + driver_id: str + + +class OrderPlacedV2(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "order_placed" Ledger Entry. + + Derived from the `PostOrderPlaced_v2` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "order_placed" + TYPE_VERSION: ClassVar[Optional[int]] = 2 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "user_id": "user_id", + "order_id": "order_id", + "order_cost": "order_cost", + "currency": "currency", + "platform_fee": "platform_fee", + "service_fee": "service_fee", + "driver_fee": "driver_fee", + "restaurant_id": "restaurant_id", + "driver_id": "driver_id", + } + + user_id: str + order_id: str + order_cost: str + currency: str + platform_fee: str + service_fee: str + driver_fee: str + restaurant_id: str + driver_id: str + + +class RestaurantPayoutInitiateV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "restaurant_payout_initiate" Ledger Entry. + + Derived from the `PostRestaurantPayoutInitiate` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "restaurant_payout_initiate" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "restaurant_id": "restaurant_id", + "order_id": "order_id", + "currency": "currency", + "amount": "amount", + "payout_id": "payout_id", + } + + restaurant_id: str + order_id: str + currency: str + amount: str + payout_id: str + + +class RestaurantPayoutSettleV1(TypedLedgerEntry): + """Typed `addLedgerEntries` payload for the "restaurant_payout_settle" Ledger Entry. + + Derived from the `PostRestaurantPayoutSettle` operation. + """ + + ENTRY_TYPE: ClassVar[str] = "restaurant_payout_settle" + TYPE_VERSION: ClassVar[Optional[int]] = 1 + PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { + "restaurant_id": "restaurant_id", + "payout_id": "payout_id", + "currency": "currency", + "amount": "amount", + } + + restaurant_id: str + payout_id: str + currency: str + amount: str diff --git a/tests/test_add_ledger_entries.py b/tests/test_add_ledger_entries.py new file mode 100644 index 0000000..5d85b45 --- /dev/null +++ b/tests/test_add_ledger_entries.py @@ -0,0 +1,168 @@ +"""Integration tests for the `addLedgerEntries` batch mutation. + +Stores the vendored template Schema, creates a Ledger against it, and posts a +batch of mixed entry types using the typed payloads generated in +`tests/snapshots/001-marketing-schema`. Exercising the snapshotted client means +these tests cover the code a customer actually gets, rather than a hand-written +approximation of it. + +Requires live credentials; see tests/conftest.py. +""" + +import json +from pathlib import Path +from typing import AsyncIterator, Dict +from uuid import uuid4 + +import pytest +import pytest_asyncio + +# `sdk` is the snapshotted client, on sys.path via the `pythonpath` setting in +# pyproject.toml. Regenerate it with `make snapshots`. +from sdk.add_ledger_entries import ( + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult, + AddLedgerEntriesAddLedgerEntriesBadRequestError, +) +from sdk.client import Client +from sdk.input_types import ( + AddLedgerEntryInput, + CreateLedgerInput, + LedgerEntryInput, + LedgerMatchInput, + SchemaInput, +) +from sdk.typed_entries import CardSettleV1, OrderPlacedV1 + +TEMPLATE_SCHEMA = Path(__file__).parent / "template-schema" / "schema.json" +CURRENCY = "USD" +UNKNOWN_ENTRY_TYPE = "not-in-this-schema" + +# `addLedgerEntries` is gated behind this header. Passed per call rather than +# baked into the client, so the SDK stays free of experiment-specific behaviour. +EXPERIMENTAL_HEADERS = {"X-Fragment-Experimental": "true"} + + +@pytest_asyncio.fixture +async def snapshot_client(credentials: Dict[str, str]) -> AsyncIterator[Client]: + """A client built from the snapshotted SDK, not from `fragment.sdk`. + + Credentials are passed explicitly rather than as `**credentials` so the call + typechecks -- `Client` also takes an `http_client`, which a `Dict[str, str]` + cannot satisfy. + """ + async with Client( + client_id=credentials["client_id"], + client_secret=credentials["client_secret"], + auth_scope=credentials["auth_scope"], + auth_url=credentials["auth_url"], + api_url=credentials["api_url"], + ) as client: + yield client + + +def load_template_schema(key: str) -> SchemaInput: + """The vendored template Schema, re-keyed so each run gets its own.""" + raw = json.loads(TEMPLATE_SCHEMA.read_text()) + raw["key"] = key + raw["name"] = key + return SchemaInput.model_validate(raw) + + +async def setup_ledger(client: Client) -> str: + """Store the template Schema, create a Ledger on it, return the Ledger IK.""" + schema_key = str(uuid4()) + stored = await client.store_schema(schema=load_template_schema(schema_key)) + assert stored.store_schema.typename__ == "StoreSchemaResult" + + ledger_ik = str(uuid4()) + created = await client.create_ledger( + ik=ledger_ik, + ledger=CreateLedgerInput(name="Batch Ledger Entries Test Ledger"), + schema_key=schema_key, + ) + assert created.create_ledger.typename__ == "CreateLedgerResult" + return ledger_ik + + +@pytest.mark.asyncio +async def test_add_ledger_entries(snapshot_client: Client) -> None: + """A batch of two different typed entry types commits, in input order.""" + ledger_ik = await setup_ledger(snapshot_client) + user_id, order_id = str(uuid4()), str(uuid4()) + order_ik, settle_ik = str(uuid4()), str(uuid4()) + + response = await snapshot_client.add_ledger_entries( + entries=[ + OrderPlacedV1( + ik=order_ik, + ledger_ik=ledger_ik, + user_id=user_id, + order_id=order_id, + order_cost="1000", + currency=CURRENCY, + platform_fee="100", + driver_fee="200", + restaurant_id=str(uuid4()), + driver_id=str(uuid4()), + ), + # The user owes 1300 across cost and fees; settle it in the same batch. + CardSettleV1( + ik=settle_ik, + ledger_ik=ledger_ik, + user_id=user_id, + order_id=order_id, + currency=CURRENCY, + amount="1300", + ), + ], + headers=EXPERIMENTAL_HEADERS, + ) + + result = response.add_ledger_entries + assert isinstance(result, AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult) + order, settle = result.results + assert (order.entry.ik, order.entry.type_) == (order_ik, "order_placed") + assert (settle.entry.ik, settle.entry.type_) == (settle_ik, "card_settle") + + cash_lines = [line for line in settle.lines if line.account.path == "assets/cash"] + assert [line.amount for line in cash_lines] == ["1300"] + + +@pytest.mark.asyncio +async def test_add_ledger_entries_rejects_unknown_entry_type( + snapshot_client: Client, +) -> None: + """An entry type absent from the Schema rejects the batch. + + The bad entry is a raw `AddLedgerEntryInput` because a typed payload cannot + express an entry type the Schema does not define -- which incidentally covers + mixing raw and typed entries in one call. + """ + ledger_ik = await setup_ledger(snapshot_client) + + response = await snapshot_client.add_ledger_entries( + entries=[ + CardSettleV1( + ik=str(uuid4()), + ledger_ik=ledger_ik, + user_id=str(uuid4()), + order_id=str(uuid4()), + currency=CURRENCY, + amount="100", + ), + AddLedgerEntryInput( + ik=str(uuid4()), + entry=LedgerEntryInput( + ledger=LedgerMatchInput(ik=ledger_ik), + type=UNKNOWN_ENTRY_TYPE, + parameters=dict(amount="100"), + ), + ), + ], + headers=EXPERIMENTAL_HEADERS, + ) + + error = response.add_ledger_entries + assert isinstance(error, AddLedgerEntriesAddLedgerEntriesBadRequestError) + assert error.code + assert error.message From b97d7e69d3d4e4841932d7f75f672bda17425849 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Tue, 4 Aug 2026 20:08:30 -0400 Subject: [PATCH 02/15] use the enum --- tests/test_add_ledger_entries.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_add_ledger_entries.py b/tests/test_add_ledger_entries.py index 5d85b45..8c0c1a4 100644 --- a/tests/test_add_ledger_entries.py +++ b/tests/test_add_ledger_entries.py @@ -24,6 +24,7 @@ AddLedgerEntriesAddLedgerEntriesBadRequestError, ) from sdk.client import Client +from sdk.enums import CurrencyCode from sdk.input_types import ( AddLedgerEntryInput, CreateLedgerInput, @@ -34,9 +35,14 @@ from sdk.typed_entries import CardSettleV1, OrderPlacedV1 TEMPLATE_SCHEMA = Path(__file__).parent / "template-schema" / "schema.json" -CURRENCY = "USD" UNKNOWN_ENTRY_TYPE = "not-in-this-schema" +# The Schema declares `currency` as a templated `String`, so the generated +# payloads annotate it `str` rather than `CurrencyCode`. Passing the generated +# enum anyway keeps the code typo-proof; because it subclasses `str`, pydantic +# coerces it to the plain `"USD"` and the wire payload is unchanged. +CURRENCY = CurrencyCode.USD + # `addLedgerEntries` is gated behind this header. Passed per call rather than # baked into the client, so the SDK stays free of experiment-specific behaviour. EXPERIMENTAL_HEADERS = {"X-Fragment-Experimental": "true"} From 15a2a08fe927e7ced3300277f44d7ba292ecdacd Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Tue, 4 Aug 2026 20:10:32 -0400 Subject: [PATCH 03/15] Use the same order in a batch --- tests/test_add_ledger_entries.py | 82 ++++++++++++++++---------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/tests/test_add_ledger_entries.py b/tests/test_add_ledger_entries.py index 8c0c1a4..9f88d51 100644 --- a/tests/test_add_ledger_entries.py +++ b/tests/test_add_ledger_entries.py @@ -1,7 +1,7 @@ """Integration tests for the `addLedgerEntries` batch mutation. Stores the vendored template Schema, creates a Ledger against it, and posts a -batch of mixed entry types using the typed payloads generated in +batch using the typed payloads generated in `tests/snapshots/001-marketing-schema`. Exercising the snapshotted client means these tests cover the code a customer actually gets, rather than a hand-written approximation of it. @@ -32,7 +32,7 @@ LedgerMatchInput, SchemaInput, ) -from sdk.typed_entries import CardSettleV1, OrderPlacedV1 +from sdk.typed_entries import OrderPlacedV1 TEMPLATE_SCHEMA = Path(__file__).parent / "template-schema" / "schema.json" UNKNOWN_ENTRY_TYPE = "not-in-this-schema" @@ -90,48 +90,55 @@ async def setup_ledger(client: Client) -> str: return ledger_ik +def order_placed( + ik: str, ledger_ik: str, user_id: str, order_cost: str, platform_fee: str +) -> OrderPlacedV1: + """An `order_placed` payload; the ids that do not matter here are random.""" + return OrderPlacedV1( + ik=ik, + ledger_ik=ledger_ik, + user_id=user_id, + order_id=str(uuid4()), + order_cost=order_cost, + currency=CURRENCY, + platform_fee=platform_fee, + driver_fee="200", + restaurant_id=str(uuid4()), + driver_id=str(uuid4()), + ) + + @pytest.mark.asyncio async def test_add_ledger_entries(snapshot_client: Client) -> None: - """A batch of two different typed entry types commits, in input order.""" + """A batch of two `order_placed` entries commits, in input order.""" ledger_ik = await setup_ledger(snapshot_client) - user_id, order_id = str(uuid4()), str(uuid4()) - order_ik, settle_ik = str(uuid4()), str(uuid4()) + user_id = str(uuid4()) + first_ik, second_ik = str(uuid4()), str(uuid4()) response = await snapshot_client.add_ledger_entries( entries=[ - OrderPlacedV1( - ik=order_ik, - ledger_ik=ledger_ik, - user_id=user_id, - order_id=order_id, - order_cost="1000", - currency=CURRENCY, - platform_fee="100", - driver_fee="200", - restaurant_id=str(uuid4()), - driver_id=str(uuid4()), - ), - # The user owes 1300 across cost and fees; settle it in the same batch. - CardSettleV1( - ik=settle_ik, - ledger_ik=ledger_ik, - user_id=user_id, - order_id=order_id, - currency=CURRENCY, - amount="1300", - ), + order_placed(first_ik, ledger_ik, user_id, "1000", "100"), + order_placed(second_ik, ledger_ik, user_id, "500", "50"), ], headers=EXPERIMENTAL_HEADERS, ) result = response.add_ledger_entries assert isinstance(result, AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult) - order, settle = result.results - assert (order.entry.ik, order.entry.type_) == (order_ik, "order_placed") - assert (settle.entry.ik, settle.entry.type_) == (settle_ik, "card_settle") + first, second = result.results + assert [first.entry.ik, second.entry.ik] == [first_ik, second_ik] + assert {r.entry.type_ for r in result.results} == {"order_placed"} + + # Each entry books its own platform fee, so the amounts track the inputs. + def platform_fee(committed) -> list: + return [ + line.amount + for line in committed.lines + if line.account.path == "income/platform_fee" + ] - cash_lines = [line for line in settle.lines if line.account.path == "assets/cash"] - assert [line.amount for line in cash_lines] == ["1300"] + assert platform_fee(first) == ["100"] + assert platform_fee(second) == ["50"] @pytest.mark.asyncio @@ -140,22 +147,13 @@ async def test_add_ledger_entries_rejects_unknown_entry_type( ) -> None: """An entry type absent from the Schema rejects the batch. - The bad entry is a raw `AddLedgerEntryInput` because a typed payload cannot - express an entry type the Schema does not define -- which incidentally covers - mixing raw and typed entries in one call. + Uses a raw `AddLedgerEntryInput` because a typed payload cannot express an + entry type the Schema does not define. """ ledger_ik = await setup_ledger(snapshot_client) response = await snapshot_client.add_ledger_entries( entries=[ - CardSettleV1( - ik=str(uuid4()), - ledger_ik=ledger_ik, - user_id=str(uuid4()), - order_id=str(uuid4()), - currency=CURRENCY, - amount="100", - ), AddLedgerEntryInput( ik=str(uuid4()), entry=LedgerEntryInput( From f281dc9e986ee22e527ed0c376aeac673353bfd5 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Tue, 4 Aug 2026 20:12:58 -0400 Subject: [PATCH 04/15] assert that it's an AddLedgerEntriesError --- tests/test_add_ledger_entries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_add_ledger_entries.py b/tests/test_add_ledger_entries.py index 9f88d51..354b75d 100644 --- a/tests/test_add_ledger_entries.py +++ b/tests/test_add_ledger_entries.py @@ -20,8 +20,8 @@ # `sdk` is the snapshotted client, on sys.path via the `pythonpath` setting in # pyproject.toml. Regenerate it with `make snapshots`. from sdk.add_ledger_entries import ( + AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError, AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesResult, - AddLedgerEntriesAddLedgerEntriesBadRequestError, ) from sdk.client import Client from sdk.enums import CurrencyCode @@ -167,6 +167,6 @@ async def test_add_ledger_entries_rejects_unknown_entry_type( ) error = response.add_ledger_entries - assert isinstance(error, AddLedgerEntriesAddLedgerEntriesBadRequestError) + assert isinstance(error, AddLedgerEntriesAddLedgerEntriesAddLedgerEntriesError) assert error.code assert error.message From bef1a9b52a70a7f85209a2660f5ad02debecd94a Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Wed, 5 Aug 2026 11:27:56 -0400 Subject: [PATCH 05/15] Address review comments --- README.md | 101 ---------- .../codegen/plugins/generate_typed_entries.py | 60 ++++-- fragment/codegen/typed_entries.py | 172 ++++++++++-------- fragment/sdk/typed_entries.py | 9 +- fragment/sync_sdk/typed_entries.py | 9 +- poetry.toml | 2 - .../001-marketing-schema/sdk/typed_entries.py | 27 ++- tests/test_typed_entries.py | 139 ++++++++++++++ 8 files changed, 314 insertions(+), 205 deletions(-) delete mode 100644 poetry.toml create mode 100644 tests/test_typed_entries.py diff --git a/README.md b/README.md index da40942..130c621 100644 --- a/README.md +++ b/README.md @@ -83,107 +83,6 @@ await graphql_client.add_ledger_entry( ) ``` -### Post a batch of Ledger Entries - -`add_ledger_entries` commits every entry in one atomic, strongly-consistent -transaction — either all of them are committed, or none are. It takes a list of -`AddLedgerEntryInput`: - -```python -from fragment.sdk.input_types import ( - AddLedgerEntryInput, - LedgerEntryInput, - LedgerMatchInput, -) - -await graphql_client.add_ledger_entries( - entries=[ - AddLedgerEntryInput( - ik="some-ik", - entry=LedgerEntryInput( - ledger=LedgerMatchInput(ik="your-ledger-ik"), - type="user_funds_account", - parameters=dict(user_id="user-1", funding_amount="20000"), - ), - ), - ], -) -``` - -Because `parameters` is an untyped JSON field, nothing checks those parameter -names or values. See below for typed payloads that do. - -### Strongly-typed batch payloads - -A batch mutation takes one list of one input type, so GraphQL cannot type each -entry's `parameters` individually. The SDK closes that gap at codegen time: if -your codegen input directory contains the per-entry-type `addLedgerEntry` -operations for your Schema, a `typed_entries` module is generated alongside the -client with one model per entry type. - -Given an operation like this in `queries/`: - -```graphql -mutation PostAuthCapture( - $ik: SafeString! - $ledgerIk: SafeString! - $user_id: String! - $capture_amount: String! -) { - addLedgerEntry( - ik: $ik - entry: { - ledger: { ik: $ledgerIk } - type: "auth_capture" - parameters: { user_id: $user_id, capture_amount: $capture_amount } - } - ) { - __typename - } -} -``` - -you get an `AuthCaptureV1` model named for the entry type and version, and can build batch -payloads with real field names and types: - -```python -from .libs.fragment.custom_queries_package.typed_entries import ( - AuthCaptureV1, - PlatformFundsAccountV1, -) - -await graphql_client.add_ledger_entries( - entries=[ - AuthCaptureV1( - ik="ik-1", - ledger_ik="your-ledger-ik", - user_id="user-1", - capture_amount="100", - ), - PlatformFundsAccountV1( - ik="ik-2", - ledger_ik="your-ledger-ik", - funding_amount="20000", - ), - ], -) -``` - -Typed entries can be mixed with raw `AddLedgerEntryInput` values in the same -call. Every model also accepts the optional `posted`, `tags`, `groups`, and -`conditions` fields, and exposes `to_input()` if you want the raw -`AddLedgerEntryInput` — for example to inspect or adjust a payload before -sending it. `to_entry_inputs()` converts a whole list at once. - -Parameter names are preserved on the wire even when the Python field has to be -escaped: a parameter named `type`, `class`, or `json` becomes `type_`, `class_`, -or `json_` in Python but is still sent under its original Schema name. - -Model names always carry the entry type's version, defaulting to `V1` when the -operation pins no `typeVersion`. This means adding a new version to your Schema -never renames an existing model, so it cannot break call sites. The default is -naming only — an unpinned `typeVersion` is still omitted from the request. - ### Read a Ledger Account's Balance To read a Ledger Account's [balance](https://fragment.dev/docs#read-balances-latest): diff --git a/fragment/codegen/plugins/generate_typed_entries.py b/fragment/codegen/plugins/generate_typed_entries.py index b0bdcb0..c3a2a5f 100644 --- a/fragment/codegen/plugins/generate_typed_entries.py +++ b/fragment/codegen/plugins/generate_typed_entries.py @@ -1,6 +1,5 @@ import ast from pathlib import Path -from typing import Dict, List, Union from ariadne_codegen.plugins.base import Plugin from graphql import OperationDefinitionNode @@ -11,7 +10,9 @@ collect_annotations, extract_entry_spec, render_module, + resolve_class_names, ) +from fragment.logger import console_log ADD_LEDGER_ENTRIES_OPERATION = "addLedgerEntries" ENTRIES_ARGUMENT = "entries" @@ -37,15 +38,18 @@ def __init__(self, schema, config_dict: dict) -> None: self.package_path = Path( settings.get("target_package_path", Path.cwd()) ) / settings.get("target_package_name", "graphql_client") - self.specs: List[EntrySpec] = [] + self.specs: list[EntrySpec] = [] + # Both names below are owned upstream in fragment-dev/graphql-queries, + # so track whether each was actually seen rather than assuming. + self.saw_batch_operation = False self.widened_entries_argument = False def generate_client_method( self, - method_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], + method_def: ast.FunctionDef | ast.AsyncFunctionDef, operation_definition: OperationDefinitionNode, - ) -> Union[ast.FunctionDef, ast.AsyncFunctionDef]: - annotations: Dict[str, str] = collect_annotations( + ) -> ast.FunctionDef | ast.AsyncFunctionDef: + annotations: dict[str, str] = collect_annotations( method_def, operation_definition ) spec = extract_entry_spec(operation_definition, annotations) @@ -55,11 +59,12 @@ def generate_client_method( operation_definition.name and operation_definition.name.value == ADD_LEDGER_ENTRIES_OPERATION ): + self.saw_batch_operation = True self._widen_entries_argument(method_def) return method_def def _widen_entries_argument( - self, method_def: Union[ast.FunctionDef, ast.AsyncFunctionDef] + self, method_def: ast.FunctionDef | ast.AsyncFunctionDef ) -> None: """Let `add_ledger_entries` take typed entries as well as raw inputs. @@ -72,10 +77,22 @@ def _widen_entries_argument( for arg in method_def.args.args: if arg.arg != ENTRIES_ARGUMENT: continue - arg.annotation = ast.Name( - id="Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]" - ) + # Parsed rather than hand-built: an ast.Name whose id is an entire + # expression unparses fine but is not a valid tree, so anything that + # visits or compiles it breaks. + arg.annotation = ast.parse( + "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]", + mode="eval", + ).body self.widened_entries_argument = True + return + + console_log.warning( + "Could not find an %r argument on the generated add_ledger_entries " + "method, so its signature was left as-is. Typed entry payloads will " + "still serialise correctly but will not typecheck when passed to it.", + ENTRIES_ARGUMENT, + ) def generate_client_code(self, generated_code: str) -> str: if not self.widened_entries_argument: @@ -89,7 +106,7 @@ def generate_client_code(self, generated_code: str) -> str: ) @staticmethod - def _insert_imports(code: str, imports: List[str]) -> str: + def _insert_imports(code: str, imports: list[str]) -> str: """Insert imports after the module's existing top-level import block.""" lines = code.splitlines() last_import_line = 0 @@ -100,8 +117,21 @@ def _insert_imports(code: str, imports: List[str]) -> str: return "\n".join(lines) + "\n" def generate_init_code(self, generated_code: str) -> str: + if self.specs and not self.saw_batch_operation: + # Typed payloads exist but no batch operation was generated to take + # them. Silence here would leave users with models nothing accepts. + console_log.warning( + "Generated %d typed entry payload(s) but found no %r operation, " + "so no batch method accepts them. Has the operation been renamed " + "upstream?", + len(self.specs), + ADD_LEDGER_ENTRIES_OPERATION, + ) module_path = self.package_path / f"{MODULE_NAME}.py" - module_path.write_text(self._add_comment(render_module(self.specs))) + module_path.parent.mkdir(parents=True, exist_ok=True) + module_path.write_text( + self._add_comment(render_module(self.specs)), encoding="utf-8" + ) return generated_code + self._init_additions() def _add_comment(self, code: str) -> str: @@ -118,9 +148,13 @@ def _add_comment(self, code: str) -> str: return f"{comment}\n\n{code}" def _init_additions(self) -> str: - """Re-export the typed models and extend `__all__`.""" + """Re-export the typed models and extend `__all__`. + + Resolves names itself; `resolve_class_names` being pure is what makes this + agree with the renderer without depending on hook order. + """ names = ["TypedLedgerEntry", "to_entry_inputs"] + [ - spec.class_name for spec in self.specs + class_name for class_name, _ in resolve_class_names(self.specs) ] names.sort() imported = ",\n ".join(names) diff --git a/fragment/codegen/typed_entries.py b/fragment/codegen/typed_entries.py index 411e1ba..4017922 100644 --- a/fragment/codegen/typed_entries.py +++ b/fragment/codegen/typed_entries.py @@ -23,24 +23,30 @@ import ast from dataclasses import dataclass, field -from functools import lru_cache -from typing import Dict, FrozenSet, List, Optional +from functools import cache from ariadne_codegen.utils import process_name, str_to_pascal_case, str_to_snake_case from graphql import ( FieldNode, + IntValueNode, + NonNullTypeNode, ObjectValueNode, OperationDefinitionNode, OperationType, StringValueNode, + TypeNode, + ValueNode, VariableNode, ) +from fragment.logger import console_log + ADD_LEDGER_ENTRY_FIELD = "addLedgerEntry" MODULE_NAME = "typed_entries" -# Used for the model *name* when an operation pins no typeVersion. Naming only: -# the wire payload still omits typeVersion in that case. +# An entry with no `typeVersion` resolves to version 1 server-side, so an +# unpinned operation is normalised to 1 at extraction. That keeps one rule for +# both the model name and the wire payload instead of letting them disagree. DEFAULT_TYPE_VERSION = 1 @@ -65,16 +71,28 @@ class EntrySpec: """Everything needed to render one typed batch-payload model.""" entry_type: str - class_name: str + # Always the unversioned pascal name: `OrderPlaced`, never `OrderPlacedV1`. + base_name: str operation_name: str # Kept in the order the parameters appear in the source operation's # `parameters: {...}` literal. That order is the cross-SDK canonical key # order for the `parameters` payload, so it must not be re-sorted: pydantic # emits fields in declaration order, and the Go/Node/Ruby SDKs key off the # same source order. Reordering here would silently diverge the wire bytes. - parameters: List[EntryParameter] = field(default_factory=list) - # Present only when the source operation pins a type version. - type_version: Optional[int] = None + parameters: list[EntryParameter] = field(default_factory=list) + # Always concrete: an unpinned operation is normalised to + # DEFAULT_TYPE_VERSION, because that is what the API resolves it to. + type_version: int = DEFAULT_TYPE_VERSION + + @property + def identity(self) -> tuple[str, int]: + """What a model is keyed on: (entry type, version). Spec 2.2.""" + return (self.entry_type, self.type_version) + + @property + def versioned_name(self) -> str: + """`base_name` plus the version it resolves to. Spec 2.5.""" + return f"{self.base_name}V{self.type_version}" def _safe_field_name(name: str) -> str: @@ -99,25 +117,21 @@ def _safe_field_name(name: str) -> str: return processed -def _unwrap_type(type_node) -> tuple: - """Return ``(named_type_name, required)`` for a variable's type node.""" - required = type_node.kind == "non_null_type" - node = type_node - while hasattr(node, "type"): - node = node.type - return node.name.value, required +def _is_required(type_node: TypeNode) -> bool: + """Whether a variable is non-null, i.e. the caller must supply it.""" + return isinstance(type_node, NonNullTypeNode) -def _get_object_field(node: ObjectValueNode, name: str): - for f in node.fields: - if f.name.value == name: - return f.value +def _get_object_field(node: ObjectValueNode, name: str) -> ValueNode | None: + for field_node in node.fields: + if field_node.name.value == name: + return field_node.value return None def _get_entry_argument( operation_definition: OperationDefinitionNode, -) -> Optional[ObjectValueNode]: +) -> ObjectValueNode | None: """Return the inline `entry:` object of a single-field `addLedgerEntry` post. ``None`` for anything else: a query, a multi-field selection, a root fragment @@ -142,31 +156,42 @@ def _get_entry_argument( def _extract_parameters( - parameters_node, + parameters_node: ValueNode | None, operation_definition: OperationDefinitionNode, - annotations: Dict[str, str], -) -> List[EntryParameter]: + annotations: dict[str, str], +) -> list[EntryParameter]: """Recover the typed parameters bound to the entry's `parameters` object.""" if not isinstance(parameters_node, ObjectValueNode): return [] - variable_types = { - vd.variable.name.value: _unwrap_type(vd.type) + required_by_variable = { + vd.variable.name.value: _is_required(vd.type) for vd in operation_definition.variable_definitions } - parameters: List[EntryParameter] = [] + parameters: list[EntryParameter] = [] for param in parameters_node.fields: # Only variable-bound parameters are typeable. A parameter hardcoded in # the operation is already fixed and must not become a field. if not isinstance(param.value, VariableNode): continue variable_name = param.value.name.value - _, required = variable_types.get(variable_name, (None, False)) + required = required_by_variable.get(variable_name, False) # Keyed off the variable, not the parameter: a Schema is free to bind # `{captureAmount: $capture_amount}`, and the variable carries the type. - # Falls back to the loosest annotation rather than dropping a parameter. - annotation = annotations.get(str_to_snake_case(variable_name)) or "Any" + annotation = annotations.get(str_to_snake_case(variable_name)) + if annotation is None: + # Falling back keeps the parameter rather than dropping it, but the + # caller loses type checking on it, so say so rather than degrade + # quietly. + console_log.warning( + "Could not resolve a type for parameter %r (variable $%s) in " + "operation %s; generating it as Any.", + param.name.value, + variable_name, + operation_definition.name.value if operation_definition.name else "?", + ) + annotation = "Any" parameters.append( EntryParameter( name=param.name.value, @@ -180,8 +205,8 @@ def _extract_parameters( def extract_entry_spec( operation_definition: OperationDefinitionNode, - annotations: Dict[str, str], -) -> Optional[EntrySpec]: + annotations: dict[str, str], +) -> EntrySpec | None: """Recover an :class:`EntrySpec` from a typed ``addLedgerEntry`` operation. Returns ``None`` for any operation that is not a single-entry post with a @@ -212,9 +237,9 @@ def extract_entry_spec( annotations, ) - type_version = None + type_version = DEFAULT_TYPE_VERSION version_node = _get_object_field(entry_arg, "typeVersion") - if version_node is not None and version_node.kind == "int_value": + if isinstance(version_node, IntValueNode): type_version = int(version_node.value) return EntrySpec( @@ -222,7 +247,7 @@ def extract_entry_spec( # Named for the entry type rather than the operation, so the model is # recognisable from the Schema regardless of how the operation that # produced it happened to be named. - class_name=str_to_pascal_case(str_to_snake_case(entry_type)), + base_name=str_to_pascal_case(str_to_snake_case(entry_type)), operation_name=operation_definition.name.value, parameters=parameters, type_version=type_version, @@ -230,9 +255,9 @@ def extract_entry_spec( def collect_annotations( - method_def, + method_def: ast.FunctionDef | ast.AsyncFunctionDef, operation_definition: OperationDefinitionNode, -) -> Dict[str, str]: +) -> dict[str, str]: """Map snake_cased variable names to the annotations ariadne generated. Reusing ariadne's own annotations keeps a typed model's fields identical to @@ -240,13 +265,13 @@ def collect_annotations( ariadne reorders arguments to put required ones first, so this matches by name rather than by position. """ - generated: Dict[str, str] = {} + generated: dict[str, str] = {} for arg in method_def.args.args: if arg.arg == "self" or arg.annotation is None: continue generated[arg.arg] = ast.unparse(arg.annotation) - annotations: Dict[str, str] = {} + annotations: dict[str, str] = {} for vd in operation_definition.variable_definitions: name = vd.variable.name.value snake = str_to_snake_case(name) @@ -263,16 +288,22 @@ def collect_annotations( Subclasses declare one field per Schema parameter. The serializer below reshapes those flat fields into the nested `AddLedgerEntryInput` the API expects, so instances can be passed to `add_ledger_entries` directly. + + That makes dumping one-way: `model_dump()` returns the `AddLedgerEntryInput` + shape rather than this model's own fields, so + `type(entry).model_validate(entry.model_dump())` does not round-trip. Dumps + are for sending; keep the instance itself if you need to log or cache one. """ ENTRY_TYPE: ClassVar[str] = "" - TYPE_VERSION: ClassVar[Optional[int]] = None + TYPE_VERSION: ClassVar[int] = 1 # Maps Schema parameter name -> Python field name. These differ only when a # parameter is camelCased or collides with a field on this class. PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} ik: Any ledger_ik: Any + description: Optional[str] = None posted: Optional[Any] = None tags: Optional[List[LedgerEntryTagInput]] = None groups: Optional[List[LedgerEntryGroupInput]] = None @@ -295,6 +326,7 @@ def to_input(self) -> AddLedgerEntryInput: type=self.ENTRY_TYPE, typeVersion=self.TYPE_VERSION, parameters=self.entry_parameters(), + description=self.description, posted=self.posted, tags=self.tags, groups=self.groups, @@ -323,8 +355,8 @@ def to_entry_inputs( return [entry.to_input() for entry in entries]''' -@lru_cache(maxsize=1) -def base_class_attribute_names() -> FrozenSet[str]: +@cache +def base_class_attribute_names() -> frozenset[str]: """The attribute names TypedLedgerEntry itself declares. Read out of the rendered source rather than hand-listed, so adding a field or @@ -367,39 +399,27 @@ def base_class_attribute_names() -> FrozenSet[str]: # generate them.""" -def assign_class_names(specs: List[EntrySpec]) -> List[EntrySpec]: - """Give every distinct entry type and version its own uniquely named model. - - A model's identity is the (entry type, type version) pair, not the entry type - alone: the same type at two versions is two different parameter sets, and - collapsing them would silently drop one and post the wrong version. +def resolve_class_names(specs: list[EntrySpec]) -> list[tuple[str, EntrySpec]]: + """Pair each distinct (entry type, version) with a unique model name. - Every name carries a version, defaulting to V1 when the operation pins none. - A name therefore depends only on that payload's own identity, never on which - other operations are in the input: suffixing only on collision would mean - adding a second version later renames the first and breaks every existing - call site (spec §2.6). + Deduplicates on identity, then names each `V` -- see spec 2.2 and + 2.5 for why identity is the pair and why the version is always present. + Distinct entry types that pascal-case alike (`auth_hold`, `authHold`) fall + back to the operation name, then a counter, so a model is never dropped. - The default is naming-only. `TYPE_VERSION` stays `None` when unpinned, so - `typeVersion` is still omitted from the wire -- "unspecified" and "explicitly - 1" are not assumed to be equivalent to the API. - - Remaining collisions (distinct entry types that pascal-case alike, e.g. - `auth_hold` and `authHold`) fall back to the source operation name, then to a - counter, so a model is never dropped. + Pure, so repeated calls agree. Both callers -- the renderer and the plugin's + `__init__` re-exports -- resolve names independently rather than one reading + what the other left behind. """ - unique: Dict[tuple, EntrySpec] = {} + unique: dict[tuple[str, int], EntrySpec] = {} for spec in specs: # The same (type, version) from two operations really is one model. - unique.setdefault((spec.entry_type, spec.type_version), spec) + unique.setdefault(spec.identity, spec) - named: List[EntrySpec] = [] - seen: set = set() + resolved: list[tuple[str, EntrySpec]] = [] + seen: set[str] = set() for spec in unique.values(): - version = ( - DEFAULT_TYPE_VERSION if spec.type_version is None else spec.type_version - ) - base = f"{spec.class_name}V{version}" + base = spec.versioned_name name = base if name in seen: name = f"{base}{str_to_pascal_case(spec.operation_name)}" @@ -407,15 +427,14 @@ def assign_class_names(specs: List[EntrySpec]) -> List[EntrySpec]: while name in seen: name = f"{base}{counter}" counter += 1 - spec.class_name = name seen.add(name) - named.append(spec) - return named + resolved.append((name, spec)) + return resolved -def _render_class(spec: EntrySpec) -> List[str]: +def _render_class(class_name: str, spec: EntrySpec) -> list[str]: lines = [ - f"class {spec.class_name}(TypedLedgerEntry):", + f"class {class_name}(TypedLedgerEntry):", f' """Typed `addLedgerEntries` payload for the ' f'"{spec.entry_type}" Ledger Entry.', "", @@ -424,8 +443,7 @@ def _render_class(spec: EntrySpec) -> List[str]: "", f' ENTRY_TYPE: ClassVar[str] = "{spec.entry_type}"', ] - if spec.type_version is not None: - lines.append(f" TYPE_VERSION: ClassVar[Optional[int]] = {spec.type_version}") + lines.append(f" TYPE_VERSION: ClassVar[int] = {spec.type_version}") parameters = spec.parameters if parameters: @@ -442,11 +460,11 @@ def _render_class(spec: EntrySpec) -> List[str]: return lines -def render_module(specs: List[EntrySpec]) -> str: +def render_module(specs: list[EntrySpec]) -> str: """Render the `typed_entries` module source for the given entry specs.""" blocks = [MODULE_HEADER, BASE_CLASS_SOURCE] - for spec in sorted(assign_class_names(specs), key=lambda s: s.class_name): - blocks.append("\n".join(_render_class(spec))) + for class_name, spec in sorted(resolve_class_names(specs)): + blocks.append("\n".join(_render_class(class_name, spec))) if not specs: blocks.append(EMPTY_MODULE_NOTE.strip("\n")) return "\n\n\n".join(blocks) + "\n" diff --git a/fragment/sdk/typed_entries.py b/fragment/sdk/typed_entries.py index 6d1b515..f0f2f2b 100644 --- a/fragment/sdk/typed_entries.py +++ b/fragment/sdk/typed_entries.py @@ -22,16 +22,22 @@ class TypedLedgerEntry(BaseModel): Subclasses declare one field per Schema parameter. The serializer below reshapes those flat fields into the nested `AddLedgerEntryInput` the API expects, so instances can be passed to `add_ledger_entries` directly. + + That makes dumping one-way: `model_dump()` returns the `AddLedgerEntryInput` + shape rather than this model's own fields, so + `type(entry).model_validate(entry.model_dump())` does not round-trip. Dumps + are for sending; keep the instance itself if you need to log or cache one. """ ENTRY_TYPE: ClassVar[str] = "" - TYPE_VERSION: ClassVar[Optional[int]] = None + TYPE_VERSION: ClassVar[int] = 1 # Maps Schema parameter name -> Python field name. These differ only when a # parameter is camelCased or collides with a field on this class. PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} ik: Any ledger_ik: Any + description: Optional[str] = None posted: Optional[Any] = None tags: Optional[List[LedgerEntryTagInput]] = None groups: Optional[List[LedgerEntryGroupInput]] = None @@ -54,6 +60,7 @@ def to_input(self) -> AddLedgerEntryInput: type=self.ENTRY_TYPE, typeVersion=self.TYPE_VERSION, parameters=self.entry_parameters(), + description=self.description, posted=self.posted, tags=self.tags, groups=self.groups, diff --git a/fragment/sync_sdk/typed_entries.py b/fragment/sync_sdk/typed_entries.py index 6d1b515..f0f2f2b 100644 --- a/fragment/sync_sdk/typed_entries.py +++ b/fragment/sync_sdk/typed_entries.py @@ -22,16 +22,22 @@ class TypedLedgerEntry(BaseModel): Subclasses declare one field per Schema parameter. The serializer below reshapes those flat fields into the nested `AddLedgerEntryInput` the API expects, so instances can be passed to `add_ledger_entries` directly. + + That makes dumping one-way: `model_dump()` returns the `AddLedgerEntryInput` + shape rather than this model's own fields, so + `type(entry).model_validate(entry.model_dump())` does not round-trip. Dumps + are for sending; keep the instance itself if you need to log or cache one. """ ENTRY_TYPE: ClassVar[str] = "" - TYPE_VERSION: ClassVar[Optional[int]] = None + TYPE_VERSION: ClassVar[int] = 1 # Maps Schema parameter name -> Python field name. These differ only when a # parameter is camelCased or collides with a field on this class. PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} ik: Any ledger_ik: Any + description: Optional[str] = None posted: Optional[Any] = None tags: Optional[List[LedgerEntryTagInput]] = None groups: Optional[List[LedgerEntryGroupInput]] = None @@ -54,6 +60,7 @@ def to_input(self) -> AddLedgerEntryInput: type=self.ENTRY_TYPE, typeVersion=self.TYPE_VERSION, parameters=self.entry_parameters(), + description=self.description, posted=self.posted, tags=self.tags, groups=self.groups, diff --git a/poetry.toml b/poetry.toml deleted file mode 100644 index ab1033b..0000000 --- a/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -in-project = true diff --git a/tests/snapshots/001-marketing-schema/sdk/typed_entries.py b/tests/snapshots/001-marketing-schema/sdk/typed_entries.py index 699f531..d16849a 100644 --- a/tests/snapshots/001-marketing-schema/sdk/typed_entries.py +++ b/tests/snapshots/001-marketing-schema/sdk/typed_entries.py @@ -22,16 +22,22 @@ class TypedLedgerEntry(BaseModel): Subclasses declare one field per Schema parameter. The serializer below reshapes those flat fields into the nested `AddLedgerEntryInput` the API expects, so instances can be passed to `add_ledger_entries` directly. + + That makes dumping one-way: `model_dump()` returns the `AddLedgerEntryInput` + shape rather than this model's own fields, so + `type(entry).model_validate(entry.model_dump())` does not round-trip. Dumps + are for sending; keep the instance itself if you need to log or cache one. """ ENTRY_TYPE: ClassVar[str] = "" - TYPE_VERSION: ClassVar[Optional[int]] = None + TYPE_VERSION: ClassVar[int] = 1 # Maps Schema parameter name -> Python field name. These differ only when a # parameter is camelCased or collides with a field on this class. PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {} ik: Any ledger_ik: Any + description: Optional[str] = None posted: Optional[Any] = None tags: Optional[List[LedgerEntryTagInput]] = None groups: Optional[List[LedgerEntryGroupInput]] = None @@ -54,6 +60,7 @@ def to_input(self) -> AddLedgerEntryInput: type=self.ENTRY_TYPE, typeVersion=self.TYPE_VERSION, parameters=self.entry_parameters(), + description=self.description, posted=self.posted, tags=self.tags, groups=self.groups, @@ -89,7 +96,7 @@ class CardSettleV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "card_settle" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "user_id": "user_id", "order_id": "order_id", @@ -110,7 +117,7 @@ class DisputePayoutInitiateV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "dispute_payout_initiate" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "user_id": "user_id", "disputes_id": "disputes_id", @@ -135,7 +142,7 @@ class DisputePayoutSettleV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "dispute_payout_settle" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "user_id": "user_id", "disputes_id": "disputes_id", @@ -158,7 +165,7 @@ class DriverPayoutInitiateV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "driver_payout_initiate" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "driver_id": "driver_id", "order_id": "order_id", @@ -181,7 +188,7 @@ class DriverPayoutSettleV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "driver_payout_settle" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "driver_id": "driver_id", "payout_id": "payout_id", @@ -202,7 +209,7 @@ class OrderPlacedV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "order_placed" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "user_id": "user_id", "order_id": "order_id", @@ -231,7 +238,7 @@ class OrderPlacedV2(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "order_placed" - TYPE_VERSION: ClassVar[Optional[int]] = 2 + TYPE_VERSION: ClassVar[int] = 2 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "user_id": "user_id", "order_id": "order_id", @@ -262,7 +269,7 @@ class RestaurantPayoutInitiateV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "restaurant_payout_initiate" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "restaurant_id": "restaurant_id", "order_id": "order_id", @@ -285,7 +292,7 @@ class RestaurantPayoutSettleV1(TypedLedgerEntry): """ ENTRY_TYPE: ClassVar[str] = "restaurant_payout_settle" - TYPE_VERSION: ClassVar[Optional[int]] = 1 + TYPE_VERSION: ClassVar[int] = 1 PARAMETER_FIELDS: ClassVar[Dict[str, str]] = { "restaurant_id": "restaurant_id", "payout_id": "payout_id", diff --git a/tests/test_typed_entries.py b/tests/test_typed_entries.py new file mode 100644 index 0000000..82da56b --- /dev/null +++ b/tests/test_typed_entries.py @@ -0,0 +1,139 @@ +"""Unit tests for typed batch-entry derivation. + +No credentials or network required, unlike the integration tests alongside them. + +These cover what the shared conformance fixtures structurally cannot: a fixture +generates once, so it can never catch generation that misbehaves the *second* +time it runs. `resolve_class_names` previously mutated its input, which made +`OrderPlaced` become `OrderPlacedV1` and then `OrderPlacedV1V1`. +""" + +from typing import List, Optional + +import pytest +from ariadne_codegen.utils import str_to_pascal_case, str_to_snake_case + +from fragment.codegen.typed_entries import ( + DEFAULT_TYPE_VERSION, + EntryParameter, + EntrySpec, + render_module, + resolve_class_names, +) + + +def spec( + entry_type: str, + type_version: int = DEFAULT_TYPE_VERSION, + operation_name: str = "PostSomething", + parameters: Optional[List[EntryParameter]] = None, +) -> EntrySpec: + # Derived exactly as `extract_entry_spec` does. Rolling this by hand instead + # let `authHold` become `Authhold`, which silently stopped the collision test + # from colliding. + base_name = str_to_pascal_case(str_to_snake_case(entry_type)) + return EntrySpec( + entry_type=entry_type, + base_name=base_name, + operation_name=operation_name, + parameters=parameters or [], + type_version=type_version, + ) + + +def names(specs: List[EntrySpec]) -> List[str]: + return [class_name for class_name, _ in resolve_class_names(specs)] + + +def test_version_is_always_in_the_name() -> None: + """Spec 2.5: the name always carries the version the entry resolves to. + + An operation that pins no `typeVersion` is normalised to 1 at extraction, + because that is what the API resolves it to -- so there is no unpinned case + left by the time a name is chosen. + """ + assert names([spec("card_settle")]) == ["CardSettleV1"] + assert names([spec("card_settle", 1)]) == ["CardSettleV1"] + assert names([spec("card_settle", 3)]) == ["CardSettleV3"] + + +def test_versions_of_one_type_are_separate_models() -> None: + """Spec 2.2: identity is (type, version), so v1 and v2 both survive.""" + assert names([spec("order_placed", 1), spec("order_placed", 2)]) == [ + "OrderPlacedV1", + "OrderPlacedV2", + ] + + +def test_same_identity_from_two_operations_is_one_model() -> None: + """Spec 2.2: deduplicated, and lossless given the CLI/API uniqueness rule.""" + assert names( + [spec("card_settle", 1, "PostA"), spec("card_settle", 1, "PostB")] + ) == ["CardSettleV1"] + + +def test_colliding_base_names_are_disambiguated() -> None: + """Distinct types that pascal-case alike must not collapse into one model. + + `auth_hold` and `authHold` are different entry types that both pascal-case to + `AuthHold`, so the second falls back to its source operation name. Operation + names are realistic: a generator derives them from the entry type too, and + GraphQL forbids duplicates in one document, so it must already have + disambiguated them itself. + """ + resolved = names( + [spec("auth_hold", 1, "PostAuthHold"), spec("authHold", 1, "PostAuthHold2")] + ) + assert resolved == ["AuthHoldV1", "AuthHoldV1PostAuthHold2"] + + +def test_adding_a_version_does_not_rename_the_existing_model() -> None: + """Spec 2.6: an additive Schema change must not break caller source. + + A name depends only on its own identity, never on which other operations + happen to be present. + """ + before = names([spec("order_placed", 1)]) + after = names([spec("order_placed", 1), spec("order_placed", 2)]) + assert before == ["OrderPlacedV1"] + assert after[0] == before[0] + + +@pytest.mark.parametrize("calls", [2, 3]) +def test_resolve_class_names_is_idempotent(calls: int) -> None: + specs = [spec("order_placed", 1), spec("order_placed", 2), spec("card_settle")] + results = [names(specs) for _ in range(calls)] + assert len(set(map(tuple, results))) == 1, results + + +def test_resolve_class_names_does_not_mutate_its_input() -> None: + specs = [spec("order_placed", 1), spec("card_settle")] + before = [(s.entry_type, s.base_name, s.type_version) for s in specs] + resolve_class_names(specs) + resolve_class_names(specs) + after = [(s.entry_type, s.base_name, s.type_version) for s in specs] + assert after == before + + +def test_render_module_is_idempotent() -> None: + """Rendering twice must produce identical source, or snapshots would churn.""" + specs = [ + spec( + "order_placed", + 1, + parameters=[ + EntryParameter( + name="order_cost", + field_name="order_cost", + annotation="str", + required=True, + ) + ], + ), + spec("order_placed", 2), + ] + first = render_module(specs) + assert render_module(specs) == first + assert "class OrderPlacedV1(TypedLedgerEntry):" in first + assert "class OrderPlacedV2(TypedLedgerEntry):" in first + assert "V1V1" not in first From 4cc620190a636d14b5da921bbb1b03529d606cbe Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Wed, 5 Aug 2026 11:36:40 -0400 Subject: [PATCH 06/15] Add unit tests for parameter naming and to_input Covers the three cases Steven flagged as untested and credential-free: - PARAMETER_FIELDS keeping the Schema name when the Python field is escaped or snake_cased - camelCase parameters becoming snake_case fields with the wire key unchanged - to_input() / serialisation shape, including omission of unset fields The to_input tests execute BASE_CLASS_SOURCE rather than importing the committed fragment/sdk/typed_entries, so they test what codegen emits now instead of whenever it last ran. Subclassing the generated module first meant removing `description=self.description` from the renderer left all tests green. Claude-Session: https://claude.ai/code/session_01J1SSSkp8AgFGobLLaEAN6K --- tests/test_typed_entries.py | 187 +++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/tests/test_typed_entries.py b/tests/test_typed_entries.py index 82da56b..e5deaa4 100644 --- a/tests/test_typed_entries.py +++ b/tests/test_typed_entries.py @@ -8,18 +8,32 @@ `OrderPlaced` become `OrderPlacedV1` and then `OrderPlacedV1V1`. """ -from typing import List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Sequence import pytest from ariadne_codegen.utils import str_to_pascal_case, str_to_snake_case +from graphql import OperationDefinitionNode, parse +from pydantic import model_serializer from fragment.codegen.typed_entries import ( + BASE_CLASS_SOURCE, DEFAULT_TYPE_VERSION, EntryParameter, EntrySpec, + _safe_field_name, + extract_entry_spec, render_module, resolve_class_names, ) +from fragment.sdk.base_model import BaseModel +from fragment.sdk.input_types import ( + AddLedgerEntryInput, + LedgerEntryConditionInput, + LedgerEntryGroupInput, + LedgerEntryInput, + LedgerEntryTagInput, + LedgerMatchInput, +) def spec( @@ -137,3 +151,174 @@ def test_render_module_is_idempotent() -> None: assert "class OrderPlacedV1(TypedLedgerEntry):" in first assert "class OrderPlacedV2(TypedLedgerEntry):" in first assert "V1V1" not in first + + +# --- Parameter naming: what reaches Python vs what reaches the wire ----------- + + +@pytest.mark.parametrize( + ("schema_name", "field_name"), + [ + ("order_cost", "order_cost"), # already safe, left alone + ("type", "type_"), # shadows a builtin + ("class", "class_"), # Python keyword + ("def", "def_"), + ("json", "json_"), # pydantic reserved + ("copy", "copy_"), + ("model_dump", "model_dump_"), + ("ik", "ik_"), # collides with TypedLedgerEntry's own field + ("posted", "posted_"), + ("description", "description_"), + ("userId", "user_id"), # camelCase is snake_cased + ("captureAmount", "capture_amount"), + ], +) +def test_schema_parameter_becomes_a_safe_field_name( + schema_name: str, field_name: str +) -> None: + assert _safe_field_name(schema_name) == field_name + + +def operation(parameters: str, variables: str) -> OperationDefinitionNode: + doc = parse( + f"""mutation PostThing($ik: SafeString!, $ledgerIk: SafeString!, {variables}) {{ + addLedgerEntry( + ik: $ik + entry: {{ledger: {{ik: $ledgerIk}}, type: "thing", parameters: {{{parameters}}}}} + ) {{ __typename }} + }}""" + ) + node = doc.definitions[0] + assert isinstance(node, OperationDefinitionNode) + return node + + +def test_parameter_fields_keeps_the_schema_name_when_the_field_is_escaped() -> None: + """The README's promise: escaping is local and never reaches the wire.""" + spec_ = extract_entry_spec( + operation( + parameters="type: $entryType, json: $blob, userId: $userId", + variables="$entryType: String!, $blob: String!, $userId: String!", + ), + annotations={"entry_type": "str", "blob": "str", "user_id": "str"}, + ) + assert spec_ is not None + # (Schema name kept verbatim, Python field escaped or snake_cased.) + assert [(p.name, p.field_name) for p in spec_.parameters] == [ + ("type", "type_"), + ("json", "json_"), + ("userId", "user_id"), + ] + + +def test_rendered_parameter_fields_maps_wire_name_to_python_field() -> None: + spec_ = extract_entry_spec( + operation( + parameters="type: $entryType, userId: $userId", + variables="$entryType: String!, $userId: String!", + ), + annotations={"entry_type": "str", "user_id": "str"}, + ) + assert spec_ is not None + rendered = render_module([spec_]) + assert '"type": "type_",' in rendered + assert '"userId": "user_id",' in rendered + assert " type_: str" in rendered + assert " user_id: str" in rendered + + +# --- to_input() / serialisation ------------------------------------------------ + + +@pytest.fixture(scope="module") +def sample() -> type: + """A model built on the base class *as rendered*, not as last generated. + + `BASE_CLASS_SOURCE` is a source template, so importing the committed + `fragment.sdk.typed_entries` would test whenever codegen last ran instead of + what it emits now. Executing the template keeps these assertions pointed at + the thing under test. + """ + namespace: dict = { + "Any": Any, + "ClassVar": ClassVar, + "Dict": Dict, + "List": List, + "Optional": Optional, + "Sequence": Sequence, + "BaseModel": BaseModel, + "model_serializer": model_serializer, + "AddLedgerEntryInput": AddLedgerEntryInput, + "LedgerEntryInput": LedgerEntryInput, + "LedgerMatchInput": LedgerMatchInput, + "LedgerEntryTagInput": LedgerEntryTagInput, + "LedgerEntryGroupInput": LedgerEntryGroupInput, + "LedgerEntryConditionInput": LedgerEntryConditionInput, + } + exec(BASE_CLASS_SOURCE, namespace) # noqa: S102 + return type( + "Sample", + (namespace["TypedLedgerEntry"],), + { + "__annotations__": { + "type_": str, + "user_id": str, + "optional_thing": Optional[str], + }, + "optional_thing": None, + "ENTRY_TYPE": "thing", + "TYPE_VERSION": 2, + "PARAMETER_FIELDS": { + "type": "type_", + "userId": "user_id", + "optionalThing": "optional_thing", + }, + }, + ) + + +def test_to_input_builds_the_nested_add_ledger_entry_input(sample: type) -> None: + entry = sample(ik="ik-1", ledger_ik="prod", type_="t", user_id="u").to_input() + assert entry.ik == "ik-1" + assert entry.entry.ledger is not None + assert entry.entry.ledger.ik == "prod" + assert entry.entry.type_ == "thing" + assert entry.entry.type_version == 2 + assert entry.entry.parameters == {"type": "t", "userId": "u"} + assert entry.entry.lines is None + + +def test_serialisation_uses_schema_names_and_omits_what_was_not_set( + sample: type, +) -> None: + dumped = sample(ik="ik-1", ledger_ik="prod", type_="t", user_id="u").model_dump( + by_alias=True + ) + assert dumped == { + "ik": "ik-1", + "entry": { + "ledger": {"ik": "prod"}, + "type": "thing", + "typeVersion": 2, + "parameters": {"type": "t", "userId": "u"}, + }, + } + + +def test_optional_parameter_is_carried_when_set(sample: type) -> None: + dumped = sample( + ik="ik-1", + ledger_ik="prod", + type_="t", + user_id="u", + optional_thing="here", + description="a description", + posted="1968-01-01T16:45:00Z", + ).model_dump(by_alias=True) + assert dumped["entry"]["parameters"] == { + "type": "t", + "userId": "u", + "optionalThing": "here", + } + assert dumped["entry"]["description"] == "a description" + assert dumped["entry"]["posted"] == "1968-01-01T16:45:00Z" From 5af71d44c47e3ed20b30c26cff2b509484530048 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Thu, 6 Aug 2026 09:53:33 -0400 Subject: [PATCH 07/15] Coerce batch entries to a list before serialising Widening the entries argument to Sequence let a tuple through the type checker, but ariadne's base client only recurses into variables with isinstance(value, list). A tuple skipped conversion and reached json.dumps holding model objects: list -> {"entries": [{"entry": {...}, "ik": "a"}]} tuple -> TypeError: Object of type OrderPlacedV1 is not JSON serializable The plugin now also rewrites the method body's variables assignment to {"entries": list(entries)}, so tuples, generators and mixed sequences all serialise. Warns if that assignment cannot be found, matching the other two warnings in this plugin. --- .../codegen/plugins/generate_typed_entries.py | 39 +++++++++++++++++++ fragment/sdk/client.py | 2 +- fragment/sync_sdk/client.py | 2 +- .../001-marketing-schema/sdk/client.py | 2 +- tests/test_typed_entries.py | 34 ++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/fragment/codegen/plugins/generate_typed_entries.py b/fragment/codegen/plugins/generate_typed_entries.py index c3a2a5f..e1a6802 100644 --- a/fragment/codegen/plugins/generate_typed_entries.py +++ b/fragment/codegen/plugins/generate_typed_entries.py @@ -73,6 +73,11 @@ def _widen_entries_argument( `Sequence` of either keeps raw inputs working while accepting typed models directly -- `Sequence` because `list` is invariant, so `list[AuthCapture]` would otherwise be rejected. + + Widening the annotation alone would be a runtime trap. The base client + recurses into variables with `isinstance(value, list)`, so a tuple would + satisfy the annotation, skip conversion, and reach `json.dumps` as model + objects. `_coerce_entries_to_list` closes that. """ for arg in method_def.args.args: if arg.arg != ENTRIES_ARGUMENT: @@ -84,6 +89,7 @@ def _widen_entries_argument( "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]", mode="eval", ).body + self._coerce_entries_to_list(method_def) self.widened_entries_argument = True return @@ -94,6 +100,39 @@ def _widen_entries_argument( ENTRIES_ARGUMENT, ) + def _coerce_entries_to_list( + self, method_def: ast.FunctionDef | ast.AsyncFunctionDef + ) -> None: + """Rewrite `{"entries": entries}` to `{"entries": list(entries)}`. + + The base client only recurses into `list`, so any other sequence reaches + the JSON encoder holding model objects. Widening the annotation is what + makes that reachable, so the coercion belongs with it. + """ + for node in ast.walk(method_def): + if not isinstance(node, ast.Dict): + continue + for index, key in enumerate(node.keys): + if not ( + isinstance(key, ast.Constant) and key.value == ENTRIES_ARGUMENT + ): + continue + value = node.values[index] + if isinstance(value, ast.Name) and value.id == ENTRIES_ARGUMENT: + node.values[index] = ast.Call( + func=ast.Name(id="list", ctx=ast.Load()), + args=[value], + keywords=[], + ) + return + + console_log.warning( + "Could not find the %r variables assignment in add_ledger_entries, so " + "it was left as-is. Passing a non-list sequence of entries will fail " + "to serialise.", + ENTRIES_ARGUMENT, + ) + def generate_client_code(self, generated_code: str) -> str: if not self.widened_entries_argument: return generated_code diff --git a/fragment/sdk/client.py b/fragment/sdk/client.py index f528586..1e5f7e7 100644 --- a/fragment/sdk/client.py +++ b/fragment/sdk/client.py @@ -263,7 +263,7 @@ async def add_ledger_entries( } } """) - variables: dict[str, object] = {"entries": entries} + variables: dict[str, object] = {"entries": list(entries)} response = await self.execute( query=query, operation_name="addLedgerEntries", diff --git a/fragment/sync_sdk/client.py b/fragment/sync_sdk/client.py index 9243e16..0013c85 100644 --- a/fragment/sync_sdk/client.py +++ b/fragment/sync_sdk/client.py @@ -259,7 +259,7 @@ def add_ledger_entries( } } """) - variables: dict[str, object] = {"entries": entries} + variables: dict[str, object] = {"entries": list(entries)} response = self.execute( query=query, operation_name="addLedgerEntries", diff --git a/tests/snapshots/001-marketing-schema/sdk/client.py b/tests/snapshots/001-marketing-schema/sdk/client.py index 8d45bf2..72e01cd 100644 --- a/tests/snapshots/001-marketing-schema/sdk/client.py +++ b/tests/snapshots/001-marketing-schema/sdk/client.py @@ -1032,7 +1032,7 @@ async def add_ledger_entries( } } """) - variables: dict[str, object] = {"entries": entries} + variables: dict[str, object] = {"entries": list(entries)} response = await self.execute( query=query, operation_name="addLedgerEntries", diff --git a/tests/test_typed_entries.py b/tests/test_typed_entries.py index e5deaa4..a5d21d4 100644 --- a/tests/test_typed_entries.py +++ b/tests/test_typed_entries.py @@ -8,6 +8,8 @@ `OrderPlaced` become `OrderPlacedV1` and then `OrderPlacedV1V1`. """ +import ast +from pathlib import Path from typing import Any, ClassVar, Dict, List, Optional, Sequence import pytest @@ -26,6 +28,11 @@ resolve_class_names, ) from fragment.sdk.base_model import BaseModel + +SNAPSHOT_CLIENT = ( + Path(__file__).parent / "snapshots" / "001-marketing-schema" / "sdk" / "client.py" +) + from fragment.sdk.input_types import ( AddLedgerEntryInput, LedgerEntryConditionInput, @@ -322,3 +329,30 @@ def test_optional_parameter_is_carried_when_set(sample: type) -> None: } assert dumped["entry"]["description"] == "a description" assert dumped["entry"]["posted"] == "1968-01-01T16:45:00Z" + + +# --- The widened argument must not outrun what the base client converts ------- + + +def test_generated_batch_method_coerces_entries_to_a_list() -> None: + """Widening to `Sequence` is only safe if the body narrows back. + + The base client recurses into variables with `isinstance(value, list)`, so a + tuple would satisfy the annotation, skip conversion, and reach `json.dumps` + still holding model objects. + """ + source = (SNAPSHOT_CLIENT).read_text() + tree = ast.parse(source) + method = next( + node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "add_ledger_entries" + ) + assignments = [ + ast.unparse(node) + for node in ast.walk(method) + if isinstance(node, ast.Dict) + and any(isinstance(k, ast.Constant) and k.value == "entries" for k in node.keys) + ] + assert assignments == ["{'entries': list(entries)}"] From 0d14d2c8b3eb351089122167717736394f281644 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Thu, 6 Aug 2026 09:59:40 -0400 Subject: [PATCH 08/15] Disambiguate parameters that snake_case to the same field resolve_class_names guards class-name collisions, but nothing guarded field names within a class. A Schema declaring both user_id and userId rendered the field twice; pydantic kept the last one and both wire keys took the same value, with no warning: {"user_id": "VALUE", "userId": "VALUE"} _safe_field_name cannot see its siblings, so the check belongs in _extract_parameters where the parameter list is assembled. Later collisions take a numeric suffix, the same shape resolve_class_names uses one level up, and a warning names the parameter that moved. PARAMETER_FIELDS still maps each Schema name to its own field, so the wire payload is unchanged: {"user_id": "SNAKE", "userId": "CAMEL"} --- fragment/codegen/typed_entries.py | 39 ++++++++++++++++++++++++- tests/test_typed_entries.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/fragment/codegen/typed_entries.py b/fragment/codegen/typed_entries.py index 4017922..9f8fcd6 100644 --- a/fragment/codegen/typed_entries.py +++ b/fragment/codegen/typed_entries.py @@ -170,6 +170,7 @@ def _extract_parameters( } parameters: list[EntryParameter] = [] + taken: set[str] = set() for param in parameters_node.fields: # Only variable-bound parameters are typeable. A parameter hardcoded in # the operation is already fixed and must not become a field. @@ -195,7 +196,9 @@ def _extract_parameters( parameters.append( EntryParameter( name=param.name.value, - field_name=_safe_field_name(param.name.value), + field_name=_unique_field_name( + param.name.value, taken, operation_definition + ), annotation=annotation, required=required, ) @@ -203,6 +206,40 @@ def _extract_parameters( return parameters +def _unique_field_name( + schema_name: str, + taken: set[str], + operation_definition: OperationDefinitionNode, +) -> str: + """Give this parameter a field name no sibling parameter already holds. + + `_safe_field_name` cannot see the other parameters, so two Schema names that + snake_case alike (`user_id` and `userId`) both arrive as `user_id`. Pydantic + accepts the duplicate declaration and the last one wins, which puts one + value under both wire keys. The suffix here is the same idea as the one + `resolve_class_names` applies to class names, one level down. + + `taken` is mutated so later parameters see the names already claimed. + """ + field_name = _safe_field_name(schema_name) + if field_name in taken: + base = field_name + counter = 2 + while field_name in taken: + field_name = f"{base}_{counter}" + counter += 1 + console_log.warning( + "Parameters in operation %s map to the same Python field %r; %r is " + "generated as %r instead. The wire payload is unaffected.", + operation_definition.name.value if operation_definition.name else "?", + base, + schema_name, + field_name, + ) + taken.add(field_name) + return field_name + + def extract_entry_spec( operation_definition: OperationDefinitionNode, annotations: dict[str, str], diff --git a/tests/test_typed_entries.py b/tests/test_typed_entries.py index a5d21d4..e8a9762 100644 --- a/tests/test_typed_entries.py +++ b/tests/test_typed_entries.py @@ -356,3 +356,51 @@ def test_generated_batch_method_coerces_entries_to_a_list() -> None: and any(isinstance(k, ast.Constant) and k.value == "entries" for k in node.keys) ] assert assignments == ["{'entries': list(entries)}"] + + +def test_parameters_that_snake_case_alike_get_separate_fields() -> None: + """Two Schema names can collapse to one Python field; they must not share it. + + Pydantic accepts a duplicated field declaration and lets the last one win, + which would put a single value under both wire keys. + """ + spec_ = extract_entry_spec( + operation( + parameters="user_id: $snake, userId: $camel", + variables="$snake: String!, $camel: String!", + ), + annotations={"snake": "str", "camel": "str"}, + ) + assert spec_ is not None + assert [(p.name, p.field_name) for p in spec_.parameters] == [ + ("user_id", "user_id"), + ("userId", "user_id_2"), + ] + + +def test_three_way_field_collision_keeps_going() -> None: + spec_ = extract_entry_spec( + operation( + parameters="user_id: $a, userId: $b, USER_ID: $c", + variables="$a: String!, $b: String!, $c: String!", + ), + annotations={"a": "str", "b": "str", "c": "str"}, + ) + assert spec_ is not None + field_names = [p.field_name for p in spec_.parameters] + assert len(set(field_names)) == 3, field_names + + +def test_colliding_parameters_keep_distinct_wire_keys(sample: type) -> None: + """The rename is local. Each Schema name still carries its own value.""" + spec_ = extract_entry_spec( + operation( + parameters="user_id: $snake, userId: $camel", + variables="$snake: String!, $camel: String!", + ), + annotations={"snake": "str", "camel": "str"}, + ) + assert spec_ is not None + rendered = render_module([spec_]) + assert '"user_id": "user_id",' in rendered + assert '"userId": "user_id_2",' in rendered From 78348c2ab5fb62e28aaae6de225bb5dc14088ec3 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Thu, 6 Aug 2026 10:15:21 -0400 Subject: [PATCH 09/15] Fail codegen if the plugin order breaks annotation harvesting collect_annotations copies annotations off the generated client methods, so it depends on RewriteUnsetTypeMethodArguments having already collapsed Union[Optional[X], UnsetType] into Optional[X]. That only held because of where GenerateTypedLedgerEntries sits in the plugin list, with nothing saying so. Listed earlier it emitted: memo: Union[Optional[str], UnsetType] = None into a module that imports neither name, and the generated SDK failed at import with NameError. collect_annotations now raises and names the cause. Raising rather than warning because the output is an unimportable package, not a model with a weaker type. helpers.py documents why the order matters. --- fragment/codegen/helpers.py | 6 ++++++ fragment/codegen/typed_entries.py | 19 ++++++++++++++++++- tests/test_typed_entries.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/fragment/codegen/helpers.py b/fragment/codegen/helpers.py index 86df7b3..409e010 100644 --- a/fragment/codegen/helpers.py +++ b/fragment/codegen/helpers.py @@ -39,6 +39,12 @@ def get_codegen_config( base_client_name=client_name, base_client_file_path=get_project_path_relative_to_file(file_path), async_client=False if use_sync_client else True, + # Order matters. GenerateTypedLedgerEntries copies annotations + # off the generated client methods, so it has to run after + # RewriteUnsetTypeMethodArguments has turned + # `Union[Optional[X], UnsetType]` into `Optional[X]`. Listed + # earlier, it emits `UnsetType` into a module that never imports + # it. collect_annotations raises if that ever happens. plugins=[ "fragment.codegen.plugins.get_file_comment.GenerateFileComment", "fragment.codegen.plugins.generate_client_method.RewriteUnsetTypeMethodArguments", diff --git a/fragment/codegen/typed_entries.py b/fragment/codegen/typed_entries.py index 9f8fcd6..99ccb74 100644 --- a/fragment/codegen/typed_entries.py +++ b/fragment/codegen/typed_entries.py @@ -25,6 +25,7 @@ from dataclasses import dataclass, field from functools import cache +from ariadne_codegen.client_generators.constants import UNSET_TYPE_NAME from ariadne_codegen.utils import process_name, str_to_pascal_case, str_to_snake_case from graphql import ( FieldNode, @@ -301,12 +302,28 @@ def collect_annotations( the equivalent client method's arguments, including custom scalar handling. ariadne reorders arguments to put required ones first, so this matches by name rather than by position. + + Depends on RewriteUnsetTypeMethodArguments having already collapsed + `Union[Optional[X], UnsetType]` down to `Optional[X]`, which the plugin + ordering guarantees. Raises if that has not happened. """ generated: dict[str, str] = {} for arg in method_def.args.args: if arg.arg == "self" or arg.annotation is None: continue - generated[arg.arg] = ast.unparse(arg.annotation) + annotation = ast.unparse(arg.annotation) + if UNSET_TYPE_NAME in annotation: + # RewriteUnsetTypeMethodArguments has not run yet. Copying this + # annotation would emit `UnsetType` into a module that does not + # import it, and the generated SDK would fail to import at all. + # Better to stop here than to write out a broken package. + raise RuntimeError( + f"Annotation {annotation!r} for argument {arg.arg!r} still " + f"mentions {UNSET_TYPE_NAME}. GenerateTypedLedgerEntries must be " + "listed after RewriteUnsetTypeMethodArguments in the codegen " + "plugin list; see get_codegen_config in fragment/codegen/helpers.py." + ) + generated[arg.arg] = annotation annotations: dict[str, str] = {} for vd in operation_definition.variable_definitions: diff --git a/tests/test_typed_entries.py b/tests/test_typed_entries.py index e8a9762..cceb92d 100644 --- a/tests/test_typed_entries.py +++ b/tests/test_typed_entries.py @@ -23,6 +23,7 @@ EntryParameter, EntrySpec, _safe_field_name, + collect_annotations, extract_entry_spec, render_module, resolve_class_names, @@ -404,3 +405,31 @@ def test_colliding_parameters_keep_distinct_wire_keys(sample: type) -> None: rendered = render_module([spec_]) assert '"user_id": "user_id",' in rendered assert '"userId": "user_id_2",' in rendered + + +def test_collect_annotations_rejects_an_unrewritten_unset_type() -> None: + """Guards a load-bearing plugin order. + + `collect_annotations` copies annotations off the generated client method, so + it depends on `RewriteUnsetTypeMethodArguments` having already collapsed + `Union[Optional[X], UnsetType]`. Listed the other way round, the typed module + gets `UnsetType` without importing it and the whole SDK fails to import. + """ + method = ast.parse( + "async def post_thing(self, memo: Union[Optional[str], UnsetType] = None): ..." + ).body[0] + assert isinstance(method, ast.AsyncFunctionDef) + op = operation(parameters="memo: $memo", variables="$memo: String") + + with pytest.raises(RuntimeError, match="UnsetType"): + collect_annotations(method, op) + + +def test_collect_annotations_accepts_a_rewritten_annotation() -> None: + method = ast.parse( + "async def post_thing(self, memo: Optional[str] = None): ..." + ).body[0] + assert isinstance(method, ast.AsyncFunctionDef) + op = operation(parameters="memo: $memo", variables="$memo: String") + + assert collect_annotations(method, op) == {"memo": "Optional[str]"} From 27c1aaa83500807afa26eabc0692ff9510bb467d Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Mon, 10 Aug 2026 15:04:36 -0400 Subject: [PATCH 10/15] Add --schema-path argument to the SDK --- Makefile | 13 +- fragment/codegen/main.py | 79 +- fragment/sdk/__init__.py | 2 + fragment/sdk/enums.py | 6 + fragment/sdk/input_types.py | 4 +- fragment/sync_sdk/__init__.py | 2 + fragment/sync_sdk/enums.py | 6 + fragment/sync_sdk/input_types.py | 4 +- .../001-marketing-schema/sdk/__init__.py | 2 + .../001-marketing-schema/sdk/enums.py | 6 + .../001-marketing-schema/sdk/input_types.py | 4 +- tests/snapshots/schema.graphql | 4704 +++++++++++++++++ 12 files changed, 4799 insertions(+), 33 deletions(-) create mode 100644 tests/snapshots/schema.graphql diff --git a/Makefile b/Makefile index 0ce0cc4..1f27728 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,14 @@ -.PHONY: lint test snapshots check-snapshots +.PHONY: lint test snapshots check-snapshots refresh-snapshot-schema # Each tests/snapshots/*/ holds a queries.graphql and the client generated from # it, checked in. The pair is a regression guard: a change to codegen that alters # generated output shows up as a reviewable diff instead of silently. SNAPSHOT_DIRS := $(sort $(dir $(wildcard tests/snapshots/*/queries.graphql))) SNAPSHOT_PACKAGE := sdk +# Pinned so a snapshot is a function of checked-in inputs alone. Generating +# against the live schema made every PR fail whenever the API changed. Kept +# outside the fixture directories, which codegen scans for operations. +SNAPSHOT_SCHEMA := tests/snapshots/schema.graphql install: poetry install --with dev @@ -34,6 +38,7 @@ snapshots: rm -rf "$$dir$(SNAPSHOT_PACKAGE)"; \ poetry run fragment-python-client-codegen \ --input-dir="$$dir" \ + --schema-path=$(SNAPSHOT_SCHEMA) \ --target-package-name=$(SNAPSHOT_PACKAGE) \ --output-dir="$$dir" || exit 1; \ done @@ -55,6 +60,12 @@ check-snapshots: snapshots fi @echo "Snapshots up to date." +# Repin the snapshot schema to the current API. Run deliberately; the diff shows +# what changed upstream. +refresh-snapshot-schema: + poetry run python -c "import httpx; from fragment.codegen.main import GRAPHQL_SCHEMA_API_URL as u; open('$(SNAPSHOT_SCHEMA)','w').write(httpx.get(u).text)" + $(MAKE) snapshots + build: install poetry run fragment-python-client-codegen --input-dir=queries/ --target-package-name=sdk --output-dir fragment/ poetry run fragment-python-client-codegen --input-dir=queries/ --target-package-name=sync_sdk --output-dir fragment/ --sync diff --git a/fragment/codegen/main.py b/fragment/codegen/main.py index 9286f10..e16578e 100644 --- a/fragment/codegen/main.py +++ b/fragment/codegen/main.py @@ -1,6 +1,8 @@ +import contextlib import logging import sys import tempfile +from typing import Iterator import click import httpx @@ -15,6 +17,31 @@ GRAPHQL_SCHEMA_API_URL = "https://api.us-west-2.fragment.dev/schema.graphql" +@contextlib.contextmanager +def resolved_schema_path(schema_path: str | None) -> Iterator[str]: + """Yield a path to the schema to generate against. + + A local path is used as-is, which keeps generation reproducible and offline. + Without one the current schema is downloaded to a temporary file, so output + depends on whatever the API looks like at that moment. + """ + if schema_path is not None: + console_log.info(f"Using the GraphQL schema at {schema_path}") + yield schema_path + return + + console_log.info(f"Downloading the GraphQL schema from {GRAPHQL_SCHEMA_API_URL}") + try: + response = httpx.get(GRAPHQL_SCHEMA_API_URL) + except httpx.RequestError as error: + console_log.error(f"An error occurred while downloading the schema: {error}") + sys.exit(1) + with tempfile.NamedTemporaryFile(mode="w", suffix=".graphql") as schema_file: + schema_file.write(response.text) + schema_file.flush() + yield schema_file.name + + @click.command() @click.option( "-i", @@ -37,35 +64,35 @@ help="The output directory for the generated SDK. Defaults to CWD.", required=False, ) +@click.option( + "-s", + "--schema-path", + default=None, + type=click.Path(exists=True, dir_okay=False, readable=True), + help=( + "Path to a local GraphQL schema. Defaults to downloading the current " + "schema. Pass a file to make generation reproducible and offline." + ), + required=False, +) @click.option( "--sync", help="Generate a synchronous client. Defaults to async.", required=False, is_flag=True, ) -def run(input_dir, target_package_name, sync, output_dir=None): - console_log.info(f"Downloading the GraphQL schema from {GRAPHQL_SCHEMA_API_URL}") - try: - r = httpx.get(GRAPHQL_SCHEMA_API_URL) - with tempfile.NamedTemporaryFile( - mode="w" - ) as schema_file, tempfile.NamedTemporaryFile( - dir=input_dir, mode="w", suffix=".graphql" - ) as standard_query_file: - # Write and flush the most recent schema - schema_file.write(r.text) - schema_file.flush() - # Write and flush the standard queries to the provided input - standard_query_file.write(get_standard_queries()) - standard_query_file.flush() - config_dict = get_codegen_config( - use_sync_client=sync, - schema_path=schema_file.name, - queries_path=input_dir, - target_package_name=target_package_name, - target_package_path=output_dir, - ) - generate_graphql_client(config_dict) - except httpx.RequestError as e: - console_log.error(f"An error occurred while downloading the schema: {e}") - sys.exit(1) +def run(input_dir, target_package_name, sync, output_dir=None, schema_path=None): + with resolved_schema_path(schema_path) as resolved, tempfile.NamedTemporaryFile( + dir=input_dir, mode="w", suffix=".graphql" + ) as standard_query_file: + # Write and flush the standard queries to the provided input + standard_query_file.write(get_standard_queries()) + standard_query_file.flush() + config_dict = get_codegen_config( + use_sync_client=sync, + schema_path=resolved, + queries_path=input_dir, + target_package_name=target_package_name, + target_package_path=output_dir, + ) + generate_graphql_client(config_dict) diff --git a/fragment/sdk/__init__.py b/fragment/sdk/__init__.py index cade118..7501db8 100644 --- a/fragment/sdk/__init__.py +++ b/fragment/sdk/__init__.py @@ -90,6 +90,7 @@ LedgerMigrationStatus, LedgerTypes, LinkType, + PaymentStatus, PostLinesAs, ReadBalanceConsistencyMode, SceneEventType, @@ -641,6 +642,7 @@ "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLines", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodes", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodesAccount", + "PaymentStatus", "PostLinesAs", "ReadBalanceConsistencyMode", "ReconcileTx", diff --git a/fragment/sdk/enums.py b/fragment/sdk/enums.py index b9f0234..1c9468d 100644 --- a/fragment/sdk/enums.py +++ b/fragment/sdk/enums.py @@ -260,6 +260,12 @@ class LinkType(str, Enum): UnitLink = "UnitLink" +class PaymentStatus(str, Enum): + processing = "processing" + requires_confirmation = "requires_confirmation" + settled = "settled" + + class PostLinesAs(str, Enum): net_amounts = "net_amounts" raw_lines = "raw_lines" diff --git a/fragment/sdk/input_types.py b/fragment/sdk/input_types.py index 22e83ff..8aa8a9f 100644 --- a/fragment/sdk/input_types.py +++ b/fragment/sdk/input_types.py @@ -27,9 +27,9 @@ class AddLedgerEntryInput(BaseModel): entry: "LedgerEntryInput" - "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to commit" + "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to add" ik: Any - "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this entry" + "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this Ledger Entry" class ChartOfAccountsInput(BaseModel): diff --git a/fragment/sync_sdk/__init__.py b/fragment/sync_sdk/__init__.py index cf1bff9..13c45a6 100644 --- a/fragment/sync_sdk/__init__.py +++ b/fragment/sync_sdk/__init__.py @@ -89,6 +89,7 @@ LedgerMigrationStatus, LedgerTypes, LinkType, + PaymentStatus, PostLinesAs, ReadBalanceConsistencyMode, SceneEventType, @@ -640,6 +641,7 @@ "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLines", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodes", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodesAccount", + "PaymentStatus", "PostLinesAs", "ReadBalanceConsistencyMode", "ReconcileTx", diff --git a/fragment/sync_sdk/enums.py b/fragment/sync_sdk/enums.py index b9f0234..1c9468d 100644 --- a/fragment/sync_sdk/enums.py +++ b/fragment/sync_sdk/enums.py @@ -260,6 +260,12 @@ class LinkType(str, Enum): UnitLink = "UnitLink" +class PaymentStatus(str, Enum): + processing = "processing" + requires_confirmation = "requires_confirmation" + settled = "settled" + + class PostLinesAs(str, Enum): net_amounts = "net_amounts" raw_lines = "raw_lines" diff --git a/fragment/sync_sdk/input_types.py b/fragment/sync_sdk/input_types.py index 22e83ff..8aa8a9f 100644 --- a/fragment/sync_sdk/input_types.py +++ b/fragment/sync_sdk/input_types.py @@ -27,9 +27,9 @@ class AddLedgerEntryInput(BaseModel): entry: "LedgerEntryInput" - "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to commit" + "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to add" ik: Any - "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this entry" + "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this Ledger Entry" class ChartOfAccountsInput(BaseModel): diff --git a/tests/snapshots/001-marketing-schema/sdk/__init__.py b/tests/snapshots/001-marketing-schema/sdk/__init__.py index eb8cb5f..ffa9633 100644 --- a/tests/snapshots/001-marketing-schema/sdk/__init__.py +++ b/tests/snapshots/001-marketing-schema/sdk/__init__.py @@ -90,6 +90,7 @@ LedgerMigrationStatus, LedgerTypes, LinkType, + PaymentStatus, PostLinesAs, ReadBalanceConsistencyMode, SceneEventType, @@ -758,6 +759,7 @@ "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLines", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodes", "MigrateLedgerEntryMigrateLedgerEntryMigrateLedgerEntryResultReversingLedgerEntryLinesNodesAccount", + "PaymentStatus", "PostCardSettle", "PostCardSettleAddLedgerEntryAddLedgerEntryResult", "PostCardSettleAddLedgerEntryAddLedgerEntryResultEntry", diff --git a/tests/snapshots/001-marketing-schema/sdk/enums.py b/tests/snapshots/001-marketing-schema/sdk/enums.py index b9f0234..1c9468d 100644 --- a/tests/snapshots/001-marketing-schema/sdk/enums.py +++ b/tests/snapshots/001-marketing-schema/sdk/enums.py @@ -260,6 +260,12 @@ class LinkType(str, Enum): UnitLink = "UnitLink" +class PaymentStatus(str, Enum): + processing = "processing" + requires_confirmation = "requires_confirmation" + settled = "settled" + + class PostLinesAs(str, Enum): net_amounts = "net_amounts" raw_lines = "raw_lines" diff --git a/tests/snapshots/001-marketing-schema/sdk/input_types.py b/tests/snapshots/001-marketing-schema/sdk/input_types.py index 22e83ff..8aa8a9f 100644 --- a/tests/snapshots/001-marketing-schema/sdk/input_types.py +++ b/tests/snapshots/001-marketing-schema/sdk/input_types.py @@ -27,9 +27,9 @@ class AddLedgerEntryInput(BaseModel): entry: "LedgerEntryInput" - "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to commit" + "The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to add" ik: Any - "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this entry" + "The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this Ledger Entry" class ChartOfAccountsInput(BaseModel): diff --git a/tests/snapshots/schema.graphql b/tests/snapshots/schema.graphql new file mode 100644 index 0000000..0db10f9 --- /dev/null +++ b/tests/snapshots/schema.graphql @@ -0,0 +1,4704 @@ +""" +Error returned when one or more Ledger Entries in the batch could not be added. +""" +type AddLedgerEntriesError implements Error { + """ + The status code of error. For example, 'ledger_entry_batch_operation_failed'. + """ + code: String! + + """ + The list of errors for each Ledger Entry that was responsible for the batch's failure. + """ + errors: [AddLedgerEntryError!]! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +union AddLedgerEntriesResponse = AddLedgerEntriesError | AddLedgerEntriesResult | BadRequestError | InternalError + +type AddLedgerEntriesResult { + """The added Ledger Entries, in the same order as the input""" + results: [AddLedgerEntryResult!]! +} + +""" +Error details for a single Ledger Entry that was responsible for the batch's failure. +""" +type AddLedgerEntryError implements Error { + """The status code of error. For example, 'ledger_entry_too_many_lines'.""" + code: String! + + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) of the Ledger Entry + """ + ik: SafeString! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +input AddLedgerEntryInput { + """ + The [Ledger Entry](https://fragment.dev/api-reference/api-types#input-types-ledgerentryinput) to add + """ + entry: LedgerEntryInput! + + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) for this Ledger Entry + """ + ik: SafeString! +} + +union AddLedgerEntryResponse = AddLedgerEntryResult | BadRequestError | InternalError + +type AddLedgerEntryResult { + """The ledger entry that was posted""" + entry: LedgerEntry! + + """ + True if this request successfully completed before and the previous response is being returned + """ + isIkReplay: Boolean! + + """The ledger lines that were created in that entry""" + lines: [LedgerLine!]! +} + +"""A string that must be alphanumeric""" +scalar AlphaNumericString + +""" +Equivalent to an HTTP 400 - request either has missing or incorrect data +""" +type BadRequestError implements Error { + """The status code of error. For example, 'ledger_not_found'.""" + code: String! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +"""A single amount and the timestamp requested""" +type BalanceChangeDuring { + """The balance or balance change""" + amount: CurrencyAmount! + + """The period of the requested balance change""" + period: Period! +} + +"""A paginated list of amounts and their periods""" +type BalanceChangeDuringConnection { + """ + The end time of the period across which the balance changes are requested + """ + endTime: LastMoment! + + """The granularity of the return data""" + granularity: Granularity! + + """The current page of results""" + nodes: [BalanceChangeDuring!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! + + """ + The start time of the period across which the balance changes are requested + """ + startTime: FirstMoment! +} + +""" +Used to configure the write-consistency of a Ledger Account's balance. See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +enum BalanceUpdateConsistencyMode { + eventual + strong +} + +"""The input for your Chart of Accounts in a Schema.""" +input ChartOfAccountsInput { + """ + The Ledger Accounts modeled by your Schema. Ledger Accounts may be nested up to a maximum depth of 10. + """ + accounts: [SchemaLedgerAccountInput!]! + + """ + The default consistency configuration for all Ledger Accounts in this Schema. + If a Ledger Account does not specify its own consistency configuration, it will use the default values provided here. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + defaultConsistencyConfig: LedgerAccountConsistencyConfigInput + + """ + The default currency of each Ledger Account in the Chart Of Accounts. + It must be provided if `defaultCurrencyMode` is set to `single`. + Additionally, `defaultCurrency` must be omitted if `defaultCurrencyMode` is set to `multi`. + """ + defaultCurrency: CurrencyMatchInput + + """ + The default currency mode of each Ledger Account in the Chart Of Accounts. + """ + defaultCurrencyMode: CurrencyMode +} + +input CreateCustomCurrencyInput { + """ + The currency code for custom currencies. It can be up to 36 characters long. This is used for display purposes. + """ + customCode: String! + + """ + The ID for a custom currency. This is specified when creating the custom currency using the [createCustomCurrency](https://fragment.dev/api-reference/api-mutations#createcustomcurrency) mutation. It can be up to 36 characters long. + """ + customCurrencyId: SafeString! + + """ + A human readable name for the currency (e.g. United States Dollar). This is used for display purposes. + """ + name: String! + + """ + The number of decimal places this currency goes to. For example, United States Dollars have a precision of 2 (i.e. 100 cents in a dollar), whereas the Jordanian Dinar has a precision of 3. This is used for display purposes. + """ + precision: Int! +} + +union CreateCustomCurrencyResponse = BadRequestError | CreateCustomCurrencyResult | InternalError + +type CreateCustomCurrencyResult { + """The Currency that was created.""" + customCurrency: Currency! +} + +union CreateCustomLinkResponse = BadRequestError | CreateCustomLinkResult | InternalError + +type CreateCustomLinkResult { + isIkReplay: Boolean! + + """ + The custom link that was created. Represents an instance of an external system. + """ + link: CustomLink! +} + +input CreateLedgerAccountInput { + """ + The consistency configuration for this Ledger Account. This defines how updates to this Ledger Account's balance are handled. + """ + consistencyConfig: LedgerAccountConsistencyConfigInput + + """ + The currency of this Ledger Account. If this is not set, and `currencyMode` is + not set to `multi`, the workspace-level default is used. + """ + currency: CurrencyMatchInput + + """ + If set to `multi`, creates a multi-currency Ledger Account. If set to `single`, creates a single-currency Ledger Account. + """ + currencyMode: CurrencyMode + + """The External Account to link to this Ledger Account.""" + linkedAccount: ExternalAccountMatchInput + + """The human-readable name of this Ledger Account.""" + name: String! + + """The parent of this Ledger Account.""" + parent: LedgerAccountMatchInput + + """ + The type of ledger account to create. Required if this is a top-level Ledger Account. If not provided, the type will be inferred from the parent. + """ + type: LedgerAccountTypes +} + +union CreateLedgerAccountResponse = BadRequestError | CreateLedgerAccountResult | InternalError + +type CreateLedgerAccountResult { + """true if a previous request successfully created this ledger account""" + isIkReplay: Boolean! + + """The ledger account that was created""" + ledgerAccount: LedgerAccount! +} + +input CreateLedgerAccountsInput { + """Ledger Accounts to create as children of this Ledger Account.""" + childLedgerAccounts: [CreateLedgerAccountsInput!] + + """ + The consistency configuration for this ledger account. See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + consistencyConfig: LedgerAccountConsistencyConfigInput + + """ + The currency of this Ledger Account. If this is not set, the workspace level default is used. + """ + currency: CurrencyMatchInput + + """ + The currency mode of this Ledger Account. If this is not set, the workspace level default is used. + """ + currencyMode: CurrencyMode + + """The idempotency key for creating this Ledger Account.""" + ik: SafeString! + + """ + The External Account to link to this Ledger Account. This can only be specified on leaf Ledger Accounts. See [Reconcile payments](https://fragment.dev/guides/reconcile-payments). + """ + linkedAccount: ExternalAccountMatchInput + + """The name of the Ledger Account.""" + name: String! + + """ + The parent of this Ledger Account. This is only valid on the top level Ledger Account in the payload. + """ + parent: LedgerAccountMatchInput + + """ + The type of this Ledger Account. This field is only required if this is a root Ledger Account. Otherwise, the type will get inherited from its parent. + """ + type: LedgerAccountTypes +} + +union CreateLedgerAccountsResponse = BadRequestError | CreateLedgerAccountsResult | InternalError + +type CreateLedgerAccountsResult { + """ + Whether the ledger accounts were successfully created by a previous request + """ + ikReplays: [IkReplay!]! + + """The ledger accounts that were created""" + ledgerAccounts: [LedgerAccount!]! +} + +input CreateLedgerInput { + """ + Use this field to specify a timezone for queries to your Ledger. + + When aggregating balances, all transactions within a 24 hour period starting at midnight UTC are included in each day. + You can specify a different starting hour for balances. For example, use "-08:00" to align balances with Pacific Standard Time. + Balance queries would then consider the start of each local day to be at 8am UTC the next day in UTC. + The default timezone is UTC. + """ + balanceUTCOffset: UTCOffset + name: String! + type: LedgerTypes +} + +union CreateLedgerResponse = BadRequestError | CreateLedgerResult | InternalError + +type CreateLedgerResult { + """ + true if this request successfully completed before and the previous response is being returned + """ + isIkReplay: Boolean! + + """The Ledger that was created""" + ledger: Ledger! +} + +union CreatePaymentResponse = BadRequestError | InternalError | Payment + +type Currency { + """ + The currency code. This is an [enum type](https://fragment.dev/api-reference/api-types#scalars-and-enums-currencycode) . + """ + code: CurrencyCode! + + """ + The currency code for custom currencies. This is only set if 'currency' is set to CUSTOM. It can be up to 36 characters long. + """ + customCode: String + + """ + The ID for a custom currency. This is specified when creating the custom currency using the [createCustomCurrency](https://fragment.dev/api-reference/api-mutations#createcustomcurrency) mutation. + """ + customCurrencyId: SafeString + + """ + A human readable name for the currency (e.g. United States Dollar). This is used for display purposes. + """ + name: String! + + """ + The number of decimal places this currency goes to. For example, United States Dollars have a precision of 2 (i.e. 100 cents in a dollar), whereas the Jordanian Dinar has a precision of 3. This is used for display purposes. + """ + precision: Int! +} + +"""A single amount accompanied by its currency""" +type CurrencyAmount { + """Numerical integer value, serialized as a string""" + amount: Int96! + + """The currency this amount is in""" + currency: Currency! +} + +"""A paginated list of amounts with their currencies""" +type CurrencyAmountConnection { + """The current page of results""" + nodes: [CurrencyAmount!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +enum CurrencyCode { + AAVE + ADA + AED + AFN + ALL + AMD + ANG + AOA + ARS + AUD + AWG + AZN + BAM + BBD + BCH + BDT + BGN + BHD + BIF + BMD + BND + BOB + BRL + BSD + BTC + BTN + BWP + BYR + BZD + CAD + CADC + CADT + CDF + CHF + CLP + CNY + COP + CRC + CUC + CUP + CUSTOM + CVE + CZK + DAI + DJF + DKK + DOP + DZD + EGP + ERN + ETB + ETH + EUR + EURC + FJD + FKP + GBP + GEL + GGP + GHS + GIP + GMD + GNF + GTQ + GYD + HKD + HNL + HRK + HTG + HUF + IDR + ILS + IMP + INR + IQD + IRR + ISK + JMD + JOD + JPY + KES + KGS + KHR + KMF + KPW + KRW + KWD + KYD + KZT + LAK + LBP + LINK + LKR + LOGICAL + LRD + LSL + LTC + LYD + MAD + MATIC + MDL + MGA + MKD + MMK + MNT + MOP + MUR + MVR + MWK + MXN + MYR + MZN + NAD + NGN + NIO + NOK + NPR + NZD + OMR + PAB + PEN + PGK + PHP + PKR + PLN + PTS + PYG + QAR + RON + RSD + RUB + RWF + SAR + SBD + SCR + SDG + SEK + SGD + SHP + SLL + SOL + SOS + SPL + SRD + STN + SVC + SYP + SZL + THB + TJS + TMT + TND + TOP + TRY + TTD + TVD + TWD + TZS + UAH + UGX + UNI + USD + USDC + USDG + USDT + UYU + UZS + VEF + VND + VUV + WST + XAF + XCD + XLM + XOF + XPF + YER + ZAR + ZMW +} + +input CurrencyFilter { + """Must match the value provided""" + equalTo: CurrencyMatchInput + + """Must match one of the values provided. Limited to 100 items maximum.""" + in: [CurrencyMatchInput!] +} + +input CurrencyMatchInput { + """ + The currency code. This is an [enum type](https://fragment.dev/api-reference/api-types#scalars-and-enums-currencycode). + """ + code: CurrencyCode! + + """ + The ID for a custom currency. This is specified when creating the custom currency using the [createCustomCurrency](https://fragment.dev/api-reference/api-mutations#createcustomcurrency) mutation. + """ + customCurrencyId: SafeString +} + +""" +Defines the currency handling of a LedgerAccount, which can either be restricted to a single currency or allow multiple currencies. +""" +enum CurrencyMode { + multi + single +} + +input CustomAccountInput { + """ + The currency of this external account. If this is not set, the workspace level default is used. 'currency' cannot be set if 'currencyMode' is 'multi'. + """ + currency: CurrencyMatchInput + + """ + The currency mode of this external account. If set to multi, creates a multi-currency account. + """ + currencyMode: CurrencyMode + + """ + The ID of this account at the external system. This is used as the idempotency key, within the scope of its Custom Link. + """ + externalId: SafeString! + + """The name of the account at the external system.""" + name: String! +} + +"""A paginated list of Custom Currencies""" +type CustomCurrenciesConnection { + """The current page of results""" + nodes: [Currency!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +type CustomLink implements Link { + """ISO-8601 timestamp when the Link was created.""" + created: String! + + """URL to the Fragment Dashboard for this Link.""" + dashboardUrl: String! + + """A list of External Accounts associated with this Link.""" + externalAccounts: ExternalAccountsConnection! + + """FRAGMENT ID of the Custom Link.""" + id: ID! + + """Name of the Link as it appears in the Fragment Dashboard.""" + name: String! +} + +input CustomTxInput { + account: ExternalAccountMatchInput! + amount: Int96! + + """The currency of this tx. Should be set for multi-currency accounts.""" + currency: CurrencyMatchInput + description: String! + + """ + The ID of this tx at the external system. This is used as the idempotency key, within the scope of its Custom Account. + """ + externalId: SafeString! + posted: DateTime! +} + +"""ISO 8601 Date e.g. `1969-07-21`""" +scalar Date + +input DateFilter { + equalTo: Date + + """Must match one of the values provided. Limited to 100 items maximum.""" + in: [Date!] + + """ + Must fall within the given period. Supports a year (e.g. "2026") or a month (e.g. "2026-05"). To match a specific day, use `equalTo`. Cannot be combined with `withinBalanceUTCOffset`. + """ + within: PeriodFilter + + """ + Must fall within the given period, taking into account the Ledger's `balanceUTCOffset`. Supports a year (e.g. "2026") or a month (e.g. "2026-05"). Cannot be combined with `within`. + """ + withinBalanceUTCOffset: PeriodFilter +} + +""" +ISO 8601 DateTime e.g. `1969-07-16T13:32:00.000Z`. You can also provide a date e.g. `1969-01-01` and it will be converted to `1969-01-01T00:00:00.000Z` +""" +scalar DateTime + +"""Filters a timestamp field between two moments in time""" +input DateTimeFilter { + """ + The timestamp value must be after this moment. Specified in ISO 8601 format e.g "1968-01-01T16:45:00Z" + """ + after: DateTime + + """ + The timestamp value must be before this moment. Specified in ISO 8601 format e.g "1968-01-01T16:45:00Z" + """ + before: DateTime +} + +union DeleteCustomTxsResponse = BadRequestError | DeleteCustomTxsResult | InternalError + +type DeleteCustomTxsResult { + """List of Txs deleted in this operation""" + txs: [DeletedCustomTx!]! +} + +union DeleteLedgerResponse = BadRequestError | DeleteLedgerResult | InternalError + +type DeleteLedgerResult { + success: Boolean! +} + +union DeleteSchemaResponse = BadRequestError | DeleteSchemaResult | InternalError + +type DeleteSchemaResult { + success: Boolean! +} + +type DeletedCustomTx { + """A deleted Tx""" + tx: Tx! +} + +input EntryGroupMatchInput { + key: SafeString! + value: SafeString! +} + +"""Base error interface""" +interface Error { + """The status code of error. For example, 'ledger_not_found'.""" + code: String! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +type ExternalAccount { + """The currency of this external account.""" + currency: Currency + + """ + Indicates if the account allows multiple currencies or is restricted to a single currency + """ + currencyMode: CurrencyMode! + + """ID used for the external account""" + externalId: ID! + + """FRAGMENT ID of External Account""" + id: ID! + + """ + Ledger Accounts linked to this External Account. Ledger Accounts are paginated and sorted in reverse-chronological order by created date. + """ + ledgerAccounts( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Accounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Accounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerAccountsConnection! + + """The Link that this External Account belongs to.""" + link: Link! + + """FRAGMENT ID of this transaction's external link""" + linkId: ID! + name: String! + + """All Txs in this External Account.""" + txs( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of transactions to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of transactions to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): TxsConnection! +} + +input ExternalAccountFilter { + """Ledger Account must linked to the the specified external account""" + equalTo: ExternalAccountMatchInput + + """ + Ledger Account can be linked to any of the specified external accounts. Limited to 100 items maximum. + """ + in: [ExternalAccountMatchInput!] +} + +""" +Specify an External Account by using `id`, or `linkId` and `externalId`. +""" +input ExternalAccountMatchInput { + """ + The external system's ID of the External Account. If this is specified, `linkId` is required. `id` is optional, but will be validated if provided. + """ + externalId: ID + + """ + The FRAGMENT ID of the External Account. If this is specified, both `linkId` and `externalId` are optional, but will be validated if provided. + """ + id: ID + + """ + The FRAGMENT ID of the Link the External Account is in. If this is specified, `externalId` is required. `id` is optional, but will be validated if provided. + """ + linkId: ID +} + +"""A paginated list of External Accounts""" +type ExternalAccountsConnection { + """The current page of results""" + nodes: [ExternalAccount!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +enum ExternalTransferType { + ach + card + check + internal + wire +} + +enum ExternalTxSource { + increase +} + +""" +The first moment of a specific year, month or day or hour e.g. 1969 or 1969-1 or 1969-1-1 or 1969-1-1T00. All of the previous examples are equivalent to `1969-1-1T00:00:00.000`. +""" +scalar FirstMoment + +enum Granularity { + daily + hourly + monthly +} + +"""A filter to query balances of a specific subset of accounts""" +input GroupBalanceAccountFilter { + """A filter that must match the account ID""" + id: StringFilter + + """ + A filter that must match the account path. Wildcards ('*') may be used only for template variables, and will only match a single variable each. + """ + path: StringMatchFilter +} + +"""Filter for finding entries by group membership""" +input GroupFilter { + """ + Find entries that have ALL of the specified groups. Limited to 10 items maximum. + """ + all: [GroupMatchInput!] + + """Find groups that exactly match this group""" + equalTo: GroupMatchInput + + """Find groups that match any of these groups""" + in: [GroupMatchInput!] + + """Find groups with a specific key""" + keyEqualTo: SafeString + + """Find groups with any of these keys""" + keyIn: [SafeString!] + + """Find groups that do not match this predicate""" + not: GroupNotFilter @deprecated(reason: "not filter is deprecated. Use notKeyIn or notKeyEqualTo instead.") + + """Find groups that do not exactly match this group""" + notEqualTo: GroupMatchInput + + """Find groups that do not match any of these groups""" + notIn: [GroupMatchInput!] + + """Find groups that do not have a specific key""" + notKeyEqualTo: SafeString + + """Find groups that do not have any of these keys""" + notKeyIn: [SafeString!] +} + +""" +A Group in a Schema. Group define sequences of Ledger Entries and can help with reconciliation tasks. +""" +input GroupInput { + """Human-readable description of the Group.""" + description: ParameterizedString + + """ + The key of this Group. This combined with its value is a stable, unique identifier for this group. + """ + key: SafeString! + + """ + The parameters that are used to enable reconciliation abilities in a group. + """ + reconciliation: GroupReconciliationParametersInput +} + +"""Input type for matching a specific group by key and value""" +input GroupMatchInput { + """The key of the group to match""" + key: SafeString! + + """The value of the group to match""" + value: SafeString! +} + +""" +DEPRECATED: Use GroupFilter and notKeyIn or notKeyEqualTo instead. Filter for finding entries that do not match this predicate +""" +input GroupNotFilter { + """ + DEPRECATED: Find entries that are not members of all of these groups. This is an AND filter. + """ + keyIn: [SafeString!] +} + +""" +A set of parameters that are used to enable reconciliation abilities in a group +""" +input GroupReconciliationParametersInput { + """ + The path to the clearing account for this group. A clearing account is an account that is used to indicate funds that are in transit. Also called a suspense account, pending account, or zero balance account. + """ + clearingAccountPath: SchemaLedgerAccountMatchInput! +} + +"""A single amount and the timestamp requested""" +type HistoricalBalance { + """The balance or balance change""" + amount: CurrencyAmount! + + """The timestamp of the requested balance""" + at: LastMoment! +} + +"""A paginated list of amounts and their periods""" +type HistoricalBalanceConnection { + """ + The end time of the period across which the balance changes are requested + """ + endTime: LastMoment! + + """The granularity of the return data""" + granularity: Granularity! + + """The current page of results""" + nodes: [HistoricalBalance!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! + + """ + The start time of the period across which the balance changes are requested + """ + startTime: FirstMoment! +} + +type IkReplay { + ik: SafeString! + isIkReplay: Boolean! +} + +enum IncreaseEnv { + production + sandbox +} + +type IncreaseLink implements Link { + """ISO-8601 timestamp when the Link was created.""" + created: String! + + """URL to the Fragment Dashboard for this Link.""" + dashboardUrl: String! + + """A list of External Accounts associated with this Link.""" + externalAccounts: ExternalAccountsConnection! + + """FRAGMENT ID of the Increase Link.""" + id: ID! + + """The environment of the Increase Link, either sandbox or production.""" + increaseEnv: IncreaseEnv! + + """Name of the Link as it appears in the Dashboard.""" + name: String! +} + +""" +A string representing integers up to 9,223,372,036,854,775,807 (i.e. 2^63-1) +""" +scalar Int64 + +""" +A string representing integers as big as 2^120-1. The number is signed so the range is from -1,329,227,995,784,915,872,903,807,060,280,344,575 to 1,329,227,995,784,915,872,903,807,060,280,344,575. +""" +scalar Int96 + +"""A condition that must be met on an `Int96` field.""" +type Int96Condition { + """ + Amount must exactly match this value. You may not specify this alongside `gte` or `lte`. + """ + eq: Int96 + + """Amount must be greater than or equal to this value.""" + gte: Int96 + + """Amount must be less than or equal to this value.""" + lte: Int96 +} + +"""A condition that must be met on an `Int96` field.""" +input Int96ConditionInput { + """ + Amount must exactly match this value. You may not specify this alongside `gte` or `lte`. + """ + eq: Int96 + + """Amount must be greater than or equal to this value.""" + gte: Int96 + + """Amount must be less than or equal to this value.""" + lte: Int96 +} + +input Int96Filter { + """Must exactly equal this Int96 value""" + eq: Int96 + + """Must be greater than or equal to this Int96 value""" + gte: Int96 + + """Must be less than or equal to this Int96 value""" + lte: Int96 + + """Must not equal this Int96 value""" + ne: Int96 +} + +"""Equivalent to an HTTP 5XX - something went wrong with our API.""" +type InternalError implements Error { + """The status code of error. For example, 'ledger_not_found'.""" + code: String! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject + +""" +The last moment of a specific year, month or day or hour e.g. 1969 or 1969-12 or 1969-12-31 or 1969-12-31T23. All of the previous examples are equivalent to `1969-12-31T23:59:59.999`. +""" +scalar LastMoment + +"""Ledgers are databases designed for managing money""" +type Ledger { + """ + When aggregating balances, all transactions within a 24 hour period starting at midnight UTC plus this offset are included in each day. + """ + balanceUTCOffset: UTCOffset! + created: DateTime! + + """URL to the Fragment Dashboard for this Ledger.""" + dashboardUrl: String! + + """Entry statistics for this Ledger.""" + entryStats( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Entry Stats to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entry Stats to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntryStatsConnection! + id: ID! + + """ + The IK passed into the [createLedger](/api-reference/api-mutations#createledger) mutation. This is treated as a unique identifier for this Ledger. + """ + ik: SafeString! + + """Ledger Account data migrations affecting this Ledger.""" + ledgerAccountDataMigrations( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """Filter the list of Ledger Account data migrations returned.""" + filter: LedgerAccountDataMigrationsFilterSet + + """ + The number of Ledger Account Data Migrations to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Account Data Migrations to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerAccountDataMigrationConnection! + + """ + Query LedgerAccounts in Ledger. Ledger Accounts are paginated and returned in reverse-chronological order by their created date. + """ + ledgerAccounts( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + Filter the Ledger Accounts returned. Learn more about [querying Ledger Accounts](https://fragment.dev/guides/query-data/ledger-accounts). + """ + filter: LedgerAccountsFilterSet + + """ + The number of Ledger Accounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Accounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerAccountsConnection! + + """ + Query Ledger Entries in a Ledger. Ledger Entries are paginated and sorted in reverse-chronological order by posted date. + """ + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + Filter the Ledger Entries returned. Learn more about [querying Ledger Entries](https://fragment.dev/guides/query-data#ledger-entries). + """ + filter: LedgerEntriesFilterSet + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """Ledger Entry data migrations affecting this Ledger.""" + ledgerEntryDataMigrations( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """Filter the list of Ledger Entry data migrations returned.""" + filter: LedgerEntryDataMigrationsFilterSet + + """ + The number of Ledger Entry Data Migrations to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entry Data Migrations to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntryDataMigrationConnection! + + """Query a Ledger Entry Group for this Ledger given its key and value.""" + ledgerEntryGroup(ledgerEntryGroup: EntryGroupMatchInput!): LedgerEntryGroup! + + """ + Query LedgerEntryGroups in Ledger. Ledger Entry Groups are paginated and returned in order lexigraphically key then inverse chronologically by created. + """ + ledgerEntryGroups( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """Filter the Ledger Entry Groups returned.""" + filter: LedgerEntryGroupsFilterSet + + """ + The number of Ledger Entry Groups to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entry Groups to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntryGroupsConnection! + + """ + List Ledger Lines across accounts in this Ledger, sorted by `posted` in reverse chronological order. + Specify a single Ledger Account via the `ledgerAccount` field, or query across multiple accounts using the `path` filter or `ledgerAccount.in`. + """ + lines( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + Filter the Ledger Lines returned. Either the `ledgerAccount` or `path` field is required. Learn more about [querying Ledger Lines](https://fragment.dev/guides/query-data#ledger-lines). + """ + filter: LedgerLinesFilterSet! + + """ + The number of Ledger Lines to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Lines to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerLinesConnection! + + """Schema migrations affecting this Ledger.""" + migrations( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Migrations to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Migrations to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerMigrationConnection! + + """ + The name of the Ledger. Can be updated with the [updateLedger](/api-reference/api-mutations#updateledger) mutation. + """ + name: String! + + """Schema key associated with this Ledger.""" + schema: Schema + type: LedgerTypes! + workspaceId: ID! @deprecated(reason: "Callers should not need to query or store this value.") +} + +"""A ledger account is a container for money""" +type LedgerAccount { + """ + Total of all lines in this ledger account and child ledger accounts of the same currency as this ledger account + """ + balance( + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-03 or 1969-07-21T02 + """ + at: LastMoment + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The currency of the balance to query. Required if the account is a multi-currency Ledger Account or if the the Ledger Account has child Ledger Accounts with different currencies. + """ + currency: CurrencyMatchInput + ): Int96! + + """ + How much did the this ledger account's balance change during the specified period. This query will include all child accounts in the same currency as this ledger account. + """ + balanceChange( + """ + The currency of the balance change to query. Required if the account is a multi-currency Ledger Account or if the the Ledger Account has child Ledger Accounts with different currencies. + """ + currency: CurrencyMatchInput + + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + period: Period! + ): Int96! + + """ + How much did the this ledger account's balances change during the specified period. This query will include all child accounts of all currencies. + """ + balanceChanges( + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + period: Period! + ): CurrencyAmountConnection! + + """ + How much did the ledger account's balances change over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance changes for each day in the month. + """ + balanceChangesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): BalanceChangeDuringConnection! + + """ + Total of all lines in this ledger account and child ledger accounts in all currencies + """ + balances( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + at: LastMoment + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The number of currency amounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of currency amounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): CurrencyAmountConnection! + + """ + The ledger account's balances over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance for each day in the month. + """ + balancesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): HistoricalBalanceConnection! + + """ + Total of all lines in child ledger accounts of the same currency as this ledger account + """ + childBalance( + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + at: LastMoment + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The currency of the balance to query. Required if the Ledger Account has child Ledger Accounts with different currencies. + """ + currency: CurrencyMatchInput + ): Int96! + + """ + How much did the this ledger account's childBalance change during the specified period. This query will only include child accounts which are in the same currency as this one. See childBalanceChanges to include children of different currencies. + """ + childBalanceChange( + """ + The currency of the balance change to query. Required if the Ledger Account has child Ledger Accounts with different currencies. + """ + currency: CurrencyMatchInput + + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02. + """ + period: Period! + ): Int96! + + """ + How much did the this ledger account's child accounts' balances change during the specified period. This query will include all child accounts of all currencies. + """ + childBalanceChanges( + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + period: Period! + ): CurrencyAmountConnection! + + """ + How much did the ledger account's childBalances change over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance changes for each day in the month. + """ + childBalanceChangesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): BalanceChangeDuringConnection! + + """ + Total of all lines in child ledger accounts of this ledger in all currencies + """ + childBalances( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + at: LastMoment + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The number of currency amounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of currency amounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): CurrencyAmountConnection! + + """ + The ledger account's childBalances over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance for each day in the month. + """ + childBalancesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): HistoricalBalanceConnection! + + """The child Ledger Accounts of this Ledger Accountw""" + childLedgerAccounts( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Child Ledger Accounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Child Ledger Accounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerAccountsConnection! + + """ + The clearing status of the Ledger Account. + + This field is null when the Ledger Account is not configured to be a Clearing account. + """ + clearingStatus: LedgerAccountClearingStatus + + """ + The consistency configuration for this Ledger Account. This defines how updates to this Ledger Account's ownBalance are handled. + """ + consistencyConfig: LedgerAccountConsistencyConfig! + created: DateTime! + + """Currency of this ledger account""" + currency: Currency + + """ + Indicates if the account allows multiple currencies or is restricted to a single currency + """ + currencyMode: CurrencyMode! + + """URL to the Fragment Dashboard for this Ledger Account.""" + dashboardUrl: String! + id: ID! + + """The idempotency key used to create this account""" + ik: String! + + """Ledger this account is in""" + ledger: Ledger! + + """ + All Ledger Entries that have posted to this Ledger Account. Ledger Entries are paginated and sorted in reverse-chronological order by posted date. + """ + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """ID of the ledger this account is in""" + ledgerId: ID! + + """ + List Ledger Lines in this account, sorted by `posted` in reverse chronological order. Does not include Ledger Lines from child Ledger Accounts. + """ + lines( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + Filter the Ledger Lines returned. Learn more about [querying Ledger Lines](https://fragment.dev/guides/query-data#ledger-lines). + """ + filter: LedgerLinesFilterSet + + """ + The number of Ledger Lines to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Lines to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerLinesConnection! + + """ + The Link for the External Account that is linked to this ledger account + """ + link: Link + + """External Account that is linked to this ledger account""" + linkedAccount: ExternalAccount + + """The name of your Ledger Account""" + name: String + + """ + Total of all lines in this ledger account, excluding all child ledger accounts + """ + ownBalance( + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + at: LastMoment + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The currency of the balance to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + ): Int96! + + """ + How much did the this ledger account's ownBalance change during the specified period. This query will exclude all child accounts. + """ + ownBalanceChange( + """ + The currency of the balance change to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + period: Period! + ): Int96! + + """ + How much did the this ledger account's ownBalance change during the specified period. This is the total of all lines in this ledger account, excluding all child ledger accounts + """ + ownBalanceChanges( + """ + Specifies the period of time over which this query will calculate the balance difference e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + period: Period! + ): CurrencyAmountConnection! + + """ + How much did the ledger account's ownBalances change over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance changes for each day in the month. + """ + ownBalanceChangesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): BalanceChangeDuringConnection! + + """ + Total of all lines across all currencies in this ledger account, excluding all child ledger accounts + """ + ownBalances( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Provide a timestamp to get this balance at a specific logical time. If not specified, the latest value will be returned e.g. 1969 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + at: LastMoment + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + - eventual: Returns an eventually consistent balance, even if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `strong` (default). + - strong: Returns a strongly consistent balance or an error if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `eventual`. + - use_account: Returns a strongly consistent balance if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise. + """ + consistencyMode: ReadBalanceConsistencyMode + + """ + The number of currency amounts to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of currency amounts to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): CurrencyAmountConnection! + + """ + The ledger account's ownBalances over a time period with a specified granularity. + For example, if the period is 1969-07 and the granularity is daily, the query will return the balance for each day in the month. + """ + ownBalancesDuring( + """ + The currency of the balance changes to query. Required if the account is a multi-currency Ledger Account. + """ + currency: CurrencyMatchInput + + """ + The duration of the period to query in units of the granularity. The duration can be positive or negative. + Negative durations will return the balance changes for the period before the startTime. + """ + duration: Int! + + """ + The granularity of the balance changes to query. + For yearly or quarterly periods, the granularity should be "monthly", "daily", or "hourly". + For monthly periods, the granularity should be "daily" or "hourly". + For daily periods, the granularity should be "hourly". + """ + granularity: Granularity! + + """ + Specifies the start time from which to calculate the balance differences for the given duration e.g. 1969 or 1969-Q3 or 1969-07 or 1969-07-21 or 1969-07-21T02 + """ + startTime: FirstMoment! + ): HistoricalBalanceConnection! + + """The parent ledger account of this ledger account""" + parentLedgerAccount: LedgerAccount + + """ID of the parent ledger account of this ledger account""" + parentLedgerAccountId: ID + + """ + The unique Path of the ledger account. This is a slash-delimited string containing the location of an account in its chart of accounts. + For accounts created with a schema, this will be composed of account keys. Else, for accounts created with the createLedgerAccounts API, + this will be composed of the IKs of an account and its ancestors. + """ + path: String! + + """ + Payment configuration of this Ledger Account, if it is a payment account. + """ + payment: LedgerAccountPayment + + """ + The posted timestamp window for this clearing account, representing the earliest and latest + posted timestamps across all currencies. + + This field is null when the Ledger Account is not configured to be a Clearing account + or when no entries have been posted to this account. + """ + postedWindow: PostedWindow + type: LedgerAccountTypes! + + """ + A list of external account transactions that haven't been reconciled to this ledger account yet. Only populated for linked ledger accounts. Transactions are sorted in reverse chronological order by posted date. + """ + unreconciledTxs( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of unreconciled transactions to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of unreconciled transactions to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): TxsConnection! + workspaceId: ID! @deprecated(reason: "Callers should not need to query or store this value.") +} + +"""The clearing status of a Ledger Account.""" +enum LedgerAccountClearingStatus { + """The account has no outstanding balances.""" + cleared + + """The account has outstanding balances that have not been cleared.""" + pending +} + +input LedgerAccountClearingStatusFilter { + """Results must match the specified clearing account status""" + equalTo: LedgerAccountClearingStatus +} + +""" +A set of conditions that a Ledger Account must meet for an operation to succeed. +""" +type LedgerAccountCondition { + """ + A condition that the `ownBalance` field must satisfy. Note that this condition always applies to the latest balance, not to balances at a specific date or time. See [Read balances](https://fragment.dev/guides/read-balances) for more on the different types of Ledger Account balances. + """ + ownBalance: Int96Condition + + """ + A condition that the `totalBalance` field must satisfy. Note that this condition always applies to the latest balance, not to balances at a specific date or time. See [Read balances](https://fragment.dev/guides/read-balances) for more on the different types of Ledger Account balances. + """ + totalBalance: Int96Condition +} + +""" +A set of conditions that a Ledger Account must meet for an operation to succeed. +""" +input LedgerAccountConditionInput { + """ + A condition that the ownBalance field must satisfy. Note that this condition always applies to the latest balance, not to balances at a specific date or time. See [Read balances](https://fragment.dev/read-balances) for more on the different types of Ledger Account balances. + """ + ownBalance: Int96ConditionInput + + """ + A condition that the totalBalance field must satisfy. Note that this condition always applies to the latest balance, not to balances at a specific date or time. See [Read balances](https://fragment.dev/read-balances) for more on the different types of Ledger Account balances. + """ + totalBalance: Int96ConditionInput +} + +""" +The consistency configuration of a Ledger Account's balance updates. +See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +type LedgerAccountConsistencyConfig { + lines: LedgerLinesConsistencyMode! + + """ + If set to `strong`, then a Ledger Account's `ownBalance` updates will be strongly consistent with + the API response. This Ledger Account's balance will be updated and + available for strongly consistent reads once you receive an API response. + + Otherwise if not set or set to `eventual`, `ownBalance` updates are applied + asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + ownBalanceUpdates: BalanceUpdateConsistencyMode! + + """ + If set to `strong`, then a Ledger Account's `ownBalance`, `childBalance`, and `balance` fields' updates will be strongly consistent with + the API response. This Ledger Account's balance will be updated and + available for strongly consistent reads once you receive an API response. + + Otherwise if not set or set to `eventual`, updates are applied + asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + totalBalanceUpdates: BalanceUpdateConsistencyMode +} + +""" +The payload configuring the consistency for this Ledger Account. +See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +input LedgerAccountConsistencyConfigInput { + """ + The consistency configuration for Ledger Entry Groups affecting this account. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + groups: [LedgerAccountGroupConsistencyConfigInput!] + + """ + If set to `strong`, then a Ledger Account's `lines` updates will be strongly consistent with the API response. + This Ledger Account's balance will be updated and available for strongly consistent reads before you receive an API response. + + Otherwise if unset or set to `eventual`, `lines` updates are applied asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + lines: LedgerLinesConsistencyMode + + """ + If set to `strong`, then a Ledger Account's `ownBalance` updates will be strongly consistent with the API response. + This Ledger Account's balance will be updated and available for strongly consistent reads before you receive an API response. + + Otherwise if unset or set to `eventual`, `ownBalance` updates are applied asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + ownBalanceUpdates: BalanceUpdateConsistencyMode + + """ + EXPERIMENTAL: If set to `strong`, then a Ledger Account's `totalBalance` updates will be strongly consistent with the API response. + This Ledger Account's balance will be updated and available for strongly consistent reads before you receive an API response. + + Otherwise if unset or set to `eventual`, `totalBalance` updates are applied asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + totalBalanceUpdates: BalanceUpdateConsistencyMode +} + +"""Represents a data migration for a specific Ledger Account in a Ledger.""" +type LedgerAccountDataMigration implements LedgerDataMigration { + """The path of the Ledger Account being migrated.""" + accountPath: String! + + """Current active migration info (null if migration is inactive).""" + currentMigration: LedgerDataMigrationHistoryEntry + + """The historical transitions of this migration.""" + history( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Migration History to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Migration History to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerDataMigrationHistoryConnection! + + """The ledger entries to be migrated.""" + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """The status of the data migration.""" + status: LedgerDataMigrationStatus! +} + +type LedgerAccountDataMigrationConnection { + """The current page of results""" + nodes: [LedgerAccountDataMigration!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +input LedgerAccountDataMigrationsFilterSet { + """Filter by Ledger Account path.""" + accountPath: StringFilter + + """Filter by the status of the data migration.""" + status: LedgerDataMigrationStatus +} + +input LedgerAccountFilter { + """Result must match the specified Ledger Account""" + equalTo: LedgerAccountMatchInput + + """Results can match any of specified Ledger Accounts""" + in: [LedgerAccountMatchInput!] +} + +""" +The consistency configuration for a specific Ledger Entry Group in this account. +""" +input LedgerAccountGroupConsistencyConfigInput { + """The group key for this configuration.""" + key: SafeString! + + """ + If set to `strong`, then Ledger Entry Group `ownBalance`s updates for this account will be strongly consistent with the API response. + This Ledger Account's Ledger Entry Group balances will be updated and available for strongly consistent reads before you receive an API response. + + Otherwise if unset or set to `eventual`, Ledger Entry Group `ownBalance` updates are applied asynchronously and may not be immediately reflected in queries. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + ownBalanceUpdates: BalanceUpdateConsistencyMode! +} + +""" +Specify a Ledger Account by using `id` or `path`. + +When specifying a Ledger Account by `path`, you must provide `ledger`. +""" +input LedgerAccountMatchInput { + """The FRAGMENT ID of the Ledger Account""" + id: ID + + """ + The Ledger to which this Ledger Account belongs. This is required if you are specifying the Ledger Account by `path`. + """ + ledger: LedgerMatchInput + + """ + The unique path of the Ledger Account. + This is a slash-delimited string containing the keys of an account and all its direct ancestors. + """ + path: String +} + +"""Payment configuration of a Ledger Account.""" +type LedgerAccountPayment { + penguin: Boolean! +} + +input LedgerAccountTypeFilter { + """Results must be of the specified Ledger Account type""" + equalTo: LedgerAccountTypes + + """Results can have any of the specified Ledger Account types""" + in: [LedgerAccountTypes!] +} + +enum LedgerAccountTypes { + asset + expense + income + liability +} + +"""A paginated list of Ledger Accounts""" +type LedgerAccountsConnection { + """The current page of results""" + nodes: [LedgerAccount!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +input LedgerAccountsFilterSet { + """Use this to filter Ledger Accounts by their clearing account status""" + clearingStatus: LedgerAccountClearingStatusFilter + + """ + Filter by the earliest posted timestamp across all currencies for clearing accounts. You must also provide clearingStatus in the same filter. + Only clearing accounts where the minimum posted timestamp (across all currencies) matches this filter will be included. + """ + earliestPosted: DateTimeFilter + + """Use this to filter Ledger Accounts by their parent status""" + hasParentLedgerAccount: Boolean + + """Use this to filter Ledger Accounts by their linked status""" + isLinkedAccount: Boolean + + """ + Filter by the latest posted timestamp across all currencies for clearing accounts. You must also provide clearingStatus in the same filter. + Only clearing accounts where the maximum posted timestamp (across all currencies) matches this filter will be included. + """ + latestPosted: DateTimeFilter + + """Use this to filter Ledger Accounts by their ID or path""" + ledgerAccount: LedgerAccountFilter + + """Use this to filter Ledger Accounts by their external linked account ID""" + linkedAccount: ExternalAccountFilter + + """Use this to filter Ledger Accounts by their parent account IDs""" + parentLedgerAccount: LedgerAccountFilter + + """ + A filter that must match the account path. Wildcards ('*') may be used only for template variables, and will only match a single variable each. + For example: 'assets-root/accounts-receivable/merchant:*' would match: 'assets-root/accounts-receivable/merchant:1' and 'assets-root/accounts-receivable/merchant:1/child'. + Wildcards may not be used outside of template variables. For example, passing in 'assets-root/*' as a filter is invalid and would raise a GraphQL error. + """ + path: StringMatchFilter + + """Use this to filter Ledger Accounts by their type""" + type: LedgerAccountTypeFilter +} + +"""Represents a data migration for a Ledger.""" +interface LedgerDataMigration { + """Current active migration info (null if migration is inactive).""" + currentMigration: LedgerDataMigrationHistoryEntry + + """The historical transitions of this migration.""" + history( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Migration History to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Migration History to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerDataMigrationHistoryConnection! + + """The ledger entries to be migrated.""" + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """The status of the data migration.""" + status: LedgerDataMigrationStatus! +} + +"""A paginated list of migration history entries.""" +type LedgerDataMigrationHistoryConnection { + """The current page of results""" + nodes: [LedgerDataMigrationHistoryEntry!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +"""A single schema version in the migration history.""" +type LedgerDataMigrationHistoryEntry { + """The schema version.""" + schemaVersion: Int! + + """ + The current status of this schema version (active if it's the latest and migration is active, otherwise inactive). + """ + status: LedgerDataMigrationStatus! +} + +"""The status of a ledger data migration.""" +enum LedgerDataMigrationStatus { + """The migration is active.""" + active + + """The migration is inactive.""" + inactive +} + +"""A paginated list of Ledger Entries""" +type LedgerEntriesConnection { + """The current page of results""" + nodes: [LedgerEntry!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +input LedgerEntriesFilterSet { + """Use this filter to filter Ledger Entries by their `posted` date.""" + date: DateFilter + + """ + Use this to filter Ledger Entries by groups. The response will include entries that contain or do not contain specific groups. + """ + group: GroupFilter + + """ + Use this to filter Ledger Entries that were posted using `reverseLedgerEntry`. + """ + isReversal: Boolean + + """Use this to filter Ledger Entries that have been reversed.""" + isReversed: Boolean + + """Use to filter Ledger Entries by their IDs or IKs.""" + ledgerEntry: LedgerEntryFilter + + """Use this filter to filter Ledger Entries by their `posted` timestamp.""" + posted: DateTimeFilter + + """Use this filter to show hidden Ledger Entries.""" + showHidden: Boolean + + """ + Use this to filter Ledger Entries by tags. The response will include entries that contain tags matching the filter. + """ + tag: TagFilter + + """ + Use this to filter Ledger Entries by type. Ledger Entry types are defined in Schemas. + """ + type: StringFilter + + """Use this to filter Ledger Entries by their type version.""" + typeVersion: StringFilter +} + +type LedgerEntry { + """ + The conditions that were satisfied by this Ledger Entry when it was posted. + """ + conditions: [LedgerEntryCondition!]! + + """ISO-8601 timestamp this LedgerEntry was created in Fragment.""" + created: DateTime! + + """URL to the Fragment Dashboard for this Ledger Entry.""" + dashboardUrl: String! + + """Date this LedgerEntry posted to its Ledger e.g. "2021-01-01".""" + date: Date! + + """Description posted for this Ledger Entry.""" + description: String + + """The Ledger Entry Groups this Ledger Entry is in.""" + groups: [LedgerEntryGroup!]! + + """ + Indicates whether this Ledger Entry is hidden when listing Ledger Entries. + Reversed and Reversal Ledger Entries are hidden by default because taken together they have no impact on a Ledger's balances. + """ + hidden: Boolean! + + """The ID of this LedgerEntry.""" + id: ID! + + """The idempotency key used to post this ledger entry""" + ik: String! + + """ + Indicates whether this Ledger Entry is a reversal of another Ledger Entry. + If so, reverses will point to that Ledger Entry. + """ + isReversal: Boolean! + + """ + Indicates whether this Ledger Entry has been reversed by another Ledger Entry. + If so, reversedBy will point to that Ledger Entry. + """ + isReversed: Boolean! + + """The Ledger that this Ledger Entry is posted to.""" + ledger: Ledger! + + """The ID of the Ledger this Ledger Entry is posted to.""" + ledgerId: ID! + + """Lines posted in this Ledger Entry.""" + lines: LedgerLinesConnection! + + """The parameters used to post this Ledger Entry.""" + parameters: Parameters + + """ISO-8601 timestamp this LedgerEntry posted to its Ledger.""" + posted: DateTime! + + """ + The reversal history of this Ledger Entry. Each entry in this connection shares the same IK. + """ + reversalHistory: LedgerEntriesConnection! + + """ + The position of this Ledger Entry in its reversalHistory. This is a one-indexed value, so the initial entry will have reversalPosition 1. + """ + reversalPosition: Int! + + """ISO-8601 timestamp of when this Ledger Entry was reversed.""" + reversedAt: DateTime + + """The Ledger Entry that reversed this Ledger Entry.""" + reversedBy: LedgerEntry + + """The Ledger Entry that was reversed by this Ledger Entry.""" + reverses: LedgerEntry + + """The set of tags attached to this Ledger Entry.""" + tags: [LedgerEntryTag!]! + + """The type of the Ledger Entry.""" + type: SafeString + + """The version of the Ledger Entry type used when it was posted.""" + typeVersion: Int + workspaceId: ID! @deprecated(reason: "Callers should not need to query or store this value.") +} + +""" +A set of pre-conditions and post-conditions that a Ledger Account must have satisfied. Each `LedgerEntryCondition` has at least one of `precondition` or `postcondition`. +""" +type LedgerEntryCondition { + """The Ledger Account that must satisfied the provided conditions.""" + account: LedgerAccount! + + """ + The currency of the balance associated with this `LedgerEntryCondition`. + """ + currency: Currency! + + """The conditions that must be satisfied after the operation.""" + postcondition: LedgerAccountCondition + + """The conditions that must be satisfied prior to the operation.""" + precondition: LedgerAccountCondition +} + +""" +A set of pre-conditions and post-conditions that a Ledger Account balance must meet for an operation to succeed. You must specify at least one of `precondition` or `postcondition` for each condition. +""" +input LedgerEntryConditionInput { + """The Ledger Account that must satisfy the provided conditions.""" + account: LedgerAccountMatchInput! + + """ + For Ledger Accounts in the `multi` currency mode, you must specify the currency of the balance affected by the condition. You only need to specify this field for multi-currency accounts. + """ + currency: CurrencyMatchInput + + """The conditions that must hold after the operation.""" + postcondition: LedgerAccountConditionInput + + """The conditions that must hold prior to the operation.""" + precondition: LedgerAccountConditionInput +} + +"""Represents a data migration for a specific entry type in a Ledger.""" +type LedgerEntryDataMigration implements LedgerDataMigration { + """Current active migration info (null if migration is inactive).""" + currentMigration: LedgerDataMigrationHistoryEntry + + """The entry type being migrated.""" + entryType: SafeString! + + """The historical transitions of this migration.""" + history( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Migration History to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Migration History to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerDataMigrationHistoryConnection! + + """The ledger entries to be migrated.""" + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """The status of the data migration.""" + status: LedgerDataMigrationStatus! + + """The version of the entry type being migrated.""" + typeVersion: Int! +} + +type LedgerEntryDataMigrationConnection { + """The current page of results""" + nodes: [LedgerEntryDataMigration!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +input LedgerEntryDataMigrationsFilterSet { + """Filter by Ledger Entry type.""" + entryType: StringFilter + + """Filter by the status of the data migration.""" + status: LedgerDataMigrationStatus + + """Filter by Ledger Entry type version.""" + typeVersion: StringFilter +} + +input LedgerEntryFilter { + """Result must be the specified Ledger Entry.""" + equalTo: LedgerEntryMatchInput + + """ + Result can be any of the specified Ledger Entries. Limited to 100 items maximum. + """ + in: [LedgerEntryMatchInput!] +} + +"""A group of Ledger Entries""" +type LedgerEntryGroup { + balances( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + filter: LedgerEntryGroupBalanceFilterSet + + """ + The number of group balances to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of group balances to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntryGroupBalanceConnection! + + """ISO-8601 timestamp this LedgerEntryGroup was created in Fragment.""" + created: DateTime + + """URL to the Fragment Dashboard for this Ledger Entry Group.""" + dashboardUrl: String! + + """The key of this Ledger Entry Group.""" + key: SafeString! + + """The Ledger that this Ledger Entry Group is within.""" + ledger: Ledger! + ledgerEntries( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + filter: LedgerEntriesFilterSet + + """ + The number of Ledger Entries to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledger Entries to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgerEntriesConnection! + + """The ID of the Ledger this Ledger Entry Group is within.""" + ledgerId: ID! + + """The value associated with Ledger Entry Group.""" + value: SafeString! +} + +""" +Represents the total effect of a Ledger Entry Group on a Ledger Account balance for a single currency. +""" +type LedgerEntryGroupBalance { + """The Ledger Account whose balance is affected.""" + account: LedgerAccount! + + """The currency of the affected balance.""" + currency: Currency! + + """The total balance change for this Ledger Account and currency.""" + ownBalance( + """ + The consistency mode to use when fetching the balance. Use 'use_account' to match the configured consistency mode of the account. + """ + consistencyMode: ReadBalanceConsistencyMode + ): Int96! +} + +"""A set of balance changes for a specific Ledger Entry Group.""" +type LedgerEntryGroupBalanceConnection { + nodes: [LedgerEntryGroupBalance!]! + pageInfo: PageInfo! +} + +""" +Filter Ledger Entry Groups by their balance impact on a Ledger Account. If a group has a matching balance for the specified account, the group will be included in the results. +""" +input LedgerEntryGroupBalanceFilter { + """ + A Ledger Entry Group will be included in the result if it has a balance for the specified account. If 'account' is the only filter specified, then any non-null balance in any currency will match. + """ + account: GroupBalanceAccountFilter! + + """ + A Ledger Entry Group will be included in the result if it has a balance for the specified account in the specified currency. If the 'ownBalance' filter is omitted then any non-null balance will match. + """ + currency: CurrencyFilter + + """ + A Ledger Entry Group will be included in the result if it has a balance for the specified account that passes the specified value predicate. If the 'currency' filter is omitted then any balance in any currency that passes the predicate will match. If the 'currency' filter is included, the value predicate will only be evaluated against the specified currency. + """ + ownBalance: Int96Filter +} + +"""Optional filters for querying balances on a Ledger Entry Group.""" +input LedgerEntryGroupBalanceFilterSet { + """Filter to a subset of accounts""" + account: GroupBalanceAccountFilter + + """Filter to one or more currencies""" + currency: CurrencyFilter + + """Filter to only balances in a certain range""" + ownBalance: Int96Filter +} + +input LedgerEntryGroupInput { + """The key of this group. Can be up to 128 characters long.""" + key: SafeString! + + """ + The value associated with this group's key. Can be up to 128 characters long. + """ + value: SafeString! +} + +input LedgerEntryGroupMatchInput { + key: SafeString! + ledger: LedgerMatchInput! + value: SafeString! +} + +"""A paginated list of Ledger Entry Groups""" +type LedgerEntryGroupsConnection { + """The current page of results""" + nodes: [LedgerEntryGroup!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +input LedgerEntryGroupsFilterSet { + """ + Filter Ledger Entry Groups by their balance impact on a Ledger Account. If a group has a matching balance for the specified account, the group will be included in the results. + """ + balance: LedgerEntryGroupBalanceFilter + + """Use to filter Ledger Entry Groups by their created timestamp""" + created: DateTimeFilter + + """Use to filter Ledger Entry Groups by their key""" + key: StringFilter + + """Use to filter Ledger Entry Groups by their value""" + value: StringFilter +} + +"""Ledger Entries are limited to 30 Ledger Lines.""" +input LedgerEntryInput { + """ + Conditions that must be satisfied to post this Ledger Entry. The Ledger Entry will reject with a BadRequestError if any condition is not met. You can only add a condition on a Ledger Account containing a Line in this Ledger Entry. + """ + conditions: [LedgerEntryConditionInput!] + + """ + If specified, will also be used as the description for LedgerLines unless they specify their own description. + """ + description: String + + """Adds this Ledger Entry to this set of Ledger Entry Groups""" + groups: [LedgerEntryGroupInput!] + + """ + The Ledger to which to post this Ledger Entry. Must be linked to a Schema that defines the provided Ledger Entry type. + """ + ledger: LedgerMatchInput + + """ + The Ledger Lines to create as part of this Ledger Entry. This cannot be used with Ledger Entries that have a 'type' i.e. Ledger Entries defined in the Schema. This can be useful during non-routine operations such as an incident. It is not recommended to use 'lines' during routine operations. + """ + lines: [LedgerLineInput!] + + """ + Parameters to be included in a templated Ledger Entry. All provided parameters must be present in the typed Ledger Entry within the Schema linked to the provided Ledger. + """ + parameters: JSON + + """ + ISO 8601 timestamp to post this Ledger Entry e.g. "2021-01-01" or "2021-01-01T16:45:00Z". Will error out if supplied to reconcileTx or createOrder since the transaction timestamp will be used instead + """ + posted: DateTime + + """A set of tags attached to this Ledger Entry.""" + tags: [LedgerEntryTagInput!] + + """ + The type of the Ledger Entry. Must be defined in the Schema linked to the Ledger specified below. + """ + type: String + + """ + Experimental: This field is reserved for an upcoming feature and is not yet supported. + """ + typeVersion: Int +} + +"""Specify a Ledger Entry by using `id`.""" +input LedgerEntryMatchInput { + """The FRAGMENT ID of the Ledger Entry""" + id: ID + + """ + The IK provided to the `addLedgerEntry` mutation or the `ik` field returned from a `reconcileTx` mutation. This is required if you have not provided `id`. + """ + ik: SafeString + + """ + The FRAGMENT ID of the Ledger to which this Ledger Entry belongs. This is required if you have not provided `id`. + """ + ledger: LedgerMatchInput +} + +""" +Posting count statistics for a specific type and typeVersion of entry in a Ledger. +""" +type LedgerEntryStats { + """The total number of entries of this type.""" + count: Int96! + + """The ledger ID these stats are for.""" + ledgerId: SafeString! + + """The net number of entries (count - reversalsCount).""" + netCount: Int96! + + """The number of entries that are reversals.""" + reversalsCount: Int96! + + """The schema key associated with these stats.""" + schemaKey: SafeString! + + """The type of entry these stats are for.""" + type: SafeString! + + """The version of the entry type these stats are for.""" + typeVersion: Int! +} + +"""A paginated list of Ledger Entry Stats""" +type LedgerEntryStatsConnection { + """The current page of results""" + nodes: [LedgerEntryStats!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +"""A tag attached to a Ledger Entry.""" +type LedgerEntryTag { + """The key of this tag.""" + key: SafeString! + + """The value associated with this tag's key.""" + value: SafeString! +} + +input LedgerEntryTagInput { + """The key of this tag. Can be up to 128 characters long.""" + key: SafeString! + + """ + The value associated with this tag's key. Can be up to 128 characters long. + """ + value: SafeString! +} + +type LedgerLine { + """LedgerAccount that contains this line""" + account: LedgerAccount! + accountId: ID! + + """ + How much this line's LedgerAccount's balance changed in integer cents (i.e. in USD 100 is 1 dollar, 100 cents) + """ + amount( + """ + If the absolute flag is passed, amount will always a positive integer in cents. Refer to type to see if this LedgerLine increased or decreased it's LedgerAccount's balance + """ + absolute: Boolean + ): Int96! + + """ISO-8601 timestamp this LedgerLine was created in Fragment""" + created: DateTime + + """Currency of this LedgerLine""" + currency: Currency + + """ + Date this LedgerLine posted to its LedgerAccount e.g. "2021-01-01" + """ + date: Date + + """Description of this LedgerLine""" + description: String + + """ + ID in the external system of the payment or transfer that created the transaction linked to this LedgerLine + """ + externalTransferId: String + + """ + Whether the transaction linked to this LedgerLine was a payment or transfer + """ + externalTransferType: ExternalTransferType + + """ID in the external system of the transaction linked to this LedgerLine""" + externalTxId: String + + """ + Indicates whether this Ledger Line is hidden when listing Ledger Lines. + Reversed and Reversal Ledger Lines are hidden by default because taken together they have no impact on a Ledger Account's balance + """ + hidden: Boolean! + id: ID! + + """ + Indicates whether this Ledger Line is a reversal of another Ledger Line. + If so, reverses will point to that Ledger Line. + """ + isReversal: Boolean! + + """ + Indicates whether this Ledger Line has been reversed by another Ledger Line. + If so, reversedBy will point to that Ledger Line. + """ + isReversed: Boolean! + key: String + ledger: Ledger! + + """LedgerEntry that contains this line""" + ledgerEntry: LedgerEntry! + + """ID of the LedgerEntry that contains this line""" + ledgerEntryId: ID! + + """Ledger that contains this line""" + ledgerId: ID! + + """ + ID in the external system of destination or source bank account for an internal bank transfer. Only for internal bank transfers - see otherTxId + """ + otherTxExternalAccountExternalId: String + + """ + FRAGMENT ID of destination or source bank account. Only for internal bank transfers - see otherTxId + """ + otherTxExternalAccountId: String + + """ + ID in the external system of transaction in the destination or source bank account. Only for internal bank transfers - see otherTxId + """ + otherTxExternalId: String + + """ + FRAGMENT ID of the transaction in the destination account (if sending money from this account) or source account (if pulling money into this account). Only applicable if this line is linked to a transaction created through an internal transfer + """ + otherTxId: String + + """ISO-8601 timestamp this LedgerLine posted to its LedgerAccount""" + posted: DateTime + + """ISO-8601 timestamp of when this Ledger Line was reversed.""" + reversedAt: DateTime + + """The Ledger Line that reverses the balance changes of this Ledger Line.""" + reversedBy: LedgerLine + + """ + The Ledger Line whose balance changes are reversed by this Ledger Line. + """ + reverses: LedgerLine + + """Tags attached to this Ledger Line.""" + tags: [LedgerLineTag!]! + + """The transaction linked to this LedgerLine""" + tx: Tx + + """Fragment ID of the transaction linked to this LedgerLine""" + txId: String + + """credit or debit""" + type: TxType! + workspaceId: ID! @deprecated(reason: "Callers should not need to query or store this value.") +} + +input LedgerLineInput { + """The LedgerAccount this line is being added to""" + account: LedgerAccountMatchInput! + + """ + A positive amount increases the balance of its LedgerAccount, a negative amount reduces the balance of its LedgerAccount + """ + amount: Int96 + + """The currency the ledger line is in""" + currency: CurrencyMatchInput + + """ + If not specified the description from the parent LedgerEntryInput will be used + """ + description: String + + """ + Optional identifier for Ledger Line. You can filter lines by key using [LedgerLinesFilterSet](https://fragment.dev/api-reference/api-types#filter-types-ledgerlinesfilterset). + """ + key: String + + """A set of tags attached to this Ledger Line.""" + tags: [LedgerEntryTagInput!] + + """ + Required for reconcileTx to specify the transaction being reconciled, you can specify either the FRAGMENT ID or external ID of the transaction + """ + tx: TxMatchInput +} + +"""Specify a Ledger Line by using `id`.""" +input LedgerLineMatchInput { + """The FRAGMENT ID of the ledger line""" + id: ID! +} + +"""A tag attached to a Ledger Line.""" +type LedgerLineTag { + """The key of this tag.""" + key: SafeString! + + """The value associated with this tag's key.""" + value: SafeString! +} + +"""A paginated list of Ledger Lines""" +type LedgerLinesConnection { + """The current page of results""" + nodes: [LedgerLine!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +enum LedgerLinesConsistencyMode { + eventual + strong +} + +input LedgerLinesFilterSet { + """ + Filter by the created timestamp of the Ledger Line. This is the wall-clock time when the Ledger Line was created. + """ + created: DateTimeFilter + + """Filter by the currency of the Ledger Line.""" + currency: CurrencyFilter + + """Use this filter to filter Ledger Lines by their `posted` date.""" + date: DateFilter + + """ + Use this to filter Ledger Lines that were posted to this Ledger Account, using `reverseLedgerEntry`. + """ + isReversal: Boolean + + """Use this to filter Ledger Lines that have been reversed.""" + isReversed: Boolean + + """ + Use this to filter Ledger Lines by key. Ledger Line keys are defined in Schemas. + """ + key: StringFilter + + """ + Specify which Ledger Account to read lines from. Required when querying lines via `Ledger.lines` without a `path` filter. Not allowed when querying via `LedgerAccount.lines`. + """ + ledgerAccount: LedgerAccountFilter + + """ + A filter that string matches the account path. Wildcards ('*') can be used to return lines across multiple accounts. + To search for all instances of a a Ledger Account template, use the `matches` filter with an wildcard character in place of the template value e.g. `assets/user:*`. This returns lines from all instances of this template, interleaved by `posted` timestamp. + To search for all descendant Ledger Accounts under a given path, use a trailing `/*` in the `matches` filter e.g. `assets/user:user-1>/*`. This returns lines from all descendants at any depth, but not lines from the parent account at `assets/user:user-1>`. + To OR multiple `matches` patterns and get a single paginated list, use `matchesAny` — e.g. `matchesAny: ["assets/user:user-1/*", "assets/user:user-2/*"]` returns descendants of both prefixes interleaved by `posted` timestamp. + Cannot be combined with `ledgerAccount` filter. Not allowed when querying via `LedgerAccount.lines`. You cannot use wildcards for both descendant and template instance matching in the same query. + """ + path: StringMatchFilter + + """Use this filter to filter Ledger Lines by their `posted` timestamp.""" + posted: DateTimeFilter + + """Use this filter to find hidden Ledger Lines.""" + showHidden: Boolean + + """ + Filter Ledger Lines by tag. Only matches lines that have the specified tags attached directly to them. + """ + tag: TagFilter + type: TxTypeFilter +} + +"""Specify a Ledger by using `id` or `ik`.""" +input LedgerMatchInput { + """The FRAGMENT ID of the Ledger""" + id: ID + + """ + The IK passed into the [createLedger](/api-reference/api-mutations#createledger) mutation. This is treated as a second unique identifier for this Ledger. + """ + ik: SafeString +} + +""" +Represents a Schema being applied to a Ledger. +It contains metadata about the Ledger, the Schema, and the status of the migration. +""" +type LedgerMigration { + """The Ledger that the migration is run on.""" + ledger: Ledger! + schemaVersion: SchemaVersion! + + """The status of the Ledger Migration.""" + status: LedgerMigrationStatus! +} + +"""A paginated list of Ledger Migrations""" +type LedgerMigrationConnection { + """The current page of results""" + nodes: [LedgerMigration!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +"""The status of a ledger migration.""" +enum LedgerMigrationStatus { + """ + The Ledger Migration has been successfully completed. + This is a terminal state. + """ + completed + + """ + The Ledger Migration has failed. + This can happen either due to an invalid schema or an internal error. + This is a terminal state. + """ + failed + + """The Ledger Migration has been queued.""" + queued + + """ + The Ledger Migration has been skipped because a newer version is available. + This is a terminal state. + """ + skipped + + """The Ledger Migration has been started.""" + started +} + +input LedgerTypeFilter { + equalTo: LedgerTypes + + """Must match one of the values provided. Limited to 100 items maximum.""" + in: [LedgerTypes!] +} + +enum LedgerTypes { + double +} + +"""A paginated list of Ledgers""" +type LedgersConnection { + """The current page of results""" + nodes: [Ledger!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +input LedgersFilterSet { + hasSchema: Boolean + type: LedgerTypeFilter +} + +interface Link { + """ISO-8601 timestamp when the Link was created.""" + created: String! + + """URL to the Fragment Dashboard for this Link.""" + dashboardUrl: String! + + """A list of External Accounts associated with this Link.""" + externalAccounts: ExternalAccountsConnection! + + """FRAGMENT ID of the Link.""" + id: ID! + + """Name of the Link as it appears in the Dashboard.""" + name: String! +} + +input LinkMatchInput { + id: ID! +} + +"""The type of Link an external account belongs to.""" +enum LinkType { + """A Custom Link""" + CustomLink + + """An Increase Link""" + IncreaseLink + + """A Stripe Link""" + StripeLink + + """A Unit Link""" + UnitLink +} + +"""A paginated list of Links""" +type LinksConnection { + """The current page of results""" + nodes: [Link!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +"""An object defining the input for migrating a Ledger Entry.""" +input MigrateLedgerEntryInput { + """The Ledger Entry to migrate""" + id: ID! + + """The Ledger Entry you want to migrate it to""" + newLedgerEntry: LedgerEntryInput! +} + +union MigrateLedgerEntryResponse = BadRequestError | InternalError | MigrateLedgerEntryResult + +type MigrateLedgerEntryResult { + """Whether this migration was an IK replay or not""" + isIkReplay: Boolean! + + """The new Ledger Entry posted as a result of the migration""" + newLedgerEntry: LedgerEntry! + + """The Ledger Entry that was migrated""" + reversedLedgerEntry: LedgerEntry! + + """ + The reversal Ledger Entry that was posted to reverse the Ledger Entry being migrated + """ + reversingLedgerEntry: LedgerEntry! +} + +""" +View the API guide [here](https://fragment.dev/api-reference/api-mutations) +""" +type Mutation { + _empty: String + + """ + Batch version of [addLedgerEntry](http://localhost:3001/api-reference/ledger-mutations#addledgerentry). + + Adds a batch of Ledger Entries in one synchronous and atomic transaction. Either every entry is added or none are. + """ + addLedgerEntries( + """ + The Ledger Entries to post, each with its own [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency). + """ + entries: [AddLedgerEntryInput!]! + ): AddLedgerEntriesResponse! + + """ + Adds a Ledger Entry to a Ledger. This Ledger Entry cannot be into a Linked Ledger Account. For that, use [reconcileTx](https://fragment.dev/api-reference/api-mutations#reconciletx) + """ + addLedgerEntry( + """ + An object containing the [Ledger Lines](https://fragment.dev/api-reference/api-types#input-types-ledgerlineinput) as well as an optional description and posted timestamp. + """ + entry: LedgerEntryInput! + + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) + """ + ik: SafeString! + ): AddLedgerEntryResponse! + + """Creates a custom currency. """ + createCustomCurrency( + """The custom currency to be created.""" + customCurrency: CreateCustomCurrencyInput! + ): CreateCustomCurrencyResponse! + + """ + Custom Links let you integrate external systems that don't have native support. See [Custom Links](https://fragment.dev/guides/sync-payments#custom-link) + """ + createCustomLink( + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) + """ + ik: SafeString! + + """The name of your custom link""" + name: String! + ): CreateCustomLinkResponse! + + """Creates a Ledger. """ + createLedger( + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) + """ + ik: SafeString! + + """The payload representing the Ledger to be created""" + ledger: CreateLedgerInput! + + """The Schema to create this Ledger with""" + schema: SchemaMatchInput + ): CreateLedgerResponse! + createLedgerAccount( + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) + """ + ik: SafeString! + + """ + An object containing the ID of the ledger under which to create the ledger account + """ + ledger: LedgerMatchInput! + + """The payload representing the ledger account to be created""" + ledgerAccount: CreateLedgerAccountInput! + ): CreateLedgerAccountResponse! + + """ + This API call is used to create Ledger Accounts. It is only used if you are not using a Schema. Unlike other mutations that take a single IK, 'createLedgerAccount' accepts an IK for each of the ledger accounts in the request payload. This is so you can recover in the case of a partial failure. One API call can create up to 200 Ledger Accounts, up to 10 levels deep. + """ + createLedgerAccounts( + """ + An object containing the ID of the Ledger under which to create the Ledger Account. + """ + ledger: LedgerMatchInput! + + """The list of objects representing the Ledger Accounts to be created.""" + ledgerAccounts: [CreateLedgerAccountsInput!]! + ): CreateLedgerAccountsResponse! + + """ + EXPERIMENTAL — subject to change. + + Create a Payment. + """ + createPayment( + """The amount in cents, between 100 and 10000000 inclusive.""" + amount: Int96! + + """ + The [Idempotency Key](https://fragment.dev/api-reference/api-overview#idempotency) + """ + ik: SafeString! + + """The Ledger this Payment belongs to.""" + ledger: LedgerMatchInput! + ): CreatePaymentResponse! + + """ + Delete Txs on a Custom Link. Once deleted, a Tx will not show up in listing queries, but can be resolved by if you lookup by its Fragment ID. + """ + deleteCustomTxs( + """The Fragment IDs of the Txs to delete""" + txs: [ID!]! + ): DeleteCustomTxsResponse! + + """ + Delete a Ledger. + + After using the deleteLedger mutation you can re-use the ik in a new ledger after a 30 second wait. + """ + deleteLedger(ledger: LedgerMatchInput!): DeleteLedgerResponse! + + """Delete a Schema""" + deleteSchema(schema: SchemaMatchInput!): DeleteSchemaResponse! + + """ + Migrate an existing Ledger Entry to a new type and typeVersion. + + Migrating a Ledger Entry will do the following: + 1. Reverse the existing Ledger Entry + 2. Post a new Ledger Entry with the new type, typeVersion, and parameters provided + """ + migrateLedgerEntry(input: MigrateLedgerEntryInput!): MigrateLedgerEntryResponse! + + """ + This mutation is used to [reconcile](https://fragment.dev/guides/reconcile-payments#reconcile-a-tx) transactions from an external system into a Ledger Entry. This mutation does not require an idempotency key since a transaction can only be reconciled once per Linked Ledger Account. If you are reconciling a transfer between two Link Accounts which are both linked to the same Ledger, use a transit account in between to split the transfer into two `reconcileTx` calls. + """ + reconcileTx( + """ + The ledger entry containing lines that specify the transaction from a linked ledger account to reconcile, as well + as the ledger account with which to offset the external transaction. + """ + entry: LedgerEntryInput! + ): ReconcileTxResponse! + + """Reverses a Ledger Entry""" + reverseLedgerEntry( + """The Fragment ID of the Ledger Entry to reverse""" + id: ID! + ): ReverseLedgerEntryResponse! + + """ + Stores a Schema in your workspace. If no Schema with the same key exists in your worksapce, a new Schema is created. + Else, the Schema is updated, and every Ledger associated with it is migrated to the latest version. + """ + storeSchema( + """The Schema to store.""" + schema: SchemaInput! + ): StoreSchemaResponse! + + """ + Once you've created a [Custom Link](https://fragment.dev/guides/sync-payments#custom-link), create accounts under it using this mutation. Each Custom Account is an immutable, single-entry view of all the transactions in the external account. You can sync up to 100 Custom Accounts in one API call. + """ + syncCustomAccounts( + """A list of external accounts to sync""" + accounts: [CustomAccountInput!]! + + """An object containing the ID of a Custom Link.""" + link: LinkMatchInput! + ): SyncCustomAccountsResponse! + + """ + You can create transactions under a Custom Account in a [Custom Link](https://fragment.dev/guides/sync-payments#custom-link) using this mutation. Once you've imported transactions, you can use the reconcileTx mutation to add them to a Ledger via the Linked Ledger Account. You can sync up to 100 Custom Transactions in one API call. + """ + syncCustomTxs( + """An object containing the ID of a Custom Link.""" + link: LinkMatchInput! + + """A list of external transactions to sync""" + txs: [CustomTxInput!]! + ): SyncCustomTxsResponse! + + """Updates a Ledger. Currently, you can change only the Ledger 'name'.""" + updateLedger( + """An object containing the ID of the Ledger to update.""" + ledger: LedgerMatchInput! + + """ + A payload of fields to update. Currently, you can change only the Ledger 'name'. + """ + update: UpdateLedgerInput! + ): UpdateLedgerResponse! + + """Updates a ledger account. Only supports name right now.""" + updateLedgerAccount( + """The Ledger Account that is being updated""" + ledgerAccount: LedgerAccountMatchInput! + + """ + The payload containing the fields to update. Currency, only the name can be updated. + """ + update: UpdateLedgerAccountInput! + ): UpdateLedgerAccountResponse! + + """Update a ledger entry""" + updateLedgerEntry( + """The Ledger Entry that is being updated""" + ledgerEntry: LedgerEntryMatchInput! + + """ + The payload containing the fields to update. Only a Ledger Entry's tags can be updated. + """ + update: UpdateLedgerEntryInput! + ): UpdateLedgerEntryResponse! +} + +"""Equivalent to an HTTP 404""" +type NotFoundError implements Error { + """The status code of error. For example, 'ledger_not_found'.""" + code: String! + + """The error message""" + message: String! + + """Whether or not the operation is retryable""" + retryable: Boolean! +} + +""" +An object containing [pagination](https://fragment.dev/guides/query-data#basics-pagination) details. +""" +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +""" +A string of non-zero length that can contain parameterized values via handlebars syntax. ex: `"Hello from {{country}}"`. +""" +scalar ParameterizedString + +"""A mapping of parameter keys to values.""" +scalar Parameters + +type Payment { + """ + The secret handed to the payments SDK to render the payment method capture. + """ + clientSecret: String! + + """The status of this Payment.""" + status: PaymentStatus! +} + +""" +EXPERIMENTAL — subject to change. + +Status of a Payment. +""" +enum PaymentStatus { + processing + requires_confirmation + settled +} + +""" +A specific year ("2021"), quarter ("2021-Q1"), month ("2021-02"), day ("2021-02-03") or hour ("2021-02-03T04") +""" +scalar Period + +""" +A specific year ("2026") or month ("2026-05") used to match dates that fall within it. +""" +scalar PeriodFilter + +""" +Controls how lines are posted for a Ledger Entry. +New entries created via the dashboard default to `net_amounts`. +Existing entries without this field set are treated as `raw_lines`. +""" +enum PostLinesAs { + """ + Lines targeting the same account, currency, and tx are aggregated into a single line with the net amount. Lines that sum to zero are skipped. If all lines sum to zero, no lines are skipped. + """ + net_amounts + + """Lines are posted as-is without aggregation.""" + raw_lines + + """ + Lines with a zero amount are skipped, but lines are not aggregated. If all lines have a zero amount, no lines are skipped. + """ + skip_zero_lines +} + +""" +The posted timestamp window for a clearing account, representing the earliest and latest +posted timestamps across all currencies. +""" +type PostedWindow { + """ + The earliest posted timestamp across all currencies for this clearing account. + """ + earliest: DateTime! + + """ + The latest posted timestamp across all currencies for this clearing account. + """ + latest: DateTime! +} + +""" +View the API guide [here](https://fragment.dev/api-reference/api-queries) +""" +type Query { + _empty: String + + """Query Custom Currencies in the workspace""" + customCurrencies( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of currencies to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of currencies to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): CustomCurrenciesConnection! + + """Get External Account by Link and External ID or FRAGMENT ID.""" + externalAccount(externalAccount: ExternalAccountMatchInput!): ExternalAccount + + """Get a Ledger by ID""" + ledger( + """An object specifying the ID of the ledger you want to query""" + ledger: LedgerMatchInput! + ): Ledger + + """Get a Ledger Account by ID""" + ledgerAccount( + """An object specifying the ID of the ledger account you want to query""" + ledgerAccount: LedgerAccountMatchInput! + ): LedgerAccount + + """Get Ledger Entry by ID.""" + ledgerEntry( + """An object specifying the ID of the Ledger Entry you want to query.""" + ledgerEntry: LedgerEntryMatchInput! + ): LedgerEntry + + """Query a Ledger Entry Group given its Ledger, key, and value.""" + ledgerEntryGroup(ledgerEntryGroup: LedgerEntryGroupMatchInput!): LedgerEntryGroup + + """Get the reversal history of a Ledger Entry.""" + ledgerEntryHistory( + """ + An object specifying the ID or IK of the Ledger Entry's reversal history to query. + """ + ledgerEntry: LedgerEntryMatchInput! + ): LedgerEntriesConnection! + + """Get LedgerLine by ID""" + ledgerLine( + """An object specifying the ID of the LedgerLine you want to query""" + ledgerLine: LedgerLineMatchInput! + ): LedgerLine + + """ + Query Ledgers in workspace. Ledgers are paginated and returned in reverse-chronological order by their created date. + """ + ledgers( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + Filter the Ledgers returned. Learn more about [querying Ledgers](https://fragment.dev/guides/query-data#ledgers). + """ + filter: LedgersFilterSet + + """ + The number of Ledgers to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledgers to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgersConnection! + + """Get a Link by ID. Returns a BadRequestError if the Link is not found.""" + link( + """An object containing the ID of the Link you are querying""" + link: LinkMatchInput! + ): Link + + """Get all links in a workspace""" + links: LinksConnection! + + """Get a Schema by key.""" + schema( + """This contains the key of the Schema you want to retrieve.""" + schema: SchemaMatchInput! + ): Schema + + """Retrieve all of the Schemas in the workspace.""" + schemas( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of schemas to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of schemas to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): SchemaConnection! + + """Get a Tx by ID""" + tx( + """ + The transaction you're querying. You can specify either: + - The FRAGMENT ID of the transaction (id) + - The external system's transaction ID (externalId) and FRAGMENT ID of the external account (accountId) + - The external system's transaction ID (externalId), the external system's account ID (externalAccountId) and FRAGMENT ID of the Link (linkId) + """ + tx: TxMatchInput! + ): Tx + + """Get the current Workspace""" + workspace: Workspace! +} + +""" +The consistency configuration of a Ledger Account's balance queries. If not provided as an argument to a balance query, the default behavior is to read eventually consistent balances. See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +enum ReadBalanceConsistencyMode { + """ + Balance queries will read eventually consistent balances. This is the default behavior if `ReadBalanceConsistencyMode` is not provided as an argument to the balance field. Both Ledger Accounts configured with strongly and eventually consistent balance updates support this enum. + """ + eventual + + """ + Balance queries will read strongly consistent balances. This is only allowed if the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig` is `strong`. + """ + strong + + """ + Balance queries will use the value from the Ledger Account's `ownBalanceUpdates` in its `consistencyConfig`. + """ + use_account +} + +union ReconcileTxResponse = BadRequestError | InternalError | ReconcileTxResult + +type ReconcileTxResult { + """The ledger entry that was posted""" + entry: LedgerEntry! + + """ + True if this request successfully completed before and the previous response is being returned + """ + isIkReplay: Boolean! + + """The ledger lines that were created in that entry""" + lines: [LedgerLine!]! +} + +union ReverseLedgerEntryResponse = BadRequestError | InternalError | ReverseLedgerEntryResult + +type ReverseLedgerEntryResult { + """Whether the reversal was an IK replay""" + isIkReplay: Boolean! + + """The Ledger Entry that was reversed""" + reversedLedgerEntry: LedgerEntry! + + """The reversal Ledger Entry that was created""" + reversingLedgerEntry: LedgerEntry! +} + +""" +A string with delimiter characters `/`, `#`, and `:` disallowed, as well as parameters in {{handlebar}} syntax. +""" +scalar SafeString + +"""A simulated Ledger Entry posted as a part of a Scene.""" +input SceneEntryInput { + """Any parameters to be used as inputs to this simulated Ledger Entry.""" + parameters: JSON + + """ + The type of the simulated Ledger Entry. Must match one of the types provided in schema.ledgerEntries.types. + """ + type: SafeString! + + """The version of the Ledger Entry type.""" + typeVersion: Int +} + +input SceneEventInput { + """The simulated Ledger Entry.""" + entry: SceneEntryInput! + + """The type of the Scene Event. Currently, only entries are supported.""" + eventType: SceneEventType! +} + +enum SceneEventType { + entry +} + +input SceneInput { + """A list of simulated ledger entries that make up the Scene.""" + events: [SceneEventInput!]! + + """The human-readable name of the Scene.""" + name: String! +} + +type Schema { + """ + The identifier for a Schema. + `key` is unique to a Workspace. + """ + key: SafeString! + + """The paginated list of ledgers the Schema has been applied to.""" + ledgers( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of Ledgers to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of Ledgers to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): LedgersConnection! + + """ + The name of a Schema. It defaults to the `key` if not provided in your SchemaInput. + """ + name: String! + + """The metadata for a specific SchemaVersion.""" + version( + """ + The version of the schema to retrieve. If this is not provided, the latest version will be returned. + """ + version: Int + ): SchemaVersion! + + """A paginated list of SchemaVersions.""" + versions( + """ + Where to start paginating from, when paginating forwards. Send endCursor from a response to get its next page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + after: String + + """ + Where to start paginating from, when paginating backwards. Send startCursor from a response to get the previous page. Learn more about [pagination](https://fragment.dev/guides/query-data#basics-pagination). + """ + before: String + + """ + The number of schema versions to return per page, when paginating forwards. Defaults to 20, maximum is 200. + """ + first: Int + + """ + The number of schema versions to return per page, when paginating backwards. Defaults to 20, maximum is 200. + """ + last: Int + ): SchemaVersionConnection! +} + +""" +A condition that must be met on a Ledger Account balance. The condition can be +either a `precondition` or `postcondition`. +""" +input SchemaConditionInput { + """A condition on the `ownBalance` of the Ledger Account.""" + ownBalance: SchemaInt96ConditionInput + + """A condition on the `totalBalance` of the Ledger Account.""" + totalBalance: SchemaInt96ConditionInput +} + +"""A paginated list of Schemas in a Workspace.""" +type SchemaConnection { + """The current page of results""" + nodes: [Schema!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +""" +The consistency configuration for entities created within Ledgers created by this Schema. + +See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +input SchemaConsistencyConfigInput { + """ + The consistency mode for the Ledger Entries list query within Ledgers created by this Schema. + + See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + entries: SchemaConsistencyMode +} + +""" +The consistency modes available for entities created within this Schema. + +See [Configure consistency](https://fragment.dev/guides/configure-consistency). +""" +enum SchemaConsistencyMode { + """Eventually consistent entity updates""" + eventual + + """Strongly consistent entity updates""" + strong +} + +""" +Matches a Currency. Can be a built-in [CurrencyCode](https://fragment.dev/api-reference/api-types#scalars-and-enums-currencycode), custom Currency, or a parameterized string. +If you supply a parameterized string, you must pass in a valid CurrencyCode as a parameter when posting a Ledger Entry. +""" +input SchemaCurrencyMatchInput { + """ + The currency code. This must either be a [CurrencyCode](https://fragment.dev/api-reference/api-types#scalars-and-enums-currencycode) or a parameterized string that resolves to a CurrencyCode . + """ + code: ParameterizedString! + + """ + The ID for a custom currency. This is specified when creating the custom currency using the [createCustomCurrency](https://fragment.dev/api-reference/api-mutations#createcustomcurrency) mutation. + """ + customCurrencyId: ParameterizedString +} + +input SchemaExternalAccountMatchInput { + """The External systems's ID of the account""" + externalId: ParameterizedString + + """The FRAGMENT ID of the external account""" + id: ParameterizedString + + """The FRAGMENT ID of the link""" + linkId: ParameterizedString + + """ + The type of Link this external account belongs to. Must be one of: IncreaseLink, UnitLink, CustomLink, or StripeLink. + """ + linkType: LinkType +} + +"""Input to the API for creating a Schema.""" +input SchemaInput { + """The Chart of Accounts for the Schema.""" + chartOfAccounts: ChartOfAccountsInput! + + """The consistency configuration for this Schema.""" + consistencyConfig: SchemaConsistencyConfigInput + + """Any groups associated with this Schema.""" + groups: [GroupInput!] + + """ + The key of the Schema. This is a stable, unique identifier for the Schema. Uniqueness is enforced at the Workspace level. + """ + key: SafeString! + + """The Ledger Entries to add to the Schema.""" + ledgerEntries: SchemaLedgerEntriesInput + + """The human-readable name of the Schema.""" + name: ParameterizedString + + """Any scenes associated with this Schema.""" + scenes: [SceneInput!] +} + +"""A condition that must be met on a field.""" +input SchemaInt96ConditionInput { + """ + Amount must be exactly equal to this value. You may not specify this alongside `gte` or `lte`. + """ + eq: ParameterizedString + + """Amount must be greater than or equal to this value.""" + gte: ParameterizedString + + """Amount must be less than or equal to this value.""" + lte: ParameterizedString +} + +""" +Models a Ledger Account in a Schema. +Upon successfully storing a [Schema](https://fragment.dev/api-reference/api-types#core-types-schema), a [LedgerAccount](https://fragment.dev/api-reference/api-types#core-types-ledgeraccount) will be created for +each corresponding non-templated `SchemaLedgerAccountInput` in your Chart of Accounts. +""" +input SchemaLedgerAccountInput { + """ + Ledger Accounts to create as children of this Ledger Account. Ledger Accounts may be nested up to a maximum depth of 10. + """ + children: [SchemaLedgerAccountInput!] + + """ + EXPERIMENTAL: Whether or not this Ledger Account is a Clearing Account. + Clearing Accounts have balances that should tend to zero. They are used to track in-progress workflows and payments. + """ + clearing: Boolean + + """ + The consistency configuration for this ledger account. See [Configure consistency](https://fragment.dev/guides/configure-consistency). + """ + consistencyConfig: LedgerAccountConsistencyConfigInput + + """ + The currency of this Ledger Account. If this is not set, and `currencyMode` is + not set to `multi`, it is derived from the Chart of Accounts' default. + """ + currency: SchemaCurrencyMatchInput + + """ + If set to `multi`, creates a multi-currency Ledger Account. If set to `single`, creates a single-currency Ledger Account. + """ + currencyMode: CurrencyMode + + """ + The key of this Ledger Account. Keys are used to formulate the unique path of the Ledger Account in your Chart of Accounts. + Siblings must have unique keys. + """ + key: SafeString! + + """ + The External Account to link to this Ledger Account. + It must be provided of `linked` is true. + """ + linkedAccount: SchemaExternalAccountMatchInput + + """The human-readable name of this Ledger Account.""" + name: ParameterizedString + + """EXPERIMENTAL: Marks this as a Payment Account.""" + payment: SchemaPaymentInput + + """The status of this Ledger Account. Defaults to active.""" + status: SchemaLedgerAccountStatus + + """Whether or not this Ledger Account should be templated.""" + template: Boolean + + """ + The type of ledger account to create. Required if this is a top-level Ledger Account. If not provided, the type will be inferred from the parent. + """ + type: LedgerAccountTypes +} + +"""Matches a Ledger Account in a Schema.""" +input SchemaLedgerAccountMatchInput { + """ + The unique path of the Ledger Account in the Schema. + This is a slash-delimited string containing the keys of a Ledger Account and all its direct ancestors. + ex: expense-root/subscriptions/netflix + For Templated Ledger Accounts, you must supply a parameter in the path that will be used to name an instance of the template. + ex: `"expense-root/subscriptions/vendor:{{vendor_name}}"` + """ + path: ParameterizedString! +} + +"""The status of a Ledger Account.""" +enum SchemaLedgerAccountStatus { + """The Ledger Account is active.""" + active + + """The Ledger Account is archived.""" + archived + + """The Ledger Account is disabled.""" + disabled +} + +"""The Ledger Entries in your Schema.""" +input SchemaLedgerEntriesInput { + """A list of Ledger Entry definitions.""" + types: [SchemaLedgerEntryInput!]! +} + +""" +A condition that must be met on a Ledger Account when a Ledger Entry is posted. +""" +input SchemaLedgerEntryConditionInput { + """The Ledger Account to apply the condition to.""" + account: SchemaLedgerAccountMatchInput! + + """ + The currency of the balance to apply the condition to. Required if the Ledger Account matched is a multi-currency Ledger Account. + Otherwise, this field is defaults to the Ledger Account's currency. + """ + currency: SchemaCurrencyMatchInput + + """ + A `postcondition` must be met after the Ledger Entry updates are applied. + """ + postcondition: SchemaConditionInput + + """ + A `precondition` must be met before any Ledger Entry updates are applied. + """ + precondition: SchemaConditionInput + + """ + Repeated expansion configuration. When set, this condition is expanded at runtime for each element + in the array parameter named by the key. + """ + repeated: SchemaRepeatedConfigInput +} + +"""A Ledger Entry Group associated with a Ledger Entry type.""" +input SchemaLedgerEntryGroupInput { + """The key for this Ledger Entry Group.""" + key: SafeString! + + """The value associated with this Ledger Entry Group.""" + value: ParameterizedString! +} + +""" +A Ledger Entry in a Schema. All Ledger Entries defined in a Schema must have a unique `type`. +""" +input SchemaLedgerEntryInput { + """ + Conditions that must be satisfied to post this Ledger Entry. The Ledger Entry will reject with a BadRequestError if any condition is not met. You can only add a condition on a Ledger Account containing a Line in this Ledger Entry. + """ + conditions: [SchemaLedgerEntryConditionInput!] + + """Human-readable description of the Ledger Entry.""" + description: ParameterizedString + + """ + Ledger Entries posted with this type will be in these Ledger Entry Groups. + """ + groups: [SchemaLedgerEntryGroupInput!] + + """ + The Ledger Lines in the Ledger Entry. + If provided, when posting a Typed Entry, a [LedgerEntry](https://fragment.dev/api-reference/api-types#core-types-ledgerline) will be posted containing [LedgerLines](https://fragment.dev/api-reference/api-types#core-types-ledgerline) corresponding + to the values you provide here. If your lines contain parameters, you must supply values for those parameters that balance out the Ledger Entry. If not provided, lines will be required when posting a Typed Entry. + """ + lines: [SchemaLedgerLineInput!] + + """ + Fixed partial set of parameters to be included in a templated Ledger Entry. + """ + parameters: JSON + + """ + Controls how lines are posted. When set to `net_amounts`, all lines targeting the same account, currency, and tx are aggregated into a single line with the net amount, and lines that sum to zero are skipped. When set to `skip_zero_lines`, lines with a zero amount are skipped but not aggregated. In both modes, if all lines are zero, no lines are skipped. When set to `raw_lines`, lines are posted as-is without aggregation. New entries created via the dashboard default to `net_amounts`. Existing entries without this field set are treated as `raw_lines`. + """ + postLinesAs: PostLinesAs + + """The status of this Ledger Entry. Defaults to active.""" + status: SchemaLedgerEntryStatus + + """ + Ledger Entries posted with this type will be associated with these tags. + """ + tags: [SchemaLedgerEntryTagInput!] + + """ + The type of this Ledger Entry. This is a stable, unique identifier for this entry. Uniqueness is enforced at the Schema level. + You can filter on this field when querying for Ledger Entries. See the docs on [LedgerEntryFilterSet](https://fragment.dev/api-reference/api-types#filter-types-ledgerentriesfilterset) + """ + type: SafeString! + + """The version of the Ledger Entry type.""" + typeVersion: Int +} + +"""The status of a Ledger Entry.""" +enum SchemaLedgerEntryStatus { + """The Ledger Entry is active.""" + active + + """The Ledger Entry is archived.""" + archived + + """The Ledger Entry is disabled.""" + disabled +} + +"""A tag associated with a Ledger Entry type.""" +input SchemaLedgerEntryTagInput { + """The key for this tag.""" + key: SafeString! + + """The value associated with the given key for this tag.""" + value: ParameterizedString! +} + +"""A Ledger Line in a Ledger Entry.""" +input SchemaLedgerLineInput { + """ + The Ledger Account this Ledger Line will be posted to. + It supports parameters in its attributes via handlebars syntax. + """ + account: SchemaLedgerAccountMatchInput! + + """ + The amount of the Ledger Line. It supports parameters via the handlebars syntax and addition (+) and subtraction (-). + """ + amount: ParameterizedString + + """ + The currency of the Ledger Line. This is required if the Ledger Account has currencyMode multi. + It supports parameters in its attributes via handlebars syntax. + """ + currency: SchemaCurrencyMatchInput + + """Human-readable description of the line.""" + description: ParameterizedString + + """ + The key for the Ledger Line. Ledger Line keys must be unique within a Ledger Entry. Key can be filtered on as part of the LedgerLinesFilterSet. + """ + key: SafeString! + + """ + Repeated expansion configuration. When set, this line is expanded at runtime for each element + in the array parameter named by the key. + """ + repeated: SchemaRepeatedConfigInput + + """ + Tags to attach to this Ledger Line. Supports parameterized values via handlebars syntax. + """ + tags: [SchemaLedgerEntryTagInput!] + + """ + The external transaction to reconcile. + This field is required if the Ledger Account being posted to is a Linked Ledger Account. Otherwise, this field is disallowed. + It supports parameters in its attributes via handlebars syntax. + + See the docs on [reconciling payments](https://fragment.dev/guides/reconcile-payments). + """ + tx: SchemaTxMatchInput +} + +"""An object used to retrieve a Schema.""" +input SchemaMatchInput { + """ + The key to retrieve a Schema by. + `key` is unique to a Workspace. + """ + key: SafeString! + + """ + Optional parameter to specify version of requested Schema. If not provided, it defaults to 0, representing the latest available version for the provided Schema key. + """ + version: Int +} + +"""EXPERIMENTAL: Marks a Ledger Account as a Payment Account.""" +input SchemaPaymentInput { + penguin: Boolean! +} + +""" +Configuration for repeated expansion of a line or condition. The key names a client-supplied +array parameter whose elements each generate one copy of the line or condition at runtime. +""" +input SchemaRepeatedConfigInput { + """ + The key of the array parameter whose elements expand this line or condition. + """ + key: SafeString! +} + +""" +Matches a transaction at an external system. +This is used to specify the transaction being reconciled into a Linked Ledger Account +""" +input SchemaTxMatchInput { + """The external system's ID for the transaction.""" + externalId: ParameterizedString + + """The FRAGMENT ID for the transaction.""" + id: ParameterizedString +} + +""" +An instance of a Schema stored in a Workspace. +A new SchemaVersion is created each time a Schema is stored. +It stores the Chart of Accounts and list of Ledger Entries as well as a history of its Ledger migrations. +""" +type SchemaVersion { + created: DateTime! + json: JSON! + migrations: LedgerMigrationConnection! + + """The version of the schema.""" + version: Int! +} + +"""A paginated list of SchemaVersions for a given Schema.""" +type SchemaVersionConnection { + """The current page of results""" + nodes: [SchemaVersion!]! + + """Pagination info for this list.""" + pageInfo: PageInfo! +} + +""" +Returned by the [storeSchema](https://fragment.dev/api-reference/api-mutations#storeschema) mutation. +""" +union StoreSchemaResponse = BadRequestError | InternalError | StoreSchemaResult + +""" +`StoreSchemaResult` represents a successful execution of `storeSchema`. +""" +type StoreSchemaResult { + """The Schema that was stored as a result of calling `storeSchema`.""" + schema: Schema! +} + +input StringFilter { + equalTo: String + + """Must match one of the values provided. Limited to 100 items maximum.""" + in: [String!] + + """Must not equal this string value""" + notEqualTo: String + + """ + Must not match any of the values provided. Limited to 100 items maximum. + """ + notIn: [String!] +} + +input StringMatchFilter { + """ + Must contain the provided pattern somewhere within the string. For example, 'contains: hat' will match 'hat', 'chat', and 'hate'. + """ + contains: String + + """Must exactly equal the provided value""" + equalTo: String + + """ + Must exactly equal one of the provided values. Limited to 100 items maximum. + """ + in: [String!] + + """ + Must match the provided pattern. Wildcards ("*") will match any substring + """ + matches: String + + """ + Must match any one of the provided `matches` patterns, OR-ed together — results are returned as a single paginated list. Each entry uses the same wildcard grammar as `matches`. Cannot be combined with `matches`. Limited to 100 entries. + """ + matchesAny: [String!] +} + +enum StripeEnv { + livemode + testmode +} + +type StripeLink implements Link { + """ISO-8601 timestamp when the Link was created.""" + created: String! + + """URL to the Fragment Dashboard for this Link.""" + dashboardUrl: String! + + """A list of External Accounts associated with this Link.""" + externalAccounts: ExternalAccountsConnection! + + """FRAGMENT ID of the Custom Link.""" + id: ID! + + """Name of the Link as it appears in the Fragment Dashboard.""" + name: String! + + """The environment of the Stripe Link, either testmode or livemode.""" + stripeEnv: StripeEnv! +} + +union SyncCustomAccountsResponse = BadRequestError | InternalError | SyncCustomAccountsResult + +type SyncCustomAccountsResult { + """The external accounts that were synced.""" + accounts: [ExternalAccount!]! +} + +union SyncCustomTxsResponse = BadRequestError | InternalError | SyncCustomTxsResult + +type SyncCustomTxsResult { + txs: [Tx!]! +} + +"""Filters a result set based on the tags it contains.""" +input TagFilter { + """ + Matches entries that have ALL of the specified tags. The key and value are both matched exactly. Limited to 10 items maximum. + """ + all: [TagMatchInput!] + + """ + Matches tag values based on the existence of the provided string within the tag value. The key is matched exactly. + """ + contains: TagMatchInput + + """ + Matches tags based on the exact value provided. The key and value are both matched exactly. + """ + equalTo: TagMatchInput + + """ + Matches tags based on a list of possible tag matches. The key and value are both matched exactly. Limited to 100 items maximum. + """ + in: [TagMatchInput!] + + """Matches tags where the key exactly equals the provided value.""" + keyEqualTo: SafeString + + """ + Matches tags where the key matches any of the provided values. Limited to 100 items maximum. + """ + keyIn: [SafeString!] + + """ + Matches tags that do not equal the provided value. The key and value are both matched exactly. + """ + notEqualTo: TagMatchInput + + """ + Matches tags that do not match any of the provided values. The key and value are both matched exactly. Limited to 100 items maximum. + """ + notIn: [TagMatchInput!] + + """Matches tags where the key does not equal the provided value.""" + notKeyEqualTo: SafeString + + """ + Matches tags where the key does not match any of the provided values. Limited to 100 items maximum. + """ + notKeyIn: [SafeString!] +} + +""" +Specifies a single tag that an entity is expected to have. You must specify both the key and the value. +""" +input TagMatchInput { + """The key of this tag.""" + key: SafeString! + + """The value associated with this tag's key.""" + value: SafeString! +} + +type Tx { + """FRAGMENT ID of this transaction's external account""" + accountId: ID! + + """ + Integer amount in cents. Positive indicates money entering the external account, negative indicates money leaving + """ + amount: Int96! + + """Currency of this Tx""" + currency: Currency + + """Date this Tx posted to the external account""" + date: Date! + + """ISO-8601 timestamp when this Tx was deleted""" + deletedAt: DateTime + + """Description at the external account""" + description: String! + + """The External Account that this transaction belongs to.""" + externalAccount: ExternalAccount! + + """ID in the external system of this transaction's external account""" + externalAccountId: ID! + + """ID of this transaction in the external system""" + externalId: ID! + + """ + FRAGMENT ID of this Tx. If you delete a Tx via deleteCustomTxs, it will not show up in listing queries, but can be resolved by if you lookup by its Fragment ID. If you resync a Tx with the same externalId, its Fragment ID will be different than the previous Tx. + """ + id: ID! + + """Whether this Tx has been deleted via deleteCustomTxs""" + isDeleted: Boolean! + + """ + Returns ledger entries that are linked to this transaction. You can link the same external account to multiple ledgers, so there could be multiple entries associated with one transaction - one for each linked ledger account this transaction has been reconciled with + """ + ledgerEntries: LedgerEntriesConnection! + + """Same as ledgerEntries, but returns an array of IDs instead""" + ledgerEntryIds: [ID!] + + """Same as ledgerLines, but returns an array of IDs instead""" + ledgerLineIds: [ID!] + + """ + Returns ledger lines that are linked to this transaction. You can link the same external account to multiple ledgers, so there could be multipe lines associated with one transaction - one for each linked ledger account this transaction has been reconciled with + """ + ledgerLines: LedgerLinesConnection! + + """This transaction's Link.""" + link: Link! + + """FRAGMENT ID of this transaction's Link""" + linkId: ID! + + """ISO-8601 timestamp when this Tx posted to the external account""" + posted: DateTime! + + """ + When a Tx is deleted and a new Tx is synced with the same externalId, its sequence will be higher than the previous Tx. You can use this to distinguish different instances of Txs that have the same externalId. + """ + sequence: Int! + workspaceId: ID! @deprecated(reason: "Callers should not need to query or store this value.") +} + +""" +Specify a Tx by using `id` or `externalId`, the Link it belongs to by `linkId`, and the External Account it is a part of by `accountId` or `externalAccountId`. +""" +input TxMatchInput { + """The FRAGMENT ID of the external account""" + accountId: ID + + """The external system's ID for the account""" + externalAccountId: ID + + """The external system's ID for the transaction""" + externalId: ID + + """The FRAGMENT ID of the transaction""" + id: ID + + """The FRAGMENT ID of the link""" + linkId: ID +} + +enum TxType { + credit + debit +} + +input TxTypeFilter { + equalTo: TxType + + """Must match one of the values provided. Limited to 100 items maximum.""" + in: [TxType!] +} + +"""A paginated list of Txs""" +type TxsConnection { + """The current page of results""" + nodes: [Tx!]! + + """ + The [pagination info](https://fragment.dev/api-reference/api-types#connection-types-pageinfo) for this list + """ + pageInfo: PageInfo! +} + +""" +All hour-aligned offsets from -11:00 to +12:00 are supported, e.g. "-08:00" (PT), "-05:00" (ET), "+00:00" (UTC) +""" +scalar UTCOffset + +enum UnitEnv { + production + sandbox +} + +type UnitLink implements Link { + """ISO-8601 timestamp when the Link was created.""" + created: String! + + """URL to the Fragment Dashboard for this Link.""" + dashboardUrl: String! + + """A list of External Accounts associated with this Link.""" + externalAccounts: ExternalAccountsConnection! + + """FRAGMENT ID of the Unit Link.""" + id: ID! + + """Name of the Link as it appears in the Dashboard.""" + name: String! + + """The environment of the Unit Link, either sandbox or production.""" + unitEnv: UnitEnv! +} + +input UpdateLedgerAccountInput { + """ + The consistency configuration for this ledger account. This defines how updates to this ledger account's balance are handled. + """ + consistencyConfig: LedgerAccountConsistencyConfigInput + + """The name to update the ledger account to""" + name: String +} + +union UpdateLedgerAccountResponse = BadRequestError | InternalError | UpdateLedgerAccountResult + +type UpdateLedgerAccountResult { + """The ledger account that was updated""" + ledgerAccount: LedgerAccount! +} + +input UpdateLedgerEntryInput { + """The list of Groups to add to this Ledger Entry.""" + groups: [LedgerEntryGroupInput!] + + """The list of Tags to add and/or update on this Ledger Entry.""" + tags: [LedgerEntryTagInput!] + + """The list of Tags to remove from this Ledger Entry.""" + tagsToRemove: [LedgerEntryTagInput!] +} + +union UpdateLedgerEntryResponse = BadRequestError | InternalError | UpdateLedgerEntryResult + +type UpdateLedgerEntryResult { + """The Ledger Entry that was updated.""" + entry: LedgerEntry! +} + +input UpdateLedgerInput { + """The new Ledger name. """ + name: String +} + +union UpdateLedgerResponse = BadRequestError | InternalError | UpdateLedgerResult + +type UpdateLedgerResult { + """The updated Ledger. """ + ledger: Ledger! +} + +type Workspace { + """The ID of the Workspace""" + id: String! + + """The name of the Workspace""" + name: String! +} \ No newline at end of file From e629f72cc82b0e4160ff72fb7897751ef1c3571f Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Mon, 10 Aug 2026 15:08:22 -0400 Subject: [PATCH 11/15] Update SDK queries --- .github/workflows/updateSDKQueries.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/updateSDKQueries.yml b/.github/workflows/updateSDKQueries.yml index 16b5a8c..02014e8 100644 --- a/.github/workflows/updateSDKQueries.yml +++ b/.github/workflows/updateSDKQueries.yml @@ -57,11 +57,15 @@ jobs: # `make snapshots` regenerates tests/snapshots from the queries just # copied. Without it the sync PR ships updated queries alongside stale # snapshots, and the `snapshots` job in tests.yml fails on every sync. + # `refresh-snapshot-schema` repins tests/snapshots/schema.graphql to the + # current API and regenerates. Snapshots are pinned so that unrelated PRs + # do not fail when the API changes; this sync is where that drift is meant + # to surface, as a reviewable diff next to the queries it came with. - name: Generate SDK working-directory: ./fragment-python run: | make build - make snapshots + make refresh-snapshot-schema make lint - name: Create Pull Request From 1a1baa4a0e4b69f9150a8688780190e028c53eda Mon Sep 17 00:00:00 2001 From: Steven Klaiber-Noble Date: Thu, 6 Aug 2026 10:19:25 -0700 Subject: [PATCH 12/15] Cover the typed batch payloads with offline tests and typechecking The typed-entry tests assert on render_module's output as a string, which cannot tell a working module from one that merely contains the right substrings. Nothing imported the per-entry classes, so a duplicated field declaration or an annotation the header does not import passed every test. tests/test_typed_entries_generated.py renders into a throwaway package and imports it, then uses the classes: the optional-parameter branch (no snapshot query has a nullable parameter), field-name collisions, escaped names, and to_entry_inputs. It also exercises every model in the committed snapshot, which is the artifact a customer gets. tests/test_typed_entries_warnings.py covers the paths where codegen degrades rather than fails. Each leaves a working but quietly worse SDK, and the warning is the only signal, so a silent version of any of them passed the whole suite. tests/type_checks/ asserts what a caller sees, which no runtime test can: a runtime test passes just as happily against `entries: Any`. Negative cases are written as `# type: ignore[...]` and warn_unused_ignores makes them fail if the call ever starts passing, so the signature cannot loosen unnoticed. CI ran mypy -p fragment only, leaving both tests/ and the mypy_path setting unchecked. `make typecheck` now covers tests/ as well, and typecheck plus the offline tests run in a job with no secrets -- on forks the credentialed job errors on every test, so nothing ran at all. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 30 ++- Makefile | 7 + pyproject.toml | 5 + tests/conftest.py | 21 +- tests/test_add_ledger_entries.py | 2 + tests/test_add_ledger_entry.py | 2 + tests/test_typed_entries_generated.py | 324 ++++++++++++++++++++++++++ tests/test_typed_entries_warnings.py | 255 ++++++++++++++++++++ tests/type_checks/batch_entries.py | 119 ++++++++++ 9 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 tests/test_typed_entries_generated.py create mode 100644 tests/test_typed_entries_warnings.py create mode 100644 tests/type_checks/batch_entries.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5cf1f6e..37862b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,33 @@ on: types: [opened, reopened, synchronize, ready_for_review] jobs: + # Typecheck and the offline tests. Needs no credentials, so this still runs on + # forks, where the integration job has no secrets and every test errors. + unit-tests: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + name: Checkout fragment-python + + - name: Use Python 3.10.14 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.10.14' + + - name: Install dependencies + run: | + pip install poetry + poetry install --with dev + + - name: Typecheck + run: make typecheck + + - name: Run offline tests + run: make unit + integration-tests: runs-on: ubuntu-latest permissions: @@ -30,9 +57,6 @@ jobs: pip install poetry poetry install --with dev - - name: Typecheck - run: poetry run mypy -p fragment - - name: Run tests run: poetry run pytest -v diff --git a/Makefile b/Makefile index 1f27728..a30bf20 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,15 @@ sort_order: style: poetry run black fragment/ tests/ +# tests/ is checked too: tests/type_checks/ asserts what a caller sees when +# calling the generated client, which no runtime test can cover. typecheck: poetry run mypy -p fragment + poetry run mypy tests/ + +# Everything that needs no credentials and no network. +unit: + poetry run pytest -m "not integration" # Integration tests. Requires CLIENT_ID, CLIENT_SECRET, SCOPE, AUTH_URL and # API_URL in the environment; the tests fail if any are missing. diff --git a/pyproject.toml b/pyproject.toml index 08b54ad..27e6d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,11 +43,16 @@ testpaths = ["tests"] # generated code rather than a hand-written approximation of it. Regenerate with # `make snapshots`. pythonpath = ["tests/snapshots/001-marketing-schema"] +# Everything unmarked runs offline. `make unit` deselects this marker. +markers = ["integration: needs live API credentials; see tests/conftest.py"] [tool.mypy] # Same path pytest uses, so `mypy tests/` can resolve the snapshotted `sdk` # package and actually typecheck calls against the generated typed payloads. mypy_path = "tests/snapshots/001-marketing-schema" +# tests/type_checks/ writes "this call is rejected" as `# type: ignore[...]`. +# This turns the day it stops being rejected into a failure, not a silent pass. +warn_unused_ignores = true [tool.pylint.messages_control] max-line-length = 88 diff --git a/tests/conftest.py b/tests/conftest.py index 7c6f93b..964c3a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import os -from typing import AsyncIterator, Dict +from typing import AsyncIterator, TypedDict import pytest import pytest_asyncio @@ -9,8 +9,23 @@ REQUIRED_ENV_VARS = ("CLIENT_ID", "CLIENT_SECRET", "SCOPE", "AUTH_URL", "API_URL") +class Credentials(TypedDict): + """The `Client` keyword arguments read from the environment. + + A TypedDict rather than `Dict[str, str]` so `Client(**credentials)` + typechecks: against a plain str mapping, a key could land on `http_client`, + which takes an `AsyncClient`. + """ + + client_id: str + client_secret: str + auth_scope: str + auth_url: str + api_url: str + + @pytest.fixture(scope="session") -def credentials() -> Dict[str, str]: +def credentials() -> Credentials: missing = [name for name in REQUIRED_ENV_VARS if not os.environ.get(name)] if missing: pytest.fail( @@ -29,6 +44,6 @@ def credentials() -> Dict[str, str]: @pytest_asyncio.fixture -async def client(credentials: Dict[str, str]) -> AsyncIterator[Client]: +async def client(credentials: Credentials) -> AsyncIterator[Client]: async with Client(**credentials) as graphql_client: yield graphql_client diff --git a/tests/test_add_ledger_entries.py b/tests/test_add_ledger_entries.py index 354b75d..03eb320 100644 --- a/tests/test_add_ledger_entries.py +++ b/tests/test_add_ledger_entries.py @@ -34,6 +34,8 @@ ) from sdk.typed_entries import OrderPlacedV1 +pytestmark = pytest.mark.integration + TEMPLATE_SCHEMA = Path(__file__).parent / "template-schema" / "schema.json" UNKNOWN_ENTRY_TYPE = "not-in-this-schema" diff --git a/tests/test_add_ledger_entry.py b/tests/test_add_ledger_entry.py index 52f53a5..7ebfc8b 100644 --- a/tests/test_add_ledger_entry.py +++ b/tests/test_add_ledger_entry.py @@ -19,6 +19,8 @@ SchemaLedgerLineInput, ) +pytestmark = pytest.mark.integration + ENTRY_TYPE = "user-funds-account" diff --git a/tests/test_typed_entries_generated.py b/tests/test_typed_entries_generated.py new file mode 100644 index 0000000..7beb5d7 --- /dev/null +++ b/tests/test_typed_entries_generated.py @@ -0,0 +1,324 @@ +"""Tests that import and use generated code rather than reading it as text. + +String assertions on `render_module`'s output cannot distinguish a working +module from one that merely contains the right substrings: a duplicated field +declaration or an annotation the header does not import satisfies both. These +tests import what codegen emits -- header, base class and per-entry classes -- +and exercise the resulting classes. + +Offline; no credentials required. +""" + +import importlib +import itertools +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, Dict, List, Optional, Type + +import pytest +from ariadne_codegen.utils import str_to_snake_case +from graphql import OperationDefinitionNode, parse + +# `sdk` is the snapshotted client, on sys.path via `pythonpath` in pyproject.toml. +from sdk import typed_entries as snapshot_typed_entries +from sdk.input_types import AddLedgerEntryInput + +from fragment.codegen.typed_entries import EntrySpec, extract_entry_spec, render_module + +SNAPSHOT_QUERIES = ( + Path(__file__).parent / "snapshots" / "001-marketing-schema" / "queries.graphql" +) + +_PACKAGE_COUNTER = itertools.count() + + +def build_module(tmp_path: Path, specs: List[EntrySpec]) -> ModuleType: + """Render `specs` and import the result as a module. + + The throwaway package re-exports the snapshot's `base_model` and + `input_types`, so the rendered module's own relative imports resolve as + written instead of being replaced by injected names. The package name is + unique per call so repeated renders are not served from `sys.modules`. + """ + name = f"generated_typed_entries_{next(_PACKAGE_COUNTER)}" + package = tmp_path / name + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "base_model.py").write_text( + "from sdk.base_model import BaseModel\n", encoding="utf-8" + ) + (package / "input_types.py").write_text( + "from sdk.input_types import * # noqa: F401,F403\n", encoding="utf-8" + ) + (package / "typed_entries.py").write_text(render_module(specs), encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + return importlib.import_module(f"{name}.typed_entries") + finally: + sys.path.remove(str(tmp_path)) + + +def operation(parameters: str, variables: str) -> OperationDefinitionNode: + doc = parse( + f"""mutation PostThing($ik: SafeString!, $ledgerIk: SafeString!, {variables}) {{ + addLedgerEntry( + ik: $ik + entry: {{ledger: {{ik: $ledgerIk}}, type: "thing", typeVersion: 2, + parameters: {{{parameters}}}}} + ) {{ __typename }} + }}""" + ) + node = doc.definitions[0] + assert isinstance(node, OperationDefinitionNode) + return node + + +def spec_from( + parameters: str, variables: str, annotations: Dict[str, str] +) -> EntrySpec: + spec = extract_entry_spec(operation(parameters, variables), annotations) + assert spec is not None + return spec + + +def models_in(module: ModuleType) -> List[Type[Any]]: + base = module.TypedLedgerEntry + return [ + value + for value in vars(module).values() + if isinstance(value, type) and issubclass(value, base) and value is not base + ] + + +@pytest.fixture +def thing(tmp_path: Path) -> Type[Any]: + """A model covering four parameter shapes at once. + + A plain parameter, a camelCase one, one that escapes because it shadows a + builtin, and an optional one. The optional case is synthetic: every + CLI-generated parameter is non-null, so no snapshot query produces one. + """ + module = build_module( + tmp_path, + [ + spec_from( + parameters="amount: $amount, userId: $userId, type: $entryType, " + "memo: $memo", + variables="$amount: String!, $userId: String!, " + "$entryType: String!, $memo: String", + annotations={ + "amount": "str", + "user_id": "str", + "entry_type": "str", + "memo": "Optional[str]", + }, + ) + ], + ) + return module.ThingV2 + + +def test_rendered_module_imports_as_written(thing: Type[Any]) -> None: + """The emitted header covers everything the classes reference.""" + assert thing.ENTRY_TYPE == "thing" + assert thing.TYPE_VERSION == 2 + + +def test_rendered_class_declares_each_field_exactly_once(thing: Type[Any]) -> None: + """A duplicated declaration is invisible in the source but not on the class. + + Pydantic keeps the last of two identically named fields, so both Schema + names would read one value. + """ + field_names = list(thing.PARAMETER_FIELDS.values()) + assert len(field_names) == len(set(field_names)), field_names + assert set(field_names) <= set(thing.model_fields) + + +def test_colliding_parameters_carry_their_own_values(tmp_path: Path) -> None: + """`user_id` and `userId` reduce to one Python field but two wire keys.""" + module = build_module( + tmp_path, + [ + spec_from( + parameters="user_id: $snake, userId: $camel", + variables="$snake: String!, $camel: String!", + annotations={"snake": "str", "camel": "str"}, + ) + ], + ) + entry = module.ThingV2( + ik="ik-1", ledger_ik="prod", user_id="SNAKE", user_id_2="CAMEL" + ) + assert entry.to_input().entry.parameters == { + "user_id": "SNAKE", + "userId": "CAMEL", + } + + +def test_required_parameters_are_required_and_optional_ones_are_not( + thing: Type[Any], +) -> None: + entry = thing(ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t") + assert entry.memo is None + + with pytest.raises(ValueError): + thing(ik="ik-1", ledger_ik="prod", amount="100") + + +def test_optional_parameter_annotation_survives_into_the_model( + thing: Type[Any], +) -> None: + field = thing.model_fields["memo"] + assert field.annotation is Optional[str] + assert not field.is_required() + + +def test_unset_optional_parameter_is_left_out_of_the_payload( + thing: Type[Any], +) -> None: + entry = thing(ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t") + assert entry.to_input().entry.parameters == { + "amount": "100", + "userId": "u", + "type": "t", + } + + +def test_set_optional_parameter_reaches_the_payload(thing: Type[Any]) -> None: + entry = thing( + ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t", memo="note" + ) + assert entry.to_input().entry.parameters["memo"] == "note" + + +def test_escaped_field_keeps_its_schema_name_on_the_wire(thing: Type[Any]) -> None: + """`type` shadows a builtin, so the field is `type_`; the wire key is not.""" + dumped = thing( + ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t" + ).model_dump(by_alias=True) + assert dumped == { + "ik": "ik-1", + "entry": { + "ledger": {"ik": "prod"}, + "type": "thing", + "typeVersion": 2, + "parameters": {"amount": "100", "userId": "u", "type": "t"}, + }, + } + + +def test_to_entry_inputs_preserves_order(thing: Type[Any]) -> None: + """`addLedgerEntries` commits and reports in input order.""" + module = sys.modules[thing.__module__] + entries = [ + thing(ik=f"ik-{n}", ledger_ik="prod", amount=str(n), user_id="u", type_="t") + for n in range(3) + ] + inputs = module.to_entry_inputs(entries) + assert all(isinstance(entry, AddLedgerEntryInput) for entry in inputs) + assert [entry.ik for entry in inputs] == ["ik-0", "ik-1", "ik-2"] + + +def test_module_with_no_entry_types_is_still_importable(tmp_path: Path) -> None: + """The shape `fragment/sdk/typed_entries.py` has. + + The std queries type nothing, so the shipped SDK gets the base class, the + helper and the explanatory note -- and `fragment/sdk/__init__.py` imports + that module on every import of the package. + """ + module = build_module(tmp_path, []) + assert module.TypedLedgerEntry.ENTRY_TYPE == "" + assert module.to_entry_inputs([]) == [] + + +# --- The committed snapshot, the artifact a customer gets --------------------- + + +def snapshot_models() -> List[Type[Any]]: + return models_in(snapshot_typed_entries) + + +def test_the_snapshot_generated_some_models() -> None: + """Keeps the parametrized tests below from passing vacuously.""" + assert len(snapshot_models()) == 9 + + +@pytest.mark.parametrize("model", snapshot_models(), ids=lambda m: m.__name__) +def test_every_snapshot_model_serialises(model: Type[Any]) -> None: + """Every parameter in the marketing Schema is a required `String`.""" + entry = model( + ik="ik-1", + ledger_ik="prod", + **{field: field for field in model.PARAMETER_FIELDS.values()}, + ) + dumped: Dict[str, Any] = entry.model_dump(by_alias=True) + assert dumped["entry"]["type"] == model.ENTRY_TYPE + assert dumped["entry"]["typeVersion"] == model.TYPE_VERSION + assert set(dumped["entry"]["parameters"]) == set(model.PARAMETER_FIELDS) + + +@pytest.mark.parametrize("model", snapshot_models(), ids=lambda m: m.__name__) +def test_every_snapshot_model_is_exported(model: Type[Any]) -> None: + import sdk + + assert model.__name__ in sdk.__all__ + assert getattr(sdk, model.__name__) is model + + +def test_snapshot_models_do_not_shadow_a_base_class_field() -> None: + """A parameter named `ik` or `posted` escapes rather than replacing the base.""" + base_fields = set(snapshot_typed_entries.TypedLedgerEntry.model_fields) + for model in snapshot_models(): + overlap = set(model.PARAMETER_FIELDS.values()) & base_fields + assert not overlap, f"{model.__name__} shadows {overlap}" + + +def test_versioned_snapshot_models_are_distinct() -> None: + v1 = snapshot_typed_entries.OrderPlacedV1 + v2 = snapshot_typed_entries.OrderPlacedV2 + assert (v1.ENTRY_TYPE, v1.TYPE_VERSION) == ("order_placed", 1) + assert (v2.ENTRY_TYPE, v2.TYPE_VERSION) == ("order_placed", 2) + assert set(v2.PARAMETER_FIELDS) - set(v1.PARAMETER_FIELDS) == {"service_fee"} + + +def test_no_snapshot_parameter_is_optional() -> None: + """Why the optional case in `thing` is synthetic. + + A CLI-generated Schema that produces a nullable parameter fails this, and + the optional path gains real coverage. + """ + assert all( + model.model_fields[name].is_required() + for model in snapshot_models() + for name in model.PARAMETER_FIELDS.values() + ) + + +def test_snapshot_module_matches_a_fresh_render_of_its_own_queries( + tmp_path: Path, +) -> None: + """Narrows `make check-snapshots` to the renderer, without the network.""" + document = parse(SNAPSHOT_QUERIES.read_text(encoding="utf-8")) + specs = [] + for definition in document.definitions: + if not isinstance(definition, OperationDefinitionNode): + continue + spec = extract_entry_spec(definition, _annotations_for(definition)) + if spec is not None: + specs.append(spec) + + module = build_module(tmp_path, specs) + assert {model.__name__ for model in models_in(module)} == { + model.__name__ for model in snapshot_models() + } + + +def _annotations_for(definition: OperationDefinitionNode) -> Dict[str, str]: + """Every marketing-Schema variable is a non-null String.""" + return { + str_to_snake_case(vd.variable.name.value): "str" + for vd in definition.variable_definitions + } diff --git a/tests/test_typed_entries_warnings.py b/tests/test_typed_entries_warnings.py new file mode 100644 index 0000000..c50c6eb --- /dev/null +++ b/tests/test_typed_entries_warnings.py @@ -0,0 +1,255 @@ +"""Tests for the paths where codegen degrades instead of failing. + +Each produces a working SDK that is quietly worse than intended: a parameter +that lost its type, a batch method that does not typecheck, models nothing +accepts. The warning is the only signal, so it is what these assert on. + +`addLedgerEntries` and its `entries` argument are owned upstream in +fragment-dev/graphql-queries. Neither degraded path is reachable from the +queries as they stand; they cover a rename of either name. + +Offline; no credentials required. +""" + +import ast +import logging +from pathlib import Path +from typing import Any, Dict + +import pytest +from graphql import GraphQLSchema, OperationDefinitionNode, parse + +from fragment.codegen.plugins.generate_typed_entries import GenerateTypedLedgerEntries +from fragment.codegen.typed_entries import extract_entry_spec + +BATCH_METHOD = '''async def add_ledger_entries( + self, entries: list[AddLedgerEntryInput], **kwargs: Any +) -> AddLedgerEntries: + query = gql("""mutation addLedgerEntries { __typename }""") + variables: dict[str, object] = {"entries": entries} + response = await self.execute(query=query, variables=variables, **kwargs) + return AddLedgerEntries.model_validate(self.get_data(response))''' + +# Carries the `amount` annotation `typed_operation` needs, so extracting a spec +# from it does not trip the unresolvable-parameter warning on its own. +POST_METHOD = """async def post_thing( + self, ik: Any, ledger_ik: Any, amount: str, **kwargs: Any +) -> PostThing: + return PostThing.model_validate({})""" + + +@pytest.fixture(autouse=True) +def capture_console(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.WARNING, logger="console") + + +def plugin(tmp_path: Path) -> GenerateTypedLedgerEntries: + config: Dict[str, Any] = { + "tool": { + "ariadne-codegen": { + "target_package_path": str(tmp_path), + "target_package_name": "sdk", + "queries_path": "queries/", + } + } + } + return GenerateTypedLedgerEntries(GraphQLSchema(), config) + + +def method(source: str) -> ast.AsyncFunctionDef: + node = ast.parse(source).body[0] + assert isinstance(node, ast.AsyncFunctionDef) + return node + + +def operation(source: str) -> OperationDefinitionNode: + node = parse(source).definitions[0] + assert isinstance(node, OperationDefinitionNode) + return node + + +BATCH_OPERATION = operation( + "mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) " + "{ addLedgerEntries(entries: $entries) { __typename } }" +) + + +def typed_operation(name: str = "PostThing") -> OperationDefinitionNode: + return operation( + f"""mutation {name}($ik: SafeString!, $ledgerIk: SafeString!, $amount: String!) {{ + addLedgerEntry( + ik: $ik + entry: {{ledger: {{ik: $ledgerIk}}, type: "thing", + parameters: {{amount: $amount}}}} + ) {{ __typename }} + }}""" + ) + + +# --- Extraction --------------------------------------------------------------- + + +def test_unresolvable_parameter_type_warns_and_falls_back_to_any( + caplog: pytest.LogCaptureFixture, +) -> None: + """A parameter with no matching client argument keeps its wire key. + + Dropping it would change the payload, so `Any` is the fallback; the cost is + the caller's type checking on that one field. + """ + spec = extract_entry_spec(typed_operation(), annotations={}) + assert spec is not None + assert [(p.name, p.annotation) for p in spec.parameters] == [("amount", "Any")] + + assert "Could not resolve a type for parameter 'amount'" in caplog.text + assert "PostThing" in caplog.text + + +def test_resolvable_parameter_type_warns_about_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + spec = extract_entry_spec(typed_operation(), annotations={"amount": "str"}) + assert spec is not None + assert caplog.text == "" + + +def test_field_collision_names_the_parameter_that_moved( + caplog: pytest.LogCaptureFixture, +) -> None: + """The rename is invisible in the payload, so the log is the only signal.""" + spec = extract_entry_spec( + operation("""mutation PostThing($ik: SafeString!, $ledgerIk: SafeString!, + $a: String!, $b: String!) { + addLedgerEntry( + ik: $ik + entry: {ledger: {ik: $ledgerIk}, type: "thing", + parameters: {user_id: $a, userId: $b}} + ) { __typename } + }"""), + annotations={"a": "str", "b": "str"}, + ) + assert spec is not None + assert "'userId'" in caplog.text + assert "'user_id_2'" in caplog.text + assert "wire payload is unaffected" in caplog.text + + +# --- The batch method --------------------------------------------------------- + + +def test_missing_entries_argument_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A renamed argument costs callers their type checking.""" + renamed = method(BATCH_METHOD.replace("entries:", "items:", 1)) + instance = plugin(tmp_path) + + instance.generate_client_method(renamed, BATCH_OPERATION) + + assert not instance.widened_entries_argument + assert "Could not find an 'entries' argument" in caplog.text + assert "will not typecheck" in caplog.text + + +def test_missing_variables_assignment_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Widening without the coercion lets a non-list sequence reach json.dumps.""" + without_assignment = method( + BATCH_METHOD.replace( + 'variables: dict[str, object] = {"entries": entries}', "pass" + ) + ) + instance = plugin(tmp_path) + + instance.generate_client_method(without_assignment, BATCH_OPERATION) + + assert instance.widened_entries_argument + assert "Could not find the 'entries' variables assignment" in caplog.text + assert "fail to serialise" in caplog.text + + +def test_the_intact_batch_method_warns_about_nothing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An intact method is silent, so the warnings above mean what they say.""" + instance = plugin(tmp_path) + + rewritten = instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + assert instance.widened_entries_argument + assert caplog.text == "" + source = ast.unparse(rewritten) + assert "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]" in source + assert "{'entries': list(entries)}" in source + + +def test_typed_models_with_no_batch_operation_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Typed models with no batch method to accept them.""" + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + + instance.generate_init_code("__all__ = []\n") + + assert not instance.saw_batch_operation + assert "found no 'addLedgerEntries' operation" in caplog.text + + +def test_seeing_the_batch_operation_warns_about_nothing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + instance.generate_init_code("__all__ = []\n") + + assert caplog.text == "" + + +# --- What the plugin writes --------------------------------------------------- + + +def test_generate_init_code_writes_a_module_that_parses(tmp_path: Path) -> None: + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + init_code = instance.generate_init_code("__all__ = []\n") + + written = (tmp_path / "sdk" / "typed_entries.py").read_text(encoding="utf-8") + ast.parse(written) + assert written.startswith("# Generated by fragment") + assert "# Source: queries/" in written + assert "class ThingV1(TypedLedgerEntry):" in written + + ast.parse(init_code) + assert '"ThingV1",' in init_code + assert "from .typed_entries import" in init_code + + +def test_generate_init_code_creates_the_package_directory(tmp_path: Path) -> None: + """The hook does not depend on ariadne having made the directory first.""" + instance = plugin(tmp_path / "nested" / "deeper") + instance.generate_init_code("__all__ = []\n") + + assert (tmp_path / "nested" / "deeper" / "sdk" / "typed_entries.py").exists() + + +def test_client_imports_are_only_added_when_the_argument_was_widened( + tmp_path: Path, +) -> None: + """An unwidened client imports no name it never references.""" + instance = plugin(tmp_path) + code = "from typing import Any\n\nclass Client:\n pass\n" + + assert instance.generate_client_code(code) == code + + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + widened = instance.generate_client_code(code) + + assert "from .typed_entries import TypedLedgerEntry" in widened + assert "from typing import Sequence" in widened + ast.parse(widened) diff --git a/tests/type_checks/batch_entries.py b/tests/type_checks/batch_entries.py new file mode 100644 index 0000000..b9bc964 --- /dev/null +++ b/tests/type_checks/batch_entries.py @@ -0,0 +1,119 @@ +"""Type-level assertions for the typed batch API. Checked by mypy, never run. + +The point of the typed payloads is what a type checker says about them, and +nothing else in the suite can assert that -- a runtime test passes just as +happily against `entries: Any`. `make typecheck` covers this file. + +Each `# type: ignore[...]` is an assertion in both directions. It says the call +below is expected to be rejected, and because `warn_unused_ignores` is on, mypy +fails if the call ever starts passing. So loosening the signature breaks this +file just as surely as tightening it too far does. + +Imports resolve through `mypy_path` in pyproject.toml, which points at the +snapshotted `sdk` -- the same package the runtime tests import. +""" + +from typing import List, Sequence, Union + +from sdk.client import Client +from sdk.input_types import AddLedgerEntryInput +from sdk.typed_entries import CardSettleV1, OrderPlacedV1, TypedLedgerEntry + + +def order_placed() -> OrderPlacedV1: + return OrderPlacedV1( + ik="ik", + ledger_ik="prod", + user_id="u", + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +async def accepts_the_shapes_callers_actually_build( + client: Client, + typed: OrderPlacedV1, + other: CardSettleV1, + raw: AddLedgerEntryInput, +) -> None: + """None of these may error. A comprehension over orders is the common shape.""" + await client.add_ledger_entries(entries=[typed, typed]) + + # `Sequence`, not `list`, is what makes this one work: `list` is invariant, + # so a pre-built `list[OrderPlacedV1]` is not a `list[Union[...]]`. + prebuilt: List[OrderPlacedV1] = [order_placed() for _ in range(3)] + await client.add_ledger_entries(entries=prebuilt) + + await client.add_ledger_entries(entries=[raw]) + await client.add_ledger_entries(entries=[typed, raw]) + await client.add_ledger_entries(entries=(typed,)) + await client.add_ledger_entries(entries=[typed], headers={"X-Test": "1"}) + + mixed: Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]] = [typed, other, raw] + await client.add_ledger_entries(entries=mixed) + + +async def rejects_what_is_not_an_entry(client: Client, typed: OrderPlacedV1) -> None: + """The widening must not have degraded into `Any`.""" + await client.add_ledger_entries(entries=["not an entry"]) # type: ignore[list-item] + await client.add_ledger_entries(entries=[{"ik": "x"}]) # type: ignore[list-item] + await client.add_ledger_entries(entries=typed) # type: ignore[arg-type] + await client.add_ledger_entries(entries=None) # type: ignore[arg-type] + + +def rejects_a_missing_parameter() -> OrderPlacedV1: + """Every templated parameter of `order_placed` V1 is required.""" + return OrderPlacedV1( # type: ignore[call-arg] + ik="ik", + ledger_ik="prod", + user_id="u", + ) + + +def rejects_a_parameter_of_the_wrong_type() -> OrderPlacedV1: + return OrderPlacedV1( + ik="ik", + ledger_ik="prod", + user_id=1, # type: ignore[arg-type] + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +def rejects_a_parameter_this_version_does_not_have() -> OrderPlacedV1: + """`service_fee` arrived in V2. Asking V1 for it is the mistake the split prevents.""" + return OrderPlacedV1( # type: ignore[call-arg] + ik="ik", + ledger_ik="prod", + user_id="u", + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + service_fee="50", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +def parameters_are_reachable_under_their_python_names(typed: OrderPlacedV1) -> str: + """Snake_cased fields, and `PARAMETER_FIELDS` holding the Schema names.""" + schema_name: str = typed.PARAMETER_FIELDS["user_id"] + typed.nonexistent_field # type: ignore[attr-defined] + return typed.order_cost + typed.driver_fee + schema_name + + +def to_input_is_the_raw_type(typed: OrderPlacedV1) -> AddLedgerEntryInput: + """So a caller can adjust the payload before sending without losing types.""" + return typed.to_input() From 288e682baa1797ce28b75f9b296baa272f437833 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Mon, 10 Aug 2026 15:29:18 -0400 Subject: [PATCH 13/15] Derive the integration marker from fixture usage Merging typed-batch-ledger-entries dropped the `pytestmark` line from tests/test_add_ledger_entries.py. Nothing failed loudly: `make unit` deselected 1 test instead of 3, ran the two credential-bound tests offline, and they errored on missing environment variables. conftest now marks anything whose fixture closure includes `credentials`, so the marker follows from what a test actually needs. A new integration test cannot forget it and a merge cannot drop it. The per-module markers are redundant under that rule and are removed, leaving one mechanism. --- tests/conftest.py | 13 +++++++++++++ tests/test_add_ledger_entry.py | 2 -- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 964c3a8..603c73c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,3 +47,16 @@ def credentials() -> Credentials: async def client(credentials: Credentials) -> AsyncIterator[Client]: async with Client(**credentials) as graphql_client: yield graphql_client + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Mark anything needing live credentials as `integration`. + + Derived from fixture usage rather than written at the top of each module, so + a new integration test cannot forget it and a merge cannot drop it. Losing + one `pytestmark` line silently put two credential-bound tests into the + offline run, where they errored on missing environment variables. + """ + for item in items: + if "credentials" in getattr(item, "fixturenames", ()): + item.add_marker(pytest.mark.integration) diff --git a/tests/test_add_ledger_entry.py b/tests/test_add_ledger_entry.py index 7ebfc8b..52f53a5 100644 --- a/tests/test_add_ledger_entry.py +++ b/tests/test_add_ledger_entry.py @@ -19,8 +19,6 @@ SchemaLedgerLineInput, ) -pytestmark = pytest.mark.integration - ENTRY_TYPE = "user-funds-account" From b6c1e35e00ea0b271e389aa3bf12545ecd7b2680 Mon Sep 17 00:00:00 2001 From: Steven Klaiber-Noble Date: Thu, 6 Aug 2026 11:54:22 -0700 Subject: [PATCH 14/15] Ship py.typed and hold the SDK to strict typing Without a PEP 561 marker, type checkers skip the installed package outright. Against a consumer of the published SDK, mypy reports only: error: Skipping analyzing "fragment.sdk.client": module is installed, but missing library stubs or py.typed marker [import-untyped] and then `ik=123`, `schema_key=None` and a call to a method that does not exist all pass. The typed batch payloads are built so callers get type errors, and no installed consumer could see one. With the marker, the same file fails on the nonexistent method. poetry already ships anything under `packages`, so the marker file alone is enough; no `include` entry is needed. tests/test_packaging.py asserts the built wheel carries it, because `poetry install` puts the source tree on the path and so passes whether or not the build is configured to ship it. fragment.sdk, fragment.sync_sdk, fragment.client and fragment.exceptions now typecheck under strict. That took four annotations: `refresh_token` and both `__init__`s were untyped, and `self.token` was inferred as None, which made `self.token["expires_in"]` an error. The generated modules already passed. The codegen package stays on the default settings; it is build tooling, not something a customer imports. The strict flags are spelled out rather than `strict = true`, which mypy only honours globally. Edits are to fragment/client/, the source ariadne copies into both SDKs; the four generated copies are updated to match. Co-Authored-By: Claude Opus 5 --- fragment/client/async_client.py | 6 +- fragment/client/sync_client.py | 6 +- fragment/exceptions.py | 4 +- fragment/py.typed | 0 fragment/sdk/async_client.py | 6 +- fragment/sync_sdk/async_client.py | 6 +- fragment/sync_sdk/sync_client.py | 6 +- pyproject.toml | 21 +++++++ .../001-marketing-schema/sdk/async_client.py | 6 +- tests/test_packaging.py | 55 +++++++++++++++++++ 10 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 fragment/py.typed create mode 100644 tests/test_packaging.py diff --git a/fragment/client/async_client.py b/fragment/client/async_client.py index 43c7dfa..7206b65 100644 --- a/fragment/client/async_client.py +++ b/fragment/client/async_client.py @@ -35,13 +35,13 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = AsyncOAuth2Client( client_id, client_secret, scope=auth_scope ) - async def refresh_token(self): + async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = await self.oauth2_client.fetch_token(self.auth_url) diff --git a/fragment/client/sync_client.py b/fragment/client/sync_client.py index d15e50a..d8fa02d 100644 --- a/fragment/client/sync_client.py +++ b/fragment/client/sync_client.py @@ -33,11 +33,11 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope) - def refresh_token(self): + def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = self.oauth2_client.fetch_token(self.auth_url) diff --git a/fragment/exceptions.py b/fragment/exceptions.py index 699e1b5..ea07b85 100644 --- a/fragment/exceptions.py +++ b/fragment/exceptions.py @@ -1,12 +1,12 @@ class MissingTokenException(ValueError): """Token not found.""" - def __init__(self): + def __init__(self) -> None: super().__init__("Token is None") class MissingArgumentException(ValueError): """Argument not present.""" - def __init__(self, argument: str): + def __init__(self, argument: str) -> None: super().__init__(f"{argument} must be provided") diff --git a/fragment/py.typed b/fragment/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/fragment/sdk/async_client.py b/fragment/sdk/async_client.py index 98e1f61..bd0068b 100644 --- a/fragment/sdk/async_client.py +++ b/fragment/sdk/async_client.py @@ -37,13 +37,13 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = AsyncOAuth2Client( client_id, client_secret, scope=auth_scope ) - async def refresh_token(self): + async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = await self.oauth2_client.fetch_token(self.auth_url) diff --git a/fragment/sync_sdk/async_client.py b/fragment/sync_sdk/async_client.py index 98e1f61..bd0068b 100644 --- a/fragment/sync_sdk/async_client.py +++ b/fragment/sync_sdk/async_client.py @@ -37,13 +37,13 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = AsyncOAuth2Client( client_id, client_secret, scope=auth_scope ) - async def refresh_token(self): + async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = await self.oauth2_client.fetch_token(self.auth_url) diff --git a/fragment/sync_sdk/sync_client.py b/fragment/sync_sdk/sync_client.py index 30700b8..3f1fdf4 100644 --- a/fragment/sync_sdk/sync_client.py +++ b/fragment/sync_sdk/sync_client.py @@ -35,11 +35,11 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope) - def refresh_token(self): + def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = self.oauth2_client.fetch_token(self.auth_url) diff --git a/pyproject.toml b/pyproject.toml index 27e6d14..8c2547a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,27 @@ mypy_path = "tests/snapshots/001-marketing-schema" # This turns the day it stops being rejected into a failure, not a silent pass. warn_unused_ignores = true +# The SDK is what customers typecheck against, so it is held to strict. The +# codegen package is build tooling and stays on the default settings. Spelled +# out rather than `strict = true`, which mypy only honours globally. +[[tool.mypy.overrides]] +module = [ + "fragment.sdk.*", + "fragment.sync_sdk.*", + "fragment.client.*", + "fragment.exceptions", +] +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_decorators = true +no_implicit_reexport = true +warn_return_any = true +strict_equality = true +extra_checks = true + [tool.pylint.messages_control] max-line-length = 88 disable = [ diff --git a/tests/snapshots/001-marketing-schema/sdk/async_client.py b/tests/snapshots/001-marketing-schema/sdk/async_client.py index 98e1f61..bd0068b 100644 --- a/tests/snapshots/001-marketing-schema/sdk/async_client.py +++ b/tests/snapshots/001-marketing-schema/sdk/async_client.py @@ -37,13 +37,13 @@ def __init__( super().__init__(url=api_url, http_client=http_client) self.auth_url = auth_url - self.expiration_time = None - self.token = None + self.expiration_time: Optional[float] = None + self.token: Optional[Dict[str, Any]] = None self.oauth2_client = AsyncOAuth2Client( client_id, client_secret, scope=auth_scope ) - async def refresh_token(self): + async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: self.token = await self.oauth2_client.fetch_token(self.auth_url) diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..ce3eeff --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,55 @@ +"""Tests for what the built distribution contains. + +`poetry install` puts the source tree on the path, so `fragment/py.typed` is +found whether or not the build is configured to ship it. Only the built wheel +shows what a customer gets, and without the marker a type checker skips the +installed package entirely -- every call into the SDK goes unchecked, and the +typed batch payloads become decoration. + +Offline; needs poetry on PATH. +""" + +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +MARKER = "fragment/py.typed" + +pytestmark = pytest.mark.skipif( + shutil.which("poetry") is None, reason="needs poetry to build the wheel" +) + + +@pytest.fixture(scope="module") +def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path: + output = tmp_path_factory.mktemp("dist") + subprocess.run( + ["poetry", "build", "--format", "wheel", "--output", str(output)], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ) + built = list(output.glob("*.whl")) + assert len(built) == 1, built + return built[0] + + +def test_the_marker_file_exists_in_the_source_tree() -> None: + assert (REPO_ROOT / MARKER).is_file() + + +def test_the_wheel_ships_the_marker(wheel: Path) -> None: + with zipfile.ZipFile(wheel) as archive: + assert MARKER in archive.namelist(), archive.namelist()[:20] + + +def test_the_wheel_ships_both_sdks(wheel: Path) -> None: + """A marker only helps for modules that are actually packaged.""" + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + assert "fragment/sdk/typed_entries.py" in names + assert "fragment/sync_sdk/typed_entries.py" in names From 900a3ed8edd4493283b916f83779182b3c440518 Mon Sep 17 00:00:00 2001 From: Vigneshwer Vaidyanathan Date: Mon, 10 Aug 2026 15:42:14 -0400 Subject: [PATCH 15/15] Narrow the token before reading expires_in The strict settings this branch adds surface an error the SDK already had: `self.token` is declared `dict | None`, so reading the attribute straight back after assigning it is not narrowed. Value of type "dict[str, Any] | None" is not indexable Assign through a local instead. Applied to both hand-written base clients and regenerated, which covers the copies in fragment/sdk, fragment/sync_sdk and the snapshot. fragment/sync_sdk/async_client.py is patched by hand because nothing regenerates it: codegen writes sync_client.py for that package, and this is a leftover from before the sync base client existed. Nothing imports it. --- fragment/client/async_client.py | 7 +++++-- fragment/client/sync_client.py | 7 +++++-- fragment/sdk/async_client.py | 7 +++++-- fragment/sync_sdk/async_client.py | 7 +++++-- fragment/sync_sdk/sync_client.py | 7 +++++-- tests/snapshots/001-marketing-schema/sdk/async_client.py | 7 +++++-- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/fragment/client/async_client.py b/fragment/client/async_client.py index 7206b65..cd2f3e9 100644 --- a/fragment/client/async_client.py +++ b/fragment/client/async_client.py @@ -44,8 +44,11 @@ def __init__( async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = await self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = await self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] async def execute( self, diff --git a/fragment/client/sync_client.py b/fragment/client/sync_client.py index d8fa02d..41796bf 100644 --- a/fragment/client/sync_client.py +++ b/fragment/client/sync_client.py @@ -40,8 +40,11 @@ def __init__( def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] def execute( self, diff --git a/fragment/sdk/async_client.py b/fragment/sdk/async_client.py index bd0068b..2d8bb46 100644 --- a/fragment/sdk/async_client.py +++ b/fragment/sdk/async_client.py @@ -46,8 +46,11 @@ def __init__( async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = await self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = await self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] async def execute( self, diff --git a/fragment/sync_sdk/async_client.py b/fragment/sync_sdk/async_client.py index bd0068b..2d8bb46 100644 --- a/fragment/sync_sdk/async_client.py +++ b/fragment/sync_sdk/async_client.py @@ -46,8 +46,11 @@ def __init__( async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = await self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = await self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] async def execute( self, diff --git a/fragment/sync_sdk/sync_client.py b/fragment/sync_sdk/sync_client.py index 3f1fdf4..883e546 100644 --- a/fragment/sync_sdk/sync_client.py +++ b/fragment/sync_sdk/sync_client.py @@ -42,8 +42,11 @@ def __init__( def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] def execute( self, diff --git a/tests/snapshots/001-marketing-schema/sdk/async_client.py b/tests/snapshots/001-marketing-schema/sdk/async_client.py index bd0068b..2d8bb46 100644 --- a/tests/snapshots/001-marketing-schema/sdk/async_client.py +++ b/tests/snapshots/001-marketing-schema/sdk/async_client.py @@ -46,8 +46,11 @@ def __init__( async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: - self.token = await self.oauth2_client.fetch_token(self.auth_url) - self.expiration_time = now + self.token["expires_in"] + # Held in a local because `self.token` is declared `dict | None`, + # so reading the attribute back is not narrowed by the assignment. + token = await self.oauth2_client.fetch_token(self.auth_url) + self.token = token + self.expiration_time = now + token["expires_in"] async def execute( self,