> ## Documentation Index
> Fetch the complete documentation index at: https://ramps-09-25-periodic-statements-spec.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Periodic statements

> What a periodic statement for a Grid account contains, and how to build one from Grid transaction data

export const StatementExample = () => {
  const font = "'Suisse Intl', 'Helvetica Neue', Helvetica, Arial, sans-serif";
  const primary = '#1a1a1a';
  const secondary = '#4f5960';
  const hairline = '0.5px solid rgba(26,26,26,0.1)';
  const muted70 = 'rgba(26,26,26,0.7)';
  const muted78 = 'rgba(26,26,26,0.78)';
  const muted56 = 'rgba(26,26,26,0.56)';
  const ledgerColumns = '48px minmax(0, 1fr) 96px';
  const definitionRow = {
    display: 'flex',
    alignItems: 'baseline',
    justifyContent: 'space-between',
    gap: '24px',
    padding: '7px 0'
  };
  const definitionLabel = {
    color: muted70
  };
  const definitionValue = {
    textAlign: 'right'
  };
  const currency = {
    marginLeft: '6px',
    color: muted70,
    fontSize: '10px'
  };
  const ledgerGrid = {
    display: 'grid',
    gridTemplateColumns: ledgerColumns,
    columnGap: '12px'
  };
  const flag = {
    marginLeft: '2px',
    color: muted56,
    fontSize: '0.72em',
    fontVariantNumeric: 'tabular-nums',
    lineHeight: 0,
    verticalAlign: 'super',
    position: 'static',
    top: 'auto'
  };
  const legalFlag = {
    marginLeft: '2px',
    fontSize: '0.72em',
    lineHeight: 0,
    verticalAlign: 'super',
    position: 'static',
    top: 'auto'
  };
  const footerP = {
    margin: 0
  };
  const details = [['Statement period', '09/01/2026 – 09/30/2026'], ['Issued', '10/01/2026'], ['Account holder', 'Marcus Chen'], ['Account type', 'Consumer prepaid account'], ['Account number', '****4821']];
  const rows = [{
    id: 'c1',
    day: '03',
    type: 'ACH deposit',
    party: 'Acme Corp Payroll',
    amount: '+$1,850.00',
    disputable: true
  }, {
    id: 'c2',
    day: '08',
    type: 'Debit card purchase',
    party: 'Blue Bottle Coffee',
    terminal: 'Los Angeles, CA',
    amount: '-$18.75',
    disputable: true
  }, {
    id: 'c3',
    day: '12',
    type: 'ACH debit',
    party: 'Pacific Gas & Electric',
    amount: '-$142.30',
    disputable: true
  }, {
    id: 'c4',
    day: '18',
    type: 'Wire transfer out',
    party: 'First National Escrow',
    amount: '-$1,000.00',
    disputable: false
  }, {
    id: 'c4-fee',
    day: '18',
    type: 'Wire transfer fee',
    party: 'Lead Bank',
    amount: '-$15.00',
    disputable: false
  }, {
    id: 'c5',
    day: '24',
    type: 'RTP received',
    party: 'Sofía Herrera',
    amount: '+$250.00',
    disputable: false
  }];
  const noticeSteps = ['(1) Tell us your name and account number (if any).', '(2) Describe the error or the transfer you are unsure about, and explain as clearly as you can why you believe it is an error or why you need more information.', '(3) Tell us the dollar amount of the suspected error.'];
  return <div className="not-prose" style={{
    background: '#f0f0ee',
    padding: '28px 16px',
    borderRadius: '12px',
    display: 'flex',
    justifyContent: 'center'
  }}>
      <div style={{
    width: '100%',
    maxWidth: '600px',
    overflow: 'hidden',
    border: hairline,
    borderRadius: '4px',
    background: '#ffffff',
    color: primary,
    fontFamily: font,
    fontSize: '13px',
    fontWeight: 400,
    fontVariantNumeric: 'tabular-nums',
    lineHeight: 1.3
  }}>

        <div style={{
    display: 'flex',
    alignItems: 'baseline',
    justifyContent: 'space-between',
    gap: '24px',
    padding: '26px 32px 20px',
    background: '#ffffff',
    color: primary
  }}>
          <div style={{
    minWidth: 0,
    color: secondary,
    display: 'flex',
    alignItems: 'center'
  }}>
            <svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true" style={{
    display: 'block'
  }}>
              <rect width="22" height="22" rx="5" fill={secondary} />
              <circle cx="11" cy="11" r="4.5" stroke="#ffffff" strokeWidth="2" />
            </svg>
          </div>
          <span style={{
    marginLeft: 'auto',
    textAlign: 'right',
    fontWeight: 450
  }}>September statement</span>
        </div>

        <div style={{
    margin: '0 32px',
    padding: '14px 0 22px'
  }}>
          {details.map(row => <div key={row[0]} style={definitionRow}>
              <span style={definitionLabel}>{row[0]}</span>
              <span style={definitionValue}>{row[1]}</span>
            </div>)}
          <div style={definitionRow}>
            <span style={definitionLabel}>Opening balance</span>
            <span style={definitionValue}>$2,450.00<span style={currency}>USD</span></span>
          </div>
          <div style={definitionRow}>
            <span style={definitionLabel}>Closing balance</span>
            <span style={definitionValue}>$3,373.95<span style={currency}>USD</span></span>
          </div>
        </div>

        <div style={{
    margin: '0 32px',
    borderTop: hairline,
    padding: '14px 0 22px'
  }}>
          <div style={{
    ...ledgerGrid,
    paddingBottom: '4px',
    color: muted70,
    fontSize: '10px'
  }}>
            <span style={{
    gridColumn: '1 / 3'
  }}>Transactions</span>
            <span style={{
    textAlign: 'right'
  }}>Amount</span>
          </div>
          {rows.map(row => <div key={row.id} style={{
    ...ledgerGrid,
    alignItems: 'baseline',
    padding: '9px 0'
  }}>
              <span style={{
    color: muted78
  }}>09/{row.day}</span>
              <span style={{
    minWidth: 0,
    overflow: 'hidden',
    whiteSpace: 'nowrap'
  }}>
                <span style={{
    color: primary
  }}>{row.type}</span>
                {row.party ? <span style={{
    marginLeft: '10px',
    color: muted70
  }}>{row.party}</span> : null}
                {row.terminal ? <span style={{
    display: 'inline',
    color: muted70
  }}> · {row.terminal}</span> : null}
              </span>
              <span style={{
    textAlign: 'right',
    whiteSpace: 'nowrap'
  }}>
                <span>{row.amount}</span>
                {row.disputable ? <sup style={flag}>*</sup> : null}
              </span>
            </div>)}
          <div style={{
    ...ledgerGrid,
    alignItems: 'baseline',
    marginTop: '4px',
    padding: '12px 0 10px',
    borderTop: hairline
  }}>
            <span style={{
    gridColumn: '1 / 3',
    fontWeight: 450
  }}>Total fees for period</span>
            <span style={{
    textAlign: 'right'
  }}>$15.00</span>
          </div>
          <div style={{
    paddingTop: '2px',
    color: muted56,
    fontSize: '10px'
  }}>* See below in case of errors or questions</div>
        </div>

        <div style={{
    display: 'flex',
    flexDirection: 'column',
    gap: '8px',
    margin: 0,
    padding: '14px 32px 24px',
    borderTop: hairline,
    background: '#ffffff',
    color: secondary,
    fontSize: '10px',
    lineHeight: 1.35,
    textWrap: 'pretty'
  }}>
          <div style={{
    display: 'flex',
    flexDirection: 'column',
    gap: '4px'
  }}>
            <div style={{
    marginBottom: '2px',
    fontWeight: 450
  }}>
              In case of errors or questions about your electronic transfers<sup style={legalFlag}>*</sup>
            </div>
            <p style={footerP}>Telephone us at (855) 516-0103 or Write us at 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 as soon as you can, if you think your statement or receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared.</p>
            <ol style={{
    display: 'flex',
    flexDirection: 'column',
    gap: '2px',
    margin: '2px 0',
    padding: 0,
    listStyle: 'none'
  }}>
              {noticeSteps.map(step => <li key={step} style={{
    paddingLeft: '14px',
    textIndent: '-14px'
  }}>{step}</li>)}
            </ol>
            <p style={footerP}>We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to do this, we will credit your account for the amount you think is in error, so that you will have the use of the money during the time it takes us to complete our investigation.</p>
            <p style={{
    margin: '2px 0 0'
  }}>Report errors within 60 days after we send this statement.</p>
          </div>
          <p style={{
    margin: 0,
    paddingTop: '8px',
    borderTop: hairline
  }}>This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank.</p>
        </div>

      </div>
    </div>;
};

