From 6720ff4a3643c04b864ad47494697df4fd51eff7 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Fri, 25 Sep 2026 23:11:31 +0000 Subject: [PATCH 1/7] feat(statements): balance changes and statement delivery confirmation Adds the two operations a platform needs to build and evidence a periodic statement for an internal account: GET /internal-accounts/{id}/balance-changes returns one row per change to the balance with the window's opening and closing balances in the same response, and POST /internal-accounts/{id}/statement-confirmations records that a statement was delivered. Adds 409 BALANCE_NOT_YET_FINAL and 422 PERIOD_NOT_REPORTABLE. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- .stainless/stainless.yml | 9 + mintlify/openapi.yaml | 307 ++++++++++++++++++ openapi.yaml | 307 ++++++++++++++++++ .../components/schemas/errors/Error409.yaml | 2 + .../components/schemas/errors/Error422.yaml | 2 + .../internal_accounts/BalanceChange.yaml | 32 ++ .../BalanceChangeListResponse.yaml | 51 +++ .../ConfirmStatementDeliveryRequest.yaml | 19 ++ .../internal_accounts/StatementDelivery.yaml | 32 ++ openapi/openapi.yaml | 29 ++ ...nternal_accounts_{id}_balance-changes.yaml | 134 ++++++++ ...accounts_{id}_statement-confirmations.yaml | 75 +++++ 12 files changed, 999 insertions(+) create mode 100644 openapi/components/schemas/internal_accounts/BalanceChange.yaml create mode 100644 openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml create mode 100644 openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml create mode 100644 openapi/components/schemas/internal_accounts/StatementDelivery.yaml create mode 100644 openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml create mode 100644 openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml diff --git a/.stainless/stainless.yml b/.stainless/stainless.yml index 6f49e64b6..e3c8774ad 100644 --- a/.stainless/stainless.yml +++ b/.stainless/stainless.yml @@ -125,6 +125,11 @@ resources: internal_account_update_request: '#/components/schemas/InternalAccountUpdateRequest' internal_account_export_request: '#/components/schemas/InternalAccountExportRequest' internal_account_export_response: '#/components/schemas/InternalAccountExportResponse' + # Periodic statements + balance_change: '#/components/schemas/BalanceChange' + balance_change_list_response: '#/components/schemas/BalanceChangeListResponse' + confirm_statement_delivery_request: '#/components/schemas/ConfirmStatementDeliveryRequest' + statement_delivery: '#/components/schemas/StatementDelivery' # KYC link generation kyc_link_create_request: '#/components/schemas/KycLinkCreateRequest' kyc_link_response: '#/components/schemas/KycLinkResponse' @@ -143,6 +148,10 @@ resources: update_internal_account: endpoint: patch /internal-accounts/{id} body_param_name: InternalAccountUpdateRequest + list_balance_changes: get /internal-accounts/{id}/balance-changes + confirm_statement_delivery: + endpoint: post /internal-accounts/{id}/statement-confirmations + body_param_name: ConfirmStatementDeliveryRequest create_kyc_link: endpoint: post /customers/{customerId}/kyc-link body_param_name: KycLinkCreateRequest diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 509364c2d..991fb6b83 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -31,6 +31,31 @@ tags: description: Endpoints for uploading and managing verification documents for customers and beneficial owners. Supports KYC and KYB document requirements. - name: Internal Accounts description: Internal account management endpoints for creating and managing internal accounts + - name: Periodic Statements + description: | + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + + **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** + + One row per change to the balance, in the order the money moved, with the opening and closing balances for the window in the same response. Page until `hasMore` is false, then assert this identity across every page before you render anything: + + ``` + openingBalance + Σ(data[].amount) == closingBalance + ``` + + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + + **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + + Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. + + **Coverage** + + Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + + **Timing** + + A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6186,6 +6211,170 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /internal-accounts/{id}/balance-changes: + parameters: + - name: id + in: path + description: The id of the internal account to list balance changes for. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + get: + summary: List balance changes for a period + description: | + Every change to this account's balance in a window, in the order the money moved, with the opening and closing balances for that window in the same response. + + This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. + + **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. + + `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. + + A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + + Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. + operationId: listInternalAccountBalanceChanges + tags: + - Periodic Statements + security: + - BasicAuth: [] + parameters: + - name: from + in: query + required: true + description: Start of the window, inclusive. Must include a timezone offset. + schema: + type: string + format: date-time + example: '2026-08-01T00:00:00Z' + - name: to + in: query + required: true + description: End of the window, exclusive. Must include a timezone offset and must not be in the future. + schema: + type: string + format: date-time + example: '2026-09-01T00:00:00Z' + - name: limit + in: query + required: false + description: Maximum number of changes to return per page + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: cursor + in: query + required: false + description: Cursor for pagination (returned from previous request) + schema: + type: string + responses: + '200': + description: The balance changes in the window, with the balances that bound it + content: + application/json: + schema: + $ref: '#/components/schemas/BalanceChangeListResponse' + '400': + description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, or when `from` is not before `to`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The window is not final yet. Card settlement for one of its boundaries has not closed, so its balances and changes could still change. Retry once it has settled. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '422': + description: No statement is available for this window, and retrying will not change that. + content: + application/json: + schema: + $ref: '#/components/schemas/Error422' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /internal-accounts/{id}/statement-confirmations: + parameters: + - name: id + in: path + description: The id of the internal account whose statement was delivered. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + post: + summary: Confirm delivery of a periodic statement + description: | + Record that this account's periodic statement for a period was delivered to the customer. + + Regulation E requires the statement to be delivered, not only available. Grid provides the statement through `GET /internal-accounts/{id}/balance-changes`; only you know whether it reached your customer. Call this once you have delivered it. + + `periodStart` names the period: the first day of the statement month, as a date. It must be the first of a month, and a month that has already begun. + + Confirming is idempotent. The stored time is when delivery first happened, so re-sending a statement or calling this endpoint again does not move it. If `deliveredAt` is omitted, the current time is used. + operationId: confirmStatementDelivery + tags: + - Periodic Statements + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmStatementDeliveryRequest' + responses: + '200': + description: Statement delivery recorded + content: + application/json: + schema: + $ref: '#/components/schemas/StatementDelivery' + '400': + description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, or when `deliveredAt` is in the future. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /auth/credentials: post: summary: Create an authentication credential @@ -15156,6 +15345,7 @@ components: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | + | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -15180,6 +15370,7 @@ components: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE + - BALANCE_NOT_YET_FINAL - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT @@ -24583,8 +24774,10 @@ components: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | + | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED + - PERIOD_NOT_REPORTABLE reason: type: string description: Error message @@ -26665,6 +26858,120 @@ components: anyOf: - $ref: '#/components/schemas/InternalAccountExportResponse' - $ref: '#/components/schemas/WalletOperationProcessing' + BalanceChange: + type: object + description: 'One movement of the account''s balance, signed in the account holder''s polarity: money out is negative and money in is positive, so a window''s changes sum to its closing balance less its opening balance.' + required: + - id + - transactionId + - amount + - effectiveAt + properties: + id: + type: string + description: Stable identifier for this balance change + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + transactionId: + type: string + description: Opaque identifier for the movement this change belongs to. Several changes can share one, so it groups the lines of a single movement on a statement. It is not a `Transaction` id and does not resolve through `GET /transactions/{transactionId}`. + example: 019542f5-b3e7-1d02-0000-000000000004 + amount: + $ref: '#/components/schemas/CurrencyAmount' + effectiveAt: + type: string + format: date-time + description: When the account holder's balance moved. Changes are ordered by this time. + example: '2026-09-03T14:31:00Z' + BalanceChangeListResponse: + type: object + description: A window of balance changes with the balances that bound it. The opening and closing balances describe the whole window, not the page, so they are the same on every page, and the identity `openingBalance + Σ(data[].amount) == closingBalance` holds only once every page's `data` is summed. + required: + - data + - periodStart + - periodEnd + - openingBalance + - closingBalance + - hasMore + properties: + data: + type: array + description: Balance changes on this page, ordered by `effectiveAt` + items: + $ref: '#/components/schemas/BalanceChange' + periodStart: + type: string + format: date-time + description: Start of the window, inclusive + example: '2026-08-01T00:00:00Z' + periodEnd: + type: string + format: date-time + description: End of the window, exclusive + example: '2026-09-01T00:00:00Z' + openingBalance: + $ref: '#/components/schemas/CurrencyAmount' + closingBalance: + $ref: '#/components/schemas/CurrencyAmount' + hasMore: + type: boolean + description: Indicates if more changes are available beyond this page + example: false + nextCursor: + type: string + description: Cursor to retrieve the next page of results (only present if hasMore is true) + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + countIsExact: + type: boolean + description: Whether `totalCount` is exact rather than capped + example: true + totalCount: + type: integer + description: Number of balance changes in the window, across all pages + example: 42 + ConfirmStatementDeliveryRequest: + type: object + required: + - periodStart + properties: + periodStart: + type: string + format: date + description: First day of the statement period being confirmed. Must be the first day of a month that has already begun. + example: '2026-09-01' + deliveredAt: + type: string + format: date-time + description: When the statement was delivered to the customer. Must include a timezone offset and must not be in the future. Defaults to the current time. + example: '2026-10-03T14:31:00Z' + StatementDelivery: + type: object + required: + - internalAccountId + - periodStart + properties: + internalAccountId: + type: string + description: The account whose statement this is + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + periodStart: + type: string + format: date + description: First day of the statement period + example: '2026-09-01' + fetchedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: When the statement for this period was first read from Grid, or null if it never has been + example: '2026-10-01T09:00:00Z' + confirmedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: When the statement was first delivered to the customer, or null if delivery has not been confirmed. Later confirmations do not move it. + example: '2026-10-03T14:31:00Z' AuthMethodType: type: string enum: diff --git a/openapi.yaml b/openapi.yaml index 509364c2d..991fb6b83 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -31,6 +31,31 @@ tags: description: Endpoints for uploading and managing verification documents for customers and beneficial owners. Supports KYC and KYB document requirements. - name: Internal Accounts description: Internal account management endpoints for creating and managing internal accounts + - name: Periodic Statements + description: | + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + + **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** + + One row per change to the balance, in the order the money moved, with the opening and closing balances for the window in the same response. Page until `hasMore` is false, then assert this identity across every page before you render anything: + + ``` + openingBalance + Σ(data[].amount) == closingBalance + ``` + + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + + **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + + Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. + + **Coverage** + + Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + + **Timing** + + A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6186,6 +6211,170 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /internal-accounts/{id}/balance-changes: + parameters: + - name: id + in: path + description: The id of the internal account to list balance changes for. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + get: + summary: List balance changes for a period + description: | + Every change to this account's balance in a window, in the order the money moved, with the opening and closing balances for that window in the same response. + + This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. + + **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. + + `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. + + A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + + Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. + operationId: listInternalAccountBalanceChanges + tags: + - Periodic Statements + security: + - BasicAuth: [] + parameters: + - name: from + in: query + required: true + description: Start of the window, inclusive. Must include a timezone offset. + schema: + type: string + format: date-time + example: '2026-08-01T00:00:00Z' + - name: to + in: query + required: true + description: End of the window, exclusive. Must include a timezone offset and must not be in the future. + schema: + type: string + format: date-time + example: '2026-09-01T00:00:00Z' + - name: limit + in: query + required: false + description: Maximum number of changes to return per page + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: cursor + in: query + required: false + description: Cursor for pagination (returned from previous request) + schema: + type: string + responses: + '200': + description: The balance changes in the window, with the balances that bound it + content: + application/json: + schema: + $ref: '#/components/schemas/BalanceChangeListResponse' + '400': + description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, or when `from` is not before `to`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The window is not final yet. Card settlement for one of its boundaries has not closed, so its balances and changes could still change. Retry once it has settled. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '422': + description: No statement is available for this window, and retrying will not change that. + content: + application/json: + schema: + $ref: '#/components/schemas/Error422' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /internal-accounts/{id}/statement-confirmations: + parameters: + - name: id + in: path + description: The id of the internal account whose statement was delivered. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + post: + summary: Confirm delivery of a periodic statement + description: | + Record that this account's periodic statement for a period was delivered to the customer. + + Regulation E requires the statement to be delivered, not only available. Grid provides the statement through `GET /internal-accounts/{id}/balance-changes`; only you know whether it reached your customer. Call this once you have delivered it. + + `periodStart` names the period: the first day of the statement month, as a date. It must be the first of a month, and a month that has already begun. + + Confirming is idempotent. The stored time is when delivery first happened, so re-sending a statement or calling this endpoint again does not move it. If `deliveredAt` is omitted, the current time is used. + operationId: confirmStatementDelivery + tags: + - Periodic Statements + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmStatementDeliveryRequest' + responses: + '200': + description: Statement delivery recorded + content: + application/json: + schema: + $ref: '#/components/schemas/StatementDelivery' + '400': + description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, or when `deliveredAt` is in the future. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /auth/credentials: post: summary: Create an authentication credential @@ -15156,6 +15345,7 @@ components: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | + | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -15180,6 +15370,7 @@ components: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE + - BALANCE_NOT_YET_FINAL - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT @@ -24583,8 +24774,10 @@ components: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | + | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED + - PERIOD_NOT_REPORTABLE reason: type: string description: Error message @@ -26665,6 +26858,120 @@ components: anyOf: - $ref: '#/components/schemas/InternalAccountExportResponse' - $ref: '#/components/schemas/WalletOperationProcessing' + BalanceChange: + type: object + description: 'One movement of the account''s balance, signed in the account holder''s polarity: money out is negative and money in is positive, so a window''s changes sum to its closing balance less its opening balance.' + required: + - id + - transactionId + - amount + - effectiveAt + properties: + id: + type: string + description: Stable identifier for this balance change + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + transactionId: + type: string + description: Opaque identifier for the movement this change belongs to. Several changes can share one, so it groups the lines of a single movement on a statement. It is not a `Transaction` id and does not resolve through `GET /transactions/{transactionId}`. + example: 019542f5-b3e7-1d02-0000-000000000004 + amount: + $ref: '#/components/schemas/CurrencyAmount' + effectiveAt: + type: string + format: date-time + description: When the account holder's balance moved. Changes are ordered by this time. + example: '2026-09-03T14:31:00Z' + BalanceChangeListResponse: + type: object + description: A window of balance changes with the balances that bound it. The opening and closing balances describe the whole window, not the page, so they are the same on every page, and the identity `openingBalance + Σ(data[].amount) == closingBalance` holds only once every page's `data` is summed. + required: + - data + - periodStart + - periodEnd + - openingBalance + - closingBalance + - hasMore + properties: + data: + type: array + description: Balance changes on this page, ordered by `effectiveAt` + items: + $ref: '#/components/schemas/BalanceChange' + periodStart: + type: string + format: date-time + description: Start of the window, inclusive + example: '2026-08-01T00:00:00Z' + periodEnd: + type: string + format: date-time + description: End of the window, exclusive + example: '2026-09-01T00:00:00Z' + openingBalance: + $ref: '#/components/schemas/CurrencyAmount' + closingBalance: + $ref: '#/components/schemas/CurrencyAmount' + hasMore: + type: boolean + description: Indicates if more changes are available beyond this page + example: false + nextCursor: + type: string + description: Cursor to retrieve the next page of results (only present if hasMore is true) + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + countIsExact: + type: boolean + description: Whether `totalCount` is exact rather than capped + example: true + totalCount: + type: integer + description: Number of balance changes in the window, across all pages + example: 42 + ConfirmStatementDeliveryRequest: + type: object + required: + - periodStart + properties: + periodStart: + type: string + format: date + description: First day of the statement period being confirmed. Must be the first day of a month that has already begun. + example: '2026-09-01' + deliveredAt: + type: string + format: date-time + description: When the statement was delivered to the customer. Must include a timezone offset and must not be in the future. Defaults to the current time. + example: '2026-10-03T14:31:00Z' + StatementDelivery: + type: object + required: + - internalAccountId + - periodStart + properties: + internalAccountId: + type: string + description: The account whose statement this is + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + periodStart: + type: string + format: date + description: First day of the statement period + example: '2026-09-01' + fetchedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: When the statement for this period was first read from Grid, or null if it never has been + example: '2026-10-01T09:00:00Z' + confirmedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: When the statement was first delivered to the customer, or null if delivery has not been confirmed. Later confirmations do not move it. + example: '2026-10-03T14:31:00Z' AuthMethodType: type: string enum: diff --git a/openapi/components/schemas/errors/Error409.yaml b/openapi/components/schemas/errors/Error409.yaml index d786a2363..c10308376 100644 --- a/openapi/components/schemas/errors/Error409.yaml +++ b/openapi/components/schemas/errors/Error409.yaml @@ -22,6 +22,7 @@ properties: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | + | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -46,6 +47,7 @@ properties: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE + - BALANCE_NOT_YET_FINAL - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT diff --git a/openapi/components/schemas/errors/Error422.yaml b/openapi/components/schemas/errors/Error422.yaml index 583825a44..53a7ac050 100644 --- a/openapi/components/schemas/errors/Error422.yaml +++ b/openapi/components/schemas/errors/Error422.yaml @@ -9,8 +9,10 @@ properties: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | + | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED + - PERIOD_NOT_REPORTABLE reason: type: string description: Error message diff --git a/openapi/components/schemas/internal_accounts/BalanceChange.yaml b/openapi/components/schemas/internal_accounts/BalanceChange.yaml new file mode 100644 index 000000000..ab6149a12 --- /dev/null +++ b/openapi/components/schemas/internal_accounts/BalanceChange.yaml @@ -0,0 +1,32 @@ +type: object +description: >- + One movement of the account's balance, signed in the account holder's + polarity: money out is negative and money in is positive, so a window's + changes sum to its closing balance less its opening balance. +required: + - id + - transactionId + - amount + - effectiveAt +properties: + id: + type: string + description: Stable identifier for this balance change + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + transactionId: + type: string + description: >- + Opaque identifier for the movement this change belongs to. Several + changes can share one, so it groups the lines of a single movement on a + statement. It is not a `Transaction` id and does not resolve through + `GET /transactions/{transactionId}`. + example: 019542f5-b3e7-1d02-0000-000000000004 + amount: + $ref: ../common/CurrencyAmount.yaml + effectiveAt: + type: string + format: date-time + description: >- + When the account holder's balance moved. Changes are ordered by this + time. + example: '2026-09-03T14:31:00Z' diff --git a/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml b/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml new file mode 100644 index 000000000..b7ead16b5 --- /dev/null +++ b/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml @@ -0,0 +1,51 @@ +type: object +description: >- + A window of balance changes with the balances that bound it. The opening and + closing balances describe the whole window, not the page, so they are the + same on every page, and the identity `openingBalance + Σ(data[].amount) == + closingBalance` holds only once every page's `data` is summed. +required: + - data + - periodStart + - periodEnd + - openingBalance + - closingBalance + - hasMore +properties: + data: + type: array + description: Balance changes on this page, ordered by `effectiveAt` + items: + $ref: ./BalanceChange.yaml + periodStart: + type: string + format: date-time + description: Start of the window, inclusive + example: '2026-08-01T00:00:00Z' + periodEnd: + type: string + format: date-time + description: End of the window, exclusive + example: '2026-09-01T00:00:00Z' + openingBalance: + $ref: ../common/CurrencyAmount.yaml + closingBalance: + $ref: ../common/CurrencyAmount.yaml + hasMore: + type: boolean + description: Indicates if more changes are available beyond this page + example: false + nextCursor: + type: string + description: >- + Cursor to retrieve the next page of results (only present if hasMore is + true) + example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 + countIsExact: + type: boolean + description: Whether `totalCount` is exact rather than capped + example: true + totalCount: + type: integer + description: Number of balance changes in the window, across all pages + example: 42 diff --git a/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml new file mode 100644 index 000000000..185e92abd --- /dev/null +++ b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml @@ -0,0 +1,19 @@ +type: object +required: + - periodStart +properties: + periodStart: + type: string + format: date + description: >- + First day of the statement period being confirmed. Must be the first + day of a month that has already begun. + example: '2026-09-01' + deliveredAt: + type: string + format: date-time + description: >- + When the statement was delivered to the customer. Must include a + timezone offset and must not be in the future. Defaults to the current + time. + example: '2026-10-03T14:31:00Z' diff --git a/openapi/components/schemas/internal_accounts/StatementDelivery.yaml b/openapi/components/schemas/internal_accounts/StatementDelivery.yaml new file mode 100644 index 000000000..e4d8364e1 --- /dev/null +++ b/openapi/components/schemas/internal_accounts/StatementDelivery.yaml @@ -0,0 +1,32 @@ +type: object +required: + - internalAccountId + - periodStart +properties: + internalAccountId: + type: string + description: The account whose statement this is + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + periodStart: + type: string + format: date + description: First day of the statement period + example: '2026-09-01' + fetchedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: >- + When the statement for this period was first read from Grid, or null if + it never has been + example: '2026-10-01T09:00:00Z' + confirmedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + description: >- + When the statement was first delivered to the customer, or null if + delivery has not been confirmed. Later confirmations do not move it. + example: '2026-10-03T14:31:00Z' diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 3e0455dc2..7b6b4b9a0 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -38,6 +38,31 @@ tags: and beneficial owners. Supports KYC and KYB document requirements. - name: Internal Accounts description: Internal account management endpoints for creating and managing internal accounts + - name: Periodic Statements + description: | + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + + **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** + + One row per change to the balance, in the order the money moved, with the opening and closing balances for the window in the same response. Page until `hasMore` is false, then assert this identity across every page before you render anything: + + ``` + openingBalance + Σ(data[].amount) == closingBalance + ``` + + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + + **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + + Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. + + **Coverage** + + Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + + **Timing** + + A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -283,6 +308,10 @@ paths: $ref: paths/internal_accounts/internal_accounts_{id}.yaml /internal-accounts/{id}/export: $ref: paths/internal_accounts/internal_accounts_{id}_export.yaml + /internal-accounts/{id}/balance-changes: + $ref: paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml + /internal-accounts/{id}/statement-confirmations: + $ref: paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml /auth/credentials: $ref: paths/auth/auth_credentials.yaml /auth/credentials/{id}: diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml new file mode 100644 index 000000000..0ebd18889 --- /dev/null +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -0,0 +1,134 @@ +parameters: + - name: id + in: path + description: The id of the internal account to list balance changes for. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + +get: + summary: List balance changes for a period + description: > + Every change to this account's balance in a window, in the order the money + moved, with the opening and closing balances for that window in the same + response. + + + This is the statement feed. `GET /transactions` returns one row per + transaction. This returns one row per change to the balance, and a + transaction that moves the balance more than once produces more than one + change: an ACH deposit and its later return are two, and a card purchase + that clears in two parts is two. + + + **The identity to assert:** `openingBalance + Σ(data[].amount) == + closingBalance`, summed over every page. The opening and closing balances + describe the window rather than the page, so they are the same on every + page. Page until `hasMore` is false, then assert the identity before + rendering a statement. + + + `from` and `to` are instants and the window is half-open: a change at + exactly `to` belongs to the next window. Consecutive periods therefore + tile with no gap and no overlap. For a monthly statement, pass the first + instant of the month and the first instant of the following month. + + + A window whose card settlement has not closed is refused with `409 + BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once + it has settled. A window Grid cannot report at all is refused with `422 + PERIOD_NOT_REPORTABLE`, and retrying will not change that. + + + Merchant, counterparty and rail detail live on the transaction; read them + from `GET /transactions`. + operationId: listInternalAccountBalanceChanges + tags: + - Periodic Statements + security: + - BasicAuth: [] + parameters: + - name: from + in: query + required: true + description: Start of the window, inclusive. Must include a timezone offset. + schema: + type: string + format: date-time + example: '2026-08-01T00:00:00Z' + - name: to + in: query + required: true + description: >- + End of the window, exclusive. Must include a timezone offset and must + not be in the future. + schema: + type: string + format: date-time + example: '2026-09-01T00:00:00Z' + - name: limit + in: query + required: false + description: Maximum number of changes to return per page + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: cursor + in: query + required: false + description: Cursor for pagination (returned from previous request) + schema: + type: string + responses: + '200': + description: The balance changes in the window, with the balances that bound it + content: + application/json: + schema: + $ref: ../../components/schemas/internal_accounts/BalanceChangeListResponse.yaml + '400': + description: >- + Bad request. Returned when `from` or `to` is missing, has no timezone + offset, or is in the future, or when `from` is not before `to`. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + The window is not final yet. Card settlement for one of its boundaries + has not closed, so its balances and changes could still change. Retry + once it has settled. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '422': + description: >- + No statement is available for this window, and retrying will not change + that. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error422.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml new file mode 100644 index 000000000..5fb52d225 --- /dev/null +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml @@ -0,0 +1,75 @@ +parameters: + - name: id + in: path + description: The id of the internal account whose statement was delivered. + required: true + schema: + type: string + example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 + +post: + summary: Confirm delivery of a periodic statement + description: > + Record that this account's periodic statement for a period was delivered + to the customer. + + + Regulation E requires the statement to be delivered, not only available. + Grid provides the statement through `GET + /internal-accounts/{id}/balance-changes`; only you know whether it reached + your customer. Call this once you have delivered it. + + + `periodStart` names the period: the first day of the statement month, as a + date. It must be the first of a month, and a month that has already + begun. + + + Confirming is idempotent. The stored time is when delivery first + happened, so re-sending a statement or calling this endpoint again does + not move it. If `deliveredAt` is omitted, the current time is used. + operationId: confirmStatementDelivery + tags: + - Periodic Statements + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml + responses: + '200': + description: Statement delivery recorded + content: + application/json: + schema: + $ref: ../../components/schemas/internal_accounts/StatementDelivery.yaml + '400': + description: >- + Bad request. Returned when `periodStart` is not the first day of a + month or names a month that has not begun, or when `deliveredAt` is + in the future. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Internal account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml From 8dca224825fc90fd402914eb30451178e81a21f2 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Sat, 26 Sep 2026 00:26:28 +0000 Subject: [PATCH 2/7] fix(statements): name the retry-later 409 NOT_YET_AVAILABLE The code tells a caller to come back later, which is not specific to balances. A generic name lets any endpoint whose answer is not ready yet reuse it, and the reason text still says what is outstanding. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 8 ++++---- openapi.yaml | 8 ++++---- openapi/components/schemas/errors/Error409.yaml | 4 ++-- openapi/openapi.yaml | 2 +- .../internal_accounts_{id}_balance-changes.yaml | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 991fb6b83..678000ba6 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -55,7 +55,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6231,7 +6231,7 @@ paths: `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. - A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges @@ -15345,7 +15345,7 @@ components: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | - | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | + | NOT_YET_AVAILABLE | The request is valid, but its result is not available yet because the data it depends on could still change. Retry later. `reason` says what is outstanding. For balance changes, card settlement for a window boundary has not closed | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -15370,7 +15370,7 @@ components: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE - - BALANCE_NOT_YET_FINAL + - NOT_YET_AVAILABLE - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT diff --git a/openapi.yaml b/openapi.yaml index 991fb6b83..678000ba6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -55,7 +55,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6231,7 +6231,7 @@ paths: `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. - A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges @@ -15345,7 +15345,7 @@ components: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | - | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | + | NOT_YET_AVAILABLE | The request is valid, but its result is not available yet because the data it depends on could still change. Retry later. `reason` says what is outstanding. For balance changes, card settlement for a window boundary has not closed | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -15370,7 +15370,7 @@ components: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE - - BALANCE_NOT_YET_FINAL + - NOT_YET_AVAILABLE - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT diff --git a/openapi/components/schemas/errors/Error409.yaml b/openapi/components/schemas/errors/Error409.yaml index c10308376..13517d5bd 100644 --- a/openapi/components/schemas/errors/Error409.yaml +++ b/openapi/components/schemas/errors/Error409.yaml @@ -22,7 +22,7 @@ properties: | CARD_NOT_MUTABLE | The card is `CLOSED`, so it can no longer be mutated | | CARD_LIMIT_REACHED | The platform has reached the maximum number of live cards it may hold, or the cardholder already holds a card and the platform is limited to one per cardholder. Closing a card frees its slot; contact Lightspark to raise the limit | | STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE | Grid enablement can only be requested while the stablecoin is `NOT_ENABLED` (a repeat request while already `PENDING_APPROVAL` succeeds). `ENABLING`, `ENABLED` and `DISABLED` are driven by Lightspark and cannot be requested | - | BALANCE_NOT_YET_FINAL | Card settlement for a boundary of the requested window has not closed, so its balances and balance changes could still change. Retry once it has settled | + | NOT_YET_AVAILABLE | The request is valid, but its result is not available yet because the data it depends on could still change. Retry later. `reason` says what is outstanding. For balance changes, card settlement for a window boundary has not closed | | CONFLICT | Generic resource-state conflict. Returned, for example, when `platformCustomerId` on a customer create call collides with an existing active customer on the same platform | | DOCUMENT_ALREADY_EXISTS | A document of this type already exists for the holder; replace it with PUT | | DUPLICATE_EXTERNAL_ACCOUNT | An equivalent external account already exists | @@ -47,7 +47,7 @@ properties: - CARD_NOT_MUTABLE - CARD_LIMIT_REACHED - STABLECOIN_GRID_ENABLEMENT_NOT_REQUESTABLE - - BALANCE_NOT_YET_FINAL + - NOT_YET_AVAILABLE - CONFLICT - DOCUMENT_ALREADY_EXISTS - DUPLICATE_EXTERNAL_ACCOUNT diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 7b6b4b9a0..920886a36 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -62,7 +62,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 BALANCE_NOT_YET_FINAL` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml index 0ebd18889..f2608b207 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -36,7 +36,7 @@ get: A window whose card settlement has not closed is refused with `409 - BALANCE_NOT_YET_FINAL`, because its figures could still change. Retry once + NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. From a4404604f429bcda49ca0e74928478a29412dcd9 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Sat, 26 Sep 2026 01:05:13 +0000 Subject: [PATCH 3/7] fix(statements): drop the unreportable-period refusal Every settled window can now be served, so balance changes no longer answers 422 PERIOD_NOT_REPORTABLE. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 12 ++---------- openapi.yaml | 12 ++---------- openapi/components/schemas/errors/Error422.yaml | 2 -- openapi/openapi.yaml | 2 +- .../internal_accounts_{id}_balance-changes.yaml | 11 +---------- 5 files changed, 6 insertions(+), 33 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 678000ba6..1f4b62326 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -55,7 +55,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6231,7 +6231,7 @@ paths: `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. - A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges @@ -6302,12 +6302,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' - '422': - description: No statement is available for this window, and retrying will not change that. - content: - application/json: - schema: - $ref: '#/components/schemas/Error422' '500': description: Internal service error content: @@ -24774,10 +24768,8 @@ components: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | - | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED - - PERIOD_NOT_REPORTABLE reason: type: string description: Error message diff --git a/openapi.yaml b/openapi.yaml index 678000ba6..1f4b62326 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -55,7 +55,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers @@ -6231,7 +6231,7 @@ paths: `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. - A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`, and retrying will not change that. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges @@ -6302,12 +6302,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' - '422': - description: No statement is available for this window, and retrying will not change that. - content: - application/json: - schema: - $ref: '#/components/schemas/Error422' '500': description: Internal service error content: @@ -24774,10 +24768,8 @@ components: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | - | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED - - PERIOD_NOT_REPORTABLE reason: type: string description: Error message diff --git a/openapi/components/schemas/errors/Error422.yaml b/openapi/components/schemas/errors/Error422.yaml index 53a7ac050..583825a44 100644 --- a/openapi/components/schemas/errors/Error422.yaml +++ b/openapi/components/schemas/errors/Error422.yaml @@ -9,10 +9,8 @@ properties: | Error Code | Description | |------------|-------------| | DOCUMENT_REJECTED | The verification provider screened the uploaded file and marked it unusable. `precheckErrors` names each problem it found | - | PERIOD_NOT_REPORTABLE | No statement is available for the requested window. Retrying will not change that | enum: - DOCUMENT_REJECTED - - PERIOD_NOT_REPORTABLE reason: type: string description: Error message diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 920886a36..8bf59ecd4 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -62,7 +62,7 @@ tags: **Timing** - A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. A window Grid cannot report at all is refused with `422 PERIOD_NOT_REPORTABLE`. + A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE` rather than answered with figures that could still change; retry once it has settled. - name: External Accounts description: External account management endpoints for creating and managing external bank accounts - name: Same-Currency Transfers diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml index f2608b207..e288b8885 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -37,8 +37,7 @@ get: A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once - it has settled. A window Grid cannot report at all is refused with `422 - PERIOD_NOT_REPORTABLE`, and retrying will not change that. + it has settled. Merchant, counterparty and rail detail live on the transaction; read them @@ -118,14 +117,6 @@ get: application/json: schema: $ref: ../../components/schemas/errors/Error409.yaml - '422': - description: >- - No statement is available for this window, and retrying will not change - that. - content: - application/json: - schema: - $ref: ../../components/schemas/errors/Error422.yaml '500': description: Internal service error content: From 7b41fea59950a65360f3b0640935935f0da5cf23 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Sat, 26 Sep 2026 01:37:28 +0000 Subject: [PATCH 4/7] Say which accounts get a statement, when a period starts, and when a delivery can be confirmed A statement covers a customer's whole balance and is issued for their own USD account; rule-based, bulk settlement and platform-owned accounts are refused. A period is a calendar month in US Central time, and only a window that is exactly one such month counts as a statement fetch, so the examples now carry the Central offset. deliveredAt, and the request time it defaults to, must fall after the period ends. The balance-changes 400 also names an unrecognized cursor. Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 24 ++++++++++--------- openapi.yaml | 24 ++++++++++--------- .../BalanceChangeListResponse.yaml | 4 ++-- .../ConfirmStatementDeliveryRequest.yaml | 9 +++---- openapi/openapi.yaml | 6 +++-- ...nternal_accounts_{id}_balance-changes.yaml | 12 ++++++---- ...accounts_{id}_statement-confirmations.yaml | 5 ++-- 7 files changed, 48 insertions(+), 36 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 1f4b62326..52065259e 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -33,7 +33,9 @@ tags: description: Internal account management endpoints for creating and managing internal accounts - name: Periodic Statements description: | - Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month in US Central time (`America/Chicago`). + + A statement is issued for each customer's own USD internal account and covers their whole balance. Money received through a rule-based account appears on its owner's statement; rule-based, bulk settlement and platform-owned accounts have no statement of their own. **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** @@ -43,7 +45,7 @@ tags: openingBalance + Σ(data[].amount) == closingBalance ``` - If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** @@ -6229,7 +6231,7 @@ paths: **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. - `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. + `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (`America/Chicago`). Grid records a statement as fetched only for a window that is exactly one such month. A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. @@ -6247,7 +6249,7 @@ paths: schema: type: string format: date-time - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' - name: to in: query required: true @@ -6255,7 +6257,7 @@ paths: schema: type: string format: date-time - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' - name: limit in: query required: false @@ -6279,7 +6281,7 @@ paths: schema: $ref: '#/components/schemas/BalanceChangeListResponse' '400': - description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, or when `from` is not before `to`. + description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, when `from` is not before `to`, when `cursor` was not issued by this endpoint, or when the account has no statement of its own. content: application/json: schema: @@ -6346,7 +6348,7 @@ paths: schema: $ref: '#/components/schemas/StatementDelivery' '400': - description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, or when `deliveredAt` is in the future. + description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, when `deliveredAt` (or, if omitted, the time of the request) is in the future or before the period has ended, or when the account has no statement of its own. content: application/json: schema: @@ -26894,12 +26896,12 @@ components: type: string format: date-time description: Start of the window, inclusive - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' periodEnd: type: string format: date-time description: End of the window, exclusive - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' openingBalance: $ref: '#/components/schemas/CurrencyAmount' closingBalance: @@ -26928,12 +26930,12 @@ components: periodStart: type: string format: date - description: First day of the statement period being confirmed. Must be the first day of a month that has already begun. + description: First day of the statement period being confirmed, a calendar month in US Central time. Must be the first day of a month that has already begun. example: '2026-09-01' deliveredAt: type: string format: date-time - description: When the statement was delivered to the customer. Must include a timezone offset and must not be in the future. Defaults to the current time. + description: When the statement was delivered to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' StatementDelivery: type: object diff --git a/openapi.yaml b/openapi.yaml index 1f4b62326..52065259e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -33,7 +33,9 @@ tags: description: Internal account management endpoints for creating and managing internal accounts - name: Periodic Statements description: | - Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month in US Central time (`America/Chicago`). + + A statement is issued for each customer's own USD internal account and covers their whole balance. Money received through a rule-based account appears on its owner's statement; rule-based, bulk settlement and platform-owned accounts have no statement of their own. **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** @@ -43,7 +45,7 @@ tags: openingBalance + Σ(data[].amount) == closingBalance ``` - If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** @@ -6229,7 +6231,7 @@ paths: **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. - `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month. + `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (`America/Chicago`). Grid records a statement as fetched only for a window that is exactly one such month. A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. @@ -6247,7 +6249,7 @@ paths: schema: type: string format: date-time - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' - name: to in: query required: true @@ -6255,7 +6257,7 @@ paths: schema: type: string format: date-time - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' - name: limit in: query required: false @@ -6279,7 +6281,7 @@ paths: schema: $ref: '#/components/schemas/BalanceChangeListResponse' '400': - description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, or when `from` is not before `to`. + description: Bad request. Returned when `from` or `to` is missing, has no timezone offset, or is in the future, when `from` is not before `to`, when `cursor` was not issued by this endpoint, or when the account has no statement of its own. content: application/json: schema: @@ -6346,7 +6348,7 @@ paths: schema: $ref: '#/components/schemas/StatementDelivery' '400': - description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, or when `deliveredAt` is in the future. + description: Bad request. Returned when `periodStart` is not the first day of a month or names a month that has not begun, when `deliveredAt` (or, if omitted, the time of the request) is in the future or before the period has ended, or when the account has no statement of its own. content: application/json: schema: @@ -26894,12 +26896,12 @@ components: type: string format: date-time description: Start of the window, inclusive - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' periodEnd: type: string format: date-time description: End of the window, exclusive - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' openingBalance: $ref: '#/components/schemas/CurrencyAmount' closingBalance: @@ -26928,12 +26930,12 @@ components: periodStart: type: string format: date - description: First day of the statement period being confirmed. Must be the first day of a month that has already begun. + description: First day of the statement period being confirmed, a calendar month in US Central time. Must be the first day of a month that has already begun. example: '2026-09-01' deliveredAt: type: string format: date-time - description: When the statement was delivered to the customer. Must include a timezone offset and must not be in the future. Defaults to the current time. + description: When the statement was delivered to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' StatementDelivery: type: object diff --git a/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml b/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml index b7ead16b5..5e54c1879 100644 --- a/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml +++ b/openapi/components/schemas/internal_accounts/BalanceChangeListResponse.yaml @@ -21,12 +21,12 @@ properties: type: string format: date-time description: Start of the window, inclusive - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' periodEnd: type: string format: date-time description: End of the window, exclusive - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' openingBalance: $ref: ../common/CurrencyAmount.yaml closingBalance: diff --git a/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml index 185e92abd..e39033dcb 100644 --- a/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml +++ b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml @@ -6,14 +6,15 @@ properties: type: string format: date description: >- - First day of the statement period being confirmed. Must be the first - day of a month that has already begun. + First day of the statement period being confirmed, a calendar month in + US Central time. Must be the first day of a month that has already + begun. example: '2026-09-01' deliveredAt: type: string format: date-time description: >- When the statement was delivered to the customer. Must include a - timezone offset and must not be in the future. Defaults to the current - time. + timezone offset, must not be in the future, and must be at or after the + end of the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 8bf59ecd4..728e0093f 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -40,7 +40,9 @@ tags: description: Internal account management endpoints for creating and managing internal accounts - name: Periodic Statements description: | - Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month. + Build, deliver, and evidence a Regulation E periodic statement for a customer's internal account. A statement period is a calendar month in US Central time (`America/Chicago`). + + A statement is issued for each customer's own USD internal account and covers their whole balance. Money received through a rule-based account appears on its owner's statement; rule-based, bulk settlement and platform-owned accounts have no statement of their own. **1. The statement — `GET /internal-accounts/{id}/balance-changes?from=&to=`** @@ -50,7 +52,7 @@ tags: openingBalance + Σ(data[].amount) == closingBalance ``` - If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month. + If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml index e288b8885..dbd698635 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -32,7 +32,9 @@ get: `from` and `to` are instants and the window is half-open: a change at exactly `to` belongs to the next window. Consecutive periods therefore tile with no gap and no overlap. For a monthly statement, pass the first - instant of the month and the first instant of the following month. + instant of the month and the first instant of the following month, both + in US Central time (`America/Chicago`). Grid records a statement as + fetched only for a window that is exactly one such month. A window whose card settlement has not closed is refused with `409 @@ -55,7 +57,7 @@ get: schema: type: string format: date-time - example: '2026-08-01T00:00:00Z' + example: '2026-08-01T00:00:00-05:00' - name: to in: query required: true @@ -65,7 +67,7 @@ get: schema: type: string format: date-time - example: '2026-09-01T00:00:00Z' + example: '2026-09-01T00:00:00-05:00' - name: limit in: query required: false @@ -91,7 +93,9 @@ get: '400': description: >- Bad request. Returned when `from` or `to` is missing, has no timezone - offset, or is in the future, or when `from` is not before `to`. + offset, or is in the future, when `from` is not before `to`, when + `cursor` was not issued by this endpoint, or when the account has no + statement of its own. content: application/json: schema: diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml index 5fb52d225..cc8417396 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml @@ -49,8 +49,9 @@ post: '400': description: >- Bad request. Returned when `periodStart` is not the first day of a - month or names a month that has not begun, or when `deliveredAt` is - in the future. + month or names a month that has not begun, when `deliveredAt` (or, + if omitted, the time of the request) is in the future or before the + period has ended, or when the account has no statement of its own. content: application/json: schema: From 134bea6a0df5d04b516d410764c3eea3e1d210b9 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Wed, 30 Sep 2026 00:55:17 +0000 Subject: [PATCH 5/7] Describe statement confirmation as a monthly receipt The confirmation is the platform's monthly receipt that it pulled and issued a period's statement, which Grid stores as the delivery record. Grid does not monitor for missing receipts, so the coverage section goes. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 24 ++++++++----------- openapi.yaml | 24 ++++++++----------- .../ConfirmStatementDeliveryRequest.yaml | 8 +++---- .../internal_accounts/StatementDelivery.yaml | 4 ++-- openapi/openapi.yaml | 8 ++----- ...accounts_{id}_statement-confirmations.yaml | 21 ++++++++-------- 6 files changed, 38 insertions(+), 51 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 52065259e..db3870b58 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -47,13 +47,9 @@ tags: If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. - **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + **2. The receipt — `POST /internal-accounts/{id}/statement-confirmations`** - Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. - - **Coverage** - - Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + Once a month, after you have pulled and issued a period's statement, send a receipt with the `periodStart` it covers. Grid stores it as the delivery record for that account and period. Sending it again is harmless: the first receipt's time is kept. **Timing** @@ -6320,15 +6316,15 @@ paths: type: string example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 post: - summary: Confirm delivery of a periodic statement + summary: Send a receipt for a periodic statement description: | - Record that this account's periodic statement for a period was delivered to the customer. + Record your monthly receipt that you pulled and issued this account's periodic statement for a period. Grid stores it as the delivery record for that account and period. - Regulation E requires the statement to be delivered, not only available. Grid provides the statement through `GET /internal-accounts/{id}/balance-changes`; only you know whether it reached your customer. Call this once you have delivered it. + Send it once a month for each account, after you have issued the statement built from `GET /internal-accounts/{id}/balance-changes`. `periodStart` names the period: the first day of the statement month, as a date. It must be the first of a month, and a month that has already begun. - Confirming is idempotent. The stored time is when delivery first happened, so re-sending a statement or calling this endpoint again does not move it. If `deliveredAt` is omitted, the current time is used. + Sending a receipt is idempotent. The stored time is the first receipt's, so calling this again does not move it. If `deliveredAt` is omitted, the current time is used. operationId: confirmStatementDelivery tags: - Periodic Statements @@ -6342,7 +6338,7 @@ paths: $ref: '#/components/schemas/ConfirmStatementDeliveryRequest' responses: '200': - description: Statement delivery recorded + description: Receipt recorded content: application/json: schema: @@ -26930,12 +26926,12 @@ components: periodStart: type: string format: date - description: First day of the statement period being confirmed, a calendar month in US Central time. Must be the first day of a month that has already begun. + description: First day of the statement period the receipt covers, a calendar month in US Central time. Must be the first day of a month that has already begun. example: '2026-09-01' deliveredAt: type: string format: date-time - description: When the statement was delivered to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. + description: When you issued the statement to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' StatementDelivery: type: object @@ -26964,7 +26960,7 @@ components: - type: string format: date-time - type: 'null' - description: When the statement was first delivered to the customer, or null if delivery has not been confirmed. Later confirmations do not move it. + description: When the statement was issued, from the first receipt for this period, or null if no receipt has been sent. Later receipts do not move it. example: '2026-10-03T14:31:00Z' AuthMethodType: type: string diff --git a/openapi.yaml b/openapi.yaml index 52065259e..db3870b58 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -47,13 +47,9 @@ tags: If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. - **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + **2. The receipt — `POST /internal-accounts/{id}/statement-confirmations`** - Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. - - **Coverage** - - Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + Once a month, after you have pulled and issued a period's statement, send a receipt with the `periodStart` it covers. Grid stores it as the delivery record for that account and period. Sending it again is harmless: the first receipt's time is kept. **Timing** @@ -6320,15 +6316,15 @@ paths: type: string example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 post: - summary: Confirm delivery of a periodic statement + summary: Send a receipt for a periodic statement description: | - Record that this account's periodic statement for a period was delivered to the customer. + Record your monthly receipt that you pulled and issued this account's periodic statement for a period. Grid stores it as the delivery record for that account and period. - Regulation E requires the statement to be delivered, not only available. Grid provides the statement through `GET /internal-accounts/{id}/balance-changes`; only you know whether it reached your customer. Call this once you have delivered it. + Send it once a month for each account, after you have issued the statement built from `GET /internal-accounts/{id}/balance-changes`. `periodStart` names the period: the first day of the statement month, as a date. It must be the first of a month, and a month that has already begun. - Confirming is idempotent. The stored time is when delivery first happened, so re-sending a statement or calling this endpoint again does not move it. If `deliveredAt` is omitted, the current time is used. + Sending a receipt is idempotent. The stored time is the first receipt's, so calling this again does not move it. If `deliveredAt` is omitted, the current time is used. operationId: confirmStatementDelivery tags: - Periodic Statements @@ -6342,7 +6338,7 @@ paths: $ref: '#/components/schemas/ConfirmStatementDeliveryRequest' responses: '200': - description: Statement delivery recorded + description: Receipt recorded content: application/json: schema: @@ -26930,12 +26926,12 @@ components: periodStart: type: string format: date - description: First day of the statement period being confirmed, a calendar month in US Central time. Must be the first day of a month that has already begun. + description: First day of the statement period the receipt covers, a calendar month in US Central time. Must be the first day of a month that has already begun. example: '2026-09-01' deliveredAt: type: string format: date-time - description: When the statement was delivered to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. + description: When you issued the statement to the customer. Must include a timezone offset, must not be in the future, and must be at or after the end of the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' StatementDelivery: type: object @@ -26964,7 +26960,7 @@ components: - type: string format: date-time - type: 'null' - description: When the statement was first delivered to the customer, or null if delivery has not been confirmed. Later confirmations do not move it. + description: When the statement was issued, from the first receipt for this period, or null if no receipt has been sent. Later receipts do not move it. example: '2026-10-03T14:31:00Z' AuthMethodType: type: string diff --git a/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml index e39033dcb..ec0158fb3 100644 --- a/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml +++ b/openapi/components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml @@ -6,7 +6,7 @@ properties: type: string format: date description: >- - First day of the statement period being confirmed, a calendar month in + First day of the statement period the receipt covers, a calendar month in US Central time. Must be the first day of a month that has already begun. example: '2026-09-01' @@ -14,7 +14,7 @@ properties: type: string format: date-time description: >- - When the statement was delivered to the customer. Must include a - timezone offset, must not be in the future, and must be at or after the - end of the period. Defaults to the current time. + When you issued the statement to the customer. Must include a timezone + offset, must not be in the future, and must be at or after the end of + the period. Defaults to the current time. example: '2026-10-03T14:31:00Z' diff --git a/openapi/components/schemas/internal_accounts/StatementDelivery.yaml b/openapi/components/schemas/internal_accounts/StatementDelivery.yaml index e4d8364e1..2bbad56d1 100644 --- a/openapi/components/schemas/internal_accounts/StatementDelivery.yaml +++ b/openapi/components/schemas/internal_accounts/StatementDelivery.yaml @@ -27,6 +27,6 @@ properties: format: date-time - type: 'null' description: >- - When the statement was first delivered to the customer, or null if - delivery has not been confirmed. Later confirmations do not move it. + When the statement was issued, from the first receipt for this period, + or null if no receipt has been sent. Later receipts do not move it. example: '2026-10-03T14:31:00Z' diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 728e0093f..9b6a29e3e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -54,13 +54,9 @@ tags: If it does not hold, do not send the statement; contact support instead. `from` and `to` bound a half-open window `[from, to)`: for a monthly statement, pass the first instant of the month and the first instant of the following month, both in US Central time (for August 2026, `2026-08-01T00:00:00-05:00` and `2026-09-01T00:00:00-05:00`). Grid records a statement as fetched only for a window that is exactly one such month. - **2. The attestation — `POST /internal-accounts/{id}/statement-confirmations`** + **2. The receipt — `POST /internal-accounts/{id}/statement-confirmations`** - Regulation E requires the statement to be delivered, not only available. Once you have delivered it, confirm with the `periodStart` you delivered for. Confirming is idempotent and keeps the first delivery time. - - **Coverage** - - Grid monitors periods that were never fetched and periods that were fetched but never confirmed. Confirm each period you deliver and neither signal fires. + Once a month, after you have pulled and issued a period's statement, send a receipt with the `periodStart` it covers. Grid stores it as the delivery record for that account and period. Sending it again is harmless: the first receipt's time is kept. **Timing** diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml index cc8417396..986716b28 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_statement-confirmations.yaml @@ -8,16 +8,15 @@ parameters: example: InternalAccount:019542f5-b3e7-1d02-0000-000000000002 post: - summary: Confirm delivery of a periodic statement + summary: Send a receipt for a periodic statement description: > - Record that this account's periodic statement for a period was delivered - to the customer. + Record your monthly receipt that you pulled and issued this account's + periodic statement for a period. Grid stores it as the delivery record for + that account and period. - Regulation E requires the statement to be delivered, not only available. - Grid provides the statement through `GET - /internal-accounts/{id}/balance-changes`; only you know whether it reached - your customer. Call this once you have delivered it. + Send it once a month for each account, after you have issued the statement + built from `GET /internal-accounts/{id}/balance-changes`. `periodStart` names the period: the first day of the statement month, as a @@ -25,9 +24,9 @@ post: begun. - Confirming is idempotent. The stored time is when delivery first - happened, so re-sending a statement or calling this endpoint again does - not move it. If `deliveredAt` is omitted, the current time is used. + Sending a receipt is idempotent. The stored time is the first receipt's, + so calling this again does not move it. If `deliveredAt` is omitted, the + current time is used. operationId: confirmStatementDelivery tags: - Periodic Statements @@ -41,7 +40,7 @@ post: $ref: ../../components/schemas/internal_accounts/ConfirmStatementDeliveryRequest.yaml responses: '200': - description: Statement delivery recorded + description: Receipt recorded content: application/json: schema: From 196763a3d403111dc5dc61389ba9c46f193b054b Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Wed, 30 Sep 2026 02:02:42 +0000 Subject: [PATCH 6/7] Make a balance change's transactionId the Transaction it belongs to transactionId now names the transaction a change belongs to, fetchable from GET /transactions/{transactionId} for the type, counterparty and merchant a statement line shows. It is null for an adjustment with no transaction behind it. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 11 ++++++----- openapi.yaml | 11 ++++++----- .../schemas/internal_accounts/BalanceChange.yaml | 16 +++++++++------- .../internal_accounts_{id}_balance-changes.yaml | 4 +++- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index db3870b58..17c0536f7 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -6223,7 +6223,7 @@ paths: description: | Every change to this account's balance in a window, in the order the money moved, with the opening and closing balances for that window in the same response. - This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. + This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. Each change's `transactionId` names its transaction, so fetch it from `GET /transactions/{transactionId}` for the type, counterparty and merchant a statement line shows. **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. @@ -26853,7 +26853,6 @@ components: description: 'One movement of the account''s balance, signed in the account holder''s polarity: money out is negative and money in is positive, so a window''s changes sum to its closing balance less its opening balance.' required: - id - - transactionId - amount - effectiveAt properties: @@ -26862,9 +26861,11 @@ components: description: Stable identifier for this balance change example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 transactionId: - type: string - description: Opaque identifier for the movement this change belongs to. Several changes can share one, so it groups the lines of a single movement on a statement. It is not a `Transaction` id and does not resolve through `GET /transactions/{transactionId}`. - example: 019542f5-b3e7-1d02-0000-000000000004 + anyOf: + - type: string + - type: 'null' + description: The transaction this change belongs to. Fetch it with `GET /transactions/{transactionId}` for its type, counterparty, merchant and rail. Several changes can share one transaction, for example an ACH deposit and its return, or a card purchase that settles in parts. Null for an adjustment with no transaction behind it. + example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: '#/components/schemas/CurrencyAmount' effectiveAt: diff --git a/openapi.yaml b/openapi.yaml index db3870b58..17c0536f7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6223,7 +6223,7 @@ paths: description: | Every change to this account's balance in a window, in the order the money moved, with the opening and closing balances for that window in the same response. - This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. + This is the statement feed. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. Each change's `transactionId` names its transaction, so fetch it from `GET /transactions/{transactionId}` for the type, counterparty and merchant a statement line shows. **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity before rendering a statement. @@ -26853,7 +26853,6 @@ components: description: 'One movement of the account''s balance, signed in the account holder''s polarity: money out is negative and money in is positive, so a window''s changes sum to its closing balance less its opening balance.' required: - id - - transactionId - amount - effectiveAt properties: @@ -26862,9 +26861,11 @@ components: description: Stable identifier for this balance change example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 transactionId: - type: string - description: Opaque identifier for the movement this change belongs to. Several changes can share one, so it groups the lines of a single movement on a statement. It is not a `Transaction` id and does not resolve through `GET /transactions/{transactionId}`. - example: 019542f5-b3e7-1d02-0000-000000000004 + anyOf: + - type: string + - type: 'null' + description: The transaction this change belongs to. Fetch it with `GET /transactions/{transactionId}` for its type, counterparty, merchant and rail. Several changes can share one transaction, for example an ACH deposit and its return, or a card purchase that settles in parts. Null for an adjustment with no transaction behind it. + example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: '#/components/schemas/CurrencyAmount' effectiveAt: diff --git a/openapi/components/schemas/internal_accounts/BalanceChange.yaml b/openapi/components/schemas/internal_accounts/BalanceChange.yaml index ab6149a12..1b11b0d9d 100644 --- a/openapi/components/schemas/internal_accounts/BalanceChange.yaml +++ b/openapi/components/schemas/internal_accounts/BalanceChange.yaml @@ -5,7 +5,6 @@ description: >- changes sum to its closing balance less its opening balance. required: - id - - transactionId - amount - effectiveAt properties: @@ -14,13 +13,16 @@ properties: description: Stable identifier for this balance change example: BalanceChange:019542f5-b3e7-1d02-0000-000000000003 transactionId: - type: string + anyOf: + - type: string + - type: 'null' description: >- - Opaque identifier for the movement this change belongs to. Several - changes can share one, so it groups the lines of a single movement on a - statement. It is not a `Transaction` id and does not resolve through - `GET /transactions/{transactionId}`. - example: 019542f5-b3e7-1d02-0000-000000000004 + The transaction this change belongs to. Fetch it with + `GET /transactions/{transactionId}` for its type, counterparty, merchant + and rail. Several changes can share one transaction, for example an ACH + deposit and its return, or a card purchase that settles in parts. Null + for an adjustment with no transaction behind it. + example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: ../common/CurrencyAmount.yaml effectiveAt: diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml index dbd698635..8c684d08d 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -19,7 +19,9 @@ get: transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase - that clears in two parts is two. + that clears in two parts is two. Each change's `transactionId` names its + transaction, so fetch it from `GET /transactions/{transactionId}` for the + type, counterparty and merchant a statement line shows. **The identity to assert:** `openingBalance + Σ(data[].amount) == From cd75364f4ba755eb88821dab503f1f79d3b72f93 Mon Sep 17 00:00:00 2001 From: Ben Goldberger Date: Wed, 30 Sep 2026 05:43:02 +0000 Subject: [PATCH 7/7] Add each balance change's fee, and build statements from balance changes A balance change now carries fee: the part of its amount that is a fee, negative when charged and positive when refunded. It is already inside the amount, so the identity is unchanged. The periodic statements guide builds a statement from balance changes rather than the transactions list: a Central-time month, opening and closing balances from the response, each line's detail from its transaction, fee lines split out of their change, the 409 before a month settles, and the monthly receipt. Co-Authored-By: benwgold Co-Authored-By: Claude Opus 5.5 (1M context) --- mintlify/openapi.yaml | 7 + mintlify/snippets/statements.mdx | 195 ++++++++++-------- openapi.yaml | 7 + .../internal_accounts/BalanceChange.yaml | 9 + ...nternal_accounts_{id}_balance-changes.yaml | 9 + 5 files changed, 141 insertions(+), 86 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 17c0536f7..41182e689 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -6231,6 +6231,8 @@ paths: A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. + **Fees are inside the changes.** A fee Grid charges comes out of the balance, so it is already in `amount`: inside a send's change, or a change of its own for a withdrawal's fee. Each change's `fee` says how much of its `amount` was a fee, negative when charged and positive when refunded. To show a fee as its own statement line, split the change into `amount - fee` and `fee`. Never add `fee` on top of `amount`, or the identity stops holding. Card transactions carry no Grid fee. + Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges tags: @@ -26854,6 +26856,7 @@ components: required: - id - amount + - fee - effectiveAt properties: id: @@ -26868,6 +26871,10 @@ components: example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: '#/components/schemas/CurrencyAmount' + fee: + allOf: + - $ref: '#/components/schemas/CurrencyAmount' + description: 'The part of `amount` that is a fee, signed the same way: negative for a fee charged, positive for a fee refunded, zero when the change carries no fee. Already included in `amount`, so never add it on top. Sum it across a period for the period''s total fees.' effectiveAt: type: string format: date-time diff --git a/mintlify/snippets/statements.mdx b/mintlify/snippets/statements.mdx index f89b11046..e79826b60 100644 --- a/mintlify/snippets/statements.mdx +++ b/mintlify/snippets/statements.mdx @@ -2,7 +2,7 @@ import { StatementExample } from '/snippets/cards/statement-example.mdx'; 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 account and transaction data to each statement field, and how to build one at period close. Consumer accounts carry a few extra items, called out where they apply. +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 @@ -14,12 +14,14 @@ The sample is a consumer statement. It carries three items a commercial statemen ## When to send a statement -Close the statement period on the same day each month and issue a statement for every account, with or without activity. +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. -Send the statement to the account holder when the period closes, by email, in-app notification, or any channel that lets the account holder **retain** it, and record the issue date. Your platform agreement sets out the cadence, who delivers the statement, and who keeps the delivery record and for how long. +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. -List every transaction that moved the balance in the period. Without them, your opening and closing balances do not reconcile. Once you issue a statement, **freeze its contents**. A refund that posts later appears on the next statement as its own dated line. +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. ## What a statement contains @@ -45,8 +47,8 @@ Every statement carries the provider line. A consumer statement also carries the | 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 | Ledger balance at the start of the period | Always | -| Closing balance | Ledger balance at the end of the period | 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 | @@ -57,32 +59,45 @@ Every statement carries the provider line. A consumer statement also carries the ## Mapping Grid data to statement fields -You build a statement from three 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, and [List transactions](/api-reference/transactions/list-transactions) for the period's activity. Bound the period with `startDate` and `endDate`, both inclusive, sort with `sortOrder=asc`, and page with `cursor` while `hasMore` is `true`. List payment transactions by `accountIdentifier`. If the account funds cards, card transactions are listed by `customerId` or `cardId`, so keep the rows whose `accountId` is the statement account. `type=CARD` cannot be combined with `status`. All amounts are integers in the smallest unit of their currency (for example, cents), so format them using the currency's `decimals`. +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 `startDate` and `endDate` you pass to List transactions | +| Statement period | The Central-time month you pass as `from` and `to` | | Issue date | You supply it: the date the statement is sent | -| Closing balance | `totalBalance` on the internal account (includes pending and held funds; `balance` does not). Grid has no historical balance, so read it at period close and store it | -| Opening balance | The previous period's closing balance from your records. For the first statement, read `totalBalance` at period open | -| Transaction date | `settledAt` on `INCOMING` and `OUTGOING`. `authorizedAt` on `CARD`; Grid has no card posting date. A card row that settles after period close appears on the next statement, dated by its authorization | -| Transaction type | `type` (`INCOMING` / `OUTGOING` / `CARD`) and `direction` (`CREDIT` / `DEBIT`); on `OUTGOING` transactions, `paymentRail` names the rail | -| Payee or merchant | `counterpartyInformation` or `description` on `INCOMING` and `OUTGOING` transactions; `merchant.descriptor` on `CARD` transactions | +| 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 | `receivedAmount` on `INCOMING`, `sentAmount` on `OUTGOING`, `settledAmount` on `CARD`, signed by `direction` (`CREDIT` adds, `DEBIT` subtracts) | -| Fee line items | `fees` on `OUTGOING` transactions. `fees` is included in `sentAmount`, so show `sentAmount.amount - fees` as the transfer line and `fees` as its own line. `INCOMING` and `CARD` transactions have no fee line | -| Total fees for the period | Sum of `fees` across the period's `OUTGOING` transactions | +| 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. -Only settled money is a statement line. Card rows count when `status` is `SETTLED` or `PARTIALLY_SETTLED`, at their `settledAmount` as of period close; later clearings appear on the next statement. Skip `AUTHORIZED`, `DECLINED`, and `VOIDED` rows, and hold `EXCEPTION` rows until they are reconciled. Payment transactions count only when `status` is `COMPLETED`. A merchant refund is its own `CARD` row (`direction: CREDIT`, `originalTransactionId` set). For the status model, see [Reconciliation](/cards/transactions/reconciliation). +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". -## Example: build a statement at period close +## Example: build a monthly statement -When your period closes, read the account holder and account, list the period's transactions, derive the lines and totals, render the statement, and hand it to your delivery channel. +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 // `grid` is your authenticated HTTP client for the Grid base URL. @@ -100,7 +115,37 @@ async function* listAll(path, params) { } while (cursor); } -async function buildStatement(customerId, accountId, startDate, endDate) { +// 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'; @@ -113,77 +158,50 @@ async function buildStatement(customerId, accountId, startDate, endDate) { } if (!account) throw new Error(`Account ${accountId} does not belong to customer ${customerId}`); - // Card rows are listed by customer; keep the ones funded by this account - const window = { startDate, endDate, sortOrder: 'asc' }; - async function* periodRows() { - for await (const tx of listAll('/transactions', { ...window, accountIdentifier: accountId })) { - if (tx.type !== 'CARD') yield tx; - } - for await (const tx of listAll('/transactions', { ...window, customerId, type: 'CARD' })) { - if (tx.accountId === accountId) yield tx; - } - } + const from = centralMonthStart(year, month); + const to = month === 12 ? centralMonthStart(year + 1, 1) : centralMonthStart(year, month + 1); - // One line per settled movement - const lines = []; - for await (const tx of periodRows()) { - const sign = tx.direction === 'CREDIT' ? 1 : -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; - if (tx.type === 'CARD') { - if (tx.status !== 'SETTLED' && tx.status !== 'PARTIALLY_SETTLED') continue; - lines.push({ - date: tx.authorizedAt, - type: 'CARD', - direction: tx.direction, - payee: tx.merchant.descriptor, - terminalLocation: - !commercial && tx.merchant.city && tx.merchant.state - ? `${tx.merchant.city}, ${tx.merchant.state}` - : undefined, - amount: sign * tx.settledAmount.amount, - }); - continue; - } - - if (tx.status !== 'COMPLETED') continue; + const changes = []; + for await (const change of listAll(path, { from, to })) changes.push(change); - if (tx.type === 'INCOMING') { - lines.push({ - date: tx.settledAt, - type: 'INCOMING', - direction: tx.direction, - payee: tx.description, - amount: sign * tx.receivedAmount.amount, - }); - continue; - } + 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'); + } - // OUTGOING: sentAmount already includes fees, so split it into two lines - const fees = tx.fees ?? 0; - lines.push({ - date: tx.settledAt, - type: 'OUTGOING', - direction: tx.direction, - payee: tx.description, - amount: sign * (tx.sentAmount.amount - fees), - }); - if (fees > 0) { - lines.push({ date: tx.settledAt, type: 'FEE', direction: tx.direction, amount: sign * fees }); + // 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}`)); } } - lines.sort((a, b) => a.date.localeCompare(b.date)); // oldest first across both lists - const closingBalance = account.totalBalance.amount; - const openingBalance = await previousClosingBalance(accountId); // from your records, same unit - const movement = lines.reduce((sum, line) => sum + line.amount, 0); - if (openingBalance + movement !== closingBalance) { - throw new Error('Statement does not reconcile: opening balance plus lines is not the closing balance'); + // 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 = -lines - .filter((line) => line.type === 'FEE') - .reduce((sum, line) => sum + line.amount, 0); + const totalFees = -changes.reduce((sum, change) => sum + change.fee.amount, 0); - const { currency } = account.totalBalance; + const { currency } = closingBalance; const format = (amount) => (amount / 10 ** currency.decimals).toFixed(currency.decimals); const statement = { @@ -198,15 +216,20 @@ async function buildStatement(customerId, accountId, startDate, endDate) { accountHolder: commercial ? customer.businessInfo.legalName : customer.fullName, accountType: account.type, // render as a label, for example "Consumer prepaid account" accountNumber: maskAccountNumber(account.fundingPaymentInstructions), - period: { startDate, endDate }, + period: { from, to }, issueDate: new Date().toISOString(), - openingBalance: format(openingBalance), - closingBalance: format(closingBalance), + 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 - await recordStatementDelivery(accountId, statement.issueDate); + + // 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, + }); } ``` diff --git a/openapi.yaml b/openapi.yaml index 17c0536f7..41182e689 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6231,6 +6231,8 @@ paths: A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. + **Fees are inside the changes.** A fee Grid charges comes out of the balance, so it is already in `amount`: inside a send's change, or a change of its own for a withdrawal's fee. Each change's `fee` says how much of its `amount` was a fee, negative when charged and positive when refunded. To show a fee as its own statement line, split the change into `amount - fee` and `fee`. Never add `fee` on top of `amount`, or the identity stops holding. Card transactions carry no Grid fee. + Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges tags: @@ -26854,6 +26856,7 @@ components: required: - id - amount + - fee - effectiveAt properties: id: @@ -26868,6 +26871,10 @@ components: example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: '#/components/schemas/CurrencyAmount' + fee: + allOf: + - $ref: '#/components/schemas/CurrencyAmount' + description: 'The part of `amount` that is a fee, signed the same way: negative for a fee charged, positive for a fee refunded, zero when the change carries no fee. Already included in `amount`, so never add it on top. Sum it across a period for the period''s total fees.' effectiveAt: type: string format: date-time diff --git a/openapi/components/schemas/internal_accounts/BalanceChange.yaml b/openapi/components/schemas/internal_accounts/BalanceChange.yaml index 1b11b0d9d..adbd4d0e5 100644 --- a/openapi/components/schemas/internal_accounts/BalanceChange.yaml +++ b/openapi/components/schemas/internal_accounts/BalanceChange.yaml @@ -6,6 +6,7 @@ description: >- required: - id - amount + - fee - effectiveAt properties: id: @@ -25,6 +26,14 @@ properties: example: Transaction:019542f5-b3e7-1d02-0000-000000000004 amount: $ref: ../common/CurrencyAmount.yaml + fee: + allOf: + - $ref: ../common/CurrencyAmount.yaml + description: >- + The part of `amount` that is a fee, signed the same way: negative for a + fee charged, positive for a fee refunded, zero when the change carries + no fee. Already included in `amount`, so never add it on top. Sum it + across a period for the period's total fees. effectiveAt: type: string format: date-time diff --git a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml index 8c684d08d..176f68668 100644 --- a/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml +++ b/openapi/paths/internal_accounts/internal_accounts_{id}_balance-changes.yaml @@ -44,6 +44,15 @@ get: it has settled. + **Fees are inside the changes.** A fee Grid charges comes out of the + balance, so it is already in `amount`: inside a send's change, or a change + of its own for a withdrawal's fee. Each change's `fee` says how much of + its `amount` was a fee, negative when charged and positive when refunded. + To show a fee as its own statement line, split the change into + `amount - fee` and `fee`. Never add `fee` on top of `amount`, or the + identity stops holding. Card transactions carry no Grid fee. + + Merchant, counterparty and rail detail live on the transaction; read them from `GET /transactions`. operationId: listInternalAccountBalanceChanges