diff --git a/CHANGELOG.md b/CHANGELOG.md index 6096153..8374f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,26 @@ # Changelog +All notable changes to `fragment-dev` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +Releases prior to `2.0.0` were published before this changelog was added and +are not documented here. + ## [2.1.0] ### Added -- `add_ledger_entries` commits a batch of Ledger Entries atomically. It accepts +- `add_ledger_entries` posts a batch of Ledger Entries atomically. It accepts raw `AddLedgerEntryInput` hashes, typed payloads, or both in one batch, and preserves their order. -- Typed batch payloads. `FragmentClient::TypedEntries.load` derives one payload - class per `(Ledger Entry type, typeVersion)` from the per-entry-type - `addLedgerEntry` operations the Fragment CLI generates for your Schema, so a - batch can be built with real parameter names instead of untyped hashes: +- Strongly-typed batch payloads. `FragmentClient::TypedEntries.load` derives one + payload class per `(Ledger Entry type, typeVersion)` from the per-entry-type + `addLedgerEntry` operations the Fragment CLI generates for your Schema. Because a + batch mutation takes one list of one input type, GraphQL cannot type each entry's + `parameters` field individually; these payloads do. Payload names always carry the + entry type version, defaulting to `V1`: ```ruby fragment.add_ledger_entries(entries: [ diff --git a/README.md b/README.md index 809e320..28c4009 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [Fragment](https://fragment.dev) is the Ledger API for engineers that move money. Stop wrangling payment tables, debugging balance errors and hacking together data pipelines. Start shipping the features that make a difference. +See [CHANGELOG.md](CHANGELOG.md) for release notes and upgrade guidance. + ## Installation To install the Fragment SDK for Ruby, you'll need to install the gem. Run the following command in your terminal: @@ -34,7 +36,7 @@ fragment = FragmentClient.new( ### Post a Ledger Entry -To post a Ledger Entry defined in your schema: +To [post](https://fragment.dev/guides/post-ledger-entries#post-to-the-api) a Ledger Entry defined in your Schema: ```ruby fragment.add_ledger_entry({ @@ -51,95 +53,36 @@ fragment.add_ledger_entry({ ### Post a batch of Ledger Entries -`add_ledger_entries` commits a batch atomically — either every entry commits or -none do. Idempotency keys are per entry, so a retried batch reports `isIkReplay` -for each one. - -```ruby -result = fragment.add_ledger_entries(entries: [ - { - ik: "some-ik", - entry: { - ledger: { ik: "your-ledger-ik" }, - type: "user_funds_account", - posted: "1968-01-01T16:45:00Z", - parameters: { user_id: "user-1", funding_amount: "200" } - } - } -]) - -case result.data.add_ledger_entries.__typename -when "AddLedgerEntriesResult" - result.data.add_ledger_entries.results.each { |r| puts [r.entry.ik, r.is_ik_replay].inspect } -when "AddLedgerEntriesError" - # One element per failing entry, each carrying the ik that identifies it. - result.data.add_ledger_entries.errors.each { |e| warn "#{e.ik}: #{e.message}" } -end -``` - -Writing those nested hashes by hand is easy to get wrong, and nothing checks the -parameter names against your Schema. The next section is about not doing that. - -### Typed batch payloads - -The Fragment CLI generates a per-entry-type `addLedgerEntry` operation for each -Ledger Entry in your Schema. Those operations know two things GraphQL cannot -express for a batch: the entry type, and the type of every parameter. The SDK -derives a payload class per Ledger Entry from them. - -Pass the `.graphql` file as an extra queries file and the payloads are registered -for you: +To [post](https://fragment.dev/guides/post-ledger-entries#batch-ledger-entries) a +batch of Ledger Entries atomically: ```ruby -fragment = FragmentClient.new( - 'your-client-id', - 'your-client-secret', - extra_queries_filenames: ['app/graphql/entries.graphql'] -) - fragment.add_ledger_entries(entries: [ FragmentClient::Entries::UserFundsAccountV1.new( - ik: "some-ik", + ik: "some-ik-1", ledger_ik: "your-ledger-ik", posted: "1968-01-01T16:45:00Z", user_id: "user-1", - funding_amount: "200" + funding_amount: "20000" + ), + FragmentClient::Entries::UserFundsAccountV1.new( + ik: "some-ik-2", + ledger_ik: "your-ledger-ik", + posted: "1968-01-01T16:45:00Z", + user_id: "user-2", + funding_amount: "20000" ) ]) ``` -A payload is named for its Ledger Entry type and the version it posts, so adding -`user_funds_account` v2 later leaves every existing `...V1` call site alone. A -misspelled or missing parameter raises immediately rather than reaching the API, -and typed payloads can be mixed with the raw hashes above in a single batch. - -Nothing you did not set is sent. An omitted field is absent from the request; an -explicit `nil` is sent as `null`, because those mean different things to the API. - -Readers behave the way you would expect either way — an unset field reads as `nil`, -so `entry.posted&.iso8601` and `if entry.description` do the obvious thing. When -you need to tell "never set" from "set to `nil`", ask: - -```ruby -entry = FragmentClient::Entries::UserFundsAccountV1.new( - ik: "some-ik", ledger_ik: "your-ledger-ik", user_id: "user-1", funding_amount: "200" -) -entry.posted #=> nil -entry.set?(:posted) #=> false - -entry.to_entry_input # the exact hash that goes on the wire, if you want to inspect it -``` - -Payloads compare by value, so they are straightforward to assert on in your own -tests. +Construct the entries in the batch using the typed payloads generated for your +Schema, named `V` under `FragmentClient::Entries`. -You can register payloads without constructing a client — no credentials, no +Payloads can also be registered without constructing a client — no credentials, no network: ```ruby FragmentClient::TypedEntries.load('app/graphql/entries.graphql') -FragmentClient::TypedEntries.fetch('user_funds_account', 1) -#=> FragmentClient::Entries::UserFundsAccountV1 ``` ### Sorbet @@ -159,7 +102,7 @@ does not declare. ### Sync Transactions -To sync transaction using a custom link: +To sync transaction using a [Custom Link](https://fragment.dev/guides/sync-payments#custom-link): ```ruby fragment.sync_custom_accounts({ @@ -197,7 +140,7 @@ fragment.sync_custom_txs({ ### Reconcile a Transaction -To reconcile a transaction: +To [reconcile](https://fragment.dev/guides/reconcile-payments#reconcile-a-tx) a transaction: ```ruby fragment.reconcile_tx({ @@ -250,7 +193,7 @@ ledger_entry_details = ledger_entry_response.data.ledger_entry ### Get a Ledger Account with Balance -To get the balance details of a specific ledger account: +To read a Ledger Account's [balance](https://fragment.dev/guides/read-balances#latest): ```ruby ledger_account_balance_response = fragment.get_ledger_account_balance({ diff --git a/lib/fragment.schema.json b/lib/fragment.schema.json index af8e4d5..8c6cc02 100644 --- a/lib/fragment.schema.json +++ b/lib/fragment.schema.json @@ -14283,6 +14283,16 @@ }, "defaultValue": null }, + { + "name": "payments", + "description": "EXPERIMENTAL: The Payment Types to add to the Schema.", + "type": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentsInput", + "ofType": null + }, + "defaultValue": null + }, { "name": "scenes", "description": "Any scenes associated with this Schema.", @@ -15038,6 +15048,101 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentAccountingInput", + "description": "EXPERIMENTAL: The Ledger Entries a Payment Type posts as a payment moves\nthrough its lifecycle, keyed by lifecycle transition.", + "fields": null, + "inputFields": [ + { + "name": "needs_confirmation_to_processing", + "description": "Posted when the payment enters processing. Optional.", + "type": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentEntryInput", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "processing_to_settled", + "description": "Posted when the payment settles. Every Payment Type must define it.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentEntryInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentEntryInput", + "description": "EXPERIMENTAL: The Ledger Entry a Payment Type posts on a payment lifecycle event.", + "fields": null, + "inputFields": [ + { + "name": "description", + "description": "Human-readable description of the payment entry.", + "type": { + "kind": "SCALAR", + "name": "ParameterizedString", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "lines", + "description": "The Ledger Lines in the payment entry.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentLineInput", + "ofType": null + } + } + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "SchemaPaymentEntryStatus", + "description": "The status of a Payment Type.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": "The Payment Type is active.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "SchemaPaymentInput", @@ -15063,6 +15168,265 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentLineInput", + "description": "EXPERIMENTAL: A Ledger Line in a payment entry.", + "fields": null, + "inputFields": [ + { + "name": "account", + "description": "The Ledger Account this line will be posted to.\nIt supports parameters in its attributes via handlebars syntax.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaLedgerAccountMatchInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "amount", + "description": "The amount of the line. It supports parameters via the handlebars syntax and addition (+) and subtraction (-).", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ParameterizedString", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "currency", + "description": "The currency of the line. This is required if the Ledger Account has currencyMode multi.\nIt supports parameters in its attributes via handlebars syntax.", + "type": { + "kind": "INPUT_OBJECT", + "name": "SchemaCurrencyMatchInput", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "description", + "description": "Human-readable description of the line.", + "type": { + "kind": "SCALAR", + "name": "ParameterizedString", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "key", + "description": "The key for the line. Keys must be unique within a payment entry.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "SafeString", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "system", + "description": "Marks this as a system-owned line. Fragment fills the amounts of system\nlines when the payment entry is posted.", + "type": { + "kind": "ENUM", + "name": "SchemaSystemLineKind", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentTypeDetailsInput", + "description": "EXPERIMENTAL: The payment a Payment Type creates.", + "fields": null, + "inputFields": [ + { + "name": "amount", + "description": "The amount requested for the payment, as a parameterized expression filled\nfrom createPayment parameters.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ParameterizedString", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "direction", + "description": "The direction the payment moves money.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "SchemaPaymentTypeDirection", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "SchemaPaymentTypeDirection", + "description": "The direction a Payment Type moves money.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "payin", + "description": "Money moves into the Payment Account.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payout", + "description": "Money moves out of the Payment Account.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentTypeInput", + "description": "EXPERIMENTAL: A Payment Type in a Schema. All Payment Types defined in a\nSchema must have a unique `type` and `typeVersion` pair.", + "fields": null, + "inputFields": [ + { + "name": "accounting", + "description": "The Ledger Entries posted as the payment moves through its lifecycle.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentAccountingInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "payment", + "description": "The payment this Payment Type creates.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentTypeDetailsInput", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "status", + "description": "The status of this Payment Type.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "SchemaPaymentEntryStatus", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "type", + "description": "The type of this Payment Type. This is a stable, unique identifier for it.\nUniqueness is enforced at the Schema level.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "SafeString", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "typeVersion", + "description": "The version of the Payment Type.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentsInput", + "description": "EXPERIMENTAL: The Payment Types in your Schema.", + "fields": null, + "inputFields": [ + { + "name": "types", + "description": "A list of Payment Type definitions.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SchemaPaymentTypeInput", + "ofType": null + } + } + } + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "SchemaRepeatedConfigInput", @@ -15088,6 +15452,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "SchemaSystemLineKind", + "description": "Identifies a system-owned line in a payment entry. The amounts of system\nlines are filled by Fragment when the payment entry is posted.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "payment_fee_line", + "description": "The line carrying the Fragment fee amount, posted to the Payment Account.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_settlement_line", + "description": "The line carrying the settled payment amount, posted to the Payment Account.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "SchemaTxMatchInput", diff --git a/test/add_ledger_entries_test.rb b/test/add_ledger_entries_test.rb index 4622b7e..449a8f2 100644 --- a/test/add_ledger_entries_test.rb +++ b/test/add_ledger_entries_test.rb @@ -79,8 +79,7 @@ def test_a_payload_converts_to_a_hash_under_either_name def test_raw_entries_work_without_loading_any_typed_payloads # The batch method is not conditional on the typed-payload machinery: a client - # constructed with no extra queries still posts a batch of plain hashes, which - # is the first example in the README. + # constructed with no extra queries still posts a batch of plain hashes. entry = { ik: 'raw-1', entry: { ledger: { ik: 'prod' }, type: 'user_funds_account', parameters: { user_id: 'user-1' } } }