Every account held at Lead Bank gets a periodic statement each month, whether or not it had activity. Your platform agreement sets out what the statement contains and who delivers it. Use this page as a reference for a typical statement and how to build one from Grid data.

This guide covers when to send a statement, what it contains, how to map Grid data to each statement field, and how to build one once the month settles. Consumer accounts carry a few extra items, called out where they apply.

## Sample statement layout

Your statement's visual design is up to you. The layout below shows a consumer account statement with sample data filled in. Check your platform agreement for the fields and disclosures your program requires:

<StatementExample />

The sample is a consumer statement. It carries three items a commercial statement omits: the asterisk on each line the error-resolution notice covers, the notice itself, and the terminal location on card purchases. Your platform agreement sets out which transaction types carry the marker.

## When to send a statement

A statement period is a calendar month in US Central time (`America/Chicago`). Issue a statement for every account each month, with or without activity.

Build the statement once the month's card settlement has closed. Until then, [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) refuses the month with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry later.

Send the statement to the account holder by email, in-app notification, or any channel that lets the account holder **retain** it. Then send Grid a receipt with [Send a receipt for a periodic statement](/api-reference/periodic-statements/send-a-receipt-for-a-periodic-statement), once a month for each account. Grid stores it as the delivery record for that account and month. Your platform agreement sets out who delivers the statement.

