FRAGMENT is a toolkit for building products that move and track money. It includes an API and Dashboard for designing, implementing, and operating your Ledger.
Don't have a FRAGMENT workspace? Get access.
A Ledger uses a Schema to define functionality for a specific product and use case. A Schema may be shared across multiple Ledgers. Updating a Schema will trigger migrations to update each Ledger. Use the Ledger designer in the Dashboard to model and store your Schema.
Ledgers track money using:
A Ledger Account has a balance. Changes to a Ledger Account's balance are called Ledger Lines.
There are four types of Ledger Accounts, split into two layers:
State
Change
State Ledger Accounts track your product's financial relationships with your bank, payment systems and users. Balance Sheets are a report of State Ledger Account balances.
Change Ledger Accounts track when and how your product makes a profit or loss. They produce Income Statements.
Within a Schema, the chartOfAccounts
key contains a nested tree of Ledger Accounts, up to a maximum depth of 10:
Ledger Accounts that share a parent require a unique key
, but the same key
can be used in different parts of the tree.
For some Ledger Accounts, you must set additional properties:
linkedAccount
enables reconciliation with an external systemtemplate
allows multiple instances to be created on demandcurrencyMode
configures the account's currency mode, single
or multi
consistencyConfig
configures the whether the Ledger Account's balances and Ledger Lines are strongly or eventually consistentA Ledger Entry is a single update to a Ledger. Define a Ledger Entry type in your Schema for every financial event in your product and bank.
A Ledger Entry must be balanced, which means it follows the Accounting Equation:
How you balance a Ledger Entry depends upon its net effect to the Ledger's balances.
When the net change to the State Ledger Accounts is zero, the financial event being recorded did not change the net worth of the business. In this example, an increase to an asset account is balanced by an increase in a liability account:
When the net change to the State Ledger Accounts is non-zero, the financial event being recorded made a profit or loss. In this example, a difference in asset and liability accounts is balanced by an increase in an income account.
Once you've designed your Ledger, you can deploy it using the dashboard, the API, or by embedding our CLI in your CI.
You can edit and store your Schema and create Ledgers directly from the FRAGMENT Dashboard. This is useful during development, but is not recommended for production workflows.
You can call the API to store your Schema and create Ledgers. This is useful if you want to automate your Ledger deployment or have multiple environments which you want to keep in-sync. If you are creating many Schemas and Ledgers, you can also call the storeSchema
and createLedger
APIs directly from your product.
Call storeSchema
to store a Schema. Depending on your use case, you may share one Schema for all your users, or create a Schema per user.
mutation QuickstartStoreSchema($schema: SchemaInput!) {
storeSchema(schema: $schema) {
... on StoreSchemaResult {
schema {
key
name
version {
version
created
json
}
}
}
... on Error {
code
message
}
}
}
{
"schema": {
"key": "quickstart-schema",
"name": "Quickstart Schema",
"chartOfAccounts": {
"defaultCurrency": {
"code": "USD"
},
"defaultCurrencyMode": "single",
"accounts": [
{
"key": "assets",
"type": "asset",
"children": [
{
"key": "banks",
"children": [
{
"key": "user-cash"
}
]
}
]
},
{
"key": "liabilities",
"type": "liability",
"children": [
{
"key": "users",
"template": true,
"consistencyConfig": {
"ownBalanceUpdates": "strong"
},
"children": [
{
"key": "available"
},
{
"key": "pending"
}
]
}
]
},
{
"key": "income",
"type": "income"
},
{
"key": "expense",
"type": "expense",
"children": []
}
]
},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Funding {{user_id}} for {{funding_amount}}.",
"lines": [
{
"account": { "path": "assets/banks/user-cash" },
"key": "funds_arrive_in_bank",
"amount": "{{funding_amount}}"
},
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"key": "increase_user_balance",
"amount": "{{funding_amount}}"
}
]
},
{
"type": "p2p_transfer",
"description": "P2P of {{transfer_amount}} from {{from_user_id}} to {{to_user_id}}.",
"lines": [
{
"account": {
"path": "liabilities/users:{{from_user_id}}/available"
},
"key": "decrease_from_user",
"amount": "-{{transfer_amount}}"
},
{
"account": { "path": "liabilities/users:{{to_user_id}}/available" },
"key": "increase_to_user",
"amount": "{{transfer_amount}}"
}
],
"conditions": [
{
"account": {
"path": "liabilities/users:{{from_user_id}}/available"
},
"postcondition": {
"ownBalance": {
"gte": "0"
}
}
}
]
}
]
}
}
}
Create a Ledger using the createLedger
mutation.
mutation QuickstartCreateLedger(
$ik: SafeString!
$ledger: CreateLedgerInput!
$schema: SchemaMatchInput
) {
createLedger(
ik: $ik,
ledger: $ledger,
schema:$schema
) {
... on CreateLedgerResult {
ledger {
ik
name
created
schema {
key
}
}
}
... on Error {
code
message
}
}
}
The schema.key
field is set to the key
from the storeSchema
API call.
{
"ik": "quickstart-ledger",
"ledger": {
"name": "Quickstart Ledger"
},
"schema": {
"key": "quickstart-schema"
}
}
The FRAGMENT CLI can be installed in your CI and used to store your Schema.
Here's an example of how you can use the CLI in a Github Action workflow:
- name: Set up Homebrew
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Install Fragment CLI
run: |
brew tap fragment-dev/tap
brew install fragment-dev/tap/fragment-cli
echo "Fragment CLI installed"
- name: Authenticate with Fragment
run: |
fragment login \
--client-id ${{ vars.FRAGMENT_CLIENT_ID }} \
--client-secret ${{ vars.FRAGMENT_CLIENT_SECRET }} \
--api-url ${{ vars.FRAGMENT_API_URL }} \
--oauth-url ${{ vars.FRAGMENT_OAUTH_URL }} \
--oauth-scope ${{ vars.FRAGMENT_OAUTH_SCOPE }}
- name: Store Schema
run: |
fragment store-schema --path my-schema.jsonc
Read the CLI Command Reference to learn more about the FRAGMENT CLI.
FRAGMENT publishes SDKs in TypeScript, Go, and Ruby. The SDKs are open-source, implement authentication, and come with predefined GraphQL queries to help you get started.
The FRAGMENT CLI is used to generate GraphQL queries specific to your Schema. The SDKs use the generated queries to give you a strongly-typed interface for posting Ledger Entries defined in your Ledger.
Install the CLI using Homebrew:
brew tap fragment-dev/tap &&\
brew install fragment-dev/tap/fragment-cli
Authenticate the CLI to your FRAGMENT workspace, run the login
command:
fragment login
The TypeScript Node SDK is available at @fragment-dev/node-client
.
npm install --save @fragment-dev/node-client
yarn add @fragment-dev/node-client
Create an API client in the FRAGMENT dashboard. Initialize the SDK using the credentials:
import {
createFragmentClient
} from '@fragment-dev/node-client';
const fragment = createFragmentClient({
params: {
clientId: "<Client ID>",
clientSecret: "<Client Secret>",
apiUrl: "<API URL>",
authUrl: "<OAuth URL>",
scope: "<OAuth Scope>",
},
});
// Verify the SDK is authenticated by retrieving
// the workspace
const { workspace } = await fragment.getWorkspace();
console.log('Workspace Name:', workspace.name);
Read the SDK's README for additional code examples.
This workflow is a two-step process:
Run the get-schema
CLI command to download your Schema locally to fragment/schema.jsonc
:
fragment get-schema --output=fragment/schema.jsonc
Run the gen-graphql
CLI command to generate the GraphQL queries:
fragment gen-graphql \
--path=fragment/schema.jsonc \
--output=fragment/queries.graphql
This will create a queries.graphql
file which you will use to generate the GraphQL client.
Run the TypeScript codegen to generate the GraphQL client code:
npx fragment-node-client-codegen \
--input=fragment/queries.graphql \
--outputFilename=fragment/fragment-client.ts
yarn fragment-node-client-codegen \
--input=fragment/queries.graphql \
--outputFilename=fragment/fragment-client.ts
Pass the getSdk
function from the generated file to createFragmentClient
to use the queries:
import {
createFragmentClient
} from "@fragment-dev/node-client";
import {
getSdk
} from './fragment/fragment-client';
const fragment = createFragmentClient({
params: {
clientId: "<Client ID>",
clientSecret: "<Client Secret>",
apiUrl: "<API URL>",
authUrl: "<OAuth URL>",
scope: "<OAuth Scope>",
},
getSdk,
});
// The returned client includes the
// pre-defined queries as well as
// the queries generated by the CLI.
await fragment.Post_YourLedgerEntry({});
The Python SDK is available at github.com/fragment-dev/fragment-python
.
To install:
pip install fragment-python
poetry add fragment-python
Create an API client in the FRAGMENT dashboard. Initialize the client using the credentials:
from fragment.sdk.client import Client
client = Client(
client_id="<Client ID>",
client_secret="<Client Secret>",
api_url="<API URL>",
auth_url="<OAuth URL>",
auth_scope="<OAuth Scope>",
)
async def print_workspace():
response = await client.get_workspace()
print(response.workspace.name)
import asyncio
loop.get_event_loop().run_until_complete(
print_workspace())
Read the SDK's README for additional code examples.
This workflow is a two-step process:
Run the get-schema
CLI command to download your Schema locally to fragment/schema.jsonc
:
fragment get-schema --output=fragment/schema.jsonc
Run the gen-graphql
CLI command to generate the GraphQL queries:
fragment gen-graphql \
--path=fragment_lib/schema.jsonc \
--output=fragment_lib/queries/queries.graphql
This will create a queries.graphql
file which you will use to generate the GraphQL client.
Run the Python codegen to generate the Python GraphQL client code:
fragment-python-client-codegen \
--input-dir=fragment_lib/queries \
--target-package-name=sdk \
--output-dir=fragment_lib
You can optionally provide the --sync
flag to generate a synchronous client.
Instantiate the generated client from fragment_lib/sdk/client.py
:
from .fragment_lib.sdk.client import Client
# The generated client includes the pre-defined
# queries as well as the queries generated by the CLI.
client = Client(
client_id="<Client ID>",
client_secret="<Client Secret>",
api_url="<API URL>",
auth_url="<OAuth URL>",
auth_scope="<OAuth Scope>",
)
async def print_workspace_and_post_entry():
response = await client.get_workspace()
print(response.workspace.name)
await client.post_your_ledger_entry_type(...)
import asyncio
loop.get_event_loop().run_until_complete(
print_workspace_and_post_entry())
The Go SDK is available at github.com/fragment-dev/fragment-go
.
To install, run:
go get 'github.com/fragment-dev/fragment-go'
Create an API client in the FRAGMENT dashboard. Initialize the SDK using the credentials:
package main
import (
"context"
"fmt"
"github.com/fragment-dev/fragment-go/auth"
"github.com/fragment-dev/fragment-go/queries"
)
func main() {
ctx, err := auth.GetAuthenticatedContext(
context.Background(),
&auth.GetTokenParams{
ClientID: "<Client ID>",
ClientSecret: "<Client Secret>",
ApiUrl: "<API URL>",
AuthUrl: "<OAuth URL>",
Scope: "<OAuth Scope>",
}
)
if err != nil {
fmt.Println(err)
return
}
// Verify the SDK is authenticated by retrieving
// the workspace
response, _ := queries.GetWorkspace(ctx)
}
Read the SDK's README for additional code examples.
This workflow is a two-step process:
Run the get-schema
CLI command to download your Schema locally to fragment/schema.jsonc
:
fragment get-schema --output=fragment/schema.jsonc
Run the gen-graphql
CLI command to generate the GraphQL queries:
fragment gen-graphql \
--path=fragment/schema.jsonc \
--output=fragment/queries.graphql
This will create a queries.graphql
file which you will use to generate the corresponding Go methods.
Run the Go SDK codegen to generate methods for each GraphQL query:
go run github.com/fragment-dev/fragment-go \
--input=fragment/queries.graphql \
--output=fragment/generated.go \
--package=main
You can then issue the GraphQL request from your main
package:
package main
import (
"context"
"fmt"
"github.com/fragment-dev/fragment-go/auth"
)
func main() {
ctx, err := auth.GetAuthenticatedContext(
context.Background(),
&auth.GetTokenParams{
ClientID: "<Client ID>",
ClientSecret: "<Client Secret>",
ApiUrl: "<API URL>",
AuthUrl: "<OAuth URL>",
Scope: "<OAuth Scope>",
}
)
if err != nil {
fmt.Println(err)
return
}
response, _ := Post_YourLedgerEntryType(
ctx,
...
)
}
The Ruby SDK is available at fragment-dev
.
To install, run:
gem install fragment-dev
Create an API client in the FRAGMENT dashboard. Initialize the SDK using the credentials:
require 'fragment_client'
fragment = FragmentClient.new(
"<Client ID>",
"<Client Secret>",
api_url: "<API URL>",
oauth_url: "<OAuth URL>",
oauth_scope: "<OAuth Scope>"
)
// Verify the SDK is authenticated by retrieving
// the workspace
workspace = fragment.get_workspace()
Read the SDK's README for additional code examples.
This workflow is a two-step process:
Run the get-schema
CLI command to download your Schema locally to fragment/schema.jsonc
:
fragment get-schema --output=fragment/schema.jsonc
Run the gen-graphql
CLI command to generate the GraphQL queries:
fragment gen-graphql \
--path=fragment/schema.jsonc \
--output=fragment/queries.graphql
This will create a queries.graphql
file which you will provide as input to FragmentClient
.
Initialize FragmentClient
with the extra_queries_filenames
keyword argument set to the path of the generated queries.graphql
file:
require 'fragment_client'
fragment = FragmentClient.new(
"<Client ID>",
"<Client Secret>",
api_url: "<API URL>",
oauth_url: "<OAuth URL>",
oauth_scope: "<OAuth Scope>",
extra_queries_filenames:
["path/to/queries.graphql"]
)
fragment.post_your_ledger_entry_type()
FRAGMENT exposes a GraphQL API to write and read your data. The latest GraphQL schema is hosted at:
https://api.fragment.dev/schema.graphql
To create an SDK in a language not listed above:
Run the get-schema
CLI command to download your Schema locally to fragment/schema.jsonc
:
fragment get-schema --output=fragment/schema.jsonc
Run the gen-graphql
CLI command to generate the GraphQL queries:
fragment gen-graphql \
--path=fragment/schema.jsonc \
--output=fragment/queries.graphql
Provide the optional --include-standard-queries
flag to include the set of standard GraphQL queries in the output.
This will create a queries.graphql
file which you will provide as input to your codegen tool.
Some codegen tools require each GraphQL query to be in a separate file. You can use the --output-file-per-query
flag:
fragment gen-graphql \
--path=fragment/schema.jsonc \
--output=fragment/queries \
--output-file-per-query
Use a GraphQL codegen tool to generate the SDK. A list of clients is available on the GraphQL website.
You will need to implement authentication by customizing the generated SDK. FRAGMENT uses OAuth2's client credentials flow to authenticate API clients. Read the API Authentication section to learn more.
You should also add support for handling errors and retries. See the API Errors section.
Posting a Ledger Entry to your Ledger is a two-step process:
Ledger Entries are defined in your Schema under the ledgerEntries.types
key. Every Ledger Entry has a type
that must be unique within the Schema.
{
"key": "schema-key",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Fund {{user_id}} for {{funding_amount}}",
"lines": [
{
"account": {
"path": "assets/banks/user-cash"
},
"key": "funds_arrive_in_bank",
"amount": "{{funding_amount}}"
},
{
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"key": "increase_user_balance",
"amount": "{{funding_amount}}"
}
]
}
]
}
}
The amounts of a Ledger Line can be parameterized using {{handlebar}}
syntax and can contain basic arithmetic (+ or -):
{
"key": "schema-key",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account_with_fee",
"description": "Fund {{user_id}} for {{funding_amount}} with {{fee_amount}} fee",
"lines": [
{
"account": {
"path": "assets/banks/user-cash"
},
"key": "funds_arrive_in_bank",
"amount": "{{funding_amount}}"
},
{
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"key": "increase_user_balance",
"amount": "{{funding_amount}} - {{fee_amount}}"
},
{
"account": { "path": "income/funding-fees" },
"key": "take_fee",
"amount": "{{fee_amount}}"
}
]
}
]
}
}
Ledger Entries must be balanced by the Accounting Equation. If they are not, the Ledger designer throws an error.
Call the addLedgerEntry
mutation to post a Ledger Entry:
mutation AddLedgerEntry(
$ik: SafeString!
$entry: LedgerEntryInput!
) {
addLedgerEntry(
ik: $ik,
entry: $entry
) {
__typename
... on AddLedgerEntryResult {
entry {
type
created
posted
}
lines {
amount
key
description
account {
path
}
}
}
... on Error {
code
message
}
}
}
Set the Ledger Entry's type
and the required parameters
.
{
"ik": "add-ledger-entry",
"entry": {
"ledger": {
"ik": "ledger-ik"
},
"type": "user_funds_account",
"posted": "1234-01-01T01:01:01",
"parameters": {
"user_id": "testing-user",
"funding_amount": "200"
}
}
}
All numbers in FRAGMENT are integers representing the smallest unit, encoded as strings. For example, USD $2.50 is provided as "250".
To ensure a Ledger Entry is only posted once, provide an Idempotency Key ik
to the addLedgerEntry
mutation. This identifies the Ledger Entry and lets you safely retry the API call. See Integrate the API.
Ledger Entries have two timestamps:
posted
, the time the money movement event happened. You provide this to the addLedgerEntry
API call.created
, the time at which the Ledger Entry was posted to the API. FRAGMENT auto-generates this value.You can post entries with a posted
timestamp in the past or future. Posting a Ledger Entry updates all balances from the posted
time into the future.
Linked Ledger Accounts are used for reconciling your Ledger with your external financial systems.
To define a Ledger Entry to a Linked Ledger Account, you must specify the tx
of the Ledger Line. This is the ID of the transaction at the external system.
{
"key": "linked-ledger-account-schema",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "reconciliation-type",
"lines": [
{
"key": "reconciliation-line-key",
"account": { "path": "bank" },
"amount": "{{some_amount}}",
"tx": {
"externalId": "{{bank_transaction_id}}"
}
},
{...other line}
]
}
]
}
}
To post Ledger Entries to a Linked Ledger Account, call reconcileTx
instead of addLedgerEntry
:
mutation ReconcileTx(
$entry: LedgerEntryInput!
) {
reconcileTx(entry: $entry) {
__typename
... on ReconcileTxResult {
entry {
ik
posted
created
}
isIkReplay
lines {
amount
currency {
code
}
}
}
... on Error {
code
message
}
}
}
The query variables are the same as addLedgerEntry
, except posted
and ik
are omitted. They are inferred from the external Tx.
{
"entry": {
"type": "reconciliation-type",
"ledger": {
"ik": "ledger-ik"
},
"parameters": {
"bank_transaction_id": "tx_1234"
}
}
}
Read more about creating and using Linked Ledger Accounts in Reconcile transactions.
If a Ledger Account is in currencyMode: multi
, you must specify the currency
of the Ledger Lines posted to it.
{
"key": "schema-key",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Funding {{user_id}} for {{funding_amount}}",
"lines": [
{
"account": { "path": "assets/banks/user-cash" },
"key": "funds_arrive_in_bank",
"amount": "{{funding_amount}}",
"currency": {
"code": "USD"
}
},
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"key": "increase_user_balance",
"amount": "{{funding_amount}}",
"currency": {
"code": "USD"
}
}
]
}
]
}
}
Read more about how to implement a product that handles multiple currencies in Handle currencies.
Ledger Entry conditions are rules defined in your Schema used to manage concurrency and enforce correctness within your Ledger. If a condition is not met, the Ledger Entry is not posted and the mutation throws a BadRequestError
.
Conditions are defined in the Schema:
{
"type": "user_withdraws_funds",
"description": "{{user_id}} withdraws for {{withdraw_amount}}",
"lines": [
{
"account": { "path": "assets/banks/user-cash" },
"key": "funds_leave_bank",
"amount": "-{{withdraw_amount}}"
},
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"key": "decrease_user_balance",
"amount": "-{{withdraw_amount}}"
}
],
"conditions": [
{
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"postcondition": {
"ownBalance": {
"gte": "0"
}
}
}
]
}
Read more about using Ledger Entry Conditions in Configure consistency.
Ledger Entry Groups provide a way to tie together related Ledger Entries. You can configure them on a Ledger Entry Type in the Schema.
{
"type": "user_initiates_withdrawal",
"description": "{{user_id}} initiates withdrawal",
"lines": [
{
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"key": "decrease_user_balance",
"amount": "-{{withdraw_amount}}"
},
{...other line}
],
"groups": [
{
"key": "withdrawal",
"value": "{{withdrawal_id}}"
}
]
}
Read more about using Ledger Entry Groups in Group Ledger Entries.
You can define a Ledger Entry whose Ledger Lines are defined at runtime.
This can be useful if you're building a product where your end user, not you, defines the Ledger Entry structure. This is common for FRAGMENT users offering accounting services to their users.
To support runtime-defined Ledger Entries, omit the lines
field in the Schema.
{
"type": "runtime_entry",
"description": "Runtime-defined ledger entry"
}
Then, set the lines
field when posting the Ledger Entry using addLedgerEntry
or reconcileTx
:
mutation AddRuntimeLedgerEntry(
$ik: SafeString!
$entry: LedgerEntryInput!
) {
addLedgerEntry(
ik: $ik,
entry: $entry
) {
... on AddLedgerEntryResult {
entry {
type
created
posted
}
lines {
amount
key
description
account {
path
}
}
}
... on Error {
code
message
}
}
}
{
"ik": "add-arbitrary-ledger-entry",
"entry": {
"type": "runtime_entry",
"lines": [
{
"account": { "path": "assets/banks/user-cash" },
"key": "funds_arrive_in_bank",
"amount": "100"
},
{
"account": { "path": "liabilities/users:test-user/available" },
"key": "increase_user_balance",
"amount": "100"
}
]
}
}
Your Ledger should accurately track the real-world movement of money within your product. This is done by reconciling transactions from your external financial systems with your Ledger.
The overall workflow is a three-step process:
This whole process happens through a FRAGMENT Link.
Links are a mirror of external financial systems used to keep your Ledger up to date. Create a Link for each external financial system you have, such as banks, payment processors and card issuers.
For each account in your external financial system, there's a corresponding External Account. External Accounts have a Tx for each transaction in that account.
After you set up the Link, create a Linked Ledger Account, an account in your Ledger that maps 1:1 with an External Account.
Any Ledger Line that posts to a Linked Ledger Account must match a transaction in its corresponding External Account.
Native Links are built-in integrations that automatically sync External Accounts and Txs.
Once you onboard a Native Link, Txs automatically sync periodically and via webhook. Txs also sync when you call the API for reconciliation.
To onboard Stripe Connect, in addition to creating a Stripe link, you'll need to create a Restricted Access Key (RAK) with the following permissions:
Read
for both Permissions and Connect PermissionsRead
for both Permissions and Connect PermissionsRead
for both Permissions and Connect PermissionsRead
Write
. This is used to setup webhooks to FRAGMENT.Once you have the RAK:
Details
pageCustom Links allow you to build your own integration between FRAGMENT and any external financial system. Instead of syncing automatically like a Native Link, you sync External Accounts and Txs by calling the API.
You can either create a Link in the dashboard or using createCustomLink
:
mutation NewCustomLink($name: String!, $ik: SafeString!) {
createCustomLink(name: $name, ik: $ik) {
__typename
... on CreateCustomLinkResult {
link {
id
name
}
isIkReplay
}
... on Error {
code
message
}
}
}
{
"name": "Stripe",
"ik": "dev-stripe"
}
If you only have a few accounts, sync them manually as part of bootstrapping your Ledger.
Otherwise, sync accounts as you create them at the external system. The sync process can run either periodically or by webhook, or both.
Once you have a set of accounts to sync, call syncCustomAccounts
:
mutation CreateBankAccounts(
$link: LinkMatchInput!
$accounts: [CustomAccountInput!]!
) {
syncCustomAccounts(link: $link, accounts: $accounts) {
__typename
... on SyncCustomAccountsResult {
accounts {
id
externalId
name
}
}
... on Error {
code
message
}
}
}
{
"link": {
"id": "some-link-id"
},
"accounts": [
{
"externalId": "bank-account-1",
"name": "Operational Account"
},
{
"externalId": "bank-account-2",
"name": "Reserve Account"
}
]
}
You should ensure that externalId
is a stable and unique identifier for each account, within the scope of its Link. This ensures that syncing is idempotent. externalId
is typically set to the ID of the account at the external system.
Calling syncCustomAccounts
with a different name
for an existing externalId
updates the name of the External Account.
After you set up the Link, create Linked Ledger Accounts, accounts in your Ledger that map 1:1 with accounts at your external financial system.
To define a Linked Ledger Account, set linkedAccount
on a Ledger Account in your Schema:
{
"chartOfAccounts": {
"accounts": [
{
"key": "assets-root",
"name": "Assets",
"type": "asset",
"children": [{
"key": "operating",
"name": "Operating Bank",
"linkedAccount": {
"linkId": "some-link-id",
"externalId": "bank-account-1"
}
}]
}
]
}
}
The linkId
comes from the Link you create in the dashboard. The externalId
is the ID of the account at your external financial system.
The CLI automatically replaces variables with ${ENV_VAR}
syntax when running fragment store-schema
. This lets you use different External Accounts per environment:
{
"chartOfAccounts": {
"accounts": [
{
"key": "assets-root",
"name": "Assets",
"type": "asset",
"children": [{
"key": "operating",
"name": "Operating Bank",
"linkedAccount": {
"linkId": "${BANK_LINK_ID}",
"externalId": "${BANK_ACCOUNT_ID}"
}
}]
}
]
}
}
To create a Linked Ledger Account that is part of a Ledger Account template, parameterize the linkedAccount
field:
{
"chartOfAccounts": {
"accounts": [
{
"key": "assets-root",
"name": "Assets",
"type": "asset",
"children": [{
"key": "operating",
"name": "Operating Bank",
"template": true,
"linkedAccount": {
"linkId": "{{BANK_LINK_ID}}",
"externalId": "{{BANK_ACCOUNT_ID}}"
}
}]
}
]
}
}
These parameters are then required when posting a Ledger Entry to a Linked Ledger Account. This can be useful if you're creating bank accounts per customer, for example.
Transactions at your external financial system are initiated in one of two ways:
Once a transaction occurs, you need to sync it with your FRAGMENT Ledger.
If you're using a Native Link, FRAGMENT automatically syncs transactions. You can skip ahead to Reconcile transactions.
If you're using a Custom Link, your sync process should call syncCustomTxs
. Your sync process can periodically enumerate transactions in your external system or be triggered by webhook.
You may also want to sync and reconcile when your product makes a payment. You should only sync transactions that are settled, not pending or declined.
Once you have a set of transactions to sync, call syncCustomTxs
:
mutation SyncTransactions(
$link: LinkMatchInput!
$txs: [CustomTxInput!]!
) {
syncCustomTxs(link: $link, txs: $txs) {
__typename
... on SyncCustomTxsResult {
txs {
id
externalId
amount
date
description
}
}
... on Error {
code
message
}
}
}
{
"link": { "id": "some-link-id" },
"txs": [
{
"account": {
"externalId": "bank-account-1"
},
"externalId": "tx-1",
"description": "Processed ACH batch",
"amount": "-100",
"posted": "1968-01-01"
},
{
"account": {
"externalId": "bank-account-2"
},
"externalId": "tx-2",
"description": "Received RTP payment",
"amount": "100",
"posted": "1968-01-01T16:45:00Z"
}
]
}
You should ensure that externalId
is a stable and unique identifier for each transaction, within the scope of its account. This identifier enforces idempotentency. This identifier is typically the ID of the transaction at the external system. Make sure you use the lowest level transaction ID available, not the ID of a higher level construct that may be linked to multiple transactions, like a payment.
Calling syncCustomTxs
with a different description
for an existing externalId
updates the name of the External Account. amount
and posted
timestamp are immutable.
You can sync transactions from different accounts in the same API call, but they must all belong to the same Custom Link.
To reconcile a transaction from an external system, follow the same two-step process as when posting Ledger Entries:
Ledger Entries posting to a Linked Ledger Account must specify the Tx from the External Account tied to the Ledger Account. This lets FRAGMENT know which transaction to reconcile:
{
"key": "schema-key",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account_via_link",
"description": "Funding {{user_id}} for {{funding_amount}}",
"lines": [
{
"account": { "path": "assets/operating" },
"key": "funds_arrive_in_bank",
"tx": {
"externalId": "{{bank_transaction_id}}"
}
},
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"key": "increase_user_balance"
}
]
}
]
}
}
Notes:
bank_transaction_id
represents the ID of the transaction at the external system.Instead of calling addLedgerEntry
, Linked Ledger Accounts use the reconcileTx
mutation:
mutation ReconcileTx(
$entry: LedgerEntryInput!
) {
reconcileTx(
entry: $entry
) {
... on ReconcileTxResult {
entry {
type
created
posted
}
lines {
amount
key
description
account {
path
}
}
}
... on Error {
code
message
}
}
}
The parameters look similar to addLedgerEntry
. Specify the Ledger Entry type
that you are using and provide the parameters
defined in the Schema:
{
"entry": {
"type": "user_funding",
"ledger": {
"ik": "ledger-ik"
},
"parameters": {
"txId": "tx_12345",
"customerId": "customer-1"
}
}
}
ik
and posted
are optional when posting Ledger Entries with reconcileTx
:
ik
: the Tx.externalId
is used to ensure that reconciling a transaction is idempotentposted
: the timestamp of the Ledger Entry is taken from the Tx to ensure the Ledger mirrors the external systemBook transfers are a common type of money movement which produce two Txs at your bank as part of one payment.
To reconcile multiple Txs using reconcileTx
:
{
"key": "schema-key",
"chartOfAccounts": {...},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account_via_link",
"description": "Funding {{user_id}} for {{funding_amount}}",
"lines": [
{
"key": "funds_arrive_in_operating_bank",
"account": { "path": "assets/operating-bank-account" },
"tx": {
"externalId": "{{bank_transaction_id}}"
}
},
{
"key": "funds_leave_holding_bank",
"account": { "path": "assets/holding-bank-account" },
"tx": {
"externalId": "{{bank_transaction_id}}"
}
}
]
}
]
}
}
Notes:
posted
timestamp.Transactions synced to FRAGMENT but not reconciled to a Ledger are considered unreconciled.
You can query unreconciled transactions in a Linked Ledger Account using the unreconciledTxs
field on theLedgerAccount
. It is recommended to periodically call this query to ensure your Ledger stays up to date. It can also be used to build reconciliation UIs:
query GetUnreconciledTxs (
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
id
unreconciledTxs {
nodes {
id
description
amount
externalId
externalAccountId
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
The variables specify the path
of the Linked Ledger Account you are querying and the Ledger it belongs to.
{
"ledgerAccount": {
"path": "assets/operating",
"ledger": {...}
}
}
unreconciledTxs
is eventually consistent, so if you've recently transacted at a Native Link or just synced manually for a Custom Link, you may not see the transaction in the query results immediately.
Transactions synced to FRAGMENT within a Stripe Link have special handling. Every Balance Transaction at Stripe has two amount fields:
This doesn't neatly map 1:1 with Fragment's recon model, so FRAGMENT creates two Txs for each Balance Transaction at Stripe: one for the gross amount and the other for the fee. This allows you to account for these amounts independently in your Ledger. The external IDs for these Txs are:
{{stripe_tx_id}}_gross
for the gross amount Tx{{stripe_tx_id}}_fee
for the fee amount TxFor example, a Stripe Balance Transaction with ID txn_123
will result in two Txs in FRAGMENT with external IDs txn_123_gross
and txn_123_fee
. You can reconcile both of these in a single reconcileTx
call.
You can provide up to 10 tags on Ledger Entries to store arbitrary key-value pairs, like IDs from your product.
FRAGMENT supports querying a Ledger Account for its latest balances, historical balances and balance changes.
Use the ledgerAccount
query to look up a Ledger Account's balance.
query GetBalance(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
balance
}
}
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
Ledger Accounts have three balances:
ownBalance
is the sum of all Ledger Lines posted to the Ledger Account, excluding Ledger Lines in child Ledger AccountschildBalance
is the sum of all Ledger Lines posted to the children of this Ledger Accountbalance
is the sum of all Ledger Lines posted to this Ledger Account and its childrenquery GetAggregatedBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
childBalance
balance
}
}
{
"ledgerAccount": {
"path": "liabilities/users"
}
}
Balance reads are eventually consistent by default. This means that the balance may not reflect all the Ledger Lines in the account.
To read a strongly consistent balance, a Ledger Account must have its balances updated in a strongly consistent manner. This is set in the Schema on a Ledger Account's consistencyConfig
:
{
"key": "strongly-consistent-ledger-accounts",
"name": "Strongly consistent Ledger Accounts",
"chartOfAccounts": {
"defaultCurrency": { "code": "USD" },
"accounts": [
{
"key": "liabilities",
"type": "liability",
"children": [
{
"key": "users",
"template": true,
"consistencyConfig": {
"ownBalanceUpdates": "strong"
},
"children": [
{
"key": "available",
"consistencyConfig": {
"ownBalanceUpdates": "strong"
}
},
{
"key": "pending"
},
{
"key": "blocked"
}
]
}
]
}
]
}
}
Once a Ledger Account's balance is updated consistently, set the consistencyMode
on balance queries to determine the consistency of the read you issue.
query GetStronglyConsistentBalance(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalance(consistencyMode: strong)
}
}
consistencyMode
can be set to:
strong
to perform a strongly consistent balance readeventual
to perform an eventually consistent balance readuse_account
to use the value of consistencyConfig.ownBalanceUpdates
when performing a balance read{
"ledgerAccount": {
"path": "liabilities/users:user-1/available",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
Only ownBalance
can be queried with consistencyMode: strong
.
Read the Configure consistency section to learn more about FRAGMENT's consistency semantics and Ledger Account consistency modes.
To query the balance of a Ledger Account at a particular point in time use the at
argument:
query GetHistoricalBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
end_of_year: balance(at: "1969")
end_of_month: balance(at: "1969-07")
end_of_day: balance(at: "1969-07-21")
end_of_hour: balance(at: "1969-07-21T02")
}
}
If you don't specify at
, you'll get the latest balance. at
is supported for all balances and works granularly to the hour.
You can also query the net change on a Ledger Account over a specific reporting period. This can be useful for generating financial statements.
Similar to balances, there are three types of balance changes:
ownBalanceChange
, how much ownBalance
changedchildBalanceChange
, how much childBalance
changedbalanceChange
, how much balance
changedBalance change queries require you to specify a period
. This can be a year, quarter, month, day or hour.
query GetBalanceChanges(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalanceChange(period: "1969")
childBalanceChange(period: "1969")
balanceChange(period: "1969")
currentYear: balanceChange(period: "1969")
lastYear: balanceChange(period: "1968")
lastYearQ4: balanceChange(period: "1968-Q4")
lastYearDecember: balanceChange(period: "1968-12")
lastYearChristmas: balanceChange(period: "1968-12-25")
lastYearLastHour: balanceChange(period: "1968-12-31T23")
}
}
You can also perform multiple balance queries using aliases.
{
"ledgerAccount": {
"path": "liabilities/users:user-1/available",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
For Ledger Accounts in currencyMode: multi
, use the currency
argument to query the balance in a specific currency.
query GetMultiCurrencyBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
latestUSDBalance: balance(currency: { code: USD })
latestGBPBalance: balance(currency: { code: GBP })
USDBalanceChange:
balanceChange(period: "1969", currency: { code: USD })
GBPBalanceChange:
balanceChange(period: "1969", currency: { code: GBP })
}
}
{
"ledgerAccount": {
"path": "liabilities/users:user-1/available",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
You can also query balances in all of a multi-currency Ledger Account's currencies, see Handle currencies.
Balance queries respect the Ledger's balanceUTCOffset
when specifying periods and times. This field is specified when creating the Ledger.
-08:00
, then querying balance(at: "1969-01-31")
returns the balance at midnight PT on that date, or 8am on 1969-02-01 UTC.balanceChange(period: "1969")
returns the net change between 8am on 1969-01-01 UTC and 8am on 1969-01-01 UTC. This gives you the net change between midnights local time.You can flexibly query your data to generate financial reports, embed FRAGMENT in your product and build internal dashboards.
FRAGMENT is a GraphQL API, so data in the system is modeled as a graph. Entities in the API are nodes, and their relationships with other entities are edges. Ledgers have Ledger Accounts; Ledger Accounts and Ledger Entries have Ledger Lines; and so on.
FRAGMENT exposes several queries as entry points to the data graph. They return data about a single entity, or a list of entities.
When making a query, you can fetch related entities using expansions. As opposed to a REST interface, where you may require several round-trips to query nested data, expansions are nested queries that you let you retrieve related data in a single request. For example, you can expand from a Ledger Entry to all the Ledger Lines in it in one API call.
FRAGMENT uses connection types to return lists of entities. A connection type is a list of nodes, and a pageInfo
object that contains cursors to the next and previous pages of results.
query ListLedgerAccounts($ledger: LedgerMatchInput!) {
ledger(ledger: $ledger) {
ledgerAccounts {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
You can filter connection types with a filter
argument.
For example, you can filter a list of Ledger Accounts by their type:
query FilterLedgerAccounts(
$ledger: LedgerMatchInput!,
$filter: LedgerAccountsFilterSet!
) {
ledger(ledger: $ledger) {
ledgerAccounts {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"type": {
"equalTo": "asset"
}
}
}
You can combine filters by adding multiple components to the filter block. Results are AND'd:
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"type": {
"equalTo": "asset",
"hasParentLedgerAccount": true
}
}
}
Fields that return lists support cursor-based pagination:
nodes
property as an array. The pageInfo
property contains cursors pointing to the next page and previous pages.after
(or before
) arguments on list fields to retrieve a specific page.first
(or last
) argument sets the page size. The default is 20 and the maximum is 200.This query uses pagination to retrieve two Ledger Accounts at a time:
query GetLedgerAccounts(
$ledgerIk: SafeString!
$after: String
$first: Int
$before: String
) {
ledger(ledger: { ik: $ledgerIk }) {
ledgerAccounts(
after: $after
first: $first
before: $before
) {
nodes {
path
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledgerIk": "ik-used-to-create-ledger",
"first": 2
}
The response is:
{
"data": {
"ledger": {
"ledgerAccounts": {
"nodes": [
{
"path": "assets/test-assets/test:1",
"name": "Test 1",
"type": "asset"
},
{
"path": "assets/test-assets/test:2",
"name": "Test 2",
"type": "asset"
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "<some-end-cursor>",
"hasPreviousPage": false,
"startCursor": null
}
}
}
}
}
To retrieve the next page, send the same query but with the after
parameter set on ledgerAccounts
:
{
"ledgerIk": "ik-used-to-create-ledger",
"after": "<some-end-cursor>"
}
The response is:
{
"data": {
"ledger": {
"ledgerAccounts": {
"nodes": [
{
"path": "assets/test-assets/test:3",
"name": "Test 3",
"type": "asset"
},
{
"path": "assets/test-assets/test:4",
"name": "Test 4",
"type": "asset"
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": null,
"hasPreviousPage": true,
"startCursor": "<some-start-cursor>"
}
}
}
}
}
To retrieve the previous page of results, send the same query but with the before
parameter set on ledgerAccounts
. The response is the first page of results.
{
"ledgerIk": "ik-used-to-create-ledger",
"before": "<some-start-cursor>"
}
Use the ledger
query to retrieve a Ledger by the IK used to create it:
query GetLedger($ledger: LedgerMatchInput!) {
ledger(ledger: $ledger) {
name
created
balanceUTCOffset
ledgerAccounts {
nodes {
name
type
}
}
schema {
key
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
}
}
Use the ledgers
query to list all the Ledgers in your workspace:
query ListLedgers {
ledgers {
nodes {
name
created
balanceUTCOffset
ledgerAccounts {
nodes {
name
type
}
}
schema {
key
}
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
The response is a paginated list of Ledgers.
Use the ledgerAccount
query to retrieve a Ledger Account by its path in the Schema:
query GetLedgerAccount(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
name
balance
type
lines {
nodes {
amount
posted
}
}
}
}
The IK of the Ledger needs to be provided along with the Ledger Account's path:
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
You can also retrieve multiple Ledger Accounts using the ledgerAccounts
query and the in
filter:
query ListLedgerAccounts($ledger: LedgerMatchInput!) {
ledger(ledger: $ledger) {
ledgerAccounts {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"ledgerAccount": {
"in": [
{
"path": "assets/banks/user-cash"
},
{
"path": "income-root/income-revenue-root"
}
]
}
}
}
Use the ledger.ledgerAccounts
query to list all Ledger Accounts within a Ledger:
query ListLedgerAccounts($ledger: LedgerMatchInput!) {
ledger(ledger: $ledger) {
ledgerAccounts {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
}
}
The response is a paginated list of Ledger Accounts.
Since a Ledger Account can have balances in multiple currencies, you can list its balance and balance changes across all currencies:
query GetLedgerAccountBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
name
balances {
nodes {
amount
currency {
code
customCurrencyId
}
}
}
end_of_year_balances: balances(at: "1969") {
nodes {
amount
currency {
code
customCurrencyId
}
}
}
last_year: balanceChanges(period: "1968") {
nodes {
amount
currency {
code
customCurrencyId
}
}
}
}
}
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
Read more about handling multi-currency balances in Handle currencies.
Use the type
parameter to filter Ledger Account lists:
query FilterLedgerAccounts(
$ledger: LedgerMatchInput!,
$filter: LedgerAccountsFilterSet!
) {
ledger(ledger: $ledger) {
ledgerAccounts(filter: $filter) {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
Use type
to filter Ledger Accounts by their type:
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"type": {
"equalTo": "asset"
}
}
}
You can also filter for multiple types in one query, using in
. This can be useful to Generate reports:
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"type": {
"in": ["asset", "liability"]
}
}
}
Use path
and wildcard matching (*
) in place of template variables to query all instances of Ledger Accounts with template: true
.
query FilterLedgerAccounts(
$ledger: LedgerMatchInput!,
$filter: LedgerAccountsFilterSet!
) {
ledger(ledger: $ledger) {
ledgerAccounts(filter: $filter) {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"path": {
"matches": "liability-root/user:*/pending"
}
}
}
Read more about filtering Ledger Accounts in filtering.
Use linkedAccount
to filter Ledger Accounts by the External Account they're linked to:
query FilterLedgerAccounts(
$ledger: LedgerMatchInput!,
$filter: LedgerAccountsFilterSet!
) {
ledger(ledger: $ledger) {
ledgerAccounts(filter: $filter) {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"linkedAccount": {
"in": [
{
"linkId": "<link id>",
"externalId": "account-id-at-bank"
},
{
"linkId": "<link id>",
"externalId": "account2-id-at-bank"
}
]
}
}
}
Use hasParentLedgerAccount
to filter Ledger Accounts by their parent status:
query FilterLedgerAccounts(
$ledger: LedgerMatchInput!,
$filter: LedgerAccountsFilterSet!
) {
ledger(ledger: $ledger) {
ledgerAccounts(filter: $filter) {
nodes {
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"hasParentLedgerAccount": false
}
}
Read more about filtering Ledger Accounts in filtering.
Use the ledgerLine
query to retrieve a Ledger Line by its ID:
query GetLedgerLine(
$ledgerLine: LedgerLineMatchInput!
) {
ledgerLine(ledgerLine: $ledgerLine) {
amount
currency {
code
customCurrencyId
}
account {
name
type
}
}
}
{
"ledgerLine": {
"id": "<ledger line ID>"
}
}
Use the ledgerAccount.lines
query to list the lines in a Ledger Account:
query GetLedgerAccountLines(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
name
lines {
nodes {
amount
currency {
code
customCurrencyId
}
}
}
}
}
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
Use posted
to filter Ledger Lines by their posted timestamp between any two points in time:
query GetLedgerAccountLines(
$ledgerAccount: LedgerAccountMatchInput!,
$filter: LedgerLinesFilterSet!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
name
lines(filter: $filter) {
nodes {
id
amount
currency {
code
customCurrencyId
}
}
}
}
}
{
"ledgerAccount": {
"path": "assets/banks/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
},
"filter": {
"posted": {
"after": "1969-07-01T00:00:00.000Z",
"before": "1969-07-30T23:59:59.999Z"
}
}
}
The after
and before
filters are inclusive, so use timestamps for the first and last moments of the period you're querying for.
Use key
to filter Ledger Lines by their keys in your Schema:
query GetLedgerAccountLines(
$ledgerAccount: LedgerAccountMatchInput!,
$filter: LedgerLinesFilterSet!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
name
lines(filter: $filter) {
nodes {
id
amount
currency {
code
customCurrencyId
}
}
}
}
}
{
"ledgerAccount": {
"path": "assets/banks/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
},
"linesFilter": {
"key": {
"equalTo": "increase_user_balance"
}
}
}
Use the ledgerEntry
query to retrieve a Ledger Entry by its IK. You provide the IK when posting entries via addLedgerEntry
:
query GetLedgerEntry(
$ledgerEntry: LedgerEntryMatchInput!
) {
ledgerEntry(ledgerEntry: $ledgerEntry) {
id
ik
ledger {
id
name
}
lines {
nodes {
amount
currency {
code
customCurrencyId
}
}
}
}
}
{
"ledgerEntry": {
"ik": "<ledger entry IK>"
}
}
When you Reconcile transactions using reconcileTx
, the IK is the Transaction's externalId
. Query entry.ik
in ReconcileTxResult
to retrieve it:
mutation ReconcileTx(
$entry: LedgerEntryInput!
) {
reconcileTx(entry: $entry) {
... on ReconcileTxResult {
entry {
ik
type
created
posted
}
lines {
amount
account {
path
}
}
}
... on Error {
code
message
}
}
}
A Ledger Entry can also be retrieved using its ID:
{
"ledgerEntry": {
"id": "<ledger entry ID>"
}
}
You can also retrieve multiple Ledger Entries using the ledgerEntries
query and the in
filter:
query ListLedgerEntries(
$ledger: LedgerMatchInput!
$filter: LedgerEntriesFilterSet!
) {
ledger(ledger: $ledger) {
ledgerEntries(filter: $filter) {
nodes {
ik
type
posted
lines {
nodes {
amount
account {
path
}
}
}
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"ledgerEntry": {
"in": [
{
"ik": "fund-user-1-account"
},
{
"ik": "fund-user-2-account"
}
]
}
}
}
You can get a paginated list of Ledger Entries in a given group using the ledgerEntryGroup.ledgerEntries
expansion:
query GetGroupedLedgerEntries(
$ledger: LedgerMatchInput!,
$entryGroup: EntryGroupMatchInput!,
) {
ledger(ledger: $ledger) {
ledgerEntryGroup(ledgerEntryGroup: $entryGroup) {
ledgerEntries {
nodes {
ik
description
posted
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
}
}
{
"ledgerEntryGroup": {
"key": "withdrawal",
"value": "12345"
},
"ledger": {
"ik": "quickstart-ledger"
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroup": {
"ledgerEntries": {
"nodes": [
{
"ik": "ledger-entry-2",
"description":"User user-id withdrawal settled",
"posted": "1969-06-21T02:56:05.000Z"
},
{
"ik": "ledger-entry-1",
"description": "User user-id initiated withdrawal for 50000.",
"posted": "1969-06-16T13:32:00.000Z"
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": null,
"hasPreviousPage": false,
"startCursor": null
}
}
}
}
}
}
Use the ledgerEntry.lines
expansion to list the Ledger Lines in a Ledger Entry:
query GetLedgerEntryLines(
$ledgerEntry: LedgerEntryMatchInput!
) {
ledgerEntry(ledgerEntry: $ledgerEntry) {
id
ik
lines {
nodes {
account {
path
}
amount
currency {
code
customCurrencyId
}
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
}
{
"ledgerEntry": {
"ik": "<ledger entry IK>"
}
}
Similar to Ledger Lines, Ledger Entries can be filtered by their posted timestamp between any two points in time using posted
:
query FilterLedgerEntries(
$ledger: LedgerMatchInput!,
$entriesFilter: LedgerEntriesFilterSet!
) {
ledger(ledger: $ledger) {
ledgerEntries(filter: $entriesFilter) {
nodes {
ik
type
posted
lines {
nodes {
amount
account {
path
}
}
}
}
}
}
}
Use after
and before
to filter Ledger Entries by their posted timestamp between any two points in time:
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"posted": {
"after": "01-01-1968",
"before": "01-01-1969"
}
}
}
Use date
to filter Ledger Entries by the date they were posted on:
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"date": {
"equalTo": "12-31-1968"
}
}
}
Use type
to filter Ledger Entries by their type defined in your Schema:
query FilterLedgerEntries(
$ledger: LedgerMatchInput!,
$entriesFilter: LedgerEntriesFilterSet!
) {
ledger(ledger: $ledger) {
ledgerEntries(filter: $entriesFilter) {
nodes {
ik
type
posted
lines {
nodes {
amount
account {
path
}
}
}
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"type": {
"in": ["withdrawal", "p2p_transfer"]
}
}
}
To retrieve Ledger Entries of multiple types, use the in
operator:
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"type": {
"in": ["withdrawal", "p2p_transfer"]
}
}
}
Use tag
to filter Ledger Entries by tags:
query FilterLedgerEntries(
$ledger: LedgerMatchInput!,
$entriesFilter: LedgerEntriesFilterSet!
) {
ledger(ledger: $ledger) {
ledgerEntries(filter: $entriesFilter) {
nodes {
ik
type
posted
tags {
key
value
}
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"tag": {
"equalTo": {
"key": "user_id",
"value": "user-1"
}
}
}
}
Use the in
operator to return Ledger Entries that have any of the specified tags:
{
"ledger": {
"ik": "quickstart-ledger"
},
"entriesFilter": {
"tag": {
"in": [{
"key": "user_id",
"value": "user-1"
},{
"key": "user_id",
"value": "user-2"
}]
}
}
}
See Group Ledger Entries for more information about how to use Ledger Entry Groups.
Use the ledger.ledgerEntryGroup
expansion to lookup a group by key and value.
query GetLedgerEntryGroup(
$ledger: LedgerMatchInput!
$entryGroup: EntryGroupMatchInput!
) {
ledger(ledger: $ledger) {
ledgerEntryGroup(ledgerEntryGroup: $entryGroup) {
key
value
created
}
}
}
{
"entryGroup": {
"key": "withdrawal",
"value": "12345"
},
"ledger": {
"ik": "quickstart-ledger"
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroup": {
"key": "withdrawal",
"value": "12345",
"created": "1969-06-16T13:32:00.000Z"
}
}
}
}
query ListLedgerEntryGroups(
$ledger: LedgerMatchInput!
) {
ledger(ledger: $ledger) {
ledgerEntryGroups {
nodes {
key
value
created
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroups": {
"nodes": [
{
"key": "withdrawal",
"value": "12345",
"created": "1969-06-16T13:32:00.000Z"
},
{
"key": "withdrawal",
"value": "54321",
"created": "1969-06-21T02:56:05.000Z"
}
],
"pageInfo": {
"endCursor": null,
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null
}
}
}
}
}
You can filter groups by key
, created
, and/or value
query ListLedgerEntryGroups(
$ledger: LedgerMatchInput!
$filter: LedgerEntryGroupsFilterSet,
) {
ledger(ledger: $ledger) {
ledgerEntryGroups(filter: $filter) {
nodes {
key
value
created
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}
}
{
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"key": {
"equalTo": "withdrawal"
},
"created": {
"before": "1969-06-20T00:00:00.000Z"
}
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroups": {
"nodes": [
{
"key": "withdrawal",
"value": "12345",
"created": "1969-06-16T13:32:00.000Z"
}
],
"pageInfo": {
"endCursor": null,
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null
}
}
}
}
}
Use the link
query to retrieve a Link by ID:
query GetLink($link: LinkMatchInput!) {
link(link: $link) {
__typename
name
}
}
{
"link": {
"id": "<Link ID>"
}
}
The __typename
field will indicate whether it is a Native Link or a Custom Link.
Use the link.externalAccounts
to list External Accounts represented by the Link.
query GetLinkExternalAccounts($link: LinkMatchInput!) {
link(link: $link) {
id
externalAccounts {
nodes {
name
id
externalId
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"link": {
"id": "<Link ID>"
}
}
The response is a paginated list of External Accounts.
Use the externalAccount
query to retrieve an ExternalAccount by its ID at your external system:
query GetExternalAccount(
$externalId: ID!
$linkId: ID!
) {
externalAccount(
externalAccount: {
externalId: $externalId
linkId: $linkId
}
) {
name
link {
__typename
id
name
}
}
}
{
"externalId": "<External ID>",
"linkId": "<Link ID>"
}
Or by its FRAGMENT ID:
query GetExternalAccount($id: ID!) {
externalAccount(externalAccount: { id: $id }) {
name
externalId
linkId
link {
__typename
id
name
}
}
}
{
"id": "<Fragment External Account ID>"
}
Use the externalAccount.txs
query to list Txs synced to an External Account:
query ListExternalAccountTxs(
$externalAccount: ExternalAccountMatchInput!
$after: String
$first: Int
$before: String
) {
externalAccount(
externalAccount: $externalAccount
) {
externalId
link {
__typename
id
name
}
txs(
after: $after
first: $first
before: $before
) {
nodes {
externalId
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
You may optionally specify before
and after
to paginate the results and first
to specify the page size.
{
"externalAccount": {
"externalId": "<External ID>",
"linkId": "<Link ID>"
},
"after": "2023-07-01T00:00:00.000Z",
"before": "2023-07-30T23:59:59.999Z",
"first": 20
}
The response is a paginated list of Txs.
Use the externalAccount.ledgerAccounts
query to list Ledger Accounts linked to this External Account:
query GetExternalAccountLinkedAccounts(
$externalAccount: ExternalAccountMatchInput!
) {
externalAccount(
externalAccount: $externalAccount
) {
externalId
name
link {
__typename
id
name
}
ledgerAccounts {
nodes {
path
name
type
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
{
"externalAccount": {
"externalId": "<External ID>",
"linkId": "<Link ID>"
}
}
The response is a paginated list of Ledger Accounts.
Use the tx
query to retrieve a Tx by its ID and Account ID at your external system:
query GetTx(
$externalId: ID!
$externalAccountId: ID!
$linkId: ID!
) {
tx(
tx: {
externalId: $externalId
externalAccountId: $externalAccountId
linkId: $linkId
}
) {
id
description
amount
currency {
code
}
externalId
link {
id
}
externalAccount {
id
externalId
}
}
}
{
"externalAccountId": "<External Account ID>",
"external": "<External Tx ID>",
"linkId": "<Link ID>"
}
Or by its FRAGMENT ID:
query GetTx(
$id: ID!
) {
tx(
tx: {
id: $id
}
) {
id
description
amount
currency {
code
}
externalId
link {
id
}
externalAccount {
id
externalId
}
}
}
{
"id": "<Fragment ID>"
}
Use the ledgerAccount.unreconciledTx
query to list a Ledger Account's unreconciled Txs:
query GetUnreconciledTxs(
$ledgerAccount: LedgerAccountMatchInput!
$after: String
$first: Int
$before: String
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
path
unreconciledTxs(
after: $after
first: $first
before: $before
) {
nodes {
id
description
amount
currency {
code
}
externalId
link {
id
}
externalAccount {
id
externalId
}
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
}
You may optionally specify before
and after
to paginate the results and first
to specify the page size.
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "quickstart-ledger"
}
},
"after": "2023-07-01T00:00:00.000Z",
"before": "2023-07-30T23:59:59.999Z",
"first": 20
}
The response is a paginated list of Txs.
Use the schema
query to retrieve a Schema by its key:
query GetSchema($schema: SchemaMatchInput!) {
schema(schema: $schema) {
key
name
latestVersion: version {
created
version
}
firstVersion: version(version: 1) {
created
version
}
}
}
When retrieving a Schema, use the version
argument to query a specific version of the Schema. By default, the latest version is returned:
{
"schema": {
"key": "quickstart-schema"
}
}
The JSON of a Schema version can be retrieved by querying the json
field:
query GetLatestSchemaJSON(
$schema: SchemaMatchInput!
) {
schema(schema: $schema) {
key
name
version {
created
version
json
}
}
}
{
"schema": {
"key": "quickstart-schema"
}
}
Use the schema.versions
query to query all the versions of your Schema:
query ListSchemaVersions(
$schema: SchemaMatchInput!
) {
schema(schema: $schema) {
key
name
versions {
nodes {
created
version
json
}
}
}
}
The response is a paginated list of your Schema's versions:
{
"schema": {
"key": "quickstart-schema"
}
}
Use the schema.ledgers
query to list the Ledgers created off a Schema:
query ListSchemaLedgers(
$schema: SchemaMatchInput!
) {
schema(schema: $schema) {
key
name
ledgers {
nodes {
ik
name
created
balanceUTCOffset
}
}
}
}
{
"schema": {
"key": "quickstart-schema"
}
}
FRAGMENT asynchronously migrates Ledgers when their Schema is updated. The current status of a Ledger's migration can be queried using the API, via version.migrations
:
query GetLedgerMigrationStatus(
$schema: SchemaMatchInput!
) {
schema(schema: $schema) {
key
name
latestVersion: version {
migrations {
nodes {
ledger {
ik
name
}
status
}
}
}
}
}
{
"schema": {
"key": "quickstart-schema"
}
}
FRAGMENT supports queries for generating common financial reports.
A balance sheet reports the net worth of a business at the end of a reporting period.
query GetBalanceSheet(
$ledgerIk: SafeString!
$balanceAtEndOf: LastMoment!
$accountsFilter: LedgerAccountsFilterSet!
) {
ledger(ledger: { ik: $ledgerIk }) {
ledgerAccounts(filter: $accountsFilter) {
nodes {
id
name
type
balance(at: $balanceAtEndOf)
}
}
}
}
{
"ledgerIk": "ik-used-to-create-ledger",
"accountsFilter": {
"type": {
"in": ["asset", "liability"]
}
},
"balanceAtEndOf": "1969"
}
Generate a balance sheet by querying balance
on all asset
and liability
Ledger Accounts.
Providing a LastMoment
to the at
parameter on balance
returns the balance at the end of that period. Values provided to in
operators are OR'd, so both asset
and liability
accounts are returned.
An income statement reports how a business's net worth changed over the course of a reporting period.
query GetIncomeStatement(
$ledgerIk: SafeString!
$balanceChangeDuring: Period!
$accountsFilter: LedgerAccountsFilterSet!
) {
ledger(ledger: { ik: $ledgerIk }) {
ledgerAccounts(filter: $accountsFilter) {
nodes {
path
name
type
balanceChange(period: $balanceChangeDuring)
}
}
}
}
{
"ledgerIk": "ik-used-to-create-ledger",
"accountsFilter": {
"type": {
"in": ["income", "expense"]
}
},
"balanceChangeDuring": "1969"
}
Generate an income statement by querying balanceChange
on all income
and expense
Ledger Accounts.
Providing a Period
to the period
parameter on balanceChange
retrieves the difference in the Ledger Account's balance between the start and end of that period.
An account statement reports how a Ledger Account changed over the course of a reporting period. It contains a Ledger Account's starting balance, ending balance and all Ledger Lines posted to it.
query GetAccountStatement(
$accountMatch: LedgerAccountMatchInput!
$startingBalanceAtEndOf: LastMoment!
$endingBalanceAtEndOf: LastMoment!
$linesFilter: LedgerLinesFilterSet!
) {
ledgerAccount(ledgerAccount: $accountMatch) {
path
name
type
startingBalance: balance(at: $startingBalanceAtEndOf)
endingBalance: balance(at: $endingBalanceAtEndOf)
lines(filter: $linesFilter) {
nodes {
id
key
posted
description
amount
ledgerEntryId
}
}
}
}
{
"accountMatch": {
"ledger": {
"ik": "ik-used-to-create-ledger"
},
"path": "liabilities/customer-deposits/customer:123"
},
"linesFilter": {
"posted": {
"after": "1969-07-01T00:00:00.000Z",
"before": "1969-07-30T23:59:59.999Z"
}
},
"startingBalanceAtEndOf": "1969-06",
"endingBalanceAtEndOf": "1969-07"
}
Generate an account statement by querying for balance
and lines
on a Ledger Account.
Get the starting balance by passing a DateTime
to the at
parameter on balance
. Use a GraphQL alias to make multiple balance
queries within one request.
To get all Ledger Lines that were posted during the reporting period, use the filter
parameter on lines
. The after
and before
filters are inclusive, so use timestamps for the first and last moments of the reporting period.
A journal export lists all Ledger Entries posted to a Ledger during a reporting period.
query GetJournalExport(
$ledgerIk: SafeString!
$entriesFilter: LedgerEntriesFilterSet!
$entriesCursor: String
) {
ledger(ledger: { ik: $ledgerIk }) {
ledgerEntries(filter: $entriesFilter, after: $entriesCursor) {
nodes {
id
type
posted
description
lines {
nodes {
id
description
account {
name
path
type
}
amount
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
{
"ledgerIk": "ik-used-to-create-ledger",
"entriesFilter": {
"posted": {
"after": "1969-01-01T00:00:00.000Z",
"before": "1969-03-31T23:59:59.999Z"
}
},
"entriesCursor": "{{data.ledger.ledgerEntries.pageInfo.endCursor}}"
}
Generate a journal export by listing Ledger Entries. For each Ledger Entry, include its Ledger Lines and their Ledger Accounts.
This can be a long list, so query pageInfo
to get a pagination cursor. It can be passed to the after
parameter on ledgerEntries
to page through the results.
To ensure correctness at scale, FRAGMENT's consistency mode lets you build guardrails without sacrificing performance.
You can configure consistency within your Schema to make granular tradeoffs between throughput and consistency.
You can configure the consistency of the Ledger Entries list query in your Ledger. To do this, set consistencyConfig
at the top level of your Schema:
entries: eventual
for Ledgers that require high throughput but can tolerate a stale entry listentries: strong
for Ledgers that have lower throughput but require strong consistency, such as those powering reconcilation dashboards{
"consistencyConfig": {
"entries": "strong"
},
"chartOfAccounts": [...]
}
By default, all Ledgers use eventual
consistency.
You can configure the consistency of balances, as well as the Ledger Lines list query, in your Ledger Account.
To configure an account's balance consistency, set consistencyConfig.ownBalanceUpdates
within a Ledger Account's definition:
ownBalanceUpdates: eventual
for Ledger Accounts that require high throughput but can tolerate stale balances, such as those used for reportingownBalanceUpdates: strong
for Ledger Accounts that have lower throughput but require strong consistency, such as those used to authorize transactionsSimilarly, to configure the consistency of an account's lines, set consistencyConfig.lines
:
lines: eventual
for Ledger Accounts that require high throughput but can tolerate a stale line listlines: strong
for Ledger Accounts that have lower throughput but require strong consistency, such as those powering transaction histories displayed to end users{
"accounts": [
{
"key": "user-balance",
"template": true,
"type": "asset",
"consistencyConfig": {
"ownBalanceUpdates": "strong",
"lines": "eventual"
}
}
]
}
By default, all Ledger Accounts use eventual
for both properties.
For low-throughput applications, setting all Ledger Accounts as strong
may make implementation easier. To do this, set defaultConsistencyConfig
on chartOfAccounts
:
{
"chartOfAccounts": {
"defaultConsistencyConfig": {
"ownBalanceUpdates": "strong",
"lines": "strong"
},
"accounts": [...]
}
}
Strongly consistent Ledger Accounts generally won't have children, but in all cases child Ledger Accounts inherit the parent's consistencyConfig
setting.
To query a strongly consistent ownBalance
, set consistencyMode
to strong
when querying the Ledger Account:
query GetOwnBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalance(consistencyMode: strong)
ownBalances(consistencyMode: strong) {
nodes {
amount
currency {
code
}
}
}
}
}
By default, balance queries on all Ledger Accounts are eventually consistent.
Restrictions:
ownBalance
supports strongly consistent reads; balance
and childBalance
support only eventually consistent readsat
with consistencyMode
to query strongly consistent historical balancesEntry conditions are rules defined in your Schema to manage concurrency and enforce correctness within your Ledger.
Conditions are evaluated when a Ledger Entry is posted. If a condition is not met, the Ledger Entry is not posted and the mutation returns a BadRequestError
with code conditional_request_failed
.
Use precondition
when your application reads a balance and needs to guarantee that it hasn't changed before posting the Ledger Entry.
{
"type": "pay-employee",
"lines": [...],
"conditions": [
{
"account": {
"path": "bank-account"
},
"precondition": {
"ownBalance": {
"eq": "{{current_balance}}"
}
}
}
]
}
Use postcondition
to guarantee that a write never puts a Ledger Account's balance in an undesirable state.
{
"type": "pay-employee",
"lines": [...],
"conditions": [
{
"account": {
"path": "bank-account"
},
"postcondition": {
"ownBalance": {
"gte": "0"
}
}
}
]
}
Restrictions:
ownBalance
, which changes only for Ledger Accounts directly posted to in the Ledger Entry.consistencyConfig.ownBalanceUpdates
set to strong
You can configure an account to have a strongly-consistent ownBalance for specific group keys.
To do this, use the consistencyConfig.groups
configuration in the Schema.
{
"accounts": [
{
"key": "user-balance",
"template": true,
"type": "asset",
"consistencyConfig": {
"groups": [
{
"key": "invoice_id",
"ownBalanceUpdates": "strong"
}
]
}
}
]
}
See Group Ledger Entries for more information about how to use Ledger Entry Groups.
To query a strongly consistent Ledger Entry Group balance, set consistencyMode
to strong
or use_account
when querying the group's balances.
query ListLedgerEntryGroupBalances(
$ledger: LedgerMatchInput!
$groupKey: SafeString!
$groupValue: SafeString!
$consistencyMode: ReadBalanceConsistencyMode
) {
ledgerEntryGroup(ledgerEntryGroup: {
ledger: $ledger,
key: $groupKey,
value: $groupValue,
}) {
balances {
nodes {
account {
path
}
ownBalance(consistencyMode: $consistencyMode)
}
}
}
}
By default, Ledger Entry Group balances are eventually consistent. Like other consistency options, enabling strong consistency will reduce the maximum throughput of an account.
Restrictions:
Use multi-currency Ledgers to easily build products that track currencies, stocks and inventories.
A Ledger can contain Ledger Accounts of different currencies, like USD and GBP bank accounts. Ledger Accounts can also be multi-currency, like one representing a stock portfolio, with a balance for each symbol.
You can post Ledger Entries with multiple currencies. It must follow the Accounting Equation per currency, so you'll need at least four Ledger Lines.
FRAGMENT includes a list of common currencies. You can also add your own custom currencies.
Multi-currency ledgers often reflect transitory states: a company accepts payment in one currency intending to convert it to another currency. Between accepting and converting the money, the exchange rate could change. Tracking the potential gain or loss from this change is called exposure.
To track exposure, use a Change Ledger Account that has multiple balances, one for each currency. Here's an example that tracks exposure between USD and EUR:
In this example,
To create a multi-currency Ledger, set defaultCurrencyMode
to multi
and unset defaultCurrency
:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{...},
{...}
]
},
"ledgerEntries": {...}
}
For Ledger Accounts in a single currency, such as bank accounts, set currencyMode
and currency
on the account directly:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{
"key": "bank-account",
"currencyMode": "single",
"currency": {
"code": "USD"
}
},
{...}
]
},
"ledgerEntries": {...}
}
Like other Ledger Account properties, currencyMode
and currency
are inherited by child Ledger Accounts unless they are overridden.
You can define multi-currency Ledger Entries types in your Schema in the same way as single-currency Ledger Entries.
Multi-currency Ledger Accounts accept Ledger Lines in any currency, so Ledger Entries that post to them must specify the currency of each Ledger Line:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{
"type": "asset",
"key": "bank-account"
},
{...}
]
},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account_usd",
"description": "Fund {{funding_amount}} USD",
"lines": [
{
"key": "funds_arrive_in_bank",
"account": {
"path": "bank-account"
},
"amount": "{{funding_amount}}",
"currency": {
"code": "USD"
}
},
{...other line}
]
}
]
}
}
You can parameterize currency
to make your Ledger Entries more reusable:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{
"type": "asset",
"key": "bank-account"
},
{...}
]
},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Fund {{funding_amount}} {{currency}}",
"lines": [
{
"key": "funds_arrive_in_bank",
"account": {
"path": "bank-account"
},
"amount": "{{funding_amount}}",
"currency": {
"code": "{{currency}}"
}
},
{...other line}
]
}
]
}
}
You can also post multi-currency Ledger Entries to Ledger Account templates which parameterize currency
. This is useful for creating Linked Ledger Accounts if you have multiple bank accounts in different currencies in a multi-currency Ledger.
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{
"key": "bank-accounts",
"template": true,
"currencyMode": "single",
"currency": {
"code": "{{currency}}"
}
},
{...}
]
},
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Fund {{funding_amount}} {{currency}}",
"lines": [
{
"key": "funds_arrive_in_bank",
"account": {
"path": "bank-accounts:{{currency}}"
},
"amount": "{{funding_amount}}",
"currency": {
"code": "{{currency}}"
}
},
{...other line}
]
}
]
}
}
Ledger Entry Conditions against multi-currency Ledger Accounts need to specify the currency the condition applies to:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{...},
{...}
]
},
"ledgerEntries": {
"types": [
{
"type": "p2p_transfer",
"description": "Move {{funding_amount}} {{currency}}",
"lines": [
{...},
{...}
],
"conditions": [
{
"account": {
"path": "liabilities/users:{{from_user_id}}"
},
"currency": {
"code": "{{currency}}"
},
"postcondition": {
"ownBalance": {
"gte": "0"
}
}
}
]
}
]
}
}
Balances on multi-currency Ledger Accounts are lists of currency and amount, as opposed to just a single amount. To read all balances in all currencies, query the plural versions of the singular balance field.
You can read the latest balance in a specific currency or list the latest balance in all currencies.
Multi-currency Ledger Accounts have three balance lists:
ownBalances
, the sum of all Ledger Lines in the Ledger Account per currency, excluding Ledger Lines in child Ledger AccountschildBalances
, the sum of all Ledger Lines in child Ledger Accounts per currencybalances
, the sum of all Ledger Lines, including child Ledger Accounts per currencyTo read a specific currency's balance, pass in the currency
argument:
query GetUSDBalance(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalance(currency: { code: USD })
}
}
{
"ledgerAccount": {
"path": "assets/bank/user-cash",
"ledger": {
"ik": "multi-currency-ledger"
}
}
}
To read all the balances for a multi-currency Ledger Account, use ownBalances
:
query GetAllBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalances {
nodes {
currency {
code
}
amount
}
}
}
}
To read aggregated balances for multi-currency Ledger Accounts, pass in the currency
argument, or query childBalances
and balances
:
query GetBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
childBalance(currency: { code: USD })
childBalances {
nodes {
currency {
code
}
amount
}
}
balance(currency: { code: USD })
balances {
nodes {
currency {
code
}
amount
}
}
}
}
If any Ledger Account has a descendant that is a multi-currency Ledger Account or if it has descendants of different currencies, it has childBalances
and balances
.
To read consistent balances for multi-currency Ledger Accounts, pass in the consistencyMode
argument:
query GetOwnBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalance(
consistencyMode: strong
currency: { code: USD }
)
ownBalances(consistencyMode: strong) {
nodes {
currency {
code
}
amount
}
}
}
}
To read historical balances for multi-currency Ledger Accounts, pass in the at
argument:
query GetOldBalances(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalance(at: "1969", currency: { code: USD })
ownBalances(at: "1969") {
nodes {
currency {
code
}
amount
}
}
}
}
Multi-currency Ledger Accounts support reading balance changes:
ownBalanceChanges
, how much ownBalances
changedchildBalanceChanges
, how much childBalances
changedbalanceChanges
, how much balances
changedquery GetBalanceChanges(
$ledgerAccount: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $ledgerAccount) {
ownBalanceChange(
period: "1969"
currency: { code: USD }
)
ownBalanceChanges(period: "1969") {
nodes {
currency {
code
}
amount
}
}
childBalanceChange(
period: "1968-12"
currency: { code: USD }
)
childBalanceChanges(period: "1968-12") {
nodes {
currency {
code
}
amount
}
}
balanceChange(
period: "1968-12-25"
currency: { code: USD }
)
balanceChanges(period: "1968-12-25") {
nodes {
currency {
code
}
amount
}
}
}
}
Balance change queries require you to specify a period
. This can be a year, quarter, month, day or hour.
You can define your own currencies to track any type of value, like rewards points, stocks or physical items.
To create a custom currency, call the createCustomCurrency
mutation:
mutation CreateCustomCurrency (
$customCurrency: CreateCustomCurrencyInput!,
) {
createCustomCurrency(
customCurrency: $customCurrency
) {
... on CreateCustomCurrencyResult {
customCurrency {
code
customCurrencyId
precision
name
customCode
}
}
... on Error {
code
message
}
}
}
{
"customCurrency": {
"customCurrencyId": "blue-gems",
"precision": 0,
"name": "Blue Gems",
"customCode": "BLUE"
}
}
To use a custom currency, set the customCurrencyId
on the currency
field of a Ledger Account and Ledger Line:
{
"key": "...",
"chartOfAccounts": {
"defaultCurrencyMode": "multi",
"accounts": [
{
"key": "gems-issued",
"currencyMode": "single",
"currency": {
"code": "CUSTOM",
"customCurrencyId": "blue-gems"
}
},
{...}
]
},
"ledgerEntries": {
"types": [
{
"type": "issue-blue-gems",
"description": "Issue blue gems",
"lines": [
{
"key": "increase-pool",
"account": {
"path": "gems-issued"
},
"amount": "{{amount}}",
"currency": {
"code": "CUSTOM",
"customCurrencyId": "blue-gems"
}
},
{...other line}
]
}
]
}
}
A Ledger Entry Group is a collection of related Ledger Entries that occur at different points in time. Each Group tracks the net change to each Ledger Account balance it affects.
Use Ledger Entry Groups to tie together Ledger Entries that are part of the same funds flow, such as a deposit, settlement or invoice. To store metadata, use tags instead.
Groups for a Ledger Entry Type are defined as a list of key/value pairs in the Schema:
{
"type": "user_initiates_withdrawal",
"description": "{{user_id}} initiates withdrawal",
"lines": [
{
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"key": "decrease_user_balance",
"amount": "-{{withdraw_amount}}"
},
{...other line}
],
"groups": [
{
"key": "withdrawal",
"value": "{{withdrawal_id}}"
}
]
}
Ledger Entry Groups have the following limitations:
value
of a Group, but not the key
.Use the ledgerEntryGroup.balances
expansion to get the net change per Ledger Account balance from all Ledger Entries in a Group. Group balances are eventually consistent.
query GetLedgerEntryGroupBalances(
$ledger: LedgerMatchInput!
$entryGroup: EntryGroupMatchInput!,
) {
ledger(ledger: $ledger) {
ledgerEntryGroup(ledgerEntryGroup: $entryGroup) {
balances {
nodes {
account {
path
}
ownBalance
}
}
}
}
}
{
"entryGroup": {
"key": "withdrawal",
"value": "some-withdrawal-id"
},
"ledger": {
"ik": "quickstart-ledger"
}
}
The response for a Ledger Entry Group with a settled withdrawal:
{
"data": {
"ledger": {
"ledgerEntryGroup": {
"balances": {
"nodes": [
{
"account": {
"path": "asset-root/bank"
},
"ownBalance": "-50000"
},
{
"account": {
"path": "liability-root/user:user-id/available"
},
"ownBalance": "-50000"
},
{
"account": {
"path": "liability-root/user:user-id/pending"
},
"ownBalance": "0"
}
]
}
}
}
}
}
Ledger Entry Groups support strongly-consistent reads for the ownBalance field. See Consistent Ledger Entry Group balances for more information on how to configure this in your Schema. See Query Consistent Groups for how to query strongly consistent Ledger Entry Group balances.
Balances in a Group may be filtered by account, currency, and ownBalance.
query QueryLedgerEntryGroupBalances(
$ledger: LedgerMatchInput!
$entryGroup: EntryGroupMatchInput!,
$filter: LedgerEntryGroupBalanceFilterSet,
) {
ledger(ledger: $ledger) {
ledgerEntryGroup(ledgerEntryGroup: $entryGroup) {
balances(filter: $filter) {
nodes {
account {
path
}
ownBalance
currency {
code
}
}
}
}
}
}
{
"entryGroup": {
"key": "withdrawal",
"value": "12345"
},
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"currency": {
"equalTo": { "code": "USD" }
},
"ownBalance": {
"gte": "-1000",
"lte": "1000"
},
"account": {
"path": {
"equalTo": "liability-root/user:user-id/pending"
}
}
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroup": {
"balances": {
"nodes": [
{
"account": {
"path": "liability-root/user:user-id/pending"
},
"ownBalance": "0",
"currency": {
"code": "USD"
}
}
]
}
}
}
}
}
Group balances support filtering account paths using '*' in place of a template variable.
query QueryLedgerEntryGroupBalances(
$ledger: LedgerMatchInput!
$entryGroup: EntryGroupMatchInput!,
$filter: LedgerEntryGroupBalanceFilterSet,
) {
ledger(ledger: $ledger) {
ledgerEntryGroup(ledgerEntryGroup: $entryGroup) {
balances(filter: $filter) {
nodes {
account {
path
}
ownBalance
}
}
}
}
}
{
"entryGroup": {
"key": "withdrawal",
"value": "12345"
},
"ledger": {
"ik": "quickstart-ledger"
},
"filter": {
"account": {
"path": {
"matches": "liability-root/user:*/pending"
}
}
}
}
The response is:
{
"data": {
"ledger": {
"ledgerEntryGroup": {
"balances": {
"nodes": [
{
"account": {
"path": "liability-root/user:user-id/pending"
},
"ownBalance": "0"
},
{
"account": {
"path": "liability-root/user:another-user-id/pending"
},
"ownBalance": "2500"
}
]
}
}
}
}
}
In addition to Groups defined in your Schema, you can add a posted Ledger Entry to additional Groups.
mutation UpdateLedgerEntryGroups(
$ledgerEntry: LedgerEntryMatchInput!
$update: UpdateLedgerEntryInput!
) {
updateLedgerEntry(
ledgerEntry: $ledgerEntry,
update: $update
) {
__typename
... on UpdateLedgerEntryResult {
entry {
type
ik
groups {
key
value
}
lines {
nodes {
amount
description
account {
path
}
}
}
}
}
... on Error {
code
message
}
}
}
{
"ledgerEntry": {
"ik": "add-ledger-entry",
"ledger": {
"ik": "ledger-ik"
}
},
"update": {
"groups": [
{
"key": "withdrawal",
"value": "12345"
}
]
}
}
This is an additive operation:
You can only update a Ledger Entry a maximum of 10 times.
FRAGMENT supports exporting your Ledger data to AWS S3. This makes your data available for analytics, regulatory compliance and ingestion into third-party systems.
Data exports are delivered approximately every 5 minutes. Each data export contains the Ledger data created or updated since the previous export.
Exports are divided by data type into three files. Each file contains newline-separated JSON, where each line represents an individual instance of the respective data type: 'LedgerEntry', 'LedgerLine' or 'LedgerAccount'.
The files use a naming scheme of {File Prefix}/{type}/day={day}/hour={hour}/{partition}.part
.
Example files:
To enable data export, first create an S3 bucket. No special settings are required, but take note of the bucket name and AWS region.
Once the bucket exists, navigate to the Settings -> S3 Exports subsection of the FRAGMENT dashboard, and follow the instructions to create a new export. You need to provide:
us-east-1
).The instructions in the dashboard include applying a S3 bucket policy to your bucket. Depending on how you manage your infrastructure, this may take some time, so the onboarding flow in the dashboard can be restarted at any time.
Once you've onboarded your data export, the dashboard lets you test the connection by writing a sample file to test/testLedgerEntry.part
in your bucket. The test result can be:
Policy Not Applied
, FRAGMENT received an authorization error. This typically means the provided resource policy has not yet been applied within your AWS account. This may also mean the provided bucket name or region differs from the one created in the initial setup.Invalid Bucket Name
, the provided bucket name does not exist in AWS.Incorrect AWS Region
, the provided bucket exists in a different region than provided.Verified Permissions
, the file was successfully written.FRAGMENT does not remove the test file after verifying its existence.
You can add FRAGMENT as a GraphQL resource in Retool.
Authorization
Header with the value Bearer OAUTH2_TOKEN
. Retool will replace OAUTH2_TOKEN with the actual token at runtime.OAuth 2.0
Use Client Credentials Flow
consent
Note: Testing the connection in Retool may fail even when the resource is configured correctly. To check, use the resource in an app by running the query.
FRAGMENT is the database we wish we had at Stripe and Robinhood. It's the abstraction we want at the performance we need.
On February 13, 2024, we ran a load test to simulate traffic using Grafana K6. A total of 19,622,609 requests were made over a 15 minute period. 6,769,966 of them were reads and 12,852,643 of them were writes. We observed:
By comparison, at Robinhood it took 18 months of dedicated engineering effort to get even remotely close to these numbers.
To achieve this performance, FRAGMENT is built on top of two distributed databases:
A two-tier system, as opposed to one built on a single Postgres instance, is harder to build and maintain. But by having separate write-optimized and read-optimized systems, we can tune each system independently and use the best tool for each job.
When customers hit any of our GraphQL mutations, all data is synchronously written into DynamoDB then asynchronously indexed on ElasticSearch.
We optimize DynamoDB by:
Depending on the query, GraphQL queries API requests are served from either DynamoDB or ElasticSearch.
Our ElasticSearch strategy is based on the idea that each query should only hit a single server. When a list query comes in, it gets routed to a single server, which uses sorting to cut down the search space, applies additional filters on indexed attributes, then returns the results. The results are fully hydrated so the FRAGMENT API can return data directly from ElasticSearch without hitting DynamoDB again.
This strategy is opposite to Elasticsearch's default where docs are thrown onto random servers and queries map out to every server in the cluster. Our strategy works well for a highly structured search with a high hit rate: filtering data in a Ledger. The default strategy is better for a fuzzy search with a low hit rate, like searching for a string across millions of documents.