FRAGMENT is a toolkit for building products that move and track money. It includes a dashboard, API and a CLI-based Ledger design tool.
Don't have a FRAGMENT workspace? Get access.
Follow this Quickstart to build and use a Ledger for a basic peer-to-peer payments app.
This app has users who can fund their account and transfer funds to others. Behind the scenes a bank account pools every user's cash.
To build and use the Ledger, you:
To call the API, use the API Explorer in the dashboard. This is like Postman, but for GraphQL.
FRAGMENT is a GraphQL API, so every request has two parts: a GraphQL query and variables passed to the query. For each API call, copy-paste both the query and variables into the API Explorer. Then click the play button to call the API and see the response.
In FRAGMENT, you use a Schema to model a product's flow of funds. A Schema defines the structure of a Ledger, and is specific to a given use case.
Call the storeSchema
mutation to store a Schema for the app.
mutation QuickstartStoreSchema($schema: SchemaInput!) {
storeSchema(schema: $schema) {
... on StoreSchemaResult {
schema {
key
name
version {
version
created
json
}
}
}
... on Error {
code
message
}
}
}
The Schema defines Ledger Accounts for the users and the bank. The Schema also defines Ledger Entries for product events, like a user funding their account or making p2p transfers.
{
"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"
}
}
}
]
},
{
"type": "withdrawal",
"description": "{{user_id}} withdraws {{withdrawal_amount}}",
"lines": [
{
"account": { "path": "assets/banks/user-cash" },
"key": "funds_leave_bank",
"amount": "{{withdrawal_amount}}"
},
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"key": "decrease_user_balance",
"amount": "{{withdrawal_amount}}"
}
],
"conditions": [
{
"account": { "path": "liabilities/users:{{user_id}}/available" },
"postcondition": {
"ownBalance": {
"gte": "0"
}
}
}
]
}
]
}
}
}
The chartOfAccounts
field defines the Ledger Accounts to create. It has two types of accounts:
asset
account because it is money the platform holds.liability
accounts since the platform owes the users these funds. They are also marked as template: true
, to indicate that instances of these accounts are created for each user.All Ledger writes go through Ledger Entries, defined in the ledgerEntries
field. Think of them like stored procedures. This Schema has two Ledger Entries:
user_funds_account
takes in a user_id
, the ID of the user from your system, and funding_amount
. It increases the user's available balance and increases the bank's cash.p2p_transfer
takes in two user IDs—from_user_id
and to_user_id
, and a transfer_amount
. It decreases the available balance of the from_user_id
and increases the available balance of the to_user_id
.Create a Ledger for the app 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 quickstart-schema
so the Ledger is created using the Schema stored by the previous API call.
{
"ik": "quickstart-ledger",
"ledger": {
"name": "Quickstart Ledger"
},
"schema": {
"key": "quickstart-schema"
}
}
Use the addLedgerEntry
mutation to post Ledger Entries.
mutation QuickstartAddLedgerEntry(
$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
}
}
}
Start with user_funds_account
.
{
"ik": "fund-user-1-account",
"entry": {
"type": "user_funds_account",
"posted": "1234-11-11T13:00:00.000Z",
"ledger": {
"ik": "quickstart-ledger"
},
"parameters": {
"funding_amount": "10000",
"user_id": "user-1"
}
}
}
The important fields in an addLedgerEntry
call are type
and parameters
. For details on other fields, check out Post Ledger Entries.
entry.type
is set to user_funds_account
, defined in the Schema.entry.parameters
contains a key/value pair of all the parameters defined in the Schema. The funding_amount
parameter sets the Ledger Line amounts and the user_id
parameter sets the Ledger Account path. Since the user account is a template, the Ledger Account is created on demand.After funding the first user, change the parameter values and re-run the mutation to create another funded user account.
{
"ik": "fund-user-2-account",
"entry": {
"type": "user_funds_account",
"posted": "1234-11-11T13:00:00.000Z",
"ledger": {
"ik": "quickstart-ledger"
},
"parameters": {
"funding_amount": "6000",
"user_id": "user-2"
}
}
}
You can then try the p2p_transfer
Ledger Entry by setting type
to p2p_transfer
and providing the appropriate parameters from the Schema.
{
"ik": "p2p-transfer",
"entry": {
"type": "p2p_transfer",
"posted": "1234-12-11T13:00:00.000Z",
"ledger": {
"ik": "quickstart-ledger"
},
"parameters": {
"transfer_amount": "5000",
"from_user_id": "user-1",
"to_user_id": "user-2"
}
}
}
With accounts funded and a transfer made, query a Ledger Account's balance using the ledgerAccount
query.
query QuickstartGetBalance (
$account: LedgerAccountMatchInput!
) {
ledgerAccount(ledgerAccount: $account) {
path
name
type
ownBalance(consistencyMode: use_account)
created
currency {
code
}
}
}
{
"account": {
"path": "liabilities/users:user-1/available",
"ledger": {
"ik": "quickstart-ledger"
}
}
}
Try changing path
to assets/banks/user-cash
or liabilities/users:user-2/available
to query the other Ledger Accounts.
Now that you've created a demonstrative Ledger for this p2p app, model your own product's flow of funds and build your own Ledger. To do that, continue to the next section, Design your Ledger.
A Ledger uses a Schema to define functionality for a specific product and use case. Run the Ledger designer, a CLI tool, to create 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:
{
"schema": {
"chartOfAccounts": {
"accounts": [
{
"key": "banks",
"type": "asset",
"children": [
{
"key": "bank",
"children": [
{"key": "user-cash"},
{"key": "reserve"}
]
}
]
}
]
}
}
}
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:
{
"ledgerEntries": {
"types": [
{
"type": "user_funds_account",
"description": "Funding {{user_id}} for {{funding_amount}}.",
"lines": [
{
"key": "funds_arrive_in_bank",
"account": {
"path": "assets/banks/user-cash"
},
"amount": "{{funding_amount}}"
},
{
"key": "increase_user_balance",
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"amount": "{{funding_amount}}"
}
]
}
]
}
}
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.
{
"schema": {
"ledgerEntries": {
"types": [
{
"type": "withdrawal",
"description": "{{user_id}} withdraws {{withdrawal_amount}}",
"lines": [
{
"key": "funds_leave_bank",
"account": {
"path": "assets/banks/user-cash"
},
"amount": "-{{withdrawal_amount}} + {{instant_fee}}"
},
{
"key": "decrease_user_balance",
"account": {
"path": "liabilities/users:{{user_id}}/available"
},
"amount": "-{{withdrawal_amount}}"
},
{
"key": "record_instant_payout_income",
"account": {
"path": "income/fees/instant_payouts"
},
"amount": "{{instant_fee}}"
}
]
}
]
}
}
}
The fragment
CLI includes a Ledger designer to create, visualize and simulate your Schema.
To install the CLI, run:
brew tap fragment-dev/tap
brew install fragment-dev/tap/fragment-cli
To write an example Schema to ./fragment.jsonc
run:
fragment init
To start the Ledger designer run:
fragment start
The Ledger designer creates a visualization of the example Schema. Whenever you edit the fragment.jsonc
file, the Ledger designer updates to reflect your changes.
Once you've designed your Ledger in a Schema, you can deploy it. This is a two-step process:
Once your Ledger is created, subsequent updates to the Schema automatically update the Ledger.
FRAGMENT supports Ledger deployment within your development process: locally, by your CI/CD or directly from your product.
While developing locally, use the fragment
CLI. See the installation instructions if you don't already have the CLI.
First, authenticate your local environment:
fragment login
Second, store your Schema:
fragment store-schema
Third, create the Ledger:
fragment create-ledger \
--name "sample ledger" \
--ik "ledger-1" \
--schema "sample-schema"
You can use the same Schema to create multiple Ledgers. Many applications create one Ledger for each development environment.
You can integrate the CLI commands into your CI/CD pipeline to automatically deploy your Ledger.
Use env vars with fragment login
to re-use it across environments.
export FRAGMENT_CLIENT_ID="fake-client-id"
export FRAGMENT_CLIENT_SECRET="fake-client-secret"
export FRAGMENT_API_URL="https://api.us-west-2.fragment.dev/graphql"
export FRAGMENT_OAUTH_URL="https://auth.us-east-2.fragment.dev/oauth2/token"
export FRAGMENT_OAUTH_SCOPE="https://api.us-east-2.fragment.dev/*"
fragment login \
--client-id ${FRAGMENT_CLIENT_ID} \
--client-secret ${FRAGMENT_CLIENT_SECRET} \
--api-url ${FRAGMENT_API_URL} \
--oauth-url ${FRAGMENT_OAUTH_URL} \
--oauth-scope ${FRAGMENT_OAUTH_SCOPE}
Similar to logging in, when storing the Schema, use env vars for your Links. The CLI finds and replaces ${env vars}
in your Schema with the values from your environment before calling the storeSchema
API.
EXTERNAL_ACCOUNT_ID="external-account-id" \
fragment store-schema
If you are creating a Ledger for each of your users, 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"
}
}
This section covers how to build a reliabile and fault tolerant integration with FRAGMENT.
To integrate the API, you need to:
FRAGMENT has a GraphQL API to read and write data from your Ledger. The API is available in the following AWS regions:
us-east-1
us-east-2
us-west-2
eu-west-1
You can find the region for your workspace in the API URL in the settings tab of the dashboard. If you need to create a new workspace, you can do so through the top-left dropdown in the dashboard.
If you don't see your desired AWS region, contact us.
To ensure you always make valid queries, generate a typed SDK for your programming language. Do so using an SDK codegen tool and the current version of the FRAGMENT GraphQL schema.
FRAGMENT hosts the latest GraphQL schema at:
https://api.fragment.dev/schema.graphql
Use this URL as the SDK codegen input. All changes to the GraphQL API are backwards-compatible. You only need to generate a new version of the SDK to use new features.
graphql-codegen
is a tool for generating Typescript SDKs:
package.json
: graphql-codegen/typescript-graphql-request
, graphql-codegen/cli
, graphql-request
, graphql-tag
.codegen.yml
file.queries
folder.yarn graphql-codegen
.An example codegen.yml
:
schema: "https://api.fragment.dev/schema.graphql"
generates:
fragment-sdk.ts:
plugins:
- typescript
- typescript-operations
- typescript-graphql-request
- add:
content: "/* This file was auto-generated. Don't edit it directly. See 'codegen.yml' */"
documents: './queries/**/*.ts'
config:
scalars:
Int96: string
As an example, to generate a typed call to addLedgerEntry
, define your query in ./queries/addLedgerEntry.ts
as:
import gql from 'graphql-tag';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const QUERY = gql`
mutation addLedgerEntryApiTest($entry: LedgerEntryInput!, $ik: SafeString!) {
addLedgerEntry(ik: $ik, entry: $entry) {
__typename
... on AddLedgerEntryResult {
entry {
id
posted
created
date
description
}
lines {
id
date
posted
created
amount
type
ledgerId
accountId
ledgerEntryId
}
isIkReplay
}
... on Error {
code
message
}
}
}
`;
FRAGMENT uses OAuth2's client credentials flow to authenticate API clients. The call requesting an access token follows the OAuth2 spec. You can use any OAuth2 library that supports the client credentials grant to retrieve the token or make an HTTP request. The flow is:
Authorization
header with the value Bearer {{access_token}}
when calling the GraphQL API.Your token request payload should be in x-www-form-urlencoded
format, with the keys grant_type
, scope
and client_id
.
grant_type=client_credentials&scope={{scope}}&client_id={{client_id}}
Your token request headers should contain the following, where client_credentials
is the Base64-encoded version of: {{client_id}}:{client_secret}}
.
{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic {{client_credentials}}",
"Accept": "*/*"
}
The response is a JSON object containing your access token.
{
"access_token": "<access token>",
"expires_in": 3600,
"token_type": "Bearer"
}
Putting it all together:
import axios from "axios";
const btoa = (str) => Buffer.from(str).toString("base64");
// credentials from the dashboard
const clientId = "2h4t0cv7qv5c9r0qgs1o3erkuk";
const secret = "superSecretClientSecret";
const scope = "https://api.us-west-2.fragment.dev/*";
const authUrl = "https://auth.us-west-2.fragment.dev/oauth2/token";
const getToken = async () => {
// encode the client id and secret
const auth = btoa(`${clientId}:${secret}`);
// create the request body
const data = new URLSearchParams();
data.append("grant_type", "client_credentials");
data.append("scope", scope);
data.append("client_id", clientId);
// retrieve the token
const response = await axios.request({
method: "POST",
url: authUrl,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${auth}`,
Accept: "*/*",
},
data,
});
if (!response.data.access_token) {
throw new Error(
"didn't get an access token from auth endpoint"
);
}
return response.data.access_token;
};
To ensure write operations are executed exactly once, all write mutations in FRAGMENT are idempotent. This lets you safely retry operations without risk of duplicate effects, so your application only needs to guarantee that it calls the API at least one time.
Mutations require you to provide a unique idempotency key (ik
). For calls to reconcileTx
, syncCustomAccounts
and syncCustomTxs
, FRAGMENT internally uses the transaction or account ID from the request as the idempotency key.
If you call the API more than once with the same ik
and the same values, it executes only once. FRAGMENT ignores additional requests and returns the original response with isikReplay: true
in the response.
Idempotency keys are scoped per-Ledger; if you send multiple requests with the same IK
to different Ledgers, all mutations execute and return isIkReplay: false
.
All successful mutation requests return a union type that represents an application error or a successful result. For example, the response type of the addLedgerEntry
mutation:
union AddLedgerEntryResponse =
AddLedgerEntryResult | BadRequestError | InternalError
When calling the API, your code should handle errors in this order:
__typename
field. This contains BadRequestError
, InternalError
or your MutationResult
type.InternalError
with retries and exponential backoff.BadRequestError
by failing the operation.<Mutation>Result
types such as AddLedgerEntryResult
.Here is an example of this written in Typescript:
import assert from 'assert';
type RequestParams = {
query: string;
variables: Record<string, unknown>;
};
const makeRequest = async ({ query, variables }: RequestParams): Promise<Response> => {
const token = await getToken();
return fetch('Fragment GraphQL URL', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
};
const query = '''
mutation addLedgerEntry($ik: SafeString, $entry: LedgerEntryInput!) {
addLedgerEntry(ik: $ik, entry: $entry) {
__typename
...
}
}
''';
const variables = { ik: 'sample-ik', entry: {...} };
const handleRequest = (req: Response) => {
if (req.status === 429 || req.status >= 500) {
// Rate-limited or intermittent http failures. Retry the request.
return handleRequest(await makeRequest({ query, variables }));
}
if ((req.status >= 400 || req.status < 500) && req.status !== 429) {
// Invalid GraphQL request provided to Fragment. Handle the error.
throw new Error('Invalid GraphQL request');
}
// .json() checks that it was a 200
const response = await req.json();
if (response.data.addLedgerEntry.__typename === 'InternalError') {
// Retry the request in case of internal errors, with backoff.
return handleRequest(await makeRequest({ query, variables }));
}
if (response.data.addLedgerEntry.__typename === 'BadRequestError') {
// Invalid request provided to Fragment. Handle the error.
throw new Error('Invalid API request to Fragment');
}
return response;
};
const response = handleRequest(await makeRequest({ query, variables }));
// Entry successfully posted to Fragment. Handle the response.
assert(response.data.addLedgerEntry.__typename === 'AddLedgerEntryResult');
handlePostedEntry(response.data.addLedgerEntry);
You can call FRAGMENT to update your Ledger with your product both synchronously and asynchronously.
To show users balances in real time, call the API synchronously. This ensures product flows block until the Ledger is updated; your users won't need to refresh to see updated balances. Sync updates can be both strongly or eventually consistent, depending on the use case. To make updates fault tolerant, implement your API calls with retries and exponential backoff.
For updates that don't need to be shown in real time, you can call the API asynchronously. This ensures your product flows don't block user activity while the balances are updated in your Ledger. Async updates are eventually consistent. A good easy pattern here is to have a table in your database that represents a call to FRAGMENT. Update this table transactionally from your product, then use an async queue that calls the API at least once for each transaction.
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": "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}}"
}
]
}
]
}
}
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": "Funding {{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
) {
... on AddLedgerEntryResult {
entry {
type
created
posted
created
}
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"
}
}
}
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. More details are in the Integrate the API section.
All numbers in FRAGMENT are integers representing the smallest unit. For example, USD $2.50 is provided as 250.
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.
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.
Custom 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": {
"linkId": "some-link-id",
"id": "bank-account-1"
},
"externalId": "tx-1",
"description": "Processed ACH batch",
"amount": "-100",
"posted": "1968-01-01"
},
{
"account": {
"linkId": "some-link-id",
"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 systemTransactions 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.
FRAGMENT supports querying a Ledger Account for its latest balances, historical balances and balance changes.