<Info>
  Once you issue a statement, **freeze its contents**. A refund or return that posts after the month closes appears on a later statement as its own dated line. ACH returns often land in the following month.
</Info>

## What a statement contains

A statement has two parts: disclosure text, and fields you fill from the account and its transactions.

### Provider disclosures

Every statement carries the provider line. A consumer statement also carries the error-resolution notice, which gives the phone number and mailing address. A commercial statement has no notice, so it carries a contact line instead. The sample uses the wording below; your platform agreement provides the disclosure text for your program.

| Field | Value |
| - | - |
| Provider line | “This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank.” |
| Error-resolution notice | Consumer statements. The notice text shown in the sample above. |
| Phone and address | Consumer statements: inside the error-resolution notice (“Telephone us at … or Write us at …”). Commercial statements: a line reading “Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.” |

### Statement fields

| Field | Description | Included when |
| - | - | - |
| Account holder | Name of the account holder | Always |
| Account type | Kind of account, for example “Consumer prepaid account” | Always |
| Account number | Number that identifies the account, masked to the last four digits | Always |
| Statement period | Start and end date of the period | Always |
| Issue date | Date the statement is sent | Always |
| Opening balance | Balance at the start of the period | Always |
| Closing balance | Balance at the end of the period | Always |
| Transaction date | Date each transaction posted to the account | Always |
| Transaction type | Kind of transfer, for example ACH deposit, debit card purchase, wire transfer out | Always |
| Payee or merchant | Counterparty name, or the merchant descriptor for a card purchase | Always |
| Terminal location | Merchant city and state | Consumer statements, for card purchases at a merchant terminal, when the card network reports a location |
| Transaction amount | Signed amount in the account currency | Always |
| Fee line items | Each fee charged in the period, as its own line | When a fee was charged |
| Total fees for the period | Sum of all fees charged in the period | Always |

## Mapping Grid data to statement fields

You build a statement from four calls:

* [Get customer by ID](/api-reference/customers/get-customer-by-id) for the account holder.
* [List Customer internal accounts](/api-reference/internal-accounts/list-customer-internal-accounts) for the account.
* [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) for the month's lines and its opening and closing balances.
* [Get transaction by ID](/api-reference/transactions/get-transaction-by-id) for each line's type, payee, and merchant.

Pass `from` as the first instant of the month and `to` as the first instant of the next month, both in US Central time. Page with `cursor` while `hasMore` is `true`. The opening and closing balances describe the whole month, so they are the same on every page. All amounts are integers in the smallest unit of their currency (for example, cents), so format them using the currency's `decimals`.

| Statement field | Grid source |
| - | - |
| Account holder | `fullName` on the customer (`customerType: INDIVIDUAL`) or `businessInfo.legalName` (`customerType: BUSINESS`) |
| Account type | `type` on the internal account (`INTERNAL_FIAT` for the platform-managed fiat account), shown as a plain-language label |
| Account number | `fundingPaymentInstructions[].accountOrWalletInfo.accountNumber` on the internal account, masked to the last four digits |
| Statement period | The Central-time month you pass as `from` and `to` |
| Issue date | You supply it: the date the statement is sent |
| Opening balance | `openingBalance` from List balance changes |
| Closing balance | `closingBalance` from List balance changes |
| Transaction date | `effectiveAt` on each balance change: when the balance moved |
| Transaction type | `type` (`INCOMING` / `OUTGOING` / `CARD`) and `direction` (`CREDIT` / `DEBIT`) on the change's transaction. On `OUTGOING` transactions, `paymentRail` names the rail |
| Payee or merchant | `counterpartyInformation` or `description` on `INCOMING` and `OUTGOING` transactions. `merchant.descriptor` on `CARD` transactions |
| Terminal location | `merchant.city` and `merchant.state` on `CARD` transactions, when present |
| Transaction amount | `amount` on each balance change, already signed: money out is negative |
| Fee line items | `fee` on each balance change. It is part of `amount`, so show the change as two lines: `amount - fee` for the transfer and `fee` for the fee |
| Total fees for the period | Sum of `fee` across the month's balance changes, shown as a positive amount |

**Check before you issue:** `openingBalance + Σ(amount) == closingBalance`, summed over every page. It holds by construction, so a mismatch means a page was missed.

<Note>
  Each balance change is one movement of the balance, so one transaction can produce several lines. An ACH deposit and its later return are two lines. A card purchase that clears in two parts is two lines. A withdrawal's fee can be a line of its own. Several changes can share a `transactionId`, so fetch each transaction once.

  `fee` is negative for a fee charged and positive for a fee refunded, for example when a returned withdrawal gives its fee back. Grid charges no fee on card transactions. An ATM operator's surcharge is part of the transaction amount. Never add `fee` on top of `amount`.

  `transactionId` is `null` for an adjustment with no transaction behind it. List it with a generic description, such as "Account adjustment".
</Note>

## Example: build a monthly statement

Once the month has settled, read the account holder and account, list the month's balance changes, look up each line's transaction, render the statement, deliver it, and send Grid the receipt.

```javascript theme={null}
// `grid` is your authenticated HTTP client for the Grid base URL.
// ERROR_RESOLUTION_NOTICE, maskAccountNumber, and the delivery helpers are yours.

// Follow nextCursor until hasMore is false
async function* listAll(path, params) {
  let cursor;
  do {
    const query = new URLSearchParams({ ...params, limit: '100' });
    if (cursor) query.set('cursor', cursor);
    const page = await grid.get(`${path}?${query}`);
    yield* page.data;
    cursor = page.hasMore ? page.nextCursor : undefined;
  } while (cursor);
}

// The first instant of a month in US Central time, as an ISO string with its offset
function centralMonthStart(year, month) {
  const utcMidnight = new Date(Date.UTC(year, month - 1, 1));
  const offset = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/Chicago',
    timeZoneName: 'longOffset',
  })
    .formatToParts(utcMidnight)
    .find((part) => part.type === 'timeZoneName')
    .value.replace('GMT', '');
  return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00${offset}`;
}

function describe(transaction) {
  if (!transaction) return { type: 'ADJUSTMENT', payee: 'Account adjustment' };
  if (transaction.type === 'CARD') {
    const { descriptor, city, state } = transaction.merchant;
    return {
      type: 'CARD',
      payee: descriptor,
      terminalLocation: city && state ? `${city}, ${state}` : undefined,
    };
  }
  return {
    type: transaction.type,
    rail: transaction.paymentRail,
    payee: transaction.description,
  };
}

async function buildStatement(customerId, accountId, year, month) {
  const customer = await grid.get(`/customers/${customerId}`);
  const commercial = customer.customerType === 'BUSINESS';

  let account;
  for await (const candidate of listAll('/customers/internal-accounts', { customerId })) {
    if (candidate.id === accountId) {
      account = candidate;
      break;
    }
  }
  if (!account) throw new Error(`Account ${accountId} does not belong to customer ${customerId}`);

  const from = centralMonthStart(year, month);
  const to = month === 12 ? centralMonthStart(year + 1, 1) : centralMonthStart(year, month + 1);

  // A 409 NOT_YET_AVAILABLE means the month has not settled yet: retry later
  const path = `/internal-accounts/${accountId}/balance-changes`;
  const first = await grid.get(`${path}?${new URLSearchParams({ from, to, limit: '1' })}`);
  const { openingBalance, closingBalance } = first;

  const changes = [];
  for await (const change of listAll(path, { from, to })) changes.push(change);

  const movement = changes.reduce((sum, change) => sum + change.amount.amount, 0);
  if (openingBalance.amount + movement !== closingBalance.amount) {
    throw new Error('Statement does not reconcile: opening balance plus lines is not the closing balance');
  }

  // Several changes can share one transaction: fetch each once
  const transactions = new Map();
  for (const { transactionId } of changes) {
    if (transactionId && !transactions.has(transactionId)) {
      transactions.set(transactionId, await grid.get(`/transactions/${transactionId}`));
    }
  }

  // A fee is inside its change's amount: split it into its own line
  const lines = [];
  for (const change of changes) {
    const detail = describe(transactions.get(change.transactionId));
    const fee = change.fee.amount;
    const principal = change.amount.amount - fee;
    if (commercial) delete detail.terminalLocation;
    if (principal !== 0) lines.push({ date: change.effectiveAt, ...detail, amount: principal });
    if (fee !== 0) {
      lines.push({
        date: change.effectiveAt,
        type: fee < 0 ? 'FEE' : 'FEE_REFUND',
        payee: detail.payee,
        amount: fee,
      });
    }
  }
  const totalFees = -changes.reduce((sum, change) => sum + change.fee.amount, 0);

  const { currency } = closingBalance;
  const format = (amount) => (amount / 10 ** currency.decimals).toFixed(currency.decimals);

  const statement = {
    // Disclosures: a consumer statement carries the notice, a commercial statement the contact line
    providerLine:
      'This account is held at Lead Bank, the account-holding institution. ' +
      'Lightspark is the program manager and is not a bank.',
    ...(commercial
      ? { contactLine: 'Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.' }
      : { errorResolutionNotice: ERROR_RESOLUTION_NOTICE }), // the notice includes the phone number and mailing address
    // Account fields
    accountHolder: commercial ? customer.businessInfo.legalName : customer.fullName,
    accountType: account.type, // render as a label, for example "Consumer prepaid account"
    accountNumber: maskAccountNumber(account.fundingPaymentInstructions),
    period: { from, to },
    issueDate: new Date().toISOString(),
    openingBalance: format(openingBalance.amount),
    closingBalance: format(closingBalance.amount),
    lines: lines.map((line) => ({ ...line, amount: format(line.amount) })),
    totalFees: format(totalFees),
  };

  await sendStatementEmail(customer.platformCustomerId, statement); // your delivery channel

  // The monthly receipt: Grid's record that this account's statement was issued
  await grid.post(`/internal-accounts/${accountId}/statement-confirmations`, {
    periodStart: `${year}-${String(month).padStart(2, '0')}-01`,
    deliveredAt: statement.issueDate,
  });
}
```
